1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-30 11:28:22 +02:00

refactor(mobile): app settings (#7749)

* refactor(mobile): app settings

* Font size

* refactor(mobile): backup settings ui (#7771)

* refactor: SettingsButtonListTile

* refactor: Backup settings to App settings

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>

* fix: invalidate appsettingsprovider on timeline setting change

* styling

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
shenlong 2024-03-12 14:56:08 +00:00 committed by GitHub
parent 4733de25af
commit 7489db9481
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 1358 additions and 1261 deletions

View File

@ -43,7 +43,11 @@
"asset_list_layout_settings_group_by_month": "Month",
"asset_list_layout_settings_group_by_month_day": "Month + day",
"asset_list_settings_subtitle": "Photo grid layout settings",
"asset_list_settings_title": "Photo Grid",
"asset_list_settings_title": "Timeline",
"asset_list_group_by_sub_title": "Group by",
"asset_list_layout_sub_title": "Layout",
"asset_viewer_settings_title": "Asset Viewer",
"preferences_settings_title": "Preferences",
"backup_album_selection_page_albums_device": "Albums on device ({})",
"backup_album_selection_page_albums_tap": "Tap to include, double tap to exclude",
"backup_album_selection_page_assets_scatter": "Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.",

View File

@ -0,0 +1,109 @@
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/services/backup_verification.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
part 'backup_verification.provider.g.dart';
@riverpod
class BackupVerification extends _$BackupVerification {
@override
bool build() => false;
void performBackupCheck(BuildContext context) async {
try {
state = true;
final backupState = ref.read(backupProvider);
if (backupState.allUniqueAssets.length >
backupState.selectedAlbumsBackupAssetsIds.length) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Backup all assets before starting this check!",
toastType: ToastType.error,
);
}
return;
}
final connection = await Connectivity().checkConnectivity();
if (connection != ConnectivityResult.wifi) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Make sure to be connected to unmetered Wi-Fi",
toastType: ToastType.error,
);
}
return;
}
WakelockPlus.enable();
const limit = 100;
final toDelete = await ref
.read(backupVerificationServiceProvider)
.findWronglyBackedUpAssets(limit: limit);
if (toDelete.isEmpty) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Did not find any corrupt asset backups!",
toastType: ToastType.success,
);
}
} else {
if (context.mounted) {
await showDialog(
context: context,
builder: (ctx) => ConfirmDialog(
onOk: () => _performDeletion(context, toDelete),
title: "Corrupt backups!",
ok: "Delete",
content:
"Found ${toDelete.length} (max $limit at once) corrupt asset backups. "
"Run the check again to find more.\n"
"Do you want to delete the corrupt asset backups now?",
),
);
}
}
} finally {
WakelockPlus.disable();
state = false;
}
}
Future<void> _performDeletion(
BuildContext context,
List<Asset> assets,
) async {
try {
state = true;
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Deleting ${assets.length} assets on the server...",
);
}
await ref.read(assetProvider.notifier).deleteAssets(assets, force: true);
if (context.mounted) {
ImmichToast.show(
context: context,
msg: "Deleted ${assets.length} assets on the server. "
"You can now start a manual backup",
toastType: ToastType.success,
);
}
} finally {
state = false;
}
}
}

View File

