diff --git a/walkguide-backend/demo/src/main/java/com/walkguide/config/WebSocketConfig.java b/walkguide-backend/demo/src/main/java/com/walkguide/config/WebSocketConfig.java index f4a46b2..9e32122 100644 --- a/walkguide-backend/demo/src/main/java/com/walkguide/config/WebSocketConfig.java +++ b/walkguide-backend/demo/src/main/java/com/walkguide/config/WebSocketConfig.java @@ -36,11 +36,14 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { - // Endpoint WebSocket utama + // Endpoint WebSocket utama untuk Flutter/stomp_dart_client. // Flutter connect ke: ws://host:port/ws (tanpa SockJS) - // Browser/Postman bisa pakai SockJS fallback: http://host:port/ws registry.addEndpoint("/ws") - .setAllowedOriginPatterns("*") // Allow semua origin untuk testing HP di LAN - .withSockJS(); // SockJS fallback untuk browser compatibility + .setAllowedOriginPatterns("*"); // Allow semua origin untuk testing HP di LAN + + // Endpoint fallback SockJS untuk browser tooling bila dibutuhkan. + registry.addEndpoint("/ws-sockjs") + .setAllowedOriginPatterns("*") + .withSockJS(); } } diff --git a/walkguide-mobile/walkguide_app/android/app/src/main/AndroidManifest.xml b/walkguide-mobile/walkguide_app/android/app/src/main/AndroidManifest.xml index 2a004e9..60a6ae4 100644 --- a/walkguide-mobile/walkguide_app/android/app/src/main/AndroidManifest.xml +++ b/walkguide-mobile/walkguide_app/android/app/src/main/AndroidManifest.xml @@ -17,6 +17,7 @@ android:label="WalkGuide" android:name="${applicationName}" android:icon="@mipmap/ic_launcher" + android:enableOnBackInvokedCallback="true" android:usesCleartextTraffic="true"> - + - diff --git a/walkguide-mobile/walkguide_app/assets/models/labels.txt b/walkguide-mobile/walkguide_app/assets/models/labels.txt index 941cb4e..1f42c8e 100644 --- a/walkguide-mobile/walkguide_app/assets/models/labels.txt +++ b/walkguide-mobile/walkguide_app/assets/models/labels.txt @@ -77,4 +77,4 @@ vase scissors teddy bear hair drier -toothbrush +toothbrush \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/assets/models/yolov8n.tflite b/walkguide-mobile/walkguide_app/assets/models/yolov8n.tflite index e69de29..cab4ab1 100644 Binary files a/walkguide-mobile/walkguide_app/assets/models/yolov8n.tflite and b/walkguide-mobile/walkguide_app/assets/models/yolov8n.tflite differ diff --git a/walkguide-mobile/walkguide_app/lib/app/app.dart b/walkguide-mobile/walkguide_app/lib/app/app.dart index 705b440..1a16f07 100644 --- a/walkguide-mobile/walkguide_app/lib/app/app.dart +++ b/walkguide-mobile/walkguide_app/lib/app/app.dart @@ -1,19 +1,20 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; -import 'package:google_fonts/google_fonts.dart'; import 'app_cubit.dart'; import 'router.dart'; import '../core/i18n/app_strings.dart'; import '../core/theme/app_colors.dart'; +import '../core/theme/app_decorations.dart'; +import '../core/theme/app_text_styles.dart'; class WalkGuideApp extends StatelessWidget { const WalkGuideApp({super.key}); @override Widget build(BuildContext context) { - const seed = AppColors.primary; + const seed = AppColors.primaryBlue; return BlocProvider( create: (_) => AppCubit(), @@ -55,7 +56,7 @@ class WalkGuideApp extends StatelessWidget { error: AppColors.danger, ), scaffoldBackgroundColor: AppColors.surface, - textTheme: GoogleFonts.interTextTheme().apply( + textTheme: AppTextStyles.textTheme.apply( bodyColor: AppColors.text, displayColor: AppColors.text, ), @@ -73,14 +74,12 @@ class WalkGuideApp extends StatelessWidget { elevation: 0, surfaceTintColor: Colors.transparent, ), - cardTheme: CardThemeData( + cardTheme: const CardThemeData( elevation: 0, color: AppColors.surfaceRaised, surfaceTintColor: Colors.transparent, - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), - side: const BorderSide(color: AppColors.border), - ), + margin: EdgeInsets.zero, + shape: AppDecorations.cardShape, ), dividerTheme: const DividerThemeData( color: AppColors.border, @@ -92,7 +91,7 @@ class WalkGuideApp extends StatelessWidget { foregroundColor: AppColors.text, backgroundColor: Colors.white, shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(14), side: const BorderSide(color: AppColors.border), ), ), @@ -101,7 +100,7 @@ class WalkGuideApp extends StatelessWidget { elevation: 0, height: 76, backgroundColor: Colors.white, - indicatorColor: const Color(0xFFDDEAFE), + indicatorColor: AppColors.softBlueBg, surfaceTintColor: Colors.transparent, labelTextStyle: WidgetStateProperty.resolveWith( (states) => TextStyle( @@ -117,9 +116,12 @@ class WalkGuideApp extends StatelessWidget { backgroundColor: seed, foregroundColor: Colors.white, minimumSize: const Size(0, 50), - textStyle: const TextStyle(fontWeight: FontWeight.w800), + textStyle: AppTextStyles.body.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(50), ), ), ), @@ -127,22 +129,25 @@ class WalkGuideApp extends StatelessWidget { style: OutlinedButton.styleFrom( minimumSize: const Size(0, 50), foregroundColor: seed, - textStyle: const TextStyle(fontWeight: FontWeight.w800), + textStyle: AppTextStyles.body.copyWith( + color: seed, + fontWeight: FontWeight.w700, + ), side: const BorderSide(color: AppColors.border), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(50), ), ), ), snackBarTheme: SnackBarThemeData( behavior: SnackBarBehavior.floating, backgroundColor: AppColors.text, - contentTextStyle: GoogleFonts.inter( + contentTextStyle: AppTextStyles.body.copyWith( color: Colors.white, fontWeight: FontWeight.w600, ), shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(14), ), ), inputDecorationTheme: InputDecorationTheme( @@ -151,15 +156,15 @@ class WalkGuideApp extends StatelessWidget { contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), border: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(14), borderSide: const BorderSide(color: AppColors.border), ), enabledBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(14), borderSide: const BorderSide(color: AppColors.border), ), focusedBorder: OutlineInputBorder( - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(14), borderSide: const BorderSide(color: seed, width: 1.5), ), ), diff --git a/walkguide-mobile/walkguide_app/lib/app/injection_container.dart b/walkguide-mobile/walkguide_app/lib/app/injection_container.dart index b5c1191..797500b 100644 --- a/walkguide-mobile/walkguide_app/lib/app/injection_container.dart +++ b/walkguide-mobile/walkguide_app/lib/app/injection_container.dart @@ -17,7 +17,6 @@ import '../core/services/voice_command_handler.dart'; import '../core/services/websocket_service.dart'; import '../core/storage/local_database.dart'; import '../core/storage/secure_storage.dart'; -import '../core/utils/init_guard.dart'; import '../features/notifications/application/notification_cubit.dart'; import '../features/notifications/data/repositories/notification_repository_impl.dart'; import '../features/notifications/domain/repositories/notification_repository.dart'; @@ -82,6 +81,5 @@ Future initDependencies() async { await sl().init(serverUrl); } - await ignoreInitFailure(() => sl().init(), label: 'TTS init'); sl().loadDefaultCommands(); } diff --git a/walkguide-mobile/walkguide_app/lib/app/router.dart b/walkguide-mobile/walkguide_app/lib/app/router.dart index 4e123c5..21d3b7b 100644 --- a/walkguide-mobile/walkguide_app/lib/app/router.dart +++ b/walkguide-mobile/walkguide_app/lib/app/router.dart @@ -25,6 +25,7 @@ import '../features/guardian_dashboard/presentation/screens/guardian_tools_scree as guardian_tools; import '../features/home/presentation/guardian_dashboard_screen.dart' as guardian_home; +import '../features/manual/manual_screen.dart' as manual; import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart' as nav; import '../features/notifications/presentation/screens/notification_screen.dart' @@ -41,7 +42,7 @@ import '../features/walk_guide/presentation/screens/walk_guide_screen.dart' import '../shared/widgets/app_shells.dart'; final GoRouter appRouter = GoRouter( - initialLocation: '/splash', + initialLocation: '/server-connect', redirect: (context, state) async { final path = state.matchedLocation; final serverUrl = await AppConstants.getServerUrl(); @@ -141,6 +142,9 @@ final GoRouter appRouter = GoRouter( GoRoute( path: '/user/benchmark', builder: (_, __) => const benchmark.AiBenchmarkScreen()), + GoRoute( + path: '/user/manual', + builder: (_, __) => const manual.ManualScreen()), ], ), ShellRoute( diff --git a/walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart b/walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart index 4d92b62..5201a43 100644 --- a/walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart +++ b/walkguide-mobile/walkguide_app/lib/core/ai/yolo_detector.dart @@ -586,14 +586,31 @@ const Set _walkGuideObstacleLabels = { 'bicycle', 'car', 'motorcycle', + 'truck', 'bus', 'train', - 'truck', + 'boat', 'traffic light', 'fire hydrant', 'stop sign', 'parking meter', 'bench', + 'stairs', + 'stair', + 'pothole', + 'curb', + 'pole', + 'bollard', + 'cone', + 'road cone', + 'barrier', + 'fence', + 'door', + 'trash can', + 'signboard', + 'crosswalk', + 'sidewalk', + 'wall', 'backpack', 'umbrella', 'handbag', @@ -608,6 +625,7 @@ const Set _walkGuideObstacleLabels = { 'bottle', 'cup', 'book', + 'object', }; const Map _cocoObstacleLabels = { diff --git a/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart b/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart index 9857224..13e0109 100644 --- a/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart +++ b/walkguide-mobile/walkguide_app/lib/core/constants/app_constants.dart @@ -1,7 +1,8 @@ import 'package:shared_preferences/shared_preferences.dart'; class AppConstants { - static const String defaultServerUrl = 'http://202.46.28.170:8080'; + static const String defaultServerUrl = 'http://127.0.0.1:8080'; + static const String publicServerUrl = 'http://202.46.28.170:8080'; static const String _serverUrlKey = 'server_base_url'; static const String _selectedYoloModelKey = 'selected_yolo_model'; @@ -10,7 +11,7 @@ class AppConstants { final prefs = await SharedPreferences.getInstance(); final saved = prefs.getString(_serverUrlKey); if (saved == null || saved.trim().isEmpty) { - return defaultServerUrl; + return null; } return saved; } @@ -38,7 +39,7 @@ class AppConstants { static Future clearServerUrl() async { final prefs = await SharedPreferences.getInstance(); - await prefs.setString(_serverUrlKey, defaultServerUrl); + await prefs.remove(_serverUrlKey); } static String buildApiUrl(String baseUrl) => '$baseUrl/api/v1'; diff --git a/walkguide-mobile/walkguide_app/lib/core/network/api_client.dart b/walkguide-mobile/walkguide_app/lib/core/network/api_client.dart index 6df900b..340bc5b 100644 --- a/walkguide-mobile/walkguide_app/lib/core/network/api_client.dart +++ b/walkguide-mobile/walkguide_app/lib/core/network/api_client.dart @@ -61,7 +61,11 @@ class _AuthInterceptor extends Interceptor { @override void onError(DioException err, ErrorInterceptorHandler handler) async { - if (err.response?.statusCode == 401 && !_refreshing) { + final status = err.response?.statusCode; + final canRefresh = (status == 401 || status == 403) && + !_refreshing && + !err.requestOptions.path.startsWith('/auth/'); + if (canRefresh) { _refreshing = true; try { final refresh = await _storage.getRefreshToken(); @@ -87,14 +91,20 @@ class _AuthInterceptor extends Interceptor { // Retry original request err.requestOptions.headers['Authorization'] = 'Bearer ${data['accessToken']}'; - final retryRes = await _dio.fetch(err.requestOptions); - _refreshing = false; - handler.resolve(retryRes); + try { + final retryRes = await _dio.fetch(err.requestOptions); + _refreshing = false; + handler.resolve(retryRes); + } on DioException catch (retryErr) { + _refreshing = false; + handler.next(retryErr); + } return; } - } catch (_) {} + } catch (_) { + await _storage.clearAll(); + } _refreshing = false; - await _storage.clearAll(); } handler.next(err); } diff --git a/walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart index 30f89a7..349b8e1 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/fcm_service.dart @@ -1,5 +1,6 @@ import 'dart:convert'; +import 'package:firebase_core/firebase_core.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_local_notifications/flutter_local_notifications.dart'; @@ -7,6 +8,11 @@ import 'package:flutter_local_notifications/flutter_local_notifications.dart'; import '../../app/router.dart'; import '../network/api_client.dart'; +@pragma('vm:entry-point') +Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { + await Firebase.initializeApp(); +} + class FcmService { final ApiClient _apiClient; final FlutterLocalNotificationsPlugin _localNotifications = @@ -17,6 +23,9 @@ class FcmService { Future init() async { if (kIsWeb) return; try { + await Firebase.initializeApp(); + FirebaseMessaging.onBackgroundMessage( + _firebaseMessagingBackgroundHandler); final messaging = FirebaseMessaging.instance; await _localNotifications.initialize( const InitializationSettings( diff --git a/walkguide-mobile/walkguide_app/lib/core/services/haptic_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/haptic_service.dart index e452e21..fd21fa7 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/haptic_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/haptic_service.dart @@ -1,36 +1,107 @@ +import 'package:flutter/services.dart'; import 'package:vibration/vibration.dart'; class HapticService { - Future get _hasVibrator async => Vibration.hasVibrator(); + bool _enabled = true; + DateTime _lastObstacleVibrationAt = DateTime.fromMillisecondsSinceEpoch(0); + + static const _obstacleCooldown = Duration(seconds: 3); + + bool get enabled => _enabled; + + void setEnabled(bool enabled) { + _enabled = enabled; + if (!enabled) { + Vibration.cancel().ignore(); + } + } + + Future get _hasVibrator async { + try { + final hasVibrator = await Vibration.hasVibrator(); + return hasVibrator == true; + } catch (_) { + return false; + } + } + + bool _canRunObstacleVibration() { + final now = DateTime.now(); + if (now.difference(_lastObstacleVibrationAt) < _obstacleCooldown) { + return false; + } + _lastObstacleVibrationAt = now; + return true; + } + + Future _vibrate({ + int? duration, + List? pattern, + required Future Function() fallback, + bool obstacle = false, + }) async { + if (!_enabled) return; + if (obstacle && !_canRunObstacleVibration()) return; + + try { + if (await _hasVibrator) { + if (pattern != null) { + await Vibration.vibrate(pattern: pattern); + } else if (duration != null) { + await Vibration.vibrate(duration: duration); + } + return; + } + } catch (_) { + // Use Flutter's platform haptics below when the vibration plugin fails. + } + + await fallback(); + } Future obstacleVeryClose() async { - if (!await _hasVibrator) return; - Vibration.vibrate(pattern: [0, 500, 100, 500, 100, 500]); + await _vibrate( + pattern: [0, 500, 100, 500, 100, 500], + fallback: HapticFeedback.heavyImpact, + obstacle: true, + ); } Future obstacleClose() async { - if (!await _hasVibrator) return; - Vibration.vibrate(pattern: [0, 300, 100, 300]); + await _vibrate( + pattern: [0, 300, 100, 300], + fallback: HapticFeedback.mediumImpact, + obstacle: true, + ); } Future obstacleMedium() async { - if (!await _hasVibrator) return; - Vibration.vibrate(duration: 150); + await _vibrate( + duration: 150, + fallback: HapticFeedback.lightImpact, + obstacle: true, + ); } Future sosTriggered() async { - if (!await _hasVibrator) return; - Vibration.vibrate(pattern: [0, 1000, 200, 1000, 200, 1000]); + await _vibrate( + pattern: [0, 1000, 200, 1000, 200, 1000], + fallback: HapticFeedback.heavyImpact, + ); } Future callIncoming() async { - if (!await _hasVibrator) return; - Vibration.vibrate(pattern: [0, 500, 500, 500, 500, 500, 500, 500]); + await _vibrate( + pattern: [0, 500, 500, 500, 500, 500, 500, 500], + fallback: HapticFeedback.mediumImpact, + ); } Future success() async { - if (!await _hasVibrator) return; - Vibration.vibrate(duration: 80); + await _vibrate( + duration: 80, + fallback: HapticFeedback.selectionClick, + ); } Future stop() async => Vibration.cancel(); diff --git a/walkguide-mobile/walkguide_app/lib/core/services/hardware_shortcut_listener.dart b/walkguide-mobile/walkguide_app/lib/core/services/hardware_shortcut_listener.dart index 8549edf..b969fb0 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/hardware_shortcut_listener.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/hardware_shortcut_listener.dart @@ -27,11 +27,14 @@ class HardwareShortcutBinding { class HardwareShortcutListener { final ApiClient _apiClient; final Map _bindings = {}; + final Map _lastHandledAt = {}; bool _listening = false; void Function(HardwareShortcutAction action)? _onAction; void Function(int buttonCode, String buttonName)? _captureCallback; + static const Duration _repeatDebounce = Duration(milliseconds: 900); + HardwareShortcutListener(this._apiClient); Future startListening({ @@ -68,7 +71,8 @@ class HardwareShortcutListener { ); } - void captureNextButton(void Function(int buttonCode, String buttonName) onCapture) { + void captureNextButton( + void Function(int buttonCode, String buttonName) onCapture) { _captureCallback = onCapture; } @@ -88,6 +92,12 @@ class HardwareShortcutListener { final binding = _bindings[code]; if (binding == null || !binding.enabled) return false; + final now = DateTime.now(); + final lastHandled = _lastHandledAt[code]; + if (lastHandled != null && now.difference(lastHandled) < _repeatDebounce) { + return true; + } + _lastHandledAt[code] = now; _onAction?.call(binding.action); return true; } @@ -103,7 +113,8 @@ HardwareShortcutBinding? _bindingFromJson(Map item) { final action = _actionFromBackend(item['shortcutKey']?.toString()); final rawCode = item['buttonCode']; final enabled = item['enabled'] != false; - final code = rawCode is int ? rawCode : int.tryParse(rawCode?.toString() ?? ''); + final code = + rawCode is int ? rawCode : int.tryParse(rawCode?.toString() ?? ''); if (action == null || code == null || code <= 0) return null; return HardwareShortcutBinding( action: action, diff --git a/walkguide-mobile/walkguide_app/lib/core/services/stt_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/stt_service.dart index 260f8d6..5f11385 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/stt_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/stt_service.dart @@ -6,17 +6,24 @@ class SttService { final SpeechToText _stt = SpeechToText(); bool _available = false; bool _listening = false; + bool _shouldListen = false; + bool _initializing = false; Function(String)? onResult; Future init() async { + if (_available) return true; + if (_initializing) return _available; + _initializing = true; _available = await _stt.initialize( onError: (e) => _onError(e), onStatus: (s) => _onStatus(s), ); + _initializing = false; return _available; } Future startListening() async { + _shouldListen = true; if (!_available || _listening) return; _listening = true; await _stt.listen( @@ -25,14 +32,15 @@ class SttService { onResult?.call(result.recognizedWords.toLowerCase().trim()); } }, - listenFor: const Duration(seconds: 10), - pauseFor: const Duration(seconds: 3), + listenFor: const Duration(seconds: 60), + pauseFor: const Duration(seconds: 8), localeId: 'id_ID', cancelOnError: false, ); } Future stopListening() async { + _shouldListen = false; _listening = false; await _stt.stop(); } @@ -42,15 +50,17 @@ class SttService { void _onError(dynamic error) { _listening = false; - // Auto-restart setelah error - Future.delayed(const Duration(seconds: 1), startListening); + if (_shouldListen) { + Future.delayed(const Duration(seconds: 2), startListening); + } } void _onStatus(String status) { if (status == 'done' || status == 'notListening') { _listening = false; - // Auto-restart agar selalu mendengarkan - Future.delayed(const Duration(milliseconds: 500), startListening); + if (_shouldListen) { + Future.delayed(const Duration(seconds: 2), startListening); + } } } } diff --git a/walkguide-mobile/walkguide_app/lib/core/services/tts_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/tts_service.dart index 13c04a7..4905555 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/tts_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/tts_service.dart @@ -4,9 +4,15 @@ class TtsService { final FlutterTts _tts = FlutterTts(); final List _queue = []; bool _speaking = false; + bool _initialized = false; String _lastSpoken = ''; + DateTime _lastQueuedAt = DateTime.fromMillisecondsSinceEpoch(0); - Future init({String language = 'id-ID', double pitch = 1.0, double rate = 0.5}) async { + Future init( + {String language = 'id-ID', + double pitch = 1.0, + double rate = 0.5}) async { + if (_initialized) return; await _tts.setLanguage(language); await _tts.setPitch(pitch); await _tts.setSpeechRate(rate); @@ -15,11 +21,25 @@ class TtsService { _speaking = false; _processQueue(); }); + _initialized = true; } /// Tambah ke antrian - tidak memotong yg sedang bicara void speak(String text) { if (text.isEmpty) return; + if (!_initialized) { + init().then((_) => speak(text)); + return; + } + final now = DateTime.now(); + if (text == _lastSpoken && + now.difference(_lastQueuedAt) < const Duration(milliseconds: 900)) { + return; + } + _lastQueuedAt = now; + if (_queue.length >= 3) { + _queue.removeAt(0); + } _queue.add(text); if (!_speaking) _processQueue(); } @@ -27,6 +47,7 @@ class TtsService { /// Potong TTS sekarang dan langsung ucapkan ini - untuk obstacle alert Future speakImmediate(String text) async { if (text.isEmpty) return; + await init(); _queue.clear(); await _tts.stop(); _speaking = true; @@ -43,9 +64,20 @@ class TtsService { String get lastSpoken => _lastSpoken; bool get isSpeaking => _speaking; - Future setLanguage(String lang) async => _tts.setLanguage(lang); - Future setPitch(double pitch) async => _tts.setPitch(pitch); - Future setRate(double rate) async => _tts.setSpeechRate(rate); + Future setLanguage(String lang) async { + await init(language: lang); + await _tts.setLanguage(lang); + } + + Future setPitch(double pitch) async { + await init(); + await _tts.setPitch(pitch); + } + + Future setRate(double rate) async { + await init(); + await _tts.setSpeechRate(rate); + } void repeatLast() { if (_lastSpoken.isNotEmpty) speak(_lastSpoken); @@ -58,4 +90,4 @@ class TtsService { _lastSpoken = text; _tts.speak(text); } -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart b/walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart index a32bebf..defee10 100644 --- a/walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart +++ b/walkguide-mobile/walkguide_app/lib/core/theme/app_colors.dart @@ -1,15 +1,28 @@ import 'package:flutter/material.dart'; class AppColors { - static const primary = Color(0xFF2563EB); - static const primaryDark = Color(0xFF0F3EA8); - static const accent = Color(0xFF0891B2); + static const primaryBlue = Color(0xFF4A90D9); + static const softBlueBg = Color(0xFFEBF4FF); + static const softPinkBg = Color(0xFFFFF0F5); + static const softYellowBg = Color(0xFFFFFBEB); + static const cardWhite = Color(0xFFFFFFFF); + static const textDark = Color(0xFF2D3748); + static const textMuted = Color(0xFFA0AEC0); + static const gold = Color(0xFFF6C90E); + static const gradientBlueStart = Color(0xFF6BB8FF); + static const gradientBlueEnd = Color(0xFF4A90D9); + static const gradientPinkStart = Color(0xFFFFB3C6); + static const gradientPinkEnd = Color(0xFFFF6B9D); + + static const primary = primaryBlue; + static const primaryDark = Color(0xFF256FB8); + static const accent = Color(0xFFFF6B9D); static const warning = Color(0xFFD97706); static const danger = Color(0xFFDC2626); static const success = Color(0xFF059669); - static const surface = Color(0xFFF7FAFC); - static const surfaceRaised = Color(0xFFFFFFFF); - static const text = Color(0xFF0F172A); - static const muted = Color(0xFF64748B); + static const surface = softBlueBg; + static const surfaceRaised = cardWhite; + static const text = textDark; + static const muted = textMuted; static const border = Color(0xFFE2E8F0); } diff --git a/walkguide-mobile/walkguide_app/lib/core/theme/app_decorations.dart b/walkguide-mobile/walkguide_app/lib/core/theme/app_decorations.dart new file mode 100644 index 0000000..aee4080 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/theme/app_decorations.dart @@ -0,0 +1,74 @@ +import 'package:flutter/material.dart'; + +import 'app_colors.dart'; + +class AppDecorations { + static const cardRadius = BorderRadius.all(Radius.circular(20)); + static const pillRadius = BorderRadius.all(Radius.circular(50)); + static const inputRadius = BorderRadius.all(Radius.circular(14)); + static const iconCircleSize = 44.0; + + static const cardShadow = [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 20, + offset: Offset(0, 4), + ), + ]; + + static const avatarShadow = [ + BoxShadow( + color: Color(0x18000000), + blurRadius: 18, + offset: Offset(0, 6), + ), + ]; + + static const blueGradient = LinearGradient( + colors: [AppColors.gradientBlueStart, AppColors.gradientBlueEnd], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + static const pinkGradient = LinearGradient( + colors: [AppColors.gradientPinkStart, AppColors.gradientPinkEnd], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ); + + static const cardShape = RoundedRectangleBorder( + borderRadius: cardRadius, + ); + + static BoxDecoration get card => const BoxDecoration( + color: AppColors.cardWhite, + borderRadius: cardRadius, + boxShadow: cardShadow, + ); + + static BoxDecoration pillGradient({ + Gradient gradient = blueGradient, + }) => + BoxDecoration( + gradient: gradient, + borderRadius: pillRadius, + ); + + static BoxDecoration iconCircle({ + Color color = AppColors.softBlueBg, + }) => + BoxDecoration( + color: color, + borderRadius: pillRadius, + ); + + static BoxDecoration avatar({ + Color borderColor = Colors.white, + }) => + BoxDecoration( + shape: BoxShape.circle, + color: AppColors.cardWhite, + border: Border.all(color: borderColor, width: 3), + boxShadow: avatarShadow, + ); +} diff --git a/walkguide-mobile/walkguide_app/lib/core/theme/app_text_styles.dart b/walkguide-mobile/walkguide_app/lib/core/theme/app_text_styles.dart new file mode 100644 index 0000000..9ea10be --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/core/theme/app_text_styles.dart @@ -0,0 +1,46 @@ +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import 'app_colors.dart'; + +class AppTextStyles { + static TextStyle get heading => GoogleFonts.poppins( + fontSize: 24, + fontWeight: FontWeight.w700, + color: AppColors.textDark, + letterSpacing: 0, + height: 1.2, + ); + + static TextStyle get subheading => GoogleFonts.poppins( + fontSize: 18, + fontWeight: FontWeight.w600, + color: AppColors.textDark, + letterSpacing: 0, + height: 1.25, + ); + + static TextStyle get body => GoogleFonts.poppins( + fontSize: 14, + fontWeight: FontWeight.w400, + color: AppColors.textDark, + letterSpacing: 0, + height: 1.45, + ); + + static TextStyle get caption => GoogleFonts.poppins( + fontSize: 12, + fontWeight: FontWeight.w400, + color: AppColors.textMuted, + letterSpacing: 0, + height: 1.35, + ); + + static TextTheme get textTheme => GoogleFonts.poppinsTextTheme().copyWith( + headlineSmall: heading, + titleMedium: subheading, + bodyMedium: body, + bodySmall: caption, + labelLarge: body.copyWith(fontWeight: FontWeight.w700), + ); +} diff --git a/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart b/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart index eff1907..6f305ef 100644 --- a/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/activity_log/activity_log_screen.dart @@ -8,6 +8,9 @@ import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../core/theme/app_text_styles.dart'; +import '../../shared/widgets/animations/animations.dart'; Dio get _api => sl().dio; @@ -103,91 +106,114 @@ class _ActivityLogScreenState extends State { @override Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.softBlueBg, Colors.white], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: FadeSlideWrapper( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Activity Log', - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.w800), + // Header + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Activity Log', + style: AppTextStyles.heading, + ), + Text( + '${_items.length} aktivitas tercatat', + style: const TextStyle(color: AppColors.muted), + ), + ], ), - Text( - '${_items.length} aktivitas tercatat', - style: const TextStyle(color: AppColors.muted), - ), - ], + ), + IconButton( + onPressed: _load, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + ), + ], + ), + const SizedBox(height: 12), + + // Filter chips + SizedBox( + height: 36, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _filters.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, i) { + final f = _filters[i]; + final selected = _selectedFilter == f; + return FilterChip( + label: Text(f), + selected: selected, + onSelected: (_) { + setState(() => _applyFilter(f)); + }, + selectedColor: + AppColors.primary.withValues(alpha: 0.15), + backgroundColor: AppColors.cardWhite, + side: BorderSide( + color: selected + ? AppColors.primary.withValues(alpha: 0.4) + : AppColors.border, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(50), + ), + checkmarkColor: AppColors.primary, + labelStyle: TextStyle( + color: selected ? AppColors.primary : AppColors.muted, + fontWeight: + selected ? FontWeight.w700 : FontWeight.normal, + fontSize: 12, + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + ); + }, ), ), - IconButton( - onPressed: _load, - icon: const Icon(Icons.refresh), - tooltip: 'Refresh', + const SizedBox(height: 16), + + // Body + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _error != null + ? _ErrorPanel(message: _error!, onRetry: _load) + : _filtered.isEmpty + ? _EmptyPanel(filter: _selectedFilter) + : RefreshIndicator( + onRefresh: _load, + child: ListView( + children: [ + StaggerWrapper( + children: [ + for (final item in _filtered) + _LogCard(item: item), + ], + ), + ], + ), + ), ), ], ), - const SizedBox(height: 12), - - // Filter chips - SizedBox( - height: 36, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _filters.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (_, i) { - final f = _filters[i]; - final selected = _selectedFilter == f; - return FilterChip( - label: Text(f), - selected: selected, - onSelected: (_) { - setState(() => _applyFilter(f)); - }, - selectedColor: AppColors.primary.withValues(alpha: 0.15), - checkmarkColor: AppColors.primary, - labelStyle: TextStyle( - color: selected ? AppColors.primary : AppColors.muted, - fontWeight: - selected ? FontWeight.w700 : FontWeight.normal, - fontSize: 12, - ), - padding: const EdgeInsets.symmetric(horizontal: 4), - ); - }, - ), - ), - const SizedBox(height: 16), - - // Body - Expanded( - child: _loading - ? const Center(child: CircularProgressIndicator()) - : _error != null - ? _ErrorPanel(message: _error!, onRetry: _load) - : _filtered.isEmpty - ? _EmptyPanel(filter: _selectedFilter) - : RefreshIndicator( - onRefresh: _load, - child: ListView.builder( - itemCount: _filtered.length, - itemBuilder: (ctx, i) => - _LogCard(item: _filtered[i]), - ), - ), - ), - ], + ), ), ), ); @@ -228,71 +254,76 @@ class _LogCard extends StatelessWidget { Widget build(BuildContext context) { final meta = _logMeta(item.logType); return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Timeline dot + line - Column( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: meta.color.withValues(alpha: 0.12), - shape: BoxShape.circle, + padding: const EdgeInsets.only(bottom: 10), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: AppColors.border), + boxShadow: AppDecorations.cardShadow, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timeline dot + line + Column( + children: [ + Container( + width: 36, + height: 36, + decoration: BoxDecoration( + color: meta.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(50), + ), + child: Icon(meta.icon, color: meta.color, size: 18), ), - child: Icon(meta.icon, color: meta.color, size: 18), - ), - Container( - width: 1.5, - height: 20, - color: const Color(0xFFE2E8F0), - ), - ], - ), - const SizedBox(width: 12), - // Content - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 4), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - meta.label, - style: TextStyle( - fontWeight: FontWeight.w700, - color: meta.color, - fontSize: 13, + ], + ), + const SizedBox(width: 12), + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 4), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + meta.label, + style: TextStyle( + fontWeight: FontWeight.w700, + color: meta.color, + fontSize: 13, + ), ), ), - ), - Text( - _formatTime(item.createdAt), - style: const TextStyle( - color: AppColors.muted, fontSize: 11), - ), - ], - ), - if (item.description != null && item.description!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 2), - child: Text( - item.description!, - style: const TextStyle( - fontSize: 13, color: AppColors.text), - ), + Text( + _formatTime(item.createdAt), + style: const TextStyle( + color: AppColors.muted, fontSize: 11), + ), + ], ), - const SizedBox(height: 12), - ], + if (item.description != null && + item.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + item.description!, + style: const TextStyle( + fontSize: 13, color: AppColors.text), + ), + ), + const SizedBox(height: 12), + ], + ), ), ), - ), - ], + ], + ), ), ); } @@ -394,21 +425,29 @@ class _ErrorPanel extends StatelessWidget { @override Widget build(BuildContext context) { return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.wifi_off, size: 48, color: AppColors.muted), - const SizedBox(height: 12), - Text(message, - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.muted)), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh), - label: const Text('Coba lagi'), - ), - ], + child: Container( + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + boxShadow: AppDecorations.cardShadow, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off, size: 48, color: AppColors.muted), + const SizedBox(height: 12), + Text(message, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted)), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Coba lagi'), + ), + ], + ), ), ); } @@ -421,21 +460,29 @@ class _EmptyPanel extends StatelessWidget { @override Widget build(BuildContext context) { return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.history, size: 64, color: AppColors.muted), - const SizedBox(height: 12), - Text( - filter == 'ALL' - ? 'Belum ada aktivitas' - : 'Tidak ada aktivitas "$filter"', - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.muted), - ), - ], + child: Container( + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + boxShadow: AppDecorations.cardShadow, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.history, size: 64, color: AppColors.muted), + const SizedBox(height: 12), + Text( + filter == 'ALL' + ? 'Belum ada aktivitas' + : 'Tidak ada aktivitas "$filter"', + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.muted), + ), + ], + ), ), ); } diff --git a/walkguide-mobile/walkguide_app/lib/features/ai_benchmark/ai_benchmark_screen.dart b/walkguide-mobile/walkguide_app/lib/features/ai_benchmark/ai_benchmark_screen.dart index c312b42..561b944 100644 --- a/walkguide-mobile/walkguide_app/lib/features/ai_benchmark/ai_benchmark_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/ai_benchmark/ai_benchmark_screen.dart @@ -9,8 +9,11 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../app/injection_container.dart'; import '../../core/ai/detection_export.dart'; import '../../core/constants/app_constants.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; import '../../core/services/tts_service.dart'; import '../../core/utils/operation_guard.dart'; +import '../../shared/widgets/animations/animations.dart'; import '../../shared/widgets/feature_page.dart'; class AiBenchmarkScreen extends StatefulWidget { @@ -116,18 +119,22 @@ class _AiBenchmarkScreenState extends State { CameraController? controller; await guarded( () async { - final cameras = - await availableCameras().timeout(const Duration(seconds: 3)); - if (cameras.isNotEmpty) { - final activeController = CameraController( - cameras.first, - ResolutionPreset.low, - enableAudio: false, - ); - controller = activeController; - await activeController.initialize().timeout(const Duration(seconds: 5)); - await activeController.takePicture().timeout(const Duration(seconds: 5)); - } + final cameras = + await availableCameras().timeout(const Duration(seconds: 3)); + if (cameras.isNotEmpty) { + final activeController = CameraController( + cameras.first, + ResolutionPreset.low, + enableAudio: false, + ); + controller = activeController; + await activeController + .initialize() + .timeout(const Duration(seconds: 5)); + await activeController + .takePicture() + .timeout(const Duration(seconds: 5)); + } }, onError: (_) {}, ); @@ -198,7 +205,11 @@ class _AiBenchmarkScreenState extends State { label: const Text('Clear log'), ), const SizedBox(height: 16), - for (final run in _runs) _BenchmarkCard(run: run), + StaggerWrapper( + children: [ + for (final run in _runs) _BenchmarkCard(run: run), + ], + ), if (_runs.isEmpty) const FeatureEmptyPanel( icon: Icons.speed, @@ -224,9 +235,10 @@ class _BenchmarkCard extends StatelessWidget { margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFE2E8F0)), + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: AppColors.border), + boxShadow: AppDecorations.cardShadow, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -262,7 +274,8 @@ class _StatusBox extends StatelessWidget { return DecoratedBox( decoration: BoxDecoration( color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2), - borderRadius: BorderRadius.circular(14), + borderRadius: AppDecorations.cardRadius, + boxShadow: AppDecorations.cardShadow, ), child: Padding( padding: const EdgeInsets.all(12), @@ -279,17 +292,17 @@ class _StatusBox extends StatelessWidget { Future> _discoverTfliteModels() async { return await guarded>( - () async { - final manifestRaw = await rootBundle.loadString('AssetManifest.json'); - final manifest = jsonDecode(manifestRaw) as Map; - final models = manifest.keys - .where((key) => - key.startsWith('assets/models/') && key.endsWith('.tflite')) - .toList() - ..sort(); - return models; - }, - ) ?? + () async { + final manifestRaw = await rootBundle.loadString('AssetManifest.json'); + final manifest = jsonDecode(manifestRaw) as Map; + final models = manifest.keys + .where((key) => + key.startsWith('assets/models/') && key.endsWith('.tflite')) + .toList() + ..sort(); + return models; + }, + ) ?? const []; } diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart index a62bbf1..ca90611 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/login_screen.dart @@ -19,6 +19,9 @@ import '../../core/services/offline_queue_service.dart'; import '../../core/services/tts_service.dart'; import '../../core/services/websocket_service.dart'; import '../../core/storage/secure_storage.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../core/theme/app_text_styles.dart'; // --------------------------------------------------------------------------- // LoginScreen @@ -79,7 +82,8 @@ class _LoginScreenState extends State { }, onError: (message) => _snack(context, message), fallback: 'Login gagal. Periksa email dan password kamu.', - connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.', + connectionHint: + 'Tidak bisa ke server. Cek URL backend di Connect Server dan pastikan server aktif.', ); if (mounted) setState(() => _loading = false); } @@ -150,37 +154,25 @@ class _AuthFrame extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFEAF4FF), + backgroundColor: AppColors.softBlueBg, body: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < 480 || constraints.maxHeight < 720; - return Stack( - children: [ - const Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)], - ), - ), - ), + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.softBlueBg, + Colors.white, + AppColors.softPinkBg, + ], ), - Positioned( - top: compact ? -70 : -90, - right: compact ? -70 : -60, - child: Container( - width: compact ? 180 : 260, - height: compact ? 180 : 260, - decoration: BoxDecoration( - color: const Color(0xFF2563EB).withValues(alpha: 0.12), - shape: BoxShape.circle, - ), - ), - ), - Center( + ), + child: SafeArea( + child: Center( child: SingleChildScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, @@ -206,19 +198,10 @@ class _AuthFrame extends StatelessWidget { child: RepaintBoundary( child: Container( decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.96), + color: AppColors.cardWhite, borderRadius: - BorderRadius.circular(compact ? 22 : 30), - border: Border.all( - color: Colors.white.withValues(alpha: 0.8)), - boxShadow: [ - BoxShadow( - color: const Color(0xFF1E3A8A) - .withValues(alpha: 0.14), - blurRadius: compact ? 24 : 40, - offset: const Offset(0, 18), - ), - ], + BorderRadius.circular(compact ? 22 : 28), + boxShadow: AppDecorations.cardShadow, ), child: Padding( padding: EdgeInsets.fromLTRB( @@ -236,13 +219,15 @@ class _AuthFrame extends StatelessWidget { width: compact ? 44 : 56, height: compact ? 44 : 56, decoration: BoxDecoration( - gradient: const LinearGradient( - colors: [ - Color(0xFF2563EB), - Color(0xFF0891B2) - ], - ), + gradient: AppDecorations.blueGradient, borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Color(0x334A90D9), + blurRadius: 18, + offset: Offset(0, 8), + ), + ], ), child: Icon(Icons.navigation_rounded, color: Colors.white, @@ -256,8 +241,8 @@ class _AuthFrame extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 18, - fontWeight: FontWeight.w900, - color: Color(0xFF0F172A), + fontWeight: FontWeight.w800, + color: AppColors.textDark, ), ), ), @@ -269,21 +254,22 @@ class _AuthFrame extends StatelessWidget { padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 6), decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), + color: AppColors.softBlueBg, borderRadius: BorderRadius.circular(999), ), child: const Row( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.shield_outlined, - size: 14, color: Color(0xFF1D4ED8)), + size: 14, + color: AppColors.primaryBlue), SizedBox(width: 6), Text( 'Secure Assistive Navigation', style: TextStyle( - color: Color(0xFF1D4ED8), + color: AppColors.primaryBlue, fontSize: 11, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, ), ), ], @@ -294,14 +280,10 @@ class _AuthFrame extends StatelessWidget { title, maxLines: 2, overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .headlineMedium - ?.copyWith( - fontSize: compact ? 26 : null, - fontWeight: FontWeight.w900, - color: const Color(0xFF0F172A), - ), + style: AppTextStyles.heading.copyWith( + fontSize: compact ? 26 : null, + fontWeight: FontWeight.w800, + ), ), const SizedBox(height: 6), Text( @@ -309,7 +291,7 @@ class _AuthFrame extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Color(0xFF64748B), + color: AppColors.muted, height: 1.35, ), ), @@ -324,7 +306,7 @@ class _AuthFrame extends StatelessWidget { ), ), ), - ], + ), ); }, ), diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart index 39a1671..3c57234 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/register_screen.dart @@ -7,6 +7,10 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../core/theme/app_text_styles.dart'; +import '../../shared/widgets/animations/animations.dart'; // --------------------------------------------------------------------------- // RegisterScreen @@ -69,7 +73,8 @@ class _RegisterScreenState extends State { }, onError: (message) => _snack(context, message), fallback: 'Registrasi gagal. Periksa data akun kamu.', - connectionHint: 'Tidak bisa ke server. Pakai URL backend publik/aktif.', + connectionHint: + 'Tidak bisa ke server. Cek URL backend di Connect Server dan pastikan server aktif.', ); if (mounted) setState(() => _loading = false); } @@ -128,7 +133,7 @@ class _RegisterScreenState extends State { padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), decoration: BoxDecoration( color: _role == 'USER' - ? const Color(0xFFEFF6FF) + ? AppColors.softBlueBg : const Color(0xFFF0FDF4), borderRadius: BorderRadius.circular(999), ), @@ -234,18 +239,19 @@ class _RoleCard extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( + return BounceTap( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 180), padding: const EdgeInsets.all(16), decoration: BoxDecoration( - color: selected ? const Color(0xFFEFF6FF) : Colors.white, - borderRadius: BorderRadius.circular(14), + color: selected ? AppColors.softBlueBg : Colors.white, + borderRadius: BorderRadius.circular(20), border: Border.all( - color: selected ? const Color(0xFF1A56DB) : const Color(0xFFE2E8F0), + color: selected ? AppColors.primaryBlue : AppColors.border, width: selected ? 2 : 1, ), + boxShadow: selected ? AppDecorations.cardShadow : null, ), child: Row( children: [ @@ -253,10 +259,9 @@ class _RoleCard extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - color: selected - ? const Color(0xFF1A56DB) - : const Color(0xFFF1F5F9), - borderRadius: BorderRadius.circular(12), + color: + selected ? AppColors.primaryBlue : const Color(0xFFF1F5F9), + borderRadius: BorderRadius.circular(50), ), child: Icon(icon, color: selected ? Colors.white : const Color(0xFF64748B)), @@ -267,16 +272,16 @@ class _RoleCard extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, - style: const TextStyle( - fontWeight: FontWeight.w800, fontSize: 16)), + style: AppTextStyles.subheading.copyWith(fontSize: 16)), Text(subtitle, style: const TextStyle( - color: Color(0xFF64748B), fontSize: 13)), + color: AppColors.muted, fontSize: 13)), ], ), ), if (selected) - const Icon(Icons.check_circle_rounded, color: Color(0xFF1A56DB)), + const Icon(Icons.check_circle_rounded, + color: AppColors.primaryBlue), ], ), ), @@ -298,37 +303,25 @@ class _AuthFrame extends StatelessWidget { @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFEAF4FF), + backgroundColor: AppColors.softBlueBg, body: LayoutBuilder( builder: (context, constraints) { final compact = constraints.maxWidth < 480 || constraints.maxHeight < 720; - return Stack( - children: [ - const Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, - colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)], - ), - ), - ), + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + AppColors.softBlueBg, + Colors.white, + AppColors.softPinkBg, + ], ), - Positioned( - top: compact ? -70 : -90, - right: compact ? -70 : -60, - child: Container( - width: compact ? 180 : 260, - height: compact ? 180 : 260, - decoration: BoxDecoration( - color: const Color(0xFF2563EB).withValues(alpha: 0.12), - shape: BoxShape.circle, - ), - ), - ), - Center( + ), + child: SafeArea( + child: Center( child: SingleChildScrollView( keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag, @@ -354,19 +347,10 @@ class _AuthFrame extends StatelessWidget { child: RepaintBoundary( child: Container( decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.96), + color: AppColors.cardWhite, borderRadius: - BorderRadius.circular(compact ? 22 : 30), - border: Border.all( - color: Colors.white.withValues(alpha: 0.8)), - boxShadow: [ - BoxShadow( - color: const Color(0xFF1E3A8A) - .withValues(alpha: 0.14), - blurRadius: compact ? 24 : 40, - offset: const Offset(0, 18), - ), - ], + BorderRadius.circular(compact ? 22 : 28), + boxShadow: AppDecorations.cardShadow, ), child: Padding( padding: EdgeInsets.fromLTRB( @@ -384,8 +368,15 @@ class _AuthFrame extends StatelessWidget { width: compact ? 44 : 56, height: compact ? 44 : 56, decoration: BoxDecoration( - color: const Color(0xFF1D4ED8), + gradient: AppDecorations.blueGradient, borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Color(0x334A90D9), + blurRadius: 18, + offset: Offset(0, 8), + ), + ], ), child: Icon(Icons.navigation_rounded, color: Colors.white, @@ -399,8 +390,8 @@ class _AuthFrame extends StatelessWidget { overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 18, - fontWeight: FontWeight.w900, - color: Color(0xFF0F172A), + fontWeight: FontWeight.w800, + color: AppColors.textDark, ), ), ), @@ -411,14 +402,10 @@ class _AuthFrame extends StatelessWidget { title, maxLines: 2, overflow: TextOverflow.ellipsis, - style: Theme.of(context) - .textTheme - .headlineMedium - ?.copyWith( - fontSize: compact ? 26 : null, - fontWeight: FontWeight.w900, - color: const Color(0xFF0F172A), - ), + style: AppTextStyles.heading.copyWith( + fontSize: compact ? 26 : null, + fontWeight: FontWeight.w800, + ), ), const SizedBox(height: 6), Text( @@ -426,7 +413,7 @@ class _AuthFrame extends StatelessWidget { maxLines: 2, overflow: TextOverflow.ellipsis, style: const TextStyle( - color: Color(0xFF64748B), + color: AppColors.muted, height: 1.35, ), ), @@ -441,7 +428,7 @@ class _AuthFrame extends StatelessWidget { ), ), ), - ], + ), ); }, ), diff --git a/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart b/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart index 3fa7e11..9667293 100644 --- a/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/auth/splash_screen.dart @@ -13,6 +13,7 @@ import '../../core/services/incoming_call_polling_service.dart'; import '../../core/services/offline_queue_service.dart'; import '../../core/services/websocket_service.dart'; import '../../core/storage/secure_storage.dart'; +import '../../shared/widgets/walkguide_loading_screen.dart'; // --------------------------------------------------------------------------- // SplashScreen @@ -39,32 +40,35 @@ class SplashScreen extends StatefulWidget { class _SplashScreenState extends State with SingleTickerProviderStateMixin { - late final AnimationController _animCtrl; - late final Animation _fadeAnim; + late final AnimationController _screenCtrl; + late final Animation _screenFade; @override void initState() { super.initState(); - _animCtrl = AnimationController( + _screenCtrl = AnimationController( vsync: this, - duration: const Duration(milliseconds: 700), + duration: const Duration(milliseconds: 260), + value: 1, + ); + _screenFade = CurvedAnimation( + parent: _screenCtrl, + curve: Curves.easeOutCubic, ); - _fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeIn); - _animCtrl.forward(); _route(); } @override void dispose() { - _animCtrl.dispose(); + _screenCtrl.dispose(); super.dispose(); } Future _route() async { final routed = await runFriendlyAction( () async { - // Animasi logo selalu tampil minimal 500ms agar tidak langsung flash. - await Future.delayed(const Duration(milliseconds: 500)); + // Animasi logo selalu tampil minimal 900ms agar tidak langsung flash. + await Future.delayed(const Duration(milliseconds: 900)); final storage = sl(); final token = await storage.getAccessToken().timeout( @@ -77,7 +81,7 @@ class _SplashScreenState extends State if (!mounted) return; if (token == null || role == null) { - context.go('/login'); + await _fadeOutThenGo('/login'); return; } @@ -88,67 +92,28 @@ class _SplashScreenState extends State sl().start(); } // Auto-login: arahkan ke home sesuai role. - context.go( + await _fadeOutThenGo( role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide', ); }, onError: (_) {}, fallback: 'Sesi belum bisa dipulihkan.', ); - if (!routed && mounted) context.go('/login'); + if (!routed && mounted) await _fadeOutThenGo('/login'); + } + + Future _fadeOutThenGo(String route) async { + if (!mounted) return; + await _screenCtrl.reverse(); + if (mounted) context.go(route); } @override Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFF1A56DB), - body: Center( - child: FadeTransition( - opacity: _fadeAnim, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - // Logo / icon - Container( - width: 100, - height: 100, - decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(28), - ), - child: const Icon( - Icons.navigation_rounded, - color: Colors.white, - size: 60, - ), - ), - const SizedBox(height: 24), - const Text( - 'WalkGuide', - style: TextStyle( - color: Colors.white, - fontSize: 34, - fontWeight: FontWeight.w800, - letterSpacing: -0.5, - ), - ), - const SizedBox(height: 6), - const Text( - 'AI-powered navigation for everyone', - style: TextStyle(color: Colors.white70, fontSize: 13), - ), - const SizedBox(height: 48), - const SizedBox( - width: 28, - height: 28, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2.5, - ), - ), - ], - ), - ), + return FadeTransition( + opacity: _screenFade, + child: const WalkGuideLoadingScreen( + subtitle: 'Restoring your session', ), ); } diff --git a/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart b/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart index c234345..7e2103d 100644 --- a/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/call/call_screen.dart @@ -10,8 +10,11 @@ import '../../core/services/call_service.dart'; import '../../core/services/haptic_service.dart'; import '../../core/services/tts_service.dart'; import '../../core/storage/secure_storage.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../shared/widgets/animations/animations.dart'; -const _kBlue = Color(0xFF1A56DB); +const _kBlue = AppColors.primaryBlue; const _kGreen = Color(0xFF16A34A); const _kRed = Color(0xFFDC2626); const _kMuted = Color(0xFF64748B); @@ -588,30 +591,42 @@ class _CallScaffold extends StatelessWidget { Widget build(BuildContext context) { return Scaffold( backgroundColor: _kBg, - body: SafeArea( - child: Column( - children: [ - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - const SizedBox(width: 48), - Expanded( - child: Text( - title, - textAlign: TextAlign.center, - style: const TextStyle( - color: Colors.white70, - fontWeight: FontWeight.w600, + body: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [_kBg, Color(0xFF172554)], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: FadeSlideWrapper( + child: Column( + children: [ + Padding( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + const SizedBox(width: 48), + Expanded( + child: Text( + title, + textAlign: TextAlign.center, + style: const TextStyle( + color: Colors.white70, + fontWeight: FontWeight.w600, + ), + ), ), - ), + const SizedBox(width: 48), + ], ), - const SizedBox(width: 48), - ], - ), + ), + Expanded(child: child), + ], ), - Expanded(child: child), - ], + ), ), ), ); @@ -666,7 +681,8 @@ class _Avatar extends StatelessWidget { decoration: BoxDecoration( shape: BoxShape.circle, color: color.withValues(alpha: 0.2), - border: Border.all(color: color, width: 3), + border: Border.all(color: Colors.white, width: 3), + boxShadow: AppDecorations.avatarShadow, ), child: Icon(icon, color: Colors.white, size: 56), ); @@ -688,7 +704,7 @@ class _ControlButton extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( + return BounceTap( onTap: onTap, child: Column( children: [ @@ -718,7 +734,7 @@ class _EndCallButton extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( + return BounceTap( onTap: onTap, child: Column( children: [ @@ -754,7 +770,7 @@ class _RoundCallButton extends StatelessWidget { @override Widget build(BuildContext context) { - return GestureDetector( + return BounceTap( onTap: onTap, child: Opacity( opacity: onTap == null ? 0.4 : 1, diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart index a30065e..d3ec251 100644 --- a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart @@ -1,542 +1,564 @@ -// lib/features/guardian_dashboard/guardian_activity_log_screen.dart -// ignore_for_file: use_build_context_synchronously - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import 'package:intl/intl.dart'; - -import '../../../app/injection_container.dart'; -import '../../../core/errors/friendly_error.dart'; -import '../../../core/network/api_client.dart'; - -Dio get _api => sl().dio; - -class GuardianActivityLogScreen extends StatefulWidget { - const GuardianActivityLogScreen({super.key}); - - @override - State createState() => - _GuardianActivityLogScreenState(); -} - -class _GuardianActivityLogScreenState extends State { - List<_LogItem> _items = []; - List<_LogItem> _filtered = []; - bool _loading = true; - String? _error; - String _selectedFilter = 'ALL'; - bool _needsPairing = false; - - static const _filters = [ - 'ALL', - 'WALKGUIDE', - 'SOS', - 'AUTH', - 'OBSTACLE', - 'LOCATION', - ]; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - setState(() { - _loading = true; - _error = null; - _needsPairing = false; - }); - await runFriendlyAction( - () async { - // Cek pairing dulu - final paired = await _hasActivePairing(); - if (!paired) { - setState(() { - _needsPairing = true; - _loading = false; - }); - return; - } - - final res = await _api.get('/guardian/activity-logs', queryParameters: { - 'size': 50, - 'page': 0 - }).timeout(const Duration(seconds: 10)); - - // Response bisa berupa list langsung atau paged {content: [...]} - final data = res.data['data']; - List list; - if (data is List) { - list = data; - } else if (data is Map && data['content'] is List) { - list = data['content'] as List; - } else { - list = []; - } - - final items = list - .whereType() - .map((e) => _LogItem.fromJson(Map.from(e))) - .toList(); - - setState(() { - _items = items; - _applyFilter(_selectedFilter); - _loading = false; - }); - }, - onError: (message) => setState(() { - _error = message; - _loading = false; - }), - fallback: 'Gagal memuat activity log. Coba refresh lagi.', - ); - } - - Future _hasActivePairing() async { - final active = await runFriendly( - () async { - final res = await _api - .get('/shared/pairing/status') - .timeout(const Duration(seconds: 5)); - final data = res.data['data']; - if (data is Map) return data['status'] == 'ACTIVE'; - return false; - }, - onError: (_) {}, - fallback: 'Status pairing belum bisa dicek.', - ); - return active ?? false; - } - - void _applyFilter(String filter) { - _selectedFilter = filter; - if (filter == 'ALL') { - _filtered = List.from(_items); - } else { - _filtered = _items.where((item) { - switch (filter) { - case 'WALKGUIDE': - return item.logType.contains('WALKGUIDE'); - case 'SOS': - return item.logType.contains('SOS'); - case 'AUTH': - return item.logType == 'LOGIN' || - item.logType == 'LOGOUT' || - item.logType == 'APP_OPEN' || - item.logType == 'APP_CLOSE'; - case 'OBSTACLE': - return item.logType.contains('OBSTACLE'); - case 'LOCATION': - return item.logType.contains('LOCATION') || - item.logType.contains('GEOFENCE'); - default: - return true; - } - }).toList(); - } - } - - @override - Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Header ────────────────────────────────────────────────────── - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'User Logs', - style: GoogleFonts.outfit( - fontSize: 22, - fontWeight: FontWeight.w800, - color: const Color(0xFF0F172A), - ), - ), - Text( - _needsPairing - ? 'Pairing dulu untuk melihat log' - : '${_items.length} aktivitas tercatat', - style: GoogleFonts.inter( - fontSize: 13, - color: const Color(0xFF64748B), - ), - ), - ], - ), - ), - IconButton( - onPressed: _load, - icon: const Icon(Icons.refresh_rounded), - tooltip: 'Refresh', - color: const Color(0xFF64748B), - ), - ], - ), - const SizedBox(height: 12), - - // ── Filter chips ───────────────────────────────────────────────── - if (!_needsPairing && !_loading && _error == null) - SizedBox( - height: 36, - child: ListView.separated( - scrollDirection: Axis.horizontal, - itemCount: _filters.length, - separatorBuilder: (_, __) => const SizedBox(width: 8), - itemBuilder: (_, i) { - final f = _filters[i]; - final selected = _selectedFilter == f; - return FilterChip( - label: Text(f), - selected: selected, - onSelected: (_) => setState(() => _applyFilter(f)), - selectedColor: - const Color(0xFF1A56DB).withValues(alpha: 0.12), - checkmarkColor: const Color(0xFF1A56DB), - labelStyle: GoogleFonts.inter( - color: selected - ? const Color(0xFF1A56DB) - : const Color(0xFF64748B), - fontWeight: - selected ? FontWeight.w700 : FontWeight.normal, - fontSize: 12, - ), - padding: const EdgeInsets.symmetric(horizontal: 4), - side: BorderSide( - color: selected - ? const Color(0xFF1A56DB) - : const Color(0xFFE2E8F0), - ), - ); - }, - ), - ), - - if (!_needsPairing && !_loading && _error == null) - const SizedBox(height: 16), - - // ── Body ───────────────────────────────────────────────────────── - Expanded( - child: _loading - ? const Center(child: CircularProgressIndicator()) - : _needsPairing - ? _buildNoPairingPanel() - : _error != null - ? _buildErrorPanel() - : _filtered.isEmpty - ? _buildEmptyPanel() - : RefreshIndicator( - onRefresh: _load, - color: const Color(0xFF1A56DB), - child: ListView.builder( - itemCount: _filtered.length, - itemBuilder: (ctx, i) => - _LogCard(item: _filtered[i]), - ), - ), - ), - ], - ), - ), - ); - } - - Widget _buildNoPairingPanel() { - return Center( - child: Container( - padding: const EdgeInsets.all(24), - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: const Color(0xFFFFFBEB), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFFDE68A)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.link_off, color: Color(0xFFD97706), size: 52), - const SizedBox(height: 14), - Text( - 'Belum Pairing', - style: GoogleFonts.outfit( - fontSize: 18, - fontWeight: FontWeight.w700, - color: const Color(0xFF92400E), - ), - ), - const SizedBox(height: 8), - Text( - 'Hubungkan akun Guardian dengan User terlebih dahulu untuk melihat log aktivitas.', - style: GoogleFonts.inter( - fontSize: 13, - color: const Color(0xFF92400E), - ), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } - - Widget _buildErrorPanel() { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.wifi_off, size: 52, color: Color(0xFF94A3B8)), - const SizedBox(height: 14), - Text( - _error!, - textAlign: TextAlign.center, - style: - GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B)), - ), - const SizedBox(height: 18), - FilledButton.icon( - onPressed: _load, - icon: const Icon(Icons.refresh), - label: const Text('Coba lagi'), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF1A56DB)), - ), - ], - ), - ); - } - - Widget _buildEmptyPanel() { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.history, size: 64, color: Color(0xFF94A3B8)), - const SizedBox(height: 14), - Text( - _selectedFilter == 'ALL' - ? 'Belum ada aktivitas' - : 'Tidak ada aktivitas "$_selectedFilter"', - style: GoogleFonts.inter( - fontSize: 15, - fontWeight: FontWeight.w600, - color: const Color(0xFF94A3B8), - ), - ), - ], - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// DATA MODEL -// ───────────────────────────────────────────────────────────────────────────── - -class _LogItem { - final int id; - final String logType; - final String? description; - final DateTime createdAt; - - const _LogItem({ - required this.id, - required this.logType, - this.description, - required this.createdAt, - }); - - factory _LogItem.fromJson(Map j) => _LogItem( - id: (j['id'] as num?)?.toInt() ?? 0, - logType: j['logType']?.toString() ?? 'UNKNOWN', - description: j['description']?.toString(), - createdAt: - DateTime.tryParse(j['createdAt']?.toString() ?? '')?.toLocal() ?? - DateTime.now(), - ); -} - -// ───────────────────────────────────────────────────────────────────────────── -// LOG CARD -// ───────────────────────────────────────────────────────────────────────────── - -class _LogCard extends StatelessWidget { - final _LogItem item; - const _LogCard({required this.item}); - - @override - Widget build(BuildContext context) { - final meta = _logMeta(item.logType); - return Padding( - padding: const EdgeInsets.only(bottom: 8), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Timeline dot + connector line - Column( - children: [ - Container( - width: 38, - height: 38, - decoration: BoxDecoration( - color: meta.color.withValues(alpha: 0.12), - shape: BoxShape.circle, - ), - child: Icon(meta.icon, color: meta.color, size: 18), - ), - Container( - width: 1.5, - height: 22, - color: const Color(0xFFE2E8F0), - ), - ], - ), - const SizedBox(width: 12), - // Content - Expanded( - child: Padding( - padding: const EdgeInsets.only(top: 6), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded( - child: Text( - meta.label, - style: GoogleFonts.inter( - fontWeight: FontWeight.w700, - color: meta.color, - fontSize: 13, - ), - ), - ), - Text( - _formatTime(item.createdAt), - style: GoogleFonts.jetBrainsMono( - color: const Color(0xFF94A3B8), - fontSize: 11, - ), - ), - ], - ), - if (item.description != null && item.description!.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: 3), - child: Text( - item.description!, - style: GoogleFonts.inter( - fontSize: 12, - color: const Color(0xFF64748B), - ), - ), - ), - const SizedBox(height: 14), - ], - ), - ), - ), - ], - ), - ); - } - - String _formatTime(DateTime dt) { - final now = DateTime.now(); - if (dt.day == now.day && dt.month == now.month && dt.year == now.year) { - return DateFormat('HH:mm').format(dt); - } - return DateFormat('dd MMM HH:mm').format(dt); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// LOG METADATA -// ───────────────────────────────────────────────────────────────────────────── - -class _LogMeta { - final IconData icon; - final Color color; - final String label; - const _LogMeta( - {required this.icon, required this.color, required this.label}); -} - -_LogMeta _logMeta(String logType) { - switch (logType.toUpperCase()) { - case 'LOGIN': - return const _LogMeta( - icon: Icons.login, color: Color(0xFF16A34A), label: 'Login'); - case 'LOGOUT': - return const _LogMeta( - icon: Icons.logout, color: Color(0xFF94A3B8), label: 'Logout'); - case 'APP_OPEN': - return const _LogMeta( - icon: Icons.open_in_new, - color: Color(0xFF1A56DB), - label: 'App Dibuka'); - case 'APP_CLOSE': - return const _LogMeta( - icon: Icons.close, color: Color(0xFF94A3B8), label: 'App Ditutup'); - case 'WALKGUIDE_START': - return const _LogMeta( - icon: Icons.directions_walk, - color: Color(0xFF1A56DB), - label: 'WalkGuide Mulai'); - case 'WALKGUIDE_STOP': - return const _LogMeta( - icon: Icons.stop_circle, - color: Color(0xFF94A3B8), - label: 'WalkGuide Berhenti'); - case 'OBSTACLE_DETECTED': - return const _LogMeta( - icon: Icons.warning_amber, - color: Color(0xFFD97706), - label: 'Obstacle Terdeteksi'); - case 'SOS_TRIGGERED': - return const _LogMeta( - icon: Icons.sos, color: Color(0xFFDC2626), label: 'SOS Terkirim'); - case 'SOS_ACKNOWLEDGED': - return const _LogMeta( - icon: Icons.check_circle, - color: Color(0xFF16A34A), - label: 'SOS Diakui Guardian'); - case 'CALL_INITIATED': - return const _LogMeta( - icon: Icons.call, - color: Color(0xFF16A34A), - label: 'Panggilan Dimulai'); - case 'CALL_ENDED': - return const _LogMeta( - icon: Icons.call_end, - color: Color(0xFF94A3B8), - label: 'Panggilan Selesai'); - case 'LOCATION_UPDATE': - return const _LogMeta( - icon: Icons.location_on, - color: Color(0xFF1A56DB), - label: 'Lokasi Diperbarui'); - case 'GEOFENCE_EXIT': - return const _LogMeta( - icon: Icons.fence, - color: Color(0xFFDC2626), - label: 'Keluar Area Aman'); - case 'GEOFENCE_ENTER': - return const _LogMeta( - icon: Icons.home, color: Color(0xFF16A34A), label: 'Masuk Area Aman'); - default: - return _LogMeta( - icon: Icons.circle_outlined, - color: const Color(0xFF94A3B8), - label: logType); - } -} +// lib/features/guardian_dashboard/guardian_activity_log_screen.dart +// ignore_for_file: use_build_context_synchronously + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; + +import '../../../app/injection_container.dart'; +import '../../../core/errors/friendly_error.dart'; +import '../../../core/network/api_client.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_decorations.dart'; +import '../../../core/theme/app_text_styles.dart'; +import '../../../shared/widgets/animations/animations.dart'; + +Dio get _api => sl().dio; + +class GuardianActivityLogScreen extends StatefulWidget { + const GuardianActivityLogScreen({super.key}); + + @override + State createState() => + _GuardianActivityLogScreenState(); +} + +class _GuardianActivityLogScreenState extends State { + List<_LogItem> _items = []; + List<_LogItem> _filtered = []; + bool _loading = true; + String? _error; + String _selectedFilter = 'ALL'; + bool _needsPairing = false; + + static const _filters = [ + 'ALL', + 'WALKGUIDE', + 'SOS', + 'AUTH', + 'OBSTACLE', + 'LOCATION', + ]; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + _needsPairing = false; + }); + await runFriendlyAction( + () async { + // Cek pairing dulu + final paired = await _hasActivePairing(); + if (!paired) { + setState(() { + _needsPairing = true; + _loading = false; + }); + return; + } + + final res = await _api.get('/guardian/activity-logs', queryParameters: { + 'size': 50, + 'page': 0 + }).timeout(const Duration(seconds: 10)); + + // Response bisa berupa list langsung atau paged {content: [...]} + final data = res.data['data']; + List list; + if (data is List) { + list = data; + } else if (data is Map && data['content'] is List) { + list = data['content'] as List; + } else { + list = []; + } + + final items = list + .whereType() + .map((e) => _LogItem.fromJson(Map.from(e))) + .toList(); + + setState(() { + _items = items; + _applyFilter(_selectedFilter); + _loading = false; + }); + }, + onError: (message) => setState(() { + _error = message; + _loading = false; + }), + fallback: 'Gagal memuat activity log. Coba refresh lagi.', + ); + } + + Future _hasActivePairing() async { + final active = await runFriendly( + () async { + final res = await _api + .get('/shared/pairing/status') + .timeout(const Duration(seconds: 5)); + final data = res.data['data']; + if (data is Map) return data['status'] == 'ACTIVE'; + return false; + }, + onError: (_) {}, + fallback: 'Status pairing belum bisa dicek.', + ); + return active ?? false; + } + + void _applyFilter(String filter) { + _selectedFilter = filter; + if (filter == 'ALL') { + _filtered = List.from(_items); + } else { + _filtered = _items.where((item) { + switch (filter) { + case 'WALKGUIDE': + return item.logType.contains('WALKGUIDE'); + case 'SOS': + return item.logType.contains('SOS'); + case 'AUTH': + return item.logType == 'LOGIN' || + item.logType == 'LOGOUT' || + item.logType == 'APP_OPEN' || + item.logType == 'APP_CLOSE'; + case 'OBSTACLE': + return item.logType.contains('OBSTACLE'); + case 'LOCATION': + return item.logType.contains('LOCATION') || + item.logType.contains('GEOFENCE'); + default: + return true; + } + }).toList(); + } + } + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.softBlueBg, Colors.white], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: FadeSlideWrapper( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────────────── + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'User Logs', + style: AppTextStyles.heading, + ), + Text( + _needsPairing + ? 'Pairing dulu untuk melihat log' + : '${_items.length} aktivitas tercatat', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF64748B), + ), + ), + ], + ), + ), + IconButton( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh', + color: const Color(0xFF64748B), + ), + ], + ), + const SizedBox(height: 12), + + // ── Filter chips ───────────────────────────────────────────────── + if (!_needsPairing && !_loading && _error == null) + SizedBox( + height: 36, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _filters.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, i) { + final f = _filters[i]; + final selected = _selectedFilter == f; + return FilterChip( + label: Text(f), + selected: selected, + onSelected: (_) => setState(() => _applyFilter(f)), + selectedColor: + const Color(0xFF1A56DB).withValues(alpha: 0.12), + checkmarkColor: const Color(0xFF1A56DB), + labelStyle: GoogleFonts.inter( + color: selected + ? const Color(0xFF1A56DB) + : const Color(0xFF64748B), + fontWeight: + selected ? FontWeight.w700 : FontWeight.normal, + fontSize: 12, + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + side: BorderSide( + color: selected + ? const Color(0xFF1A56DB) + : const Color(0xFFE2E8F0), + ), + ); + }, + ), + ), + + if (!_needsPairing && !_loading && _error == null) + const SizedBox(height: 16), + + // ── Body ───────────────────────────────────────────────────────── + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _needsPairing + ? _buildNoPairingPanel() + : _error != null + ? _buildErrorPanel() + : _filtered.isEmpty + ? _buildEmptyPanel() + : RefreshIndicator( + onRefresh: _load, + color: const Color(0xFF1A56DB), + child: ListView( + children: [ + StaggerWrapper( + children: [ + for (final item in _filtered) + _LogCard(item: item), + ], + ), + ], + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildNoPairingPanel() { + return Center( + child: Container( + padding: const EdgeInsets.all(24), + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: const Color(0xFFFDE68A)), + boxShadow: AppDecorations.cardShadow, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.link_off, color: Color(0xFFD97706), size: 52), + const SizedBox(height: 14), + Text( + 'Belum Pairing', + style: GoogleFonts.outfit( + fontSize: 18, + fontWeight: FontWeight.w700, + color: const Color(0xFF92400E), + ), + ), + const SizedBox(height: 8), + Text( + 'Hubungkan akun Guardian dengan User terlebih dahulu untuk melihat log aktivitas.', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF92400E), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildErrorPanel() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off, size: 52, color: Color(0xFF94A3B8)), + const SizedBox(height: 14), + Text( + _error!, + textAlign: TextAlign.center, + style: + GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B)), + ), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Coba lagi'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF1A56DB)), + ), + ], + ), + ); + } + + Widget _buildEmptyPanel() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.history, size: 64, color: Color(0xFF94A3B8)), + const SizedBox(height: 14), + Text( + _selectedFilter == 'ALL' + ? 'Belum ada aktivitas' + : 'Tidak ada aktivitas "$_selectedFilter"', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: const Color(0xFF94A3B8), + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// DATA MODEL +// ───────────────────────────────────────────────────────────────────────────── + +class _LogItem { + final int id; + final String logType; + final String? description; + final DateTime createdAt; + + const _LogItem({ + required this.id, + required this.logType, + this.description, + required this.createdAt, + }); + + factory _LogItem.fromJson(Map j) => _LogItem( + id: (j['id'] as num?)?.toInt() ?? 0, + logType: j['logType']?.toString() ?? 'UNKNOWN', + description: j['description']?.toString(), + createdAt: + DateTime.tryParse(j['createdAt']?.toString() ?? '')?.toLocal() ?? + DateTime.now(), + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// LOG CARD +// ───────────────────────────────────────────────────────────────────────────── + +class _LogCard extends StatelessWidget { + final _LogItem item; + const _LogCard({required this.item}); + + @override + Widget build(BuildContext context) { + final meta = _logMeta(item.logType); + return Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: AppColors.border), + boxShadow: AppDecorations.cardShadow, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timeline dot + connector line + Column( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: meta.color.withValues(alpha: 0.12), + borderRadius: BorderRadius.circular(50), + ), + child: Icon(meta.icon, color: meta.color, size: 18), + ), + ], + ), + const SizedBox(width: 12), + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + meta.label, + style: GoogleFonts.inter( + fontWeight: FontWeight.w700, + color: meta.color, + fontSize: 13, + ), + ), + ), + Text( + _formatTime(item.createdAt), + style: GoogleFonts.jetBrainsMono( + color: const Color(0xFF94A3B8), + fontSize: 11, + ), + ), + ], + ), + if (item.description != null && + item.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 3), + child: Text( + item.description!, + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF64748B), + ), + ), + ), + const SizedBox(height: 14), + ], + ), + ), + ), + ], + ), + ), + ); + } + + String _formatTime(DateTime dt) { + final now = DateTime.now(); + if (dt.day == now.day && dt.month == now.month && dt.year == now.year) { + return DateFormat('HH:mm').format(dt); + } + return DateFormat('dd MMM HH:mm').format(dt); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// LOG METADATA +// ───────────────────────────────────────────────────────────────────────────── + +class _LogMeta { + final IconData icon; + final Color color; + final String label; + const _LogMeta( + {required this.icon, required this.color, required this.label}); +} + +_LogMeta _logMeta(String logType) { + switch (logType.toUpperCase()) { + case 'LOGIN': + return const _LogMeta( + icon: Icons.login, color: Color(0xFF16A34A), label: 'Login'); + case 'LOGOUT': + return const _LogMeta( + icon: Icons.logout, color: Color(0xFF94A3B8), label: 'Logout'); + case 'APP_OPEN': + return const _LogMeta( + icon: Icons.open_in_new, + color: Color(0xFF1A56DB), + label: 'App Dibuka'); + case 'APP_CLOSE': + return const _LogMeta( + icon: Icons.close, color: Color(0xFF94A3B8), label: 'App Ditutup'); + case 'WALKGUIDE_START': + return const _LogMeta( + icon: Icons.directions_walk, + color: Color(0xFF1A56DB), + label: 'WalkGuide Mulai'); + case 'WALKGUIDE_STOP': + return const _LogMeta( + icon: Icons.stop_circle, + color: Color(0xFF94A3B8), + label: 'WalkGuide Berhenti'); + case 'OBSTACLE_DETECTED': + return const _LogMeta( + icon: Icons.warning_amber, + color: Color(0xFFD97706), + label: 'Obstacle Terdeteksi'); + case 'SOS_TRIGGERED': + return const _LogMeta( + icon: Icons.sos, color: Color(0xFFDC2626), label: 'SOS Terkirim'); + case 'SOS_ACKNOWLEDGED': + return const _LogMeta( + icon: Icons.check_circle, + color: Color(0xFF16A34A), + label: 'SOS Diakui Guardian'); + case 'CALL_INITIATED': + return const _LogMeta( + icon: Icons.call, + color: Color(0xFF16A34A), + label: 'Panggilan Dimulai'); + case 'CALL_ENDED': + return const _LogMeta( + icon: Icons.call_end, + color: Color(0xFF94A3B8), + label: 'Panggilan Selesai'); + case 'LOCATION_UPDATE': + return const _LogMeta( + icon: Icons.location_on, + color: Color(0xFF1A56DB), + label: 'Lokasi Diperbarui'); + case 'GEOFENCE_EXIT': + return const _LogMeta( + icon: Icons.fence, + color: Color(0xFFDC2626), + label: 'Keluar Area Aman'); + case 'GEOFENCE_ENTER': + return const _LogMeta( + icon: Icons.home, color: Color(0xFF16A34A), label: 'Masuk Area Aman'); + default: + return _LogMeta( + icon: Icons.circle_outlined, + color: const Color(0xFF94A3B8), + label: logType); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart index 2c3e9cd..ecfeaa1 100644 --- a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart @@ -1,83 +1,88 @@ -// lib/features/guardian_dashboard/guardian_ai_config_screen.dart -// ignore_for_file: use_build_context_synchronously - -import 'package:dio/dio.dart'; -import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; -import 'package:google_fonts/google_fonts.dart'; - +// lib/features/guardian_dashboard/guardian_ai_config_screen.dart +// ignore_for_file: use_build_context_synchronously + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + import '../../../app/injection_container.dart'; import '../../../core/errors/friendly_error.dart'; import '../../../core/network/api_client.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_decorations.dart'; +import '../../../core/theme/app_text_styles.dart'; import '../../../core/utils/operation_guard.dart'; - -Dio get _api => sl().dio; - -class GuardianAiConfigScreen extends StatefulWidget { - const GuardianAiConfigScreen({super.key}); - - @override - State createState() => _GuardianAiConfigScreenState(); -} - -class _GuardianAiConfigScreenState extends State { - bool _loading = true; - bool _saving = false; - String? _error; - bool _needsPairing = false; - - // Config values - double _confidenceThreshold = 0.5; - double _alertDistanceClose = 1.5; - double _alertDistanceMedium = 3.0; - int _maxInferenceFps = 5; - String _enabledLabels = 'ALL'; - - static const _labelOptions = ['ALL', 'PERSON', 'VEHICLE', 'OBSTACLE']; - - @override - void initState() { - super.initState(); - _load(); - } - - Future _load() async { - setState(() { - _loading = true; - _error = null; - _needsPairing = false; - }); +import '../../../shared/widgets/animations/animations.dart'; + +Dio get _api => sl().dio; + +class GuardianAiConfigScreen extends StatefulWidget { + const GuardianAiConfigScreen({super.key}); + + @override + State createState() => _GuardianAiConfigScreenState(); +} + +class _GuardianAiConfigScreenState extends State { + bool _loading = true; + bool _saving = false; + String? _error; + bool _needsPairing = false; + + // Config values + double _confidenceThreshold = 0.5; + double _alertDistanceClose = 1.5; + double _alertDistanceMedium = 3.0; + int _maxInferenceFps = 5; + String _enabledLabels = 'ALL'; + + static const _labelOptions = ['ALL', 'PERSON', 'VEHICLE', 'OBSTACLE']; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + _needsPairing = false; + }); await guarded( () async { - final paired = await _hasActivePairing(); - if (!paired) { - setState(() { - _needsPairing = true; - _loading = false; - }); - return; - } - - final res = await _api - .get('/guardian/ai-config') - .timeout(const Duration(seconds: 8)); - final data = res.data['data']; - if (data is Map) { - setState(() { - _confidenceThreshold = - (data['confidenceThreshold'] as num?)?.toDouble() ?? 0.5; - _alertDistanceClose = - (data['alertDistanceClose'] as num?)?.toDouble() ?? 1.5; - _alertDistanceMedium = - (data['alertDistanceMedium'] as num?)?.toDouble() ?? 3.0; - _maxInferenceFps = (data['maxInferenceFps'] as num?)?.toInt() ?? 5; - _enabledLabels = data['enabledLabels']?.toString() ?? 'ALL'; - }); - } + final paired = await _hasActivePairing(); + if (!paired) { + setState(() { + _needsPairing = true; + _loading = false; + }); + return; + } + + final res = await _api + .get('/guardian/ai-config') + .timeout(const Duration(seconds: 8)); + final data = res.data['data']; + if (data is Map) { + setState(() { + _confidenceThreshold = + (data['confidenceThreshold'] as num?)?.toDouble() ?? 0.5; + _alertDistanceClose = + (data['alertDistanceClose'] as num?)?.toDouble() ?? 1.5; + _alertDistanceMedium = + (data['alertDistanceMedium'] as num?)?.toDouble() ?? 3.0; + _maxInferenceFps = (data['maxInferenceFps'] as num?)?.toInt() ?? 5; + _enabledLabels = data['enabledLabels']?.toString() ?? 'ALL'; + }); + } }, onError: (error) => setState(() { _error = error is DioException - ? friendlyDioMessage(error, fallback: 'Gagal memuat konfigurasi AI.') + ? friendlyDioMessage(error, + fallback: 'Gagal memuat konfigurasi AI.') : 'Gagal memuat konfigurasi AI. Coba refresh lagi.'; }), ); @@ -88,21 +93,21 @@ class _GuardianAiConfigScreenState extends State { setState(() => _saving = true); await guarded( () async { - await _api.put('/guardian/ai-config', data: { - 'confidenceThreshold': _confidenceThreshold, - 'alertDistanceClose': _alertDistanceClose, - 'alertDistanceMedium': _alertDistanceMedium, - 'maxInferenceFps': _maxInferenceFps, - 'enabledLabels': _enabledLabels, - }).timeout(const Duration(seconds: 8)); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('Konfigurasi AI berhasil disimpan'), - backgroundColor: Color(0xFF16A34A), - ), - ); - } + await _api.put('/guardian/ai-config', data: { + 'confidenceThreshold': _confidenceThreshold, + 'alertDistanceClose': _alertDistanceClose, + 'alertDistanceMedium': _alertDistanceMedium, + 'maxInferenceFps': _maxInferenceFps, + 'enabledLabels': _enabledLabels, + }).timeout(const Duration(seconds: 8)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Konfigurasi AI berhasil disimpan'), + backgroundColor: Color(0xFF16A34A), + ), + ); + } }, onError: (error) { if (!mounted) return; @@ -122,514 +127,531 @@ class _GuardianAiConfigScreenState extends State { Future _hasActivePairing() async { return await guarded( - () async { - final res = await _api - .get('/shared/pairing/status') - .timeout(const Duration(seconds: 5)); - final data = res.data['data']; - if (data is Map) return data['status'] == 'ACTIVE'; - return false; - }, - ) ?? + () async { + final res = await _api + .get('/shared/pairing/status') + .timeout(const Duration(seconds: 5)); + final data = res.data['data']; + if (data is Map) return data['status'] == 'ACTIVE'; + return false; + }, + ) ?? false; } - - @override - Widget build(BuildContext context) { - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Header ────────────────────────────────────────────────────── - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'AI Config', - style: GoogleFonts.outfit( - fontSize: 22, - fontWeight: FontWeight.w800, - color: const Color(0xFF0F172A), - ), - ), - Text( - 'Konfigurasi deteksi YOLO untuk User', - style: GoogleFonts.inter( - fontSize: 13, - color: const Color(0xFF64748B), - ), - ), - ], - ), - ), - IconButton( - onPressed: () => context.go('/guardian/benchmark'), - icon: const Icon(Icons.speed_outlined), - tooltip: 'Benchmark', - color: const Color(0xFF64748B), - ), - IconButton( - onPressed: _loading ? null : _load, - icon: const Icon(Icons.refresh_rounded), - tooltip: 'Refresh', - color: const Color(0xFF64748B), - ), - ], - ), - const SizedBox(height: 16), - - // ── Body ───────────────────────────────────────────────────────── - Expanded( - child: _loading - ? const Center(child: CircularProgressIndicator()) - : _needsPairing - ? _buildNoPairingPanel() - : _error != null - ? _buildErrorPanel() - : _buildConfigForm(), - ), - ], - ), - ), - ); - } - - Widget _buildConfigForm() { - return SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // ── Confidence Threshold ────────────────────────────────────────── - _SectionCard( - title: 'Confidence Threshold', - subtitle: - 'Minimal keyakinan AI untuk menganggap objek sebagai obstacle', - icon: Icons.tune_outlined, - iconColor: const Color(0xFF1A56DB), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Nilai saat ini:', - style: GoogleFonts.inter( - fontSize: 13, color: const Color(0xFF64748B))), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF1A56DB).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - _confidenceThreshold.toStringAsFixed(2), - style: GoogleFonts.jetBrainsMono( - fontSize: 14, - fontWeight: FontWeight.w700, - color: const Color(0xFF1A56DB), - ), - ), - ), - ], - ), - Slider( - value: _confidenceThreshold, - min: 0.1, - max: 0.9, - divisions: 8, - activeColor: const Color(0xFF1A56DB), - onChanged: (v) => setState(() => _confidenceThreshold = v), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('0.1 (sensitif)', - style: GoogleFonts.inter( - fontSize: 11, color: const Color(0xFF94A3B8))), - Text('0.9 (ketat)', - style: GoogleFonts.inter( - fontSize: 11, color: const Color(0xFF94A3B8))), - ], - ), - ], - ), - ), - const SizedBox(height: 12), - - // ── Alert Distances ─────────────────────────────────────────────── - _SectionCard( - title: 'Jarak Peringatan', - subtitle: 'Batas jarak (meter) untuk level peringatan', - icon: Icons.radar_outlined, - iconColor: const Color(0xFFD97706), - child: Column( - children: [ - // Close - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row(children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Color(0xFFDC2626), - ), - ), - const SizedBox(width: 6), - Text('Jarak Dekat', - style: GoogleFonts.inter( - fontSize: 13, color: const Color(0xFF0F172A))), - ]), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFFDC2626).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - '${_alertDistanceClose.toStringAsFixed(1)} m', - style: GoogleFonts.jetBrainsMono( - fontSize: 13, - fontWeight: FontWeight.w700, - color: const Color(0xFFDC2626), - ), - ), - ), - ], - ), - Slider( - value: _alertDistanceClose, - min: 0.5, - max: 3.0, - divisions: 5, - activeColor: const Color(0xFFDC2626), - onChanged: (v) => setState(() => _alertDistanceClose = v), - ), - const SizedBox(height: 8), - // Medium - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Row(children: [ - Container( - width: 8, - height: 8, - decoration: const BoxDecoration( - shape: BoxShape.circle, - color: Color(0xFFD97706), - ), - ), - const SizedBox(width: 6), - Text('Jarak Sedang', - style: GoogleFonts.inter( - fontSize: 13, color: const Color(0xFF0F172A))), - ]), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFFD97706).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - '${_alertDistanceMedium.toStringAsFixed(1)} m', - style: GoogleFonts.jetBrainsMono( - fontSize: 13, - fontWeight: FontWeight.w700, - color: const Color(0xFFD97706), - ), - ), - ), - ], - ), - Slider( - value: _alertDistanceMedium, - min: 1.0, - max: 8.0, - divisions: 7, - activeColor: const Color(0xFFD97706), - onChanged: (v) => setState(() => _alertDistanceMedium = v), - ), - ], - ), - ), - const SizedBox(height: 12), - - // ── Max Inference FPS ───────────────────────────────────────────── - _SectionCard( - title: 'Max Inference FPS', - subtitle: - 'Maksimal frame per detik untuk inferensi AI (lebih tinggi = lebih boros baterai)', - icon: Icons.speed_outlined, - iconColor: const Color(0xFF059669), - child: Column( - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('FPS saat ini:', - style: GoogleFonts.inter( - fontSize: 13, color: const Color(0xFF64748B))), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF059669).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(20), - ), - child: Text( - '$_maxInferenceFps fps', - style: GoogleFonts.jetBrainsMono( - fontSize: 14, - fontWeight: FontWeight.w700, - color: const Color(0xFF059669), - ), - ), - ), - ], - ), - Slider( - value: _maxInferenceFps.toDouble(), - min: 1, - max: 30, - divisions: 29, - activeColor: const Color(0xFF059669), - onChanged: (v) => - setState(() => _maxInferenceFps = v.toInt()), - ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('1 fps (hemat baterai)', - style: GoogleFonts.inter( - fontSize: 11, color: const Color(0xFF94A3B8))), - Text('30 fps (real-time)', - style: GoogleFonts.inter( - fontSize: 11, color: const Color(0xFF94A3B8))), - ], - ), - ], - ), - ), - const SizedBox(height: 12), - - // ── Enabled Labels ──────────────────────────────────────────────── - _SectionCard( - title: 'Label yang Diaktifkan', - subtitle: 'Jenis objek yang akan dideteksi AI', - icon: Icons.label_outline, - iconColor: const Color(0xFF7C3AED), - child: Wrap( - spacing: 8, - runSpacing: 8, - children: _labelOptions.map((label) { - final selected = _enabledLabels == label; - return GestureDetector( - onTap: () => setState(() => _enabledLabels = label), - child: Container( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - decoration: BoxDecoration( - color: selected ? const Color(0xFF7C3AED) : Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all( - color: selected - ? const Color(0xFF7C3AED) - : const Color(0xFFE2E8F0), - ), - ), - child: Text( - label, - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: FontWeight.w600, - color: - selected ? Colors.white : const Color(0xFF64748B), - ), - ), - ), - ); - }).toList(), - ), - ), - const SizedBox(height: 24), - - // ── Save button ─────────────────────────────────────────────────── - SizedBox( - width: double.infinity, - child: FilledButton.icon( - onPressed: _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.save_outlined), - label: Text(_saving ? 'Menyimpan...' : 'Simpan Konfigurasi'), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF1A56DB), - padding: const EdgeInsets.symmetric(vertical: 14), - textStyle: GoogleFonts.inter( - fontSize: 14, fontWeight: FontWeight.w600), - ), - ), - ), - const SizedBox(height: 8), - ], - ), - ); - } - - Widget _buildNoPairingPanel() { - return Center( - child: Container( - padding: const EdgeInsets.all(24), - margin: const EdgeInsets.symmetric(horizontal: 16), - decoration: BoxDecoration( - color: const Color(0xFFFFFBEB), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFFDE68A)), - ), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.link_off, color: Color(0xFFD97706), size: 52), - const SizedBox(height: 14), - Text( - 'Belum Pairing', - style: GoogleFonts.outfit( - fontSize: 18, - fontWeight: FontWeight.w700, - color: const Color(0xFF92400E), - ), - ), - const SizedBox(height: 8), - Text( - 'Hubungkan akun Guardian dengan User terlebih dahulu untuk mengatur konfigurasi AI.', - style: GoogleFonts.inter( - fontSize: 13, color: const Color(0xFF92400E)), - textAlign: TextAlign.center, - ), - ], - ), - ), - ); - } - - Widget _buildErrorPanel() { - return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.wifi_off, size: 52, color: Color(0xFF94A3B8)), - const SizedBox(height: 14), - Text( - _error!, - textAlign: TextAlign.center, - style: - GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B)), - ), - const SizedBox(height: 18), - FilledButton.icon( - onPressed: _load, - icon: const Icon(Icons.refresh), - label: const Text('Coba lagi'), - style: FilledButton.styleFrom( - backgroundColor: const Color(0xFF1A56DB)), - ), - ], - ), - ); - } -} - -// ───────────────────────────────────────────────────────────────────────────── -// SECTION CARD -// ───────────────────────────────────────────────────────────────────────────── - -class _SectionCard extends StatelessWidget { - final String title; - final String subtitle; - final IconData icon; - final Color iconColor; - final Widget child; - - const _SectionCard({ - required this.title, - required this.subtitle, - required this.icon, - required this.iconColor, - required this.child, - }); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFE2E8F0)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.03), - blurRadius: 8, - offset: const Offset(0, 2), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row(children: [ - Container( - width: 34, - height: 34, - decoration: BoxDecoration( - color: iconColor.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(icon, color: iconColor, size: 18), - ), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - title, - style: GoogleFonts.outfit( - fontSize: 14, - fontWeight: FontWeight.w700, - color: const Color(0xFF0F172A), - ), - ), - Text( - subtitle, - style: GoogleFonts.inter( - fontSize: 11, - color: const Color(0xFF94A3B8), - ), - ), - ], - ), - ), - ]), - const SizedBox(height: 16), - const Divider(height: 1, color: Color(0xFFF1F5F9)), - const SizedBox(height: 12), - child, - ], - ), - ); - } -} + + @override + Widget build(BuildContext context) { + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.softBlueBg, Colors.white], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: FadeSlideWrapper( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────────────── + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AI Config', + style: AppTextStyles.heading, + ), + Text( + 'Konfigurasi deteksi YOLO untuk User', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF64748B), + ), + ), + ], + ), + ), + IconButton( + onPressed: () => context.go('/guardian/benchmark'), + icon: const Icon(Icons.speed_outlined), + tooltip: 'Benchmark', + color: const Color(0xFF64748B), + ), + IconButton( + onPressed: _loading ? null : _load, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh', + color: const Color(0xFF64748B), + ), + ], + ), + const SizedBox(height: 16), + + // ── Body ───────────────────────────────────────────────────────── + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _needsPairing + ? _buildNoPairingPanel() + : _error != null + ? _buildErrorPanel() + : _buildConfigForm(), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _buildConfigForm() { + return SingleChildScrollView( + child: StaggerWrapper( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Confidence Threshold ────────────────────────────────────────── + _SectionCard( + title: 'Confidence Threshold', + subtitle: + 'Minimal keyakinan AI untuk menganggap objek sebagai obstacle', + icon: Icons.tune_outlined, + iconColor: const Color(0xFF1A56DB), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Nilai saat ini:', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF64748B))), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: + const Color(0xFF1A56DB).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _confidenceThreshold.toStringAsFixed(2), + style: GoogleFonts.jetBrainsMono( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF1A56DB), + ), + ), + ), + ], + ), + Slider( + value: _confidenceThreshold, + min: 0.1, + max: 0.9, + divisions: 8, + activeColor: const Color(0xFF1A56DB), + onChanged: (v) => + setState(() => _confidenceThreshold = v), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('0.1 (sensitif)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + Text('0.9 (ketat)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + + // ── Alert Distances ─────────────────────────────────────────────── + _SectionCard( + title: 'Jarak Peringatan', + subtitle: 'Batas jarak (meter) untuk level peringatan', + icon: Icons.radar_outlined, + iconColor: const Color(0xFFD97706), + child: Column( + children: [ + // Close + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFDC2626), + ), + ), + const SizedBox(width: 6), + Text('Jarak Dekat', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF0F172A))), + ]), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: + const Color(0xFFDC2626).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_alertDistanceClose.toStringAsFixed(1)} m', + style: GoogleFonts.jetBrainsMono( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFFDC2626), + ), + ), + ), + ], + ), + Slider( + value: _alertDistanceClose, + min: 0.5, + max: 3.0, + divisions: 5, + activeColor: const Color(0xFFDC2626), + onChanged: (v) => setState(() => _alertDistanceClose = v), + ), + const SizedBox(height: 8), + // Medium + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFD97706), + ), + ), + const SizedBox(width: 6), + Text('Jarak Sedang', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF0F172A))), + ]), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: + const Color(0xFFD97706).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_alertDistanceMedium.toStringAsFixed(1)} m', + style: GoogleFonts.jetBrainsMono( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFFD97706), + ), + ), + ), + ], + ), + Slider( + value: _alertDistanceMedium, + min: 1.0, + max: 8.0, + divisions: 7, + activeColor: const Color(0xFFD97706), + onChanged: (v) => + setState(() => _alertDistanceMedium = v), + ), + ], + ), + ), + const SizedBox(height: 12), + + // ── Max Inference FPS ───────────────────────────────────────────── + _SectionCard( + title: 'Max Inference FPS', + subtitle: + 'Maksimal frame per detik untuk inferensi AI (lebih tinggi = lebih boros baterai)', + icon: Icons.speed_outlined, + iconColor: const Color(0xFF059669), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('FPS saat ini:', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF64748B))), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: + const Color(0xFF059669).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$_maxInferenceFps fps', + style: GoogleFonts.jetBrainsMono( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF059669), + ), + ), + ), + ], + ), + Slider( + value: _maxInferenceFps.toDouble(), + min: 1, + max: 30, + divisions: 29, + activeColor: const Color(0xFF059669), + onChanged: (v) => + setState(() => _maxInferenceFps = v.toInt()), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('1 fps (hemat baterai)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + Text('30 fps (real-time)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + + // ── Enabled Labels ──────────────────────────────────────────────── + _SectionCard( + title: 'Label yang Diaktifkan', + subtitle: 'Jenis objek yang akan dideteksi AI', + icon: Icons.label_outline, + iconColor: const Color(0xFF7C3AED), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _labelOptions.map((label) { + final selected = _enabledLabels == label; + return GestureDetector( + onTap: () => setState(() => _enabledLabels = label), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: + selected ? const Color(0xFF7C3AED) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected + ? const Color(0xFF7C3AED) + : const Color(0xFFE2E8F0), + ), + ), + child: Text( + label, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: selected + ? Colors.white + : const Color(0xFF64748B), + ), + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 24), + + // ── Save button ─────────────────────────────────────────────────── + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.save_outlined), + label: Text(_saving ? 'Menyimpan...' : 'Simpan Konfigurasi'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF1A56DB), + padding: const EdgeInsets.symmetric(vertical: 14), + textStyle: GoogleFonts.inter( + fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ], + ), + ); + } + + Widget _buildNoPairingPanel() { + return Center( + child: Container( + padding: const EdgeInsets.all(24), + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: const Color(0xFFFDE68A)), + boxShadow: AppDecorations.cardShadow, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.link_off, color: Color(0xFFD97706), size: 52), + const SizedBox(height: 14), + Text( + 'Belum Pairing', + style: GoogleFonts.outfit( + fontSize: 18, + fontWeight: FontWeight.w700, + color: const Color(0xFF92400E), + ), + ), + const SizedBox(height: 8), + Text( + 'Hubungkan akun Guardian dengan User terlebih dahulu untuk mengatur konfigurasi AI.', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF92400E)), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildErrorPanel() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off, size: 52, color: Color(0xFF94A3B8)), + const SizedBox(height: 14), + Text( + _error!, + textAlign: TextAlign.center, + style: + GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B)), + ), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Coba lagi'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF1A56DB)), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// SECTION CARD +// ───────────────────────────────────────────────────────────────────────────── + +class _SectionCard extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final Color iconColor; + final Widget child; + + const _SectionCard({ + required this.title, + required this.subtitle, + required this.icon, + required this.iconColor, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: AppColors.border), + boxShadow: AppDecorations.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(50), + ), + child: Icon(icon, color: iconColor, size: 18), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.outfit( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A), + ), + ), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11, + color: const Color(0xFF94A3B8), + ), + ), + ], + ), + ), + ]), + const SizedBox(height: 16), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 12), + child, + ], + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_map_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_map_screen.dart index 4b9ca5b..5b02d9e 100644 --- a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_map_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_map_screen.dart @@ -8,6 +8,9 @@ import 'package:latlong2/latlong.dart'; import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../shared/widgets/animations/animations.dart'; import '../../shared/widgets/feature_page.dart'; class GuardianMapScreen extends StatefulWidget { @@ -106,8 +109,9 @@ class _GuardianMapCard extends StatelessWidget { final center = _pointFrom(location) ?? (points.isNotEmpty ? points.first : null) ?? const LatLng(-7.2575, 112.7521); - return ClipRRect( - borderRadius: BorderRadius.circular(20), + return Container( + decoration: AppDecorations.card, + clipBehavior: Clip.antiAlias, child: FlutterMap( options: MapOptions(initialCenter: center, initialZoom: 16), children: [ @@ -121,7 +125,7 @@ class _GuardianMapCard extends StatelessWidget { Polyline( points: points, strokeWidth: 4, - color: const Color(0xFF2563EB), + color: AppColors.primaryBlue, ), ], ), @@ -171,10 +175,18 @@ class _TimelineList extends StatelessWidget { ), ); } - return ListView.separated( - itemCount: segments.length, - separatorBuilder: (_, __) => const SizedBox(height: 10), - itemBuilder: (_, index) => _TimelineCard(segment: segments[index]), + return ListView( + children: [ + StaggerWrapper( + children: [ + for (final segment in segments) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _TimelineCard(segment: segment), + ), + ], + ), + ], ); } } @@ -189,9 +201,10 @@ class _TimelineCard extends StatelessWidget { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFE2E8F0)), + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: AppColors.border), + boxShadow: AppDecorations.cardShadow, ), child: Row( children: [ @@ -199,10 +212,10 @@ class _TimelineCard extends StatelessWidget { width: 42, height: 42, decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(14), + color: AppColors.softBlueBg, + borderRadius: BorderRadius.circular(50), ), - child: Icon(segment.icon, color: const Color(0xFF1D4ED8)), + child: Icon(segment.icon, color: AppColors.primaryBlue), ), const SizedBox(width: 12), Expanded( diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_send_notification_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_send_notification_screen.dart index fbec03c..e59691c 100644 --- a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_send_notification_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_send_notification_screen.dart @@ -8,6 +8,9 @@ import 'package:record/record.dart'; import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../shared/widgets/animations/animations.dart'; import '../../shared/widgets/feature_page.dart'; class GuardianSendNotifScreen extends StatefulWidget { @@ -132,132 +135,130 @@ class _GuardianSendNotifScreenState extends State { subtitle: 'Kirim pesan singkat ke User yang sudah pairing', child: ListView( children: [ - Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(20), - border: Border.all(color: const Color(0xFFE2E8F0)), - boxShadow: [ - BoxShadow( - color: const Color(0xFF1E293B).withValues(alpha: 0.06), - blurRadius: 22, - offset: const Offset(0, 12), - ), - ], - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - SegmentedButton( - segments: const [ - ButtonSegment( - value: false, - icon: Icon(Icons.message_outlined), - label: Text('Text'), + FadeSlideWrapper( + child: Container( + padding: const EdgeInsets.all(18), + decoration: BoxDecoration( + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: AppColors.border), + boxShadow: AppDecorations.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SegmentedButton( + segments: const [ + ButtonSegment( + value: false, + icon: Icon(Icons.message_outlined), + label: Text('Text'), + ), + ButtonSegment( + value: true, + icon: Icon(Icons.mic_none_outlined), + label: Text('Voice'), + ), + ], + selected: {_voiceMode}, + onSelectionChanged: _loading || _recording + ? null + : (value) => setState(() => _voiceMode = value.first), + ), + const SizedBox(height: 14), + TextField( + controller: _message, + minLines: _voiceMode ? 2 : 5, + maxLines: _voiceMode ? 3 : 8, + decoration: const InputDecoration( + labelText: 'Message', + hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.', + prefixIcon: Icon(Icons.message_outlined), + alignLabelWithHint: true, ), - ButtonSegment( - value: true, - icon: Icon(Icons.mic_none_outlined), - label: Text('Voice'), + ), + if (_voiceMode) ...[ + const SizedBox(height: 14), + Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: const Color(0xFFF8FAFC), + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: AppColors.border), + ), + child: Row( + children: [ + CircleAvatar( + backgroundColor: _recording + ? const Color(0xFFFEE2E2) + : const Color(0xFFEFF6FF), + child: Icon( + _recording ? Icons.graphic_eq : Icons.mic, + color: _recording + ? const Color(0xFFDC2626) + : const Color(0xFF2563EB), + ), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _recording + ? 'Recording... tap stop when done' + : _voicePath == null + ? 'No voice note recorded' + : 'Voice note ready', + style: const TextStyle( + fontWeight: FontWeight.w800), + ), + Text( + _recording + ? 'Speak clearly near the microphone' + : _voicePath == null + ? 'Record a short message for User' + : '${_voiceDuration}s audio attached', + style: const TextStyle( + color: Color(0xFF64748B), fontSize: 12), + ), + ], + ), + ), + FilledButton.icon( + onPressed: _loading ? null : _toggleRecording, + icon: Icon(_recording + ? Icons.stop + : Icons.fiber_manual_record), + label: Text(_recording ? 'Stop' : 'Record'), + style: FilledButton.styleFrom( + backgroundColor: _recording + ? const Color(0xFFDC2626) + : const Color(0xFF2563EB), + ), + ), + ], + ), ), ], - selected: {_voiceMode}, - onSelectionChanged: _loading || _recording - ? null - : (value) => setState(() => _voiceMode = value.first), - ), - const SizedBox(height: 14), - TextField( - controller: _message, - minLines: _voiceMode ? 2 : 5, - maxLines: _voiceMode ? 3 : 8, - decoration: const InputDecoration( - labelText: 'Message', - hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.', - prefixIcon: Icon(Icons.message_outlined), - alignLabelWithHint: true, - ), - ), - if (_voiceMode) ...[ const SizedBox(height: 14), - Container( - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFE2E8F0)), - ), - child: Row( - children: [ - CircleAvatar( - backgroundColor: _recording - ? const Color(0xFFFEE2E2) - : const Color(0xFFEFF6FF), - child: Icon( - _recording ? Icons.graphic_eq : Icons.mic, - color: _recording - ? const Color(0xFFDC2626) - : const Color(0xFF2563EB), - ), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _recording - ? 'Recording... tap stop when done' - : _voicePath == null - ? 'No voice note recorded' - : 'Voice note ready', - style: const TextStyle( - fontWeight: FontWeight.w800), - ), - Text( - _recording - ? 'Speak clearly near the microphone' - : _voicePath == null - ? 'Record a short message for User' - : '${_voiceDuration}s audio attached', - style: const TextStyle( - color: Color(0xFF64748B), fontSize: 12), - ), - ], - ), - ), - FilledButton.icon( - onPressed: _loading ? null : _toggleRecording, - icon: Icon(_recording ? Icons.stop : Icons.fiber_manual_record), - label: Text(_recording ? 'Stop' : 'Record'), - style: FilledButton.styleFrom( - backgroundColor: _recording - ? const Color(0xFFDC2626) - : const Color(0xFF2563EB), - ), - ), - ], - ), + FilledButton.icon( + onPressed: _loading ? null : _send, + icon: _loading + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Icon(Icons.send), + label: Text(_loading + ? 'Sending...' + : _voiceMode + ? 'Send Voice Message' + : 'Send Message'), ), ], - const SizedBox(height: 14), - FilledButton.icon( - onPressed: _loading ? null : _send, - icon: _loading - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Icon(Icons.send), - label: Text(_loading - ? 'Sending...' - : _voiceMode - ? 'Send Voice Message' - : 'Send Message'), - ), - ], + ), ), ), ], diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_settings_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_settings_screen.dart index c9b38fd..56c439d 100644 --- a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_settings_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_settings_screen.dart @@ -9,6 +9,9 @@ import '../../app/injection_container.dart'; import '../../core/constants/app_constants.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../shared/widgets/animations/animations.dart'; import '../../core/storage/secure_storage.dart'; import '../../shared/widgets/feature_page.dart'; @@ -49,23 +52,27 @@ class GuardianSettingsScreen extends StatelessWidget { subtitle: 'Account, pairing, AI tools, and server', child: ListView( children: [ - _SettingsTile( - icon: Icons.link, - title: 'Pair User', - subtitle: 'Masukkan Pairing Code User atau cek status pairing.', - onTap: () => context.go('/guardian/pairing'), - ), - _SettingsTile( - icon: Icons.speed, - title: 'AI Benchmark', - subtitle: 'Catat capture, inference, notification, dan TTS.', - onTap: () => context.go('/guardian/benchmark'), - ), - _SettingsTile( - icon: Icons.tune, - title: 'AI Config', - subtitle: 'Atur threshold deteksi dan label yang aktif.', - onTap: () => context.go('/guardian/ai-config'), + StaggerWrapper( + children: [ + _SettingsTile( + icon: Icons.link, + title: 'Pair User', + subtitle: 'Masukkan Pairing Code User atau cek status pairing.', + onTap: () => context.go('/guardian/pairing'), + ), + _SettingsTile( + icon: Icons.speed, + title: 'AI Benchmark', + subtitle: 'Catat capture, inference, notification, dan TTS.', + onTap: () => context.go('/guardian/benchmark'), + ), + _SettingsTile( + icon: Icons.tune, + title: 'AI Config', + subtitle: 'Atur threshold deteksi dan label yang aktif.', + onTap: () => context.go('/guardian/ai-config'), + ), + ], ), const SizedBox(height: 18), OutlinedButton.icon( @@ -103,19 +110,28 @@ class _SettingsTile extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - margin: const EdgeInsets.only(bottom: 10), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(16), - border: Border.all(color: const Color(0xFFE2E8F0)), - ), - child: ListTile( - leading: Icon(icon, color: const Color(0xFF1D4ED8)), - title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)), - subtitle: Text(subtitle), - trailing: const Icon(Icons.chevron_right), - onTap: onTap, + return BounceTap( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: AppColors.border), + boxShadow: AppDecorations.cardShadow, + ), + child: ListTile( + leading: Container( + width: 44, + height: 44, + decoration: AppDecorations.iconCircle(), + child: Icon(icon, color: AppColors.primaryBlue), + ), + title: + Text(title, style: const TextStyle(fontWeight: FontWeight.w800)), + subtitle: Text(subtitle), + trailing: const Icon(Icons.chevron_right), + ), ), ); } diff --git a/walkguide-mobile/walkguide_app/lib/features/home/home_screen.dart b/walkguide-mobile/walkguide_app/lib/features/home/home_screen.dart index e8e503e..c54d455 100644 --- a/walkguide-mobile/walkguide_app/lib/features/home/home_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/home/home_screen.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_text_styles.dart'; + class HomeScreen extends StatelessWidget { final String role; @@ -7,14 +10,26 @@ class HomeScreen extends StatelessWidget { @override Widget build(BuildContext context) { - return Scaffold( - appBar: AppBar(title: const Text('Dashboard Walk Guide')), - body: Center( - child: Text( - role == 'ROLE_ADMIN' ? 'Selamat Datang Admin!' : 'Mode Walk Guide Siap!', - style: const TextStyle(fontSize: 24), + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.softBlueBg, Colors.white], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: Center( + child: Padding( + padding: const EdgeInsets.all(24), + child: Text( + role == 'ROLE_ADMIN' + ? 'Selamat Datang Admin!' + : 'Mode Walk Guide Siap!', + textAlign: TextAlign.center, + style: AppTextStyles.heading, + ), ), ), ); } -} \ No newline at end of file +} diff --git a/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart b/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart index 4171ecf..34b54be 100644 --- a/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart @@ -14,7 +14,10 @@ import '../../../core/network/api_client.dart'; import '../../../core/services/websocket_service.dart'; import '../../../core/services/incoming_call_polling_service.dart'; import '../../../core/storage/secure_storage.dart'; +import '../../../core/theme/app_colors.dart'; +import '../../../core/theme/app_decorations.dart'; import '../../../core/utils/operation_guard.dart'; +import '../../../shared/widgets/animations/animations.dart'; // ───────────────────────────────────────────────────────────────────────────── // GUARDIAN DASHBOARD SCREEN @@ -382,7 +385,7 @@ class _GuardianDashboardScreenState extends State @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFFF0F4FF), + backgroundColor: AppColors.softBlueBg, body: SafeArea( child: Column( children: [ @@ -398,24 +401,26 @@ class _GuardianDashboardScreenState extends State child: SingleChildScrollView( physics: const AlwaysScrollableScrollPhysics(), padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _buildGreetingRow(), - const SizedBox(height: 14), - if (_sosAlert || _pendingSos.isNotEmpty) ...[ - _buildSosBanner(), + child: FadeSlideWrapper( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildGreetingRow(), const SizedBox(height: 14), + if (_sosAlert || _pendingSos.isNotEmpty) ...[ + _buildSosBanner(), + const SizedBox(height: 14), + ], + _buildKpiStrip(), + const SizedBox(height: 14), + _buildMainRow(), + const SizedBox(height: 14), + _buildActivitySection(), + const SizedBox(height: 14), + _buildQuickActions(), + const SizedBox(height: 8), ], - _buildKpiStrip(), - const SizedBox(height: 14), - _buildMainRow(), - const SizedBox(height: 14), - _buildActivitySection(), - const SizedBox(height: 14), - _buildQuickActions(), - const SizedBox(height: 8), - ], + ), ), ), ), @@ -1613,13 +1618,17 @@ class _KpiCard extends StatelessWidget { padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: highlight ? const Color(0xFFFFF1F2) : Colors.white, - borderRadius: BorderRadius.circular(12), + borderRadius: AppDecorations.cardRadius, border: Border.all( color: highlight ? const Color(0xFFFECACA) : const Color(0xFFE2E8F0), width: 1, ), - boxShadow: [ - BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8), + boxShadow: const [ + BoxShadow( + color: Color(0x14000000), + blurRadius: 20, + offset: Offset(0, 4), + ), ], ), child: Column( @@ -1745,53 +1754,50 @@ class _QuickActionCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Material( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - child: InkWell( - onTap: item.onTap, - borderRadius: BorderRadius.circular(12), - child: Container( - padding: const EdgeInsets.all(10), - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0)), - ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 28, - height: 28, - decoration: BoxDecoration( - color: item.color.withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(7), - ), - child: Icon(item.icon, size: 15, color: item.color), + return BounceTap( + onTap: item.onTap, + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: AppDecorations.cardShadow, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Container( + width: 28, + height: 28, + decoration: BoxDecoration( + color: item.color.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(7), ), - const SizedBox(height: 6), - Text( - item.label, - style: GoogleFonts.inter( - fontSize: 11, - fontWeight: FontWeight.w700, - color: const Color(0xFF0F172A), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + child: Icon(item.icon, size: 15, color: item.color), + ), + const SizedBox(height: 6), + Text( + item.label, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A), ), - Text( - item.sub, - style: GoogleFonts.inter( - fontSize: 10, - color: const Color(0xFF94A3B8), - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + Text( + item.sub, + style: GoogleFonts.inter( + fontSize: 10, + color: const Color(0xFF94A3B8), ), - ], - ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], ), ), ); diff --git a/walkguide-mobile/walkguide_app/lib/features/manual/manual_screen.dart b/walkguide-mobile/walkguide_app/lib/features/manual/manual_screen.dart index 0d990da..7b2c22a 100644 --- a/walkguide-mobile/walkguide_app/lib/features/manual/manual_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/manual/manual_screen.dart @@ -1,6 +1,10 @@ import 'package:flutter/material.dart'; import '../../core/services/voice_command_handler.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../shared/widgets/animations/animations.dart'; +import '../../shared/widgets/feature_page.dart'; class ManualScreen extends StatelessWidget { const ManualScreen({super.key}); @@ -8,16 +12,38 @@ class ManualScreen extends StatelessWidget { @override Widget build(BuildContext context) { final commands = VoiceCommandKey.values.map((key) => key.name).toList(); - return Scaffold( - appBar: AppBar(title: const Text('Manual')), - body: ListView.separated( - padding: const EdgeInsets.all(16), - itemCount: commands.length, - separatorBuilder: (_, __) => const Divider(height: 1), - itemBuilder: (context, index) => ListTile( - leading: const Icon(Icons.record_voice_over), - title: Text(commands[index]), - ), + return FeaturePage( + title: 'Manual', + subtitle: 'Voice command yang tersedia', + child: ListView( + children: [ + StaggerWrapper( + children: [ + for (final command in commands) + Container( + margin: const EdgeInsets.only(bottom: 10), + decoration: BoxDecoration( + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + border: Border.all(color: AppColors.border), + boxShadow: AppDecorations.cardShadow, + ), + child: ListTile( + leading: Container( + width: 44, + height: 44, + decoration: AppDecorations.iconCircle(), + child: const Icon( + Icons.record_voice_over, + color: AppColors.primaryBlue, + ), + ), + title: Text(command), + ), + ), + ], + ), + ], ), ); } diff --git a/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart b/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart index 0efbb7d..0f0b9f7 100644 --- a/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/navigation_mode/navigation_mode_screen.dart @@ -16,7 +16,11 @@ import 'package:latlong2/latlong.dart'; import '../../app/injection_container.dart'; import '../../core/network/api_client.dart'; import '../../core/services/tts_service.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../core/theme/app_text_styles.dart'; import '../../core/utils/operation_guard.dart'; +import '../../shared/widgets/animations/animations.dart'; // ─── helpers ──────────────────────────────────────────────────────────────── @@ -498,7 +502,7 @@ class _NavigationModeScreenState extends State { Polyline( points: _navState.routePoints, strokeWidth: 5, - color: const Color(0xFF1A56DB), + color: AppColors.primaryBlue, ), ], ), @@ -536,8 +540,8 @@ class _NavigationModeScreenState extends State { decoration: BoxDecoration( shape: BoxShape.circle, color: i == _navState.currentStepIndex - ? const Color(0xFF1A56DB) - : const Color(0xFF93C5FD), + ? AppColors.primaryBlue + : AppColors.gradientBlueStart, border: Border.all(color: Colors.white, width: 2), ), ), @@ -552,26 +556,28 @@ class _NavigationModeScreenState extends State { top: 12, left: 12, right: 12, - child: Column( - children: [ - // search bar - _SearchBar( - controller: _searchCtrl, - focusNode: _searchFocus, - loading: _searchLoading, - onChanged: _onSearchChanged, - onClear: () { - _searchCtrl.clear(); - setState(() => _showSuggestions = false); - }, - ), - // suggestions dropdown - if (_showSuggestions) - _SuggestionList( - items: _suggestions, - onSelect: _selectPlace, + child: FadeSlideWrapper( + child: Column( + children: [ + // search bar + _SearchBar( + controller: _searchCtrl, + focusNode: _searchFocus, + loading: _searchLoading, + onChanged: _onSearchChanged, + onClear: () { + _searchCtrl.clear(); + setState(() => _showSuggestions = false); + }, ), - ], + // suggestions dropdown + if (_showSuggestions) + _SuggestionList( + items: _suggestions, + onSelect: _selectPlace, + ), + ], + ), ), ), @@ -610,7 +616,7 @@ class _NavigationModeScreenState extends State { child: FloatingActionButton.small( heroTag: 'nav_center', backgroundColor: Colors.white, - foregroundColor: const Color(0xFF1A56DB), + foregroundColor: AppColors.primaryBlue, onPressed: _centerOnUser, child: const Icon(Icons.my_location), ), @@ -621,7 +627,13 @@ class _NavigationModeScreenState extends State { Positioned.fill( child: Container( color: Colors.black26, - child: const Center(child: CircularProgressIndicator()), + child: Center( + child: Container( + padding: const EdgeInsets.all(20), + decoration: AppDecorations.card, + child: const CircularProgressIndicator(), + ), + ), ), ), ], @@ -650,39 +662,41 @@ class _SearchBar extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - elevation: 4, - borderRadius: BorderRadius.circular(14), - child: TextField( - controller: controller, - focusNode: focusNode, - onChanged: onChanged, - textInputAction: TextInputAction.search, - decoration: InputDecoration( - hintText: 'Cari tujuan…', - prefixIcon: const Icon(Icons.search, color: Color(0xFF1A56DB)), - suffixIcon: loading - ? const Padding( - padding: EdgeInsets.all(12), - child: SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator(strokeWidth: 2), - ), - ) - : controller.text.isNotEmpty - ? IconButton( - icon: const Icon(Icons.close), - onPressed: onClear, - ) - : null, - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(14), - borderSide: BorderSide.none, + color: Colors.transparent, + child: Container( + decoration: AppDecorations.card, + child: TextField( + controller: controller, + focusNode: focusNode, + onChanged: onChanged, + textInputAction: TextInputAction.search, + decoration: InputDecoration( + hintText: 'Cari tujuan…', + prefixIcon: const Icon(Icons.search, color: AppColors.primaryBlue), + suffixIcon: loading + ? const Padding( + padding: EdgeInsets.all(12), + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator(strokeWidth: 2), + ), + ) + : controller.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.close), + onPressed: onClear, + ) + : null, + border: const OutlineInputBorder( + borderRadius: AppDecorations.inputRadius, + borderSide: BorderSide.none, + ), + filled: true, + fillColor: Colors.white, + contentPadding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 14), ), - filled: true, - fillColor: Colors.white, - contentPadding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 14), ), ), ); @@ -698,36 +712,41 @@ class _SuggestionList extends StatelessWidget { @override Widget build(BuildContext context) { return Material( - elevation: 6, - borderRadius: const BorderRadius.vertical(bottom: Radius.circular(14)), + color: Colors.transparent, child: ClipRRect( - borderRadius: const BorderRadius.vertical(bottom: Radius.circular(14)), - child: Column( - children: [ - for (final place in items) - InkWell( - onTap: () => onSelect(place), - child: Padding( - padding: - const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row( - children: [ - const Icon(Icons.place_outlined, - color: Color(0xFF64748B), size: 20), - const SizedBox(width: 12), - Expanded( - child: Text( - place.displayName, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: const TextStyle(fontSize: 14), + borderRadius: const BorderRadius.vertical(bottom: Radius.circular(20)), + child: Container( + decoration: const BoxDecoration( + color: AppColors.cardWhite, + boxShadow: AppDecorations.cardShadow, + ), + child: StaggerWrapper( + children: [ + for (final place in items) + BounceTap( + onTap: () => onSelect(place), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 12), + child: Row( + children: [ + const Icon(Icons.place_outlined, + color: AppColors.textMuted, size: 20), + const SizedBox(width: 12), + Expanded( + child: Text( + place.displayName, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: AppTextStyles.body, + ), ), - ), - ], + ], + ), ), ), - ), - ], + ], + ), ), ), ); @@ -761,16 +780,10 @@ class _TurnCard extends StatelessWidget { Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: const Color(0xFF1A56DB), - borderRadius: BorderRadius.circular(16), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.18), - blurRadius: 12, - offset: const Offset(0, 4), - ), - ], + decoration: const BoxDecoration( + gradient: AppDecorations.blueGradient, + borderRadius: AppDecorations.cardRadius, + boxShadow: AppDecorations.cardShadow, ), child: Row( children: [ @@ -864,13 +877,8 @@ class _StatusBar extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: _bgColor, - borderRadius: BorderRadius.circular(14), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.1), - blurRadius: 10, - ), - ], + borderRadius: AppDecorations.cardRadius, + boxShadow: AppDecorations.cardShadow, border: Border.all( color: phase == _NavPhase.error ? const Color(0xFFFECACA) @@ -954,11 +962,11 @@ class _PulsingDotState extends State<_PulsingDot> height: 44, decoration: BoxDecoration( shape: BoxShape.circle, - color: const Color(0xFF1A56DB) + color: AppColors.primaryBlue .withValues(alpha: _opacity.value * 0.4), border: Border.all( color: - const Color(0xFF1A56DB).withValues(alpha: _opacity.value), + AppColors.primaryBlue.withValues(alpha: _opacity.value), width: 2, ), ), @@ -970,11 +978,11 @@ class _PulsingDotState extends State<_PulsingDot> height: 20, decoration: BoxDecoration( shape: BoxShape.circle, - color: const Color(0xFF1A56DB), + color: AppColors.primaryBlue, border: Border.all(color: Colors.white, width: 3), boxShadow: [ BoxShadow( - color: const Color(0xFF1A56DB).withValues(alpha: 0.4), + color: AppColors.primaryBlue.withValues(alpha: 0.4), blurRadius: 6, ), ], diff --git a/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart b/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart index 4e67af4..a00ca94 100644 --- a/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/notifications/notification_screen.dart @@ -13,6 +13,9 @@ import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/services/tts_service.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../core/theme/app_text_styles.dart'; +import '../../shared/widgets/animations/animations.dart'; import 'application/notification_cubit.dart'; import 'domain/entities/guardian_notification.dart'; @@ -124,84 +127,105 @@ class _NotificationScreenState extends State { builder: (context, state) { final items = state.items.map(_NotifItem.fromEntity).toList(); final unreadCount = items.where((n) => !n.isRead).length; - return SafeArea( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Header - Row( - children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'Notifications', - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.w800), - ), - if (unreadCount > 0) ...[ - const SizedBox(width: 8), - _UnreadBadge(count: unreadCount), - ], - ], - ), - const Text( - 'Pesan dari Guardian kamu', - style: TextStyle(color: AppColors.muted), - ), - ], - ), - ), - if (unreadCount > 0) - TextButton.icon( - onPressed: state.markingAll ? null : _markAllRead, - icon: state.markingAll - ? const SizedBox( - width: 14, - height: 14, - child: CircularProgressIndicator(strokeWidth: 2)) - : const Icon(Icons.done_all, size: 18), - label: const Text('Baca semua'), - ), - IconButton( - onPressed: _load, - icon: const Icon(Icons.refresh), - tooltip: 'Refresh', - ), - ], + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.softBlueBg, Colors.white], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, ), - const SizedBox(height: 16), - - // Body - Expanded( - child: state.loading - ? const Center(child: CircularProgressIndicator()) - : state.error != null - ? _ErrorPanel(message: state.error!, onRetry: _load) - : items.isEmpty - ? const _EmptyPanel() - : RefreshIndicator( - onRefresh: _load, - child: ListView.separated( - itemCount: items.length, - separatorBuilder: (_, __) => - const SizedBox(height: 10), - itemBuilder: (ctx, i) => _NotifCard( - notif: items[i], - onMarkRead: () => _markRead(items[i].id), - onReadAloud: () => _readAloud(items[i]), - ), + ), + child: SafeArea( + child: FadeSlideWrapper( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + 'Notifications', + style: AppTextStyles.heading, + ), + if (unreadCount > 0) ...[ + const SizedBox(width: 8), + _UnreadBadge(count: unreadCount), + ], + ], ), - ), - ), - ], + const Text( + 'Pesan dari Guardian kamu', + style: TextStyle(color: AppColors.muted), + ), + ], + ), + ), + if (unreadCount > 0) + TextButton.icon( + onPressed: state.markingAll ? null : _markAllRead, + icon: state.markingAll + ? const SizedBox( + width: 14, + height: 14, + child: CircularProgressIndicator( + strokeWidth: 2)) + : const Icon(Icons.done_all, size: 18), + label: const Text('Baca semua'), + ), + IconButton( + onPressed: _load, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh', + ), + ], + ), + const SizedBox(height: 16), + + // Body + Expanded( + child: state.loading + ? const Center(child: CircularProgressIndicator()) + : state.error != null + ? _ErrorPanel( + message: state.error!, onRetry: _load) + : items.isEmpty + ? const _EmptyPanel() + : RefreshIndicator( + onRefresh: _load, + child: ListView( + children: [ + StaggerWrapper( + children: [ + for (final item in items) + Padding( + padding: + const EdgeInsets.only( + bottom: 10), + child: _NotifCard( + notif: item, + onMarkRead: () => + _markRead(item.id), + onReadAloud: () => + _readAloud(item), + ), + ), + ], + ), + ], + ), + ), + ), + ], + ), + ), ), ), ); @@ -271,7 +295,7 @@ class _NotifCard extends StatelessWidget { duration: const Duration(milliseconds: 300), decoration: BoxDecoration( color: unread ? const Color(0xFFEFF6FF) : Colors.white, - borderRadius: BorderRadius.circular(14), + borderRadius: AppDecorations.cardRadius, border: Border.all( color: unread ? AppColors.primary.withValues(alpha: 0.3) @@ -280,9 +304,9 @@ class _NotifCard extends StatelessWidget { ), boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 8, - offset: const Offset(0, 2), + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 20, + offset: const Offset(0, 4), ), ], ), @@ -301,7 +325,7 @@ class _NotifCard extends StatelessWidget { color: isVoice ? AppColors.success.withValues(alpha: 0.12) : AppColors.primary.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(10), + borderRadius: BorderRadius.circular(50), ), child: Icon( isVoice ? Icons.mic : Icons.message, @@ -413,6 +437,7 @@ class _UnreadBadge extends StatelessWidget { decoration: BoxDecoration( color: AppColors.primary, borderRadius: BorderRadius.circular(999), + boxShadow: AppDecorations.cardShadow, ), child: Text( '$count', @@ -431,21 +456,29 @@ class _ErrorPanel extends StatelessWidget { @override Widget build(BuildContext context) { return Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const Icon(Icons.wifi_off, size: 48, color: AppColors.muted), - const SizedBox(height: 12), - Text(message, - textAlign: TextAlign.center, - style: const TextStyle(color: AppColors.muted)), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: onRetry, - icon: const Icon(Icons.refresh), - label: const Text('Coba lagi'), - ), - ], + child: Container( + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + boxShadow: AppDecorations.cardShadow, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off, size: 48, color: AppColors.muted), + const SizedBox(height: 12), + Text(message, + textAlign: TextAlign.center, + style: const TextStyle(color: AppColors.muted)), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh), + label: const Text('Coba lagi'), + ), + ], + ), ), ); } @@ -456,21 +489,29 @@ class _EmptyPanel extends StatelessWidget { @override Widget build(BuildContext context) { - return const Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon(Icons.notifications_none, size: 64, color: AppColors.muted), - SizedBox(height: 12), - Text('Belum ada notifikasi', - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: AppColors.muted)), - SizedBox(height: 4), - Text('Guardian belum mengirim pesan.', - style: TextStyle(color: AppColors.muted)), - ], + return Center( + child: Container( + padding: const EdgeInsets.all(24), + decoration: const BoxDecoration( + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, + boxShadow: AppDecorations.cardShadow, + ), + child: const Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.notifications_none, size: 64, color: AppColors.muted), + SizedBox(height: 12), + Text('Belum ada notifikasi', + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: AppColors.muted)), + SizedBox(height: 4), + Text('Guardian belum mengirim pesan.', + style: TextStyle(color: AppColors.muted)), + ], + ), ), ); } diff --git a/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart b/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart index 480fc75..7652ca1 100644 --- a/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart +++ b/walkguide-mobile/walkguide_app/lib/features/pairing/pairing_screens.dart @@ -8,6 +8,10 @@ import '../../app/injection_container.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/storage/secure_storage.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../core/theme/app_text_styles.dart'; +import '../../shared/widgets/animations/animations.dart'; // --------------------------------------------------------------------------- // UserPairingScreen @@ -27,37 +31,11 @@ class UserPairingScreen extends StatefulWidget { } class _UserPairingScreenState extends State { - String? _uniqueId; String? _pairingCode; DateTime? _pairingCodeExpiresAt; int? _pairingCodeSeconds; bool _regenerating = false; - @override - void initState() { - super.initState(); - _loadUniqueId(); - } - - Future _loadUniqueId() async { - var value = await sl().getUniqueUserId(); - if (value == null || value.isEmpty) { - await runFriendlyAction( - () async { - final res = await sl() - .dio - .get('/user/profile') - .timeout(const Duration(seconds: 5)); - final data = res.data['data']; - if (data is Map) value = data['uniqueUserId']?.toString(); - }, - onError: (_) {}, - fallback: 'Profil belum bisa dimuat.', - ); - } - if (mounted) setState(() => _uniqueId = value); - } - Future _regeneratePairingCode() async { setState(() => _regenerating = true); await runFriendlyAction( @@ -90,7 +68,7 @@ class _UserPairingScreenState extends State { return _Page( title: 'Pairing', subtitle: 'Bagikan pairing code sementara ini ke Guardian.', - child: Column( + child: StaggerWrapper( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (_pairingCode == null || _pairingCode!.isEmpty) @@ -118,11 +96,6 @@ class _UserPairingScreenState extends State { : const Icon(Icons.autorenew), label: Text(_regenerating ? 'Generating...' : 'Generate New Code'), ), - if (_uniqueId != null && _uniqueId!.isNotEmpty) ...[ - const SizedBox(height: 8), - Text('Account ID: $_uniqueId', - style: const TextStyle(color: Color(0xFF64748B), fontSize: 12)), - ], const SizedBox(height: 16), _PairingStatusCard(allowUserResponse: true), ], @@ -348,26 +321,20 @@ class _PairingStatusCardState extends State<_PairingStatusCard> { final cardColor = _active ? const Color(0xFFF0FDF4) : pending - ? const Color(0xFFEFF6FF) + ? AppColors.softBlueBg : const Color(0xFFFFFBEB); final accent = _active ? const Color(0xFF059669) : pending - ? const Color(0xFF2563EB) + ? AppColors.primaryBlue : const Color(0xFFD97706); return Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: cardColor, - borderRadius: BorderRadius.circular(22), + borderRadius: BorderRadius.circular(20), border: Border.all(color: accent.withValues(alpha: 0.28)), - boxShadow: [ - BoxShadow( - color: accent.withValues(alpha: 0.10), - blurRadius: 24, - offset: const Offset(0, 12), - ), - ], + boxShadow: AppDecorations.cardShadow, ), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, @@ -393,7 +360,7 @@ class _PairingStatusCardState extends State<_PairingStatusCard> { Expanded( child: Text(_status, style: const TextStyle( - color: Color(0xFF0F172A), + color: AppColors.textDark, fontWeight: FontWeight.w700, height: 1.25)), ), @@ -465,7 +432,7 @@ class _Page extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)], + colors: [AppColors.softBlueBg, Colors.white], ), ), child: Padding( @@ -486,15 +453,9 @@ class _Page extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(18), decoration: BoxDecoration( - color: const Color(0xFF0F172A), + gradient: AppDecorations.blueGradient, borderRadius: BorderRadius.circular(24), - boxShadow: [ - BoxShadow( - color: const Color(0xFF0F172A).withValues(alpha: 0.18), - blurRadius: 28, - offset: const Offset(0, 14), - ), - ], + boxShadow: AppDecorations.cardShadow, ), child: Row( children: [ @@ -516,13 +477,11 @@ class _Page extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith( - fontWeight: FontWeight.w900, - color: Colors.white, - )), + style: AppTextStyles.heading.copyWith( + fontSize: 24, + fontWeight: FontWeight.w900, + color: Colors.white, + )), if (subtitle != null) Text(subtitle!, style: const TextStyle( @@ -535,7 +494,7 @@ class _Page extends StatelessWidget { ), ), const SizedBox(height: 16), - Expanded(child: child), + Expanded(child: FadeSlideWrapper(child: child)), ], ), ), @@ -561,15 +520,8 @@ class _InfoCard extends StatelessWidget { padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(22), - border: Border.all(color: const Color(0xFFDDEAFE)), - boxShadow: [ - BoxShadow( - color: const Color(0xFF2563EB).withValues(alpha: 0.10), - blurRadius: 24, - offset: const Offset(0, 12), - ), - ], + borderRadius: BorderRadius.circular(20), + boxShadow: AppDecorations.cardShadow, ), child: Row( children: [ @@ -577,10 +529,10 @@ class _InfoCard extends StatelessWidget { width: 48, height: 48, decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(14), + color: AppColors.softBlueBg, + borderRadius: BorderRadius.circular(50), ), - child: Icon(icon, color: const Color(0xFF2563EB)), + child: Icon(icon, color: AppColors.primaryBlue), ), const SizedBox(width: 14), Expanded( @@ -589,19 +541,19 @@ class _InfoCard extends StatelessWidget { children: [ Text(title, style: const TextStyle( - color: Color(0xFF64748B), fontWeight: FontWeight.w700)), + color: AppColors.muted, fontWeight: FontWeight.w700)), SelectableText(value, style: const TextStyle( fontSize: 25, height: 1.1, letterSpacing: 1.2, fontWeight: FontWeight.w900, - color: Color(0xFF0F172A))), + color: AppColors.textDark)), if (helper != null) ...[ const SizedBox(height: 6), Text(helper!, style: const TextStyle( - color: Color(0xFF64748B), fontSize: 12)), + color: AppColors.muted, fontSize: 12)), ], ])), ], diff --git a/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart b/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart index 4af3991..068556c 100644 --- a/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart +++ b/walkguide-mobile/walkguide_app/lib/features/server_connect/server_connect_server.dart @@ -8,6 +8,10 @@ import '../../app/injection_container.dart'; import '../../core/constants/app_constants.dart'; import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../core/theme/app_text_styles.dart'; +import '../../shared/widgets/animations/animations.dart'; class ServerConnectScreen extends StatefulWidget { final bool editMode; @@ -64,7 +68,7 @@ class _ServerConnectScreenState extends State { }, onError: (message) => _message = message, fallback: - 'Tidak bisa terhubung ke server publik. Pastikan backend aktif dan jaringan HP stabil.', + 'Tidak bisa terhubung ke server. Pastikan backend aktif, URL benar, dan jaringan HP stabil.', ); if (mounted) setState(() => _loading = false); } @@ -76,25 +80,27 @@ class _ServerConnectScreenState extends State { if (mounted) context.go(widget.editMode ? '/login' : '/splash'); } - void _usePublicUrl() => + void _useLocalUrl() => setState(() => _url.text = AppConstants.defaultServerUrl); + void _usePublicUrl() => + setState(() => _url.text = AppConstants.publicServerUrl); @override Widget build(BuildContext context) { return Scaffold( - backgroundColor: const Color(0xFF061421), + backgroundColor: AppColors.softBlueBg, body: Stack( children: [ const Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( gradient: LinearGradient( - begin: Alignment.topLeft, - end: Alignment.bottomRight, + begin: Alignment.topCenter, + end: Alignment.bottomCenter, colors: [ - Color(0xFF071226), - Color(0xFF0F3B57), - Color(0xFF0B6B6C), + AppColors.softBlueBg, + Colors.white, + AppColors.softPinkBg, ], ), ), @@ -140,20 +146,14 @@ class _ServerConnectScreenState extends State { ), child: Container( decoration: BoxDecoration( - color: Colors.white.withValues(alpha: 0.96), + color: AppColors.cardWhite, borderRadius: BorderRadius.circular( compact ? 22 : 28, ), border: Border.all( color: Colors.white.withValues(alpha: 0.7), ), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.18), - blurRadius: 34, - offset: const Offset(0, 22), - ), - ], + boxShadow: AppDecorations.cardShadow, ), child: ClipRRect( borderRadius: BorderRadius.circular( @@ -170,7 +170,7 @@ class _ServerConnectScreenState extends State { compact ? 14 : 20, ), decoration: const BoxDecoration( - color: Color(0xFF071226), + color: AppColors.softBlueBg, ), child: Column( crossAxisAlignment: @@ -182,9 +182,17 @@ class _ServerConnectScreenState extends State { width: compact ? 38 : 48, height: compact ? 38 : 48, decoration: BoxDecoration( - color: const Color(0xFF2563EB), + gradient: + AppDecorations.blueGradient, borderRadius: BorderRadius.circular(16), + boxShadow: const [ + BoxShadow( + color: Color(0x334A90D9), + blurRadius: 18, + offset: Offset(0, 8), + ), + ], ), child: Icon( Icons.navigation_rounded, @@ -197,9 +205,9 @@ class _ServerConnectScreenState extends State { child: Text( 'WalkGuide', style: TextStyle( - color: Colors.white, + color: AppColors.textDark, fontSize: compact ? 16 : 20, - fontWeight: FontWeight.w900, + fontWeight: FontWeight.w800, ), ), ), @@ -208,10 +216,9 @@ class _ServerConnectScreenState extends State { SizedBox(height: compact ? 14 : 18), Text( 'Connect to Server', - style: TextStyle( - color: Colors.white, + style: AppTextStyles.heading.copyWith( fontSize: compact ? 22 : 30, - fontWeight: FontWeight.w900, + fontWeight: FontWeight.w800, height: 1, ), ), @@ -219,11 +226,9 @@ class _ServerConnectScreenState extends State { Text( widget.editMode ? 'Ubah alamat backend Spring Boot WalkGuide yang aktif.' - : 'Sambungkan app HP ke backend Spring Boot publik WalkGuide.', - style: TextStyle( - color: Colors.white.withValues( - alpha: 0.72, - ), + : 'Pilih backend WalkGuide yang aktif sebelum login atau register.', + style: AppTextStyles.body.copyWith( + color: AppColors.muted, height: 1.35, ), ), @@ -255,9 +260,14 @@ class _ServerConnectScreenState extends State { spacing: 8, runSpacing: 8, children: [ + _HintChip( + icon: Icons.usb_outlined, + label: 'USB: 127.0.0.1', + onTap: _useLocalUrl, + ), _HintChip( icon: Icons.public_outlined, - label: 'Server: 202.46.28.170', + label: 'Kampus: 202.46.28.170', onTap: _usePublicUrl, ), ], @@ -350,27 +360,27 @@ class _HintChip extends StatelessWidget { @override Widget build(BuildContext context) { - return InkWell( + return BounceTap( onTap: onTap, - borderRadius: BorderRadius.circular(999), child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), + color: AppColors.softBlueBg, borderRadius: BorderRadius.circular(999), - border: Border.all(color: const Color(0xFFBFDBFE)), + border: + Border.all(color: AppColors.primaryBlue.withValues(alpha: 0.2)), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(icon, size: 14, color: const Color(0xFF1D4ED8)), + Icon(icon, size: 14, color: AppColors.primaryBlue), const SizedBox(width: 6), Text( label, style: const TextStyle( - color: Color(0xFF1D4ED8), + color: AppColors.primaryBlue, fontSize: 12, - fontWeight: FontWeight.w800, + fontWeight: FontWeight.w700, ), ), ], @@ -392,7 +402,7 @@ class _StatusBox extends StatelessWidget { padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: color.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(12), + borderRadius: BorderRadius.circular(16), border: Border.all(color: color.withValues(alpha: 0.22)), ), child: Row( diff --git a/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart b/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart index 0cfdc49..5dbf131 100644 --- a/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/settings/user_settings_screen.dart @@ -5,7 +5,6 @@ import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -17,16 +16,16 @@ import '../../core/network/api_client.dart'; import '../../core/services/haptic_service.dart'; import '../../core/services/tts_service.dart'; import '../../core/storage/secure_storage.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../core/theme/app_text_styles.dart'; Dio get _api => sl().dio; // ─── Colours (inline, tidak butuh import app_colors.dart) ──────────────────── const _kBlue = Color(0xFF1A56DB); const _kRed = Color(0xFFDC2626); -const _kSurface = Color(0xFFF8FAFC); -const _kBorder = Color(0xFFE2E8F0); const _kMuted = Color(0xFF64748B); -const _kText = Color(0xFF0F172A); // ─── Screen ────────────────────────────────────────────────────────────────── @@ -53,7 +52,6 @@ class _UserSettingsScreenState extends State { // Account info (from SecureStorage) String _displayName = ''; - String _uniqueId = ''; // Pairing status String _pairingStatus = '—'; @@ -75,7 +73,6 @@ class _UserSettingsScreenState extends State { Future _loadAccount() async { final storage = sl(); _displayName = await storage.getDisplayName() ?? ''; - _uniqueId = await storage.getUniqueUserId() ?? ''; } Future _loadSettings() async { @@ -91,6 +88,7 @@ class _UserSettingsScreenState extends State { _ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9; _warnNoGuardian = data['warnNoGuardian'] as bool? ?? true; _hapticEnabled = data['hapticEnabled'] as bool? ?? true; + sl().setEnabled(_hapticEnabled); } }, onError: (_) {}, @@ -126,6 +124,7 @@ class _UserSettingsScreenState extends State { // Apply TTS locally dulu await sl().setLanguage(_ttsLanguage); context.read().setLocaleCode(_ttsLanguage); + sl().setEnabled(_hapticEnabled); if (_hapticEnabled) { await sl().success(); } @@ -169,290 +168,258 @@ class _UserSettingsScreenState extends State { // ── build ────────────────────────────────────────────────────────────────── @override Widget build(BuildContext context) { - return SafeArea( - child: _loading - ? const Center(child: CircularProgressIndicator()) - : ListView( - padding: const EdgeInsets.all(16), - children: [ - // ── header ───────────────────────────────────────────────── - Text('Settings', - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.w800)), - const Text('TTS, haptic, pairing, account', - style: TextStyle(color: _kMuted)), - const SizedBox(height: 20), + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.softBlueBg, Colors.white], + ), + ), + child: SafeArea( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── header ───────────────────────────────────────────────── + Text('Settings', + style: AppTextStyles.heading.copyWith( + fontWeight: FontWeight.w800, + )), + const Text('TTS, haptic, pairing, account', + style: TextStyle(color: AppColors.muted)), + const SizedBox(height: 20), - // ── 1. TTS Settings ──────────────────────────────────────── - _SectionHeader('1. TTS Settings', Icons.record_voice_over), - const SizedBox(height: 10), - _Card( - child: Column( - children: [ - // Language (editable) - DropdownButtonFormField( - value: _ttsLanguage, - decoration: const InputDecoration( - labelText: 'Bahasa TTS', - border: OutlineInputBorder(), - contentPadding: EdgeInsets.symmetric( - horizontal: 12, vertical: 10), - ), - items: const [ - DropdownMenuItem( - value: 'id-ID', child: Text('Bahasa Indonesia')), - DropdownMenuItem( - value: 'en-US', child: Text('English (US)')), - ], - onChanged: (v) => - setState(() => _ttsLanguage = v ?? _ttsLanguage), - ), - const SizedBox(height: 12), - // Pitch — read-only info - _InfoRow( - label: 'Pitch', - value: _ttsPitch.toStringAsFixed(1), - note: 'Diatur oleh Guardian', - icon: Icons.tune, - ), - const Divider(height: 20), - // Speed — read-only info - _InfoRow( - label: 'Speed', - value: _ttsSpeed.toStringAsFixed(1), - note: 'Diatur oleh Guardian', - icon: Icons.speed, - ), - ], - ), - ), - - const SizedBox(height: 20), - - // ── 2. Pairing ───────────────────────────────────────────── - _SectionHeader('2. Pairing', Icons.link), - const SizedBox(height: 10), - _Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // unique ID - if (_uniqueId.isNotEmpty) ...[ - const Text('Unique User ID', - style: TextStyle( - fontSize: 12, - color: _kMuted, - fontWeight: FontWeight.w600)), - const SizedBox(height: 4), - GestureDetector( - onTap: () { - Clipboard.setData(ClipboardData(text: _uniqueId)); - _snack('ID disalin ke clipboard.'); - }, - child: Container( - width: double.infinity, - padding: const EdgeInsets.symmetric( - horizontal: 14, vertical: 10), - decoration: BoxDecoration( - color: const Color(0xFFEFF6FF), - borderRadius: BorderRadius.circular(10), - ), - child: Row( - children: [ - const Icon(Icons.qr_code_2, - color: _kBlue, size: 20), - const SizedBox(width: 10), - Text( - _uniqueId, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w800, - letterSpacing: 2, - color: _kBlue), - ), - const Spacer(), - const Icon(Icons.copy, - color: _kMuted, size: 16), - ], - ), + // ── 1. TTS Settings ──────────────────────────────────────── + _SectionHeader('1. TTS Settings', Icons.record_voice_over), + const SizedBox(height: 10), + _Card( + child: Column( + children: [ + // Language (editable) + DropdownButtonFormField( + value: _ttsLanguage, + decoration: const InputDecoration( + labelText: 'Bahasa TTS', + border: OutlineInputBorder(), + contentPadding: EdgeInsets.symmetric( + horizontal: 12, vertical: 10), ), + items: const [ + DropdownMenuItem( + value: 'id-ID', + child: Text('Bahasa Indonesia')), + DropdownMenuItem( + value: 'en-US', child: Text('English (US)')), + ], + onChanged: (v) => + setState(() => _ttsLanguage = v ?? _ttsLanguage), ), const SizedBox(height: 12), - const Divider(height: 1), - const SizedBox(height: 12), + // Pitch — read-only info + _InfoRow( + label: 'Pitch', + value: _ttsPitch.toStringAsFixed(1), + note: 'Diatur oleh Guardian', + icon: Icons.tune, + ), + const Divider(height: 20), + // Speed — read-only info + _InfoRow( + label: 'Speed', + value: _ttsSpeed.toStringAsFixed(1), + note: 'Diatur oleh Guardian', + icon: Icons.speed, + ), ], - // pairing status - Row( - children: [ - Icon( - _paired ? Icons.link : Icons.link_off, - color: _paired - ? const Color(0xFF16A34A) - : const Color(0xFFD97706), - size: 20, - ), - const SizedBox(width: 10), - Expanded( - child: Text(_pairingStatus, - style: TextStyle( - color: _paired - ? const Color(0xFF166534) - : const Color(0xFF92400E), - fontWeight: FontWeight.w600)), - ), - IconButton( - icon: const Icon(Icons.refresh, size: 18), - onPressed: () async { - await _loadPairing(); - setState(() {}); - }, - tooltip: 'Refresh status', - ), - ], - ), - const SizedBox(height: 8), - SizedBox( - width: double.infinity, - child: OutlinedButton.icon( - onPressed: () => context.go('/user/pairing'), - icon: const Icon(Icons.manage_accounts_outlined), - label: const Text('Buka menu Pairing'), + ), + ), + + const SizedBox(height: 20), + + // ── 2. Pairing ───────────────────────────────────────────── + _SectionHeader('2. Pairing', Icons.link), + const SizedBox(height: 10), + _Card( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + _paired ? Icons.link : Icons.link_off, + color: _paired + ? const Color(0xFF16A34A) + : const Color(0xFFD97706), + size: 20, + ), + const SizedBox(width: 10), + Expanded( + child: Text(_pairingStatus, + style: TextStyle( + color: _paired + ? const Color(0xFF166534) + : const Color(0xFF92400E), + fontWeight: FontWeight.w600)), + ), + IconButton( + icon: const Icon(Icons.refresh, size: 18), + onPressed: () async { + await _loadPairing(); + setState(() {}); + }, + tooltip: 'Refresh status', + ), + ], ), - ), - ], + const SizedBox(height: 8), + const Text( + 'Kode pairing dibuat dari menu Pairing dan hanya valid sementara. Jangan pakai Unique User ID untuk pairing.', + style: TextStyle( + color: _kMuted, + fontSize: 12, + height: 1.35, + ), + ), + const SizedBox(height: 12), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + onPressed: () => context.go('/user/pairing'), + icon: const Icon(Icons.manage_accounts_outlined), + label: const Text('Buka menu Pairing'), + ), + ), + ], + ), ), - ), - const SizedBox(height: 20), + const SizedBox(height: 20), - // ── 3. Manual / Instructions ─────────────────────────────── - _SectionHeader('3. Manual & Instruksi', Icons.menu_book), - const SizedBox(height: 10), - _Card( - child: ListTile( - contentPadding: EdgeInsets.zero, - leading: const Icon(Icons.help_outline, color: _kBlue), - title: const Text('Daftar Voice Commands & Shortcuts'), - subtitle: - const Text('Lihat semua perintah suara yang tersedia'), - trailing: const Icon(Icons.chevron_right), - onTap: () { - // Route /user/manual belum ada di router — - // tambahkan GoRoute('/user/manual', ManualScreen) di router.dart - // lalu ganti baris ini dengan: context.go('/user/manual'); - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text( - 'Manual screen: tambah route /user/manual di router.dart')), - ); - }, + // ── 3. Manual / Instructions ─────────────────────────────── + _SectionHeader('3. Manual & Instruksi', Icons.menu_book), + const SizedBox(height: 10), + _Card( + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.help_outline, color: _kBlue), + title: const Text('Daftar Voice Commands & Shortcuts'), + subtitle: const Text( + 'Lihat semua perintah suara yang tersedia'), + trailing: const Icon(Icons.chevron_right), + onTap: () { + context.go('/user/manual'); + }, + ), ), - ), - const SizedBox(height: 20), + const SizedBox(height: 20), - // ── 4. Caution Settings ──────────────────────────────────── - _SectionHeader('4. Caution Settings', Icons.warning_amber), - const SizedBox(height: 10), - _Card( - child: Column( - children: [ - SwitchListTile( - contentPadding: EdgeInsets.zero, - value: _warnNoGuardian, - onChanged: (v) => setState(() => _warnNoGuardian = v), - title: const Text('Peringatan belum paired'), - subtitle: const Text( - 'TTS ingatkan jika belum terhubung Guardian'), - secondary: const Icon( - Icons.notifications_active_outlined, - color: _kBlue), - ), - const Divider(height: 1), - SwitchListTile( - contentPadding: EdgeInsets.zero, - value: _hapticEnabled, - onChanged: (v) => setState(() => _hapticEnabled = v), - title: const Text('Haptic feedback'), - subtitle: - const Text('Getaran saat obstacle terdeteksi'), - secondary: const Icon(Icons.vibration, color: _kBlue), - ), - ], + // ── 4. Caution Settings ──────────────────────────────────── + _SectionHeader('4. Caution Settings', Icons.warning_amber), + const SizedBox(height: 10), + _Card( + child: Column( + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _warnNoGuardian, + onChanged: (v) => setState(() => _warnNoGuardian = v), + title: const Text('Peringatan belum paired'), + subtitle: const Text( + 'TTS ingatkan jika belum terhubung Guardian'), + secondary: const Icon( + Icons.notifications_active_outlined, + color: _kBlue), + ), + const Divider(height: 1), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: _hapticEnabled, + onChanged: (v) { + sl().setEnabled(v); + setState(() => _hapticEnabled = v); + }, + title: const Text('Haptic feedback'), + subtitle: + const Text('Getaran saat obstacle terdeteksi'), + secondary: const Icon(Icons.vibration, color: _kBlue), + ), + ], + ), ), - ), - const SizedBox(height: 20), + const SizedBox(height: 20), - // ── 5. Account ───────────────────────────────────────────── - _SectionHeader('5. Account', Icons.person), - const SizedBox(height: 10), - _Card( - child: Column( - children: [ - _InfoRow( - label: 'Display Name', - value: _displayName.isNotEmpty ? _displayName : '—', - icon: Icons.badge_outlined, - ), - const Divider(height: 20), - _InfoRow( - label: 'Role', - value: 'User', - icon: Icons.accessibility_new, - ), - ], + // ── 5. Account ───────────────────────────────────────────── + _SectionHeader('5. Account', Icons.person), + const SizedBox(height: 10), + _Card( + child: Column( + children: [ + _InfoRow( + label: 'Display Name', + value: _displayName.isNotEmpty ? _displayName : '—', + icon: Icons.badge_outlined, + ), + const Divider(height: 20), + _InfoRow( + label: 'Role', + value: 'User', + icon: Icons.accessibility_new, + ), + ], + ), ), - ), - const SizedBox(height: 24), + const SizedBox(height: 24), - // ── Save button ──────────────────────────────────────────── - FilledButton.icon( - onPressed: _saving ? null : _save, - icon: _saving - ? const SizedBox( - width: 18, - height: 18, - child: CircularProgressIndicator( - strokeWidth: 2, color: Colors.white)) - : const Icon(Icons.save_outlined), - label: Text(_saving ? 'Menyimpan…' : 'Simpan Settings'), - style: FilledButton.styleFrom( - minimumSize: const Size.fromHeight(48), + // ── Save button ──────────────────────────────────────────── + FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.save_outlined), + label: Text(_saving ? 'Menyimpan…' : 'Simpan Settings'), + style: FilledButton.styleFrom( + minimumSize: const Size.fromHeight(48), + ), ), - ), - const SizedBox(height: 10), + const SizedBox(height: 10), - // ── Change server ────────────────────────────────────────── - OutlinedButton.icon( - onPressed: _changeServer, - icon: const Icon(Icons.dns_outlined), - label: const Text('Ganti Server'), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(44), + // ── Change server ────────────────────────────────────────── + OutlinedButton.icon( + onPressed: _changeServer, + icon: const Icon(Icons.dns_outlined), + label: const Text('Ganti Server'), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(44), + ), ), - ), - const SizedBox(height: 10), + const SizedBox(height: 10), - // ── Logout ───────────────────────────────────────────────── - OutlinedButton.icon( - onPressed: () => _confirmLogout(context), - icon: const Icon(Icons.logout, color: _kRed), - label: const Text('Logout', style: TextStyle(color: _kRed)), - style: OutlinedButton.styleFrom( - minimumSize: const Size.fromHeight(44), - side: const BorderSide(color: _kRed), + // ── Logout ───────────────────────────────────────────────── + OutlinedButton.icon( + onPressed: () => _confirmLogout(context), + icon: const Icon(Icons.logout, color: _kRed), + label: const Text('Logout', style: TextStyle(color: _kRed)), + style: OutlinedButton.styleFrom( + minimumSize: const Size.fromHeight(44), + side: const BorderSide(color: _kRed), + ), ), - ), - const SizedBox(height: 32), - ], - ), + const SizedBox(height: 32), + ], + ), + ), ); } @@ -489,11 +456,18 @@ class _SectionHeader extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - Icon(icon, size: 18, color: _kBlue), + Container( + width: 36, + height: 36, + decoration: AppDecorations.iconCircle(color: AppColors.softBlueBg), + child: Icon(icon, size: 18, color: AppColors.primaryBlue), + ), const SizedBox(width: 8), Text(title, style: const TextStyle( - fontWeight: FontWeight.w800, fontSize: 14, color: _kBlue)), + fontWeight: FontWeight.w800, + fontSize: 14, + color: AppColors.primaryBlue)), ], ); } @@ -510,8 +484,8 @@ class _Card extends StatelessWidget { padding: const EdgeInsets.all(16), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: _kBorder), + borderRadius: BorderRadius.circular(20), + boxShadow: AppDecorations.cardShadow, ), child: child, ); @@ -533,16 +507,22 @@ class _InfoRow extends StatelessWidget { Widget build(BuildContext context) { return Row( children: [ - Icon(icon, size: 18, color: _kMuted), + Container( + width: 36, + height: 36, + decoration: AppDecorations.iconCircle(color: AppColors.softBlueBg), + child: Icon(icon, size: 18, color: AppColors.primaryBlue), + ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: const TextStyle(fontSize: 12, color: _kMuted)), + Text(label, + style: const TextStyle(fontSize: 12, color: AppColors.muted)), Text(value, style: const TextStyle( - fontWeight: FontWeight.w700, color: _kText)), + fontWeight: FontWeight.w700, color: AppColors.textDark)), ], ), ), @@ -550,12 +530,12 @@ class _InfoRow extends StatelessWidget { Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( - color: _kSurface, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: _kBorder), + color: AppColors.softBlueBg, + borderRadius: BorderRadius.circular(50), + border: Border.all(color: AppColors.border), ), child: Text(note!, - style: const TextStyle(fontSize: 11, color: _kMuted)), + style: const TextStyle(fontSize: 11, color: AppColors.muted)), ), ], ); diff --git a/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart b/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart index f7648cc..002e0be 100644 --- a/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/sos/sos_screen.dart @@ -14,6 +14,10 @@ import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/services/haptic_service.dart'; import '../../core/services/tts_service.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../core/theme/app_text_styles.dart'; +import '../../shared/widgets/animations/animations.dart'; import 'application/sos_cubit.dart'; Dio get _api => sl().dio; @@ -252,106 +256,120 @@ class _SosScreenState extends State : compact ? 12.0 : 24.0; - return SafeArea( - child: Padding( - padding: EdgeInsets.all(pagePadding), - child: Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - // Header - Row( + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.softBlueBg, Colors.white], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: FadeSlideWrapper( + child: Padding( + padding: EdgeInsets.all(pagePadding), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'SOS', - style: Theme.of(context) - .textTheme - .headlineSmall - ?.copyWith(fontWeight: FontWeight.w800), - ), - const Text( - 'Emergency alert ke Guardian', - style: TextStyle(color: Color(0xFF64748B)), - ), - ], - ), - ), - IconButton( - onPressed: _loadHistory, - icon: const Icon(Icons.refresh), - tooltip: 'Refresh riwayat', - ), - ], - ), - - SizedBox(height: sectionGap), - - // Active SOS banner - if (_hasActiveSos) - _ActiveSosBanner( - event: _events.first, onRefresh: _loadHistory), - - SizedBox(height: sectionGap), - - // SOS Button - Center( - child: sending - ? const _SendingIndicator() - : AnimatedBuilder( - animation: _pulseAnim, - builder: (_, child) => Transform.scale( - scale: _hasActiveSos ? _pulseAnim.value : 1.0, - child: child, - ), - child: _SosButton( - active: _hasActiveSos, - onPressed: _confirmAndSend, + // Header + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'SOS', + style: AppTextStyles.heading, + ), + Text( + 'Emergency alert ke Guardian', + style: AppTextStyles.body.copyWith( + color: AppColors.textMuted, + ), + ), + ], ), ), - ), - - const SizedBox(height: 8), - - // Hint text - Text( - _hasActiveSos - ? 'SOS aktif — Guardian sudah mendapat notifikasi' - : 'Tekan tombol untuk kirim SOS darurat ke Guardian', - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, - style: TextStyle( - color: _hasActiveSos - ? const Color(0xFFDC2626) - : const Color(0xFF64748B), - fontWeight: - _hasActiveSos ? FontWeight.w700 : FontWeight.normal, - ), - ), - - SizedBox(height: sectionGap), - - // History section - if (!landscapeTight) ...[ - const Text( - 'Riwayat SOS', - style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16), - ), - const SizedBox(height: 10), - Expanded( - child: _SosHistory( - loading: _historyLoading, - error: _historyError, - events: _events, - onRefresh: _loadHistory, + IconButton( + onPressed: _loadHistory, + icon: const Icon(Icons.refresh), + tooltip: 'Refresh riwayat', + ), + ], ), - ), - ] else - const Spacer(), - ], + + SizedBox(height: sectionGap), + + // Active SOS banner + if (_hasActiveSos) + _ActiveSosBanner( + event: _events.first, onRefresh: _loadHistory), + + SizedBox(height: sectionGap), + + // SOS Button + Center( + child: sending + ? const _SendingIndicator() + : AnimatedBuilder( + animation: _pulseAnim, + builder: (_, child) => Transform.scale( + scale: _hasActiveSos ? _pulseAnim.value : 1.0, + child: child, + ), + child: _SosButton( + active: _hasActiveSos, + onPressed: _confirmAndSend, + ), + ), + ), + + const SizedBox(height: 8), + + // Hint text + Text( + _hasActiveSos + ? 'SOS aktif — Guardian sudah mendapat notifikasi' + : 'Tekan tombol untuk kirim SOS darurat ke Guardian', + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: _hasActiveSos + ? const Color(0xFFDC2626) + : const Color(0xFF64748B), + fontWeight: + _hasActiveSos ? FontWeight.w700 : FontWeight.normal, + ), + ), + + SizedBox(height: sectionGap), + + // History section + if (!landscapeTight) ...[ + const Text( + 'Riwayat SOS', + style: TextStyle( + fontWeight: FontWeight.w800, + fontSize: 16, + color: AppColors.textDark, + ), + ), + const SizedBox(height: 10), + Expanded( + child: _SosHistory( + loading: _historyLoading, + error: _historyError, + events: _events, + onRefresh: _loadHistory, + ), + ), + ] else + const Spacer(), + ], + ), + ), ), ), ); @@ -383,36 +401,52 @@ class _SosButton extends StatelessWidget { : compact ? 154.0 : 200.0; - return SizedBox.square( - dimension: dimension, - child: FilledButton( - style: FilledButton.styleFrom( - shape: const CircleBorder(), - backgroundColor: - active ? const Color(0xFFB91C1C) : const Color(0xFFDC2626), - elevation: active ? 12 : 4, - shadowColor: const Color(0xFFDC2626).withValues(alpha: 0.5), - ), - onPressed: onPressed, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Icon( - active ? Icons.emergency : Icons.emergency_outlined, - size: dimension < 150 ? 34 : 48, - color: Colors.white, + return BounceTap( + onTap: onPressed, + child: Semantics( + button: true, + label: 'Kirim SOS', + child: Container( + width: dimension, + height: dimension, + decoration: BoxDecoration( + shape: BoxShape.circle, + gradient: LinearGradient( + colors: active + ? const [Color(0xFFDC2626), Color(0xFF991B1B)] + : const [Color(0xFFFF5A5A), Color(0xFFDC2626)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, ), - SizedBox(height: dimension < 150 ? 3 : 6), - Text( - 'SOS', - style: TextStyle( - fontSize: dimension < 150 ? 28 : 38, - fontWeight: FontWeight.w900, - color: Colors.white, - letterSpacing: 2, + boxShadow: [ + BoxShadow( + color: AppColors.danger.withValues(alpha: active ? 0.34 : 0.22), + blurRadius: active ? 28 : 18, + offset: const Offset(0, 10), ), - ), - ], + ], + ), + alignment: Alignment.center, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon( + active ? Icons.emergency : Icons.emergency_outlined, + size: dimension < 150 ? 34 : 48, + color: Colors.white, + ), + SizedBox(height: dimension < 150 ? 3 : 6), + Text( + 'SOS', + style: TextStyle( + fontSize: dimension < 150 ? 28 : 38, + fontWeight: FontWeight.w900, + color: Colors.white, + letterSpacing: 2, + ), + ), + ], + ), ), ), ); @@ -439,6 +473,7 @@ class _SendingIndicator extends StatelessWidget { color: const Color(0xFFDC2626).withValues(alpha: 0.15), shape: BoxShape.circle, border: Border.all(color: const Color(0xFFDC2626), width: 3), + boxShadow: AppDecorations.cardShadow, ), child: const Center( child: Column( @@ -470,8 +505,9 @@ class _ActiveSosBanner extends StatelessWidget { padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: const Color(0xFFFEE2E2), - borderRadius: BorderRadius.circular(12), + borderRadius: AppDecorations.cardRadius, border: Border.all(color: const Color(0xFFFCA5A5), width: 1.5), + boxShadow: AppDecorations.cardShadow, ), child: Row( children: [ @@ -531,10 +567,21 @@ class _SosHistory extends StatelessWidget { if (events.isEmpty) { return _HistoryEmpty(onRefresh: onRefresh); } - return ListView.separated( - itemCount: events.length, - separatorBuilder: (_, __) => const SizedBox(height: 8), - itemBuilder: (_, i) => _SosEventTile(event: events[i]), + return RefreshIndicator( + onRefresh: () async => onRefresh(), + child: ListView( + children: [ + StaggerWrapper( + children: [ + for (final event in events) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: _SosEventTile(event: event), + ), + ], + ), + ], + ), ); } } @@ -570,8 +617,9 @@ class _SosEventTile extends StatelessWidget { padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(12), + borderRadius: AppDecorations.cardRadius, border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: AppDecorations.cardShadow, ), child: Row( children: [ @@ -662,9 +710,10 @@ class _HistoryEmpty extends StatelessWidget { width: double.infinity, padding: const EdgeInsets.all(24), decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(12), + color: AppColors.cardWhite, + borderRadius: AppDecorations.cardRadius, border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: AppDecorations.cardShadow, ), child: Column( mainAxisAlignment: MainAxisAlignment.center, diff --git a/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart b/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart index e4c040c..d78cf18 100644 --- a/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/walk_guide/walk_guide_screen.dart @@ -15,6 +15,9 @@ import '../../core/errors/friendly_error.dart'; import '../../core/network/api_client.dart'; import '../../core/services/location_reporter_service.dart'; import '../../core/services/tts_service.dart'; +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../shared/widgets/animations/animations.dart'; import 'application/walk_guide_cubit.dart'; // --------------------------------------------------------------------------- @@ -48,7 +51,7 @@ class _WalkGuideScreenState extends State _scanCtrl = AnimationController( vsync: this, duration: const Duration(milliseconds: 2200), - )..repeat(); + ); _loadPairingStatus(); } @@ -73,8 +76,10 @@ class _WalkGuideScreenState extends State await _startCamera(); await sl().start(walkGuideActive: true); await _cubit.start(); + _scanCtrl.repeat(); _cubit.updateStatus(_activeStatusText()); } else { + _scanCtrl.stop(); await _stopCamera(); await sl().stop(); await _cubit.stop(); @@ -151,7 +156,7 @@ class _WalkGuideScreenState extends State ); final controller = CameraController( backCamera, - ResolutionPreset.medium, + ResolutionPreset.low, enableAudio: false, imageFormatGroup: ImageFormatGroup.yuv420, ); @@ -195,7 +200,7 @@ class _WalkGuideScreenState extends State void _onCameraImage(CameraImage image) { if (!_cubit.state.active || _processingFrame) return; final now = DateTime.now(); - if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) { + if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 1500)) { return; } _lastInferenceAt = now; @@ -258,58 +263,60 @@ class _WalkGuideScreenState extends State onPressed: () => context.go('/user/pairing'), icon: const Icon(Icons.link)), ], - child: Column( - children: [ - Expanded( - child: _VisionPanel( - state: state, - camera: _camera, - scanCtrl: _scanCtrl, - paired: _paired, - pairingLoading: _pairingLoading, - onPairingTap: () => context.go('/user/pairing'), + child: FadeSlideWrapper( + child: Column( + children: [ + Expanded( + child: _VisionPanel( + state: state, + camera: _camera, + scanCtrl: _scanCtrl, + paired: _paired, + pairingLoading: _pairingLoading, + onPairingTap: () => context.go('/user/pairing'), + ), ), - ), - const SizedBox(height: 14), - _StatusStrip( - active: state.active, - paired: _paired, - latestDetection: state.latestDetection, - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - flex: 2, - child: FilledButton.icon( - onPressed: _pairingLoading ? null : _toggle, - icon: Icon(state.active ? Icons.stop : Icons.play_arrow), - label: Text(state.active ? 'Stop Scan' : 'Start Scan'), + const SizedBox(height: 14), + _StatusStrip( + active: state.active, + paired: _paired, + latestDetection: state.latestDetection, + ), + const SizedBox(height: 12), + Row( + children: [ + Expanded( + flex: 2, + child: FilledButton.icon( + onPressed: _pairingLoading ? null : _toggle, + icon: Icon(state.active ? Icons.stop : Icons.play_arrow), + label: Text(state.active ? 'Stop Scan' : 'Start Scan'), + ), ), - ), - const SizedBox(width: 10), - _ActionSquare( - icon: Icons.sos_outlined, - color: const Color(0xFFDC2626), - onTap: () async { - if (await _ensurePaired() && context.mounted) { - context.go('/user/sos'); - } - }, - ), - const SizedBox(width: 10), - _ActionSquare( - icon: Icons.call_outlined, - color: const Color(0xFF059669), - onTap: () async { - if (await _ensurePaired() && context.mounted) { - context.go('/user/call'); - } - }, - ), - ], - ), - ], + const SizedBox(width: 10), + _ActionSquare( + icon: Icons.sos_outlined, + color: AppColors.danger, + onTap: () async { + if (await _ensurePaired() && context.mounted) { + context.go('/user/sos'); + } + }, + ), + const SizedBox(width: 10), + _ActionSquare( + icon: Icons.call_outlined, + color: AppColors.success, + onTap: () async { + if (await _ensurePaired() && context.mounted) { + context.go('/user/call'); + } + }, + ), + ], + ), + ], + ), ), ), ); @@ -336,8 +343,12 @@ class _VisionPanel extends StatelessWidget { @override Widget build(BuildContext context) { final cameraReady = camera != null && camera!.value.isInitialized; - return ClipRRect( - borderRadius: BorderRadius.circular(28), + return Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(28), + boxShadow: AppDecorations.cardShadow, + ), + clipBehavior: Clip.antiAlias, child: DecoratedBox( decoration: const BoxDecoration(color: Color(0xFF07111F)), child: Stack( @@ -393,20 +404,21 @@ class _VisionPanel extends StatelessWidget { ), ), Positioned( - top: 18, - left: 18, - right: 18, - child: Row( + top: 14, + left: 14, + right: 14, + child: Wrap( + spacing: 8, + runSpacing: 6, children: [ _Pill( - text: state.active ? 'LIVE AI SCAN' : 'STANDBY', + text: state.active ? 'LIVE AI' : 'STANDBY', color: state.active ? const Color(0xFF22C55E) : const Color(0xFFF59E0B), ), - const SizedBox(width: 8), _Pill( - text: paired ? 'GUARDIAN LINKED' : 'PAIRING REQUIRED', + text: paired ? 'LINKED' : 'PAIRING', color: paired ? const Color(0xFF38BDF8) : const Color(0xFFF97316), @@ -444,56 +456,71 @@ class _VisionPanel extends StatelessWidget { ), if (!paired && !pairingLoading) Positioned.fill( - child: DecoratedBox( - decoration: BoxDecoration( - color: const Color(0xFF020617).withValues(alpha: 0.72), - ), - child: Center( - child: Padding( - padding: const EdgeInsets.all(24), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Container( - width: 70, - height: 70, - decoration: BoxDecoration( - color: const Color(0xFFFFFBEB) - .withValues(alpha: 0.14), - borderRadius: BorderRadius.circular(18), - border: - Border.all(color: const Color(0xFFF59E0B)), - ), - child: const Icon(Icons.link_off, - color: Color(0xFFFBBF24), size: 34), - ), - const SizedBox(height: 14), - const Text( - 'Guardian belum terhubung', - textAlign: TextAlign.center, - style: TextStyle( - color: Colors.white, - fontSize: 22, - fontWeight: FontWeight.w900, - ), - ), - const SizedBox(height: 6), - const Text( - 'Pairing dulu supaya SOS, panggilan, dan pemantauan live punya penerima yang jelas.', - textAlign: TextAlign.center, - style: - TextStyle(color: Colors.white70, height: 1.35), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: onPairingTap, - icon: const Icon(Icons.link), - label: const Text('Buka Pairing'), - ), - ], + child: LayoutBuilder( + builder: (context, constraints) { + final compact = constraints.maxHeight < 270; + return DecoratedBox( + decoration: BoxDecoration( + color: const Color(0xFF020617).withValues(alpha: 0.72), ), - ), - ), + child: Center( + child: SingleChildScrollView( + padding: EdgeInsets.symmetric( + horizontal: compact ? 16 : 24, + vertical: compact ? 10 : 20, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: compact ? 46 : 64, + height: compact ? 46 : 64, + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB) + .withValues(alpha: 0.14), + borderRadius: BorderRadius.circular(16), + border: Border.all( + color: const Color(0xFFF59E0B)), + ), + child: Icon(Icons.link_off, + color: const Color(0xFFFBBF24), + size: compact ? 24 : 32), + ), + SizedBox(height: compact ? 8 : 12), + Text( + 'Guardian belum terhubung', + textAlign: TextAlign.center, + maxLines: 1, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white, + fontSize: compact ? 16 : 21, + fontWeight: FontWeight.w900, + ), + ), + if (!compact) ...[ + const SizedBox(height: 6), + const Text( + 'Pairing dulu supaya SOS, panggilan, dan pemantauan live punya penerima yang jelas.', + textAlign: TextAlign.center, + maxLines: 2, + overflow: TextOverflow.ellipsis, + style: TextStyle( + color: Colors.white70, height: 1.35), + ), + ], + SizedBox(height: compact ? 10 : 14), + FilledButton.icon( + onPressed: onPairingTap, + icon: const Icon(Icons.link), + label: const Text('Buka Pairing'), + ), + ], + ), + ), + ), + ); + }, ), ), Positioned( @@ -624,15 +651,8 @@ class _MetricChip extends StatelessWidget { padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: Colors.white, - borderRadius: BorderRadius.circular(14), - border: Border.all(color: const Color(0xFFE2E8F0)), - boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.04), - blurRadius: 14, - offset: const Offset(0, 8), - ), - ], + borderRadius: BorderRadius.circular(20), + boxShadow: AppDecorations.cardShadow, ), child: Row( children: [ @@ -680,12 +700,20 @@ class _ActionSquare extends StatelessWidget { @override Widget build(BuildContext context) { - return Material( - color: color, - borderRadius: BorderRadius.circular(14), - child: InkWell( - onTap: onTap, - borderRadius: BorderRadius.circular(14), + return BounceTap( + onTap: onTap, + child: DecoratedBox( + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(50), + boxShadow: [ + BoxShadow( + color: color.withValues(alpha: 0.22), + blurRadius: 16, + offset: const Offset(0, 8), + ), + ], + ), child: SizedBox( width: 54, height: 50, @@ -816,7 +844,7 @@ class _Page extends StatelessWidget { gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, - colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)], + colors: [AppColors.softBlueBg, Colors.white], ), ), child: Padding( @@ -835,12 +863,11 @@ class _Page extends StatelessWidget { width: compact ? 38 : 46, height: compact ? 38 : 46, decoration: BoxDecoration( - color: const Color(0xFF2563EB), + gradient: AppDecorations.blueGradient, borderRadius: BorderRadius.circular(14), boxShadow: [ BoxShadow( - color: - const Color(0xFF2563EB).withValues(alpha: 0.28), + color: AppColors.primaryBlue.withValues(alpha: 0.28), blurRadius: 18, offset: const Offset(0, 8), ), @@ -862,14 +889,14 @@ class _Page extends StatelessWidget { .headlineSmall ?.copyWith( fontWeight: FontWeight.w900, - color: const Color(0xFF0F172A), + color: AppColors.textDark, fontSize: compact ? 22 : null, )), if (subtitle != null) Text(subtitle!, maxLines: 1, overflow: TextOverflow.ellipsis, - style: const TextStyle(color: Color(0xFF64748B))), + style: const TextStyle(color: AppColors.muted)), ], ), ), diff --git a/walkguide-mobile/walkguide_app/lib/main.dart b/walkguide-mobile/walkguide_app/lib/main.dart index 7e6f5ae..7c3d0bb 100644 --- a/walkguide-mobile/walkguide_app/lib/main.dart +++ b/walkguide-mobile/walkguide_app/lib/main.dart @@ -2,18 +2,10 @@ import 'dart:async'; import 'dart:ui'; import 'package:flutter/material.dart'; -import 'package:firebase_core/firebase_core.dart'; -import 'package:firebase_messaging/firebase_messaging.dart'; -import 'package:flutter/foundation.dart'; import 'app/injection_container.dart'; import 'app/app.dart'; import 'core/constants/app_constants.dart'; -import 'core/utils/init_guard.dart'; - -@pragma('vm:entry-point') -Future _firebaseMessagingBackgroundHandler(RemoteMessage message) async { - await Firebase.initializeApp(); -} +import 'shared/widgets/walkguide_loading_screen.dart'; Future main() async { await runZonedGuarded( @@ -21,18 +13,7 @@ Future main() async { WidgetsFlutterBinding.ensureInitialized(); _installGlobalErrorUi(); await AppConstants.clearServerUrl(); - - if (!kIsWeb) { - final firebaseApp = await ignoreInitFailure( - () => Firebase.initializeApp(), - label: 'Firebase init', - ); - if (firebaseApp != null) { - FirebaseMessaging.onBackgroundMessage( - _firebaseMessagingBackgroundHandler, - ); - } - } + runApp(const WalkGuideBootApp()); try { await initDependencies(); @@ -51,6 +32,20 @@ Future main() async { ); } +class WalkGuideBootApp extends StatelessWidget { + const WalkGuideBootApp({super.key}); + + @override + Widget build(BuildContext context) { + return const MaterialApp( + debugShowCheckedModeBanner: false, + home: WalkGuideLoadingScreen( + subtitle: 'Preparing connection setup', + ), + ); + } +} + void _installGlobalErrorUi() { FlutterError.onError = (details) { FlutterError.presentError(details); diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/animations.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/animations.dart new file mode 100644 index 0000000..d99cb6c --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/animations.dart @@ -0,0 +1,3 @@ +export 'bounce_tap.dart'; +export 'fade_slide_wrapper.dart'; +export 'stagger_wrapper.dart'; diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/bounce_tap.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/bounce_tap.dart new file mode 100644 index 0000000..aa7ec5d --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/bounce_tap.dart @@ -0,0 +1,90 @@ +import 'package:flutter/material.dart'; + +class BounceTap extends StatefulWidget { + final Widget child; + final VoidCallback? onTap; + final HitTestBehavior behavior; + final Duration pressDuration; + final Duration releaseDuration; + + const BounceTap({ + super.key, + required this.child, + this.onTap, + this.behavior = HitTestBehavior.opaque, + this.pressDuration = const Duration(milliseconds: 80), + this.releaseDuration = const Duration(milliseconds: 120), + }); + + @override + State createState() => _BounceTapState(); +} + +class _BounceTapState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _scale; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + value: 1, + lowerBound: 0.94, + upperBound: 1, + duration: widget.pressDuration, + reverseDuration: widget.releaseDuration, + ); + _scale = CurvedAnimation( + parent: _controller, + curve: Curves.easeOut, + reverseCurve: Curves.elasticOut, + ); + } + + void _press() { + if (widget.onTap == null) return; + _controller.animateTo( + 0.94, + duration: widget.pressDuration, + curve: Curves.easeOut, + ); + } + + void _release({bool triggerTap = false}) { + if (widget.onTap == null) return; + _controller.animateTo( + 1, + duration: widget.releaseDuration, + curve: Curves.elasticOut, + ); + if (triggerTap) { + widget.onTap?.call(); + } + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Listener( + behavior: widget.behavior, + onPointerDown: (_) => _press(), + onPointerCancel: (_) => _release(), + onPointerUp: (_) => _release(triggerTap: true), + child: AnimatedBuilder( + animation: _scale, + child: widget.child, + builder: (context, child) => Transform.scale( + scale: _scale.value, + child: child, + ), + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/fade_slide_wrapper.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/fade_slide_wrapper.dart new file mode 100644 index 0000000..0fcbf06 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/fade_slide_wrapper.dart @@ -0,0 +1,75 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +class FadeSlideWrapper extends StatefulWidget { + final Widget child; + final Duration delay; + final Duration duration; + + const FadeSlideWrapper({ + super.key, + required this.child, + this.delay = Duration.zero, + this.duration = const Duration(milliseconds: 400), + }); + + @override + State createState() => _FadeSlideWrapperState(); +} + +class _FadeSlideWrapperState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _opacity; + late final Animation _offset; + Timer? _delayTimer; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: widget.duration, + ); + final curved = CurvedAnimation( + parent: _controller, + curve: Curves.easeOutCubic, + ); + _opacity = Tween(begin: 0, end: 1).animate(curved); + _offset = Tween( + begin: const Offset(0, 30), + end: Offset.zero, + ).animate(curved); + + if (widget.delay == Duration.zero) { + _controller.forward(); + } else { + _delayTimer = Timer(widget.delay, () { + if (mounted) _controller.forward(); + }); + } + } + + @override + void dispose() { + _delayTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _controller, + child: widget.child, + builder: (context, child) => Opacity( + opacity: _opacity.value, + child: Transform.translate( + offset: _offset.value, + child: child, + ), + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/stagger_wrapper.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/stagger_wrapper.dart new file mode 100644 index 0000000..765ed68 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/animations/stagger_wrapper.dart @@ -0,0 +1,149 @@ +import 'package:flutter/material.dart'; + +class StaggerWrapper extends StatefulWidget { + final List children; + final Axis direction; + final Duration duration; + final Duration stagger; + final MainAxisAlignment mainAxisAlignment; + final CrossAxisAlignment crossAxisAlignment; + final MainAxisSize mainAxisSize; + final TextDirection? textDirection; + final VerticalDirection verticalDirection; + final TextBaseline? textBaseline; + + const StaggerWrapper({ + super.key, + required this.children, + this.direction = Axis.vertical, + this.duration = const Duration(milliseconds: 400), + this.stagger = const Duration(milliseconds: 60), + this.mainAxisAlignment = MainAxisAlignment.start, + this.crossAxisAlignment = CrossAxisAlignment.center, + this.mainAxisSize = MainAxisSize.max, + this.textDirection, + this.verticalDirection = VerticalDirection.down, + this.textBaseline, + }); + + @override + State createState() => _StaggerWrapperState(); +} + +class _StaggerWrapperState extends State + with SingleTickerProviderStateMixin { + late AnimationController _controller; + + @override + void initState() { + super.initState(); + _controller = _createController()..forward(); + } + + @override + void didUpdateWidget(covariant StaggerWrapper oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.children.length != widget.children.length || + oldWidget.duration != widget.duration || + oldWidget.stagger != widget.stagger) { + _controller.dispose(); + _controller = _createController()..forward(); + } + } + + AnimationController _createController() { + final totalDuration = widget.duration + + widget.stagger * + (widget.children.isEmpty ? 0 : widget.children.length - 1); + return AnimationController(vsync: this, duration: totalDuration); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final animatedChildren = List.generate( + widget.children.length, + (index) => _StaggeredChild( + controller: _controller, + duration: widget.duration, + stagger: widget.stagger, + index: index, + child: widget.children[index], + ), + ); + + if (widget.direction == Axis.horizontal) { + return Row( + mainAxisAlignment: widget.mainAxisAlignment, + crossAxisAlignment: widget.crossAxisAlignment, + mainAxisSize: widget.mainAxisSize, + textDirection: widget.textDirection, + verticalDirection: widget.verticalDirection, + textBaseline: widget.textBaseline, + children: animatedChildren, + ); + } + + return Column( + mainAxisAlignment: widget.mainAxisAlignment, + crossAxisAlignment: widget.crossAxisAlignment, + mainAxisSize: widget.mainAxisSize, + textDirection: widget.textDirection, + verticalDirection: widget.verticalDirection, + textBaseline: widget.textBaseline, + children: animatedChildren, + ); + } +} + +class _StaggeredChild extends StatelessWidget { + final AnimationController controller; + final Duration duration; + final Duration stagger; + final int index; + final Widget child; + + const _StaggeredChild({ + required this.controller, + required this.duration, + required this.stagger, + required this.index, + required this.child, + }); + + @override + Widget build(BuildContext context) { + final totalMs = + controller.duration?.inMilliseconds ?? duration.inMilliseconds; + final beginMs = stagger.inMilliseconds * index; + final endMs = beginMs + duration.inMilliseconds; + final begin = totalMs <= 0 ? 0.0 : (beginMs / totalMs).clamp(0.0, 1.0); + final end = totalMs <= 0 ? 1.0 : (endMs / totalMs).clamp(begin, 1.0); + final curve = CurvedAnimation( + parent: controller, + curve: Interval(begin, end, curve: Curves.easeOutCubic), + ); + final opacity = Tween(begin: 0, end: 1).animate(curve); + final offset = Tween( + begin: const Offset(0, 30), + end: Offset.zero, + ).animate(curve); + + return AnimatedBuilder( + animation: controller, + child: child, + builder: (context, child) => Opacity( + opacity: opacity.value, + child: Transform.translate( + offset: offset.value, + child: child, + ), + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart index 6b0d64f..bcb8a5b 100644 --- a/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/app_shells.dart @@ -1,5 +1,7 @@ // 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'; @@ -11,6 +13,8 @@ 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; @@ -26,9 +30,6 @@ class _UserShellState extends State { super.initState(); _loadVoiceCommands(); _startHardwareShortcuts(); - WidgetsBinding.instance.addPostFrameCallback((_) { - _startVoiceListening(); - }); sl().onCommand = (key) { if (!mounted) return; switch (key) { @@ -66,17 +67,6 @@ class _UserShellState extends State { }; } - Future _startVoiceListening() async { - await runFriendlyAction( - () async { - await sl().init(); - await sl().startListening(); - }, - onError: (_) {}, - fallback: 'Voice listener belum bisa dimuat.', - ); - } - Future _loadVoiceCommands() async { await runFriendlyAction( () async { @@ -139,6 +129,7 @@ class _UserShellState extends State { @override void dispose() { sl().stopListening(); + unawaited(sl().stopListening()); super.dispose(); } @@ -198,15 +189,7 @@ class _AppShell extends StatelessWidget { return LayoutBuilder( builder: (context, constraints) { final useRail = constraints.maxWidth >= 760; - final content = AnimatedSwitcher( - duration: const Duration(milliseconds: 180), - switchInCurve: Curves.easeOutCubic, - switchOutCurve: Curves.easeInCubic, - child: KeyedSubtree( - key: ValueKey(location), - child: child, - ), - ); + final content = child; return Scaffold( backgroundColor: AppColors.surface, @@ -257,71 +240,40 @@ class _RailNavigation extends StatelessWidget { final itemHeight = compact ? 58.0 : 70.0; return DecoratedBox( - decoration: const BoxDecoration(color: Colors.white), + 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: ListView.separated( + child: Padding( padding: EdgeInsets.symmetric( horizontal: 8, vertical: compact ? 6 : 12, ), - itemCount: items.length, - separatorBuilder: (_, __) => SizedBox(height: compact ? 2 : 6), - itemBuilder: (context, index) { - final item = items[index]; - final selected = index == selectedIndex; - return Semantics( - button: true, - selected: selected, - label: item.label, - child: InkWell( - borderRadius: BorderRadius.circular(18), - onTap: () => context.go(items[index].route), - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - height: itemHeight, - padding: const EdgeInsets.symmetric(horizontal: 4), - decoration: BoxDecoration( - color: selected - ? const Color(0xFFEFF6FF) - : Colors.transparent, - borderRadius: BorderRadius.circular(18), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - selected ? item.selectedIcon : item.icon, - size: compact ? 23 : 25, - color: selected - ? AppColors.primary - : const Color(0xFF334155), - ), - SizedBox(height: compact ? 2 : 5), - Text( - item.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: compact ? 10 : 12, - height: 1, - fontWeight: selected - ? FontWeight.w800 - : FontWeight.w600, - color: selected - ? const Color(0xFF1D4ED8) - : const Color(0xFF334155), - ), - ), - ], + 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, + ), ), ), - ), - ); - }, + ], + ), ), ), ), @@ -344,82 +296,154 @@ class _BottomScrollNavigation extends StatelessWidget { Widget build(BuildContext context) { final bottom = MediaQuery.of(context).padding.bottom; final extraBottom = bottom > 12 ? 12.0 : bottom; - return DecoratedBox( + return Container( + margin: EdgeInsets.zero, decoration: BoxDecoration( color: Colors.white, - border: const Border(top: BorderSide(color: AppColors.border)), + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), boxShadow: [ - BoxShadow( - color: Colors.black.withValues(alpha: 0.06), - blurRadius: 18, - offset: const Offset(0, -8), - ), + AppDecorations.cardShadow.first, ], ), - child: SafeArea( - top: false, - child: SizedBox( - height: 68 + extraBottom, - child: ListView.separated( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), - itemCount: items.length, - separatorBuilder: (_, __) => const SizedBox(width: 6), - itemBuilder: (context, index) { - final item = items[index]; - final selected = index == selectedIndex; - return Semantics( - button: true, - selected: selected, - label: item.label, - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () => context.go(item.route), - child: AnimatedContainer( - duration: const Duration(milliseconds: 160), - width: 72, - padding: const EdgeInsets.symmetric(horizontal: 6), - decoration: BoxDecoration( - color: selected - ? const Color(0xFFEFF6FF) - : Colors.transparent, - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: selected - ? const Color(0xFFBFDBFE) - : Colors.transparent, - ), - ), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - selected ? item.selectedIcon : item.icon, - color: selected - ? AppColors.primary - : const Color(0xFF64748B), - size: 22, - ), - const SizedBox(height: 4), - Text( - item.label, - maxLines: 1, - overflow: TextOverflow.ellipsis, - style: TextStyle( - fontSize: 11, - fontWeight: - selected ? FontWeight.w800 : FontWeight.w600, - color: selected - ? AppColors.primary - : const Color(0xFF64748B), - ), - ), - ], + 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, + ), + ), + ], + ), ), ), ), diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart index 477b9fc..7d47f19 100644 --- a/walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/feature_page.dart @@ -1,6 +1,9 @@ import 'package:flutter/material.dart'; import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../core/theme/app_text_styles.dart'; +import 'animations/animations.dart'; class FeaturePage extends StatelessWidget { final String title; @@ -18,80 +21,79 @@ class FeaturePage extends StatelessWidget { @override Widget build(BuildContext context) { - return SafeArea( - child: LayoutBuilder( - builder: (context, constraints) { - final short = constraints.maxHeight < 520; - final compact = constraints.maxWidth < 420 || short; - final wide = constraints.maxWidth >= 900; - final horizontal = compact ? 12.0 : 20.0; - return Padding( - padding: EdgeInsets.fromLTRB( - horizontal, - short ? 8 : 12, - horizontal, - short ? 10 : 14, - ), - child: Center( - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: wide ? 1160 : double.infinity, + return DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + colors: [AppColors.softBlueBg, Colors.white], + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + ), + ), + child: SafeArea( + child: LayoutBuilder( + builder: (context, constraints) { + final short = constraints.maxHeight < 520; + final compact = constraints.maxWidth < 420 || short; + final wide = constraints.maxWidth >= 900; + final horizontal = compact ? 12.0 : 20.0; + return FadeSlideWrapper( + child: Padding( + padding: EdgeInsets.fromLTRB( + horizontal, + short ? 8 : 12, + horizontal, + short ? 10 : 14, ), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TweenAnimationBuilder( - tween: Tween(begin: 12, end: 0), - duration: const Duration(milliseconds: 360), - curve: Curves.easeOutCubic, - builder: (_, offset, child) => Opacity( - opacity: (1 - offset / 12).clamp(0.0, 1.0), - child: Transform.translate( - offset: Offset(0, offset), - child: child, - ), - ), - child: compact - ? Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _FeatureHeading( - title: title, - subtitle: subtitle, - compact: compact, - ), - if (trailing != null) ...[ - const SizedBox(height: 10), - Align( - alignment: Alignment.centerLeft, - child: trailing!, - ), - ], - ], - ) - : Row( - children: [ - Expanded( - child: _FeatureHeading( + child: Center( + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: wide ? 1160 : double.infinity, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + compact + ? Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _FeatureHeading( title: title, subtitle: subtitle, compact: compact, ), - ), - if (trailing != null) trailing!, - ], - ), + if (trailing != null) ...[ + const SizedBox(height: 10), + Align( + alignment: Alignment.centerLeft, + child: trailing!, + ), + ], + ], + ) + : Row( + children: [ + Expanded( + child: _FeatureHeading( + title: title, + subtitle: subtitle, + compact: compact, + ), + ), + if (trailing != null) trailing!, + ], + ), + SizedBox(height: short ? 8 : (compact ? 12 : 16)), + Expanded( + child: child, + ), + ], ), - SizedBox(height: short ? 8 : (compact ? 12 : 16)), - Expanded( - child: child, - ), - ], + ), ), ), - ), - ); - }, + ); + }, + ), ), ); } @@ -118,21 +120,18 @@ class _FeatureHeading extends StatelessWidget { title, maxLines: short ? 1 : 2, overflow: TextOverflow.ellipsis, - style: Theme.of(context).textTheme.headlineSmall?.copyWith( - fontSize: compact ? 22 : null, - fontWeight: FontWeight.w900, - color: AppColors.text, - ), + style: AppTextStyles.heading.copyWith( + fontSize: compact ? 22 : null, + ), ), const SizedBox(height: 2), Text( subtitle, maxLines: short ? 1 : (compact ? 2 : 3), overflow: TextOverflow.ellipsis, - style: const TextStyle( - color: AppColors.muted, + style: AppTextStyles.body.copyWith( + color: AppColors.textMuted, fontWeight: FontWeight.w500, - height: 1.25, ), ), ], @@ -157,8 +156,10 @@ class FeatureEmptyPanel extends StatelessWidget { @override Widget build(BuildContext context) { return Center( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 360), + child: Container( + constraints: const BoxConstraints(maxWidth: 380), + padding: const EdgeInsets.all(24), + decoration: AppDecorations.card, child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -167,7 +168,7 @@ class FeatureEmptyPanel extends StatelessWidget { height: 72, decoration: BoxDecoration( color: AppColors.primary.withValues(alpha: 0.08), - borderRadius: BorderRadius.circular(8), + borderRadius: BorderRadius.circular(50), border: Border.all(color: AppColors.border), ), child: Icon(icon, size: 36, color: AppColors.primary), @@ -214,8 +215,9 @@ class FeatureErrorPanel extends StatelessWidget { padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: const Color(0xFFFEF2F2), - borderRadius: BorderRadius.circular(8), + borderRadius: AppDecorations.cardRadius, border: Border.all(color: const Color(0xFFFECACA)), + boxShadow: AppDecorations.cardShadow, ), child: Column( mainAxisSize: MainAxisSize.min, diff --git a/walkguide-mobile/walkguide_app/lib/shared/widgets/walkguide_loading_screen.dart b/walkguide-mobile/walkguide_app/lib/shared/widgets/walkguide_loading_screen.dart new file mode 100644 index 0000000..18cf9d1 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/shared/widgets/walkguide_loading_screen.dart @@ -0,0 +1,186 @@ +import 'dart:async'; +import 'dart:math' as math; + +import 'package:flutter/material.dart'; + +import '../../core/theme/app_colors.dart'; +import '../../core/theme/app_decorations.dart'; +import '../../core/theme/app_text_styles.dart'; + +class WalkGuideLoadingScreen extends StatefulWidget { + final String subtitle; + + const WalkGuideLoadingScreen({ + super.key, + required this.subtitle, + }); + + @override + State createState() => _WalkGuideLoadingScreenState(); +} + +class _WalkGuideLoadingScreenState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _logoScale; + bool _showName = false; + Timer? _nameTimer; + + @override + void initState() { + super.initState(); + _controller = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(); + _logoScale = TweenSequence([ + TweenSequenceItem( + tween: Tween(begin: 1.0, end: 1.08).chain( + CurveTween(curve: Curves.easeInOut), + ), + weight: 50, + ), + TweenSequenceItem( + tween: Tween(begin: 1.08, end: 1.0).chain( + CurveTween(curve: Curves.easeInOut), + ), + weight: 50, + ), + ]).animate(_controller); + _nameTimer = Timer(const Duration(milliseconds: 300), () { + if (mounted) setState(() => _showName = true); + }); + } + + @override + void dispose() { + _nameTimer?.cancel(); + _controller.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: AppColors.softBlueBg, + body: DecoratedBox( + decoration: const BoxDecoration( + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [AppColors.softBlueBg, Colors.white], + ), + ), + child: SafeArea( + child: Center( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 340), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 28), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + AnimatedBuilder( + animation: _logoScale, + child: Container( + width: 92, + height: 92, + decoration: BoxDecoration( + gradient: AppDecorations.blueGradient, + borderRadius: BorderRadius.circular(28), + boxShadow: const [ + BoxShadow( + color: Color(0x334A90D9), + blurRadius: 28, + offset: Offset(0, 14), + ), + ], + ), + child: const Icon( + Icons.navigation_rounded, + color: Colors.white, + size: 54, + ), + ), + builder: (context, child) => Transform.scale( + scale: _logoScale.value, + child: child, + ), + ), + const SizedBox(height: 24), + AnimatedOpacity( + opacity: _showName ? 1 : 0, + duration: const Duration(milliseconds: 420), + curve: Curves.easeOutCubic, + child: Column( + children: [ + Text( + 'WalkGuide', + textAlign: TextAlign.center, + style: AppTextStyles.heading.copyWith( + fontSize: 32, + fontWeight: FontWeight.w800, + ), + ), + const SizedBox(height: 8), + Text( + widget.subtitle, + textAlign: TextAlign.center, + style: AppTextStyles.body.copyWith( + color: AppColors.textMuted, + fontWeight: FontWeight.w500, + ), + ), + ], + ), + ), + const SizedBox(height: 34), + _DotWave(controller: _controller), + ], + ), + ), + ), + ), + ), + ), + ); + } +} + +class _DotWave extends StatelessWidget { + final AnimationController controller; + + const _DotWave({required this.controller}); + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: controller, + builder: (context, child) { + return Row( + mainAxisSize: MainAxisSize.min, + children: List.generate( + 3, + (index) { + final phase = (controller.value + index * 0.18) % 1.0; + final y = math.sin(phase * math.pi * 2) * -5; + final opacity = 0.45 + (math.sin(phase * math.pi * 2) + 1) * 0.25; + return Transform.translate( + offset: Offset(0, y), + child: Container( + width: 9, + height: 9, + margin: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + color: AppColors.primaryBlue.withValues(alpha: opacity), + shape: BoxShape.circle, + ), + ), + ); + }, + ), + ); + }, + ); + } +} diff --git a/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/battery_plus b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/battery_plus new file mode 120000 index 0000000..9caf7ae --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/battery_plus @@ -0,0 +1 @@ +C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/battery_plus-6.2.3/ \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/connectivity_plus b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/connectivity_plus new file mode 120000 index 0000000..7f6e141 --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/connectivity_plus @@ -0,0 +1 @@ +C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/connectivity_plus-6.1.5/ \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/device_info_plus b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/device_info_plus new file mode 120000 index 0000000..139c09d --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/device_info_plus @@ -0,0 +1 @@ +C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/device_info_plus-11.5.0/ \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/flutter_local_notifications_linux b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/flutter_local_notifications_linux new file mode 120000 index 0000000..ac7f0ab --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/flutter_local_notifications_linux @@ -0,0 +1 @@ +C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_local_notifications_linux-4.0.1/ \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux new file mode 120000 index 0000000..f82280d --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/flutter_secure_storage_linux @@ -0,0 +1 @@ +C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/ \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux new file mode 120000 index 0000000..e99a84c --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/path_provider_linux @@ -0,0 +1 @@ +C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/path_provider_linux-2.2.1/ \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/record_linux b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/record_linux new file mode 120000 index 0000000..c6ef754 --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/record_linux @@ -0,0 +1 @@ +C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/record_linux-1.3.0/ \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux new file mode 120000 index 0000000..559faea --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/shared_preferences_linux @@ -0,0 +1 @@ +C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_linux-2.4.1/ \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/sqlite3_flutter_libs b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/sqlite3_flutter_libs new file mode 120000 index 0000000..7715617 --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/sqlite3_flutter_libs @@ -0,0 +1 @@ +C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/ \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/tflite_flutter b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/tflite_flutter new file mode 120000 index 0000000..8a2075c --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/ephemeral/.plugin_symlinks/tflite_flutter @@ -0,0 +1 @@ +C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/tflite_flutter-0.12.1/ \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/linux/flutter/generated_plugin_registrant.cc b/walkguide-mobile/walkguide_app/linux/flutter/generated_plugin_registrant.cc new file mode 100644 index 0000000..4088865 --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/generated_plugin_registrant.cc @@ -0,0 +1,23 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#include "generated_plugin_registrant.h" + +#include +#include +#include + +void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) flutter_secure_storage_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "FlutterSecureStorageLinuxPlugin"); + flutter_secure_storage_linux_plugin_register_with_registrar(flutter_secure_storage_linux_registrar); + g_autoptr(FlPluginRegistrar) record_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "RecordLinuxPlugin"); + record_linux_plugin_register_with_registrar(record_linux_registrar); + g_autoptr(FlPluginRegistrar) sqlite3_flutter_libs_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "Sqlite3FlutterLibsPlugin"); + sqlite3_flutter_libs_plugin_register_with_registrar(sqlite3_flutter_libs_registrar); +} diff --git a/walkguide-mobile/walkguide_app/linux/flutter/generated_plugin_registrant.h b/walkguide-mobile/walkguide_app/linux/flutter/generated_plugin_registrant.h new file mode 100644 index 0000000..e0f0a47 --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/generated_plugin_registrant.h @@ -0,0 +1,15 @@ +// +// Generated file. Do not edit. +// + +// clang-format off + +#ifndef GENERATED_PLUGIN_REGISTRANT_ +#define GENERATED_PLUGIN_REGISTRANT_ + +#include + +// Registers Flutter plugins. +void fl_register_plugins(FlPluginRegistry* registry); + +#endif // GENERATED_PLUGIN_REGISTRANT_ diff --git a/walkguide-mobile/walkguide_app/linux/flutter/generated_plugins.cmake b/walkguide-mobile/walkguide_app/linux/flutter/generated_plugins.cmake new file mode 100644 index 0000000..1a53f44 --- /dev/null +++ b/walkguide-mobile/walkguide_app/linux/flutter/generated_plugins.cmake @@ -0,0 +1,27 @@ +# +# Generated file, do not edit. +# + +list(APPEND FLUTTER_PLUGIN_LIST + flutter_secure_storage_linux + record_linux + sqlite3_flutter_libs +) + +list(APPEND FLUTTER_FFI_PLUGIN_LIST + tflite_flutter +) + +set(PLUGIN_BUNDLED_LIBRARIES) + +foreach(plugin ${FLUTTER_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${plugin}/linux plugins/${plugin}) + target_link_libraries(${BINARY_NAME} PRIVATE ${plugin}_plugin) + list(APPEND PLUGIN_BUNDLED_LIBRARIES $) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${plugin}_bundled_libraries}) +endforeach(plugin) + +foreach(ffi_plugin ${FLUTTER_FFI_PLUGIN_LIST}) + add_subdirectory(flutter/ephemeral/.plugin_symlinks/${ffi_plugin}/linux plugins/${ffi_plugin}) + list(APPEND PLUGIN_BUNDLED_LIBRARIES ${${ffi_plugin}_bundled_libraries}) +endforeach(ffi_plugin) diff --git a/walkguide-mobile/walkguide_app/macos/Flutter/GeneratedPluginRegistrant.swift b/walkguide-mobile/walkguide_app/macos/Flutter/GeneratedPluginRegistrant.swift new file mode 100644 index 0000000..c64d224 --- /dev/null +++ b/walkguide-mobile/walkguide_app/macos/Flutter/GeneratedPluginRegistrant.swift @@ -0,0 +1,48 @@ +// +// Generated file. Do not edit. +// + +import FlutterMacOS +import Foundation + +import agora_rtc_engine +import audio_session +import battery_plus +import connectivity_plus +import device_info_plus +import firebase_core +import firebase_messaging +import flutter_local_notifications +import flutter_secure_storage_macos +import flutter_tts +import geolocator_apple +import iris_method_channel +import just_audio +import path_provider_foundation +import record_darwin +import shared_preferences_foundation +import speech_to_text +import sqflite_darwin +import sqlite3_flutter_libs + +func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { + AgoraRtcNgPlugin.register(with: registry.registrar(forPlugin: "AgoraRtcNgPlugin")) + AudioSessionPlugin.register(with: registry.registrar(forPlugin: "AudioSessionPlugin")) + BatteryPlusMacosPlugin.register(with: registry.registrar(forPlugin: "BatteryPlusMacosPlugin")) + ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin")) + DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin")) + FLTFirebaseCorePlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseCorePlugin")) + FLTFirebaseMessagingPlugin.register(with: registry.registrar(forPlugin: "FLTFirebaseMessagingPlugin")) + FlutterLocalNotificationsPlugin.register(with: registry.registrar(forPlugin: "FlutterLocalNotificationsPlugin")) + FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FlutterTtsPlugin.register(with: registry.registrar(forPlugin: "FlutterTtsPlugin")) + GeolocatorPlugin.register(with: registry.registrar(forPlugin: "GeolocatorPlugin")) + IrisMethodChannelPlugin.register(with: registry.registrar(forPlugin: "IrisMethodChannelPlugin")) + JustAudioPlugin.register(with: registry.registrar(forPlugin: "JustAudioPlugin")) + PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) + RecordPlugin.register(with: registry.registrar(forPlugin: "RecordPlugin")) + SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) + SpeechToTextPlugin.register(with: registry.registrar(forPlugin: "SpeechToTextPlugin")) + SqflitePlugin.register(with: registry.registrar(forPlugin: "SqflitePlugin")) + Sqlite3FlutterLibsPlugin.register(with: registry.registrar(forPlugin: "Sqlite3FlutterLibsPlugin")) +} diff --git a/walkguide-mobile/walkguide_app/macos/Flutter/ephemeral/Flutter-Generated.xcconfig b/walkguide-mobile/walkguide_app/macos/Flutter/ephemeral/Flutter-Generated.xcconfig new file mode 100644 index 0000000..c334719 --- /dev/null +++ b/walkguide-mobile/walkguide_app/macos/Flutter/ephemeral/Flutter-Generated.xcconfig @@ -0,0 +1,12 @@ +// This is a generated file; do not edit or check into version control. +FLUTTER_ROOT=D:\Tools\Flutter\flutter +FLUTTER_APPLICATION_PATH=D:\CodeSpace\Final Project Gabungan - Broken Test\walkguide-mobile\walkguide_app +COCOAPODS_PARALLEL_CODE_SIGN=true +FLUTTER_BUILD_DIR=build +FLUTTER_BUILD_NAME=1.0.0 +FLUTTER_BUILD_NUMBER=1 +FLUTTER_CLI_BUILD_MODE=debug +DART_OBFUSCATION=false +TRACK_WIDGET_CREATION=true +TREE_SHAKE_ICONS=false +PACKAGE_CONFIG=.dart_tool/package_config.json diff --git a/walkguide-mobile/walkguide_app/macos/Flutter/ephemeral/flutter_export_environment.sh b/walkguide-mobile/walkguide_app/macos/Flutter/ephemeral/flutter_export_environment.sh new file mode 100644 index 0000000..f2c0e85 --- /dev/null +++ b/walkguide-mobile/walkguide_app/macos/Flutter/ephemeral/flutter_export_environment.sh @@ -0,0 +1,13 @@ +#!/bin/sh +# This is a generated file; do not edit or check into version control. +export "FLUTTER_ROOT=D:\Tools\Flutter\flutter" +export "FLUTTER_APPLICATION_PATH=D:\CodeSpace\Final Project Gabungan - Broken Test\walkguide-mobile\walkguide_app" +export "COCOAPODS_PARALLEL_CODE_SIGN=true" +export "FLUTTER_BUILD_DIR=build" +export "FLUTTER_BUILD_NAME=1.0.0" +export "FLUTTER_BUILD_NUMBER=1" +export "FLUTTER_CLI_BUILD_MODE=debug" +export "DART_OBFUSCATION=false" +export "TRACK_WIDGET_CREATION=true" +export "TREE_SHAKE_ICONS=false" +export "PACKAGE_CONFIG=.dart_tool/package_config.json"