POLISHING STUFF and Smarter AI, and alot i mean.. ALOT OF BUG FIX AND OPTIMIZE

This commit is contained in:
Wowieee4 2026-05-29 15:50:51 +07:00
parent 1110e5a42d
commit d2b3534dde
65 changed files with 4150 additions and 2974 deletions

View File

@ -36,11 +36,14 @@ public class WebSocketConfig implements WebSocketMessageBrokerConfigurer {
@Override @Override
public void registerStompEndpoints(StompEndpointRegistry registry) { 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) // Flutter connect ke: ws://host:port/ws (tanpa SockJS)
// Browser/Postman bisa pakai SockJS fallback: http://host:port/ws
registry.addEndpoint("/ws") registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*") // Allow semua origin untuk testing HP di LAN .setAllowedOriginPatterns("*"); // Allow semua origin untuk testing HP di LAN
.withSockJS(); // SockJS fallback untuk browser compatibility
// Endpoint fallback SockJS untuk browser tooling bila dibutuhkan.
registry.addEndpoint("/ws-sockjs")
.setAllowedOriginPatterns("*")
.withSockJS();
} }
} }

View File

@ -17,6 +17,7 @@
android:label="WalkGuide" android:label="WalkGuide"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:enableOnBackInvokedCallback="true"
android:usesCleartextTraffic="true"> android:usesCleartextTraffic="true">
<activity <activity
android:name=".MainActivity" android:name=".MainActivity"

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen --> <!-- Modify this file to customize your launch splash screen -->
<layer-list xmlns:android="http://schemas.android.com/apk/res/android"> <layer-list xmlns:android="http://schemas.android.com/apk/res/android">
<item android:drawable="?android:colorBackground" /> <item android:drawable="@android:color/white" />
<!-- You can insert your own image assets here --> <!-- You can insert your own image assets here -->
<!-- <item> <!-- <item>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources> <resources>
<!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on --> <!-- Theme applied to the Android Window while the process is starting when the OS's Dark Mode setting is on -->
<style name="LaunchTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="LaunchTheme" parent="@android:style/Theme.Light.NoTitleBar">
<!-- Show a splash screen on the activity. Automatically removed when <!-- Show a splash screen on the activity. Automatically removed when
the Flutter engine draws its first frame --> the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item> <item name="android:windowBackground">@drawable/launch_background</item>
@ -12,7 +12,7 @@
running. running.
This Theme is only used starting with V2 of Flutter's Android embedding. --> This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar"> <style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item> <item name="android:windowBackground">#F8FAFC</item>
</style> </style>
</resources> </resources>

View File

@ -77,4 +77,4 @@ vase
scissors scissors
teddy bear teddy bear
hair drier hair drier
toothbrush toothbrush

View File

@ -1,19 +1,20 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:google_fonts/google_fonts.dart';
import 'app_cubit.dart'; import 'app_cubit.dart';
import 'router.dart'; import 'router.dart';
import '../core/i18n/app_strings.dart'; import '../core/i18n/app_strings.dart';
import '../core/theme/app_colors.dart'; import '../core/theme/app_colors.dart';
import '../core/theme/app_decorations.dart';
import '../core/theme/app_text_styles.dart';
class WalkGuideApp extends StatelessWidget { class WalkGuideApp extends StatelessWidget {
const WalkGuideApp({super.key}); const WalkGuideApp({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
const seed = AppColors.primary; const seed = AppColors.primaryBlue;
return BlocProvider( return BlocProvider(
create: (_) => AppCubit(), create: (_) => AppCubit(),
@ -55,7 +56,7 @@ class WalkGuideApp extends StatelessWidget {
error: AppColors.danger, error: AppColors.danger,
), ),
scaffoldBackgroundColor: AppColors.surface, scaffoldBackgroundColor: AppColors.surface,
textTheme: GoogleFonts.interTextTheme().apply( textTheme: AppTextStyles.textTheme.apply(
bodyColor: AppColors.text, bodyColor: AppColors.text,
displayColor: AppColors.text, displayColor: AppColors.text,
), ),
@ -73,14 +74,12 @@ class WalkGuideApp extends StatelessWidget {
elevation: 0, elevation: 0,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
), ),
cardTheme: CardThemeData( cardTheme: const CardThemeData(
elevation: 0, elevation: 0,
color: AppColors.surfaceRaised, color: AppColors.surfaceRaised,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
shape: RoundedRectangleBorder( margin: EdgeInsets.zero,
borderRadius: BorderRadius.circular(8), shape: AppDecorations.cardShape,
side: const BorderSide(color: AppColors.border),
),
), ),
dividerTheme: const DividerThemeData( dividerTheme: const DividerThemeData(
color: AppColors.border, color: AppColors.border,
@ -92,7 +91,7 @@ class WalkGuideApp extends StatelessWidget {
foregroundColor: AppColors.text, foregroundColor: AppColors.text,
backgroundColor: Colors.white, backgroundColor: Colors.white,
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(14),
side: const BorderSide(color: AppColors.border), side: const BorderSide(color: AppColors.border),
), ),
), ),
@ -101,7 +100,7 @@ class WalkGuideApp extends StatelessWidget {
elevation: 0, elevation: 0,
height: 76, height: 76,
backgroundColor: Colors.white, backgroundColor: Colors.white,
indicatorColor: const Color(0xFFDDEAFE), indicatorColor: AppColors.softBlueBg,
surfaceTintColor: Colors.transparent, surfaceTintColor: Colors.transparent,
labelTextStyle: WidgetStateProperty.resolveWith( labelTextStyle: WidgetStateProperty.resolveWith(
(states) => TextStyle( (states) => TextStyle(
@ -117,9 +116,12 @@ class WalkGuideApp extends StatelessWidget {
backgroundColor: seed, backgroundColor: seed,
foregroundColor: Colors.white, foregroundColor: Colors.white,
minimumSize: const Size(0, 50), minimumSize: const Size(0, 50),
textStyle: const TextStyle(fontWeight: FontWeight.w800), textStyle: AppTextStyles.body.copyWith(
color: Colors.white,
fontWeight: FontWeight.w700,
),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(50),
), ),
), ),
), ),
@ -127,22 +129,25 @@ class WalkGuideApp extends StatelessWidget {
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size(0, 50), minimumSize: const Size(0, 50),
foregroundColor: seed, foregroundColor: seed,
textStyle: const TextStyle(fontWeight: FontWeight.w800), textStyle: AppTextStyles.body.copyWith(
color: seed,
fontWeight: FontWeight.w700,
),
side: const BorderSide(color: AppColors.border), side: const BorderSide(color: AppColors.border),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(50),
), ),
), ),
), ),
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
backgroundColor: AppColors.text, backgroundColor: AppColors.text,
contentTextStyle: GoogleFonts.inter( contentTextStyle: AppTextStyles.body.copyWith(
color: Colors.white, color: Colors.white,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
), ),
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(14),
), ),
), ),
inputDecorationTheme: InputDecorationTheme( inputDecorationTheme: InputDecorationTheme(
@ -151,15 +156,15 @@ class WalkGuideApp extends StatelessWidget {
contentPadding: contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 16), const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.border), borderSide: const BorderSide(color: AppColors.border),
), ),
enabledBorder: OutlineInputBorder( enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: AppColors.border), borderSide: const BorderSide(color: AppColors.border),
), ),
focusedBorder: OutlineInputBorder( focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(14),
borderSide: const BorderSide(color: seed, width: 1.5), borderSide: const BorderSide(color: seed, width: 1.5),
), ),
), ),

View File

@ -17,7 +17,6 @@ import '../core/services/voice_command_handler.dart';
import '../core/services/websocket_service.dart'; import '../core/services/websocket_service.dart';
import '../core/storage/local_database.dart'; import '../core/storage/local_database.dart';
import '../core/storage/secure_storage.dart'; import '../core/storage/secure_storage.dart';
import '../core/utils/init_guard.dart';
import '../features/notifications/application/notification_cubit.dart'; import '../features/notifications/application/notification_cubit.dart';
import '../features/notifications/data/repositories/notification_repository_impl.dart'; import '../features/notifications/data/repositories/notification_repository_impl.dart';
import '../features/notifications/domain/repositories/notification_repository.dart'; import '../features/notifications/domain/repositories/notification_repository.dart';
@ -82,6 +81,5 @@ Future<void> initDependencies() async {
await sl<ApiClient>().init(serverUrl); await sl<ApiClient>().init(serverUrl);
} }
await ignoreInitFailure(() => sl<TtsService>().init(), label: 'TTS init');
sl<VoiceCommandHandler>().loadDefaultCommands(); sl<VoiceCommandHandler>().loadDefaultCommands();
} }

View File

