mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-08-11 16:30:28 +00:00
Keep contact GPS location saved
This commit is contained in:
@@ -46,12 +46,19 @@ class ContactRouteCodec {
|
||||
static const int maxPathBytes = 64;
|
||||
static const int _unknownDescriptor = 0xFF;
|
||||
|
||||
static ParsedContactRoute parse(String input) {
|
||||
static ParsedContactRoute parse(String input, {int? expectedHashSize}) {
|
||||
final normalized = input.trim().toUpperCase();
|
||||
if (normalized.isEmpty) {
|
||||
throw const ContactRouteFormatException('Route cannot be empty.');
|
||||
}
|
||||
|
||||
if (expectedHashSize != null &&
|
||||
(expectedHashSize < 1 || expectedHashSize > maxHashSize)) {
|
||||
throw const ContactRouteFormatException(
|
||||
'Hash size must be 1, 2, or 3 bytes.',
|
||||
);
|
||||
}
|
||||
|
||||
final hopTokens = normalized
|
||||
.split(',')
|
||||
.map((token) => token.trim())
|
||||
@@ -80,6 +87,13 @@ class ContactRouteCodec {
|
||||
);
|
||||
}
|
||||
|
||||
if (expectedHashSize != null && currentHashSize != expectedHashSize) {
|
||||
throw ContactRouteFormatException(
|
||||
'Hop "$token" must be $expectedHashSize '
|
||||
'byte${expectedHashSize == 1 ? '' : 's'}.',
|
||||
);
|
||||
}
|
||||
|
||||
hashSize ??= currentHashSize;
|
||||
if (hashSize != currentHashSize) {
|
||||
throw const ContactRouteFormatException(
|
||||
|
||||
@@ -19,6 +19,7 @@ import '../services/update_checker_service.dart';
|
||||
import '../services/voice_codec_service.dart';
|
||||
import '../services/voice_bitrate_preferences.dart';
|
||||
import '../services/image_preferences.dart';
|
||||
import '../services/route_hash_preferences.dart';
|
||||
import '../services/image_codec_service.dart';
|
||||
import '../utils/sample_data_generator.dart';
|
||||
import '../utils/image_message_parser.dart';
|
||||
@@ -56,6 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
bool _showRxTxIndicators = true;
|
||||
bool _isCheckingForUpdates = false;
|
||||
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
|
||||
int _routeHashSize = RouteHashPreferences.defaultHashSize;
|
||||
int _imageMaxSize = ImagePreferences.defaultMaxSize;
|
||||
int _imageCompression = ImagePreferences.defaultQuality;
|
||||
bool _imageGrayscale = ImagePreferences.defaultGrayscale;
|
||||
@@ -77,6 +79,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
_initializeLocationService();
|
||||
_loadRxTxPreference();
|
||||
_loadVoiceBitratePreference();
|
||||
_loadRouteHashSizePreference();
|
||||
_loadImagePreferences();
|
||||
}
|
||||
|
||||
@@ -132,6 +135,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
return '$bitrate bps';
|
||||
}
|
||||
|
||||
Future<void> _loadRouteHashSizePreference() async {
|
||||
final value = await RouteHashPreferences.getHashSize();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_routeHashSize = value;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _saveRouteHashSizePreference(int value) async {
|
||||
await RouteHashPreferences.setHashSize(value);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_routeHashSize = value;
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> _loadImagePreferences() async {
|
||||
final size = await ImagePreferences.getMaxSize();
|
||||
final compression = await ImagePreferences.getCompression();
|
||||
@@ -658,6 +677,46 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _showRouteHashSizeDialog() async {
|
||||
final selected = await showDialog<int>(
|
||||
context: context,
|
||||
builder: (context) => SimpleDialog(
|
||||
title: const Text('Route path byte size'),
|
||||
children: [
|
||||
for (final value in [1, 2, 3])
|
||||
SimpleDialogOption(
|
||||
onPressed: () => Navigator.of(context).pop(value),
|
||||
child: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text('$value byte${value == 1 ? '' : 's'}'),
|
||||
Text(
|
||||
'Use ${value * 2} hex characters per hop',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (_routeHashSize == value)
|
||||
Icon(
|
||||
Icons.check,
|
||||
color: Theme.of(context).colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (selected == null) return;
|
||||
await _saveRouteHashSizePreference(selected);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
@@ -757,6 +816,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: () => _showLanguageDialog(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.alt_route),
|
||||
title: const Text('Route path byte size'),
|
||||
subtitle: Text(
|
||||
'$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes',
|
||||
),
|
||||
trailing: const Icon(Icons.chevron_right),
|
||||
onTap: _showRouteHashSizeDialog,
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.delete_sweep, color: Colors.red),
|
||||
title: const Text(
|
||||
|
||||
26
lib/services/route_hash_preferences.dart
Normal file
26
lib/services/route_hash_preferences.dart
Normal file
@@ -0,0 +1,26 @@
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
class RouteHashPreferences {
|
||||
static const String _hashSizeKey = 'route_hash_size';
|
||||
static const int defaultHashSize = 1;
|
||||
|
||||
static Future<int> getHashSize() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final value = prefs.getInt(_hashSizeKey) ?? defaultHashSize;
|
||||
return _normalize(value);
|
||||
}
|
||||
|
||||
static Future<void> setHashSize(int value) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setInt(_hashSizeKey, _normalize(value));
|
||||
}
|
||||
|
||||
static int normalizeSync(int value) => _normalize(value);
|
||||
|
||||
static int _normalize(int value) {
|
||||
if (value < 1 || value > 3) {
|
||||
return defaultHashSize;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../services/route_hash_preferences.dart';
|
||||
|
||||
class ContactRouteDialog extends StatefulWidget {
|
||||
final Contact contact;
|
||||
@@ -17,11 +18,20 @@ class ContactRouteDialog extends StatefulWidget {
|
||||
required Contact contact,
|
||||
required List<Contact> availableContacts,
|
||||
}) {
|
||||
return showDialog<ParsedContactRoute>(
|
||||
return showModalBottomSheet<ParsedContactRoute>(
|
||||
context: context,
|
||||
builder: (context) => ContactRouteDialog(
|
||||
contact: contact,
|
||||
availableContacts: availableContacts,
|
||||
isScrollControlled: true,
|
||||
showDragHandle: true,
|
||||
builder: (context) => SafeArea(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: ContactRouteDialog(
|
||||
contact: contact,
|
||||
availableContacts: availableContacts,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -32,20 +42,18 @@ class ContactRouteDialog extends StatefulWidget {
|
||||
|
||||
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
late final TextEditingController _controller;
|
||||
late int _selectedHashSize;
|
||||
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
||||
ParsedContactRoute? _parsedRoute;
|
||||
String? _errorText;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_selectedHashSize = widget.contact.routeHasPath
|
||||
? widget.contact.routeHashSize
|
||||
: 1;
|
||||
_controller = TextEditingController(
|
||||
text: widget.contact.routeCanonicalText,
|
||||
);
|
||||
_controller.addListener(_reparse);
|
||||
_loadHashSizePreference();
|
||||
_reparse();
|
||||
}
|
||||
|
||||
@@ -68,10 +76,12 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
}
|
||||
|
||||
try {
|
||||
final parsed = ContactRouteCodec.parse(input);
|
||||
final parsed = ContactRouteCodec.parse(
|
||||
input,
|
||||
expectedHashSize: _selectedHashSize,
|
||||
);
|
||||
setState(() {
|
||||
_parsedRoute = parsed;
|
||||
_selectedHashSize = parsed.hashSize;
|
||||
_errorText = null;
|
||||
});
|
||||
} on ContactRouteFormatException catch (error) {
|
||||
@@ -82,6 +92,15 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _loadHashSizePreference() async {
|
||||
final hashSize = await RouteHashPreferences.getHashSize();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_selectedHashSize = hashSize;
|
||||
});
|
||||
_reparse();
|
||||
}
|
||||
|
||||
String _tokenFor(Contact contact, int hashSize) {
|
||||
final hex = contact.publicKeyHex.toUpperCase();
|
||||
final length = hashSize * 2;
|
||||
@@ -108,123 +127,103 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
.toList()
|
||||
..sort((a, b) => a.displayName.compareTo(b.displayName));
|
||||
|
||||
return AlertDialog(
|
||||
title: Text('Set Route for ${widget.contact.displayName}'),
|
||||
content: SizedBox(
|
||||
width: 560,
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Path hash size',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
return FractionallySizedBox(
|
||||
heightFactor: 0.85,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Set Route for ${widget.contact.displayName}',
|
||||
style: Theme.of(context).textTheme.headlineSmall,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Route',
|
||||
hintText: _selectedHashSize == 1
|
||||
? 'AA,BB,CC'
|
||||
: _selectedHashSize == 2
|
||||
? 'AABB,CCDD'
|
||||
: 'AABBCC,DDEEFF',
|
||||
helperText:
|
||||
'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
|
||||
errorText: _errorText,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
children: [1, 2, 3]
|
||||
.map(
|
||||
(hashSize) => ChoiceChip(
|
||||
label: Text(
|
||||
'$hashSize byte${hashSize == 1 ? '' : 's'}',
|
||||
),
|
||||
selected: _selectedHashSize == hashSize,
|
||||
onSelected: (_) {
|
||||
setState(() {
|
||||
_selectedHashSize = hashSize;
|
||||
});
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_parsedRoute == null
|
||||
? 'Preview: enter a route to validate it.'
|
||||
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (_parsedRoute != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
_parsedRoute!.canonicalText,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Pick hops from contacts',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (routeCandidates.isEmpty)
|
||||
const Text(
|
||||
'No repeater or room contacts are available for route building.',
|
||||
)
|
||||
else
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
itemCount: routeCandidates.length,
|
||||
itemBuilder: (context, index) {
|
||||
final candidate = routeCandidates[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(candidate.displayName),
|
||||
subtitle: Text(
|
||||
'1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}',
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
TextField(
|
||||
controller: _controller,
|
||||
textCapitalization: TextCapitalization.characters,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Route',
|
||||
hintText: _selectedHashSize == 1
|
||||
? 'AA,BB,CC'
|
||||
: _selectedHashSize == 2
|
||||
? 'AABB,CCDD'
|
||||
: 'AABBCC,DDEEFF',
|
||||
helperText:
|
||||
'Use comma-separated hops. Colon form like AA:BB is also accepted.',
|
||||
errorText: _errorText,
|
||||
border: const OutlineInputBorder(),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _appendHop(candidate),
|
||||
child: Text(
|
||||
'Use ${_tokenFor(candidate, _selectedHashSize)}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_parsedRoute == null
|
||||
? 'Preview: enter a route to validate it.'
|
||||
: 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (_parsedRoute != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
SelectableText(
|
||||
_parsedRoute!.canonicalText,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||
const SizedBox(height: 16),
|
||||
Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
const Spacer(),
|
||||
FilledButton(
|
||||
onPressed: _parsedRoute == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(_parsedRoute),
|
||||
child: const Text('Set Route'),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Pick hops from contacts',
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
if (routeCandidates.isEmpty)
|
||||
const Text(
|
||||
'No repeater or room contacts are available for route building.',
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxHeight: 240),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: routeCandidates.length,
|
||||
itemBuilder: (context, index) {
|
||||
final candidate = routeCandidates[index];
|
||||
return ListTile(
|
||||
dense: true,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text(candidate.displayName),
|
||||
subtitle: Text(
|
||||
'1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}',
|
||||
style: const TextStyle(fontFamily: 'monospace'),
|
||||
),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _appendHop(candidate),
|
||||
child: Text(
|
||||
'Use ${_tokenFor(candidate, _selectedHashSize)}',
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
FilledButton(
|
||||
onPressed: _parsedRoute == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(_parsedRoute),
|
||||
child: const Text('Set Route'),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user