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

@@ -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'),
),
],
);
}
}