Merge branch 'feat/290-panel-footer' into integration/290-306-335

integration/290-306-335
Strycher 23 hours ago
commit 47904eb4c3

@ -2,13 +2,32 @@ package com.meshcore.meshcore_open
import io.flutter.embedding.android.FlutterActivity
import io.flutter.embedding.engine.FlutterEngine
import io.flutter.plugin.common.MethodChannel
class MainActivity : FlutterActivity() {
private val usbFunctions by lazy { MeshcoreUsbFunctions(this) }
private val appLifecycleChannelName = "meshcore_open/app_lifecycle"
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine)
usbFunctions.configureFlutterEngine(flutterEngine)
MethodChannel(
flutterEngine.dartExecutor.binaryMessenger,
appLifecycleChannelName
).setMethodCallHandler { call, result ->
when (call.method) {
// Send the task to the background WITHOUT finishing the
// activity. SystemNavigator.pop() calls finish(), which tears
// down the Flutter engine and drops the radio connection, so
// reopening the app lands on a disconnected radio.
"moveTaskToBack" -> {
result.success(moveTaskToBack(true))
}
else -> result.notImplemented()
}
}
}
override fun onDestroy() {

@ -976,6 +976,7 @@
"map_repeaters": "Repeaters",
"map_otherNodes": "Other Nodes",
"map_showOverlaps": "Repeater Key Overlaps",
"map_alwaysShowNames": "Always show names",
"map_keyPrefix": "Key Prefix",
"map_filterByKeyPrefix": "Filter by key prefix",
"map_publicKeyPrefix": "Public key prefix",

@ -3412,6 +3412,12 @@ abstract class AppLocalizations {
/// **'Repeater Key Overlaps'**
String get map_showOverlaps;
/// No description provided for @map_alwaysShowNames.
///
/// In en, this message translates to:
/// **'Always show names'**
String get map_alwaysShowNames;
/// No description provided for @map_keyPrefix.
///
/// In en, this message translates to:

@ -1890,6 +1890,9 @@ class AppLocalizationsBg extends AppLocalizations {
@override
String get map_showOverlaps => 'Покриване на ключа на повтаряча';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Префикс на ключа';

@ -1887,6 +1887,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get map_showOverlaps => 'Überlappungen der Repeater-Taste';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Schlüsselpräfix';

@ -1854,6 +1854,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get map_showOverlaps => 'Repeater Key Overlaps';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Key Prefix';

@ -1885,6 +1885,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get map_showOverlaps => 'Superposiciones de tecla repetidora';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Prefijo de clave';

@ -1895,6 +1895,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get map_showOverlaps => 'Chevauchement de la touche répétitive';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Préfixe clé';

@ -1896,6 +1896,9 @@ class AppLocalizationsHu extends AppLocalizations {
@override
String get map_showOverlaps => 'Az ismétlő kulcsok ütköznek';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Kulcsfontosságú előtag';

@ -1888,6 +1888,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get map_showOverlaps => 'Sovrapposizioni della chiave ripetitore';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Prefisso Chiave';

@ -1811,6 +1811,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get map_showOverlaps => 'リピーターキーの重複';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => '主要なプレフィックス';

@ -1807,6 +1807,9 @@ class AppLocalizationsKo extends AppLocalizations {
@override
String get map_showOverlaps => '반복 키 중복';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => '핵심 접두사';

@ -1874,6 +1874,9 @@ class AppLocalizationsNl extends AppLocalizations {
@override
String get map_showOverlaps => 'Herhalingssleutel overlapt';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Prefix sleutel';

@ -1900,6 +1900,9 @@ class AppLocalizationsPl extends AppLocalizations {
@override
String get map_showOverlaps => 'Nakładające się klucze przekaźników';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Prefiks klucza';

@ -1885,6 +1885,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get map_showOverlaps => 'Sobreposições da Chave Repeater';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Prefixo Chave';

@ -1889,6 +1889,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get map_showOverlaps => 'Перекрытия ключа повтора';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Префикс ключа';

@ -1876,6 +1876,9 @@ class AppLocalizationsSk extends AppLocalizations {
@override
String get map_showOverlaps => 'Prekrývanie opakovača kľúča';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Päťciferné predpona';

@ -1871,6 +1871,9 @@ class AppLocalizationsSl extends AppLocalizations {
@override
String get map_showOverlaps => 'Prekrivanje ključa ponovnega predvajanja';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Predpona ključa';

@ -1864,6 +1864,9 @@ class AppLocalizationsSv extends AppLocalizations {
@override
String get map_showOverlaps => 'Repeater-nyckelöverlappningar';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Nyckelprefix';

@ -1884,6 +1884,9 @@ class AppLocalizationsUk extends AppLocalizations {
@override
String get map_showOverlaps => 'Перекриття ключів ретрансляторів';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => 'Префікс ключа';

@ -1778,6 +1778,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get map_showOverlaps => '重复键重叠';
@override
String get map_alwaysShowNames => 'Always show names';
@override
String get map_keyPrefix => '关键字前缀';

@ -112,6 +112,9 @@ class AppSettings {
final bool mapShowChatNodes;
final bool mapShowOtherNodes;
final bool mapShowOverlaps;
/// Show node names at any zoom, instead of only past the zoom threshold.
final bool mapAlwaysShowNames;
final double mapTimeFilterHours; // 0 = all time
final bool mapKeyPrefixEnabled;
final String mapKeyPrefix;
@ -188,6 +191,7 @@ class AppSettings {
this.mapShowChatNodes = true,
this.mapShowOtherNodes = true,
this.mapShowOverlaps = false,
this.mapAlwaysShowNames = false,
this.mapTimeFilterHours = 0, // Default to all time
this.mapKeyPrefixEnabled = false,
this.mapKeyPrefix = '',
@ -252,6 +256,7 @@ class AppSettings {
'map_show_chat_nodes': mapShowChatNodes,
'map_show_other_nodes': mapShowOtherNodes,
'map_show_overlaps': mapShowOverlaps,
'map_always_show_names': mapAlwaysShowNames,
'map_time_filter_hours': mapTimeFilterHours,
'map_key_prefix_enabled': mapKeyPrefixEnabled,
'map_key_prefix': mapKeyPrefix,
@ -338,6 +343,7 @@ class AppSettings {
mapShowChatNodes: json['map_show_chat_nodes'] as bool? ?? true,
mapShowOtherNodes: json['map_show_other_nodes'] as bool? ?? true,
mapShowOverlaps: json['map_show_overlaps'] as bool? ?? false,
mapAlwaysShowNames: json['map_always_show_names'] as bool? ?? false,
mapTimeFilterHours:
(json['map_time_filter_hours'] as num?)?.toDouble() ?? 0,
mapKeyPrefixEnabled: json['map_key_prefix_enabled'] as bool? ?? false,
@ -462,6 +468,7 @@ class AppSettings {
bool? mapShowChatNodes,
bool? mapShowOtherNodes,
bool? mapShowOverlaps,
bool? mapAlwaysShowNames,
double? mapTimeFilterHours,
bool? mapKeyPrefixEnabled,
String? mapKeyPrefix,
@ -510,6 +517,7 @@ class AppSettings {
mapShowChatNodes: mapShowChatNodes ?? this.mapShowChatNodes,
mapShowOtherNodes: mapShowOtherNodes ?? this.mapShowOtherNodes,
mapShowOverlaps: mapShowOverlaps ?? this.mapShowOverlaps,
mapAlwaysShowNames: mapAlwaysShowNames ?? this.mapAlwaysShowNames,
mapTimeFilterHours: mapTimeFilterHours ?? this.mapTimeFilterHours,
mapKeyPrefixEnabled: mapKeyPrefixEnabled ?? this.mapKeyPrefixEnabled,
mapKeyPrefix: mapKeyPrefix ?? this.mapKeyPrefix,

@ -29,6 +29,8 @@ import '../services/chat_text_scale_service.dart';
import '../services/translation_service.dart';
import '../utils/emoji_utils.dart';
import '../utils/route_transitions.dart';
import 'settings_screen.dart';
import '../utils/dialog_utils.dart';
import '../widgets/app_shell.dart';
import '../widgets/channel_drawer_list.dart';
import '../widgets/mention_autocomplete.dart';
@ -287,6 +289,11 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
);
}
Future<void> _disconnect(BuildContext context) async {
final connector = context.read<MeshCoreConnector>();
await showDisconnectDialog(context, connector);
}
/// Bottom-bar navigation out of an open channel.
///
/// Tapping Channels returns to the channel list. Tapping Contacts or Map
@ -351,6 +358,13 @@ class _ChannelChatScreenState extends State<ChannelChatScreen> {
currentChannelIndex: _currentChannel.index,
onChannelSelected: _switchChannel,
),
// Present here too: the footer is app-level, so it belongs on every
// screen carrying the panel, not just the primary views.
onDisconnect: () => _disconnect(context),
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
appBarBuilder: (context, pinned) => AppBar(
// Pinned: the panel is already docked, so no hamburger is needed.
// Unpinned: this is a pushed route, so Scaffold would put a back arrow

@ -97,11 +97,7 @@ class _ChannelsScreenState extends State<ChannelsScreen>
return const SizedBox.shrink();
}
final allowBack = !connector.isConnected;
return PopScope(
canPop: allowBack,
child: AppShell(
return AppShell(
selectedIndex: 1,
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
@ -109,24 +105,22 @@ class _ChannelsScreenState extends State<ChannelsScreen>
drawerContent: ChannelDrawerList(
onChannelSelected: (channel) => _openChannel(context, channel),
),
onDisconnect: () => _disconnect(context),
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
appBar: AppBar(
title: AppBarTitle(context.l10n.channels_title),
centerTitle: true,
bottom: const SyncProgressAppBarBottom(),
actions: [
// Disconnect and Settings moved to the panel footer (#290); only
// the screen-level Communities entry remains, and it already only
// appears once a community has been joined.
if (_communities.isNotEmpty)
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context),
),
if (_communities.isNotEmpty)
PopupMenuItem(
child: Row(
children: [
@ -137,21 +131,6 @@ class _ChannelsScreenState extends State<ChannelsScreen>
),
onTap: () => _showManageCommunitiesDialog(context),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
icon: const Icon(Icons.more_vert),
),
@ -342,7 +321,6 @@ class _ChannelsScreenState extends State<ChannelsScreen>
tooltip: context.l10n.channels_addChannel,
child: const Icon(Icons.add),
),
),
);
}

@ -317,15 +317,17 @@ class _ContactsScreenState extends State<ContactsScreen>
return const SizedBox.shrink();
}
final allowBack = !connector.isConnected;
return PopScope(
canPop: allowBack,
child: AppShell(
return AppShell(
selectedIndex: 0,
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
drawerContent: const ContactFilterRail(),
onDisconnect: () => _disconnect(context, connector),
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
appBar: AppBar(
title: AppBarTitle(context.l10n.contacts_title),
bottom: const SyncProgressAppBarBottom(),
@ -387,18 +389,10 @@ class _ContactsScreenState extends State<ContactsScreen>
],
icon: const Icon(Icons.connect_without_contact),
),
// Disconnect and Settings moved to the panel footer (#290).
// Discovered contacts is screen-level and stays here.
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context, connector),
),
PopupMenuItem(
child: Row(
children: [
@ -414,28 +408,12 @@ class _ContactsScreenState extends State<ContactsScreen>
),
),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
icon: const Icon(Icons.more_vert),
),
],
),
body: _buildContactsBody(context, connector),
),
);
}

@ -24,6 +24,7 @@ import '../services/map_tile_cache_service.dart';
import '../utils/contact_search.dart';
import '../utils/route_transitions.dart';
import '../widgets/app_shell.dart';
import '../widgets/map_layer_panel.dart';
import '../widgets/sync_progress_overlay.dart';
import '../icons/los_icon.dart';
import 'channels_screen.dart';
@ -398,7 +399,8 @@ class _MapScreenState extends State<MapScreen> {
// Re center map after removed markers have loaded
if (!_hasInitializedMap && _removedMarkersLoaded) {
_hasInitializedMap = true;
_showNodeLabels = initialZoom >= _labelZoomThreshold;
_showNodeLabels =
settings.mapAlwaysShowNames || initialZoom >= _labelZoomThreshold;
if (hasMapContent) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
@ -408,16 +410,17 @@ class _MapScreenState extends State<MapScreen> {
}
}
final allowBack = !connector.isConnected;
return PopScope(
canPop: allowBack,
child: AppShell(
return AppShell(
selectedIndex: 2,
onDestinationSelected: (index) =>
_handleQuickSwitch(index, context),
onDestinationSelected: (index) => _handleQuickSwitch(index, context),
contactsUnreadCount: connector.getTotalContactsUnreadCount(),
channelsUnreadCount: connector.getTotalChannelsUnreadCount(),
drawerContent: const MapLayerPanel(),
onDisconnect: () => _disconnect(context, connector),
onSettings: () => Navigator.push(
context,
MaterialPageRoute(builder: (context) => const SettingsScreen()),
),
appBar: AppBar(
title: AppBarTitle(context.l10n.map_title),
centerTitle: true,
@ -472,36 +475,6 @@ class _MapScreenState extends State<MapScreen> {
},
tooltip: context.l10n.map_lineOfSight,
),
PopupMenuButton(
itemBuilder: (context) => [
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.logout, color: Colors.red),
const SizedBox(width: 8),
Text(context.l10n.common_disconnect),
],
),
onTap: () => _disconnect(context, connector),
),
PopupMenuItem(
child: Row(
children: [
const Icon(Icons.settings),
const SizedBox(width: 8),
Text(context.l10n.settings_title),
],
),
onTap: () => Navigator.push(
context,
MaterialPageRoute(
builder: (context) => const SettingsScreen(),
),
),
),
],
icon: const Icon(Icons.more_vert),
),
],
),
body: Stack(
@ -561,7 +534,9 @@ class _MapScreenState extends State<MapScreen> {
);
},
onPositionChanged: (camera, hasGesture) {
final shouldShow = camera.zoom >= _labelZoomThreshold;
final shouldShow =
settings.mapAlwaysShowNames ||
camera.zoom >= _labelZoomThreshold;
if (shouldShow != _showNodeLabels && mounted) {
setState(() {
_showNodeLabels = shouldShow;
@ -599,12 +574,14 @@ class _MapScreenState extends State<MapScreen> {
if (!settings.mapShowOverlaps)
..._buildGuessedMarker(
guessedLocations,
showLabels: _showNodeLabels,
showLabels:
settings.mapAlwaysShowNames || _showNodeLabels,
),
..._buildMarkers(
contactsWithLocation,
settings,
showLabels: _showNodeLabels,
showLabels:
settings.mapAlwaysShowNames || _showNodeLabels,
),
...sharedMarkers.map(_buildSharedMarker),
if (connector.selfLatitude != null &&
@ -629,9 +606,7 @@ class _MapScreenState extends State<MapScreen> {
),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(
alpha: 0.3,
),
color: Colors.black.withValues(alpha: 0.3),
blurRadius: 4,
offset: const Offset(0, 2),
),
@ -683,7 +658,6 @@ class _MapScreenState extends State<MapScreen> {
tooltip: context.l10n.map_filterNodes,
child: const Icon(Icons.filter_list),
),
),
);
},
);

@ -25,6 +25,11 @@ class ScannerScreen extends StatefulWidget {
class _ScannerScreenState extends State<ScannerScreen> {
bool _changedNavigation = false;
/// Re-entrancy guard for routing into the app. Distinct from
/// [_changedNavigation], which records that we entered at least once and
/// gates the disconnect-on-dispose cleanup.
bool _routingIntoApp = false;
late final MeshCoreConnector _connector;
late final VoidCallback _connectionListener;
BluetoothAdapterState _bluetoothState = BluetoothAdapterState.unknown;
@ -40,14 +45,28 @@ class _ScannerScreenState extends State<ScannerScreen> {
if (_connector.state == MeshCoreConnectionState.disconnected) {
_changedNavigation = false;
} else if (_connector.state == MeshCoreConnectionState.connected &&
_connector.activeTransport == MeshCoreTransportType.bluetooth &&
isCurrentRoute &&
!_changedNavigation) {
!_routingIntoApp) {
// Route in whenever this screen is showing while connected, not just
// on the first connect. Otherwise landing back here with a live
// connection is a dead end: Connect does nothing, because there is
// nothing left to connect.
//
// Deliberately transport-agnostic. This was bluetooth-only, which left
// the same dead end for USB and TCP users, who cannot connect their way
// out of it either. The first-connect path for those transports is not
// affected: their own screen sits on top while connecting, so
// isCurrentRoute is false here and they still navigate themselves.
_changedNavigation = true;
_routingIntoApp = true;
if (mounted) {
Navigator.of(context).push(
Navigator.of(context)
.push(
MaterialPageRoute(builder: (context) => const ChannelsScreen()),
);
)
.then((_) {
if (mounted) _routingIntoApp = false;
});
}
}
};

@ -76,6 +76,10 @@ class AppSettingsService extends ChangeNotifier {
await updateSettings(_settings.copyWith(mapShowOverlaps: value));
}
Future<void> setMapAlwaysShowNames(bool value) async {
await updateSettings(_settings.copyWith(mapAlwaysShowNames: value));
}
Future<void> setMapTimeFilterHours(double value) async {
await updateSettings(_settings.copyWith(mapTimeFilterHours: value));
}

@ -0,0 +1,33 @@
import 'package:flutter/services.dart';
import 'platform_info.dart';
/// Sends the app to the background without tearing it down.
///
/// `SystemNavigator.pop()` is NOT this: on Android it calls `finish()` on the
/// activity, which destroys the Flutter engine and drops the radio connection,
/// so reopening the app finds itself disconnected. `moveTaskToBack` leaves the
/// activity alive and simply hands focus back to whatever was in front before.
class AppBackgrounder {
static const MethodChannel _channel = MethodChannel(
'meshcore_open/app_lifecycle',
);
/// Returns true when the app was actually backgrounded.
///
/// Android only. Desktop windows are closed by their own chrome and iOS
/// forbids programmatic backgrounding, so elsewhere this is a no-op and the
/// caller should leave the press unhandled rather than act on it.
static Future<bool> moveToBackground() async {
if (!PlatformInfo.isAndroid) return false;
try {
return await _channel.invokeMethod<bool>('moveTaskToBack') ?? false;
} on PlatformException catch (_) {
// Surface nothing to the user: the fallback is simply that back does
// nothing at the root, which is the pre-existing behaviour.
return false;
} on MissingPluginException catch (_) {
return false;
}
}
}

@ -1,7 +1,9 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../services/ui_view_state_service.dart';
import '../utils/app_backgrounder.dart';
import 'quick_switch_bar.dart';
/// Shared shell for the primary views (Contacts / Channels / Map).
@ -11,7 +13,7 @@ import 'quick_switch_bar.dart';
/// dockable pane that can be pinned open on wide ones.
///
/// The bottom bar stays a bottom bar at every width; it never becomes a rail.
class AppShell extends StatelessWidget {
class AppShell extends StatefulWidget {
static const double wideBreakpoint = 720;
static const double _drawerWidth = 300;
@ -36,6 +38,12 @@ class AppShell extends StatelessWidget {
/// List content for the active view. Null renders an empty drawer body.
final Widget? drawerContent;
/// App-level actions, pinned to the bottom of the panel. These are not
/// contextual, which is why they were duplicated in three screens' overflow
/// menus before. Screen-level actions stay in their own overflow menu.
final VoidCallback? onDisconnect;
final VoidCallback? onSettings;
const AppShell({
super.key,
required this.body,
@ -45,15 +53,63 @@ class AppShell extends StatelessWidget {
this.appBarBuilder,
this.floatingActionButton,
this.drawerContent,
this.onDisconnect,
this.onSettings,
this.contactsUnreadCount = 0,
this.channelsUnreadCount = 0,
});
@override
State<AppShell> createState() => _AppShellState();
}
class _AppShellState extends State<AppShell> {
final GlobalKey<ScaffoldState> _scaffoldKey = GlobalKey<ScaffoldState>();
/// System back, in priority order:
/// 1. an open drawer closes,
/// 2. on a detail screen, pop back to the list it came from,
/// 3. on a primary view, send the app to the background so Android
/// returns to the home screen or the previous app.
///
/// Step 3 must NOT pop, even though the route below can be popped. The
/// primary views sit on top of the scanner, so popping would dump a
/// connected user back onto the radio-connect list. Reaching the scanner is
/// what Disconnect is for, not what Back is for.
///
/// A primary view is one carrying the bottom bar; a detail screen (a channel
/// chat) has no [selectedIndex] and is genuinely pushed.
Future<void> _handleBack() async {
final scaffold = _scaffoldKey.currentState;
if (scaffold?.isDrawerOpen ?? false) {
scaffold!.closeDrawer();
return;
}
final isPrimaryView = widget.selectedIndex != null;
final navigator = Navigator.of(context);
if (!isPrimaryView && navigator.canPop()) {
navigator.pop();
return;
}
// Background, do NOT finish. SystemNavigator.pop() would call finish() on
// the activity, tearing down the Flutter engine and dropping the radio
// connection, so reopening the app would show a disconnected radio.
await AppBackgrounder.moveToBackground();
}
@override
Widget build(BuildContext context) {
return LayoutBuilder(
return PopScope(
canPop: false,
onPopInvokedWithResult: (didPop, _) {
if (didPop) return;
_handleBack();
},
child: LayoutBuilder(
builder: (context, constraints) {
final isWide = constraints.maxWidth >= wideBreakpoint;
final isWide = constraints.maxWidth >= AppShell.wideBreakpoint;
final pinned =
isWide && context.watch<UiViewStateService>().navDrawerPinned;
@ -61,32 +117,34 @@ class AppShell extends StatelessWidget {
? _buildPinned(context)
: _buildTransient(context, isWide);
},
),
);
}
/// Null on detail screens, which show the panel but no bottom bar.
Widget? _bottomBar() {
final index = selectedIndex;
final onSelected = onDestinationSelected;
final index = widget.selectedIndex;
final onSelected = widget.onDestinationSelected;
if (index == null || onSelected == null) return null;
return SafeArea(
top: false,
child: QuickSwitchBar(
selectedIndex: index,
onDestinationSelected: onSelected,
contactsUnreadCount: contactsUnreadCount,
channelsUnreadCount: channelsUnreadCount,
contactsUnreadCount: widget.contactsUnreadCount,
channelsUnreadCount: widget.channelsUnreadCount,
),
);
}
PreferredSizeWidget? _appBar(BuildContext context, bool pinned) {
return appBarBuilder?.call(context, pinned) ?? appBar;
return widget.appBarBuilder?.call(context, pinned) ?? widget.appBar;
}
/// Wide + pinned: the panel is laid out beside the body, not overlaid.
Widget _buildPinned(BuildContext context) {
return Scaffold(
key: _scaffoldKey,
appBar: _appBar(context, true),
body: SafeArea(
top: false,
@ -94,15 +152,20 @@ class AppShell extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SizedBox(
width: _drawerWidth,
child: _NavPanel(isWide: true, content: drawerContent),
width: AppShell._drawerWidth,
child: _NavPanel(
isWide: true,
content: widget.drawerContent,
onDisconnect: widget.onDisconnect,
onSettings: widget.onSettings,
),
),
const VerticalDivider(width: 1),
Expanded(child: body),
Expanded(child: widget.body),
],
),
),
floatingActionButton: floatingActionButton,
floatingActionButton: widget.floatingActionButton,
bottomNavigationBar: _bottomBar(),
);
}
@ -111,13 +174,19 @@ class AppShell extends StatelessWidget {
/// the hamburger into the app bar automatically.
Widget _buildTransient(BuildContext context, bool isWide) {
return Scaffold(
key: _scaffoldKey,
appBar: _appBar(context, false),
drawer: Drawer(
width: _drawerWidth,
child: _NavPanel(isWide: isWide, content: drawerContent),
width: AppShell._drawerWidth,
child: _NavPanel(
isWide: isWide,
content: widget.drawerContent,
onDisconnect: widget.onDisconnect,
onSettings: widget.onSettings,
),
body: body,
floatingActionButton: floatingActionButton,
),
body: widget.body,
floatingActionButton: widget.floatingActionButton,
bottomNavigationBar: _bottomBar(),
);
}
@ -128,8 +197,15 @@ class AppShell extends StatelessWidget {
class _NavPanel extends StatelessWidget {
final bool isWide;
final Widget? content;
final VoidCallback? onDisconnect;
final VoidCallback? onSettings;
const _NavPanel({required this.isWide, this.content});
const _NavPanel({
required this.isWide,
this.content,
this.onDisconnect,
this.onSettings,
});
@override
Widget build(BuildContext context) {
@ -164,6 +240,51 @@ class _NavPanel extends StatelessWidget {
),
if (isWide) const Divider(height: 1),
Expanded(child: content ?? const SizedBox.shrink()),
if (onDisconnect != null || onSettings != null) ...[
const Divider(height: 1),
_Footer(onDisconnect: onDisconnect, onSettings: onSettings),
],
],
),
);
}
}
/// App-level actions pinned to the bottom of the panel.
class _Footer extends StatelessWidget {
final VoidCallback? onDisconnect;
final VoidCallback? onSettings;
const _Footer({this.onDisconnect, this.onSettings});
@override
Widget build(BuildContext context) {
final l10n = context.l10n;
final colors = Theme.of(context).colorScheme;
return Padding(
padding: const EdgeInsets.fromLTRB(8, 6, 8, 8),
child: Row(
children: [
if (onDisconnect != null)
Expanded(
child: TextButton.icon(
// Kept visually distinct: this drops the radio connection,
// and it was a red menu entry before the move.
style: TextButton.styleFrom(foregroundColor: colors.error),
icon: const Icon(Icons.logout, size: 18),
label: Text(l10n.common_disconnect),
onPressed: onDisconnect,
),
),
if (onSettings != null)
Expanded(
child: TextButton.icon(
icon: const Icon(Icons.settings, size: 18),
label: Text(l10n.settings_title),
onPressed: onSettings,
),
),
],
),
);

@ -0,0 +1,106 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/l10n.dart';
import '../services/app_settings_service.dart';
/// Map layer toggles for the nav panel.
///
/// The Map view has no list to show, so its panel carries the layer controls
/// that otherwise live in a modal behind the floating button. Pinned on a wide
/// screen these stay visible and the map updates underneath as they are
/// flipped, instead of a dialog covering the thing being filtered.
///
/// The same settings back both surfaces, so a change here shows in the dialog
/// and vice versa.
class MapLayerPanel extends StatelessWidget {
const MapLayerPanel({super.key});
@override
Widget build(BuildContext context) {
final service = context.watch<AppSettingsService>();
final settings = service.settings;
final l10n = context.l10n;
final theme = Theme.of(context);
return ListView(
padding: EdgeInsets.zero,
children: [
_sectionHeader(theme, l10n.map_nodeTypes),
_toggle(
label: l10n.map_chatNodes,
icon: Icons.person_outline,
value: settings.mapShowChatNodes,
onChanged: service.setMapShowChatNodes,
),
_toggle(
label: l10n.map_repeaters,
icon: Icons.cell_tower,
value: settings.mapShowRepeaters,
onChanged: service.setMapShowRepeaters,
),
_toggle(
label: l10n.map_otherNodes,
icon: Icons.help_outline,
value: settings.mapShowOtherNodes,
onChanged: service.setMapShowOtherNodes,
),
const Divider(height: 1),
_toggle(
label: l10n.map_alwaysShowNames,
icon: Icons.label_outline,
value: settings.mapAlwaysShowNames,
onChanged: service.setMapAlwaysShowNames,
),
const Divider(height: 1),
_sectionHeader(theme, l10n.map_filterNodes),
_toggle(
label: l10n.map_showDiscoveryContacts,
icon: Icons.person_search,
value: settings.mapShowDiscoveryContacts,
onChanged: service.setMapShowDiscoveryContacts,
),
_toggle(
label: l10n.map_showGuessedLocations,
icon: Icons.location_searching,
value: settings.mapShowGuessedLocations,
onChanged: service.setMapShowGuessedLocations,
),
_toggle(
label: l10n.map_showOverlaps,
icon: Icons.layers,
value: settings.mapShowOverlaps,
onChanged: service.setMapShowOverlaps,
),
],
);
}
Widget _sectionHeader(ThemeData theme, String label) {
return Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
label,
style: theme.textTheme.labelMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w700,
),
),
);
}
Widget _toggle({
required String label,
required IconData icon,
required bool value,
required ValueChanged<bool> onChanged,
}) {
return SwitchListTile(
dense: true,
secondary: Icon(icon, size: 20),
title: Text(label, maxLines: 2, overflow: TextOverflow.ellipsis),
value: value,
onChanged: onChanged,
);
}
}
Loading…
Cancel
Save

Powered by TurnKey Linux.