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:
Janez Troha
2026-09-04 16:53:52 +02:00
commit 7924803351
516 changed files with 207265 additions and 0 deletions

View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
enum _RefreshEdge { top, bottom }
class BidirectionalRefresh extends StatefulWidget {
static const double defaultTriggerDistance = 90;
final Widget child;
final Future<void> Function() onRefresh;
final double triggerDistance;
const BidirectionalRefresh({
super.key,
required this.child,
required this.onRefresh,
this.triggerDistance = defaultTriggerDistance,
});
@override
State<BidirectionalRefresh> createState() => _BidirectionalRefreshState();
}
class _BidirectionalRefreshState extends State<BidirectionalRefresh> {
double _topPullDistance = 0;
double _bottomPullDistance = 0;
bool _isRefreshing = false;
bool _handleScrollNotification(ScrollNotification notification) {
if (_isRefreshing) {
return false;
}
if (notification is ScrollStartNotification) {
_resetPullDistance();
return false;
}
if (notification is OverscrollNotification) {
final metrics = notification.metrics;
final isAtTop = metrics.extentBefore == 0;
final isAtBottom = metrics.extentAfter == 0;
if (!isAtTop && !isAtBottom) {
return false;
}
var shouldRefresh = false;
setState(() {
if (isAtTop) {
_topPullDistance += notification.overscroll.abs();
shouldRefresh = _topPullDistance >= widget.triggerDistance;
}
if (isAtBottom) {
_bottomPullDistance += notification.overscroll.abs();
shouldRefresh =
shouldRefresh || _bottomPullDistance >= widget.triggerDistance;
}
});
if (shouldRefresh) {
_runRefresh();
}
return false;
}
if (notification is ScrollEndNotification) {
_resetPullDistance();
}
return false;
}
Future<void> _runRefresh() async {
if (_isRefreshing) {
return;
}
setState(() {
_isRefreshing = true;
});
try {
await widget.onRefresh();
} finally {
if (mounted) {
setState(() {
_isRefreshing = false;
_topPullDistance = 0;
_bottomPullDistance = 0;
});
}
}
}
void _resetPullDistance() {
if (_topPullDistance == 0 && _bottomPullDistance == 0) {
return;
}
setState(() {
_topPullDistance = 0;
_bottomPullDistance = 0;
});
}
@override
Widget build(BuildContext context) {
final topProgress = (_topPullDistance / widget.triggerDistance).clamp(
0.0,
1.0,
);
final bottomProgress = (_bottomPullDistance / widget.triggerDistance).clamp(
0.0,
1.0,
);
return NotificationListener<ScrollNotification>(
onNotification: _handleScrollNotification,
child: Stack(
children: [
widget.child,
_RefreshIndicatorOverlay(
alignment: Alignment.topCenter,
progress: _isRefreshing ? 1 : topProgress,
visible: _isRefreshing || _topPullDistance > 0,
edge: _RefreshEdge.top,
),
_RefreshIndicatorOverlay(
alignment: Alignment.bottomCenter,
progress: _isRefreshing ? 1 : bottomProgress,
visible: _isRefreshing || _bottomPullDistance > 0,
edge: _RefreshEdge.bottom,
),
],
),
);
}
}
class _RefreshIndicatorOverlay extends StatelessWidget {
final Alignment alignment;
final double progress;
final bool visible;
final _RefreshEdge edge;
const _RefreshIndicatorOverlay({
required this.alignment,
required this.progress,
required this.visible,
required this.edge,
});
@override
Widget build(BuildContext context) {
if (!visible) {
return const SizedBox.shrink();
}
final indicator = RefreshProgressIndicator(value: progress);
final padding = edge == _RefreshEdge.top
? const EdgeInsets.only(top: 12)
: const EdgeInsets.only(bottom: 12);
return Positioned.fill(
child: IgnorePointer(
child: Align(
alignment: alignment,
child: Padding(
padding: padding,
child: SizedBox.square(dimension: 28, child: indicator),
),
),
),
);
}
}

View File

@@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../services/trail_color_service.dart';
import '../../utils/avatar_label_helper.dart';
class ContactAvatar extends StatelessWidget {
final Contact contact;
final double radius;
final String? displayName;
const ContactAvatar({
super.key,
required this.contact,
this.radius = 20,
this.displayName,
});
@override
Widget build(BuildContext context) {
final backgroundColor = _getBackgroundColor(context);
final foregroundColor = _getForegroundColor(backgroundColor);
final emoji = contact.roleEmoji;
if (_showsLeadingEmoji && emoji != null && emoji.isNotEmpty) {
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Text(emoji, style: TextStyle(fontSize: radius * 1.05)),
);
}
if (_shouldUseLabelFallback) {
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Text(
AvatarLabelHelper.buildLabel(displayName ?? contact.displayName),
style: TextStyle(
color: foregroundColor,
fontSize: radius * 0.68,
fontWeight: FontWeight.w700,
letterSpacing: -0.4,
),
),
);
}
return _buildAvatarFrame(
backgroundColor: backgroundColor,
child: Icon(
_getTypeIcon(contact.type),
color: foregroundColor,
size: radius,
),
);
}
Widget _buildAvatarFrame({
required Color backgroundColor,
required Widget child,
}) {
if (_usesSquareShape) {
return Container(
width: radius * 2,
height: radius * 2,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(radius * 0.6),
),
alignment: Alignment.center,
child: child,
);
}
return CircleAvatar(
radius: radius,
backgroundColor: backgroundColor,
child: child,
);
}
bool get _shouldUseLabelFallback =>
contact.type == ContactType.chat ||
contact.type == ContactType.channel ||
contact.type == ContactType.room;
bool get _usesSquareShape =>
contact.type == ContactType.channel || contact.type == ContactType.room;
bool get _showsLeadingEmoji {
final emoji = contact.roleEmoji;
if (emoji == null || emoji.isEmpty) return false;
final effectiveName = (displayName ?? contact.displayName).trimLeft();
return effectiveName.startsWith(emoji);
}
Color _getBackgroundColor(BuildContext context) {
if (_shouldUseLabelFallback || _showsLeadingEmoji) {
return TrailColorService.getTrailColor(contact);
}
switch (contact.type) {
case ContactType.none:
return Theme.of(context).colorScheme.surfaceContainerHighest;
case ContactType.chat:
return Colors.blue;
case ContactType.repeater:
return Colors.orange;
case ContactType.room:
return Colors.purple;
case ContactType.sensor:
return Colors.green;
case ContactType.channel:
return Colors.teal;
}
}
Color _getForegroundColor(Color backgroundColor) {
return ThemeData.estimateBrightnessForColor(backgroundColor) ==
Brightness.dark
? Colors.white
: Colors.black87;
}
IconData _getTypeIcon(ContactType type) {
switch (type) {
case ContactType.none:
return Icons.help_outline;
case ContactType.chat:
return Icons.person;
case ContactType.repeater:
return Icons.router;
case ContactType.room:
return Icons.meeting_room;
case ContactType.sensor:
return Icons.sensors;
case ContactType.channel:
return Icons.public;
}
}
}

View File

@@ -0,0 +1,245 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:latlong2/latlong.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/location_formats.dart';
/// Reusable location display widget with tap-to-show modal
/// Shows coordinates in a compact format with ability to view all formats
class LocationDisplay extends StatelessWidget {
final LatLng location;
final bool compact;
const LocationDisplay({
super.key,
required this.location,
this.compact = true,
});
@override
Widget build(BuildContext context) {
if (compact) {
return GestureDetector(
onTap: () => _showLocationFormats(context),
child: Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.location_on, size: 18),
const SizedBox(width: 6),
Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
),
),
const SizedBox(width: 6),
Icon(
Icons.open_in_new,
size: 14,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
);
}
// Non-compact version (just text)
return Text(
'${location.latitude.toStringAsFixed(5)}, ${location.longitude.toStringAsFixed(5)}',
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
);
}
void _showLocationFormats(BuildContext context) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (context) => SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Location Formats',
style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
),
IconButton(
icon: const Icon(Icons.close),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 16),
const Divider(),
const SizedBox(height: 8),
// Decimal Degrees (DD)
_buildFormatRow(
context,
'DD (Decimal Degrees)',
'${location.latitude.toStringAsFixed(6)}, ${location.longitude.toStringAsFixed(6)}',
),
// Degrees Minutes Seconds (DMS)
_buildFormatRow(
context,
'DMS (Degrees Minutes Seconds)',
_convertToDMS(location.latitude, location.longitude),
),
// Degrees Decimal Minutes (DDM)
_buildFormatRow(
context,
'DDM (Degrees Decimal Minutes)',
_convertToDDM(location.latitude, location.longitude),
),
// MGRS (Military Grid Reference System)
_buildFormatRow(
context,
'MGRS (Military Grid)',
_convertToMGRS(location.latitude, location.longitude),
),
// Google Plus Code
_buildFormatRow(
context,
'Plus Code',
formatPlusCode(location.latitude, location.longitude),
),
const SizedBox(height: 8),
],
),
),
),
);
}
Widget _buildFormatRow(BuildContext context, String label, String value) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Colors.grey,
fontWeight: FontWeight.w500,
),
),
const SizedBox(height: 4),
InkWell(
onTap: () {
Clipboard.setData(ClipboardData(text: value));
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(context)!.copiedToClipboard(label),
),
duration: const Duration(seconds: 2),
),
);
},
borderRadius: BorderRadius.circular(8),
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
Expanded(
child: Text(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
fontWeight: FontWeight.w500,
),
),
),
Icon(
Icons.copy,
size: 18,
color: Theme.of(context).colorScheme.primary,
),
],
),
),
),
],
),
);
}
/// Convert to Degrees Minutes Seconds (DMS) format
String _convertToDMS(double lat, double lon) {
String latDir = lat >= 0 ? 'N' : 'S';
String lonDir = lon >= 0 ? 'E' : 'W';
lat = lat.abs();
lon = lon.abs();
int latDeg = lat.floor();
double latMinDec = (lat - latDeg) * 60;
int latMin = latMinDec.floor();
double latSec = (latMinDec - latMin) * 60;
int lonDeg = lon.floor();
double lonMinDec = (lon - lonDeg) * 60;
int lonMin = lonMinDec.floor();
double lonSec = (lonMinDec - lonMin) * 60;
return '$latDeg°$latMin\'${latSec.toStringAsFixed(2)}"$latDir, $lonDeg°$lonMin\'${lonSec.toStringAsFixed(2)}"$lonDir';
}
/// Convert to Degrees Decimal Minutes (DDM) format
String _convertToDDM(double lat, double lon) {
String latDir = lat >= 0 ? 'N' : 'S';
String lonDir = lon >= 0 ? 'E' : 'W';
lat = lat.abs();
lon = lon.abs();
int latDeg = lat.floor();
double latMin = (lat - latDeg) * 60;
int lonDeg = lon.floor();
double lonMin = (lon - lonDeg) * 60;
return '$latDeg° ${latMin.toStringAsFixed(4)}\'$latDir, $lonDeg° ${lonMin.toStringAsFixed(4)}\'$lonDir';
}
/// Convert to MGRS (Military Grid Reference System) format
/// Simplified implementation - returns approximate grid zone
String _convertToMGRS(double lat, double lon) {
// Zone number (1-60)
int zone = ((lon + 180) / 6).floor() + 1;
// Zone letter (C-X, excluding I and O)
const letters = 'CDEFGHJKLMNPQRSTUVWX';
int letterIndex = ((lat + 80) / 8).floor();
if (letterIndex < 0) letterIndex = 0;
if (letterIndex >= letters.length) letterIndex = letters.length - 1;
String letter = letters[letterIndex];
// Simplified - just show zone designation
// Full MGRS would require UTM conversion library
return '$zone$letter (approximate)';
}
}

View File

@@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:meshcore_client/meshcore_client.dart' show LogRxDataInfo;
class SignalMetric {
final String valueLabel;
final Color color;
final int activeBars;
const SignalMetric({
required this.valueLabel,
required this.color,
required this.activeBars,
});
static SignalMetric? fromRxInfo(LogRxDataInfo? rxInfo) {
if (rxInfo == null) return null;
return fromValues(rssiDbm: rxInfo.rssiDbm, snrDb: rxInfo.snrDb);
}
static SignalMetric? fromValues({int? rssiDbm, double? snrDb}) {
if (snrDb != null) {
return SignalMetric(
valueLabel: '${snrDb.toStringAsFixed(1)}dB',
color: snrDb >= 10
? Colors.green
: snrDb >= 0
? Colors.amber
: Colors.redAccent,
activeBars: snrDb >= 10
? 3
: snrDb >= 0
? 2
: 1,
);
}
if (rssiDbm != null) {
return SignalMetric(
valueLabel: '$rssiDbm dBm',
color: rssiDbm >= -80
? Colors.green
: rssiDbm >= -95
? Colors.amber
: Colors.redAccent,
activeBars: rssiDbm >= -80
? 3
: rssiDbm >= -95
? 2
: 1,
);
}
return null;
}
}
class CompactSignalIndicator extends StatelessWidget {
final SignalMetric metric;
final bool dense;
const CompactSignalIndicator({
super.key,
required this.metric,
this.dense = false,
});
@override
Widget build(BuildContext context) {
final inactive = Theme.of(context).colorScheme.outlineVariant;
final barWidth = dense ? 4.0 : 5.0;
final baseHeight = dense ? 8.0 : 10.0;
final barStep = dense ? 6.0 : 8.0;
final barSpacing = dense ? 2.0 : 3.0;
final labelGap = dense ? 4.0 : 6.0;
final labelFontSize = dense ? 10.0 : 12.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (var index = 0; index < 3; index++) ...[
if (index > 0) SizedBox(width: barSpacing),
Container(
width: barWidth,
height: baseHeight + (index * barStep),
decoration: BoxDecoration(
color: index < metric.activeBars ? metric.color : inactive,
borderRadius: BorderRadius.circular(2),
),
),
],
],
),
SizedBox(height: labelGap),
Text(
metric.valueLabel,
style: TextStyle(
fontSize: labelFontSize,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface,
),
),
],
);
}
}

File diff suppressed because it is too large Load Diff

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

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

File diff suppressed because it is too large Load Diff

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

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

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

View File

@@ -0,0 +1,152 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart' as flutter_map;
import 'package:latlong2/latlong.dart';
import '../models/map_drawing.dart';
import '../models/map_coordinate_space.dart';
/// Minimap preview widget for map drawings
/// Renders a small 80x80px preview of a drawing on a map background
class DrawingMinimapPreview extends StatelessWidget {
final MapDrawing drawing;
final Widget? tileLayer;
const DrawingMinimapPreview({
super.key,
required this.drawing,
this.tileLayer,
});
/// Calculate bounds for the drawing to fit in the preview
flutter_map.LatLngBounds _calculateBounds() {
if (drawing is LineDrawing) {
final lineDrawing = drawing as LineDrawing;
if (lineDrawing.points.isEmpty) {
// Fallback to default bounds if no points
return flutter_map.LatLngBounds(
const LatLng(0, 0),
const LatLng(0.01, 0.01),
);
}
// Calculate bounds from points
double minLat = lineDrawing.points.first.latitude;
double maxLat = lineDrawing.points.first.latitude;
double minLon = lineDrawing.points.first.longitude;
double maxLon = lineDrawing.points.first.longitude;
for (final point in lineDrawing.points) {
if (point.latitude < minLat) minLat = point.latitude;
if (point.latitude > maxLat) maxLat = point.latitude;
if (point.longitude < minLon) minLon = point.longitude;
if (point.longitude > maxLon) maxLon = point.longitude;
}
// Add padding (10% on each side)
final latPadding = (maxLat - minLat) * 0.1;
final lonPadding = (maxLon - minLon) * 0.1;
return flutter_map.LatLngBounds(
LatLng(minLat - latPadding, minLon - lonPadding),
LatLng(maxLat + latPadding, maxLon + lonPadding),
);
} else if (drawing is RectangleDrawing) {
final rectDrawing = drawing as RectangleDrawing;
// Add padding (10% on each side)
final latDiff =
(rectDrawing.bottomRight.latitude - rectDrawing.topLeft.latitude)
.abs();
final lonDiff =
(rectDrawing.bottomRight.longitude - rectDrawing.topLeft.longitude)
.abs();
final latPadding = latDiff * 0.1;
final lonPadding = lonDiff * 0.1;
return flutter_map.LatLngBounds(
LatLng(
rectDrawing.topLeft.latitude - latPadding,
rectDrawing.topLeft.longitude - lonPadding,
),
LatLng(
rectDrawing.bottomRight.latitude + latPadding,
rectDrawing.bottomRight.longitude + lonPadding,
),
);
}
// Fallback to default bounds
return flutter_map.LatLngBounds(
const LatLng(0, 0),
const LatLng(0.01, 0.01),
);
}
@override
Widget build(BuildContext context) {
final bounds = _calculateBounds();
final isCustomMap = drawing.coordinateSpace == MapCoordinateSpace.customMap;
final drawingPoints = drawing is LineDrawing
? (drawing as LineDrawing).points
: drawing is RectangleDrawing
? (drawing as RectangleDrawing).corners
: const <LatLng>[];
return Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.grey.shade400, width: 1),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(7),
child: flutter_map.FlutterMap(
options: flutter_map.MapOptions(
crs: isCustomMap
? const flutter_map.CrsSimple()
: const flutter_map.Epsg3857(),
initialCameraFit: flutter_map.CameraFit.bounds(
bounds: bounds,
padding: const EdgeInsets.all(8),
),
interactionOptions: const flutter_map.InteractionOptions(
flags:
flutter_map.InteractiveFlag.none, // Disable all interactions
),
),
children: [
// Use provided tile layer or fallback to gray background
if (!isCustomMap && tileLayer != null)
tileLayer!
else
Container(color: Colors.grey.shade300),
// Render the drawing
if (drawing is LineDrawing)
flutter_map.PolylineLayer(
polylines: [
flutter_map.Polyline(
points: drawingPoints,
strokeWidth: 3.0,
color: drawing.color,
),
],
)
else if (drawing is RectangleDrawing)
flutter_map.PolygonLayer(
polygons: [
flutter_map.Polygon(
points: drawingPoints,
color: drawing.color.withValues(alpha: 0.3),
borderColor: drawing.color,
borderStrokeWidth: 3.0,
),
],
),
],
),
),
);
}
}

View File

@@ -0,0 +1,315 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/contact.dart';
/// Contact list section for the compass dialog.
/// Shows all contacts with location sorted by distance with bearing information.
/// Splits contacts by type: Persons/Team, Repeaters, and Rooms.
class CompassContactList extends StatelessWidget {
final List<Contact> contacts;
final Position? position;
final double? heading;
final Contact? selectedContact;
final bool showContacts;
final bool showRepeaters;
final ValueChanged<Contact?> onContactTap;
const CompassContactList({
super.key,
required this.contacts,
required this.position,
this.heading,
required this.selectedContact,
required this.showContacts,
required this.showRepeaters,
required this.onContactTap,
});
@override
Widget build(BuildContext context) {
if (contacts.isEmpty) {
return const SizedBox.shrink();
}
if (position == null) {
return Text(AppLocalizations.of(context)!.locationUnavailable);
}
final l10n = AppLocalizations.of(context)!;
// Split contacts by type
final persons = <Map<String, dynamic>>[];
final repeaters = <Map<String, dynamic>>[];
final sensors = <Map<String, dynamic>>[];
final rooms = <Map<String, dynamic>>[];
// Calculate bearings and distances for each contact
for (final contact in contacts) {
if (contact.displayLocation == null) continue;
final bearing = _calculateBearing(
position!.latitude,
position!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
final distance = _calculateDistance(
position!.latitude,
position!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
final item = {
'contact': contact,
'bearing': bearing,
'distance': distance,
};
if (contact.isRepeater) {
repeaters.add(item);
} else if (contact.isSensor) {
sensors.add(item);
} else if (contact.isRoom) {
rooms.add(item);
} else {
persons.add(item);
}
}
// Sort each list by distance
persons.sort(
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
);
repeaters.sort(
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
);
sensors.sort(
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
);
rooms.sort(
(a, b) => (a['distance'] as double).compareTo(b['distance'] as double),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Persons/Team section
if (showContacts && persons.isNotEmpty) ...[
Padding(
padding: EdgeInsets.only(left: 16, bottom: 8, top: 4),
child: Text(
l10n.teamMembers,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
...persons.map(
(item) => _buildContactTile(
context,
item,
Icons.groups,
Theme.of(context).colorScheme.primary,
),
),
],
if (showContacts && sensors.isNotEmpty) ...[
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
'Sensors',
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
...sensors.map(
(item) =>
_buildContactTile(context, item, Icons.sensors, Colors.green),
),
],
// Repeaters section
if (showRepeaters && repeaters.isNotEmpty) ...[
Padding(
padding: EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
l10n.repeaters,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
...repeaters.map(
(item) =>
_buildContactTile(context, item, Icons.router, Colors.purple),
),
],
// Rooms section
if (rooms.isNotEmpty) ...[
Padding(
padding: EdgeInsets.only(left: 16, bottom: 8, top: 12),
child: Text(
l10n.rooms,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
),
...rooms.map(
(item) => _buildContactTile(
context,
item,
Icons.meeting_room,
Colors.teal,
),
),
],
],
);
}
Widget _buildContactTile(
BuildContext context,
Map<String, dynamic> item,
IconData defaultIcon,
Color iconColor,
) {
final contact = item['contact'] as Contact;
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: selectedContact == contact
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: selectedContact == contact
? Border.all(color: Theme.of(context).colorScheme.primary, width: 2)
: null,
),
child: ListTile(
dense: true,
leading: contact.roleEmoji != null
? Text(contact.roleEmoji!, style: const TextStyle(fontSize: 24))
: Icon(defaultIcon, color: iconColor, size: 24),
title: Text(contact.displayName),
subtitle: Text(
'${_bearingToCardinal(bearing)} • ${_formatDistance(distance)}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${bearing.round()}°',
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.bold),
),
if (heading != null)
Text(
_formatRelativeBearing(bearing, heading!, context),
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: Colors.grey),
),
],
),
onTap: () {
if (selectedContact == contact) {
// Deselect if already selected
onContactTap(null);
} else {
// Select this contact
onContactTap(contact);
}
},
),
);
}
// Calculate bearing between two points (in degrees)
double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x =
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
}
// Calculate distance between two points (in meters)
double _calculateDistance(
double lat1,
double lon1,
double lat2,
double lon2,
) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
String _bearingToCardinal(double bearing) {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
final index = ((bearing + 22.5) / 45).floor() % 8;
return directions[index];
}
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
} else {
return '${(meters / 1000).toStringAsFixed(1)}km';
}
}
String _formatRelativeBearing(
double bearing,
double heading,
BuildContext context,
) {
final l10n = AppLocalizations.of(context)!;
// Calculate relative bearing (how much to turn from current heading)
double relative = bearing - heading;
// Normalize to -180 to +180
while (relative > 180) {
relative -= 360;
}
while (relative < -180) {
relative += 360;
}
final absRelative = relative.abs().round();
if (absRelative < 10) {
return l10n.ahead;
} else if (relative > 0) {
return l10n.degreesRight(absRelative);
} else {
return l10n.degreesLeft(absRelative);
}
}
}

View File

@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../../l10n/app_localizations.dart';
import '../../../providers/map_provider.dart';
/// Filter controls for the compass dialog.
/// Allows filtering of contacts and SAR marker types.
class CompassFilters extends StatefulWidget {
final bool showContacts;
final bool showRepeaters;
final bool showFoundPerson;
final bool showFire;
final bool showStagingArea;
final ValueChanged<bool> onShowContactsChanged;
final ValueChanged<bool> onShowRepeatersChanged;
final ValueChanged<bool> onShowFoundPersonChanged;
final ValueChanged<bool> onShowFireChanged;
final ValueChanged<bool> onShowStagingAreaChanged;
final VoidCallback onShowAll;
const CompassFilters({
super.key,
required this.showContacts,
required this.showRepeaters,
required this.showFoundPerson,
required this.showFire,
required this.showStagingArea,
required this.onShowContactsChanged,
required this.onShowRepeatersChanged,
required this.onShowFoundPersonChanged,
required this.onShowFireChanged,
required this.onShowStagingAreaChanged,
required this.onShowAll,
});
@override
State<CompassFilters> createState() => _CompassFiltersState();
}
class _CompassFiltersState extends State<CompassFilters> {
void _showFilterDialog() {
final l10n = AppLocalizations.of(context)!;
final mapProvider = Provider.of<MapProvider>(context, listen: false);
showDialog(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: Row(
children: [
const Icon(Icons.filter_list, size: 20),
SizedBox(width: 8),
Text(l10n.filterMarkers),
],
),
contentPadding: const EdgeInsets.fromLTRB(24, 16, 24, 0),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Trail visibility toggle
_CompactFilterItem(
icon: Icons.timeline,
color: Colors.blue,
label: l10n.locationTrail,
value: mapProvider.isTrailVisible,
onChanged: (value) {
mapProvider.toggleTrailVisibility();
setDialogState(() {});
},
),
const SizedBox(height: 4),
const Divider(height: 8),
const SizedBox(height: 4),
// Contacts filter
_CompactFilterItem(
icon: Icons.person,
color: Theme.of(context).colorScheme.primary,
label: l10n.contactsFilter,
value: widget.showContacts,
onChanged: (value) {
widget.onShowContactsChanged(value);
setDialogState(() {});
},
),
const SizedBox(height: 4),
// Repeaters filter
_CompactFilterItem(
icon: Icons.router,
color: Colors.purple,
label: l10n.repeatersFilter,
value: widget.showRepeaters,
onChanged: (value) {
widget.onShowRepeatersChanged(value);
setDialogState(() {});
},
),
const SizedBox(height: 4),
const Divider(height: 8),
const SizedBox(height: 4),
// SAR Markers section
Padding(
padding: EdgeInsets.only(left: 8, bottom: 8, top: 4),
child: Text(
l10n.sarMarkers,
style: Theme.of(context).textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
_CompactFilterItem(
icon: Icons.person_pin,
color: Colors.green,
label: l10n.foundPerson,
value: widget.showFoundPerson,
onChanged: (value) {
widget.onShowFoundPersonChanged(value);
setDialogState(() {});
},
),
SizedBox(height: 4),
_CompactFilterItem(
icon: Icons.local_fire_department,
color: Colors.red,
label: l10n.fire,
value: widget.showFire,
onChanged: (value) {
widget.onShowFireChanged(value);
setDialogState(() {});
},
),
SizedBox(height: 4),
_CompactFilterItem(
icon: Icons.home_work,
color: Colors.orange,
label: l10n.stagingArea,
value: widget.showStagingArea,
onChanged: (value) {
widget.onShowStagingAreaChanged(value);
setDialogState(() {});
},
),
],
),
actions: [
TextButton(
onPressed: () {
widget.onShowAll();
setDialogState(() {});
},
child: Text(l10n.showAll),
),
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.close),
),
],
),
),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return IconButton(
icon: Icon(Icons.filter_list),
tooltip: l10n.filterMarkersTooltip,
onPressed: () => _showFilterDialog(),
);
}
}
/// Compact filter item widget
class _CompactFilterItem extends StatelessWidget {
final IconData icon;
final Color color;
final String label;
final bool value;
final ValueChanged<bool> onChanged;
const _CompactFilterItem({
required this.icon,
required this.color,
required this.label,
required this.value,
required this.onChanged,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () => onChanged(!value),
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Row(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: Theme.of(context).textTheme.bodyMedium,
),
),
Checkbox(
value: value,
onChanged: (val) => onChanged(val ?? false),
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
visualDensity: VisualDensity.compact,
),
],
),
),
);
}
}

View File

