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
public void registerStompEndpoints(StompEndpointRegistry registry) {
// Endpoint WebSocket utama
// Endpoint WebSocket utama untuk Flutter/stomp_dart_client.
// Flutter connect ke: ws://host:port/ws (tanpa SockJS)
// Browser/Postman bisa pakai SockJS fallback: http://host:port/ws
registry.addEndpoint("/ws")
.setAllowedOriginPatterns("*") // Allow semua origin untuk testing HP di LAN
.withSockJS(); // SockJS fallback untuk browser compatibility
.setAllowedOriginPatterns("*"); // Allow semua origin untuk testing HP di LAN
// Endpoint fallback SockJS untuk browser tooling bila dibutuhkan.
registry.addEndpoint("/ws-sockjs")
.setAllowedOriginPatterns("*")
.withSockJS();
}
}

View File

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

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Modify this file to customize your launch splash screen -->
<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 -->
<!-- <item>

View File

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<!-- 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
the Flutter engine draws its first frame -->
<item name="android:windowBackground">@drawable/launch_background</item>
@ -12,7 +12,7 @@
running.
This Theme is only used starting with V2 of Flutter's Android embedding. -->
<style name="NormalTheme" parent="@android:style/Theme.Black.NoTitleBar">
<item name="android:windowBackground">?android:colorBackground</item>
<style name="NormalTheme" parent="@android:style/Theme.Light.NoTitleBar">
<item name="android:windowBackground">#F8FAFC</item>
</style>
</resources>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -61,7 +61,11 @@ class _AuthInterceptor extends Interceptor {
@override
void onError(DioException err, ErrorInterceptorHandler handler) async {
if (err.response?.statusCode == 401 && !_refreshing) {
final status = err.response?.statusCode;
final canRefresh = (status == 401 || status == 403) &&
!_refreshing &&
!err.requestOptions.path.startsWith('/auth/');
if (canRefresh) {
_refreshing = true;
try {
final refresh = await _storage.getRefreshToken();
@ -87,14 +91,20 @@ class _AuthInterceptor extends Interceptor {
// Retry original request
err.requestOptions.headers['Authorization'] =
'Bearer ${data['accessToken']}';
final retryRes = await _dio.fetch(err.requestOptions);
_refreshing = false;
handler.resolve(retryRes);
try {
final retryRes = await _dio.fetch(err.requestOptions);
_refreshing = false;
handler.resolve(retryRes);
} on DioException catch (retryErr) {
_refreshing = false;
handler.next(retryErr);
}
return;
}
} catch (_) {}
} catch (_) {
await _storage.clearAll();
}
_refreshing = false;
await _storage.clearAll();
}
handler.next(err);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -1,15 +1,28 @@
import 'package:flutter/material.dart';
class AppColors {
static const primary = Color(0xFF2563EB);
static const primaryDark = Color(0xFF0F3EA8);
static const accent = Color(0xFF0891B2);
static const primaryBlue = Color(0xFF4A90D9);
static const softBlueBg = Color(0xFFEBF4FF);
static const softPinkBg = Color(0xFFFFF0F5);
static const softYellowBg = Color(0xFFFFFBEB);
static const cardWhite = Color(0xFFFFFFFF);
static const textDark = Color(0xFF2D3748);
static const textMuted = Color(0xFFA0AEC0);
static const gold = Color(0xFFF6C90E);
static const gradientBlueStart = Color(0xFF6BB8FF);
static const gradientBlueEnd = Color(0xFF4A90D9);
static const gradientPinkStart = Color(0xFFFFB3C6);
static const gradientPinkEnd = Color(0xFFFF6B9D);
static const primary = primaryBlue;
static const primaryDark = Color(0xFF256FB8);
static const accent = Color(0xFFFF6B9D);
static const warning = Color(0xFFD97706);
static const danger = Color(0xFFDC2626);
static const success = Color(0xFF059669);
static const surface = Color(0xFFF7FAFC);
static const surfaceRaised = Color(0xFFFFFFFF);
static const text = Color(0xFF0F172A);
static const muted = Color(0xFF64748B);
static const surface = softBlueBg;
static const surfaceRaised = cardWhite;
static const text = textDark;
static const muted = textMuted;
static const border = Color(0xFFE2E8F0);
}

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/network/api_client.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../core/theme/app_text_styles.dart';
import '../../shared/widgets/animations/animations.dart';
Dio get _api => sl<ApiClient>().dio;
@ -103,91 +106,114 @@ class _ActivityLogScreenState extends State<ActivityLogScreen> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.softBlueBg, Colors.white],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: FadeSlideWrapper(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Activity Log',
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
// Header
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Activity Log',
style: AppTextStyles.heading,
),
Text(
'${_items.length} aktivitas tercatat',
style: const TextStyle(color: AppColors.muted),
),
],
),
Text(
'${_items.length} aktivitas tercatat',
style: const TextStyle(color: AppColors.muted),
),
],
),
IconButton(
onPressed: _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
),
],
),
const SizedBox(height: 12),
// Filter chips
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _filters.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final f = _filters[i];
final selected = _selectedFilter == f;
return FilterChip(
label: Text(f),
selected: selected,
onSelected: (_) {
setState(() => _applyFilter(f));
},
selectedColor:
AppColors.primary.withValues(alpha: 0.15),
backgroundColor: AppColors.cardWhite,
side: BorderSide(
color: selected
? AppColors.primary.withValues(alpha: 0.4)
: AppColors.border,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(50),
),
checkmarkColor: AppColors.primary,
labelStyle: TextStyle(
color: selected ? AppColors.primary : AppColors.muted,
fontWeight:
selected ? FontWeight.w700 : FontWeight.normal,
fontSize: 12,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
);
},
),
),
IconButton(
onPressed: _load,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh',
const SizedBox(height: 16),
// Body
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? _ErrorPanel(message: _error!, onRetry: _load)
: _filtered.isEmpty
? _EmptyPanel(filter: _selectedFilter)
: RefreshIndicator(
onRefresh: _load,
child: ListView(
children: [
StaggerWrapper(
children: [
for (final item in _filtered)
_LogCard(item: item),
],
),
],
),
),
),
],
),
const SizedBox(height: 12),
// Filter chips
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _filters.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final f = _filters[i];
final selected = _selectedFilter == f;
return FilterChip(
label: Text(f),
selected: selected,
onSelected: (_) {
setState(() => _applyFilter(f));
},
selectedColor: AppColors.primary.withValues(alpha: 0.15),
checkmarkColor: AppColors.primary,
labelStyle: TextStyle(
color: selected ? AppColors.primary : AppColors.muted,
fontWeight:
selected ? FontWeight.w700 : FontWeight.normal,
fontSize: 12,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
);
},
),
),
const SizedBox(height: 16),
// Body
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _error != null
? _ErrorPanel(message: _error!, onRetry: _load)
: _filtered.isEmpty
? _EmptyPanel(filter: _selectedFilter)
: RefreshIndicator(
onRefresh: _load,
child: ListView.builder(
itemCount: _filtered.length,
itemBuilder: (ctx, i) =>
_LogCard(item: _filtered[i]),
),
),
),
],
),
),
),
);
@ -228,71 +254,76 @@ class _LogCard extends StatelessWidget {
Widget build(BuildContext context) {
final meta = _logMeta(item.logType);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline dot + line
Column(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: meta.color.withValues(alpha: 0.12),
shape: BoxShape.circle,
padding: const EdgeInsets.only(bottom: 10),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline dot + line
Column(
children: [
Container(
width: 36,
height: 36,
decoration: BoxDecoration(
color: meta.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(50),
),
child: Icon(meta.icon, color: meta.color, size: 18),
),
child: Icon(meta.icon, color: meta.color, size: 18),
),
Container(
width: 1.5,
height: 20,
color: const Color(0xFFE2E8F0),
),
],
),
const SizedBox(width: 12),
// Content
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
meta.label,
style: TextStyle(
fontWeight: FontWeight.w700,
color: meta.color,
fontSize: 13,
],
),
const SizedBox(width: 12),
// Content
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
meta.label,
style: TextStyle(
fontWeight: FontWeight.w700,
color: meta.color,
fontSize: 13,
),
),
),
),
Text(
_formatTime(item.createdAt),
style: const TextStyle(
color: AppColors.muted, fontSize: 11),
),
],
),
if (item.description != null && item.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
item.description!,
style: const TextStyle(
fontSize: 13, color: AppColors.text),
),
Text(
_formatTime(item.createdAt),
style: const TextStyle(
color: AppColors.muted, fontSize: 11),
),
],
),
const SizedBox(height: 12),
],
if (item.description != null &&
item.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
item.description!,
style: const TextStyle(
fontSize: 13, color: AppColors.text),
),
),
const SizedBox(height: 12),
],
),
),
),
),
],
],
),
),
);
}
@ -394,21 +425,29 @@ class _ErrorPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off, size: 48, color: AppColors.muted),
const SizedBox(height: 12),
Text(message,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted)),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Coba lagi'),
),
],
child: Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
boxShadow: AppDecorations.cardShadow,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wifi_off, size: 48, color: AppColors.muted),
const SizedBox(height: 12),
Text(message,
textAlign: TextAlign.center,
style: const TextStyle(color: AppColors.muted)),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: onRetry,
icon: const Icon(Icons.refresh),
label: const Text('Coba lagi'),
),
],
),
),
);
}
@ -421,21 +460,29 @@ class _EmptyPanel extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.history, size: 64, color: AppColors.muted),
const SizedBox(height: 12),
Text(
filter == 'ALL'
? 'Belum ada aktivitas'
: 'Tidak ada aktivitas "$filter"',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.muted),
),
],
child: Container(
padding: const EdgeInsets.all(24),
decoration: const BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
boxShadow: AppDecorations.cardShadow,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.history, size: 64, color: AppColors.muted),
const SizedBox(height: 12),
Text(
filter == 'ALL'
? 'Belum ada aktivitas'
: 'Tidak ada aktivitas "$filter"',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
color: AppColors.muted),
),
],
),
),
);
}

