import 'dart:ui'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import '../../app/app.dart'; import '../../app/motion.dart'; import '../../app/responsive.dart'; import '../../backend/backend.dart'; import '../../core/utils/result.dart'; class AppShell extends ConsumerStatefulWidget { const AppShell({required this.child, super.key}); final Widget child; static const destinations = [ _Destination('feed', 'Feed', Icons.dashboard_rounded, '/'), _Destination('search', 'Search', Icons.search_rounded, '/search'), _Destination( 'favorites', 'Favorites', Icons.favorite_rounded, '/favorites'), _Destination('viewed', 'Viewed', Icons.history_rounded, '/viewed'), _Destination( 'collections', 'Collections', Icons.collections_bookmark_rounded, '/collections', mobileLabel: 'Boards', ), _Destination('artists', 'Artists', Icons.person_search_rounded, '/artists'), _Destination('providers', 'Providers', Icons.hub_rounded, '/providers'), _Destination('settings', 'Settings', Icons.settings_rounded, '/settings'), ]; static List<_Destination> _visibleDestinations( AppSettings settings, bool hasArtists, ) { return destinations.where((item) { if (settings.hiddenTabs.contains(item.id)) return false; if (item.id == 'artists' && !hasArtists) return false; return true; }).toList(growable: false); } @override ConsumerState createState() => _AppShellState(); } class _AppShellState extends ConsumerState { final List _tabHistory = []; String? _currentPath; bool _isBackNavigating = false; @override void didChangeDependencies() { super.didChangeDependencies(); final path = GoRouterState.of(context).uri.path; if (_isBackNavigating) { _isBackNavigating = false; _currentPath = path; return; } if (_currentPath != null && _currentPath != path) { if (path == '/') { _tabHistory.clear(); } else { _tabHistory.remove(_currentPath); _tabHistory.add(_currentPath!); if (_tabHistory.length > 25) { _tabHistory.removeAt(0); } } } _currentPath = path; } bool _handleBack() { if (Navigator.of(context).canPop()) { Navigator.of(context).pop(); return true; } final currentPath = GoRouterState.of(context).uri.path; while (_tabHistory.isNotEmpty && _tabHistory.last == currentPath) { _tabHistory.removeLast(); } if (_tabHistory.isNotEmpty) { final previous = _tabHistory.removeLast(); _isBackNavigating = true; context.go(previous); return true; } if (currentPath != '/') { _isBackNavigating = true; context.go('/'); return true; } return false; } @override Widget build(BuildContext context) { final settings = ref.watch(appSettingsProvider).value ?? AppSettings.defaults; final artistConfigs = ref.watch(_enabledArtistConfigsProvider).value ?? const []; final destinations = AppShell._visibleDestinations(settings, artistConfigs.isNotEmpty); final ru = settings.languageCode == 'ru'; return PopScope( canPop: false, onPopInvokedWithResult: (didPop, _) { if (didPop) return; final handled = _handleBack(); if (!handled) { SystemNavigator.pop(); } }, child: Shortcuts( shortcuts: { LogicalKeySet(LogicalKeyboardKey.control, LogicalKeyboardKey.keyF): const _NavigateIntent('/search'), LogicalKeySet(LogicalKeyboardKey.escape): const _BackIntent(), LogicalKeySet(LogicalKeyboardKey.digit1): const _NavigateIntent('/'), LogicalKeySet(LogicalKeyboardKey.digit2): const _NavigateIntent('/search'), LogicalKeySet(LogicalKeyboardKey.digit3): const _NavigateIntent('/favorites'), LogicalKeySet(LogicalKeyboardKey.digit4): const _NavigateIntent('/viewed'), LogicalKeySet(LogicalKeyboardKey.digit5): const _NavigateIntent('/collections'), LogicalKeySet(LogicalKeyboardKey.digit6): const _NavigateIntent('/artists'), LogicalKeySet(LogicalKeyboardKey.digit7): const _NavigateIntent('/providers'), LogicalKeySet(LogicalKeyboardKey.digit8): const _NavigateIntent('/settings'), }, child: Actions( actions: { _NavigateIntent: CallbackAction<_NavigateIntent>( onInvoke: (intent) { context.go(intent.location); return null; }, ), _BackIntent: CallbackAction<_BackIntent>( onInvoke: (_) { _handleBack(); return null; }, ), }, child: Focus( autofocus: true, child: Responsive.isDesktop(context) ? _DesktopShell( destinations: destinations, ru: ru, child: widget.child, ) : _MobileShell( destinations: destinations, ru: ru, child: widget.child, ), ), ), ), ); } } class _DesktopShell extends StatefulWidget { const _DesktopShell({ required this.child, required this.destinations, required this.ru, }); final Widget child; final List<_Destination> destinations; final bool ru; @override State<_DesktopShell> createState() => _DesktopShellState(); } class _DesktopShellState extends State<_DesktopShell> { bool _hovered = false; @override Widget build(BuildContext context) { final location = GoRouterState.of(context).uri.path; final selected = widget.destinations.indexWhere( (item) => item.location == '/' ? location == '/' : location.startsWith(item.location), ); return Scaffold( body: Row( children: [ MouseRegion( onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: AnimatedContainer( duration: AppMotion.duration(context, 180), curve: Curves.easeOutCubic, width: _hovered ? 256 : 72, color: Theme.of(context).navigationRailTheme.backgroundColor, child: SafeArea( child: Padding( padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 8), child: Column( children: [ for (var index = 0; index < widget.destinations.length; index++) _RailButton( destination: widget.destinations[index], ru: widget.ru, selected: (selected < 0 ? 0 : selected) == index, expanded: _hovered, onTap: () { context.go(widget.destinations[index].location); }, ), const Spacer(), ], ), ), ), ), ), const VerticalDivider(width: 1), Expanded(child: widget.child), ], ), ); } } class _RailButton extends StatelessWidget { const _RailButton({ required this.destination, required this.selected, required this.expanded, required this.ru, required this.onTap, }); final _Destination destination; final bool selected; final bool expanded; final bool ru; final VoidCallback onTap; @override Widget build(BuildContext context) { final colors = Theme.of(context).colorScheme; return Padding( padding: const EdgeInsets.only(bottom: 8), child: Tooltip( message: expanded ? '' : destination.labelFor(ru), waitDuration: const Duration(milliseconds: 450), child: Material( color: selected ? colors.primaryContainer : Colors.transparent, borderRadius: BorderRadius.circular(28), clipBehavior: Clip.antiAlias, child: InkWell( onTap: onTap, child: SizedBox( height: 48, child: Row( children: [ SizedBox( width: 56, child: Icon( destination.icon, color: selected ? colors.onPrimaryContainer : colors.onSurfaceVariant, ), ), if (expanded) Expanded( child: ClipRect( child: AnimatedOpacity( opacity: 1, duration: AppMotion.duration(context, 120), curve: Curves.easeOut, child: Align( alignment: Alignment.centerLeft, child: Text( destination.labelFor(ru), maxLines: 1, overflow: TextOverflow.clip, softWrap: false, style: TextStyle( fontWeight: selected ? FontWeight.w700 : FontWeight.w600, color: selected ? colors.onPrimaryContainer : colors.onSurface, ), ), ), ), ), ) else const SizedBox.shrink(), if (expanded) const SizedBox(width: 12), ], ), ), ), ), ), ); } } class _MobileShell extends StatelessWidget { const _MobileShell({ required this.child, required this.destinations, required this.ru, }); final Widget child; final List<_Destination> destinations; final bool ru; @override Widget build(BuildContext context) { final items = destinations .where((item) => item.location != '/providers' && item.location != '/settings') .take(6) .toList(); final location = GoRouterState.of(context).uri.path; final selected = items.indexWhere( (item) => item.location == '/' ? location == '/' : location.startsWith(item.location), ); final bottomInset = 76.0 + MediaQuery.paddingOf(context).bottom; return Scaffold( extendBody: true, body: MediaQuery( data: MediaQuery.of(context).copyWith( padding: MediaQuery.of(context).padding.copyWith( bottom: bottomInset, ), ), child: child, ), floatingActionButton: location == '/settings' ? null : Padding( padding: const EdgeInsets.only(bottom: 0, right: 2), child: _LiquidGlassSettingsButton( ru: ru, onTap: () => context.go('/settings'), ), ), floatingActionButtonLocation: FloatingActionButtonLocation.endFloat, bottomNavigationBar: _LiquidGlassBottomBar( selectedIndex: selected < 0 ? 0 : selected, onDestinationSelected: (index) => context.go(items[index].location), items: items, ru: ru, ), ); } } class _LiquidGlassBottomBar extends StatefulWidget { const _LiquidGlassBottomBar({ required this.selectedIndex, required this.onDestinationSelected, required this.items, required this.ru, }); final int selectedIndex; final ValueChanged onDestinationSelected; final List<_Destination> items; final bool ru; @override State<_LiquidGlassBottomBar> createState() => _LiquidGlassBottomBarState(); } class _LiquidGlassBottomBarState extends State<_LiquidGlassBottomBar> { int? _pressedIndex; @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final scheme = theme.colorScheme; final count = widget.items.length; if (count == 0) return const SizedBox.shrink(); return SafeArea( top: false, left: false, right: false, child: Padding( padding: const EdgeInsets.fromLTRB(14, 0, 14, 10), child: LayoutBuilder( builder: (context, constraints) { final barWidth = constraints.maxWidth; final itemWidth = barWidth / count; final pillWidth = (itemWidth - 6).clamp(36.0, 64.0); final pillLeft = widget.selectedIndex * itemWidth + (itemWidth - pillWidth) / 2; return Container( height: 64, decoration: BoxDecoration( borderRadius: BorderRadius.circular(28), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: isDark ? 0.38 : 0.09), blurRadius: 22, offset: const Offset(0, 8), spreadRadius: -2, ), if (isDark) BoxShadow( color: scheme.primary.withValues(alpha: 0.07), blurRadius: 18, offset: const Offset(0, 2), ), ], ), child: ClipRRect( borderRadius: BorderRadius.circular(28), child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(28), gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: isDark ? [ scheme.surfaceContainerHigh .withValues(alpha: 0.70), scheme.surface.withValues(alpha: 0.82), ] : [ Colors.white.withValues(alpha: 0.84), Colors.white.withValues(alpha: 0.68), ], ), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.16) : Colors.white.withValues(alpha: 0.75), width: 1.2, ), ), child: Stack( alignment: Alignment.centerLeft, children: [ // Animated sliding liquid pill indicator AnimatedPositioned( duration: const Duration(milliseconds: 300), curve: Curves.easeOutBack, left: pillLeft, top: 7, child: Container( width: pillWidth, height: 32, decoration: BoxDecoration( color: scheme.primaryContainer.withValues( alpha: isDark ? 0.75 : 0.88, ), borderRadius: BorderRadius.circular(16), border: Border.all( color: scheme.primary.withValues( alpha: isDark ? 0.35 : 0.22, ), width: 1, ), boxShadow: [ BoxShadow( color: scheme.primary.withValues( alpha: isDark ? 0.22 : 0.12, ), blurRadius: 10, offset: const Offset(0, 2), ), ], ), ), ), // Navigation item buttons Row( children: [ for (var i = 0; i < count; i++) Expanded( child: _LiquidNavItem( destination: widget.items[i], label: widget.items[i] .mobileLabelFor(widget.ru), isSelected: widget.selectedIndex == i, isPressed: _pressedIndex == i, onTapDown: () => setState(() => _pressedIndex = i), onTapUp: () => setState(() => _pressedIndex = null), onTapCancel: () => setState(() => _pressedIndex = null), onTap: () { HapticFeedback.lightImpact(); widget.onDestinationSelected(i); }, ), ), ], ), ], ), ), ), ), ); }, ), ), ); } } class _LiquidNavItem extends StatelessWidget { const _LiquidNavItem({ required this.destination, required this.label, required this.isSelected, required this.isPressed, required this.onTapDown, required this.onTapUp, required this.onTapCancel, required this.onTap, }); final _Destination destination; final String label; final bool isSelected; final bool isPressed; final VoidCallback onTapDown; final VoidCallback onTapUp; final VoidCallback onTapCancel; final VoidCallback onTap; @override Widget build(BuildContext context) { final theme = Theme.of(context); final scheme = theme.colorScheme; final isDark = theme.brightness == Brightness.dark; final targetScale = isPressed ? 0.86 : (isSelected ? 1.05 : 1.0); return GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => onTapDown(), onTapUp: (_) => onTapUp(), onTapCancel: onTapCancel, onTap: onTap, child: Center( child: AnimatedScale( scale: targetScale, duration: const Duration(milliseconds: 180), curve: Curves.easeOutCubic, child: Column( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.center, children: [ SizedBox( height: 32, child: Center( child: AnimatedSwitcher( duration: const Duration(milliseconds: 200), transitionBuilder: (child, anim) => ScaleTransition( scale: anim, child: child, ), child: Icon( destination.icon, key: ValueKey('${destination.id}_$isSelected'), size: isSelected ? 22 : 20, color: isSelected ? scheme.onPrimaryContainer : (isDark ? scheme.onSurfaceVariant .withValues(alpha: 0.78) : scheme.onSurfaceVariant .withValues(alpha: 0.85)), ), ), ), ), const SizedBox(height: 2), AnimatedDefaultTextStyle( duration: const Duration(milliseconds: 200), style: TextStyle( fontSize: 10.5, fontWeight: isSelected ? FontWeight.w700 : FontWeight.w500, color: isSelected ? (isDark ? scheme.primary : scheme.onSurface) : scheme.onSurfaceVariant.withValues(alpha: 0.75), letterSpacing: -0.2, ), maxLines: 1, overflow: TextOverflow.ellipsis, child: Text(label), ), ], ), ), ), ); } } class _LiquidGlassSettingsButton extends StatefulWidget { const _LiquidGlassSettingsButton({ required this.ru, required this.onTap, }); final bool ru; final VoidCallback onTap; @override State<_LiquidGlassSettingsButton> createState() => _LiquidGlassSettingsButtonState(); } class _LiquidGlassSettingsButtonState extends State<_LiquidGlassSettingsButton> { bool _pressed = false; @override Widget build(BuildContext context) { final theme = Theme.of(context); final isDark = theme.brightness == Brightness.dark; final scheme = theme.colorScheme; return Tooltip( message: widget.ru ? 'Настройки' : 'Settings', child: GestureDetector( behavior: HitTestBehavior.opaque, onTapDown: (_) => setState(() => _pressed = true), onTapUp: (_) => setState(() => _pressed = false), onTapCancel: () => setState(() => _pressed = false), onTap: () { HapticFeedback.mediumImpact(); widget.onTap(); }, child: AnimatedScale( scale: _pressed ? 0.88 : 1.0, duration: const Duration(milliseconds: 160), curve: Curves.easeOutCubic, child: Container( width: 48, height: 48, decoration: BoxDecoration( shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: isDark ? 0.38 : 0.12), blurRadius: 18, offset: const Offset(0, 6), spreadRadius: -1, ), BoxShadow( color: scheme.primary.withValues(alpha: isDark ? 0.20 : 0.12), blurRadius: 12, offset: const Offset(0, 2), ), ], ), child: ClipOval( child: BackdropFilter( filter: ImageFilter.blur(sigmaX: 20, sigmaY: 20), child: Container( decoration: BoxDecoration( shape: BoxShape.circle, gradient: RadialGradient( center: const Alignment(-0.3, -0.4), radius: 1.1, colors: isDark ? [ scheme.surfaceContainerHigh.withValues(alpha: 0.85), scheme.surface.withValues(alpha: 0.75), ] : [ Colors.white.withValues(alpha: 0.92), Colors.white.withValues(alpha: 0.70), ], ), border: Border.all( color: isDark ? Colors.white.withValues(alpha: 0.24) : Colors.white.withValues(alpha: 0.85), width: 1.4, ), ), child: Center( child: AnimatedRotation( turns: _pressed ? 0.125 : 0.0, duration: const Duration(milliseconds: 250), curve: Curves.easeOutBack, child: Icon( Icons.settings_rounded, size: 23, color: scheme.primary, ), ), ), ), ), ), ), ), ), ); } } class _Destination { const _Destination( this.id, this.label, this.icon, this.location, { String? mobileLabel, }) : mobileLabel = mobileLabel ?? label; final String id; final String label; final String mobileLabel; final IconData icon; final String location; String labelFor(bool ru) { if (!ru) return label; return switch (id) { 'feed' => '\u041b\u0435\u043d\u0442\u0430', 'search' => '\u041f\u043e\u0438\u0441\u043a', 'favorites' => '\u0418\u0437\u0431\u0440\u0430\u043d\u043d\u043e\u0435', 'viewed' => '\u0418\u0441\u0442\u043e\u0440\u0438\u044f', 'collections' => '\u041a\u043e\u043b\u043b\u0435\u043a\u0446\u0438\u0438', 'artists' => '\u0410\u0440\u0442\u0438\u0441\u0442\u044b', 'providers' => '\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u044b', 'settings' => '\u041d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0438', _ => label, }; } String mobileLabelFor(bool ru) { if (!ru) return mobileLabel; return switch (id) { 'collections' => '\u0414\u043e\u0441\u043a\u0438', 'favorites' => '\u041b\u044e\u0431\u0438\u043c\u043e\u0435', _ => labelFor(true), }; } } final _enabledArtistConfigsProvider = FutureProvider>((ref) async { ref.watch(appSettingsProvider); final result = await ref .watch(providerManagerProvider) .loadArtistConfigs(enabledOnly: true); return result is Success> ? result.data : const []; }); class _NavigateIntent extends Intent { const _NavigateIntent(this.location); final String location; } class _BackIntent extends Intent { const _BackIntent(); }