@ -1,487 +1,12 @@
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
import 'package:immich_mobile/modules/backup/services/backup_verification.service.dart';
import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
import 'package:immich_mobile/modules/settings/ui/backup_settings/backup_settings.dart';
@RoutePage()
class BackupOptionsPage extends HookConsumerWidget {
class BackupOptionsPage extends StatelessWidget {
const BackupOptionsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
final settings = ref.watch(iOSBackgroundSettingsProvider.notifier).settings;
final settingsService = ref.watch(appSettingsServiceProvider);
final showBackupFix = Platform.isAndroid &&
settingsService.getSetting(AppSettingsEnum.advancedTroubleshooting);
final ignoreIcloudAssets = useState(
settingsService.getSetting(AppSettingsEnum.ignoreIcloudAssets),
);
final appRefreshDisabled =
Platform.isIOS && settings?.appRefreshEnabled != true;
final checkInProgress = useState(false);
Future<void> performDeletion(List<Asset> assets) async {
try {
checkInProgress.value = true;
ImmichToast.show(
context: context,
msg: "Deleting ${assets.length} assets on the server...",
);
await ref
.read(assetProvider.notifier)
.deleteAssets(assets, force: true);
ImmichToast.show(
context: context,
msg: "Deleted ${assets.length} assets on the server. "
"You can now start a manual backup",
toastType: ToastType.success,
);
} finally {
checkInProgress.value = false;
}
}
void performBackupCheck() async {
try {
checkInProgress.value = true;
if (backupState.allUniqueAssets.length >
backupState.selectedAlbumsBackupAssetsIds.length) {
ImmichToast.show(
context: context,
msg: "Backup all assets before starting this check!",
toastType: ToastType.error,
);
return;
}
final connection = await Connectivity().checkConnectivity();
if (connection != ConnectivityResult.wifi) {
ImmichToast.show(
context: context,
msg: "Make sure to be connected to unmetered Wi-Fi",
toastType: ToastType.error,
);
return;
}
WakelockPlus.enable();
const limit = 100;
final toDelete = await ref
.read(backupVerificationServiceProvider)
.findWronglyBackedUpAssets(limit: limit);
if (toDelete.isEmpty) {
ImmichToast.show(
context: context,
msg: "Did not find any corrupt asset backups!",
toastType: ToastType.success,
);
} else {
await showDialog(
context: context,
builder: (context) => ConfirmDialog(
onOk: () => performDeletion(toDelete),
title: "Corrupt backups!",
ok: "Delete",
content:
"Found ${toDelete.length} (max $limit at once) corrupt asset backups. "
"Run the check again to find more.\n"
"Do you want to delete the corrupt asset backups now?",
),
);
}
} finally {
WakelockPlus.disable();
checkInProgress.value = false;
}
}
Widget buildCheckCorruptBackups() {
return ListTile(
leading: Icon(
Icons.warning_rounded,
color: context.primaryColor,
),
title: const Text(
"Check for corrupt asset backups",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
isThreeLine: true,
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text("Run this check only over Wi-Fi and once all assets "
"have been backed-up. The procedure might take a few minutes."),
ElevatedButton(
onPressed: checkInProgress.value ? null : performBackupCheck,
child: checkInProgress.value
? const CircularProgressIndicator()
: const Text("Perform check"),
),
],
),
);
}
void showErrorToUser(String msg) {
final snackBar = SnackBar(
content: Text(
msg.tr(),
style: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
),
),
backgroundColor: Colors.red,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void showBatteryOptimizationInfoToUser() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext context) {
return AlertDialog(
title: const Text(
'backup_controller_page_background_battery_info_title',
).tr(),
content: SingleChildScrollView(
child: const Text(
'backup_controller_page_background_battery_info_message',
).tr(),
),
actions: [
ElevatedButton(
onPressed: () => launchUrl(
Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication,
),
child: const Text(
"backup_controller_page_background_battery_info_link",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
),
ElevatedButton(
child: const Text(
'backup_controller_page_background_battery_info_ok',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
onPressed: () {
context.pop();
},
),
],
);
},
);
}
Widget buildBackgroundBackupController() {
final bool isBackgroundEnabled = backupState.backgroundBackup;
final bool isWifiRequired = backupState.backupRequireWifi;
final bool isChargingRequired = backupState.backupRequireCharging;
final Color activeColor = context.primaryColor;
String formatBackupDelaySliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_seconds'.tr(args: const ['5']);
} else if (v == 1.0) {
return 'setting_notifications_notify_seconds'.tr(args: const ['30']);
} else if (v == 2.0) {
return 'setting_notifications_notify_minutes'.tr(args: const ['2']);
} else {
return 'setting_notifications_notify_minutes'.tr(args: const ['10']);
}
}
int backupDelayToMilliseconds(double v) {
if (v == 0.0) {
return 5000;
} else if (v == 1.0) {
return 30000;
} else if (v == 2.0) {
return 120000;
} else {
return 600000;
}
}
double backupDelayToSliderValue(int ms) {
if (ms == 5000) {
return 0.0;
} else if (ms == 30000) {
return 1.0;
} else if (ms == 120000) {
return 2.0;
} else {
return 3.0;
}
}
final triggerDelay =
useState(backupDelayToSliderValue(backupState.backupTriggerDelay));
return Column(
children: [
ListTile(
isThreeLine: true,
leading: isBackgroundEnabled
? Icon(
Icons.cloud_sync_rounded,
color: activeColor,
)
: const Icon(Icons.cloud_sync_rounded),
title: Text(
isBackgroundEnabled
? "backup_controller_page_background_is_on"
: "backup_controller_page_background_is_off",
style: context.textTheme.titleSmall,
).tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isBackgroundEnabled)
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: const Text(
"backup_controller_page_background_description",
).tr(),
),
if (isBackgroundEnabled)
SwitchListTile.adaptive(
title: const Text("backup_controller_page_background_wifi")
.tr(),
secondary: Icon(
Icons.wifi,
color: isWifiRequired ? activeColor : null,
),
dense: true,
activeColor: activeColor,
value: isWifiRequired,
onChanged: (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireWifi: isChecked,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
),
if (isBackgroundEnabled)
SwitchListTile.adaptive(
title:
const Text("backup_controller_page_background_charging")
.tr(),
secondary: Icon(
Icons.charging_station,
color: isChargingRequired ? activeColor : null,
),
dense: true,
activeColor: activeColor,
value: isChargingRequired,
onChanged: (isChecked) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
requireCharging: isChecked,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
),
if (isBackgroundEnabled && Platform.isAndroid)
ListTile(
isThreeLine: false,
dense: true,
title: const Text(
'backup_controller_page_background_delay',
style: TextStyle(
fontWeight: FontWeight.bold,
),
).tr(
args: [formatBackupDelaySliderValue(triggerDelay.value)],
),
subtitle: Slider(
value: triggerDelay.value,
onChanged: (double v) => triggerDelay.value = v,
onChangeEnd: (double v) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
triggerDelay: backupDelayToMilliseconds(v),
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
max: 3.0,
divisions: 3,
label: formatBackupDelaySliderValue(triggerDelay.value),
activeColor: context.primaryColor,
),
),
ElevatedButton(
onPressed: () => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
enabled: !isBackgroundEnabled,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
child: Text(
isBackgroundEnabled
? "backup_controller_page_background_turn_off"
: "backup_controller_page_background_turn_on",
style: context.textTheme.labelLarge?.copyWith(
color: context.isDarkTheme ? Colors.black : Colors.white,
),
).tr(),
),
],
),
),
if (isBackgroundEnabled && Platform.isIOS)
FutureBuilder(
future: ref
.read(backgroundServiceProvider)
.getIOSBackgroundAppRefreshEnabled(),
builder: (context, snapshot) {
final enabled = snapshot.data;
// If it's not enabled, show them some kind of alert that says
// background refresh is not enabled
if (enabled != null && !enabled) {}
// If it's enabled, no need to bother them
return Container();
},
),
if (Platform.isIOS && isBackgroundEnabled && settings != null)
IosDebugInfoTile(
settings: settings,
),
],
);
}
Widget buildBackgroundAppRefreshWarning() {
return ListTile(
isThreeLine: true,
leading: const Icon(
Icons.task_outlined,
),
title: const Text(
'backup_controller_page_background_app_refresh_disabled_title',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
).tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: const Text(
'backup_controller_page_background_app_refresh_disabled_content',
).tr(),
),
ElevatedButton(
onPressed: () => openAppSettings(),
child: const Text(
'backup_controller_page_background_app_refresh_enable_button_text',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
).tr(),
),
],
),
);
}
ListTile buildAutoBackupController() {
final isAutoBackup = backupState.autoBackup;
final backUpOption = isAutoBackup
? "backup_controller_page_status_on".tr()
: "backup_controller_page_status_off".tr();
final backupBtnText = isAutoBackup
? "backup_controller_page_turn_off".tr()
: "backup_controller_page_turn_on".tr();
return ListTile(
isThreeLine: true,
leading: isAutoBackup
? Icon(
Icons.cloud_done_rounded,
color: context.primaryColor,
)
: const Icon(Icons.cloud_off_rounded),
title: Text(
backUpOption,
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (!isAutoBackup)
const Text(
"backup_controller_page_desc_backup",
).tr(),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: ElevatedButton(
onPressed: () => ref
.read(backupProvider.notifier)
.setAutoBackup(!isAutoBackup),
child: Text(
backupBtnText,
style: context.textTheme.labelLarge?.copyWith(
color: context.isDarkTheme ? Colors.black : Colors.white,
),
),
),
),
],
),
),
);
}
void switchChanged(bool value) {
settingsService.setSetting(AppSettingsEnum.ignoreIcloudAssets, value);
ignoreIcloudAssets.value = value;
ref.invalidate(appSettingsServiceProvider);
}
buildIgnoreIcloudAssetSetting() {
return [
const Divider(),
SwitchListTile.adaptive(
title: const Text(
"Ignore iCloud photos",
style: TextStyle(fontWeight: FontWeight.bold),
),
subtitle: const Text(
"Photos that are stored on iCloud will not be uploaded to the Immich server",
),
value: ignoreIcloudAssets.value,
onChanged: switchChanged,
),
];
}
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
@ -496,26 +21,7 @@ class BackupOptionsPage extends HookConsumerWidget {
),
),
),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 32.0),
child: ListView(
children: [
buildAutoBackupController(),
const Divider(),
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: Platform.isIOS
? (appRefreshDisabled
? buildBackgroundAppRefreshWarning()
: buildBackgroundBackupController())
: buildBackgroundBackupController(),
),
if (Platform.isIOS) ...buildIgnoreIcloudAssetSetting(),
if (showBackupFix) const Divider(),
if (showBackupFix) buildCheckCorruptBackups(),
],
),
),
body: const BackupSettings(),
);
}
}

View File

@ -0,0 +1,69 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:immich_mobile/modules/settings/ui/local_storage_settings.dart';
import 'package:immich_mobile/modules/settings/ui/settings_slider_list_tile.dart';
import 'package:immich_mobile/modules/settings/ui/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:immich_mobile/modules/settings/utils/app_settings_update_hook.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/providers/user.provider.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:logging/logging.dart';
class AdvancedSettings extends HookConsumerWidget {
const AdvancedSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
bool isLoggedIn = ref.read(currentUserProvider) != null;
final advancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert =
useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert);
final logLevel = Level.LEVELS[levelId.value].name;
useValueChanged(
levelId.value,
(_, __) => ImmichLogger().level = Level.LEVELS[levelId.value],
);
final advancedSettings = [
SettingsSwitchListTile(
enabled: true,
valueNotifier: advancedTroubleshooting,
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(args: [logLevel]),
valueNotifier: levelId,
maxValue: 8,
minValue: 1,
noDivisons: 7,
label: logLevel,
),
SettingsSwitchListTile(
valueNotifier: preferRemote,
title: "advanced_settings_prefer_remote_title".tr(),
subtitle: "advanced_settings_prefer_remote_subtitle".tr(),
),
const LocalStorageSettings(),
SettingsSwitchListTile(
enabled: !isLoggedIn,
valueNotifier: allowSelfSignedSSLCert,
title: "advanced_settings_self_signed_ssl_title".tr(),
subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(),
onChanged: (_) => HttpOverrides.global = HttpSSLCertOverride(),
),
];
return SettingsSubPageScaffold(settings: advancedSettings);
}
}

View File