View File

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

View File

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

View File

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

View File

@ -13,6 +13,7 @@ import '../../core/services/incoming_call_polling_service.dart';
import '../../core/services/offline_queue_service.dart';
import '../../core/services/websocket_service.dart';
import '../../core/storage/secure_storage.dart';
import '../../shared/widgets/walkguide_loading_screen.dart';
// ---------------------------------------------------------------------------
// SplashScreen
@ -39,32 +40,35 @@ class SplashScreen extends StatefulWidget {
class _SplashScreenState extends State<SplashScreen>
with SingleTickerProviderStateMixin {
late final AnimationController _animCtrl;
late final Animation<double> _fadeAnim;
late final AnimationController _screenCtrl;
late final Animation<double> _screenFade;
@override
void initState() {
super.initState();
_animCtrl = AnimationController(
_screenCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 700),
duration: const Duration(milliseconds: 260),
value: 1,
);
_screenFade = CurvedAnimation(
parent: _screenCtrl,
curve: Curves.easeOutCubic,
);
_fadeAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeIn);
_animCtrl.forward();
_route();
}
@override
void dispose() {
_animCtrl.dispose();
_screenCtrl.dispose();
super.dispose();
}
Future<void> _route() async {
final routed = await runFriendlyAction(
() async {
// Animasi logo selalu tampil minimal 500ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 500));
// Animasi logo selalu tampil minimal 900ms agar tidak langsung flash.
await Future.delayed(const Duration(milliseconds: 900));
final storage = sl<SecureStorage>();
final token = await storage.getAccessToken().timeout(
@ -77,7 +81,7 @@ class _SplashScreenState extends State<SplashScreen>
if (!mounted) return;
if (token == null || role == null) {
context.go('/login');
await _fadeOutThenGo('/login');
return;
}
@ -88,67 +92,28 @@ class _SplashScreenState extends State<SplashScreen>
sl<IncomingCallPollingService>().start();
}
// Auto-login: arahkan ke home sesuai role.
context.go(
await _fadeOutThenGo(
role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide',
);
},
onError: (_) {},
fallback: 'Sesi belum bisa dipulihkan.',
);
if (!routed && mounted) context.go('/login');
if (!routed && mounted) await _fadeOutThenGo('/login');
}
Future<void> _fadeOutThenGo(String route) async {
if (!mounted) return;
await _screenCtrl.reverse();
if (mounted) context.go(route);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF1A56DB),
body: Center(
child: FadeTransition(
opacity: _fadeAnim,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Logo / icon
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(28),
),
child: const Icon(
Icons.navigation_rounded,
color: Colors.white,
size: 60,
),
),
const SizedBox(height: 24),
const Text(
'WalkGuide',
style: TextStyle(
color: Colors.white,
fontSize: 34,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
),
),
const SizedBox(height: 6),
const Text(
'AI-powered navigation for everyone',
style: TextStyle(color: Colors.white70, fontSize: 13),
),
const SizedBox(height: 48),
const SizedBox(
width: 28,
height: 28,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2.5,
),
),
],
),
),
return FadeTransition(
opacity: _screenFade,
child: const WalkGuideLoadingScreen(
subtitle: 'Restoring your session',
),
);
}

View File

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

View File