@ -25,6 +25,7 @@ import '../features/guardian_dashboard/presentation/screens/guardian_tools_scree
as guardian_tools; as guardian_tools;
import '../features/home/presentation/guardian_dashboard_screen.dart' import '../features/home/presentation/guardian_dashboard_screen.dart'
as guardian_home; as guardian_home;
import '../features/manual/manual_screen.dart' as manual;
import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart' import '../features/navigation_mode/presentation/screens/navigation_mode_screen.dart'
as nav; as nav;
import '../features/notifications/presentation/screens/notification_screen.dart' 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'; import '../shared/widgets/app_shells.dart';
final GoRouter appRouter = GoRouter( final GoRouter appRouter = GoRouter(
initialLocation: '/splash', initialLocation: '/server-connect',
redirect: (context, state) async { redirect: (context, state) async {
final path = state.matchedLocation; final path = state.matchedLocation;
final serverUrl = await AppConstants.getServerUrl(); final serverUrl = await AppConstants.getServerUrl();
@ -141,6 +142,9 @@ final GoRouter appRouter = GoRouter(
GoRoute( GoRoute(
path: '/user/benchmark', path: '/user/benchmark',
builder: (_, __) => const benchmark.AiBenchmarkScreen()), builder: (_, __) => const benchmark.AiBenchmarkScreen()),
GoRoute(
path: '/user/manual',
builder: (_, __) => const manual.ManualScreen()),
], ],
), ),
ShellRoute( ShellRoute(

View File

@ -586,14 +586,31 @@ const Set<String> _walkGuideObstacleLabels = {
'bicycle', 'bicycle',
'car', 'car',
'motorcycle', 'motorcycle',
'truck',
'bus', 'bus',
'train', 'train',
'truck', 'boat',
'traffic light', 'traffic light',
'fire hydrant', 'fire hydrant',
'stop sign', 'stop sign',
'parking meter', 'parking meter',
'bench', 'bench',
'stairs',
'stair',
'pothole',
'curb',
'pole',
'bollard',
'cone',
'road cone',
'barrier',
'fence',
'door',
'trash can',
'signboard',
'crosswalk',
'sidewalk',
'wall',
'backpack', 'backpack',
'umbrella', 'umbrella',
'handbag', 'handbag',
@ -608,6 +625,7 @@ const Set<String> _walkGuideObstacleLabels = {
'bottle', 'bottle',
'cup', 'cup',
'book', 'book',
'object',
}; };
const Map<int, String> _cocoObstacleLabels = { const Map<int, String> _cocoObstacleLabels = {

View File

@ -1,7 +1,8 @@
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
class AppConstants { 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 _serverUrlKey = 'server_base_url';
static const String _selectedYoloModelKey = 'selected_yolo_model'; static const String _selectedYoloModelKey = 'selected_yolo_model';
@ -10,7 +11,7 @@ class AppConstants {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final saved = prefs.getString(_serverUrlKey); final saved = prefs.getString(_serverUrlKey);
if (saved == null || saved.trim().isEmpty) { if (saved == null || saved.trim().isEmpty) {
return defaultServerUrl; return null;
} }
return saved; return saved;
} }
@ -38,7 +39,7 @@ class AppConstants {
static Future<void> clearServerUrl() async { static Future<void> clearServerUrl() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.setString(_serverUrlKey, defaultServerUrl); await prefs.remove(_serverUrlKey);
} }
static String buildApiUrl(String baseUrl) => '$baseUrl/api/v1'; static String buildApiUrl(String baseUrl) => '$baseUrl/api/v1';

View File

@ -61,7 +61,11 @@ class _AuthInterceptor extends Interceptor {
@override @override
void onError(DioException err, ErrorInterceptorHandler handler) async { 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; _refreshing = true;
try { try {
final refresh = await _storage.getRefreshToken(); final refresh = await _storage.getRefreshToken();
@ -87,14 +91,20 @@ class _AuthInterceptor extends Interceptor {
// Retry original request // Retry original request
err.requestOptions.headers['Authorization'] = err.requestOptions.headers['Authorization'] =
'Bearer ${data['accessToken']}'; 'Bearer ${data['accessToken']}';
final retryRes = await _dio.fetch(err.requestOptions); try {
_refreshing = false; final retryRes = await _dio.fetch(err.requestOptions);
handler.resolve(retryRes); _refreshing = false;
handler.resolve(retryRes);
} on DioException catch (retryErr) {
_refreshing = false;
handler.next(retryErr);
}
return; return;
} }
} catch (_) {} } catch (_) {
await _storage.clearAll();
}
_refreshing = false; _refreshing = false;
await _storage.clearAll();
} }
handler.next(err); handler.next(err);
} }

View File

@ -1,5 +1,6 @@
import 'dart:convert'; import 'dart:convert';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.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 '../../app/router.dart';
import '../network/api_client.dart'; import '../network/api_client.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
}
class FcmService { class FcmService {
final ApiClient _apiClient; final ApiClient _apiClient;
final FlutterLocalNotificationsPlugin _localNotifications = final FlutterLocalNotificationsPlugin _localNotifications =
@ -17,6 +23,9 @@ class FcmService {
Future<void> init() async { Future<void> init() async {
if (kIsWeb) return; if (kIsWeb) return;
try { try {
await Firebase.initializeApp();
FirebaseMessaging.onBackgroundMessage(
_firebaseMessagingBackgroundHandler);
final messaging = FirebaseMessaging.instance; final messaging = FirebaseMessaging.instance;
await _localNotifications.initialize( await _localNotifications.initialize(
const InitializationSettings( const InitializationSettings(

View File

@ -1,36 +1,107 @@
import 'package:flutter/services.dart';
import 'package:vibration/vibration.dart'; import 'package:vibration/vibration.dart';
class HapticService { class HapticService {
Future<bool> 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<bool> 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<void> _vibrate({
int? duration,
List<int>? pattern,
required Future<void> 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<void> obstacleVeryClose() async { Future<void> obstacleVeryClose() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(pattern: [0, 500, 100, 500, 100, 500]); pattern: [0, 500, 100, 500, 100, 500],
fallback: HapticFeedback.heavyImpact,
obstacle: true,
);
} }
Future<void> obstacleClose() async { Future<void> obstacleClose() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(pattern: [0, 300, 100, 300]); pattern: [0, 300, 100, 300],
fallback: HapticFeedback.mediumImpact,
obstacle: true,
);
} }
Future<void> obstacleMedium() async { Future<void> obstacleMedium() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(duration: 150); duration: 150,
fallback: HapticFeedback.lightImpact,
obstacle: true,
);
} }
Future<void> sosTriggered() async { Future<void> sosTriggered() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(pattern: [0, 1000, 200, 1000, 200, 1000]); pattern: [0, 1000, 200, 1000, 200, 1000],
fallback: HapticFeedback.heavyImpact,
);
} }
Future<void> callIncoming() async { Future<void> callIncoming() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(pattern: [0, 500, 500, 500, 500, 500, 500, 500]); pattern: [0, 500, 500, 500, 500, 500, 500, 500],
fallback: HapticFeedback.mediumImpact,
);
} }
Future<void> success() async { Future<void> success() async {
if (!await _hasVibrator) return; await _vibrate(
Vibration.vibrate(duration: 80); duration: 80,
fallback: HapticFeedback.selectionClick,
);
} }
Future<void> stop() async => Vibration.cancel(); Future<void> stop() async => Vibration.cancel();

View File

@ -27,11 +27,14 @@ class HardwareShortcutBinding {
class HardwareShortcutListener { class HardwareShortcutListener {
final ApiClient _apiClient; final ApiClient _apiClient;
final Map<int, HardwareShortcutBinding> _bindings = {}; final Map<int, HardwareShortcutBinding> _bindings = {};
final Map<int, DateTime> _lastHandledAt = {};
bool _listening = false; bool _listening = false;
void Function(HardwareShortcutAction action)? _onAction; void Function(HardwareShortcutAction action)? _onAction;
void Function(int buttonCode, String buttonName)? _captureCallback; void Function(int buttonCode, String buttonName)? _captureCallback;
static const Duration _repeatDebounce = Duration(milliseconds: 900);
HardwareShortcutListener(this._apiClient); HardwareShortcutListener(this._apiClient);
Future<void> startListening({ Future<void> 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; _captureCallback = onCapture;
} }
@ -88,6 +92,12 @@ class HardwareShortcutListener {
final binding = _bindings[code]; final binding = _bindings[code];
if (binding == null || !binding.enabled) return false; 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); _onAction?.call(binding.action);
return true; return true;
} }
@ -103,7 +113,8 @@ HardwareShortcutBinding? _bindingFromJson(Map<String, dynamic> item) {
final action = _actionFromBackend(item['shortcutKey']?.toString()); final action = _actionFromBackend(item['shortcutKey']?.toString());
final rawCode = item['buttonCode']; final rawCode = item['buttonCode'];
final enabled = item['enabled'] != false; 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; if (action == null || code == null || code <= 0) return null;
return HardwareShortcutBinding( return HardwareShortcutBinding(
action: action, action: action,

View File

@ -6,17 +6,24 @@ class SttService {
final SpeechToText _stt = SpeechToText(); final SpeechToText _stt = SpeechToText();
bool _available = false; bool _available = false;
bool _listening = false; bool _listening = false;
bool _shouldListen = false;
bool _initializing = false;
Function(String)? onResult; Function(String)? onResult;
Future<bool> init() async { Future<bool> init() async {
if (_available) return true;
if (_initializing) return _available;
_initializing = true;
_available = await _stt.initialize( _available = await _stt.initialize(
onError: (e) => _onError(e), onError: (e) => _onError(e),
onStatus: (s) => _onStatus(s), onStatus: (s) => _onStatus(s),
); );
_initializing = false;
return _available; return _available;
} }
Future<void> startListening() async { Future<void> startListening() async {
_shouldListen = true;
if (!_available || _listening) return; if (!_available || _listening) return;
_listening = true; _listening = true;
await _stt.listen( await _stt.listen(
@ -25,14 +32,15 @@ class SttService {
onResult?.call(result.recognizedWords.toLowerCase().trim()); onResult?.call(result.recognizedWords.toLowerCase().trim());
} }
}, },
listenFor: const Duration(seconds: 10), listenFor: const Duration(seconds: 60),
pauseFor: const Duration(seconds: 3), pauseFor: const Duration(seconds: 8),
localeId: 'id_ID', localeId: 'id_ID',
cancelOnError: false, cancelOnError: false,
); );
} }
Future<void> stopListening() async { Future<void> stopListening() async {
_shouldListen = false;
_listening = false; _listening = false;
await _stt.stop(); await _stt.stop();
} }
@ -42,15 +50,17 @@ class SttService {
void _onError(dynamic error) { void _onError(dynamic error) {
_listening = false; _listening = false;
// Auto-restart setelah error if (_shouldListen) {
Future.delayed(const Duration(seconds: 1), startListening); Future.delayed(const Duration(seconds: 2), startListening);
}
} }
void _onStatus(String status) { void _onStatus(String status) {
if (status == 'done' || status == 'notListening') { if (status == 'done' || status == 'notListening') {
_listening = false; _listening = false;
// Auto-restart agar selalu mendengarkan if (_shouldListen) {
Future.delayed(const Duration(milliseconds: 500), startListening); Future.delayed(const Duration(seconds: 2), startListening);
}
} }
} }
} }

View File

@ -4,9 +4,15 @@ class TtsService {
final FlutterTts _tts = FlutterTts(); final FlutterTts _tts = FlutterTts();
final List<String> _queue = []; final List<String> _queue = [];
bool _speaking = false; bool _speaking = false;
bool _initialized = false;
String _lastSpoken = ''; String _lastSpoken = '';
DateTime _lastQueuedAt = DateTime.fromMillisecondsSinceEpoch(0);
Future<void> init({String language = 'id-ID', double pitch = 1.0, double rate = 0.5}) async { Future<void> init(
{String language = 'id-ID',
double pitch = 1.0,
double rate = 0.5}) async {
if (_initialized) return;
await _tts.setLanguage(language); await _tts.setLanguage(language);
await _tts.setPitch(pitch); await _tts.setPitch(pitch);
await _tts.setSpeechRate(rate); await _tts.setSpeechRate(rate);
@ -15,11 +21,25 @@ class TtsService {
_speaking = false; _speaking = false;
_processQueue(); _processQueue();
}); });
_initialized = true;
} }
/// Tambah ke antrian - tidak memotong yg sedang bicara /// Tambah ke antrian - tidak memotong yg sedang bicara
void speak(String text) { void speak(String text) {
if (text.isEmpty) return; 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); _queue.add(text);
if (!_speaking) _processQueue(); if (!_speaking) _processQueue();
} }
@ -27,6 +47,7 @@ class TtsService {
/// Potong TTS sekarang dan langsung ucapkan ini - untuk obstacle alert /// Potong TTS sekarang dan langsung ucapkan ini - untuk obstacle alert
Future<void> speakImmediate(String text) async { Future<void> speakImmediate(String text) async {
if (text.isEmpty) return; if (text.isEmpty) return;
await init();
_queue.clear(); _queue.clear();
await _tts.stop(); await _tts.stop();
_speaking = true; _speaking = true;
@ -43,9 +64,20 @@ class TtsService {
String get lastSpoken => _lastSpoken; String get lastSpoken => _lastSpoken;
bool get isSpeaking => _speaking; bool get isSpeaking => _speaking;
Future<void> setLanguage(String lang) async => _tts.setLanguage(lang); Future<void> setLanguage(String lang) async {
Future<void> setPitch(double pitch) async => _tts.setPitch(pitch); await init(language: lang);
Future<void> setRate(double rate) async => _tts.setSpeechRate(rate); await _tts.setLanguage(lang);
}
Future<void> setPitch(double pitch) async {
await init();
await _tts.setPitch(pitch);
}
Future<void> setRate(double rate) async {
await init();
await _tts.setSpeechRate(rate);
}
void repeatLast() { void repeatLast() {
if (_lastSpoken.isNotEmpty) speak(_lastSpoken); if (_lastSpoken.isNotEmpty) speak(_lastSpoken);
@ -58,4 +90,4 @@ class TtsService {
_lastSpoken = text; _lastSpoken = text;
_tts.speak(text); _tts.speak(text);
} }
} }

View File

@ -1,15 +1,28 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class AppColors { class AppColors {
static const primary = Color(0xFF2563EB); static const primaryBlue = Color(0xFF4A90D9);
static const primaryDark = Color(0xFF0F3EA8); static const softBlueBg = Color(0xFFEBF4FF);
static const accent = Color(0xFF0891B2); 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 warning = Color(0xFFD97706);
static const danger = Color(0xFFDC2626); static const danger = Color(0xFFDC2626);
static const success = Color(0xFF059669); static const success = Color(0xFF059669);
static const surface = Color(0xFFF7FAFC); static const surface = softBlueBg;
static const surfaceRaised = Color(0xFFFFFFFF); static const surfaceRaised = cardWhite;
static const text = Color(0xFF0F172A); static const text = textDark;
static const muted = Color(0xFF64748B); static const muted = textMuted;
static const border = Color(0xFFE2E8F0); static const border = Color(0xFFE2E8F0);
} }

View File

@ -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,
);
}

View File

@ -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),
);
}

View File