@ -1,108 +0,0 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' show useEffect, useState;
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:immich_mobile/shared/services/immich_logger.service.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:logging/logging.dart';
class AdvancedSettings extends HookConsumerWidget {
const AdvancedSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
bool isLoggedIn = Store.tryGet(StoreKey.currentUser) != null;
final appSettingService = ref.watch(appSettingsServiceProvider);
final isEnabled =
useState(AppSettingsEnum.advancedTroubleshooting.defaultValue);
final levelId = useState(AppSettingsEnum.logLevel.defaultValue);
final preferRemote =
useState(AppSettingsEnum.preferRemoteImage.defaultValue);
final allowSelfSignedSSLCert =
useState(AppSettingsEnum.allowSelfSignedSSLCert.defaultValue);
useEffect(
() {
isEnabled.value = appSettingService.getSetting<bool>(
AppSettingsEnum.advancedTroubleshooting,
);
levelId.value = appSettingService.getSetting(AppSettingsEnum.logLevel);
preferRemote.value =
appSettingService.getSetting(AppSettingsEnum.preferRemoteImage);
allowSelfSignedSSLCert.value = appSettingService
.getSetting(AppSettingsEnum.allowSelfSignedSSLCert);
return null;
},
[],
);
final logLevel = Level.LEVELS[levelId.value].name;
return ExpansionTile(
textColor: context.primaryColor,
title: Text(
"advanced_settings_tile_title",
style: context.textTheme.titleMedium,
).tr(),
subtitle: const Text(
"advanced_settings_tile_subtitle",
).tr(),
children: [
SettingsSwitchListTile(
enabled: true,
appSettingService: appSettingService,
valueNotifier: isEnabled,
settingsEnum: AppSettingsEnum.advancedTroubleshooting,
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
ListTile(
dense: true,
title: const Text(
"advanced_settings_log_level_title",
style: TextStyle(fontWeight: FontWeight.bold),
).tr(args: [logLevel]),
subtitle: Slider(
value: levelId.value.toDouble(),
onChanged: (double v) => levelId.value = v.toInt(),
onChangeEnd: (double v) {
appSettingService.setSetting(
AppSettingsEnum.logLevel,
v.toInt(),
);
ImmichLogger().level = Level.LEVELS[v.toInt()];
},
max: 8,
min: 1.0,
divisions: 7,
label: logLevel,
activeColor: context.primaryColor,
),
),
SettingsSwitchListTile(
appSettingService: appSettingService,
valueNotifier: preferRemote,
settingsEnum: AppSettingsEnum.preferRemoteImage,
title: "advanced_settings_prefer_remote_title".tr(),
subtitle: "advanced_settings_prefer_remote_subtitle".tr(),
),
SettingsSwitchListTile(
enabled: !isLoggedIn,
appSettingService: appSettingService,
valueNotifier: allowSelfSignedSSLCert,
settingsEnum: AppSettingsEnum.allowSelfSignedSSLCert,
title: "advanced_settings_self_signed_ssl_title".tr(),
subtitle: "advanced_settings_self_signed_ssl_subtitle".tr(),
onChanged: (value) {
HttpOverrides.global = HttpSSLCertOverride();
},
),
],
);
}
}

View File

@ -0,0 +1,53 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/settings_radio_list_tile.dart';
import 'package:immich_mobile/modules/settings/ui/settings_sub_title.dart';
import 'package:immich_mobile/modules/settings/utils/app_settings_update_hook.dart';
class GroupSettings extends HookConsumerWidget {
const GroupSettings({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final groupByIndex = useAppSettingsState(AppSettingsEnum.groupAssetsBy);
final groupBy = GroupAssetsBy.values[groupByIndex.value];
void changeGroupValue(GroupAssetsBy? value) {
if (value != null) {
groupByIndex.value = value.index;
ref.invalidate(appSettingsServiceProvider);
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSubTitle(title: "asset_list_group_by_sub_title".tr()),
SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'asset_list_layout_settings_group_by_month_day'.tr(),
value: GroupAssetsBy.day,
),
SettingsRadioGroup(
title: 'asset_list_layout_settings_group_by_month'.tr(),
value: GroupAssetsBy.month,
),
SettingsRadioGroup(
title: 'asset_list_layout_settings_group_automatically'.tr(),
value: GroupAssetsBy.auto,
),
],
groupBy: groupBy,
onRadioChanged: changeGroupValue,
),
],
);
}
}

View File