@@ -0,0 +1,619 @@
import 'dart:math';
import 'dart:ui' as ui;
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import 'package:latlong2/latlong.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/contact.dart';
import '../../../models/sar_marker.dart';
import '../../../utils/location_formats.dart';
import 'compass_math.dart';
/// Header component for the compass dialog showing compass rose,
/// heading, elevation, accuracy, and current location in multiple formats.
class CompassHeader extends StatelessWidget {
final double? heading;
final Position? position;
final bool hasHeading;
final Position? currentPosition;
final List<Contact> contacts;
final List<SarMarker> sarMarkers;
final double zoomLevel;
final double previousScale;
final ValueChanged<double> onZoomUpdate;
final VoidCallback onScaleStart;
final VoidCallback onScaleEnd;
const CompassHeader({
super.key,
required this.heading,
required this.position,
required this.hasHeading,
required this.currentPosition,
required this.contacts,
required this.sarMarkers,
required this.zoomLevel,
required this.previousScale,
required this.onZoomUpdate,
required this.onScaleStart,
required this.onScaleEnd,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// Heading and Elevation info
_buildInfoRow(context, heading, position),
const SizedBox(height: 12),
// Current location in multiple formats
if (position != null) _LocationFormatToggle(position: position),
const SizedBox(height: 12),
// Large compass with zoom controls
GestureDetector(
onScaleStart: (details) {
onScaleStart();
},
onScaleUpdate: (details) {
onZoomUpdate(details.scale);
},
onScaleEnd: (details) {
onScaleEnd();
},
child: SizedBox(
width: 300,
height: 300,
child: _DetailedCompassPainter(
heading: heading ?? 0,
hasHeading: hasHeading,
currentPosition: currentPosition,
contacts: contacts,
sarMarkers: sarMarkers,
zoomLevel: zoomLevel,
),
),
),
],
);
}
Widget _buildInfoRow(
BuildContext context,
double? heading,
Position? position,
) {
final l10n = AppLocalizations.of(context)!;
return Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildInfoCard(
context,
l10n.heading,
heading != null ? '${heading.round()}°' : '--',
Icons.explore,
),
_buildInfoCard(
context,
l10n.elevation,
position?.altitude != null ? '${position!.altitude.round()}m' : '--',
Icons.terrain,
),
_buildInfoCard(
context,
l10n.accuracy,
position?.accuracy != null ? '±${position!.accuracy.round()}m' : '--',
Icons.gps_fixed,
),
],
);
}
Widget _buildInfoCard(
BuildContext context,
String label,
String value,
IconData icon,
) {
return Column(
children: [
Icon(icon, size: 20, color: Theme.of(context).colorScheme.primary),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold),
),
Text(label, style: Theme.of(context).textTheme.bodySmall),
],
);
}
}
/// Detailed Compass Painter with contacts
class _DetailedCompassPainter extends StatelessWidget {
final double heading;
final bool hasHeading;
final Position? currentPosition;
final List<Contact> contacts;
final List<SarMarker> sarMarkers;
final double zoomLevel;
const _DetailedCompassPainter({
required this.heading,
required this.hasHeading,
required this.currentPosition,
required this.contacts,
required this.sarMarkers,
this.zoomLevel = 1.0,
});
@override
Widget build(BuildContext context) {
return CustomPaint(
painter: _LargeCompassPainter(
heading: heading,
hasHeading: hasHeading,
currentPosition: currentPosition,
contacts: contacts,
sarMarkers: sarMarkers,
zoomLevel: zoomLevel,
),
child: Container(),
);
}
}
class _LargeCompassPainter extends CustomPainter {
final double heading;
final bool hasHeading;
final Position? currentPosition;
final List<Contact> contacts;
final List<SarMarker> sarMarkers;
final double zoomLevel;
_LargeCompassPainter({
required this.heading,
required this.hasHeading,
required this.currentPosition,
required this.contacts,
required this.sarMarkers,
this.zoomLevel = 1.0,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
// Draw outer circle
final circlePaint = Paint()
..color = Colors.grey.withValues(alpha: 0.2)
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawCircle(center, radius, circlePaint);
// Draw degree markers
for (int i = 0; i < 360; i += 10) {
final angle = compassDialAngleRadians(
markerDegrees: i.toDouble(),
headingDegrees: heading,
);
final isCardinal = i % 90 == 0;
final isMajor = i % 30 == 0;
final startRadius = isCardinal
? radius - 25
: (isMajor ? radius - 15 : radius - 10);
final start = Offset(
center.dx + startRadius * cos(angle),
center.dy + startRadius * sin(angle),
);
final end = Offset(
center.dx + radius * cos(angle),
center.dy + radius * sin(angle),
);
final markerPaint = Paint()
..color = isCardinal ? Colors.red : Colors.grey
..strokeWidth = isCardinal ? 3 : (isMajor ? 2 : 1);
canvas.drawLine(start, end, markerPaint);
}
// Draw cardinal directions
final textPainter = TextPainter(textDirection: TextDirection.ltr);
final directions = ['N', 'E', 'S', 'W'];
for (int i = 0; i < 4; i++) {
final angle = compassDialAngleRadians(
markerDegrees: (i * 90).toDouble(),
headingDegrees: heading,
);
final x = center.dx + (radius - 35) * cos(angle);
final y = center.dy + (radius - 35) * sin(angle);
textPainter.text = TextSpan(
text: directions[i],
style: TextStyle(
color: i == 0 ? Colors.red : Colors.grey.shade700,
fontSize: 24,
fontWeight: FontWeight.bold,
),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
);
}
// Draw contacts as dots relative to distance, scaled by zoom level
if (currentPosition != null && contacts.isNotEmpty) {
// Calculate distances for all contacts
final contactsWithDistance = contacts
.where((c) => c.displayLocation != null)
.map((contact) {
final bearing = _calculateBearing(
currentPosition!.latitude,
currentPosition!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
final distance = _calculateDistance(
currentPosition!.latitude,
currentPosition!.longitude,
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
);
return {
'contact': contact,
'bearing': bearing,
'distance': distance,
};
})
.toList();
if (contactsWithDistance.isEmpty) return;
// Base distance for zoom level 1.0 (in meters)
// At 1x zoom, contacts within 1km appear inside the compass
final baseDistance = 1000.0 / zoomLevel;
for (final item in contactsWithDistance) {
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
// Adjust bearing relative to current heading
final relativeBearing = (bearing - heading + 360) % 360;
final angle = relativeBearing * pi / 180 - pi / 2;
// Calculate normalized distance (0 to 1, where 1 is at the rim)
// Apply zoom level: higher zoom = contacts appear closer
double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0);
// Calculate contact position radius (from center to rim based on distance)
final contactRadius =
radius * normalizedDistance * 0.85; // 0.85 to keep inside rim
// Position of contact dot
final dotX = center.dx + contactRadius * cos(angle);
final dotY = center.dy + contactRadius * sin(angle);
// Draw line from center to contact
final linePaint = Paint()
..color = Colors.lightBlue.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
canvas.drawLine(center, Offset(dotX, dotY), linePaint);
// Draw contact dot (size varies with zoom)
final dotSize = (6.0 * (1.0 + zoomLevel * 0.3)).clamp(4.0, 12.0);
final dotPaint = Paint()
..color = Colors.lightBlue
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint);
// Draw darker shade border (same color family)
final borderPaint = Paint()
..color = Colors.blue.shade800
..style = PaintingStyle.stroke
..strokeWidth = 2.5;
canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint);
// Draw distance label near the contact (only if not too crowded)
if (zoomLevel >= 0.75) {
final distanceText = _formatDistance(distance);
final labelOffset = dotSize + 12;
final labelX = center.dx + (contactRadius + labelOffset) * cos(angle);
final labelY = center.dy + (contactRadius + labelOffset) * sin(angle);
textPainter.text = TextSpan(
text: distanceText,
style: const TextStyle(
color: Colors.lightBlue,
fontSize: 9,
fontWeight: FontWeight.bold,
),
);
textPainter.layout();
// Draw background for readability
final bgRect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(labelX, labelY),
width: textPainter.width + 4,
height: textPainter.height + 2,
),
const Radius.circular(3),
);
final bgPaint = Paint()
..color = Colors.white.withValues(alpha: 0.9)
..style = PaintingStyle.fill;
canvas.drawRRect(bgRect, bgPaint);
textPainter.paint(
canvas,
Offset(
labelX - textPainter.width / 2,
labelY - textPainter.height / 2,
),
);
}
}
}
// Draw SAR markers as colored dots relative to distance, scaled by zoom level
if (currentPosition != null && sarMarkers.isNotEmpty) {
// Calculate distances for all SAR markers
final markersWithDistance = sarMarkers.map((marker) {
final bearing = _calculateBearing(
currentPosition!.latitude,
currentPosition!.longitude,
marker.location.latitude,
marker.location.longitude,
);
final distance = _calculateDistance(
currentPosition!.latitude,
currentPosition!.longitude,
marker.location.latitude,
marker.location.longitude,
);
return {'marker': marker, 'bearing': bearing, 'distance': distance};
}).toList();
// Base distance for zoom level 1.0 (in meters)
final baseDistance = 1000.0 / zoomLevel;
for (final item in markersWithDistance) {
final marker = item['marker'] as SarMarker;
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
// Adjust bearing relative to current heading
final relativeBearing = (bearing - heading + 360) % 360;
final angle = relativeBearing * pi / 180 - pi / 2;
// Calculate normalized distance (0 to 1, where 1 is at the rim)
double normalizedDistance = (distance / baseDistance).clamp(0.0, 1.0);
// Calculate marker position radius (from center to rim based on distance)
final markerRadius = radius * normalizedDistance * 0.85;
// Position of marker dot
final dotX = center.dx + markerRadius * cos(angle);
final dotY = center.dy + markerRadius * sin(angle);
// Determine color based on SAR marker type
Color markerColor;
Color borderColor;
switch (marker.type) {
case SarMarkerType.foundPerson:
markerColor = Colors.green;
borderColor = Colors.green.shade900;
break;
case SarMarkerType.fire:
markerColor = Colors.red;
borderColor = Colors.red.shade900;
break;
case SarMarkerType.stagingArea:
markerColor = Colors.orange;
borderColor = Colors.orange.shade900;
break;
case SarMarkerType.object:
markerColor = Colors.purple;
borderColor = Colors.purple.shade900;
break;
case SarMarkerType.unknown:
markerColor = Colors.grey;
borderColor = Colors.grey.shade900;
break;
}
// Draw line from center to SAR marker
final linePaint = Paint()
..color = markerColor.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 2;
canvas.drawLine(center, Offset(dotX, dotY), linePaint);
// Draw SAR marker dot (slightly larger than contacts)
final dotSize = (8.0 * (1.0 + zoomLevel * 0.3)).clamp(6.0, 14.0);
final dotPaint = Paint()
..color = markerColor
..style = PaintingStyle.fill;
canvas.drawCircle(Offset(dotX, dotY), dotSize, dotPaint);
// Draw darker shade border (same color family)
final borderPaint = Paint()
..color = borderColor
..style = PaintingStyle.stroke
..strokeWidth = 2.5;
canvas.drawCircle(Offset(dotX, dotY), dotSize, borderPaint);
// Draw distance label near the SAR marker
if (zoomLevel >= 0.75) {
final distanceText = _formatDistance(distance);
final labelOffset = dotSize + 14;
final labelX = center.dx + (markerRadius + labelOffset) * cos(angle);
final labelY = center.dy + (markerRadius + labelOffset) * sin(angle);
textPainter.text = TextSpan(
text: distanceText,
style: TextStyle(
color: markerColor,
fontSize: 10,
fontWeight: FontWeight.bold,
),
);
textPainter.layout();
// Draw background for readability
final bgRect = RRect.fromRectAndRadius(
Rect.fromCenter(
center: Offset(labelX, labelY),
width: textPainter.width + 4,
height: textPainter.height + 2,
),
const Radius.circular(3),
);
final bgPaint = Paint()
..color = Colors.white.withValues(alpha: 0.9)
..style = PaintingStyle.fill;
canvas.drawRRect(bgRect, bgPaint);
textPainter.paint(
canvas,
Offset(
labelX - textPainter.width / 2,
labelY - textPainter.height / 2,
),
);
}
}
}
// Draw center heading indicator (fixed pointing up)
final indicatorPaint = Paint()
..color = hasHeading ? Colors.red : Colors.grey
..style = PaintingStyle.fill;
final path = ui.Path()
..moveTo(center.dx, center.dy - 40)
..lineTo(center.dx - 10, center.dy + 10)
..lineTo(center.dx + 10, center.dy + 10)
..close();
canvas.drawPath(path, indicatorPaint);
}
double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x =
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
}
double _calculateDistance(
double lat1,
double lon1,
double lat2,
double lon2,
) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
} else {
return '${(meters / 1000).toStringAsFixed(1)}km';
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => true;
}
/// Location format toggle widget
class _LocationFormatToggle extends StatefulWidget {
final Position? position;
const _LocationFormatToggle({required this.position});
@override
State<_LocationFormatToggle> createState() => _LocationFormatToggleState();
}
class _LocationFormatToggleState extends State<_LocationFormatToggle> {
CoordinateDisplayFormat _format = CoordinateDisplayFormat.decimal;
@override
Widget build(BuildContext context) {
final position = widget.position;
if (position == null) {
return SizedBox.shrink();
}
final l10n = AppLocalizations.of(context)!;
final String displayText;
if (_format == CoordinateDisplayFormat.decimal) {
displayText = l10n.latLonFormat(
position.latitude.toStringAsFixed(5),
position.longitude.toStringAsFixed(5),
);
} else {
displayText = formatCoordinates(
position.latitude,
position.longitude,
_format,
);
}
return GestureDetector(
onTap: () {
setState(() {
_format = CoordinateDisplayFormat.values[
(_format.index + 1) % CoordinateDisplayFormat.values.length];
});
},
behavior: HitTestBehavior.opaque,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Center(
child: Text(
displayText,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
fontFamily: 'monospace',
),
),
),
),
);
}
}

View File

@@ -0,0 +1,22 @@
import 'dart:math';
/// Rotate the compass rose opposite the device heading so the current heading
/// stays under the fixed indicator at the top of the dial.
double compassRoseRotationRadians(double headingDegrees) {
final normalizedHeading = headingDegrees % 360;
return _normalizeRadians(-normalizedHeading * pi / 180);
}
/// Convert a compass marker bearing into an on-screen angle for the dial.
double compassDialAngleRadians({
required double markerDegrees,
required double headingDegrees,
}) {
return _normalizeRadians(
markerDegrees * pi / 180 -
pi / 2 +
compassRoseRotationRadians(headingDegrees),
);
}
double _normalizeRadians(double angle) => atan2(sin(angle), cos(angle));

View File

@@ -0,0 +1,236 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import '../../../l10n/app_localizations.dart';
import '../../../models/sar_marker.dart';
/// SAR marker list section for the compass dialog.
/// Shows all filtered SAR markers sorted by distance with bearing information.
class CompassSarList extends StatelessWidget {
final List<SarMarker> sarMarkers;
final Position? position;
final double? heading;
final SarMarker? selectedSarMarker;
final ValueChanged<SarMarker?> onSarMarkerTap;
const CompassSarList({
super.key,
required this.sarMarkers,
required this.position,
this.heading,
required this.selectedSarMarker,
required this.onSarMarkerTap,
});
@override
Widget build(BuildContext context) {
if (sarMarkers.isEmpty) {
return const SizedBox.shrink();
}
if (position == null) {
return Text(AppLocalizations.of(context)!.locationUnavailable);
}
// Calculate bearings and distances for SAR markers
final markersWithBearing = sarMarkers.map((marker) {
final bearing = _calculateBearing(
position!.latitude,
position!.longitude,
marker.location.latitude,
marker.location.longitude,
);
final distance = _calculateDistance(
position!.latitude,
position!.longitude,
marker.location.latitude,
marker.location.longitude,
);
return {
'marker': marker,
'bearing': bearing,
'distance': distance,
};
}).toList();
// Sort by distance
markersWithBearing.sort((a, b) =>
(a['distance'] as double).compareTo(b['distance'] as double));
final l10n = AppLocalizations.of(context)!;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: EdgeInsets.only(left: 16, top: 16, bottom: 8),
child: Text(
l10n.sarMarkers,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
...markersWithBearing.map((item) {
final marker = item['marker'] as SarMarker;
final bearing = item['bearing'] as double;
final distance = item['distance'] as double;
// Determine color and icon based on marker type
Color markerColor;
IconData markerIcon;
switch (marker.type) {
case SarMarkerType.foundPerson:
markerColor = Colors.green;
markerIcon = Icons.person_pin;
break;
case SarMarkerType.fire:
markerColor = Colors.red;
markerIcon = Icons.local_fire_department;
break;
case SarMarkerType.stagingArea:
markerColor = Colors.orange;
markerIcon = Icons.home_work;
break;
case SarMarkerType.object:
markerColor = Colors.purple;
markerIcon = Icons.inventory_2;
break;
case SarMarkerType.unknown:
markerColor = Colors.grey;
markerIcon = Icons.help_outline;
break;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: selectedSarMarker == marker
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: selectedSarMarker == marker
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
),
child: ListTile(
dense: true,
leading: Icon(
markerIcon,
color: markerColor,
size: 24,
),
title: Text(marker.displayName),
subtitle: Text(
'${_bearingToCardinal(bearing)} • ${_formatDistance(distance)} • ${marker.timeAgo}',
style: Theme.of(context).textTheme.bodySmall,
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${bearing.round()}°',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
if (heading != null)
Text(
_formatRelativeBearing(bearing, heading!, context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Colors.grey,
),
),
],
),
onTap: () {
if (selectedSarMarker == marker) {
// Deselect if already selected
onSarMarkerTap(null);
} else {
// Select this marker
onSarMarkerTap(marker);
}
},
),
);
}),
],
);
}
// Calculate bearing between two points (in degrees)
double _calculateBearing(
double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x = cos(lat1Rad) * sin(lat2Rad) -
sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
}
// Calculate distance between two points (in meters)
double _calculateDistance(
double lat1, double lon1, double lat2, double lon2) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a = sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
String _bearingToCardinal(double bearing) {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
final index = ((bearing + 22.5) / 45).floor() % 8;
return directions[index];
}
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
} else {
return '${(meters / 1000).toStringAsFixed(1)}km';
}
}
String _formatRelativeBearing(double bearing, double heading, BuildContext context) {
final l10n = AppLocalizations.of(context)!;
// Calculate relative bearing (how much to turn from current heading)
double relative = bearing - heading;
// Normalize to -180 to +180
while (relative > 180) {
relative -= 360;
}
while (relative < -180) {
relative += 360;
}
final absRelative = relative.abs().round();
if (absRelative < 10) {
return l10n.ahead;
} else if (relative > 0) {
return l10n.degreesRight(absRelative);
} else {
return l10n.degreesLeft(absRelative);
}
}
}

View File

@@ -0,0 +1,106 @@
import 'dart:math';
import 'package:flutter/material.dart';
import 'compass/compass_math.dart';
class CompassWidget extends StatelessWidget {
final double heading;
final bool hasHeading;
const CompassWidget({
super.key,
required this.heading,
required this.hasHeading,
});
@override
Widget build(BuildContext context) {
return Card(
child: Container(
width: 56,
height: 56,
padding: const EdgeInsets.all(8),
child: Stack(
alignment: Alignment.center,
children: [
// Rotate the rose opposite the heading under the fixed needle.
Transform.rotate(
angle: compassRoseRotationRadians(heading),
child: CustomPaint(
size: const Size(40, 40),
painter: _CompassRosePainter(),
),
),
// Fixed needle pointing up (since map rotates)
Icon(
Icons.navigation,
color: hasHeading ? Colors.red : Colors.grey,
size: 28,
),
// Heading text
Positioned(
bottom: 0,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(4),
),
child: Text(
hasHeading ? '${heading.round()}°' : '--',
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
),
],
),
),
);
}
}
class _CompassRosePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()
..color = Colors.grey.withValues(alpha: 0.3)
..style = PaintingStyle.stroke
..strokeWidth = 1;
final center = Offset(size.width / 2, size.height / 2);
final radius = size.width / 2;
// Draw circle
canvas.drawCircle(center, radius, paint);
// Draw cardinal direction markers
final textPainter = TextPainter(textDirection: TextDirection.ltr);
final directions = ['N', 'E', 'S', 'W'];
for (int i = 0; i < 4; i++) {
final angle = i * pi / 2 - pi / 2; // Start from North (top)
final x = center.dx + radius * 0.7 * cos(angle);
final y = center.dy + radius * 0.7 * sin(angle);
textPainter.text = TextSpan(
text: directions[i],
style: TextStyle(
color: Colors.grey.shade700,
fontSize: 10,
fontWeight: FontWeight.bold,
),
);
textPainter.layout();
textPainter.paint(
canvas,
Offset(x - textPainter.width / 2, y - textPainter.height / 2),
);
}
}
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
}

View File

