Fix local dev config and ALOTTTT OFF FLUTTER, im tired boss..
This commit is contained in:
+1
@@ -0,0 +1 @@
|
||||
export '../../activity_log_screen.dart';
|
||||
@@ -10,6 +10,7 @@ import '../../app/injection_container.dart';
|
||||
import '../../core/ai/detection_export.dart';
|
||||
import '../../core/constants/app_constants.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import '../../core/utils/operation_guard.dart';
|
||||
import '../../shared/widgets/feature_page.dart';
|
||||
|
||||
class AiBenchmarkScreen extends StatefulWidget {
|
||||
@@ -79,11 +80,11 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
||||
notifWatch.stop();
|
||||
|
||||
final ttsWatch = Stopwatch()..start();
|
||||
try {
|
||||
await sl<TtsService>()
|
||||
await guarded<void>(
|
||||
() => sl<TtsService>()
|
||||
.speakImmediate(notificationText)
|
||||
.timeout(const Duration(seconds: 3));
|
||||
} catch (_) {}
|
||||
.timeout(const Duration(seconds: 3)),
|
||||
);
|
||||
ttsWatch.stop();
|
||||
|
||||
final run = {
|
||||
@@ -113,23 +114,27 @@ class _AiBenchmarkScreenState extends State<AiBenchmarkScreen> {
|
||||
Future<int> _measureCapture() async {
|
||||
final watch = Stopwatch()..start();
|
||||
CameraController? controller;
|
||||
try {
|
||||
await guarded<void>(
|
||||
() async {
|
||||
final cameras =
|
||||
await availableCameras().timeout(const Duration(seconds: 3));
|
||||
if (cameras.isNotEmpty) {
|
||||
controller = CameraController(
|
||||
final activeController = CameraController(
|
||||
cameras.first,
|
||||
ResolutionPreset.low,
|
||||
enableAudio: false,
|
||||
);
|
||||
await controller.initialize().timeout(const Duration(seconds: 5));
|
||||
await controller.takePicture().timeout(const Duration(seconds: 5));
|
||||
controller = activeController;
|
||||
await activeController.initialize().timeout(const Duration(seconds: 5));
|
||||
await activeController.takePicture().timeout(const Duration(seconds: 5));
|
||||
}
|
||||
} catch (_) {
|
||||
},
|
||||
onError: (_) {},
|
||||
);
|
||||
if (controller == null) {
|
||||
await Future<void>.delayed(const Duration(milliseconds: 16));
|
||||
} finally {
|
||||
await controller?.dispose();
|
||||
}
|
||||
await controller?.dispose();
|
||||
watch.stop();
|
||||
return watch.elapsedMilliseconds;
|
||||
}
|
||||
@@ -273,7 +278,8 @@ class _StatusBox extends StatelessWidget {
|
||||
}
|
||||
|
||||
Future<List<String>> _discoverTfliteModels() async {
|
||||
try {
|
||||
return await guarded<List<String>>(
|
||||
() async {
|
||||
final manifestRaw = await rootBundle.loadString('AssetManifest.json');
|
||||
final manifest = jsonDecode(manifestRaw) as Map<String, dynamic>;
|
||||
final models = manifest.keys
|
||||
@@ -282,9 +288,9 @@ Future<List<String>> _discoverTfliteModels() async {
|
||||
.toList()
|
||||
..sort();
|
||||
return models;
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
},
|
||||
) ??
|
||||
const [];
|
||||
}
|
||||
|
||||
String _two(int value) => value.toString().padLeft(2, '0');
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../ai_benchmark_screen.dart';
|
||||
@@ -1,5 +1,5 @@
|
||||
import 'package:dio/dio.dart';
|
||||
import '../../../../core/api_service.dart';
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import 'auth_model.dart';
|
||||
|
||||
abstract class AuthRemoteDataSource {
|
||||
@@ -7,14 +7,14 @@ abstract class AuthRemoteDataSource {
|
||||
}
|
||||
|
||||
class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||
final ApiService apiService;
|
||||
final ApiClient apiClient;
|
||||
|
||||
AuthRemoteDataSourceImpl(this.apiService);
|
||||
AuthRemoteDataSourceImpl(this.apiClient);
|
||||
|
||||
@override
|
||||
Future<AuthModel> login(String email, String password) async {
|
||||
try {
|
||||
final response = await apiService.post('/auth/login', {
|
||||
final response = await apiClient.dio.post('/auth/login', data: {
|
||||
'email': email,
|
||||
'password': password,
|
||||
});
|
||||
@@ -30,4 +30,4 @@ class AuthRemoteDataSourceImpl implements AuthRemoteDataSource {
|
||||
throw Exception(e.response?.data['message'] ?? 'Terjadi kesalahan jaringan');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
export '../../call_screen.dart';
|
||||
+59
-63
@@ -6,9 +6,10 @@ 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/errors/friendly_error.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../app/injection_container.dart';
|
||||
import '../../../core/errors/friendly_error.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/utils/operation_guard.dart';
|
||||
|
||||
Dio get _api => sl<ApiClient>().dio;
|
||||
|
||||
@@ -46,9 +47,10 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
||||
_error = null;
|
||||
_needsPairing = false;
|
||||
});
|
||||
try {
|
||||
final paired = await _hasActivePairing();
|
||||
if (!paired) {
|
||||
await guarded<void>(
|
||||
() async {
|
||||
final paired = await _hasActivePairing();
|
||||
if (!paired) {
|
||||
setState(() {
|
||||
_needsPairing = true;
|
||||
_loading = false;
|
||||
@@ -69,26 +71,24 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
||||
_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 =
|
||||
friendlyDioMessage(e, fallback: 'Gagal memuat konfigurasi AI.');
|
||||
});
|
||||
} catch (e) {
|
||||
setState(
|
||||
() => _error = 'Gagal memuat konfigurasi AI. Coba refresh lagi.');
|
||||
} finally {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() => _saving = true);
|
||||
try {
|
||||
await _api.put('/guardian/ai-config', data: {
|
||||
_enabledLabels = data['enabledLabels']?.toString() ?? 'ALL';
|
||||
});
|
||||
}
|
||||
},
|
||||
onError: (error) => setState(() {
|
||||
_error = error is DioException
|
||||
? friendlyDioMessage(error, fallback: 'Gagal memuat konfigurasi AI.')
|
||||
: 'Gagal memuat konfigurasi AI. Coba refresh lagi.';
|
||||
}),
|
||||
);
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
Future<void> _save() async {
|
||||
setState(() => _saving = true);
|
||||
await guarded<void>(
|
||||
() async {
|
||||
await _api.put('/guardian/ai-config', data: {
|
||||
'confidenceThreshold': _confidenceThreshold,
|
||||
'alertDistanceClose': _alertDistanceClose,
|
||||
'alertDistanceMedium': _alertDistanceMedium,
|
||||
@@ -100,43 +100,39 @@ class _GuardianAiConfigScreenState extends State<GuardianAiConfigScreen> {
|
||||
const SnackBar(
|
||||
content: Text('Konfigurasi AI berhasil disimpan'),
|
||||
backgroundColor: Color(0xFF16A34A),
|
||||
),
|
||||
);
|
||||
}
|
||||
} on DioException catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(friendlyDioMessage(e,
|
||||
fallback: 'Gagal menyimpan konfigurasi.')),
|
||||
backgroundColor: const Color(0xFFDC2626),
|
||||
),
|
||||
);
|
||||
}
|
||||
} catch (e) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Gagal menyimpan konfigurasi. Coba lagi.'),
|
||||
backgroundColor: 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;
|
||||
}
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onError: (error) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(error is DioException
|
||||
? friendlyDioMessage(error,
|
||||
fallback: 'Gagal menyimpan konfigurasi.')
|
||||
: 'Gagal menyimpan konfigurasi. Coba lagi.'),
|
||||
backgroundColor: const Color(0xFFDC2626),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
if (mounted) setState(() => _saving = false);
|
||||
}
|
||||
|
||||
Future<bool> _hasActivePairing() async {
|
||||
return await guarded<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;
|
||||
},
|
||||
) ??
|
||||
false;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
|
||||
+167
-8
@@ -1,4 +1,9 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:record/record.dart';
|
||||
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
@@ -15,29 +20,54 @@ class GuardianSendNotifScreen extends StatefulWidget {
|
||||
|
||||
class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
||||
final _message = TextEditingController();
|
||||
final _recorder = AudioRecorder();
|
||||
bool _loading = false;
|
||||
bool _recording = false;
|
||||
bool _voiceMode = false;
|
||||
String? _voicePath;
|
||||
DateTime? _recordStart;
|
||||
int _voiceDuration = 0;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_message.dispose();
|
||||
_recorder.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _send() async {
|
||||
final message = _message.text.trim();
|
||||
if (message.isEmpty) {
|
||||
if (!_voiceMode && message.isEmpty) {
|
||||
_snack('Tulis pesan dulu.');
|
||||
return;
|
||||
}
|
||||
if (_voiceMode && _voicePath == null) {
|
||||
_snack('Rekam voice note dulu.');
|
||||
return;
|
||||
}
|
||||
setState(() => _loading = true);
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final data = _voiceMode
|
||||
? {
|
||||
'notifType': 'VOICE_NOTE',
|
||||
'content': await _voiceAsDataUrl(),
|
||||
'voiceNoteUrl': 'inline-audio',
|
||||
'voiceNoteDuration': _voiceDuration,
|
||||
}
|
||||
: {
|
||||
'notifType': 'TEXT',
|
||||
'content': message,
|
||||
};
|
||||
await sl<ApiClient>().dio.post('/guardian/notifications/send', data: {
|
||||
'notifType': 'TEXT',
|
||||
'content': message,
|
||||
}).timeout(const Duration(seconds: 8));
|
||||
...data,
|
||||
}).timeout(const Duration(seconds: 12));
|
||||
_message.clear();
|
||||
_snack('Notifikasi terkirim ke User.');
|
||||
_voicePath = null;
|
||||
_voiceDuration = 0;
|
||||
_snack(_voiceMode
|
||||
? 'Voice message terkirim ke User.'
|
||||
: 'Notifikasi terkirim ke User.');
|
||||
},
|
||||
onError: _snack,
|
||||
fallback: 'Gagal mengirim notifikasi. Coba lagi.',
|
||||
@@ -45,6 +75,50 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
||||
if (mounted) setState(() => _loading = false);
|
||||
}
|
||||
|
||||
Future<void> _toggleRecording() async {
|
||||
if (_recording) {
|
||||
final path = await _recorder.stop();
|
||||
final duration = _recordStart == null
|
||||
? 0
|
||||
: DateTime.now().difference(_recordStart!).inSeconds;
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_recording = false;
|
||||
_voicePath = path;
|
||||
_voiceDuration = duration.clamp(1, 600).toInt();
|
||||
_recordStart = null;
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
final hasPermission = await _recorder.hasPermission();
|
||||
if (!hasPermission) {
|
||||
_snack('Izin mikrofon dibutuhkan untuk rekam voice message.');
|
||||
return;
|
||||
}
|
||||
final dir = await getTemporaryDirectory();
|
||||
final path =
|
||||
'${dir.path}/walkguide_voice_${DateTime.now().millisecondsSinceEpoch}.m4a';
|
||||
await _recorder.start(
|
||||
const RecordConfig(encoder: AudioEncoder.aacLc),
|
||||
path: path,
|
||||
);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_recording = true;
|
||||
_recordStart = DateTime.now();
|
||||
_voicePath = null;
|
||||
_voiceDuration = 0;
|
||||
});
|
||||
}
|
||||
|
||||
Future<String> _voiceAsDataUrl() async {
|
||||
final path = _voicePath;
|
||||
if (path == null) return '';
|
||||
final bytes = await File(path).readAsBytes();
|
||||
return 'data:audio/mp4;base64,${base64Encode(bytes)}';
|
||||
}
|
||||
|
||||
void _snack(String message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
@@ -75,10 +149,29 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SegmentedButton<bool>(
|
||||
segments: const [
|
||||
ButtonSegment(
|
||||
value: false,
|
||||
icon: Icon(Icons.message_outlined),
|
||||
label: Text('Text'),
|
||||
),
|
||||
ButtonSegment(
|
||||
value: true,
|
||||
icon: Icon(Icons.mic_none_outlined),
|
||||
label: Text('Voice'),
|
||||
),
|
||||
],
|
||||
selected: {_voiceMode},
|
||||
onSelectionChanged: _loading || _recording
|
||||
? null
|
||||
: (value) => setState(() => _voiceMode = value.first),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
TextField(
|
||||
controller: _message,
|
||||
minLines: 5,
|
||||
maxLines: 8,
|
||||
minLines: _voiceMode ? 2 : 5,
|
||||
maxLines: _voiceMode ? 3 : 8,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Message',
|
||||
hintText: 'Contoh: Hati-hati, belok kanan setelah pintu.',
|
||||
@@ -86,6 +179,68 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
||||
alignLabelWithHint: true,
|
||||
),
|
||||
),
|
||||
if (_voiceMode) ...[
|
||||
const SizedBox(height: 14),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFFF8FAFC),
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: const Color(0xFFE2E8F0)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
backgroundColor: _recording
|
||||
? const Color(0xFFFEE2E2)
|
||||
: const Color(0xFFEFF6FF),
|
||||
child: Icon(
|
||||
_recording ? Icons.graphic_eq : Icons.mic,
|
||||
color: _recording
|
||||
? const Color(0xFFDC2626)
|
||||
: const Color(0xFF2563EB),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
_recording
|
||||
? 'Recording... tap stop when done'
|
||||
: _voicePath == null
|
||||
? 'No voice note recorded'
|
||||
: 'Voice note ready',
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w800),
|
||||
),
|
||||
Text(
|
||||
_recording
|
||||
? 'Speak clearly near the microphone'
|
||||
: _voicePath == null
|
||||
? 'Record a short message for User'
|
||||
: '${_voiceDuration}s audio attached',
|
||||
style: const TextStyle(
|
||||
color: Color(0xFF64748B), fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _loading ? null : _toggleRecording,
|
||||
icon: Icon(_recording ? Icons.stop : Icons.fiber_manual_record),
|
||||
label: Text(_recording ? 'Stop' : 'Record'),
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: _recording
|
||||
? const Color(0xFFDC2626)
|
||||
: const Color(0xFF2563EB),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: _loading ? null : _send,
|
||||
@@ -96,7 +251,11 @@ class _GuardianSendNotifScreenState extends State<GuardianSendNotifScreen> {
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: const Icon(Icons.send),
|
||||
label: Text(_loading ? 'Sending...' : 'Send Message'),
|
||||
label: Text(_loading
|
||||
? 'Sending...'
|
||||
: _voiceMode
|
||||
? 'Send Voice Message'
|
||||
: 'Send Message'),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
+225
-3
@@ -63,6 +63,8 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
List<Map<String, dynamic>> _items = const [];
|
||||
bool get _isVoiceCommands => widget.endpoint.contains('voice-commands');
|
||||
bool get _isShortcuts => widget.endpoint.contains('shortcuts');
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -131,28 +133,188 @@ class _GuardianEndpointScreenState extends State<_GuardianEndpointScreen> {
|
||||
itemBuilder: (_, index) => _EndpointCard(
|
||||
icon: widget.icon,
|
||||
item: _items[index],
|
||||
editable: _isVoiceCommands || _isShortcuts,
|
||||
onEdit: () => _editItem(_items[index]),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _editItem(Map<String, dynamic> item) async {
|
||||
if (_isVoiceCommands) {
|
||||
await _editVoiceCommand(item);
|
||||
} else if (_isShortcuts) {
|
||||
await _editShortcut(item);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _editVoiceCommand(Map<String, dynamic> item) async {
|
||||
final phrase = TextEditingController(
|
||||
text: item['triggerPhrase']?.toString() ?? '',
|
||||
);
|
||||
var enabled = item['enabled'] != false;
|
||||
final saved = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: Text(_labelFromKey(item['commandKey']?.toString() ?? '') ??
|
||||
'Voice Command'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: phrase,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Trigger phrase',
|
||||
prefixIcon: Icon(Icons.record_voice_over_outlined),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: enabled,
|
||||
onChanged: (value) => setDialogState(() => enabled = value),
|
||||
title: const Text('Enabled'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Save')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (saved != true) return;
|
||||
await _submitUpdate({
|
||||
'commandKey': item['commandKey'],
|
||||
'triggerPhrase': phrase.text.trim(),
|
||||
'enabled': enabled,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _editShortcut(Map<String, dynamic> item) async {
|
||||
final buttonName = TextEditingController(
|
||||
text: item['buttonName']?.toString() ?? '',
|
||||
);
|
||||
final buttonCode = TextEditingController(
|
||||
text: item['buttonCode']?.toString() ?? '',
|
||||
);
|
||||
var enabled = item['enabled'] != false;
|
||||
final saved = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (dialogContext) => StatefulBuilder(
|
||||
builder: (context, setDialogState) => AlertDialog(
|
||||
title: Text(_labelFromKey(item['shortcutKey']?.toString() ?? '') ??
|
||||
'Shortcut'),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: buttonName,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Button name',
|
||||
prefixIcon: Icon(Icons.touch_app_outlined),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextField(
|
||||
controller: buttonCode,
|
||||
keyboardType: TextInputType.number,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Android key code',
|
||||
prefixIcon: Icon(Icons.numbers_outlined),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
value: enabled,
|
||||
onChanged: (value) => setDialogState(() => enabled = value),
|
||||
title: const Text('Enabled'),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, false),
|
||||
child: const Text('Cancel')),
|
||||
FilledButton(
|
||||
onPressed: () => Navigator.pop(dialogContext, true),
|
||||
child: const Text('Save')),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
if (saved != true) return;
|
||||
await _submitUpdate({
|
||||
'shortcutKey': item['shortcutKey'],
|
||||
'buttonName': buttonName.text.trim(),
|
||||
'buttonCode': int.tryParse(buttonCode.text.trim()),
|
||||
'enabled': enabled,
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _submitUpdate(Map<String, dynamic> payload) async {
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
await sl<ApiClient>()
|
||||
.dio
|
||||
.put(widget.endpoint, data: payload)
|
||||
.timeout(const Duration(seconds: 8));
|
||||
await _load();
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Konfigurasi berhasil disimpan.')),
|
||||
);
|
||||
},
|
||||
onError: (message) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text(message)));
|
||||
},
|
||||
fallback: 'Konfigurasi belum bisa disimpan.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _EndpointCard extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Map<String, dynamic> item;
|
||||
final bool editable;
|
||||
final VoidCallback? onEdit;
|
||||
|
||||
const _EndpointCard({required this.icon, required this.item});
|
||||
const _EndpointCard({
|
||||
required this.icon,
|
||||
required this.item,
|
||||
this.editable = false,
|
||||
this.onEdit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final title = _firstText(item, ['name', 'command', 'label', 'type']) ??
|
||||
final title = _labelFromKey(
|
||||
_firstText(item, ['commandKey', 'shortcutKey', 'name', 'command']) ??
|
||||
'',
|
||||
) ??
|
||||
'Item #${item['id'] ?? '-'}';
|
||||
final subtitle = _firstText(
|
||||
item,
|
||||
['description', 'action', 'shortcut', 'status', 'createdAt'],
|
||||
[
|
||||
'triggerPhrase',
|
||||
'buttonName',
|
||||
'description',
|
||||
'action',
|
||||
'shortcut',
|
||||
'status',
|
||||
'createdAt'
|
||||
],
|
||||
) ??
|
||||
'Data aktif';
|
||||
final enabled = item['enabled'] != false;
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
@@ -181,15 +343,65 @@ class _EndpointCard extends StatelessWidget {
|
||||
const SizedBox(height: 3),
|
||||
Text(subtitle,
|
||||
style: const TextStyle(color: Color(0xFF64748B))),
|
||||
const SizedBox(height: 6),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
_SmallPill(
|
||||
label: enabled ? 'Enabled' : 'Disabled',
|
||||
color: enabled
|
||||
? const Color(0xFF16A34A)
|
||||
: const Color(0xFF94A3B8),
|
||||
),
|
||||
if (item['buttonCode'] != null)
|
||||
_SmallPill(
|
||||
label: 'Key ${item['buttonCode']}',
|
||||
color: const Color(0xFF2563EB),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (editable)
|
||||
IconButton(
|
||||
onPressed: onEdit,
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
tooltip: 'Edit',
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SmallPill extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
const _SmallPill({required this.label, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(
|
||||
color: color,
|
||||
fontSize: 11,
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
String? _firstText(Map<String, dynamic> item, List<String> keys) {
|
||||
for (final key in keys) {
|
||||
final value = item[key]?.toString().trim();
|
||||
@@ -197,3 +409,13 @@ String? _firstText(Map<String, dynamic> item, List<String> keys) {
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _labelFromKey(String value) {
|
||||
if (value.trim().isEmpty) return null;
|
||||
return value
|
||||
.split('_')
|
||||
.where((part) => part.isNotEmpty)
|
||||
.map((part) =>
|
||||
part[0].toUpperCase() + part.substring(1).toLowerCase())
|
||||
.join(' ');
|
||||
}
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../guardian_activity_log_screen.dart';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../guardian_ai_config_screen.dart';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../guardian_map_screen.dart';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../guardian_send_notification_screen.dart';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../guardian_settings_screen.dart';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../guardian_tools_screen.dart';
|
||||
+91
-33
@@ -13,6 +13,7 @@ import '../../../app/injection_container.dart';
|
||||
import '../../../core/network/api_client.dart';
|
||||
import '../../../core/services/websocket_service.dart';
|
||||
import '../../../core/storage/secure_storage.dart';
|
||||
import '../../../core/utils/operation_guard.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// GUARDIAN DASHBOARD SCREEN
|
||||
@@ -57,6 +58,7 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
||||
duration: const Duration(milliseconds: 600),
|
||||
);
|
||||
bool _sosAlert = false;
|
||||
List<Map<String, dynamic>> _pendingSos = const [];
|
||||
|
||||
// ── Refresh button animation ─────────────────────────────────────────────────
|
||||
late final AnimationController _refreshCtrl = AnimationController(
|
||||
@@ -89,7 +91,8 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
||||
_error = null;
|
||||
});
|
||||
}
|
||||
try {
|
||||
await guarded<void>(
|
||||
() async {
|
||||
_guardianName =
|
||||
await sl<SecureStorage>().getDisplayName() ?? 'Guardian';
|
||||
|
||||
@@ -103,7 +106,8 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
||||
final dashboard = results[0] as Map<String, dynamic>?;
|
||||
final activityList =
|
||||
results[1] as List<Map<String, dynamic>>;
|
||||
final sosPending = results[2] as int;
|
||||
final sosPendingEvents = results[2] as List<Map<String, dynamic>>;
|
||||
final sosPending = sosPendingEvents.length;
|
||||
|
||||
// Extract latest GPS from dashboard
|
||||
final lastLoc = dashboard?['lastLocation'] as Map<String, dynamic>?;
|
||||
@@ -150,6 +154,7 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
||||
recentActivity: activityList,
|
||||
isPaired: userStatus != null || dashboard != null,
|
||||
);
|
||||
_pendingSos = sosPendingEvents;
|
||||
if (newLatLng != null) {
|
||||
_liveLatLng = newLatLng;
|
||||
}
|
||||
@@ -163,32 +168,31 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
||||
|
||||
// Move map to latest location
|
||||
if (newLatLng != null) {
|
||||
try {
|
||||
_mapController.move(newLatLng, 15);
|
||||
} catch (_) {}
|
||||
_moveMapSafely(newLatLng);
|
||||
}
|
||||
} catch (e) {
|
||||
setState(() {
|
||||
},
|
||||
onError: (e) => setState(() {
|
||||
_loading = false;
|
||||
_error = _friendlyError(e);
|
||||
});
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>?> _fetchDashboard() async {
|
||||
try {
|
||||
return await guarded<Map<String, dynamic>?>(
|
||||
() async {
|
||||
final res = await _api
|
||||
.get('/guardian/dashboard')
|
||||
.timeout(const Duration(seconds: 8));
|
||||
final d = res.data['data'];
|
||||
return d is Map ? Map<String, dynamic>.from(d) : null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Future<List<Map<String, dynamic>>> _fetchActivity() async {
|
||||
try {
|
||||
return await guarded<List<Map<String, dynamic>>>(
|
||||
() async {
|
||||
final res = await _api
|
||||
.get('/guardian/activity-logs',
|
||||
queryParameters: {'size': 5, 'page': 0})
|
||||
@@ -202,12 +206,15 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
return const [];
|
||||
return const [];
|
||||
},
|
||||
) ??
|
||||
const [];
|
||||
}
|
||||
|
||||
Future<int> _fetchSosPending() async {
|
||||
try {
|
||||
Future<List<Map<String, dynamic>>> _fetchSosPending() async {
|
||||
return await guarded<List<Map<String, dynamic>>>(
|
||||
() async {
|
||||
final res = await _api
|
||||
.get('/guardian/sos-events',
|
||||
queryParameters: {'size': 10, 'page': 0})
|
||||
@@ -219,17 +226,21 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
||||
return content
|
||||
.whereType<Map>()
|
||||
.where((e) => e['status'] == 'TRIGGERED')
|
||||
.length;
|
||||
.map((e) => Map<String, dynamic>.from(e))
|
||||
.toList();
|
||||
}
|
||||
} catch (_) {}
|
||||
return 0;
|
||||
return const [];
|
||||
},
|
||||
) ??
|
||||
const [];
|
||||
}
|
||||
|
||||
// ── WebSocket subscription ──────────────────────────────────────────────────
|
||||
void _subscribeWebSocket() {
|
||||
final ws = sl<WebSocketService>();
|
||||
Future.microtask(() async {
|
||||
try {
|
||||
await guarded<void>(
|
||||
() async {
|
||||
final userId = await _getLinkedUserId();
|
||||
if (userId == null) return;
|
||||
ws.subscribeLocation(userId, (lat, lng) {
|
||||
@@ -239,26 +250,30 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
||||
_liveLatLng = newPos;
|
||||
_liveConnected = true;
|
||||
});
|
||||
try {
|
||||
_mapController.move(newPos, 15);
|
||||
} catch (_) {}
|
||||
_moveMapSafely(newPos);
|
||||
});
|
||||
ws.subscribeSos((sosData) {
|
||||
if (!mounted) return;
|
||||
_triggerSosFlash();
|
||||
setState(() {
|
||||
_pendingSos = [
|
||||
Map<String, dynamic>.from(sosData),
|
||||
..._pendingSos,
|
||||
];
|
||||
_data = _data?.copyWith(
|
||||
unreadSos: (_data?.unreadSos ?? 0) + 1);
|
||||
});
|
||||
_showSosSnackbar(sosData);
|
||||
});
|
||||
if (mounted) setState(() => _liveConnected = true);
|
||||
} catch (_) {}
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Future<String?> _getLinkedUserId() async {
|
||||
try {
|
||||
return await guarded<String?>(
|
||||
() async {
|
||||
final res = await _api
|
||||
.get('/shared/pairing/status')
|
||||
.timeout(const Duration(seconds: 5));
|
||||
@@ -267,8 +282,9 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
||||
return d['pairedWithId']?.toString() ??
|
||||
d['userId']?.toString();
|
||||
}
|
||||
} catch (_) {}
|
||||
return null;
|
||||
return null;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _triggerSosFlash() {
|
||||
@@ -303,14 +319,56 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
||||
),
|
||||
]),
|
||||
action: SnackBarAction(
|
||||
label: 'Lihat',
|
||||
label: 'Tangani',
|
||||
textColor: Colors.white,
|
||||
onPressed: () => context.go('/guardian/logs'),
|
||||
onPressed: _handleLatestSos,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _handleLatestSos() async {
|
||||
final sosId = _pendingSos
|
||||
.map((e) => e['id'])
|
||||
.where((id) => id != null)
|
||||
.map((id) => int.tryParse(id.toString()))
|
||||
.firstWhere((id) => id != null, orElse: () => null);
|
||||
if (sosId == null) {
|
||||
await _loadAll(silent: true);
|
||||
return;
|
||||
}
|
||||
await guarded<void>(
|
||||
() async {
|
||||
await _api
|
||||
.put('/guardian/sos/$sosId/resolve')
|
||||
.timeout(const Duration(seconds: 8));
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pendingSos =
|
||||
_pendingSos.where((e) => e['id']?.toString() != '$sosId').toList();
|
||||
_data = _data?.copyWith(unreadSos: _pendingSos.length);
|
||||
if (_pendingSos.isEmpty) {
|
||||
_sosAlert = false;
|
||||
_sosCtrl.stop();
|
||||
}
|
||||
});
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('SOS ditandai sudah ditangani.')),
|
||||
);
|
||||
},
|
||||
onError: (_) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text('Gagal menandai SOS. Coba refresh.')),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _moveMapSafely(LatLng position) {
|
||||
guarded<void>(() async => _mapController.move(position, 15));
|
||||
}
|
||||
|
||||
Future<void> _refresh() async {
|
||||
HapticFeedback.lightImpact();
|
||||
_refreshCtrl.forward(from: 0);
|
||||
@@ -517,10 +575,10 @@ class _GuardianDashboardScreenState extends State<GuardianDashboardScreen>
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/guardian/logs'),
|
||||
onPressed: _handleLatestSos,
|
||||
style: TextButton.styleFrom(
|
||||
foregroundColor: Colors.white),
|
||||
child: const Text('Tangani'),
|
||||
child: const Text('Handle'),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
|
||||
+24
-22
@@ -16,6 +16,7 @@ import 'package:latlong2/latlong.dart';
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import '../../core/utils/operation_guard.dart';
|
||||
|
||||
// ─── helpers ────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -70,7 +71,8 @@ class _NavState extends Cubit<int> {
|
||||
// ── locate ──────────────────────────────────────────────────────────────
|
||||
Future<bool> locate() async {
|
||||
_set(_NavPhase.locating, 'Mencari lokasi GPS…');
|
||||
try {
|
||||
final located = await guarded<bool>(
|
||||
() async {
|
||||
LocationPermission perm = await Geolocator.checkPermission();
|
||||
if (perm == LocationPermission.denied) {
|
||||
perm = await Geolocator.requestPermission();
|
||||
@@ -86,14 +88,12 @@ class _NavState extends Cubit<int> {
|
||||
_set(_NavPhase.idle, 'Lokasi ditemukan. Cari tujuan atau ketuk peta.');
|
||||
_reportToBackend(pos);
|
||||
return true;
|
||||
} on TimeoutException {
|
||||
_set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.');
|
||||
return false;
|
||||
} catch (e) {
|
||||
_set(_NavPhase.error,
|
||||
'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.');
|
||||
return false;
|
||||
}
|
||||
},
|
||||
onTimeout: () => _set(_NavPhase.error, 'GPS timeout. Pastikan GPS aktif.'),
|
||||
onError: (_) => _set(_NavPhase.error,
|
||||
'Lokasi belum bisa dibaca. Pastikan GPS dan izin lokasi aktif.'),
|
||||
);
|
||||
return located ?? false;
|
||||
}
|
||||
|
||||
void _reportToBackend(Position pos) {
|
||||
@@ -112,7 +112,8 @@ class _NavState extends Cubit<int> {
|
||||
// ── search Nominatim ─────────────────────────────────────────────────────
|
||||
Future<List<_Place>> searchPlaces(String query) async {
|
||||
if (query.trim().isEmpty) return const [];
|
||||
try {
|
||||
return await guarded<List<_Place>>(
|
||||
() async {
|
||||
final res = await Dio().get(
|
||||
'https://nominatim.openstreetmap.org/search',
|
||||
queryParameters: {
|
||||
@@ -137,9 +138,8 @@ class _NavState extends Cubit<int> {
|
||||
position: LatLng(lat, lng),
|
||||
);
|
||||
}).toList();
|
||||
} catch (_) {
|
||||
return const [];
|
||||
}
|
||||
},
|
||||
) ?? const [];
|
||||
}
|
||||
|
||||
String _viewbox(LatLng c) =>
|
||||
@@ -147,7 +147,8 @@ class _NavState extends Cubit<int> {
|
||||
|
||||
// ── reverse geocode ──────────────────────────────────────────────────────
|
||||
Future<String> reverseGeocode(LatLng pos) async {
|
||||
try {
|
||||
return await guarded<String>(
|
||||
() async {
|
||||
final res = await Dio().get(
|
||||
'https://nominatim.openstreetmap.org/reverse',
|
||||
queryParameters: {
|
||||
@@ -162,9 +163,9 @@ class _NavState extends Cubit<int> {
|
||||
);
|
||||
return res.data['display_name']?.toString() ??
|
||||
'${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}';
|
||||
} catch (_) {
|
||||
return '${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}';
|
||||
}
|
||||
},
|
||||
) ??
|
||||
'${pos.latitude.toStringAsFixed(5)}, ${pos.longitude.toStringAsFixed(5)}';
|
||||
}
|
||||
|
||||
// ── OSRM routing ─────────────────────────────────────────────────────────
|
||||
@@ -177,7 +178,8 @@ class _NavState extends Cubit<int> {
|
||||
_set(_NavPhase.routing, 'Menghitung rute ke ${_shortName(dest)}…');
|
||||
|
||||
final origin = currentPosition!;
|
||||
try {
|
||||
await guarded<void>(
|
||||
() async {
|
||||
final url = 'http://router.project-osrm.org/route/v1/foot/'
|
||||
'${origin.longitude},${origin.latitude};'
|
||||
'${dest.position.longitude},${dest.position.latitude}'
|
||||
@@ -219,10 +221,10 @@ class _NavState extends Cubit<int> {
|
||||
steps.isNotEmpty ? steps[0].instruction : 'Ikuti rute biru.');
|
||||
_notify();
|
||||
_startTracking();
|
||||
} catch (e) {
|
||||
_set(_NavPhase.error,
|
||||
'Gagal mendapat rute. Periksa koneksi lalu coba lagi.');
|
||||
}
|
||||
},
|
||||
onError: (_) => _set(_NavPhase.error,
|
||||
'Gagal mendapat rute. Periksa koneksi lalu coba lagi.'),
|
||||
);
|
||||
}
|
||||
|
||||
String _shortName(_Place p) {
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../navigation_mode_screen.dart';
|
||||
+91
@@ -0,0 +1,91 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../domain/entities/guardian_notification.dart';
|
||||
import '../domain/repositories/notification_repository.dart';
|
||||
|
||||
class NotificationState {
|
||||
final bool loading;
|
||||
final List<GuardianNotificationEntity> items;
|
||||
final String? error;
|
||||
final bool markingAll;
|
||||
|
||||
const NotificationState({
|
||||
this.loading = false,
|
||||
this.items = const [],
|
||||
this.error,
|
||||
this.markingAll = false,
|
||||
});
|
||||
|
||||
NotificationState copyWith({
|
||||
bool? loading,
|
||||
List<GuardianNotificationEntity>? items,
|
||||
String? error,
|
||||
bool? markingAll,
|
||||
}) {
|
||||
return NotificationState(
|
||||
loading: loading ?? this.loading,
|
||||
items: items ?? this.items,
|
||||
error: error,
|
||||
markingAll: markingAll ?? this.markingAll,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class NotificationCubit extends Cubit<NotificationState> {
|
||||
final NotificationRepository _repository;
|
||||
|
||||
NotificationCubit(this._repository) : super(const NotificationState());
|
||||
|
||||
Future<void> load() async {
|
||||
emit(const NotificationState(loading: true));
|
||||
final result = await _repository.getNotifications();
|
||||
result.fold(
|
||||
(failure) => emit(NotificationState(error: failure.message)),
|
||||
(items) => emit(NotificationState(items: items)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markOneRead(int id) async {
|
||||
final result = await _repository.markOneRead(id);
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(error: failure.message)),
|
||||
(_) => emit(state.copyWith(
|
||||
items: state.items
|
||||
.map((item) => item.id == id
|
||||
? GuardianNotificationEntity(
|
||||
id: item.id,
|
||||
notificationType: item.notificationType,
|
||||
content: item.content,
|
||||
voiceNoteUrl: item.voiceNoteUrl,
|
||||
voiceNoteDuration: item.voiceNoteDuration,
|
||||
isRead: true,
|
||||
createdAt: item.createdAt,
|
||||
)
|
||||
: item)
|
||||
.toList(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> markAllRead() async {
|
||||
emit(state.copyWith(markingAll: true));
|
||||
final result = await _repository.markAllRead();
|
||||
result.fold(
|
||||
(failure) => emit(state.copyWith(error: failure.message, markingAll: false)),
|
||||
(_) => emit(state.copyWith(
|
||||
markingAll: false,
|
||||
items: state.items
|
||||
.map((item) => GuardianNotificationEntity(
|
||||
id: item.id,
|
||||
notificationType: item.notificationType,
|
||||
content: item.content,
|
||||
voiceNoteUrl: item.voiceNoteUrl,
|
||||
voiceNoteDuration: item.voiceNoteDuration,
|
||||
isRead: true,
|
||||
createdAt: item.createdAt,
|
||||
))
|
||||
.toList(),
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
+90
@@ -0,0 +1,90 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/storage/local_database.dart';
|
||||
import '../../domain/entities/guardian_notification.dart';
|
||||
import '../../domain/repositories/notification_repository.dart';
|
||||
|
||||
class NotificationRepositoryImpl implements NotificationRepository {
|
||||
final ApiClient _apiClient;
|
||||
final LocalDatabase _database;
|
||||
|
||||
const NotificationRepositoryImpl(this._apiClient, this._database);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, List<GuardianNotificationEntity>>> getNotifications() async {
|
||||
try {
|
||||
final response = await _apiClient.dio.get('/user/notifications');
|
||||
final body = response.data;
|
||||
final data = body is Map ? body['data'] : body;
|
||||
final rawList = data is Map ? data['content'] : data;
|
||||
final list = rawList is List
|
||||
? rawList
|
||||
.whereType<Map>()
|
||||
.map((item) => _fromJson(Map<String, dynamic>.from(item)))
|
||||
.toList()
|
||||
: <GuardianNotificationEntity>[];
|
||||
await _database.notifications.replaceAll(
|
||||
list
|
||||
.map((item) => CachedNotification(
|
||||
id: item.id,
|
||||
notificationType: item.notificationType,
|
||||
content: item.content,
|
||||
voiceNoteUrl: item.voiceNoteUrl,
|
||||
isRead: item.isRead,
|
||||
createdAt: item.createdAt ?? DateTime.now(),
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
return Right(list);
|
||||
} catch (_) {
|
||||
final cached = await _database.notifications.getAll();
|
||||
return Right(
|
||||
cached
|
||||
.map((item) => GuardianNotificationEntity(
|
||||
id: item.id,
|
||||
notificationType: item.notificationType,
|
||||
content: item.content,
|
||||
voiceNoteUrl: item.voiceNoteUrl,
|
||||
isRead: item.isRead,
|
||||
createdAt: item.createdAt,
|
||||
))
|
||||
.toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> markAllRead() async {
|
||||
try {
|
||||
await _apiClient.dio.put('/user/notifications/mark-all-read');
|
||||
return const Right(null);
|
||||
} catch (_) {
|
||||
return const Left(NetworkFailure('Gagal menandai semua notifikasi.'));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> markOneRead(int id) async {
|
||||
try {
|
||||
await _apiClient.dio.put('/user/notifications/$id/read');
|
||||
return const Right(null);
|
||||
} catch (_) {
|
||||
return const Left(NetworkFailure('Gagal menandai notifikasi.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
GuardianNotificationEntity _fromJson(Map<String, dynamic> json) {
|
||||
return GuardianNotificationEntity(
|
||||
id: (json['id'] as num?)?.toInt(),
|
||||
notificationType:
|
||||
json['notifType']?.toString() ?? json['notificationType']?.toString() ?? 'TEXT',
|
||||
content: json['content']?.toString(),
|
||||
voiceNoteUrl: json['voiceNoteUrl']?.toString(),
|
||||
voiceNoteDuration: (json['voiceNoteDuration'] as num?)?.toInt(),
|
||||
isRead: json['isRead'] == true,
|
||||
createdAt: DateTime.tryParse(json['createdAt']?.toString() ?? ''),
|
||||
);
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
class GuardianNotificationEntity {
|
||||
final int? id;
|
||||
final String notificationType;
|
||||
final String? content;
|
||||
final String? voiceNoteUrl;
|
||||
final int? voiceNoteDuration;
|
||||
final bool isRead;
|
||||
final DateTime? createdAt;
|
||||
|
||||
const GuardianNotificationEntity({
|
||||
this.id,
|
||||
required this.notificationType,
|
||||
this.content,
|
||||
this.voiceNoteUrl,
|
||||
this.voiceNoteDuration,
|
||||
this.isRead = false,
|
||||
this.createdAt,
|
||||
});
|
||||
}
|
||||
+10
@@ -0,0 +1,10 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../entities/guardian_notification.dart';
|
||||
|
||||
abstract class NotificationRepository {
|
||||
Future<Either<Failure, List<GuardianNotificationEntity>>> getNotifications();
|
||||
Future<Either<Failure, void>> markAllRead();
|
||||
Future<Either<Failure, void>> markOneRead(int id);
|
||||
}
|
||||
+103
-86
@@ -2,16 +2,19 @@
|
||||
// ignore_for_file: use_build_context_synchronously
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:just_audio/just_audio.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import '../../core/theme/app_colors.dart';
|
||||
|
||||
Dio get _api => sl<ApiClient>().dio;
|
||||
import 'application/notification_cubit.dart';
|
||||
import 'domain/entities/guardian_notification.dart';
|
||||
|
||||
class NotificationScreen extends StatefulWidget {
|
||||
const NotificationScreen({super.key});
|
||||
@@ -21,58 +24,31 @@ class NotificationScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _NotificationScreenState extends State<NotificationScreen> {
|
||||
List<_NotifItem> _items = [];
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
bool _markingAll = false;
|
||||
late final NotificationCubit _notificationCubit;
|
||||
final AudioPlayer _audioPlayer = AudioPlayer();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_notificationCubit = sl<NotificationCubit>();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final res = await _api
|
||||
.get('/user/notifications')
|
||||
.timeout(const Duration(seconds: 10));
|
||||
final list = _extractList(res.data);
|
||||
setState(() {
|
||||
_items = list.map((e) => _NotifItem.fromJson(e)).toList();
|
||||
});
|
||||
},
|
||||
onError: (message) => setState(() => _error = message),
|
||||
fallback: 'Gagal memuat notifikasi. Coba refresh lagi.',
|
||||
);
|
||||
if (mounted) setState(() => _loading = false);
|
||||
@override
|
||||
void dispose() {
|
||||
_audioPlayer.dispose();
|
||||
_notificationCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
List<Map<String, dynamic>> _extractList(dynamic responseBody) {
|
||||
final data = responseBody is Map ? responseBody['data'] : null;
|
||||
final rawList = data is Map ? data['content'] : data;
|
||||
if (rawList is! List) return const [];
|
||||
return rawList
|
||||
.whereType<Map>()
|
||||
.map((item) => Map<String, dynamic>.from(item))
|
||||
.toList();
|
||||
Future<void> _load() async {
|
||||
await _notificationCubit.load();
|
||||
}
|
||||
|
||||
Future<void> _markRead(int id) async {
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
await _api
|
||||
.put('/user/notifications/$id/read')
|
||||
.timeout(const Duration(seconds: 6));
|
||||
setState(() {
|
||||
final idx = _items.indexWhere((n) => n.id == id);
|
||||
if (idx != -1) _items[idx] = _items[idx].copyWith(isRead: true);
|
||||
});
|
||||
await _notificationCubit.markOneRead(id);
|
||||
},
|
||||
onError: (_) {},
|
||||
fallback: 'Gagal menandai notifikasi.',
|
||||
@@ -80,45 +56,80 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
}
|
||||
|
||||
Future<void> _markAllRead() async {
|
||||
setState(() => _markingAll = true);
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
await _api
|
||||
.put('/user/notifications/mark-all-read')
|
||||
.timeout(const Duration(seconds: 8));
|
||||
setState(() {
|
||||
_items = _items.map((n) => n.copyWith(isRead: true)).toList();
|
||||
});
|
||||
await _notificationCubit.markAllRead();
|
||||
_snack('Semua notifikasi ditandai sudah dibaca.');
|
||||
},
|
||||
onError: _snack,
|
||||
fallback: 'Gagal menandai semua dibaca.',
|
||||
);
|
||||
if (mounted) setState(() => _markingAll = false);
|
||||
}
|
||||
|
||||
Future<void> _readAloud(_NotifItem notif) async {
|
||||
final tts = sl<TtsService>();
|
||||
tts.speak(notif.content ?? 'Voice note dari Guardian.');
|
||||
if (notif.type == 'VOICE_NOTE') {
|
||||
final source = notif.voiceNoteUrl == 'inline-audio'
|
||||
? notif.content
|
||||
: notif.voiceNoteUrl;
|
||||
if (source == null || source.isEmpty) {
|
||||
_snack('Voice note kosong atau belum tersedia.');
|
||||
return;
|
||||
}
|
||||
await _playVoiceNote(source);
|
||||
} else {
|
||||
final tts = sl<TtsService>();
|
||||
tts.speak(notif.content ?? 'Pesan dari Guardian.');
|
||||
}
|
||||
await _markRead(notif.id);
|
||||
}
|
||||
|
||||
Future<void> _playVoiceNote(String source) async {
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
String? localPath;
|
||||
if (source.startsWith('data:audio')) {
|
||||
final comma = source.indexOf(',');
|
||||
if (comma == -1) {
|
||||
throw const FormatException('Invalid inline audio payload');
|
||||
}
|
||||
final bytes = base64Decode(source.substring(comma + 1));
|
||||
final dir = await getTemporaryDirectory();
|
||||
final file = File(
|
||||
'${dir.path}/walkguide_voice_${DateTime.now().millisecondsSinceEpoch}.m4a');
|
||||
await file.writeAsBytes(bytes, flush: true);
|
||||
localPath = file.path;
|
||||
}
|
||||
if (localPath != null) {
|
||||
await _audioPlayer.setFilePath(localPath);
|
||||
} else {
|
||||
await _audioPlayer.setUrl(source);
|
||||
}
|
||||
await _audioPlayer.play();
|
||||
},
|
||||
onError: _snack,
|
||||
fallback: 'Voice note belum bisa diputar.',
|
||||
);
|
||||
}
|
||||
|
||||
void _snack(String msg) {
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(msg)));
|
||||
}
|
||||
}
|
||||
|
||||
int get _unreadCount => _items.where((n) => !n.isRead).length;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
return BlocBuilder<NotificationCubit, NotificationState>(
|
||||
bloc: _notificationCubit,
|
||||
builder: (context, state) {
|
||||
final items = state.items.map(_NotifItem.fromEntity).toList();
|
||||
final unreadCount = items.where((n) => !n.isRead).length;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
@@ -135,9 +146,9 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
.headlineSmall
|
||||
?.copyWith(fontWeight: FontWeight.w800),
|
||||
),
|
||||
if (_unreadCount > 0) ...[
|
||||
if (unreadCount > 0) ...[
|
||||
const SizedBox(width: 8),
|
||||
_UnreadBadge(count: _unreadCount),
|
||||
_UnreadBadge(count: unreadCount),
|
||||
],
|
||||
],
|
||||
),
|
||||
@@ -148,10 +159,10 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_unreadCount > 0)
|
||||
if (unreadCount > 0)
|
||||
TextButton.icon(
|
||||
onPressed: _markingAll ? null : _markAllRead,
|
||||
icon: _markingAll
|
||||
onPressed: state.markingAll ? null : _markAllRead,
|
||||
icon: state.markingAll
|
||||
? const SizedBox(
|
||||
width: 14,
|
||||
height: 14,
|
||||
@@ -170,29 +181,31 @@ class _NotificationScreenState extends State<NotificationScreen> {
|
||||
|
||||
// Body
|
||||
Expanded(
|
||||
child: _loading
|
||||
child: state.loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _error != null
|
||||
? _ErrorPanel(message: _error!, onRetry: _load)
|
||||
: _items.isEmpty
|
||||
: state.error != null
|
||||
? _ErrorPanel(message: state.error!, onRetry: _load)
|
||||
: items.isEmpty
|
||||
? const _EmptyPanel()
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.separated(
|
||||
itemCount: _items.length,
|
||||
itemCount: items.length,
|
||||
separatorBuilder: (_, __) =>
|
||||
const SizedBox(height: 10),
|
||||
itemBuilder: (ctx, i) => _NotifCard(
|
||||
notif: _items[i],
|
||||
onMarkRead: () => _markRead(_items[i].id),
|
||||
onReadAloud: () => _readAloud(_items[i]),
|
||||
notif: items[i],
|
||||
onMarkRead: () => _markRead(items[i].id),
|
||||
onReadAloud: () => _readAloud(items[i]),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -216,14 +229,14 @@ class _NotifItem {
|
||||
required this.createdAt,
|
||||
});
|
||||
|
||||
factory _NotifItem.fromJson(Map<String, dynamic> j) => _NotifItem(
|
||||
id: j['id'] as int,
|
||||
type: j['notifType']?.toString() ?? 'TEXT',
|
||||
content: j['content']?.toString(),
|
||||
voiceNoteUrl: j['voiceNoteUrl']?.toString(),
|
||||
isRead: j['isRead'] == true,
|
||||
createdAt: DateTime.tryParse(j['createdAt']?.toString() ?? '') ??
|
||||
DateTime.now(),
|
||||
factory _NotifItem.fromEntity(GuardianNotificationEntity entity) =>
|
||||
_NotifItem(
|
||||
id: entity.id ?? 0,
|
||||
type: entity.notificationType,
|
||||
content: entity.content,
|
||||
voiceNoteUrl: entity.voiceNoteUrl,
|
||||
isRead: entity.isRead,
|
||||
createdAt: entity.createdAt ?? DateTime.now(),
|
||||
);
|
||||
|
||||
_NotifItem copyWith({bool? isRead}) => _NotifItem(
|
||||
@@ -329,7 +342,9 @@ class _NotifCard extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
),
|
||||
if (notif.content != null && notif.content!.isNotEmpty) ...[
|
||||
if (!isVoice &&
|
||||
notif.content != null &&
|
||||
notif.content!.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
notif.content!,
|
||||
@@ -342,8 +357,10 @@ class _NotifCard extends StatelessWidget {
|
||||
// Read aloud button
|
||||
OutlinedButton.icon(
|
||||
onPressed: onReadAloud,
|
||||
icon: const Icon(Icons.volume_up, size: 16),
|
||||
label: const Text('Bacakan', style: TextStyle(fontSize: 13)),
|
||||
icon: Icon(isVoice ? Icons.play_arrow : Icons.volume_up,
|
||||
size: 16),
|
||||
label: Text(isVoice ? 'Putar' : 'Bacakan',
|
||||
style: const TextStyle(fontSize: 13)),
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../notification_screen.dart';
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../pairing_screens.dart';
|
||||
+2
-2
@@ -27,7 +27,7 @@ class ServerConnectScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
||||
final _url = TextEditingController(text: 'http://202.46.28.160:8080');
|
||||
final _url = TextEditingController();
|
||||
bool _loading = false;
|
||||
bool _ok = false;
|
||||
String? _message;
|
||||
@@ -76,7 +76,7 @@ class _ServerConnectScreenState extends State<ServerConnectScreen> {
|
||||
keyboardType: TextInputType.url,
|
||||
decoration: const InputDecoration(
|
||||
labelText: 'Server URL',
|
||||
hintText: 'http://202.46.28.160:8080',
|
||||
hintText: 'http://server-ip:8080',
|
||||
prefixIcon: Icon(Icons.dns_outlined),
|
||||
)),
|
||||
const SizedBox(height: 12),
|
||||
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../user_settings_screen.dart';
|
||||
@@ -0,0 +1,38 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../domain/repositories/sos_repository.dart';
|
||||
|
||||
enum SosPhase { idle, sending, sent, error }
|
||||
|
||||
class SosState {
|
||||
final SosPhase phase;
|
||||
final String? message;
|
||||
|
||||
const SosState({this.phase = SosPhase.idle, this.message});
|
||||
}
|
||||
|
||||
class SosCubit extends Cubit<SosState> {
|
||||
final SosRepository _repository;
|
||||
|
||||
SosCubit(this._repository) : super(const SosState());
|
||||
|
||||
Future<void> trigger({
|
||||
String triggerType = 'MANUAL',
|
||||
double? lat,
|
||||
double? lng,
|
||||
}) async {
|
||||
emit(const SosState(phase: SosPhase.sending));
|
||||
final result = await _repository.triggerSos(
|
||||
triggerType: triggerType,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
);
|
||||
result.fold(
|
||||
(failure) => emit(SosState(phase: SosPhase.error, message: failure.message)),
|
||||
(_) => emit(const SosState(
|
||||
phase: SosPhase.sent,
|
||||
message: 'SOS terkirim. Guardian sudah diberi tahu.',
|
||||
)),
|
||||
);
|
||||
}
|
||||
}
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../domain/repositories/sos_repository.dart';
|
||||
|
||||
class SosRepositoryImpl implements SosRepository {
|
||||
final ApiClient _apiClient;
|
||||
|
||||
const SosRepositoryImpl(this._apiClient);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> triggerSos({
|
||||
required String triggerType,
|
||||
double? lat,
|
||||
double? lng,
|
||||
}) async {
|
||||
try {
|
||||
await _apiClient.dio.post('/user/sos', data: {
|
||||
'triggerType': triggerType,
|
||||
'lat': lat,
|
||||
'lng': lng,
|
||||
});
|
||||
return const Right(null);
|
||||
} catch (_) {
|
||||
return const Left(NetworkFailure('SOS belum terkirim. Coba lagi.'));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
class SosEvent {
|
||||
final int? id;
|
||||
final String triggerType;
|
||||
final double? lat;
|
||||
final double? lng;
|
||||
final String status;
|
||||
final DateTime? createdAt;
|
||||
|
||||
const SosEvent({
|
||||
this.id,
|
||||
required this.triggerType,
|
||||
this.lat,
|
||||
this.lng,
|
||||
this.status = 'TRIGGERED',
|
||||
this.createdAt,
|
||||
});
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
import '../../../../core/errors/failures.dart';
|
||||
|
||||
abstract class SosRepository {
|
||||
Future<Either<Failure, void>> triggerSos({
|
||||
required String triggerType,
|
||||
double? lat,
|
||||
double? lng,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export '../../sos_screen.dart';
|
||||
@@ -5,6 +5,7 @@ import 'dart:async';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
import '../../app/injection_container.dart';
|
||||
@@ -12,6 +13,7 @@ import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/services/haptic_service.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import 'application/sos_cubit.dart';
|
||||
|
||||
Dio get _api => sl<ApiClient>().dio;
|
||||
|
||||
@@ -61,7 +63,7 @@ class SosScreen extends StatefulWidget {
|
||||
class _SosScreenState extends State<SosScreen>
|
||||
with SingleTickerProviderStateMixin {
|
||||
// State
|
||||
bool _sending = false;
|
||||
late final SosCubit _sosCubit;
|
||||
bool _historyLoading = true;
|
||||
List<_SosEvent> _events = const [];
|
||||
String? _historyError;
|
||||
@@ -76,6 +78,7 @@ class _SosScreenState extends State<SosScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_sosCubit = sl<SosCubit>();
|
||||
_pulseCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 900),
|
||||
@@ -89,6 +92,7 @@ class _SosScreenState extends State<SosScreen>
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseCtrl.dispose();
|
||||
_sosCubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -134,7 +138,7 @@ class _SosScreenState extends State<SosScreen>
|
||||
}
|
||||
|
||||
Future<void> _confirmAndSend() async {
|
||||
if (_sending) return;
|
||||
if (_sosCubit.state.phase == SosPhase.sending) return;
|
||||
|
||||
// Confirmation dialog — prevents accidental tap
|
||||
final confirm = await showDialog<bool>(
|
||||
@@ -178,15 +182,17 @@ class _SosScreenState extends State<SosScreen>
|
||||
}
|
||||
|
||||
Future<void> _sendSos() async {
|
||||
setState(() => _sending = true);
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final pos = await _getPosition();
|
||||
await _api.post('/user/sos', data: {
|
||||
'triggerType': 'BUTTON',
|
||||
'lat': pos?.latitude,
|
||||
'lng': pos?.longitude,
|
||||
});
|
||||
await _sosCubit.trigger(
|
||||
triggerType: 'BUTTON',
|
||||
lat: pos?.latitude,
|
||||
lng: pos?.longitude,
|
||||
);
|
||||
if (_sosCubit.state.phase == SosPhase.error) {
|
||||
throw StateError(_sosCubit.state.message ?? 'Gagal kirim SOS.');
|
||||
}
|
||||
await sl<HapticService>().sosTriggered();
|
||||
sl<TtsService>().speak('SOS terkirim ke Guardian.');
|
||||
_snack('SOS berhasil dikirim! Guardian sudah diberitahu.');
|
||||
@@ -195,19 +201,22 @@ class _SosScreenState extends State<SosScreen>
|
||||
onError: _snack,
|
||||
fallback: 'Gagal kirim SOS. Coba lagi sebentar.',
|
||||
);
|
||||
if (mounted) setState(() => _sending = false);
|
||||
}
|
||||
|
||||
// ── Build ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
return BlocBuilder<SosCubit, SosState>(
|
||||
bloc: _sosCubit,
|
||||
builder: (context, sosState) {
|
||||
final sending = sosState.phase == SosPhase.sending;
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
@@ -247,7 +256,7 @@ class _SosScreenState extends State<SosScreen>
|
||||
|
||||
// SOS Button
|
||||
Center(
|
||||
child: _sending
|
||||
child: sending
|
||||
? const _SendingIndicator()
|
||||
: AnimatedBuilder(
|
||||
animation: _pulseAnim,
|
||||
@@ -288,15 +297,17 @@ class _SosScreenState extends State<SosScreen>
|
||||
const SizedBox(height: 10),
|
||||
|
||||
Expanded(
|
||||
child: _SosHistory(
|
||||
loading: _historyLoading,
|
||||
error: _historyError,
|
||||
events: _events,
|
||||
onRefresh: _loadHistory,
|
||||
)),
|
||||
],
|
||||
child: _SosHistory(
|
||||
loading: _historyLoading,
|
||||
error: _historyError,
|
||||
events: _events,
|
||||
onRefresh: _loadHistory,
|
||||
)),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
|
||||
import '../../../core/ai/obstacle_analyzer.dart';
|
||||
import '../domain/repositories/walk_guide_repository.dart';
|
||||
|
||||
class WalkGuideState {
|
||||
final bool active;
|
||||
final DetectionResult? latestDetection;
|
||||
final String status;
|
||||
|
||||
const WalkGuideState({
|
||||
this.active = false,
|
||||
this.latestDetection,
|
||||
this.status = 'Ready',
|
||||
});
|
||||
|
||||
WalkGuideState copyWith({
|
||||
bool? active,
|
||||
DetectionResult? latestDetection,
|
||||
String? status,
|
||||
}) {
|
||||
return WalkGuideState(
|
||||
active: active ?? this.active,
|
||||
latestDetection: latestDetection ?? this.latestDetection,
|
||||
status: status ?? this.status,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class WalkGuideCubit extends Cubit<WalkGuideState> {
|
||||
final WalkGuideRepository _repository;
|
||||
|
||||
WalkGuideCubit(this._repository) : super(const WalkGuideState());
|
||||
|
||||
Future<void> start() async {
|
||||
emit(state.copyWith(active: true, status: 'WalkGuide active'));
|
||||
await _repository.startSession();
|
||||
}
|
||||
|
||||
Future<void> stop() async {
|
||||
emit(state.copyWith(active: false, status: 'WalkGuide stopped'));
|
||||
await _repository.stopSession();
|
||||
}
|
||||
|
||||
void updateStatus(String status) {
|
||||
emit(state.copyWith(status: status));
|
||||
}
|
||||
|
||||
void clearDetection({String status = 'Ready'}) {
|
||||
emit(WalkGuideState(active: state.active, status: status));
|
||||
}
|
||||
|
||||
Future<void> recordObstacle(DetectionResult detection) async {
|
||||
emit(state.copyWith(latestDetection: detection, status: detection.spokenId));
|
||||
await _repository.logObstacle({
|
||||
'label': detection.label,
|
||||
'confidence': detection.confidence,
|
||||
'direction': detection.directionName,
|
||||
'estimatedDist': detection.estimatedDistance,
|
||||
'lat': null,
|
||||
'lng': null,
|
||||
});
|
||||
}
|
||||
}
|
||||
+46
@@ -0,0 +1,46 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
import '../../../../core/errors/failures.dart';
|
||||
import '../../../../core/network/api_client.dart';
|
||||
import '../../../../core/services/offline_queue_service.dart';
|
||||
import '../../domain/repositories/walk_guide_repository.dart';
|
||||
|
||||
class WalkGuideRepositoryImpl implements WalkGuideRepository {
|
||||
final ApiClient _apiClient;
|
||||
final OfflineQueueService _offlineQueue;
|
||||
|
||||
const WalkGuideRepositoryImpl(this._apiClient, this._offlineQueue);
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> startSession() {
|
||||
return _post('/user/walkguide/start', const {});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> stopSession() {
|
||||
return _post('/user/walkguide/stop', const {});
|
||||
}
|
||||
|
||||
@override
|
||||
Future<Either<Failure, void>> logObstacle(Map<String, dynamic> payload) {
|
||||
return _post('/user/obstacle', payload);
|
||||
}
|
||||
|
||||
Future<Either<Failure, void>> _post(
|
||||
String path,
|
||||
Map<String, dynamic> payload,
|
||||
) async {
|
||||
try {
|
||||
await _apiClient.dio.post(path, data: payload);
|
||||
return const Right(null);
|
||||
} catch (_) {
|
||||
await _offlineQueue.enqueue(OfflineRequest(
|
||||
method: 'POST',
|
||||
path: path,
|
||||
body: payload,
|
||||
createdAt: DateTime.now(),
|
||||
));
|
||||
return const Right(null);
|
||||
}
|
||||
}
|
||||
}
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
class WalkSession {
|
||||
final String sessionId;
|
||||
final DateTime startedAt;
|
||||
final DateTime? stoppedAt;
|
||||
final int obstacleCount;
|
||||
|
||||
const WalkSession({
|
||||
required this.sessionId,
|
||||
required this.startedAt,
|
||||
this.stoppedAt,
|
||||
this.obstacleCount = 0,
|
||||
});
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import 'package:dartz/dartz.dart';
|
||||
|
||||
import '../../../../core/errors/failures.dart';
|
||||
|
||||
abstract class WalkGuideRepository {
|
||||
Future<Either<Failure, void>> startSession();
|
||||
Future<Either<Failure, void>> stopSession();
|
||||
Future<Either<Failure, void>> logObstacle(Map<String, dynamic> payload);
|
||||
}
|
||||
+1
@@ -0,0 +1 @@
|
||||
export '../../walk_guide_screen.dart';
|
||||
@@ -5,14 +5,16 @@ import 'dart:async';
|
||||
import 'package:camera/camera.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../app/injection_container.dart';
|
||||
import '../../core/ai/detection_export.dart';
|
||||
import '../../core/network/api_client.dart';
|
||||
import '../../core/services/haptic_service.dart';
|
||||
import '../../core/ai/obstacle_alert_strategy.dart';
|
||||
import '../../core/errors/friendly_error.dart';
|
||||
import '../../core/services/location_reporter_service.dart';
|
||||
import '../../core/services/tts_service.dart';
|
||||
import 'application/walk_guide_cubit.dart';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// WalkGuideScreen
|
||||
@@ -26,15 +28,19 @@ class WalkGuideScreen extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
bool _active = false;
|
||||
String _status = 'Ready';
|
||||
late final WalkGuideCubit _cubit;
|
||||
CameraController? _camera;
|
||||
DetectionResult? _lastDetection;
|
||||
bool _processingFrame = false;
|
||||
DateTime _lastInferenceAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastAlertAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
DateTime _lastModelWarningAt = DateTime.fromMillisecondsSinceEpoch(0);
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_cubit = sl<WalkGuideCubit>();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
final camera = _camera;
|
||||
@@ -43,25 +49,23 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
}
|
||||
_camera?.dispose();
|
||||
sl<LocationReporterService>().stop();
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _toggle() async {
|
||||
final next = !_active;
|
||||
final next = !_cubit.state.active;
|
||||
if (next) {
|
||||
await _startCamera();
|
||||
await sl<LocationReporterService>().start(walkGuideActive: true);
|
||||
await _cubit.start();
|
||||
_cubit.updateStatus(_activeStatusText());
|
||||
} else {
|
||||
await _stopCamera();
|
||||
await sl<LocationReporterService>().stop();
|
||||
await _cubit.stop();
|
||||
_cubit.clearDetection(status: 'Stopped');
|
||||
}
|
||||
setState(() {
|
||||
_active = next;
|
||||
_status = next ? _activeStatusText() : 'Stopped';
|
||||
});
|
||||
await sl<ApiClient>()
|
||||
.dio
|
||||
.post(next ? '/user/walkguide/start' : '/user/walkguide/stop');
|
||||
sl<TtsService>().speak(next ? 'WalkGuide dimulai' : 'WalkGuide dihentikan');
|
||||
}
|
||||
|
||||
@@ -80,7 +84,8 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
|
||||
Future<void> _startCamera() async {
|
||||
if (_camera != null) return;
|
||||
try {
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
final cameras = await availableCameras();
|
||||
if (cameras.isEmpty) return;
|
||||
final backCamera = cameras.firstWhere(
|
||||
@@ -98,33 +103,40 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
await controller.dispose();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await controller.startImageStream(_onCameraImage);
|
||||
} catch (_) {
|
||||
setState(() => _status = kIsWeb
|
||||
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
||||
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
||||
}
|
||||
await runFriendlyAction(
|
||||
() => controller.startImageStream(_onCameraImage),
|
||||
onError: (_) {
|
||||
_cubit.updateStatus(kIsWeb
|
||||
? 'Camera preview aktif, tapi image stream YOLO tidak tersedia di Chrome/web.'
|
||||
: 'Camera preview aktif, tapi image stream belum tersedia.');
|
||||
},
|
||||
fallback: 'Camera preview aktif, tapi image stream belum tersedia.',
|
||||
);
|
||||
setState(() => _camera = controller);
|
||||
} catch (_) {
|
||||
setState(() => _status = 'Camera unavailable.');
|
||||
}
|
||||
},
|
||||
onError: (_) => _cubit.updateStatus('Camera unavailable.'),
|
||||
fallback: 'Camera unavailable.',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _stopCamera() async {
|
||||
final camera = _camera;
|
||||
_camera = null;
|
||||
if (camera == null) return;
|
||||
try {
|
||||
if (camera.value.isStreamingImages) {
|
||||
await camera.stopImageStream();
|
||||
}
|
||||
} catch (_) {}
|
||||
await runFriendlyAction(
|
||||
() async {
|
||||
if (camera.value.isStreamingImages) {
|
||||
await camera.stopImageStream();
|
||||
}
|
||||
},
|
||||
onError: (_) {},
|
||||
fallback: 'Camera stream already stopped.',
|
||||
);
|
||||
await camera.dispose();
|
||||
}
|
||||
|
||||
void _onCameraImage(CameraImage image) {
|
||||
if (!_active || _processingFrame) return;
|
||||
if (!_cubit.state.active || _processingFrame) return;
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_lastInferenceAt) < const Duration(milliseconds: 900)) {
|
||||
return;
|
||||
@@ -141,7 +153,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
final now = DateTime.now();
|
||||
if (now.difference(_lastModelWarningAt) > const Duration(seconds: 3)) {
|
||||
_lastModelWarningAt = now;
|
||||
setState(() => _status = detector.isReady
|
||||
_cubit.updateStatus(detector.isReady
|
||||
? 'Scanning... ${detector.diagnosticsSummary}'
|
||||
: 'YOLO model belum siap. Pastikan file model tersedia di assets/models.');
|
||||
}
|
||||
@@ -154,8 +166,7 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
DetectionResult detection, {
|
||||
bool forceAlert = false,
|
||||
}) async {
|
||||
_lastDetection = detection;
|
||||
setState(() => _status =
|
||||
_cubit.updateStatus(
|
||||
'Obstacle: ${ObstacleAnalyzer.spokenLabel(detection.label)} ${detection.directionName} ${detection.estimatedDistance}');
|
||||
|
||||
final now = DateTime.now();
|
||||
@@ -165,94 +176,94 @@ class _WalkGuideScreenState extends State<WalkGuideScreen> {
|
||||
}
|
||||
_lastAlertAt = now;
|
||||
|
||||
try {
|
||||
await sl<ApiClient>().dio.post('/user/obstacle', data: {
|
||||
'label': detection.label,
|
||||
'confidence': detection.confidence,
|
||||
'direction': detection.directionName,
|
||||
'estimatedDist': detection.estimatedDistance,
|
||||
'lat': null,
|
||||
'lng': null,
|
||||
});
|
||||
} catch (_) {}
|
||||
await sl<HapticService>().obstacleClose();
|
||||
await sl<TtsService>().speakImmediate(detection.spokenId);
|
||||
await runFriendlyAction(
|
||||
() => _cubit.recordObstacle(detection),
|
||||
onError: (_) {},
|
||||
fallback: 'Obstacle tersimpan offline.',
|
||||
);
|
||||
await sl<ObstacleAlertStrategy>().alert(detection);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _Page(
|
||||
title: 'WalkGuide',
|
||||
subtitle: 'On-device AI detection surface',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go('/user/benchmark'),
|
||||
icon: const Icon(Icons.speed)),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/user/pairing'),
|
||||
icon: const Icon(Icons.link)),
|
||||
],
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0F172A),
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_camera != null && _camera!.value.isInitialized)
|
||||
Positioned.fill(child: CameraPreview(_camera!))
|
||||
else
|
||||
const Center(
|
||||
child: Icon(Icons.videocam_outlined,
|
||||
color: Colors.white30, size: 96)),
|
||||
if (_lastDetection?.box != null)
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter: _DetectionOverlayPainter(_lastDetection!),
|
||||
return BlocBuilder<WalkGuideCubit, WalkGuideState>(
|
||||
bloc: _cubit,
|
||||
builder: (context, state) => _Page(
|
||||
title: 'WalkGuide',
|
||||
subtitle: 'On-device AI detection surface',
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.go('/user/benchmark'),
|
||||
icon: const Icon(Icons.speed)),
|
||||
IconButton(
|
||||
onPressed: () => context.go('/user/pairing'),
|
||||
icon: const Icon(Icons.link)),
|
||||
],
|
||||
child: Column(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: const Color(0xFF0F172A),
|
||||
borderRadius: BorderRadius.circular(16)),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (_camera != null && _camera!.value.isInitialized)
|
||||
Positioned.fill(child: CameraPreview(_camera!))
|
||||
else
|
||||
const Center(
|
||||
child: Icon(Icons.videocam_outlined,
|
||||
color: Colors.white30, size: 96)),
|
||||
if (state.latestDetection?.box != null)
|
||||
Positioned.fill(
|
||||
child: CustomPaint(
|
||||
painter:
|
||||
_DetectionOverlayPainter(state.latestDetection!),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: 16,
|
||||
left: 16,
|
||||
child: _Pill(
|
||||
text: _active ? 'AI ACTIVE' : 'STANDBY',
|
||||
color: _active ? Colors.green : Colors.orange)),
|
||||
if (_lastDetection != null)
|
||||
Positioned(
|
||||
top: 64,
|
||||
left: 16,
|
||||
child: _Pill(
|
||||
text:
|
||||
'${ObstacleAnalyzer.spokenLabel(_lastDetection!.label)} ${_lastDetection!.directionName}',
|
||||
color: Colors.redAccent),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: Text(_status,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700))),
|
||||
],
|
||||
top: 16,
|
||||
left: 16,
|
||||
child: _Pill(
|
||||
text: state.active ? 'AI ACTIVE' : 'STANDBY',
|
||||
color:
|
||||
state.active ? Colors.green : Colors.orange)),
|
||||
if (state.latestDetection != null)
|
||||
Positioned(
|
||||
top: 64,
|
||||
left: 16,
|
||||
child: _Pill(
|
||||
text:
|
||||
'${ObstacleAnalyzer.spokenLabel(state.latestDetection!.label)} ${state.latestDetection!.directionName}',
|
||||
color: Colors.redAccent),
|
||||
),
|
||||
Positioned(
|
||||
left: 16,
|
||||
right: 16,
|
||||
bottom: 16,
|
||||
child: Text(state.status,
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700))),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _toggle,
|
||||
icon: Icon(_active ? Icons.stop : Icons.play_arrow),
|
||||
label: Text(_active ? 'Stop' : 'Start'))),
|
||||
],
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: FilledButton.icon(
|
||||
onPressed: _toggle,
|
||||
icon:
|
||||
Icon(state.active ? Icons.stop : Icons.play_arrow),
|
||||
label: Text(state.active ? 'Stop' : 'Start'))),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user