@ -1,11 +1,12 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/settings_slider_list_tile.dart';
import 'package:immich_mobile/modules/settings/ui/settings_sub_title.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:immich_mobile/modules/settings/utils/app_settings_update_hook.dart';
class LayoutSettings extends HookConsumerWidget {
const LayoutSettings({
@ -14,96 +15,27 @@ class LayoutSettings extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final useDynamicLayout = useState(true);
final groupBy = useState(GroupAssetsBy.day);
void switchChanged(bool value) {
appSettingService.setSetting(AppSettingsEnum.dynamicLayout, value);
useDynamicLayout.value = value;
ref.invalidate(appSettingsServiceProvider);
}
void changeGroupValue(GroupAssetsBy? value) {
if (value != null) {
appSettingService.setSetting(
AppSettingsEnum.groupAssetsBy,
value.index,
);
groupBy.value = value;
ref.invalidate(appSettingsServiceProvider);
}
}
useEffect(
() {
useDynamicLayout.value =
appSettingService.getSetting<bool>(AppSettingsEnum.dynamicLayout);
groupBy.value = GroupAssetsBy.values[
appSettingService.getSetting<int>(AppSettingsEnum.groupAssetsBy)];
return null;
},
[],
);
final useDynamicLayout = useAppSettingsState(AppSettingsEnum.dynamicLayout);
final tilesPerRow = useAppSettingsState(AppSettingsEnum.tilesPerRow);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SwitchListTile.adaptive(
activeColor: context.primaryColor,
title: Text(
"asset_list_layout_settings_dynamic_layout_title",
style: context.textTheme.labelLarge,
).tr(),
onChanged: switchChanged,
value: useDynamicLayout.value,
SettingsSubTitle(title: "asset_list_layout_sub_title".tr()),
SettingsSwitchListTile(
valueNotifier: useDynamicLayout,
title: "asset_list_layout_settings_dynamic_layout_title".tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
const Divider(
indent: 18,
endIndent: 18,
),
ListTile(
title: const Text(
"asset_list_layout_settings_group_by",
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
).tr(),
),
RadioListTile(
activeColor: context.primaryColor,
title: Text(
"asset_list_layout_settings_group_by_month_day",
style: context.textTheme.labelLarge,
).tr(),
value: GroupAssetsBy.day,
groupValue: groupBy.value,
onChanged: changeGroupValue,
controlAffinity: ListTileControlAffinity.trailing,
),
RadioListTile(
activeColor: context.primaryColor,
title: Text(
"asset_list_layout_settings_group_by_month",
style: context.textTheme.labelLarge,
).tr(),
value: GroupAssetsBy.month,
groupValue: groupBy.value,
onChanged: changeGroupValue,
controlAffinity: ListTileControlAffinity.trailing,
),
RadioListTile(
activeColor: context.primaryColor,
title: Text(
"asset_list_layout_settings_group_automatically",
style: context.textTheme.labelLarge,
).tr(),
value: GroupAssetsBy.auto,
groupValue: groupBy.value,
onChanged: changeGroupValue,
controlAffinity: ListTileControlAffinity.trailing,
SettingsSliderListTile(
valueNotifier: tilesPerRow,
text: 'theme_setting_asset_list_tiles_per_row_title'
.tr(args: ["${tilesPerRow.value}"]),
label: "${tilesPerRow.value}",
maxValue: 6,
minValue: 2,
noDivisons: 4,
onChangeEnd: (_) => ref.invalidate(appSettingsServiceProvider),
),
],
);

View File

@ -1,31 +1,37 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_layout_settings.dart';
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_storage_indicator.dart';
import 'asset_list_tiles_per_row.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_group_settings.dart';
import 'package:immich_mobile/modules/settings/ui/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:immich_mobile/modules/settings/utils/app_settings_update_hook.dart';
import 'asset_list_layout_settings.dart';
class AssetListSettings extends StatelessWidget {
class AssetListSettings extends HookConsumerWidget {
const AssetListSettings({
super.key,
});
@override
Widget build(BuildContext context) {
return ExpansionTile(
textColor: context.primaryColor,
title: Text(
'asset_list_settings_title',
style: context.textTheme.titleMedium,
).tr(),
subtitle: const Text(
'asset_list_settings_subtitle',
).tr(),
children: const [
TilesPerRow(),
StorageIndicator(),
LayoutSettings(),
],
Widget build(BuildContext context, WidgetRef ref) {
final showStorageIndicator =
useAppSettingsState(AppSettingsEnum.storageIndicator);
final assetListSetting = [
SettingsSwitchListTile(
valueNotifier: showStorageIndicator,
title: 'theme_setting_asset_list_storage_indicator_title'.tr(),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
const LayoutSettings(),
const GroupSettings(),
];
return SettingsSubPageScaffold(
settings: assetListSetting,
showDivider: true,
);
}
}

View File

@ -1,46 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class StorageIndicator extends HookConsumerWidget {
const StorageIndicator({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final showStorageIndicator = useState(true);
void switchChanged(bool value) {
appSettingService.setSetting(AppSettingsEnum.storageIndicator, value);
showStorageIndicator.value = value;
ref.invalidate(appSettingsServiceProvider);
}
useEffect(
() {
showStorageIndicator.value = appSettingService
.getSetting<bool>(AppSettingsEnum.storageIndicator);
return null;
},
[],
);
return SwitchListTile.adaptive(
activeColor: context.primaryColor,
title: Text(
"theme_setting_asset_list_storage_indicator_title",
style: context.textTheme.labelLarge,
).tr(),
onChanged: switchChanged,
value: showStorageIndicator.value,
);
}
}

View File

@ -1,57 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class TilesPerRow extends HookConsumerWidget {
const TilesPerRow({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final itemsValue = useState(4.0);
void sliderChanged(double value) {
appSettingService.setSetting(AppSettingsEnum.tilesPerRow, value.toInt());
itemsValue.value = value;
ref.invalidate(appSettingsServiceProvider);
}
useEffect(
() {
int tilesPerRow =
appSettingService.getSetting(AppSettingsEnum.tilesPerRow);
itemsValue.value = tilesPerRow.toDouble();
return null;
},
[],
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ListTile(
title: Text(
"theme_setting_asset_list_tiles_per_row_title",
style: context.textTheme.labelLarge,
).tr(args: ["${itemsValue.value.toInt()}"]),
),
Slider(
onChanged: sliderChanged,
value: itemsValue.value,
min: 2,
max: 6,
divisions: 4,
label: "${itemsValue.value.toInt()}",
activeColor: context.primaryColor,
),
],
);
}
}

View File

@ -0,0 +1,234 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/backup/providers/ios_background_settings.provider.dart';
import 'package:immich_mobile/modules/backup/ui/ios_debug_info_tile.dart';
import 'package:immich_mobile/modules/settings/ui/settings_button_list_tile.dart';
import 'package:immich_mobile/modules/settings/ui/settings_slider_list_tile.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
class BackgroundBackupSettings extends ConsumerWidget {
const BackgroundBackupSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBackgroundEnabled =
ref.watch(backupProvider.select((s) => s.backgroundBackup));
final iosSettings = ref.watch(iOSBackgroundSettingsProvider);
void showErrorToUser(String msg) {
final snackBar = SnackBar(
content: Text(
msg.tr(),
style: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
),
),
backgroundColor: Colors.red,
);
ScaffoldMessenger.of(context).showSnackBar(snackBar);
}
void showBatteryOptimizationInfoToUser() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text(
'backup_controller_page_background_battery_info_title',
).tr(),
content: SingleChildScrollView(
child: const Text(
'backup_controller_page_background_battery_info_message',
).tr(),
),
actions: [
ElevatedButton(
onPressed: () => launchUrl(
Uri.parse('https://dontkillmyapp.com'),
mode: LaunchMode.externalApplication,
),
child: const Text(
"backup_controller_page_background_battery_info_link",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
),
ElevatedButton(
child: const Text(
'backup_controller_page_background_battery_info_ok',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
onPressed: () => ctx.pop(),
),
],
);
},
);
}
if (!isBackgroundEnabled) {
return SettingsButtonListTile(
icon: Icons.cloud_sync_outlined,
title: 'backup_controller_page_background_is_off'.tr(),
subtileText: 'backup_controller_page_background_description'.tr(),
buttonText: 'backup_controller_page_background_turn_on'.tr(),
onButtonTap: () =>
ref.read(backupProvider.notifier).configureBackgroundBackup(
enabled: true,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
);
}
return Column(
children: [
if (!Platform.isIOS || iosSettings?.appRefreshEnabled == true)
_BackgroundSettingsEnabled(
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
if (Platform.isIOS && iosSettings?.appRefreshEnabled != true)
_IOSBackgroundRefreshDisabled(),
if (Platform.isIOS && iosSettings != null)
IosDebugInfoTile(settings: iosSettings),
],
);
}
}
class _IOSBackgroundRefreshDisabled extends StatelessWidget {
@override
Widget build(BuildContext context) {
return SettingsButtonListTile(
icon: Icons.task_outlined,
title:
'backup_controller_page_background_app_refresh_disabled_title'.tr(),
subtileText:
'backup_controller_page_background_app_refresh_disabled_content'.tr(),
buttonText:
'backup_controller_page_background_app_refresh_enable_button_text'
.tr(),
onButtonTap: () => openAppSettings(),
);
}
}
class _BackgroundSettingsEnabled extends HookConsumerWidget {
final void Function(String msg) onError;
final void Function() onBatteryInfo;
const _BackgroundSettingsEnabled({
required this.onError,
required this.onBatteryInfo,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isWifiRequired =
ref.watch(backupProvider.select((s) => s.backupRequireWifi));
final isWifiRequiredNotifier = useValueNotifier(isWifiRequired);
useValueChanged(
isWifiRequired,
(_, __) => WidgetsBinding.instance.addPostFrameCallback(
(_) => isWifiRequiredNotifier.value = isWifiRequired,
),
);
final isChargingRequired =
ref.watch(backupProvider.select((s) => s.backupRequireCharging));
final isChargingRequiredNotifier = useValueNotifier(isChargingRequired);
useValueChanged(
isChargingRequired,
(_, __) => WidgetsBinding.instance.addPostFrameCallback(
(_) => isChargingRequiredNotifier.value = isChargingRequired,
),
);
int backupDelayToSliderValue(int ms) => switch (ms) {
5000 => 0,
30000 => 1,
120000 => 2,
_ => 3,
};
int backupDelayToMilliseconds(int v) =>
switch (v) { 0 => 5000, 1 => 30000, 2 => 120000, _ => 600000 };
String formatBackupDelaySliderValue(int v) => switch (v) {
0 => 'setting_notifications_notify_seconds'.tr(args: const ['5']),
1 => 'setting_notifications_notify_seconds'.tr(args: const ['30']),
2 => 'setting_notifications_notify_minutes'.tr(args: const ['2']),
_ => 'setting_notifications_notify_minutes'.tr(args: const ['10']),
};
final backupTriggerDelay =
ref.watch(backupProvider.select((s) => s.backupTriggerDelay));
final triggerDelay = useState(backupDelayToSliderValue(backupTriggerDelay));
useValueChanged(
triggerDelay.value,
(_, __) => ref.read(backupProvider.notifier).configureBackgroundBackup(
triggerDelay: backupDelayToMilliseconds(triggerDelay.value),
onError: onError,
onBatteryInfo: onBatteryInfo,
),
);
return SettingsButtonListTile(
icon: Icons.cloud_sync_rounded,
iconColor: context.primaryColor,
title: 'backup_controller_page_background_is_on'.tr(),
buttonText: 'backup_controller_page_background_turn_off'.tr(),
onButtonTap: () =>
ref.read(backupProvider.notifier).configureBackgroundBackup(
enabled: false,
onError: onError,
onBatteryInfo: onBatteryInfo,
),
subtitle: Column(
children: [
SettingsSwitchListTile(
valueNotifier: isWifiRequiredNotifier,
title: 'backup_controller_page_background_wifi'.tr(),
icon: Icons.wifi,
onChanged: (enabled) =>
ref.read(backupProvider.notifier).configureBackgroundBackup(
requireWifi: enabled,
onError: onError,
onBatteryInfo: onBatteryInfo,
),
),
SettingsSwitchListTile(
valueNotifier: isChargingRequiredNotifier,
title: 'backup_controller_page_background_charging'.tr(),
icon: Icons.charging_station,
onChanged: (enabled) =>
ref.read(backupProvider.notifier).configureBackgroundBackup(
requireCharging: enabled,
onError: onError,
onBatteryInfo: onBatteryInfo,
),
),
if (Platform.isAndroid)
SettingsSliderListTile(
valueNotifier: triggerDelay,
text: 'backup_controller_page_background_delay'.tr(
args: [formatBackupDelaySliderValue(triggerDelay.value)],
),
maxValue: 3.0,
noDivisons: 3,
label: formatBackupDelaySliderValue(triggerDelay.value),
),
],
),
);
}
}

View File

@ -0,0 +1,68 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/backup_verification.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/backup_settings/background_settings.dart';
import 'package:immich_mobile/modules/settings/ui/backup_settings/foreground_settings.dart';
import 'package:immich_mobile/modules/settings/ui/settings_button_list_tile.dart';
import 'package:immich_mobile/modules/settings/ui/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:immich_mobile/modules/settings/utils/app_settings_update_hook.dart';
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
class BackupSettings extends HookConsumerWidget {
const BackupSettings({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ignoreIcloudAssets =
useAppSettingsState(AppSettingsEnum.ignoreIcloudAssets);
final isAdvancedTroubleshooting =
useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final isCorruptCheckInProgress = ref.watch(backupVerificationProvider);
final backupSettings = [
const ForegroundBackupSettings(),
const BackgroundBackupSettings(),
if (Platform.isIOS)
SettingsSwitchListTile(
valueNotifier: ignoreIcloudAssets,
title: 'Ignore iCloud photos',
subtitle:
'Photos that are stored on iCloud will not be uploaded to the Immich server',
),
if (Platform.isAndroid && isAdvancedTroubleshooting.value)
SettingsButtonListTile(
icon: Icons.warning_rounded,
title: 'Check for corrupt asset backups',
subtitle: isCorruptCheckInProgress
? const Column(
children: [
SizedBox(height: 20),
Center(child: ImmichLoadingIndicator()),
SizedBox(height: 20),
],
)
: null,
subtileText: !isCorruptCheckInProgress
? 'Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.'
: null,
buttonText: 'Perform check',
onButtonTap: !isCorruptCheckInProgress
? () => ref
.read(backupVerificationProvider.notifier)
.performBackupCheck(context)
: null,
),
];
return SettingsSubPageScaffold(
settings: backupSettings,
showDivider: true,
);
}
}

View File

@ -0,0 +1,36 @@
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/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/modules/settings/ui/settings_button_list_tile.dart';
class ForegroundBackupSettings extends ConsumerWidget {
const ForegroundBackupSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isAutoBackup = ref.watch(backupProvider.select((s) => s.autoBackup));
void onButtonTap() =>
ref.read(backupProvider.notifier).setAutoBackup(!isAutoBackup);
if (isAutoBackup) {
return SettingsButtonListTile(
icon: Icons.cloud_done_rounded,
iconColor: context.primaryColor,
title: 'backup_controller_page_status_on'.tr(),
buttonText: 'backup_controller_page_turn_off'.tr(),
onButtonTap: onButtonTap,
);
}
return SettingsButtonListTile(
icon: Icons.cloud_off_rounded,
title: 'backup_controller_page_status_off'.tr(),
subtileText: 'backup_controller_page_desc_backup'.tr(),
buttonText: 'backup_controller_page_turn_on'.tr(),
onButtonTap: onButtonTap,
);
}
}

View File

@ -0,0 +1,41 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:immich_mobile/modules/settings/utils/app_settings_update_hook.dart';
class ImageViewerQualitySetting extends HookWidget {
const ImageViewerQualitySetting({
super.key,
});
@override
Widget build(BuildContext context) {
final isPreview = useAppSettingsState(AppSettingsEnum.loadPreview);
final isOriginal = useAppSettingsState(AppSettingsEnum.loadOriginal);
final viewerSettings = [
ListTile(
title: Text(
'setting_image_viewer_help',
style: context.textTheme.bodyMedium,
).tr(),
),
SettingsSwitchListTile(
valueNotifier: isPreview,
title: "setting_image_viewer_preview_title".tr(),
subtitle: "setting_image_viewer_preview_subtitle".tr(),
),
SettingsSwitchListTile(
valueNotifier: isOriginal,
title: "setting_image_viewer_original_title".tr(),
subtitle: "setting_image_viewer_original_subtitle".tr(),
),
];
return SettingsSubPageScaffold(settings: viewerSettings);
}
}

View File

@ -1,62 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
class ImageViewerQualitySetting extends HookConsumerWidget {
const ImageViewerQualitySetting({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final settings = ref.watch(appSettingsServiceProvider);
final isPreview = useState(AppSettingsEnum.loadPreview.defaultValue);
final isOriginal = useState(AppSettingsEnum.loadOriginal.defaultValue);
useEffect(
() {
isPreview.value = settings.getSetting(AppSettingsEnum.loadPreview);
isOriginal.value = settings.getSetting(AppSettingsEnum.loadOriginal);
return null;
},
);
return ExpansionTile(
textColor: context.primaryColor,
title: Text(
'theme_setting_image_viewer_quality_title',
style: context.textTheme.titleMedium,
).tr(),
subtitle: const Text(
'theme_setting_image_viewer_quality_subtitle',
).tr(),
children: [
ListTile(
title: Text(
'setting_image_viewer_help',
style: context.textTheme.bodyMedium,
).tr(),
),
SettingsSwitchListTile(
appSettingService: settings,
valueNotifier: isPreview,
settingsEnum: AppSettingsEnum.loadPreview,
title: "setting_image_viewer_preview_title".tr(),
subtitle: "setting_image_viewer_preview_subtitle".tr(),
),
SettingsSwitchListTile(
appSettingService: settings,
valueNotifier: isOriginal,
settingsEnum: AppSettingsEnum.loadOriginal,
title: "setting_image_viewer_original_title".tr(),
subtitle: "setting_image_viewer_original_subtitle".tr(),
),
],
);
}
}

View File

@ -0,0 +1,54 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' show useEffect, useState;
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
class LocalStorageSettings extends HookConsumerWidget {
const LocalStorageSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isarDb = ref.watch(dbProvider);
final cacheItemCount = useState(0);
useEffect(
() {
cacheItemCount.value = isarDb.duplicatedAssets.countSync();
return null;
},
[],
);
void clearCache() async {
await isarDb.writeTxn(() => isarDb.duplicatedAssets.clear());
cacheItemCount.value = await isarDb.duplicatedAssets.count();
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
dense: true,
title: Text(
"cache_settings_duplicated_assets_title",
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
).tr(args: ["${cacheItemCount.value}"]),
subtitle: const Text(
"cache_settings_duplicated_assets_subtitle",
).tr(),
trailing: TextButton(
onPressed: cacheItemCount.value > 0 ? clearCache : null,
child: Text(
"cache_settings_duplicated_assets_clear_button",
style: TextStyle(
fontSize: 12,
color: cacheItemCount.value > 0 ? Colors.red : Colors.grey,
fontWeight: FontWeight.bold,
),
).tr(),
),
);
}
}

View File

@ -1,61 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' show useEffect, useState;
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/backup/models/duplicated_asset.model.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
class LocalStorageSettings extends HookConsumerWidget {
const LocalStorageSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isarDb = ref.watch(dbProvider);
final cacheItemCount = useState(0);
useEffect(
() {
cacheItemCount.value = isarDb.duplicatedAssets.countSync();
return null;
},
[],
);
void clearCache() {
isarDb.writeTxnSync(() => isarDb.duplicatedAssets.clearSync());
cacheItemCount.value = isarDb.duplicatedAssets.countSync();
}
return ExpansionTile(
textColor: context.primaryColor,
title: Text(
"cache_settings_tile_title",
style: context.textTheme.titleMedium,
).tr(),
subtitle: const Text(
"cache_settings_tile_subtitle",
).tr(),
children: [
ListTile(
title: Text(
"cache_settings_duplicated_assets_title",
style: context.textTheme.titleSmall,
).tr(args: ["${cacheItemCount.value}"]),
subtitle: const Text(
"cache_settings_duplicated_assets_subtitle",
).tr(),
trailing: TextButton(
onPressed: cacheItemCount.value > 0 ? clearCache : null,
child: Text(
"cache_settings_duplicated_assets_clear_button",
style: TextStyle(
fontSize: 12,
color: cacheItemCount.value > 0 ? Colors.red : Colors.grey,
fontWeight: FontWeight.bold,
),
).tr(),
),
),
],
);
}
}

View File

@ -0,0 +1,118 @@
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/modules/settings/providers/notification_permission.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/settings_button_list_tile.dart';
import 'package:immich_mobile/modules/settings/ui/settings_slider_list_tile.dart';
import 'package:immich_mobile/modules/settings/ui/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:immich_mobile/modules/settings/utils/app_settings_update_hook.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationSetting extends HookConsumerWidget {
const NotificationSetting({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final permissionService = ref.watch(notificationPermissionProvider);
final sliderValue =
useAppSettingsState(AppSettingsEnum.uploadErrorNotificationGracePeriod);
final totalProgressValue =
useAppSettingsState(AppSettingsEnum.backgroundBackupTotalProgress);
final singleProgressValue =
useAppSettingsState(AppSettingsEnum.backgroundBackupSingleProgress);
final hasPermission = permissionService == PermissionStatus.granted;
openAppNotificationSettings(BuildContext ctx) {
ctx.pop();
openAppSettings();
}
// When permissions are permanently denied, you need to go to settings to
// allow them
showPermissionsDialog() {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: const Text('notification_permission_dialog_content').tr(),
actions: [
TextButton(
child: const Text('notification_permission_dialog_cancel').tr(),
onPressed: () => ctx.pop(),
),
TextButton(
onPressed: () => openAppNotificationSettings(ctx),
child: const Text('notification_permission_dialog_settings').tr(),
),
],
),
);
}
final String formattedValue =
_formatSliderValue(sliderValue.value.toDouble());
final notificationSettings = [
if (!hasPermission)
SettingsButtonListTile(
icon: Icons.notifications_outlined,
title: 'notification_permission_list_tile_title'.tr(),
subtileText: 'notification_permission_list_tile_content'.tr(),
buttonText: 'notification_permission_list_tile_enable_button'.tr(),
onButtonTap: () => ref
.watch(notificationPermissionProvider.notifier)
.requestNotificationPermission()
.then((permission) {
if (permission == PermissionStatus.permanentlyDenied) {
showPermissionsDialog();
}
}),
),
SettingsSwitchListTile(
enabled: hasPermission,
valueNotifier: totalProgressValue,
title: 'setting_notifications_total_progress_title'.tr(),
subtitle: 'setting_notifications_total_progress_subtitle'.tr(),
),
SettingsSwitchListTile(
enabled: hasPermission,
valueNotifier: singleProgressValue,
title: 'setting_notifications_single_progress_title'.tr(),
subtitle: 'setting_notifications_single_progress_subtitle'.tr(),
),
SettingsSliderListTile(
enabled: hasPermission,
valueNotifier: sliderValue,
text: 'setting_notifications_notify_failures_grace_period'
.tr(args: [formattedValue]),
maxValue: 5.0,
noDivisons: 5,
label: formattedValue,
),
];
return SettingsSubPageScaffold(settings: notificationSettings);
}
}
String _formatSliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_immediately'.tr();
} else if (v == 1.0) {
return 'setting_notifications_notify_minutes'.tr(args: const ['30']);
} else if (v == 2.0) {
return 'setting_notifications_notify_hours'.tr(args: const ['2']);
} else if (v == 3.0) {
return 'setting_notifications_notify_hours'.tr(args: const ['8']);
} else if (v == 4.0) {
return 'setting_notifications_notify_hours'.tr(args: const ['24']);
} else {
return 'setting_notifications_notify_never'.tr();
}
}

View File

@ -1,168 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:permission_handler/permission_handler.dart';
class NotificationSetting extends HookConsumerWidget {
const NotificationSetting({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final permissionService = ref.watch(notificationPermissionProvider);
final sliderValue = useState(0.0);
final totalProgressValue =
useState(AppSettingsEnum.backgroundBackupTotalProgress.defaultValue);
final singleProgressValue =
useState(AppSettingsEnum.backgroundBackupSingleProgress.defaultValue);
final hasPermission = permissionService == PermissionStatus.granted;
useEffect(
() {
sliderValue.value = appSettingService
.getSetting<int>(AppSettingsEnum.uploadErrorNotificationGracePeriod)
.toDouble();
totalProgressValue.value = appSettingService
.getSetting<bool>(AppSettingsEnum.backgroundBackupTotalProgress);
singleProgressValue.value = appSettingService
.getSetting<bool>(AppSettingsEnum.backgroundBackupSingleProgress);
return null;
},
[],
);
// When permissions are permanently denied, you need to go to settings to
// allow them
showPermissionsDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
content: const Text('notification_permission_dialog_content').tr(),
actions: [
TextButton(
child: const Text('notification_permission_dialog_cancel').tr(),
onPressed: () => context.pop(),
),
TextButton(
child: const Text('notification_permission_dialog_settings').tr(),
onPressed: () {
context.pop();
openAppSettings();
},
),
],
),
);
}
final String formattedValue = _formatSliderValue(sliderValue.value);
return ExpansionTile(
textColor: context.primaryColor,
title: Text(
'setting_notifications_title',
style: context.textTheme.titleMedium,
).tr(),
subtitle: const Text(
'setting_notifications_subtitle',
).tr(),
children: [
if (!hasPermission)
ListTile(
leading: const Icon(Icons.notifications_outlined),
title: Text(
'notification_permission_list_tile_title',
style: context.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'notification_permission_list_tile_content',
style: context.textTheme.labelMedium,
).tr(),
const SizedBox(height: 8),
ElevatedButton(
onPressed: () => ref
.watch(notificationPermissionProvider.notifier)
.requestNotificationPermission()
.then((permission) {
if (permission == PermissionStatus.permanentlyDenied) {
showPermissionsDialog();
}
}),
child: const Text(
'notification_permission_list_tile_enable_button',
).tr(),
),
],
),
isThreeLine: true,
),
SettingsSwitchListTile(
enabled: hasPermission,
appSettingService: appSettingService,
valueNotifier: totalProgressValue,
settingsEnum: AppSettingsEnum.backgroundBackupTotalProgress,
title: 'setting_notifications_total_progress_title'.tr(),
subtitle: 'setting_notifications_total_progress_subtitle'.tr(),
),
SettingsSwitchListTile(
enabled: hasPermission,
appSettingService: appSettingService,
valueNotifier: singleProgressValue,
settingsEnum: AppSettingsEnum.backgroundBackupSingleProgress,
title: 'setting_notifications_single_progress_title'.tr(),
subtitle: 'setting_notifications_single_progress_subtitle'.tr(),
),
ListTile(
enabled: hasPermission,
isThreeLine: false,
dense: true,
title: const Text(
'setting_notifications_notify_failures_grace_period',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(args: [formattedValue]),
subtitle: Slider(
value: sliderValue.value,
onChanged:
!hasPermission ? null : (double v) => sliderValue.value = v,
onChangeEnd: (double v) => appSettingService.setSetting(
AppSettingsEnum.uploadErrorNotificationGracePeriod,
v.toInt(),
),
max: 5.0,
divisions: 5,
label: formattedValue,
activeColor: context.primaryColor,
),
),
],
);
}
}
String _formatSliderValue(double v) {
if (v == 0.0) {
return 'setting_notifications_notify_immediately'.tr();
} else if (v == 1.0) {
return 'setting_notifications_notify_minutes'.tr(args: const ['30']);
} else if (v == 2.0) {
return 'setting_notifications_notify_hours'.tr(args: const ['2']);
} else if (v == 3.0) {
return 'setting_notifications_notify_hours'.tr(args: const ['8']);
} else if (v == 4.0) {
return 'setting_notifications_notify_hours'.tr(args: const ['24']);
} else {
return 'setting_notifications_notify_never'.tr();
}
}