@@ -0,0 +1,783 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show MissingPluginException;
import 'package:geolocator/geolocator.dart';
import 'package:flutter_compass/flutter_compass.dart';
import 'package:latlong2/latlong.dart';
import '../../services/compass_support.dart';
import '../../models/contact.dart';
import '../../models/sar_marker.dart';
import '../common/location_display.dart';
import 'compass/compass_header.dart';
import 'compass/compass_filters.dart';
import 'compass/compass_sar_list.dart';
import 'compass/compass_contact_list.dart';
import '../../l10n/app_localizations.dart';
enum HeadingAccuracySeverity { low, medium, high }
class HeadingAccuracyInfo {
final bool isAccurate;
final String? warning;
final HeadingAccuracySeverity severity;
HeadingAccuracyInfo({
required this.isAccurate,
this.warning,
this.severity = HeadingAccuracySeverity.low,
});
}
class DetailedCompassDialog extends StatefulWidget {
final Position? initialPosition;
final double? initialHeading;
final List<Contact> contacts;
final List<SarMarker> sarMarkers;
final Contact? preSelectedContact;
final SarMarker? preSelectedSarMarker;
const DetailedCompassDialog({
super.key,
required this.initialPosition,
required this.initialHeading,
required this.contacts,
required this.sarMarkers,
this.preSelectedContact,
this.preSelectedSarMarker,
});
@override
State<DetailedCompassDialog> createState() => _DetailedCompassDialogState();
}
class _DetailedCompassDialogState extends State<DetailedCompassDialog> {
double? _currentHeading;
double? _compassAccuracy; // Compass accuracy in degrees
Position? _currentPosition;
StreamSubscription<CompassEvent>? _compassSubscription;
StreamSubscription<Position>? _positionSubscription;
double _zoomLevel =
1.0; // 1.0 = default, 0.5 = zoomed out 2x, 2.0 = zoomed in 2x
double _previousScale = 1.0; // Track previous scale for smoother zooming
static const double _minZoom = 0.25;
static const double _maxZoom = 4.0;
static const double _zoomSensitivity =
0.5; // Lower = less sensitive (0.5 = half speed)
// Visibility toggles
bool _showContacts = true;
bool _showRepeaters = false; // Hide repeaters by default
bool _showFoundPerson = true;
bool _showFire = true;
bool _showStagingArea = true;
// Selected item for isolation
Contact? _selectedContact;
SarMarker? _selectedSarMarker;
@override
void initState() {
super.initState();
_currentHeading = widget.initialHeading;
_currentPosition = widget.initialPosition;
_selectedContact = widget.preSelectedContact;
_selectedSarMarker = widget.preSelectedSarMarker;
// Auto-zoom if item is preselected
if (_selectedContact != null || _selectedSarMarker != null) {
// Use post-frame callback to ensure position is set
WidgetsBinding.instance.addPostFrameCallback((_) {
_autoZoomForSelection();
});
}
// Subscribe to compass updates on supported targets only.
final compassStream = CompassSupport.isAvailable
? FlutterCompass.events
: null;
if (compassStream != null) {
_compassSubscription = compassStream.listen(
(event) {
if (mounted && event.heading != null) {
setState(() {
_currentHeading = event.heading;
_compassAccuracy = event.accuracy;
});
}
},
onError: (Object error, StackTrace stackTrace) {
if (!mounted) {
return;
}
if (error is MissingPluginException) {
debugPrint('Compass plugin is unavailable on this platform');
return;
}
debugPrint('Compass dialog stream error: $error');
},
);
}
// Subscribe to position updates
_positionSubscription =
Geolocator.getPositionStream(
locationSettings: const LocationSettings(
accuracy: LocationAccuracy.best,
distanceFilter: 1,
),
).listen((position) {
if (mounted) {
setState(() {
_currentPosition = position;
});
}
});
}
@override
void dispose() {
_compassSubscription?.cancel();
_positionSubscription?.cancel();
super.dispose();
}
// Get current heading (prefer compass over GPS)
double? get currentHeading {
if (_currentHeading != null) return _currentHeading;
if (_currentPosition?.heading != null && _currentPosition!.heading >= 0) {
return _currentPosition!.heading;
}
return null;
}
// Check heading accuracy and return warning information
HeadingAccuracyInfo get headingAccuracyInfo {
// Using compass
if (_currentHeading != null) {
if (_compassAccuracy == null) {
return HeadingAccuracyInfo(
isAccurate: false,
warning: 'Compass accuracy unknown',
severity: HeadingAccuracySeverity.low,
);
} else if (_compassAccuracy! > 30) {
return HeadingAccuracyInfo(
isAccurate: false,
warning:
'Low compass accuracy (±${_compassAccuracy!.round()}°). Calibrate device.',
severity: HeadingAccuracySeverity.high,
);
} else if (_compassAccuracy! > 15) {
return HeadingAccuracyInfo(
isAccurate: true,
warning: 'Moderate compass accuracy (±${_compassAccuracy!.round()}°)',
severity: HeadingAccuracySeverity.medium,
);
}
return HeadingAccuracyInfo(isAccurate: true);
}
// Using GPS heading
if (_currentPosition?.heading != null && _currentPosition!.heading >= 0) {
final headingAccuracy = _currentPosition!.headingAccuracy;
if (headingAccuracy > 0) {
if (headingAccuracy > 30) {
return HeadingAccuracyInfo(
isAccurate: false,
warning:
'Low GPS heading accuracy (±${headingAccuracy.round()}°). Move faster or use compass.',
severity: HeadingAccuracySeverity.high,
);
} else if (headingAccuracy > 15) {
return HeadingAccuracyInfo(
isAccurate: true,
warning:
'Moderate GPS heading accuracy (±${headingAccuracy.round()}°)',
severity: HeadingAccuracySeverity.medium,
);
}
return HeadingAccuracyInfo(isAccurate: true);
}
// GPS heading available but no accuracy info
return HeadingAccuracyInfo(
isAccurate: true,
warning: 'Using GPS heading (accuracy unknown)',
severity: HeadingAccuracySeverity.low,
);
}
// No heading available
return HeadingAccuracyInfo(
isAccurate: false,
warning: 'No heading available',
severity: HeadingAccuracySeverity.high,
);
}
// Filter SAR markers based on visibility settings
List<SarMarker> _getFilteredSarMarkers() {
return widget.sarMarkers.where((marker) {
switch (marker.type) {
case SarMarkerType.foundPerson:
return _showFoundPerson;
case SarMarkerType.fire:
return _showFire;
case SarMarkerType.stagingArea:
return _showStagingArea;
case SarMarkerType.object:
return true; // Always show object markers (add filter if needed)
case SarMarkerType.unknown:
return true; // Always show unknown markers
}
}).toList();
}
void _handleZoomUpdate(double scale) {
setState(() {
// Calculate scale delta from previous scale
final scaleDelta = scale - _previousScale;
// Apply sensitivity factor to make it more coarse
final adjustedDelta = scaleDelta * _zoomSensitivity;
// Apply the delta to current zoom level
_zoomLevel = (_zoomLevel * (1.0 + adjustedDelta)).clamp(
_minZoom,
_maxZoom,
);
// Update previous scale
_previousScale = scale;
});
}
void _handleScaleStart() {
_previousScale = 1.0;
}
void _handleScaleEnd() {
_previousScale = 1.0;
}
// Calculate appropriate zoom level for selected item
void _autoZoomForSelection() {
if (_currentPosition == null) return;
double? targetDistance;
if (_selectedContact != null && _selectedContact!.displayLocation != null) {
targetDistance = _calculateDistance(
_currentPosition!.latitude,
_currentPosition!.longitude,
_selectedContact!.displayLocation!.latitude,
_selectedContact!.displayLocation!.longitude,
);
} else if (_selectedSarMarker != null) {
targetDistance = _calculateDistance(
_currentPosition!.latitude,
_currentPosition!.longitude,
_selectedSarMarker!.location.latitude,
_selectedSarMarker!.location.longitude,
);
}
if (targetDistance != null) {
// Calculate zoom level to fit target within 70% of compass radius
// Base distance at 1x zoom is 1000m
// We want target at 70% of radius, so: targetDistance / zoomLevel = 700m
final targetZoom = (targetDistance / 700.0).clamp(_minZoom, _maxZoom);
setState(() {
_zoomLevel = targetZoom;
});
}
}
@override
Widget build(BuildContext context) {
final heading = currentHeading;
final position = _currentPosition;
final accuracyInfo = headingAccuracyInfo;
return Column(
children: [
// Header with back button
Container(
padding: const EdgeInsets.all(16),
child: Row(
children: [
IconButton(
icon: const Icon(Icons.arrow_back),
onPressed: () => Navigator.pop(context),
),
Expanded(
child: Column(
children: [
Text(
AppLocalizations.of(context)!.compass,
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
AppLocalizations.of(context)!.navigationAndContacts,
style: const TextStyle(color: Colors.grey, fontSize: 14),
),
],
),
),
CompassFilters(
showContacts: _showContacts,
showRepeaters: _showRepeaters,
showFoundPerson: _showFoundPerson,
showFire: _showFire,
showStagingArea: _showStagingArea,
onShowContactsChanged: (value) {
setState(() {
_showContacts = value;
});
},
onShowRepeatersChanged: (value) {
setState(() {
_showRepeaters = value;
});
},
onShowFoundPersonChanged: (value) {
setState(() {
_showFoundPerson = value;
});
},
onShowFireChanged: (value) {
setState(() {
_showFire = value;
});
},
onShowStagingAreaChanged: (value) {
setState(() {
_showStagingArea = value;
});
},
onShowAll: () {
setState(() {
_showContacts = true;
_showRepeaters = true;
_showFoundPerson = true;
_showFire = true;
_showStagingArea = true;
});
},
),
],
),
),
// Heading accuracy warning banner
if (accuracyInfo.warning != null)
_buildAccuracyWarning(context, accuracyInfo),
Expanded(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Compass header with info and location formats
CompassHeader(
heading: heading,
position: position,
hasHeading: heading != null,
currentPosition: position,
contacts: _selectedContact != null
? [_selectedContact!]
: (_selectedSarMarker != null
? []
: widget.contacts
.where(
(c) =>
(_showContacts &&
!c.isRepeater &&
!c.isRoom) ||
(_showRepeaters && c.isRepeater),
)
.toList()),
sarMarkers: _selectedSarMarker != null
? [_selectedSarMarker!]
: (_selectedContact != null
? []
: _getFilteredSarMarkers()),
zoomLevel: _zoomLevel,
previousScale: _previousScale,
onZoomUpdate: _handleZoomUpdate,
onScaleStart: _handleScaleStart,
onScaleEnd: _handleScaleEnd,
),
const SizedBox(height: 12),
// Selected item detail view
if (_selectedContact != null || _selectedSarMarker != null)
_buildSelectedItemDetail(context, heading, position),
const SizedBox(height: 12),
// Contacts list
if (widget.contacts.isNotEmpty)
CompassContactList(
contacts: widget.contacts,
position: position,
heading: heading,
selectedContact: _selectedContact,
showContacts: _showContacts,
showRepeaters: _showRepeaters,
onContactTap: (contact) {
setState(() {
_selectedContact = contact;
if (contact != null) {
_selectedSarMarker = null;
}
});
_autoZoomForSelection();
},
),
// SAR Markers list
if (_getFilteredSarMarkers().isNotEmpty)
CompassSarList(
sarMarkers: _getFilteredSarMarkers(),
position: position,
heading: heading,
selectedSarMarker: _selectedSarMarker,
onSarMarkerTap: (marker) {
setState(() {
_selectedSarMarker = marker;
if (marker != null) {
_selectedContact = null;
}
});
_autoZoomForSelection();
},
),
],
),
),
),
),
],
);
}
Widget _buildAccuracyWarning(BuildContext context, HeadingAccuracyInfo info) {
Color backgroundColor;
Color iconColor;
IconData icon;
switch (info.severity) {
case HeadingAccuracySeverity.high:
backgroundColor = Colors.red.shade100;
iconColor = Colors.red.shade700;
icon = Icons.error_outline;
break;
case HeadingAccuracySeverity.medium:
backgroundColor = Colors.orange.shade100;
iconColor = Colors.orange.shade700;
icon = Icons.warning_amber_outlined;
break;
case HeadingAccuracySeverity.low:
backgroundColor = Colors.blue.shade100;
iconColor = Colors.blue.shade700;
icon = Icons.info_outline;
break;
}
return Container(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: iconColor.withValues(alpha: 0.3), width: 1),
),
child: Row(
children: [
Icon(icon, color: iconColor, size: 20),
const SizedBox(width: 12),
Expanded(
child: Text(
info.warning!,
style: TextStyle(
color: iconColor,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
}
Widget _buildSelectedItemDetail(
BuildContext context,
double? heading,
Position? position,
) {
if (position == null) {
return const SizedBox.shrink();
}
String title;
IconData icon;
Color color;
double? bearing;
double? distance;
LatLng? targetLocation;
String? additionalInfo;
if (_selectedContact != null) {
title = _selectedContact!.displayName;
icon = Icons.person;
color = Theme.of(context).colorScheme.primary;
targetLocation = _selectedContact!.displayLocation;
if (targetLocation != null) {
bearing = _calculateBearing(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
distance = _calculateDistance(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
}
// Show voltage/battery if available
if (_selectedContact!.telemetry?.batteryMilliVolts != null) {
final volts = (_selectedContact!.telemetry!.batteryMilliVolts! / 1000)
.toStringAsFixed(3);
final percent = _selectedContact!.telemetry!.batteryPercentage != null
? ' (${_selectedContact!.telemetry!.batteryPercentage!.round()}%)'
: '';
additionalInfo = 'Voltage: ${volts}V$percent';
} else if (_selectedContact!.telemetry?.batteryPercentage != null) {
additionalInfo =
'Battery: ${_selectedContact!.telemetry!.batteryPercentage!.round()}%';
}
} else if (_selectedSarMarker != null) {
title = _selectedSarMarker!.displayName;
targetLocation = _selectedSarMarker!.location;
additionalInfo = _selectedSarMarker!.timeAgo;
switch (_selectedSarMarker!.type) {
case SarMarkerType.foundPerson:
icon = Icons.person_pin;
color = Colors.green;
break;
case SarMarkerType.fire:
icon = Icons.local_fire_department;
color = Colors.red;
break;
case SarMarkerType.stagingArea:
icon = Icons.home_work;
color = Colors.orange;
break;
case SarMarkerType.object:
icon = Icons.inventory_2;
color = Colors.purple;
break;
case SarMarkerType.unknown:
icon = Icons.help_outline;
color = Colors.grey;
break;
}
bearing = _calculateBearing(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
distance = _calculateDistance(
position.latitude,
position.longitude,
targetLocation.latitude,
targetLocation.longitude,
);
} else {
return const SizedBox.shrink();
}
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16),
elevation: 4,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: [
// Header with icon and title
Row(
children: [
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
child:
_selectedContact != null &&
_selectedContact!.roleEmoji != null
? Text(
_selectedContact!.roleEmoji!,
style: const TextStyle(fontSize: 24),
)
: Icon(icon, size: 24, color: color),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.bold),
),
if (additionalInfo != null)
Text(
additionalInfo,
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: Colors.grey),
),
],
),
),
// Close button to deselect contact
IconButton(
icon: const Icon(Icons.close, size: 20),
padding: EdgeInsets.zero,
constraints: const BoxConstraints(),
onPressed: () {
setState(() {
_selectedContact = null;
_selectedSarMarker = null;
});
},
),
],
),
if (bearing != null && distance != null) ...[
const SizedBox(height: 12),
const Divider(height: 1),
const SizedBox(height: 12),
// Distance and bearing info
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_buildLargeInfoCard(
context,
AppLocalizations.of(context)!.distance,
_formatDistance(distance),
Icons.straighten,
color,
),
_buildLargeInfoCard(
context,
AppLocalizations.of(context)!.bearing,
'${bearing.round()}°',
Icons.navigation,
color,
),
_buildLargeInfoCard(
context,
AppLocalizations.of(context)!.direction,
_bearingToCardinal(bearing),
Icons.explore,
color,
),
],
),
const SizedBox(height: 8),
// Coordinates with modal
if (targetLocation != null)
LocationDisplay(location: targetLocation),
],
],
),
),
);
}
Widget _buildLargeInfoCard(
BuildContext context,
String label,
String value,
IconData icon,
Color color,
) {
return Column(
children: [
Icon(icon, size: 20, color: color),
const SizedBox(height: 4),
Text(
value,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.bold,
color: color,
),
),
Text(label, style: Theme.of(context).textTheme.labelSmall),
],
);
}
// Calculate bearing between two points (in degrees)
double _calculateBearing(double lat1, double lon1, double lat2, double lon2) {
final dLon = (lon2 - lon1) * pi / 180;
final lat1Rad = lat1 * pi / 180;
final lat2Rad = lat2 * pi / 180;
final y = sin(dLon) * cos(lat2Rad);
final x =
cos(lat1Rad) * sin(lat2Rad) - sin(lat1Rad) * cos(lat2Rad) * cos(dLon);
final bearing = atan2(y, x) * 180 / pi;
return (bearing + 360) % 360;
}
// Calculate distance between two points (in meters)
double _calculateDistance(
double lat1,
double lon1,
double lat2,
double lon2,
) {
const R = 6371000; // Earth's radius in meters
final dLat = (lat2 - lat1) * pi / 180;
final dLon = (lon2 - lon1) * pi / 180;
final a =
sin(dLat / 2) * sin(dLat / 2) +
cos(lat1 * pi / 180) *
cos(lat2 * pi / 180) *
sin(dLon / 2) *
sin(dLon / 2);
final c = 2 * atan2(sqrt(a), sqrt(1 - a));
return R * c;
}
String _bearingToCardinal(double bearing) {
const directions = ['N', 'NE', 'E', 'SE', 'S', 'SW', 'W', 'NW'];
final index = ((bearing + 22.5) / 45).floor() % 8;
return directions[index];
}
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.round()}m';
} else {
return '${(meters / 1000).toStringAsFixed(1)}km';
}
}
}

View File

@@ -0,0 +1,200 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import '../../models/map_drawing.dart';
import '../../l10n/app_localizations.dart';
/// Widget that renders map drawings as polylines
class DrawingLayer extends StatelessWidget {
final List<MapDrawing> drawings;
final MapDrawing? previewDrawing;
final LatLng Function(LatLng point)? pointTransformer;
const DrawingLayer({
super.key,
required this.drawings,
this.previewDrawing,
this.pointTransformer,
});
@override
Widget build(BuildContext context) {
final List<Polyline> polylines = [];
// Add completed drawings
for (final drawing in drawings) {
polylines.add(_createPolyline(drawing, isPreview: false));
}
// Add preview drawing (if any)
if (previewDrawing != null) {
polylines.add(_createPolyline(previewDrawing!, isPreview: true));
}
return PolylineLayer(polylines: polylines);
}
/// Create a polyline from a drawing
Polyline _createPolyline(MapDrawing drawing, {required bool isPreview}) {
final points = _getPoints(drawing);
// Different styles for different drawing sources
final double opacity;
final double strokeWidth;
if (isPreview) {
// Preview drawing (currently being drawn)
opacity = 0.6;
strokeWidth = 4.0;
} else if (drawing.isReceived) {
// Received drawing from another node
opacity = 1.0;
strokeWidth = 3.0;
} else {
// Local drawing (solid line, normal thickness)
opacity = 1.0;
strokeWidth = 4.0;
}
return Polyline(
points: points,
color: drawing.color.withValues(alpha: opacity),
strokeWidth: strokeWidth,
borderColor: Colors.white.withValues(alpha: opacity * 0.8),
borderStrokeWidth: 1.0,
// Use dotted pattern for received drawings
pattern: drawing.isReceived && !isPreview
? StrokePattern.dotted(spacingFactor: 2)
: const StrokePattern.solid(),
);
}
/// Get points from a drawing based on its type
List<LatLng> _getPoints(MapDrawing drawing) {
if (drawing is LineDrawing) {
return drawing.points.map(_transformPoint).toList();
} else if (drawing is RectangleDrawing) {
return drawing.corners.map(_transformPoint).toList();
}
return [];
}
LatLng _transformPoint(LatLng point) {
return pointTransformer?.call(point) ?? point;
}
}
/// Widget that shows drawing markers (start/end points)
class DrawingMarkersLayer extends StatelessWidget {
final List<MapDrawing> drawings;
final Function(String drawingId)? onDeleteDrawing;
final Function(MapDrawing drawing)? onTapDrawing;
final bool showDeleteButtons;
final LatLng Function(LatLng point)? pointTransformer;
const DrawingMarkersLayer({
super.key,
required this.drawings,
this.onDeleteDrawing,
this.onTapDrawing,
this.showDeleteButtons = false,
this.pointTransformer,
});
@override
Widget build(BuildContext context) {
final List<Marker> markers = [];
// Add markers for each drawing
for (final drawing in drawings) {
final centerPoint = _getCenterPoint(drawing);
if (centerPoint != null) {
if (showDeleteButtons) {
// Show delete button when in drawing mode
markers.add(
Marker(
point: centerPoint,
width: 40,
height: 40,
child: GestureDetector(
onTap: () {
if (onDeleteDrawing != null) {
_showDeleteDialog(context, drawing);
}
},
child: Container(
decoration: BoxDecoration(
color: drawing.color.withValues(alpha: 0.9),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: const Icon(Icons.close, color: Colors.white, size: 20),
),
),
),
);
}
}
}
if (markers.isEmpty) {
return const SizedBox.shrink();
}
return MarkerLayer(markers: markers);
}
/// Get the center point of a drawing
LatLng? _getCenterPoint(MapDrawing drawing) {
if (drawing is LineDrawing && drawing.points.isNotEmpty) {
// Use the middle point of the line
final midIndex = drawing.points.length ~/ 2;
return _transformPoint(drawing.points[midIndex]);
} else if (drawing is RectangleDrawing) {
// Use the center of the rectangle
return _transformPoint(
LatLng(
(drawing.topLeft.latitude + drawing.bottomRight.latitude) / 2,
(drawing.topLeft.longitude + drawing.bottomRight.longitude) / 2,
),
);
}
return null;
}
LatLng _transformPoint(LatLng point) {
return pointTransformer?.call(point) ?? point;
}
/// Show delete confirmation dialog
void _showDeleteDialog(BuildContext context, MapDrawing drawing) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(AppLocalizations.of(context)!.deleteDrawing),
content: Text(AppLocalizations.of(context)!.deleteThisDrawing(drawing.type.name)),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.cancel),
),
TextButton(
onPressed: () {
Navigator.pop(context);
onDeleteDrawing?.call(drawing.id);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(AppLocalizations.of(context)!.delete),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,175 @@
import 'package:flutter/material.dart';
/// A navigation arrow pointer that indicates the user's location and direction of travel.
///
/// The pointer consists of:
/// - An outer semi-transparent circle representing GPS accuracy
/// - An inner triangular arrow pointing in the direction of travel/heading
/// - Optional rotation based on compass or GPS heading
class LocationPointer extends StatelessWidget {
/// The heading in degrees (0-360, where 0 = North, 90 = East)
/// If null or -1, the pointer will not rotate
final double? heading;
/// The primary color for the pointer
final Color color;
/// The size of the entire widget
final double size;
const LocationPointer({
super.key,
this.heading,
required this.color,
this.size = 40.0,
});
@override
Widget build(BuildContext context) {
// Determine if we have valid heading data
final hasValidHeading = heading != null && heading! >= 0;
// Calculate rotation angle (convert heading to radians)
final rotationAngle = hasValidHeading ? (heading! * 3.14159 / 180.0) : 0.0;
return SizedBox(
width: size,
height: size,
child: Stack(
alignment: Alignment.center,
children: [
// Outer accuracy circle (very subtle, uses theme color)
Container(
width: size * 0.6,
height: size * 0.6,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
shape: BoxShape.circle,
),
),
// Inner rotatable arrow pointer (much larger - 90% of size)
Transform.rotate(
angle: rotationAngle,
child: CustomPaint(
size: Size(size * 0.9, size * 0.9),
painter: _NavigationPointerPainter(
color: color,
hasValidHeading: hasValidHeading,
),
),
),
],
),
);
}
}
/// Custom painter that draws a navigation arrow pointer
class _NavigationPointerPainter extends CustomPainter {
final Color color;
final bool hasValidHeading;
_NavigationPointerPainter({
required this.color,
required this.hasValidHeading,
});
@override
void paint(Canvas canvas, Size size) {
final center = Offset(size.width / 2, size.height / 2);
final width = size.width;
final height = size.height;
if (hasValidHeading) {
// Create navigation arrow with V-shaped cutout at bottom
final arrowPath = Path();
// Top point (sharp tip)
arrowPath.moveTo(center.dx, height * 0.08);
// Right side down to bottom right
arrowPath.lineTo(center.dx + width * 0.42, height * 0.92);
// V-cutout at bottom - right side to center
arrowPath.lineTo(center.dx, height * 0.70);
// V-cutout - center to left side
arrowPath.lineTo(center.dx - width * 0.42, height * 0.92);
// Left side back up to top
arrowPath.lineTo(center.dx, height * 0.08);
arrowPath.close();
// Draw shadow for depth
final shadowPaint = Paint()
..color = Colors.black.withValues(alpha: 0.25)
..style = PaintingStyle.fill
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 4);
canvas.save();
canvas.translate(2, 2);
canvas.drawPath(arrowPath, shadowPaint);
canvas.restore();
// Left side (lighter - 70% opacity of theme color)
final leftSidePath = Path();
leftSidePath.moveTo(center.dx, height * 0.08);
leftSidePath.lineTo(center.dx - width * 0.42, height * 0.92);
leftSidePath.lineTo(center.dx, height * 0.70);
leftSidePath.close();
final leftPaint = Paint()
..color = color.withValues(alpha: 0.7)
..style = PaintingStyle.fill;
canvas.drawPath(leftSidePath, leftPaint);
// Right side (darker - full theme color)
final rightSidePath = Path();
rightSidePath.moveTo(center.dx, height * 0.08);
rightSidePath.lineTo(center.dx, height * 0.70);
rightSidePath.lineTo(center.dx + width * 0.42, height * 0.92);
rightSidePath.close();
final rightPaint = Paint()
..color = color
..style = PaintingStyle.fill;
canvas.drawPath(rightSidePath, rightPaint);
// Optional: Draw white border for contrast
final borderPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 2.0
..strokeJoin = StrokeJoin.round;
canvas.drawPath(arrowPath, borderPaint);
} else {
// No heading available - draw a circle with white border (uses theme color)
final circlePaint = Paint()
..color = color
..style = PaintingStyle.fill;
canvas.drawCircle(center, width * 0.4, circlePaint);
// White border
final borderPaint = Paint()
..color = Colors.white
..style = PaintingStyle.stroke
..strokeWidth = 2.5;
canvas.drawCircle(center, width * 0.4, borderPaint);
// Center white dot
final centerDot = Paint()
..color = Colors.white
..style = PaintingStyle.fill;
canvas.drawCircle(center, width * 0.15, centerDot);
}
}
@override
bool shouldRepaint(_NavigationPointerPainter oldDelegate) {
return oldDelegate.color != color ||
oldDelegate.hasValidHeading != hasValidHeading;
}
}

View File

@@ -0,0 +1,155 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:provider/provider.dart';
import '../../providers/map_provider.dart';
/// Widget that renders the user's location trail on the map
class LocationTrailLayer extends StatelessWidget {
const LocationTrailLayer({super.key});
@override
Widget build(BuildContext context) {
return Consumer<MapProvider>(
builder: (context, mapProvider, child) {
final trail = mapProvider.currentTrail;
final isVisible = mapProvider.isTrailVisible;
// Don't render if trail is hidden or empty
if (!isVisible || trail == null || trail.points.length < 2) {
return const SizedBox.shrink();
}
final points = trail.latLngPoints;
return PolylineLayer(
polylines: [
Polyline(
points: points,
strokeWidth: 4.0,
color: Colors.blue.withValues(alpha: 0.7),
borderStrokeWidth: 2.0,
borderColor: Colors.white.withValues(alpha: 0.5),
),
],
);
},
);
}
}
/// Widget that shows trail statistics overlay
class TrailStatsOverlay extends StatelessWidget {
const TrailStatsOverlay({super.key});
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.toStringAsFixed(0)} m';
} else {
return '${(meters / 1000).toStringAsFixed(2)} km';
}
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s';
} else {
return '${seconds}s';
}
}
@override
Widget build(BuildContext context) {
return Consumer<MapProvider>(
builder: (context, mapProvider, child) {
final trail = mapProvider.currentTrail;
final isVisible = mapProvider.isTrailVisible;
// Don't show if trail is hidden or doesn't exist
if (!isVisible || trail == null || trail.points.isEmpty) {
return const SizedBox.shrink();
}
final distance = mapProvider.totalTrailDistance;
final duration = mapProvider.trailDuration;
final pointCount = trail.points.length;
return Positioned(
top: 16,
left: 16,
child: Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(8),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.timeline,
color: Colors.blue,
size: 20,
),
const SizedBox(width: 8),
const Text(
'Location Trail',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
],
),
const SizedBox(height: 8),
_buildStatRow(Icons.straighten, _formatDistance(distance)),
const SizedBox(height: 4),
_buildStatRow(Icons.access_time, _formatDuration(duration)),
const SizedBox(height: 4),
_buildStatRow(Icons.place, '$pointCount points'),
],
),
),
);
},
);
}
Widget _buildStatRow(IconData icon, String text) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
color: Colors.white70,
size: 16,
),
const SizedBox(width: 6),
Text(
text,
style: const TextStyle(
color: Colors.white70,
fontSize: 12,
),
),
],
);
}
}

View File

@@ -0,0 +1,117 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
class MapLegend extends StatelessWidget {
final int teamMemberCount;
final int foundPersonCount;
final int fireCount;
final int stagingAreaCount;
final int objectCount;
const MapLegend({
super.key,
required this.teamMemberCount,
required this.foundPersonCount,
required this.fireCount,
required this.stagingAreaCount,
required this.objectCount,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Legend',
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
_LegendItem(
icon: Icons.person,
color: Theme.of(context).colorScheme.primary,
label: AppLocalizations.of(context)!.team,
count: teamMemberCount,
),
_LegendItem(
icon: Icons.person_pin,
color: Colors.green,
label: AppLocalizations.of(context)!.found,
count: foundPersonCount,
),
_LegendItem(
icon: Icons.local_fire_department,
color: Colors.red,
label: AppLocalizations.of(context)!.fire,
count: fireCount,
),
_LegendItem(
icon: Icons.home_work,
color: Colors.orange,
label: AppLocalizations.of(context)!.staging,
count: stagingAreaCount,
),
_LegendItem(
icon: Icons.inventory_2,
color: Colors.purple,
label: AppLocalizations.of(context)!.object,
count: objectCount,
),
],
),
),
);
}
}
class _LegendItem extends StatelessWidget {
final IconData icon;
final Color color;
final String label;
final int count;
const _LegendItem({
required this.icon,
required this.color,
required this.label,
required this.count,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 16, color: color),
const SizedBox(width: 8),
Text(
label,
style: Theme.of(context).textTheme.bodySmall,
),
const SizedBox(width: 4),
Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(8),
),
child: Text(
count.toString(),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.bold,
),
),
),
],
),
);
}
}

View File

@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import '../../models/message.dart';
import '../../l10n/app_localizations.dart';
import '../messages/message_bubble.dart';
/// Message overlay widget for displaying recent messages on the map
/// Only shown in fullscreen mode on large screens (>= 800px width)
class MapMessageOverlay extends StatefulWidget {
final List<Message> messages;
final VoidCallback? onNavigateToMessages;
final Function(String messageId)? onMessageTap;
const MapMessageOverlay({
super.key,
required this.messages,
this.onNavigateToMessages,
this.onMessageTap,
});
@override
State<MapMessageOverlay> createState() => _MapMessageOverlayState();
}
class _MapMessageOverlayState extends State<MapMessageOverlay> {
final ScrollController _scrollController = ScrollController();
@override
void initState() {
super.initState();
// Scroll to bottom on initial build
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom(animate: false);
});
}
@override
void didUpdateWidget(MapMessageOverlay oldWidget) {
super.didUpdateWidget(oldWidget);
// Auto-scroll to bottom when new messages arrive
if (widget.messages.length > oldWidget.messages.length) {
WidgetsBinding.instance.addPostFrameCallback((_) {
_scrollToBottom(animate: true);
});
}
}
void _scrollToBottom({bool animate = true}) {
if (_scrollController.hasClients) {
if (animate) {
_scrollController.animateTo(
_scrollController.position.maxScrollExtent,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
} else {
_scrollController.jumpTo(_scrollController.position.maxScrollExtent);
}
}
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (widget.messages.isEmpty) {
return const SizedBox.shrink();
}
return Container(
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.75),
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Column(
children: [
// Header
Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.3),
borderRadius: const BorderRadius.vertical(top: Radius.circular(12)),
),
child: Row(
children: [
const Icon(
Icons.message,
color: Colors.white,
size: 20,
),
SizedBox(width: 8),
Expanded(
child: Text(
AppLocalizations.of(context)!.recentMessages,
style: const TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
),
Text(
'${widget.messages.length}',
style: TextStyle(
color: Colors.white.withValues(alpha: 0.7),
fontSize: 12,
),
),
],
),
),
// Message list
Expanded(
child: ListView.separated(
controller: _scrollController,
padding: const EdgeInsets.all(8),
itemCount: widget.messages.length,
separatorBuilder: (context, index) => const SizedBox(height: 4),
itemBuilder: (context, index) {
final message = widget.messages[index];
return MessageBubble(
message: message,
isCompact: true,
onTap: () {
widget.onMessageTap?.call(message.id);
},
);
},
),
),
],
),
);
}
}

View File

