mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-19 08:55:33 +10:00
Merge branch 'main' into unread-peoplefirst
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AdaptiveAppBarTitle extends StatelessWidget {
|
||||
final String text;
|
||||
|
||||
const AdaptiveAppBarTitle(this.text, {super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) => SizedBox(
|
||||
width: constraints.maxWidth,
|
||||
child: FittedBox(fit: BoxFit.scaleDown, child: Text(text, maxLines: 1)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/connector/meshcore_connector.dart';
|
||||
import 'package:meshcore_open/widgets/battery_indicator.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'snr_indicator.dart';
|
||||
|
||||
class AppBarTitle extends StatelessWidget {
|
||||
final String title;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
const AppBarTitle(this.title, {this.leading, this.trailing, super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final connector = context.watch<MeshCoreConnector>();
|
||||
final selfName = connector.selfName;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final availableWidth = constraints.hasBoundedWidth
|
||||
? constraints.maxWidth
|
||||
: MediaQuery.sizeOf(context).width;
|
||||
final compact = availableWidth < 240;
|
||||
final showSubtitle =
|
||||
!compact && connector.isConnected && selfName != null;
|
||||
final showBattery = availableWidth >= 60;
|
||||
final showSnr = availableWidth >= 110;
|
||||
final showIndicators = showBattery || showSnr;
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
children: [
|
||||
leading ?? const SizedBox.shrink(),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
|
||||
if (showSubtitle)
|
||||
Text(
|
||||
'($selfName)',
|
||||
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showIndicators) const SizedBox(width: 6),
|
||||
if (showIndicators)
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (showBattery) BatteryIndicator(connector: connector),
|
||||
if (showSnr) SNRIndicator(connector: connector),
|
||||
],
|
||||
),
|
||||
trailing ?? const SizedBox.shrink(),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -68,20 +68,24 @@ class _BatteryIndicatorState extends State<BatteryIndicator> {
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(batteryUi.icon, size: 18, color: batteryUi.color),
|
||||
const SizedBox(width: 2),
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: batteryUi.color,
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(batteryUi.icon, size: 18, color: batteryUi.color),
|
||||
const SizedBox(height: 2),
|
||||
Flexible(
|
||||
child: Text(
|
||||
displayText,
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: batteryUi.color,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
overflow: TextOverflow.visible,
|
||||
maxLines: 1,
|
||||
softWrap: false,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../services/chat_text_scale_service.dart';
|
||||
|
||||
/// Gesture wrapper that exposes two-finger pinch-to-zoom for chat scrollables.
|
||||
/// Double-tap resets the scale. Only the wrapper itself listens to gestures;
|
||||
/// child scrollables keep their normal touch handling.
|
||||
class ChatZoomWrapper extends StatefulWidget {
|
||||
const ChatZoomWrapper({super.key, required this.child, this.onDoubleTap});
|
||||
|
||||
final Widget child;
|
||||
final VoidCallback? onDoubleTap;
|
||||
|
||||
@override
|
||||
State<ChatZoomWrapper> createState() => _ChatZoomWrapperState();
|
||||
}
|
||||
|
||||
class _ChatZoomWrapperState extends State<ChatZoomWrapper> {
|
||||
double? _startScale;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final service = context.read<ChatTextScaleService>();
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.translucent,
|
||||
onDoubleTap: () {
|
||||
service.reset();
|
||||
service.persist();
|
||||
widget.onDoubleTap?.call();
|
||||
},
|
||||
onScaleStart: (details) {
|
||||
if (details.pointerCount != 2) return;
|
||||
_startScale = service.scale;
|
||||
},
|
||||
onScaleUpdate: (details) {
|
||||
if (details.pointerCount != 2) return;
|
||||
final baseScale = _startScale ?? service.scale;
|
||||
service.setScale(baseScale * details.scale);
|
||||
},
|
||||
onScaleEnd: (_) {
|
||||
_startScale = null;
|
||||
service.persist();
|
||||
},
|
||||
child: widget.child,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import '../l10n/l10n.dart';
|
||||
|
||||
enum ContactSortOption { lastSeen, recentMessages, name }
|
||||
|
||||
enum ContactTypeFilter { all, users, repeaters, rooms }
|
||||
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
|
||||
|
||||
class SortFilterMenuOption {
|
||||
final int value;
|
||||
@@ -94,11 +94,12 @@ const int _actionSortRecentMessages = 1;
|
||||
const int _actionSortName = 2;
|
||||
const int _actionSortLastSeen = 3;
|
||||
const int _actionFilterAll = 4;
|
||||
const int _actionFilterUsers = 5;
|
||||
const int _actionFilterRepeaters = 6;
|
||||
const int _actionFilterRooms = 7;
|
||||
const int _actionToggleUnreadOnly = 8;
|
||||
const int _actionNewGroup = 9;
|
||||
const int _actionFilterFavorites = 5;
|
||||
const int _actionFilterUsers = 6;
|
||||
const int _actionFilterRepeaters = 7;
|
||||
const int _actionFilterRooms = 8;
|
||||
const int _actionToggleUnreadOnly = 9;
|
||||
const int _actionNewGroup = 10;
|
||||
const int _actionTogglePrioritizeUsers = 10;
|
||||
|
||||
class ContactsFilterMenu extends StatelessWidget {
|
||||
@@ -164,6 +165,11 @@ class ContactsFilterMenu extends StatelessWidget {
|
||||
label: l10n.listFilter_all,
|
||||
checked: typeFilter == ContactTypeFilter.all,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterFavorites,
|
||||
label: l10n.listFilter_favorites,
|
||||
checked: typeFilter == ContactTypeFilter.favorites,
|
||||
),
|
||||
SortFilterMenuOption(
|
||||
value: _actionFilterUsers,
|
||||
label: l10n.listFilter_users,
|
||||
@@ -211,6 +217,9 @@ class ContactsFilterMenu extends StatelessWidget {
|
||||
case _actionFilterUsers:
|
||||
onTypeFilterChanged(ContactTypeFilter.users);
|
||||
break;
|
||||
case _actionFilterFavorites:
|
||||
onTypeFilterChanged(ContactTypeFilter.favorites);
|
||||
break;
|
||||
case _actionFilterRepeaters:
|
||||
onTypeFilterChanged(ContactTypeFilter.repeaters);
|
||||
break;
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_svg/flutter_svg.dart';
|
||||
|
||||
class MessageStatusIcon extends StatelessWidget {
|
||||
final bool isAcked;
|
||||
final bool isFailed;
|
||||
final double size;
|
||||
|
||||
const MessageStatusIcon({
|
||||
super.key,
|
||||
required this.isAcked,
|
||||
this.isFailed = false,
|
||||
this.size = 14,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isFailed) {
|
||||
return Icon(Icons.cancel, size: size, color: Colors.red);
|
||||
}
|
||||
|
||||
final Color color;
|
||||
if (isAcked) {
|
||||
color = Colors.green;
|
||||
} else {
|
||||
color = Colors.grey;
|
||||
}
|
||||
|
||||
return SvgPicture.asset(
|
||||
'assets/icons/done_all.svg',
|
||||
width: size,
|
||||
height: size,
|
||||
colorFilter: ColorFilter.mode(color, BlendMode.srcIn),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,9 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:meshcore_open/models/path_history.dart';
|
||||
import 'package:meshcore_open/screens/path_trace_map.dart';
|
||||
import 'package:meshcore_open/widgets/elements_ui.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../connector/meshcore_connector.dart';
|
||||
@@ -19,15 +21,22 @@ class PathManagementDialog {
|
||||
}
|
||||
}
|
||||
|
||||
class _PathManagementDialog extends StatelessWidget {
|
||||
class _PathManagementDialog extends StatefulWidget {
|
||||
final Contact contact;
|
||||
|
||||
const _PathManagementDialog({required this.contact});
|
||||
|
||||
@override
|
||||
State<_PathManagementDialog> createState() => _PathManagementDialogState();
|
||||
}
|
||||
|
||||
class _PathManagementDialogState extends State<_PathManagementDialog> {
|
||||
bool _showAllPaths = false;
|
||||
|
||||
Contact _resolveContact(MeshCoreConnector connector) {
|
||||
return connector.contacts.firstWhere(
|
||||
(c) => c.publicKeyHex == contact.publicKeyHex,
|
||||
orElse: () => contact,
|
||||
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
|
||||
orElse: () => widget.contact,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -134,6 +143,59 @@ class _PathManagementDialog extends StatelessWidget {
|
||||
final currentContact = _resolveContact(connector);
|
||||
final paths = pathService.getRecentPaths(currentContact.publicKeyHex);
|
||||
|
||||
final repeatersList = List.of(connector.directRepeaters)
|
||||
..sort((a, b) => b.ranking.compareTo(a.ranking));
|
||||
|
||||
if (repeatersList.isEmpty) {
|
||||
_showAllPaths = true;
|
||||
}
|
||||
|
||||
final directRepeater = repeatersList.isEmpty
|
||||
? null
|
||||
: repeatersList.first;
|
||||
final secondDirectRepeater = repeatersList.length < 2
|
||||
? null
|
||||
: repeatersList.elementAt(1);
|
||||
final thirdDirectRepeater = repeatersList.length < 3
|
||||
? null
|
||||
: repeatersList.elementAt(2);
|
||||
|
||||
List<MapEntry<int, MapEntry<Color, PathRecord>>> pathsWithRepeaters =
|
||||
paths.map((path) {
|
||||
final isDirectRepeater =
|
||||
directRepeater != null &&
|
||||
path.pathBytes.isNotEmpty &&
|
||||
directRepeater.pubkeyFirstByte == path.pathBytes.first;
|
||||
final isSecondDirectRepeater =
|
||||
secondDirectRepeater != null &&
|
||||
path.pathBytes.isNotEmpty &&
|
||||
secondDirectRepeater.pubkeyFirstByte == path.pathBytes.first;
|
||||
final isThirdDirectRepeater =
|
||||
thirdDirectRepeater != null &&
|
||||
path.pathBytes.isNotEmpty &&
|
||||
thirdDirectRepeater.pubkeyFirstByte == path.pathBytes.first;
|
||||
|
||||
int ranking = -1;
|
||||
Color color = Colors.grey;
|
||||
if (isDirectRepeater) {
|
||||
color = Colors.green;
|
||||
ranking = 3;
|
||||
} else if (isSecondDirectRepeater) {
|
||||
color = Colors.yellow;
|
||||
ranking = 2;
|
||||
} else if (isThirdDirectRepeater) {
|
||||
color = Colors.red;
|
||||
ranking = 1;
|
||||
} else if (path.wasFloodDiscovery) {
|
||||
color = Colors.blue;
|
||||
ranking = 0;
|
||||
}
|
||||
|
||||
return MapEntry(ranking, MapEntry(color, path));
|
||||
}).toList();
|
||||
|
||||
pathsWithRepeaters.sort((a, b) => b.key.compareTo(a.key));
|
||||
|
||||
return AlertDialog(
|
||||
title: Text(l10n.chat_pathManagement),
|
||||
content: SingleChildScrollView(
|
||||
@@ -147,6 +209,17 @@ class _PathManagementDialog extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
if (paths.isNotEmpty) ...[
|
||||
if (repeatersList.isNotEmpty)
|
||||
FeatureToggleRow(
|
||||
title: l10n.chat_ShowAllPaths,
|
||||
subtitle: "",
|
||||
value: _showAllPaths,
|
||||
onChanged: (val) {
|
||||
setState(() {
|
||||
_showAllPaths = val;
|
||||
});
|
||||
},
|
||||
),
|
||||
Text(
|
||||
l10n.chat_recentAckPaths,
|
||||
style: const TextStyle(
|
||||
@@ -154,7 +227,7 @@ class _PathManagementDialog extends StatelessWidget {
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
if (paths.length >= 100) ...[
|
||||
if (pathsWithRepeaters.length >= 100) ...[
|
||||
const SizedBox(height: 8),
|
||||
Container(
|
||||
width: double.infinity,
|
||||
@@ -173,92 +246,99 @@ class _PathManagementDialog extends StatelessWidget {
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 8),
|
||||
...paths.map((path) {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: path.wasFloodDiscovery
|
||||
? Colors.blue
|
||||
: Colors.green,
|
||||
child: Text(
|
||||
'${path.hopCount}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
l10n.chat_hopsCount(path.hopCount),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)} • ${path.successCount} ${l10n.chat_successes}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
tooltip: l10n.chat_removePath,
|
||||
onPressed: () async {
|
||||
await pathService.removePathRecord(
|
||||
currentContact.publicKeyHex,
|
||||
path.pathBytes,
|
||||
);
|
||||
},
|
||||
...pathsWithRepeaters.map((entry) {
|
||||
final path = entry.value.value;
|
||||
final color = entry.value.key;
|
||||
|
||||
if (!_showAllPaths && entry.key < 1) {
|
||||
return const SizedBox.shrink();
|
||||
} else {
|
||||
return Card(
|
||||
margin: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: ListTile(
|
||||
dense: true,
|
||||
leading: CircleAvatar(
|
||||
radius: 16,
|
||||
backgroundColor: color,
|
||||
child: Text(
|
||||
'${path.hopCount}',
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
path.wasFloodDiscovery
|
||||
? const Icon(
|
||||
Icons.waves,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.route,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
title: Text(
|
||||
l10n.chat_hopsCount(path.hopCount),
|
||||
style: const TextStyle(fontSize: 14),
|
||||
),
|
||||
subtitle: Text(
|
||||
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)} • ${path.successCount} ${l10n.chat_successes}',
|
||||
style: const TextStyle(fontSize: 11),
|
||||
),
|
||||
trailing: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.close, size: 16),
|
||||
tooltip: l10n.chat_removePath,
|
||||
onPressed: () async {
|
||||
await pathService.removePathRecord(
|
||||
currentContact.publicKeyHex,
|
||||
path.pathBytes,
|
||||
);
|
||||
},
|
||||
),
|
||||
path.wasFloodDiscovery
|
||||
? const Icon(
|
||||
Icons.waves,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.route,
|
||||
size: 16,
|
||||
color: Colors.grey,
|
||||
),
|
||||
],
|
||||
),
|
||||
onLongPress: () =>
|
||||
_showFullPathDialog(context, path.pathBytes),
|
||||
onTap: () async {
|
||||
if (path.pathBytes.isEmpty) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
l10n.chat_pathDetailsNotAvailable,
|
||||
),
|
||||
],
|
||||
),
|
||||
onLongPress: () =>
|
||||
_showFullPathDialog(context, path.pathBytes),
|
||||
onTap: () async {
|
||||
if (path.pathBytes.isEmpty) {
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final pathBytes = Uint8List.fromList(
|
||||
path.pathBytes,
|
||||
);
|
||||
final pathLength = path.pathBytes.length;
|
||||
|
||||
await connector.setPathOverride(
|
||||
currentContact,
|
||||
pathLen: pathLength,
|
||||
pathBytes: pathBytes,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
l10n.chat_pathDetailsNotAvailable,
|
||||
l10n.path_usingHopsPath(path.hopCount),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
final pathBytes = Uint8List.fromList(path.pathBytes);
|
||||
final pathLength = path.pathBytes.length;
|
||||
|
||||
await connector.setPathOverride(
|
||||
currentContact,
|
||||
pathLen: pathLength,
|
||||
pathBytes: pathBytes,
|
||||
);
|
||||
|
||||
if (!context.mounted) return;
|
||||
Navigator.pop(context);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
l10n.path_usingHopsPath(path.hopCount),
|
||||
),
|
||||
duration: const Duration(seconds: 2),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}),
|
||||
const Divider(),
|
||||
] else ...[
|
||||
|
||||
@@ -156,7 +156,7 @@ class _QrScannerWidgetState extends State<QrScannerWidget>
|
||||
MobileScanner(
|
||||
controller: _controller,
|
||||
onDetect: _handleDetection,
|
||||
errorBuilder: (context, error, child) {
|
||||
errorBuilder: (context, error) {
|
||||
return _buildErrorWidget(context, error);
|
||||
},
|
||||
),
|
||||
|
||||
+174
-31
@@ -1,4 +1,13 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../connector/meshcore_connector.dart';
|
||||
import '../l10n/l10n.dart';
|
||||
|
||||
class SNRUi {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String text;
|
||||
const SNRUi(this.icon, this.color, this.text);
|
||||
}
|
||||
|
||||
List<double> getSNRfromSF(int spreadingFactor) {
|
||||
switch (spreadingFactor) {
|
||||
@@ -19,44 +28,178 @@ List<double> getSNRfromSF(int spreadingFactor) {
|
||||
}
|
||||
}
|
||||
|
||||
class SNRIcon extends StatelessWidget {
|
||||
final double snr;
|
||||
final List<double> snrLevels;
|
||||
SNRUi snrUiFromSNR(double? snr, int? spreadingFactor) {
|
||||
if (snr == null ||
|
||||
spreadingFactor == null ||
|
||||
spreadingFactor < 7 ||
|
||||
spreadingFactor > 12) {
|
||||
return const SNRUi(Icons.signal_cellular_off, Colors.grey, '—');
|
||||
}
|
||||
|
||||
const SNRIcon({
|
||||
super.key,
|
||||
required this.snr,
|
||||
this.snrLevels = const [4.0, -2.0, -4.0, -6.0],
|
||||
});
|
||||
final snrLevels = getSNRfromSF(spreadingFactor);
|
||||
|
||||
IconData icon;
|
||||
Color color;
|
||||
String text = '${snr.toStringAsFixed(1)} dB';
|
||||
|
||||
if (snr >= snrLevels[0]) {
|
||||
icon = Icons.signal_cellular_alt;
|
||||
color = Colors.green;
|
||||
} else if (snr >= snrLevels[1]) {
|
||||
icon = Icons.signal_cellular_alt;
|
||||
color = Colors.lightGreen;
|
||||
} else if (snr >= snrLevels[2]) {
|
||||
icon = Icons.signal_cellular_alt;
|
||||
color = Colors.yellow;
|
||||
} else if (snr >= snrLevels[3]) {
|
||||
icon = Icons.signal_cellular_alt_2_bar;
|
||||
color = Colors.orange;
|
||||
} else {
|
||||
icon = Icons.signal_cellular_alt_1_bar;
|
||||
color = Colors.red;
|
||||
}
|
||||
|
||||
return SNRUi(icon, color, text);
|
||||
}
|
||||
|
||||
class SNRIndicator extends StatefulWidget {
|
||||
final MeshCoreConnector connector;
|
||||
|
||||
const SNRIndicator({super.key, required this.connector});
|
||||
|
||||
@override
|
||||
State<SNRIndicator> createState() => _SNRIndicatorState();
|
||||
}
|
||||
|
||||
class _SNRIndicatorState extends State<SNRIndicator> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
IconData icon;
|
||||
Color color;
|
||||
final directRepeaters = widget.connector.directRepeaters;
|
||||
final directBestRepeaters = List.of(directRepeaters)
|
||||
..sort((a, b) => (b.ranking).compareTo(a.ranking));
|
||||
final directRepeater = directBestRepeaters.isEmpty
|
||||
? null
|
||||
: directBestRepeaters.first;
|
||||
|
||||
if (snr >= snrLevels[0]) {
|
||||
icon = Icons.signal_cellular_alt;
|
||||
color = Colors.green;
|
||||
} else if (snr >= snrLevels[1]) {
|
||||
icon = Icons.signal_cellular_alt;
|
||||
color = Colors.lightGreen;
|
||||
} else if (snr >= snrLevels[2]) {
|
||||
icon = Icons.signal_cellular_alt;
|
||||
color = Colors.yellow;
|
||||
} else if (snr >= snrLevels[3]) {
|
||||
icon = Icons.signal_cellular_alt_2_bar;
|
||||
color = Colors.orange;
|
||||
} else {
|
||||
icon = Icons.signal_cellular_alt_1_bar;
|
||||
color = Colors.red;
|
||||
final snrUi = snrUiFromSNR(
|
||||
directBestRepeaters.isNotEmpty ? directRepeater!.snr : null,
|
||||
widget.connector.currentSf,
|
||||
);
|
||||
|
||||
return InkWell(
|
||||
onTap: () {
|
||||
if (directRepeater != null) {
|
||||
_showFullPathDialog(context, directBestRepeaters);
|
||||
}
|
||||
},
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(snrUi.icon, size: 18, color: snrUi.color),
|
||||
Text(
|
||||
snrUi.text,
|
||||
style: TextStyle(fontSize: 12, color: snrUi.color),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (directRepeater != null)
|
||||
Text(
|
||||
'${directRepeaters.length}: ${directRepeater.pubkeyFirstByte.toRadixString(16).padLeft(2, '0')}: ${_formatLastUpdated(directRepeater.lastUpdated)}',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: Colors.grey,
|
||||
),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatLastUpdated(DateTime lastSeen) {
|
||||
final now = DateTime.now();
|
||||
final diff = now.difference(lastSeen);
|
||||
if (diff.isNegative) {
|
||||
return "0s";
|
||||
}
|
||||
if (diff.inMinutes < 1) {
|
||||
return "${diff.inSeconds}s";
|
||||
}
|
||||
if (diff.inMinutes < 60) {
|
||||
return "${diff.inMinutes}m";
|
||||
}
|
||||
if (diff.inHours < 24) {
|
||||
final hours = diff.inHours;
|
||||
return "${hours}h";
|
||||
}
|
||||
final days = diff.inDays;
|
||||
return "${days}d";
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(icon, color: color),
|
||||
Text('$snr dB', style: TextStyle(fontSize: 10, color: color)),
|
||||
],
|
||||
void _showFullPathDialog(
|
||||
BuildContext context,
|
||||
List<DirectRepeater> directBestRepeaters,
|
||||
) {
|
||||
final l10n = context.l10n;
|
||||
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: Text(l10n.snrIndicator_nearByRepeaters),
|
||||
content: SizedBox(
|
||||
child: Scrollbar(
|
||||
child: ListView.separated(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
itemCount: directBestRepeaters.length,
|
||||
separatorBuilder: (_, _) => const Divider(height: 1),
|
||||
itemBuilder: (context, index) {
|
||||
final repeater = directBestRepeaters[index];
|
||||
final snrUi = snrUiFromSNR(
|
||||
repeater.snr,
|
||||
widget.connector.currentSf,
|
||||
);
|
||||
|
||||
final name = widget.connector.contacts
|
||||
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
|
||||
.map((c) => c.name)
|
||||
.firstOrNull;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
ListTile(
|
||||
leading: Icon(snrUi.icon, color: snrUi.color),
|
||||
title: Text(
|
||||
name ??
|
||||
repeater.pubkeyFirstByte
|
||||
.toRadixString(16)
|
||||
.padLeft(2, '0'),
|
||||
),
|
||||
subtitle: Text(
|
||||
'SNR: ${repeater.snr.toStringAsFixed(1)} dB\n${l10n.snrIndicator_lastSeen}: ${_formatLastUpdated(repeater.lastUpdated)}',
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: Text(l10n.common_close),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user