@ -9,6 +9,10 @@ import 'package:intl/intl.dart';
import '../../../app/injection_container.dart';
import '../../../core/errors/friendly_error.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_decorations.dart';
import '../../../core/theme/app_text_styles.dart';
import '../../../shared/widgets/animations/animations.dart';
Dio get _api => sl<ApiClient>().dio;
@ -142,110 +146,122 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.softBlueBg, Colors.white],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: FadeSlideWrapper(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'User Logs',
style: GoogleFonts.outfit(
fontSize: 22,
fontWeight: FontWeight.w800,
color: const Color(0xFF0F172A),
),
// ââ Header ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'User Logs',
style: AppTextStyles.heading,
),
Text(
_needsPairing
? 'Pairing dulu untuk melihat log'
: '${_items.length} aktivitas tercatat',
style: GoogleFonts.inter(
fontSize: 13,
color: const Color(0xFF64748B),
),
),
],
),
Text(
_needsPairing
? 'Pairing dulu untuk melihat log'
: '${_items.length} aktivitas tercatat',
style: GoogleFonts.inter(
fontSize: 13,
color: const Color(0xFF64748B),
),
),
],
),
),
IconButton(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Refresh',
color: const Color(0xFF64748B),
),
],
),
IconButton(
onPressed: _load,
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Refresh',
color: const Color(0xFF64748B),
const SizedBox(height: 12),
// ââ Filter chips âââââââââââââââââââââââââââââââââââââââââââââââââ
if (!_needsPairing && !_loading && _error == null)
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _filters.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final f = _filters[i];
final selected = _selectedFilter == f;
return FilterChip(
label: Text(f),
selected: selected,
onSelected: (_) => setState(() => _applyFilter(f)),
selectedColor:
const Color(0xFF1A56DB).withValues(alpha: 0.12),
checkmarkColor: const Color(0xFF1A56DB),
labelStyle: GoogleFonts.inter(
color: selected
? const Color(0xFF1A56DB)
: const Color(0xFF64748B),
fontWeight:
selected ? FontWeight.w700 : FontWeight.normal,
fontSize: 12,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
side: BorderSide(
color: selected
? const Color(0xFF1A56DB)
: const Color(0xFFE2E8F0),
),
);
},
),
),
if (!_needsPairing && !_loading && _error == null)
const SizedBox(height: 16),
// ââ Body âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _needsPairing
? _buildNoPairingPanel()
: _error != null
? _buildErrorPanel()
: _filtered.isEmpty
? _buildEmptyPanel()
: RefreshIndicator(
onRefresh: _load,
color: const Color(0xFF1A56DB),
child: ListView(
children: [
StaggerWrapper(
children: [
for (final item in _filtered)
_LogCard(item: item),
],
),
],
),
),
),
],
),
const SizedBox(height: 12),
// Filter chips
if (!_needsPairing && !_loading && _error == null)
SizedBox(
height: 36,
child: ListView.separated(
scrollDirection: Axis.horizontal,
itemCount: _filters.length,
separatorBuilder: (_, __) => const SizedBox(width: 8),
itemBuilder: (_, i) {
final f = _filters[i];
final selected = _selectedFilter == f;
return FilterChip(
label: Text(f),
selected: selected,
onSelected: (_) => setState(() => _applyFilter(f)),
selectedColor:
const Color(0xFF1A56DB).withValues(alpha: 0.12),
checkmarkColor: const Color(0xFF1A56DB),
labelStyle: GoogleFonts.inter(
color: selected
? const Color(0xFF1A56DB)
: const Color(0xFF64748B),
fontWeight:
selected ? FontWeight.w700 : FontWeight.normal,
fontSize: 12,
),
padding: const EdgeInsets.symmetric(horizontal: 4),
side: BorderSide(
color: selected
? const Color(0xFF1A56DB)
: const Color(0xFFE2E8F0),
),
);
},
),
),
if (!_needsPairing && !_loading && _error == null)
const SizedBox(height: 16),
// Body
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _needsPairing
? _buildNoPairingPanel()
: _error != null
? _buildErrorPanel()
: _filtered.isEmpty
? _buildEmptyPanel()
: RefreshIndicator(
onRefresh: _load,
color: const Color(0xFF1A56DB),
child: ListView.builder(
itemCount: _filtered.length,
itemBuilder: (ctx, i) =>
_LogCard(item: _filtered[i]),
),
),
),
],
),
),
),
);
@ -258,8 +274,9 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: const Color(0xFFFFFBEB),
borderRadius: BorderRadius.circular(16),
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFFDE68A)),
boxShadow: AppDecorations.cardShadow,
),
child: Column(
mainAxisSize: MainAxisSize.min,
@ -338,9 +355,9 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
}
}
//
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
// DATA MODEL
//
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
class _LogItem {
final int id;
@ -365,9 +382,9 @@ class _LogItem {
);
}
//
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
// LOG CARD
//
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
class _LogCard extends StatelessWidget {
final _LogItem item;
@ -377,75 +394,80 @@ class _LogCard extends StatelessWidget {
Widget build(BuildContext context) {
final meta = _logMeta(item.logType);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline dot + connector line
Column(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: meta.color.withValues(alpha: 0.12),
shape: BoxShape.circle,
padding: const EdgeInsets.only(bottom: 10),
child: Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Timeline dot + connector line
Column(
children: [
Container(
width: 38,
height: 38,
decoration: BoxDecoration(
color: meta.color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(50),
),
child: Icon(meta.icon, color: meta.color, size: 18),
),
child: Icon(meta.icon, color: meta.color, size: 18),
),
Container(
width: 1.5,
height: 22,
color: const Color(0xFFE2E8F0),
),
],
),
const SizedBox(width: 12),
// Content
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
],
),
const SizedBox(width: 12),
// Content
Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
meta.label,
style: GoogleFonts.inter(
fontWeight: FontWeight.w700,
color: meta.color,
fontSize: 13,
),
),
),
Text(
_formatTime(item.createdAt),
style: GoogleFonts.jetBrainsMono(
color: const Color(0xFF94A3B8),
fontSize: 11,
),
),
],
),
if (item.description != null &&
item.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(
meta.label,
item.description!,
style: GoogleFonts.inter(
fontWeight: FontWeight.w700,
color: meta.color,
fontSize: 13,
fontSize: 12,
color: const Color(0xFF64748B),
),
),
),
Text(
_formatTime(item.createdAt),
style: GoogleFonts.jetBrainsMono(
color: const Color(0xFF94A3B8),
fontSize: 11,
),
),
],
),
if (item.description != null && item.description!.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: 3),
child: Text(
item.description!,
style: GoogleFonts.inter(
fontSize: 12,
color: const Color(0xFF64748B),
),
),
),
const SizedBox(height: 14),
],
const SizedBox(height: 14),
],
),
),
),
),
],
],
),
),
);
}
@ -459,9 +481,9 @@ class _LogCard extends StatelessWidget {
}
}
//
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
// LOG METADATA
//
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
class _LogMeta {
final IconData icon;

View File

@ -9,7 +9,11 @@ import 'package:google_fonts/google_fonts.dart';
import '../../../app/injection_container.dart';
import '../../../core/errors/friendly_error.dart';
import '../../../core/network/api_client.dart';
import '../../../core/theme/app_colors.dart';
import '../../../core/theme/app_decorations.dart';
import '../../../core/theme/app_text_styles.dart';
import '../../../core/utils/operation_guard.dart';
import '../../../shared/widgets/animations/animations.dart';
Dio get _api => sl<ApiClient>().dio;
@ -49,35 +53,36 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
});
await guarded<void>(
() async {
final paired = await _hasActivePairing();
if (!paired) {
setState(() {
_needsPairing = true;
_loading = false;
});
return;
}
final paired = await _hasActivePairing();
if (!paired) {
setState(() {
_needsPairing = true;
_loading = false;
});
return;
}
final res = await _api
.get('/guardian/ai-config')
.timeout(const Duration(seconds: 8));
final data = res.data['data'];
if (data is Map) {
setState(() {
_confidenceThreshold =
(data['confidenceThreshold'] as num?)?.toDouble() ?? 0.5;
_alertDistanceClose =
(data['alertDistanceClose'] as num?)?.toDouble() ?? 1.5;
_alertDistanceMedium =
(data['alertDistanceMedium'] as num?)?.toDouble() ?? 3.0;
_maxInferenceFps = (data['maxInferenceFps'] as num?)?.toInt() ?? 5;
_enabledLabels = data['enabledLabels']?.toString() ?? 'ALL';
});
}
final res = await _api
.get('/guardian/ai-config')
.timeout(const Duration(seconds: 8));
final data = res.data['data'];
if (data is Map) {
setState(() {
_confidenceThreshold =
(data['confidenceThreshold'] as num?)?.toDouble() ?? 0.5;
_alertDistanceClose =
(data['alertDistanceClose'] as num?)?.toDouble() ?? 1.5;
_alertDistanceMedium =
(data['alertDistanceMedium'] as num?)?.toDouble() ?? 3.0;
_maxInferenceFps = (data['maxInferenceFps'] as num?)?.toInt() ?? 5;
_enabledLabels = data['enabledLabels']?.toString() ?? 'ALL';
});
}
},
onError: (error) => setState(() {
_error = error is DioException
? friendlyDioMessage(error, fallback: 'Gagal memuat konfigurasi AI.')
? friendlyDioMessage(error,
fallback: 'Gagal memuat konfigurasi AI.')
: 'Gagal memuat konfigurasi AI. Coba refresh lagi.';
}),
);
@ -88,21 +93,21 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
setState(() => _saving = true);
await guarded<void>(
() async {
await _api.put('/guardian/ai-config', data: {
'confidenceThreshold': _confidenceThreshold,
'alertDistanceClose': _alertDistanceClose,
'alertDistanceMedium': _alertDistanceMedium,
'maxInferenceFps': _maxInferenceFps,
'enabledLabels': _enabledLabels,
}).timeout(const Duration(seconds: 8));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Konfigurasi AI berhasil disimpan'),
backgroundColor: Color(0xFF16A34A),
),
);
}
await _api.put('/guardian/ai-config', data: {
'confidenceThreshold': _confidenceThreshold,
'alertDistanceClose': _alertDistanceClose,
'alertDistanceMedium': _alertDistanceMedium,
'maxInferenceFps': _maxInferenceFps,
'enabledLabels': _enabledLabels,
}).timeout(const Duration(seconds: 8));
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Konfigurasi AI berhasil disimpan'),
backgroundColor: Color(0xFF16A34A),
),
);
}
},
onError: (error) {
if (!mounted) return;
@ -122,78 +127,85 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
Future<bool> _hasActivePairing() async {
return await guarded<bool>(
() async {
final res = await _api
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) return data['status'] == 'ACTIVE';
return false;
},
) ??
() async {
final res = await _api
.get('/shared/pairing/status')
.timeout(const Duration(seconds: 5));
final data = res.data['data'];
if (data is Map) return data['status'] == 'ACTIVE';
return false;
},
) ??
false;
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.softBlueBg, Colors.white],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: FadeSlideWrapper(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ââ Header ââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AI Config',
style: AppTextStyles.heading,
),
Text(
'Konfigurasi deteksi YOLO untuk User',
style: GoogleFonts.inter(
fontSize: 13,
color: const Color(0xFF64748B),
),
),
],
),
),
IconButton(
onPressed: () => context.go('/guardian/benchmark'),
icon: const Icon(Icons.speed_outlined),
tooltip: 'Benchmark',
color: const Color(0xFF64748B),
),
IconButton(
onPressed: _loading ? null : _load,
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Refresh',
color: const Color(0xFF64748B),
),
],
),
const SizedBox(height: 16),
// ââ Body âââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'AI Config',
style: GoogleFonts.outfit(
fontSize: 22,
fontWeight: FontWeight.w800,
color: const Color(0xFF0F172A),
),
),
Text(
'Konfigurasi deteksi YOLO untuk User',
style: GoogleFonts.inter(
fontSize: 13,
color: const Color(0xFF64748B),
),
),
],
),
),
IconButton(
onPressed: () => context.go('/guardian/benchmark'),
icon: const Icon(Icons.speed_outlined),
tooltip: 'Benchmark',
color: const Color(0xFF64748B),
),
IconButton(
onPressed: _loading ? null : _load,
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Refresh',
color: const Color(0xFF64748B),
child: _loading
? const Center(child: CircularProgressIndicator())
: _needsPairing
? _buildNoPairingPanel()
: _error != null
? _buildErrorPanel()
: _buildConfigForm(),
),
],
),
const SizedBox(height: 16),
// Body
Expanded(
child: _loading
? const Center(child: CircularProgressIndicator())
: _needsPairing
? _buildNoPairingPanel()
: _error != null
? _buildErrorPanel()
: _buildConfigForm(),
),
],
),
),
),
);
@ -201,289 +213,304 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
Widget _buildConfigForm() {
return SingleChildScrollView(
child: Column(
child: StaggerWrapper(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Confidence Threshold
_SectionCard(
title: 'Confidence Threshold',
subtitle:
'Minimal keyakinan AI untuk menganggap objek sebagai obstacle',
icon: Icons.tune_outlined,
iconColor: const Color(0xFF1A56DB),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ââ Confidence Threshold ââââââââââââââââââââââââââââââââââââââââââ
_SectionCard(
title: 'Confidence Threshold',
subtitle:
'Minimal keyakinan AI untuk menganggap objek sebagai obstacle',
icon: Icons.tune_outlined,
iconColor: const Color(0xFF1A56DB),
child: Column(
children: [
Text('Nilai saat ini:',
style: GoogleFonts.inter(
fontSize: 13, color: const Color(0xFF64748B))),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF1A56DB).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_confidenceThreshold.toStringAsFixed(2),
style: GoogleFonts.jetBrainsMono(
fontSize: 14,
fontWeight: FontWeight.w700,
color: const Color(0xFF1A56DB),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Nilai saat ini:',
style: GoogleFonts.inter(
fontSize: 13, color: const Color(0xFF64748B))),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color:
const Color(0xFF1A56DB).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
_confidenceThreshold.toStringAsFixed(2),
style: GoogleFonts.jetBrainsMono(
fontSize: 14,
fontWeight: FontWeight.w700,
color: const Color(0xFF1A56DB),
),
),
),
),
],
),
Slider(
value: _confidenceThreshold,
min: 0.1,
max: 0.9,
divisions: 8,
activeColor: const Color(0xFF1A56DB),
onChanged: (v) =>
setState(() => _confidenceThreshold = v),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('0.1 (sensitif)',
style: GoogleFonts.inter(
fontSize: 11, color: const Color(0xFF94A3B8))),
Text('0.9 (ketat)',
style: GoogleFonts.inter(
fontSize: 11, color: const Color(0xFF94A3B8))),
],
),
],
),
Slider(
value: _confidenceThreshold,
min: 0.1,
max: 0.9,
divisions: 8,
activeColor: const Color(0xFF1A56DB),
onChanged: (v) => setState(() => _confidenceThreshold = v),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('0.1 (sensitif)',
style: GoogleFonts.inter(
fontSize: 11, color: const Color(0xFF94A3B8))),
Text('0.9 (ketat)',
style: GoogleFonts.inter(
fontSize: 11, color: const Color(0xFF94A3B8))),
],
),
],
),
),
const SizedBox(height: 12),
// Alert Distances
_SectionCard(
title: 'Jarak Peringatan',
subtitle: 'Batas jarak (meter) untuk level peringatan',
icon: Icons.radar_outlined,
iconColor: const Color(0xFFD97706),
child: Column(
children: [
// Close
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFDC2626),
),
),
const SizedBox(width: 6),
Text('Jarak Dekat',
style: GoogleFonts.inter(
fontSize: 13, color: const Color(0xFF0F172A))),
]),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFDC2626).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_alertDistanceClose.toStringAsFixed(1)} m',
style: GoogleFonts.jetBrainsMono(
fontSize: 13,
fontWeight: FontWeight.w700,
color: const Color(0xFFDC2626),
),
),
),
],
),
Slider(
value: _alertDistanceClose,
min: 0.5,
max: 3.0,
divisions: 5,
activeColor: const Color(0xFFDC2626),
onChanged: (v) => setState(() => _alertDistanceClose = v),
),
const SizedBox(height: 8),
// Medium
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFD97706),
),
),
const SizedBox(width: 6),
Text('Jarak Sedang',
style: GoogleFonts.inter(
fontSize: 13, color: const Color(0xFF0F172A))),
]),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFD97706).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_alertDistanceMedium.toStringAsFixed(1)} m',
style: GoogleFonts.jetBrainsMono(
fontSize: 13,
fontWeight: FontWeight.w700,
color: const Color(0xFFD97706),
),
),
),
],
),
Slider(
value: _alertDistanceMedium,
min: 1.0,
max: 8.0,
divisions: 7,
activeColor: const Color(0xFFD97706),
onChanged: (v) => setState(() => _alertDistanceMedium = v),
),
],
),
),
const SizedBox(height: 12),
// Max Inference FPS
_SectionCard(
title: 'Max Inference FPS',
subtitle:
'Maksimal frame per detik untuk inferensi AI (lebih tinggi = lebih boros baterai)',
icon: Icons.speed_outlined,
iconColor: const Color(0xFF059669),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('FPS saat ini:',
style: GoogleFonts.inter(
fontSize: 13, color: const Color(0xFF64748B))),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF059669).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'$_maxInferenceFps fps',
style: GoogleFonts.jetBrainsMono(
fontSize: 14,
fontWeight: FontWeight.w700,
color: const Color(0xFF059669),
),
),
),
],
),
Slider(
value: _maxInferenceFps.toDouble(),
min: 1,
max: 30,
divisions: 29,
activeColor: const Color(0xFF059669),
onChanged: (v) =>
setState(() => _maxInferenceFps = v.toInt()),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('1 fps (hemat baterai)',
style: GoogleFonts.inter(
fontSize: 11, color: const Color(0xFF94A3B8))),
Text('30 fps (real-time)',
style: GoogleFonts.inter(
fontSize: 11, color: const Color(0xFF94A3B8))),
],
),
],
),
),
const SizedBox(height: 12),
// Enabled Labels
_SectionCard(
title: 'Label yang Diaktifkan',
subtitle: 'Jenis objek yang akan dideteksi AI',
icon: Icons.label_outline,
iconColor: const Color(0xFF7C3AED),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: _labelOptions.map((label) {
final selected = _enabledLabels == label;
return GestureDetector(
onTap: () => setState(() => _enabledLabels = label),
child: Container(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: selected ? const Color(0xFF7C3AED) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: selected
? const Color(0xFF7C3AED)
: const Color(0xFFE2E8F0),
),
),
child: Text(
label,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color:
selected ? Colors.white : const Color(0xFF64748B),
),
),
),
);
}).toList(),
),
),
const SizedBox(height: 24),
// Save button
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Icon(Icons.save_outlined),
label: Text(_saving ? 'Menyimpan...' : 'Simpan Konfigurasi'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF1A56DB),
padding: const EdgeInsets.symmetric(vertical: 14),
textStyle: GoogleFonts.inter(
fontSize: 14, fontWeight: FontWeight.w600),
),
),
const SizedBox(height: 12),
// ââ Alert Distances âââââââââââââââââââââââââââââââââââââââââââââââ
_SectionCard(
title: 'Jarak Peringatan',
subtitle: 'Batas jarak (meter) untuk level peringatan',
icon: Icons.radar_outlined,
iconColor: const Color(0xFFD97706),
child: Column(
children: [
// Close
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFDC2626),
),
),
const SizedBox(width: 6),
Text('Jarak Dekat',
style: GoogleFonts.inter(
fontSize: 13,
color: const Color(0xFF0F172A))),
]),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color:
const Color(0xFFDC2626).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_alertDistanceClose.toStringAsFixed(1)} m',
style: GoogleFonts.jetBrainsMono(
fontSize: 13,
fontWeight: FontWeight.w700,
color: const Color(0xFFDC2626),
),
),
),
],
),
Slider(
value: _alertDistanceClose,
min: 0.5,
max: 3.0,
divisions: 5,
activeColor: const Color(0xFFDC2626),
onChanged: (v) => setState(() => _alertDistanceClose = v),
),
const SizedBox(height: 8),
// Medium
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Row(children: [
Container(
width: 8,
height: 8,
decoration: const BoxDecoration(
shape: BoxShape.circle,
color: Color(0xFFD97706),
),
),
const SizedBox(width: 6),
Text('Jarak Sedang',
style: GoogleFonts.inter(
fontSize: 13,
color: const Color(0xFF0F172A))),
]),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color:
const Color(0xFFD97706).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'${_alertDistanceMedium.toStringAsFixed(1)} m',
style: GoogleFonts.jetBrainsMono(
fontSize: 13,
fontWeight: FontWeight.w700,
color: const Color(0xFFD97706),
),
),
),
],
),
Slider(
value: _alertDistanceMedium,
min: 1.0,
max: 8.0,
divisions: 7,
activeColor: const Color(0xFFD97706),
onChanged: (v) =>
setState(() => _alertDistanceMedium = v),
),
],
),
),
const SizedBox(height: 12),
// ââ Max Inference FPS âââââââââââââââââââââââââââââââââââââââââââââ
_SectionCard(
title: 'Max Inference FPS',
subtitle:
'Maksimal frame per detik untuk inferensi AI (lebih tinggi = lebih boros baterai)',
icon: Icons.speed_outlined,
iconColor: const Color(0xFF059669),
child: Column(
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('FPS saat ini:',
style: GoogleFonts.inter(
fontSize: 13, color: const Color(0xFF64748B))),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color:
const Color(0xFF059669).withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(20),
),
child: Text(
'$_maxInferenceFps fps',
style: GoogleFonts.jetBrainsMono(
fontSize: 14,
fontWeight: FontWeight.w700,
color: const Color(0xFF059669),
),
),
),
],
),
Slider(
value: _maxInferenceFps.toDouble(),
min: 1,
max: 30,
divisions: 29,
activeColor: const Color(0xFF059669),
onChanged: (v) =>
setState(() => _maxInferenceFps = v.toInt()),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('1 fps (hemat baterai)',
style: GoogleFonts.inter(
fontSize: 11, color: const Color(0xFF94A3B8))),
Text('30 fps (real-time)',
style: GoogleFonts.inter(
fontSize: 11, color: const Color(0xFF94A3B8))),
],
),
],
),
),
const SizedBox(height: 12),
// ââ Enabled Labels ââââââââââââââââââââââââââââââââââââââââââââââââ
_SectionCard(
title: 'Label yang Diaktifkan',
subtitle: 'Jenis objek yang akan dideteksi AI',
icon: Icons.label_outline,
iconColor: const Color(0xFF7C3AED),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: _labelOptions.map((label) {
final selected = _enabledLabels == label;
return GestureDetector(
onTap: () => setState(() => _enabledLabels = label),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color:
selected ? const Color(0xFF7C3AED) : Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: selected
? const Color(0xFF7C3AED)
: const Color(0xFFE2E8F0),
),
),
child: Text(
label,
style: GoogleFonts.inter(
fontSize: 13,
fontWeight: FontWeight.w600,
color: selected
? Colors.white
: const Color(0xFF64748B),
),
),
),
);
}).toList(),
),
),
const SizedBox(height: 24),
// ââ Save button âââââââââââââââââââââââââââââââââââââââââââââââââââ
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: _saving ? null : _save,
icon: _saving
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Icon(Icons.save_outlined),
label: Text(_saving ? 'Menyimpan...' : 'Simpan Konfigurasi'),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF1A56DB),
padding: const EdgeInsets.symmetric(vertical: 14),
textStyle: GoogleFonts.inter(
fontSize: 14, fontWeight: FontWeight.w600),
),
),
),
const SizedBox(height: 8),
],
),
const SizedBox(height: 8),
],
),
);
@ -496,8 +523,9 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: const Color(0xFFFFFBEB),
borderRadius: BorderRadius.circular(16),
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFFDE68A)),
boxShadow: AppDecorations.cardShadow,
),
child: Column(
mainAxisSize: MainAxisSize.min,
@ -552,9 +580,9 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
}
}
//
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
// SECTION CARD
//
// âââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââââ
class _SectionCard extends StatelessWidget {
final String title;
@ -576,16 +604,10 @@ class _SectionCard extends StatelessWidget {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.03),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -596,7 +618,7 @@ class _SectionCard extends StatelessWidget {
height: 34,
decoration: BoxDecoration(
color: iconColor.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
borderRadius: BorderRadius.circular(50),
),
child: Icon(icon, color: iconColor, size: 18),
),

View File

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

View File

@ -8,6 +8,9 @@ import 'package:record/record.dart';
import '../../app/injection_container.dart';
import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../shared/widgets/animations/animations.dart';
import '../../shared/widgets/feature_page.dart';
class GuardianSendNotifScreen extends StatefulWidget {
@ -132,132 +135,130 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
child: ListView(
children: [
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: [
BoxShadow(
color: const Color(0xFF1E293B).withValues(alpha: 0.06),
blurRadius: 22,
offset: const Offset(0, 12),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(
value: false,
icon: Icon(Icons.message_outlined),
label: Text('Text'),
FadeSlideWrapper(
child: Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SegmentedButton<bool>(
segments: const [
ButtonSegment(
value: false,
icon: Icon(Icons.message_outlined),
label: Text('Text'),
),
ButtonSegment(
value: true,
icon: Icon(Icons.mic_none_outlined),
label: Text('Voice'),
),
],
selected: {_voiceMode},
onSelectionChanged: _loading || _recording
? null
: (value) => setState(() => _voiceMode = value.first),
),
const SizedBox(height: 14),
TextField(
controller: _message,
minLines: _voiceMode ? 2 : 5,
maxLines: _voiceMode ? 3 : 8,
decoration: const InputDecoration(
labelText: 'Message',
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
prefixIcon: Icon(Icons.message_outlined),
alignLabelWithHint: true,
),
ButtonSegment(
value: true,
icon: Icon(Icons.mic_none_outlined),
label: Text('Voice'),
),
if (_voiceMode) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
),
child: Row(
children: [
CircleAvatar(
backgroundColor: _recording
? const Color(0xFFFEE2E2)
: const Color(0xFFEFF6FF),
child: Icon(
_recording ? Icons.graphic_eq : Icons.mic,
color: _recording
? const Color(0xFFDC2626)
: const Color(0xFF2563EB),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_recording
? 'Recording... tap stop when done'
: _voicePath == null
? 'No voice note recorded'
: 'Voice note ready',
style: const TextStyle(
fontWeight: FontWeight.w800),
),
Text(
_recording
? 'Speak clearly near the microphone'
: _voicePath == null
? 'Record a short message for User'
: '${_voiceDuration}s audio attached',
style: const TextStyle(
color: Color(0xFF64748B), fontSize: 12),
),
],
),
),
FilledButton.icon(
onPressed: _loading ? null : _toggleRecording,
icon: Icon(_recording
? Icons.stop
: Icons.fiber_manual_record),
label: Text(_recording ? 'Stop' : 'Record'),
style: FilledButton.styleFrom(
backgroundColor: _recording
? const Color(0xFFDC2626)
: const Color(0xFF2563EB),
),
),
],
),
),
],
selected: {_voiceMode},
onSelectionChanged: _loading || _recording
? null
: (value) => setState(() => _voiceMode = value.first),
),
const SizedBox(height: 14),
TextField(
controller: _message,
minLines: _voiceMode ? 2 : 5,
maxLines: _voiceMode ? 3 : 8,
decoration: const InputDecoration(
labelText: 'Message',
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
prefixIcon: Icon(Icons.message_outlined),
alignLabelWithHint: true,
),
),
if (_voiceMode) ...[
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFFE2E8F0)),
),
child: Row(
children: [
CircleAvatar(
backgroundColor: _recording
? const Color(0xFFFEE2E2)
: const Color(0xFFEFF6FF),
child: Icon(
_recording ? Icons.graphic_eq : Icons.mic,
color: _recording
? const Color(0xFFDC2626)
: const Color(0xFF2563EB),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_recording
? 'Recording... tap stop when done'
: _voicePath == null
? 'No voice note recorded'
: 'Voice note ready',
style: const TextStyle(
fontWeight: FontWeight.w800),
),
Text(
_recording
? 'Speak clearly near the microphone'
: _voicePath == null
? 'Record a short message for User'
: '${_voiceDuration}s audio attached',
style: const TextStyle(
color: Color(0xFF64748B), fontSize: 12),
),
],
),
),
FilledButton.icon(
onPressed: _loading ? null : _toggleRecording,
icon: Icon(_recording ? Icons.stop : Icons.fiber_manual_record),
label: Text(_recording ? 'Stop' : 'Record'),
style: FilledButton.styleFrom(
backgroundColor: _recording
? const Color(0xFFDC2626)
: const Color(0xFF2563EB),
),
),
],
),
FilledButton.icon(
onPressed: _loading ? null : _send,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(_loading
? 'Sending...'
: _voiceMode
? 'Send Voice Message'
: 'Send Message'),
),
],
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _loading ? null : _send,
icon: _loading
? const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.send),
label: Text(_loading
? 'Sending...'
: _voiceMode
? 'Send Voice Message'
: 'Send Message'),
),
],
),
),
),
],