View File

@ -0,0 +1,18 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/settings/ui/preference_settings/theme_setting.dart';
import 'package:immich_mobile/modules/settings/ui/settings_sub_page_scaffold.dart';
class PreferenceSetting extends StatelessWidget {
const PreferenceSetting({
super.key,
});
@override
Widget build(BuildContext context) {
const preferenceSettings = [
ThemeSetting(),
];
return const SettingsSubPageScaffold(settings: preferenceSettings);
}
}

View File

@ -0,0 +1,81 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/modules/settings/ui/settings_sub_title.dart';
import 'package:immich_mobile/modules/settings/ui/settings_switch_list_tile.dart';
import 'package:immich_mobile/modules/settings/utils/app_settings_update_hook.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
class ThemeSetting extends HookConsumerWidget {
const ThemeSetting({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentThemeString = useAppSettingsState(AppSettingsEnum.themeMode);
final currentTheme = useValueNotifier(ref.read(immichThemeProvider));
final isDarkTheme = useValueNotifier(currentTheme.value == ThemeMode.dark);
final isSystemTheme =
useValueNotifier(currentTheme.value == ThemeMode.system);
useValueChanged(
currentThemeString.value,
(_, __) => currentTheme.value = switch (currentThemeString.value) {
"light" => ThemeMode.light,
"dark" => ThemeMode.dark,
_ => ThemeMode.system,
},
);
void onThemeChange(bool isDark) {
if (isDark) {
ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark;
currentThemeString.value = "dark";
} else {
ref.watch(immichThemeProvider.notifier).state = ThemeMode.light;
currentThemeString.value = "light";
}
}
void onSystemThemeChange(bool isSystem) {
if (isSystem) {
currentThemeString.value = "system";
isSystemTheme.value = true;
ref.watch(immichThemeProvider.notifier).state = ThemeMode.system;
} else {
final currentSystemBrightness =
MediaQuery.platformBrightnessOf(context);
isSystemTheme.value = false;
isDarkTheme.value = currentSystemBrightness == Brightness.dark;
if (currentSystemBrightness == Brightness.light) {
currentThemeString.value = "light";
ref.watch(immichThemeProvider.notifier).state = ThemeMode.light;
} else if (currentSystemBrightness == Brightness.dark) {
currentThemeString.value = "dark";
ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark;
}
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSubTitle(title: "theme_setting_theme_title".tr()),
SettingsSwitchListTile(
valueNotifier: isSystemTheme,
title: 'theme_setting_system_theme_switch'.tr(),
onChanged: onSystemThemeChange,
),
if (currentTheme.value != ThemeMode.system)
SettingsSwitchListTile(
valueNotifier: isDarkTheme,
title: 'theme_setting_dark_mode_switch'.tr(),
onChanged: onThemeChange,
),
],
);
}
}

View File

@ -0,0 +1,50 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class SettingsButtonListTile extends StatelessWidget {
final IconData icon;
final Color? iconColor;
final String title;
final Widget? subtitle;
final String? subtileText;
final String buttonText;
final void Function()? onButtonTap;
const SettingsButtonListTile({
required this.icon,
this.iconColor,
required this.title,
this.subtileText,
this.subtitle,
required this.buttonText,
this.onButtonTap,
super.key,
});
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
horizontalTitleGap: 20,
isThreeLine: true,
leading: Icon(icon, color: iconColor),
title: Text(
title,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (subtileText != null) const SizedBox(height: 4),
if (subtileText != null)
Text(subtileText!, style: context.textTheme.bodyMedium),
if (subtitle != null) subtitle!,
const SizedBox(height: 6),
ElevatedButton(onPressed: onButtonTap, child: Text(buttonText)),
],
),
);
}
}

