mirror of
https://github.com/dz0ny/meshcore-sar.git
synced 2026-10-09 19:55:05 +00:00
chore: Initial commit
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPovRHpejR4zRxuhjY4Qs3
This commit is contained in:
304
lib/widgets/contacts/add_channel_dialog.dart
Normal file
304
lib/widgets/contacts/add_channel_dialog.dart
Normal file
@@ -0,0 +1,304 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
|
||||
/// Bottom sheet for adding a new channel
|
||||
class AddChannelSheet extends StatefulWidget {
|
||||
final Future<void> Function(String name, String secret) onCreateChannel;
|
||||
|
||||
const AddChannelSheet({super.key, required this.onCreateChannel});
|
||||
|
||||
@override
|
||||
State<AddChannelSheet> createState() => _AddChannelSheetState();
|
||||
}
|
||||
|
||||
class _AddChannelSheetState extends State<AddChannelSheet> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
final _nameController = TextEditingController();
|
||||
final _secretController = TextEditingController();
|
||||
bool _isCreating = false;
|
||||
bool _obscureSecret = true;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_nameController.dispose();
|
||||
_secretController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Validate that a string contains only ASCII characters
|
||||
bool _isAscii(String text) {
|
||||
return text.codeUnits.every((unit) => unit < 128);
|
||||
}
|
||||
|
||||
/// Validate channel name
|
||||
String? _validateName(String? value) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final trimmedValue = value?.trim() ?? '';
|
||||
|
||||
if (trimmedValue.isEmpty) {
|
||||
return l10n.channelNameRequired;
|
||||
}
|
||||
|
||||
if (trimmedValue.length > 31) {
|
||||
return l10n.channelNameTooLong;
|
||||
}
|
||||
|
||||
if (!_isAscii(trimmedValue)) {
|
||||
return l10n.invalidAsciiCharacters;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Validate channel secret
|
||||
String? _validateSecret(String? value) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (value == null || value.isEmpty) {
|
||||
return l10n.channelSecretRequired;
|
||||
}
|
||||
|
||||
if (value.length > 32) {
|
||||
return l10n.channelSecretTooLong;
|
||||
}
|
||||
|
||||
if (!_isAscii(value)) {
|
||||
return l10n.invalidAsciiCharacters;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/// Handle channel creation
|
||||
Future<void> _handleCreate() async {
|
||||
if (!_formKey.currentState!.validate()) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isCreating = true;
|
||||
});
|
||||
|
||||
try {
|
||||
final channelName = _nameController.text.trim();
|
||||
final isHashChannel = channelName.startsWith('#');
|
||||
|
||||
// For hash channels, pass empty secret (will be auto-generated)
|
||||
// For private channels, use the provided secret
|
||||
final secret = isHashChannel ? '' : _secretController.text;
|
||||
|
||||
await widget.onCreateChannel(channelName, secret);
|
||||
|
||||
if (mounted) {
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
} catch (e) {
|
||||
// Error is handled by parent
|
||||
setState(() {
|
||||
_isCreating = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final theme = Theme.of(context);
|
||||
final normalizedName = _nameController.text.trimLeft();
|
||||
final isHashChannel = normalizedName.startsWith('#');
|
||||
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.85,
|
||||
),
|
||||
child: SingleChildScrollView(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.addChannel,
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: _isCreating
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
icon: Icon(Icons.close),
|
||||
tooltip: l10n.close,
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.3,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: theme.colorScheme.primary.withValues(alpha: 0.3),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 20,
|
||||
color: theme.colorScheme.primary,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.channelTypesInfo,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _nameController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.channelName,
|
||||
hintText: l10n.channelNameHint,
|
||||
border: const OutlineInputBorder(),
|
||||
prefixIcon: Icon(
|
||||
isHashChannel ? Icons.tag : Icons.lock_outline,
|
||||
color: isHashChannel ? Colors.blue : Colors.orange,
|
||||
),
|
||||
),
|
||||
enabled: !_isCreating,
|
||||
maxLength: 31,
|
||||
validator: _validateName,
|
||||
textInputAction: isHashChannel
|
||||
? TextInputAction.done
|
||||
: TextInputAction.next,
|
||||
onChanged: (_) => setState(() {}),
|
||||
onFieldSubmitted: (_) {
|
||||
if (isHashChannel) {
|
||||
_handleCreate();
|
||||
}
|
||||
},
|
||||
),
|
||||
if (!isHashChannel) ...[
|
||||
SizedBox(height: 16),
|
||||
TextFormField(
|
||||
controller: _secretController,
|
||||
decoration: InputDecoration(
|
||||
labelText: l10n.channelSecret,
|
||||
hintText: l10n.channelSecretHint,
|
||||
border: const OutlineInputBorder(),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: _isCreating
|
||||
? null
|
||||
: () => setState(() {
|
||||
_obscureSecret = !_obscureSecret;
|
||||
}),
|
||||
icon: Icon(
|
||||
_obscureSecret
|
||||
? Icons.visibility_outlined
|
||||
: Icons.visibility_off_outlined,
|
||||
),
|
||||
tooltip: _obscureSecret
|
||||
? 'Show secret'
|
||||
: 'Hide secret',
|
||||
),
|
||||
),
|
||||
obscureText: _obscureSecret,
|
||||
enabled: !_isCreating,
|
||||
maxLength: 32,
|
||||
validator: _validateSecret,
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _handleCreate(),
|
||||
),
|
||||
SizedBox(height: 8),
|
||||
Text(
|
||||
l10n.channelSecretHelp,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
if (isHashChannel) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: theme.colorScheme.primaryContainer.withValues(
|
||||
alpha: 0.5,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.auto_awesome,
|
||||
size: 20,
|
||||
color: Colors.blue,
|
||||
),
|
||||
SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
l10n.hashChannelInfo,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: theme.colorScheme.primary,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 20),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
onPressed: _isCreating
|
||||
? null
|
||||
: () => Navigator.of(context).pop(),
|
||||
child: Text(l10n.cancel),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: FilledButton(
|
||||
onPressed: _isCreating ? null : _handleCreate,
|
||||
child: _isCreating
|
||||
? const SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
),
|
||||
)
|
||||
: Text(l10n.createChannel),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
842
lib/widgets/contacts/contact_route_dialog.dart
Normal file
842
lib/widgets/contacts/contact_route_dialog.dart
Normal file
@@ -0,0 +1,842 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' as flutter_map;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../providers/app_provider.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../services/relay_candidate_sorter.dart';
|
||||
import '../../services/route_hash_preferences.dart';
|
||||
|
||||
class ContactRouteDialogResult {
|
||||
final ParsedContactRoute? route;
|
||||
final bool shouldClear;
|
||||
final LatLng? inferredFallbackLocation;
|
||||
|
||||
const ContactRouteDialogResult._({
|
||||
this.route,
|
||||
required this.shouldClear,
|
||||
this.inferredFallbackLocation,
|
||||
});
|
||||
|
||||
const ContactRouteDialogResult.set(ParsedContactRoute route)
|
||||
: this._(route: route, shouldClear: false);
|
||||
|
||||
const ContactRouteDialogResult.setWithFallback(
|
||||
ParsedContactRoute route, {
|
||||
LatLng? inferredFallbackLocation,
|
||||
}) : this._(
|
||||
route: route,
|
||||
shouldClear: false,
|
||||
inferredFallbackLocation: inferredFallbackLocation,
|
||||
);
|
||||
|
||||
const ContactRouteDialogResult.clear() : this._(shouldClear: true);
|
||||
}
|
||||
|
||||
class ContactRouteDialog extends StatefulWidget {
|
||||
final Contact contact;
|
||||
final List<Contact> availableContacts;
|
||||
|
||||
const ContactRouteDialog({
|
||||
super.key,
|
||||
required this.contact,
|
||||
required this.availableContacts,
|
||||
});
|
||||
|
||||
static Future<ContactRouteDialogResult?> show(
|
||||
BuildContext context, {
|
||||
required Contact contact,
|
||||
required List<Contact> availableContacts,
|
||||
}) {
|
||||
return Navigator.of(context).push<ContactRouteDialogResult>(
|
||||
MaterialPageRoute(
|
||||
builder: (context) => ContactRouteDialog(
|
||||
contact: contact,
|
||||
availableContacts: availableContacts,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
State<ContactRouteDialog> createState() => _ContactRouteDialogState();
|
||||
}
|
||||
|
||||
class _ContactRouteDialogState extends State<ContactRouteDialog> {
|
||||
late final TextEditingController _controller;
|
||||
late final TextEditingController _relaySearchController;
|
||||
final RelayCandidateSorter _relayCandidateSorter =
|
||||
const RelayCandidateSorter();
|
||||
int _selectedHashSize = RouteHashPreferences.defaultHashSize;
|
||||
ParsedContactRoute? _parsedRoute;
|
||||
String? _errorText;
|
||||
bool _showRoutingInfo = false;
|
||||
List<Contact> _selectedMapHops = const [];
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = TextEditingController(
|
||||
text: widget.contact.routeCanonicalText,
|
||||
);
|
||||
_relaySearchController = TextEditingController();
|
||||
_controller.addListener(_reparse);
|
||||
_loadHashSizePreference();
|
||||
_reparse();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_relaySearchController.dispose();
|
||||
_controller
|
||||
..removeListener(_reparse)
|
||||
..dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _reparse() {
|
||||
final input = _controller.text.trim();
|
||||
if (input.isEmpty) {
|
||||
setState(() {
|
||||
_parsedRoute = null;
|
||||
_errorText = null;
|
||||
_selectedMapHops = const [];
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
final parsed = ContactRouteCodec.parse(
|
||||
input,
|
||||
expectedHashSize: _selectedHashSize,
|
||||
);
|
||||
final selectedMapHops = _mapSelectionForText(input);
|
||||
setState(() {
|
||||
_parsedRoute = parsed;
|
||||
_errorText = null;
|
||||
_selectedMapHops = selectedMapHops;
|
||||
});
|
||||
} on ContactRouteFormatException catch (error) {
|
||||
setState(() {
|
||||
_parsedRoute = null;
|
||||
_errorText = error.message;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
List<Contact> _routeCandidates({required LatLng? selfPoint}) =>
|
||||
_relayCandidateSorter.sortByDistanceFromSelf(
|
||||
widget.availableContacts
|
||||
.where(
|
||||
(contact) =>
|
||||
contact.isRepeater && contact.displayLocation != null,
|
||||
)
|
||||
.toList(),
|
||||
selfPoint: selfPoint,
|
||||
);
|
||||
|
||||
List<Contact> _mapSelectionForText(String text) {
|
||||
final tokens = text
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((token) => token.trim().toUpperCase())
|
||||
.where((token) => token.isNotEmpty)
|
||||
.toList();
|
||||
final selected = <Contact>[];
|
||||
final seen = <String>{};
|
||||
for (final token in tokens) {
|
||||
final match = _routeCandidates(selfPoint: null)
|
||||
.where(
|
||||
(contact) => contact.publicKeyHex.toUpperCase().startsWith(token),
|
||||
)
|
||||
.firstOrNull;
|
||||
if (match != null && seen.add(match.publicKeyHex)) {
|
||||
selected.add(match);
|
||||
}
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
Future<void> _loadHashSizePreference() async {
|
||||
// Use the contact's current path hash size if it has a path,
|
||||
// otherwise fall back to the global preference.
|
||||
final contactHashSize = widget.contact.hasPath
|
||||
? widget.contact.pathHashSize
|
||||
: null;
|
||||
final hashSize =
|
||||
contactHashSize ?? 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;
|
||||
if (hex.length < length) {
|
||||
return hex;
|
||||
}
|
||||
return hex.substring(0, length);
|
||||
}
|
||||
|
||||
void _syncControllerFromSelectedHops() {
|
||||
final tokens = _selectedMapHops
|
||||
.map((contact) => _tokenFor(contact, _selectedHashSize))
|
||||
.toList();
|
||||
_controller.text = tokens.join(',');
|
||||
_controller.selection = TextSelection.fromPosition(
|
||||
TextPosition(offset: _controller.text.length),
|
||||
);
|
||||
}
|
||||
|
||||
ParsedContactRoute? get _effectiveRoute {
|
||||
if (_parsedRoute != null) {
|
||||
return _parsedRoute;
|
||||
}
|
||||
if (_errorText != null || _controller.text.trim().isNotEmpty) {
|
||||
return null;
|
||||
}
|
||||
return ContactRouteCodec.direct(hashSize: _selectedHashSize);
|
||||
}
|
||||
|
||||
void _toggleHop(Contact contact) {
|
||||
setState(() {
|
||||
if (_selectedMapHops.any(
|
||||
(item) => item.publicKeyHex == contact.publicKeyHex,
|
||||
)) {
|
||||
_selectedMapHops = _selectedMapHops
|
||||
.where((item) => item.publicKeyHex != contact.publicKeyHex)
|
||||
.toList();
|
||||
} else {
|
||||
_selectedMapHops = [..._selectedMapHops, contact];
|
||||
}
|
||||
_syncControllerFromSelectedHops();
|
||||
_reparse();
|
||||
});
|
||||
}
|
||||
|
||||
LatLng? _resolveLastHopLocation() {
|
||||
if (_selectedMapHops.isNotEmpty) {
|
||||
return _selectedMapHops.last.displayLocation == null
|
||||
? null
|
||||
: LatLng(
|
||||
_selectedMapHops.last.displayLocation!.latitude,
|
||||
_selectedMapHops.last.displayLocation!.longitude,
|
||||
);
|
||||
}
|
||||
|
||||
final tokens = _controller.text
|
||||
.trim()
|
||||
.split(',')
|
||||
.map((token) => token.trim().toUpperCase())
|
||||
.where((token) => token.isNotEmpty)
|
||||
.toList();
|
||||
if (tokens.isEmpty) return null;
|
||||
final lastToken = tokens.last;
|
||||
final match = _routeCandidates(selfPoint: null)
|
||||
.where(
|
||||
(contact) => contact.publicKeyHex.toUpperCase().startsWith(lastToken),
|
||||
)
|
||||
.firstOrNull;
|
||||
final location = match?.displayLocation;
|
||||
if (location == null) return null;
|
||||
return LatLng(location.latitude, location.longitude);
|
||||
}
|
||||
|
||||
LatLng? _buildSyntheticFallbackLocation() {
|
||||
final lastHopLocation = _resolveLastHopLocation();
|
||||
if (lastHopLocation == null) return null;
|
||||
|
||||
final seed = widget.contact.publicKey.fold<int>(
|
||||
_controller.text.codeUnits.fold<int>(0, (sum, unit) => sum + unit),
|
||||
(sum, byte) => sum + byte,
|
||||
);
|
||||
final angle = (seed % 360) * (math.pi / 180.0);
|
||||
const radiusMeters = 500.0;
|
||||
final latOffset = (radiusMeters / 111320.0) * math.cos(angle);
|
||||
final lonDenominator =
|
||||
111320.0 * math.cos(lastHopLocation.latitude * (math.pi / 180.0));
|
||||
final lonOffset = lonDenominator.abs() < 1e-6
|
||||
? 0.0
|
||||
: (radiusMeters / lonDenominator) * math.sin(angle);
|
||||
return LatLng(
|
||||
lastHopLocation.latitude + latOffset,
|
||||
lastHopLocation.longitude + lonOffset,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreviewSection() {
|
||||
final previewRoute = _effectiveRoute;
|
||||
if (previewRoute == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
previewRoute.summary,
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
'${previewRoute.byteLength}B • 0x${previewRoute.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
if (previewRoute.canonicalText.isNotEmpty) ...[
|
||||
const SizedBox(height: 8),
|
||||
SelectableText(
|
||||
previewRoute.canonicalText,
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSelectedHopSection() {
|
||||
if (_selectedMapHops.isEmpty) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context)!.selectedRelays, style: Theme.of(context).textTheme.titleSmall),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: _selectedMapHops.asMap().entries.map((entry) {
|
||||
final index = entry.key;
|
||||
final contact = entry.value;
|
||||
return InputChip(
|
||||
label: Text('${index + 1}. ${contact.displayName}'),
|
||||
deleteIcon: const Icon(Icons.close),
|
||||
onDeleted: () => _toggleHop(contact),
|
||||
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
||||
visualDensity: VisualDensity.compact,
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildRelayPicker(List<Contact> routeCandidates) {
|
||||
final query = _relaySearchController.text.trim().toLowerCase();
|
||||
final filteredCandidates = routeCandidates.where((contact) {
|
||||
if (query.isEmpty) return true;
|
||||
return contact.displayName.toLowerCase().contains(query) ||
|
||||
_tokenFor(contact, _selectedHashSize).toLowerCase().contains(query);
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _relaySearchController,
|
||||
onChanged: (_) => setState(() {}),
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: const Icon(Icons.search),
|
||||
labelText: 'Find relay',
|
||||
hintText: AppLocalizations.of(context)!.searchByNameOrToken,
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (filteredCandidates.isEmpty)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: Theme.of(context).dividerColor),
|
||||
),
|
||||
child: Text(
|
||||
'No visible relays match this search.',
|
||||
style: Theme.of(context).textTheme.bodyMedium,
|
||||
),
|
||||
)
|
||||
else
|
||||
...filteredCandidates.map((candidate) {
|
||||
final isSelected = _selectedMapHops.any(
|
||||
(item) => item.publicKeyHex == candidate.publicKeyHex,
|
||||
);
|
||||
final location = candidate.displayLocation;
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
vertical: 4,
|
||||
),
|
||||
leading: Icon(
|
||||
isSelected
|
||||
? Icons.check_circle
|
||||
: Icons.radio_button_unchecked,
|
||||
color: isSelected
|
||||
? Theme.of(context).colorScheme.primary
|
||||
: null,
|
||||
),
|
||||
title: Text(candidate.displayName),
|
||||
subtitle: Text(
|
||||
[
|
||||
_tokenFor(candidate, _selectedHashSize),
|
||||
if (location != null)
|
||||
'${location.latitude.toStringAsFixed(4)}, ${location.longitude.toStringAsFixed(4)}',
|
||||
].join(' • '),
|
||||
style: Theme.of(
|
||||
context,
|
||||
).textTheme.bodySmall?.copyWith(fontFamily: 'monospace'),
|
||||
),
|
||||
trailing: TextButton(
|
||||
onPressed: () => _toggleHop(candidate),
|
||||
child: Text(isSelected ? 'Remove' : 'Add'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildMapPreview({
|
||||
required List<Contact> routeCandidates,
|
||||
required List<LatLng> mapPoints,
|
||||
required List<LatLng> routePoints,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: SizedBox(
|
||||
height: 220,
|
||||
child: mapPoints.length < 2
|
||||
? const Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16),
|
||||
child: Text(
|
||||
'Map preview needs your advertised location, the contact location, and visible repeater locations.',
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
)
|
||||
: flutter_map.FlutterMap(
|
||||
options: flutter_map.MapOptions(
|
||||
initialCameraFit: flutter_map.CameraFit.bounds(
|
||||
bounds: flutter_map.LatLngBounds.fromPoints(mapPoints),
|
||||
padding: const EdgeInsets.all(32),
|
||||
),
|
||||
),
|
||||
children: [
|
||||
flutter_map.TileLayer(
|
||||
urlTemplate:
|
||||
'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
|
||||
userAgentPackageName: 'com.meshcore.sar',
|
||||
),
|
||||
if (routePoints.length >= 2)
|
||||
flutter_map.PolylineLayer(
|
||||
polylines: [
|
||||
flutter_map.Polyline(
|
||||
points: routePoints,
|
||||
strokeWidth: 4,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
flutter_map.MarkerLayer(
|
||||
markers: [
|
||||
...routeCandidates.map((candidate) {
|
||||
final isSelected = _selectedMapHops.any(
|
||||
(item) => item.publicKeyHex == candidate.publicKeyHex,
|
||||
);
|
||||
return flutter_map.Marker(
|
||||
point: LatLng(
|
||||
candidate.displayLocation!.latitude,
|
||||
candidate.displayLocation!.longitude,
|
||||
),
|
||||
width: 64,
|
||||
height: 70,
|
||||
child: GestureDetector(
|
||||
onTap: () => _toggleHop(candidate),
|
||||
child: _RouteMarkerDot(
|
||||
label: _tokenFor(candidate, _selectedHashSize),
|
||||
color: isSelected
|
||||
? colorScheme.primary
|
||||
: Colors.blueGrey,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBuilderTab({
|
||||
required List<Contact> routeCandidates,
|
||||
}) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(AppLocalizations.of(context)!.pathSize, style: Theme.of(context).textTheme.labelLarge),
|
||||
const Spacer(),
|
||||
SegmentedButton<int>(
|
||||
segments: [
|
||||
for (final size in RouteHashPreferences.supportedSizes)
|
||||
ButtonSegment<int>(value: size, label: Text('${size}B')),
|
||||
],
|
||||
selected: {_selectedHashSize},
|
||||
onSelectionChanged: (selection) {
|
||||
setState(() {
|
||||
_selectedHashSize = selection.first;
|
||||
_syncControllerFromSelectedHops();
|
||||
});
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
if (_errorText != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_errorText!,
|
||||
style: TextStyle(color: Theme.of(context).colorScheme.error),
|
||||
),
|
||||
],
|
||||
if (_selectedMapHops.isNotEmpty) ...[
|
||||
const SizedBox(height: 12),
|
||||
_buildSelectedHopSection(),
|
||||
],
|
||||
const SizedBox(height: 12),
|
||||
_buildRelayPicker(routeCandidates),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildInfoTab({
|
||||
required AppProvider appProvider,
|
||||
required List<Contact> routeCandidates,
|
||||
required List<LatLng> mapPoints,
|
||||
required List<LatLng> routePoints,
|
||||
}) {
|
||||
return ListView(
|
||||
children: [
|
||||
_buildPreviewSection(),
|
||||
const SizedBox(height: 16),
|
||||
_buildMapPreview(
|
||||
routeCandidates: routeCandidates,
|
||||
mapPoints: mapPoints,
|
||||
routePoints: routePoints,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
_AutomationRoutingInfo(
|
||||
isExpanded: _showRoutingInfo,
|
||||
onToggle: () {
|
||||
setState(() {
|
||||
_showRoutingInfo = !_showRoutingInfo;
|
||||
});
|
||||
},
|
||||
nearestRelayFallbackEnabled: appProvider.nearestRelayFallbackEnabled,
|
||||
clearPathOnMaxRetry: appProvider.clearPathOnMaxRetry,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final effectiveRoute = _effectiveRoute;
|
||||
final appProvider = context.watch<AppProvider>();
|
||||
final connectionProvider = context.watch<ConnectionProvider>();
|
||||
final selfPoint =
|
||||
connectionProvider.deviceInfo.advLat != null &&
|
||||
connectionProvider.deviceInfo.advLon != null &&
|
||||
!(connectionProvider.deviceInfo.advLat == 0 &&
|
||||
connectionProvider.deviceInfo.advLon == 0)
|
||||
? LatLng(
|
||||
connectionProvider.deviceInfo.advLat! / 1e6,
|
||||
connectionProvider.deviceInfo.advLon! / 1e6,
|
||||
)
|
||||
: null;
|
||||
final routeCandidates = _routeCandidates(selfPoint: selfPoint);
|
||||
final recipientLocation = widget.contact.displayLocation;
|
||||
final recipientPoint = recipientLocation == null
|
||||
? null
|
||||
: LatLng(recipientLocation.latitude, recipientLocation.longitude);
|
||||
final routePoints = <LatLng>[
|
||||
...?selfPoint == null ? null : [selfPoint],
|
||||
..._selectedMapHops
|
||||
.where((contact) => contact.displayLocation != null)
|
||||
.map(
|
||||
(contact) => LatLng(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
...?recipientPoint == null ? null : [recipientPoint],
|
||||
];
|
||||
final mapPoints = <LatLng>[
|
||||
...?selfPoint == null ? null : [selfPoint],
|
||||
...?recipientPoint == null ? null : [recipientPoint],
|
||||
...routeCandidates.map(
|
||||
(contact) => LatLng(
|
||||
contact.displayLocation!.latitude,
|
||||
contact.displayLocation!.longitude,
|
||||
),
|
||||
),
|
||||
];
|
||||
|
||||
return DefaultTabController(
|
||||
length: 2,
|
||||
child: Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(AppLocalizations.of(context)!.setPathForContact(widget.contact.displayName)),
|
||||
bottom: const TabBar(
|
||||
tabs: [
|
||||
Tab(text: 'Build'),
|
||||
Tab(text: 'Info'),
|
||||
],
|
||||
),
|
||||
),
|
||||
body: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
|
||||
child: TabBarView(
|
||||
children: [
|
||||
ListView(
|
||||
children: [
|
||||
_buildBuilderTab(
|
||||
routeCandidates: routeCandidates,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
],
|
||||
),
|
||||
_buildInfoTab(
|
||||
appProvider: appProvider,
|
||||
routeCandidates: routeCandidates,
|
||||
mapPoints: mapPoints,
|
||||
routePoints: routePoints,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
bottomNavigationBar: SafeArea(
|
||||
top: false,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||
child: OverflowBar(
|
||||
alignment: MainAxisAlignment.spaceBetween,
|
||||
spacing: 8,
|
||||
overflowSpacing: 8,
|
||||
children: [
|
||||
if (widget.contact.routeHasPath)
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(
|
||||
context,
|
||||
).pop(ContactRouteDialogResult.clear()),
|
||||
child: Text(
|
||||
'${AppLocalizations.of(context)!.clearRoute} (${AppLocalizations.of(context)!.flood})',
|
||||
),
|
||||
)
|
||||
else
|
||||
const SizedBox.shrink(),
|
||||
FilledButton(
|
||||
onPressed: effectiveRoute == null
|
||||
? null
|
||||
: () => Navigator.of(context).pop(
|
||||
ContactRouteDialogResult.setWithFallback(
|
||||
effectiveRoute,
|
||||
inferredFallbackLocation:
|
||||
_buildSyntheticFallbackLocation(),
|
||||
),
|
||||
),
|
||||
child: Text(AppLocalizations.of(context)!.savePath),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _RouteMarkerDot extends StatelessWidget {
|
||||
final String label;
|
||||
final Color color;
|
||||
|
||||
const _RouteMarkerDot({required this.label, required this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Tooltip(
|
||||
message: label,
|
||||
child: Center(
|
||||
child: Container(
|
||||
width: 26,
|
||||
height: 26,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
shape: BoxShape.circle,
|
||||
border: Border.all(color: Colors.white, width: 2),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AutomationRoutingInfo extends StatelessWidget {
|
||||
final bool isExpanded;
|
||||
final VoidCallback onToggle;
|
||||
final bool nearestRelayFallbackEnabled;
|
||||
final bool clearPathOnMaxRetry;
|
||||
|
||||
const _AutomationRoutingInfo({
|
||||
required this.isExpanded,
|
||||
required this.onToggle,
|
||||
required this.nearestRelayFallbackEnabled,
|
||||
required this.clearPathOnMaxRetry,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.45),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(color: colorScheme.outlineVariant),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
onTap: onToggle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
size: 18,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Automatic direct-send routing',
|
||||
style: Theme.of(context).textTheme.titleSmall,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
isExpanded ? Icons.expand_less : Icons.expand_more,
|
||||
color: colorScheme.primary,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isExpanded) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Room/contact sends use the current direct path when one is known, switch to flood on the last normal retry, then try one final nearest repeater if everything else fails.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Public and channel broadcasts are not affected by this automation.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: [
|
||||
_InfoChip(
|
||||
label: nearestRelayFallbackEnabled
|
||||
? 'Nearest repeater fallback on'
|
||||
: 'Nearest repeater fallback off',
|
||||
icon: Icons.router,
|
||||
),
|
||||
_InfoChip(
|
||||
label: clearPathOnMaxRetry
|
||||
? 'Clear path on max retry on'
|
||||
: 'Clear path on max retry off',
|
||||
icon: Icons.route,
|
||||
),
|
||||
],
|
||||
),
|
||||
] else ...[
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Shows retry and final repeater fallback behavior.',
|
||||
style: Theme.of(context).textTheme.bodySmall,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoChip extends StatelessWidget {
|
||||
final String label;
|
||||
final IconData icon;
|
||||
|
||||
const _InfoChip({required this.label, required this.icon});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
color: Theme.of(context).colorScheme.surface,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 14),
|
||||
const SizedBox(width: 6),
|
||||
Text(label, style: Theme.of(context).textTheme.labelMedium),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
2825
lib/widgets/contacts/contact_tile.dart
Normal file
2825
lib/widgets/contacts/contact_tile.dart
Normal file
File diff suppressed because it is too large
Load Diff
638
lib/widgets/contacts/ping_contact_sheet.dart
Normal file
638
lib/widgets/contacts/ping_contact_sheet.dart
Normal file
@@ -0,0 +1,638 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:math' as math;
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:audioplayers/audioplayers.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/contact.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../services/location_tracking_service.dart';
|
||||
import '../../utils/link_quality.dart';
|
||||
import '../../utils/time_ago_extensions.dart';
|
||||
|
||||
class PingContactSheet extends StatefulWidget {
|
||||
final Contact contact;
|
||||
|
||||
const PingContactSheet({super.key, required this.contact});
|
||||
|
||||
@override
|
||||
State<PingContactSheet> createState() => _PingContactSheetState();
|
||||
}
|
||||
|
||||
class _PingEntry {
|
||||
final RelayPingResult result;
|
||||
final DateTime timestamp;
|
||||
final String? distance;
|
||||
|
||||
const _PingEntry({
|
||||
required this.result,
|
||||
required this.timestamp,
|
||||
this.distance,
|
||||
});
|
||||
}
|
||||
|
||||
class _PingContactSheetState extends State<PingContactSheet> {
|
||||
static const Duration _autoPingInterval = Duration(seconds: 2);
|
||||
static const Duration _maxAutoPingDelay = Duration(seconds: 5);
|
||||
static const int _maxHistoryEntries = 24;
|
||||
static const double _maxHistoryHeight = 320;
|
||||
static const Duration _timeoutPenalty = Duration(seconds: 10);
|
||||
|
||||
bool _pinging = false;
|
||||
bool _autoPingEnabled = false;
|
||||
final List<_PingEntry> _history = [];
|
||||
final _PingCuePlayer _cuePlayer = _PingCuePlayer();
|
||||
Timer? _autoPingTimer;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_doPing();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_autoPingTimer?.cancel();
|
||||
_cuePlayer.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _doPing() async {
|
||||
if (_pinging) {
|
||||
return;
|
||||
}
|
||||
setState(() => _pinging = true);
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final distance = _distanceText();
|
||||
final result = await connectionProvider.pingRelay(widget.contact);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_pinging = false;
|
||||
_history.insert(
|
||||
0,
|
||||
_PingEntry(
|
||||
result: result,
|
||||
timestamp: DateTime.now(),
|
||||
distance: distance,
|
||||
),
|
||||
);
|
||||
if (_history.length > _maxHistoryEntries) {
|
||||
_history.removeRange(_maxHistoryEntries, _history.length);
|
||||
}
|
||||
});
|
||||
await _maybePlayCue(result);
|
||||
_scheduleNextAutoPing(result);
|
||||
}
|
||||
|
||||
Future<void> _maybePlayCue(RelayPingResult result) async {
|
||||
if (!_autoPingEnabled) {
|
||||
return;
|
||||
}
|
||||
|
||||
final previous = _history.length >= 2 ? _history[1].result : null;
|
||||
await _cuePlayer.playCue(result, previous: previous);
|
||||
}
|
||||
|
||||
void _setAutoPingEnabled(bool enabled) {
|
||||
setState(() {
|
||||
_autoPingEnabled = enabled;
|
||||
});
|
||||
_autoPingTimer?.cancel();
|
||||
if (!enabled) {
|
||||
return;
|
||||
}
|
||||
_scheduleNextAutoPing();
|
||||
}
|
||||
|
||||
void _scheduleNextAutoPing([RelayPingResult? result]) {
|
||||
_autoPingTimer?.cancel();
|
||||
if (!_autoPingEnabled || !mounted) {
|
||||
return;
|
||||
}
|
||||
final responseDelay = result == null
|
||||
? Duration.zero
|
||||
: result.success
|
||||
? Duration(milliseconds: result.durationMs)
|
||||
: _timeoutPenalty;
|
||||
final nextDelay = _autoPingInterval + responseDelay;
|
||||
final clampedDelay = nextDelay > _maxAutoPingDelay
|
||||
? _maxAutoPingDelay
|
||||
: nextDelay;
|
||||
_autoPingTimer = Timer(clampedDelay, () {
|
||||
unawaited(_doPing());
|
||||
});
|
||||
}
|
||||
|
||||
String? _distanceText() {
|
||||
final location = widget.contact.displayLocation;
|
||||
if (location == null) return null;
|
||||
final currentPosition = LocationTrackingService().currentPosition;
|
||||
if (currentPosition == null) return null;
|
||||
final meters = Geolocator.distanceBetween(
|
||||
currentPosition.latitude,
|
||||
currentPosition.longitude,
|
||||
location.latitude,
|
||||
location.longitude,
|
||||
);
|
||||
if (meters < 1000) return '${meters.round()} m';
|
||||
if (meters < 10000) return '${(meters / 1000).toStringAsFixed(2)} km';
|
||||
return '${(meters / 1000).toStringAsFixed(1)} km';
|
||||
}
|
||||
|
||||
Widget _buildPill(
|
||||
BuildContext context, {
|
||||
required IconData icon,
|
||||
required String label,
|
||||
Color? iconColor,
|
||||
}) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest.withValues(alpha: 0.75),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 12, color: iconColor ?? colorScheme.onSurfaceVariant),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSnrPill(BuildContext context, String direction, double snrDb) {
|
||||
final quality = linkQualityLabel(null, snrDb);
|
||||
final color = linkQualityColor(quality);
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(999),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(
|
||||
direction == 'there'
|
||||
? Icons.arrow_upward_rounded
|
||||
: Icons.arrow_downward_rounded,
|
||||
size: 12,
|
||||
color: color,
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(
|
||||
'${snrDb.toStringAsFixed(1)} dB',
|
||||
style: Theme.of(context).textTheme.labelSmall?.copyWith(
|
||||
color: color,
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildResultRow(BuildContext context, _PingEntry entry, int seq) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
final r = entry.result;
|
||||
final age = DateTime.now().difference(entry.timestamp);
|
||||
final timeAgo = age.toLocalizedTimeAgoWithSeconds(context);
|
||||
|
||||
if (!r.success) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: colorScheme.error.withValues(alpha: 0.15),
|
||||
child: Text(
|
||||
'$seq',
|
||||
style: TextStyle(
|
||||
color: colorScheme.error,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Text(
|
||||
'Timeout',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.error,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
Text(
|
||||
timeAgo,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 12,
|
||||
backgroundColor: colorScheme.surfaceContainerHighest,
|
||||
child: Text(
|
||||
'$seq',
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
_buildPill(
|
||||
context,
|
||||
icon: Icons.timer_outlined,
|
||||
label: '${r.durationMs} ms',
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
_buildSnrPill(context, 'there', r.snrThere),
|
||||
const SizedBox(width: 6),
|
||||
_buildSnrPill(context, 'back', r.snrBack),
|
||||
const Spacer(),
|
||||
Text(
|
||||
timeAgo,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 34, top: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
if (entry.distance != null) ...[
|
||||
Icon(
|
||||
Icons.straighten,
|
||||
size: 10,
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.5),
|
||||
),
|
||||
const SizedBox(width: 3),
|
||||
Text(
|
||||
entry.distance!,
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.6),
|
||||
fontSize: 10,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 12, 16, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Ping ${widget.contact.displayName}',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w800,
|
||||
),
|
||||
),
|
||||
),
|
||||
FilledButton.icon(
|
||||
onPressed: _pinging ? null : _doPing,
|
||||
icon: _pinging
|
||||
? SizedBox(
|
||||
width: 16,
|
||||
height: 16,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2,
|
||||
color: colorScheme.onPrimary,
|
||||
),
|
||||
)
|
||||
: const Icon(Icons.network_ping, size: 18),
|
||||
label: Text(_pinging ? 'Pinging...' : 'Ping again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
widget.contact.publicKeyShort,
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontFamily: 'monospace',
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerLow,
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
border: Border.all(
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(
|
||||
_autoPingEnabled
|
||||
? Icons.radar_rounded
|
||||
: Icons.radar_outlined,
|
||||
color: _autoPingEnabled
|
||||
? colorScheme.primary
|
||||
: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'Auto ping',
|
||||
style: theme.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
'2s sonar cues for there/back SNR',
|
||||
style: theme.textTheme.bodySmall?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch.adaptive(
|
||||
value: _autoPingEnabled,
|
||||
onChanged: _setAutoPingEnabled,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
if (_history.isEmpty && !_pinging)
|
||||
Text(
|
||||
'No ping results yet.',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
)
|
||||
else
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(
|
||||
maxHeight: _maxHistoryHeight,
|
||||
),
|
||||
child: ListView.separated(
|
||||
shrinkWrap: true,
|
||||
itemCount: _history.length,
|
||||
separatorBuilder: (_, _) => Divider(
|
||||
height: 1,
|
||||
color: colorScheme.outlineVariant.withValues(alpha: 0.4),
|
||||
),
|
||||
itemBuilder: (context, index) =>
|
||||
_buildResultRow(context, _history[index], index + 1),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PingCuePlayer {
|
||||
static const double _fairSnrThresholdDb = 0;
|
||||
|
||||
AudioPlayer? _player;
|
||||
|
||||
Future<void> playCue(
|
||||
RelayPingResult result, {
|
||||
RelayPingResult? previous,
|
||||
}) async {
|
||||
_player ??= AudioPlayer();
|
||||
final player = _player!;
|
||||
final filePath = await _writeCueFile(
|
||||
result,
|
||||
previous: previous,
|
||||
);
|
||||
await player.stop();
|
||||
await player.setVolume(1.0);
|
||||
await player.play(DeviceFileSource(filePath));
|
||||
}
|
||||
|
||||
Future<String> _writeCueFile(
|
||||
RelayPingResult result, {
|
||||
RelayPingResult? previous,
|
||||
}) async {
|
||||
final tempDir = await getTemporaryDirectory();
|
||||
final file = File('${tempDir.path}/ping_sonar_alert.wav');
|
||||
final samples = _buildCueSamples(result, previous: previous);
|
||||
final wavBytes = _buildWav(samples, sampleRate: 16000);
|
||||
await file.writeAsBytes(wavBytes, flush: true);
|
||||
return file.path;
|
||||
}
|
||||
|
||||
Int16List _buildCueSamples(
|
||||
RelayPingResult result, {
|
||||
RelayPingResult? previous,
|
||||
}) {
|
||||
const sampleRate = 16000;
|
||||
final segments = <double>[];
|
||||
if (!result.success) {
|
||||
segments.addAll(
|
||||
_timeoutAlert(sampleRate),
|
||||
);
|
||||
} else {
|
||||
segments.addAll(
|
||||
_tone(
|
||||
sampleRate,
|
||||
frequencyHz: _frequencyForSnr(result.snrThere),
|
||||
durationMs: 90,
|
||||
amplitude: 0.42,
|
||||
),
|
||||
);
|
||||
segments.addAll(_silence(sampleRate, durationMs: 35));
|
||||
segments.addAll(
|
||||
_tone(
|
||||
sampleRate,
|
||||
frequencyHz: _frequencyForSnr(result.snrBack),
|
||||
durationMs: 90,
|
||||
amplitude: 0.42,
|
||||
),
|
||||
);
|
||||
final belowFair =
|
||||
result.snrThere < _fairSnrThresholdDb &&
|
||||
result.snrBack < _fairSnrThresholdDb;
|
||||
final worsening =
|
||||
previous != null &&
|
||||
result.snrThere < previous.snrThere &&
|
||||
result.snrBack < previous.snrBack;
|
||||
if (belowFair && worsening) {
|
||||
segments.addAll(_silence(sampleRate, durationMs: 40));
|
||||
segments.addAll(
|
||||
_descendingAlert(sampleRate, baseFrequencyHz: 520),
|
||||
);
|
||||
}
|
||||
}
|
||||
final pcm = Int16List(segments.length);
|
||||
for (var i = 0; i < segments.length; i++) {
|
||||
pcm[i] = (segments[i] * 32767).round().clamp(-32768, 32767);
|
||||
}
|
||||
return pcm;
|
||||
}
|
||||
|
||||
double _frequencyForSnr(double snrDb) {
|
||||
final clamped = snrDb.clamp(-12.0, 12.0);
|
||||
final normalized = (clamped + 12.0) / 24.0;
|
||||
return 320 + (normalized * 900);
|
||||
}
|
||||
|
||||
List<double> _descendingAlert(int sampleRate, {required double baseFrequencyHz}) {
|
||||
return <double>[
|
||||
..._tone(
|
||||
sampleRate,
|
||||
frequencyHz: baseFrequencyHz,
|
||||
durationMs: 80,
|
||||
amplitude: 0.44,
|
||||
),
|
||||
..._silence(sampleRate, durationMs: 30),
|
||||
..._tone(
|
||||
sampleRate,
|
||||
frequencyHz: baseFrequencyHz * 0.82,
|
||||
durationMs: 110,
|
||||
amplitude: 0.40,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<double> _timeoutAlert(int sampleRate) {
|
||||
return <double>[
|
||||
..._tone(
|
||||
sampleRate,
|
||||
frequencyHz: 240,
|
||||
durationMs: 180,
|
||||
amplitude: 0.46,
|
||||
),
|
||||
..._silence(sampleRate, durationMs: 60),
|
||||
..._tone(
|
||||
sampleRate,
|
||||
frequencyHz: 180,
|
||||
durationMs: 220,
|
||||
amplitude: 0.52,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<double> _tone(
|
||||
int sampleRate, {
|
||||
required double frequencyHz,
|
||||
required int durationMs,
|
||||
required double amplitude,
|
||||
}) {
|
||||
final sampleCount = (sampleRate * durationMs / 1000).round();
|
||||
return List<double>.generate(sampleCount, (index) {
|
||||
final t = index / sampleRate;
|
||||
final envelope = math.sin(math.pi * index / sampleCount);
|
||||
return math.sin(2 * math.pi * frequencyHz * t) * amplitude * envelope;
|
||||
});
|
||||
}
|
||||
|
||||
List<double> _silence(int sampleRate, {required int durationMs}) {
|
||||
final sampleCount = (sampleRate * durationMs / 1000).round();
|
||||
return List<double>.filled(sampleCount, 0);
|
||||
}
|
||||
|
||||
Uint8List _buildWav(Int16List samples, {required int sampleRate}) {
|
||||
const numChannels = 1;
|
||||
const bitsPerSample = 16;
|
||||
const audioFormat = 1;
|
||||
final dataSize = samples.length * 2;
|
||||
final byteRate = sampleRate * numChannels * bitsPerSample ~/ 8;
|
||||
final blockAlign = numChannels * bitsPerSample ~/ 8;
|
||||
final totalSize = 36 + dataSize;
|
||||
final buffer = ByteData(44 + dataSize);
|
||||
var offset = 0;
|
||||
|
||||
void writeString(String value) {
|
||||
for (final codeUnit in value.codeUnits) {
|
||||
buffer.setUint8(offset++, codeUnit);
|
||||
}
|
||||
}
|
||||
|
||||
void writeUint32(int value) {
|
||||
buffer.setUint32(offset, value, Endian.little);
|
||||
offset += 4;
|
||||
}
|
||||
|
||||
void writeUint16(int value) {
|
||||
buffer.setUint16(offset, value, Endian.little);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
writeString('RIFF');
|
||||
writeUint32(totalSize);
|
||||
writeString('WAVE');
|
||||
writeString('fmt ');
|
||||
writeUint32(16);
|
||||
writeUint16(audioFormat);
|
||||
writeUint16(numChannels);
|
||||
writeUint32(sampleRate);
|
||||
writeUint32(byteRate);
|
||||
writeUint16(blockAlign);
|
||||
writeUint16(bitsPerSample);
|
||||
writeString('data');
|
||||
writeUint32(dataSize);
|
||||
|
||||
for (final sample in samples) {
|
||||
buffer.setInt16(offset, sample, Endian.little);
|
||||
offset += 2;
|
||||
}
|
||||
|
||||
return buffer.buffer.asUint8List();
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_player?.dispose();
|
||||
}
|
||||
}
|
||||
513
lib/widgets/contacts/room_login_sheet.dart
Normal file
513
lib/widgets/contacts/room_login_sheet.dart
Normal file
@@ -0,0 +1,513 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../../l10n/app_localizations.dart';
|
||||
import '../../models/contact.dart';
|
||||
import '../../providers/connection_provider.dart';
|
||||
import '../../providers/contacts_provider.dart';
|
||||
|
||||
class RoomLoginSheet extends StatefulWidget {
|
||||
final Contact contact;
|
||||
|
||||
const RoomLoginSheet({super.key, required this.contact});
|
||||
|
||||
@override
|
||||
State<RoomLoginSheet> createState() => _RoomLoginSheetState();
|
||||
}
|
||||
|
||||
class _RoomLoginSheetState extends State<RoomLoginSheet> {
|
||||
final TextEditingController _passwordController = TextEditingController();
|
||||
final FocusNode _focusNode = FocusNode();
|
||||
bool _isLoggingIn = false;
|
||||
bool _obscurePassword = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_loadSavedPassword();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_passwordController.dispose();
|
||||
_focusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Load saved password for this room
|
||||
Future<void> _loadSavedPassword() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final roomKey = 'room_password_${widget.contact.publicKeyHex}';
|
||||
final savedPassword = prefs.getString(roomKey);
|
||||
if (savedPassword != null) {
|
||||
_passwordController.text = savedPassword;
|
||||
}
|
||||
}
|
||||
|
||||
/// Save password for this room
|
||||
Future<void> _savePassword(String password) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final roomKey = 'room_password_${widget.contact.publicKeyHex}';
|
||||
await prefs.setString(roomKey, password);
|
||||
}
|
||||
|
||||
Future<void> _loginToRoom() async {
|
||||
final password = _passwordController.text.trim();
|
||||
|
||||
final connectionProvider = context.read<ConnectionProvider>();
|
||||
final contactsProvider = context.read<ContactsProvider>();
|
||||
|
||||
if (password.isEmpty) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.pleaseEnterPassword),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!connectionProvider.deviceInfo.isConnected) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(AppLocalizations.of(context)!.deviceNotConnected),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_isLoggingIn = true;
|
||||
});
|
||||
|
||||
// 🕐 CLOCK DRIFT CHECK: Get device time to detect synchronization issues
|
||||
debugPrint(
|
||||
'🕐 [RoomLogin] Checking for clock drift between app and radio...',
|
||||
);
|
||||
try {
|
||||
await connectionProvider.getDeviceTime();
|
||||
// Give time for response to be logged
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
} catch (e) {
|
||||
debugPrint('⚠️ [RoomLogin] Failed to get device time: $e');
|
||||
// Don't fail login - this is just a diagnostic check
|
||||
}
|
||||
|
||||
// 🔍 PRE-LOGIN CHECK: Ensure room contact exists in device
|
||||
debugPrint(
|
||||
'🔍 [RoomLogin] Checking if room "${widget.contact.advName}" exists in contacts...',
|
||||
);
|
||||
debugPrint(
|
||||
' Target public key prefix: ${widget.contact.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')}',
|
||||
);
|
||||
|
||||
// Check if the room exists in our local contacts
|
||||
bool roomExists = contactsProvider.rooms.any(
|
||||
(room) => room.publicKeyHex == widget.contact.publicKeyHex,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
' Local contact list: ${roomExists ? "✅ Found" : "❌ Not found"}',
|
||||
);
|
||||
|
||||
if (!roomExists) {
|
||||
debugPrint(
|
||||
'⚠️ [RoomLogin] Room not in local contacts - syncing with device...',
|
||||
);
|
||||
|
||||
try {
|
||||
// Sync contacts from device
|
||||
await connectionProvider.getContacts();
|
||||
|
||||
// Give time for contacts to be processed
|
||||
await Future.delayed(const Duration(milliseconds: 800));
|
||||
|
||||
// Check again after sync
|
||||
roomExists = contactsProvider.rooms.any(
|
||||
(room) => room.publicKeyHex == widget.contact.publicKeyHex,
|
||||
);
|
||||
|
||||
debugPrint(
|
||||
' After sync: ${roomExists ? "✅ Found" : "❌ Still not found"}',
|
||||
);
|
||||
|
||||
if (!roomExists) {
|
||||
// Room still doesn't exist on the device - try to add it manually
|
||||
debugPrint('❌ [RoomLogin] Room still not found after sync');
|
||||
debugPrint(
|
||||
'🔧 [RoomLogin] Attempting to add room contact to companion radio...',
|
||||
);
|
||||
|
||||
try {
|
||||
// Manually add the room contact to the radio's flash storage
|
||||
await connectionProvider.addOrUpdateContact(widget.contact);
|
||||
final addError = connectionProvider.error;
|
||||
if (addError != null) {
|
||||
throw Exception(addError);
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'✅ [RoomLogin] Room contact added via CMD_ADD_UPDATE_CONTACT',
|
||||
);
|
||||
debugPrint(' Waiting 500ms for radio to save to flash...');
|
||||
|
||||
// Give the radio time to save the contact to flash
|
||||
await Future.delayed(const Duration(milliseconds: 500));
|
||||
|
||||
debugPrint(
|
||||
'✅ [RoomLogin] Room contact should now be available - proceeding with login',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [RoomLogin] Failed to add room contact: $e');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoggingIn = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.failedToAddRoom(e.toString()),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
duration: const Duration(seconds: 7),
|
||||
),
|
||||
);
|
||||
|
||||
// Log available rooms for debugging
|
||||
final availableRooms = contactsProvider.rooms;
|
||||
debugPrint(
|
||||
'📋 [RoomLogin] Available rooms on device (${availableRooms.length}):',
|
||||
);
|
||||
for (final room in availableRooms) {
|
||||
debugPrint(
|
||||
' - ${room.advName} (${room.publicKeyPrefix.map((b) => b.toRadixString(16).padLeft(2, '0')).join(':')})',
|
||||
);
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
debugPrint(
|
||||
'✅ [RoomLogin] Room contact found after sync - proceeding with login',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('❌ [RoomLogin] Contact sync failed: $e');
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
setState(() {
|
||||
_isLoggingIn = false;
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
AppLocalizations.of(context)!.failedToSyncContacts(e.toString()),
|
||||
),
|
||||
backgroundColor: Theme.of(context).colorScheme.error,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
debugPrint(
|
||||
'✅ [RoomLogin] Room contact found in local contacts - proceeding with login',
|
||||
);
|
||||
}
|
||||
|
||||
// Save password before sending
|
||||
await _savePassword(password);
|
||||
|
||||
if (!mounted) return;
|
||||
|
||||
// Capture references before the sheet pops: the login result arrives
|
||||
// after Navigator.pop, when this State is disposed, so the callbacks
|
||||
// below must not depend on this widget's context.
|
||||
final messenger = ScaffoldMessenger.of(context);
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
// Set up login callbacks
|
||||
Function(Uint8List, int, bool, int)? originalOnSuccess;
|
||||
Function(Uint8List)? originalOnFail;
|
||||
|
||||
originalOnSuccess = connectionProvider.onLoginSuccess;
|
||||
originalOnFail = connectionProvider.onLoginFail;
|
||||
|
||||
connectionProvider
|
||||
.onLoginSuccess = (publicKeyPrefix, permissions, isAdmin, tag) async {
|
||||
// Restore original callback
|
||||
connectionProvider.onLoginSuccess = originalOnSuccess;
|
||||
connectionProvider.onLoginFail = originalOnFail;
|
||||
|
||||
debugPrint(
|
||||
'✅ [RoomLogin] Login successful! Tag: $tag, Permissions: $permissions, Admin: $isAdmin',
|
||||
);
|
||||
debugPrint(
|
||||
'📡 [RoomLogin] Room server will now push messages automatically via PUSH_CODE_MSG_WAITING',
|
||||
);
|
||||
debugPrint(
|
||||
' Messages will be fetched when onMessageWaiting callback is triggered',
|
||||
);
|
||||
|
||||
// Use the captured messenger: the sheet has already been popped by the
|
||||
// time this callback fires, so this State's context is gone.
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.loggedInSuccessfully),
|
||||
backgroundColor: colorScheme.primary,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
connectionProvider.onLoginFail = (publicKeyPrefix) {
|
||||
// Restore original callback
|
||||
connectionProvider.onLoginSuccess = originalOnSuccess;
|
||||
connectionProvider.onLoginFail = originalOnFail;
|
||||
|
||||
debugPrint('❌ [RoomLogin] Login failed - incorrect password');
|
||||
|
||||
// Use the captured messenger: the sheet has already been popped by the
|
||||
// time this callback fires, so this State's context is gone.
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.loginFailed),
|
||||
backgroundColor: colorScheme.error,
|
||||
duration: const Duration(seconds: 3),
|
||||
),
|
||||
);
|
||||
};
|
||||
|
||||
try {
|
||||
// Send login request to room
|
||||
await connectionProvider.loginToRoom(
|
||||
roomPublicKey: widget.contact.publicKey,
|
||||
password: password,
|
||||
);
|
||||
|
||||
_focusNode.unfocus();
|
||||
|
||||
if (mounted) {
|
||||
Navigator.pop(context); // Close the dialog
|
||||
}
|
||||
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.loggingIn(widget.contact.displayName)),
|
||||
backgroundColor: colorScheme.primary,
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
} catch (e) {
|
||||
// Restore original callbacks on error
|
||||
connectionProvider.onLoginSuccess = originalOnSuccess;
|
||||
connectionProvider.onLoginFail = originalOnFail;
|
||||
|
||||
messenger.showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(l10n.failedToSendLogin(e.toString())),
|
||||
backgroundColor: colorScheme.error,
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
if (mounted) {
|
||||
setState(() {
|
||||
_isLoggingIn = false;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final colorScheme = theme.colorScheme;
|
||||
|
||||
return Container(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.75,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surface,
|
||||
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// Header
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Row(
|
||||
children: [
|
||||
IconButton(
|
||||
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
|
||||
onPressed: () => Navigator.pop(context),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(
|
||||
AppLocalizations.of(context)!.loginToRoom,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
widget.contact.displayName,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 48), // Balance the back button
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// Scrollable content area
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
children: [
|
||||
// Info banner
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(
|
||||
Icons.info_outline,
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
size: 20,
|
||||
),
|
||||
SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
AppLocalizations.of(context)!.enterPasswordInfo,
|
||||
style: TextStyle(
|
||||
color: colorScheme.onPrimaryContainer,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
// Password input (fixed at bottom)
|
||||
Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: colorScheme.surfaceContainerHighest,
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
TextField(
|
||||
controller: _passwordController,
|
||||
focusNode: _focusNode,
|
||||
maxLength: 15, // Max password length from protocol
|
||||
obscureText: _obscurePassword,
|
||||
autofocus: true,
|
||||
maxLengthEnforcement: MaxLengthEnforcement.enforced,
|
||||
style: TextStyle(color: colorScheme.onSurface),
|
||||
decoration: InputDecoration(
|
||||
labelText: AppLocalizations.of(context)!.password,
|
||||
labelStyle: TextStyle(
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
hintText: AppLocalizations.of(context)!.enterRoomPassword,
|
||||
hintStyle: TextStyle(color: colorScheme.onSurfaceVariant),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(color: colorScheme.outline),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide(
|
||||
color: colorScheme.primary,
|
||||
width: 2,
|
||||
),
|
||||
),
|
||||
contentPadding: const EdgeInsets.all(16),
|
||||
suffixIcon: IconButton(
|
||||
icon: Icon(
|
||||
_obscurePassword
|
||||
? Icons.visibility
|
||||
: Icons.visibility_off,
|
||||
color: colorScheme.onSurfaceVariant,
|
||||
),
|
||||
onPressed: () {
|
||||
setState(() {
|
||||
_obscurePassword = !_obscurePassword;
|
||||
});
|
||||
},
|
||||
),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onSubmitted: (_) => _loginToRoom(),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: _isLoggingIn ? null : _loginToRoom,
|
||||
icon: _isLoggingIn
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Icon(Icons.login),
|
||||
label: Text(
|
||||
_isLoggingIn
|
||||
? AppLocalizations.of(context)!.loggingInDots
|
||||
: AppLocalizations.of(context)!.login,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
45
lib/widgets/contacts/section_header.dart
Normal file
45
lib/widgets/contacts/section_header.dart
Normal file
@@ -0,0 +1,45 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final int count;
|
||||
final IconData icon;
|
||||
|
||||
const SectionHeader({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.count,
|
||||
required this.icon,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).colorScheme.primaryContainer,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
count.toString(),
|
||||
style: Theme.of(context).textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user