View File

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

View File

@ -1,5 +1,8 @@
import 'package:flutter/material.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_text_styles.dart';
class HomeScreen extends StatelessWidget {
final String role;
@ -7,12 +10,24 @@ class HomeScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Dashboard Walk Guide')),
body: Center(
child: Text(
role == 'ROLE_ADMIN' ? 'Selamat Datang Admin!' : 'Mode Walk Guide Siap!',
style: const TextStyle(fontSize: 24),
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.softBlueBg, Colors.white],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Center(
child: Padding(
padding: const EdgeInsets.all(24),
child: Text(
role == 'ROLE_ADMIN'
? 'Selamat Datang Admin!'
: 'Mode Walk Guide Siap!',
textAlign: TextAlign.center,
style: AppTextStyles.heading,
),
),
),
);

View File

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

View File

@ -1,6 +1,10 @@
import 'package:flutter/material.dart';
import '../../core/services/voice_command_handler.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../shared/widgets/animations/animations.dart';
import '../../shared/widgets/feature_page.dart';
class ManualScreen extends StatelessWidget {
const ManualScreen({super.key});
@ -8,16 +12,38 @@ class ManualScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
final commands = VoiceCommandKey.values.map((key) => key.name).toList();
return Scaffold(
appBar: AppBar(title: const Text('Manual')),
body: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: commands.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, index) => ListTile(
leading: const Icon(Icons.record_voice_over),
title: Text(commands[index]),
),
return FeaturePage(
title: 'Manual',
subtitle: 'Voice command yang tersedia',
child: ListView(
children: [
StaggerWrapper(
children: [
for (final command in commands)
Container(
margin: const EdgeInsets.only(bottom: 10),
decoration: BoxDecoration(
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: AppColors.border),
boxShadow: AppDecorations.cardShadow,
),
child: ListTile(
leading: Container(
width: 44,
height: 44,
decoration: AppDecorations.iconCircle(),
child: const Icon(
Icons.record_voice_over,
color: AppColors.primaryBlue,
),
),
title: Text(command),
),
),
],
),
],
),
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -14,6 +14,10 @@ import '../../core/errors/friendly_error.dart';
import '../../core/network/api_client.dart';
import '../../core/services/haptic_service.dart';
import '../../core/services/tts_service.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import '../../core/theme/app_text_styles.dart';
import '../../shared/widgets/animations/animations.dart';
import 'application/sos_cubit.dart';
Dio get _api => sl<ApiClient>().dio;
@ -252,106 +256,120 @@ class _SosScreenState extends State<SosScreen>
: compact
? 12.0
: 24.0;
return SafeArea(
child: Padding(
padding: EdgeInsets.all(pagePadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header
Row(
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
colors: [AppColors.softBlueBg, Colors.white],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: SafeArea(
child: FadeSlideWrapper(
child: Padding(
padding: EdgeInsets.all(pagePadding),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SOS',
style: Theme.of(context)
.textTheme
.headlineSmall
?.copyWith(fontWeight: FontWeight.w800),
),
const Text(
'Emergency alert ke Guardian',
style: TextStyle(color: Color(0xFF64748B)),
),
],
),
),
IconButton(
onPressed: _loadHistory,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh riwayat',
),
],
),
SizedBox(height: sectionGap),
// Active SOS banner
if (_hasActiveSos)
_ActiveSosBanner(
event: _events.first, onRefresh: _loadHistory),
SizedBox(height: sectionGap),
// SOS Button
Center(
child: sending
? const _SendingIndicator()
: AnimatedBuilder(
animation: _pulseAnim,
builder: (_, child) => Transform.scale(
scale: _hasActiveSos ? _pulseAnim.value : 1.0,
child: child,
),
child: _SosButton(
active: _hasActiveSos,
onPressed: _confirmAndSend,
// Header
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'SOS',
style: AppTextStyles.heading,
),
Text(
'Emergency alert ke Guardian',
style: AppTextStyles.body.copyWith(
color: AppColors.textMuted,
),
),
],
),
),
),
const SizedBox(height: 8),
// Hint text
Text(
_hasActiveSos
? 'SOS aktif — Guardian sudah mendapat notifikasi'
: 'Tekan tombol untuk kirim SOS darurat ke Guardian',
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _hasActiveSos
? const Color(0xFFDC2626)
: const Color(0xFF64748B),
fontWeight:
_hasActiveSos ? FontWeight.w700 : FontWeight.normal,
),
),
SizedBox(height: sectionGap),
// History section
if (!landscapeTight) ...[
const Text(
'Riwayat SOS',
style: TextStyle(fontWeight: FontWeight.w800, fontSize: 16),
),
const SizedBox(height: 10),
Expanded(
child: _SosHistory(
loading: _historyLoading,
error: _historyError,
events: _events,
onRefresh: _loadHistory,
IconButton(
onPressed: _loadHistory,
icon: const Icon(Icons.refresh),
tooltip: 'Refresh riwayat',
),
],
),
),
] else
const Spacer(),
],
SizedBox(height: sectionGap),
// Active SOS banner
if (_hasActiveSos)
_ActiveSosBanner(
event: _events.first, onRefresh: _loadHistory),
SizedBox(height: sectionGap),
// SOS Button
Center(
child: sending
? const _SendingIndicator()
: AnimatedBuilder(
animation: _pulseAnim,
builder: (_, child) => Transform.scale(
scale: _hasActiveSos ? _pulseAnim.value : 1.0,
child: child,
),
child: _SosButton(
active: _hasActiveSos,
onPressed: _confirmAndSend,
),
),
),
const SizedBox(height: 8),
// Hint text
Text(
_hasActiveSos
? 'SOS aktif — Guardian sudah mendapat notifikasi'
: 'Tekan tombol untuk kirim SOS darurat ke Guardian',
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
color: _hasActiveSos
? const Color(0xFFDC2626)
: const Color(0xFF64748B),
fontWeight:
_hasActiveSos ? FontWeight.w700 : FontWeight.normal,
),
),
SizedBox(height: sectionGap),
// History section
if (!landscapeTight) ...[
const Text(
'Riwayat SOS',
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 16,
color: AppColors.textDark,
),
),
const SizedBox(height: 10),
Expanded(
child: _SosHistory(
loading: _historyLoading,
error: _historyError,
events: _events,
onRefresh: _loadHistory,
),
),
] else
const Spacer(),
],
),
),
),
),
);
@ -383,36 +401,52 @@ class _SosButton extends StatelessWidget {
: compact
? 154.0
: 200.0;
return SizedBox.square(
dimension: dimension,
child: FilledButton(
style: FilledButton.styleFrom(
shape: const CircleBorder(),
backgroundColor:
active ? const Color(0xFFB91C1C) : const Color(0xFFDC2626),
elevation: active ? 12 : 4,
shadowColor: const Color(0xFFDC2626).withValues(alpha: 0.5),
),
onPressed: onPressed,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
active ? Icons.emergency : Icons.emergency_outlined,
size: dimension < 150 ? 34 : 48,
color: Colors.white,
return BounceTap(
onTap: onPressed,
child: Semantics(
button: true,
label: 'Kirim SOS',
child: Container(
width: dimension,
height: dimension,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: LinearGradient(
colors: active
? const [Color(0xFFDC2626), Color(0xFF991B1B)]
: const [Color(0xFFFF5A5A), Color(0xFFDC2626)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
SizedBox(height: dimension < 150 ? 3 : 6),
Text(
'SOS',
style: TextStyle(
fontSize: dimension < 150 ? 28 : 38,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 2,
boxShadow: [
BoxShadow(
color: AppColors.danger.withValues(alpha: active ? 0.34 : 0.22),
blurRadius: active ? 28 : 18,
offset: const Offset(0, 10),
),
),
],
],
),
alignment: Alignment.center,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
active ? Icons.emergency : Icons.emergency_outlined,
size: dimension < 150 ? 34 : 48,
color: Colors.white,
),
SizedBox(height: dimension < 150 ? 3 : 6),
Text(
'SOS',
style: TextStyle(
fontSize: dimension < 150 ? 28 : 38,
fontWeight: FontWeight.w900,
color: Colors.white,
letterSpacing: 2,
),
),
],
),
),
),
);
@ -439,6 +473,7 @@ class _SendingIndicator extends StatelessWidget {
color: const Color(0xFFDC2626).withValues(alpha: 0.15),
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFFDC2626), width: 3),
boxShadow: AppDecorations.cardShadow,
),
child: const Center(
child: Column(
@ -470,8 +505,9 @@ class _ActiveSosBanner extends StatelessWidget {
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFFEE2E2),
borderRadius: BorderRadius.circular(12),
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFFCA5A5), width: 1.5),
boxShadow: AppDecorations.cardShadow,
),
child: Row(
children: [
@ -531,10 +567,21 @@ class _SosHistory extends StatelessWidget {
if (events.isEmpty) {
return _HistoryEmpty(onRefresh: onRefresh);
}
return ListView.separated(
itemCount: events.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (_, i) => _SosEventTile(event: events[i]),
return RefreshIndicator(
onRefresh: () async => onRefresh(),
child: ListView(
children: [
StaggerWrapper(
children: [
for (final event in events)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: _SosEventTile(event: event),
),
],
),
],
),
);
}
}
@ -570,8 +617,9 @@ class _SosEventTile extends StatelessWidget {
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: AppDecorations.cardShadow,
),
child: Row(
children: [
@ -662,9 +710,10 @@ class _HistoryEmpty extends StatelessWidget {
width: double.infinity,
padding: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFFF8FAFC),
borderRadius: BorderRadius.circular(12),
color: AppColors.cardWhite,
borderRadius: AppDecorations.cardRadius,
border: Border.all(color: const Color(0xFFE2E8F0)),
boxShadow: AppDecorations.cardShadow,
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,

View File

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

View File

@ -2,18 +2,10 @@ import 'dart:async';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:flutter/foundation.dart';
import 'app/injection_container.dart';
import 'app/app.dart';
import 'core/constants/app_constants.dart';
import 'core/utils/init_guard.dart';
@pragma('vm:entry-point')
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
await Firebase.initializeApp();
}
import 'shared/widgets/walkguide_loading_screen.dart';
Future<void> main() async {
await runZonedGuarded(
@ -21,18 +13,7 @@ Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
_installGlobalErrorUi();
await AppConstants.clearServerUrl();
if (!kIsWeb) {
final firebaseApp = await ignoreInitFailure(
() => Firebase.initializeApp(),
label: 'Firebase init',
);
if (firebaseApp != null) {
FirebaseMessaging.onBackgroundMessage(
_firebaseMessagingBackgroundHandler,
);
}
}
runApp(const WalkGuideBootApp());
try {
await initDependencies();
@ -51,6 +32,20 @@ Future<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() {
FlutterError.onError = (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
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
@ -11,6 +13,8 @@ import '../../core/services/stt_service.dart';
import '../../core/services/tts_service.dart';
import '../../core/services/voice_command_handler.dart';
import '../../core/theme/app_colors.dart';
import '../../core/theme/app_decorations.dart';
import 'animations/animations.dart';
class UserShell extends StatefulWidget {
final Widget child;
@ -26,9 +30,6 @@ class _UserShellState extends State<UserShell> {
super.initState();
_loadVoiceCommands();
_startHardwareShortcuts();
WidgetsBinding.instance.addPostFrameCallback((_) {
_startVoiceListening();
});
sl<VoiceCommandHandler>().onCommand = (key) {
if (!mounted) return;
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 {
await runFriendlyAction(
() async {
@ -139,6 +129,7 @@ class _UserShellState extends State<UserShell> {
@override
void dispose() {
sl<HardwareShortcutListener>().stopListening();
unawaited(sl<SttService>().stopListening());
super.dispose();
}
@ -198,15 +189,7 @@ class _AppShell extends StatelessWidget {
return LayoutBuilder(
builder: (context, constraints) {
final useRail = constraints.maxWidth >= 760;
final content = AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
switchInCurve: Curves.easeOutCubic,
switchOutCurve: Curves.easeInCubic,
child: KeyedSubtree(
key: ValueKey(location),
child: child,
),
);
final content = child;
return Scaffold(
backgroundColor: AppColors.surface,
@ -257,71 +240,40 @@ class _RailNavigation extends StatelessWidget {
final itemHeight = compact ? 58.0 : 70.0;
return DecoratedBox(
decoration: const BoxDecoration(color: Colors.white),
decoration: const BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Color(0x14000000),
blurRadius: 18,
offset: Offset(6, 0),
),
],
),
child: SafeArea(
right: false,
child: SizedBox(
width: width,
child: ListView.separated(
child: Padding(
padding: EdgeInsets.symmetric(
horizontal: 8,
vertical: compact ? 6 : 12,
),
itemCount: items.length,
separatorBuilder: (_, __) => SizedBox(height: compact ? 2 : 6),
itemBuilder: (context, index) {
final item = items[index];
final selected = index == selectedIndex;
return Semantics(
button: true,
selected: selected,
label: item.label,
child: InkWell(
borderRadius: BorderRadius.circular(18),
onTap: () => context.go(items[index].route),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
height: itemHeight,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: selected
? const Color(0xFFEFF6FF)
: Colors.transparent,
borderRadius: BorderRadius.circular(18),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
selected ? item.selectedIcon : item.icon,
size: compact ? 23 : 25,
color: selected
? AppColors.primary
: const Color(0xFF334155),
),
SizedBox(height: compact ? 2 : 5),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: compact ? 10 : 12,
height: 1,
fontWeight: selected
? FontWeight.w800
: FontWeight.w600,
color: selected
? const Color(0xFF1D4ED8)
: const Color(0xFF334155),
),
),
],
child: Column(
children: [
for (var index = 0; index < items.length; index++)
Expanded(
child: Center(
child: _RailNavItem(
item: items[index],
selected: index == selectedIndex,
compact: compact,
height: itemHeight,
),
),
),
),
);
},
],
),
),
),
),
@ -344,82 +296,154 @@ class _BottomScrollNavigation extends StatelessWidget {
Widget build(BuildContext context) {
final bottom = MediaQuery.of(context).padding.bottom;
final extraBottom = bottom > 12 ? 12.0 : bottom;
return DecoratedBox(
return Container(
margin: EdgeInsets.zero,
decoration: BoxDecoration(
color: Colors.white,
border: const Border(top: BorderSide(color: AppColors.border)),
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.06),
blurRadius: 18,
offset: const Offset(0, -8),
),
AppDecorations.cardShadow.first,
],
),
child: SafeArea(
top: false,
child: SizedBox(
height: 68 + extraBottom,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
itemCount: items.length,
separatorBuilder: (_, __) => const SizedBox(width: 6),
itemBuilder: (context, index) {
final item = items[index];
final selected = index == selectedIndex;
return Semantics(
button: true,
selected: selected,
label: item.label,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: () => context.go(item.route),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
width: 72,
padding: const EdgeInsets.symmetric(horizontal: 6),
decoration: BoxDecoration(
color: selected
? const Color(0xFFEFF6FF)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: selected
? const Color(0xFFBFDBFE)
: Colors.transparent,
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
selected ? item.selectedIcon : item.icon,
color: selected
? AppColors.primary
: const Color(0xFF64748B),
size: 22,
),
const SizedBox(height: 4),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight:
selected ? FontWeight.w800 : FontWeight.w600,
color: selected
? AppColors.primary
: const Color(0xFF64748B),
),
),
],
child: ClipRRect(
borderRadius: const BorderRadius.vertical(top: Radius.circular(24)),
child: SafeArea(
top: false,
child: SizedBox(
height: 68 + extraBottom,
child: Row(
children: [
for (var index = 0; index < items.length; index++)
Expanded(
child: _BottomNavItem(
item: items[index],
selected: index == selectedIndex,
),
),
],
),
),
),
),
);
}
}
class _RailNavItem extends StatelessWidget {
final _ShellItem item;
final bool selected;
final bool compact;
final double height;
const _RailNavItem({
required this.item,
required this.selected,
required this.compact,
required this.height,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
label: item.label,
child: BounceTap(
onTap: () => context.go(item.route),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
height: height,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 4),
decoration: BoxDecoration(
color: selected ? AppColors.softBlueBg : Colors.transparent,
borderRadius: BorderRadius.circular(24),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
selected ? item.selectedIcon : item.icon,
size: compact ? 21 : 24,
color: selected ? AppColors.primary : AppColors.muted,
),
SizedBox(height: compact ? 1 : 4),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: compact ? 9.5 : 11.5,
height: 1,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
color: selected ? AppColors.primary : AppColors.muted,
),
);
},
),
],
),
),
),
);
}
}
class _BottomNavItem extends StatelessWidget {
final _ShellItem item;
final bool selected;
const _BottomNavItem({
required this.item,
required this.selected,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
selected: selected,
label: item.label,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 8),
child: BounceTap(
onTap: () => context.go(item.route),
child: AnimatedContainer(
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
padding: const EdgeInsets.symmetric(horizontal: 2),
decoration: BoxDecoration(
gradient: selected ? AppDecorations.blueGradient : null,
borderRadius: BorderRadius.circular(50),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedScale(
scale: selected ? 1.08 : 1.0,
duration: const Duration(milliseconds: 160),
curve: Curves.easeOutCubic,
child: Icon(
selected ? item.selectedIcon : item.icon,
color: selected ? Colors.white : AppColors.muted,
size: 21,
),
),
const SizedBox(height: 3),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 10,
height: 1,
fontWeight: selected ? FontWeight.w800 : FontWeight.w600,
color: selected ? Colors.white : AppColors.muted,
),
),
],
),
),
),
),

View File

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

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"