View File

@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class SettingsRadioGroup<T> {
final String title;
final T value;
SettingsRadioGroup({required this.title, required this.value});
}
class SettingsRadioListTile<T> extends StatelessWidget {
final List<SettingsRadioGroup> groups;
final T groupBy;
final void Function(T?) onRadioChanged;
const SettingsRadioListTile({
super.key,
required this.groups,
required this.groupBy,
required this.onRadioChanged,
});
@override
Widget build(BuildContext context) {
return Column(
children: groups
.map(
(g) => RadioListTile<T>(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
dense: true,
activeColor: context.primaryColor,
title: Text(
g.title,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
value: g.value,
groupValue: groupBy,
onChanged: onRadioChanged,
controlAffinity: ListTileControlAffinity.trailing,
),
)
.toList(),
);
}
}

View File

@ -0,0 +1,49 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class SettingsSliderListTile extends StatelessWidget {
final ValueNotifier<int> valueNotifier;
final String text;
final double maxValue;
final double minValue;
final int noDivisons;
final String? label;
final bool enabled;
final Function(int)? onChangeEnd;
const SettingsSliderListTile({
required this.valueNotifier,
required this.text,
required this.maxValue,
this.minValue = 0.0,
required this.noDivisons,
this.enabled = true,
this.label,
this.onChangeEnd,
super.key,
});
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
dense: true,
title: Text(
text,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
subtitle: Slider(
value: valueNotifier.value.toDouble(),
onChanged: (double v) => valueNotifier.value = v.toInt(),
onChangeEnd: (double v) => onChangeEnd?.call(v.toInt()),
max: maxValue,
min: minValue,
divisions: noDivisons,
label: label ?? "${valueNotifier.value}",
activeColor: context.primaryColor,
),
);
}
}

