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