Keep contact GPS location saved

This commit is contained in:
Janez T
2026-03-07 18:30:22 +01:00
parent fa37a20fc7
commit 6bfc08f3ae
6 changed files with 238 additions and 124 deletions

View File

@@ -5,22 +5,22 @@
<testcase classname="fastlane.lanes" name="0: default_platform" time="0.000209"> <testcase classname="fastlane.lanes" name="0: default_platform" time="0.001195">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.489082"> <testcase classname="fastlane.lanes" name="1: increment_build_number" time="0.410921">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="2: build_app" time="115.233655"> <testcase classname="fastlane.lanes" name="2: build_app" time="97.264031">
</testcase> </testcase>
<testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="296.0895"> <testcase classname="fastlane.lanes" name="3: upload_to_app_store" time="11727.562963">
</testcase> </testcase>

View File

@@ -46,12 +46,19 @@ class ContactRouteCodec {
static const int maxPathBytes = 64; static const int maxPathBytes = 64;
static const int _unknownDescriptor = 0xFF; static const int _unknownDescriptor = 0xFF;
static ParsedContactRoute parse(String input) { static ParsedContactRoute parse(String input, {int? expectedHashSize}) {
final normalized = input.trim().toUpperCase(); final normalized = input.trim().toUpperCase();
if (normalized.isEmpty) { if (normalized.isEmpty) {
throw const ContactRouteFormatException('Route cannot be empty.'); 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 final hopTokens = normalized
.split(',') .split(',')
.map((token) => token.trim()) .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; hashSize ??= currentHashSize;
if (hashSize != currentHashSize) { if (hashSize != currentHashSize) {
throw const ContactRouteFormatException( throw const ContactRouteFormatException(

View File

@@ -19,6 +19,7 @@ import '../services/update_checker_service.dart';
import '../services/voice_codec_service.dart'; import '../services/voice_codec_service.dart';
import '../services/voice_bitrate_preferences.dart'; import '../services/voice_bitrate_preferences.dart';
import '../services/image_preferences.dart'; import '../services/image_preferences.dart';
import '../services/route_hash_preferences.dart';
import '../services/image_codec_service.dart'; import '../services/image_codec_service.dart';
import '../utils/sample_data_generator.dart'; import '../utils/sample_data_generator.dart';
import '../utils/image_message_parser.dart'; import '../utils/image_message_parser.dart';
@@ -56,6 +57,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
bool _showRxTxIndicators = true; bool _showRxTxIndicators = true;
bool _isCheckingForUpdates = false; bool _isCheckingForUpdates = false;
int _voiceBitrate = VoiceBitratePreferences.defaultBitrate; int _voiceBitrate = VoiceBitratePreferences.defaultBitrate;
int _routeHashSize = RouteHashPreferences.defaultHashSize;
int _imageMaxSize = ImagePreferences.defaultMaxSize; int _imageMaxSize = ImagePreferences.defaultMaxSize;
int _imageCompression = ImagePreferences.defaultQuality; int _imageCompression = ImagePreferences.defaultQuality;
bool _imageGrayscale = ImagePreferences.defaultGrayscale; bool _imageGrayscale = ImagePreferences.defaultGrayscale;
@@ -77,6 +79,7 @@ class _SettingsScreenState extends State<SettingsScreen> {
_initializeLocationService(); _initializeLocationService();
_loadRxTxPreference(); _loadRxTxPreference();
_loadVoiceBitratePreference(); _loadVoiceBitratePreference();
_loadRouteHashSizePreference();
_loadImagePreferences(); _loadImagePreferences();
} }
@@ -132,6 +135,22 @@ class _SettingsScreenState extends State<SettingsScreen> {
return '$bitrate bps'; 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 { Future<void> _loadImagePreferences() async {
final size = await ImagePreferences.getMaxSize(); final size = await ImagePreferences.getMaxSize();
final compression = await ImagePreferences.getCompression(); 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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@@ -757,6 +816,15 @@ class _SettingsScreenState extends State<SettingsScreen> {
trailing: const Icon(Icons.chevron_right), trailing: const Icon(Icons.chevron_right),
onTap: () => _showLanguageDialog(), 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( ListTile(
leading: const Icon(Icons.delete_sweep, color: Colors.red), leading: const Icon(Icons.delete_sweep, color: Colors.red),
title: const Text( title: const Text(

View 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;
}
}

View File

@@ -1,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../../models/contact.dart'; import '../../models/contact.dart';
import '../../services/route_hash_preferences.dart';
class ContactRouteDialog extends StatefulWidget { class ContactRouteDialog extends StatefulWidget {
final Contact contact; final Contact contact;
@@ -17,12 +18,21 @@ class ContactRouteDialog extends StatefulWidget {
required Contact contact, required Contact contact,
required List<Contact> availableContacts, required List<Contact> availableContacts,
}) { }) {
return showDialog<ParsedContactRoute>( return showModalBottomSheet<ParsedContactRoute>(
context: context, context: context,
builder: (context) => ContactRouteDialog( isScrollControlled: true,
showDragHandle: true,
builder: (context) => SafeArea(
child: Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom,
),
child: ContactRouteDialog(
contact: contact, contact: contact,
availableContacts: availableContacts, availableContacts: availableContacts,
), ),
),
),
); );
} }
@@ -32,20 +42,18 @@ class ContactRouteDialog extends StatefulWidget {
class _ContactRouteDialogState extends State<ContactRouteDialog> { class _ContactRouteDialogState extends State<ContactRouteDialog> {
late final TextEditingController _controller; late final TextEditingController _controller;
late int _selectedHashSize; int _selectedHashSize = RouteHashPreferences.defaultHashSize;
ParsedContactRoute? _parsedRoute; ParsedContactRoute? _parsedRoute;
String? _errorText; String? _errorText;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_selectedHashSize = widget.contact.routeHasPath
? widget.contact.routeHashSize
: 1;
_controller = TextEditingController( _controller = TextEditingController(
text: widget.contact.routeCanonicalText, text: widget.contact.routeCanonicalText,
); );
_controller.addListener(_reparse); _controller.addListener(_reparse);
_loadHashSizePreference();
_reparse(); _reparse();
} }
@@ -68,10 +76,12 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
} }
try { try {
final parsed = ContactRouteCodec.parse(input); final parsed = ContactRouteCodec.parse(
input,
expectedHashSize: _selectedHashSize,
);
setState(() { setState(() {
_parsedRoute = parsed; _parsedRoute = parsed;
_selectedHashSize = parsed.hashSize;
_errorText = null; _errorText = null;
}); });
} on ContactRouteFormatException catch (error) { } 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) { String _tokenFor(Contact contact, int hashSize) {
final hex = contact.publicKeyHex.toUpperCase(); final hex = contact.publicKeyHex.toUpperCase();
final length = hashSize * 2; final length = hashSize * 2;
@@ -108,37 +127,16 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
.toList() .toList()
..sort((a, b) => a.displayName.compareTo(b.displayName)); ..sort((a, b) => a.displayName.compareTo(b.displayName));
return AlertDialog( return FractionallySizedBox(
title: Text('Set Route for ${widget.contact.displayName}'), heightFactor: 0.85,
content: SizedBox( child: Padding(
width: 560, padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: SingleChildScrollView(
child: Column( child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Text( Text(
'Path hash size', 'Set Route for ${widget.contact.displayName}',
style: Theme.of(context).textTheme.labelLarge, style: Theme.of(context).textTheme.headlineSmall,
),
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;
});
},
),
)
.toList(),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextField( TextField(
@@ -152,7 +150,7 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
? 'AABB,CCDD' ? 'AABB,CCDD'
: 'AABBCC,DDEEFF', : 'AABBCC,DDEEFF',
helperText: helperText:
'Use comma-separated hops. Colon form like AA:BB is also accepted.', 'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.',
errorText: _errorText, errorText: _errorText,
border: const OutlineInputBorder(), border: const OutlineInputBorder(),
), ),
@@ -184,10 +182,8 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
'No repeater or room contacts are available for route building.', 'No repeater or room contacts are available for route building.',
) )
else else
ConstrainedBox( Expanded(
constraints: const BoxConstraints(maxHeight: 240),
child: ListView.builder( child: ListView.builder(
shrinkWrap: true,
itemCount: routeCandidates.length, itemCount: routeCandidates.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final candidate = routeCandidates[index]; final candidate = routeCandidates[index];
@@ -209,15 +205,14 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
}, },
), ),
), ),
], const SizedBox(height: 16),
), Row(
), children: [
),
actions: [
TextButton( TextButton(
onPressed: () => Navigator.of(context).pop(), onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'), child: const Text('Cancel'),
), ),
const Spacer(),
FilledButton( FilledButton(
onPressed: _parsedRoute == null onPressed: _parsedRoute == null
? null ? null
@@ -225,6 +220,10 @@ class _ContactRouteDialogState extends State<ContactRouteDialog> {
child: const Text('Set Route'), child: const Text('Set Route'),
), ),
], ],
),
],
),
),
); );
} }
} }

View File

@@ -67,6 +67,13 @@ void main() {
); );
}); });
test('rejects routes that do not match the configured hash size', () {
expect(
() => ContactRouteCodec.parse('AABB,CCDD', expectedHashSize: 1),
throwsA(isA<ContactRouteFormatException>()),
);
});
test('rejects invalid tokens', () { test('rejects invalid tokens', () {
expect( expect(
() => ContactRouteCodec.parse('AA,XYZ'), () => ContactRouteCodec.parse('AA,XYZ'),