Fix local dev config and ALOTTTT OFF FLUTTER, im tired boss..
This commit is contained in:
+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';
|
||||
Reference in New Issue
Block a user