@@ -0,0 +1,304 @@
import 'dart:math' show log, ln2;
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:latlong2/latlong.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../providers/offline_tiles_provider.dart';
/// Renders polygon/rectangle drawing interaction on the map.
///
/// Shows completed polygons, in-progress vertices, and handles tap events.
class PolygonDrawLayer extends StatelessWidget {
const PolygonDrawLayer({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
final polygons = <Polygon>[];
final markers = <Marker>[];
// Completed polygons
for (int i = 0; i < provider.polygons.length; i++) {
final poly = provider.polygons[i];
polygons.add(Polygon(
points: poly,
color: Colors.blue.withValues(alpha: 0.2),
borderColor: Colors.blue,
borderStrokeWidth: 2,
));
}
// In-progress polygon vertices
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.isNotEmpty) {
final verts = provider.currentVertices;
// Draw lines between vertices
if (verts.length >= 2) {
polygons.add(Polygon(
points: verts,
color: Colors.orange.withValues(alpha: 0.1),
borderColor: Colors.orange,
borderStrokeWidth: 2,
));
}
// Draw vertex markers
for (final v in verts) {
markers.add(Marker(
point: v,
width: 12,
height: 12,
child: Container(
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
),
),
));
}
}
// Rectangle first corner marker
if (provider.drawingMode == DrawingMode.rectangle &&
provider.rectangleFirstCorner != null) {
markers.add(Marker(
point: provider.rectangleFirstCorner!,
width: 14,
height: 14,
child: Container(
decoration: BoxDecoration(
color: Colors.orange,
shape: BoxShape.rectangle,
border: Border.all(color: Colors.white, width: 2),
),
),
));
}
return Stack(
children: [
if (polygons.isNotEmpty) PolygonLayer(polygons: polygons),
if (markers.isNotEmpty) MarkerLayer(markers: markers),
],
);
},
);
}
}
/// Renders the download progress overlay tiles.
class DownloadProgressLayer extends StatelessWidget {
const DownloadProgressLayer({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.tileOverlays.isEmpty) return const SizedBox.shrink();
final polygons = provider.tileOverlays.map((overlay) {
return Polygon(
points: [
LatLng(overlay.north, overlay.west),
LatLng(overlay.north, overlay.east),
LatLng(overlay.south, overlay.east),
LatLng(overlay.south, overlay.west),
],
color: overlay.isSkipped
? Colors.green.withValues(alpha: 0.15)
: Colors.orange.withValues(alpha: 0.15),
borderColor:
overlay.isSkipped ? Colors.green : Colors.orange,
borderStrokeWidth: 1,
);
}).toList();
return PolygonLayer(polygons: polygons);
},
);
}
}
/// Renders the coverage overlay for a cached style.
///
/// Only renders tiles at the zoom level closest to the current map zoom
/// to avoid drawing thousands of rectangles at once.
class CoverageLayer extends StatelessWidget {
final double currentZoom;
const CoverageLayer({super.key, required this.currentZoom});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.coverageOverlays.isEmpty) {
return const SizedBox.shrink();
}
// Filter to tiles near the current zoom level to keep rendering fast.
// Show the zoom level that's <= current map zoom (best visual match).
final targetZoom = currentZoom.floor();
// Group overlays by approximate zoom level based on tile size.
// A tile at zoom z covers roughly (360/2^z) degrees of longitude.
// We filter by checking if the tile width matches the target zoom.
final filtered = <Polygon>[];
for (final overlay in provider.coverageOverlays) {
// Estimate the zoom level from the tile's longitude span
final lonSpan = (overlay.east - overlay.west).abs();
if (lonSpan <= 0) continue;
final estimatedZoom = (log(360.0 / lonSpan) / ln2).round();
if (estimatedZoom == targetZoom ||
estimatedZoom == targetZoom - 1 ||
estimatedZoom == targetZoom + 1) {
filtered.add(Polygon(
points: [
LatLng(overlay.north, overlay.west),
LatLng(overlay.north, overlay.east),
LatLng(overlay.south, overlay.east),
LatLng(overlay.south, overlay.west),
],
color: Colors.blue.withValues(alpha: 0.1),
borderColor: Colors.blue.withValues(alpha: 0.4),
borderStrokeWidth: 1,
));
}
// Cap at 1000 visible tiles to avoid jank
if (filtered.length >= 1000) break;
}
if (filtered.isEmpty) return const SizedBox.shrink();
return PolygonLayer(polygons: filtered);
},
);
}
}
/// Toolbar for drawing controls.
class DrawingToolbar extends StatelessWidget {
const DrawingToolbar({super.key});
@override
Widget build(BuildContext context) {
return Consumer<OfflineTilesProvider>(
builder: (context, provider, _) {
if (provider.isDownloading) return const SizedBox.shrink();
return Positioned(
right: 16,
top: 100,
child: Column(
children: [
_ToolButton(
icon: Icons.crop_square,
label: AppLocalizations.of(context)!.rectangle,
isActive: provider.drawingMode == DrawingMode.rectangle,
onTap: () => provider.setDrawingMode(
provider.drawingMode == DrawingMode.rectangle
? DrawingMode.none
: DrawingMode.rectangle,
),
),
const SizedBox(height: 8),
_ToolButton(
icon: Icons.pentagon_outlined,
label: AppLocalizations.of(context)!.polygon,
isActive: provider.drawingMode == DrawingMode.polygon,
onTap: () => provider.setDrawingMode(
provider.drawingMode == DrawingMode.polygon
? DrawingMode.none
: DrawingMode.polygon,
),
),
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.length >= 3) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.check,
label: AppLocalizations.of(context)!.finish,
isActive: false,
color: Colors.green,
onTap: () => provider.finishPolygon(),
),
],
if (provider.drawingMode == DrawingMode.polygon &&
provider.currentVertices.isNotEmpty) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.undo,
label: AppLocalizations.of(context)!.undo,
isActive: false,
onTap: () => provider.undoLastVertex(),
),
],
if (provider.hasPolygons) ...[
const SizedBox(height: 8),
_ToolButton(
icon: Icons.delete_outline,
label: AppLocalizations.of(context)!.clear,
isActive: false,
color: Colors.red,
onTap: () => provider.clearPolygons(),
),
],
],
),
);
},
);
}
}
class _ToolButton extends StatelessWidget {
final IconData icon;
final String label;
final bool isActive;
final Color? color;
final VoidCallback onTap;
const _ToolButton({
required this.icon,
required this.label,
required this.isActive,
this.color,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final activeColor = color ?? theme.colorScheme.primary;
return Tooltip(
message: label,
child: Material(
elevation: 2,
shape: const CircleBorder(),
color: isActive ? activeColor : theme.colorScheme.surface,
child: InkWell(
customBorder: const CircleBorder(),
onTap: onTap,
child: SizedBox(
width: 48,
height: 48,
child: Icon(
icon,
color: isActive
? theme.colorScheme.onPrimary
: (color ?? theme.colorScheme.onSurface),
),
),
),
),
);
}
}

View File

@@ -0,0 +1,349 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/location_trail.dart';
import '../../providers/map_provider.dart';
import '../../services/gpx_service.dart';
import '../../l10n/app_localizations.dart';
/// Trail management controls widget
class TrailControls extends StatelessWidget {
const TrailControls({super.key});
void _showTrailMenu(BuildContext context) {
final mapProvider = Provider.of<MapProvider>(context, listen: false);
final l10n = AppLocalizations.of(context)!;
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => StatefulBuilder(
builder: (context, setModalState) => SingleChildScrollView(
child: Container(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
const Icon(Icons.timeline, size: 24),
SizedBox(width: 12),
Text(
l10n.locationTrail,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
],
),
const SizedBox(height: 20),
// Trail recording toggle
SwitchListTile(
secondary: Icon(Icons.fiber_manual_record),
title: Text('Record location trail'),
subtitle: Text(
mapProvider.isTrailRecordingEnabled
? 'Trail recording is on'
: 'Trail recording is stopped',
),
value: mapProvider.isTrailRecordingEnabled,
onChanged: (value) {
mapProvider.setTrailRecordingEnabled(value);
setModalState(() {});
},
),
const Divider(),
// Trail visibility toggle
SwitchListTile(
secondary: Icon(Icons.visibility),
title: Text(l10n.showTrailOnMap),
subtitle: Text(
!mapProvider.isTrailRecordingEnabled
? 'Trail recording is stopped'
: (mapProvider.isTrailVisible
? l10n.trailVisible
: l10n.trailHiddenRecording),
),
value: mapProvider.isTrailVisible,
onChanged: (value) {
mapProvider.toggleTrailVisibility();
setModalState(() {}); // Update modal UI
},
),
const Divider(),
const SizedBox(height: 8),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
OutlinedButton.icon(
onPressed: () => _importTrail(context, mapProvider),
icon: Icon(Icons.file_upload_outlined),
label: Text(l10n.importFromClipboard),
),
if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.length >= 2)
OutlinedButton.icon(
onPressed: () => _exportTrail(
context,
mapProvider.currentTrail!,
customName: 'My Trail',
),
icon: Icon(Icons.file_download_outlined),
label: Text(l10n.exportToClipboard),
),
],
),
const SizedBox(height: 16),
// Trail stats
if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.isNotEmpty)
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.blue.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.blue.withValues(alpha: 0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildStatRow(
icon: Icons.straighten,
label: l10n.distance,
value: _formatDistance(
mapProvider.totalTrailDistance,
),
),
SizedBox(height: 8),
_buildStatRow(
icon: Icons.access_time,
label: l10n.duration,
value: _formatDuration(mapProvider.trailDuration),
),
SizedBox(height: 8),
_buildStatRow(
icon: Icons.place,
label: l10n.points,
value: '${mapProvider.currentTrail!.points.length}',
),
],
),
),
// Clear trail button
if (mapProvider.currentTrail != null &&
mapProvider.currentTrail!.points.isNotEmpty)
ElevatedButton.icon(
onPressed: () {
_showClearConfirmation(context, mapProvider, l10n);
},
icon: Icon(Icons.delete_outline),
label: Text(l10n.clearTrail),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
padding: const EdgeInsets.all(16),
),
),
// No trail message
if (mapProvider.currentTrail == null ||
mapProvider.currentTrail!.points.isEmpty)
Padding(
padding: const EdgeInsets.all(16),
child: Center(
child: Column(
children: [
const Icon(
Icons.timeline,
size: 48,
color: Colors.grey,
),
SizedBox(height: 8),
Text(
l10n.noTrailRecorded,
style: const TextStyle(
color: Colors.grey,
fontSize: 16,
),
),
SizedBox(height: 8),
Text(
l10n.startTrackingToRecord,
style: const TextStyle(
color: Colors.grey,
fontSize: 12,
),
textAlign: TextAlign.center,
),
],
),
),
),
const SizedBox(height: 8),
// Close button
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.close),
),
],
),
),
),
),
);
}
Future<void> _importTrail(
BuildContext context,
MapProvider mapProvider,
) async {
final l10n = AppLocalizations.of(context)!;
try {
final importedTrail = await GpxService.importTrailFromFile();
if (importedTrail == null || !context.mounted) {
return;
}
mapProvider.replaceCurrentTrailWithImport(importedTrail);
mapProvider.clearImportedTrail();
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${l10n.locationTrail}: ${importedTrail.points.length} ${l10n.points.toLowerCase()}',
),
),
);
} catch (error) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.importFailed(error.toString()))),
);
}
}
Future<void> _exportTrail(
BuildContext context,
LocationTrail trail, {
String? customName,
}) async {
final l10n = AppLocalizations.of(context)!;
try {
final success = await GpxService.exportTrailToFile(
trail,
customName: customName,
);
if (!success && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.exportFailed('unable to share GPX'))),
);
}
} catch (error) {
if (!context.mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.exportFailed(error.toString()))),
);
}
}
void _showClearConfirmation(
BuildContext context,
MapProvider mapProvider,
AppLocalizations l10n,
) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.clearTrailQuestion),
content: Text(l10n.clearTrailConfirmation),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(l10n.cancel),
),
TextButton(
onPressed: () {
mapProvider.clearCurrentTrail();
Navigator.pop(context); // Close dialog
Navigator.pop(context); // Close bottom sheet
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: Text(l10n.clearTrail),
),
],
),
);
}
Widget _buildStatRow({
required IconData icon,
required String label,
required String value,
}) {
return Row(
children: [
Icon(icon, size: 18, color: Colors.blue),
const SizedBox(width: 8),
Text(
label,
style: const TextStyle(fontWeight: FontWeight.w500, fontSize: 14),
),
const Spacer(),
Text(
value,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
],
);
}
String _formatDistance(double meters) {
if (meters < 1000) {
return '${meters.toStringAsFixed(0)} m';
} else {
return '${(meters / 1000).toStringAsFixed(2)} km';
}
}
String _formatDuration(Duration duration) {
final hours = duration.inHours;
final minutes = duration.inMinutes.remainder(60);
final seconds = duration.inSeconds.remainder(60);
if (hours > 0) {
return '${hours}h ${minutes}m ${seconds}s';
} else if (minutes > 0) {
return '${minutes}m ${seconds}s';
} else {
return '${seconds}s';
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context)!;
return FloatingActionButton.small(
heroTag: 'trail_controls',
tooltip: l10n.trailControls,
onPressed: () => _showTrailMenu(context),
child: const Icon(Icons.timeline),
);
}
}

View File

@@ -0,0 +1,102 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
/// Map Debug Info Widget
/// Displays current zoom level and visible bounds in bottom-left corner
class MapDebugInfo extends StatefulWidget {
final MapController mapController;
const MapDebugInfo({super.key, required this.mapController});
@override
State<MapDebugInfo> createState() => _MapDebugInfoState();
}
class _MapDebugInfoState extends State<MapDebugInfo> {
StreamSubscription<MapEvent>? _mapEventSubscription;
@override
void initState() {
super.initState();
// Listen to map events and trigger rebuild
_mapEventSubscription = widget.mapController.mapEventStream.listen((event) {
if (mounted) {
setState(() {
// Rebuild when map moves, zooms, or rotates
});
}
});
}
@override
void dispose() {
_mapEventSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
try {
final camera = widget.mapController.camera;
final bounds = camera.visibleBounds;
return Card(
color: Colors.black.withValues(alpha: 0.7),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
'Z: ${camera.zoom.toStringAsFixed(2)}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontFamily: 'monospace',
),
),
const SizedBox(height: 2),
Text(
'N: ${bounds.north.toStringAsFixed(5)}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontFamily: 'monospace',
),
),
Text(
'S: ${bounds.south.toStringAsFixed(5)}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontFamily: 'monospace',
),
),
Text(
'E: ${bounds.east.toStringAsFixed(5)}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontFamily: 'monospace',
),
),
Text(
'W: ${bounds.west.toStringAsFixed(5)}',
style: const TextStyle(
color: Colors.white,
fontSize: 10,
fontFamily: 'monospace',
),
),
],
),
),
);
} catch (e) {
// Map not ready yet
return const SizedBox.shrink();
}
}
}

View File

@@ -0,0 +1,438 @@
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import '../models/contact.dart';
import '../models/sar_marker.dart';
import '../models/sar_template.dart';
import '../l10n/app_localizations.dart';
import '../utils/location_formats.dart';
class MapMarkers {
static List<Marker> createTeamMemberMarkers(
List<Contact> contacts,
BuildContext context, {
Function(Contact)? onContactTap,
double mapRotation = 0,
}) {
return contacts
.map((contact) {
final location = contact.displayLocation;
if (location == null) return null;
return Marker(
point: location,
width: 80,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * 3.14159265359 / 180,
child: GestureDetector(
onTap: () {
if (onContactTap != null) {
onContactTap(contact);
} else {
_showContactInfo(context, contact);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Location update time indicator
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: _getLocationAgeColor(contact),
borderRadius: BorderRadius.circular(3),
),
child: Text(
contact.timeSinceLocationUpdate,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 2),
// Marker icon or emoji
Container(
decoration: BoxDecoration(
color: _getContactTypeColor(contact, context),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(6),
child: contact.roleEmoji != null
? Text(
contact.roleEmoji!,
style: const TextStyle(fontSize: 18),
)
: Icon(
_getContactTypeIcon(contact),
color: Colors.white,
size: 18,
),
),
const SizedBox(height: 2),
// Name label (without emoji)
Container(
constraints: const BoxConstraints(maxWidth: 80),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Text(
contact.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
),
),
],
),
),
),
);
})
.whereType<Marker>()
.toList();
}
static List<Marker> createSarMarkers(
List<SarMarker> sarMarkers,
BuildContext context, {
Function(SarMarker)? onSarMarkerTap,
double mapRotation = 0,
}) {
return sarMarkers.map((marker) {
return Marker(
point: marker.location,
width: 90,
height: 100,
rotate: false, // Don't rotate the entire marker with map
child: Transform.rotate(
angle: -mapRotation * 3.14159265359 / 180,
child: GestureDetector(
onTap: () {
if (onSarMarkerTap != null) {
onSarMarkerTap(marker);
} else {
_showSarMarkerInfo(context, marker);
}
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// Time ago label
Container(
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: _getSarMarkerColor(marker),
borderRadius: BorderRadius.circular(3),
),
child: Text(
marker.timeAgo,
style: const TextStyle(
color: Colors.white,
fontSize: 8,
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(height: 2),
// Marker emoji/icon
Container(
decoration: BoxDecoration(
color: _getSarMarkerColor(marker),
shape: BoxShape.circle,
border: Border.all(color: Colors.white, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
),
padding: const EdgeInsets.all(6),
child: Text(
marker.emoji, // Use custom emoji if available
style: const TextStyle(fontSize: 18),
),
),
const SizedBox(height: 2),
// Type label
Container(
constraints: const BoxConstraints(maxWidth: 90),
padding: const EdgeInsets.symmetric(
horizontal: 4,
vertical: 1,
),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.7),
borderRadius: BorderRadius.circular(3),
),
child: Builder(
builder: (context) {
// Debug: Print what we're actually displaying
debugPrint('🗺️ [MapMarker] Displaying SAR marker:');
debugPrint(' marker.notes: "${marker.notes}"');
debugPrint(' marker.type: ${marker.type}');
debugPrint(
' marker.type.displayName: ${marker.type.displayName}',
);
debugPrint(
' marker.displayName: ${marker.displayName}',
);
return Text(
marker.displayName,
style: const TextStyle(
color: Colors.white,
fontSize: 9,
fontWeight: FontWeight.bold,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
textAlign: TextAlign.center,
);
},
),
),
],
),
),
),
);
}).toList();
}
static void _showContactInfo(BuildContext context, Contact contact) {
// Import provider to get all contacts and SAR markers for detailed view
// This will be handled by importing the screen's detailed compass dialog
// Since we can't directly access _DetailedCompassDialog from here,
// we'll pass a callback to the screen
// For now, show the simple dialog as a fallback
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
if (contact.roleEmoji != null)
Text(contact.roleEmoji!, style: const TextStyle(fontSize: 24))
else
Icon(Icons.person, color: Theme.of(context).colorScheme.primary),
const SizedBox(width: 8),
Expanded(child: Text(contact.displayName)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (contact.displayLocation != null) ...[
_InfoRow(
'Location',
formatCoordinates(
contact.displayLocation!.latitude,
contact.displayLocation!.longitude,
CoordinateDisplayFormat.utm,
),
),
],
if (contact.telemetry?.batteryMilliVolts != null)
_InfoRow(
'Voltage',
'${(contact.telemetry!.batteryMilliVolts! / 1000).toStringAsFixed(3)}V'
'${contact.telemetry!.batteryPercentage != null ? ' (${contact.telemetry!.batteryPercentage!.toStringAsFixed(1)}%)' : ''}',
)
else if (contact.displayBattery != null)
_InfoRow('Battery', '${contact.displayBattery!.round()}%'),
if (contact.telemetry?.temperature != null)
_InfoRow(
'Temperature',
'${contact.telemetry!.temperature!.toStringAsFixed(1)}°C',
),
if (contact.telemetry?.humidity != null)
_InfoRow(
'Humidity',
'${contact.telemetry!.humidity!.toStringAsFixed(1)}%',
),
if (contact.telemetry?.pressure != null)
_InfoRow(
'Pressure',
'${contact.telemetry!.pressure!.toStringAsFixed(1)} hPa',
),
_InfoRow('Last Seen', contact.timeSinceLastSeen),
_InfoRow('Public Key', contact.publicKeyShort),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.close),
),
],
),
);
}
static void _showSarMarkerInfo(BuildContext context, SarMarker marker) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Row(
children: [
Text(
marker.emoji,
style: const TextStyle(fontSize: 24),
), // Use custom emoji if available
const SizedBox(width: 8),
Expanded(child: Text(marker.displayName)),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_InfoRow(
'Location',
formatCoordinates(
marker.location.latitude,
marker.location.longitude,
CoordinateDisplayFormat.utm,
),
),
_InfoRow('Reported', marker.timeAgo),
if (marker.senderName != null)
_InfoRow('Reporter', marker.senderName!),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text(AppLocalizations.of(context)!.close),
),
],
),
);
}
static Color _getLocationAgeColor(Contact contact) {
final updateTime = contact.locationUpdateTime;
if (updateTime == null) return Colors.grey;
final diff = DateTime.now().difference(updateTime);
if (diff.inMinutes < 5) return Colors.green; // Very recent
if (diff.inMinutes < 30) return Colors.lightBlue; // Recent
if (diff.inHours < 2) return Colors.orange; // Getting old
return Colors.red; // Stale
}
static Color _getSarMarkerColor(SarMarker marker) {
// If marker has a color index, use it (new format)
if (marker.colorIndex != null &&
marker.colorIndex! >= 0 &&
marker.colorIndex! < 8) {
final colorHex = SarTemplate.getColorFromIndex(marker.colorIndex!);
final hexCode = colorHex.replaceAll('#', '');
return Color(int.parse('FF$hexCode', radix: 16));
}
// Otherwise fall back to type-based colors (old format or backward compatibility)
switch (marker.type) {
case SarMarkerType.foundPerson:
return Colors.green;
case SarMarkerType.fire:
return Colors.red;
case SarMarkerType.stagingArea:
return Colors.orange;
case SarMarkerType.object:
return Colors.purple;
case SarMarkerType.unknown:
return Colors.grey;
}
}
static Color _getContactTypeColor(Contact contact, BuildContext context) {
switch (contact.type) {
case ContactType.chat:
return Theme.of(context).colorScheme.primary; // Blue for team members
case ContactType.repeater:
return Colors.deepPurple; // Purple for repeaters
case ContactType.room:
return Colors.teal; // Teal for rooms
case ContactType.sensor:
return Colors.green; // Green for sensors
case ContactType.channel:
return Colors.orange; // Orange for channels
case ContactType.none:
return Colors.grey;
}
}
static IconData _getContactTypeIcon(Contact contact) {
switch (contact.type) {
case ContactType.chat:
return Icons.person; // Person for team members
case ContactType.repeater:
return Icons.router; // Router icon for repeaters
case ContactType.room:
return Icons.forum; // Forum/chat icon for rooms
case ContactType.sensor:
return Icons.sensors; // Sensors icon for sensor nodes
case ContactType.channel:
return Icons.public; // Public icon for channels
case ContactType.none:
return Icons.help_outline;
}
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
const _InfoRow(this.label, this.value);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 90,
child: Text(
'$label:',
style: const TextStyle(fontWeight: FontWeight.w600),
),
),
Expanded(child: Text(value)),
],
),
);
}
}

View File

