feat: show notification and battery optimization warning

This commit is contained in:
shenlong-tanwen
2026-03-01 10:16:23 +05:30
parent 3e1c8aacb1
commit 0c9263b69f
12 changed files with 432 additions and 22 deletions
+142 -5
View File
@@ -8,6 +8,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
@@ -15,11 +16,16 @@ import 'package:immich_mobile/presentation/widgets/backup/backup_toggle_button.w
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup_album.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/infrastructure/store.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
@@ -162,11 +168,7 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
),
),
},
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text("view_details".t(context: context)),
),
const _BackupFooter(),
],
],
),
@@ -177,6 +179,137 @@ class _DriftBackupPageState extends ConsumerState<DriftBackupPage> {
}
}
class _BackupFooter extends ConsumerStatefulWidget {
const _BackupFooter();
@override
ConsumerState<_BackupFooter> createState() => _BackupFooterState();
}
class _BackupFooterState extends ConsumerState<_BackupFooter> with WidgetsBindingObserver {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (CurrentPlatform.isAndroid && state == AppLifecycleState.resumed && mounted) {
unawaited(ref.read(notificationPermissionProvider.notifier).getNotificationPermission());
unawaited(ref.read(batteryOptimizationProvider.notifier).getBatteryOptimizationPermission());
}
}
void showPermissionsDialog() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: Text(context.t.notification_permission_dialog_content),
actions: [
ImmichTextButton(
labelText: context.t.cancel,
variant: .ghost,
expanded: false,
onPressed: () => ContextHelper(ctx).pop(),
),
ImmichTextButton(
labelText: context.t.settings,
variant: .ghost,
expanded: false,
onPressed: () {
ContextHelper(context).pop();
openAppSettings();
},
),
],
),
);
}
void showBatteryOptimizationInfo() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext ctx) {
return AlertDialog(
title: Text(context.t.backup_controller_page_background_battery_info_title),
content: SingleChildScrollView(child: Text(context.t.backup_controller_page_background_battery_info_message)),
actions: [
ImmichTextButton(
labelText: context.t.backup_controller_page_background_battery_info_link,
variant: .ghost,
expanded: false,
onPressed: () => launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
),
ImmichTextButton(
labelText: context.t.backup_controller_page_background_battery_info_ok,
variant: .ghost,
expanded: false,
onPressed: () => ContextHelper(ctx).pop(),
),
],
);
},
);
}
@override
Widget build(BuildContext context) {
final isBackupEnabled = ref.watch(_backupStatusProvider).valueOrNull ?? false;
final notificationStatus = ref.watch(notificationPermissionProvider);
final batteryOptimizationStatus = ref.watch(batteryOptimizationProvider).valueOrNull;
return Column(
children: [
if (CurrentPlatform.isAndroid && isBackupEnabled) ...[
if (notificationStatus != PermissionStatus.granted)
TextButton.icon(
iconAlignment: .end,
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
label: Text(
context.t.notification_backup_reliability,
textAlign: TextAlign.left,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onPressed: () {
ref.read(notificationPermissionProvider.notifier).requestNotificationPermission().then((p) {
if (p == PermissionStatus.permanentlyDenied) {
showPermissionsDialog();
}
});
},
),
if (notificationStatus != PermissionStatus.granted && batteryOptimizationStatus != PermissionStatus.granted)
const Divider(indent: 32, endIndent: 32),
if (batteryOptimizationStatus != PermissionStatus.granted)
TextButton.icon(
iconAlignment: .end,
icon: Icon(Icons.open_in_new_outlined, color: context.colorScheme.onSurfaceSecondary),
label: Text(
context.t.battery_optimization_backup_reliability,
textAlign: TextAlign.left,
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onPressed: showBatteryOptimizationInfo,
),
],
TextButton.icon(
icon: const Icon(Icons.info_outline_rounded),
onPressed: () => context.pushRoute(const DriftUploadDetailRoute()),
label: Text(context.t.view_details),
),
],
);
}
}
class _BackupAlbumSelectionCard extends ConsumerWidget {
const _BackupAlbumSelectionCard();
@@ -527,3 +660,7 @@ class _PreparingStatusState extends ConsumerState {
);
}
}
final _backupStatusProvider = StreamProvider.autoDispose<bool?>((ref) async* {
yield* ref.watch(storeServiceProvider).watch(StoreKey.enableBackup);
});
+89
View File
@@ -0,0 +1,89 @@
// Autogenerated from Pigeon (v26.3.4), do not edit directly.
// See also: https://pub.dev/packages/pigeon
// ignore_for_file: unused_import, unused_shown_name
// ignore_for_file: type=lint
import 'dart:async';
import 'dart:typed_data' show Float64List, Int32List, Int64List;
import 'package:flutter/services.dart';
import 'package:meta/meta.dart' show immutable, protected, visibleForTesting;
Object? _extractReplyValueOrThrow(List<Object?>? replyList, String channelName, {required bool isNullValid}) {
if (replyList == null) {
throw PlatformException(
code: 'channel-error',
message: 'Unable to establish connection on channel: "$channelName".',
);
} else if (replyList.length > 1) {
throw PlatformException(code: replyList[0]! as String, message: replyList[1] as String?, details: replyList[2]);
} else if (!isNullValid && (replyList.isNotEmpty && replyList[0] == null)) {
throw PlatformException(
code: 'null-error',
message: 'Host platform returned null value for non-null return value.',
);
}
return replyList.firstOrNull;
}
enum PermissionStatus { granted, denied, permanentlyDenied }
class _PigeonCodec extends StandardMessageCodec {
const _PigeonCodec();
@override
void writeValue(WriteBuffer buffer, Object? value) {
if (value is int) {
buffer.putUint8(4);
buffer.putInt64(value);
} else if (value is PermissionStatus) {
buffer.putUint8(129);
writeValue(buffer, value.index);
} else {
super.writeValue(buffer, value);
}
}
@override
Object? readValueOfType(int type, ReadBuffer buffer) {
switch (type) {
case 129:
final value = readValue(buffer) as int?;
return value == null ? null : PermissionStatus.values[value];
default:
return super.readValueOfType(type, buffer);
}
}
}
class PermissionApi {
/// Constructor for [PermissionApi]. The [binaryMessenger] named argument is
/// available for dependency injection. If it is left null, the default
/// BinaryMessenger will be used which routes to the host platform.
PermissionApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''})
: pigeonVar_binaryMessenger = binaryMessenger,
pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : '';
final BinaryMessenger? pigeonVar_binaryMessenger;
static const MessageCodec<Object?> pigeonChannelCodec = _PigeonCodec();
final String pigeonVar_messageChannelSuffix;
Future<PermissionStatus> isIgnoringBatteryOptimizations() async {
final pigeonVar_channelName =
'dev.flutter.pigeon.immich_mobile.PermissionApi.isIgnoringBatteryOptimizations$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(null);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
final Object? pigeonVar_replyValue = _extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: false,
);
return pigeonVar_replyValue! as PermissionStatus;
}
}
@@ -11,7 +11,7 @@ import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
@@ -3,9 +3,10 @@ import 'package:immich_mobile/domain/services/background_worker.service.dart';
import 'package:immich_mobile/platform/background_worker_api.g.dart';
import 'package:immich_mobile/platform/background_worker_lock_api.g.dart';
import 'package:immich_mobile/platform/connectivity_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/local_image_api.g.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/platform/network_api.g.dart';
import 'package:immich_mobile/platform/permission_api.g.dart';
import 'package:immich_mobile/platform/remote_image_api.g.dart';
final backgroundWorkerFgServiceProvider = Provider((_) => BackgroundWorkerFgService(BackgroundWorkerFgHostApi()));
@@ -18,6 +19,8 @@ final nativeSyncApiProvider = Provider<NativeSyncApi>((_) => NativeSyncApi());
final connectivityApiProvider = Provider<ConnectivityApi>((_) => ConnectivityApi());
final permissionApiProvider = Provider<PermissionApi>((_) => PermissionApi());
final localImageApi = LocalImageApi();
final remoteImageApi = RemoteImageApi();
@@ -1,6 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/platform/permission_api.g.dart' as pm;
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
@@ -39,3 +42,32 @@ class NotificationPermissionNotifier extends StateNotifier<PermissionStatus> {
final notificationPermissionProvider = StateNotifierProvider<NotificationPermissionNotifier, PermissionStatus>(
(ref) => NotificationPermissionNotifier(),
);
final batteryOptimizationProvider = AsyncNotifierProvider<BatteryOptimizationNotifier, PermissionStatus>(
BatteryOptimizationNotifier.new,
);
class BatteryOptimizationNotifier extends AsyncNotifier<PermissionStatus> {
Future<PermissionStatus> getBatteryOptimizationPermission() async {
final PermissionStatus status;
final isIgnoring = await ref.read(permissionApiProvider).isIgnoringBatteryOptimizations().then((p) => p.toStatus());
if (isIgnoring == PermissionStatus.granted) {
status = PermissionStatus.granted;
} else {
status = PermissionStatus.denied;
}
state = AsyncValue.data(status);
return status;
}
@override
FutureOr<PermissionStatus> build() => getBatteryOptimizationPermission();
}
extension on pm.PermissionStatus {
PermissionStatus toStatus() => switch (this) {
pm.PermissionStatus.granted => PermissionStatus.granted,
pm.PermissionStatus.denied => PermissionStatus.denied,
pm.PermissionStatus.permanentlyDenied => PermissionStatus.permanentlyDenied,
};
}
@@ -2,7 +2,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/notification_permission.provider.dart';
import 'package:immich_mobile/providers/permission.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';