View File

@ -0,0 +1,30 @@
import 'package:flutter/material.dart';
class SettingsSubPageScaffold extends StatelessWidget {
final List<Widget> settings;
final bool showDivider;
const SettingsSubPageScaffold({
super.key,
required this.settings,
this.showDivider = false,
});
@override
Widget build(BuildContext context) {
return ListView.separated(
padding: const EdgeInsets.symmetric(vertical: 20),
itemCount: settings.length,
itemBuilder: (ctx, index) => settings[index],
separatorBuilder: (context, index) => showDivider
? const Column(
children: [
SizedBox(height: 5),
Divider(height: 10, indent: 15, endIndent: 15),
SizedBox(height: 15),
],
)
: const SizedBox(height: 10),
);
}
}

View File

@ -0,0 +1,25 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class SettingsSubTitle extends StatelessWidget {
final String title;
const SettingsSubTitle({
super.key,
required this.title,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 20),
child: Text(
title,
style: context.textTheme.bodyLarge?.copyWith(
color: context.primaryColor,
fontWeight: FontWeight.w700,
),
),
);
}
}

View File

@ -1,51 +1,61 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
class SettingsSwitchListTile extends StatelessWidget {
final AppSettingsService appSettingService;
final ValueNotifier<bool> valueNotifier;
final AppSettingsEnum settingsEnum;
final String title;
final bool enabled;
final String? subtitle;
final IconData? icon;
final Function(bool)? onChanged;
SettingsSwitchListTile({
required this.appSettingService,
const SettingsSwitchListTile({
required this.valueNotifier,
required this.settingsEnum,
required this.title,
this.subtitle,
this.icon,
this.enabled = true,
this.onChanged,
}) : super(key: Key(settingsEnum.name));
super.key,
});
@override
Widget build(BuildContext context) {
void onSwitchChanged(bool value) {
if (!enabled) return;
valueNotifier.value = value;
onChanged?.call(value);
}
return SwitchListTile.adaptive(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
selectedTileColor: enabled ? null : context.themeData.disabledColor,
value: valueNotifier.value,
onChanged: (bool value) {
if (enabled) {
valueNotifier.value = value;
appSettingService.setSetting(settingsEnum, value);
}
if (onChanged != null) {
onChanged!(value);
}
},
onChanged: onSwitchChanged,
activeColor:
enabled ? context.primaryColor : context.themeData.disabledColor,
dense: true,
secondary: icon != null
? Icon(
icon!,
color: valueNotifier.value ? context.primaryColor : null,
)
: null,
title: Text(
title,
style: context.textTheme.titleSmall,
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: enabled ? null : context.themeData.disabledColor,
height: 1.5,
),
),
subtitle: subtitle != null
? Text(
subtitle!,
style: context.textTheme.bodyMedium,
style: context.textTheme.bodyMedium?.copyWith(
color: enabled ? null : context.themeData.disabledColor,
),
)
: null,
);

View File

@ -1,98 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/utils/immich_app_theme.dart';
class ThemeSetting extends HookConsumerWidget {
const ThemeSetting({
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentTheme = useState<ThemeMode>(ThemeMode.system);
useEffect(
() {
currentTheme.value = ref.read(immichThemeProvider);
return null;
},
[],
);
return ExpansionTile(
textColor: context.primaryColor,
title: Text(
'theme_setting_theme_title',
style: context.textTheme.titleMedium,
).tr(),
subtitle: const Text(
'theme_setting_theme_subtitle',
).tr(),
children: [
SwitchListTile.adaptive(
activeColor: context.primaryColor,
title: Text(
'theme_setting_system_theme_switch',
style: context.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
value: currentTheme.value == ThemeMode.system,
onChanged: (bool isSystem) {
var currentSystemBrightness =
MediaQuery.of(context).platformBrightness;
if (isSystem) {
currentTheme.value = ThemeMode.system;
ref.watch(immichThemeProvider.notifier).state = ThemeMode.system;
ref
.watch(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.themeMode, "system");
} else {
if (currentSystemBrightness == Brightness.light) {
currentTheme.value = ThemeMode.light;
ref.watch(immichThemeProvider.notifier).state = ThemeMode.light;
ref
.watch(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.themeMode, "light");
} else if (currentSystemBrightness == Brightness.dark) {
currentTheme.value = ThemeMode.dark;
ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark;
ref
.watch(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.themeMode, "dark");
}
}
},
),
if (currentTheme.value != ThemeMode.system)
SwitchListTile.adaptive(
activeColor: context.primaryColor,
title: Text(
'theme_setting_dark_mode_switch',
style: context.textTheme.labelLarge
?.copyWith(fontWeight: FontWeight.bold),
).tr(),
value: ref.watch(immichThemeProvider) == ThemeMode.dark,
onChanged: (bool isDark) {
if (isDark) {
ref.watch(immichThemeProvider.notifier).state = ThemeMode.dark;
ref
.watch(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.themeMode, "dark");
} else {
ref.watch(immichThemeProvider.notifier).state = ThemeMode.light;
ref
.watch(appSettingsServiceProvider)
.setSetting(AppSettingsEnum.themeMode, "light");
}
},
),
],
);
}
}

View File

@ -0,0 +1,18 @@
import 'package:flutter/cupertino.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/store.dart';
ValueNotifier<T> useAppSettingsState<T>(
AppSettingsEnum<T> key,
) {
final notifier = useState<T>(Store.get(key.storeKey, key.defaultValue));
// Listen to changes to the notifier and update app settings
useValueChanged(
notifier.value,
(_, __) => Store.put(key.storeKey, notifier.value),
);
return notifier;
}

View File

@ -1,51 +1,118 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/modules/settings/ui/advanced_settings/advanced_settings.dart';
import 'package:immich_mobile/modules/settings/ui/advanced_settings.dart';
import 'package:immich_mobile/modules/settings/ui/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/modules/settings/ui/local_storage_settings/local_storage_settings.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting/image_viewer_quality_setting.dart';
import 'package:immich_mobile/modules/settings/ui/notification_setting/notification_setting.dart';
import 'package:immich_mobile/modules/settings/ui/theme_setting/theme_setting.dart';
import 'package:immich_mobile/modules/settings/ui/backup_settings/backup_settings.dart';
import 'package:immich_mobile/modules/settings/ui/image_viewer_quality_setting.dart';
import 'package:immich_mobile/modules/settings/ui/notification_setting.dart';
import 'package:immich_mobile/modules/settings/ui/preference_settings/preference_setting.dart';
import 'package:immich_mobile/routing/router.dart';
enum SettingSection {
notifications(
'setting_notifications_title',
Icons.notifications_none_rounded,
),
preferences('preferences_settings_title', Icons.interests_outlined),
backup('backup_controller_page_backup', Icons.cloud_upload_outlined),
timeline('asset_list_settings_title', Icons.auto_awesome_mosaic_outlined),
viewer('asset_viewer_settings_title', Icons.image_outlined),
advanced('advanced_settings_tile_title', Icons.build_outlined);
final String title;
final IconData icon;
Widget get widget => switch (this) {
SettingSection.notifications => const NotificationSetting(),
SettingSection.preferences => const PreferenceSetting(),
SettingSection.backup => const BackupSettings(),
SettingSection.timeline => const AssetListSettings(),
SettingSection.viewer => const ImageViewerQualitySetting(),
SettingSection.advanced => const AdvancedSettings(),
};
const SettingSection(this.title, this.icon);
}
@RoutePage()
class SettingsPage extends HookConsumerWidget {
class SettingsPage extends StatelessWidget {
const SettingsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
leading: IconButton(
iconSize: 20,
splashRadius: 24,
onPressed: () => context.pop(),
icon: const Icon(Icons.arrow_back_ios_new_rounded),
),
automaticallyImplyLeading: false,
centerTitle: false,
title: const Text(
'setting_pages_app_bar_settings',
).tr(),
),
body: ListView(
children: [
...ListTile.divideTiles(
context: context,
tiles: [
const ImageViewerQualitySetting(),
const ThemeSetting(),
const AssetListSettings(),
const NotificationSetting(),
// const ExperimentalSettings(),
const LocalStorageSettings(),
const AdvancedSettings(),
],
),
],
bottom: const PreferredSize(
preferredSize: Size.fromHeight(1),
child: Divider(height: 1),
),
title: const Text('setting_pages_app_bar_settings').tr(),
),
body: context.isMobile ? _MobileLayout() : _TabletLayout(),
);
}
}
class _MobileLayout extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ListView(
children: SettingSection.values
.map(
(s) => ListTile(
title: Text(
s.title,
style: const TextStyle(
fontWeight: FontWeight.bold,
),
).tr(),
leading: Icon(s.icon),
onTap: () => context.pushRoute(SettingsSubRoute(section: s)),
),
)
.toList(),
);
}
}
class _TabletLayout extends HookWidget {
@override
Widget build(BuildContext context) {
final selectedSection =
useState<SettingSection>(SettingSection.values.first);
return Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
Expanded(
flex: 2,
child: CustomScrollView(
slivers: SettingSection.values
.map(
(s) => SliverToBoxAdapter(
child: ListTile(
title: Text(s.title).tr(),
leading: Icon(s.icon),
selected: s.index == selectedSection.value.index,
selectedColor: context.primaryColor,
selectedTileColor: context.primaryColor.withAlpha(50),
onTap: () => selectedSection.value = s,
),
),
)
.toList(),
),
),
const VerticalDivider(width: 1),
Expanded(
flex: 4,
child: selectedSection.value.widget,
),
],
);
}
}

