POLISHING STUFF and Smarter AI, and alot i mean.. ALOT OF BUG FIX AND OPTIMIZE
This commit is contained in:
parent
1110e5a42d
commit
d2b3534dde
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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"
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -77,4 +77,4 @@ vase
|
||||
scissors
|
||||
teddy bear
|
||||
hair drier
|
||||
toothbrush
|
||||
toothbrush
|
||||
Binary file not shown.
@ -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),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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 = {
|
||||
|
||||
@ -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';
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
@ -58,4 +90,4 @@ class TtsService {
|
||||
_lastSpoken = text;
|
||||
_tts.speak(text);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
@ -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),
|
||||
);
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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 [];
|
||||
}
|
||||
|
||||
|
||||
@ -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 {
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@ -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 {
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
@ -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',
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@ -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(
|
||||
|
||||
@ -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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,5 +1,8 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
import '../../core/theme/app_text_styles.dart';
|
||||
|
||||
class HomeScreen extends StatelessWidget {
|
||||
final String role;
|
||||
|
||||
@ -7,14 +10,26 @@ class HomeScreen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: AppBar(title: const Text('Dashboard Walk Guide')),
|
||||
body: Center(
|
||||
child: Text(
|
||||
role == 'ROLE_ADMIN' ? 'Selamat Datang Admin!' : 'Mode Walk Guide Siap!',
|
||||
style: const TextStyle(fontSize: 24),
|
||||
return DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [AppColors.softBlueBg, Colors.white],
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Text(
|
||||
role == 'ROLE_ADMIN'
|
||||
? 'Selamat Datang Admin!'
|
||||
: 'Mode Walk Guide Siap!',
|
||||
textAlign: TextAlign.center,
|
||||
style: AppTextStyles.heading,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
|
||||
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -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)),
|
||||
],
|
||||
])),
|
||||
],
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -0,0 +1,3 @@
|
||||
export 'bounce_tap.dart';
|
||||
export 'fade_slide_wrapper.dart';
|
||||
export 'stagger_wrapper.dart';
|
||||
@ -0,0 +1,90 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class BounceTap extends StatefulWidget {
|
||||
final Widget child;
|
||||
final VoidCallback? onTap;
|
||||
final HitTestBehavior behavior;
|
||||
final Duration pressDuration;
|
||||
final Duration releaseDuration;
|
||||
|
||||
const BounceTap({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.onTap,
|
||||
this.behavior = HitTestBehavior.opaque,
|
||||
this.pressDuration = const Duration(milliseconds: 80),
|
||||
this.releaseDuration = const Duration(milliseconds: 120),
|
||||
});
|
||||
|
||||
@override
|
||||
State<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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -0,0 +1 @@
|
||||
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/battery_plus-6.2.3/
|
||||
@ -0,0 +1 @@
|
||||
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/connectivity_plus-6.1.5/
|
||||
@ -0,0 +1 @@
|
||||
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/device_info_plus-11.5.0/
|
||||
@ -0,0 +1 @@
|
||||
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_local_notifications_linux-4.0.1/
|
||||
@ -0,0 +1 @@
|
||||
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/flutter_secure_storage_linux-1.2.3/
|
||||
@ -0,0 +1 @@
|
||||
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/path_provider_linux-2.2.1/
|
||||
@ -0,0 +1 @@
|
||||
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/record_linux-1.3.0/
|
||||
@ -0,0 +1 @@
|
||||
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/shared_preferences_linux-2.4.1/
|
||||
@ -0,0 +1 @@
|
||||
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/sqlite3_flutter_libs-0.5.42/
|
||||
@ -0,0 +1 @@
|
||||
C:/Users/Evan/AppData/Local/Pub/Cache/hosted/pub.dev/tflite_flutter-0.12.1/
|
||||
@ -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);
|
||||
}
|
||||
@ -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_
|
||||
@ -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)
|
||||
@ -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"))
|
||||
}
|
||||
@ -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
|
||||
@ -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"
|
||||
Loading…
x
Reference in New Issue
Block a user