Update UI + Agora Call
This commit is contained in:
@@ -5,6 +5,10 @@ plugins {
|
||||
id("dev.flutter.flutter-gradle-plugin")
|
||||
}
|
||||
|
||||
if (file("google-services.json").exists()) {
|
||||
apply(plugin = "com.google.gms.google-services")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "com.example.walkguide_app"
|
||||
compileSdk = flutter.compileSdkVersion
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
org.gradle.jvmargs=-Xmx8G -XX:MaxMetaspaceSize=4G -XX:ReservedCodeCacheSize=512m -XX:+HeapDumpOnOutOfMemoryError
|
||||
org.gradle.jvmargs=-Xmx2G -XX:MaxMetaspaceSize=1G -XX:ReservedCodeCacheSize=256m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8
|
||||
org.gradle.workers.max=2
|
||||
org.gradle.parallel=false
|
||||
org.gradle.daemon=false
|
||||
android.useAndroidX=true
|
||||
android.enableJetifier=true
|
||||
kotlin.incremental=false
|
||||
kotlin.incremental=false
|
||||
@@ -21,6 +21,7 @@ plugins {
|
||||
id("dev.flutter.flutter-plugin-loader") version "1.0.0"
|
||||
id("com.android.application") version "8.9.1" apply false
|
||||
id("org.jetbrains.kotlin.android") version "2.1.0" apply false
|
||||
id("com.google.gms.google-services") version "4.4.2" apply false
|
||||
}
|
||||
|
||||
include(":app")
|
||||
|
||||
@@ -4,13 +4,14 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import 'app_cubit.dart';
|
||||
import 'router.dart';
|
||||
import '../core/theme/app_colors.dart';
|
||||
|
||||
class WalkGuideApp extends StatelessWidget {
|
||||
const WalkGuideApp({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const seed = Color(0xFF1A56DB);
|
||||
const seed = AppColors.primary;
|
||||
|
||||
return BlocProvider(
|
||||
create: (_) => AppCubit(),
|
||||
@@ -23,9 +24,15 @@ class WalkGuideApp extends StatelessWidget {
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: seed,
|
||||
brightness: Brightness.light,
|
||||
primary: seed,
|
||||
secondary: AppColors.accent,
|
||||
error: AppColors.danger,
|
||||
),
|
||||
scaffoldBackgroundColor: AppColors.surface,
|
||||
textTheme: GoogleFonts.interTextTheme().apply(
|
||||
bodyColor: AppColors.text,
|
||||
displayColor: AppColors.text,
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFFF4F7FB),
|
||||
textTheme: GoogleFonts.interTextTheme(),
|
||||
pageTransitionsTheme: const PageTransitionsTheme(
|
||||
builders: {
|
||||
TargetPlatform.android: ZoomPageTransitionsBuilder(),
|
||||
@@ -35,16 +42,41 @@ class WalkGuideApp extends StatelessWidget {
|
||||
),
|
||||
appBarTheme: const AppBarTheme(
|
||||
centerTitle: false,
|
||||
backgroundColor: Color(0xFFF4F7FB),
|
||||
foregroundColor: Color(0xFF0F172A),
|
||||
backgroundColor: AppColors.surface,
|
||||
foregroundColor: AppColors.text,
|
||||
elevation: 0,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
),
|
||||
cardTheme: CardThemeData(
|
||||
elevation: 0,
|
||||
color: AppColors.surfaceRaised,
|
||||
surfaceTintColor: Colors.transparent,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
dividerTheme: const DividerThemeData(
|
||||
color: AppColors.border,
|
||||
thickness: 1,
|
||||
space: 1,
|
||||
),
|
||||
iconButtonTheme: IconButtonThemeData(
|
||||
style: IconButton.styleFrom(
|
||||
foregroundColor: AppColors.text,
|
||||
backgroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
elevation: 0,
|
||||
height: 76,
|
||||
backgroundColor: Colors.white.withValues(alpha: 0.96),
|
||||
indicatorColor: const Color(0xFFE0E7FF),
|
||||
backgroundColor: Colors.white,
|
||||
indicatorColor: const Color(0xFFDDEAFE),
|
||||
surfaceTintColor: Colors.transparent,
|
||||
labelTextStyle: WidgetStateProperty.resolveWith(
|
||||
(states) => TextStyle(
|
||||
fontSize: 12,
|
||||
@@ -61,7 +93,7 @@ class WalkGuideApp extends StatelessWidget {
|
||||
minimumSize: const Size(0, 50),
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -70,27 +102,38 @@ class WalkGuideApp extends StatelessWidget {
|
||||
minimumSize: const Size(0, 50),
|
||||
foregroundColor: seed,
|
||||
textStyle: const TextStyle(fontWeight: FontWeight.w800),
|
||||
side: const BorderSide(color: Color(0xFFCBD5E1)),
|
||||
side: const BorderSide(color: AppColors.border),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
),
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
behavior: SnackBarBehavior.floating,
|
||||
backgroundColor: AppColors.text,
|
||||
contentTextStyle: GoogleFonts.inter(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
filled: true,
|
||||
fillColor: const Color(0xFFF8FAFC),
|
||||
fillColor: Colors.white,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderSide: const BorderSide(color: Color(0xFFE2E8F0)),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: AppColors.border),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
borderSide: const BorderSide(color: seed, width: 1.5),
|
||||
),
|
||||
),
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../core/services/haptic_service.dart';
|
||||
import '../core/services/call_service.dart';
|
||||
import '../core/services/fcm_service.dart';
|
||||
import '../core/services/hardware_shortcut_listener.dart';
|
||||
import '../core/services/incoming_call_polling_service.dart';
|
||||
import '../core/services/location_reporter_service.dart';
|
||||
import '../core/services/offline_queue_service.dart';
|
||||
import '../core/services/stt_service.dart';
|
||||
@@ -39,17 +40,24 @@ Future<void> initDependencies() async {
|
||||
sl.registerLazySingleton<SttService>(() => SttService());
|
||||
sl.registerLazySingleton<HapticService>(() => HapticService());
|
||||
sl.registerLazySingleton<ObstacleAlertStrategy>(
|
||||
() => TtsWithHapticObstacleAlertStrategy(sl<TtsService>(), sl<HapticService>()),
|
||||
() => TtsWithHapticObstacleAlertStrategy(
|
||||
sl<TtsService>(), sl<HapticService>()),
|
||||
);
|
||||
sl.registerLazySingleton<ObstacleAnalyzer>(() => ObstacleAnalyzer());
|
||||
sl.registerLazySingleton<YoloDetector>(() => YoloDetector(sl<ObstacleAnalyzer>()));
|
||||
sl.registerLazySingleton<YoloDetector>(
|
||||
() => YoloDetector(sl<ObstacleAnalyzer>()));
|
||||
sl.registerLazySingleton<OfflineQueueService>(
|
||||
() => OfflineQueueService(sl<LocalDatabase>()),
|
||||
);
|
||||
sl.registerLazySingleton<FcmService>(() => FcmService(sl<ApiClient>()));
|
||||
sl.registerLazySingleton<WebSocketService>(() => WebSocketService(sl<SecureStorage>()));
|
||||
sl.registerLazySingleton<LocationReporterService>(() => LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
|
||||
sl.registerLazySingleton<WebSocketService>(
|
||||
() => WebSocketService(sl<SecureStorage>()));
|
||||
sl.registerLazySingleton<LocationReporterService>(() =>
|
||||
LocationReporterService(sl<ApiClient>(), sl<OfflineQueueService>()));
|
||||
sl.registerLazySingleton<CallService>(() => CallService(sl<ApiClient>()));
|
||||
sl.registerLazySingleton<IncomingCallPollingService>(
|
||||
() => IncomingCallPollingService(sl<ApiClient>()),
|
||||
);
|
||||
sl.registerLazySingleton<HardwareShortcutListener>(
|
||||
() => HardwareShortcutListener(sl<ApiClient>()),
|
||||
);
|
||||
@@ -59,8 +67,10 @@ Future<void> initDependencies() async {
|
||||
sl.registerLazySingleton<WalkGuideRepository>(
|
||||
() => WalkGuideRepositoryImpl(sl<ApiClient>(), sl<OfflineQueueService>()),
|
||||
);
|
||||
sl.registerFactory<WalkGuideCubit>(() => WalkGuideCubit(sl<WalkGuideRepository>()));
|
||||
sl.registerLazySingleton<SosRepository>(() => SosRepositoryImpl(sl<ApiClient>()));
|
||||
sl.registerFactory<WalkGuideCubit>(
|
||||
() => WalkGuideCubit(sl<WalkGuideRepository>()));
|
||||
sl.registerLazySingleton<SosRepository>(
|
||||
() => SosRepositoryImpl(sl<ApiClient>()));
|
||||
sl.registerFactory<SosCubit>(() => SosCubit(sl<SosRepository>()));
|
||||
sl.registerLazySingleton<NotificationRepository>(
|
||||
() => NotificationRepositoryImpl(sl<ApiClient>(), sl<LocalDatabase>()),
|
||||
|
||||
@@ -29,7 +29,8 @@ import '../features/navigation_mode/presentation/screens/navigation_mode_screen.
|
||||
as nav;
|
||||
import '../features/notifications/presentation/screens/notification_screen.dart'
|
||||
as notifications;
|
||||
import '../features/pairing/presentation/screens/pairing_screens.dart' as pairing;
|
||||
import '../features/pairing/presentation/screens/pairing_screens.dart'
|
||||
as pairing;
|
||||
import '../features/server_connect/server_connect_server.dart'
|
||||
as server_connect;
|
||||
import '../features/settings/presentation/screens/user_settings_screen.dart'
|
||||
@@ -96,7 +97,17 @@ final GoRouter appRouter = GoRouter(
|
||||
builder: (_, __) => const auth_register.RegisterScreen()),
|
||||
GoRoute(
|
||||
path: '/incoming-call',
|
||||
builder: (_, __) => const call.IncomingCallScreen()),
|
||||
builder: (_, state) {
|
||||
final extra = state.extra is Map
|
||||
? Map<String, dynamic>.from(state.extra as Map)
|
||||
: <String, dynamic>{};
|
||||
return call.IncomingCallScreen(
|
||||
callerName: extra['callerName']?.toString() ?? 'Guardian',
|
||||
callerId: int.tryParse(extra['callerId']?.toString() ?? ''),
|
||||
channelName: extra['channelName']?.toString(),
|
||||
agoraToken: extra['agoraToken']?.toString(),
|
||||
);
|
||||
}),
|
||||
ShellRoute(
|
||||
builder: (_, __, child) => UserShell(child: child),
|
||||
routes: [
|
||||
@@ -161,6 +172,12 @@ final GoRouter appRouter = GoRouter(
|
||||
path: '/guardian/settings',
|
||||
builder: (_, __) =>
|
||||
const guardian_settings.GuardianSettingsScreen()),
|
||||
GoRoute(
|
||||
path: '/guardian/call',
|
||||
builder: (_, __) => const call.CallScreen(
|
||||
targetLabel: 'User',
|
||||
returnRoute: '/guardian/dashboard',
|
||||
)),
|
||||
GoRoute(
|
||||
path: '/guardian/benchmark',
|
||||
builder: (_, __) => const benchmark.AiBenchmarkScreen()),
|
||||
|
||||
@@ -61,7 +61,7 @@ class AppConstants {
|
||||
await prefs.setString(_selectedYoloModelKey, path);
|
||||
}
|
||||
|
||||
// Agora App ID diisi saat build: --dart-define=AGORA_APP_ID=...
|
||||
static const String agoraAppId =
|
||||
String.fromEnvironment('AGORA_APP_ID', defaultValue: '');
|
||||
// Agora App ID tetap bisa dioverride saat build: --dart-define=AGORA_APP_ID=...
|
||||
static const String agoraAppId = String.fromEnvironment('AGORA_APP_ID',
|
||||
defaultValue: 'e36c2b6592e34cfda1f6ea6432a5e68d');
|
||||
}
|
||||
|
||||
@@ -71,6 +71,10 @@ bool _looksTechnical(String message) {
|
||||
'null check operator',
|
||||
'nosuchmethod',
|
||||
'formatexception',
|
||||
'could not execute statement',
|
||||
'duplicate key',
|
||||
'constraint',
|
||||
'sql [',
|
||||
];
|
||||
return blocked.any(lower.contains);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:agora_rtc_engine/agora_rtc_engine.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
import '../constants/app_constants.dart';
|
||||
import '../network/api_client.dart';
|
||||
@@ -7,9 +10,19 @@ import '../network/api_client.dart';
|
||||
class CallService {
|
||||
final ApiClient _apiClient;
|
||||
RtcEngine? _engine;
|
||||
VoidCallback? _onRemoteUserJoined;
|
||||
VoidCallback? _onRemoteUserOffline;
|
||||
|
||||
CallService(this._apiClient);
|
||||
|
||||
void setRemoteUserJoinedCallback(VoidCallback? callback) {
|
||||
_onRemoteUserJoined = callback;
|
||||
}
|
||||
|
||||
void setRemoteUserOfflineCallback(VoidCallback? callback) {
|
||||
_onRemoteUserOffline = callback;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> requestToken({required int receiverId}) async {
|
||||
final res = await _apiClient.dio.post(
|
||||
'/shared/call/token',
|
||||
@@ -41,29 +54,83 @@ class CallService {
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> callPairedUser({int uid = 0}) async {
|
||||
Future<Map<String, dynamic>?> startPairedCall({int uid = 0}) async {
|
||||
final receiverId = await getPairedReceiverId();
|
||||
if (receiverId == null) return false;
|
||||
if (receiverId == null) return null;
|
||||
|
||||
final tokenData = await requestToken(receiverId: receiverId);
|
||||
final channelName = tokenData?['channelName']?.toString();
|
||||
final token = tokenData?['token']?.toString();
|
||||
if (channelName == null || channelName.isEmpty) return false;
|
||||
final localUid = (tokenData?['uid'] as num?)?.toInt() ?? uid;
|
||||
if (channelName == null || channelName.isEmpty) return null;
|
||||
|
||||
final joined = await joinChannel(
|
||||
channelName: channelName,
|
||||
token: token,
|
||||
uid: uid,
|
||||
uid: localUid,
|
||||
);
|
||||
if (joined) {
|
||||
await notifyIncomingCall(
|
||||
receiverId: receiverId,
|
||||
channelName: channelName,
|
||||
agoraToken: token,
|
||||
receiverUid: uid,
|
||||
);
|
||||
}
|
||||
return joined;
|
||||
if (!joined) return null;
|
||||
|
||||
await notifyIncomingCall(
|
||||
receiverId: receiverId,
|
||||
channelName: channelName,
|
||||
agoraToken: token,
|
||||
receiverUid: 0,
|
||||
);
|
||||
|
||||
return {
|
||||
'receiverId': receiverId,
|
||||
'channelName': channelName,
|
||||
'token': token,
|
||||
'uid': localUid,
|
||||
};
|
||||
}
|
||||
|
||||
Future<bool> callPairedUser({int uid = 0}) async {
|
||||
return await startPairedCall(uid: uid) != null;
|
||||
}
|
||||
|
||||
Future<void> acceptIncomingCall({
|
||||
required int callerId,
|
||||
required String channelName,
|
||||
}) async {
|
||||
await _apiClient.dio.post('/shared/call/accept', data: {
|
||||
'callerId': callerId.toString(),
|
||||
'channelName': channelName,
|
||||
});
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getAcceptedCall() async {
|
||||
final res = await _apiClient.dio.get('/shared/call/accepted');
|
||||
final data = res.data['data'];
|
||||
return data is Map ? Map<String, dynamic>.from(data) : null;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> getCallState(String? channelName) async {
|
||||
if (channelName == null || channelName.isEmpty) return null;
|
||||
final res = await _apiClient.dio.get(
|
||||
'/shared/call/state',
|
||||
queryParameters: {'channelName': channelName},
|
||||
);
|
||||
final data = res.data['data'];
|
||||
return data is Map ? Map<String, dynamic>.from(data) : null;
|
||||
}
|
||||
|
||||
Future<void> clearAcceptedCall() async {
|
||||
await _apiClient.dio.delete('/shared/call/accepted');
|
||||
}
|
||||
|
||||
Future<void> clearPendingCall() async {
|
||||
await _apiClient.dio.delete('/shared/call/pending');
|
||||
}
|
||||
|
||||
Future<void> endCall(int? otherId, {String? channelName}) async {
|
||||
if (otherId == null) return;
|
||||
await _apiClient.dio.post('/shared/call/end', data: {
|
||||
'otherId': otherId.toString(),
|
||||
if (channelName != null && channelName.isNotEmpty)
|
||||
'channelName': channelName,
|
||||
});
|
||||
}
|
||||
|
||||
Future<bool> joinChannel({
|
||||
@@ -71,32 +138,94 @@ class CallService {
|
||||
String? token,
|
||||
int uid = 0,
|
||||
}) async {
|
||||
final joinCompleter = Completer<bool>();
|
||||
try {
|
||||
if (AppConstants.agoraAppId.isEmpty) {
|
||||
debugPrint('Agora join skipped: AGORA_APP_ID is not configured');
|
||||
return false;
|
||||
}
|
||||
if (!await _ensureMicrophonePermission()) {
|
||||
debugPrint('Agora join skipped: microphone permission denied');
|
||||
return false;
|
||||
}
|
||||
|
||||
_engine ??= createAgoraRtcEngine();
|
||||
await _engine!.initialize(const RtcEngineContext(appId: AppConstants.agoraAppId));
|
||||
await _engine!.initialize(
|
||||
const RtcEngineContext(appId: AppConstants.agoraAppId),
|
||||
);
|
||||
_engine!.registerEventHandler(
|
||||
RtcEngineEventHandler(
|
||||
onJoinChannelSuccess: (_, __) {
|
||||
if (!joinCompleter.isCompleted) joinCompleter.complete(true);
|
||||
},
|
||||
onUserJoined: (_, remoteUid, __) {
|
||||
debugPrint('Agora remote user joined: $remoteUid');
|
||||
_onRemoteUserJoined?.call();
|
||||
},
|
||||
onUserOffline: (_, remoteUid, reason) {
|
||||
debugPrint('Agora remote user offline: $remoteUid $reason');
|
||||
_onRemoteUserOffline?.call();
|
||||
},
|
||||
onError: (type, msg) {
|
||||
debugPrint('Agora error: $type $msg');
|
||||
if (!joinCompleter.isCompleted) joinCompleter.complete(false);
|
||||
},
|
||||
),
|
||||
);
|
||||
await _engine!.setChannelProfile(
|
||||
ChannelProfileType.channelProfileCommunication,
|
||||
);
|
||||
await _engine!.enableAudio();
|
||||
await _engine!.enableLocalAudio(true);
|
||||
await _engine!.muteLocalAudioStream(false);
|
||||
await _engine!.setEnableSpeakerphone(true);
|
||||
await _engine!.joinChannel(
|
||||
token: token ?? '',
|
||||
channelId: channelName,
|
||||
uid: uid,
|
||||
options: const ChannelMediaOptions(),
|
||||
options: const ChannelMediaOptions(
|
||||
channelProfile: ChannelProfileType.channelProfileCommunication,
|
||||
clientRoleType: ClientRoleType.clientRoleBroadcaster,
|
||||
publishMicrophoneTrack: true,
|
||||
autoSubscribeAudio: true,
|
||||
),
|
||||
);
|
||||
return joinCompleter.future.timeout(
|
||||
const Duration(seconds: 10),
|
||||
onTimeout: () {
|
||||
debugPrint('Agora join timeout for channel $channelName');
|
||||
return false;
|
||||
},
|
||||
);
|
||||
return true;
|
||||
} catch (e) {
|
||||
debugPrint('Agora join skipped: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
Future<bool> _ensureMicrophonePermission() async {
|
||||
if (kIsWeb) return true;
|
||||
final status = await Permission.microphone.request();
|
||||
return status.isGranted || status.isLimited;
|
||||
}
|
||||
|
||||
Future<void> setMuted(bool muted) async {
|
||||
await _engine?.muteLocalAudioStream(muted);
|
||||
}
|
||||
|
||||
Future<void> setSpeakerEnabled(bool enabled) async {
|
||||
await _engine?.setEnableSpeakerphone(enabled);
|
||||
}
|
||||
|
||||
Future<void> leave() async {
|
||||
_onRemoteUserJoined = null;
|
||||
_onRemoteUserOffline = null;
|
||||
await _engine?.leaveChannel();
|
||||
}
|
||||
|
||||
Future<void> dispose() async {
|
||||
_onRemoteUserJoined = null;
|
||||
_onRemoteUserOffline = null;
|
||||
await _engine?.release();
|
||||
_engine = null;
|
||||
}
|
||||
|
||||
@@ -1,13 +1,17 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:firebase_messaging/firebase_messaging.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
|
||||
|
||||
import '../../app/router.dart';
|
||||
import '../network/api_client.dart';
|
||||
|
||||
class FcmService {
|
||||
final ApiClient _apiClient;
|
||||
final FirebaseMessaging _messaging = FirebaseMessaging.instance;
|
||||
final FlutterLocalNotificationsPlugin _localNotifications = FlutterLocalNotificationsPlugin();
|
||||
final FlutterLocalNotificationsPlugin _localNotifications =
|
||||
FlutterLocalNotificationsPlugin();
|
||||
|
||||
FcmService(this._apiClient);
|
||||
|
||||
@@ -18,6 +22,14 @@ class FcmService {
|
||||
const InitializationSettings(
|
||||
android: AndroidInitializationSettings('@mipmap/ic_launcher'),
|
||||
),
|
||||
onDidReceiveNotificationResponse: (response) {
|
||||
final payload = response.payload;
|
||||
if (payload == null || payload.isEmpty) return;
|
||||
try {
|
||||
final data = Map<String, dynamic>.from(jsonDecode(payload) as Map);
|
||||
_handlePayloadNavigation(data);
|
||||
} catch (_) {}
|
||||
},
|
||||
);
|
||||
await _messaging.requestPermission(alert: true, badge: true, sound: true);
|
||||
final token = await _messaging.getToken();
|
||||
@@ -26,7 +38,16 @@ class FcmService {
|
||||
FirebaseMessaging.onMessage.listen((message) {
|
||||
debugPrint('FCM foreground: ${message.data}');
|
||||
_showLocalNotification(message);
|
||||
_handlePayloadNavigation(message.data);
|
||||
});
|
||||
FirebaseMessaging.onMessageOpenedApp.listen((message) {
|
||||
_handlePayloadNavigation(message.data);
|
||||
});
|
||||
final initialMessage =
|
||||
await FirebaseMessaging.instance.getInitialMessage();
|
||||
if (initialMessage != null) {
|
||||
_handlePayloadNavigation(initialMessage.data);
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('FCM init skipped: $e');
|
||||
}
|
||||
@@ -42,8 +63,11 @@ class FcmService {
|
||||
|
||||
Future<void> _showLocalNotification(RemoteMessage message) async {
|
||||
final notification = message.notification;
|
||||
final title = notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
|
||||
final body = notification?.body ?? message.data['body']?.toString() ?? 'Ada update baru';
|
||||
final title =
|
||||
notification?.title ?? message.data['title']?.toString() ?? 'WalkGuide';
|
||||
final body = notification?.body ??
|
||||
message.data['body']?.toString() ??
|
||||
'Ada update baru';
|
||||
await _localNotifications.show(
|
||||
DateTime.now().millisecondsSinceEpoch ~/ 1000,
|
||||
title,
|
||||
@@ -57,7 +81,26 @@ class FcmService {
|
||||
priority: Priority.high,
|
||||
),
|
||||
),
|
||||
payload: message.data['type']?.toString(),
|
||||
payload: jsonEncode(message.data),
|
||||
);
|
||||
}
|
||||
|
||||
void _handlePayloadNavigation(Map<String, dynamic> data) {
|
||||
final type = data['type']?.toString();
|
||||
if (type == 'INCOMING_CALL') {
|
||||
appRouter.go('/incoming-call', extra: data);
|
||||
return;
|
||||
}
|
||||
if (type == 'SOS_ALERT') {
|
||||
appRouter.go('/guardian/dashboard');
|
||||
return;
|
||||
}
|
||||
if (type == 'PAIRING_INVITE' || type == 'PAIRING_RESPONSE') {
|
||||
appRouter.go('/user/pairing');
|
||||
return;
|
||||
}
|
||||
if (type == 'NOTIFICATION') {
|
||||
appRouter.go('/user/notifications');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../../app/router.dart';
|
||||
import '../network/api_client.dart';
|
||||
|
||||
class IncomingCallPollingService {
|
||||
IncomingCallPollingService(this._apiClient);
|
||||
|
||||
final ApiClient _apiClient;
|
||||
Timer? _timer;
|
||||
String? _lastChannel;
|
||||
|
||||
void start() {
|
||||
if (_timer != null) return;
|
||||
_timer = Timer.periodic(const Duration(seconds: 2), (_) => _check());
|
||||
unawaited(_check());
|
||||
}
|
||||
|
||||
void stop() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_lastChannel = null;
|
||||
}
|
||||
|
||||
Future<void> _check() async {
|
||||
try {
|
||||
final res = await _apiClient.dio
|
||||
.get('/shared/call/pending')
|
||||
.timeout(const Duration(seconds: 3));
|
||||
final data = res.data['data'];
|
||||
if (data is! Map) return;
|
||||
if (data['type']?.toString() != 'INCOMING_CALL') return;
|
||||
|
||||
final channel = data['channelName']?.toString();
|
||||
if (channel == null || channel.isEmpty || channel == _lastChannel) return;
|
||||
_lastChannel = channel;
|
||||
|
||||
appRouter.go('/incoming-call', extra: Map<String, dynamic>.from(data));
|
||||
} catch (e) {
|
||||
debugPrint('Incoming call polling skipped: $e');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -13,9 +13,9 @@ import '../storage/secure_storage.dart';
|
||||
/// Flutter connect pakai STOMP agar bisa subscribe ke topic spesifik per user.
|
||||
///
|
||||
/// Subscriptions yang dipakai:
|
||||
/// Guardian → /topic/location/{userId} live GPS update
|
||||
/// Guardian → /queue/sos/{guardianId} SOS alert real-time
|
||||
/// User → /queue/notif/{userId} notifikasi dari Guardian
|
||||
/// Guardian → /topic/location/{userId} live GPS update
|
||||
/// Guardian → /queue/sos/{guardianId} SOS alert real-time
|
||||
/// User → /queue/notif/{userId} notifikasi dari Guardian
|
||||
class WebSocketService {
|
||||
final SecureStorage _storage;
|
||||
|
||||
@@ -26,11 +26,13 @@ class WebSocketService {
|
||||
void Function(double lat, double lng)? _onLocation;
|
||||
void Function(Map<String, dynamic> sosData)? _onSos;
|
||||
void Function(Map<String, dynamic> notifData)? _onNotif;
|
||||
void Function(Map<String, dynamic> callData)? _onCall;
|
||||
|
||||
// Subscription frames (untuk unsubscribe)
|
||||
StompUnsubscribe? _locationUnsub;
|
||||
StompUnsubscribe? _sosUnsub;
|
||||
StompUnsubscribe? _notifUnsub;
|
||||
StompUnsubscribe? _callUnsub;
|
||||
|
||||
WebSocketService(this._storage);
|
||||
|
||||
@@ -88,18 +90,18 @@ class WebSocketService {
|
||||
await completer.future.timeout(const Duration(seconds: 5));
|
||||
} catch (e) {
|
||||
debugPrint('[WS] Connect timeout/error: $e');
|
||||
// Don't throw — let dashboard work without WS
|
||||
// Don't throw — let dashboard work without WS
|
||||
}
|
||||
}
|
||||
|
||||
/// Subscribe ke live GPS updates dari User.
|
||||
/// Guardian panggil ini setelah connect.
|
||||
/// [userId] = ID dari ROLE_USER yang dipair.
|
||||
void subscribeLocation(String userId,
|
||||
void Function(double lat, double lng) callback) {
|
||||
void subscribeLocation(
|
||||
String userId, void Function(double lat, double lng) callback) {
|
||||
_onLocation = callback;
|
||||
if (_client == null || !_connected) {
|
||||
debugPrint('[WS] subscribeLocation skipped — not connected');
|
||||
debugPrint('[WS] subscribeLocation skipped — not connected');
|
||||
return;
|
||||
}
|
||||
_locationUnsub?.call(); // unsubscribe sebelumnya jika ada
|
||||
@@ -107,8 +109,7 @@ class WebSocketService {
|
||||
destination: '/topic/location/$userId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data =
|
||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
final lat = (data['lat'] as num?)?.toDouble();
|
||||
final lng = (data['lng'] as num?)?.toDouble();
|
||||
if (lat != null && lng != null) {
|
||||
@@ -135,8 +136,7 @@ class WebSocketService {
|
||||
destination: '/queue/sos/$guardianId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data =
|
||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
_onSos?.call(data);
|
||||
} catch (e) {
|
||||
debugPrint('[WS] SOS parse error: $e');
|
||||
@@ -147,7 +147,7 @@ class WebSocketService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Subscribe ke notifikasi Guardian → User.
|
||||
/// Subscribe ke notifikasi Guardian → User.
|
||||
/// [userId] = ID dari ROLE_USER yang login.
|
||||
void subscribeNotification(
|
||||
void Function(Map<String, dynamic> notifData) callback) {
|
||||
@@ -161,8 +161,7 @@ class WebSocketService {
|
||||
destination: '/queue/notif/$userId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data =
|
||||
jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
_onNotif?.call(data);
|
||||
} catch (e) {
|
||||
debugPrint('[WS] Notif parse error: $e');
|
||||
@@ -173,20 +172,46 @@ class WebSocketService {
|
||||
});
|
||||
}
|
||||
|
||||
/// Subscribe ke panggilan masuk realtime. Ini melengkapi FCM agar call tetap
|
||||
/// masuk saat app foreground atau ketika FCM di app clone tidak stabil.
|
||||
void subscribeCall(void Function(Map<String, dynamic> callData) callback) {
|
||||
_onCall = callback;
|
||||
if (_client == null || !_connected) return;
|
||||
|
||||
_storage.getUserId().then((userId) {
|
||||
if (userId == null) return;
|
||||
_callUnsub?.call();
|
||||
_callUnsub = _client!.subscribe(
|
||||
destination: '/queue/call/$userId',
|
||||
callback: (frame) {
|
||||
try {
|
||||
final data = jsonDecode(frame.body ?? '{}') as Map<String, dynamic>;
|
||||
_onCall?.call(data);
|
||||
} catch (e) {
|
||||
debugPrint('[WS] Call parse error: $e');
|
||||
}
|
||||
},
|
||||
);
|
||||
debugPrint('[WS] Subscribed to /queue/call/$userId');
|
||||
});
|
||||
}
|
||||
|
||||
/// Disconnect dan cleanup semua subscriptions.
|
||||
Future<void> disconnect() async {
|
||||
_locationUnsub?.call();
|
||||
_sosUnsub?.call();
|
||||
_notifUnsub?.call();
|
||||
_callUnsub?.call();
|
||||
_locationUnsub = null;
|
||||
_sosUnsub = null;
|
||||
_notifUnsub = null;
|
||||
_callUnsub = null;
|
||||
_client?.deactivate();
|
||||
_client = null;
|
||||
_connected = false;
|
||||
}
|
||||
|
||||
// Legacy compat — lama pakai onMessage raw
|
||||
// Legacy compat — lama pakai onMessage raw
|
||||
void send(Object message) {
|
||||
debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.');
|
||||
}
|
||||
|
||||
@@ -1,10 +1,15 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppColors {
|
||||
static const primary = Color(0xFF1A56DB);
|
||||
static const primary = Color(0xFF2563EB);
|
||||
static const primaryDark = Color(0xFF0F3EA8);
|
||||
static const accent = Color(0xFF0891B2);
|
||||
static const warning = Color(0xFFD97706);
|
||||
static const danger = Color(0xFFDC2626);
|
||||
static const success = Color(0xFF16A34A);
|
||||
static const surface = Color(0xFFF8FAFC);
|
||||
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 border = Color(0xFFE2E8F0);
|
||||
}
|
||||
|
||||
@@ -8,10 +8,13 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../../app/app_cubit.dart';
|
||||
import '../../app/router.dart';
|
||||
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/services/fcm_service.dart';
|
||||
import '../../core/services/incoming_call_polling_service.dart';
|
||||
import '../../core/services/offline_queue_service.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import '../../core/services/websocket_service.dart';
|
||||
@@ -225,7 +228,12 @@ class _AuthFrame extends StatelessWidget {
|
||||
width: 56,
|
||||
height: 56,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF1D4ED8),
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF2563EB),
|
||||
Color(0xFF0891B2)
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
),
|
||||
child: const Icon(Icons.navigation_rounded,
|
||||
@@ -244,7 +252,32 @@ class _AuthFrame extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
const SizedBox(height: 16),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: const Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.shield_outlined,
|
||||
size: 14, color: Color(0xFF1D4ED8)),
|
||||
SizedBox(width: 6),
|
||||
Text(
|
||||
'Secure Assistive Navigation',
|
||||
style: TextStyle(
|
||||
color: Color(0xFF1D4ED8),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
@@ -311,9 +344,16 @@ Future<void> _saveAuthAndRoute(
|
||||
|
||||
void _startPostLoginServices(String serverUrl) {
|
||||
Future.microtask(() async {
|
||||
await sl<WebSocketService>()
|
||||
.connect(serverUrl)
|
||||
.timeout(const Duration(seconds: 2));
|
||||
sl<IncomingCallPollingService>().start();
|
||||
await sl<FcmService>().init().timeout(const Duration(seconds: 4));
|
||||
final ws = sl<WebSocketService>();
|
||||
await ws.connect(serverUrl).timeout(const Duration(seconds: 2));
|
||||
ws.subscribeCall((data) {
|
||||
final type = data['type']?.toString();
|
||||
if (type == 'INCOMING_CALL') {
|
||||
appRouter.go('/incoming-call', extra: data);
|
||||
}
|
||||
});
|
||||
await sl<OfflineQueueService>()
|
||||
.syncPending(sl<ApiClient>())
|
||||
.timeout(const Duration(seconds: 3));
|
||||
|
||||
@@ -5,6 +5,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/services/incoming_call_polling_service.dart';
|
||||
import '../../core/storage/secure_storage.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -70,6 +71,7 @@ class _SplashScreenState extends State<SplashScreen>
|
||||
return;
|
||||
}
|
||||
|
||||
sl<IncomingCallPollingService>().start();
|
||||
// Auto-login: arahkan ke home sesuai role.
|
||||
context.go(role == 'ROLE_GUARDIAN'
|
||||
? '/guardian/dashboard'
|
||||
|
||||
@@ -1,11 +1,4 @@
|
||||
// ignore_for_file: use_build_context_synchronously, prefer_const_constructors
|
||||
// lib/features/call/call_screen.dart
|
||||
//
|
||||
// CallScreen — user memanggil Guardian via Agora
|
||||
// IncomingCallScreen — Guardian/User menerima panggilan masuk
|
||||
//
|
||||
// Keduanya pakai CallService yang sudah ada (agora_rtc_engine).
|
||||
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -15,18 +8,23 @@ import '../../app/injection_container.dart';
|
||||
import '../../core/services/call_service.dart';
|
||||
import '../../core/services/haptic_service.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import '../../core/storage/secure_storage.dart';
|
||||
|
||||
// ─── Colours ─────────────────────────────────────────────────────────────────
|
||||
const _kBlue = Color(0xFF1A56DB);
|
||||
const _kGreen = Color(0xFF16A34A);
|
||||
const _kRed = Color(0xFFDC2626);
|
||||
const _kMuted = Color(0xFF64748B);
|
||||
const _kBg = Color(0xFF0F172A); // dark bg untuk call screen
|
||||
|
||||
// ─── CallScreen ───────────────────────────────────────────────────────────────
|
||||
const _kBg = Color(0xFF0F172A);
|
||||
|
||||
class CallScreen extends StatefulWidget {
|
||||
const CallScreen({super.key});
|
||||
final String targetLabel;
|
||||
final String returnRoute;
|
||||
|
||||
const CallScreen({
|
||||
super.key,
|
||||
this.targetLabel = 'Guardian',
|
||||
this.returnRoute = '/user/walkguide',
|
||||
});
|
||||
|
||||
@override
|
||||
State<CallScreen> createState() => _CallScreenState();
|
||||
@@ -38,64 +36,153 @@ class _CallScreenState extends State<CallScreen>
|
||||
bool _muted = false;
|
||||
bool _speakerOn = true;
|
||||
int _secondsElapsed = 0;
|
||||
int? _otherId;
|
||||
String? _activeChannel;
|
||||
Timer? _timer;
|
||||
Timer? _ringTimeout;
|
||||
Timer? _acceptedPoll;
|
||||
|
||||
// animasi pulse saat ringing
|
||||
late AnimationController _pulseCtrl;
|
||||
late Animation<double> _pulseScale;
|
||||
late final AnimationController _pulseCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat(reverse: true);
|
||||
late final Animation<double> _pulseScale = Tween(begin: 0.95, end: 1.08)
|
||||
.animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulseCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat(reverse: true);
|
||||
_pulseScale = Tween(begin: 0.95, end: 1.08)
|
||||
.animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||
|
||||
sl<TtsService>().speak('Memanggil Guardian.');
|
||||
_startCall();
|
||||
sl<TtsService>().speak('Memanggil ${widget.targetLabel}.');
|
||||
unawaited(_startCall());
|
||||
}
|
||||
|
||||
Future<void> _startCall() async {
|
||||
final joined = await sl<CallService>().callPairedUser();
|
||||
final callService = sl<CallService>();
|
||||
callService.setRemoteUserJoinedCallback(_markRemoteConnected);
|
||||
callService.setRemoteUserOfflineCallback(() {
|
||||
unawaited(_finishRemoteEnded());
|
||||
});
|
||||
|
||||
if (!mounted) return;
|
||||
try {
|
||||
final invite = await callService.startPairedCall();
|
||||
if (!mounted) return;
|
||||
if (invite == null) {
|
||||
_failCall('Panggilan gagal. Pastikan sudah pairing dan server aktif.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (joined) {
|
||||
setState(() => _phase = _CallPhase.connected);
|
||||
sl<TtsService>().speak('Terhubung dengan Guardian.');
|
||||
_pulseCtrl.stop();
|
||||
_startTimer();
|
||||
} else {
|
||||
setState(() => _phase = _CallPhase.failed);
|
||||
sl<TtsService>()
|
||||
.speak('Panggilan gagal. Pastikan Agora sudah dikonfigurasi.');
|
||||
_otherId = _asInt(invite['receiverId']);
|
||||
_activeChannel = invite['channelName']?.toString();
|
||||
setState(() => _phase = _CallPhase.calling);
|
||||
sl<TtsService>().speak(
|
||||
'Panggilan dikirim ke ${widget.targetLabel}. Menunggu jawaban.',
|
||||
);
|
||||
_startAcceptedPolling();
|
||||
_ringTimeout?.cancel();
|
||||
_ringTimeout = Timer(const Duration(seconds: 45), () {
|
||||
if (!mounted || _phase == _CallPhase.connected) return;
|
||||
_failCall('Panggilan tidak dijawab.');
|
||||
});
|
||||
} catch (_) {
|
||||
if (!mounted) return;
|
||||
_failCall('Panggilan gagal. Server tidak merespons.');
|
||||
}
|
||||
}
|
||||
|
||||
void _startAcceptedPolling() {
|
||||
_acceptedPoll?.cancel();
|
||||
_acceptedPoll = Timer.periodic(const Duration(seconds: 2), (_) async {
|
||||
if (!mounted || _activeChannel == null) return;
|
||||
try {
|
||||
final state = await sl<CallService>()
|
||||
.getCallState(_activeChannel)
|
||||
.timeout(const Duration(seconds: 3));
|
||||
final status = state?['status']?.toString();
|
||||
if (status == 'ENDED') {
|
||||
await _finishRemoteEnded();
|
||||
return;
|
||||
}
|
||||
if (status == 'ACCEPTED') {
|
||||
_markRemoteConnected();
|
||||
return;
|
||||
}
|
||||
|
||||
final accepted = await sl<CallService>()
|
||||
.getAcceptedCall()
|
||||
.timeout(const Duration(seconds: 3));
|
||||
if (accepted?['type']?.toString() != 'CALL_ACCEPTED') return;
|
||||
final channel = accepted?['channelName']?.toString();
|
||||
if (_activeChannel != null &&
|
||||
channel != null &&
|
||||
channel.isNotEmpty &&
|
||||
channel != _activeChannel) {
|
||||
return;
|
||||
}
|
||||
_markRemoteConnected();
|
||||
} catch (_) {
|
||||
// Keep ringing; a short network hiccup should not cancel the call UI.
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _markRemoteConnected() {
|
||||
if (!mounted || _phase == _CallPhase.connected) return;
|
||||
_acceptedPoll?.cancel();
|
||||
_ringTimeout?.cancel();
|
||||
setState(() => _phase = _CallPhase.connected);
|
||||
sl<TtsService>().speak('Terhubung dengan ${widget.targetLabel}.');
|
||||
_pulseCtrl.stop();
|
||||
_startTimer();
|
||||
}
|
||||
|
||||
void _failCall(String message) {
|
||||
_acceptedPoll?.cancel();
|
||||
_ringTimeout?.cancel();
|
||||
sl<CallService>().setRemoteUserJoinedCallback(null);
|
||||
sl<CallService>().setRemoteUserOfflineCallback(null);
|
||||
setState(() => _phase = _CallPhase.failed);
|
||||
_pulseCtrl.stop();
|
||||
sl<TtsService>().speak(message);
|
||||
}
|
||||
|
||||
void _startTimer() {
|
||||
_timer?.cancel();
|
||||
_timer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() => _secondsElapsed++);
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _finishRemoteEnded() async {
|
||||
if (!mounted) return;
|
||||
_timer?.cancel();
|
||||
_ringTimeout?.cancel();
|
||||
_acceptedPoll?.cancel();
|
||||
await sl<CallService>().leave();
|
||||
sl<TtsService>().speak('Panggilan diakhiri oleh lawan bicara.');
|
||||
if (mounted) context.go(widget.returnRoute);
|
||||
}
|
||||
|
||||
Future<void> _endCall() async {
|
||||
_timer?.cancel();
|
||||
await sl<CallService>().leave();
|
||||
_ringTimeout?.cancel();
|
||||
_acceptedPoll?.cancel();
|
||||
final callService = sl<CallService>();
|
||||
callService.setRemoteUserJoinedCallback(null);
|
||||
callService.setRemoteUserOfflineCallback(null);
|
||||
await callService.endCall(_otherId, channelName: _activeChannel);
|
||||
await callService.leave();
|
||||
sl<TtsService>().speak('Panggilan diakhiri.');
|
||||
if (mounted) context.go('/user/walkguide');
|
||||
if (mounted) context.go(widget.returnRoute);
|
||||
}
|
||||
|
||||
Future<void> _toggleMute() async {
|
||||
setState(() => _muted = !_muted);
|
||||
// Agora engine mute via CallService jika ada — di sini cukup state lokal
|
||||
// sl<CallService>().muteLocalAudio(_muted);
|
||||
await sl<CallService>().setMuted(_muted);
|
||||
}
|
||||
|
||||
void _toggleSpeaker() {
|
||||
Future<void> _toggleSpeaker() async {
|
||||
setState(() => _speakerOn = !_speakerOn);
|
||||
await sl<CallService>().setSpeakerEnabled(_speakerOn);
|
||||
}
|
||||
|
||||
String get _timerLabel {
|
||||
@@ -107,183 +194,370 @@ class _CallScreenState extends State<CallScreen>
|
||||
@override
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_ringTimeout?.cancel();
|
||||
_acceptedPoll?.cancel();
|
||||
sl<CallService>().setRemoteUserJoinedCallback(null);
|
||||
sl<CallService>().setRemoteUserOfflineCallback(null);
|
||||
_pulseCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: _kBg,
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
// ── top bar ──────────────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
onPressed: () => context.go('/user/walkguide'),
|
||||
icon: const Icon(Icons.arrow_back_ios_new,
|
||||
color: Colors.white54),
|
||||
),
|
||||
const Expanded(
|
||||
child: Text('Panggilan',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
color: Colors.white70,
|
||||
fontWeight: FontWeight.w600)),
|
||||
),
|
||||
const SizedBox(width: 48), // balance
|
||||
],
|
||||
),
|
||||
return _CallScaffold(
|
||||
title: 'Panggilan',
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
AnimatedBuilder(
|
||||
animation: _pulseCtrl,
|
||||
builder: (_, child) => Transform.scale(
|
||||
scale: _phase == _CallPhase.calling ? _pulseScale.value : 1.0,
|
||||
child: child,
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// ── avatar + name ────────────────────────────────────────────
|
||||
AnimatedBuilder(
|
||||
animation: _pulseCtrl,
|
||||
builder: (_, child) => Transform.scale(
|
||||
scale: _phase == _CallPhase.calling ? _pulseScale.value : 1.0,
|
||||
child: child,
|
||||
),
|
||||
child: Container(
|
||||
width: 120,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _kBlue.withValues(alpha: 0.2),
|
||||
border: Border.all(color: _kBlue, width: 3),
|
||||
),
|
||||
child: const Icon(Icons.shield_outlined,
|
||||
color: Colors.white, size: 56),
|
||||
),
|
||||
child: _Avatar(
|
||||
icon: Icons.shield_outlined,
|
||||
color: _phase == _CallPhase.failed ? _kRed : _kBlue,
|
||||
),
|
||||
|
||||
const SizedBox(height: 20),
|
||||
|
||||
const Text('Guardian',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 26,
|
||||
fontWeight: FontWeight.w800)),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
_PhaseLabel(phase: _phase, timerLabel: _timerLabel),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// ── controls ─────────────────────────────────────────────────
|
||||
if (_phase == _CallPhase.connected) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_ControlButton(
|
||||
icon: _muted ? Icons.mic_off : Icons.mic,
|
||||
label: _muted ? 'Unmute' : 'Mute',
|
||||
onTap: _toggleMute,
|
||||
active: _muted,
|
||||
),
|
||||
_ControlButton(
|
||||
icon: _speakerOn ? Icons.volume_up : Icons.volume_off,
|
||||
label: _speakerOn ? 'Speaker' : 'Earpiece',
|
||||
onTap: _toggleSpeaker,
|
||||
active: _speakerOn,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
|
||||
if (_phase == _CallPhase.failed) ...[
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
'Panggilan gagal.\nPastikan Agora App ID sudah diisi di app_constants.dart dan server backend aktif.',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Colors.white54, height: 1.5),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
widget.targetLabel,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_PhaseLabel(phase: _phase, timerLabel: _timerLabel),
|
||||
const Spacer(),
|
||||
if (_phase == _CallPhase.connected) ...[
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_ControlButton(
|
||||
icon: _muted ? Icons.mic_off : Icons.mic,
|
||||
label: _muted ? 'Unmute' : 'Mute',
|
||||
onTap: _toggleMute,
|
||||
active: _muted,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
|
||||
// ── end call button ───────────────────────────────────────────
|
||||
_EndCallButton(onTap: _endCall),
|
||||
|
||||
const SizedBox(height: 48),
|
||||
_ControlButton(
|
||||
icon: _speakerOn ? Icons.volume_up : Icons.volume_off,
|
||||
label: _speakerOn ? 'Speaker' : 'Earpiece',
|
||||
onTap: _toggleSpeaker,
|
||||
active: _speakerOn,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
],
|
||||
),
|
||||
if (_phase == _CallPhase.failed) ...[
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 32),
|
||||
child: Text(
|
||||
'Panggilan belum tersambung. Pastikan device lawan login, paired, dan backend aktif.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Colors.white54, height: 1.5),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
_EndCallButton(onTap: _endCall),
|
||||
const SizedBox(height: 48),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ─── IncomingCallScreen ───────────────────────────────────────────────────────
|
||||
|
||||
class IncomingCallScreen extends StatefulWidget {
|
||||
/// callerName bisa diisi dari FCM payload via extra go_router params.
|
||||
/// Default 'Guardian' jika tidak ada.
|
||||
final String callerName;
|
||||
const IncomingCallScreen({super.key, this.callerName = 'Guardian'});
|
||||
final int? callerId;
|
||||
final String? channelName;
|
||||
final String? agoraToken;
|
||||
|
||||
const IncomingCallScreen({
|
||||
super.key,
|
||||
this.callerName = 'Guardian',
|
||||
this.callerId,
|
||||
this.channelName,
|
||||
this.agoraToken,
|
||||
});
|
||||
|
||||
@override
|
||||
State<IncomingCallScreen> createState() => _IncomingCallScreenState();
|
||||
}
|
||||
|
||||
class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
||||
static const _autoAnswerSeconds = 30;
|
||||
int _countdown = _autoAnswerSeconds;
|
||||
Timer? _autoTimer;
|
||||
int _secondsElapsed = 0;
|
||||
Timer? _callTimer;
|
||||
Timer? _statePoll;
|
||||
bool _responding = false;
|
||||
bool _connected = false;
|
||||
bool _failed = false;
|
||||
bool _muted = false;
|
||||
bool _speakerOn = true;
|
||||
String? _joinedChannel;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
sl<HapticService>().callIncoming();
|
||||
sl<TtsService>().speak('Panggilan masuk dari ${widget.callerName}.');
|
||||
|
||||
// auto-answer countdown
|
||||
_autoTimer = Timer.periodic(const Duration(seconds: 1), (t) {
|
||||
if (!mounted) {
|
||||
t.cancel();
|
||||
return;
|
||||
}
|
||||
setState(() => _countdown--);
|
||||
if (_countdown <= 0) {
|
||||
t.cancel();
|
||||
_accept();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoTimer?.cancel();
|
||||
_callTimer?.cancel();
|
||||
_statePoll?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _accept() async {
|
||||
if (_responding) return;
|
||||
setState(() => _responding = true);
|
||||
_autoTimer?.cancel();
|
||||
sl<TtsService>().speak('Menerima panggilan.');
|
||||
// Gabung ke channel yang sama (nama channel dari FCM payload — sementara hardcode)
|
||||
await sl<CallService>().joinChannel(channelName: 'walkguide-call');
|
||||
if (mounted) context.go('/user/call');
|
||||
|
||||
final joined = await _joinIncomingChannel();
|
||||
if (!mounted) return;
|
||||
if (!joined || _joinedChannel == null || widget.callerId == null) {
|
||||
setState(() {
|
||||
_failed = true;
|
||||
_responding = false;
|
||||
});
|
||||
sl<TtsService>().speak('Panggilan gagal tersambung.');
|
||||
return;
|
||||
}
|
||||
|
||||
await sl<CallService>().acceptIncomingCall(
|
||||
callerId: widget.callerId!,
|
||||
channelName: _joinedChannel!,
|
||||
);
|
||||
|
||||
setState(() {
|
||||
_connected = true;
|
||||
_responding = false;
|
||||
});
|
||||
_callTimer = Timer.periodic(const Duration(seconds: 1), (_) {
|
||||
if (mounted) setState(() => _secondsElapsed++);
|
||||
});
|
||||
_startIncomingStatePolling();
|
||||
sl<TtsService>().speak('Panggilan tersambung.');
|
||||
}
|
||||
|
||||
void _startIncomingStatePolling() {
|
||||
_statePoll?.cancel();
|
||||
_statePoll = Timer.periodic(const Duration(seconds: 2), (_) async {
|
||||
if (!mounted || _joinedChannel == null) return;
|
||||
try {
|
||||
final state = await sl<CallService>()
|
||||
.getCallState(_joinedChannel)
|
||||
.timeout(const Duration(seconds: 3));
|
||||
if (state?['status']?.toString() == 'ENDED') {
|
||||
await _finishIncomingRemoteEnded();
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _finishIncomingRemoteEnded() async {
|
||||
if (!mounted) return;
|
||||
_callTimer?.cancel();
|
||||
_statePoll?.cancel();
|
||||
await sl<CallService>().leave();
|
||||
sl<TtsService>().speak('Panggilan diakhiri oleh lawan bicara.');
|
||||
if (mounted) context.go(await _homeRoute());
|
||||
}
|
||||
|
||||
Future<void> _decline() async {
|
||||
if (_responding) return;
|
||||
setState(() => _responding = true);
|
||||
_autoTimer?.cancel();
|
||||
sl<TtsService>().speak('Panggilan ditolak.');
|
||||
sl<CallService>().setRemoteUserOfflineCallback(null);
|
||||
await sl<CallService>()
|
||||
.endCall(widget.callerId, channelName: widget.channelName);
|
||||
await sl<CallService>().clearPendingCall();
|
||||
await sl<CallService>().leave();
|
||||
if (mounted) context.go('/user/walkguide');
|
||||
if (mounted) context.go(await _homeRoute());
|
||||
}
|
||||
|
||||
Future<bool> _joinIncomingChannel() async {
|
||||
sl<CallService>().setRemoteUserOfflineCallback(() {
|
||||
unawaited(_finishIncomingRemoteEnded());
|
||||
});
|
||||
if (widget.callerId != null) {
|
||||
final tokenData =
|
||||
await sl<CallService>().requestToken(receiverId: widget.callerId!);
|
||||
final channelName = tokenData?['channelName']?.toString();
|
||||
final token = tokenData?['token']?.toString();
|
||||
final uid = (tokenData?['uid'] as num?)?.toInt() ?? 0;
|
||||
if (channelName != null && channelName.isNotEmpty) {
|
||||
_joinedChannel = channelName;
|
||||
return sl<CallService>().joinChannel(
|
||||
channelName: channelName,
|
||||
token: token,
|
||||
uid: uid,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final fallbackChannel = widget.channelName;
|
||||
if (fallbackChannel == null || fallbackChannel.isEmpty) return false;
|
||||
_joinedChannel = fallbackChannel;
|
||||
return sl<CallService>().joinChannel(
|
||||
channelName: fallbackChannel,
|
||||
token: widget.agoraToken,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _endConnectedCall() async {
|
||||
_callTimer?.cancel();
|
||||
_statePoll?.cancel();
|
||||
sl<CallService>().setRemoteUserOfflineCallback(null);
|
||||
await sl<CallService>()
|
||||
.endCall(widget.callerId, channelName: _joinedChannel);
|
||||
await sl<CallService>().leave();
|
||||
sl<TtsService>().speak('Panggilan diakhiri.');
|
||||
if (mounted) context.go(await _homeRoute());
|
||||
}
|
||||
|
||||
Future<void> _toggleMute() async {
|
||||
setState(() => _muted = !_muted);
|
||||
await sl<CallService>().setMuted(_muted);
|
||||
}
|
||||
|
||||
Future<void> _toggleSpeaker() async {
|
||||
setState(() => _speakerOn = !_speakerOn);
|
||||
await sl<CallService>().setSpeakerEnabled(_speakerOn);
|
||||
}
|
||||
|
||||
Future<String> _homeRoute() async {
|
||||
final role = await sl<SecureStorage>().getUserRole();
|
||||
return role == 'ROLE_GUARDIAN' ? '/guardian/dashboard' : '/user/walkguide';
|
||||
}
|
||||
|
||||
String get _timerLabel {
|
||||
final m = (_secondsElapsed ~/ 60).toString().padLeft(2, '0');
|
||||
final s = (_secondsElapsed % 60).toString().padLeft(2, '0');
|
||||
return '$m:$s';
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_connected) {
|
||||
return _CallScaffold(
|
||||
title: 'Terhubung',
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
const _Avatar(icon: Icons.call, color: _kGreen),
|
||||
const SizedBox(height: 18),
|
||||
Text(
|
||||
widget.callerName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_timerLabel,
|
||||
style: const TextStyle(
|
||||
color: _kGreen,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
_ControlButton(
|
||||
icon: _muted ? Icons.mic_off : Icons.mic,
|
||||
label: _muted ? 'Unmute' : 'Mute',
|
||||
onTap: _toggleMute,
|
||||
active: _muted,
|
||||
),
|
||||
_ControlButton(
|
||||
icon: _speakerOn ? Icons.volume_up : Icons.volume_off,
|
||||
label: _speakerOn ? 'Speaker' : 'Earpiece',
|
||||
onTap: _toggleSpeaker,
|
||||
active: _speakerOn,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 28),
|
||||
_EndCallButton(onTap: _endConnectedCall),
|
||||
const SizedBox(height: 56),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return _CallScaffold(
|
||||
title: 'Panggilan Masuk',
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
const Icon(Icons.call_received, color: _kGreen, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Panggilan Masuk',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 14),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.callerName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_failed
|
||||
? 'Tidak bisa tersambung. Coba panggil ulang.'
|
||||
: 'Tekan Terima untuk menyambungkan panggilan.',
|
||||
style: TextStyle(color: _failed ? _kRed : Colors.white38),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const Spacer(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_RoundCallButton(
|
||||
icon: Icons.call_end,
|
||||
color: _kRed,
|
||||
label: 'Tolak',
|
||||
onTap: _responding ? null : _decline,
|
||||
),
|
||||
_RoundCallButton(
|
||||
icon: Icons.call,
|
||||
color: _kGreen,
|
||||
label: 'Terima',
|
||||
onTap: _responding ? null : _accept,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 56),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CallScaffold extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget child;
|
||||
|
||||
const _CallScaffold({required this.title, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -291,55 +565,26 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
||||
body: SafeArea(
|
||||
child: Column(
|
||||
children: [
|
||||
const Spacer(),
|
||||
|
||||
// ── caller info ───────────────────────────────────────────────
|
||||
const Icon(Icons.call_received, color: _kGreen, size: 48),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Panggilan Masuk',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 14)),
|
||||
const SizedBox(height: 8),
|
||||
Text(widget.callerName,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 28,
|
||||
fontWeight: FontWeight.w800)),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// auto-answer countdown
|
||||
Text(
|
||||
'Auto-answer dalam $_countdown detik',
|
||||
style: const TextStyle(color: Colors.white38, fontSize: 13),
|
||||
),
|
||||
|
||||
const Spacer(),
|
||||
|
||||
// ── accept / decline ──────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 48),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
// Decline
|
||||
_RoundCallButton(
|
||||
icon: Icons.call_end,
|
||||
color: _kRed,
|
||||
label: 'Tolak',
|
||||
onTap: _responding ? null : _decline,
|
||||
),
|
||||
// Accept
|
||||
_RoundCallButton(
|
||||
icon: Icons.call,
|
||||
color: _kGreen,
|
||||
label: 'Terima',
|
||||
onTap: _responding ? null : _accept,
|
||||
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(height: 56),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -347,42 +592,73 @@ class _IncomingCallScreenState extends State<IncomingCallScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Sub-widgets ──────────────────────────────────────────────────────────────
|
||||
|
||||
enum _CallPhase { calling, connected, failed }
|
||||
|
||||
class _PhaseLabel extends StatelessWidget {
|
||||
final _CallPhase phase;
|
||||
final String timerLabel;
|
||||
|
||||
const _PhaseLabel({required this.phase, required this.timerLabel});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
switch (phase) {
|
||||
case _CallPhase.calling:
|
||||
return const Text('Memanggil…',
|
||||
style: TextStyle(color: _kMuted, fontSize: 16));
|
||||
return const Text(
|
||||
'Memanggil...',
|
||||
style: TextStyle(color: _kMuted, fontSize: 16),
|
||||
);
|
||||
case _CallPhase.connected:
|
||||
return Text(timerLabel,
|
||||
style: const TextStyle(
|
||||
color: _kGreen, fontSize: 22, fontWeight: FontWeight.w700));
|
||||
return Text(
|
||||
timerLabel,
|
||||
style: const TextStyle(
|
||||
color: _kGreen,
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
);
|
||||
case _CallPhase.failed:
|
||||
return const Text('Panggilan gagal',
|
||||
style: TextStyle(color: _kRed, fontSize: 16));
|
||||
return const Text(
|
||||
'Panggilan gagal',
|
||||
style: TextStyle(color: _kRed, fontSize: 16),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _Avatar extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _Avatar({required this.icon, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 124,
|
||||
height: 124,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color.withValues(alpha: 0.2),
|
||||
border: Border.all(color: color, width: 3),
|
||||
),
|
||||
child: Icon(icon, color: Colors.white, size: 56),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ControlButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback onTap;
|
||||
final bool active;
|
||||
const _ControlButton(
|
||||
{required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.active = false});
|
||||
|
||||
const _ControlButton({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.active = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -402,8 +678,7 @@ class _ControlButton extends StatelessWidget {
|
||||
child: Icon(icon, color: Colors.white, size: 28),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(label,
|
||||
style: const TextStyle(color: Colors.white54, fontSize: 12)),
|
||||
Text(label, style: const TextStyle(color: Colors.white54)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -412,6 +687,7 @@ class _ControlButton extends StatelessWidget {
|
||||
|
||||
class _EndCallButton extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _EndCallButton({required this.onTap});
|
||||
|
||||
@override
|
||||
@@ -421,17 +697,14 @@ class _EndCallButton extends StatelessWidget {
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: const BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: _kRed,
|
||||
),
|
||||
width: 74,
|
||||
height: 74,
|
||||
decoration:
|
||||
const BoxDecoration(shape: BoxShape.circle, color: _kRed),
|
||||
child: const Icon(Icons.call_end, color: Colors.white, size: 32),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
const Text('Akhiri',
|
||||
style: TextStyle(color: Colors.white54, fontSize: 12)),
|
||||
const Text('Akhiri', style: TextStyle(color: Colors.white54)),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -443,32 +716,38 @@ class _RoundCallButton extends StatelessWidget {
|
||||
final Color color;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
const _RoundCallButton(
|
||||
{required this.icon,
|
||||
required this.color,
|
||||
required this.label,
|
||||
this.onTap});
|
||||
|
||||
const _RoundCallButton({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.label,
|
||||
this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Opacity(
|
||||
opacity: onTap == null ? 0.4 : 1.0,
|
||||
opacity: onTap == null ? 0.4 : 1,
|
||||
child: Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
width: 74,
|
||||
height: 74,
|
||||
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
|
||||
child: Icon(icon, color: Colors.white, size: 32),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(label,
|
||||
style: const TextStyle(color: Colors.white70, fontSize: 13)),
|
||||
Text(label, style: const TextStyle(color: Colors.white70)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
int? _asInt(dynamic value) {
|
||||
if (value is num) return value.toInt();
|
||||
return int.tryParse(value?.toString() ?? '');
|
||||
}
|
||||
|
||||
+493
-440
File diff suppressed because it is too large
Load Diff
@@ -345,25 +345,58 @@ class _PairingStatusCardState extends State<_PairingStatusCard> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final pending = _data?['status'] == 'PENDING';
|
||||
final cardColor = _active
|
||||
? const Color(0xFFF0FDF4)
|
||||
: pending
|
||||
? const Color(0xFFEFF6FF)
|
||||
: const Color(0xFFFFFBEB);
|
||||
final accent = _active
|
||||
? const Color(0xFF059669)
|
||||
: pending
|
||||
? const Color(0xFF2563EB)
|
||||
: const Color(0xFFD97706);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: _active ? const Color(0xFFF0FDF4) : const Color(0xFFFFFBEB),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: _active ? const Color(0xFFBBF7D0) : const Color(0xFFFDE68A)),
|
||||
color: cardColor,
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
border: Border.all(color: accent.withValues(alpha: 0.28)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: accent.withValues(alpha: 0.10),
|
||||
blurRadius: 24,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(_active ? Icons.link : Icons.info_outline,
|
||||
color: _active
|
||||
? const Color(0xFF16A34A)
|
||||
: const Color(0xFFD97706)),
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: accent.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
_active
|
||||
? Icons.verified_user_outlined
|
||||
: pending
|
||||
? Icons.mark_email_unread_outlined
|
||||
: Icons.info_outline,
|
||||
color: accent),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(child: Text(_status)),
|
||||
Expanded(
|
||||
child: Text(_status,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF0F172A),
|
||||
fontWeight: FontWeight.w700,
|
||||
height: 1.25)),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _loading ? null : _load,
|
||||
icon: _loading
|
||||
@@ -427,33 +460,84 @@ class _Page extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 14, end: 0),
|
||||
duration: const Duration(milliseconds: 360),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, offset, child) => Opacity(
|
||||
opacity: (1 - offset / 14).clamp(0.0, 1.0),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, offset), child: child),
|
||||
),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0F172A),
|
||||
borderRadius: BorderRadius.circular(24),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0F172A).withValues(alpha: 0.18),
|
||||
blurRadius: 28,
|
||||
offset: const Offset(0, 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.w800)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!,
|
||||
style: const TextStyle(color: Color(0xFF64748B))),
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color:
|
||||
const Color(0xFF38BDF8).withValues(alpha: 0.16),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFF38BDF8)),
|
||||
),
|
||||
child: const Icon(Icons.hub_outlined,
|
||||
color: Color(0xFFBAE6FD), size: 28),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Colors.white,
|
||||
)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFFCBD5E1), height: 1.25)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -474,24 +558,47 @@ class _InfoCard extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, color: const Color(0xFF1A56DB)),
|
||||
const SizedBox(width: 12),
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(icon, color: const Color(0xFF2563EB)),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title),
|
||||
Text(title,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B), fontWeight: FontWeight.w700)),
|
||||
SelectableText(value,
|
||||
style: const TextStyle(
|
||||
fontSize: 22, fontWeight: FontWeight.w800)),
|
||||
fontSize: 25,
|
||||
height: 1.1,
|
||||
letterSpacing: 1.2,
|
||||
fontWeight: FontWeight.w900,
|
||||
color: Color(0xFF0F172A))),
|
||||
if (helper != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
const SizedBox(height: 6),
|
||||
Text(helper!,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B), fontSize: 12)),
|
||||
|
||||
+283
-118
@@ -9,16 +9,6 @@ import '../../core/constants/app_constants.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// ServerConnectScreen
|
||||
// ---------------------------------------------------------------------------
|
||||
//
|
||||
// Gerbang pertama aplikasi.
|
||||
// Muncul HANYA jika SharedPreferences tidak punya serverUrl tersimpan.
|
||||
// Setelah berhasil connect, tidak akan muncul lagi kecuali user reset via
|
||||
// Settings → "Change Server".
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class ServerConnectScreen extends StatefulWidget {
|
||||
const ServerConnectScreen({super.key});
|
||||
|
||||
@@ -27,11 +17,17 @@ class ServerConnectScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
||||
final _url = TextEditingController();
|
||||
final _url = TextEditingController(text: 'http://127.0.0.1:8080');
|
||||
bool _loading = false;
|
||||
bool _ok = false;
|
||||
String? _message;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_url.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _test() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
@@ -47,8 +43,8 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
||||
)).get('$clean/api/v1/auth/ping');
|
||||
_ok = res.statusCode == 200 && res.data['success'] == true;
|
||||
_message = _ok
|
||||
? 'Server aktif dan siap dipakai.'
|
||||
: 'Server merespons dengan format tidak valid.';
|
||||
? 'Server aktif. WalkGuide siap tersambung.'
|
||||
: 'Server merespons, tetapi format ping tidak valid.';
|
||||
},
|
||||
onError: (message) => _message = message,
|
||||
fallback: 'Tidak bisa terhubung. Periksa URL dan jaringan.',
|
||||
@@ -63,49 +59,219 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
||||
if (mounted) context.go('/splash');
|
||||
}
|
||||
|
||||
void _useUsbUrl() => setState(() => _url.text = 'http://127.0.0.1:8080');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _AuthFrame(
|
||||
title: 'Connect to Server',
|
||||
subtitle: 'Masukkan URL backend WalkGuide yang diberikan dosen.',
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF5F8FC),
|
||||
body: Stack(
|
||||
children: [
|
||||
TextField(
|
||||
controller: _url,
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'http://server-ip:8080',
|
||||
prefixIcon: Icon(Icons.dns_outlined),
|
||||
)),
|
||||
const SizedBox(height: 12),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _loading ? null : _test,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: const Icon(Icons.wifi_tethering),
|
||||
label: const Text('Test Connection'),
|
||||
const Positioned.fill(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF071226),
|
||||
Color(0xFF123D6B),
|
||||
Color(0xFFF7FAFC)
|
||||
],
|
||||
stops: [0, 0.42, 1],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (_message != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_StatusBox(success: _ok, message: _message!),
|
||||
],
|
||||
if (_ok) ...[
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _continue,
|
||||
icon: const Icon(Icons.arrow_forward),
|
||||
label: const Text('Continue')),
|
||||
],
|
||||
const SizedBox(height: 24),
|
||||
const Center(
|
||||
child: Text(
|
||||
'v1.0.0 | For Testing Purposes Only',
|
||||
style: TextStyle(fontSize: 11, color: Color(0xFF94A3B8)),
|
||||
const Positioned(
|
||||
top: -80,
|
||||
right: -70,
|
||||
child: _GlowBlob(size: 250, color: Color(0xFF38BDF8)),
|
||||
),
|
||||
const Positioned(
|
||||
bottom: -90,
|
||||
left: -80,
|
||||
child: _GlowBlob(size: 260, color: Color(0xFF22C55E)),
|
||||
),
|
||||
SafeArea(
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final compact = constraints.maxWidth < 390;
|
||||
return SingleChildScrollView(
|
||||
padding: EdgeInsets.fromLTRB(20, compact ? 20 : 36, 20, 24),
|
||||
child: Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: TweenAnimationBuilder<double>(
|
||||
tween: Tween(begin: 18, end: 0),
|
||||
duration: const Duration(milliseconds: 520),
|
||||
curve: Curves.easeOutCubic,
|
||||
builder: (_, y, child) => Opacity(
|
||||
opacity: (1 - y / 18).clamp(0, 1),
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, y), child: child),
|
||||
),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withValues(alpha: 0.96),
|
||||
borderRadius: BorderRadius.circular(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),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Container(
|
||||
padding:
|
||||
const EdgeInsets.fromLTRB(22, 22, 22, 20),
|
||||
decoration: const BoxDecoration(
|
||||
color: Color(0xFF071226),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 48,
|
||||
height: 48,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2563EB),
|
||||
borderRadius:
|
||||
BorderRadius.circular(16),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.navigation_rounded,
|
||||
color: Colors.white,
|
||||
size: 28),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
const Expanded(
|
||||
child: Text(
|
||||
'WalkGuide Link',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.w900,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
const Text(
|
||||
'Connect to Server',
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 30,
|
||||
fontWeight: FontWeight.w900,
|
||||
height: 1,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Sambungkan app HP ke backend Spring Boot yang sedang berjalan di laptop.',
|
||||
style: TextStyle(
|
||||
color: Colors.white
|
||||
.withValues(alpha: 0.72),
|
||||
height: 1.35,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(22),
|
||||
child: Column(
|
||||
crossAxisAlignment:
|
||||
CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _url,
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _test(),
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'http://127.0.0.1:8080',
|
||||
prefixIcon: Icon(Icons.dns_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_HintChip(
|
||||
icon: Icons.usb_outlined,
|
||||
label: 'USB: 127.0.0.1',
|
||||
onTap: _useUsbUrl,
|
||||
),
|
||||
const _HintChip(
|
||||
icon: Icons.wifi_tethering_outlined,
|
||||
label: 'Wi-Fi: IP laptop',
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
OutlinedButton.icon(
|
||||
onPressed: _loading ? null : _test,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child:
|
||||
CircularProgressIndicator(
|
||||
strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.radar_outlined),
|
||||
label: const Text('Test Connection'),
|
||||
),
|
||||
if (_message != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
_StatusBox(
|
||||
success: _ok, message: _message!),
|
||||
],
|
||||
if (_ok) ...[
|
||||
const SizedBox(height: 12),
|
||||
FilledButton.icon(
|
||||
onPressed: _continue,
|
||||
icon: const Icon(
|
||||
Icons.arrow_forward_rounded),
|
||||
label: const Text('Continue'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 18),
|
||||
const Center(
|
||||
child: Text(
|
||||
'v1.0.0 | Spring Boot + Flutter',
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: Color(0xFF94A3B8)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -114,55 +280,62 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Shared private widgets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
class _AuthFrame extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final Widget child;
|
||||
const _AuthFrame(
|
||||
{required this.title, required this.subtitle, required this.child});
|
||||
class _GlowBlob extends StatelessWidget {
|
||||
final double size;
|
||||
final Color color;
|
||||
const _GlowBlob({required this.size, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Center(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 460),
|
||||
child: Card(
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
side: const BorderSide(color: Color(0xFFE2E8F0))),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
const Icon(Icons.navigation_rounded,
|
||||
color: Color(0xFF1A56DB), size: 42),
|
||||
const SizedBox(height: 14),
|
||||
Text(title,
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 4),
|
||||
Text(subtitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Color(0xFF64748B))),
|
||||
const SizedBox(height: 22),
|
||||
child,
|
||||
],
|
||||
),
|
||||
return Container(
|
||||
width: size,
|
||||
height: size,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color.withValues(alpha: 0.18),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: color.withValues(alpha: 0.22),
|
||||
blurRadius: 60,
|
||||
spreadRadius: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HintChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final VoidCallback? onTap;
|
||||
const _HintChip({required this.icon, required this.label, this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
border: Border.all(color: const Color(0xFFBFDBFE)),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14, color: const Color(0xFF1D4ED8)),
|
||||
const SizedBox(width: 6),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF1D4ED8),
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -176,31 +349,23 @@ class _StatusBox extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
final color = success ? const Color(0xFF16A34A) : const Color(0xFFDC2626);
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: success ? const Color(0xFFF0FDF4) : const Color(0xFFFEF2F2),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: color.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: color.withValues(alpha: 0.22)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
success ? Icons.check_circle_outline : Icons.error_outline,
|
||||
color:
|
||||
success ? const Color(0xFF166534) : const Color(0xFF991B1B),
|
||||
size: 20,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(success ? Icons.check_circle_outline : Icons.error_outline,
|
||||
color: color, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(message,
|
||||
style: TextStyle(
|
||||
color: success
|
||||
? const Color(0xFF166534)
|
||||
: const Color(0xFF991B1B))),
|
||||
),
|
||||
],
|
||||
),
|
||||
style: TextStyle(color: color, fontWeight: FontWeight.w700))),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
@@ -139,6 +140,8 @@ class _SosScreenState extends State<SosScreen>
|
||||
|
||||
Future<void> _confirmAndSend() async {
|
||||
if (_sosCubit.state.phase == SosPhase.sending) return;
|
||||
final paired = await _ensurePaired();
|
||||
if (!paired) return;
|
||||
|
||||
// Confirmation dialog — prevents accidental tap
|
||||
final confirm = await showDialog<bool>(
|
||||
@@ -181,6 +184,35 @@ class _SosScreenState extends State<SosScreen>
|
||||
await _sendSos();
|
||||
}
|
||||
|
||||
Future<bool> _ensurePaired() async {
|
||||
bool paired = false;
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final res = await _api
|
||||
.get('/shared/pairing/status')
|
||||
.timeout(const Duration(seconds: 6));
|
||||
final data = res.data['data'];
|
||||
paired = data is Map && data['status'] == 'ACTIVE';
|
||||
},
|
||||
onError: (_) {},
|
||||
fallback: 'Status pairing belum bisa dicek.',
|
||||
);
|
||||
if (paired) return true;
|
||||
if (!mounted) return false;
|
||||
sl<TtsService>().speak('SOS belum bisa dikirim. Hubungkan Guardian dulu.');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'SOS hanya bisa dikirim setelah akun terhubung dengan Guardian.'),
|
||||
action: SnackBarAction(
|
||||
label: 'Pairing',
|
||||
onPressed: () => context.go('/user/pairing'),
|
||||
),
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> _sendSos() async {
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
@@ -217,96 +249,98 @@ class _SosScreenState extends State<SosScreen>
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
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',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Active SOS banner
|
||||
if (_hasActiveSos)
|
||||
_ActiveSosBanner(event: _events.first, onRefresh: _loadHistory),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 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: 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',
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// Active SOS banner
|
||||
if (_hasActiveSos)
|
||||
_ActiveSosBanner(
|
||||
event: _events.first, onRefresh: _loadHistory),
|
||||
|
||||
const SizedBox(height: 24),
|
||||
|
||||
// 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,
|
||||
style: TextStyle(
|
||||
color: _hasActiveSos
|
||||
? const Color(0xFFDC2626)
|
||||
: const Color(0xFF64748B),
|
||||
fontWeight:
|
||||
_hasActiveSos ? FontWeight.w700 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// History section
|
||||
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,
|
||||
)),
|
||||
],
|
||||
),
|
||||
|
||||
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,
|
||||
style: TextStyle(
|
||||
color: _hasActiveSos
|
||||
? const Color(0xFFDC2626)
|
||||
: const Color(0xFF64748B),
|
||||
fontWeight: _hasActiveSos ? FontWeight.w700 : FontWeight.normal,
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 28),
|
||||
|
||||
// History section
|
||||
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,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import '../../app/injection_container.dart';
|
||||
import '../../core/ai/detection_export.dart';
|
||||
import '../../core/ai/obstacle_alert_strategy.dart';
|
||||
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 'application/walk_guide_cubit.dart';
|
||||
@@ -27,10 +28,15 @@ class WalkGuideScreen extends StatefulWidget {
|
||||
State<WalkGuideScreen> createState() => _WalkGuideScreenState();
|
||||
}
|
||||
|
||||
class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
class _WalkGuideScreenState extends State<WalkGuideScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final WalkGuideCubit _cubit;
|
||||
late final AnimationController _scanCtrl;
|
||||
CameraController? _camera;
|
||||
bool _processingFrame = false;
|
||||
bool _pairingLoading = true;
|
||||
bool _paired = false;
|
||||
String? _pairedName;
|
||||
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
@@ -39,6 +45,11 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cubit = sl<WalkGuideCubit>();
|
||||
_scanCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 2200),
|
||||
)..repeat();
|
||||
_loadPairingStatus();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -49,6 +60,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
}
|
||||
_camera?.dispose();
|
||||
sl<LocationReporterService>().stop();
|
||||
_scanCtrl.dispose();
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
@@ -56,6 +68,8 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
Future<void> _toggle() async {
|
||||
final next = !_cubit.state.active;
|
||||
if (next) {
|
||||
final paired = await _ensurePaired();
|
||||
if (!paired) return;
|
||||
await _startCamera();
|
||||
await sl<LocationReporterService>().start(walkGuideActive: true);
|
||||
await _cubit.start();
|
||||
@@ -69,6 +83,48 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
|
||||
}
|
||||
|
||||
Future<void> _loadPairingStatus() async {
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final res = await sl<ApiClient>()
|
||||
.dio
|
||||
.get('/shared/pairing/status')
|
||||
.timeout(const Duration(seconds: 6));
|
||||
final data = res.data['data'];
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_paired = data is Map && data['status'] == 'ACTIVE';
|
||||
_pairedName = data is Map ? data['pairedWithName']?.toString() : null;
|
||||
_pairingLoading = false;
|
||||
});
|
||||
},
|
||||
onError: (_) {
|
||||
if (!mounted) return;
|
||||
setState(() => _pairingLoading = false);
|
||||
},
|
||||
fallback: 'Status pairing belum bisa dicek.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<bool> _ensurePaired() async {
|
||||
if (_paired) return true;
|
||||
await _loadPairingStatus();
|
||||
if (_paired) return true;
|
||||
if (!mounted) return false;
|
||||
sl<TtsService>().speak('Hubungkan Guardian terlebih dahulu.');
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: const Text(
|
||||
'WalkGuide, SOS, dan panggilan aktif setelah pairing dengan Guardian.'),
|
||||
action: SnackBarAction(
|
||||
label: 'Pairing',
|
||||
onPressed: () => context.go('/user/pairing'),
|
||||
),
|
||||
),
|
||||
);
|
||||
return false;
|
||||
}
|
||||
|
||||
String _activeStatusText() {
|
||||
final detector = sl<YoloDetector>();
|
||||
if (kIsWeb) {
|
||||
@@ -86,33 +142,33 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
if (_camera != null) return;
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) return;
|
||||
final backCamera = cameras.firstWhere(
|
||||
(camera) => camera.lensDirection == CameraLensDirection.back,
|
||||
orElse: () => cameras.first,
|
||||
);
|
||||
final controller = CameraController(
|
||||
backCamera,
|
||||
ResolutionPreset.medium,
|
||||
enableAudio: false,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
);
|
||||
await controller.initialize();
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
await runFriendlyAction(
|
||||
() => controller.startImageStream(_onCameraImage),
|
||||
onError: (_) {
|
||||
_cubit.updateStatus(kIsWeb
|
||||
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
||||
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
||||
},
|
||||
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
|
||||
);
|
||||
setState(() => _camera = controller);
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) return;
|
||||
final backCamera = cameras.firstWhere(
|
||||
(camera) => camera.lensDirection == CameraLensDirection.back,
|
||||
orElse: () => cameras.first,
|
||||
);
|
||||
final controller = CameraController(
|
||||
backCamera,
|
||||
ResolutionPreset.medium,
|
||||
enableAudio: false,
|
||||
imageFormatGroup: ImageFormatGroup.yuv420,
|
||||
);
|
||||
await controller.initialize();
|
||||
if (!mounted) {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
await runFriendlyAction(
|
||||
() => controller.startImageStream(_onCameraImage),
|
||||
onError: (_) {
|
||||
_cubit.updateStatus(kIsWeb
|
||||
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
||||
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
||||
},
|
||||
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
|
||||
);
|
||||
setState(() => _camera = controller);
|
||||
},
|
||||
onError: (_) => _cubit.updateStatus('Camera unavailable.'),
|
||||
fallback: 'Camera unavailable.',
|
||||
@@ -190,7 +246,9 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
bloc: _cubit,
|
||||
builder: (context, state) => _Page(
|
||||
title: 'WalkGuide',
|
||||
subtitle: 'On-device AI detection surface',
|
||||
subtitle: _paired
|
||||
? 'Connected to ${_pairedName ?? 'Guardian'}'
|
||||
: 'Pair with Guardian to unlock live protection',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go('/user/benchmark'),
|
||||
@@ -202,64 +260,52 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0F172A),
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_camera != null && _camera!.value.isInitialized)
|
||||
Positioned.fill(child: CameraPreview(_camera!))
|
||||
else
|
||||
const Center(
|
||||
child: Icon(Icons.videocam_outlined,
|
||||
color: Colors.white30, size: 96)),
|
||||
if (state.latestDetection?.box != null)
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter:
|
||||
_DetectionOverlayPainter(state.latestDetection!),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
child: _Pill(
|
||||
text: state.active ? 'AI ACTIVE' : 'STANDBY',
|
||||
color:
|
||||
state.active ? Colors.green : Colors.orange)),
|
||||
if (state.latestDetection != null)
|
||||
Positioned(
|
||||
top: 64,
|
||||
left: 16,
|
||||
child: _Pill(
|
||||
text:
|
||||
'${ObstacleAnalyzer.spokenLabel(state.latestDetection!.label)} ${state.latestDetection!.directionName}',
|
||||
color: Colors.redAccent),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: Text(state.status,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700))),
|
||||
],
|
||||
),
|
||||
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(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _toggle,
|
||||
icon:
|
||||
Icon(state.active ? Icons.stop : Icons.play_arrow),
|
||||
label: Text(state.active ? 'Stop' : 'Start'))),
|
||||
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');
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
@@ -269,6 +315,413 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
}
|
||||
}
|
||||
|
||||
class _VisionPanel extends StatelessWidget {
|
||||
final WalkGuideState state;
|
||||
final CameraController? camera;
|
||||
final AnimationController scanCtrl;
|
||||
final bool paired;
|
||||
final bool pairingLoading;
|
||||
final VoidCallback onPairingTap;
|
||||
|
||||
const _VisionPanel({
|
||||
required this.state,
|
||||
required this.camera,
|
||||
required this.scanCtrl,
|
||||
required this.paired,
|
||||
required this.pairingLoading,
|
||||
required this.onPairingTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cameraReady = camera != null && camera!.value.isInitialized;
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(28),
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(color: Color(0xFF07111F)),
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: cameraReady
|
||||
? CameraPreview(camera!)
|
||||
: const DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
colors: [
|
||||
Color(0xFF07111F),
|
||||
Color(0xFF0E2A3D),
|
||||
Color(0xFF111827),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned.fill(child: CustomPaint(painter: _HudGridPainter())),
|
||||
if (state.active)
|
||||
AnimatedBuilder(
|
||||
animation: scanCtrl,
|
||||
builder: (_, __) => Positioned(
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: 28 +
|
||||
(MediaQuery.of(context).size.height *
|
||||
0.38 *
|
||||
scanCtrl.value),
|
||||
child: Container(
|
||||
height: 3,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF22D3EE).withValues(alpha: 0.8),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
const Color(0xFF22D3EE).withValues(alpha: 0.45),
|
||||
blurRadius: 22,
|
||||
spreadRadius: 4,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (state.latestDetection?.box != null)
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _DetectionOverlayPainter(state.latestDetection!),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 18,
|
||||
left: 18,
|
||||
right: 18,
|
||||
child: Row(
|
||||
children: [
|
||||
_Pill(
|
||||
text: state.active ? 'LIVE AI SCAN' : 'STANDBY',
|
||||
color: state.active
|
||||
? const Color(0xFF22C55E)
|
||||
: const Color(0xFFF59E0B),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
_Pill(
|
||||
text: paired ? 'GUARDIAN LINKED' : 'PAIRING REQUIRED',
|
||||
color: paired
|
||||
? const Color(0xFF38BDF8)
|
||||
: const Color(0xFFF97316),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: AnimatedScale(
|
||||
duration: const Duration(milliseconds: 320),
|
||||
scale: state.active ? 1.0 : 0.92,
|
||||
child: Container(
|
||||
width: 118,
|
||||
height: 118,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.black.withValues(alpha: 0.28),
|
||||
border: Border.all(
|
||||
color: (state.active
|
||||
? const Color(0xFF22D3EE)
|
||||
: Colors.white)
|
||||
.withValues(alpha: 0.34),
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
cameraReady
|
||||
? Icons.center_focus_strong
|
||||
: Icons.videocam_off,
|
||||
color: Colors.white.withValues(alpha: 0.68),
|
||||
size: 48,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
left: 18,
|
||||
right: 18,
|
||||
bottom: 18,
|
||||
child: _GlassStatusBar(
|
||||
status: state.status,
|
||||
detection: state.latestDetection,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GlassStatusBar extends StatelessWidget {
|
||||
final String status;
|
||||
final DetectionResult? detection;
|
||||
|
||||
const _GlassStatusBar({required this.status, required this.detection});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final label = detection == null
|
||||
? status
|
||||
: '${ObstacleAnalyzer.spokenLabel(detection!.label)} detected ${detection!.directionName}';
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black.withValues(alpha: 0.42),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Colors.white.withValues(alpha: 0.16)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
detection == null ? Icons.sensors : Icons.warning_amber_rounded,
|
||||
color: detection == null
|
||||
? const Color(0xFF93C5FD)
|
||||
: const Color(0xFFFBBF24),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.w800,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StatusStrip extends StatelessWidget {
|
||||
final bool active;
|
||||
final bool paired;
|
||||
final DetectionResult? latestDetection;
|
||||
|
||||
const _StatusStrip({
|
||||
required this.active,
|
||||
required this.paired,
|
||||
required this.latestDetection,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _MetricChip(
|
||||
icon: Icons.health_and_safety_outlined,
|
||||
label: 'Guardian',
|
||||
value: paired ? 'Linked' : 'Required',
|
||||
color: paired ? const Color(0xFF059669) : const Color(0xFFD97706),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _MetricChip(
|
||||
icon: Icons.radar_outlined,
|
||||
label: 'Detector',
|
||||
value: active ? 'Scanning' : 'Idle',
|
||||
color: active ? const Color(0xFF2563EB) : const Color(0xFF64748B),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: _MetricChip(
|
||||
icon: Icons.visibility_outlined,
|
||||
label: 'Obstacle',
|
||||
value: latestDetection == null ? 'Clear' : 'Alert',
|
||||
color: latestDetection == null
|
||||
? const Color(0xFF64748B)
|
||||
: const Color(0xFFDC2626),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MetricChip extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
final Color color;
|
||||
|
||||
const _MetricChip({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B),
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w600,
|
||||
)),
|
||||
Text(value,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w900,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ActionSquare extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ActionSquare({
|
||||
required this.icon,
|
||||
required this.color,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
child: SizedBox(
|
||||
width: 54,
|
||||
height: 50,
|
||||
child: Icon(icon, color: Colors.white),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HudGridPainter extends CustomPainter {
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final line = Paint()
|
||||
..color = Colors.white.withValues(alpha: 0.045)
|
||||
..strokeWidth = 1;
|
||||
for (double x = 0; x < size.width; x += 42) {
|
||||
canvas.drawLine(Offset(x, 0), Offset(x, size.height), line);
|
||||
}
|
||||
for (double y = 0; y < size.height; y += 42) {
|
||||
canvas.drawLine(Offset(0, y), Offset(size.width, y), line);
|
||||
}
|
||||
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final ring = Paint()
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 1.2
|
||||
..color = const Color(0xFF22D3EE).withValues(alpha: 0.16);
|
||||
for (final radius in [64.0, 112.0, 164.0]) {
|
||||
canvas.drawCircle(center, radius, ring);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
|
||||
}
|
||||
|
||||
class _DetectionOverlayPainter extends CustomPainter {
|
||||
final DetectionResult detection;
|
||||
const _DetectionOverlayPainter(this.detection);
|
||||
@@ -356,34 +809,67 @@ class _Page extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.w800)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!,
|
||||
style: const TextStyle(color: Color(0xFF64748B))),
|
||||
],
|
||||
child: DecoratedBox(
|
||||
decoration: const BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
colors: [Color(0xFFF8FAFC), Color(0xFFEFF6FF)],
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 46,
|
||||
height: 46,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2563EB),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color:
|
||||
const Color(0xFF2563EB).withValues(alpha: 0.28),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, 8),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: const Icon(Icons.navigation_rounded,
|
||||
color: Colors.white, size: 26),
|
||||
),
|
||||
),
|
||||
...?actions,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: child),
|
||||
],
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: const Color(0xFF0F172A),
|
||||
)),
|
||||
if (subtitle != null)
|
||||
Text(subtitle!,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: const TextStyle(color: Color(0xFF64748B))),
|
||||
],
|
||||
),
|
||||
),
|
||||
...?actions,
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: child),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:camera/camera.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';
|
||||
@@ -8,6 +9,11 @@ import 'core/utils/init_guard.dart';
|
||||
|
||||
List<CameraDescription> cameras = [];
|
||||
|
||||
@pragma('vm:entry-point')
|
||||
Future<void> _firebaseMessagingBackgroundHandler(RemoteMessage message) async {
|
||||
await Firebase.initializeApp();
|
||||
}
|
||||
|
||||
Future<void> main() async {
|
||||
WidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
@@ -18,7 +24,9 @@ Future<void> main() async {
|
||||
[];
|
||||
|
||||
if (!kIsWeb) {
|
||||
await ignoreInitFailure(() => Firebase.initializeApp(), label: 'Firebase init');
|
||||
await ignoreInitFailure(() => Firebase.initializeApp(),
|
||||
label: 'Firebase init');
|
||||
FirebaseMessaging.onBackgroundMessage(_firebaseMessagingBackgroundHandler);
|
||||
}
|
||||
|
||||
// Init GetIt dependencies
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../core/services/hardware_shortcut_listener.dart';
|
||||
import '../../core/services/stt_service.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import '../../core/services/voice_command_handler.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
|
||||
class UserShell extends StatefulWidget {
|
||||
final Widget child;
|
||||
@@ -75,7 +76,8 @@ class _UserShellState extends State<UserShell> {
|
||||
if (data is! List) return;
|
||||
final commands = data
|
||||
.whereType<Map>()
|
||||
.map((item) => _voiceCommandFromJson(Map<String, dynamic>.from(item)))
|
||||
.map((item) =>
|
||||
_voiceCommandFromJson(Map<String, dynamic>.from(item)))
|
||||
.whereType<VoiceCommand>()
|
||||
.toList();
|
||||
if (commands.isNotEmpty) {
|
||||
@@ -181,18 +183,40 @@ class _AppShell extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: child,
|
||||
bottomNavigationBar: NavigationBar(
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) => context.go(items[index].route),
|
||||
destinations: [
|
||||
for (final item in items)
|
||||
NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
backgroundColor: AppColors.surface,
|
||||
body: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 180),
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
child: KeyedSubtree(
|
||||
key: ValueKey(location),
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
border: const Border(top: BorderSide(color: AppColors.border)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withValues(alpha: 0.06),
|
||||
blurRadius: 18,
|
||||
offset: const Offset(0, -8),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
child: NavigationBar(
|
||||
selectedIndex: _selectedIndex,
|
||||
onDestinationSelected: (index) => context.go(items[index].route),
|
||||
destinations: [
|
||||
for (final item in items)
|
||||
NavigationDestination(
|
||||
icon: Icon(item.icon),
|
||||
selectedIcon: Icon(item.selectedIcon),
|
||||
label: item.label,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../core/theme/app_colors.dart';
|
||||
|
||||
class FeaturePage extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
@@ -18,33 +20,51 @@ class FeaturePage extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
padding: const EdgeInsets.fromLTRB(16, 14, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style:
|
||||
Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: const Color(0xFF0F172A),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(color: Color(0xFF64748B)),
|
||||
),
|
||||
],
|
||||
),
|
||||
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,
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context)
|
||||
.textTheme
|
||||
.headlineSmall
|
||||
?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
color: AppColors.text,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
subtitle,
|
||||
style: const TextStyle(
|
||||
color: AppColors.muted,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (trailing != null) trailing!,
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Expanded(child: child),
|
||||
@@ -77,7 +97,16 @@ class FeatureEmptyPanel extends StatelessWidget {
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 48, color: const Color(0xFF64748B)),
|
||||
Container(
|
||||
width: 72,
|
||||
height: 72,
|
||||
decoration: BoxDecoration(
|
||||
color: AppColors.primary.withValues(alpha: 0.08),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: AppColors.border),
|
||||
),
|
||||
child: Icon(icon, size: 36, color: AppColors.primary),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
title,
|
||||
@@ -88,7 +117,7 @@ class FeatureEmptyPanel extends StatelessWidget {
|
||||
Text(
|
||||
message,
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: Color(0xFF64748B), height: 1.35),
|
||||
style: const TextStyle(color: AppColors.muted, height: 1.35),
|
||||
),
|
||||
if (action != null) ...[
|
||||
const SizedBox(height: 16),
|
||||
@@ -120,7 +149,7 @@ class FeatureErrorPanel extends StatelessWidget {
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFFEF2F2),
|
||||
borderRadius: BorderRadius.circular(18),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
border: Border.all(color: const Color(0xFFFECACA)),
|
||||
),
|
||||
child: Column(
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 452 KiB |
@@ -33,6 +33,7 @@
|
||||
<link rel="manifest" href="manifest.json">
|
||||
</head>
|
||||
<body>
|
||||
<script src="https://download.agora.io/sdk/release/AgoraRTC_N-4.20.2.js"></script>
|
||||
<script src="flutter_bootstrap.js" async></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user