@@ -0,0 +1,472 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../models/sar_template.dart';
import '../../providers/contacts_provider.dart';
import '../../services/sar_template_service.dart';
import 'sar_update_sheet.dart' show TemplateChip;
class CustomMapSarUpdateSheet extends StatefulWidget {
final String mapName;
final String mapId;
final String pointLabel;
final Future<void> Function(
String emoji,
String name,
Uint8List? roomPublicKey,
bool sendToChannel,
bool sendToAllContacts,
int colorIndex,
)
onSend;
const CustomMapSarUpdateSheet({
super.key,
required this.mapName,
required this.mapId,
required this.pointLabel,
required this.onSend,
});
@override
State<CustomMapSarUpdateSheet> createState() =>
_CustomMapSarUpdateSheetState();
}
class _CustomMapSarUpdateSheetState extends State<CustomMapSarUpdateSheet> {
final SarTemplateService _templateService = SarTemplateService();
final TextEditingController _notesController = TextEditingController();
List<SarTemplate> _templates = [];
SarTemplate? _selectedTemplate;
Contact? _selectedContact;
bool _sendToAllContacts = false;
@override
void initState() {
super.initState();
_initializeTemplates();
_setDefaultDestination();
}
Future<void> _initializeTemplates() async {
if (!_templateService.isInitialized) {
await _templateService.initialize();
}
if (!mounted) return;
setState(() {
_templates = _templateService.templates;
if (_templates.isNotEmpty) {
_selectedTemplate = _templates.first;
}
});
}
void _setDefaultDestination() {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
final contactsProvider = context.read<ContactsProvider>();
final roomsAndChannels = contactsProvider.roomsAndChannels;
final teamContacts = contactsProvider.chatContacts;
if (teamContacts.length > 1) {
setState(() {
_sendToAllContacts = true;
_selectedContact = null;
});
return;
}
if (teamContacts.length == 1) {
setState(() {
_sendToAllContacts = false;
_selectedContact = teamContacts.first;
});
return;
}
if (roomsAndChannels.any((c) => c.isRoom)) {
setState(() {
_sendToAllContacts = false;
_selectedContact = roomsAndChannels.firstWhere((c) => c.isRoom);
});
return;
}
if (roomsAndChannels.isNotEmpty) {
setState(() {
_sendToAllContacts = false;
_selectedContact = roomsAndChannels.first;
});
}
});
}
@override
void dispose() {
_notesController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final keyboardHeight = MediaQuery.of(context).viewInsets.bottom;
final bottomSafeArea = MediaQuery.of(context).padding.bottom;
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return AnimatedPadding(
padding: EdgeInsets.only(bottom: keyboardHeight),
duration: const Duration(milliseconds: 100),
child: Container(
height: MediaQuery.of(context).size.height * 0.88,
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: Column(
children: [
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: const BorderRadius.vertical(
top: Radius.circular(20),
),
),
child: Row(
children: [
IconButton(
icon: Icon(Icons.arrow_back, color: colorScheme.onSurface),
onPressed: () => Navigator.pop(context),
),
Expanded(
child: Column(
children: [
Text(
AppLocalizations.of(context)!.sendSarMarker,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
Text(
AppLocalizations.of(context)!.customCaveMapPoint,
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontSize: 14,
),
),
],
),
),
const SizedBox(width: 48),
],
),
),
Expanded(
child: SingleChildScrollView(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: 80 + bottomSafeArea,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
AppLocalizations.of(context)!.markerType,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
..._templates.map((template) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: TemplateChip(
template: template,
isSelected: _selectedTemplate?.id == template.id,
onTap: () =>
setState(() => _selectedTemplate = template),
),
);
}),
SizedBox(height: 24),
Text(
AppLocalizations.of(context)!.sendTo,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Consumer<ContactsProvider>(
builder: (context, contactsProvider, child) {
final teamContacts = contactsProvider.chatContacts;
final roomsAndChannels =
contactsProvider.roomsAndChannels;
final destinations = <Contact>[
...teamContacts,
...roomsAndChannels.where((c) => c.isRoom),
...roomsAndChannels.where((c) => c.isChannel),
];
if (destinations.isEmpty && teamContacts.isEmpty) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: Colors.red.withValues(alpha: 0.3),
),
),
child: Text(
AppLocalizations.of(
context,
)!.noDestinationsAvailableLabel,
),
);
}
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 4,
),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.3),
),
),
child: DropdownButtonHideUnderline(
child: DropdownButton<String>(
value: _sendToAllContacts
? 'all_contacts'
: _selectedContact?.publicKeyHex,
hint: Text(
AppLocalizations.of(context)!.selectDestination,
),
dropdownColor:
colorScheme.surfaceContainerHighest,
isExpanded: true,
items: [
if (teamContacts.isNotEmpty)
DropdownMenuItem<String>(
value: 'all_contacts',
child: Row(
children: [
const Icon(Icons.group, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
AppLocalizations.of(
context,
)!.allTeamContacts,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
...destinations.map((contact) {
final icon = contact.isChat
? Icons.person
: contact.isRoom
? Icons.storage
: Icons.public;
return DropdownMenuItem<String>(
value: contact.publicKeyHex,
child: Row(
children: [
Icon(icon, size: 18),
const SizedBox(width: 12),
Expanded(
child: Text(
contact.getLocalizedDisplayName(
context,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}),
],
onChanged: (value) {
setState(() {
if (value == 'all_contacts') {
_sendToAllContacts = true;
_selectedContact = null;
} else {
_sendToAllContacts = false;
_selectedContact = destinations.firstWhere(
(c) => c.publicKeyHex == value,
);
}
});
},
),
),
);
},
),
const SizedBox(height: 24),
Text(
AppLocalizations.of(context)!.mapPoint,
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(
Icons.map_outlined,
size: 20,
color: Colors.blue,
),
const SizedBox(width: 8),
Expanded(
child: Text(
widget.mapName,
style: TextStyle(
color: colorScheme.onSurface,
fontWeight: FontWeight.w600,
),
),
),
],
),
const SizedBox(height: 8),
Text(
widget.pointLabel,
style: TextStyle(
color: colorScheme.onSurface,
fontFamily: 'monospace',
fontSize: 13,
),
),
const SizedBox(height: 4),
Text(
'Map ID: ${widget.mapId}',
style: TextStyle(
color: colorScheme.onSurfaceVariant,
fontFamily: 'monospace',
fontSize: 12,
),
),
],
),
),
const SizedBox(height: 24),
Text(
'Additional notes (optional)',
style: TextStyle(
color: colorScheme.onSurface,
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
TextField(
controller: _notesController,
maxLines: 3,
decoration: InputDecoration(
hintText: AppLocalizations.of(
context,
)!.addAdditionalDetails,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.all(16),
),
),
],
),
),
),
Container(
padding: EdgeInsets.fromLTRB(16, 16, 16, 16 + bottomSafeArea),
decoration: BoxDecoration(
color: colorScheme.surface,
border: Border(
top: BorderSide(
color: colorScheme.outline.withValues(alpha: 0.2),
),
),
),
child: SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: () async {
if (_selectedTemplate == null ||
(!_sendToAllContacts && _selectedContact == null)) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
AppLocalizations.of(
context,
)!.selectMarkerTypeAndDestination,
),
),
);
return;
}
await widget.onSend(
_selectedTemplate!.emoji,
_notesController.text.trim().isEmpty
? _selectedTemplate!.name
: _notesController.text.trim(),
_selectedContact?.isChannel == true
? null
: _selectedContact?.publicKey,
_selectedContact?.isChannel == true,
_sendToAllContacts,
_templates.indexOf(_selectedTemplate!),
);
if (context.mounted) {
Navigator.pop(context);
}
},
icon: Icon(Icons.send),
label: Text(AppLocalizations.of(context)!.send),
),
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,759 @@
import 'dart:async';
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:flutter_avif/flutter_avif.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/image_provider.dart' as ip;
import '../../providers/messages_provider.dart';
import '../../utils/image_message_parser.dart';
import '../../l10n/app_localizations.dart';
import '../../utils/transmission_target_resolver.dart';
import 'transfer_timeout.dart';
/// A message bubble that shows a received or sent image.
///
/// On first render the image is not yet fetched (only the IE2 envelope is
/// known). The user taps the thumbnail placeholder → IR2 fetch request is
/// sent → binary fragments stream in → bubble rebuilds with the full image.
class ImageMessageBubble extends StatefulWidget {
final Message message;
final bool isSentByMe;
const ImageMessageBubble({
super.key,
required this.message,
required this.isSentByMe,
});
@override
State<ImageMessageBubble> createState() => _ImageMessageBubbleState();
}
class _ImageMessageBubbleState extends State<ImageMessageBubble> {
static const int _maxFetchHops = 3;
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
bool _isRequesting = false;
bool _isPartialRequest = false;
String? _errorText;
Timer? _requestTimeoutTimer;
@override
void dispose() {
_requestTimeoutTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final radioBw = context.select<ConnectionProvider, int?>(
(p) => p.deviceInfo.radioBw,
);
final radioSf = context.select<ConnectionProvider, int?>(
(p) => p.deviceInfo.radioSf,
);
final radioCr = context.select<ConnectionProvider, int?>(
(p) => p.deviceInfo.radioCr,
);
final envelope = ImageEnvelope.tryParse(widget.message.text);
if (envelope == null) return const SizedBox.shrink();
return Consumer<ip.ImageProvider>(
builder: (context, imageProvider, _) {
final transferCount = context.select<MessagesProvider, int>(
(provider) => provider.transferCountForSession(
imageSessionId: envelope.sessionId,
),
);
final contactsProvider = context.read<ContactsProvider>();
final session = imageProvider.session(envelope.sessionId);
final sender = TransmissionTargetResolver.resolveLocalTarget(
contactsProvider: contactsProvider,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderName: widget.message.senderName,
);
final effectivePathLen = sender != null && sender.routeHasPath
? sender.routeHopCount
: widget.message.pathLen;
final isComplete = imageProvider.isComplete(envelope.sessionId);
final eta = imageProvider.estimateRemainingTransferTime(
envelope.sessionId,
);
if (_isRequesting && isComplete) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = null;
});
}
});
}
final received = session?.receivedCount ?? 0;
final total = session?.total ?? envelope.total;
final imageBytes = isComplete ? session?.imageBytes : null;
final fragmentPresence =
session?.fragments.map((fragment) => fragment != null).toList() ??
List<bool>.filled(total, false);
final isReceivingData =
!_isRequesting &&
!isComplete &&
_hasRecentInboundActivity(
lastReceivedAt: session?.lastFragmentAt,
received: received,
total: total,
);
return GestureDetector(
onTap: isComplete
? () => _showFullScreen(context, imageBytes!)
: null,
child: Container(
constraints: const BoxConstraints(maxWidth: 256),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// Image area: 256×256 placeholder or actual image
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _buildImageArea(
context,
imageBytes: imageBytes,
isComplete: isComplete,
isRequesting: _isRequesting,
isReceivingData: isReceivingData,
received: received,
total: total,
fragmentPresence: fragmentPresence,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: effectivePathLen,
),
),
const SizedBox(height: 4),
// Status line
Text(
_statusText(
isComplete: isComplete,
isRequesting: _isRequesting,
isReceivingData: isReceivingData,
isPartialRequest: _isPartialRequest,
received: received,
total: total,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
error: _errorText,
isSentByMe: widget.isSentByMe,
eta: eta,
pathLen: effectivePathLen,
transferCount: transferCount,
fetchingMissingLabel: AppLocalizations.of(
context,
)!.fetchingMissingImageFragments,
loadingLabel: AppLocalizations.of(context)!.loadingImage,
receivingLabel: AppLocalizations.of(
context,
)!.receivingImage,
tapToLoadLabel: AppLocalizations.of(context)!.tapToLoad,
),
style: TextStyle(
fontSize: 11,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
),
);
},
);
}
Widget _buildImageArea(
BuildContext context, {
required Uint8List? imageBytes,
required bool isComplete,
required bool isRequesting,
required bool isReceivingData,
required int received,
required int total,
required List<bool> fragmentPresence,
required ImageEnvelope envelope,
required int? radioBw,
required int? radioSf,
required int? radioCr,
required int pathLen,
}) {
if (isComplete && imageBytes != null) {
return AspectRatio(
aspectRatio: 1.0,
child: AvifImage.memory(imageBytes, fit: BoxFit.cover),
);
}
// Placeholder with fetch/progress UI.
return AspectRatio(
aspectRatio: 1.0,
child: Container(
color: Colors.grey.shade800,
child: Stack(
alignment: Alignment.center,
children: [
if (isRequesting) ...[
Container(
margin: const EdgeInsets.symmetric(horizontal: 20),
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.black.withValues(alpha: 0.25),
borderRadius: BorderRadius.circular(12),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
_PacketBlockProgress(
presence: fragmentPresence,
activeColor: Theme.of(context).colorScheme.primary,
highlightMissing: _isPartialRequest,
),
const SizedBox(height: 8),
Text(
'$received/$total',
style: const TextStyle(color: Colors.white, fontSize: 11),
),
],
),
),
Positioned(
top: 8,
right: 8,
child: IconButton(
onPressed: () => _cancelReceive(envelope.sessionId),
icon: const Icon(Icons.close, size: 20),
color: Colors.white70,
tooltip: AppLocalizations.of(context)!.cancelImageReceive,
),
),
] else if (_errorText != null) ...[
Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.broken_image, color: Colors.red, size: 36),
const SizedBox(height: 8),
TextButton.icon(
onPressed: () => _requestAndFetch(
envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: pathLen,
),
icon: const Icon(Icons.refresh, size: 18),
label: Text(AppLocalizations.of(context)!.retry),
style: TextButton.styleFrom(
foregroundColor: Colors.white70,
),
),
],
),
] else ...[
// Tap-to-load icon.
IconButton(
onPressed: isReceivingData
? null
: () => _requestAndFetch(
envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: pathLen,
),
icon: Icon(
isReceivingData
? Icons.downloading_rounded
: Icons.download_rounded,
size: 40,
),
color: Colors.white70,
tooltip: isReceivingData
? AppLocalizations.of(context)!.imageAlreadyBeingReceived
: AppLocalizations.of(context)!.loadImage,
),
],
],
),
),
);
}
Future<void> _requestAndFetch(
ImageEnvelope envelope, {
int? radioBw,
int? radioSf,
int? radioCr,
int pathLen = 0,
}) async {
if (_isRequesting) return;
final conn = context.read<ConnectionProvider>();
final imageProvider = context.read<ip.ImageProvider>();
imageProvider.resumeIncomingSession(envelope.sessionId);
final contactsProvider = context.read<ContactsProvider>();
final appProvider = context.read<AppProvider>();
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: conn.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderContactUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderRouteUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(
context,
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unreachable) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderRouteNoPathResponse,
);
return;
}
var sender = resolution.target!;
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
await conn.getContacts();
if (!mounted) return;
resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: conn.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderContactUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderRouteUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(
context,
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
);
return;
}
sender = resolution.target!;
routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.senderRouteNoRawResponse,
);
return;
}
}
if (sender.routeHopCount >= 2) {
_showToast(
AppLocalizations.of(
context,
)!.imageFetchOverHops('${sender.routeHopCount}'),
);
}
setState(() => _errorText = null);
final deviceKey = conn.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchImage,
AppLocalizations.of(context)!.deviceKeyUnavailable,
);
return;
}
final requesterKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join();
// If we already have some fragments, request only what's missing.
final missing = imageProvider.missingFragmentIndices(envelope.sessionId);
final isPartialResume =
missing.isNotEmpty && missing.length < envelope.total;
setState(() {
_isRequesting = true;
_isPartialRequest = isPartialResume;
_errorText = null;
});
final request = isPartialResume
? ImageFetchRequest(
sessionId: envelope.sessionId,
want: 'missing',
missingIndices: missing,
requesterKey6: requesterKey6,
)
: ImageFetchRequest(
sessionId: envelope.sessionId,
requesterKey6: requesterKey6,
);
final payload = request.encodeBinary();
try {
debugPrint(
'📷 [ImageMessageBubble] Outgoing image fetch request: session=${envelope.sessionId} want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
);
await conn.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.routeEncodedPathLen,
payload: payload,
);
} catch (_) {
if (mounted) {
_showToast(AppLocalizations.of(context)!.imageFetchFailedToSendRequest);
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = AppLocalizations.of(context)!.imageUnavailable;
});
}
return;
}
if (!mounted) return;
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.routeHasPath
? sender.routeHopCount
: pathLen;
final txEstimate = estimateImageTransmitDuration(
fragmentCount: missing.isEmpty ? envelope.total : missing.length,
sizeBytes: missing.isEmpty
? envelope.sizeBytes
: (envelope.sizeBytes * missing.length / envelope.total).round(),
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
_requestTimeoutTimer?.cancel();
_requestTimeoutTimer = TransferTimeout.start(
txEstimate: txEstimate,
onTimeout: () {
if (mounted &&
_isRequesting &&
!imageProvider.isComplete(envelope.sessionId)) {
_showToast(AppLocalizations.of(context)!.imageFetchTimedOut);
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = AppLocalizations.of(context)!.imageFetchTimedOut;
});
}
},
);
}
void _showToast(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
);
}
void _cancelReceive(String sessionId) {
if (!mounted) return;
_requestTimeoutTimer?.cancel();
context.read<ip.ImageProvider>().cancelIncomingSession(sessionId);
_showToast(AppLocalizations.of(context)!.imageReceiveCanceled);
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = AppLocalizations.of(context)!.imageReceiveCanceled;
});
}
void _clearRequestState() {
if (!mounted) return;
setState(() {
_isRequesting = false;
_isPartialRequest = false;
});
}
Future<void> _showBlockingAlert(String title, String message) async {
if (!mounted) return;
_showToast('$title: $message');
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('OK'),
),
],
),
);
}
static String _statusText({
required bool isComplete,
required bool isRequesting,
required bool isReceivingData,
required bool isPartialRequest,
required int received,
required int total,
required ImageEnvelope envelope,
required int pathLen,
required int? radioBw,
required int? radioSf,
required int? radioCr,
required String? error,
required bool isSentByMe,
required Duration? eta,
required int transferCount,
required String fetchingMissingLabel,
required String loadingLabel,
required String receivingLabel,
required String tapToLoadLabel,
}) {
final txEstimate = estimateImageTransmitDuration(
fragmentCount: envelope.total,
sizeBytes: envelope.sizeBytes,
pathLen: pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
if (error != null) return error;
if (isRequesting) {
final etaLabel = _formatEta(eta);
final actionLabel = isPartialRequest
? '📥 $fetchingMissingLabel'
: '📥 $loadingLabel';
return '$actionLabel $received/$total · $etaLabel · $txEstimateLabel';
}
if (isReceivingData) {
final etaLabel = _formatEta(eta);
return '📥 $receivingLabel $received/$total · $etaLabel · $txEstimateLabel';
}
if (isComplete) {
final base =
'🖼️ ${envelope.width}×${envelope.height} ${envelope.format.label}';
return isSentByMe
? '$base · ${envelope.total} seg · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '$base · $txEstimateLabel';
}
return isSentByMe
? '🖼️ ${envelope.width}×${envelope.height} · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '🖼️ $tapToLoadLabel · ${envelope.width}×${envelope.height} · $txEstimateLabel';
}
static String _formatTransmitEstimate(Duration value) {
if (value.inSeconds < 60) return '~${value.inSeconds}s tx';
final minutes = value.inMinutes;
final seconds = value.inSeconds % 60;
return '~${minutes}m ${seconds}s tx';
}
static String _formatEta(Duration? eta) {
if (eta == null || eta <= Duration.zero) return 'ETA --';
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
final minutes = eta.inMinutes;
final seconds = eta.inSeconds % 60;
return 'ETA ~${minutes}m ${seconds}s';
}
static String _formatTransferCount(int transferCount) {
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
}
bool _hasRecentInboundActivity({
required DateTime? lastReceivedAt,
required int received,
required int total,
}) {
if (lastReceivedAt == null || received <= 0 || received >= total) {
return false;
}
return DateTime.now().difference(lastReceivedAt) <=
_recentInboundActivityWindow;
}
void _showFullScreen(BuildContext context, Uint8List imageBytes) {
showGeneralDialog<void>(
context: context,
barrierColor: Colors.black,
barrierDismissible: true,
barrierLabel: AppLocalizations.of(context)!.closeImagePreview,
pageBuilder: (dialogContext, animation, secondaryAnimation) => Material(
color: Colors.black,
child: Stack(
children: [
Positioned.fill(
child: InteractiveViewer(
minScale: 1.0,
maxScale: 1000.0,
clipBehavior: Clip.none,
boundaryMargin: const EdgeInsets.all(100000),
child: SizedBox.expand(
child: AvifImage.memory(imageBytes, fit: BoxFit.cover),
),
),
),
Positioned(
top: 16,
right: 16,
child: SafeArea(
child: IconButton(
onPressed: () => Navigator.of(dialogContext).pop(),
icon: const Icon(Icons.close),
color: Colors.white,
tooltip: AppLocalizations.of(context)!.close,
),
),
),
],
),
),
transitionBuilder: (context, animation, secondaryAnimation, child) {
return FadeTransition(
opacity: CurvedAnimation(parent: animation, curve: Curves.easeOut),
child: child,
);
},
transitionDuration: const Duration(milliseconds: 150),
);
}
}
class _PacketBlockProgress extends StatelessWidget {
final List<bool> presence;
final Color activeColor;
final bool highlightMissing;
const _PacketBlockProgress({
required this.presence,
required this.activeColor,
this.highlightMissing = false,
});
@override
Widget build(BuildContext context) {
if (presence.isEmpty) {
return const SizedBox(width: 96, height: 12);
}
final bucketCount = presence.length <= 24 ? presence.length : 24;
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
final start = (bucketIndex * presence.length) ~/ bucketCount;
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
final safeEnd = end <= start ? start + 1 : end;
final slice = presence.sublist(start, safeEnd);
final received = slice.where((value) => value).length;
return slice.isEmpty ? 0.0 : received / slice.length;
});
final missingColor = highlightMissing
? Colors.amberAccent
: Colors.white.withValues(alpha: 0.14);
return SizedBox(
width: 120,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final fill in bucketFill)
Expanded(
child: Container(
height: 12,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: fill > 0
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
: missingColor.withValues(
alpha: highlightMissing ? 0.45 : 0.14,
),
borderRadius: BorderRadius.circular(2),
border: Border.all(
color: fill > 0
? Colors.white.withValues(alpha: 0.18)
: missingColor.withValues(
alpha: highlightMissing ? 0.7 : 0.18,
),
width: 0.5,
),
),
),
),
],
),
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,231 @@
import 'package:flutter/material.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../models/message_route_metadata.dart';
import '../../utils/avatar_label_helper.dart';
import '../../utils/message_extensions.dart';
import '../common/contact_avatar.dart';
Widget buildMessageHeaderAvatar(
BuildContext context, {
required bool isOwnMessage,
required bool isChannelMessage,
required dynamic senderContact,
required String displayName,
}) {
if (isOwnMessage) {
return CircleAvatar(
radius: 10.5,
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
child: Icon(
Icons.account_circle,
size: 16,
color: Theme.of(context).colorScheme.primary,
),
);
}
if (senderContact is Contact) {
return ContactAvatar(
contact: senderContact,
radius: 10.5,
displayName: displayName,
);
}
final background = isChannelMessage
? Colors.teal.withValues(alpha: 0.16)
: Theme.of(context).colorScheme.surfaceContainerHighest;
final foreground = isChannelMessage
? Colors.teal.shade800
: Theme.of(context).colorScheme.onSurfaceVariant;
return CircleAvatar(
radius: 10.5,
backgroundColor: background,
child: Text(
AvatarLabelHelper.buildLabel(displayName),
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: foreground,
letterSpacing: -0.2,
),
),
);
}
Widget buildBubbleMetaFooter(
BuildContext context, {
required Message message,
required bool isSarMarker,
MessageRouteMetadata? routeMetadata,
}) {
final metaColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.68);
final items = <Widget>[];
final sentEchoLabel = message.isSentMessage && message.echoCount > 0
? '${message.echoCount} echo${message.echoCount == 1 ? '' : 'es'}'
: null;
if (!isSarMarker && sentEchoLabel != null) {
items.addAll([
Icon(Icons.hub_outlined, size: 11, color: metaColor),
const SizedBox(width: 3),
Text(
sentEchoLabel,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
Text(
' • ',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
]);
} else if (!isSarMarker) {
final routeLabel = _effectiveRouteFooterLabel(message, routeMetadata);
if (routeLabel != null) {
items.addAll([
Icon(Icons.alt_route, size: 11, color: metaColor),
const SizedBox(width: 3),
Text(
routeLabel,
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
Text(
' • ',
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: metaColor),
),
]);
}
}
items.add(
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: metaColor,
fontWeight: FontWeight.w500,
),
),
);
return Padding(
padding: const EdgeInsets.only(left: 6, right: 6, top: 1, bottom: 18),
child: Align(
alignment: Alignment.centerRight,
child: Row(mainAxisSize: MainAxisSize.min, children: items),
),
);
}
String? _effectiveRouteFooterLabel(
Message message,
MessageRouteMetadata? routeMetadata,
) {
if (routeMetadata?.mode.name == 'flood') {
return 'flood';
}
final effectivePathLen = _effectivePathLen(message, routeMetadata);
if (effectivePathLen >= 255) {
return null;
}
return effectivePathLen == 0 ? 'direct' : '${effectivePathLen}hop';
}
int _effectivePathLen(Message message, MessageRouteMetadata? routeMetadata) {
if (routeMetadata?.hopCount != null) {
return routeMetadata!.hopCount!;
}
if (routeMetadata?.mode.name == 'flood') {
return 255;
}
return message.pathLen;
}
Widget buildChannelHeaderPill(
BuildContext context, {
required String label,
IconData icon = Icons.campaign_outlined,
EdgeInsetsGeometry padding = const EdgeInsets.symmetric(
horizontal: 8,
vertical: 3,
),
double iconSize = 11,
double iconSpacing = 5,
TextStyle? textStyle,
}) {
final labelColor = Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82);
return Container(
padding: padding,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.65),
borderRadius: BorderRadius.circular(999),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: iconSize,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.7),
),
SizedBox(width: iconSpacing),
Flexible(
child: Text(
label,
style:
textStyle ??
Theme.of(context).textTheme.labelSmall?.copyWith(
color: labelColor,
fontWeight: FontWeight.w600,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
);
}
Widget buildDirectHeaderCounterpart(
BuildContext context, {
required String label,
}) {
return buildChannelHeaderPill(
context,
label: label,
icon: Icons.alternate_email,
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
iconSize: 10,
iconSpacing: 4,
textStyle: Theme.of(context).textTheme.labelSmall?.copyWith(
fontSize: 10,
height: 1.0,
color: Theme.of(
context,
).textTheme.labelSmall?.color?.withValues(alpha: 0.82),
fontWeight: FontWeight.w600,
),
);
}

View File

@@ -0,0 +1,386 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/message.dart';
import '../../models/message_route_metadata.dart';
import '../../models/path_selection.dart';
import '../../models/message_reception_details.dart';
import '../../providers/messages_provider.dart';
import '../../utils/link_quality.dart';
IconData getDeliveryStatusIcon(MessageDeliveryStatus status) {
switch (status) {
case MessageDeliveryStatus.sending:
return Icons.schedule;
case MessageDeliveryStatus.sent:
return Icons.done;
case MessageDeliveryStatus.delivered:
return Icons.done_all;
case MessageDeliveryStatus.failed:
return Icons.error_outline;
case MessageDeliveryStatus.received:
return Icons.inbox;
}
}
Color getDeliveryStatusColor(MessageDeliveryStatus status) {
switch (status) {
case MessageDeliveryStatus.sending:
return Colors.orange;
case MessageDeliveryStatus.sent:
return Colors.blue;
case MessageDeliveryStatus.delivered:
return Colors.green;
case MessageDeliveryStatus.failed:
return Colors.red;
case MessageDeliveryStatus.received:
return Colors.grey;
}
}
Widget buildChannelEchoStatus(BuildContext context, Message message) {
final hasEcho = message.echoCount > 0;
if (!hasEcho) {
return const SizedBox.shrink();
}
final statusColor = getDeliveryStatusColor(message.deliveryStatus);
final rssi = message.lastEchoRssiDbm;
final snr = message.lastEchoSnrRaw != null
? message.lastEchoSnrRaw!.toSigned(8) / 4.0
: null;
final quality = linkQualityLabel(rssi, snr);
final qualityColor = linkQualityColor(quality);
return Wrap(
spacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.hub_outlined,
label: 'x${message.echoCount}',
color: statusColor,
),
if (message.expectedAckTag != null)
_techChip(
context,
icon: Icons.tag,
label:
'ACK ${message.expectedAckTag!.toRadixString(16).toUpperCase()}',
color: Colors.indigo,
),
_techChip(context, icon: Icons.bolt, label: quality, color: qualityColor),
if (message.lastEchoRssiDbm != null)
_signalCapsule(
context,
icon: Icons.network_cell,
label: message.lastEchoRssiDbm!.toString(),
filled: rssiScore(message.lastEchoRssiDbm!),
color: Colors.blueGrey,
),
if (message.lastEchoSnrRaw != null)
_signalCapsule(
context,
icon: Icons.graphic_eq,
label: (message.lastEchoSnrRaw!.toSigned(8) / 4.0).toStringAsFixed(1),
filled: snrScore(message.lastEchoSnrRaw!.toSigned(8) / 4.0),
color: Colors.teal,
),
],
);
}
bool shouldShowSentChannelStats(Message message) {
if (!message.isSentMessage || !message.isChannelMessage) {
return false;
}
final hasSignalData =
message.echoCount > 0 ||
message.lastEchoRssiDbm != null ||
message.lastEchoSnrRaw != null ||
message.expectedAckTag != null;
return hasSignalData;
}
Widget buildReceivedSignalStatus(
BuildContext context,
Message message, {
MessageReceptionDetails? receptionDetails,
required int? rssiDbm,
required double? snrDb,
}) {
final hopLabel = hopDisplayLabel(context, message);
return Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.alt_route,
label: hopLabel,
color: Colors.indigo,
),
if (receptionDetails?.senderToReceiptMs != null)
_techChip(
context,
icon: Icons.schedule,
label: _formatMs(receptionDetails!.senderToReceiptMs!),
color: Colors.deepPurple,
),
if (receptionDetails?.estimatedTransmitMs != null)
_techChip(
context,
icon: Icons.timelapse,
label: '~${_formatMs(receptionDetails!.estimatedTransmitMs!)} tx',
color: Colors.blue,
),
if (receptionDetails?.postTransmitDelayMs != null)
_techChip(
context,
icon: Icons.hourglass_bottom,
label: '+${_formatMs(receptionDetails!.postTransmitDelayMs!)} lag',
color: Colors.orange,
),
if (receptionDetails?.pathBytesHex != null)
_techChip(
context,
icon: Icons.route,
label: receptionDetails!.pathBytesHex!,
color: Colors.brown,
),
if (rssiDbm != null || snrDb != null) ...[
_techChip(
context,
icon: Icons.bolt,
label: linkQualityLabel(rssiDbm, snrDb),
color: linkQualityColor(linkQualityLabel(rssiDbm, snrDb)),
),
if (rssiDbm != null)
_signalCapsule(
context,
icon: Icons.network_cell,
label: '$rssiDbm',
filled: rssiScore(rssiDbm),
color: Colors.blueGrey,
),
if (snrDb != null)
_signalCapsule(
context,
icon: Icons.graphic_eq,
label: snrDb.toStringAsFixed(1),
filled: snrScore(snrDb),
color: Colors.teal,
),
],
],
);
}
Widget buildSentDirectSignalStatus(
BuildContext context,
Message message, {
required int roundTripTimeMs,
required Duration txEstimate,
}) {
final routeMetadata = context
.read<MessagesProvider>()
.getMessageRouteMetadata(message.id);
final estimatedTransmitMs = sanitizeEstimatedTransmitMs(
estimatedTransmitMs: txEstimate > Duration.zero
? txEstimate.inMilliseconds
: null,
senderToReceiptMs: roundTripTimeMs,
);
final postTransmitDelayMs = estimatedTransmitMs != null
? (roundTripTimeMs - estimatedTransmitMs).clamp(0, 86400000).toInt()
: null;
return Wrap(
spacing: 4,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
_techChip(
context,
icon: Icons.alt_route,
label: hopDisplayLabelForMessage(context, message, routeMetadata),
color: Colors.indigo,
),
_techChip(
context,
icon: Icons.schedule,
label: _formatMs(roundTripTimeMs),
color: Colors.deepPurple,
),
if (estimatedTransmitMs != null)
_techChip(
context,
icon: Icons.timelapse,
label: '~${_formatMs(estimatedTransmitMs)} tx',
color: Colors.blue,
),
if (postTransmitDelayMs != null)
_techChip(
context,
icon: Icons.hourglass_bottom,
label: '+${_formatMs(postTransmitDelayMs)} lag',
color: Colors.orange,
),
if (message.retryAttempt > 0)
_techChip(
context,
icon: Icons.refresh,
label: 'retry ${message.retryAttempt}/4',
color: Colors.redAccent,
),
if (message.suggestedTimeoutMs != null)
_techChip(
context,
icon: Icons.timer_outlined,
label: 'timeout ${_formatMs(message.suggestedTimeoutMs!)}',
color: Colors.blueGrey,
),
if (message.usedFloodFallback)
_techChip(
context,
icon: Icons.waves,
label: 'flood route',
color: Colors.teal,
)
else if (message.expectedAckTag != null)
_techChip(
context,
icon: Icons.route,
label: 'direct ACK',
color: Colors.indigo,
),
if (routeMetadata != null)
_techChip(
context,
icon: routeMetadata.mode == PathSelectionMode.nearestRouter
? Icons.router
: Icons.alt_route,
label: routeMetadata.modeLabel,
color: routeMetadata.mode == PathSelectionMode.nearestRouter
? Colors.deepPurple
: Colors.indigo,
),
],
);
}
String _formatMs(int value) {
if (value >= 60000) {
final minutes = value ~/ 60000;
final seconds = (value % 60000) ~/ 1000;
return '${minutes}m ${seconds}s';
}
if (value >= 1000) {
return '${(value / 1000).toStringAsFixed(value >= 10000 ? 0 : 1)}s';
}
return '${value}ms';
}
String hopDisplayLabel(BuildContext context, Message message) {
final l10n = AppLocalizations.of(context)!;
if (message.pathLen == 0) return l10n.direct;
if (message.pathLen >= 255 && message.isContactMessage) return l10n.direct;
if (message.pathLen >= 255) return l10n.unknown;
return l10n.hopCount(message.pathLen);
}
String hopDisplayLabelForMessage(
BuildContext context,
Message message,
MessageRouteMetadata? routeMetadata,
) {
final l10n = AppLocalizations.of(context)!;
final effectivePathLen = routeMetadata?.hopCount ?? message.pathLen;
if (effectivePathLen == 0) return l10n.direct;
if (effectivePathLen >= 255 && message.isContactMessage) return l10n.direct;
if (effectivePathLen >= 255) return l10n.unknown;
return l10n.hopCount(effectivePathLen);
}
Widget _techChip(
BuildContext context, {
required IconData icon,
required String label,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
);
}
Widget _signalCapsule(
BuildContext context, {
required IconData icon,
required String label,
required int filled,
required Color color,
}) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 1.5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 10, color: color),
const SizedBox(width: 2),
Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(5, (i) {
final active = i < filled;
return Container(
width: 3,
height: (4 + i).toDouble(),
margin: const EdgeInsets.symmetric(horizontal: 0.5),
decoration: BoxDecoration(
color: active ? color : color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(1),
),
);
}),
),
const SizedBox(width: 2),
Text(
label,
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: color,
fontWeight: FontWeight.w600,
fontSize: 10,
),
),
],
),
);
}

View File

