// ignore_for_file: prefer_const_constructors, sort_child_properties_last 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'; 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().startListening(); 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(); 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 Scaffold( backgroundColor: AppColors.surface, body: AnimatedSwitcher( duration: const Duration(milliseconds: 180), switchInCurve: Curves.easeOutCubic, switchOutCurve: Curves.easeInCubic, child: KeyedSubtree( key: ValueKey(location), child: child, ), ), bottomNavigationBar: DecoratedBox( decoration: BoxDecoration( color: Colors.white, border: const Border(top: BorderSide(color: AppColors.border)), boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.06), blurRadius: 18, offset: const Offset(0, -8), ), ], ), child: NavigationBar( selectedIndex: _selectedIndex, onDestinationSelected: (index) => context.go(items[index].route), destinations: [ for (final item in items) NavigationDestination( icon: Icon(item.icon), selectedIcon: Icon(item.selectedIcon), label: item.label, ), ], ), ), ); } int get _selectedIndex { final index = items.indexWhere((item) => location.startsWith(item.route)); return index < 0 ? 0 : index; } } 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; } }