// ignore_for_file: prefer_const_constructors, sort_child_properties_last import 'dart:async'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/services/hardware_shortcut_listener.dart'; import '../../core/services/stt_service.dart'; import '../../core/services/tts_service.dart'; import '../../core/services/voice_command_handler.dart'; import '../../core/theme/app_colors.dart'; import '../../core/theme/app_decorations.dart'; import 'animations/animations.dart'; class UserShell extends StatefulWidget { final Widget child; const UserShell({super.key, required this.child}); @override State createState() => _UserShellState(); } class _UserShellState extends State { @override void initState() { super.initState(); _loadVoiceCommands(); _startHardwareShortcuts(); sl().onCommand = (key) { if (!mounted) return; switch (key) { case VoiceCommandKey.openWalkguide: case VoiceCommandKey.startWalkguide: context.go('/user/walkguide'); break; case VoiceCommandKey.openSos: case VoiceCommandKey.sendSos: context.go('/user/sos'); break; case VoiceCommandKey.openActivity: context.go('/user/activity'); break; case VoiceCommandKey.openNotification: case VoiceCommandKey.readAllNotif: context.go('/user/notifications'); break; case VoiceCommandKey.openNavigation: case VoiceCommandKey.whereAmI: context.go('/user/navigation'); break; case VoiceCommandKey.openSettings: context.go('/user/settings'); break; case VoiceCommandKey.callGuardian: context.go('/user/call'); break; case VoiceCommandKey.repeatLast: case VoiceCommandKey.stopTts: case VoiceCommandKey.stopWalkguide: break; } sl().speak(_spokenRouteName(key)); }; } Future _loadVoiceCommands() async { await runFriendlyAction( () async { final res = await sl() .dio .get('/user/voice-commands') .timeout(const Duration(seconds: 8)); final body = res.data; final data = body is Map ? body['data'] : body; if (data is! List) return; final commands = data .whereType() .map((item) => _voiceCommandFromJson(Map.from(item))) .whereType() .toList(); if (commands.isNotEmpty) { sl().loadCommands(commands); } }, onError: (_) {}, fallback: 'Voice command belum bisa dimuat.', ); } Future _startHardwareShortcuts() async { await runFriendlyAction( () => sl().startListening( onAction: (action) { if (!mounted) return; switch (action) { case HardwareShortcutAction.callGuardian: context.go('/user/call'); sl().speak('Memanggil guardian'); break; case HardwareShortcutAction.startWalkguide: context.go('/user/walkguide'); sl().speak('WalkGuide dibuka'); break; case HardwareShortcutAction.stopWalkguide: context.go('/user/walkguide'); sl().speak('WalkGuide dibuka untuk dihentikan'); break; case HardwareShortcutAction.sendSos: context.go('/user/sos'); sl().speak('SOS dibuka'); break; case HardwareShortcutAction.openNotification: context.go('/user/notifications'); sl().speak('Notifikasi dibuka'); break; } }, ), onError: (_) {}, fallback: 'Hardware shortcut belum bisa dimuat.', ); } @override void dispose() { sl().stopListening(); unawaited(sl().stopListening()); super.dispose(); } @override Widget build(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; final items = [ _ShellItem('Guide', Icons.visibility_outlined, Icons.visibility, '/user/walkguide'), _ShellItem('SOS', Icons.warning_amber_outlined, Icons.warning_amber, '/user/sos'), _ShellItem( 'Log', Icons.list_alt_outlined, Icons.list_alt, '/user/activity'), _ShellItem('Notif', Icons.notifications_outlined, Icons.notifications, '/user/notifications'), _ShellItem('Map', Icons.map_outlined, Icons.map, '/user/navigation'), _ShellItem( 'Set', Icons.settings_outlined, Icons.settings, '/user/settings'), ]; return _AppShell(child: widget.child, items: items, location: location); } } class GuardianShell extends StatelessWidget { final Widget child; const GuardianShell({super.key, required this.child}); @override Widget build(BuildContext context) { final location = GoRouterState.of(context).matchedLocation; final items = [ _ShellItem('Home', Icons.dashboard_outlined, Icons.dashboard, '/guardian/dashboard'), _ShellItem('Map', Icons.map_outlined, Icons.map, '/guardian/map'), _ShellItem('Logs', Icons.fact_check_outlined, Icons.fact_check, '/guardian/logs'), _ShellItem( 'Send', Icons.send_outlined, Icons.send, '/guardian/send-notif'), _ShellItem('AI', Icons.tune_outlined, Icons.tune, '/guardian/ai-config'), _ShellItem( 'Set', Icons.settings_outlined, Icons.settings, '/guardian/settings'), ]; return _AppShell(child: child, items: items, location: location); } } class _AppShell extends StatelessWidget { final Widget child; final List<_ShellItem> items; final String location; const _AppShell( {required this.child, required this.items, required this.location}); @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final useRail = constraints.maxWidth >= 760; final content = child; return Scaffold( backgroundColor: AppColors.surface, body: useRail ? Row( children: [ _RailNavigation( items: items, selectedIndex: _selectedIndex, ), const VerticalDivider(width: 1, color: AppColors.border), Expanded(child: content), ], ) : content, bottomNavigationBar: useRail ? null : _BottomScrollNavigation( items: items, selectedIndex: _selectedIndex, ), ); }, ); } int get _selectedIndex { final index = items.indexWhere((item) => location.startsWith(item.route)); return index < 0 ? 0 : index; } } class _RailNavigation extends StatelessWidget { final List<_ShellItem> items; final int selectedIndex; const _RailNavigation({ required this.items, required this.selectedIndex, }); @override Widget build(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxHeight < 520; final width = compact ? 76.0 : 86.0; final itemHeight = compact ? 58.0 : 70.0; return DecoratedBox( decoration: const BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( color: Color(0x14000000), blurRadius: 18, offset: Offset(6, 0), ), ], ), child: SafeArea( right: false, child: SizedBox( width: width, child: Padding( padding: EdgeInsets.symmetric( horizontal: 8, vertical: compact ? 6 : 12, ), child: Column( children: [ for (var index = 0; index < items.length; index++) Expanded( child: Center( child: _RailNavItem( item: items[index], selected: index == selectedIndex, compact: compact, height: itemHeight, ), ), ), ], ), ), ), ), ); }, ); } } class _BottomScrollNavigation extends StatelessWidget { final List<_ShellItem> items; final int selectedIndex; const _BottomScrollNavigation({ required this.items, required this.selectedIndex, }); @override Widget build(BuildContext context) { final bottom = MediaQuery.of(context).padding.bottom; final extraBottom = bottom > 12 ? 12.0 : bottom; return Container( margin: EdgeInsets.zero, decoration: BoxDecoration( color: Colors.white, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), boxShadow: [ AppDecorations.cardShadow.first, ], ), child: ClipRRect( borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), child: SafeArea( top: false, child: SizedBox( height: 68 + extraBottom, child: Row( children: [ for (var index = 0; index < items.length; index++) Expanded( child: _BottomNavItem( item: items[index], selected: index == selectedIndex, ), ), ], ), ), ), ), ); } } class _RailNavItem extends StatelessWidget { final _ShellItem item; final bool selected; final bool compact; final double height; const _RailNavItem({ required this.item, required this.selected, required this.compact, required this.height, }); @override Widget build(BuildContext context) { return Semantics( button: true, selected: selected, label: item.label, child: BounceTap( onTap: () => context.go(item.route), child: AnimatedContainer( duration: const Duration(milliseconds: 160), curve: Curves.easeOutCubic, height: height, width: double.infinity, padding: const EdgeInsets.symmetric(horizontal: 4), decoration: BoxDecoration( color: selected ? AppColors.softBlueBg : Colors.transparent, borderRadius: BorderRadius.circular(24), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( selected ? item.selectedIcon : item.icon, size: compact ? 21 : 24, color: selected ? AppColors.primary : AppColors.muted, ), SizedBox(height: compact ? 1 : 4), Text( item.label, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: TextStyle( fontSize: compact ? 9.5 : 11.5, height: 1, fontWeight: selected ? FontWeight.w800 : FontWeight.w600, color: selected ? AppColors.primary : AppColors.muted, ), ), ], ), ), ), ); } } class _BottomNavItem extends StatelessWidget { final _ShellItem item; final bool selected; const _BottomNavItem({ required this.item, required this.selected, }); @override Widget build(BuildContext context) { return Semantics( button: true, selected: selected, label: item.label, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 8), child: BounceTap( onTap: () => context.go(item.route), child: AnimatedContainer( duration: const Duration(milliseconds: 160), curve: Curves.easeOutCubic, padding: const EdgeInsets.symmetric(horizontal: 2), decoration: BoxDecoration( gradient: selected ? AppDecorations.blueGradient : null, borderRadius: BorderRadius.circular(50), ), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ AnimatedScale( scale: selected ? 1.08 : 1.0, duration: const Duration(milliseconds: 160), curve: Curves.easeOutCubic, child: Icon( selected ? item.selectedIcon : item.icon, color: selected ? Colors.white : AppColors.muted, size: 21, ), ), const SizedBox(height: 3), Text( item.label, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.center, style: TextStyle( fontSize: 10, height: 1, fontWeight: selected ? FontWeight.w800 : FontWeight.w600, color: selected ? Colors.white : AppColors.muted, ), ), ], ), ), ), ), ); } } class _ShellItem { final String label; final IconData icon; final IconData selectedIcon; final String route; const _ShellItem(this.label, this.icon, this.selectedIcon, this.route); } String _spokenRouteName(VoiceCommandKey key) { switch (key) { case VoiceCommandKey.openWalkguide: case VoiceCommandKey.startWalkguide: return 'WalkGuide dibuka'; case VoiceCommandKey.openSos: case VoiceCommandKey.sendSos: return 'SOS dibuka'; case VoiceCommandKey.openActivity: return 'Activity log dibuka'; case VoiceCommandKey.openNotification: case VoiceCommandKey.readAllNotif: return 'Notifikasi dibuka'; case VoiceCommandKey.openNavigation: case VoiceCommandKey.whereAmI: return 'Navigasi dibuka'; case VoiceCommandKey.openSettings: return 'Settings dibuka'; case VoiceCommandKey.callGuardian: return 'Memanggil guardian'; case VoiceCommandKey.repeatLast: case VoiceCommandKey.stopTts: case VoiceCommandKey.stopWalkguide: return ''; } } VoiceCommand? _voiceCommandFromJson(Map item) { final key = _commandKeyFromBackend(item['commandKey']?.toString()); final phrase = item['triggerPhrase']?.toString().trim(); if (key == null || phrase == null || phrase.isEmpty) return null; return VoiceCommand( key: key, phrase: phrase, enabled: item['enabled'] != false, ); } VoiceCommandKey? _commandKeyFromBackend(String? key) { switch (key) { case 'OPEN_WALKGUIDE': return VoiceCommandKey.openWalkguide; case 'START_WALKGUIDE': return VoiceCommandKey.startWalkguide; case 'STOP_WALKGUIDE': return VoiceCommandKey.stopWalkguide; case 'CALL_GUARDIAN': return VoiceCommandKey.callGuardian; case 'OPEN_NOTIFICATION': return VoiceCommandKey.openNotification; case 'READ_ALL_NOTIF': return VoiceCommandKey.readAllNotif; case 'OPEN_SOS': return VoiceCommandKey.openSos; case 'SEND_SOS': return VoiceCommandKey.sendSos; case 'WHERE_AM_I': return VoiceCommandKey.whereAmI; case 'OPEN_ACTIVITY': return VoiceCommandKey.openActivity; case 'OPEN_NAVIGATION': return VoiceCommandKey.openNavigation; case 'OPEN_SETTINGS': return VoiceCommandKey.openSettings; case 'REPEAT_LAST': return VoiceCommandKey.repeatLast; case 'STOP_TTS': return VoiceCommandKey.stopTts; default: return null; } }