fix: resolve raw JSON rendering and layout overflow in Guardian screens
This commit is contained in:
parent
8a2889633f
commit
282a918b56
@ -37,3 +37,5 @@ agora.app-certificate=${AGORA_APP_CERTIFICATE:}
|
|||||||
logging.level.com.walkguide=DEBUG
|
logging.level.com.walkguide=DEBUG
|
||||||
logging.level.org.springframework.messaging=INFO
|
logging.level.org.springframework.messaging=INFO
|
||||||
logging.level.org.springframework.web.socket=INFO
|
logging.level.org.springframework.web.socket=INFO
|
||||||
|
|
||||||
|
spring.profiles.active=dev
|
||||||
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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'
|
export '../screens.dart'
|
||||||
show
|
show
|
||||||
GuardianDashboardScreen,
|
|
||||||
GuardianMapScreen,
|
GuardianMapScreen,
|
||||||
GuardianActivityLogScreen,
|
|
||||||
GuardianSendNotifScreen,
|
GuardianSendNotifScreen,
|
||||||
GuardianAiConfigScreen,
|
|
||||||
GuardianVoiceCmdScreen,
|
GuardianVoiceCmdScreen,
|
||||||
GuardianShortcutScreen,
|
GuardianShortcutScreen,
|
||||||
GuardianGeofenceScreen;
|
GuardianGeofenceScreen;
|
||||||
|
|||||||
@ -28,6 +28,8 @@ import '../core/services/tts_service.dart';
|
|||||||
import '../core/services/websocket_service.dart';
|
import '../core/services/websocket_service.dart';
|
||||||
import '../core/storage/secure_storage.dart';
|
import '../core/storage/secure_storage.dart';
|
||||||
|
|
||||||
|
export 'guardian_dashboard/guardian_screens.dart';
|
||||||
|
|
||||||
Dio get _api => sl<ApiClient>().dio;
|
Dio get _api => sl<ApiClient>().dio;
|
||||||
|
|
||||||
class ServerConnectScreen extends StatefulWidget {
|
class ServerConnectScreen extends StatefulWidget {
|
||||||
@ -779,26 +781,12 @@ class IncomingCallScreen extends StatelessWidget {
|
|||||||
text: 'Accept or reject incoming guardian calls here.');
|
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 {
|
class GuardianMapScreen extends StatelessWidget {
|
||||||
const GuardianMapScreen({super.key});
|
const GuardianMapScreen({super.key});
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) => const _GuardianMapHistoryScreen();
|
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 {
|
class GuardianSendNotifScreen extends StatefulWidget {
|
||||||
const GuardianSendNotifScreen({super.key});
|
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 {
|
class GuardianVoiceCmdScreen extends StatelessWidget {
|
||||||
const GuardianVoiceCmdScreen({super.key});
|
const GuardianVoiceCmdScreen({super.key});
|
||||||
@override
|
@override
|
||||||
@ -977,7 +948,7 @@ class _GuardianMapHistoryScreen extends StatelessWidget {
|
|||||||
child: _MapScreenBody(guardianEndpoint: '/guardian/user-location'),
|
child: _MapScreenBody(guardianEndpoint: '/guardian/user-location'),
|
||||||
),
|
),
|
||||||
SizedBox(height: 12),
|
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) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
constraints: const BoxConstraints(minHeight: 180),
|
constraints: const BoxConstraints(minHeight: 0),
|
||||||
padding: const EdgeInsets.all(18),
|
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 8),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: const Color(0xFFF8FAFC),
|
color: const Color(0xFFF8FAFC),
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
@ -2196,15 +2167,17 @@ class _JsonCard extends StatelessWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Expanded(
|
||||||
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
constraints: const BoxConstraints(minHeight: 220),
|
|
||||||
padding: const EdgeInsets.all(14),
|
padding: const EdgeInsets.all(14),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(12),
|
||||||
border: Border.all(color: const Color(0xFFE2E8F0))),
|
border: Border.all(color: const Color(0xFFE2E8F0))),
|
||||||
child: SingleChildScrollView(child: Text(data?.toString() ?? 'No data')),
|
child:
|
||||||
|
SingleChildScrollView(child: Text(data?.toString() ?? 'No data')),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user