@@ -0,0 +1,668 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../providers/app_provider.dart';
import '../common/contact_avatar.dart';
class MessagesComposer extends StatelessWidget {
final TextEditingController textController;
final FocusNode focusNode;
final TextInputFormatter messageByteLimiter;
final int messageByteCount;
final int maxMessageBytes;
final bool isRecording;
final bool isSendingVoice;
final bool voiceSupported;
final double bottomPadding;
final String destinationLabel;
final Widget destinationAvatar;
final bool destinationLocked;
final List<Contact> mentionSuggestions;
final String mentionQuery;
final ValueChanged<Contact> onMentionSelected;
final VoidCallback onShowComposerActions;
final VoidCallback onShowRecipientSelector;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onCancelVoiceRecording;
final Future<void> Function() onSendMessage;
final VoidCallback? onLongPressSend;
final String? regionScopeName;
final VoidCallback? onRegionScopeTap;
const MessagesComposer({
super.key,
required this.textController,
required this.focusNode,
required this.messageByteLimiter,
required this.messageByteCount,
required this.maxMessageBytes,
required this.isRecording,
required this.isSendingVoice,
required this.voiceSupported,
required this.bottomPadding,
required this.destinationLabel,
required this.destinationAvatar,
required this.destinationLocked,
required this.mentionSuggestions,
required this.mentionQuery,
required this.onMentionSelected,
required this.onShowComposerActions,
required this.onShowRecipientSelector,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
required this.onCancelVoiceRecording,
required this.onSendMessage,
this.onLongPressSend,
this.regionScopeName,
this.onRegionScopeTap,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: const BoxDecoration(color: Colors.transparent),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (mentionSuggestions.isNotEmpty)
Padding(
padding: const EdgeInsets.fromLTRB(10, 0, 10, 6),
child: _MentionSuggestionsCard(
suggestions: mentionSuggestions,
query: mentionQuery,
onSelected: onMentionSelected,
),
),
SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(10, 4, 10, bottomPadding),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(28),
border: Border.all(
color: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.05),
blurRadius: 18,
offset: const Offset(0, 6),
),
],
),
child: Padding(
padding: const EdgeInsets.fromLTRB(8, 8, 8, 6),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Row(
children: [
_ComposerActionButton(
isRecording: isRecording,
onPressed: isRecording
? onStopAndSendVoice
: onShowComposerActions,
),
if (isRecording) ...[
const SizedBox(width: 6),
_CancelRecordingButton(
onPressed: onCancelVoiceRecording,
),
],
if (regionScopeName != null &&
onRegionScopeTap != null) ...[
const SizedBox(width: 6),
_RegionScopeChip(
name: regionScopeName!,
onTap: onRegionScopeTap!,
),
],
const SizedBox(width: 8),
Expanded(
child: _DestinationPill(
destinationLabel: destinationLabel,
destinationAvatar: destinationAvatar,
isLocked: destinationLocked,
onTap: destinationLocked
? null
: onShowRecipientSelector,
),
),
],
),
const SizedBox(height: 6),
ListenableBuilder(
listenable: Listenable.merge([
textController,
focusNode,
]),
builder: (context, _) {
final canSendText =
!isRecording &&
!isSendingVoice &&
textController.text.trim().isNotEmpty;
final l10n = AppLocalizations.of(context)!;
final semanticsLabel = isRecording
? l10n.recordingReleaseToSend
: (isSendingVoice
? l10n.sendingVoice
: voiceSupported
? l10n.sendLongPressToRecordVoice
: l10n.send);
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: _MessageInput(
textController: textController,
focusNode: focusNode,
messageByteLimiter: messageByteLimiter,
),
),
const SizedBox(width: 8),
_SendButton(
canSendText: canSendText,
isRecording: isRecording,
isSendingVoice: isSendingVoice,
voiceSupported: voiceSupported,
semanticsLabel: semanticsLabel,
messageByteCount: messageByteCount,
maxMessageBytes: maxMessageBytes,
onSendMessage: onSendMessage,
onLongPressSend: onLongPressSend,
onStartVoiceRecording: onStartVoiceRecording,
onStopAndSendVoice: onStopAndSendVoice,
onCancelVoiceRecording: onCancelVoiceRecording,
),
],
);
},
),
],
),
),
),
),
),
],
),
);
}
}
class _MentionSuggestionsCard extends StatelessWidget {
final List<Contact> suggestions;
final String query;
final ValueChanged<Contact> onSelected;
const _MentionSuggestionsCard({
required this.suggestions,
required this.query,
required this.onSelected,
});
@override
Widget build(BuildContext context) {
final visibleCount = suggestions.length.clamp(1, 4);
final maxHeight = visibleCount * 54.0;
return Material(
elevation: 8,
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(18),
child: Container(
constraints: BoxConstraints(maxHeight: maxHeight),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.30),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Flexible(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
itemCount: suggestions.length,
separatorBuilder: (_, _) => const SizedBox(height: 2),
itemBuilder: (context, index) {
final contact = suggestions[index];
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () => onSelected(contact),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 8,
),
child: Row(
children: [
ContactAvatar(contact: contact, radius: 16),
const SizedBox(width: 10),
Expanded(
child: Text(
contact.displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
);
},
),
),
],
),
),
);
}
}
class _ComposerActionButton extends StatelessWidget {
final bool isRecording;
final VoidCallback onPressed;
const _ComposerActionButton({
required this.isRecording,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: IconButton(
icon: Icon(isRecording ? Icons.stop : Icons.add, size: 20),
tooltip: isRecording
? AppLocalizations.of(context)!.stopRecording
: AppLocalizations.of(context)!.moreActions,
onPressed: onPressed,
color: isRecording ? Colors.red : Theme.of(context).colorScheme.primary,
),
);
}
}
class _CancelRecordingButton extends StatelessWidget {
final Future<void> Function() onPressed;
const _CancelRecordingButton({required this.onPressed});
@override
Widget build(BuildContext context) {
return Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: IconButton(
icon: const Icon(Icons.delete_outline, size: 20),
tooltip: AppLocalizations.of(context)!.discardRecording,
onPressed: onPressed,
color: Theme.of(context).colorScheme.error,
),
);
}
}
class _DestinationPill extends StatelessWidget {
final String destinationLabel;
final Widget destinationAvatar;
final bool isLocked;
final VoidCallback? onTap;
const _DestinationPill({
required this.destinationLabel,
required this.destinationAvatar,
required this.isLocked,
this.onTap,
});
@override
Widget build(BuildContext context) {
final content = Ink(
key: ValueKey(
isLocked
? 'messages_composer_destination_locked'
: 'messages_composer_destination_selector',
),
height: 40,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(context).dividerColor.withValues(alpha: 0.35),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Row(
children: [
destinationAvatar,
const SizedBox(width: 8),
Expanded(
child: Text(
destinationLabel,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface,
),
),
),
if (!isLocked)
Icon(
Icons.expand_more_rounded,
size: 18,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
],
),
),
);
if (onTap == null) {
return Material(color: Colors.transparent, child: content);
}
return Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: onTap,
child: content,
),
);
}
}
class _MessageInput extends StatelessWidget {
final TextEditingController textController;
final FocusNode focusNode;
final TextInputFormatter messageByteLimiter;
const _MessageInput({
required this.textController,
required this.focusNode,
required this.messageByteLimiter,
});
@override
Widget build(BuildContext context) {
final messageFontScale = context.watch<AppProvider>().messageFontScale;
const baseFontSize = 15.0;
final resolvedFontSize = baseFontSize * messageFontScale;
return AnimatedContainer(
duration: const Duration(milliseconds: 180),
constraints: const BoxConstraints(minHeight: 42, maxHeight: 116),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: focusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).dividerColor.withValues(alpha: 0.35),
width: focusNode.hasFocus ? 1.4 : 1,
),
boxShadow: focusNode.hasFocus
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.10),
blurRadius: 12,
offset: const Offset(0, 4),
),
]
: null,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
child: TextField(
controller: textController,
focusNode: focusNode,
minLines: 1,
maxLines: 4,
keyboardType: TextInputType.multiline,
inputFormatters: [messageByteLimiter],
style: TextStyle(fontSize: resolvedFontSize),
textAlignVertical: TextAlignVertical.center,
decoration: InputDecoration(
hintText: AppLocalizations.of(context)!.typeYourMessage,
hintStyle: TextStyle(
fontSize: resolvedFontSize,
color: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
),
filled: false,
fillColor: Colors.transparent,
border: InputBorder.none,
isCollapsed: true,
),
textInputAction: TextInputAction.newline,
),
),
);
}
}
class _SendButton extends StatelessWidget {
final bool canSendText;
final bool isRecording;
final bool isSendingVoice;
final bool voiceSupported;
final String semanticsLabel;
final int messageByteCount;
final int maxMessageBytes;
final Future<void> Function() onSendMessage;
final VoidCallback? onLongPressSend;
final Future<void> Function() onStartVoiceRecording;
final Future<void> Function() onStopAndSendVoice;
final Future<void> Function() onCancelVoiceRecording;
const _SendButton({
required this.canSendText,
required this.isRecording,
required this.isSendingVoice,
required this.voiceSupported,
required this.semanticsLabel,
required this.messageByteCount,
required this.maxMessageBytes,
required this.onSendMessage,
this.onLongPressSend,
required this.onStartVoiceRecording,
required this.onStopAndSendVoice,
required this.onCancelVoiceRecording,
});
@override
Widget build(BuildContext context) {
return Semantics(
button: true,
enabled: canSendText || (voiceSupported && !isSendingVoice),
label: semanticsLabel,
onTap: canSendText ? onSendMessage : null,
onLongPress: canSendText && onLongPressSend != null
? onLongPressSend
: (voiceSupported && !isSendingVoice)
? () {
if (isRecording) {
onStopAndSendVoice();
return;
}
onStartVoiceRecording();
}
: null,
child: Tooltip(
message: semanticsLabel,
excludeFromSemantics: true,
child: GestureDetector(
excludeFromSemantics: true,
onTap: canSendText ? onSendMessage : null,
onLongPressStart: canSendText && onLongPressSend != null
? (_) => onLongPressSend!()
: (voiceSupported && !isSendingVoice)
? (_) => onStartVoiceRecording()
: null,
onLongPressEnd: (!canSendText && voiceSupported && isRecording)
? (_) => onStopAndSendVoice()
: null,
// Finger slid off the button: discard the recording instead of
// sending it.
onLongPressCancel: (!canSendText && voiceSupported && isRecording)
? onCancelVoiceRecording
: null,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 180),
width: 42,
height: 42,
decoration: BoxDecoration(
color: canSendText || isRecording
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.surface,
shape: BoxShape.circle,
border: Border.all(
color: canSendText || isRecording
? Colors.transparent
: Theme.of(
context,
).dividerColor.withValues(alpha: 0.35),
),
boxShadow: canSendText || isRecording
? [
BoxShadow(
color: Theme.of(
context,
).colorScheme.primary.withValues(alpha: 0.22),
blurRadius: 14,
offset: const Offset(0, 6),
),
]
: null,
),
child: isSendingVoice
? Center(
child: CircularProgressIndicator(
strokeWidth: 2,
color: Theme.of(context).colorScheme.onPrimary,
),
)
: Icon(
isRecording ? Icons.mic_rounded : Icons.send_rounded,
size: 20,
color: canSendText || isRecording
? Theme.of(context).colorScheme.onPrimary
: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
'$messageByteCount/$maxMessageBytes',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
color: messageByteCount > maxMessageBytes * 0.9
? Colors.orange.shade800
: Theme.of(
context,
).colorScheme.onSurfaceVariant.withValues(alpha: 0.9),
),
),
],
),
),
),
);
}
}
class _RegionScopeChip extends StatelessWidget {
final String name;
final VoidCallback onTap;
const _RegionScopeChip({required this.name, required this.onTap});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return GestureDetector(
onTap: onTap,
child: Container(
height: 40,
padding: const EdgeInsets.symmetric(horizontal: 10),
decoration: BoxDecoration(
color: colorScheme.tertiaryContainer,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: colorScheme.tertiary.withValues(alpha: 0.3),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.language_rounded,
size: 16,
color: colorScheme.onTertiaryContainer,
),
const SizedBox(width: 4),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 80),
child: Text(
name,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: colorScheme.onTertiaryContainer,
),
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,108 @@
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import '../../models/message.dart';
import '../../providers/messages_provider.dart';
import '../common/bidirectional_refresh.dart';
import '../../widgets/messages/message_bubble.dart';
class MessagesContent extends StatelessWidget {
static const double defaultPadding = 8;
final List<DisplayMessageEntry> messages;
final ScrollController scrollController;
final String? highlightedMessageId;
final double bottomContentPadding;
final Future<void> Function() onRefresh;
final VoidCallback? onNavigateToMap;
final ValueChanged<Message>? onMessageTap;
final ValueChanged<Message>? onReplyToMessage;
const MessagesContent({
super.key,
required this.messages,
required this.scrollController,
required this.highlightedMessageId,
this.bottomContentPadding = 0,
required this.onRefresh,
this.onNavigateToMap,
this.onMessageTap,
this.onReplyToMessage,
});
@override
Widget build(BuildContext context) {
const scrollPhysics = BouncingScrollPhysics(
parent: AlwaysScrollableScrollPhysics(),
);
return BidirectionalRefresh(
onRefresh: onRefresh,
child: messages.isEmpty
? LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
keyboardDismissBehavior:
ScrollViewKeyboardDismissBehavior.onDrag,
physics: scrollPhysics,
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.message_outlined,
size: 64,
color: Theme.of(context).disabledColor,
),
SizedBox(height: 16),
Text(
AppLocalizations.of(context)!.noMessagesYet,
style: Theme.of(context).textTheme.titleLarge,
),
SizedBox(height: 8),
Text(
AppLocalizations.of(context)!.pullDownToSync,
style: Theme.of(context).textTheme.bodyMedium,
textAlign: TextAlign.center,
),
],
),
),
),
),
)
: ListView.builder(
controller: scrollController,
keyboardDismissBehavior: ScrollViewKeyboardDismissBehavior.onDrag,
physics: scrollPhysics,
reverse: true,
padding: EdgeInsets.fromLTRB(
defaultPadding,
defaultPadding,
defaultPadding,
defaultPadding + bottomContentPadding,
),
itemCount: messages.length,
itemBuilder: (context, index) {
final entry = messages[index];
final message = entry.message;
return MessageBubble(
key: ValueKey(message.id),
message: message,
receivedCopies: entry.occurrenceCount,
isHighlighted: message.id == highlightedMessageId,
onNavigateToMap: onNavigateToMap,
onTap: onMessageTap == null
? null
: () => onMessageTap!(message),
onReply: onReplyToMessage == null
? null
: () => onReplyToMessage!(message),
);
},
),
);
}
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import '../../models/message.dart';
import '../../utils/message_extensions.dart';
class SystemMessageBubble extends StatelessWidget {
final Message message;
const SystemMessageBubble({super.key, required this.message});
Color _getLevelColor(String? level) {
switch (level?.toLowerCase()) {
case 'success':
return Colors.green;
case 'warning':
return Colors.orange;
case 'error':
return Colors.red;
case 'info':
default:
return Colors.blue.shade300;
}
}
IconData _getLevelIcon(String? level) {
switch (level?.toLowerCase()) {
case 'success':
return Icons.check_circle_outline;
case 'warning':
return Icons.warning_amber_outlined;
case 'error':
return Icons.error_outline;
case 'info':
default:
return Icons.info_outline;
}
}
@override
Widget build(BuildContext context) {
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
final level = message.senderName ?? 'info';
final levelColor = _getLevelColor(level);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: levelColor.withValues(alpha: isDarkMode ? 0.18 : 0.08),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: levelColor.withValues(alpha: isDarkMode ? 0.3 : 0.16),
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(_getLevelIcon(level), size: 16, color: levelColor),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
level.toUpperCase(),
style: Theme.of(context).textTheme.labelSmall
?.copyWith(
color: levelColor,
fontWeight: FontWeight.bold,
letterSpacing: 0.4,
),
),
),
Text(
message.getLocalizedTimeAgo(context),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.6),
),
),
],
),
const SizedBox(height: 4),
Text(
message.text,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
height: 1.3,
color: Theme.of(
context,
).textTheme.bodySmall?.color?.withValues(alpha: 0.8),
),
),
],
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,307 @@
import 'dart:math' as math;
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../utils/tictactoe_message_parser.dart';
import '../../utils/toast_logger.dart';
import '../../l10n/app_localizations.dart';
class TicTacToeMessageBubble extends StatelessWidget {
final Message message;
final bool isSentByMe;
const TicTacToeMessageBubble({
super.key,
required this.message,
required this.isSentByMe,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final event = TicTacToeMessageParser.tryParse(message.text);
if (event == null) {
return const SizedBox.shrink();
}
final conn = context.watch<ConnectionProvider>();
final contacts = context.read<ContactsProvider>();
final messages = context.watch<MessagesProvider>().messages;
final selfKey = conn.deviceInfo.publicKey;
if (selfKey == null || selfKey.length < 6) {
return Text(AppLocalizations.of(context)!.tictactoeUnavailable);
}
final selfKey6 = _key6Hex(selfKey);
final opponent = _resolveOpponentContact(
message: message,
contactsProvider: contacts,
isSentByMe: isSentByMe,
);
if (opponent == null) {
return Text(AppLocalizations.of(context)!.tictactoeOpponentUnknown);
}
final opponentKey6 = _key6Hex(opponent.publicKey);
final gameEvents = <TicTacToeEvent>[];
TicTacToeEvent? start;
for (final m in messages) {
if (!m.isContactMessage) continue;
final parsed = TicTacToeMessageParser.tryParse(m.text);
if (parsed == null || parsed.gameId != event.gameId) continue;
if (!_isSameDmThread(
message: m,
selfKey: selfKey,
opponentKey6: opponentKey6,
)) {
continue;
}
if (parsed.type == TicTacToeEventType.start) {
start ??= parsed;
} else {
gameEvents.add(parsed);
}
}
start ??= event.type == TicTacToeEventType.start ? event : null;
if (start == null) {
return Text(AppLocalizations.of(context)!.tictactoeWaitingForStart);
}
final xPlayer = start.playerKey6;
final oPlayer = xPlayer == selfKey6 ? opponentKey6 : selfKey6;
final state = buildTicTacToeState(
gameId: event.gameId,
xPlayerKey6: xPlayer,
oPlayerKey6: oPlayer,
events: gameEvents,
);
final mySymbol = selfKey6 == state.xPlayerKey6 ? 'X' : 'O';
final isMyTurn = !state.isFinished && state.nextSymbol == mySymbol;
final titleColor = isSentByMe
? colorScheme.onPrimaryContainer
: colorScheme.onSurface;
final statusColor = isSentByMe
? colorScheme.onPrimaryContainer.withValues(alpha: 0.85)
: colorScheme.onSurface.withValues(alpha: 0.85);
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 230),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tic-Tac-Toe · Game ${state.gameId}',
style: Theme.of(context).textTheme.labelMedium?.copyWith(
fontWeight: FontWeight.bold,
color: titleColor,
),
),
const SizedBox(height: 8),
_BoardGrid(
board: state.board,
enabled: isMyTurn,
isSentByMe: isSentByMe,
onTapCell: (idx) => _onCellTap(
context: context,
idx: idx,
state: state,
selfKey6: selfKey6,
opponent: opponent,
connectionProvider: conn,
),
),
const SizedBox(height: 8),
Text(
_statusText(
l10n: AppLocalizations.of(context)!,
state: state,
mySymbol: mySymbol,
),
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(color: statusColor),
),
],
),
);
}
Future<void> _onCellTap({
required BuildContext context,
required int idx,
required TicTacToeGameState state,
required String selfKey6,
required Contact opponent,
required ConnectionProvider connectionProvider,
}) async {
if (idx < 0 || idx > 8 || state.board[idx] != null) return;
if (!connectionProvider.deviceInfo.isConnected) {
ToastLogger.error(
context,
AppLocalizations.of(context)!.notConnectedToDevice,
);
return;
}
final messagesProvider = context.read<MessagesProvider>();
final text = TicTacToeMessageParser.encodeMove(
gameId: state.gameId,
cell: idx,
playerKey6: selfKey6,
timestampSec: DateTime.now().millisecondsSinceEpoch ~/ 1000,
);
final messageId = '${DateTime.now().millisecondsSinceEpoch}_ttt_move';
final senderPublicKeyPrefix = connectionProvider.deviceInfo.publicKey!
.sublist(0, 6);
final sentMessage = Message(
id: messageId,
messageType: MessageType.contact,
senderPublicKeyPrefix: senderPublicKeyPrefix,
pathLen: 0,
textType: MessageTextType.plain,
senderTimestamp: DateTime.now().millisecondsSinceEpoch ~/ 1000,
text: text,
receivedAt: DateTime.now(),
deliveryStatus: MessageDeliveryStatus.sending,
recipientPublicKey: opponent.publicKey,
);
messagesProvider.addSentMessage(sentMessage, contact: opponent);
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: opponent.publicKey,
text: text,
messageId: messageId,
contact: opponent,
);
if (!sent) {
messagesProvider.markMessageFailed(messageId);
if (!context.mounted) return;
ToastLogger.error(
context,
AppLocalizations.of(context)!.failedToSendTicTacToeMove,
);
}
}
static String _statusText({
required AppLocalizations l10n,
required TicTacToeGameState state,
required String mySymbol,
}) {
if (state.winnerSymbol != null) {
return state.winnerSymbol == mySymbol ? l10n.youWon : l10n.opponentWon;
}
if (state.isDraw) return l10n.gameDraw;
return state.nextSymbol == mySymbol ? l10n.yourTurn : l10n.opponentTurn;
}
static String _key6Hex(Uint8List key) => key
.sublist(0, math.min(6, key.length))
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('')
.toLowerCase();
static Contact? _resolveOpponentContact({
required Message message,
required ContactsProvider contactsProvider,
required bool isSentByMe,
}) {
if (isSentByMe && message.recipientPublicKey != null) {
return contactsProvider.findContactByKey(message.recipientPublicKey!);
}
final sender = message.senderPublicKeyPrefix;
if (sender == null || sender.length < 6) return null;
return contactsProvider.findContactByPrefix(
Uint8List.fromList(sender.sublist(0, 6)),
);
}
static bool _isSameDmThread({
required Message message,
required Uint8List selfKey,
required String opponentKey6,
}) {
final isOwn = message.isSentMessage || message.isFromSelf(selfKey);
if (isOwn) {
final recipient = message.recipientPublicKey;
if (recipient == null || recipient.length < 6) return false;
return _key6Hex(recipient) == opponentKey6;
}
final sender = message.senderPublicKeyPrefix;
if (sender == null || sender.length < 6) return false;
return _key6Hex(sender) == opponentKey6;
}
}
class _BoardGrid extends StatelessWidget {
final List<String?> board;
final bool enabled;
final bool isSentByMe;
final ValueChanged<int> onTapCell;
const _BoardGrid({
required this.board,
required this.enabled,
required this.isSentByMe,
required this.onTapCell,
});
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final cellBackground = isSentByMe
? colorScheme.primaryContainer.withValues(alpha: 0.35)
: colorScheme.surface;
final cellBorder = isSentByMe
? colorScheme.primary.withValues(alpha: 0.45)
: colorScheme.outline.withValues(alpha: 0.35);
return SizedBox(
width: 180,
height: 180,
child: GridView.builder(
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 4,
mainAxisSpacing: 4,
),
itemCount: 9,
itemBuilder: (context, idx) {
final value = board[idx];
return InkWell(
onTap: enabled && value == null ? () => onTapCell(idx) : null,
borderRadius: BorderRadius.circular(8),
child: Container(
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(8),
color: cellBackground,
border: Border.all(color: cellBorder),
),
child: Text(
value ?? '',
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w900,
color: value == 'X'
? colorScheme.primary
: value == 'O'
? colorScheme.tertiary
: null,
),
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,24 @@
import 'dart:async';
/// Calculates a transfer timeout as 2× the estimated LoRa airtime,
/// with a minimum of 30 seconds.
///
/// Used by [ImageMessageBubble] and [VoiceMessageBubble] to reset the
/// "loading" spinner when a transfer stalls, allowing the user to retry.
class TransferTimeout {
static const Duration _minimum = Duration(seconds: 30);
/// Start a one-shot timer based on [txEstimate] × 2 (min 30s).
///
/// [onTimeout] is called on the UI thread when the timer fires.
/// Returns the [Timer] so the caller can cancel it (e.g. on dispose or
/// when the transfer completes).
static Timer start({
required Duration txEstimate,
required void Function() onTimeout,
}) {
final timeout = txEstimate * 2;
final effective = timeout < _minimum ? _minimum : timeout;
return Timer(effective, onTimeout);
}
}

View File

@@ -0,0 +1,773 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/app_localizations.dart';
import '../../models/contact.dart';
import '../../models/message.dart';
import '../../providers/app_provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/messages_provider.dart';
import '../../providers/voice_provider.dart';
import '../../utils/transmission_target_resolver.dart';
import '../../utils/voice_message_parser.dart';
import 'transfer_timeout.dart';
/// A message bubble that shows a voice recording with play/stop controls.
class VoiceMessageBubble extends StatefulWidget {
final Message message;
final bool isSentByMe;
const VoiceMessageBubble({
super.key,
required this.message,
required this.isSentByMe,
});
@override
State<VoiceMessageBubble> createState() => _VoiceMessageBubbleState();
}
class _VoiceMessageBubbleState extends State<VoiceMessageBubble> {
static const int _maxFetchHops = 3;
static const Duration _recentInboundActivityWindow = Duration(seconds: 3);
bool _isRequesting = false;
bool _isPartialRequest = false;
bool _autoPlayWhenReady = false;
String? _errorText;
Timer? _requestTimeoutTimer;
@override
void dispose() {
_requestTimeoutTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final radioBw = context.select<ConnectionProvider, int?>(
(p) => p.deviceInfo.radioBw,
);
final radioSf = context.select<ConnectionProvider, int?>(
(p) => p.deviceInfo.radioSf,
);
final radioCr = context.select<ConnectionProvider, int?>(
(p) => p.deviceInfo.radioCr,
);
final voiceId = widget.message.voiceId;
if (voiceId == null) return const SizedBox.shrink();
return Consumer<VoiceProvider>(
builder: (context, voiceProvider, _) {
final transferCount = context.select<MessagesProvider, int>(
(provider) =>
provider.transferCountForSession(voiceSessionId: voiceId),
);
final contactsProvider = context.read<ContactsProvider>();
final session = voiceProvider.session(voiceId);
final envelope = VoiceEnvelope.tryParseText(widget.message.text);
final sender = TransmissionTargetResolver.resolveLocalTarget(
contactsProvider: contactsProvider,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderName: widget.message.senderName,
);
final effectivePathLen = sender != null && sender.routeHasPath
? sender.routeHopCount
: widget.message.pathLen;
final isPlaying = voiceProvider.isPlaying(voiceId);
final isComplete = voiceProvider.isComplete(voiceId);
if (_isRequesting && isComplete) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_errorText = null;
});
});
}
if (_autoPlayWhenReady && isComplete && !isPlaying) {
_autoPlayWhenReady = false;
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
await voiceProvider.play(voiceId);
});
}
final received = session?.receivedCount ?? 0;
final total = session?.total ?? envelope?.total ?? 0;
final playbackProgress = voiceProvider.playbackProgress(voiceId);
final packetPresence =
session?.packets.map((packet) => packet != null).toList() ??
List<bool>.filled(total, false);
final isReceivingData =
!_isRequesting &&
!isComplete &&
_hasRecentInboundActivity(
lastReceivedAt: session?.lastPacketAt,
received: received,
total: total,
);
final durationSec =
session?.estimatedDurationSeconds ??
((envelope?.durationMs ?? 0) / 1000.0);
final durationLabel = _formatDuration(durationSec);
final modeLabel = session?.mode.label ?? envelope?.mode.label ?? '?';
final waveformBars = _resolveWaveformBars(
session: session,
messageText: widget.message.text,
);
final txEstimate = _resolveVoiceTransmitEstimate(
session: session,
envelope: envelope,
messageText: widget.message.text,
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
final txEstimateLabel = _formatTransmitEstimate(txEstimate);
final eta = voiceProvider.estimateRemainingTransferTime(voiceId);
Future<void> handlePrimaryTap() async {
if (isPlaying) {
await voiceProvider.stop();
return;
}
if (_isRequesting) {
_cancelReceive(voiceId);
return;
}
if (isComplete) {
await voiceProvider.play(voiceId);
return;
}
if (isReceivingData) {
return;
}
await _requestAndPlayVoice(
voiceId,
envelope: envelope,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
pathLen: effectivePathLen,
);
}
return Row(
mainAxisSize: MainAxisSize.min,
children: [
InkWell(
onTap: handlePrimaryTap,
borderRadius: BorderRadius.circular(24),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: widget.isSentByMe
? Theme.of(context).colorScheme.primaryContainer
: Theme.of(context).colorScheme.secondaryContainer,
shape: BoxShape.circle,
),
child: Icon(
isPlaying
? Icons.stop
: (_isRequesting
? Icons.close
: (isReceivingData
? Icons.downloading_rounded
: Icons.play_arrow)),
size: 28,
color: widget.isSentByMe
? Theme.of(context).colorScheme.onPrimaryContainer
: Theme.of(context).colorScheme.onSecondaryContainer
.withValues(alpha: isReceivingData ? 0.6 : 1.0),
),
),
),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (isPlaying)
SizedBox(
width: 100,
child: LinearProgressIndicator(
value: playbackProgress,
backgroundColor: Colors.grey.withValues(alpha: 0.3),
),
)
else if ((_isRequesting || isReceivingData) && total > 0)
_PacketBlockProgress(
presence: packetPresence,
activeColor: widget.isSentByMe
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.secondary,
highlightMissing: _isPartialRequest,
)
else
_WaveformBar(isComplete: isComplete, bars: waveformBars),
const SizedBox(height: 4),
Text(
_buildStatusText(
durationLabel: durationLabel,
modeLabel: modeLabel,
txEstimateLabel: txEstimateLabel,
received: received,
total: total,
isComplete: isComplete,
isRequesting: _isRequesting,
isReceivingData: isReceivingData,
isPartialRequest: _isPartialRequest,
errorText: _errorText,
requestingLabel: AppLocalizations.of(
context,
)!.requestingVoice,
fetchingMissingLabel: AppLocalizations.of(
context,
)!.fetchingMissingVoiceFragments,
receivingVoiceLabel: AppLocalizations.of(
context,
)!.receivingVoice,
eta: eta,
isSentByMe: widget.isSentByMe,
transferCount: transferCount,
),
style: TextStyle(
fontSize: 11,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
],
),
],
);
},
);
}
Future<void> _requestAndPlayVoice(
String sessionId, {
VoiceEnvelope? envelope,
int? radioBw,
int? radioSf,
int? radioCr,
int pathLen = 0,
}) async {
if (_isRequesting) return;
setState(() {
_isRequesting = true;
_isPartialRequest = false;
_autoPlayWhenReady = true;
_errorText = null;
});
final connectionProvider = context.read<ConnectionProvider>();
final voiceProvider = context.read<VoiceProvider>();
voiceProvider.resumeIncomingSession(sessionId);
final contactsProvider = context.read<ContactsProvider>();
final appProvider = context.read<AppProvider>();
var resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: connectionProvider.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderContactUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderRouteUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(
context,
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unreachable) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderRouteNoPathResponse,
);
return;
}
var sender = resolution.target!;
var routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
await connectionProvider.getContacts();
if (!mounted) return;
resolution = await TransmissionTargetResolver.resolveFetchTarget(
contactsProvider: contactsProvider,
refreshContacts: connectionProvider.getContacts,
isSentByMe: widget.isSentByMe,
recipientPublicKey: widget.message.recipientPublicKey,
senderPublicKeyPrefix: widget.message.senderPublicKeyPrefix,
senderName: widget.message.senderName,
maxFetchHops: _maxFetchHops,
);
if (!mounted) return;
if (resolution.failure == TransmissionTargetFailure.unknownContact) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderContactUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.unknownRoute) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderRouteUnknown,
);
return;
}
if (resolution.failure == TransmissionTargetFailure.tooFar) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(
context,
)!.messageTooFar('${resolution.hops}', '${resolution.maxHops}'),
);
return;
}
sender = resolution.target!;
routeVerified = await appProvider.verifyRawTransportRoute(sender);
if (!mounted) return;
if (!routeVerified) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.senderRouteNoRawResponse,
);
return;
}
}
if (sender.routeHopCount >= 2) {
_showToast(
AppLocalizations.of(
context,
)!.voiceFetchOverHops('${sender.routeHopCount}'),
);
}
final deviceKey = connectionProvider.deviceInfo.publicKey;
if (deviceKey == null || deviceKey.length < 6) {
_clearRequestState();
await _showBlockingAlert(
AppLocalizations.of(context)!.cannotFetchVoice,
AppLocalizations.of(context)!.deviceKeyUnavailable,
);
return;
}
final requesterKey6 = deviceKey
.sublist(0, 6)
.map((b) => b.toRadixString(16).padLeft(2, '0'))
.join('');
final missing = voiceProvider.missingPacketIndices(sessionId);
final totalPackets = sessionPacketCount(
voiceProvider: voiceProvider,
sessionId: sessionId,
envelope: envelope,
);
final isPartialResume =
missing.isNotEmpty && totalPackets > 0 && missing.length < totalPackets;
if (_isPartialRequest != isPartialResume && mounted) {
setState(() {
_isPartialRequest = isPartialResume;
});
}
final request = isPartialResume
? VoiceFetchRequest(
sessionId: sessionId,
want: 'missing',
missingIndices: missing,
requesterKey6: requesterKey6,
)
: VoiceFetchRequest(sessionId: sessionId, requesterKey6: requesterKey6);
try {
debugPrint(
'🎙️ [VoiceMessageBubble] Outgoing voice fetch request: session=$sessionId want=${isPartialResume ? 'missing' : 'all'} target=${sender.advName} hops=${sender.routeHopCount}',
);
await connectionProvider.sendRawVoicePacket(
contactPath: sender.outPath,
contactPathLen: sender.routeEncodedPathLen,
payload: request.encodeBinary(),
);
} catch (_) {
_setUnavailable();
return;
}
// Timeout = 2× estimated LoRa airtime (min 30s).
final effectivePathLen = sender.routeHasPath
? sender.routeHopCount
: pathLen;
final estimatedDurationMs =
envelope != null &&
totalPackets > 0 &&
missing.isNotEmpty &&
missing.length < totalPackets
? ((envelope.durationMs * missing.length) / totalPackets).round()
: envelope?.durationMs;
final txEstimate = envelope != null
? estimateVoiceTransmitDuration(
packetCount: isPartialResume ? missing.length : envelope.total,
mode: envelope.mode,
durationMs: estimatedDurationMs ?? envelope.durationMs,
pathLen: effectivePathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
)
: const Duration(seconds: 15);
_requestTimeoutTimer?.cancel();
_requestTimeoutTimer = TransferTimeout.start(
txEstimate: txEstimate,
onTimeout: () {
if (mounted && _isRequesting) {
_setUnavailable();
}
},
);
}
int sessionPacketCount({
required VoiceProvider voiceProvider,
required String sessionId,
required VoiceEnvelope? envelope,
}) {
return voiceProvider.session(sessionId)?.total ?? envelope?.total ?? 0;
}
void _setUnavailable() {
if (!mounted) return;
_showToast(AppLocalizations.of(context)!.voiceUnavailable);
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_autoPlayWhenReady = false;
_errorText = AppLocalizations.of(context)!.voiceUnavailable;
});
}
void _clearRequestState() {
if (!mounted) return;
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_autoPlayWhenReady = false;
});
}
void _cancelReceive(String sessionId) {
if (!mounted) return;
_requestTimeoutTimer?.cancel();
context.read<VoiceProvider>().cancelIncomingSession(sessionId);
_showToast(AppLocalizations.of(context)!.voiceReceiveCanceled);
setState(() {
_isRequesting = false;
_isPartialRequest = false;
_autoPlayWhenReady = false;
_errorText = AppLocalizations.of(context)!.voiceReceiveCanceled;
});
}
void _showToast(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), duration: const Duration(seconds: 3)),
);
}
Future<void> _showBlockingAlert(String title, String message) async {
if (!mounted) return;
_showToast('$title: $message');
await showDialog<void>(
context: context,
builder: (dialogContext) => AlertDialog(
title: Text(title),
content: Text(message),
actions: [
TextButton(
onPressed: () => Navigator.of(dialogContext).pop(),
child: const Text('OK'),
),
],
),
);
}
static String _formatDuration(double seconds) {
final s = seconds.round();
if (s < 60) return '${s}s';
return '${s ~/ 60}m ${s % 60}s';
}
static String _buildStatusText({
required String durationLabel,
required String modeLabel,
required String txEstimateLabel,
required int received,
required int total,
required bool isComplete,
required bool isRequesting,
required bool isReceivingData,
required bool isPartialRequest,
required String? errorText,
required String requestingLabel,
required String fetchingMissingLabel,
required String receivingVoiceLabel,
required Duration? eta,
required bool isSentByMe,
required int transferCount,
}) {
if (errorText != null) return errorText;
final progress = total > 0 ? ' ($received/$total)' : '';
if (isRequesting) {
final actionLabel = isPartialRequest
? fetchingMissingLabel
: requestingLabel;
return '$actionLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
}
if (isReceivingData) {
return '$receivingVoiceLabel$progress · ${_formatEta(eta)} · $txEstimateLabel';
}
if (!isComplete && total > 0) {
return isSentByMe
? '🎙️ $durationLabel · $modeLabel$progress · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '🎙️ $durationLabel · $modeLabel$progress · $txEstimateLabel';
}
return isSentByMe
? '🎙️ $durationLabel · $modeLabel · ${_formatTransferCount(transferCount)} · $txEstimateLabel'
: '🎙️ $durationLabel · $modeLabel · $txEstimateLabel';
}
List<double> _resolveWaveformBars({
required VoiceSession? session,
required String messageText,
}) {
if (session != null) {
final fromSession = VoiceWaveform.buildBarsFromPackets(session.packets);
if (fromSession.any((v) => v > 0.0)) return fromSession;
}
final legacyPacket = VoicePacket.tryParseText(messageText);
if (legacyPacket != null) {
final fromLegacy = VoiceWaveform.buildBarsFromPackets([legacyPacket]);
if (fromLegacy.any((v) => v > 0.0)) return fromLegacy;
}
return const [];
}
Duration _resolveVoiceTransmitEstimate({
required VoiceSession? session,
required VoiceEnvelope? envelope,
required String messageText,
required int pathLen,
required int? radioBw,
required int? radioSf,
required int? radioCr,
}) {
if (session != null) {
final fromSession = estimateVoiceTransmitDurationFromPackets(
packets: session.packets,
pathLen: pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
if (fromSession > Duration.zero) return fromSession;
}
if (envelope != null) {
return estimateVoiceTransmitDuration(
mode: envelope.mode,
packetCount: envelope.total,
durationMs: envelope.durationMs,
pathLen: pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
}
final legacyPacket = VoicePacket.tryParseText(messageText);
if (legacyPacket != null) {
return estimateVoiceTransmitDuration(
mode: legacyPacket.mode,
packetCount: legacyPacket.total,
durationMs: legacyPacket.durationMs * legacyPacket.total,
pathLen: pathLen,
radioBw: radioBw,
radioSf: radioSf,
radioCr: radioCr,
);
}
return Duration.zero;
}
static String _formatTransmitEstimate(Duration value) {
if (value <= Duration.zero) return '~0s tx';
if (value.inSeconds < 60) return '~${value.inSeconds}s tx';
final minutes = value.inMinutes;
final seconds = value.inSeconds % 60;
return '~${minutes}m ${seconds}s tx';
}
static String _formatEta(Duration? eta) {
if (eta == null || eta <= Duration.zero) return 'ETA --';
if (eta.inSeconds < 60) return 'ETA ~${eta.inSeconds}s';
final minutes = eta.inMinutes;
final seconds = eta.inSeconds % 60;
return 'ETA ~${minutes}m ${seconds}s';
}
static String _formatTransferCount(int transferCount) {
return '$transferCount transfer${transferCount == 1 ? '' : 's'}';
}
bool _hasRecentInboundActivity({
required DateTime? lastReceivedAt,
required int received,
required int total,
}) {
if (lastReceivedAt == null || received <= 0 || received >= total) {
return false;
}
return DateTime.now().difference(lastReceivedAt) <=
_recentInboundActivityWindow;
}
}
class _PacketBlockProgress extends StatelessWidget {
final List<bool> presence;
final Color activeColor;
final bool highlightMissing;
const _PacketBlockProgress({
required this.presence,
required this.activeColor,
this.highlightMissing = false,
});
@override
Widget build(BuildContext context) {
if (presence.isEmpty) {
return const SizedBox(width: 100, height: 16);
}
final bucketCount = presence.length <= 20 ? presence.length : 20;
final bucketFill = List<double>.generate(bucketCount, (bucketIndex) {
final start = (bucketIndex * presence.length) ~/ bucketCount;
final end = ((bucketIndex + 1) * presence.length) ~/ bucketCount;
final safeEnd = end <= start ? start + 1 : end;
final slice = presence.sublist(start, safeEnd);
final received = slice.where((value) => value).length;
return slice.isEmpty ? 0.0 : received / slice.length;
});
final missingColor = highlightMissing
? Colors.amberAccent
: Colors.white.withValues(alpha: 0.14);
return SizedBox(
width: 100,
height: 16,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
for (final fill in bucketFill)
Expanded(
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: fill > 0
? activeColor.withValues(alpha: 0.18 + (0.72 * fill))
: missingColor.withValues(
alpha: highlightMissing ? 0.45 : 0.14,
),
borderRadius: BorderRadius.circular(2),
border: Border.all(
color: fill > 0
? Colors.white.withValues(alpha: 0.18)
: missingColor.withValues(
alpha: highlightMissing ? 0.7 : 0.18,
),
width: 0.5,
),
),
),
),
],
),
);
}
}
/// Voice waveform rendered as a row of bars.
class _WaveformBar extends StatelessWidget {
final bool isComplete;
final List<double> bars;
const _WaveformBar({required this.isComplete, required this.bars});
@override
Widget build(BuildContext context) {
final heights = bars.isEmpty
? const [8.0, 12.0, 10.0, 14.0, 9.0, 12.0, 8.0, 11.0, 10.0, 13.0]
: bars.map((v) => 6.0 + (v.clamp(0.0, 1.0) * 14.0)).toList();
final color = isComplete
? Theme.of(context).colorScheme.primary.withValues(alpha: 0.7)
: Colors.grey.withValues(alpha: 0.5);
return Row(
children: heights
.map(
(h) => Container(
width: 3,
height: h,
margin: const EdgeInsets.symmetric(horizontal: 1),
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
)
.toList(),
);
}
}

View File

@@ -0,0 +1,218 @@
import 'package:flutter/material.dart';
import 'package:geolocator/geolocator.dart';
import '../l10n/app_localizations.dart';
/// Dialog that requests location permissions on app startup
class PermissionRequestDialog extends StatefulWidget {
final VoidCallback onPermissionsGranted;
final VoidCallback? onPermissionsDenied;
const PermissionRequestDialog({
super.key,
required this.onPermissionsGranted,
this.onPermissionsDenied,
});
@override
State<PermissionRequestDialog> createState() => _PermissionRequestDialogState();
}
class _PermissionRequestDialogState extends State<PermissionRequestDialog> {
bool _isRequesting = false;
String? _errorMessage;
@override
void initState() {
super.initState();
// Automatically check and request permissions when dialog opens
_checkAndRequestPermissions();
}
Future<void> _checkAndRequestPermissions() async {
if (_isRequesting) return;
setState(() {
_isRequesting = true;
_errorMessage = null;
});
try {
// Check if location service is enabled
final serviceEnabled = await Geolocator.isLocationServiceEnabled();
if (!mounted) return;
if (!serviceEnabled) {
setState(() {
_errorMessage = 'Location services are disabled. Please enable location services in your device settings.';
_isRequesting = false;
});
return;
}
// Check current permission
LocationPermission permission = await Geolocator.checkPermission();
if (!mounted) return;
if (permission == LocationPermission.denied) {
// Request permission
permission = await Geolocator.requestPermission();
if (!mounted) return;
}
if (permission == LocationPermission.denied) {
setState(() {
_errorMessage = 'Location permission denied. This app requires location access to track your position and share it with your team.';
_isRequesting = false;
});
widget.onPermissionsDenied?.call();
return;
}
if (permission == LocationPermission.deniedForever) {
setState(() {
_errorMessage = 'Location permission permanently denied. Please enable location access in your device settings.';
_isRequesting = false;
});
widget.onPermissionsDenied?.call();
return;
}
// Permission granted!
setState(() {
_isRequesting = false;
});
// Close dialog and notify parent
Navigator.of(context).pop();
widget.onPermissionsGranted();
} catch (e) {
if (!mounted) return;
setState(() {
_errorMessage = 'Error requesting permissions: $e';
_isRequesting = false;
});
}
}
@override
Widget build(BuildContext context) {
return PopScope(
// Allow dismissing dialog by back button or tapping outside
canPop: true,
onPopInvokedWithResult: (didPop, result) {
if (didPop) {
widget.onPermissionsDenied?.call();
}
},
child: AlertDialog(
title: Row(
children: [
Icon(
Icons.location_on,
color: Theme.of(context).colorScheme.primary,
),
SizedBox(width: 12),
Text(AppLocalizations.of(context)!.locationPermission),
],
),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'MeshCore SAR needs access to your location to:',
style: TextStyle(fontWeight: FontWeight.bold),
),
const SizedBox(height: 12),
_buildPermissionReason(
icon: Icons.track_changes,
text: 'Track your position during search and rescue operations',
),
const SizedBox(height: 8),
_buildPermissionReason(
icon: Icons.share_location,
text: 'Share your location with team members via mesh network',
),
const SizedBox(height: 8),
_buildPermissionReason(
icon: Icons.map,
text: 'Display your location and trail on the map',
),
if (_errorMessage != null) ...[
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: Colors.red.withValues(alpha: 0.1),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: Colors.red.withValues(alpha: 0.3)),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 20),
const SizedBox(width: 8),
Expanded(
child: Text(
_errorMessage!,
style: const TextStyle(color: Colors.red, fontSize: 12),
),
),
],
),
),
],
if (_isRequesting) ...[
const SizedBox(height: 16),
const Center(
child: CircularProgressIndicator(),
),
],
],
),
actions: [
// Always show a cancel/skip button
TextButton(
onPressed: () {
Navigator.of(context).pop();
widget.onPermissionsDenied?.call();
},
child: Text(AppLocalizations.of(context)!.skip),
),
if (_errorMessage != null && !_isRequesting)
ElevatedButton(
onPressed: () async {
// Open app settings
await Geolocator.openLocationSettings();
},
child: Text(AppLocalizations.of(context)!.openSettings),
),
if (_errorMessage != null && !_isRequesting &&
!_errorMessage!.contains('permanently denied'))
ElevatedButton(
onPressed: _checkAndRequestPermissions,
child: Text(AppLocalizations.of(context)!.retry),
),
],
),
);
}
Widget _buildPermissionReason({
required IconData icon,
required String text,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 20, color: Colors.grey),
const SizedBox(width: 8),
Expanded(
child: Text(
text,
style: const TextStyle(fontSize: 14),
),
),
],
);
}
}