@ -8,6 +8,9 @@ import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/theme/app_colors.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<ApiClient>().dio; Dio get _api => sl<ApiClient>().dio;
@ -103,91 +106,114 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return DecoratedBox(
child: Padding( decoration: const BoxDecoration(
padding: const EdgeInsets.all(16), gradient: LinearGradient(
child: Column( colors: [AppColors.softBlueBg, Colors.white],
crossAxisAlignment: CrossAxisAlignment.start, begin: Alignment.topCenter,
children: [ end: Alignment.bottomCenter,
// Header ),
Row( ),
child: SafeArea(
child: FadeSlideWrapper(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Expanded( // Header
child: Column( Row(
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Expanded(
Text( child: Column(
'Activity Log', crossAxisAlignment: CrossAxisAlignment.start,
style: Theme.of(context) children: [
.textTheme Text(
.headlineSmall 'Activity Log',
?.copyWith(fontWeight: FontWeight.w800), style: AppTextStyles.heading,
),
Text(
'${_items.length} aktivitas tercatat',
style: const TextStyle(color: AppColors.muted),
),
],
), ),
Text( ),
'${_items.length} aktivitas tercatat', IconButton(
style: const TextStyle(color: AppColors.muted), 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( const SizedBox(height: 16),
onPressed: _load,
icon: const Icon(Icons.refresh), // Body
tooltip: 'Refresh', 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) { Widget build(BuildContext context) {
final meta = _logMeta(item.logType); final meta = _logMeta(item.logType);
return Padding( return Padding(
padding: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.only(bottom: 10),
child: Row( child: Container(
crossAxisAlignment: CrossAxisAlignment.start, padding: const EdgeInsets.all(14),
children: [ decoration: BoxDecoration(
// Timeline dot + line color: AppColors.cardWhite,
Column( borderRadius: AppDecorations.cardRadius,
children: [ border: Border.all(color: AppColors.border),
Container( boxShadow: AppDecorations.cardShadow,
width: 36, ),
height: 36, child: Row(
decoration: BoxDecoration( crossAxisAlignment: CrossAxisAlignment.start,
color: meta.color.withValues(alpha: 0.12), children: [
shape: BoxShape.circle, // 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( const SizedBox(width: 12),
width: 1.5, // Content
height: 20, Expanded(
color: const Color(0xFFE2E8F0), child: Padding(
), padding: const EdgeInsets.only(top: 4),
], child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
const SizedBox(width: 12), children: [
// Content Row(
Expanded( children: [
child: Padding( Expanded(
padding: const EdgeInsets.only(top: 4), child: Text(
child: Column( meta.label,
crossAxisAlignment: CrossAxisAlignment.start, style: TextStyle(
children: [ fontWeight: FontWeight.w700,
Row( color: meta.color,
children: [ fontSize: 13,
Expanded( ),
child: Text(
meta.label,
style: TextStyle(
fontWeight: FontWeight.w700,
color: meta.color,
fontSize: 13,
), ),
), ),
), Text(
Text( _formatTime(item.createdAt),
_formatTime(item.createdAt), style: const TextStyle(
style: const TextStyle( color: AppColors.muted, fontSize: 11),
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),
),
), ),
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
child: Column( child: Container(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.all(24),
children: [ decoration: const BoxDecoration(
const Icon(Icons.wifi_off, size: 48, color: AppColors.muted), color: AppColors.cardWhite,
const SizedBox(height: 12), borderRadius: AppDecorations.cardRadius,
Text(message, boxShadow: AppDecorations.cardShadow,
textAlign: TextAlign.center, ),
style: const TextStyle(color: AppColors.muted)), child: Column(
const SizedBox(height: 16), mainAxisSize: MainAxisSize.min,
FilledButton.icon( children: [
onPressed: onRetry, const Icon(Icons.wifi_off, size: 48, color: AppColors.muted),
icon: const Icon(Icons.refresh), const SizedBox(height: 12),
label: const Text('Coba lagi'), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
child: Column( child: Container(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.all(24),
children: [ decoration: const BoxDecoration(
const Icon(Icons.history, size: 64, color: AppColors.muted), color: AppColors.cardWhite,
const SizedBox(height: 12), borderRadius: AppDecorations.cardRadius,
Text( boxShadow: AppDecorations.cardShadow,
filter == 'ALL' ),
? 'Belum ada aktivitas' child: Column(
: 'Tidak ada aktivitas "$filter"', mainAxisSize: MainAxisSize.min,
style: const TextStyle( children: [
fontSize: 16, const Icon(Icons.history, size: 64, color: AppColors.muted),
fontWeight: FontWeight.w600, const SizedBox(height: 12),
color: AppColors.muted), Text(
), filter == 'ALL'
], ? 'Belum ada aktivitas'
: 'Tidak ada aktivitas "$filter"',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.muted),
),
],
),
), ),
); );
} }

View File

@ -9,8 +9,11 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/ai/detection_export.dart'; import '../../core/ai/detection_export.dart';
import '../../core/constants/app_constants.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/services/tts_service.dart';
import '../../core/utils/operation_guard.dart'; import '../../core/utils/operation_guard.dart';
import '../../shared/widgets/animations/animations.dart';
import '../../shared/widgets/feature_page.dart'; import '../../shared/widgets/feature_page.dart';
class AiBenchmarkScreen extends StatefulWidget { class AiBenchmarkScreen extends StatefulWidget {
@ -116,18 +119,22 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
CameraController? controller; CameraController? controller;
await guarded<void>( await guarded<void>(
() async { () async {
final cameras = final cameras =
await availableCameras().timeout(const Duration(seconds: 3)); await availableCameras().timeout(const Duration(seconds: 3));
if (cameras.isNotEmpty) { if (cameras.isNotEmpty) {
final activeController = CameraController( final activeController = CameraController(
cameras.first, cameras.first,
ResolutionPreset.low, ResolutionPreset.low,
enableAudio: false, enableAudio: false,
); );
controller = activeController; controller = activeController;
await activeController.initialize().timeout(const Duration(seconds: 5)); await activeController
await activeController.takePicture().timeout(const Duration(seconds: 5)); .initialize()
} .timeout(const Duration(seconds: 5));
await activeController
.takePicture()
.timeout(const Duration(seconds: 5));
}
}, },
onError: (_) {}, onError: (_) {},
); );
@ -198,7 +205,11 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
label: const Text('Clear log'), label: const Text('Clear log'),
), ),
const SizedBox(height: 16), 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) if (_runs.isEmpty)
const FeatureEmptyPanel( const FeatureEmptyPanel(
icon: Icons.speed, icon: Icons.speed,
@ -224,9 +235,10 @@ class _BenchmarkCard extends StatelessWidget {
margin: const EdgeInsets.only(bottom: 10), margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.cardWhite,
borderRadius: BorderRadius.circular(16), borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFE2E8F0)), border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -262,7 +274,8 @@ class _StatusBox extends StatelessWidget {
return DecoratedBox( return DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2), color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(14), borderRadius: AppDecorations.cardRadius,
boxShadow: AppDecorations.cardShadow,
), ),
child: Padding( child: Padding(
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
@ -279,17 +292,17 @@ class _StatusBox extends StatelessWidget {
Future<List<String>> _discoverTfliteModels() async { Future<List<String>> _discoverTfliteModels() async {
return await guarded<List<String>>( return await guarded<List<String>>(
() async { () async {
final manifestRaw = await rootBundle.loadString('AssetManifest.json'); final manifestRaw = await rootBundle.loadString('AssetManifest.json');
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>; final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
final models = manifest.keys final models = manifest.keys
.where((key) => .where((key) =>
key.startsWith('assets/models/') && key.endsWith('.tflite')) key.startsWith('assets/models/') && key.endsWith('.tflite'))
.toList() .toList()
..sort(); ..sort();
return models; return models;
}, },
) ?? ) ??
const []; const [];
} }

View File

@ -19,6 +19,9 @@ import '../../core/services/offline_queue_service.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
import '../../core/services/websocket_service.dart'; import '../../core/services/websocket_service.dart';
import '../../core/storage/secure_storage.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 // LoginScreen
@ -79,7 +82,8 @@ class _LoginScreenState extends State<LoginScreen> {
}, },
onError: (message) => _snack(context, message), onError: (message) => _snack(context, message),
fallback: 'Login gagal. Periksa email dan password kamu.', 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); if (mounted) setState(() => _loading = false);
} }
@ -150,37 +154,25 @@ class _AuthFrame extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFEAF4FF), backgroundColor: AppColors.softBlueBg,
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final compact = final compact =
constraints.maxWidth < 480 || constraints.maxHeight < 720; constraints.maxWidth < 480 || constraints.maxHeight < 720;
return Stack( return DecoratedBox(
children: [ decoration: const BoxDecoration(
const Positioned.fill( gradient: LinearGradient(
child: DecoratedBox( begin: Alignment.topCenter,
decoration: BoxDecoration( end: Alignment.bottomCenter,
gradient: LinearGradient( colors: [
begin: Alignment.topLeft, AppColors.softBlueBg,
end: Alignment.bottomRight, Colors.white,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)], AppColors.softPinkBg,
), ],
),
),
), ),
Positioned( ),
top: compact ? -70 : -90, child: SafeArea(
right: compact ? -70 : -60, child: Center(
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: SingleChildScrollView( child: SingleChildScrollView(
keyboardDismissBehavior: keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag, ScrollViewKeyboardDismissBehavior.onDrag,
@ -206,19 +198,10 @@ class _AuthFrame extends StatelessWidget {
child: RepaintBoundary( child: RepaintBoundary(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96), color: AppColors.cardWhite,
borderRadius: borderRadius:
BorderRadius.circular(compact ? 22 : 30), BorderRadius.circular(compact ? 22 : 28),
border: Border.all( boxShadow: AppDecorations.cardShadow,
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),
),
],
), ),
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
@ -236,13 +219,15 @@ class _AuthFrame extends StatelessWidget {
width: compact ? 44 : 56, width: compact ? 44 : 56,
height: compact ? 44 : 56, height: compact ? 44 : 56,
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: const LinearGradient( gradient: AppDecorations.blueGradient,
colors: [
Color(0xFF2563EB),
Color(0xFF0891B2)
],
),
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x334A90D9),
blurRadius: 18,
offset: Offset(0, 8),
),
],
), ),
child: Icon(Icons.navigation_rounded, child: Icon(Icons.navigation_rounded,
color: Colors.white, color: Colors.white,
@ -256,8 +241,8 @@ class _AuthFrame extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w800,
color: Color(0xFF0F172A), color: AppColors.textDark,
), ),
), ),
), ),
@ -269,21 +254,22 @@ class _AuthFrame extends StatelessWidget {
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 6), horizontal: 10, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFEFF6FF), color: AppColors.softBlueBg,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
child: const Row( child: const Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(Icons.shield_outlined, Icon(Icons.shield_outlined,
size: 14, color: Color(0xFF1D4ED8)), size: 14,
color: AppColors.primaryBlue),
SizedBox(width: 6), SizedBox(width: 6),
Text( Text(
'Secure Assistive Navigation', 'Secure Assistive Navigation',
style: TextStyle( style: TextStyle(
color: Color(0xFF1D4ED8), color: AppColors.primaryBlue,
fontSize: 11, fontSize: 11,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w700,
), ),
), ),
], ],
@ -294,14 +280,10 @@ class _AuthFrame extends StatelessWidget {
title, title,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context) style: AppTextStyles.heading.copyWith(
.textTheme fontSize: compact ? 26 : null,
.headlineMedium fontWeight: FontWeight.w800,
?.copyWith( ),
fontSize: compact ? 26 : null,
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
@ -309,7 +291,7 @@ class _AuthFrame extends StatelessWidget {
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: Color(0xFF64748B), color: AppColors.muted,
height: 1.35, height: 1.35,
), ),
), ),
@ -324,7 +306,7 @@ class _AuthFrame extends StatelessWidget {
), ),
), ),
), ),
], ),
); );
}, },
), ),

View File

@ -7,6 +7,10 @@ import 'package:shared_preferences/shared_preferences.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.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 // RegisterScreen
@ -69,7 +73,8 @@ class _RegisterScreenState extends State<RegisterScreen> {
}, },
onError: (message) => _snack(context, message), onError: (message) => _snack(context, message),
fallback: 'Registrasi gagal. Periksa data akun kamu.', 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); if (mounted) setState(() => _loading = false);
} }
@ -128,7 +133,7 @@ class _RegisterScreenState extends State<RegisterScreen> {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _role == 'USER' color: _role == 'USER'
? const Color(0xFFEFF6FF) ? AppColors.softBlueBg
: const Color(0xFFF0FDF4), : const Color(0xFFF0FDF4),
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
), ),
@ -234,18 +239,19 @@ class _RoleCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return BounceTap(
onTap: onTap, onTap: onTap,
child: AnimatedContainer( child: AnimatedContainer(
duration: const Duration(milliseconds: 180), duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected ? const Color(0xFFEFF6FF) : Colors.white, color: selected ? AppColors.softBlueBg : Colors.white,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(20),
border: Border.all( border: Border.all(
color: selected ? const Color(0xFF1A56DB) : const Color(0xFFE2E8F0), color: selected ? AppColors.primaryBlue : AppColors.border,
width: selected ? 2 : 1, width: selected ? 2 : 1,
), ),
boxShadow: selected ? AppDecorations.cardShadow : null,
), ),
child: Row( child: Row(
children: [ children: [
@ -253,10 +259,9 @@ class _RoleCard extends StatelessWidget {
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: selected color:
? const Color(0xFF1A56DB) selected ? AppColors.primaryBlue : const Color(0xFFF1F5F9),
: const Color(0xFFF1F5F9), borderRadius: BorderRadius.circular(50),
borderRadius: BorderRadius.circular(12),
), ),
child: Icon(icon, child: Icon(icon,
color: selected ? Colors.white : const Color(0xFF64748B)), color: selected ? Colors.white : const Color(0xFF64748B)),
@ -267,16 +272,16 @@ class _RoleCard extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, Text(title,
style: const TextStyle( style: AppTextStyles.subheading.copyWith(fontSize: 16)),
fontWeight: FontWeight.w800, fontSize: 16)),
Text(subtitle, Text(subtitle,
style: const TextStyle( style: const TextStyle(
color: Color(0xFF64748B), fontSize: 13)), color: AppColors.muted, fontSize: 13)),
], ],
), ),
), ),
if (selected) 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFEAF4FF), backgroundColor: AppColors.softBlueBg,
body: LayoutBuilder( body: LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final compact = final compact =
constraints.maxWidth < 480 || constraints.maxHeight < 720; constraints.maxWidth < 480 || constraints.maxHeight < 720;
return Stack( return DecoratedBox(
children: [ decoration: const BoxDecoration(
const Positioned.fill( gradient: LinearGradient(
child: DecoratedBox( begin: Alignment.topCenter,
decoration: BoxDecoration( end: Alignment.bottomCenter,
gradient: LinearGradient( colors: [
begin: Alignment.topLeft, AppColors.softBlueBg,
end: Alignment.bottomRight, Colors.white,
colors: [Color(0xFFD9EEFF), Color(0xFFF7FAFC)], AppColors.softPinkBg,
), ],
),
),
), ),
Positioned( ),
top: compact ? -70 : -90, child: SafeArea(
right: compact ? -70 : -60, child: Center(
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: SingleChildScrollView( child: SingleChildScrollView(
keyboardDismissBehavior: keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag, ScrollViewKeyboardDismissBehavior.onDrag,
@ -354,19 +347,10 @@ class _AuthFrame extends StatelessWidget {
child: RepaintBoundary( child: RepaintBoundary(
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96), color: AppColors.cardWhite,
borderRadius: borderRadius:
BorderRadius.circular(compact ? 22 : 30), BorderRadius.circular(compact ? 22 : 28),
border: Border.all( boxShadow: AppDecorations.cardShadow,
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),
),
],
), ),
child: Padding( child: Padding(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
@ -384,8 +368,15 @@ class _AuthFrame extends StatelessWidget {
width: compact ? 44 : 56, width: compact ? 44 : 56,
height: compact ? 44 : 56, height: compact ? 44 : 56,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1D4ED8), gradient: AppDecorations.blueGradient,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x334A90D9),
blurRadius: 18,
offset: Offset(0, 8),
),
],
), ),
child: Icon(Icons.navigation_rounded, child: Icon(Icons.navigation_rounded,
color: Colors.white, color: Colors.white,
@ -399,8 +390,8 @@ class _AuthFrame extends StatelessWidget {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: TextStyle( style: TextStyle(
fontSize: 18, fontSize: 18,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w800,
color: Color(0xFF0F172A), color: AppColors.textDark,
), ),
), ),
), ),
@ -411,14 +402,10 @@ class _AuthFrame extends StatelessWidget {
title, title,
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context) style: AppTextStyles.heading.copyWith(
.textTheme fontSize: compact ? 26 : null,
.headlineMedium fontWeight: FontWeight.w800,
?.copyWith( ),
fontSize: compact ? 26 : null,
fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A),
),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
Text( Text(
@ -426,7 +413,7 @@ class _AuthFrame extends StatelessWidget {
maxLines: 2, maxLines: 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: const TextStyle(
color: Color(0xFF64748B), color: AppColors.muted,
height: 1.35, height: 1.35,
), ),
), ),
@ -441,7 +428,7 @@ class _AuthFrame extends StatelessWidget {
), ),
), ),
), ),
], ),
); );
}, },
), ),

View File

@ -13,6 +13,7 @@ import '../../core/services/incoming_call_polling_service.dart';
import '../../core/services/offline_queue_service.dart'; import '../../core/services/offline_queue_service.dart';
import '../../core/services/websocket_service.dart'; import '../../core/services/websocket_service.dart';
import '../../core/storage/secure_storage.dart'; import '../../core/storage/secure_storage.dart';
import '../../shared/widgets/walkguide_loading_screen.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// SplashScreen // SplashScreen
@ -39,32 +40,35 @@ class SplashScreen extends StatefulWidget {
class _SplashScreenState extends State<SplashScreen> class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin { with SingleTickerProviderStateMixin {
late final AnimationController _animCtrl; late final AnimationController _screenCtrl;
late final Animation<double> _fadeAnim; late final Animation<double> _screenFade;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_animCtrl = AnimationController( _screenCtrl = AnimationController(
vsync: this, 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(); _route();
} }
@override @override
void dispose() { void dispose() {
_animCtrl.dispose(); _screenCtrl.dispose();
super.dispose(); super.dispose();
} }
Future<void> _route() async { Future<void> _route() async {
final routed = await runFriendlyAction( final routed = await runFriendlyAction(
() async { () async {
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash. // Animasi logo selalu tampil minimal 900ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 500)); await Future.delayed(const Duration(milliseconds: 900));
final storage = sl<SecureStorage>(); final storage = sl<SecureStorage>();
final token = await storage.getAccessToken().timeout( final token = await storage.getAccessToken().timeout(
@ -77,7 +81,7 @@ class _SplashScreenState extends State<SplashScreen>
if (!mounted) return; if (!mounted) return;
if (token == null || role == null) { if (token == null || role == null) {
context.go('/login'); await _fadeOutThenGo('/login');
return; return;
} }
@ -88,67 +92,28 @@ class _SplashScreenState extends State<SplashScreen>
sl<IncomingCallPollingService>().start(); sl<IncomingCallPollingService>().start();
} }
// Auto-login: arahkan ke home sesuai role. // Auto-login: arahkan ke home sesuai role.
context.go( await _fadeOutThenGo(
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide', role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide',
); );
}, },
onError: (_) {}, onError: (_) {},
fallback: 'Sesi belum bisa dipulihkan.', fallback: 'Sesi belum bisa dipulihkan.',
); );
if (!routed && mounted) context.go('/login'); if (!routed && mounted) await _fadeOutThenGo('/login');
}
Future<void> _fadeOutThenGo(String route) async {
if (!mounted) return;
await _screenCtrl.reverse();
if (mounted) context.go(route);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return FadeTransition(
backgroundColor: const Color(0xFF1A56DB), opacity: _screenFade,
body: Center( child: const WalkGuideLoadingScreen(
child: FadeTransition( subtitle: 'Restoring your session',
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,
),
),
],
),
),
), ),
); );
} }

View File

