From b838f4fdbaedf74ff4a1ec464125f69d29003500 Mon Sep 17 00:00:00 2001 From: Strycher Date: Wed, 29 Jul 2026 23:40:01 -0400 Subject: [PATCH] fix(#443): warn + guide user to exempt app from battery optimization On aggressive OEMs (Samsung One UI) a backgrounded app that is not exempt from battery optimization is slept on screen-off and drops the radio connection, with no warning. Add a persistent banner on the channels screen that appears (Android only) when the app is not exempt, explains the risk, and deep-links to the battery settings via openIgnoreBatteryOptimizationSettings(). Re-checks on resume so it clears once the user applies the change; dismissible for the session. No restricted permission: reads own status and opens the settings screen (ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS); the Play-restricted REQUEST_IGNORE_BATTERY_OPTIMIZATIONS path is deliberately avoided. Co-Authored-By: Claude Opus 4.8 --- lib/l10n/app_en.arb | 16 + lib/l10n/app_localizations.dart | 24 ++ lib/l10n/app_localizations_bg.dart | 13 + lib/l10n/app_localizations_de.dart | 13 + lib/l10n/app_localizations_en.dart | 13 + lib/l10n/app_localizations_es.dart | 13 + lib/l10n/app_localizations_fr.dart | 13 + lib/l10n/app_localizations_hu.dart | 13 + lib/l10n/app_localizations_it.dart | 13 + lib/l10n/app_localizations_ja.dart | 13 + lib/l10n/app_localizations_ko.dart | 13 + lib/l10n/app_localizations_nl.dart | 13 + lib/l10n/app_localizations_pl.dart | 13 + lib/l10n/app_localizations_pt.dart | 13 + lib/l10n/app_localizations_ru.dart | 13 + lib/l10n/app_localizations_sk.dart | 13 + lib/l10n/app_localizations_sl.dart | 13 + lib/l10n/app_localizations_sv.dart | 13 + lib/l10n/app_localizations_uk.dart | 13 + lib/l10n/app_localizations_zh.dart | 13 + lib/screens/channels_screen.dart | 335 ++++++++++--------- lib/widgets/battery_optimization_banner.dart | 138 ++++++++ 22 files changed, 581 insertions(+), 166 deletions(-) create mode 100644 lib/widgets/battery_optimization_banner.dart diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 5a740d8..1241b55 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -2667,5 +2667,21 @@ }, "@storageUnavailableBody": { "description": "Persistent banner body explaining that storage is unavailable and messages are not being saved (#385)." + }, + "batteryOptimizationTitle": "Battery optimization is on", + "@batteryOptimizationTitle": { + "description": "Title of the persistent banner warning that Android battery optimization may drop the background radio connection (#443)." + }, + "batteryOptimizationBody": "Android may sleep the app when the screen is off and drop the radio connection. Set this app's battery usage to Unrestricted for a reliable background connection.", + "@batteryOptimizationBody": { + "description": "Body of the battery-optimization warning banner (#443)." + }, + "batteryOptimizationOpenSettings": "Open battery settings", + "@batteryOptimizationOpenSettings": { + "description": "Button that opens the Android battery-optimization settings screen (#443)." + }, + "batteryOptimizationDismiss": "Dismiss", + "@batteryOptimizationDismiss": { + "description": "Tooltip for the button that hides the battery-optimization banner for this session (#443)." } } diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 08b3617..08e0b0a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -8055,6 +8055,30 @@ abstract class AppLocalizations { /// In en, this message translates to: /// **'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'** String get storageUnavailableBody; + + /// Title of the persistent banner warning that Android battery optimization may drop the background radio connection (#443). + /// + /// In en, this message translates to: + /// **'Battery optimization is on'** + String get batteryOptimizationTitle; + + /// Body of the battery-optimization warning banner (#443). + /// + /// In en, this message translates to: + /// **'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'** + String get batteryOptimizationBody; + + /// Button that opens the Android battery-optimization settings screen (#443). + /// + /// In en, this message translates to: + /// **'Open battery settings'** + String get batteryOptimizationOpenSettings; + + /// Tooltip for the button that hides the battery-optimization banner for this session (#443). + /// + /// In en, this message translates to: + /// **'Dismiss'** + String get batteryOptimizationDismiss; } class _AppLocalizationsDelegate diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index 32db1ee..d08670a 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -4713,4 +4713,17 @@ class AppLocalizationsBg extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 8f7156c..b68a11f 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -4730,4 +4730,17 @@ class AppLocalizationsDe extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index 41dac4f..e706230 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -4635,4 +4635,17 @@ class AppLocalizationsEn extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 3f9179f..46906cc 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -4718,4 +4718,17 @@ class AppLocalizationsEs extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index e0ae5ed..ee66217 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -4746,4 +4746,17 @@ class AppLocalizationsFr extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index 9b94203..36766ff 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -4735,4 +4735,17 @@ class AppLocalizationsHu extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index c9d7061..b408870 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -4723,4 +4723,17 @@ class AppLocalizationsIt extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index d73a21f..d837617 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -4489,4 +4489,17 @@ class AppLocalizationsJa extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index 0431e60..f439bab 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -4490,4 +4490,17 @@ class AppLocalizationsKo extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index dc97d1e..e48faa8 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -4698,4 +4698,17 @@ class AppLocalizationsNl extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index e848f8f..e3fe08d 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -4735,4 +4735,17 @@ class AppLocalizationsPl extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 9b2c381..d84e212 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -4711,4 +4711,17 @@ class AppLocalizationsPt extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e169fca..34d80fd 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -4729,4 +4729,17 @@ class AppLocalizationsRu extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 48acee5..e476531 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -4695,4 +4695,17 @@ class AppLocalizationsSk extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index d73d0e6..c0e6b59 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -4693,4 +4693,17 @@ class AppLocalizationsSl extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index e448b38..639bcfb 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -4668,4 +4668,17 @@ class AppLocalizationsSv extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index f24414b..3da7ced 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -4730,4 +4730,17 @@ class AppLocalizationsUk extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index aa6fa6f..ce94b12 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -4363,4 +4363,17 @@ class AppLocalizationsZh extends AppLocalizations { @override String get storageUnavailableBody => 'Your history is not lost, but the app can\'t open its database, so new messages are NOT being saved. Restart the app after fixing the problem. See the app log for details.'; + + @override + String get batteryOptimizationTitle => 'Battery optimization is on'; + + @override + String get batteryOptimizationBody => + 'Android may sleep the app when the screen is off and drop the radio connection. Set this app\'s battery usage to Unrestricted for a reliable background connection.'; + + @override + String get batteryOptimizationOpenSettings => 'Open battery settings'; + + @override + String get batteryOptimizationDismiss => 'Dismiss'; } diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 2b4584a..e3e6a29 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -27,6 +27,7 @@ import '../widgets/channel_drawer_list.dart'; import '../widgets/sync_progress_overlay.dart'; import '../widgets/unread_badge.dart'; import '../widgets/channel_notify_mode.dart'; +import '../widgets/battery_optimization_banner.dart'; import '../helpers/snack_bar_builder.dart'; import 'channel_chat_screen.dart'; import 'community_qr_scanner_screen.dart'; @@ -138,185 +139,187 @@ class _ChannelsScreenState extends State ), ], ), - body: RefreshIndicator( - onRefresh: () async { - await context.read().getChannels(force: true); - }, - child: () { - final channels = connector.channels; - final waitingForFirstChannel = - connector.isLoadingChannels && channels.isEmpty; - - // Only block the list while the first channel is actively loading. - // If the initial sync aborts, show cached/partial channels instead - // of trapping the user behind an idle spinner. - if (waitingForFirstChannel) { - return const Center(child: CircularProgressIndicator()); - } + body: BatteryOptimizationBanner( + child: RefreshIndicator( + onRefresh: () async { + await context.read().getChannels(force: true); + }, + child: () { + final channels = connector.channels; + final waitingForFirstChannel = + connector.isLoadingChannels && channels.isEmpty; + + // Only block the list while the first channel is actively loading. + // If the initial sync aborts, show cached/partial channels instead + // of trapping the user behind an idle spinner. + if (waitingForFirstChannel) { + return const Center(child: CircularProgressIndicator()); + } - if (channels.isEmpty) { - return ListView( - children: [ - SizedBox( - height: MediaQuery.of(context).size.height - 200, - child: EmptyState( - icon: Icons.tag, - title: context.l10n.channels_noChannelsConfigured, - action: FilledButton.icon( - onPressed: () => _addPublicChannel(context, connector), - icon: const Icon(Icons.public), - label: Text(context.l10n.channels_addPublicChannel), + if (channels.isEmpty) { + return ListView( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height - 200, + child: EmptyState( + icon: Icons.tag, + title: context.l10n.channels_noChannelsConfigured, + action: FilledButton.icon( + onPressed: () => _addPublicChannel(context, connector), + icon: const Icon(Icons.public), + label: Text(context.l10n.channels_addPublicChannel), + ), ), ), - ), - ], - ); - } + ], + ); + } - final filteredChannels = _filterAndSortChannels( - channels, - connector, - viewState, - ); + final filteredChannels = _filterAndSortChannels( + channels, + connector, + viewState, + ); - return Column( - children: [ - Padding( - padding: const EdgeInsets.all(8.0), - child: TextField( - controller: _searchController, - decoration: InputDecoration( - hintText: context.l10n.channels_searchChannels, - prefixIcon: const Icon(Icons.search), - suffixIcon: Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (viewState.channelsSearchText.isNotEmpty) - IconButton( - icon: const Icon(Icons.clear), - onPressed: () { - _searchDebounce?.cancel(); - _searchDebounce = null; - _searchController.clear(); - context - .read() - .setChannelsSearchText(''); - }, - ), - _buildFilterButton(viewState), - ], - ), - border: OutlineInputBorder( - borderRadius: BorderRadius.circular(12), - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 12, + return Column( + children: [ + Padding( + padding: const EdgeInsets.all(8.0), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: context.l10n.channels_searchChannels, + prefixIcon: const Icon(Icons.search), + suffixIcon: Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (viewState.channelsSearchText.isNotEmpty) + IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchDebounce?.cancel(); + _searchDebounce = null; + _searchController.clear(); + context + .read() + .setChannelsSearchText(''); + }, + ), + _buildFilterButton(viewState), + ], + ), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), ), + onChanged: (value) { + _searchDebounce?.cancel(); + _searchDebounce = Timer( + const Duration(milliseconds: 300), + () { + if (!mounted) return; + context + .read() + .setChannelsSearchText(value); + }, + ); + }, ), - onChanged: (value) { - _searchDebounce?.cancel(); - _searchDebounce = Timer( - const Duration(milliseconds: 300), - () { - if (!mounted) return; - context - .read() - .setChannelsSearchText(value); - }, - ); - }, ), - ), - Expanded( - child: filteredChannels.isEmpty - ? ListView( - children: [ - SizedBox( - height: MediaQuery.of(context).size.height - 300, - child: Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon( - Icons.search_off, - size: 64, - color: Colors.grey[400], - ), - const SizedBox(height: 16), - Text( - context.l10n.channels_noChannelsFound, - style: TextStyle( - fontSize: 16, - color: Colors.grey[600], + Expanded( + child: filteredChannels.isEmpty + ? ListView( + children: [ + SizedBox( + height: MediaQuery.of(context).size.height - 300, + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.search_off, + size: 64, + color: Colors.grey[400], + ), + const SizedBox(height: 16), + Text( + context.l10n.channels_noChannelsFound, + style: TextStyle( + fontSize: 16, + color: Colors.grey[600], + ), ), - ), - ], + ], + ), ), ), + ], + ) + : (viewState.channelsSortOption == + ChannelSortOption.manual && + viewState.channelsSearchText.isEmpty) + ? ReorderableListView.builder( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: 88, ), - ], - ) - : (viewState.channelsSortOption == - ChannelSortOption.manual && - viewState.channelsSearchText.isEmpty) - ? ReorderableListView.builder( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 8, - bottom: 88, - ), - buildDefaultDragHandles: false, - itemCount: filteredChannels.length, - onReorderItem: (oldIndex, newIndex) { - // onReorderItem already adjusts newIndex after the - // removed item, unlike the deprecated onReorder. - final reordered = List.from( - filteredChannels, - ); - final item = reordered.removeAt(oldIndex); - reordered.insert(newIndex, item); - unawaited( - connector.setChannelOrder( - reordered.map((c) => c.index).toList(), - ), - ); - }, - itemBuilder: (context, index) { - final channel = filteredChannels[index]; - return _buildChannelTile( - context, - connector, - channelMessageStore, - channel, - showDragHandle: true, - dragIndex: index, - ); - }, - ) - : ListView.builder( - padding: const EdgeInsets.only( - left: 16, - right: 16, - top: 8, - bottom: 88, + buildDefaultDragHandles: false, + itemCount: filteredChannels.length, + onReorderItem: (oldIndex, newIndex) { + // onReorderItem already adjusts newIndex after the + // removed item, unlike the deprecated onReorder. + final reordered = List.from( + filteredChannels, + ); + final item = reordered.removeAt(oldIndex); + reordered.insert(newIndex, item); + unawaited( + connector.setChannelOrder( + reordered.map((c) => c.index).toList(), + ), + ); + }, + itemBuilder: (context, index) { + final channel = filteredChannels[index]; + return _buildChannelTile( + context, + connector, + channelMessageStore, + channel, + showDragHandle: true, + dragIndex: index, + ); + }, + ) + : ListView.builder( + padding: const EdgeInsets.only( + left: 16, + right: 16, + top: 8, + bottom: 88, + ), + itemCount: filteredChannels.length, + itemBuilder: (context, index) { + final channel = filteredChannels[index]; + return _buildChannelTile( + context, + connector, + channelMessageStore, + channel, + ); + }, ), - itemCount: filteredChannels.length, - itemBuilder: (context, index) { - final channel = filteredChannels[index]; - return _buildChannelTile( - context, - connector, - channelMessageStore, - channel, - ); - }, - ), - ), - ], - ); - }(), + ), + ], + ); + }(), + ), ), floatingActionButton: FloatingActionButton( onPressed: () => _showAddChannelDialog(context), diff --git a/lib/widgets/battery_optimization_banner.dart b/lib/widgets/battery_optimization_banner.dart new file mode 100644 index 0000000..29241d7 --- /dev/null +++ b/lib/widgets/battery_optimization_banner.dart @@ -0,0 +1,138 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; + +import '../l10n/l10n.dart'; +import '../utils/platform_info.dart'; + +/// Warns the user when the app is NOT exempt from Android battery optimization +/// and guides them to the settings to fix it (#443). +/// +/// On aggressive OEMs (e.g. Samsung One UI) a backgrounded app that is not +/// exempt gets put to sleep on screen-off, dropping the radio connection. The +/// app can't force the exemption without the Play-restricted +/// `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` permission, so it only reads its own +/// status and deep-links to the settings screen — no restricted permission, +/// no Play-policy impact. +/// +/// Mirrors [StorageUnavailableBanner]'s wrap-child shape: when there is nothing +/// to warn about (non-Android, already exempt, or dismissed) this is a +/// transparent pass-through and adds no layout. +class BatteryOptimizationBanner extends StatefulWidget { + final Widget child; + + const BatteryOptimizationBanner({super.key, required this.child}); + + @override + State createState() => + _BatteryOptimizationBannerState(); +} + +class _BatteryOptimizationBannerState extends State + with WidgetsBindingObserver { + // Assume exempt until the async check says otherwise, so the banner never + // flashes on first frame. + bool _ignoring = true; + bool _dismissed = false; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addObserver(this); + _check(); + } + + @override + void dispose() { + WidgetsBinding.instance.removeObserver(this); + super.dispose(); + } + + @override + void didChangeAppLifecycleState(AppLifecycleState state) { + // Re-check on resume so the banner clears once the user sets the app to + // Unrestricted in settings. + if (state == AppLifecycleState.resumed) { + _check(); + } + } + + Future _check() async { + if (!PlatformInfo.isAndroid) return; + final ignoring = await FlutterForegroundTask.isIgnoringBatteryOptimizations; + if (!mounted) return; + setState(() => _ignoring = ignoring); + } + + Future _openSettings() async { + await FlutterForegroundTask.openIgnoreBatteryOptimizationSettings(); + // The user returns via resume, which triggers _check() and clears the + // banner if they applied the change. + } + + @override + Widget build(BuildContext context) { + final show = PlatformInfo.isAndroid && !_ignoring && !_dismissed; + if (!show) return widget.child; + + final scheme = Theme.of(context).colorScheme; + return Column( + children: [ + Material( + color: scheme.secondaryContainer, + child: SafeArea( + bottom: false, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 10, 8, 10), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Icon( + Icons.battery_alert_outlined, + color: scheme.onSecondaryContainer, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + context.l10n.batteryOptimizationTitle, + style: TextStyle( + fontWeight: FontWeight.bold, + color: scheme.onSecondaryContainer, + ), + ), + const SizedBox(height: 2), + Text( + context.l10n.batteryOptimizationBody, + style: TextStyle(color: scheme.onSecondaryContainer), + ), + const SizedBox(height: 4), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: _openSettings, + child: Text( + context.l10n.batteryOptimizationOpenSettings, + ), + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.close), + color: scheme.onSecondaryContainer, + tooltip: context.l10n.batteryOptimizationDismiss, + onPressed: () => setState(() => _dismissed = true), + ), + ], + ), + ), + ), + ), + Expanded(child: widget.child), + ], + ); + } +}