fix: resolve raw JSON rendering and layout overflow in Guardian screens

This commit is contained in:
5803024019 2026-05-19 15:57:06 +07:00
parent 8a2889633f
commit 282a918b56
5 changed files with 1207 additions and 47 deletions

View File

@ -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
logging.level.org.springframework.web.socket=INFO
spring.profiles.active=dev

View File

@ -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<ApiClient>().dio;
class GuardianActivityLogScreen extends StatefulWidget {
const GuardianActivityLogScreen({super.key});
@override
State<GuardianActivityLogScreen> createState() =>
_GuardianActivityLogScreenState();
}
class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
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<void> _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<dynamic> 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>()
.map((e) => _LogItem.fromJson(Map<String, dynamic>.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<bool> _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<String, dynamic> 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);
}
}

View File

@ -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<ApiClient>().dio;
class GuardianAiConfigScreen extends StatefulWidget {
const GuardianAiConfigScreen({super.key});
@override
State<GuardianAiConfigScreen> createState() => _GuardianAiConfigScreenState();
}
class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
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<void> _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<void> _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<bool> _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,
],
),
);
}
}

View File

@ -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;

View File

@ -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<ApiClient>().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<GuardianSendNotifScreen> {
}
}
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')),
),
);
}
}