View File

@ -0,0 +1,22 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/settings/views/settings_page.dart';
@RoutePage()
class SettingsSubPage extends StatelessWidget {
const SettingsSubPage(this.section, {super.key});
final SettingSection section;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
centerTitle: false,
title: Text(section.title).tr(),
),
body: section.widget,
);
}
}

View File

@ -31,6 +31,7 @@ import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/modules/onboarding/views/permission_onboarding_page.dart';
import 'package:immich_mobile/modules/settings/views/settings_sub_page.dart';
import 'package:immich_mobile/modules/shared_link/models/shared_link.dart';
import 'package:immich_mobile/modules/shared_link/views/shared_link_edit_page.dart';
import 'package:immich_mobile/modules/shared_link/views/shared_link_page.dart';
@ -179,6 +180,7 @@ class AppRouter extends _$AppRouter {
transitionsBuilder: TransitionsBuilders.slideBottom,
),
AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: ArchiveRoute.page, guards: [_authGuard, _duplicateGuard]),

View File

@ -299,6 +299,16 @@ abstract class _$AppRouter extends RootStackRouter {
child: const SettingsPage(),
);
},
SettingsSubRoute.name: (routeData) {
final args = routeData.argsAs<SettingsSubRouteArgs>();
return AutoRoutePage<dynamic>(
routeData: routeData,
child: SettingsSubPage(
args.section,
key: args.key,
),
);
},
SharedLinkEditRoute.name: (routeData) {
final args = routeData.argsAs<SharedLinkEditRouteArgs>(
orElse: () => const SharedLinkEditRouteArgs());
@ -1260,6 +1270,44 @@ class SettingsRoute extends PageRouteInfo<void> {
static const PageInfo<void> page = PageInfo<void>(name);
}
/// generated route for
/// [SettingsSubPage]
class SettingsSubRoute extends PageRouteInfo<SettingsSubRouteArgs> {
SettingsSubRoute({
required SettingSection section,
Key? key,
List<PageRouteInfo>? children,
}) : super(
SettingsSubRoute.name,
args: SettingsSubRouteArgs(
section: section,
key: key,
),
initialChildren: children,
);
static const String name = 'SettingsSubRoute';
static const PageInfo<SettingsSubRouteArgs> page =
PageInfo<SettingsSubRouteArgs>(name);
}
class SettingsSubRouteArgs {
const SettingsSubRouteArgs({
required this.section,
this.key,
});
final SettingSection section;
final Key? key;
@override
String toString() {
return 'SettingsSubRouteArgs{section: $section, key: $key}';
}
}
/// generated route for
/// [SharedLinkEditPage]
class SharedLinkEditRoute extends PageRouteInfo<SharedLinkEditRouteArgs> {

View File

@ -9,7 +9,7 @@ class ImmichToast {
required BuildContext context,
required String msg,
ToastType toastType = ToastType.info,
ToastGravity gravity = ToastGravity.TOP,
ToastGravity gravity = ToastGravity.BOTTOM,
int durationInSecond = 3,
}) {
final fToast = FToast();