View File

@@ -0,0 +1,321 @@
import 'package:flutter/material.dart';
import '../../models/sar_template.dart';
import '../../l10n/app_localizations.dart';
/// Dialog for adding or editing SAR templates
class SarTemplateEditDialog extends StatefulWidget {
final SarTemplate? template; // Null for new template
final Function(SarTemplate) onSave;
const SarTemplateEditDialog({
super.key,
this.template,
required this.onSave,
});
@override
State<SarTemplateEditDialog> createState() => _SarTemplateEditDialogState();
}
class _SarTemplateEditDialogState extends State<SarTemplateEditDialog> {
late TextEditingController _emojiController;
late TextEditingController _nameController;
late TextEditingController _descriptionController;
late String _selectedColor;
final List<Map<String, dynamic>> _colorOptions = [
{'name': 'Green', 'hex': '#4CAF50'},
{'name': 'Red', 'hex': '#F44336'},
{'name': 'Orange', 'hex': '#FF9800'},
{'name': 'Purple', 'hex': '#9C27B0'},
{'name': 'Blue', 'hex': '#2196F3'},
{'name': 'Yellow', 'hex': '#FFC107'},
{'name': 'Brown', 'hex': '#795548'},
{'name': 'Gray', 'hex': '#9E9E9E'},
];
String? _emojiError;
String? _nameError;
@override
void initState() {
super.initState();
_emojiController = TextEditingController(text: widget.template?.emoji ?? '');
_nameController = TextEditingController(text: widget.template?.name ?? '');
_descriptionController = TextEditingController(text: widget.template?.description ?? '');
_selectedColor = widget.template?.colorHex ?? '#4CAF50';
}
@override
void dispose() {
_emojiController.dispose();
_nameController.dispose();
_descriptionController.dispose();
super.dispose();
}
bool _validate() {
final l10n = AppLocalizations.of(context)!;
setState(() {
_emojiError = null;
_nameError = null;
});
bool isValid = true;
if (_emojiController.text.trim().isEmpty) {
setState(() {
_emojiError = l10n.emojiRequired;
});
isValid = false;
}
if (_nameController.text.trim().isEmpty) {
setState(() {
_nameError = l10n.nameRequired;
});
isValid = false;
}
return isValid;
}
void _save() {
if (!_validate()) return;
final template = SarTemplate(
id: widget.template?.id ?? 'custom_${DateTime.now().millisecondsSinceEpoch}',
emoji: _emojiController.text.trim(),
name: _nameController.text.trim(),
description: _descriptionController.text.trim(),
colorHex: _selectedColor,
isDefault: widget.template?.isDefault ?? false,
);
widget.onSave(template);
Navigator.of(context).pop();
}
String _getPreview() {
final emoji = _emojiController.text.trim();
final description = _descriptionController.text.trim();
if (emoji.isEmpty) return 'S::0,0';
if (description.isEmpty) return 'S:$emoji:0,0';
return 'S:$emoji:0,0:$description';
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
final l10n = AppLocalizations.of(context)!;
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
return Container(
decoration: BoxDecoration(
color: colorScheme.surface,
borderRadius: const BorderRadius.vertical(top: Radius.circular(20)),
),
child: DraggableScrollableSheet(
initialChildSize: 0.9,
minChildSize: 0.5,
maxChildSize: 0.95,
expand: false,
builder: (context, scrollController) {
return SingleChildScrollView(
controller: scrollController,
child: Padding(
padding: EdgeInsets.fromLTRB(24, 24, 24, 24 + bottomPadding),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Drag handle
Center(
child: Container(
width: 40,
height: 4,
margin: const EdgeInsets.only(bottom: 20),
decoration: BoxDecoration(
color: colorScheme.onSurfaceVariant.withValues(alpha: 0.4),
borderRadius: BorderRadius.circular(2),
),
),
),
// Header
Text(
widget.template == null ? l10n.addTemplate : l10n.editTemplate,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 24),
// Emoji field
TextField(
controller: _emojiController,
decoration: InputDecoration(
labelText: l10n.templateEmoji,
hintText: '🧑',
errorText: _emojiError,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: const Icon(Icons.emoji_emotions),
),
maxLength: 4,
style: const TextStyle(fontSize: 24),
textAlign: TextAlign.center,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
// Name field
TextField(
controller: _nameController,
decoration: InputDecoration(
labelText: l10n.templateName,
hintText: l10n.templateNameHint,
errorText: _nameError,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: const Icon(Icons.label),
),
maxLength: 30,
onChanged: (_) => setState(() {}),
),
const SizedBox(height: 16),
// Description field
TextField(
controller: _descriptionController,
decoration: InputDecoration(
labelText: l10n.templateDescription,
hintText: l10n.templateDescriptionHint,
filled: true,
fillColor: colorScheme.surfaceContainerHighest,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
prefixIcon: const Icon(Icons.description),
),
maxLength: 100,
maxLines: 2,
onChanged: (_) => setState(() {}),
),
SizedBox(height: 16),
// Color picker
Text(
l10n.templateColor,
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.bold,
color: colorScheme.onSurface,
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: _colorOptions.map((colorOption) {
final hex = colorOption['hex'] as String;
final color = Color(int.parse('FF${hex.replaceAll('#', '')}', radix: 16));
final isSelected = _selectedColor == hex;
return GestureDetector(
onTap: () => setState(() => _selectedColor = hex),
child: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
border: Border.all(
color: isSelected ? colorScheme.primary : Colors.transparent,
width: 3,
),
boxShadow: [
if (isSelected)
BoxShadow(
color: colorScheme.primary.withValues(alpha: 0.3),
blurRadius: 8,
spreadRadius: 2,
),
],
),
child: isSelected
? const Icon(Icons.check, color: Colors.white)
: null,
),
);
}).toList(),
),
const SizedBox(height: 24),
// Preview
Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: colorScheme.outline.withValues(alpha: 0.3),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.previewFormat,
style: theme.textTheme.labelSmall?.copyWith(
color: colorScheme.onSurfaceVariant,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
_getPreview(),
style: TextStyle(
fontFamily: 'monospace',
fontSize: 14,
color: colorScheme.onSurface,
),
),
],
),
),
const SizedBox(height: 24),
// Actions
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(l10n.cancel),
),
const SizedBox(width: 12),
ElevatedButton.icon(
onPressed: _save,
icon: Icon(Icons.save),
label: Text(l10n.save),
),
],
),
],
),
),
);
},
),
);
}
}

View File

