Merge main into unread-peoplefirst

Resolved conflicts by accepting refactored state management from main:
- list_filter_widget.dart: Adopt sealed class pattern for filter actions
- contacts_screen.dart: Move state to UiViewStateService instead of local setState
- device_screen.dart: Accept deletion (consolidated into other screens in main)

Main branch includes significant improvements:
- TCP and USB transport support
- Service-based state management with UiViewStateService
- Translation support with message translation buttons
- Signal UI consistency improvements
- Additional language support (hu, ja, ko)
- Comprehensive test coverage
- Discovery screen refactoring
This commit is contained in:
Serge Tarkovski
2026-04-21 16:45:43 +03:00
202 changed files with 54585 additions and 4477 deletions
+21 -7
View File
@@ -3,13 +3,25 @@ import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/widgets/battery_indicator.dart';
import 'package:provider/provider.dart';
import 'radio_stats_entry.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});
final bool indicators;
final bool showBatteryIndicator;
final bool subtitle;
const AppBarTitle(
this.title, {
this.leading,
this.trailing,
this.indicators = true,
this.showBatteryIndicator = true,
this.subtitle = true,
super.key,
});
@override
Widget build(BuildContext context) {
@@ -21,12 +33,12 @@ class AppBarTitle extends StatelessWidget {
final availableWidth = constraints.hasBoundedWidth
? constraints.maxWidth
: MediaQuery.sizeOf(context).width;
final compact = availableWidth < 240;
final compact = availableWidth < 170;
final showSubtitle =
!compact && connector.isConnected && selfName != null;
final showBattery = availableWidth >= 60;
!compact && connector.isConnected && selfName != null && subtitle;
final showBattery = showBatteryIndicator && availableWidth >= 60;
final showSnr = availableWidth >= 110;
final showIndicators = showBattery || showSnr;
final showIndicators = (showBattery || showSnr) && indicators;
return Row(
mainAxisAlignment: MainAxisAlignment.start,
@@ -40,7 +52,7 @@ class AppBarTitle extends StatelessWidget {
Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
if (showSubtitle)
Text(
'($selfName)',
selfName,
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
@@ -51,11 +63,13 @@ class AppBarTitle extends StatelessWidget {
if (showIndicators) const SizedBox(width: 6),
if (showIndicators)
Row(
crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
if (showBattery) BatteryIndicator(connector: connector),
if (showSnr) SNRIndicator(connector: connector),
if (connector.supportsCompanionRadioStats)
const RadioStatsIconButton(compact: true),
],
),
trailing ?? const SizedBox.shrink(),
+16 -18
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../l10n/l10n.dart';
import 'signal_ui.dart';
/// A reusable tile widget for displaying a MeshCore device in a list
class DeviceTile extends StatelessWidget {
@@ -33,28 +34,25 @@ class DeviceTile extends StatelessWidget {
}
Widget _buildSignalIcon(int rssi) {
IconData icon;
Color color;
if (rssi >= -60) {
icon = Icons.signal_cellular_4_bar;
color = Colors.green;
} else if (rssi >= -70) {
icon = Icons.signal_cellular_alt;
color = Colors.lightGreen;
} else if (rssi >= -80) {
icon = Icons.signal_cellular_alt_2_bar;
color = Colors.orange;
} else {
icon = Icons.signal_cellular_alt_1_bar;
color = Colors.red;
}
final tier = rssi >= -60
? 0
: rssi >= -70
? 1
: rssi >= -80
? 2
: rssi >= -90
? 3
: 4;
final signalUi = signalUiForStrengthTier(tier);
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon, color: color),
Text('$rssi dBm', style: TextStyle(fontSize: 10, color: color)),
Icon(signalUi.icon, color: signalUi.color),
Text(
'$rssi dBm',
style: TextStyle(fontSize: 10, color: signalUi.color),
),
],
);
}
+132 -84
View File
@@ -1,12 +1,9 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
import '../utils/contact_search.dart';
enum ContactSortOption { lastSeen, recentMessages, name }
enum ContactTypeFilter { all, favorites, users, repeaters, rooms }
class SortFilterMenuOption {
final int value;
class SortFilterMenuOption<T> {
final T value;
final String label;
final bool? checked;
@@ -17,16 +14,16 @@ class SortFilterMenuOption {
});
}
class SortFilterMenuSection {
class SortFilterMenuSection<T> {
final String title;
final List<SortFilterMenuOption> options;
final List<SortFilterMenuOption<T>> options;
const SortFilterMenuSection({required this.title, required this.options});
}
class SortFilterMenu extends StatelessWidget {
final List<SortFilterMenuSection> sections;
final ValueChanged<int> onSelected;
class SortFilterMenu<T> extends StatelessWidget {
final List<SortFilterMenuSection<T>> sections;
final ValueChanged<T> onSelected;
final String tooltip;
final Widget icon;
@@ -40,7 +37,7 @@ class SortFilterMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
return PopupMenuButton<int>(
return PopupMenuButton<T>(
icon: icon,
tooltip: tooltip,
onSelected: onSelected,
@@ -53,11 +50,11 @@ class SortFilterMenu extends StatelessWidget {
final visibleSections = sections
.where((section) => section.options.isNotEmpty)
.toList();
final entries = <PopupMenuEntry<int>>[];
final entries = <PopupMenuEntry<T>>[];
for (int i = 0; i < visibleSections.length; i++) {
final section = visibleSections[i];
entries.add(
PopupMenuItem<int>(
PopupMenuItem<T>(
enabled: false,
child: Text(section.title, style: labelStyle),
),
@@ -65,14 +62,14 @@ class SortFilterMenu extends StatelessWidget {
for (final option in section.options) {
if (option.checked == null) {
entries.add(
PopupMenuItem<int>(
PopupMenuItem<T>(
value: option.value,
child: Text(option.label),
),
);
} else {
entries.add(
CheckedPopupMenuItem<int>(
CheckedPopupMenuItem<T>(
value: option.value,
checked: option.checked ?? false,
child: Text(option.label),
@@ -90,148 +87,199 @@ class SortFilterMenu extends StatelessWidget {
}
}
const int _actionSortRecentMessages = 1;
const int _actionSortName = 2;
const int _actionSortLastSeen = 3;
const int _actionFilterAll = 4;
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 = 11;
sealed class _ContactsFilterAction {
const _ContactsFilterAction();
}
class _SortAction extends _ContactsFilterAction {
final ContactSortOption option;
const _SortAction(this.option);
}
class _TypeFilterAction extends _ContactsFilterAction {
final ContactTypeFilter filter;
const _TypeFilterAction(this.filter);
}
class _ToggleUnreadAction extends _ContactsFilterAction {
const _ToggleUnreadAction();
}
class ContactsFilterMenu extends StatelessWidget {
final ContactSortOption sortOption;
final ContactTypeFilter typeFilter;
final bool showUnreadOnly;
final bool prioritizeUsers;
final ValueChanged<ContactSortOption> onSortChanged;
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
final ValueChanged<bool> onUnreadOnlyChanged;
final ValueChanged<bool> onPrioritizeUsersChanged;
final VoidCallback onNewGroup;
const ContactsFilterMenu({
super.key,
required this.sortOption,
required this.typeFilter,
required this.showUnreadOnly,
required this.prioritizeUsers,
required this.onSortChanged,
required this.onTypeFilterChanged,
required this.onUnreadOnlyChanged,
required this.onPrioritizeUsersChanged,
required this.onNewGroup,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return SortFilterMenu(
return SortFilterMenu<_ContactsFilterAction>(
tooltip: l10n.listFilter_tooltip,
sections: [
SortFilterMenuSection(
title: l10n.listFilter_sortBy,
options: [
SortFilterMenuOption(
value: _actionSortRecentMessages,
value: _SortAction(ContactSortOption.recentMessages),
label: l10n.listFilter_latestMessages,
checked: sortOption == ContactSortOption.recentMessages,
),
SortFilterMenuOption(
value: _actionSortLastSeen,
value: _SortAction(ContactSortOption.lastSeen),
label: l10n.listFilter_heardRecently,
checked: sortOption == ContactSortOption.lastSeen,
),
SortFilterMenuOption(
value: _actionSortName,
value: _SortAction(ContactSortOption.name),
label: l10n.listFilter_az,
checked: sortOption == ContactSortOption.name,
),
SortFilterMenuOption(
value: _actionTogglePrioritizeUsers,
label: l10n.listFilter_usersFirst,
checked: prioritizeUsers,
),
],
),
SortFilterMenuSection(
title: l10n.listFilter_filters,
options: [
SortFilterMenuOption(
value: _actionFilterAll,
value: _TypeFilterAction(ContactTypeFilter.all),
label: l10n.listFilter_all,
checked: typeFilter == ContactTypeFilter.all,
),
SortFilterMenuOption(
value: _actionFilterFavorites,
value: _TypeFilterAction(ContactTypeFilter.favorites),
label: l10n.listFilter_favorites,
checked: typeFilter == ContactTypeFilter.favorites,
),
SortFilterMenuOption(
value: _actionFilterUsers,
value: _TypeFilterAction(ContactTypeFilter.users),
label: l10n.listFilter_users,
checked: typeFilter == ContactTypeFilter.users,
),
SortFilterMenuOption(
value: _actionFilterRepeaters,
value: _TypeFilterAction(ContactTypeFilter.repeaters),
label: l10n.listFilter_repeaters,
checked: typeFilter == ContactTypeFilter.repeaters,
),
SortFilterMenuOption(
value: _actionFilterRooms,
value: _TypeFilterAction(ContactTypeFilter.rooms),
label: l10n.listFilter_roomServers,
checked: typeFilter == ContactTypeFilter.rooms,
),
SortFilterMenuOption(
value: _actionToggleUnreadOnly,
value: const _ToggleUnreadAction(),
label: l10n.listFilter_unreadOnly,
checked: showUnreadOnly,
),
SortFilterMenuOption(
value: _actionNewGroup,
label: l10n.listFilter_newGroup,
),
],
),
],
onSelected: (action) {
switch (action) {
case _actionSortRecentMessages:
onSortChanged(ContactSortOption.recentMessages);
break;
case _actionSortName:
onSortChanged(ContactSortOption.name);
break;
case _actionSortLastSeen:
onSortChanged(ContactSortOption.lastSeen);
break;
case _actionTogglePrioritizeUsers:
onPrioritizeUsersChanged(!prioritizeUsers);
break;
case _actionFilterAll:
onTypeFilterChanged(ContactTypeFilter.all);
break;
case _actionFilterUsers:
onTypeFilterChanged(ContactTypeFilter.users);
break;
case _actionFilterFavorites:
onTypeFilterChanged(ContactTypeFilter.favorites);
break;
case _actionFilterRepeaters:
onTypeFilterChanged(ContactTypeFilter.repeaters);
break;
case _actionFilterRooms:
onTypeFilterChanged(ContactTypeFilter.rooms);
break;
case _actionToggleUnreadOnly:
case _SortAction(:final option):
onSortChanged(option);
case _TypeFilterAction(:final filter):
onTypeFilterChanged(filter);
case _ToggleUnreadAction():
onUnreadOnlyChanged(!showUnreadOnly);
break;
case _actionNewGroup:
onNewGroup();
break;
}
},
);
}
}
sealed class _DiscoveryFilterAction {
const _DiscoveryFilterAction();
}
class _DiscoverySortAction extends _DiscoveryFilterAction {
final ContactSortOption option;
const _DiscoverySortAction(this.option);
}
class _DiscoveryTypeFilterAction extends _DiscoveryFilterAction {
final ContactTypeFilter filter;
const _DiscoveryTypeFilterAction(this.filter);
}
class DiscoveryContactsFilterMenu extends StatelessWidget {
final ContactSortOption sortOption;
final ContactTypeFilter typeFilter;
final ValueChanged<ContactSortOption> onSortChanged;
final ValueChanged<ContactTypeFilter> onTypeFilterChanged;
const DiscoveryContactsFilterMenu({
super.key,
required this.sortOption,
required this.typeFilter,
required this.onSortChanged,
required this.onTypeFilterChanged,
});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
return SortFilterMenu<_DiscoveryFilterAction>(
tooltip: l10n.listFilter_tooltip,
sections: [
SortFilterMenuSection(
title: l10n.listFilter_sortBy,
options: [
SortFilterMenuOption(
value: _DiscoverySortAction(ContactSortOption.lastSeen),
label: l10n.listFilter_heardRecently,
checked: sortOption == ContactSortOption.lastSeen,
),
SortFilterMenuOption(
value: _DiscoverySortAction(ContactSortOption.name),
label: l10n.listFilter_az,
checked: sortOption == ContactSortOption.name,
),
],
),
SortFilterMenuSection(
title: l10n.listFilter_filters,
options: [
SortFilterMenuOption(
value: _DiscoveryTypeFilterAction(ContactTypeFilter.all),
label: l10n.listFilter_all,
checked: typeFilter == ContactTypeFilter.all,
),
SortFilterMenuOption(
value: _DiscoveryTypeFilterAction(ContactTypeFilter.users),
label: l10n.listFilter_users,
checked: typeFilter == ContactTypeFilter.users,
),
SortFilterMenuOption(
value: _DiscoveryTypeFilterAction(ContactTypeFilter.repeaters),
label: l10n.listFilter_repeaters,
checked: typeFilter == ContactTypeFilter.repeaters,
),
SortFilterMenuOption(
value: _DiscoveryTypeFilterAction(ContactTypeFilter.rooms),
label: l10n.listFilter_roomServers,
checked: typeFilter == ContactTypeFilter.rooms,
),
],
),
],
onSelected: (action) {
switch (action) {
case _DiscoverySortAction(:final option):
onSortChanged(option);
case _DiscoveryTypeFilterAction(:final filter):
onTypeFilterChanged(filter);
}
},
);
+204
View File
@@ -0,0 +1,204 @@
import 'package:flutter/material.dart';
import '../l10n/l10n.dart';
import '../models/translation_support.dart';
class MessageTranslationButton extends StatelessWidget {
final bool enabled;
final String? languageCode;
final VoidCallback onPressed;
const MessageTranslationButton({
super.key,
required this.enabled,
required this.languageCode,
required this.onPressed,
});
@override
Widget build(BuildContext context) {
final label = _languageLabel(
languageCode,
context.l10n.translation_systemLanguage,
);
return IconButton(
icon: Icon(enabled ? Icons.translate : Icons.translate_outlined),
onPressed: onPressed,
tooltip: enabled
? context.l10n.translation_translateTo(label)
: context.l10n.translation_translationOptions,
);
}
}
Future<void> showMessageTranslationSheet({
required BuildContext context,
required bool enabled,
required String? selectedLanguageCode,
required ValueChanged<bool> onEnabledChanged,
required ValueChanged<String?> onLanguageSelected,
}) {
return showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
builder: (context) => _MessageTranslationSheet(
enabled: enabled,
selectedLanguageCode: selectedLanguageCode,
onEnabledChanged: onEnabledChanged,
onLanguageSelected: onLanguageSelected,
),
);
}
class _MessageTranslationSheet extends StatefulWidget {
final bool enabled;
final String? selectedLanguageCode;
final ValueChanged<bool> onEnabledChanged;
final ValueChanged<String?> onLanguageSelected;
const _MessageTranslationSheet({
required this.enabled,
required this.selectedLanguageCode,
required this.onEnabledChanged,
required this.onLanguageSelected,
});
@override
State<_MessageTranslationSheet> createState() =>
_MessageTranslationSheetState();
}
class _MessageTranslationSheetState extends State<_MessageTranslationSheet> {
late final TextEditingController _searchController;
late bool _localEnabled;
late String? _localSelectedLanguageCode;
List<TranslationLanguageOption> _filtered = supportedTranslationLanguages;
@override
void initState() {
super.initState();
_searchController = TextEditingController();
_localEnabled = widget.enabled;
_localSelectedLanguageCode = widget.selectedLanguageCode;
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _updateFilter(String query) {
final normalized = query.trim().toLowerCase();
setState(() {
_filtered = supportedTranslationLanguages.where((option) {
return option.label.toLowerCase().contains(normalized) ||
option.code.toLowerCase().contains(normalized);
}).toList();
});
}
@override
Widget build(BuildContext context) {
return SafeArea(
child: Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: 16 + MediaQuery.of(context).viewInsets.bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
context.l10n.translation_messageTranslation,
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(context.l10n.translation_translateBeforeSending),
subtitle: Text(
_localEnabled
? context.l10n.translation_composerEnabledHint
: context.l10n.translation_composerDisabledHint,
),
value: _localEnabled,
onChanged: (value) {
setState(() => _localEnabled = value);
widget.onEnabledChanged(value);
},
),
const SizedBox(height: 8),
TextField(
controller: _searchController,
onChanged: _updateFilter,
decoration: InputDecoration(
labelText: context.l10n.translation_targetLanguage,
prefixIcon: const Icon(Icons.search),
border: const OutlineInputBorder(),
),
),
const SizedBox(height: 12),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: _filtered.length + 1,
itemBuilder: (context, index) {
if (index == 0) {
final selected = _localSelectedLanguageCode == null;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
selected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: Text(context.l10n.translation_useAppLanguage),
onTap: () {
setState(() => _localSelectedLanguageCode = null);
widget.onLanguageSelected(null);
Navigator.pop(context);
},
);
}
final option = _filtered[index - 1];
final selected = option.code == _localSelectedLanguageCode;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(
selected
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
),
title: Text(option.label),
subtitle: Text(option.code.toUpperCase()),
onTap: () {
setState(() => _localSelectedLanguageCode = option.code);
widget.onLanguageSelected(option.code);
Navigator.pop(context);
},
);
},
),
),
],
),
),
);
}
}
String _languageLabel(String? languageCode, String systemLanguageFallback) {
if (languageCode == null) {
return systemLanguageFallback;
}
for (final option in supportedTranslationLanguages) {
if (option.code == languageCode) {
return option.label;
}
}
return languageCode.toUpperCase();
}
+108 -45
View File
@@ -9,7 +9,9 @@ import 'package:provider/provider.dart';
import '../connector/meshcore_connector.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../helpers/path_helper.dart';
import '../services/path_history_service.dart';
import '../helpers/snack_bar_builder.dart';
import 'path_selection_dialog.dart';
class PathManagementDialog {
@@ -33,14 +35,26 @@ class _PathManagementDialog extends StatefulWidget {
class _PathManagementDialogState extends State<_PathManagementDialog> {
bool _showAllPaths = false;
int _resolveContactIndex = -1;
Contact _resolveContact(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveContactIndex >= 0 &&
_resolveContactIndex < connector.contacts.length &&
connector.contacts[_resolveContactIndex].publicKeyHex ==
widget.contact.publicKeyHex) {
return connector.contacts[_resolveContactIndex];
}
_resolveContactIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.contact.publicKeyHex,
orElse: () => widget.contact,
);
if (_resolveContactIndex == -1) {
return widget.contact;
}
return connector.contacts[_resolveContactIndex];
}
String _formatRelativeTime(BuildContext context, DateTime time) {
String _formatRelativeTime(BuildContext context, DateTime? time) {
if (time == null) return '';
final l10n = context.l10n;
final diff = DateTime.now().difference(time);
if (diff.inSeconds < 60) return l10n.time_justNow;
@@ -52,24 +66,39 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
void _showFullPathDialog(BuildContext context, List<int> pathBytes) {
final l10n = context.l10n;
if (pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2),
),
showDismissibleSnackBar(
context,
content: Text(l10n.chat_pathDetailsNotAvailable),
duration: const Duration(seconds: 2),
);
return;
}
final formattedPath = pathBytes
.map((b) => b.toRadixString(16).padLeft(2, '0').toUpperCase())
.join(',');
final connector = context.read<MeshCoreConnector>();
final allContacts = connector.allContacts;
final formattedPath = PathHelper.formatPathHex(pathBytes);
final resolvedNames = PathHelper.resolvePathNames(pathBytes, allContacts);
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(l10n.chat_fullPath),
content: SelectableText(formattedPath),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(formattedPath),
const SizedBox(height: 8),
SelectableText(
resolvedNames,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.onSurfaceVariant,
),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.push(
@@ -78,7 +107,9 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
builder: (context) => PathTraceMapScreen(
title: context.l10n.contacts_repeaterPathTrace,
path: Uint8List.fromList(pathBytes),
flipPathRound: true,
flipPathAround: true,
targetContact: widget.contact,
pathHashByteWidth: connector.pathHashByteWidth,
),
),
),
@@ -105,8 +136,10 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
connector.getContacts();
}
final pathForInput = currentContact.pathIdList;
final availableContacts = connector.contacts
final pathForInput = currentContact.pathFormattedIdList(
connector.pathHashByteWidth,
);
final availableContacts = connector.allContacts
.where((c) => c.publicKeyHex != currentContact.publicKeyHex)
.toList();
@@ -126,11 +159,10 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.chat_hopsCount(result.length)),
duration: const Duration(seconds: 2),
),
showDismissibleSnackBar(
context,
content: Text(l10n.chat_hopsCount(result.length)),
duration: const Duration(seconds: 2),
);
}
}
@@ -261,16 +293,17 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
radius: 16,
backgroundColor: color,
child: Text(
'${path.hopCount}',
style: const TextStyle(fontSize: 12),
path.routeWeight.toStringAsFixed(1),
style: const TextStyle(fontSize: 10),
),
),
title: Text(
l10n.chat_hopsCount(path.hopCount),
style: const TextStyle(fontSize: 14),
),
isThreeLine: true,
subtitle: Text(
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}${path.successCount} ${l10n.chat_successes}',
'${(path.tripTimeMs / 1000).toStringAsFixed(2)}s • ${_formatRelativeTime(context, path.timestamp)}\n${path.successCount} ${l10n.chat_successes} • Score: ${path.routeWeight.toStringAsFixed(1)}',
style: const TextStyle(fontSize: 11),
),
trailing: Row(
@@ -303,13 +336,12 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
_showFullPathDialog(context, path.pathBytes),
onTap: () async {
if (path.pathBytes.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2),
showDismissibleSnackBar(
context,
content: Text(
l10n.chat_pathDetailsNotAvailable,
),
duration: const Duration(seconds: 2),
);
return;
}
@@ -327,13 +359,12 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
if (!context.mounted) return;
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.path_usingHopsPath(path.hopCount),
),
duration: const Duration(seconds: 2),
showDismissibleSnackBar(
context,
content: Text(
l10n.path_usingHopsPath(path.hopCount),
),
duration: const Duration(seconds: 2),
);
},
),
@@ -345,6 +376,40 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
Text(l10n.chat_noPathHistoryYet),
const Divider(),
],
// Flood delivery stats
Builder(
builder: (context) {
final floodStats = pathService.getFloodStats(
currentContact.publicKeyHex,
);
if (floodStats == null ||
(floodStats.successCount == 0 &&
floodStats.failureCount == 0)) {
return const SizedBox.shrink();
}
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
dense: true,
leading: const CircleAvatar(
radius: 16,
backgroundColor: Colors.blue,
child: Icon(Icons.waves, size: 16),
),
title: const Text(
'Flood Mode',
style: TextStyle(fontSize: 14),
),
subtitle: Text(
'${floodStats.successCount} ${l10n.chat_successes} / ${floodStats.failureCount} failures'
'${floodStats.lastTripTimeMs > 0 ? '${(floodStats.lastTripTimeMs / 1000).toStringAsFixed(2)}s' : ''}'
'${floodStats.lastUsed != null ? '${_formatRelativeTime(context, floodStats.lastUsed!)}' : ''}',
style: const TextStyle(fontSize: 11),
),
),
);
},
),
const SizedBox(height: 8),
Text(
l10n.chat_pathActions,
@@ -391,11 +456,10 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
onTap: () async {
await connector.clearContactPath(currentContact);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.chat_pathCleared),
duration: const Duration(seconds: 2),
),
showDismissibleSnackBar(
context,
content: Text(l10n.chat_pathCleared),
duration: const Duration(seconds: 2),
);
Navigator.pop(context);
},
@@ -421,11 +485,10 @@ class _PathManagementDialogState extends State<_PathManagementDialog> {
pathLen: -1,
);
if (!context.mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.chat_floodModeEnabled),
duration: const Duration(seconds: 2),
),
showDismissibleSnackBar(
context,
content: Text(l10n.chat_floodModeEnabled),
duration: const Duration(seconds: 2),
);
Navigator.pop(context);
},
+13 -15
View File
@@ -1,7 +1,9 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import '../helpers/snack_bar_builder.dart';
class PathSelectionDialog extends StatefulWidget {
final List<Contact> availableContacts;
@@ -65,7 +67,7 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
void _filterValidContacts() {
_validContacts = widget.availableContacts
.where((c) => c.type == 2 || c.type == 3)
.where((c) => c.type == advTypeRepeater || c.type == advTypeRoom)
.toList();
}
@@ -137,26 +139,22 @@ class _PathSelectionDialogState extends State<PathSelectionDialog> {
// Show error for invalid prefixes
if (invalidPrefixes.isNotEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
l10n.path_invalidHexPrefixes(invalidPrefixes.join(", ")),
),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
),
showDismissibleSnackBar(
context,
content: Text(l10n.path_invalidHexPrefixes(invalidPrefixes.join(", "))),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
);
return;
}
// Check max path length (64 hops)
if (pathBytesList.length > 64) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.path_tooLong),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
),
showDismissibleSnackBar(
context,
content: Text(l10n.path_tooLong),
duration: const Duration(seconds: 3),
backgroundColor: Colors.red,
);
return;
}
+147
View File
@@ -0,0 +1,147 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:meshcore_open/connector/meshcore_connector.dart';
import 'package:meshcore_open/models/companion_radio_stats.dart';
import 'package:meshcore_open/l10n/l10n.dart';
import 'package:meshcore_open/screens/companion_radio_stats_screen.dart';
import 'package:provider/provider.dart';
void pushCompanionRadioStatsScreen(BuildContext context) {
Navigator.push(
context,
MaterialPageRoute<void>(
builder: (context) => const CompanionRadioStatsScreen(),
),
);
}
class RadioStatsIconButton extends StatefulWidget {
final bool compact;
const RadioStatsIconButton({super.key, this.compact = false});
@override
State<RadioStatsIconButton> createState() => _RadioStatsIconButtonState();
}
class _RadioStatsIconButtonState extends State<RadioStatsIconButton> {
MeshCoreConnector? _connector;
@override
void initState() {
super.initState();
final c = context.read<MeshCoreConnector>();
_connector = c;
c.acquireRadioStatsPolling();
}
@override
void dispose() {
_connector?.releaseRadioStatsPolling();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Selector<MeshCoreConnector, ({bool connected, bool supported})>(
selector: (_, c) =>
(connected: c.isConnected, supported: c.supportsCompanionRadioStats),
builder: (context, state, _) {
if (!state.connected || !state.supported) {
return const SizedBox.shrink();
}
final connector = context.read<MeshCoreConnector>();
return ValueListenableBuilder<CompanionRadioStats?>(
valueListenable: connector.radioStatsNotifier,
builder: (context, _, child) {
final dot = AirActivityDot(
active: connector.radioStatsAirActivityPulse,
);
if (widget.compact) {
return GestureDetector(
onTap: () => pushCompanionRadioStatsScreen(context),
child: Padding(
padding: const EdgeInsets.only(left: 4),
child: dot,
),
);
}
return Tooltip(
message: context.l10n.radioStats_tooltip,
child: InkWell(
customBorder: const CircleBorder(),
onTap: () => pushCompanionRadioStatsScreen(context),
child: SizedBox(
width: 48,
height: 48,
child: Center(child: dot),
),
),
);
},
);
},
);
}
}
class AirActivityDot extends StatefulWidget {
final bool active;
const AirActivityDot({super.key, required this.active});
@override
State<AirActivityDot> createState() => AirActivityDotState();
}
class AirActivityDotState extends State<AirActivityDot> {
Timer? _timer;
bool _blink = true;
@override
void initState() {
super.initState();
if (widget.active) _startTimer();
}
@override
void didUpdateWidget(covariant AirActivityDot oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.active && !oldWidget.active) {
_startTimer();
} else if (!widget.active && oldWidget.active) {
_stopTimer();
_blink = true;
}
}
void _startTimer() {
_timer ??= Timer.periodic(const Duration(milliseconds: 400), (_) {
if (!mounted) return;
setState(() => _blink = !_blink);
});
}
void _stopTimer() {
_timer?.cancel();
_timer = null;
}
@override
void dispose() {
_stopTimer();
super.dispose();
}
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final on = widget.active && _blink;
return Icon(
Icons.circle,
size: 12,
color: on ? scheme.primary : scheme.outline,
);
}
}
+49 -11
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import '../utils/platform_info.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
@@ -13,7 +14,7 @@ import 'path_management_dialog.dart';
class RepeaterLoginDialog extends StatefulWidget {
final Contact repeater;
final Function(String password) onLogin;
final Function(String password, bool isAdmin) onLogin;
const RepeaterLoginDialog({
super.key,
@@ -68,11 +69,21 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
bool _isLoggingIn = false;
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.repeater.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.repeater.publicKeyHex,
orElse: () => widget.repeater,
);
if (_resolveRepeaterIndex == -1) {
return widget.repeater;
}
return connector.contacts[_resolveRepeaterIndex];
}
Future<void> _handleLogin() async {
@@ -102,12 +113,13 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
messageBytes: responseBytes,
);
final timeoutSeconds = (timeoutMs / 1000).ceil();
final timeout = Duration(milliseconds: timeoutMs);
final timeout = Duration(milliseconds: timeoutMs + 2000);
final selectionLabel = selection.useFlood
? 'flood'
: '${selection.hopCount} hops';
appLogger.info('Login routing: $selectionLabel', tag: 'RepeaterLogin');
bool? loginResult;
bool isAdmin = false;
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return;
setState(() {
@@ -120,7 +132,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
);
await _connector.sendFrame(loginFrame);
loginResult = await _awaitLoginResponse(timeout);
(loginResult, isAdmin) = await _awaitLoginResponse(timeout);
if (loginResult == true) {
appLogger.info(
'Login succeeded for ${repeater.name}',
@@ -176,9 +188,32 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
await _storage.removeRepeaterPassword(widget.repeater.publicKeyHex);
}
final autoClockSync = await _storage
.getRepeaterAutoClockSyncAfterLoginEnabled(
widget.repeater.publicKeyHex,
);
if (autoClockSync) {
try {
final timestampSeconds =
DateTime.now().millisecondsSinceEpoch ~/ 1000;
await _connector.sendFrame(
buildSendCliCommandFrame(
repeater.publicKey,
'clock sync',
timestampSeconds: timestampSeconds,
),
);
} catch (e) {
appLogger.warn(
'Auto clock sync failed for ${repeater.name}: $e',
tag: 'RepeaterLogin',
);
}
}
if (mounted) {
Navigator.pop(context, password);
Future.microtask(() => widget.onLogin(password));
Future.microtask(() => widget.onLogin(password, isAdmin));
}
} catch (e) {
final repeater = _resolveRepeater(_connector);
@@ -195,17 +230,21 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
}
}
Future<bool?> _awaitLoginResponse(Duration timeout) async {
// _awaitLoginResponse returns a record of bool, for success and if the client is an admin
Future<(bool?, bool)> _awaitLoginResponse(Duration timeout) async {
final completer = Completer<bool?>();
Timer? timer;
StreamSubscription<Uint8List>? subscription;
final targetPrefix = widget.repeater.publicKey.sublist(0, 6);
bool isAdmin = false;
subscription = _connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
final code = frame[0];
if (code != pushCodeLoginSuccess && code != pushCodeLoginFail) return;
if (frame.length < 8) return;
// NOTE: a bug in the repeater firmware only ever sends 1 or 0 back, not the
// expected client permissions
isAdmin = (frame[1] == 1);
final prefix = frame.sublist(2, 8);
if (!listEquals(prefix, targetPrefix)) return;
@@ -224,7 +263,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
final result = await completer.future;
timer.cancel();
await subscription.cancel();
return result;
return (result, isAdmin);
}
@override
@@ -326,8 +365,7 @@ class _RepeaterLoginDialogState extends State<RepeaterLoginDialog> {
},
onSubmitted: (_) => _handleLogin(),
autofocus:
!(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS) &&
!PlatformInfo.isMobile &&
_passwordController.text.isEmpty,
),
const SizedBox(height: 12),
+31 -15
View File
@@ -1,4 +1,5 @@
import 'dart:async';
import '../utils/platform_info.dart';
import 'package:flutter/material.dart';
import 'package:flutter/foundation.dart';
@@ -9,11 +10,12 @@ import '../services/storage_service.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../utils/app_logger.dart';
import '../helpers/snack_bar_builder.dart';
import 'path_management_dialog.dart';
class RoomLoginDialog extends StatefulWidget {
final Contact room;
final Function(String password) onLogin;
final Function(String password, bool isAdmin) onLogin;
const RoomLoginDialog({super.key, required this.room, required this.onLogin});
@@ -63,11 +65,22 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
bool _isLoggingIn = false;
int _resolveRepeaterIndex = -1;
Contact _resolveRepeater(MeshCoreConnector connector) {
return connector.contacts.firstWhere(
if (_resolveRepeaterIndex >= 0 &&
_resolveRepeaterIndex < connector.contacts.length &&
connector.contacts[_resolveRepeaterIndex].publicKeyHex ==
widget.room.publicKeyHex) {
return connector.contacts[_resolveRepeaterIndex];
}
_resolveRepeaterIndex = connector.contacts.indexWhere(
(c) => c.publicKeyHex == widget.room.publicKeyHex,
orElse: () => widget.room,
);
if (_resolveRepeaterIndex == -1) {
return widget.room;
}
return connector.contacts[_resolveRepeaterIndex];
}
Future<void> _handleLogin() async {
@@ -96,12 +109,13 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
messageBytes: responseBytes,
);
final timeoutSeconds = (timeoutMs / 1000).ceil();
final timeout = Duration(milliseconds: timeoutMs);
final timeout = Duration(milliseconds: timeoutMs + 2000);
final selectionLabel = selection.useFlood
? 'flood'
: '${selection.hopCount} hops';
appLogger.info('Login routing: $selectionLabel', tag: 'RoomLogin');
bool? loginResult;
bool isAdmin = false;
for (int attempt = 0; attempt < _maxAttempts; attempt++) {
if (!mounted) return;
setState(() {
@@ -114,7 +128,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
);
await _connector.sendFrame(loginFrame);
loginResult = await _awaitLoginResponse(timeout);
(loginResult, isAdmin) = await _awaitLoginResponse(timeout);
if (loginResult == true) {
appLogger.info('Login succeeded for ${room.name}', tag: 'RoomLogin');
break;
@@ -154,7 +168,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
if (mounted) {
Navigator.pop(context, password);
Future.microtask(() => widget.onLogin(password));
Future.microtask(() => widget.onLogin(password, isAdmin));
}
} catch (e) {
final room = _resolveRepeater(_connector);
@@ -163,26 +177,29 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
setState(() {
_isLoggingIn = false;
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(context.l10n.login_failed(e.toString())),
backgroundColor: Colors.red,
),
showDismissibleSnackBar(
context,
content: Text(context.l10n.login_failed(e.toString())),
backgroundColor: Colors.red,
);
}
}
}
Future<bool?> _awaitLoginResponse(Duration timeout) async {
Future<(bool?, bool)> _awaitLoginResponse(Duration timeout) async {
final completer = Completer<bool?>();
Timer? timer;
StreamSubscription<Uint8List>? subscription;
final targetPrefix = widget.room.publicKey.sublist(0, 6);
bool isAdmin = false;
subscription = _connector.receivedFrames.listen((frame) {
if (frame.isEmpty) return;
final code = frame[0];
if (code != pushCodeLoginSuccess && code != pushCodeLoginFail) return;
// NOTE: a bug in the repeater firmware only ever sends 1 or 0 back, not the
// expected client permissions
isAdmin = (frame[1] == 1);
if (frame.length < 8) return;
final prefix = frame.sublist(2, 8);
if (!listEquals(prefix, targetPrefix)) return;
@@ -202,7 +219,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
final result = await completer.future;
timer.cancel();
await subscription.cancel();
return result;
return (result, isAdmin);
}
@override
@@ -274,8 +291,7 @@ class _RoomLoginDialogState extends State<RoomLoginDialog> {
),
onSubmitted: (_) => _handleLogin(),
autofocus:
!(defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS) &&
!PlatformInfo.isMobile &&
_passwordController.text.isEmpty,
),
const SizedBox(height: 12),
+38
View File
@@ -0,0 +1,38 @@
import 'package:flutter/material.dart';
class SignalUi {
final IconData icon;
final Color color;
const SignalUi({required this.icon, required this.color});
}
SignalUi signalUiForStrengthTier(int tier) {
switch (tier) {
case 0:
return const SignalUi(
icon: Icons.signal_cellular_4_bar,
color: Colors.green,
);
case 1:
return const SignalUi(
icon: Icons.signal_cellular_alt,
color: Colors.lightGreen,
);
case 2:
return const SignalUi(
icon: Icons.signal_cellular_alt_2_bar,
color: Colors.amber,
);
case 3:
return const SignalUi(
icon: Icons.signal_cellular_alt_1_bar,
color: Colors.orange,
);
default:
return const SignalUi(
icon: Icons.signal_cellular_alt_1_bar,
color: Colors.red,
);
}
}
+126 -56
View File
@@ -1,6 +1,63 @@
import 'package:flutter/material.dart';
import 'package:latlong2/latlong.dart';
import '../connector/meshcore_connector.dart';
import '../connector/meshcore_protocol.dart';
import '../l10n/l10n.dart';
import '../models/contact.dart';
import 'signal_ui.dart';
Contact? _getRepeaterPrefixMatchNearLocation(
List<Contact> contacts,
int pubkeyFirstByte, {
LatLng? searchPoint,
bool preferFavorites = false,
}) {
final candidates = contacts
.where(
(c) =>
c.publicKey.isNotEmpty &&
c.publicKey.first == pubkeyFirstByte &&
(c.type == advTypeRepeater || c.type == advTypeRoom),
)
.toList();
if (candidates.isEmpty) return null;
candidates.sort((a, b) {
if (preferFavorites) {
final favA = a.isFavorite ? 1 : 0;
final favB = b.isFavorite ? 1 : 0;
final favCompare = favB.compareTo(favA);
if (favCompare != 0) return favCompare;
}
final seenCompare = b.lastSeen.compareTo(a.lastSeen);
if (seenCompare != 0) return seenCompare;
return a.publicKeyHex.compareTo(b.publicKeyHex);
});
if (searchPoint == null) {
return candidates.first;
}
final distance = Distance();
Contact best = candidates.first;
var bestDistance = double.infinity;
for (final c in candidates) {
if (c.hasLocation && c.latitude != null && c.longitude != null) {
final d = distance(searchPoint, LatLng(c.latitude!, c.longitude!));
if (d < bestDistance) {
bestDistance = d;
best = c;
}
}
}
return best;
}
class SNRUi {
final IconData icon;
@@ -38,28 +95,19 @@ SNRUi snrUiFromSNR(double? snr, int? spreadingFactor) {
final snrLevels = getSNRfromSF(spreadingFactor);
IconData icon;
Color color;
String text = '${snr.toStringAsFixed(1)} dB';
final tier = snr >= snrLevels[0]
? 0
: snr >= snrLevels[1]
? 1
: snr >= snrLevels[2]
? 2
: snr >= snrLevels[3]
? 3
: 4;
final signalUi = signalUiForStrengthTier(tier);
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);
return SNRUi(signalUi.icon, signalUi.color, text);
}
class SNRIndicator extends StatefulWidget {
@@ -72,6 +120,15 @@ class SNRIndicator extends StatefulWidget {
}
class _SNRIndicatorState extends State<SNRIndicator> {
bool _isValidSelfLocation(double lat, double lon) {
const double epsilon = 1e-6;
return (lat.abs() > epsilon || lon.abs() > epsilon) &&
lat >= -90.0 &&
lat <= 90.0 &&
lon >= -180.0 &&
lon <= 180.0;
}
@override
Widget build(BuildContext context) {
final directRepeaters = widget.connector.directRepeaters;
@@ -86,40 +143,36 @@ class _SNRIndicatorState extends State<SNRIndicator> {
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)
return ConstrainedBox(
constraints: const BoxConstraints(minWidth: 40, minHeight: 40),
child: InkWell(
onTap: directRepeater != null
? () => _showFullPathDialog(context, directBestRepeaters)
: null,
borderRadius: BorderRadius.circular(8),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 8),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(snrUi.icon, size: 18, color: snrUi.color),
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,
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,
),
],
),
),
),
);
@@ -156,8 +209,10 @@ class _SNRIndicatorState extends State<SNRIndicator> {
builder: (context) => AlertDialog(
title: Text(l10n.snrIndicator_nearByRepeaters),
content: SizedBox(
width: double.maxFinite,
child: Scrollbar(
child: ListView.separated(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(vertical: 4),
itemCount: directBestRepeaters.length,
separatorBuilder: (_, _) => const Divider(height: 1),
@@ -167,11 +222,26 @@ class _SNRIndicatorState extends State<SNRIndicator> {
repeater.snr,
widget.connector.currentSf,
);
final allContacts = widget.connector.allContacts;
final name = widget.connector.contacts
.where((c) => c.publicKey.first == repeater.pubkeyFirstByte)
.map((c) => c.name)
.firstOrNull;
final selfLat = widget.connector.selfLatitude;
final selfLon = widget.connector.selfLongitude;
LatLng? selfPoint;
if (selfLat != null &&
selfLon != null &&
_isValidSelfLocation(selfLat, selfLon)) {
selfPoint = LatLng(selfLat, selfLon);
}
final contact = _getRepeaterPrefixMatchNearLocation(
allContacts,
repeater.pubkeyFirstByte,
searchPoint: selfPoint,
preferFavorites: true,
);
final name = contact?.name;
return Column(
children: [
@@ -0,0 +1,61 @@
import 'package:flutter/material.dart';
import '../helpers/link_handler.dart';
class TranslatedMessageContent extends StatelessWidget {
final String displayText;
final String? originalText;
final TextStyle style;
final TextStyle? originalStyle;
final bool showOriginalFirst;
const TranslatedMessageContent({
super.key,
required this.displayText,
required this.style,
this.originalText,
this.originalStyle,
this.showOriginalFirst = true,
});
@override
Widget build(BuildContext context) {
final trimmedDisplay = displayText.trim();
final trimmedOriginal = originalText?.trim();
final shouldShowOriginal =
trimmedOriginal != null &&
trimmedOriginal.isNotEmpty &&
trimmedOriginal != trimmedDisplay;
final originalWidget = shouldShowOriginal
? LinkHandler.buildLinkifyText(
context: context,
text: trimmedOriginal,
style:
originalStyle ??
style.copyWith(
fontStyle: FontStyle.italic,
fontSize: style.fontSize,
),
)
: null;
final translatedWidget = LinkHandler.buildLinkifyText(
context: context,
text: trimmedDisplay,
style: style,
);
if (!shouldShowOriginal) {
return translatedWidget;
}
final children = showOriginalFirst
? [originalWidget!, const SizedBox(height: 6), translatedWidget]
: [translatedWidget, const SizedBox(height: 6), originalWidget!];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: children,
);
}
}
+1 -1
View File
@@ -7,7 +7,7 @@ class UnreadBadge extends StatelessWidget {
@override
Widget build(BuildContext context) {
final display = count > 99 ? '99+' : count.toString();
final display = count > 9999 ? '9999+' : count.toString();
return Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2),
decoration: BoxDecoration(