ADD ALOT OF POLISHING AND UPDATES...
This commit is contained in:
+55
-53
@@ -7,6 +7,7 @@ import 'package:google_fonts/google_fonts.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
|
||||
import '../../../app/injection_container.dart';
|
||||
import '../../../core/errors/friendly_error.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
|
||||
Dio get _api => sl<ApiClient>().dio;
|
||||
@@ -48,66 +49,67 @@ class _GuardianActivityLogScreenState extends State<GuardianActivityLogScreen> {
|
||||
_error = null;
|
||||
_needsPairing = false;
|
||||
});
|
||||
try {
|
||||
// Cek pairing dulu
|
||||
final paired = await _hasActivePairing();
|
||||
if (!paired) {
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
// 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(() {
|
||||
_needsPairing = true;
|
||||
_items = items;
|
||||
_applyFilter(_selectedFilter);
|
||||
_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);
|
||||
},
|
||||
onError: (message) => setState(() {
|
||||
_error = message;
|
||||
_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;
|
||||
});
|
||||
}
|
||||
}),
|
||||
fallback: 'Gagal memuat activity log. Coba refresh lagi.',
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
final active = await runFriendly<bool>(
|
||||
() async {
|
||||
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';
|
||||
return false;
|
||||
},
|
||||
onError: (_) {},
|
||||
fallback: 'Status pairing belum bisa dicek.',
|
||||
);
|
||||
return active ?? false;
|
||||
}
|
||||
|
||||
void _applyFilter(String filter) {
|
||||
|
||||
+10
-8
@@ -7,6 +7,7 @@ import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../../../app/injection_container.dart';
|
||||
import '../../../core/errors/friendly_error.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
|
||||
Dio get _api => sl<ApiClient>().dio;
|
||||
@@ -73,11 +74,12 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
setState(() {
|
||||
_error = e.response?.data?['message']?.toString() ??
|
||||
'Gagal memuat konfigurasi AI.';
|
||||
_error =
|
||||
friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.');
|
||||
});
|
||||
} catch (e) {
|
||||
setState(() => _error = 'Timeout / error: $e');
|
||||
setState(
|
||||
() => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
@@ -105,8 +107,8 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(e.response?.data?['message']?.toString() ??
|
||||
'Gagal menyimpan konfigurasi.'),
|
||||
content: Text(friendlyDioMessage(e,
|
||||
fallback: 'Gagal menyimpan konfigurasi.')),
|
||||
backgroundColor: const Color(0xFFDC2626),
|
||||
),
|
||||
);
|
||||
@@ -114,9 +116,9 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text('Error: $e'),
|
||||
backgroundColor: const Color(0xFFDC2626),
|
||||
const SnackBar(
|
||||
content: Text('Gagal menyimpan konfigurasi. Coba lagi.'),
|
||||
backgroundColor: Color(0xFFDC2626),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+301
@@ -0,0 +1,301 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../shared/widgets/feature_page.dart';
|
||||
|
||||
class GuardianMapScreen extends StatefulWidget {
|
||||
const GuardianMapScreen({super.key});
|
||||
|
||||
@override
|
||||
State<GuardianMapScreen> createState() => _GuardianMapScreenState();
|
||||
}
|
||||
|
||||
class _GuardianMapScreenState extends State<GuardianMapScreen> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
Map<String, dynamic>? _lastLocation;
|
||||
List<Map<String, dynamic>> _history = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final dio = sl<ApiClient>().dio;
|
||||
final current = await dio
|
||||
.get('/guardian/user-location')
|
||||
.timeout(const Duration(seconds: 8));
|
||||
final history = await dio.get('/guardian/location-history',
|
||||
queryParameters: {'size': 80}).timeout(const Duration(seconds: 8));
|
||||
final currentData = current.data is Map ? current.data['data'] : null;
|
||||
final historyData = history.data is Map ? history.data['data'] : null;
|
||||
_lastLocation =
|
||||
currentData is Map ? Map<String, dynamic>.from(currentData) : null;
|
||||
final content = historyData is Map ? historyData['content'] : null;
|
||||
_history = content is List
|
||||
? content
|
||||
.whereType<Map>()
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.toList()
|
||||
: const [];
|
||||
},
|
||||
onError: (message) => _error = message,
|
||||
fallback: 'Lokasi User belum bisa dimuat. Coba refresh lagi.',
|
||||
);
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FeaturePage(
|
||||
title: 'Live Map',
|
||||
subtitle: 'Lokasi terakhir User dan timeline perjalanan',
|
||||
trailing: IconButton(
|
||||
onPressed: _loading ? null : _load,
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? FeatureErrorPanel(message: _error!, onRetry: _load)
|
||||
: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _GuardianMapCard(
|
||||
location: _lastLocation,
|
||||
history: _history,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Expanded(
|
||||
flex: 2,
|
||||
child: _TimelineList(history: _history, onRetry: _load),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GuardianMapCard extends StatelessWidget {
|
||||
final Map<String, dynamic>? location;
|
||||
final List<Map<String, dynamic>> history;
|
||||
|
||||
const _GuardianMapCard({required this.location, required this.history});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final points = _pointsFrom(history);
|
||||
final center = _pointFrom(location) ??
|
||||
(points.isNotEmpty ? points.first : null) ??
|
||||
const LatLng(-7.2575, 112.7521);
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
child: FlutterMap(
|
||||
options: MapOptions(initialCenter: center, initialZoom: 16),
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.walkguide.app',
|
||||
),
|
||||
if (points.length > 1)
|
||||
PolylineLayer(
|
||||
polylines: [
|
||||
Polyline(
|
||||
points: points,
|
||||
strokeWidth: 4,
|
||||
color: const Color(0xFF2563EB),
|
||||
),
|
||||
],
|
||||
),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
point: center,
|
||||
width: 54,
|
||||
height: 54,
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF2563EB),
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 4),
|
||||
),
|
||||
child:
|
||||
const Icon(Icons.person_pin_circle, color: Colors.white),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineList extends StatelessWidget {
|
||||
final List<Map<String, dynamic>> history;
|
||||
final VoidCallback onRetry;
|
||||
|
||||
const _TimelineList({required this.history, required this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final segments = _segments(history);
|
||||
if (segments.isEmpty) {
|
||||
return FeatureEmptyPanel(
|
||||
icon: Icons.timeline,
|
||||
title: 'Belum ada timeline',
|
||||
message:
|
||||
'Timeline akan muncul setelah User mengirim beberapa titik lokasi.',
|
||||
action: OutlinedButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Refresh'),
|
||||
),
|
||||
);
|
||||
}
|
||||
return ListView.separated(
|
||||
itemCount: segments.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, index) => _TimelineCard(segment: segments[index]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineCard extends StatelessWidget {
|
||||
final _TimelineSegment segment;
|
||||
|
||||
const _TimelineCard({required this.segment});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(segment.icon, color: const Color(0xFF1D4ED8)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(segment.title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 4),
|
||||
Text(segment.subtitle,
|
||||
style: const TextStyle(color: Color(0xFF64748B))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimelineSegment {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final IconData icon;
|
||||
|
||||
const _TimelineSegment({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.icon,
|
||||
});
|
||||
}
|
||||
|
||||
List<_TimelineSegment> _segments(List<Map<String, dynamic>> history) {
|
||||
final items = [...history]..sort((a, b) => _time(a).compareTo(_time(b)));
|
||||
if (items.length < 2) return const [];
|
||||
final chunks = <_TimelineSegment>[];
|
||||
for (var i = 0; i < items.length - 1; i += 6) {
|
||||
final start = items[i];
|
||||
final end = items[math.min(i + 5, items.length - 1)];
|
||||
final distance = _distanceBetween(start, end);
|
||||
final speed = _avgSpeed(items.sublist(i, math.min(i + 6, items.length)));
|
||||
final mode = speed > 2.8
|
||||
? 'Naik kendaraan'
|
||||
: speed > 1.1
|
||||
? 'Berjalan cepat'
|
||||
: 'Berjalan';
|
||||
chunks.add(
|
||||
_TimelineSegment(
|
||||
title: '${_clock(start)} - ${_clock(end)} $mode',
|
||||
subtitle:
|
||||
'${distance.toStringAsFixed(0)} m, avg ${speed.toStringAsFixed(1)} m/s',
|
||||
icon: speed > 2.8 ? Icons.directions_bike : Icons.directions_walk,
|
||||
),
|
||||
);
|
||||
}
|
||||
return chunks.reversed.toList();
|
||||
}
|
||||
|
||||
List<LatLng> _pointsFrom(List<Map<String, dynamic>> history) {
|
||||
return history.map(_pointFrom).whereType<LatLng>().toList();
|
||||
}
|
||||
|
||||
LatLng? _pointFrom(Map<String, dynamic>? item) {
|
||||
if (item == null) return null;
|
||||
final lat = (item['lat'] as num?)?.toDouble();
|
||||
final lng = (item['lng'] as num?)?.toDouble();
|
||||
if (lat == null || lng == null) return null;
|
||||
return LatLng(lat, lng);
|
||||
}
|
||||
|
||||
DateTime _time(Map<String, dynamic> item) {
|
||||
return DateTime.tryParse(item['createdAt']?.toString() ?? '') ??
|
||||
DateTime.fromMillisecondsSinceEpoch(0);
|
||||
}
|
||||
|
||||
String _clock(Map<String, dynamic> item) {
|
||||
final time = _time(item).toLocal();
|
||||
return DateFormat('HH:mm').format(time);
|
||||
}
|
||||
|
||||
double _distanceBetween(Map<String, dynamic> a, Map<String, dynamic> b) {
|
||||
final start = _pointFrom(a);
|
||||
final end = _pointFrom(b);
|
||||
if (start == null || end == null) return 0;
|
||||
return const Distance().as(LengthUnit.Meter, start, end);
|
||||
}
|
||||
|
||||
double _avgSpeed(List<Map<String, dynamic>> items) {
|
||||
final speeds = items
|
||||
.map((e) => (e['speed'] as num?)?.toDouble())
|
||||
.whereType<double>()
|
||||
.where((speed) => speed >= 0)
|
||||
.toList();
|
||||
if (speeds.isEmpty) return 0;
|
||||
return speeds.reduce((a, b) => a + b) / speeds.length;
|
||||
}
|
||||
+11
-14
@@ -1,18 +1,15 @@
|
||||
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 'guardian_activity_log_screen.dart' show GuardianActivityLogScreen;
|
||||
|
||||
export '../screens.dart'
|
||||
show
|
||||
GuardianMapScreen,
|
||||
GuardianSendNotifScreen,
|
||||
GuardianVoiceCmdScreen,
|
||||
GuardianShortcutScreen,
|
||||
GuardianGeofenceScreen;
|
||||
export 'guardian_ai_config_screen.dart' show GuardianAiConfigScreen;
|
||||
|
||||
export 'guardian_map_screen.dart' show GuardianMapScreen;
|
||||
|
||||
export 'guardian_send_notification_screen.dart' show GuardianSendNotifScreen;
|
||||
|
||||
export 'guardian_settings_screen.dart' show GuardianSettingsScreen;
|
||||
|
||||
export 'guardian_tools_screen.dart'
|
||||
show GuardianVoiceCmdScreen, GuardianShortcutScreen, GuardianGeofenceScreen;
|
||||
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../shared/widgets/feature_page.dart';
|
||||
|
||||
class GuardianSendNotifScreen extends StatefulWidget {
|
||||
const GuardianSendNotifScreen({super.key});
|
||||
|
||||
@override
|
||||
State<GuardianSendNotifScreen> createState() =>
|
||||
_GuardianSendNotifScreenState();
|
||||
}
|
||||
|
||||
class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
||||
final _message = TextEditingController();
|
||||
bool _loading = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_message.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final message = _message.text.trim();
|
||||
if (message.isEmpty) {
|
||||
_snack('Tulis pesan dulu.');
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
await sl<ApiClient>().dio.post('/guardian/notifications/send', data: {
|
||||
'notifType': 'TEXT',
|
||||
'content': message,
|
||||
}).timeout(const Duration(seconds: 8));
|
||||
_message.clear();
|
||||
_snack('Notifikasi terkirim ke User.');
|
||||
},
|
||||
onError: _snack,
|
||||
fallback: 'Gagal mengirim notifikasi. Coba lagi.',
|
||||
);
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
void _snack(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FeaturePage(
|
||||
title: 'Send Notification',
|
||||
subtitle: 'Kirim pesan singkat ke User yang sudah pairing',
|
||||
child: ListView(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF1E293B).withValues(alpha: 0.06),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _message,
|
||||
minLines: 5,
|
||||
maxLines: 8,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Message',
|
||||
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
|
||||
prefixIcon: Icon(Icons.message_outlined),
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: _loading ? null : _send,
|
||||
icon: _loading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
label: Text(_loading ? 'Sending...' : 'Send Message'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+122
@@ -0,0 +1,122 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../app/app_cubit.dart';
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/storage/secure_storage.dart';
|
||||
import '../../shared/widgets/feature_page.dart';
|
||||
|
||||
class GuardianSettingsScreen extends StatelessWidget {
|
||||
const GuardianSettingsScreen({super.key});
|
||||
|
||||
Future<void> _logout(BuildContext context) async {
|
||||
final appCubit = context.read<AppCubit>();
|
||||
await sl<SecureStorage>().clearAll();
|
||||
appCubit.clearSession();
|
||||
unawaited(_notifyBackendLogout());
|
||||
if (context.mounted) context.go('/login');
|
||||
}
|
||||
|
||||
Future<void> _notifyBackendLogout() async {
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
await sl<ApiClient>()
|
||||
.dio
|
||||
.post('/auth/logout')
|
||||
.timeout(const Duration(seconds: 3));
|
||||
},
|
||||
onError: (_) {},
|
||||
fallback: 'Logout server belum bisa dikonfirmasi.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _changeServer(BuildContext context) async {
|
||||
await AppConstants.clearServerUrl();
|
||||
await sl<SecureStorage>().clearAll();
|
||||
if (context.mounted) context.go('/server-connect');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FeaturePage(
|
||||
title: 'Guardian Settings',
|
||||
subtitle: 'Account, pairing, AI tools, and server',
|
||||
child: ListView(
|
||||
children: [
|
||||
_SettingsTile(
|
||||
icon: Icons.link,
|
||||
title: 'Pair User',
|
||||
subtitle: 'Masukkan Pairing Code User atau cek status pairing.',
|
||||
onTap: () => context.go('/guardian/pairing'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.speed,
|
||||
title: 'AI Benchmark',
|
||||
subtitle: 'Catat capture, inference, notification, dan TTS.',
|
||||
onTap: () => context.go('/guardian/benchmark'),
|
||||
),
|
||||
_SettingsTile(
|
||||
icon: Icons.tune,
|
||||
title: 'AI Config',
|
||||
subtitle: 'Atur threshold deteksi dan label yang aktif.',
|
||||
onTap: () => context.go('/guardian/ai-config'),
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
OutlinedButton.icon(
|
||||
onPressed: () => _changeServer(context),
|
||||
icon: const Icon(Icons.dns_outlined),
|
||||
label: const Text('Change server'),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
FilledButton.icon(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: const Color(0xFFDC2626),
|
||||
),
|
||||
onPressed: () => _logout(context),
|
||||
icon: const Icon(Icons.logout),
|
||||
label: const Text('Logout'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SettingsTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _SettingsTile({
|
||||
required this.icon,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: ListTile(
|
||||
leading: Icon(icon, color: const Color(0xFF1D4ED8)),
|
||||
title: Text(title, style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||
subtitle: Text(subtitle),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: onTap,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../shared/widgets/feature_page.dart';
|
||||
|
||||
class GuardianVoiceCmdScreen extends StatelessWidget {
|
||||
const GuardianVoiceCmdScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const _GuardianEndpointScreen(
|
||||
title: 'Voice Commands',
|
||||
subtitle: 'Daftar voice command yang aktif untuk User',
|
||||
endpoint: '/guardian/voice-commands',
|
||||
icon: Icons.record_voice_over_outlined,
|
||||
);
|
||||
}
|
||||
|
||||
class GuardianShortcutScreen extends StatelessWidget {
|
||||
const GuardianShortcutScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const _GuardianEndpointScreen(
|
||||
title: 'Hardware Shortcuts',
|
||||
subtitle: 'Shortcut tombol untuk aksi cepat WalkGuide',
|
||||
endpoint: '/guardian/shortcuts',
|
||||
icon: Icons.keyboard_command_key_outlined,
|
||||
);
|
||||
}
|
||||
|
||||
class GuardianGeofenceScreen extends StatelessWidget {
|
||||
const GuardianGeofenceScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => const _GuardianEndpointScreen(
|
||||
title: 'Geofence',
|
||||
subtitle: 'Area aman dan peringatan lokasi User',
|
||||
endpoint: '/guardian/geofence',
|
||||
icon: Icons.fence_outlined,
|
||||
);
|
||||
}
|
||||
|
||||
class _GuardianEndpointScreen extends StatefulWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String endpoint;
|
||||
final IconData icon;
|
||||
|
||||
const _GuardianEndpointScreen({
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.endpoint,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
State<_GuardianEndpointScreen> createState() =>
|
||||
_GuardianEndpointScreenState();
|
||||
}
|
||||
|
||||
class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
List<Map<String, dynamic>> _items = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final res = await sl<ApiClient>().dio.get(widget.endpoint,
|
||||
queryParameters: {'size': 50}).timeout(const Duration(seconds: 8));
|
||||
_items = _extractList(res.data);
|
||||
},
|
||||
onError: (message) => _error = message,
|
||||
fallback: 'Data belum bisa dimuat. Coba refresh lagi.',
|
||||
);
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _extractList(dynamic body) {
|
||||
final data = body is Map ? body['data'] : body;
|
||||
final raw = data is Map ? data['content'] : data;
|
||||
if (raw is! List) return const [];
|
||||
return raw
|
||||
.whereType<Map>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FeaturePage(
|
||||
title: widget.title,
|
||||
subtitle: widget.subtitle,
|
||||
trailing: IconButton(
|
||||
onPressed: _loading ? null : _load,
|
||||
icon: const Icon(Icons.refresh),
|
||||
tooltip: 'Refresh',
|
||||
),
|
||||
child: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? FeatureErrorPanel(message: _error!, onRetry: _load)
|
||||
: _items.isEmpty
|
||||
? FeatureEmptyPanel(
|
||||
icon: widget.icon,
|
||||
title: 'Belum ada data',
|
||||
message:
|
||||
'Data akan muncul setelah Guardian membuat konfigurasi atau User mulai memakai fitur ini.',
|
||||
action: OutlinedButton.icon(
|
||||
onPressed: _load,
|
||||
icon: const Icon(Icons.refresh),
|
||||
label: const Text('Refresh'),
|
||||
),
|
||||
)
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
itemCount: _items.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 10),
|
||||
itemBuilder: (_, index) => _EndpointCard(
|
||||
icon: widget.icon,
|
||||
item: _items[index],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EndpointCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Map<String, dynamic> item;
|
||||
|
||||
const _EndpointCard({required this.icon, required this.item});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = _firstText(item, ['name', 'command', 'label', 'type']) ??
|
||||
'Item #${item['id'] ?? '-'}';
|
||||
final subtitle = _firstText(
|
||||
item,
|
||||
['description', 'action', 'shortcut', 'status', 'createdAt'],
|
||||
) ??
|
||||
'Data aktif';
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 42,
|
||||
height: 42,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFEFF6FF),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(icon, color: const Color(0xFF1D4ED8)),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(title,
|
||||
style: const TextStyle(fontWeight: FontWeight.w800)),
|
||||
const SizedBox(height: 3),
|
||||
Text(subtitle,
|
||||
style: const TextStyle(color: Color(0xFF64748B))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _firstText(Map<String, dynamic> item, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = item[key]?.toString().trim();
|
||||
if (value != null && value.isNotEmpty && value != 'null') return value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
Reference in New Issue
Block a user