@ -10,8 +10,11 @@ import '../../core/services/call_service.dart';
import '../../core/services/haptic_service.dart'; import '../../core/services/haptic_service.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
import '../../core/storage/secure_storage.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 _kGreen = Color(0xFF16A34A);
const _kRed = Color(0xFFDC2626); const _kRed = Color(0xFFDC2626);
const _kMuted = Color(0xFF64748B); const _kMuted = Color(0xFF64748B);
@ -588,30 +591,42 @@ class _CallScaffold extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: _kBg, backgroundColor: _kBg,
body: SafeArea( body: DecoratedBox(
child: Column( decoration: const BoxDecoration(
children: [ gradient: LinearGradient(
Padding( colors: [_kBg, Color(0xFF172554)],
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), begin: Alignment.topCenter,
child: Row( end: Alignment.bottomCenter,
children: [ ),
const SizedBox(width: 48), ),
Expanded( child: SafeArea(
child: Text( child: FadeSlideWrapper(
title, child: Column(
textAlign: TextAlign.center, children: [
style: const TextStyle( Padding(
color: Colors.white70, padding:
fontWeight: FontWeight.w600, 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( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: color.withValues(alpha: 0.2), 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), child: Icon(icon, color: Colors.white, size: 56),
); );
@ -688,7 +704,7 @@ class _ControlButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return BounceTap(
onTap: onTap, onTap: onTap,
child: Column( child: Column(
children: [ children: [
@ -718,7 +734,7 @@ class _EndCallButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return BounceTap(
onTap: onTap, onTap: onTap,
child: Column( child: Column(
children: [ children: [
@ -754,7 +770,7 @@ class _RoundCallButton extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return BounceTap(
onTap: onTap, onTap: onTap,
child: Opacity( child: Opacity(
opacity: onTap == null ? 0.4 : 1, opacity: onTap == null ? 0.4 : 1,

View File

@ -8,6 +8,9 @@ import 'package:latlong2/latlong.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.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'; import '../../shared/widgets/feature_page.dart';
class GuardianMapScreen extends StatefulWidget { class GuardianMapScreen extends StatefulWidget {
@ -106,8 +109,9 @@ class _GuardianMapCard extends StatelessWidget {
final center = _pointFrom(location) ?? final center = _pointFrom(location) ??
(points.isNotEmpty ? points.first : null) ?? (points.isNotEmpty ? points.first : null) ??
const LatLng(-7.2575, 112.7521); const LatLng(-7.2575, 112.7521);
return ClipRRect( return Container(
borderRadius: BorderRadius.circular(20), decoration: AppDecorations.card,
clipBehavior: Clip.antiAlias,
child: FlutterMap( child: FlutterMap(
options: MapOptions(initialCenter: center, initialZoom: 16), options: MapOptions(initialCenter: center, initialZoom: 16),
children: [ children: [
@ -121,7 +125,7 @@ class _GuardianMapCard extends StatelessWidget {
Polyline( Polyline(
points: points, points: points,
strokeWidth: 4, strokeWidth: 4,
color: const Color(0xFF2563EB), color: AppColors.primaryBlue,
), ),
], ],
), ),
@ -171,10 +175,18 @@ class _TimelineList extends StatelessWidget {
), ),
); );
} }
return ListView.separated( return ListView(
itemCount: segments.length, children: [
separatorBuilder: (_, __) => const SizedBox(height: 10), StaggerWrapper(
itemBuilder: (_, index) => _TimelineCard(segment: segments[index]), 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( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: AppColors.cardWhite,
borderRadius: BorderRadius.circular(16), borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFE2E8F0)), border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
), ),
child: Row( child: Row(
children: [ children: [
@ -199,10 +212,10 @@ class _TimelineCard extends StatelessWidget {
width: 42, width: 42,
height: 42, height: 42,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFEFF6FF), color: AppColors.softBlueBg,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(50),
), ),
child: Icon(segment.icon, color: const Color(0xFF1D4ED8)), child: Icon(segment.icon, color: AppColors.primaryBlue),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
Expanded( Expanded(

View File

@ -8,6 +8,9 @@ import 'package:record/record.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.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'; import '../../shared/widgets/feature_page.dart';
class GuardianSendNotifScreen extends StatefulWidget { class GuardianSendNotifScreen extends StatefulWidget {
@ -132,132 +135,130 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
subtitle: 'Kirim pesan singkat ke User yang sudah pairing', subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
child: ListView( child: ListView(
children: [ children: [
Container( FadeSlideWrapper(
padding: const EdgeInsets.all(18), child: Container(
decoration: BoxDecoration( padding: const EdgeInsets.all(18),
color: Colors.white, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(20), color: AppColors.cardWhite,
border: Border.all(color: const Color(0xFFE2E8F0)), borderRadius: AppDecorations.cardRadius,
boxShadow: [ border: Border.all(color: AppColors.border),
BoxShadow( boxShadow: AppDecorations.cardShadow,
color: const Color(0xFF1E293B).withValues(alpha: 0.06), ),
blurRadius: 22, child: Column(
offset: const Offset(0, 12), crossAxisAlignment: CrossAxisAlignment.stretch,
), children: [
], SegmentedButton<bool>(
), segments: const [
child: Column( ButtonSegment(
crossAxisAlignment: CrossAxisAlignment.stretch, value: false,
children: [ icon: Icon(Icons.message_outlined),
SegmentedButton<bool>( label: Text('Text'),
segments: const [ ),
ButtonSegment( ButtonSegment(
value: false, value: true,
icon: Icon(Icons.message_outlined), icon: Icon(Icons.mic_none_outlined),
label: Text('Text'), 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, if (_voiceMode) ...[
icon: Icon(Icons.mic_none_outlined), const SizedBox(height: 14),
label: Text('Voice'), 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), const SizedBox(height: 14),
Container( FilledButton.icon(
padding: const EdgeInsets.all(14), onPressed: _loading ? null : _send,
decoration: BoxDecoration( icon: _loading
color: const Color(0xFFF8FAFC), ? const SizedBox(
borderRadius: BorderRadius.circular(16), width: 18,
border: Border.all(color: const Color(0xFFE2E8F0)), height: 18,
), child: CircularProgressIndicator(strokeWidth: 2),
child: Row( )
children: [ : const Icon(Icons.send),
CircleAvatar( label: Text(_loading
backgroundColor: _recording ? 'Sending...'
? const Color(0xFFFEE2E2) : _voiceMode
: const Color(0xFFEFF6FF), ? 'Send Voice Message'
child: Icon( : 'Send Message'),
_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),
),
),
],
),
), ),
], ],
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'),
),
],
), ),
), ),
], ],

View File

@ -9,6 +9,9 @@ import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.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 '../../core/storage/secure_storage.dart';
import '../../shared/widgets/feature_page.dart'; import '../../shared/widgets/feature_page.dart';
@ -49,23 +52,27 @@ class GuardianSettingsScreen extends StatelessWidget {
subtitle: 'Account, pairing, AI tools, and server', subtitle: 'Account, pairing, AI tools, and server',
child: ListView( child: ListView(
children: [ children: [
_SettingsTile( StaggerWrapper(
icon: Icons.link, children: [
title: 'Pair User', _SettingsTile(
subtitle: 'Masukkan Pairing Code User atau cek status pairing.', icon: Icons.link,
onTap: () => context.go('/guardian/pairing'), title: 'Pair User',
), subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
_SettingsTile( onTap: () => context.go('/guardian/pairing'),
icon: Icons.speed, ),
title: 'AI Benchmark', _SettingsTile(
subtitle: 'Catat capture, inference, notification, dan TTS.', icon: Icons.speed,
onTap: () => context.go('/guardian/benchmark'), title: 'AI Benchmark',
), subtitle: 'Catat capture, inference, notification, dan TTS.',
_SettingsTile( onTap: () => context.go('/guardian/benchmark'),
icon: Icons.tune, ),
title: 'AI Config', _SettingsTile(
subtitle: 'Atur threshold deteksi dan label yang aktif.', icon: Icons.tune,
onTap: () => context.go('/guardian/ai-config'), title: 'AI Config',
subtitle: 'Atur threshold deteksi dan label yang aktif.',
onTap: () => context.go('/guardian/ai-config'),
),
],
), ),
const SizedBox(height: 18), const SizedBox(height: 18),
OutlinedButton.icon( OutlinedButton.icon(
@ -103,19 +110,28 @@ class _SettingsTile extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return BounceTap(
margin: const EdgeInsets.only(bottom: 10), onTap: onTap,
decoration: BoxDecoration( child: Container(
color: Colors.white, margin: const EdgeInsets.only(bottom: 10),
borderRadius: BorderRadius.circular(16), decoration: BoxDecoration(
border: Border.all(color: const Color(0xFFE2E8F0)), color: AppColors.cardWhite,
), borderRadius: AppDecorations.cardRadius,
child: ListTile( border: Border.all(color: AppColors.border),
leading: Icon(icon, color: const Color(0xFF1D4ED8)), boxShadow: AppDecorations.cardShadow,
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)), ),
subtitle: Text(subtitle), child: ListTile(
trailing: const Icon(Icons.chevron_right), leading: Container(
onTap: onTap, 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),
),
), ),
); );
} }

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_text_styles.dart';
class HomeScreen extends StatelessWidget { class HomeScreen extends StatelessWidget {
final String role; final String role;
@ -7,14 +10,26 @@ class HomeScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return DecoratedBox(
appBar: AppBar(title: const Text('Dashboard Walk Guide')), decoration: const BoxDecoration(
body: Center( gradient: LinearGradient(
child: Text( colors: [AppColors.softBlueBg, Colors.white],
role == 'ROLE_ADMIN' ? 'Selamat Datang Admin!' : 'Mode Walk Guide Siap!', begin: Alignment.topCenter,
style: const TextStyle(fontSize: 24), 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,
),
), ),
), ),
); );
} }
} }

View File

@ -14,7 +14,10 @@ import '../../../core/network/api_client.dart';
import '../../../core/services/websocket_service.dart'; import '../../../core/services/websocket_service.dart';
import '../../../core/services/incoming_call_polling_service.dart'; import '../../../core/services/incoming_call_polling_service.dart';
import '../../../core/storage/secure_storage.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 '../../../core/utils/operation_guard.dart';
import '../../../shared/widgets/animations/animations.dart';
// âââ¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ // âââ¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬ÃƒÂ¢Ã¢â¬ÂÃ¢âšÂ¬
// GUARDIAN DASHBOARD SCREEN // GUARDIAN DASHBOARD SCREEN
@ -382,7 +385,7 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF0F4FF), backgroundColor: AppColors.softBlueBg,
body: SafeArea( body: SafeArea(
child: Column( child: Column(
children: [ children: [
@ -398,24 +401,26 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
child: SingleChildScrollView( child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
child: Column( child: FadeSlideWrapper(
crossAxisAlignment: CrossAxisAlignment.start, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
_buildGreetingRow(), children: [
const SizedBox(height: 14), _buildGreetingRow(),
if (_sosAlert || _pendingSos.isNotEmpty) ...[
_buildSosBanner(),
const SizedBox(height: 14), 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), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: highlight ? const Color(0xFFFFF1F2) : Colors.white, color: highlight ? const Color(0xFFFFF1F2) : Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: AppDecorations.cardRadius,
border: Border.all( border: Border.all(
color: highlight ? const Color(0xFFFECACA) : const Color(0xFFE2E8F0), color: highlight ? const Color(0xFFFECACA) : const Color(0xFFE2E8F0),
width: 1, width: 1,
), ),
boxShadow: [ boxShadow: const [
BoxShadow(color: Colors.black.withValues(alpha: 0.03), blurRadius: 8), BoxShadow(
color: Color(0x14000000),
blurRadius: 20,
offset: Offset(0, 4),
),
], ],
), ),
child: Column( child: Column(
@ -1745,53 +1754,50 @@ class _QuickActionCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return BounceTap(
color: Colors.white, onTap: item.onTap,
borderRadius: BorderRadius.circular(12), child: Container(
child: InkWell( padding: const EdgeInsets.all(10),
onTap: item.onTap, decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12), color: AppColors.cardWhite,
child: Container( borderRadius: AppDecorations.cardRadius,
padding: const EdgeInsets.all(10), border: Border.all(color: const Color(0xFFE2E8F0)),
decoration: BoxDecoration( boxShadow: AppDecorations.cardShadow,
borderRadius: BorderRadius.circular(12), ),
border: Border.all(color: const Color(0xFFE2E8F0)), child: Column(
), crossAxisAlignment: CrossAxisAlignment.start,
child: Column( mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.start, children: [
mainAxisAlignment: MainAxisAlignment.center, Container(
children: [ width: 28,
Container( height: 28,
width: 28, decoration: BoxDecoration(
height: 28, color: item.color.withValues(alpha: 0.1),
decoration: BoxDecoration( borderRadius: BorderRadius.circular(7),
color: item.color.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(7),
),
child: Icon(item.icon, size: 15, color: item.color),
), ),
const SizedBox(height: 6), child: Icon(item.icon, size: 15, color: item.color),
Text( ),
item.label, const SizedBox(height: 6),
style: GoogleFonts.inter( Text(
fontSize: 11, item.label,
fontWeight: FontWeight.w700, style: GoogleFonts.inter(
color: const Color(0xFF0F172A), fontSize: 11,
), fontWeight: FontWeight.w700,
maxLines: 1, color: const Color(0xFF0F172A),
overflow: TextOverflow.ellipsis,
), ),
Text( maxLines: 1,
item.sub, overflow: TextOverflow.ellipsis,
style: GoogleFonts.inter( ),
fontSize: 10, Text(
color: const Color(0xFF94A3B8), item.sub,
), style: GoogleFonts.inter(
maxLines: 1, fontSize: 10,
overflow: TextOverflow.ellipsis, color: const Color(0xFF94A3B8),
), ),
], maxLines: 1,
), overflow: TextOverflow.ellipsis,
),
],
), ),
), ),
); );

View File

@ -1,6 +1,10 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/services/voice_command_handler.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 { class ManualScreen extends StatelessWidget {
const ManualScreen({super.key}); const ManualScreen({super.key});
@ -8,16 +12,38 @@ class ManualScreen extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final commands = VoiceCommandKey.values.map((key) => key.name).toList(); final commands = VoiceCommandKey.values.map((key) => key.name).toList();
return Scaffold( return FeaturePage(
appBar: AppBar(title: const Text('Manual')), title: 'Manual',
body: ListView.separated( subtitle: 'Voice command yang tersedia',
padding: const EdgeInsets.all(16), child: ListView(
itemCount: commands.length, children: [
separatorBuilder: (_, __) => const Divider(height: 1), StaggerWrapper(
itemBuilder: (context, index) => ListTile( children: [
leading: const Icon(Icons.record_voice_over), for (final command in commands)
title: Text(commands[index]), 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),
),
),
],
),
],
), ),
); );
} }

View File

@ -16,7 +16,11 @@ import 'package:latlong2/latlong.dart';
import '../../app/injection_container.dart'; import '../../app/injection_container.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/services/tts_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 '../../core/utils/operation_guard.dart'; import '../../core/utils/operation_guard.dart';
import '../../shared/widgets/animations/animations.dart';
// helpers // helpers
@ -498,7 +502,7 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
Polyline( Polyline(
points: _navState.routePoints, points: _navState.routePoints,
strokeWidth: 5, strokeWidth: 5,
color: const Color(0xFF1A56DB), color: AppColors.primaryBlue,
), ),
], ],
), ),
@ -536,8 +540,8 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: i == _navState.currentStepIndex color: i == _navState.currentStepIndex
? const Color(0xFF1A56DB) ? AppColors.primaryBlue
: const Color(0xFF93C5FD), : AppColors.gradientBlueStart,
border: Border.all(color: Colors.white, width: 2), border: Border.all(color: Colors.white, width: 2),
), ),
), ),
@ -552,26 +556,28 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
top: 12, top: 12,
left: 12, left: 12,
right: 12, right: 12,
child: Column( child: FadeSlideWrapper(
children: [ child: Column(
// search bar children: [
_SearchBar( // search bar
controller: _searchCtrl, _SearchBar(
focusNode: _searchFocus, controller: _searchCtrl,
loading: _searchLoading, focusNode: _searchFocus,
onChanged: _onSearchChanged, loading: _searchLoading,
onClear: () { onChanged: _onSearchChanged,
_searchCtrl.clear(); onClear: () {
setState(() => _showSuggestions = false); _searchCtrl.clear();
}, setState(() => _showSuggestions = false);
), },
// suggestions dropdown
if (_showSuggestions)
_SuggestionList(
items: _suggestions,
onSelect: _selectPlace,
), ),
], // suggestions dropdown
if (_showSuggestions)
_SuggestionList(
items: _suggestions,
onSelect: _selectPlace,
),
],
),
), ),
), ),
@ -610,7 +616,7 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
child: FloatingActionButton.small( child: FloatingActionButton.small(
heroTag: 'nav_center', heroTag: 'nav_center',
backgroundColor: Colors.white, backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1A56DB), foregroundColor: AppColors.primaryBlue,
onPressed: _centerOnUser, onPressed: _centerOnUser,
child: const Icon(Icons.my_location), child: const Icon(Icons.my_location),
), ),
@ -621,7 +627,13 @@ class _NavigationModeScreenState extends State<NavigationModeScreen> {
Positioned.fill( Positioned.fill(
child: Container( child: Container(
color: Colors.black26, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
elevation: 4, color: Colors.transparent,
borderRadius: BorderRadius.circular(14), child: Container(
child: TextField( decoration: AppDecorations.card,
controller: controller, child: TextField(
focusNode: focusNode, controller: controller,
onChanged: onChanged, focusNode: focusNode,
textInputAction: TextInputAction.search, onChanged: onChanged,
decoration: InputDecoration( textInputAction: TextInputAction.search,
hintText: 'Cari tujuan…', decoration: InputDecoration(
prefixIcon: const Icon(Icons.search, color: Color(0xFF1A56DB)), hintText: 'Cari tujuan…',
suffixIcon: loading prefixIcon: const Icon(Icons.search, color: AppColors.primaryBlue),
? const Padding( suffixIcon: loading
padding: EdgeInsets.all(12), ? const Padding(
child: SizedBox( padding: EdgeInsets.all(12),
width: 16, child: SizedBox(
height: 16, width: 16,
child: CircularProgressIndicator(strokeWidth: 2), height: 16,
), child: CircularProgressIndicator(strokeWidth: 2),
) ),
: controller.text.isNotEmpty )
? IconButton( : controller.text.isNotEmpty
icon: const Icon(Icons.close), ? IconButton(
onPressed: onClear, icon: const Icon(Icons.close),
) onPressed: onClear,
: null, )
border: OutlineInputBorder( : null,
borderRadius: BorderRadius.circular(14), border: const OutlineInputBorder(
borderSide: BorderSide.none, 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return Material(
elevation: 6, color: Colors.transparent,
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(14)),
child: ClipRRect( child: ClipRRect(
borderRadius: const BorderRadius.vertical(bottom: Radius.circular(14)), borderRadius: const BorderRadius.vertical(bottom: Radius.circular(20)),
child: Column( child: Container(
children: [ decoration: const BoxDecoration(
for (final place in items) color: AppColors.cardWhite,
InkWell( boxShadow: AppDecorations.cardShadow,
onTap: () => onSelect(place), ),
child: Padding( child: StaggerWrapper(
padding: children: [
const EdgeInsets.symmetric(horizontal: 16, vertical: 12), for (final place in items)
child: Row( BounceTap(
children: [ onTap: () => onSelect(place),
const Icon(Icons.place_outlined, child: Padding(
color: Color(0xFF64748B), size: 20), padding: const EdgeInsets.symmetric(
const SizedBox(width: 12), horizontal: 16, vertical: 12),
Expanded( child: Row(
child: Text( children: [
place.displayName, const Icon(Icons.place_outlined,
maxLines: 2, color: AppColors.textMuted, size: 20),
overflow: TextOverflow.ellipsis, const SizedBox(width: 12),
style: const TextStyle(fontSize: 14), 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) { Widget build(BuildContext context) {
return Container( return Container(
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: const BoxDecoration(
color: const Color(0xFF1A56DB), gradient: AppDecorations.blueGradient,
borderRadius: BorderRadius.circular(16), borderRadius: AppDecorations.cardRadius,
boxShadow: [ boxShadow: AppDecorations.cardShadow,
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
), ),
child: Row( child: Row(
children: [ children: [
@ -864,13 +877,8 @@ class _StatusBar extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _bgColor, color: _bgColor,
borderRadius: BorderRadius.circular(14), borderRadius: AppDecorations.cardRadius,
boxShadow: [ boxShadow: AppDecorations.cardShadow,
BoxShadow(
color: Colors.black.withValues(alpha: 0.1),
blurRadius: 10,
),
],
border: Border.all( border: Border.all(
color: phase == _NavPhase.error color: phase == _NavPhase.error
? const Color(0xFFFECACA) ? const Color(0xFFFECACA)
@ -954,11 +962,11 @@ class _PulsingDotState extends State<_PulsingDot>
height: 44, height: 44,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: const Color(0xFF1A56DB) color: AppColors.primaryBlue
.withValues(alpha: _opacity.value * 0.4), .withValues(alpha: _opacity.value * 0.4),
border: Border.all( border: Border.all(
color: color:
const Color(0xFF1A56DB).withValues(alpha: _opacity.value), AppColors.primaryBlue.withValues(alpha: _opacity.value),
width: 2, width: 2,
), ),
), ),
@ -970,11 +978,11 @@ class _PulsingDotState extends State<_PulsingDot>
height: 20, height: 20,
decoration: BoxDecoration( decoration: BoxDecoration(
shape: BoxShape.circle, shape: BoxShape.circle,
color: const Color(0xFF1A56DB), color: AppColors.primaryBlue,
border: Border.all(color: Colors.white, width: 3), border: Border.all(color: Colors.white, width: 3),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: const Color(0xFF1A56DB).withValues(alpha: 0.4), color: AppColors.primaryBlue.withValues(alpha: 0.4),
blurRadius: 6, blurRadius: 6,
), ),
], ],

View File

@ -13,6 +13,9 @@ import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
import '../../core/theme/app_colors.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 'application/notification_cubit.dart';
import 'domain/entities/guardian_notification.dart'; import 'domain/entities/guardian_notification.dart';
@ -124,84 +127,105 @@ class _NotificationScreenState extends State<NotificationScreen> {
builder: (context, state) { builder: (context, state) {
final items = state.items.map(_NotifItem.fromEntity).toList(); final items = state.items.map(_NotifItem.fromEntity).toList();
final unreadCount = items.where((n) => !n.isRead).length; final unreadCount = items.where((n) => !n.isRead).length;
return SafeArea( return DecoratedBox(
child: Padding( decoration: const BoxDecoration(
padding: const EdgeInsets.all(16), gradient: LinearGradient(
child: Column( colors: [AppColors.softBlueBg, Colors.white],
crossAxisAlignment: CrossAxisAlignment.start, begin: Alignment.topCenter,
children: [ end: Alignment.bottomCenter,
// 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',
),
],
), ),
const SizedBox(height: 16), ),
child: SafeArea(
// Body child: FadeSlideWrapper(
Expanded( child: Padding(
child: state.loading padding: const EdgeInsets.all(16),
? const Center(child: CircularProgressIndicator()) child: Column(
: state.error != null crossAxisAlignment: CrossAxisAlignment.start,
? _ErrorPanel(message: state.error!, onRetry: _load) children: [
: items.isEmpty // Header
? const _EmptyPanel() Row(
: RefreshIndicator( children: [
onRefresh: _load, Expanded(
child: ListView.separated( child: Column(
itemCount: items.length, crossAxisAlignment: CrossAxisAlignment.start,
separatorBuilder: (_, __) => children: [
const SizedBox(height: 10), Row(
itemBuilder: (ctx, i) => _NotifCard( children: [
notif: items[i], Text(
onMarkRead: () => _markRead(items[i].id), 'Notifications',
onReadAloud: () => _readAloud(items[i]), 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), duration: const Duration(milliseconds: 300),
decoration: BoxDecoration( decoration: BoxDecoration(
color: unread ? const Color(0xFFEFF6FF) : Colors.white, color: unread ? const Color(0xFFEFF6FF) : Colors.white,
borderRadius: BorderRadius.circular(14), borderRadius: AppDecorations.cardRadius,
border: Border.all( border: Border.all(
color: unread color: unread
? AppColors.primary.withValues(alpha: 0.3) ? AppColors.primary.withValues(alpha: 0.3)
@ -280,9 +304,9 @@ class _NotifCard extends StatelessWidget {
), ),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withValues(alpha: 0.04), color: Colors.black.withValues(alpha: 0.08),
blurRadius: 8, blurRadius: 20,
offset: const Offset(0, 2), offset: const Offset(0, 4),
), ),
], ],
), ),
@ -301,7 +325,7 @@ class _NotifCard extends StatelessWidget {
color: isVoice color: isVoice
? AppColors.success.withValues(alpha: 0.12) ? AppColors.success.withValues(alpha: 0.12)
: AppColors.primary.withValues(alpha: 0.12), : AppColors.primary.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(50),
), ),
child: Icon( child: Icon(
isVoice ? Icons.mic : Icons.message, isVoice ? Icons.mic : Icons.message,
@ -413,6 +437,7 @@ class _UnreadBadge extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primary, color: AppColors.primary,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
boxShadow: AppDecorations.cardShadow,
), ),
child: Text( child: Text(
'$count', '$count',
@ -431,21 +456,29 @@ class _ErrorPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
child: Column( child: Container(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.all(24),
children: [ decoration: const BoxDecoration(
const Icon(Icons.wifi_off, size: 48, color: AppColors.muted), color: AppColors.cardWhite,
const SizedBox(height: 12), borderRadius: AppDecorations.cardRadius,
Text(message, boxShadow: AppDecorations.cardShadow,
textAlign: TextAlign.center, ),
style: const TextStyle(color: AppColors.muted)), child: Column(
const SizedBox(height: 16), mainAxisSize: MainAxisSize.min,
FilledButton.icon( children: [
onPressed: onRetry, const Icon(Icons.wifi_off, size: 48, color: AppColors.muted),
icon: const Icon(Icons.refresh), const SizedBox(height: 12),
label: const Text('Coba lagi'), 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return const Center( return Center(
child: Column( child: Container(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.all(24),
children: [ decoration: const BoxDecoration(
Icon(Icons.notifications_none, size: 64, color: AppColors.muted), color: AppColors.cardWhite,
SizedBox(height: 12), borderRadius: AppDecorations.cardRadius,
Text('Belum ada notifikasi', boxShadow: AppDecorations.cardShadow,
style: TextStyle( ),
fontSize: 16, child: const Column(
fontWeight: FontWeight.w600, mainAxisSize: MainAxisSize.min,
color: AppColors.muted)), children: [
SizedBox(height: 4), Icon(Icons.notifications_none, size: 64, color: AppColors.muted),
Text('Guardian belum mengirim pesan.', SizedBox(height: 12),
style: TextStyle(color: AppColors.muted)), 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)),
],
),
), ),
); );
} }

View File

@ -8,6 +8,10 @@ import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/storage/secure_storage.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 // UserPairingScreen
@ -27,37 +31,11 @@ class UserPairingScreen extends StatefulWidget {
} }
class _UserPairingScreenState extends State<UserPairingScreen> { class _UserPairingScreenState extends State<UserPairingScreen> {
String? _uniqueId;
String? _pairingCode; String? _pairingCode;
DateTime? _pairingCodeExpiresAt; DateTime? _pairingCodeExpiresAt;
int? _pairingCodeSeconds; int? _pairingCodeSeconds;
bool _regenerating = false; bool _regenerating = false;
@override
void initState() {
super.initState();
_loadUniqueId();
}
Future<void> _loadUniqueId() async {
var value = await sl<SecureStorage>().getUniqueUserId();
if (value == null || value.isEmpty) {
await runFriendlyAction(
() async {
final res = await sl<ApiClient>()
.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<void> _regeneratePairingCode() async { Future<void> _regeneratePairingCode() async {
setState(() => _regenerating = true); setState(() => _regenerating = true);
await runFriendlyAction( await runFriendlyAction(
@ -90,7 +68,7 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
return _Page( return _Page(
title: 'Pairing', title: 'Pairing',
subtitle: 'Bagikan pairing code sementara ini ke Guardian.', subtitle: 'Bagikan pairing code sementara ini ke Guardian.',
child: Column( child: StaggerWrapper(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
if (_pairingCode == null || _pairingCode!.isEmpty) if (_pairingCode == null || _pairingCode!.isEmpty)
@ -118,11 +96,6 @@ class _UserPairingScreenState extends State<UserPairingScreen> {
: const Icon(Icons.autorenew), : const Icon(Icons.autorenew),
label: Text(_regenerating ? 'Generating...' : 'Generate New Code'), 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), const SizedBox(height: 16),
_PairingStatusCard(allowUserResponse: true), _PairingStatusCard(allowUserResponse: true),
], ],
@ -348,26 +321,20 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
final cardColor = _active final cardColor = _active
? const Color(0xFFF0FDF4) ? const Color(0xFFF0FDF4)
: pending : pending
? const Color(0xFFEFF6FF) ? AppColors.softBlueBg
: const Color(0xFFFFFBEB); : const Color(0xFFFFFBEB);
final accent = _active final accent = _active
? const Color(0xFF059669) ? const Color(0xFF059669)
: pending : pending
? const Color(0xFF2563EB) ? AppColors.primaryBlue
: const Color(0xFFD97706); : const Color(0xFFD97706);
return Container( return Container(
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(18),
decoration: BoxDecoration( decoration: BoxDecoration(
color: cardColor, color: cardColor,
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(20),
border: Border.all(color: accent.withValues(alpha: 0.28)), border: Border.all(color: accent.withValues(alpha: 0.28)),
boxShadow: [ boxShadow: AppDecorations.cardShadow,
BoxShadow(
color: accent.withValues(alpha: 0.10),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
), ),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
@ -393,7 +360,7 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
Expanded( Expanded(
child: Text(_status, child: Text(_status,
style: const TextStyle( style: const TextStyle(
color: Color(0xFF0F172A), color: AppColors.textDark,
fontWeight: FontWeight.w700, fontWeight: FontWeight.w700,
height: 1.25)), height: 1.25)),
), ),
@ -465,7 +432,7 @@ class _Page extends StatelessWidget {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)], colors: [AppColors.softBlueBg, Colors.white],
), ),
), ),
child: Padding( child: Padding(
@ -486,15 +453,9 @@ class _Page extends StatelessWidget {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(18),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF0F172A), gradient: AppDecorations.blueGradient,
borderRadius: BorderRadius.circular(24), borderRadius: BorderRadius.circular(24),
boxShadow: [ boxShadow: AppDecorations.cardShadow,
BoxShadow(
color: const Color(0xFF0F172A).withValues(alpha: 0.18),
blurRadius: 28,
offset: const Offset(0, 14),
),
],
), ),
child: Row( child: Row(
children: [ children: [
@ -516,13 +477,11 @@ class _Page extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(title, Text(title,
style: Theme.of(context) style: AppTextStyles.heading.copyWith(
.textTheme fontSize: 24,
.headlineSmall fontWeight: FontWeight.w900,
?.copyWith( color: Colors.white,
fontWeight: FontWeight.w900, )),
color: Colors.white,
)),
if (subtitle != null) if (subtitle != null)
Text(subtitle!, Text(subtitle!,
style: const TextStyle( style: const TextStyle(
@ -535,7 +494,7 @@ class _Page extends StatelessWidget {
), ),
), ),
const SizedBox(height: 16), 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), padding: const EdgeInsets.all(18),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(22), borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFDDEAFE)), boxShadow: AppDecorations.cardShadow,
boxShadow: [
BoxShadow(
color: const Color(0xFF2563EB).withValues(alpha: 0.10),
blurRadius: 24,
offset: const Offset(0, 12),
),
],
), ),
child: Row( child: Row(
children: [ children: [
@ -577,10 +529,10 @@ class _InfoCard extends StatelessWidget {
width: 48, width: 48,
height: 48, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFEFF6FF), color: AppColors.softBlueBg,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(50),
), ),
child: Icon(icon, color: const Color(0xFF2563EB)), child: Icon(icon, color: AppColors.primaryBlue),
), ),
const SizedBox(width: 14), const SizedBox(width: 14),
Expanded( Expanded(
@ -589,19 +541,19 @@ class _InfoCard extends StatelessWidget {
children: [ children: [
Text(title, Text(title,
style: const TextStyle( style: const TextStyle(
color: Color(0xFF64748B), fontWeight: FontWeight.w700)), color: AppColors.muted, fontWeight: FontWeight.w700)),
SelectableText(value, SelectableText(value,
style: const TextStyle( style: const TextStyle(
fontSize: 25, fontSize: 25,
height: 1.1, height: 1.1,
letterSpacing: 1.2, letterSpacing: 1.2,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
color: Color(0xFF0F172A))), color: AppColors.textDark)),
if (helper != null) ...[ if (helper != null) ...[
const SizedBox(height: 6), const SizedBox(height: 6),
Text(helper!, Text(helper!,
style: const TextStyle( style: const TextStyle(
color: Color(0xFF64748B), fontSize: 12)), color: AppColors.muted, fontSize: 12)),
], ],
])), ])),
], ],

View File

@ -8,6 +8,10 @@ import '../../app/injection_container.dart';
import '../../core/constants/app_constants.dart'; import '../../core/constants/app_constants.dart';
import '../../core/errors/friendly_error.dart'; import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.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 { class ServerConnectScreen extends StatefulWidget {
final bool editMode; final bool editMode;
@ -64,7 +68,7 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
}, },
onError: (message) => _message = message, onError: (message) => _message = message,
fallback: 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); if (mounted) setState(() => _loading = false);
} }
@ -76,25 +80,27 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
if (mounted) context.go(widget.editMode ? '/login' : '/splash'); if (mounted) context.go(widget.editMode ? '/login' : '/splash');
} }
void _usePublicUrl() => void _useLocalUrl() =>
setState(() => _url.text = AppConstants.defaultServerUrl); setState(() => _url.text = AppConstants.defaultServerUrl);
void _usePublicUrl() =>
setState(() => _url.text = AppConstants.publicServerUrl);
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF061421), backgroundColor: AppColors.softBlueBg,
body: Stack( body: Stack(
children: [ children: [
const Positioned.fill( const Positioned.fill(
child: DecoratedBox( child: DecoratedBox(
decoration: BoxDecoration( decoration: BoxDecoration(
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topLeft, begin: Alignment.topCenter,
end: Alignment.bottomRight, end: Alignment.bottomCenter,
colors: [ colors: [
Color(0xFF071226), AppColors.softBlueBg,
Color(0xFF0F3B57), Colors.white,
Color(0xFF0B6B6C), AppColors.softPinkBg,
], ],
), ),
), ),
@ -140,20 +146,14 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
), ),
child: Container( child: Container(
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.96), color: AppColors.cardWhite,
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
compact ? 22 : 28, compact ? 22 : 28,
), ),
border: Border.all( border: Border.all(
color: Colors.white.withValues(alpha: 0.7), color: Colors.white.withValues(alpha: 0.7),
), ),
boxShadow: [ boxShadow: AppDecorations.cardShadow,
BoxShadow(
color: Colors.black.withValues(alpha: 0.18),
blurRadius: 34,
offset: const Offset(0, 22),
),
],
), ),
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
@ -170,7 +170,7 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
compact ? 14 : 20, compact ? 14 : 20,
), ),
decoration: const BoxDecoration( decoration: const BoxDecoration(
color: Color(0xFF071226), color: AppColors.softBlueBg,
), ),
child: Column( child: Column(
crossAxisAlignment: crossAxisAlignment:
@ -182,9 +182,17 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
width: compact ? 38 : 48, width: compact ? 38 : 48,
height: compact ? 38 : 48, height: compact ? 38 : 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF2563EB), gradient:
AppDecorations.blueGradient,
borderRadius: borderRadius:
BorderRadius.circular(16), BorderRadius.circular(16),
boxShadow: const [
BoxShadow(
color: Color(0x334A90D9),
blurRadius: 18,
offset: Offset(0, 8),
),
],
), ),
child: Icon( child: Icon(
Icons.navigation_rounded, Icons.navigation_rounded,
@ -197,9 +205,9 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
child: Text( child: Text(
'WalkGuide', 'WalkGuide',
style: TextStyle( style: TextStyle(
color: Colors.white, color: AppColors.textDark,
fontSize: compact ? 16 : 20, fontSize: compact ? 16 : 20,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w800,
), ),
), ),
), ),
@ -208,10 +216,9 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
SizedBox(height: compact ? 14 : 18), SizedBox(height: compact ? 14 : 18),
Text( Text(
'Connect to Server', 'Connect to Server',
style: TextStyle( style: AppTextStyles.heading.copyWith(
color: Colors.white,
fontSize: compact ? 22 : 30, fontSize: compact ? 22 : 30,
fontWeight: FontWeight.w900, fontWeight: FontWeight.w800,
height: 1, height: 1,
), ),
), ),
@ -219,11 +226,9 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
Text( Text(
widget.editMode widget.editMode
? 'Ubah alamat backend Spring Boot WalkGuide yang aktif.' ? 'Ubah alamat backend Spring Boot WalkGuide yang aktif.'
: 'Sambungkan app HP ke backend Spring Boot publik WalkGuide.', : 'Pilih backend WalkGuide yang aktif sebelum login atau register.',
style: TextStyle( style: AppTextStyles.body.copyWith(
color: Colors.white.withValues( color: AppColors.muted,
alpha: 0.72,
),
height: 1.35, height: 1.35,
), ),
), ),
@ -255,9 +260,14 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
spacing: 8, spacing: 8,
runSpacing: 8, runSpacing: 8,
children: [ children: [
_HintChip(
icon: Icons.usb_outlined,
label: 'USB: 127.0.0.1',
onTap: _useLocalUrl,
),
_HintChip( _HintChip(
icon: Icons.public_outlined, icon: Icons.public_outlined,
label: 'Server: 202.46.28.170', label: 'Kampus: 202.46.28.170',
onTap: _usePublicUrl, onTap: _usePublicUrl,
), ),
], ],
@ -350,27 +360,27 @@ class _HintChip extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return InkWell( return BounceTap(
onTap: onTap, onTap: onTap,
borderRadius: BorderRadius.circular(999),
child: Container( child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFEFF6FF), color: AppColors.softBlueBg,
borderRadius: BorderRadius.circular(999), borderRadius: BorderRadius.circular(999),
border: Border.all(color: const Color(0xFFBFDBFE)), border:
Border.all(color: AppColors.primaryBlue.withValues(alpha: 0.2)),
), ),
child: Row( child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(icon, size: 14, color: const Color(0xFF1D4ED8)), Icon(icon, size: 14, color: AppColors.primaryBlue),
const SizedBox(width: 6), const SizedBox(width: 6),
Text( Text(
label, label,
style: const TextStyle( style: const TextStyle(
color: Color(0xFF1D4ED8), color: AppColors.primaryBlue,
fontSize: 12, fontSize: 12,
fontWeight: FontWeight.w800, fontWeight: FontWeight.w700,
), ),
), ),
], ],
@ -392,7 +402,7 @@ class _StatusBox extends StatelessWidget {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: color.withValues(alpha: 0.08), color: color.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(16),
border: Border.all(color: color.withValues(alpha: 0.22)), border: Border.all(color: color.withValues(alpha: 0.22)),
), ),
child: Row( child: Row(

View File

@ -5,7 +5,6 @@ import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.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/haptic_service.dart';
import '../../core/services/tts_service.dart'; import '../../core/services/tts_service.dart';
import '../../core/storage/secure_storage.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<ApiClient>().dio; Dio get _api => sl<ApiClient>().dio;
// Colours (inline, tidak butuh import app_colors.dart) // Colours (inline, tidak butuh import app_colors.dart)
const _kBlue = Color(0xFF1A56DB); const _kBlue = Color(0xFF1A56DB);
const _kRed = Color(0xFFDC2626); const _kRed = Color(0xFFDC2626);
const _kSurface = Color(0xFFF8FAFC);
const _kBorder = Color(0xFFE2E8F0);
const _kMuted = Color(0xFF64748B); const _kMuted = Color(0xFF64748B);
const _kText = Color(0xFF0F172A);
// Screen // Screen
@ -53,7 +52,6 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
// Account info (from SecureStorage) // Account info (from SecureStorage)
String _displayName = ''; String _displayName = '';
String _uniqueId = '';
// Pairing status // Pairing status
String _pairingStatus = ''; String _pairingStatus = '';
@ -75,7 +73,6 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
Future<void> _loadAccount() async { Future<void> _loadAccount() async {
final storage = sl<SecureStorage>(); final storage = sl<SecureStorage>();
_displayName = await storage.getDisplayName() ?? ''; _displayName = await storage.getDisplayName() ?? '';
_uniqueId = await storage.getUniqueUserId() ?? '';
} }
Future<void> _loadSettings() async { Future<void> _loadSettings() async {
@ -91,6 +88,7 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
_ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9; _ttsSpeed = (data['ttsSpeed'] as num?)?.toDouble() ?? 0.9;
_warnNoGuardian = data['warnNoGuardian'] as bool? ?? true; _warnNoGuardian = data['warnNoGuardian'] as bool? ?? true;
_hapticEnabled = data['hapticEnabled'] as bool? ?? true; _hapticEnabled = data['hapticEnabled'] as bool? ?? true;
sl<HapticService>().setEnabled(_hapticEnabled);
} }
}, },
onError: (_) {}, onError: (_) {},
@ -126,6 +124,7 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
// Apply TTS locally dulu // Apply TTS locally dulu
await sl<TtsService>().setLanguage(_ttsLanguage); await sl<TtsService>().setLanguage(_ttsLanguage);
context.read<AppCubit>().setLocaleCode(_ttsLanguage); context.read<AppCubit>().setLocaleCode(_ttsLanguage);
sl<HapticService>().setEnabled(_hapticEnabled);
if (_hapticEnabled) { if (_hapticEnabled) {
await sl<HapticService>().success(); await sl<HapticService>().success();
} }
@ -169,290 +168,258 @@ class _UserSettingsScreenState extends State<UserSettingsScreen> {
// build // build
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return DecoratedBox(
child: _loading decoration: const BoxDecoration(
? const Center(child: CircularProgressIndicator()) gradient: LinearGradient(
: ListView( begin: Alignment.topCenter,
padding: const EdgeInsets.all(16), end: Alignment.bottomCenter,
children: [ colors: [AppColors.softBlueBg, Colors.white],
// header ),
Text('Settings', ),
style: Theme.of(context) child: SafeArea(
.textTheme child: _loading
.headlineSmall ? const Center(child: CircularProgressIndicator())
?.copyWith(fontWeight: FontWeight.w800)), : ListView(
const Text('TTS, haptic, pairing, account', padding: const EdgeInsets.all(16),
style: TextStyle(color: _kMuted)), children: [
const SizedBox(height: 20), // 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 // 1. TTS Settings
_SectionHeader('1. TTS Settings', Icons.record_voice_over), _SectionHeader('1. TTS Settings', Icons.record_voice_over),
const SizedBox(height: 10), const SizedBox(height: 10),
_Card( _Card(
child: Column( child: Column(
children: [ children: [
// Language (editable) // Language (editable)
DropdownButtonFormField<String>( DropdownButtonFormField<String>(
value: _ttsLanguage, value: _ttsLanguage,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Bahasa TTS', labelText: 'Bahasa TTS',
border: OutlineInputBorder(), border: OutlineInputBorder(),
contentPadding: EdgeInsets.symmetric( contentPadding: EdgeInsets.symmetric(
horizontal: 12, vertical: 10), 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),
],
),
), ),
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 SizedBox(height: 12),
const Divider(height: 1), // Pitch read-only info
const SizedBox(height: 12), _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( const SizedBox(height: 20),
_paired ? Icons.link : Icons.link_off,
color: _paired // 2. Pairing
? const Color(0xFF16A34A) _SectionHeader('2. Pairing', Icons.link),
: const Color(0xFFD97706), const SizedBox(height: 10),
size: 20, _Card(
), child: Column(
const SizedBox(width: 10), crossAxisAlignment: CrossAxisAlignment.start,
Expanded( children: [
child: Text(_pairingStatus, Row(
style: TextStyle( children: [
color: _paired Icon(
? const Color(0xFF166534) _paired ? Icons.link : Icons.link_off,
: const Color(0xFF92400E), color: _paired
fontWeight: FontWeight.w600)), ? const Color(0xFF16A34A)
), : const Color(0xFFD97706),
IconButton( size: 20,
icon: const Icon(Icons.refresh, size: 18), ),
onPressed: () async { const SizedBox(width: 10),
await _loadPairing(); Expanded(
setState(() {}); child: Text(_pairingStatus,
}, style: TextStyle(
tooltip: 'Refresh status', color: _paired
), ? const Color(0xFF166534)
], : const Color(0xFF92400E),
), fontWeight: FontWeight.w600)),
const SizedBox(height: 8), ),
SizedBox( IconButton(
width: double.infinity, icon: const Icon(Icons.refresh, size: 18),
child: OutlinedButton.icon( onPressed: () async {
onPressed: () => context.go('/user/pairing'), await _loadPairing();
icon: const Icon(Icons.manage_accounts_outlined), setState(() {});
label: const Text('Buka menu Pairing'), },
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 // 3. Manual / Instructions
_SectionHeader('3. Manual & Instruksi', Icons.menu_book), _SectionHeader('3. Manual & Instruksi', Icons.menu_book),
const SizedBox(height: 10), const SizedBox(height: 10),
_Card( _Card(
child: ListTile( child: ListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
leading: const Icon(Icons.help_outline, color: _kBlue), leading: const Icon(Icons.help_outline, color: _kBlue),
title: const Text('Daftar Voice Commands & Shortcuts'), title: const Text('Daftar Voice Commands & Shortcuts'),
subtitle: subtitle: const Text(
const Text('Lihat semua perintah suara yang tersedia'), 'Lihat semua perintah suara yang tersedia'),
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () { onTap: () {
// Route /user/manual belum ada di router context.go('/user/manual');
// 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')),
);
},
), ),
),
const SizedBox(height: 20), const SizedBox(height: 20),
// 4. Caution Settings // 4. Caution Settings
_SectionHeader('4. Caution Settings', Icons.warning_amber), _SectionHeader('4. Caution Settings', Icons.warning_amber),
const SizedBox(height: 10), const SizedBox(height: 10),
_Card( _Card(
child: Column( child: Column(
children: [ children: [
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
value: _warnNoGuardian, value: _warnNoGuardian,
onChanged: (v) => setState(() => _warnNoGuardian = v), onChanged: (v) => setState(() => _warnNoGuardian = v),
title: const Text('Peringatan belum paired'), title: const Text('Peringatan belum paired'),
subtitle: const Text( subtitle: const Text(
'TTS ingatkan jika belum terhubung Guardian'), 'TTS ingatkan jika belum terhubung Guardian'),
secondary: const Icon( secondary: const Icon(
Icons.notifications_active_outlined, Icons.notifications_active_outlined,
color: _kBlue), color: _kBlue),
), ),
const Divider(height: 1), const Divider(height: 1),
SwitchListTile( SwitchListTile(
contentPadding: EdgeInsets.zero, contentPadding: EdgeInsets.zero,
value: _hapticEnabled, value: _hapticEnabled,
onChanged: (v) => setState(() => _hapticEnabled = v), onChanged: (v) {
title: const Text('Haptic feedback'), sl<HapticService>().setEnabled(v);
subtitle: setState(() => _hapticEnabled = v);
const Text('Getaran saat obstacle terdeteksi'), },
secondary: const Icon(Icons.vibration, color: _kBlue), 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 // 5. Account
_SectionHeader('5. Account', Icons.person), _SectionHeader('5. Account', Icons.person),
const SizedBox(height: 10), const SizedBox(height: 10),
_Card( _Card(
child: Column( child: Column(
children: [ children: [
_InfoRow( _InfoRow(
label: 'Display Name', label: 'Display Name',
value: _displayName.isNotEmpty ? _displayName : '', value: _displayName.isNotEmpty ? _displayName : '',
icon: Icons.badge_outlined, icon: Icons.badge_outlined,
), ),
const Divider(height: 20), const Divider(height: 20),
_InfoRow( _InfoRow(
label: 'Role', label: 'Role',
value: 'User', value: 'User',
icon: Icons.accessibility_new, icon: Icons.accessibility_new,
), ),
], ],
),
), ),
),
const SizedBox(height: 24), const SizedBox(height: 24),
// Save button // Save button
FilledButton.icon( FilledButton.icon(
onPressed: _saving ? null : _save, onPressed: _saving ? null : _save,
icon: _saving icon: _saving
? const SizedBox( ? const SizedBox(
width: 18, width: 18,
height: 18, height: 18,
child: CircularProgressIndicator( child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white)) strokeWidth: 2, color: Colors.white))
: const Icon(Icons.save_outlined), : const Icon(Icons.save_outlined),
label: Text(_saving ? 'Menyimpan…' : 'Simpan Settings'), label: Text(_saving ? 'Menyimpan…' : 'Simpan Settings'),
style: FilledButton.styleFrom( style: FilledButton.styleFrom(
minimumSize: const Size.fromHeight(48), minimumSize: const Size.fromHeight(48),
),
), ),
),
const SizedBox(height: 10), const SizedBox(height: 10),
// Change server // Change server
OutlinedButton.icon( OutlinedButton.icon(
onPressed: _changeServer, onPressed: _changeServer,
icon: const Icon(Icons.dns_outlined), icon: const Icon(Icons.dns_outlined),
label: const Text('Ganti Server'), label: const Text('Ganti Server'),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44), minimumSize: const Size.fromHeight(44),
),
), ),
),
const SizedBox(height: 10), const SizedBox(height: 10),
// Logout // Logout
OutlinedButton.icon( OutlinedButton.icon(
onPressed: () => _confirmLogout(context), onPressed: () => _confirmLogout(context),
icon: const Icon(Icons.logout, color: _kRed), icon: const Icon(Icons.logout, color: _kRed),
label: const Text('Logout', style: TextStyle(color: _kRed)), label: const Text('Logout', style: TextStyle(color: _kRed)),
style: OutlinedButton.styleFrom( style: OutlinedButton.styleFrom(
minimumSize: const Size.fromHeight(44), minimumSize: const Size.fromHeight(44),
side: const BorderSide(color: _kRed), 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) { Widget build(BuildContext context) {
return Row( return Row(
children: [ 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), const SizedBox(width: 8),
Text(title, Text(title,
style: const TextStyle( 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), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(20),
border: Border.all(color: _kBorder), boxShadow: AppDecorations.cardShadow,
), ),
child: child, child: child,
); );
@ -533,16 +507,22 @@ class _InfoRow extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Row( return Row(
children: [ 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), const SizedBox(width: 10),
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text(label, style: const TextStyle(fontSize: 12, color: _kMuted)), Text(label,
style: const TextStyle(fontSize: 12, color: AppColors.muted)),
Text(value, Text(value,
style: const TextStyle( style: const TextStyle(
fontWeight: FontWeight.w700, color: _kText)), fontWeight: FontWeight.w700, color: AppColors.textDark)),
], ],
), ),
), ),
@ -550,12 +530,12 @@ class _InfoRow extends StatelessWidget {
Container( Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration( decoration: BoxDecoration(
color: _kSurface, color: AppColors.softBlueBg,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(50),
border: Border.all(color: _kBorder), border: Border.all(color: AppColors.border),
), ),
child: Text(note!, child: Text(note!,
style: const TextStyle(fontSize: 11, color: _kMuted)), style: const TextStyle(fontSize: 11, color: AppColors.muted)),
), ),
], ],
); );

View File

@ -14,6 +14,10 @@ import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/services/haptic_service.dart'; import '../../core/services/haptic_service.dart';
import '../../core/services/tts_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'; import 'application/sos_cubit.dart';
Dio get _api => sl<ApiClient>().dio; Dio get _api => sl<ApiClient>().dio;
@ -252,106 +256,120 @@ class _SosScreenState extends State<SosScreen>
: compact : compact
? 12.0 ? 12.0
: 24.0; : 24.0;
return SafeArea( return DecoratedBox(
child: Padding( decoration: const BoxDecoration(
padding: EdgeInsets.all(pagePadding), gradient: LinearGradient(
child: Column( colors: [AppColors.softBlueBg, Colors.white],
crossAxisAlignment: CrossAxisAlignment.stretch, begin: Alignment.topCenter,
children: [ end: Alignment.bottomCenter,
// Header ),
Row( ),
child: SafeArea(
child: FadeSlideWrapper(
child: Padding(
padding: EdgeInsets.all(pagePadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [ children: [
Expanded( // Header
child: Column( Row(
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ Expanded(
Text( child: Column(
'SOS', crossAxisAlignment: CrossAxisAlignment.start,
style: Theme.of(context) children: [
.textTheme Text(
.headlineSmall 'SOS',
?.copyWith(fontWeight: FontWeight.w800), style: AppTextStyles.heading,
), ),
const Text( Text(
'Emergency alert ke Guardian', 'Emergency alert ke Guardian',
style: TextStyle(color: Color(0xFF64748B)), style: AppTextStyles.body.copyWith(
), color: AppColors.textMuted,
], ),
), ),
), ],
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,
), ),
), ),
), IconButton(
onPressed: _loadHistory,
const SizedBox(height: 8), icon: const Icon(Icons.refresh),
tooltip: 'Refresh riwayat',
// 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,
), ),
),
] else SizedBox(height: sectionGap),
const Spacer(),
], // 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 : compact
? 154.0 ? 154.0
: 200.0; : 200.0;
return SizedBox.square( return BounceTap(
dimension: dimension, onTap: onPressed,
child: FilledButton( child: Semantics(
style: FilledButton.styleFrom( button: true,
shape: const CircleBorder(), label: 'Kirim SOS',
backgroundColor: child: Container(
active ? const Color(0xFFB91C1C) : const Color(0xFFDC2626), width: dimension,
elevation: active ? 12 : 4, height: dimension,
shadowColor: const Color(0xFFDC2626).withValues(alpha: 0.5), decoration: BoxDecoration(
), shape: BoxShape.circle,
onPressed: onPressed, gradient: LinearGradient(
child: Column( colors: active
mainAxisSize: MainAxisSize.min, ? const [Color(0xFFDC2626), Color(0xFF991B1B)]
children: [ : const [Color(0xFFFF5A5A), Color(0xFFDC2626)],
Icon( begin: Alignment.topLeft,
active ? Icons.emergency : Icons.emergency_outlined, end: Alignment.bottomRight,
size: dimension < 150 ? 34 : 48,
color: Colors.white,
), ),
SizedBox(height: dimension < 150 ? 3 : 6), boxShadow: [
Text( BoxShadow(
'SOS', color: AppColors.danger.withValues(alpha: active ? 0.34 : 0.22),
style: TextStyle( blurRadius: active ? 28 : 18,
fontSize: dimension < 150 ? 28 : 38, offset: const Offset(0, 10),
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 2,
), ),
), ],
], ),
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), color: const Color(0xFFDC2626).withValues(alpha: 0.15),
shape: BoxShape.circle, shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFDC2626), width: 3), border: Border.all(color: const Color(0xFFDC2626), width: 3),
boxShadow: AppDecorations.cardShadow,
), ),
child: const Center( child: const Center(
child: Column( child: Column(
@ -470,8 +505,9 @@ class _ActiveSosBanner extends StatelessWidget {
padding: const EdgeInsets.all(14), padding: const EdgeInsets.all(14),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFEE2E2), color: const Color(0xFFFEE2E2),
borderRadius: BorderRadius.circular(12), borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFFCA5A5), width: 1.5), border: Border.all(color: const Color(0xFFFCA5A5), width: 1.5),
boxShadow: AppDecorations.cardShadow,
), ),
child: Row( child: Row(
children: [ children: [
@ -531,10 +567,21 @@ class _SosHistory extends StatelessWidget {
if (events.isEmpty) { if (events.isEmpty) {
return _HistoryEmpty(onRefresh: onRefresh); return _HistoryEmpty(onRefresh: onRefresh);
} }
return ListView.separated( return RefreshIndicator(
itemCount: events.length, onRefresh: () async => onRefresh(),
separatorBuilder: (_, __) => const SizedBox(height: 8), child: ListView(
itemBuilder: (_, i) => _SosEventTile(event: events[i]), 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), padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFE2E8F0)), border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: AppDecorations.cardShadow,
), ),
child: Row( child: Row(
children: [ children: [
@ -662,9 +710,10 @@ class _HistoryEmpty extends StatelessWidget {
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFF8FAFC), color: AppColors.cardWhite,
borderRadius: BorderRadius.circular(12), borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFE2E8F0)), border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: AppDecorations.cardShadow,
), ),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,

View File

@ -15,6 +15,9 @@ import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart'; import '../../core/network/api_client.dart';
import '../../core/services/location_reporter_service.dart'; import '../../core/services/location_reporter_service.dart';
import '../../core/services/tts_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'; import 'application/walk_guide_cubit.dart';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@ -48,7 +51,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen>
_scanCtrl = AnimationController( _scanCtrl = AnimationController(
vsync: this, vsync: this,
duration: const Duration(milliseconds: 2200), duration: const Duration(milliseconds: 2200),
)..repeat(); );
_loadPairingStatus(); _loadPairingStatus();
} }
@ -73,8 +76,10 @@ class _WalkGuideScreenState extends State<WalkGuideScreen>
await _startCamera(); await _startCamera();
await sl<LocationReporterService>().start(walkGuideActive: true); await sl<LocationReporterService>().start(walkGuideActive: true);
await _cubit.start(); await _cubit.start();
_scanCtrl.repeat();
_cubit.updateStatus(_activeStatusText()); _cubit.updateStatus(_activeStatusText());
} else { } else {
_scanCtrl.stop();
await _stopCamera(); await _stopCamera();
await sl<LocationReporterService>().stop(); await sl<LocationReporterService>().stop();
await _cubit.stop(); await _cubit.stop();
@ -151,7 +156,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen>
); );
final controller = CameraController( final controller = CameraController(
backCamera, backCamera,
ResolutionPreset.medium, ResolutionPreset.low,
enableAudio: false, enableAudio: false,
imageFormatGroup: ImageFormatGroup.yuv420, imageFormatGroup: ImageFormatGroup.yuv420,
); );
@ -195,7 +200,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen>
void _onCameraImage(CameraImage image) { void _onCameraImage(CameraImage image) {
if (!_cubit.state.active || _processingFrame) return; if (!_cubit.state.active || _processingFrame) return;
final now = DateTime.now(); final now = DateTime.now();
if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) { if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 1500)) {
return; return;
} }
_lastInferenceAt = now; _lastInferenceAt = now;
@ -258,58 +263,60 @@ class _WalkGuideScreenState extends State<WalkGuideScreen>
onPressed: () => context.go('/user/pairing'), onPressed: () => context.go('/user/pairing'),
icon: const Icon(Icons.link)), icon: const Icon(Icons.link)),
], ],
child: Column( child: FadeSlideWrapper(
children: [ child: Column(
Expanded( children: [
child: _VisionPanel( Expanded(
state: state, child: _VisionPanel(
camera: _camera, state: state,
scanCtrl: _scanCtrl, camera: _camera,
paired: _paired, scanCtrl: _scanCtrl,
pairingLoading: _pairingLoading, paired: _paired,
onPairingTap: () => context.go('/user/pairing'), pairingLoading: _pairingLoading,
onPairingTap: () => context.go('/user/pairing'),
),
), ),
), const SizedBox(height: 14),
const SizedBox(height: 14), _StatusStrip(
_StatusStrip( active: state.active,
active: state.active, paired: _paired,
paired: _paired, latestDetection: state.latestDetection,
latestDetection: state.latestDetection, ),
), const SizedBox(height: 12),
const SizedBox(height: 12), Row(
Row( children: [
children: [ Expanded(
Expanded( flex: 2,
flex: 2, child: FilledButton.icon(
child: FilledButton.icon( onPressed: _pairingLoading ? null : _toggle,
onPressed: _pairingLoading ? null : _toggle, icon: Icon(state.active ? Icons.stop : Icons.play_arrow),
icon: Icon(state.active ? Icons.stop : Icons.play_arrow), label: Text(state.active ? 'Stop Scan' : 'Start Scan'),
label: Text(state.active ? 'Stop Scan' : 'Start Scan'), ),
), ),
), const SizedBox(width: 10),
const SizedBox(width: 10), _ActionSquare(
_ActionSquare( icon: Icons.sos_outlined,
icon: Icons.sos_outlined, color: AppColors.danger,
color: const Color(0xFFDC2626), onTap: () async {
onTap: () async { if (await _ensurePaired() && context.mounted) {
if (await _ensurePaired() && context.mounted) { context.go('/user/sos');
context.go('/user/sos'); }
} },
}, ),
), const SizedBox(width: 10),
const SizedBox(width: 10), _ActionSquare(
_ActionSquare( icon: Icons.call_outlined,
icon: Icons.call_outlined, color: AppColors.success,
color: const Color(0xFF059669), onTap: () async {
onTap: () async { if (await _ensurePaired() && context.mounted) {
if (await _ensurePaired() && context.mounted) { context.go('/user/call');
context.go('/user/call'); }
} },
}, ),
), ],
], ),
), ],
], ),
), ),
), ),
); );
@ -336,8 +343,12 @@ class _VisionPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cameraReady = camera != null && camera!.value.isInitialized; final cameraReady = camera != null && camera!.value.isInitialized;
return ClipRRect( return Container(
borderRadius: BorderRadius.circular(28), decoration: BoxDecoration(
borderRadius: BorderRadius.circular(28),
boxShadow: AppDecorations.cardShadow,
),
clipBehavior: Clip.antiAlias,
child: DecoratedBox( child: DecoratedBox(
decoration: const BoxDecoration(color: Color(0xFF07111F)), decoration: const BoxDecoration(color: Color(0xFF07111F)),
child: Stack( child: Stack(
@ -393,20 +404,21 @@ class _VisionPanel extends StatelessWidget {
), ),
), ),
Positioned( Positioned(
top: 18, top: 14,
left: 18, left: 14,
right: 18, right: 14,
child: Row( child: Wrap(
spacing: 8,
runSpacing: 6,
children: [ children: [
_Pill( _Pill(
text: state.active ? 'LIVE AI SCAN' : 'STANDBY', text: state.active ? 'LIVE AI' : 'STANDBY',
color: state.active color: state.active
? const Color(0xFF22C55E) ? const Color(0xFF22C55E)
: const Color(0xFFF59E0B), : const Color(0xFFF59E0B),
), ),
const SizedBox(width: 8),
_Pill( _Pill(
text: paired ? 'GUARDIAN LINKED' : 'PAIRING REQUIRED', text: paired ? 'LINKED' : 'PAIRING',
color: paired color: paired
? const Color(0xFF38BDF8) ? const Color(0xFF38BDF8)
: const Color(0xFFF97316), : const Color(0xFFF97316),
@ -444,56 +456,71 @@ class _VisionPanel extends StatelessWidget {
), ),
if (!paired && !pairingLoading) if (!paired && !pairingLoading)
Positioned.fill( Positioned.fill(
child: DecoratedBox( child: LayoutBuilder(
decoration: BoxDecoration( builder: (context, constraints) {
color: const Color(0xFF020617).withValues(alpha: 0.72), final compact = constraints.maxHeight < 270;
), return DecoratedBox(
child: Center( decoration: BoxDecoration(
child: Padding( color: const Color(0xFF020617).withValues(alpha: 0.72),
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: 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( Positioned(
@ -624,15 +651,8 @@ class _MetricChip extends StatelessWidget {
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE2E8F0)), boxShadow: AppDecorations.cardShadow,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.04),
blurRadius: 14,
offset: const Offset(0, 8),
),
],
), ),
child: Row( child: Row(
children: [ children: [
@ -680,12 +700,20 @@ class _ActionSquare extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Material( return BounceTap(
color: color, onTap: onTap,
borderRadius: BorderRadius.circular(14), child: DecoratedBox(
child: InkWell( decoration: BoxDecoration(
onTap: onTap, color: color,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(50),
boxShadow: [
BoxShadow(
color: color.withValues(alpha: 0.22),
blurRadius: 16,
offset: const Offset(0, 8),
),
],
),
child: SizedBox( child: SizedBox(
width: 54, width: 54,
height: 50, height: 50,
@ -816,7 +844,7 @@ class _Page extends StatelessWidget {
gradient: LinearGradient( gradient: LinearGradient(
begin: Alignment.topCenter, begin: Alignment.topCenter,
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)], colors: [AppColors.softBlueBg, Colors.white],
), ),
), ),
child: Padding( child: Padding(
@ -835,12 +863,11 @@ class _Page extends StatelessWidget {
width: compact ? 38 : 46, width: compact ? 38 : 46,
height: compact ? 38 : 46, height: compact ? 38 : 46,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF2563EB), gradient: AppDecorations.blueGradient,
borderRadius: BorderRadius.circular(14), borderRadius: BorderRadius.circular(14),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: color: AppColors.primaryBlue.withValues(alpha: 0.28),
const Color(0xFF2563EB).withValues(alpha: 0.28),
blurRadius: 18, blurRadius: 18,
offset: const Offset(0, 8), offset: const Offset(0, 8),
), ),
@ -862,14 +889,14 @@ class _Page extends StatelessWidget {
.headlineSmall .headlineSmall
?.copyWith( ?.copyWith(
fontWeight: FontWeight.w900, fontWeight: FontWeight.w900,
color: const Color(0xFF0F172A), color: AppColors.textDark,
fontSize: compact ? 22 : null, fontSize: compact ? 22 : null,
)), )),
if (subtitle != null) if (subtitle != null)
Text(subtitle!, Text(subtitle!,
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Color(0xFF64748B))), style: const TextStyle(color: AppColors.muted)),
], ],
), ),
), ),

View File

@ -2,18 +2,10 @@ import 'dart:async';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/material.dart'; 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/injection_container.dart';
import 'app/app.dart'; import 'app/app.dart';
import 'core/constants/app_constants.dart'; import 'core/constants/app_constants.dart';
import 'core/utils/init_guard.dart'; import 'shared/widgets/walkguide_loading_screen.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
}
Future<void> main() async { Future<void> main() async {
await runZonedGuarded( await runZonedGuarded(
@ -21,18 +13,7 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
_installGlobalErrorUi(); _installGlobalErrorUi();
await AppConstants.clearServerUrl(); await AppConstants.clearServerUrl();
runApp(const WalkGuideBootApp());
if (!kIsWeb) {
final firebaseApp = await ignoreInitFailure(
() => Firebase.initializeApp(),
label: 'Firebase init',
);
if (firebaseApp != null) {
FirebaseMessaging.onBackgroundMessage(
_firebaseMessagingBackgroundHandler,
);
}
}
try { try {
await initDependencies(); await initDependencies();
@ -51,6 +32,20 @@ Future<void> 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() { void _installGlobalErrorUi() {
FlutterError.onError = (details) { FlutterError.onError = (details) {
FlutterError.presentError(details); FlutterError.presentError(details);

View File

@ -0,0 +1,3 @@
export 'bounce_tap.dart';
export 'fade_slide_wrapper.dart';
export 'stagger_wrapper.dart';

View File

@ -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<BounceTap> createState() => _BounceTapState();
}
class _BounceTapState extends State<BounceTap>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _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,
),
),
);
}
}

View File

@ -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<FadeSlideWrapper> createState() => _FadeSlideWrapperState();
}
class _FadeSlideWrapperState extends State<FadeSlideWrapper>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _opacity;
late final Animation<Offset> _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<double>(begin: 0, end: 1).animate(curved);
_offset = Tween<Offset>(
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,
),
),
);
}
}

View File

@ -0,0 +1,149 @@
import 'package:flutter/material.dart';
class StaggerWrapper extends StatefulWidget {
final List<Widget> 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<StaggerWrapper> createState() => _StaggerWrapperState();
}
class _StaggerWrapperState extends State<StaggerWrapper>
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<Widget>.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<double>(begin: 0, end: 1).animate(curve);
final offset = Tween<Offset>(
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,
),
),
);
}
}

View File

@ -1,5 +1,7 @@
// ignore_for_file: prefer_const_constructors, sort_child_properties_last // ignore_for_file: prefer_const_constructors, sort_child_properties_last
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.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/tts_service.dart';
import '../../core/services/voice_command_handler.dart'; import '../../core/services/voice_command_handler.dart';
import '../../core/theme/app_colors.dart'; import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import 'animations/animations.dart';
class UserShell extends StatefulWidget { class UserShell extends StatefulWidget {
final Widget child; final Widget child;
@ -26,9 +30,6 @@ class _UserShellState extends State<UserShell> {
super.initState(); super.initState();
_loadVoiceCommands(); _loadVoiceCommands();
_startHardwareShortcuts(); _startHardwareShortcuts();
WidgetsBinding.instance.addPostFrameCallback((_) {
_startVoiceListening();
});
sl<VoiceCommandHandler>().onCommand = (key) { sl<VoiceCommandHandler>().onCommand = (key) {
if (!mounted) return; if (!mounted) return;
switch (key) { switch (key) {
@ -66,17 +67,6 @@ class _UserShellState extends State<UserShell> {
}; };
} }
Future<void> _startVoiceListening() async {
await runFriendlyAction(
() async {
await sl<SttService>().init();
await sl<SttService>().startListening();
},
onError: (_) {},
fallback: 'Voice listener belum bisa dimuat.',
);
}
Future<void> _loadVoiceCommands() async { Future<void> _loadVoiceCommands() async {
await runFriendlyAction( await runFriendlyAction(
() async { () async {
@ -139,6 +129,7 @@ class _UserShellState extends State<UserShell> {
@override @override
void dispose() { void dispose() {
sl<HardwareShortcutListener>().stopListening(); sl<HardwareShortcutListener>().stopListening();
unawaited(sl<SttService>().stopListening());
super.dispose(); super.dispose();
} }
@ -198,15 +189,7 @@ class _AppShell extends StatelessWidget {
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
final useRail = constraints.maxWidth >= 760; final useRail = constraints.maxWidth >= 760;
final content = AnimatedSwitcher( final content = child;
duration: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: KeyedSubtree(
key: ValueKey(location),
child: child,
),
);
return Scaffold( return Scaffold(
backgroundColor: AppColors.surface, backgroundColor: AppColors.surface,
@ -257,71 +240,40 @@ class _RailNavigation extends StatelessWidget {
final itemHeight = compact ? 58.0 : 70.0; final itemHeight = compact ? 58.0 : 70.0;
return DecoratedBox( 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( child: SafeArea(
right: false, right: false,
child: SizedBox( child: SizedBox(
width: width, width: width,
child: ListView.separated( child: Padding(
padding: EdgeInsets.symmetric( padding: EdgeInsets.symmetric(
horizontal: 8, horizontal: 8,
vertical: compact ? 6 : 12, vertical: compact ? 6 : 12,
), ),
itemCount: items.length, child: Column(
separatorBuilder: (_, __) => SizedBox(height: compact ? 2 : 6), children: [
itemBuilder: (context, index) { for (var index = 0; index < items.length; index++)
final item = items[index]; Expanded(
final selected = index == selectedIndex; child: Center(
return Semantics( child: _RailNavItem(
button: true, item: items[index],
selected: selected, selected: index == selectedIndex,
label: item.label, compact: compact,
child: InkWell( height: itemHeight,
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),
),
),
],
), ),
), ),
), ],
); ),
},
), ),
), ),
), ),
@ -344,82 +296,154 @@ class _BottomScrollNavigation extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final bottom = MediaQuery.of(context).padding.bottom; final bottom = MediaQuery.of(context).padding.bottom;
final extraBottom = bottom > 12 ? 12.0 : bottom; final extraBottom = bottom > 12 ? 12.0 : bottom;
return DecoratedBox( return Container(
margin: EdgeInsets.zero,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
border: const Border(top: BorderSide(color: AppColors.border)), borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [ boxShadow: [
BoxShadow( AppDecorations.cardShadow.first,
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 18,
offset: const Offset(0, -8),
),
], ],
), ),
child: SafeArea( child: ClipRRect(
top: false, borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: SizedBox( child: SafeArea(
height: 68 + extraBottom, top: false,
child: ListView.separated( child: SizedBox(
scrollDirection: Axis.horizontal, height: 68 + extraBottom,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row(
itemCount: items.length, children: [
separatorBuilder: (_, __) => const SizedBox(width: 6), for (var index = 0; index < items.length; index++)
itemBuilder: (context, index) { Expanded(
final item = items[index]; child: _BottomNavItem(
final selected = index == selectedIndex; item: items[index],
return Semantics( selected: index == selectedIndex,
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),
),
),
],
), ),
), ),
],
),
),
),
),
);
}
}
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,
),
),
],
),
), ),
), ),
), ),

View File

@ -1,6 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../core/theme/app_colors.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 { class FeaturePage extends StatelessWidget {
final String title; final String title;
@ -18,80 +21,79 @@ class FeaturePage extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return SafeArea( return DecoratedBox(
child: LayoutBuilder( decoration: const BoxDecoration(
builder: (context, constraints) { gradient: LinearGradient(
final short = constraints.maxHeight < 520; colors: [AppColors.softBlueBg, Colors.white],
final compact = constraints.maxWidth < 420 || short; begin: Alignment.topCenter,
final wide = constraints.maxWidth >= 900; end: Alignment.bottomCenter,
final horizontal = compact ? 12.0 : 20.0; ),
return Padding( ),
padding: EdgeInsets.fromLTRB( child: SafeArea(
horizontal, child: LayoutBuilder(
short ? 8 : 12, builder: (context, constraints) {
horizontal, final short = constraints.maxHeight < 520;
short ? 10 : 14, final compact = constraints.maxWidth < 420 || short;
), final wide = constraints.maxWidth >= 900;
child: Center( final horizontal = compact ? 12.0 : 20.0;
child: ConstrainedBox( return FadeSlideWrapper(
constraints: BoxConstraints( child: Padding(
maxWidth: wide ? 1160 : double.infinity, padding: EdgeInsets.fromLTRB(
horizontal,
short ? 8 : 12,
horizontal,
short ? 10 : 14,
), ),
child: Column( child: Center(
crossAxisAlignment: CrossAxisAlignment.start, child: ConstrainedBox(
children: [ constraints: BoxConstraints(
TweenAnimationBuilder<double>( maxWidth: wide ? 1160 : double.infinity,
tween: Tween(begin: 12, end: 0), ),
duration: const Duration(milliseconds: 360), child: Column(
curve: Curves.easeOutCubic, crossAxisAlignment: CrossAxisAlignment.start,
builder: (_, offset, child) => Opacity( children: [
opacity: (1 - offset / 12).clamp(0.0, 1.0), compact
child: Transform.translate( ? Column(
offset: Offset(0, offset), crossAxisAlignment: CrossAxisAlignment.start,
child: child, children: [
), _FeatureHeading(
),
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(
title: title, title: title,
subtitle: subtitle, subtitle: subtitle,
compact: compact, compact: compact,
), ),
), if (trailing != null) ...[
if (trailing != null) trailing!, 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, title,
maxLines: short ? 1 : 2, maxLines: short ? 1 : 2,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: Theme.of(context).textTheme.headlineSmall?.copyWith( style: AppTextStyles.heading.copyWith(
fontSize: compact ? 22 : null, fontSize: compact ? 22 : null,
fontWeight: FontWeight.w900, ),
color: AppColors.text,
),
), ),
const SizedBox(height: 2), const SizedBox(height: 2),
Text( Text(
subtitle, subtitle,
maxLines: short ? 1 : (compact ? 2 : 3), maxLines: short ? 1 : (compact ? 2 : 3),
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
style: const TextStyle( style: AppTextStyles.body.copyWith(
color: AppColors.muted, color: AppColors.textMuted,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
height: 1.25,
), ),
), ),
], ],
@ -157,8 +156,10 @@ class FeatureEmptyPanel extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Center( return Center(
child: ConstrainedBox( child: Container(
constraints: const BoxConstraints(maxWidth: 360), constraints: const BoxConstraints(maxWidth: 380),
padding: const EdgeInsets.all(24),
decoration: AppDecorations.card,
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
@ -167,7 +168,7 @@ class FeatureEmptyPanel extends StatelessWidget {
height: 72, height: 72,
decoration: BoxDecoration( decoration: BoxDecoration(
color: AppColors.primary.withValues(alpha: 0.08), color: AppColors.primary.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(50),
border: Border.all(color: AppColors.border), border: Border.all(color: AppColors.border),
), ),
child: Icon(icon, size: 36, color: AppColors.primary), child: Icon(icon, size: 36, color: AppColors.primary),
@ -214,8 +215,9 @@ class FeatureErrorPanel extends StatelessWidget {
padding: const EdgeInsets.all(18), padding: const EdgeInsets.all(18),
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFFEF2F2), color: const Color(0xFFFEF2F2),
borderRadius: BorderRadius.circular(8), borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFFECACA)), border: Border.all(color: const Color(0xFFFECACA)),
boxShadow: AppDecorations.cardShadow,
), ),
child: Column( child: Column(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,

View File

@ -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<WalkGuideLoadingScreen> createState() => _WalkGuideLoadingScreenState();
}
class _WalkGuideLoadingScreenState extends State<WalkGuideLoadingScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _logoScale;
bool _showName = false;
Timer? _nameTimer;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1200),
)..repeat();
_logoScale = TweenSequence<double>([
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,
),
),
);
},
),
);
},
);
}
}

View File

@ -0,0 +1 @@
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/battery_plus-6.2.3/

View File

@ -0,0 +1 @@
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/connectivity_plus-6.1.5/

View File

@ -0,0 +1 @@
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/device_info_plus-11.5.0/

View File

@ -0,0 +1 @@
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_local_notifications_linux-4.0.1/

View File

@ -0,0 +1 @@
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/

View File

@ -0,0 +1 @@
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/path_provider_linux-2.2.1/

View File

@ -0,0 +1 @@
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/record_linux-1.3.0/

View File

@ -0,0 +1 @@
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_linux-2.4.1/

View File

@ -0,0 +1 @@
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/

View File

@ -0,0 +1 @@
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/tflite_flutter-0.12.1/

View File

@ -0,0 +1,23 @@
//
// Generated file. Do not edit.
//
// clang-format off
#include "generated_plugin_registrant.h"
#include <flutter_secure_storage_linux/flutter_secure_storage_linux_plugin.h>
#include <record_linux/record_linux_plugin.h>
#include <sqlite3_flutter_libs/sqlite3_flutter_libs_plugin.h>
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);
}

View File

@ -0,0 +1,15 @@
//
// Generated file. Do not edit.
//
// clang-format off
#ifndef GENERATED_PLUGIN_REGISTRANT_
#define GENERATED_PLUGIN_REGISTRANT_
#include <flutter_linux/flutter_linux.h>
// Registers Flutter plugins.
void fl_register_plugins(FlPluginRegistry* registry);
#endif // GENERATED_PLUGIN_REGISTRANT_

View File

@ -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 $<TARGET_FILE:${plugin}_plugin>)
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)

View File

@ -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"))
}

View File

@ -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

View File

@ -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"