diff --git a/walkguide-backend/demo/src/main/resources/application.properties b/walkguide-backend/demo/src/main/resources/application.properties index 99ee4f7..a7b2647 100644 --- a/walkguide-backend/demo/src/main/resources/application.properties +++ b/walkguide-backend/demo/src/main/resources/application.properties @@ -36,4 +36,6 @@ agora.app-certificate=${AGORA_APP_CERTIFICATE:} # ===== LOGGING ===== logging.level.com.walkguide=DEBUG logging.level.org.springframework.messaging=INFO -logging.level.org.springframework.web.socket=INFO \ No newline at end of file +logging.level.org.springframework.web.socket=INFO + +spring.profiles.active=dev \ No newline at end of file diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart new file mode 100644 index 0000000..66a9363 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_activity_log_screen.dart @@ -0,0 +1,540 @@ +// lib/features/guardian_dashboard/guardian_activity_log_screen.dart +// ignore_for_file: use_build_context_synchronously + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:google_fonts/google_fonts.dart'; +import 'package:intl/intl.dart'; + +import '../../../app/injection_container.dart'; +import '../../../core/network/api_client.dart'; + +Dio get _api => sl().dio; + +class GuardianActivityLogScreen extends StatefulWidget { + const GuardianActivityLogScreen({super.key}); + + @override + State createState() => + _GuardianActivityLogScreenState(); +} + +class _GuardianActivityLogScreenState extends State { + List<_LogItem> _items = []; + List<_LogItem> _filtered = []; + bool _loading = true; + String? _error; + String _selectedFilter = 'ALL'; + bool _needsPairing = false; + + static const _filters = [ + 'ALL', + 'WALKGUIDE', + 'SOS', + 'AUTH', + 'OBSTACLE', + 'LOCATION', + ]; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + _needsPairing = false; + }); + try { + // Cek pairing dulu + final paired = await _hasActivePairing(); + if (!paired) { + setState(() { + _needsPairing = true; + _loading = false; + }); + return; + } + + final res = await _api.get('/guardian/activity-logs', queryParameters: { + 'size': 50, + 'page': 0 + }).timeout(const Duration(seconds: 10)); + + // Response bisa berupa list langsung atau paged {content: [...]} + final data = res.data['data']; + List list; + if (data is List) { + list = data; + } else if (data is Map && data['content'] is List) { + list = data['content'] as List; + } else { + list = []; + } + + final items = list + .whereType() + .map((e) => _LogItem.fromJson(Map.from(e))) + .toList(); + + setState(() { + _items = items; + _applyFilter(_selectedFilter); + _loading = false; + }); + } on DioException catch (e) { + setState(() { + _error = e.response?.data?['message']?.toString() ?? + 'Gagal memuat activity log.'; + _loading = false; + }); + } catch (e) { + setState(() { + _error = 'Timeout / error: $e'; + _loading = false; + }); + } + } + + Future _hasActivePairing() async { + try { + final res = await _api + .get('/shared/pairing/status') + .timeout(const Duration(seconds: 5)); + final data = res.data['data']; + if (data is Map) return data['status'] == 'ACTIVE'; + } catch (_) {} + return false; + } + + void _applyFilter(String filter) { + _selectedFilter = filter; + if (filter == 'ALL') { + _filtered = List.from(_items); + } else { + _filtered = _items.where((item) { + switch (filter) { + case 'WALKGUIDE': + return item.logType.contains('WALKGUIDE'); + case 'SOS': + return item.logType.contains('SOS'); + case 'AUTH': + return item.logType == 'LOGIN' || + item.logType == 'LOGOUT' || + item.logType == 'APP_OPEN' || + item.logType == 'APP_CLOSE'; + case 'OBSTACLE': + return item.logType.contains('OBSTACLE'); + case 'LOCATION': + return item.logType.contains('LOCATION') || + item.logType.contains('GEOFENCE'); + default: + return true; + } + }).toList(); + } + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────────────── + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'User Logs', + style: GoogleFonts.outfit( + fontSize: 22, + fontWeight: FontWeight.w800, + color: const Color(0xFF0F172A), + ), + ), + Text( + _needsPairing + ? 'Pairing dulu untuk melihat log' + : '${_items.length} aktivitas tercatat', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF64748B), + ), + ), + ], + ), + ), + IconButton( + onPressed: _load, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh', + color: const Color(0xFF64748B), + ), + ], + ), + const SizedBox(height: 12), + + // ── Filter chips ───────────────────────────────────────────────── + if (!_needsPairing && !_loading && _error == null) + SizedBox( + height: 36, + child: ListView.separated( + scrollDirection: Axis.horizontal, + itemCount: _filters.length, + separatorBuilder: (_, __) => const SizedBox(width: 8), + itemBuilder: (_, i) { + final f = _filters[i]; + final selected = _selectedFilter == f; + return FilterChip( + label: Text(f), + selected: selected, + onSelected: (_) => setState(() => _applyFilter(f)), + selectedColor: + const Color(0xFF1A56DB).withValues(alpha: 0.12), + checkmarkColor: const Color(0xFF1A56DB), + labelStyle: GoogleFonts.inter( + color: selected + ? const Color(0xFF1A56DB) + : const Color(0xFF64748B), + fontWeight: + selected ? FontWeight.w700 : FontWeight.normal, + fontSize: 12, + ), + padding: const EdgeInsets.symmetric(horizontal: 4), + side: BorderSide( + color: selected + ? const Color(0xFF1A56DB) + : const Color(0xFFE2E8F0), + ), + ); + }, + ), + ), + + if (!_needsPairing && !_loading && _error == null) + const SizedBox(height: 16), + + // ── Body ───────────────────────────────────────────────────────── + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _needsPairing + ? _buildNoPairingPanel() + : _error != null + ? _buildErrorPanel() + : _filtered.isEmpty + ? _buildEmptyPanel() + : RefreshIndicator( + onRefresh: _load, + color: const Color(0xFF1A56DB), + child: ListView.builder( + itemCount: _filtered.length, + itemBuilder: (ctx, i) => + _LogCard(item: _filtered[i]), + ), + ), + ), + ], + ), + ), + ); + } + + Widget _buildNoPairingPanel() { + return Center( + child: Container( + padding: const EdgeInsets.all(24), + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFDE68A)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.link_off, color: Color(0xFFD97706), size: 52), + const SizedBox(height: 14), + Text( + 'Belum Pairing', + style: GoogleFonts.outfit( + fontSize: 18, + fontWeight: FontWeight.w700, + color: const Color(0xFF92400E), + ), + ), + const SizedBox(height: 8), + Text( + 'Hubungkan akun Guardian dengan User terlebih dahulu untuk melihat log aktivitas.', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF92400E), + ), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildErrorPanel() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off, size: 52, color: Color(0xFF94A3B8)), + const SizedBox(height: 14), + Text( + _error!, + textAlign: TextAlign.center, + style: + GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B)), + ), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Coba lagi'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF1A56DB)), + ), + ], + ), + ); + } + + Widget _buildEmptyPanel() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.history, size: 64, color: Color(0xFF94A3B8)), + const SizedBox(height: 14), + Text( + _selectedFilter == 'ALL' + ? 'Belum ada aktivitas' + : 'Tidak ada aktivitas "$_selectedFilter"', + style: GoogleFonts.inter( + fontSize: 15, + fontWeight: FontWeight.w600, + color: const Color(0xFF94A3B8), + ), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// DATA MODEL +// ───────────────────────────────────────────────────────────────────────────── + +class _LogItem { + final int id; + final String logType; + final String? description; + final DateTime createdAt; + + const _LogItem({ + required this.id, + required this.logType, + this.description, + required this.createdAt, + }); + + factory _LogItem.fromJson(Map j) => _LogItem( + id: (j['id'] as num?)?.toInt() ?? 0, + logType: j['logType']?.toString() ?? 'UNKNOWN', + description: j['description']?.toString(), + createdAt: + DateTime.tryParse(j['createdAt']?.toString() ?? '')?.toLocal() ?? + DateTime.now(), + ); +} + +// ───────────────────────────────────────────────────────────────────────────── +// LOG CARD +// ───────────────────────────────────────────────────────────────────────────── + +class _LogCard extends StatelessWidget { + final _LogItem item; + const _LogCard({required this.item}); + + @override + Widget build(BuildContext context) { + final meta = _logMeta(item.logType); + return Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Timeline dot + connector line + Column( + children: [ + Container( + width: 38, + height: 38, + decoration: BoxDecoration( + color: meta.color.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: Icon(meta.icon, color: meta.color, size: 18), + ), + Container( + width: 1.5, + height: 22, + color: const Color(0xFFE2E8F0), + ), + ], + ), + const SizedBox(width: 12), + // Content + Expanded( + child: Padding( + padding: const EdgeInsets.only(top: 6), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text( + meta.label, + style: GoogleFonts.inter( + fontWeight: FontWeight.w700, + color: meta.color, + fontSize: 13, + ), + ), + ), + Text( + _formatTime(item.createdAt), + style: GoogleFonts.jetBrainsMono( + color: const Color(0xFF94A3B8), + fontSize: 11, + ), + ), + ], + ), + if (item.description != null && item.description!.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: 3), + child: Text( + item.description!, + style: GoogleFonts.inter( + fontSize: 12, + color: const Color(0xFF64748B), + ), + ), + ), + const SizedBox(height: 14), + ], + ), + ), + ), + ], + ), + ); + } + + String _formatTime(DateTime dt) { + final now = DateTime.now(); + if (dt.day == now.day && dt.month == now.month && dt.year == now.year) { + return DateFormat('HH:mm').format(dt); + } + return DateFormat('dd MMM HH:mm').format(dt); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// LOG METADATA +// ───────────────────────────────────────────────────────────────────────────── + +class _LogMeta { + final IconData icon; + final Color color; + final String label; + const _LogMeta( + {required this.icon, required this.color, required this.label}); +} + +_LogMeta _logMeta(String logType) { + switch (logType.toUpperCase()) { + case 'LOGIN': + return const _LogMeta( + icon: Icons.login, color: Color(0xFF16A34A), label: 'Login'); + case 'LOGOUT': + return const _LogMeta( + icon: Icons.logout, color: Color(0xFF94A3B8), label: 'Logout'); + case 'APP_OPEN': + return const _LogMeta( + icon: Icons.open_in_new, + color: Color(0xFF1A56DB), + label: 'App Dibuka'); + case 'APP_CLOSE': + return const _LogMeta( + icon: Icons.close, color: Color(0xFF94A3B8), label: 'App Ditutup'); + case 'WALKGUIDE_START': + return const _LogMeta( + icon: Icons.directions_walk, + color: Color(0xFF1A56DB), + label: 'WalkGuide Mulai'); + case 'WALKGUIDE_STOP': + return const _LogMeta( + icon: Icons.stop_circle, + color: Color(0xFF94A3B8), + label: 'WalkGuide Berhenti'); + case 'OBSTACLE_DETECTED': + return const _LogMeta( + icon: Icons.warning_amber, + color: Color(0xFFD97706), + label: 'Obstacle Terdeteksi'); + case 'SOS_TRIGGERED': + return const _LogMeta( + icon: Icons.sos, color: Color(0xFFDC2626), label: 'SOS Terkirim'); + case 'SOS_ACKNOWLEDGED': + return const _LogMeta( + icon: Icons.check_circle, + color: Color(0xFF16A34A), + label: 'SOS Diakui Guardian'); + case 'CALL_INITIATED': + return const _LogMeta( + icon: Icons.call, + color: Color(0xFF16A34A), + label: 'Panggilan Dimulai'); + case 'CALL_ENDED': + return const _LogMeta( + icon: Icons.call_end, + color: Color(0xFF94A3B8), + label: 'Panggilan Selesai'); + case 'LOCATION_UPDATE': + return const _LogMeta( + icon: Icons.location_on, + color: Color(0xFF1A56DB), + label: 'Lokasi Diperbarui'); + case 'GEOFENCE_EXIT': + return const _LogMeta( + icon: Icons.fence, + color: Color(0xFFDC2626), + label: 'Keluar Area Aman'); + case 'GEOFENCE_ENTER': + return const _LogMeta( + icon: Icons.home, color: Color(0xFF16A34A), label: 'Masuk Area Aman'); + default: + return _LogMeta( + icon: Icons.circle_outlined, + color: const Color(0xFF94A3B8), + label: logType); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart new file mode 100644 index 0000000..a99eb24 --- /dev/null +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_ai_config_screen.dart @@ -0,0 +1,637 @@ +// lib/features/guardian_dashboard/guardian_ai_config_screen.dart +// ignore_for_file: use_build_context_synchronously + +import 'package:dio/dio.dart'; +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:google_fonts/google_fonts.dart'; + +import '../../../app/injection_container.dart'; +import '../../../core/network/api_client.dart'; + +Dio get _api => sl().dio; + +class GuardianAiConfigScreen extends StatefulWidget { + const GuardianAiConfigScreen({super.key}); + + @override + State createState() => _GuardianAiConfigScreenState(); +} + +class _GuardianAiConfigScreenState extends State { + bool _loading = true; + bool _saving = false; + String? _error; + bool _needsPairing = false; + + // Config values + double _confidenceThreshold = 0.5; + double _alertDistanceClose = 1.5; + double _alertDistanceMedium = 3.0; + int _maxInferenceFps = 5; + String _enabledLabels = 'ALL'; + + static const _labelOptions = ['ALL', 'PERSON', 'VEHICLE', 'OBSTACLE']; + + @override + void initState() { + super.initState(); + _load(); + } + + Future _load() async { + setState(() { + _loading = true; + _error = null; + _needsPairing = false; + }); + try { + final paired = await _hasActivePairing(); + if (!paired) { + setState(() { + _needsPairing = true; + _loading = false; + }); + return; + } + + final res = await _api + .get('/guardian/ai-config') + .timeout(const Duration(seconds: 8)); + final data = res.data['data']; + if (data is Map) { + setState(() { + _confidenceThreshold = + (data['confidenceThreshold'] as num?)?.toDouble() ?? 0.5; + _alertDistanceClose = + (data['alertDistanceClose'] as num?)?.toDouble() ?? 1.5; + _alertDistanceMedium = + (data['alertDistanceMedium'] as num?)?.toDouble() ?? 3.0; + _maxInferenceFps = (data['maxInferenceFps'] as num?)?.toInt() ?? 5; + _enabledLabels = data['enabledLabels']?.toString() ?? 'ALL'; + }); + } + } on DioException catch (e) { + setState(() { + _error = e.response?.data?['message']?.toString() ?? + 'Gagal memuat konfigurasi AI.'; + }); + } catch (e) { + setState(() => _error = 'Timeout / error: $e'); + } finally { + if (mounted) setState(() => _loading = false); + } + } + + Future _save() async { + setState(() => _saving = true); + try { + await _api.put('/guardian/ai-config', data: { + 'confidenceThreshold': _confidenceThreshold, + 'alertDistanceClose': _alertDistanceClose, + 'alertDistanceMedium': _alertDistanceMedium, + 'maxInferenceFps': _maxInferenceFps, + 'enabledLabels': _enabledLabels, + }).timeout(const Duration(seconds: 8)); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Konfigurasi AI berhasil disimpan'), + backgroundColor: Color(0xFF16A34A), + ), + ); + } + } on DioException catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text(e.response?.data?['message']?.toString() ?? + 'Gagal menyimpan konfigurasi.'), + backgroundColor: const Color(0xFFDC2626), + ), + ); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Error: $e'), + backgroundColor: const Color(0xFFDC2626), + ), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + Future _hasActivePairing() async { + try { + final res = await _api + .get('/shared/pairing/status') + .timeout(const Duration(seconds: 5)); + final data = res.data['data']; + if (data is Map) return data['status'] == 'ACTIVE'; + } catch (_) {} + return false; + } + + @override + Widget build(BuildContext context) { + return SafeArea( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Header ────────────────────────────────────────────────────── + Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'AI Config', + style: GoogleFonts.outfit( + fontSize: 22, + fontWeight: FontWeight.w800, + color: const Color(0xFF0F172A), + ), + ), + Text( + 'Konfigurasi deteksi YOLO untuk User', + style: GoogleFonts.inter( + fontSize: 13, + color: const Color(0xFF64748B), + ), + ), + ], + ), + ), + IconButton( + onPressed: () => context.go('/guardian/benchmark'), + icon: const Icon(Icons.speed_outlined), + tooltip: 'Benchmark', + color: const Color(0xFF64748B), + ), + IconButton( + onPressed: _loading ? null : _load, + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Refresh', + color: const Color(0xFF64748B), + ), + ], + ), + const SizedBox(height: 16), + + // ── Body ───────────────────────────────────────────────────────── + Expanded( + child: _loading + ? const Center(child: CircularProgressIndicator()) + : _needsPairing + ? _buildNoPairingPanel() + : _error != null + ? _buildErrorPanel() + : _buildConfigForm(), + ), + ], + ), + ), + ); + } + + Widget _buildConfigForm() { + return SingleChildScrollView( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Confidence Threshold ────────────────────────────────────────── + _SectionCard( + title: 'Confidence Threshold', + subtitle: + 'Minimal keyakinan AI untuk menganggap objek sebagai obstacle', + icon: Icons.tune_outlined, + iconColor: const Color(0xFF1A56DB), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('Nilai saat ini:', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF64748B))), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF1A56DB).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + _confidenceThreshold.toStringAsFixed(2), + style: GoogleFonts.jetBrainsMono( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF1A56DB), + ), + ), + ), + ], + ), + Slider( + value: _confidenceThreshold, + min: 0.1, + max: 0.9, + divisions: 8, + activeColor: const Color(0xFF1A56DB), + onChanged: (v) => setState(() => _confidenceThreshold = v), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('0.1 (sensitif)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + Text('0.9 (ketat)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + + // ── Alert Distances ─────────────────────────────────────────────── + _SectionCard( + title: 'Jarak Peringatan', + subtitle: 'Batas jarak (meter) untuk level peringatan', + icon: Icons.radar_outlined, + iconColor: const Color(0xFFD97706), + child: Column( + children: [ + // Close + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFDC2626), + ), + ), + const SizedBox(width: 6), + Text('Jarak Dekat', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF0F172A))), + ]), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFDC2626).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_alertDistanceClose.toStringAsFixed(1)} m', + style: GoogleFonts.jetBrainsMono( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFFDC2626), + ), + ), + ), + ], + ), + Slider( + value: _alertDistanceClose, + min: 0.5, + max: 3.0, + divisions: 5, + activeColor: const Color(0xFFDC2626), + onChanged: (v) => setState(() => _alertDistanceClose = v), + ), + const SizedBox(height: 8), + // Medium + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Row(children: [ + Container( + width: 8, + height: 8, + decoration: const BoxDecoration( + shape: BoxShape.circle, + color: Color(0xFFD97706), + ), + ), + const SizedBox(width: 6), + Text('Jarak Sedang', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF0F172A))), + ]), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFFD97706).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '${_alertDistanceMedium.toStringAsFixed(1)} m', + style: GoogleFonts.jetBrainsMono( + fontSize: 13, + fontWeight: FontWeight.w700, + color: const Color(0xFFD97706), + ), + ), + ), + ], + ), + Slider( + value: _alertDistanceMedium, + min: 1.0, + max: 8.0, + divisions: 7, + activeColor: const Color(0xFFD97706), + onChanged: (v) => setState(() => _alertDistanceMedium = v), + ), + ], + ), + ), + const SizedBox(height: 12), + + // ── Max Inference FPS ───────────────────────────────────────────── + _SectionCard( + title: 'Max Inference FPS', + subtitle: + 'Maksimal frame per detik untuk inferensi AI (lebih tinggi = lebih boros baterai)', + icon: Icons.speed_outlined, + iconColor: const Color(0xFF059669), + child: Column( + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('FPS saat ini:', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF64748B))), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 12, vertical: 4), + decoration: BoxDecoration( + color: const Color(0xFF059669).withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(20), + ), + child: Text( + '$_maxInferenceFps fps', + style: GoogleFonts.jetBrainsMono( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF059669), + ), + ), + ), + ], + ), + Slider( + value: _maxInferenceFps.toDouble(), + min: 1, + max: 30, + divisions: 29, + activeColor: const Color(0xFF059669), + onChanged: (v) => + setState(() => _maxInferenceFps = v.toInt()), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text('1 fps (hemat baterai)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + Text('30 fps (real-time)', + style: GoogleFonts.inter( + fontSize: 11, color: const Color(0xFF94A3B8))), + ], + ), + ], + ), + ), + const SizedBox(height: 12), + + // ── Enabled Labels ──────────────────────────────────────────────── + _SectionCard( + title: 'Label yang Diaktifkan', + subtitle: 'Jenis objek yang akan dideteksi AI', + icon: Icons.label_outline, + iconColor: const Color(0xFF7C3AED), + child: Wrap( + spacing: 8, + runSpacing: 8, + children: _labelOptions.map((label) { + final selected = _enabledLabels == label; + return GestureDetector( + onTap: () => setState(() => _enabledLabels = label), + child: Container( + padding: + const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + decoration: BoxDecoration( + color: selected ? const Color(0xFF7C3AED) : Colors.white, + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: selected + ? const Color(0xFF7C3AED) + : const Color(0xFFE2E8F0), + ), + ), + child: Text( + label, + style: GoogleFonts.inter( + fontSize: 13, + fontWeight: FontWeight.w600, + color: + selected ? Colors.white : const Color(0xFF64748B), + ), + ), + ), + ); + }).toList(), + ), + ), + const SizedBox(height: 24), + + // ── Save button ─────────────────────────────────────────────────── + SizedBox( + width: double.infinity, + child: FilledButton.icon( + onPressed: _saving ? null : _save, + icon: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.save_outlined), + label: Text(_saving ? 'Menyimpan...' : 'Simpan Konfigurasi'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF1A56DB), + padding: const EdgeInsets.symmetric(vertical: 14), + textStyle: GoogleFonts.inter( + fontSize: 14, fontWeight: FontWeight.w600), + ), + ), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + Widget _buildNoPairingPanel() { + return Center( + child: Container( + padding: const EdgeInsets.all(24), + margin: const EdgeInsets.symmetric(horizontal: 16), + decoration: BoxDecoration( + color: const Color(0xFFFFFBEB), + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFFDE68A)), + ), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.link_off, color: Color(0xFFD97706), size: 52), + const SizedBox(height: 14), + Text( + 'Belum Pairing', + style: GoogleFonts.outfit( + fontSize: 18, + fontWeight: FontWeight.w700, + color: const Color(0xFF92400E), + ), + ), + const SizedBox(height: 8), + Text( + 'Hubungkan akun Guardian dengan User terlebih dahulu untuk mengatur konfigurasi AI.', + style: GoogleFonts.inter( + fontSize: 13, color: const Color(0xFF92400E)), + textAlign: TextAlign.center, + ), + ], + ), + ), + ); + } + + Widget _buildErrorPanel() { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.wifi_off, size: 52, color: Color(0xFF94A3B8)), + const SizedBox(height: 14), + Text( + _error!, + textAlign: TextAlign.center, + style: + GoogleFonts.inter(fontSize: 13, color: const Color(0xFF64748B)), + ), + const SizedBox(height: 18), + FilledButton.icon( + onPressed: _load, + icon: const Icon(Icons.refresh), + label: const Text('Coba lagi'), + style: FilledButton.styleFrom( + backgroundColor: const Color(0xFF1A56DB)), + ), + ], + ), + ); + } +} + +// ───────────────────────────────────────────────────────────────────────────── +// SECTION CARD +// ───────────────────────────────────────────────────────────────────────────── + +class _SectionCard extends StatelessWidget { + final String title; + final String subtitle; + final IconData icon; + final Color iconColor; + final Widget child; + + const _SectionCard({ + required this.title, + required this.subtitle, + required this.icon, + required this.iconColor, + required this.child, + }); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(16), + border: Border.all(color: const Color(0xFFE2E8F0)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.03), + blurRadius: 8, + offset: const Offset(0, 2), + ), + ], + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row(children: [ + Container( + width: 34, + height: 34, + decoration: BoxDecoration( + color: iconColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, color: iconColor, size: 18), + ), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + title, + style: GoogleFonts.outfit( + fontSize: 14, + fontWeight: FontWeight.w700, + color: const Color(0xFF0F172A), + ), + ), + Text( + subtitle, + style: GoogleFonts.inter( + fontSize: 11, + color: const Color(0xFF94A3B8), + ), + ), + ], + ), + ), + ]), + const SizedBox(height: 16), + const Divider(height: 1, color: Color(0xFFF1F5F9)), + const SizedBox(height: 12), + child, + ], + ), + ); + } +} diff --git a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart index cddbcb0..834cabc 100644 --- a/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart +++ b/walkguide-mobile/walkguide_app/lib/features/guardian_dashboard/guardian_screens.dart @@ -1,10 +1,18 @@ +export '../home/presentation/guardian_dashboard_screen.dart' + show GuardianDashboardScreen; + +export 'guardian_activity_log_screen.dart' + show + GuardianActivityLogScreen; + +export 'guardian_ai_config_screen.dart' + show + GuardianAiConfigScreen; + export '../screens.dart' show - GuardianDashboardScreen, GuardianMapScreen, - GuardianActivityLogScreen, GuardianSendNotifScreen, - GuardianAiConfigScreen, GuardianVoiceCmdScreen, GuardianShortcutScreen, GuardianGeofenceScreen; diff --git a/walkguide-mobile/walkguide_app/lib/features/screens.dart b/walkguide-mobile/walkguide_app/lib/features/screens.dart index b709804..f086e75 100644 --- a/walkguide-mobile/walkguide_app/lib/features/screens.dart +++ b/walkguide-mobile/walkguide_app/lib/features/screens.dart @@ -28,6 +28,8 @@ import '../core/services/tts_service.dart'; import '../core/services/websocket_service.dart'; import '../core/storage/secure_storage.dart'; +export 'guardian_dashboard/guardian_screens.dart'; + Dio get _api => sl().dio; class ServerConnectScreen extends StatefulWidget { @@ -779,26 +781,12 @@ class IncomingCallScreen extends StatelessWidget { text: 'Accept or reject incoming guardian calls here.'); } -class GuardianDashboardScreen extends StatelessWidget { - const GuardianDashboardScreen({super.key}); - @override - Widget build(BuildContext context) => const _EndpointListScreen( - title: 'Guardian Dashboard', endpoint: '/guardian/dashboard'); -} - class GuardianMapScreen extends StatelessWidget { const GuardianMapScreen({super.key}); @override Widget build(BuildContext context) => const _GuardianMapHistoryScreen(); } -class GuardianActivityLogScreen extends StatelessWidget { - const GuardianActivityLogScreen({super.key}); - @override - Widget build(BuildContext context) => const _EndpointListScreen( - title: 'User Logs', endpoint: '/guardian/activity-logs'); -} - class GuardianSendNotifScreen extends StatefulWidget { const GuardianSendNotifScreen({super.key}); @@ -850,23 +838,6 @@ class _GuardianSendNotifScreenState extends State { } } -class GuardianAiConfigScreen extends StatelessWidget { - const GuardianAiConfigScreen({super.key}); - @override - Widget build(BuildContext context) { - return _Page( - title: 'AI Config', - subtitle: '/guardian/ai-config', - actions: [ - IconButton( - onPressed: () => context.go('/guardian/benchmark'), - icon: const Icon(Icons.speed)) - ], - child: const _EndpointList(endpoint: '/guardian/ai-config'), - ); - } -} - class GuardianVoiceCmdScreen extends StatelessWidget { const GuardianVoiceCmdScreen({super.key}); @override @@ -977,7 +948,7 @@ class _GuardianMapHistoryScreen extends StatelessWidget { child: _MapScreenBody(guardianEndpoint: '/guardian/user-location'), ), SizedBox(height: 12), - Expanded(flex: 2, child: _LocationTimeline()), + Expanded(flex: 2, child: ClipRect(child: _LocationTimeline())), ], ), ); @@ -1872,8 +1843,8 @@ class _EmptyPanel extends StatelessWidget { Widget build(BuildContext context) { return Container( width: double.infinity, - constraints: const BoxConstraints(minHeight: 180), - padding: const EdgeInsets.all(18), + constraints: const BoxConstraints(minHeight: 0), + padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8), decoration: BoxDecoration( color: const Color(0xFFF8FAFC), borderRadius: BorderRadius.circular(12), @@ -2196,15 +2167,17 @@ class _JsonCard extends StatelessWidget { @override Widget build(BuildContext context) { - return Container( - width: double.infinity, - constraints: const BoxConstraints(minHeight: 220), - padding: const EdgeInsets.all(14), - decoration: BoxDecoration( - color: Colors.white, - borderRadius: BorderRadius.circular(12), - border: Border.all(color: const Color(0xFFE2E8F0))), - child: SingleChildScrollView(child: Text(data?.toString() ?? 'No data')), + return Expanded( + child: Container( + width: double.infinity, + padding: const EdgeInsets.all(14), + decoration: BoxDecoration( + color: Colors.white, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: const Color(0xFFE2E8F0))), + child: + SingleChildScrollView(child: Text(data?.toString() ?? 'No data')), + ), ); } }