mirror of
https://github.com/zjs81/meshcore-open.git
synced 2026-06-19 17:05:33 +10:00
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:
@@ -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(),
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user