diff --git a/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart b/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart index 7472ca7..9ce364e 100644 --- a/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart +++ b/walkguide-mobile/walkguide_app/lib/core/services/websocket_service.dart @@ -1,41 +1,193 @@ import 'dart:async'; +import 'dart:convert'; import 'package:flutter/foundation.dart'; -import 'package:web_socket_channel/web_socket_channel.dart'; +import 'package:stomp_dart_client/stomp_dart_client.dart'; import '../constants/app_constants.dart'; import '../storage/secure_storage.dart'; +/// WebSocket Service pakai STOMP (stomp_dart_client). +/// +/// Spring Boot backend pakai @EnableWebSocketMessageBroker dengan SockJS. +/// 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 class WebSocketService { final SecureStorage _storage; - WebSocketChannel? _channel; - StreamSubscription? _subscription; + + StompClient? _client; + bool _connected = false; + + // Subscription callbacks + void Function(double lat, double lng)? _onLocation; + void Function(Map sosData)? _onSos; + void Function(Map notifData)? _onNotif; + + // Subscription frames (untuk unsubscribe) + StompUnsubscribe? _locationUnsub; + StompUnsubscribe? _sosUnsub; + StompUnsubscribe? _notifUnsub; WebSocketService(this._storage); - Future connect(String serverUrl, {void Function(dynamic event)? onMessage}) async { + bool get isConnected => _connected; + + /// Connect ke WebSocket server. + /// Dipanggil setelah login berhasil (dari screens.dart _startPostLoginServices). + Future connect(String serverUrl) async { await disconnect(); + final token = await _storage.getAccessToken(); - final wsUrl = Uri.parse('${AppConstants.buildWsUrl(serverUrl)}${token == null ? '' : '?token=$token'}'); + final wsUrl = AppConstants.buildWsUrl(serverUrl); + + final completer = Completer(); + + _client = StompClient( + config: StompConfig( + url: wsUrl, + onConnect: (frame) { + _connected = true; + debugPrint('[WS] STOMP connected to $wsUrl'); + if (!completer.isCompleted) completer.complete(); + }, + onDisconnect: (frame) { + _connected = false; + debugPrint('[WS] STOMP disconnected'); + }, + onStompError: (frame) { + debugPrint('[WS] STOMP error: ${frame.body}'); + if (!completer.isCompleted) { + completer.completeError(frame.body ?? 'STOMP error'); + } + }, + onWebSocketError: (dynamic error) { + debugPrint('[WS] WebSocket error: $error'); + _connected = false; + if (!completer.isCompleted) completer.completeError(error); + }, + onUnhandledMessage: (frame) { + debugPrint('[WS] Unhandled: ${frame.body}'); + }, + // Inject JWT token di header STOMP CONNECT + stompConnectHeaders: + token != null ? {'Authorization': 'Bearer $token'} : {}, + webSocketConnectHeaders: + token != null ? {'Authorization': 'Bearer $token'} : {}, + reconnectDelay: const Duration(seconds: 5), + ), + ); + + _client!.activate(); + + // Tunggu connected atau timeout try { - _channel = WebSocketChannel.connect(wsUrl); - _subscription = _channel!.stream.listen( - onMessage ?? (event) => debugPrint('$event'), - onError: (Object error, StackTrace stackTrace) => debugPrint('$error'), - ); + await completer.future.timeout(const Duration(seconds: 5)); } catch (e) { - debugPrint('WebSocket connect skipped: $e'); + debugPrint('[WS] Connect timeout/error: $e'); + // Don't throw — let dashboard work without WS } } - void send(Object message) { - _channel?.sink.add(message); + /// 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) { + _onLocation = callback; + if (_client == null || !_connected) { + debugPrint('[WS] subscribeLocation skipped — not connected'); + return; + } + _locationUnsub?.call(); // unsubscribe sebelumnya jika ada + _locationUnsub = _client!.subscribe( + destination: '/topic/location/$userId', + callback: (frame) { + try { + final data = + jsonDecode(frame.body ?? '{}') as Map; + final lat = (data['lat'] as num?)?.toDouble(); + final lng = (data['lng'] as num?)?.toDouble(); + if (lat != null && lng != null) { + _onLocation?.call(lat, lng); + } + } catch (e) { + debugPrint('[WS] Location parse error: $e'); + } + }, + ); + debugPrint('[WS] Subscribed to /topic/location/$userId'); } + /// Subscribe ke SOS alert untuk Guardian. + /// [guardianId] = ID dari ROLE_GUARDIAN yang login. + void subscribeSos(void Function(Map sosData) callback) { + _onSos = callback; + if (_client == null || !_connected) return; + + _storage.getUserId().then((guardianId) { + if (guardianId == null) return; + _sosUnsub?.call(); + _sosUnsub = _client!.subscribe( + destination: '/queue/sos/$guardianId', + callback: (frame) { + try { + final data = + jsonDecode(frame.body ?? '{}') as Map; + _onSos?.call(data); + } catch (e) { + debugPrint('[WS] SOS parse error: $e'); + } + }, + ); + debugPrint('[WS] Subscribed to /queue/sos/$guardianId'); + }); + } + + /// Subscribe ke notifikasi Guardian → User. + /// [userId] = ID dari ROLE_USER yang login. + void subscribeNotification( + void Function(Map notifData) callback) { + _onNotif = callback; + if (_client == null || !_connected) return; + + _storage.getUserId().then((userId) { + if (userId == null) return; + _notifUnsub?.call(); + _notifUnsub = _client!.subscribe( + destination: '/queue/notif/$userId', + callback: (frame) { + try { + final data = + jsonDecode(frame.body ?? '{}') as Map; + _onNotif?.call(data); + } catch (e) { + debugPrint('[WS] Notif parse error: $e'); + } + }, + ); + debugPrint('[WS] Subscribed to /queue/notif/$userId'); + }); + } + + /// Disconnect dan cleanup semua subscriptions. Future disconnect() async { - await _subscription?.cancel(); - _subscription = null; - await _channel?.sink.close(); - _channel = null; + _locationUnsub?.call(); + _sosUnsub?.call(); + _notifUnsub?.call(); + _locationUnsub = null; + _sosUnsub = null; + _notifUnsub = null; + _client?.deactivate(); + _client = null; + _connected = false; + } + + // Legacy compat — lama pakai onMessage raw + void send(Object message) { + debugPrint('[WS] send() not used in STOMP mode. Use subscribe callbacks.'); } } diff --git a/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart b/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart index 07d0551..4d11621 100644 --- a/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart +++ b/walkguide-mobile/walkguide_app/lib/features/home/presentation/guardian_dashboard_screen.dart @@ -1,423 +1,1648 @@ -import 'package:flutter/material.dart'; -import 'package:google_fonts/google_fonts.dart'; -import '../../../core/secure_storage.dart'; -import '../../auth/presentation/login_screen.dart'; +// ignore_for_file: use_build_context_synchronously, deprecated_member_use +import 'dart:async'; +import 'dart:math' as math; -class GuardianDashboardScreen extends StatelessWidget { +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:latlong2/latlong.dart'; + +import '../../../app/injection_container.dart'; +import '../../../core/network/api_client.dart'; +import '../../../core/services/websocket_service.dart'; +import '../../../core/storage/secure_storage.dart'; + +// ───────────────────────────────────────────────────────────────────────────── +// GUARDIAN DASHBOARD SCREEN +// Fully live — fetches real data from backend, subscribes to WebSocket +// for real-time GPS updates and SOS alerts. +// ───────────────────────────────────────────────────────────────────────────── + +class GuardianDashboardScreen extends StatefulWidget { const GuardianDashboardScreen({super.key}); - Future _logout(BuildContext ctx) async { - await SecureStorage().deleteToken(); - if (ctx.mounted) { - Navigator.pushAndRemoveUntil( - ctx, - MaterialPageRoute(builder: (_) => const LoginScreen()), - (_) => false, - ); - } + @override + State createState() => + _GuardianDashboardScreenState(); +} + +class _GuardianDashboardScreenState extends State + with TickerProviderStateMixin { + // ── Data state ────────────────────────────────────────────────────────────── + _DashboardData? _data; + bool _loading = true; + String? _error; + String _guardianName = 'Guardian'; + int _refreshCount = 0; + + // ── Live location (WebSocket) ──────────────────────────────────────────────── + LatLng? _liveLatLng; + bool _liveConnected = false; + final MapController _mapController = MapController(); + + // ── Pulse animation for live dot ──────────────────────────────────────────── + late final AnimationController _pulseCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1800), + )..repeat(reverse: true); + late final Animation _pulseAnim = + Tween(begin: 0.4, end: 1.0).animate( + CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut), + ); + + // ── SOS flash animation ────────────────────────────────────────────────────── + late final AnimationController _sosCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 600), + ); + bool _sosAlert = false; + + // ── Refresh button animation ───────────────────────────────────────────────── + late final AnimationController _refreshCtrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 700), + ); + + @override + void initState() { + super.initState(); + _loadAll(); + _subscribeWebSocket(); } @override - Widget build(BuildContext context) { - return Scaffold( - backgroundColor: const Color(0xFFF8FAFC), - body: Column(children: [ - _buildTopBar(context), - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.all(20), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - _buildPageHeader(), - const SizedBox(height: 18), - _buildKpiRow(), - const SizedBox(height: 18), - _buildMainGrid(context), - ]), - ), - ), - ]), - ); + void dispose() { + _pulseCtrl.dispose(); + _sosCtrl.dispose(); + _refreshCtrl.dispose(); + super.dispose(); } - Widget _buildTopBar(BuildContext ctx) { - return Container( - height: 52, - decoration: const BoxDecoration( - color: Colors.white, - border: Border(bottom: BorderSide(color: Color(0xFFE2E8F0), width: 0.5)), - ), - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Row(children: [ - Container( - width: 26, - height: 26, - decoration: BoxDecoration( - color: const Color(0xFF1A56DB), - borderRadius: BorderRadius.circular(7), - ), - child: const Icon(Icons.navigation_rounded, color: Colors.white, size: 14), - ), - const SizedBox(width: 8), - Text('WalkGuide', style: GoogleFonts.outfit(fontSize: 14, fontWeight: FontWeight.w600)), - const SizedBox(width: 20), - _navItem('Overview', true), - _navItem('Live Track', false), - _navItem('Settings', false), - _navItem('Alerts', false), - const Spacer(), - TextButton.icon( - onPressed: () => _logout(ctx), - icon: const Icon(Icons.logout, size: 14, color: Color(0xFF64748B)), - label: Text('Sign out', style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF64748B))), - ), - const SizedBox(width: 10), - Row(children: [ - Column( - crossAxisAlignment: CrossAxisAlignment.end, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Text('Guardian', style: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w600)), - Text('guardian@walkguide.com', - style: GoogleFonts.inter(fontSize: 10, color: const Color(0xFF94A3B8))), - ], - ), - const SizedBox(width: 8), - CircleAvatar( - radius: 14, - backgroundColor: const Color(0xFF1A56DB), - child: Text('GD', - style: GoogleFonts.inter(fontSize: 10, color: Colors.white, fontWeight: FontWeight.w600)), + Dio get _api => sl().dio; + + // ── Load all dashboard data in parallel ──────────────────────────────────── + Future _loadAll({bool silent = false}) async { + if (!silent) { + setState(() { + _loading = true; + _error = null; + }); + } + try { + _guardianName = + await sl().getDisplayName() ?? 'Guardian'; + + // Run dashboard + activity + SOS in parallel + final results = await Future.wait([ + _fetchDashboard(), + _fetchActivity(), + _fetchSosPending(), + ]); + + final dashboard = results[0] as Map?; + final activityList = + results[1] as List>; + final sosPending = results[2] as int; + + // Extract latest GPS from dashboard + final lastLoc = dashboard?['lastLocation'] as Map?; + LatLng? newLatLng; + if (lastLoc != null && + lastLoc['lat'] != null && + lastLoc['lng'] != null) { + newLatLng = LatLng( + (lastLoc['lat'] as num).toDouble(), + (lastLoc['lng'] as num).toDouble(), + ); + } + + // Extract user info + final userStatus = + dashboard?['userStatus'] as Map?; + + setState(() { + _data = _DashboardData( + userName: userStatus?['displayName']?.toString() ?? + dashboard?['userName']?.toString() ?? + 'User', + userOnline: + userStatus?['online'] as bool? ?? false, + userLastSeen: + userStatus?['lastSeenAt']?.toString(), + battery: userStatus?['battery'] as int?, + speed: userStatus?['lastSpeed'] as double?, + obstaclesTotal: + userStatus?['obstaclesToday'] as int? ?? + dashboard?['obstaclesToday'] as int? ?? + 0, + unreadNotif: + dashboard?['unreadNotifCount'] as int? ?? 0, + unreadSos: sosPending, + lastLat: lastLoc?['lat'] != null + ? (lastLoc!['lat'] as num).toDouble() + : null, + lastLng: lastLoc?['lng'] != null + ? (lastLoc!['lng'] as num).toDouble() + : null, + lastLocationTime: + lastLoc?['createdAt']?.toString(), + recentActivity: activityList, + isPaired: userStatus != null || dashboard != null, + ); + if (newLatLng != null) { + _liveLatLng = newLatLng; + } + _loading = false; + _refreshCount++; + }); + + // If SOS pending, start flash + if (sosPending > 0 && !_sosAlert) { + _triggerSosFlash(); + } + + // Move map to latest location + if (newLatLng != null) { + try { + _mapController.move(newLatLng, 15); + } catch (_) {} + } + } catch (e) { + setState(() { + _loading = false; + _error = _friendlyError(e); + }); + } + } + + Future?> _fetchDashboard() async { + try { + final res = await _api + .get('/guardian/dashboard') + .timeout(const Duration(seconds: 8)); + final d = res.data['data']; + return d is Map ? Map.from(d) : null; + } catch (_) { + return null; + } + } + + Future>> _fetchActivity() async { + try { + final res = await _api + .get('/guardian/activity-logs', + queryParameters: {'size': 5, 'page': 0}) + .timeout(const Duration(seconds: 8)); + final data = res.data['data']; + final content = + data is Map ? data['content'] : null; + if (content is List) { + return content + .whereType() + .map((e) => Map.from(e)) + .toList(); + } + } catch (_) {} + return const []; + } + + Future _fetchSosPending() async { + try { + final res = await _api + .get('/guardian/sos-events', + queryParameters: {'size': 10, 'page': 0}) + .timeout(const Duration(seconds: 8)); + final data = res.data['data']; + final content = + data is Map ? data['content'] : null; + if (content is List) { + return content + .whereType() + .where((e) => e['status'] == 'TRIGGERED') + .length; + } + } catch (_) {} + return 0; + } + + // ── WebSocket subscription ────────────────────────────────────────────────── + void _subscribeWebSocket() { + final ws = sl(); + Future.microtask(() async { + try { + final userId = await _getLinkedUserId(); + if (userId == null) return; + ws.subscribeLocation(userId, (lat, lng) { + if (!mounted) return; + final newPos = LatLng(lat, lng); + setState(() { + _liveLatLng = newPos; + _liveConnected = true; + }); + try { + _mapController.move(newPos, 15); + } catch (_) {} + }); + ws.subscribeSos((sosData) { + if (!mounted) return; + _triggerSosFlash(); + setState(() { + _data = _data?.copyWith( + unreadSos: (_data?.unreadSos ?? 0) + 1); + }); + _showSosSnackbar(sosData); + }); + if (mounted) setState(() => _liveConnected = true); + } catch (_) {} + }); + } + + Future _getLinkedUserId() async { + try { + final res = await _api + .get('/shared/pairing/status') + .timeout(const Duration(seconds: 5)); + final d = res.data['data']; + if (d is Map && d['status'] == 'ACTIVE') { + return d['pairedWithId']?.toString() ?? + d['userId']?.toString(); + } + } catch (_) {} + return null; + } + + void _triggerSosFlash() { + if (_sosAlert) return; + setState(() => _sosAlert = true); + _sosCtrl.repeat(reverse: true); + // Auto-dismiss after 30s + Future.delayed(const Duration(seconds: 30), () { + if (mounted) { + setState(() => _sosAlert = false); + _sosCtrl.stop(); + } + }); + } + + void _showSosSnackbar(Map data) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + backgroundColor: const Color(0xFFDC2626), + duration: const Duration(seconds: 8), + content: Row(children: [ + const Icon(Icons.warning_rounded, + color: Colors.white), + const SizedBox(width: 10), + Expanded( + child: Text( + '🚨 SOS dari User! Koordinat: ${data['lat']?.toStringAsFixed(4) ?? '-'}, ${data['lng']?.toStringAsFixed(4) ?? '-'}', + style: const TextStyle( + color: Colors.white, + fontWeight: FontWeight.w700), + ), ), ]), - ]), - ); - } - - Widget _navItem(String label, bool active) { - return Container( - margin: const EdgeInsets.only(right: 2), - child: TextButton( - onPressed: () {}, - style: TextButton.styleFrom( - backgroundColor: active ? const Color(0xFFF1F5F9) : Colors.transparent, - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(7)), - ), - child: Text( - label, - style: GoogleFonts.inter( - fontSize: 13, - fontWeight: active ? FontWeight.w600 : FontWeight.w400, - color: active ? const Color(0xFF0F172A) : const Color(0xFF64748B), - ), + action: SnackBarAction( + label: 'Lihat', + textColor: Colors.white, + onPressed: () => context.go('/guardian/logs'), ), ), ); } - Widget _buildPageHeader() { - return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Guardian Command', - style: GoogleFonts.outfit(fontSize: 20, fontWeight: FontWeight.w600, color: const Color(0xFF0F172A))), - Text('Thursday, April 23, 2026 — Real-time monitoring active', - style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF64748B))), + Future _refresh() async { + HapticFeedback.lightImpact(); + _refreshCtrl.forward(from: 0); + await _loadAll(silent: true); + } + + String _friendlyError(Object e) { + if (e is DioException) { + if (e.type == DioExceptionType.connectionError) { + return 'Tidak bisa ke server. Pastikan backend running.'; + } + if (e.response?.statusCode == 403) { + return 'Login sebagai Guardian untuk melihat dashboard.'; + } + } + return 'Gagal memuat dashboard. Coba refresh.'; + } + + // ───────────────────────────────────────────────────────────────────────── + // BUILD + // ───────────────────────────────────────────────────────────────────────── + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF0F4FF), + body: SafeArea( + child: Column( + children: [ + _buildTopBar(), + if (_sosAlert) _buildSosBanner(), + Expanded( + child: _loading + ? _buildSkeleton() + : _error != null + ? _buildError() + : RefreshIndicator( + onRefresh: _refresh, + color: const Color(0xFF1A56DB), + child: SingleChildScrollView( + physics: + const AlwaysScrollableScrollPhysics(), + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: + CrossAxisAlignment.start, + children: [ + _buildGreetingRow(), + const SizedBox(height: 14), + _buildKpiStrip(), + const SizedBox(height: 14), + _buildMainRow(), + const SizedBox(height: 14), + _buildActivitySection(), + const SizedBox(height: 14), + _buildQuickActions(), + const SizedBox(height: 8), + ], + ), + ), + ), + ), + ], + ), + ), + ); + } + + // ── Top bar ───────────────────────────────────────────────────────────────── + Widget _buildTopBar() { + return Container( + height: 56, + padding: const EdgeInsets.symmetric(horizontal: 16), + decoration: const BoxDecoration( + color: Colors.white, + border: Border( + bottom: + BorderSide(color: Color(0xFFE2E8F0), width: 1)), + ), + child: Row(children: [ + // Logo + Container( + width: 30, + height: 30, + decoration: BoxDecoration( + gradient: const LinearGradient( + colors: [Color(0xFF1A56DB), Color(0xFF3B82F6)], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(8), + ), + child: const Icon(Icons.navigation_rounded, + color: Colors.white, size: 16), + ), + const SizedBox(width: 10), + Text('WalkGuide', + style: GoogleFonts.outfit( + fontSize: 16, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A))), + const SizedBox(width: 4), + Container( + padding: + const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: const Color(0xFFEFF6FF), + borderRadius: BorderRadius.circular(20), + ), + child: Text('Guardian', + style: GoogleFonts.inter( + fontSize: 10, + color: const Color(0xFF1A56DB), + fontWeight: FontWeight.w600)), + ), + const Spacer(), + // Live dot + AnimatedBuilder( + animation: _pulseAnim, + builder: (_, __) => Row(children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _liveConnected + ? Color.fromRGBO( + 22, 163, 74, _pulseAnim.value) + : const Color(0xFF94A3B8), + boxShadow: _liveConnected + ? [ + BoxShadow( + color: Color.fromRGBO( + 22, 163, 74, _pulseAnim.value * 0.5), + blurRadius: 6, + spreadRadius: 2, + ) + ] + : null, + ), + ), + const SizedBox(width: 5), + Text( + _liveConnected ? 'Live' : 'Offline', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: _liveConnected + ? const Color(0xFF16A34A) + : const Color(0xFF94A3B8), + ), + ), + ]), + ), + const SizedBox(width: 12), + // Refresh + RotationTransition( + turns: _refreshCtrl, + child: IconButton( + onPressed: _refresh, + icon: const Icon(Icons.refresh_rounded, + size: 20, color: Color(0xFF64748B)), + tooltip: 'Refresh', + ), + ), + const SizedBox(width: 4), + // Avatar + CircleAvatar( + radius: 16, + backgroundColor: const Color(0xFF1A56DB), + child: Text( + _guardianName.isNotEmpty + ? _guardianName[0].toUpperCase() + : 'G', + style: GoogleFonts.outfit( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w700), + ), + ), + ]), + ); + } + + // ── SOS banner ────────────────────────────────────────────────────────────── + Widget _buildSosBanner() { + return AnimatedBuilder( + animation: _sosCtrl, + builder: (_, __) => Container( + color: Color.lerp(const Color(0xFFDC2626), + const Color(0xFFFF6B6B), _sosCtrl.value), + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 10), + child: Row(children: [ + const Icon(Icons.warning_rounded, + color: Colors.white, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + '🚨 SOS AKTIF — User membutuhkan bantuan segera!', + style: GoogleFonts.inter( + color: Colors.white, + fontWeight: FontWeight.w700, + fontSize: 13), + ), + ), + TextButton( + onPressed: () => context.go('/guardian/logs'), + style: TextButton.styleFrom( + foregroundColor: Colors.white), + child: const Text('Tangani'), + ), + IconButton( + onPressed: () { + setState(() => _sosAlert = false); + _sosCtrl.stop(); + }, + icon: const Icon(Icons.close, + color: Colors.white, size: 16), + padding: EdgeInsets.zero, + ), + ]), + ), + ); + } + + // ── Greeting row ──────────────────────────────────────────────────────────── + Widget _buildGreetingRow() { + final hour = DateTime.now().hour; + final greeting = hour < 12 + ? 'Selamat pagi' + : hour < 17 + ? 'Selamat siang' + : 'Selamat malam'; + return Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$greeting, $_guardianName', + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A)), + ), + Text( + _data?.isPaired == true + ? 'Memantau ${_data?.userName ?? "User"} secara real-time' + : 'Belum terhubung dengan User', + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF64748B)), + ), + ], + ), + ), + if (_data?.unreadSos != null && _data!.unreadSos > 0) + _SosBadge(count: _data!.unreadSos), + ], + ); + } + + // ── KPI strip ─────────────────────────────────────────────────────────────── + Widget _buildKpiStrip() { + if (_data == null) return const SizedBox.shrink(); + final d = _data!; + return Row(children: [ + Expanded( + child: _KpiCard( + label: 'Status', + value: d.isPaired + ? (d.userOnline ? 'Online' : 'Offline') + : 'Belum Pair', + valueColor: d.isPaired && d.userOnline + ? const Color(0xFF16A34A) + : const Color(0xFF94A3B8), + icon: Icons.person_outline, + sub: d.isPaired + ? (d.userOnline ? 'Aktif berjalan' : _formatLastSeen(d.userLastSeen)) + : 'Buka menu Pairing', + )), + const SizedBox(width: 10), + Expanded( + child: _KpiCard( + label: 'Baterai', + value: + d.battery != null ? '${d.battery}%' : '—', + valueColor: d.battery != null && d.battery! < 20 + ? const Color(0xFFDC2626) + : const Color(0xFF0F172A), + icon: Icons.battery_std_outlined, + sub: d.battery != null + ? (d.battery! > 50 + ? 'Baterai cukup' + : 'Baterai hampir habis') + : 'Belum tersedia', + )), + const SizedBox(width: 10), + Expanded( + child: _KpiCard( + label: 'Obstacle Hari Ini', + value: '${d.obstaclesTotal}', + valueColor: d.obstaclesTotal > 10 + ? const Color(0xFFD97706) + : const Color(0xFF0F172A), + icon: Icons.radar_outlined, + sub: d.obstaclesTotal > 0 + ? 'AI deteksi aktif' + : 'Belum ada deteksi', + )), + const SizedBox(width: 10), + Expanded( + child: _KpiCard( + label: 'SOS Pending', + value: '${d.unreadSos}', + valueColor: d.unreadSos > 0 + ? const Color(0xFFDC2626) + : const Color(0xFF16A34A), + icon: Icons.sos_outlined, + sub: d.unreadSos > 0 + ? 'Perlu perhatian!' + : 'Aman', + highlight: d.unreadSos > 0, + )), ]); } - Widget _buildKpiRow() { - final kpis = [ - {'label': 'User Status', 'val': '● Active', 'sub': 'Walking — Jl. Kenangan SBY', 'isGreen': true}, - {'label': 'Battery Level', 'val': '85%', 'sub': 'Good — Est. 6h left', 'isGreen': false}, - {'label': 'AI Alerts Today', 'val': '12', 'sub': '3 obstacles detected', 'isGreen': false}, - {'label': 'Alert Distance', 'val': '2.5m', 'sub': 'Haptic feedback on', 'isGreen': false}, - ]; - return Row( - children: kpis.map((k) { - final isLast = kpis.indexOf(k) == kpis.length - 1; - return Expanded( - child: Container( - margin: EdgeInsets.only(right: isLast ? 0 : 10), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(k['label'] as String, - style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF94A3B8), letterSpacing: 0.04)), - const SizedBox(height: 6), - Text( - k['val'] as String, - style: GoogleFonts.outfit( - fontSize: 20, - fontWeight: FontWeight.w600, - color: (k['isGreen'] as bool) ? const Color(0xFF16A34A) : const Color(0xFF0F172A), - ), - ), - const SizedBox(height: 3), - Text(k['sub'] as String, - style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF1A56DB))), - ]), - ), - ); - }).toList(), + // ── Main row: map + user card ──────────────────────────────────────────────── + Widget _buildMainRow() { + return IntrinsicHeight( + child: Row( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Expanded(flex: 3, child: _buildMapCard()), + const SizedBox(width: 12), + SizedBox(width: 180, child: _buildUserCard()), + ], + ), ); } - Widget _buildMainGrid(BuildContext ctx) { - return Row( + // ── Map card ───────────────────────────────────────────────────────────────── + Widget _buildMapCard() { + final pos = _liveLatLng ?? + (_data?.lastLat != null && _data?.lastLng != null + ? LatLng(_data!.lastLat!, _data!.lastLng!) + : null); + + return Container( + height: 220, + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: + Border.all(color: const Color(0xFFE2E8F0), width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, 2)), + ], + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(16), + child: Stack(children: [ + // Map or placeholder + if (pos != null) + FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: pos, initialZoom: 15), + children: [ + TileLayer( + urlTemplate: + 'https://tile.openstreetmap.org/{z}/{x}/{y}.png', + userAgentPackageName: 'com.walkguide.app', + ), + MarkerLayer(markers: [ + Marker( + point: pos, + width: 48, + height: 48, + child: AnimatedBuilder( + animation: _pulseAnim, + builder: (_, __) => Stack( + alignment: Alignment.center, + children: [ + Container( + width: 32 * _pulseAnim.value, + height: 32 * _pulseAnim.value, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: const Color(0xFF1A56DB) + .withOpacity(0.25 * _pulseAnim.value), + ), + ), + Container( + width: 16, + height: 16, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFF1A56DB), + ), + ), + ], + ), + ), + ), + ]), + ], + ) + else + Container( + color: const Color(0xFFF1F5F9), + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.map_outlined, + size: 48, + color: const Color(0xFFCBD5E1)), + const SizedBox(height: 8), + Text( + _data?.isPaired == true + ? 'Belum ada lokasi terbaru' + : 'Pairing dulu untuk melihat peta', + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF94A3B8)), + ), + ], + ), + ), + ), + + // Header overlay + Positioned( + top: 10, + left: 10, + right: 10, + child: Row(children: [ + Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.92), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.1), + blurRadius: 8), + ], + ), + child: Row(children: [ + Container( + width: 7, + height: 7, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: _liveConnected + ? const Color(0xFF16A34A) + : const Color(0xFF94A3B8), + ), + ), + const SizedBox(width: 5), + Text( + _liveConnected + ? 'Live Tracking' + : pos != null + ? 'Lokasi Terakhir' + : 'Menunggu GPS', + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w600, + color: const Color(0xFF0F172A)), + ), + ]), + ), + const Spacer(), + GestureDetector( + onTap: () => context.go('/guardian/map'), + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: + const Color(0xFF1A56DB).withOpacity(0.9), + borderRadius: BorderRadius.circular(20), + ), + child: Text('Buka Peta', + style: GoogleFonts.inter( + fontSize: 11, + color: Colors.white, + fontWeight: FontWeight.w600)), + ), + ), + ]), + ), + + // Coordinates footer + if (pos != null) + Positioned( + bottom: 10, + left: 10, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Colors.white.withOpacity(0.92), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.08), + blurRadius: 6), + ], + ), + child: Text( + '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}', + style: GoogleFonts.jetBrainsMono( + fontSize: 10, + color: const Color(0xFF0F172A), + fontWeight: FontWeight.w500), + ), + ), + ), + ]), + ), + ); + } + + // ── User card ───────────────────────────────────────────────────────────────── + Widget _buildUserCard() { + if (_data?.isPaired != true) { + return _buildNoPairingCard(); + } + final d = _data!; + return Container( + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, 2)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Avatar + name + Row(children: [ + CircleAvatar( + radius: 20, + backgroundColor: + const Color(0xFF1A56DB).withOpacity(0.1), + child: Text( + d.userName.isNotEmpty + ? d.userName[0].toUpperCase() + : 'U', + style: GoogleFonts.outfit( + fontSize: 16, + color: const Color(0xFF1A56DB), + fontWeight: FontWeight.w700), + ), + ), + const SizedBox(width: 8), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(d.userName, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + Row(children: [ + Container( + width: 6, + height: 6, + decoration: BoxDecoration( + shape: BoxShape.circle, + color: d.userOnline + ? const Color(0xFF16A34A) + : const Color(0xFF94A3B8), + ), + ), + const SizedBox(width: 4), + Text( + d.userOnline ? 'Online' : 'Offline', + style: GoogleFonts.inter( + fontSize: 10, + color: d.userOnline + ? const Color(0xFF16A34A) + : const Color(0xFF94A3B8))), + ]), + ], + ), + ), + ]), + const SizedBox(height: 14), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 12), + + // Stats grid + _buildStatRow( + Icons.battery_std_outlined, + 'Baterai', + d.battery != null ? '${d.battery}%' : '—', + d.battery != null && d.battery! < 20 + ? const Color(0xFFDC2626) + : const Color(0xFF16A34A)), + const SizedBox(height: 8), + _buildStatRow( + Icons.speed_outlined, + 'Kecepatan', + d.speed != null + ? '${(d.speed! * 3.6).toStringAsFixed(1)} km/h' + : '—', + const Color(0xFF1A56DB)), + const SizedBox(height: 8), + _buildStatRow( + Icons.radar_outlined, + 'Obstacle', + '${d.obstaclesTotal} hari ini', + d.obstaclesTotal > 5 + ? const Color(0xFFD97706) + : const Color(0xFF0F172A)), + const SizedBox(height: 8), + _buildStatRow( + Icons.notifications_outlined, + 'Notif Belum Dibaca', + '${d.unreadNotif}', + d.unreadNotif > 0 + ? const Color(0xFF7C3AED) + : const Color(0xFF0F172A)), + + const Spacer(), + + // Call button + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: () => context.go('/user/call'), + icon: const Icon(Icons.call, size: 14), + label: const Text('Hubungi User'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF16A34A), + padding: const EdgeInsets.symmetric(vertical: 8), + textStyle: GoogleFonts.inter( + fontSize: 12, fontWeight: FontWeight.w600), + ), + ), + ), + ], + ), + ); + } + + Widget _buildStatRow( + IconData icon, String label, String value, Color valueColor) { + return Row(children: [ + Icon(icon, size: 14, color: const Color(0xFF94A3B8)), + const SizedBox(width: 6), + Expanded( + child: Text(label, + style: GoogleFonts.inter( + fontSize: 10, color: const Color(0xFF64748B))), + ), + Text(value, + style: GoogleFonts.outfit( + fontSize: 12, + fontWeight: FontWeight.w700, + color: valueColor)), + ]); + } + + Widget _buildNoPairingCard() { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFDE68A)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Icon(Icons.link_off, + color: Color(0xFFD97706), size: 40), + const SizedBox(height: 12), + Text('Belum Pairing', + style: GoogleFonts.outfit( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF92400E))), + const SizedBox(height: 6), + Text( + 'Masukkan Unique ID User untuk mulai memantau.', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF92400E)), + textAlign: TextAlign.center, + ), + const SizedBox(height: 14), + SizedBox( + width: double.infinity, + child: FilledButton( + onPressed: () => context.go('/guardian/pairing'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFFD97706), + padding: const EdgeInsets.symmetric(vertical: 8)), + child: Text('Pair Sekarang', + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600)), + ), + ), + ], + ), + ); + } + + // ── Activity section ───────────────────────────────────────────────────────── + Widget _buildActivitySection() { + final items = _data?.recentActivity ?? const []; + return Container( + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.04), + blurRadius: 12, + offset: const Offset(0, 2)), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: + const EdgeInsets.fromLTRB(16, 14, 12, 10), + child: Row(children: [ + Text('Aktivitas Terkini', + style: GoogleFonts.outfit( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A))), + const Spacer(), + TextButton.icon( + onPressed: () => context.go('/guardian/logs'), + icon: const Icon(Icons.open_in_new, size: 13), + label: Text('Semua', + style: GoogleFonts.inter(fontSize: 12)), + style: TextButton.styleFrom( + foregroundColor: const Color(0xFF1A56DB), + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 4)), + ), + ]), + ), + if (items.isEmpty) + Padding( + padding: const EdgeInsets.all(20), + child: Center( + child: Text( + _data?.isPaired == true + ? 'Belum ada aktivitas. Minta User start WalkGuide.' + : 'Aktivitas akan muncul setelah pairing.', + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF94A3B8)), + textAlign: TextAlign.center, + ), + ), + ) + else + ...items.asMap().entries.map((entry) { + final i = entry.key; + final item = entry.value; + return Column(children: [ + if (i == 0) + const Divider( + height: 1, color: Color(0xFFF1F5F9)), + _ActivityTile(data: item), + ]); + }), + const SizedBox(height: 4), + ], + ), + ); + } + + // ── Quick actions ───────────────────────────────────────────────────────────── + Widget _buildQuickActions() { + final actions = [ + _ActionItem( + icon: Icons.map_outlined, + label: 'Live Map', + sub: 'Lacak lokasi', + color: const Color(0xFF1A56DB), + onTap: () => context.go('/guardian/map'), + ), + _ActionItem( + icon: Icons.send_outlined, + label: 'Kirim Pesan', + sub: 'Text / suara', + color: const Color(0xFF7C3AED), + onTap: () => context.go('/guardian/send-notif'), + ), + _ActionItem( + icon: Icons.tune_outlined, + label: 'AI Config', + sub: 'Sesuaikan YOLO', + color: const Color(0xFF0891B2), + onTap: () => context.go('/guardian/ai-config'), + ), + _ActionItem( + icon: Icons.record_voice_over_outlined, + label: 'Voice Cmd', + sub: 'Atur perintah', + color: const Color(0xFF059669), + onTap: () => context.go('/guardian/voice-cmd'), + ), + _ActionItem( + icon: Icons.fence_outlined, + label: 'Geofence', + sub: 'Area aman', + color: const Color(0xFFD97706), + onTap: () => context.go('/guardian/geofence'), + ), + _ActionItem( + icon: Icons.fact_check_outlined, + label: 'Log Lengkap', + sub: 'Semua aktivitas', + color: const Color(0xFF64748B), + onTap: () => context.go('/guardian/logs'), + ), + ]; + + return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Expanded( - child: Column(children: [ - _buildMapCard(), - const SizedBox(height: 14), - _buildActivityCard(), - ]), + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text('Aksi Cepat', + style: GoogleFonts.outfit( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A))), ), - const SizedBox(width: 14), - SizedBox( - width: 240, - child: Column(children: [ - _buildUserCard(), - const SizedBox(height: 12), - _buildActionsCard(), - ]), + GridView.count( + crossAxisCount: 3, + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisSpacing: 10, + mainAxisSpacing: 10, + childAspectRatio: 1.4, + children: actions + .map((a) => _QuickActionCard(item: a)) + .toList(), ), ], ); } - Widget _buildMapCard() { - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), - child: Row(children: [ - Text('Live location', style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500)), - const Spacer(), - Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration( - color: const Color(0x1A16A34A), - borderRadius: BorderRadius.circular(20), - ), - child: Row(children: [ - Container( - width: 5, - height: 5, - decoration: const BoxDecoration(color: Color(0xFF16A34A), shape: BoxShape.circle), - ), - const SizedBox(width: 4), - Text('Live', - style: GoogleFonts.inter(fontSize: 10, color: const Color(0xFF16A34A), fontWeight: FontWeight.w600)), - ]), - ), - ]), - ), - ClipRRect( - borderRadius: const BorderRadius.only( - bottomLeft: Radius.circular(12), - bottomRight: Radius.circular(12), - ), - child: Container( - height: 180, - decoration: const BoxDecoration(color: Color(0xFFF1F5F9)), - child: Stack(children: [ - Center( - child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ - Container( - width: 36, - height: 36, - decoration: const BoxDecoration(color: Color(0xFF1A56DB), shape: BoxShape.circle), - child: const Icon(Icons.location_on, color: Colors.white, size: 20), - ), - const SizedBox(height: 8), - Text('Map placeholder', - style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF94A3B8))), - ]), - ), - Positioned( - bottom: 10, - left: 10, - child: Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 7), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(8), - border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('Jl. Kenangan No. 14', - style: GoogleFonts.inter(fontSize: 12, fontWeight: FontWeight.w600)), - Text('Surabaya, East Java', - style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF64748B))), - ]), - ), - ), - ]), - ), - ), - ]), - ); - } - - Widget _buildActivityCard() { - final items = [ - {'icon': Icons.play_circle_outline, 'title': 'Navigation started', 'time': '08:32 — User began walking route', 'color': const Color(0xFF16A34A)}, - {'icon': Icons.warning_amber_outlined, 'title': 'Obstacle detected', 'time': '08:41 — AI alert at 1.8m distance', 'color': const Color(0xFFD97706)}, - {'icon': Icons.location_on_outlined, 'title': 'Location checkpoint', 'time': '09:05 — Arrived at Jl. Kenangan', 'color': const Color(0xFF1A56DB)}, - ]; - return Container( - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), - ), - child: Column(children: [ - Padding( - padding: const EdgeInsets.fromLTRB(16, 14, 16, 12), - child: Row(children: [ - Text('Recent activity', style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500)), - const Spacer(), - Text('Today', style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF94A3B8))), - ]), - ), - ...items.map((item) => Column(children: [ - const Divider(height: 0.5, thickness: 0.5, color: Color(0xFFE2E8F0)), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), - child: Row(children: [ - Container( - width: 30, - height: 30, - decoration: BoxDecoration( - color: (item['color'] as Color).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(8), - ), - child: Icon(item['icon'] as IconData, size: 16, color: item['color'] as Color), - ), - const SizedBox(width: 12), - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(item['title'] as String, - style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500)), - Text(item['time'] as String, - style: GoogleFonts.inter(fontSize: 12, color: const Color(0xFF64748B))), - ]), - ]), - ), - ])), - ]), - ); - } - - Widget _buildUserCard() { - return Container( + // ── Loading skeleton ────────────────────────────────────────────────────────── + Widget _buildSkeleton() { + return SingleChildScrollView( padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), - ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row(children: [ - Container( - width: 36, height: 36, - decoration: BoxDecoration( - color: const Color(0xFF1A56DB).withValues(alpha: 0.1), - borderRadius: BorderRadius.circular(18), - ), - child: const Icon(Icons.person, color: Color(0xFF1A56DB), size: 20), - ), - const SizedBox(width: 10), - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('User (Tunanetra)', style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w600)), - Text('● Online now', style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF16A34A))), - ]), - ]), + child: Column(children: [ + _SkeletonBox(height: 32, width: double.infinity, + borderRadius: 8), const SizedBox(height: 14), - // Ganti GridView → Row+Column biasa, lebih predictable - Row(children: [ - Expanded(child: _statCell('Battery', '85%')), - const SizedBox(width: 8), - Expanded(child: _statCell('Speed', '3.2 km/h')), - ]), - const SizedBox(height: 8), - Row(children: [ - Expanded(child: _statCell('Distance', '1.4 km')), - const SizedBox(width: 8), - Expanded(child: _statCell('Alerts', '12')), - ]), + Row(children: List.generate( + 4, + (i) => Expanded( + child: Padding( + padding: EdgeInsets.only( + right: i < 3 ? 10 : 0), + child: _SkeletonBox( + height: 80, + width: double.infinity, + borderRadius: 12), + ), + ))), + const SizedBox(height: 14), + _SkeletonBox( + height: 220, width: double.infinity, borderRadius: 16), + const SizedBox(height: 14), + _SkeletonBox( + height: 160, width: double.infinity, borderRadius: 16), ]), ); } - Widget _statCell(String label, String val) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), - decoration: BoxDecoration( - color: const Color(0xFFF8FAFC), - borderRadius: BorderRadius.circular(8), + // ── Error state ─────────────────────────────────────────────────────────────── + Widget _buildError() { + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.cloud_off_outlined, + size: 64, color: Color(0xFF94A3B8)), + const SizedBox(height: 16), + Text(_error ?? 'Gagal memuat', + textAlign: TextAlign.center, + style: GoogleFonts.inter( + color: const Color(0xFF64748B), fontSize: 14)), + const SizedBox(height: 20), + FilledButton.icon( + onPressed: _loadAll, + icon: const Icon(Icons.refresh), + label: const Text('Coba Lagi'), + ), + ]), ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(label, style: GoogleFonts.inter(fontSize: 10, color: const Color(0xFF94A3B8))), - const SizedBox(height: 2), - Text(val, style: GoogleFonts.outfit(fontSize: 14, fontWeight: FontWeight.w600)), - ]), ); } - Widget _buildActionsCard() { - final actions = [ - {'icon': Icons.videocam_outlined, 'label': 'Live view', 'sub': 'Camera feed', 'color': const Color(0xFF1A56DB)}, - {'icon': Icons.phone_outlined, 'label': 'Call user', 'sub': 'Voice call', 'color': const Color(0xFF16A34A)}, - {'icon': Icons.tune, 'label': 'AI sensitivity', 'sub': 'Obstacle distance', 'color': const Color(0xFF7C3AED)}, - {'icon': Icons.warning_rounded, 'label': 'Emergency ping', 'sub': 'Alert user now', 'color': const Color(0xFFDC2626)}, - ]; + String _formatLastSeen(String? iso) { + if (iso == null) return 'Belum pernah aktif'; + final dt = DateTime.tryParse(iso)?.toLocal(); + if (dt == null) return 'Waktu tidak diketahui'; + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 1) return 'Baru saja'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m lalu'; + if (diff.inHours < 24) return '${diff.inHours}j lalu'; + return '${diff.inDays}h lalu'; + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// DATA MODELS +// ───────────────────────────────────────────────────────────────────────────── + +class _DashboardData { + final String userName; + final bool userOnline; + final String? userLastSeen; + final int? battery; + final double? speed; + final int obstaclesTotal; + final int unreadNotif; + final int unreadSos; + final double? lastLat; + final double? lastLng; + final String? lastLocationTime; + final List> recentActivity; + final bool isPaired; + + const _DashboardData({ + required this.userName, + required this.userOnline, + this.userLastSeen, + this.battery, + this.speed, + required this.obstaclesTotal, + required this.unreadNotif, + required this.unreadSos, + this.lastLat, + this.lastLng, + this.lastLocationTime, + required this.recentActivity, + required this.isPaired, + }); + + _DashboardData copyWith({int? unreadSos}) { + return _DashboardData( + userName: userName, + userOnline: userOnline, + userLastSeen: userLastSeen, + battery: battery, + speed: speed, + obstaclesTotal: obstaclesTotal, + unreadNotif: unreadNotif, + unreadSos: unreadSos ?? this.unreadSos, + lastLat: lastLat, + lastLng: lastLng, + lastLocationTime: lastLocationTime, + recentActivity: recentActivity, + isPaired: isPaired, + ); + } +} + +class _ActionItem { + final IconData icon; + final String label; + final String sub; + final Color color; + final VoidCallback onTap; + const _ActionItem({ + required this.icon, + required this.label, + required this.sub, + required this.color, + required this.onTap, + }); +} + +// ───────────────────────────────────────────────────────────────────────────── +// SUB-WIDGETS +// ───────────────────────────────────────────────────────────────────────────── + +class _KpiCard extends StatelessWidget { + final String label; + final String value; + final Color valueColor; + final IconData icon; + final String sub; + final bool highlight; + + const _KpiCard({ + required this.label, + required this.value, + required this.valueColor, + required this.icon, + required this.sub, + this.highlight = false, + }); + + @override + Widget build(BuildContext context) { return Container( - padding: const EdgeInsets.all(14), + padding: const EdgeInsets.all(12), decoration: BoxDecoration( - color: Colors.white, + color: + highlight ? const Color(0xFFFFF1F2) : Colors.white, borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0), width: 0.5), + border: Border.all( + color: highlight + ? const Color(0xFFFECACA) + : const Color(0xFFE2E8F0), + width: 1), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.03), + blurRadius: 8), + ], ), - child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('QUICK ACTIONS', - style: GoogleFonts.inter( - fontSize: 11, fontWeight: FontWeight.w600, color: const Color(0xFF94A3B8), letterSpacing: 0.06)), - const SizedBox(height: 8), - ...actions.map((a) => InkWell( - borderRadius: BorderRadius.circular(8), - onTap: () {}, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 6), - child: Row(children: [ + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Icon(icon, size: 13, color: valueColor), + const SizedBox(width: 4), + Expanded( + child: Text(label, + style: GoogleFonts.inter( + fontSize: 10, + color: const Color(0xFF94A3B8), + letterSpacing: 0.02), + overflow: TextOverflow.ellipsis), + ), + ]), + const SizedBox(height: 6), + Text(value, + style: GoogleFonts.outfit( + fontSize: 20, + fontWeight: FontWeight.w700, + color: valueColor)), + const SizedBox(height: 2), + Text(sub, + style: GoogleFonts.inter( + fontSize: 10, + color: highlight + ? const Color(0xFFDC2626) + : const Color(0xFF64748B)), + overflow: TextOverflow.ellipsis), + ], + ), + ); + } +} + +class _ActivityTile extends StatelessWidget { + final Map data; + const _ActivityTile({required this.data}); + + @override + Widget build(BuildContext context) { + final logType = data['logType']?.toString() ?? ''; + final cfg = _activityConfig(logType); + final created = + DateTime.tryParse(data['createdAt']?.toString() ?? '') + ?.toLocal(); + final timeStr = created == null + ? '' + : '${_two(created.hour)}:${_two(created.minute)}'; + final desc = + data['description']?.toString() ?? logType; + + return Padding( + padding: + const EdgeInsets.symmetric(horizontal: 14, vertical: 10), + child: Row(children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: cfg.color.withOpacity(0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(cfg.icon, size: 16, color: cfg.color), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + cfg.label, + style: GoogleFonts.inter( + fontSize: 12, + fontWeight: FontWeight.w600, + color: const Color(0xFF0F172A)), + ), + Text( + desc, + style: GoogleFonts.inter( + fontSize: 11, + color: const Color(0xFF64748B)), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + if (timeStr.isNotEmpty) + Text(timeStr, + style: GoogleFonts.jetBrainsMono( + fontSize: 10, color: const Color(0xFF94A3B8))), + ]), + ); + } +} + +class _QuickActionCard extends StatelessWidget { + final _ActionItem item; + const _QuickActionCard({required this.item}); + + @override + Widget build(BuildContext context) { + return Material( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + child: InkWell( + onTap: item.onTap, + borderRadius: BorderRadius.circular(12), + child: Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + border: + Border.all(color: const Color(0xFFE2E8F0)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.center, + children: [ Container( width: 28, height: 28, decoration: BoxDecoration( - color: (a['color'] as Color).withValues(alpha: 0.1), + color: item.color.withOpacity(0.1), borderRadius: BorderRadius.circular(7), ), - child: Icon(a['icon'] as IconData, size: 15, color: a['color'] as Color), + child: Icon(item.icon, + size: 15, color: item.color), ), - const SizedBox(width: 10), - Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text(a['label'] as String, - style: GoogleFonts.inter(fontSize: 13, fontWeight: FontWeight.w500)), - Text(a['sub'] as String, - style: GoogleFonts.inter(fontSize: 11, color: const Color(0xFF94A3B8))), - ]), - ]), + const SizedBox(height: 6), + Text(item.label, + style: GoogleFonts.inter( + fontSize: 11, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + Text(item.sub, + style: GoogleFonts.inter( + fontSize: 10, + color: const Color(0xFF94A3B8)), + maxLines: 1, + overflow: TextOverflow.ellipsis), + ], ), - )), + ), + ), + ); + } +} + +class _SosBadge extends StatelessWidget { + final int count; + const _SosBadge({required this.count}); + + @override + Widget build(BuildContext context) { + return Container( + padding: + const EdgeInsets.symmetric(horizontal: 10, vertical: 5), + decoration: BoxDecoration( + color: const Color(0xFFDC2626), + borderRadius: BorderRadius.circular(20), + boxShadow: [ + BoxShadow( + color: const Color(0xFFDC2626).withOpacity(0.4), + blurRadius: 8, + spreadRadius: 1), + ], + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + const Icon(Icons.warning_rounded, + color: Colors.white, size: 13), + const SizedBox(width: 4), + Text('$count SOS', + style: GoogleFonts.inter( + color: Colors.white, + fontSize: 11, + fontWeight: FontWeight.w700)), ]), ); } -} \ No newline at end of file +} + +class _SkeletonBox extends StatefulWidget { + final double height; + final double width; + final double borderRadius; + const _SkeletonBox( + {required this.height, + required this.width, + required this.borderRadius}); + + @override + State<_SkeletonBox> createState() => _SkeletonBoxState(); +} + +class _SkeletonBoxState extends State<_SkeletonBox> + with SingleTickerProviderStateMixin { + late final AnimationController _ctrl = AnimationController( + vsync: this, + duration: const Duration(milliseconds: 1200), + )..repeat(reverse: true); + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AnimatedBuilder( + animation: _ctrl, + builder: (_, __) => Container( + height: widget.height, + width: widget.width, + decoration: BoxDecoration( + borderRadius: + BorderRadius.circular(widget.borderRadius), + color: Color.lerp(const Color(0xFFE2E8F0), + const Color(0xFFF8FAFC), _ctrl.value), + ), + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// HELPERS +// ───────────────────────────────────────────────────────────────────────────── + +class _ActivityCfg { + final IconData icon; + final Color color; + final String label; + const _ActivityCfg(this.icon, this.color, this.label); +} + +_ActivityCfg _activityConfig(String logType) { + switch (logType.toUpperCase()) { + case 'WALKGUIDE_START': + return const _ActivityCfg( + Icons.play_circle_outline, Color(0xFF16A34A), 'WalkGuide Dimulai'); + case 'WALKGUIDE_STOP': + return const _ActivityCfg( + Icons.stop_circle_outlined, Color(0xFF64748B), 'WalkGuide Berhenti'); + case 'OBSTACLE_DETECTED': + return const _ActivityCfg( + Icons.warning_amber_outlined, Color(0xFFD97706), 'Obstacle Terdeteksi'); + case 'SOS_TRIGGERED': + return const _ActivityCfg( + Icons.sos_outlined, Color(0xFFDC2626), 'SOS Dikirim'); + case 'SOS_ACKNOWLEDGED': + return const _ActivityCfg( + Icons.check_circle_outline, Color(0xFF16A34A), 'SOS Ditanggapi'); + case 'LOCATION_UPDATE': + return const _ActivityCfg( + Icons.location_on_outlined, Color(0xFF1A56DB), 'Update Lokasi'); + case 'GEOFENCE_EXIT': + return const _ActivityCfg( + Icons.fence_outlined, Color(0xFFDC2626), 'Keluar Geofence'); + case 'GEOFENCE_ENTER': + return const _ActivityCfg( + Icons.home_outlined, Color(0xFF16A34A), 'Masuk Geofence'); + case 'CALL_INITIATED': + return const _ActivityCfg( + Icons.call_outlined, Color(0xFF7C3AED), 'Panggilan Dimulai'); + case 'CALL_ENDED': + return const _ActivityCfg( + Icons.call_end_outlined, Color(0xFF94A3B8), 'Panggilan Selesai'); + case 'LOGIN': + return const _ActivityCfg( + Icons.login_outlined, Color(0xFF1A56DB), 'Login'); + case 'LOGOUT': + return const _ActivityCfg( + Icons.logout_outlined, Color(0xFF64748B), 'Logout'); + default: + return const _ActivityCfg( + Icons.circle_outlined, Color(0xFF94A3B8), 'Aktivitas'); + } +} + +String _two(int v) => v.toString().padLeft(2, '0'); diff --git a/walkguide-mobile/walkguide_app/pubspec.lock b/walkguide-mobile/walkguide_app/pubspec.lock index 6ba84ca..6a507b7 100644 --- a/walkguide-mobile/walkguide_app/pubspec.lock +++ b/walkguide-mobile/walkguide_app/pubspec.lock @@ -947,10 +947,10 @@ packages: dependency: transitive description: name: meta - sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c + sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" url: "https://pub.dev" source: hosted - version: "1.16.0" + version: "1.17.0" mgrs_dart: dependency: transitive description: @@ -1532,6 +1532,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.12.1" + stomp_dart_client: + dependency: "direct main" + description: + name: stomp_dart_client + sha256: "9ca00600a212f1e08fda614cf6815437829b1d08d8911ff5c798f130a2fa2d59" + url: "https://pub.dev" + source: hosted + version: "2.1.3" stream_channel: dependency: transitive description: @@ -1584,26 +1592,26 @@ packages: dependency: transitive description: name: test - sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.26.2" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.6" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.11" + version: "0.6.12" tflite_flutter: dependency: "direct main" description: diff --git a/walkguide-mobile/walkguide_app/pubspec.yaml b/walkguide-mobile/walkguide_app/pubspec.yaml index e5ac976..9ea287d 100644 --- a/walkguide-mobile/walkguide_app/pubspec.yaml +++ b/walkguide-mobile/walkguide_app/pubspec.yaml @@ -76,6 +76,9 @@ dependencies: cached_network_image: ^3.3.1 shimmer: ^3.0.0 + # STOMP client untuk WebSocket + stomp_dart_client: ^2.1.0 + dev_dependencies: flutter_test: sdk: flutter