@@ -0,0 +1,820 @@
import 'dart:async';
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../../l10n/app_localizations.dart';
import 'package:meshcore_client/meshcore_client.dart' show Message;
import 'package:provider/provider.dart';
import '../../models/contact.dart';
import '../../providers/connection_provider.dart';
import '../../services/bthome_met_history.dart';
Future<void> showBTHomeMetHistorySheet(
BuildContext context, {
required Contact contact,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
showDragHandle: true,
builder: (sheetContext) => _BTHomeMetHistorySheet(contact: contact),
);
}
class _BTHomeMetHistorySheet extends StatefulWidget {
const _BTHomeMetHistorySheet({required this.contact});
final Contact contact;
@override
State<_BTHomeMetHistorySheet> createState() => _BTHomeMetHistorySheetState();
}
class _BTHomeMetHistorySheetState extends State<_BTHomeMetHistorySheet> {
late final List<BTHomeMetMeasurement> _availableMeasurements;
late BTHomeMetMeasurement _selectedMeasurement;
BTHomeMetHistoryPage? _history;
bool _loading = true;
String? _error;
@override
void initState() {
super.initState();
_availableMeasurements = bTHomeMetMeasurementsForContact(widget.contact);
_selectedMeasurement = _availableMeasurements.isEmpty
? BTHomeMetMeasurement.temperature
: _availableMeasurements.first;
if (_availableMeasurements.isEmpty) {
_loading = false;
_error = 'No BTHome MET-compatible telemetry is available for this node.';
return;
}
unawaited(_loadHistory(measurement: _selectedMeasurement, page: 0));
}
Future<void> _loadHistory({
required BTHomeMetMeasurement measurement,
required int page,
}) async {
final connectionProvider = context.read<ConnectionProvider>();
final previousOnMessageReceived = connectionProvider.onMessageReceived;
String? responseText;
void onMessage(Message message) {
previousOnMessageReceived?.call(message);
if (_matchesContact(message)) {
responseText = message.text;
}
}
connectionProvider.onMessageReceived = onMessage;
if (mounted) {
setState(() {
_loading = true;
_error = null;
});
}
try {
final sent = await connectionProvider.sendTextMessage(
contactPublicKey: widget.contact.publicKey,
text: 'bthome met history ${measurement.id} $page',
);
if (!sent) {
throw Exception('Failed to send MET history request.');
}
for (var i = 0; i < 30; i++) {
await Future.delayed(const Duration(milliseconds: 500));
if (responseText != null) {
break;
}
}
final text = responseText?.trim();
if (text == null || text.isEmpty) {
throw TimeoutException('No response from sensor.');
}
if (_looksLikeError(text)) {
throw Exception(text);
}
final history = BTHomeMetHistoryParser.parse(text);
if (!mounted) {
return;
}
setState(() {
_selectedMeasurement = measurement;
_history = history;
_loading = false;
_error = null;
});
} catch (error) {
if (!mounted) {
return;
}
setState(() {
_selectedMeasurement = measurement;
_history = null;
_loading = false;
_error = _formatError(error);
});
} finally {
if (identical(connectionProvider.onMessageReceived, onMessage)) {
connectionProvider.onMessageReceived = previousOnMessageReceived;
}
}
}
bool _matchesContact(Message message) {
final prefix = message.senderPublicKeyPrefix;
if (prefix == null ||
prefix.length < 6 ||
widget.contact.publicKey.length < 6) {
return false;
}
for (var i = 0; i < 6; i++) {
if (prefix[i] != widget.contact.publicKey[i]) {
return false;
}
}
return true;
}
bool _looksLikeError(String text) {
final lower = text.toLowerCase();
return lower.startsWith('err') ||
lower.contains('unknown') ||
lower.contains('unsupported');
}
String _formatError(Object error) {
if (error is TimeoutException) {
return error.message ?? 'Timed out waiting for MET history.';
}
return error.toString().replaceFirst('Exception: ', '');
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final height = MediaQuery.of(context).size.height * 0.8;
final history = _history;
return SafeArea(
child: SizedBox(
height: height,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'MET history',
style: theme.textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w700,
),
),
Text(
widget.contact.displayName,
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
const SizedBox(height: 12),
Wrap(
spacing: 8,
runSpacing: 8,
children: _availableMeasurements
.map(
(measurement) => ChoiceChip(
label: Text(measurement.label),
selected: measurement == _selectedMeasurement,
onSelected: (selected) {
if (!selected || _loading) {
return;
}
unawaited(
_loadHistory(measurement: measurement, page: 0),
);
},
),
)
.toList(growable: false),
),
const SizedBox(height: 16),
Row(
children: [
OutlinedButton.icon(
onPressed: _loading || (history?.page ?? 0) == 0
? null
: () => unawaited(
_loadHistory(
measurement: _selectedMeasurement,
page: history!.page - 1,
),
),
icon: const Icon(Icons.chevron_left),
label: Text(AppLocalizations.of(context)!.newer),
),
const SizedBox(width: 8),
OutlinedButton.icon(
onPressed:
_loading ||
history == null ||
history.values.length < 12
? null
: () => unawaited(
_loadHistory(
measurement: _selectedMeasurement,
page: history.page + 1,
),
),
icon: const Icon(Icons.chevron_right),
label: Text(AppLocalizations.of(context)!.older),
),
const Spacer(),
if (_loading)
const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(strokeWidth: 2),
)
else
Text(
'Page ${history?.page ?? 0}',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
],
),
const SizedBox(height: 12),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (_error != null)
_HistoryMessageCard(
icon: Icons.error_outline,
title: AppLocalizations.of(context)!.couldNotLoadMetHistory,
body: _error!,
)
else if (_loading && history == null)
_HistoryMessageCard(
icon: Icons.hourglass_top,
title: AppLocalizations.of(context)!.loading,
body: 'Waiting for the sensor to reply.',
)
else if (history != null) ...[
_HistoryChartCard(history: history),
const SizedBox(height: 12),
_HistoryStatsGrid(history: history),
const SizedBox(height: 12),
_HistorySamplesCard(history: history),
],
],
),
),
),
],
),
),
),
);
}
}
class _HistoryChartCard extends StatelessWidget {
const _HistoryChartCard({required this.history});
final BTHomeMetHistoryPage history;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
history.measurement.label,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(
'Samples are shown oldest to newest. Firmware replies do not include timestamps.',
style: theme.textTheme.bodySmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
SizedBox(
height: 180,
child: LineChart(_historyLineChartData(context, history: history)),
),
],
),
);
}
}
class _HistoryStatsGrid extends StatelessWidget {
const _HistoryStatsGrid({required this.history});
final BTHomeMetHistoryPage history;
@override
Widget build(BuildContext context) {
final measurement = history.measurement;
return LayoutBuilder(
builder: (context, constraints) {
final compact = constraints.maxWidth < 520;
final tiles = <Widget>[
_HistoryStatTile(
label: AppLocalizations.of(context)!.latest,
value: _formatMeasurementValue(measurement, history.latest),
),
_HistoryStatTile(
label: AppLocalizations.of(context)!.min,
value: _formatMeasurementValue(measurement, history.minimum),
),
_HistoryStatTile(
label: AppLocalizations.of(context)!.max,
value: _formatMeasurementValue(measurement, history.maximum),
),
_HistoryStatTile(
label: AppLocalizations.of(context)!.samples,
value: history.values.length.toString(),
),
];
if (compact) {
return Column(
children: [
Row(
children: [
Expanded(child: tiles[0]),
const SizedBox(width: 8),
Expanded(child: tiles[1]),
],
),
const SizedBox(height: 8),
Row(
children: [
Expanded(child: tiles[2]),
const SizedBox(width: 8),
Expanded(child: tiles[3]),
],
),
],
);
}
return Row(
children: [
Expanded(child: tiles[0]),
const SizedBox(width: 8),
Expanded(child: tiles[1]),
const SizedBox(width: 8),
Expanded(child: tiles[2]),
const SizedBox(width: 8),
Expanded(child: tiles[3]),
],
);
},
);
}
}
class _HistoryStatTile extends StatelessWidget {
const _HistoryStatTile({required this.label, required this.value});
final String label;
final String value;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerHighest.withValues(alpha: 0.5),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
value,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
],
),
);
}
}
class _HistorySamplesCard extends StatelessWidget {
const _HistorySamplesCard({required this.history});
final BTHomeMetHistoryPage history;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.4),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Samples',
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 10),
Wrap(
spacing: 8,
runSpacing: 8,
children: history.values
.asMap()
.entries
.map((entry) {
final sampleIndex = entry.key + 1;
final isLatest = entry.key == history.values.length - 1;
return Container(
padding: const EdgeInsets.symmetric(
horizontal: 10,
vertical: 8,
),
decoration: BoxDecoration(
color: isLatest
? _historyColor(
history.measurement,
).withValues(alpha: 0.12)
: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.45),
borderRadius: BorderRadius.circular(14),
),
child: Text(
'$sampleIndex. ${_formatMeasurementValue(history.measurement, entry.value)}${isLatest ? ' latest' : ''}',
style: theme.textTheme.bodyMedium?.copyWith(
fontWeight: isLatest
? FontWeight.w700
: FontWeight.w500,
),
),
);
})
.toList(growable: false),
),
],
),
);
}
}
class _HistoryMessageCard extends StatelessWidget {
const _HistoryMessageCard({
required this.icon,
required this.title,
required this.body,
});
final IconData icon;
final String title;
final String body;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: theme.colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, color: theme.colorScheme.primary),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
title,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w700,
),
),
const SizedBox(height: 4),
Text(body),
],
),
),
],
),
);
}
}
Color _historyColor(BTHomeMetMeasurement measurement) {
switch (measurement) {
case BTHomeMetMeasurement.temperature:
return const Color(0xFFC76821);
case BTHomeMetMeasurement.humidity:
return const Color(0xFF246BB2);
case BTHomeMetMeasurement.windSpeed:
return const Color(0xFF2B78A0);
case BTHomeMetMeasurement.gust:
return const Color(0xFF1E88A8);
case BTHomeMetMeasurement.rain:
return const Color(0xFF2C6BA0);
}
}
String _formatMeasurementValue(BTHomeMetMeasurement measurement, num? value) {
if (value == null) {
return '--';
}
final digits = switch (measurement) {
BTHomeMetMeasurement.humidity => 0,
BTHomeMetMeasurement.temperature => 1,
BTHomeMetMeasurement.windSpeed => 1,
BTHomeMetMeasurement.gust => 1,
BTHomeMetMeasurement.rain => 1,
};
final text = value
.toStringAsFixed(digits)
.replaceFirst(RegExp(r'\.?0+$'), '');
return '$text${measurement.unit}';
}
LineChartData _historyLineChartData(
BuildContext context, {
required BTHomeMetHistoryPage history,
}) {
final theme = Theme.of(context);
final color = _historyColor(history.measurement);
final values = history.values;
final spots = values
.asMap()
.entries
.map((entry) => FlSpot(entry.key.toDouble(), entry.value))
.toList(growable: false);
final chartMinY = _historyChartMinY(history);
final chartMaxY = _historyChartMaxY(history);
final yInterval = _historyYAxisInterval(
measurement: history.measurement,
minY: chartMinY,
maxY: chartMaxY,
);
return LineChartData(
minX: 0,
maxX: values.length <= 1 ? 1.0 : (values.length - 1).toDouble(),
minY: chartMinY,
maxY: chartMaxY,
clipData: const FlClipData.all(),
lineTouchData: const LineTouchData(enabled: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: yInterval,
getDrawingHorizontalLine: (value) => FlLine(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.28),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 42,
interval: yInterval,
getTitlesWidget: (value, meta) => SideTitleWidget(
meta: meta,
space: 8,
child: Text(
_formatHistoryAxisValue(history.measurement, value),
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
interval: 1,
getTitlesWidget: (value, meta) {
final index = value.round();
if (value != index.toDouble() ||
!_shouldShowBottomSampleLabel(index, values.length)) {
return const SizedBox.shrink();
}
return SideTitleWidget(
meta: meta,
space: 6,
child: Text(
'${index + 1}',
style: Theme.of(context).textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
color: color,
barWidth: 2.8,
isCurved: false,
isStrokeCapRound: true,
dotData: FlDotData(
show: true,
checkToShowDot: (spot, barData) =>
barData.spots.length <= 10 || spot == barData.spots.last,
getDotPainter: (spot, percent, barData, index) => FlDotCirclePainter(
radius: spot == barData.spots.last ? 4 : 2.5,
color: color,
strokeColor: theme.colorScheme.surface,
strokeWidth: 1.6,
),
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withValues(alpha: 0.24),
color.withValues(alpha: 0.03),
],
),
),
),
],
);
}
double _historyChartMinY(BTHomeMetHistoryPage history) {
final minValue = history.minimum ?? 0;
final maxValue = history.maximum ?? 0;
final spread = maxValue - minValue;
final padding = spread == 0
? _historyMinimumPadding(history.measurement, minValue)
: math.max(
spread * 0.18,
_historyMinimumPadding(history.measurement, minValue),
);
return minValue - padding;
}
double _historyChartMaxY(BTHomeMetHistoryPage history) {
final minValue = history.minimum ?? 0;
final maxValue = history.maximum ?? 0;
final spread = maxValue - minValue;
final padding = spread == 0
? _historyMinimumPadding(history.measurement, maxValue)
: math.max(
spread * 0.18,
_historyMinimumPadding(history.measurement, maxValue),
);
return maxValue + padding;
}
double _historyMinimumPadding(
BTHomeMetMeasurement measurement,
double reference,
) {
final scaled = math.max(reference.abs() * 0.05, 0.1);
return switch (measurement) {
BTHomeMetMeasurement.temperature => math.max(0.4, scaled),
BTHomeMetMeasurement.humidity => math.max(2.0, scaled),
BTHomeMetMeasurement.windSpeed => math.max(0.4, scaled),
BTHomeMetMeasurement.gust => math.max(0.4, scaled),
BTHomeMetMeasurement.rain => math.max(0.4, scaled),
};
}
double _historyYAxisInterval({
required BTHomeMetMeasurement measurement,
required double minY,
required double maxY,
}) {
final span = maxY - minY;
if (span <= 0) {
return 1;
}
final rough = span / 3;
return switch (measurement) {
BTHomeMetMeasurement.humidity => math.max(1, rough.round()).toDouble(),
BTHomeMetMeasurement.temperature => _niceStep(rough, minStep: 0.5),
BTHomeMetMeasurement.windSpeed => _niceStep(rough, minStep: 0.5),
BTHomeMetMeasurement.gust => _niceStep(rough, minStep: 0.5),
BTHomeMetMeasurement.rain => _niceStep(rough, minStep: 0.5),
};
}
double _niceStep(double value, {required double minStep}) {
if (value <= minStep) {
return minStep;
}
final exponent = math
.pow(10.0, (math.log(value) / math.ln10).floor())
.toDouble();
final normalized = value / exponent;
final stepped = switch (normalized) {
< 1.5 => 1.0,
< 3.0 => 2.0,
< 7.0 => 5.0,
_ => 10.0,
};
return math.max(minStep, stepped * exponent);
}
String _formatHistoryAxisValue(BTHomeMetMeasurement measurement, double value) {
final digits = measurement == BTHomeMetMeasurement.humidity ? 0 : 1;
return value.toStringAsFixed(digits).replaceFirst(RegExp(r'\.?0+$'), '');
}
bool _shouldShowBottomSampleLabel(int index, int sampleCount) {
if (index < 0 || index >= sampleCount) {
return false;
}
if (sampleCount <= 3) {
return true;
}
final middle = (sampleCount - 1) ~/ 2;
return index == 0 || index == middle || index == sampleCount - 1;
}

View File

@@ -0,0 +1,646 @@
import 'dart:math' as math;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/connection_provider.dart';
import '../../providers/contacts_provider.dart';
import '../../providers/sensors_provider.dart';
import 'sensor_telemetry_card.dart';
enum SensorHistoryRange { day, week, month, all }
Future<void> showSensorHistorySheet(
BuildContext context, {
required String publicKeyHex,
String? initialFieldKey,
}) {
return Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (pageContext) => SensorHistoryScreen(
publicKeyHex: publicKeyHex,
initialFieldKey: initialFieldKey,
),
),
);
}
class SensorHistoryScreen extends StatefulWidget {
const SensorHistoryScreen({
super.key,
required this.publicKeyHex,
this.initialFieldKey,
});
final String publicKeyHex;
final String? initialFieldKey;
@override
State<SensorHistoryScreen> createState() => _SensorHistoryScreenState();
}
class _SensorHistoryScreenState extends State<SensorHistoryScreen> {
late SensorHistoryRange _selectedRange;
@override
void initState() {
super.initState();
_selectedRange = SensorHistoryRange.day;
}
@override
Widget build(BuildContext context) {
return Consumer3<SensorsProvider, ContactsProvider, ConnectionProvider>(
builder:
(
context,
sensorsProvider,
contactsProvider,
connectionProvider,
child,
) {
final contact = sensorsProvider.contactForDisplay(
widget.publicKeyHex,
contactsProvider: contactsProvider,
connectionProvider: connectionProvider,
);
final history = sensorsProvider.historyFor(widget.publicKeyHex);
final options = sensorMetricOptionsFor(
contact,
labelOverrides: sensorsProvider.labelOverridesFor(
widget.publicKeyHex,
),
);
final optionByKey = <String, SensorMetricOption>{
for (final option in options) option.key: option,
};
final availableFieldKeys = <String>{
for (final sample in history) ...sample.values.keys,
}.toList()
..sort((a, b) {
final aIndex = options.indexWhere((option) => option.key == a);
final bIndex = options.indexWhere((option) => option.key == b);
if (aIndex == -1 && bIndex == -1) {
return a.compareTo(b);
}
if (aIndex == -1) {
return 1;
}
if (bIndex == -1) {
return -1;
}
return aIndex.compareTo(bIndex);
});
final selectedFieldKey = resolveInitialSensorHistoryField(
requestedFieldKey: widget.initialFieldKey,
availableFieldKeys: availableFieldKeys,
);
final selectedOption = selectedFieldKey == null
? null
: optionByKey[selectedFieldKey];
final selectedCardData = selectedOption?.previewCardData;
final selectedSamples = selectedFieldKey == null
? const <SensorHistorySample>[]
: history
.where(
(sample) =>
sample.values.containsKey(selectedFieldKey),
)
.toList(growable: false);
final rangeSamples = filterSensorHistorySamples(
samples: selectedSamples,
range: _selectedRange,
);
final title =
selectedCardData?.label ??
selectedOption?.defaultLabel ??
'Sensor history';
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title),
Text(
contact?.displayName ?? 'Unavailable node',
style: Theme.of(context).textTheme.bodySmall,
),
],
),
bottom: const TabBar(
tabs: [
Tab(text: 'Graph'),
Tab(text: 'Values'),
],
),
),
body: selectedFieldKey == null
? _SensorHistoryEmptyState(
message:
'No history recorded yet. Enable auto refresh for this sensor and leave the app running to collect samples.',
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 0),
child: Wrap(
spacing: 8,
runSpacing: 8,
children: SensorHistoryRange.values
.map(
(range) => ChoiceChip(
label: Text(
sensorHistoryRangeLabel(range),
),
selected: _selectedRange == range,
onSelected: (selected) {
if (!selected) {
return;
}
setState(() {
_selectedRange = range;
});
},
),
)
.toList(growable: false),
),
),
Expanded(
child: TabBarView(
children: [
_SensorHistoryGraphTab(
samples: rangeSamples,
fieldKey: selectedFieldKey,
cardData: selectedCardData,
totalCount: selectedSamples.length,
range: _selectedRange,
),
_SensorHistoryValuesTab(
samples: rangeSamples,
fieldKey: selectedFieldKey,
cardData: selectedCardData,
range: _selectedRange,
),
],
),
),
],
),
),
);
},
);
}
}
String? resolveInitialSensorHistoryField({
required String? requestedFieldKey,
required List<String> availableFieldKeys,
}) {
if (requestedFieldKey != null &&
availableFieldKeys.contains(requestedFieldKey)) {
return requestedFieldKey;
}
if (availableFieldKeys.isEmpty) {
return null;
}
return availableFieldKeys.first;
}
List<SensorHistorySample> filterSensorHistorySamples({
required List<SensorHistorySample> samples,
required SensorHistoryRange range,
}) {
if (samples.isEmpty || range == SensorHistoryRange.all) {
return List<SensorHistorySample>.from(samples);
}
final latestTimestamp = samples.last.timestamp;
final cutoff = switch (range) {
SensorHistoryRange.day => latestTimestamp.subtract(const Duration(days: 1)),
SensorHistoryRange.week => latestTimestamp.subtract(const Duration(days: 7)),
SensorHistoryRange.month => latestTimestamp.subtract(
const Duration(days: 30),
),
SensorHistoryRange.all => DateTime.fromMillisecondsSinceEpoch(0),
};
return samples
.where((sample) => !sample.timestamp.isBefore(cutoff))
.toList(growable: false);
}
String sensorHistoryRangeLabel(SensorHistoryRange range) {
return switch (range) {
SensorHistoryRange.day => '24h',
SensorHistoryRange.week => '7d',
SensorHistoryRange.month => '30d',
SensorHistoryRange.all => 'All',
};
}
class _SensorHistoryGraphTab extends StatelessWidget {
const _SensorHistoryGraphTab({
required this.samples,
required this.fieldKey,
required this.cardData,
required this.totalCount,
required this.range,
});
final List<SensorHistorySample> samples;
final String fieldKey;
final SensorMetricCardData? cardData;
final int totalCount;
final SensorHistoryRange range;
@override
Widget build(BuildContext context) {
if (samples.isEmpty) {
return _SensorHistoryEmptyState(
message:
'No samples are available for ${sensorHistoryRangeLabel(range)}.',
);
}
final latestValue = samples.last.values[fieldKey]!;
final minValue = samples
.map((sample) => sample.values[fieldKey]!)
.reduce(math.min);
final maxValue = samples
.map((sample) => sample.values[fieldKey]!)
.reduce(math.max);
final accent = cardData?.accent ?? Theme.of(context).colorScheme.primary;
return ListView(
padding: const EdgeInsets.all(16),
children: [
Row(
children: [
Expanded(
child: _SensorHistoryStatTile(
label: 'Visible',
value: samples.length.toString(),
accent: accent,
),
),
const SizedBox(width: 8),
Expanded(
child: _SensorHistoryStatTile(
label: 'Latest',
value: _formatHistoryValue(cardData, latestValue),
accent: accent,
),
),
const SizedBox(width: 8),
Expanded(
child: _SensorHistoryStatTile(
label: 'Min',
value: _formatHistoryValue(cardData, minValue),
accent: accent,
),
),
const SizedBox(width: 8),
Expanded(
child: _SensorHistoryStatTile(
label: 'Max',
value: _formatHistoryValue(cardData, maxValue),
accent: accent,
),
),
],
),
const SizedBox(height: 8),
Text(
'$totalCount total readings',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: SizedBox(
height: 280,
child: LineChart(
_historyLineChartData(
context,
samples: samples,
fieldKey: fieldKey,
color: accent,
),
duration: Duration.zero,
),
),
),
],
);
}
}
class _SensorHistoryValuesTab extends StatelessWidget {
const _SensorHistoryValuesTab({
required this.samples,
required this.fieldKey,
required this.cardData,
required this.range,
});
final List<SensorHistorySample> samples;
final String fieldKey;
final SensorMetricCardData? cardData;
final SensorHistoryRange range;
@override
Widget build(BuildContext context) {
if (samples.isEmpty) {
return _SensorHistoryEmptyState(
message:
'No values are available for ${sensorHistoryRangeLabel(range)}.',
);
}
return ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: samples.length,
separatorBuilder: (context, index) => const SizedBox(height: 8),
itemBuilder: (context, index) {
final sample = samples[samples.length - index - 1];
final value = sample.values[fieldKey]!;
return Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surfaceContainerLow,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outlineVariant.withValues(alpha: 0.35),
),
),
child: Row(
children: [
Expanded(
child: Text(
_formatSampleTimestamp(sample.timestamp),
style: Theme.of(context).textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w700,
),
),
),
Text(
_formatHistoryValue(cardData, value),
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
color:
cardData?.accent ?? Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w700,
),
),
],
),
);
},
);
}
}
class _SensorHistoryEmptyState extends StatelessWidget {
const _SensorHistoryEmptyState({required this.message});
final String message;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Text(
message,
textAlign: TextAlign.center,
style: Theme.of(context).textTheme.bodyLarge,
),
),
);
}
}
class _SensorHistoryStatTile extends StatelessWidget {
const _SensorHistoryStatTile({
required this.label,
required this.value,
required this.accent,
});
final String label;
final String value;
final Color accent;
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: accent.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(18),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
label,
style: Theme.of(context).textTheme.labelMedium?.copyWith(
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
const SizedBox(height: 4),
Text(
value,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: Theme.of(
context,
).textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w700),
),
],
),
);
}
}
LineChartData _historyLineChartData(
BuildContext context, {
required List<SensorHistorySample> samples,
required String fieldKey,
required Color color,
}) {
final theme = Theme.of(context);
final values = samples
.map((sample) => sample.values[fieldKey]!)
.toList(growable: false);
final spots = values
.asMap()
.entries
.map((entry) => FlSpot(entry.key.toDouble(), entry.value))
.toList(growable: false);
final minValue = values.reduce(math.min);
final maxValue = values.reduce(math.max);
final spread = maxValue - minValue;
final padding = spread == 0
? math.max(maxValue.abs() * 0.1, 1.0)
: spread * 0.15;
final minY = minValue - padding;
final maxY = maxValue + padding;
final interval = spread <= 0
? math.max(maxValue.abs() / 3, 1.0)
: spread / 3;
return LineChartData(
minX: 0,
maxX: values.length <= 1 ? 1.0 : (values.length - 1).toDouble(),
minY: minY,
maxY: maxY,
clipData: const FlClipData.all(),
lineTouchData: const LineTouchData(enabled: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: interval,
getDrawingHorizontalLine: (value) => FlLine(
color: theme.colorScheme.outlineVariant.withValues(alpha: 0.24),
strokeWidth: 1,
),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: interval,
getTitlesWidget: (value, meta) => SideTitleWidget(
meta: meta,
space: 8,
child: Text(
value.toStringAsFixed(value.abs() >= 10 ? 0 : 1),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 24,
interval: math.max((values.length / 4).floorToDouble(), 1),
getTitlesWidget: (value, meta) {
final index = value.round();
if (index < 0 || index >= samples.length || value != index.toDouble()) {
return const SizedBox.shrink();
}
return SideTitleWidget(
meta: meta,
space: 6,
child: Text(
_formatChartTimestamp(samples[index].timestamp),
style: theme.textTheme.labelSmall?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w600,
),
),
);
},
),
),
),
lineBarsData: [
LineChartBarData(
spots: spots,
color: color,
barWidth: 2.8,
isCurved: false,
dotData: FlDotData(
show: true,
checkToShowDot: (spot, barData) =>
barData.spots.length <= 10 || spot == barData.spots.last,
),
belowBarData: BarAreaData(
show: true,
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
color.withValues(alpha: 0.20),
color.withValues(alpha: 0.03),
],
),
),
),
],
);
}
String _formatHistoryValue(SensorMetricCardData? cardData, double value) {
final template = cardData?.value;
if (template == null || template.isEmpty) {
return value.toStringAsFixed(value.abs() >= 10 ? 0 : 1);
}
if (template.endsWith('%')) {
return '${value.toStringAsFixed(1)}%';
}
if (template.endsWith('V')) {
return '${value.toStringAsFixed(3)}V';
}
if (template.contains(' hPa')) {
return '${value.toStringAsFixed(1)} hPa';
}
if (template.contains('°C')) {
return '${value.toStringAsFixed(1)}°C';
}
if (template.contains(' mph')) {
return '${value.toStringAsFixed(2)} mph';
}
if (template.contains(' km/h')) {
return '${value.toStringAsFixed(2)} km/h';
}
return value.toStringAsFixed(value.abs() >= 10 ? 0 : 1);
}
String _formatSampleTimestamp(DateTime timestamp) {
final local = timestamp.toLocal();
final month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
final year = local.year.toString().substring(2);
final hour = local.hour.toString().padLeft(2, '0');
final minute = local.minute.toString().padLeft(2, '0');
return '$month/$day/$year $hour:$minute';
}
String _formatChartTimestamp(DateTime timestamp) {
final local = timestamp.toLocal();
final month = local.month.toString().padLeft(2, '0');
final day = local.day.toString().padLeft(2, '0');
return '$month/$day';
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../l10n/app_localizations.dart';
import '../../services/traffic_stats_reporting_service.dart';
class TrafficStatsReportingSection extends StatelessWidget {
final TrafficStatsReportingService service;
const TrafficStatsReportingSection({super.key, required this.service});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final colorScheme = theme.colorScheme;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile(
dense: true,
secondary: const Icon(Icons.cloud_upload_outlined, size: 20),
title: Text(AppLocalizations.of(context)!.anonymousRxStats),
subtitle: const Text(
'Upload packet totals every 5 min',
),
value: service.isEnabled,
onChanged: (value) async {
await service.setEnabled(value);
},
),
if (service.isEnabled)
Padding(
padding: const EdgeInsets.fromLTRB(16, 4, 16, 8),
child: Row(
children: [
Expanded(
child: Text(
_statusText(service),
style: theme.textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
),
TextButton.icon(
onPressed: _openStatsDashboard,
icon: const Icon(Icons.open_in_new, size: 16),
label: Text(AppLocalizations.of(context)!.view),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
textStyle: theme.textTheme.labelSmall,
),
),
],
),
),
],
);
}
static Future<void> _openStatsDashboard() async {
final url = TrafficStatsReportingService.dashboardUri;
if (await canLaunchUrl(url)) {
await launchUrl(url, mode: LaunchMode.externalApplication);
}
}
static String _statusText(TrafficStatsReportingService service) {
final parts = <String>[];
parts.add('Pending: ${service.pendingUploadCount}');
if (service.lastSuccessAt != null) {
parts.add('Sent: ${_formatDateTime(service.lastSuccessAt!.toLocal())}');
}
if (service.lastError != null && service.lastError!.isNotEmpty) {
parts.add('Error: ${service.lastError}');
}
return parts.join(' · ');
}
static String _formatDateTime(DateTime value) {
final month = value.month.toString().padLeft(2, '0');
final day = value.day.toString().padLeft(2, '0');
final hour = value.hour.toString().padLeft(2, '0');
final minute = value.minute.toString().padLeft(2, '0');
return '${value.year}-$month-$day $hour:$minute';
}
}

View File

@@ -0,0 +1,180 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import '../models/update_info.dart';
import '../l10n/app_localizations.dart';
/// Dialog widget that displays when a new app version is available
/// Shows current vs latest commit hash and provides download button
class UpdateDialog extends StatelessWidget {
final UpdateInfo updateInfo;
const UpdateDialog({
super.key,
required this.updateInfo,
});
/// Show the update dialog
static Future<void> show(BuildContext context, UpdateInfo updateInfo) {
return showDialog(
context: context,
barrierDismissible: true,
builder: (context) => UpdateDialog(updateInfo: updateInfo),
);
}
@override
Widget build(BuildContext context) {
final loc = AppLocalizations.of(context)!;
return AlertDialog(
icon: const Icon(
Icons.system_update,
size: 48,
color: Colors.blue,
),
title: Text(loc.updateAvailable),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Current version
_buildInfoRow(
context,
label: loc.currentVersion,
value: updateInfo.currentCommitHash,
),
const SizedBox(height: 12),
// Latest version
_buildInfoRow(
context,
label: loc.latestVersion,
value: updateInfo.latestCommitHash ?? 'unknown',
),
// Optional: Build timestamp
if (updateInfo.timestamp != null) ...[
SizedBox(height: 12),
_buildInfoRow(
context,
label: AppLocalizations.of(context)!.buildTime,
value: _formatTimestamp(updateInfo.timestamp!),
),
],
],
),
actions: [
// Later button
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: Text(loc.updateLater),
),
// Download button
FilledButton.icon(
onPressed: () => _launchDownloadUrl(context),
icon: const Icon(Icons.download),
label: Text(loc.downloadUpdate),
),
],
);
}
/// Build a labeled info row
Widget _buildInfoRow(BuildContext context, {required String label, required String value}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: 100,
child: Text(
'$label:',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.grey[700],
),
),
),
Expanded(
child: SelectableText(
value,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
color: Colors.black87,
),
),
),
],
);
}
/// Format timestamp from YYYYMMDD-HHMMSS to readable format
String _formatTimestamp(String timestamp) {
try {
// Parse YYYYMMDD-HHMMSS format
if (timestamp.length >= 15) {
final year = timestamp.substring(0, 4);
final month = timestamp.substring(4, 6);
final day = timestamp.substring(6, 8);
final hour = timestamp.substring(9, 11);
final minute = timestamp.substring(11, 13);
return '$year-$month-$day $hour:$minute UTC';
}
return timestamp;
} catch (e) {
return timestamp;
}
}
/// Launch download URL in browser
Future<void> _launchDownloadUrl(BuildContext context) async {
if (updateInfo.downloadUrl == null) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.downloadUrlNotAvailable),
backgroundColor: Colors.red,
),
);
}
return;
}
try {
final url = Uri.parse(updateInfo.downloadUrl!);
final canLaunch = await canLaunchUrl(url);
if (!canLaunch) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.cannotOpenDownloadUrl),
backgroundColor: Colors.red,
),
);
}
return;
}
await launchUrl(
url,
mode: LaunchMode.externalApplication,
);
// Close dialog after launching download
if (context.mounted) {
Navigator.of(context).pop();
}
} catch (e) {
debugPrint('[UpdateDialog] Error launching download URL: $e');
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context)!.errorOpeningDownload(e.toString())),
backgroundColor: Colors.red,
),
);
}
}
}
}