diff --git a/i18n/en.json b/i18n/en.json index 7a3e719f6d..3741722585 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -557,6 +557,18 @@ "app_bar_signout_dialog_content": "Are you sure you want to sign out?", "app_bar_signout_dialog_title": "Sign out", "app_download_links": "App Download Links", + "app_icon_blossom": "Blossom", + "app_icon_change_failed": "Unable to change the app icon", + "app_icon_classic": "Classic", + "app_icon_forest": "Forest", + "app_icon_gold": "Gold", + "app_icon_ink": "Ink", + "app_icon_midnight": "Midnight", + "app_icon_neon": "Neon", + "app_icon_ocean": "Ocean", + "app_icon_subtitle": "Choose the icon shown on your home screen", + "app_icon_sunset": "Sunset", + "app_icon_title": "App Icon", "app_settings": "App Settings", "app_stores": "App Stores", "app_update_available": "App update is available", diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 1b8d2a97fb..be413a113a 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -56,11 +56,6 @@ Window background behind the Flutter UI. --> - - - - - @@ -137,6 +132,72 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + { + return listOf(result) + } + + fun wrapError(exception: Throwable): List { + return if (exception is FlutterError) { + listOf( + exception.code, + exception.message, + exception.details + ) + } else { + listOf( + exception.javaClass.simpleName, + exception.toString(), + "Cause: " + exception.cause + ", Stacktrace: " + Log.getStackTraceString(exception) + ) + } + } +} + +/** + * Error class for passing custom error details to Flutter via a thrown PlatformException. + * @property code The error code. + * @property message The error message. + * @property details The error details. Must be a datatype supported by the api codec. + */ +class FlutterError ( + val code: String, + override val message: String? = null, + val details: Any? = null +) : RuntimeException() +private open class AppIconPigeonCodec : StandardMessageCodec() { + override fun readValueOfType(type: Byte, buffer: ByteBuffer): Any? { + return super.readValueOfType(type, buffer) + } + override fun writeValue(stream: ByteArrayOutputStream, value: Any?) { + super.writeValue(stream, value) + } +} + + +/** Generated interface from Pigeon that represents a handler of messages from Flutter. */ +interface AppIconApi { + fun setAppIcon(iconId: String, callback: (Result) -> Unit) + fun getAppIcon(): String + + companion object { + /** The codec used by AppIconApi. */ + val codec: MessageCodec by lazy { + AppIconPigeonCodec() + } + /** Sets up an instance of `AppIconApi` to handle messages through the `binaryMessenger`. */ + @JvmOverloads + fun setUp(binaryMessenger: BinaryMessenger, api: AppIconApi?, messageChannelSuffix: String = "") { + val separatedMessageChannelSuffix = if (messageChannelSuffix.isNotEmpty()) ".$messageChannelSuffix" else "" + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.AppIconApi.setAppIcon$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val iconIdArg = args[0] as String + api.setAppIcon(iconIdArg) { result: Result -> + val error = result.exceptionOrNull() + if (error != null) { + reply.reply(AppIconPigeonUtils.wrapError(error)) + } else { + reply.reply(AppIconPigeonUtils.wrapResult(null)) + } + } + } + } else { + channel.setMessageHandler(null) + } + } + run { + val channel = BasicMessageChannel(binaryMessenger, "dev.flutter.pigeon.immich_mobile.AppIconApi.getAppIcon$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { _, reply -> + val wrapped: List = try { + listOf(api.getAppIcon()) + } catch (exception: Throwable) { + AppIconPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/appicon/AppIconApiImpl.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/appicon/AppIconApiImpl.kt new file mode 100644 index 0000000000..27d45f3136 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/appicon/AppIconApiImpl.kt @@ -0,0 +1,55 @@ +package app.alextran.immich.appicon + +import android.content.ComponentName +import android.content.Context +import android.content.pm.PackageManager + +/** + * Switches the launcher icon by toggling the `.AppIcon*` activity-aliases + * declared in the AndroidManifest. Exactly one alias is enabled at a time; + * the aliases all target MainActivity. + */ +class AppIconApiImpl(context: Context) : AppIconApi { + private val ctx: Context = context.applicationContext + + companion object { + private const val DEFAULT_ICON = "classic" + private val ICONS = + listOf("classic", "midnight", "ocean", "sunset", "forest", "blossom", "ink", "neon", "gold") + } + + private fun componentFor(iconId: String): ComponentName { + val alias = "AppIcon" + iconId.replaceFirstChar { it.uppercase() } + return ComponentName(ctx.packageName, "app.alextran.immich.$alias") + } + + override fun setAppIcon(iconId: String, callback: (Result) -> Unit) { + if (iconId !in ICONS) { + callback(Result.failure(FlutterError("invalid_icon", "Unknown app icon: $iconId"))) + return + } + + val pm = ctx.packageManager + for (icon in ICONS) { + val state = if (icon == iconId) { + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + } else { + PackageManager.COMPONENT_ENABLED_STATE_DISABLED + } + pm.setComponentEnabledSetting(componentFor(icon), state, PackageManager.DONT_KILL_APP) + } + callback(Result.success(Unit)) + } + + override fun getAppIcon(): String { + val pm = ctx.packageManager + for (icon in ICONS) { + if (pm.getComponentEnabledSetting(componentFor(icon)) == + PackageManager.COMPONENT_ENABLED_STATE_ENABLED + ) { + return icon + } + } + return DEFAULT_ICON + } +} diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_blossom.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_blossom.xml new file mode 100644 index 0000000000..0f3c7dec53 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_blossom.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_forest.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_forest.xml new file mode 100644 index 0000000000..e8c8e1fc36 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_forest.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_gold.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_gold.xml new file mode 100644 index 0000000000..ef94097c42 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_gold.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_ink.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_ink.xml new file mode 100644 index 0000000000..4f93f9822b --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_ink.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_midnight.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_midnight.xml new file mode 100644 index 0000000000..d14fce37ea --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_midnight.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_neon.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_neon.xml new file mode 100644 index 0000000000..088605e0d7 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_neon.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_ocean.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_ocean.xml new file mode 100644 index 0000000000..fe02311ab8 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_ocean.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_sunset.xml b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_sunset.xml new file mode 100644 index 0000000000..8cd24718c4 --- /dev/null +++ b/mobile/android/app/src/main/res/drawable/ic_launcher_foreground_sunset.xml @@ -0,0 +1,27 @@ + + + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_blossom.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_blossom.xml new file mode 100644 index 0000000000..53f5ea4ea6 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_blossom.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_forest.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_forest.xml new file mode 100644 index 0000000000..e1bfcac7c9 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_forest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_gold.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_gold.xml new file mode 100644 index 0000000000..0af836cba0 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_gold.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_ink.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_ink.xml new file mode 100644 index 0000000000..e93e5ab90f --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_ink.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_midnight.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_midnight.xml new file mode 100644 index 0000000000..7093707247 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_midnight.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_neon.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_neon.xml new file mode 100644 index 0000000000..119a62ba97 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_neon.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_ocean.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_ocean.xml new file mode 100644 index 0000000000..8d237e8439 --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_ocean.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_sunset.xml b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_sunset.xml new file mode 100644 index 0000000000..834347457b --- /dev/null +++ b/mobile/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher_sunset.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/mobile/android/app/src/main/res/values/app_icon_colors.xml b/mobile/android/app/src/main/res/values/app_icon_colors.xml new file mode 100644 index 0000000000..5de795bb4a --- /dev/null +++ b/mobile/android/app/src/main/res/values/app_icon_colors.xml @@ -0,0 +1,11 @@ + + + #0F172A + #FFFFFF + #1E1B4B + #FFFFFF + #FFF1F2 + #FFFFFF + #0A0A12 + #1C1917 + diff --git a/mobile/assets/app-icons/app-icon-blossom.svg b/mobile/assets/app-icons/app-icon-blossom.svg new file mode 100644 index 0000000000..430bf7ae40 --- /dev/null +++ b/mobile/assets/app-icons/app-icon-blossom.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/assets/app-icons/app-icon-classic.svg b/mobile/assets/app-icons/app-icon-classic.svg new file mode 100644 index 0000000000..1522e1f348 --- /dev/null +++ b/mobile/assets/app-icons/app-icon-classic.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/assets/app-icons/app-icon-forest.svg b/mobile/assets/app-icons/app-icon-forest.svg new file mode 100644 index 0000000000..9ba3659d1c --- /dev/null +++ b/mobile/assets/app-icons/app-icon-forest.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/assets/app-icons/app-icon-gold.svg b/mobile/assets/app-icons/app-icon-gold.svg new file mode 100644 index 0000000000..58b297d0d6 --- /dev/null +++ b/mobile/assets/app-icons/app-icon-gold.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/assets/app-icons/app-icon-ink.svg b/mobile/assets/app-icons/app-icon-ink.svg new file mode 100644 index 0000000000..e25b71fed6 --- /dev/null +++ b/mobile/assets/app-icons/app-icon-ink.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/assets/app-icons/app-icon-midnight.svg b/mobile/assets/app-icons/app-icon-midnight.svg new file mode 100644 index 0000000000..32477fabc9 --- /dev/null +++ b/mobile/assets/app-icons/app-icon-midnight.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/assets/app-icons/app-icon-neon.svg b/mobile/assets/app-icons/app-icon-neon.svg new file mode 100644 index 0000000000..5515def8b4 --- /dev/null +++ b/mobile/assets/app-icons/app-icon-neon.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/assets/app-icons/app-icon-ocean.svg b/mobile/assets/app-icons/app-icon-ocean.svg new file mode 100644 index 0000000000..53967c027e --- /dev/null +++ b/mobile/assets/app-icons/app-icon-ocean.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/assets/app-icons/app-icon-sunset.svg b/mobile/assets/app-icons/app-icon-sunset.svg new file mode 100644 index 0000000000..08af89d78d --- /dev/null +++ b/mobile/assets/app-icons/app-icon-sunset.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/mobile/integration_test/app_icon_test.dart b/mobile/integration_test/app_icon_test.dart new file mode 100644 index 0000000000..9b06d715d4 --- /dev/null +++ b/mobile/integration_test/app_icon_test.dart @@ -0,0 +1,20 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/platform/app_icon_api.g.dart'; +import 'package:integration_test/integration_test.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + testWidgets('switches the launcher icon and reports it back', (tester) async { + final api = AppIconApi(); + + await api.setAppIcon('neon'); + expect(await api.getAppIcon(), 'neon'); + + await api.setAppIcon('classic'); + expect(await api.getAppIcon(), 'classic'); + + await expectLater(api.setAppIcon('not-an-icon'), throwsA(isA())); + }); +} diff --git a/mobile/ios/Podfile.lock b/mobile/ios/Podfile.lock index 5d966e0033..9d2ef9f4c9 100644 --- a/mobile/ios/Podfile.lock +++ b/mobile/ios/Podfile.lock @@ -16,8 +16,6 @@ PODS: - Flutter - native_video_player (1.0.0): - Flutter - - permission_handler_apple (9.3.0): - - Flutter - share_handler_ios (0.0.14): - Flutter - share_handler_ios/share_handler_ios_models (= 0.0.14) @@ -36,7 +34,6 @@ DEPENDENCIES: - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - home_widget (from `.symlinks/plugins/home_widget/ios`) - native_video_player (from `.symlinks/plugins/native_video_player/ios`) - - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`) - share_handler_ios (from `.symlinks/plugins/share_handler_ios/ios`) - share_handler_ios_models (from `.symlinks/plugins/share_handler_ios/ios/Models`) @@ -57,8 +54,6 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/home_widget/ios" native_video_player: :path: ".symlinks/plugins/native_video_player/ios" - permission_handler_apple: - :path: ".symlinks/plugins/permission_handler_apple/ios" share_handler_ios: :path: ".symlinks/plugins/share_handler_ios/ios" share_handler_ios_models: @@ -73,7 +68,6 @@ SPEC CHECKSUMS: fluttertoast: 2c67e14dce98bbdb200df9e1acf610d7a6264ea1 home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f native_video_player: b65c58951ede2f93d103a25366bdebca95081265 - permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d share_handler_ios: e2244e990f826b2c8eaa291ac3831569438ba0fb share_handler_ios_models: fc638c9b4330dc7f082586c92aee9dfa0b87b871 diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 96d304f3d7..fe5362b52e 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -153,11 +153,15 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ B231F52D2E93A44A00BC45D1 /* Core */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Core; sourceTree = ""; }; B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Sync; sourceTree = ""; }; @@ -179,6 +183,8 @@ }; FEE084F22EC172080045228E /* Schemas */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Schemas; sourceTree = ""; }; @@ -370,7 +376,6 @@ FAC6F89A2D287C890078CB2F /* Embed Foundation Extensions */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, 513DA7292DED6106813332F4 /* [CP] Embed Pods Frameworks */, - 2FA39DEC809D6D7C4A01EFCB /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -464,7 +469,7 @@ ); mainGroup = 97C146E51CF9000F007C117D; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, FEE084F62EC172460045228E /* XCRemoteSwiftPackageReference "sqlite-data" */, FEE084F92EC1725A0045228E /* XCRemoteSwiftPackageReference "swift-http-structured-headers" */, ); @@ -511,27 +516,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 2FA39DEC809D6D7C4A01EFCB /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - inputPaths = ( - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { isa = PBXShellScriptBuildPhase; alwaysOutOfDate = 1; @@ -556,14 +540,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; @@ -772,6 +752,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/RunnerProfile.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; @@ -917,6 +898,7 @@ baseConfigurationReference = 9740EEB21CF90195004384FC /* Debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; @@ -946,6 +928,7 @@ baseConfigurationReference = 7AFA3C8E1D35360C0083082E /* Release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_INCLUDE_ALL_APPICON_ASSETS = YES; CLANG_ENABLE_MODULES = YES; CODE_SIGN_ENTITLEMENTS = Runner/Runner.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; @@ -1261,7 +1244,7 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; diff --git a/mobile/ios/Runner/AppDelegate.swift b/mobile/ios/Runner/AppDelegate.swift index e1ff3b1f6e..35baeede11 100644 --- a/mobile/ios/Runner/AppDelegate.swift +++ b/mobile/ios/Runner/AppDelegate.swift @@ -31,6 +31,7 @@ import native_video_player RemoteImageApiSetup.setUp(binaryMessenger: messenger, api: RemoteImageApiImpl()) BackgroundWorkerFgHostApiSetup.setUp(binaryMessenger: messenger, api: BackgroundWorkerApiImpl()) ConnectivityApiSetup.setUp(binaryMessenger: messenger, api: ConnectivityApiImpl()) + AppIconApiSetup.setUp(binaryMessenger: messenger, api: AppIconApiImpl()) NetworkApiSetup.setUp(binaryMessenger: messenger, api: NetworkApiImpl()) } diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconBlossom.appiconset/1024.png b/mobile/ios/Runner/Assets.xcassets/AppIconBlossom.appiconset/1024.png new file mode 100644 index 0000000000..3284a462ed Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIconBlossom.appiconset/1024.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconBlossom.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIconBlossom.appiconset/Contents.json new file mode 100644 index 0000000000..7afd6df765 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIconBlossom.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconForest.appiconset/1024.png b/mobile/ios/Runner/Assets.xcassets/AppIconForest.appiconset/1024.png new file mode 100644 index 0000000000..14a58f905a Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIconForest.appiconset/1024.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconForest.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIconForest.appiconset/Contents.json new file mode 100644 index 0000000000..7afd6df765 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIconForest.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconGold.appiconset/1024.png b/mobile/ios/Runner/Assets.xcassets/AppIconGold.appiconset/1024.png new file mode 100644 index 0000000000..77f3d92f01 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIconGold.appiconset/1024.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconGold.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIconGold.appiconset/Contents.json new file mode 100644 index 0000000000..7afd6df765 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIconGold.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconInk.appiconset/1024.png b/mobile/ios/Runner/Assets.xcassets/AppIconInk.appiconset/1024.png new file mode 100644 index 0000000000..fb5326fc00 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIconInk.appiconset/1024.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconInk.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIconInk.appiconset/Contents.json new file mode 100644 index 0000000000..7afd6df765 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIconInk.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconMidnight.appiconset/1024.png b/mobile/ios/Runner/Assets.xcassets/AppIconMidnight.appiconset/1024.png new file mode 100644 index 0000000000..39898a6751 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIconMidnight.appiconset/1024.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconMidnight.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIconMidnight.appiconset/Contents.json new file mode 100644 index 0000000000..7afd6df765 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIconMidnight.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconNeon.appiconset/1024.png b/mobile/ios/Runner/Assets.xcassets/AppIconNeon.appiconset/1024.png new file mode 100644 index 0000000000..e1468ee621 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIconNeon.appiconset/1024.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconNeon.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIconNeon.appiconset/Contents.json new file mode 100644 index 0000000000..7afd6df765 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIconNeon.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconOcean.appiconset/1024.png b/mobile/ios/Runner/Assets.xcassets/AppIconOcean.appiconset/1024.png new file mode 100644 index 0000000000..6f46bafb40 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIconOcean.appiconset/1024.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconOcean.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIconOcean.appiconset/Contents.json new file mode 100644 index 0000000000..7afd6df765 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIconOcean.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconSunset.appiconset/1024.png b/mobile/ios/Runner/Assets.xcassets/AppIconSunset.appiconset/1024.png new file mode 100644 index 0000000000..07cc6dc417 Binary files /dev/null and b/mobile/ios/Runner/Assets.xcassets/AppIconSunset.appiconset/1024.png differ diff --git a/mobile/ios/Runner/Assets.xcassets/AppIconSunset.appiconset/Contents.json b/mobile/ios/Runner/Assets.xcassets/AppIconSunset.appiconset/Contents.json new file mode 100644 index 0000000000..7afd6df765 --- /dev/null +++ b/mobile/ios/Runner/Assets.xcassets/AppIconSunset.appiconset/Contents.json @@ -0,0 +1,14 @@ +{ + "images": [ + { + "filename": "1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024" + } + ], + "info": { + "author": "xcode", + "version": 1 + } +} diff --git a/mobile/ios/Runner/Core/AppIcon.g.swift b/mobile/ios/Runner/Core/AppIcon.g.swift new file mode 100644 index 0000000000..1b91c6d984 --- /dev/null +++ b/mobile/ios/Runner/Core/AppIcon.g.swift @@ -0,0 +1,114 @@ +// Autogenerated from Pigeon (v26.3.4), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(Swift.type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + + +private class AppIconPigeonCodecReader: FlutterStandardReader { +} + +private class AppIconPigeonCodecWriter: FlutterStandardWriter { +} + +private class AppIconPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return AppIconPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return AppIconPigeonCodecWriter(data: data) + } +} + +class AppIconPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = AppIconPigeonCodec(readerWriter: AppIconPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol AppIconApi { + func setAppIcon(iconId: String, completion: @escaping (Result) -> Void) + func getAppIcon() throws -> String +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class AppIconApiSetup { + static var codec: FlutterStandardMessageCodec { AppIconPigeonCodec.shared } + /// Sets up an instance of `AppIconApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: AppIconApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let setAppIconChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.AppIconApi.setAppIcon\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + setAppIconChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let iconIdArg = args[0] as! String + api.setAppIcon(iconId: iconIdArg) { result in + switch result { + case .success: + reply(wrapResult(nil)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + setAppIconChannel.setMessageHandler(nil) + } + let getAppIconChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.immich_mobile.AppIconApi.getAppIcon\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + getAppIconChannel.setMessageHandler { _, reply in + do { + let result = try api.getAppIcon() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + getAppIconChannel.setMessageHandler(nil) + } + } +} diff --git a/mobile/ios/Runner/Core/AppIconApiImpl.swift b/mobile/ios/Runner/Core/AppIconApiImpl.swift new file mode 100644 index 0000000000..fdeb7c6859 --- /dev/null +++ b/mobile/ios/Runner/Core/AppIconApiImpl.swift @@ -0,0 +1,45 @@ +import UIKit + +/// Switches between the alternate app icons bundled in the asset catalog +/// (`AppIconMidnight`, `AppIconOcean`, ...). The "classic" id maps to the +/// primary `AppIcon`. +class AppIconApiImpl: AppIconApi { + private static let defaultIcon = "classic" + private static let assetPrefix = "AppIcon" + + private func assetName(for iconId: String) -> String? { + guard iconId != Self.defaultIcon else { return nil } + return Self.assetPrefix + iconId.prefix(1).uppercased() + String(iconId.dropFirst()) + } + + func setAppIcon(iconId: String, completion: @escaping (Result) -> Void) { + let iconName = assetName(for: iconId) + DispatchQueue.main.async { + guard UIApplication.shared.supportsAlternateIcons else { + completion( + .failure( + PigeonError( + code: "unsupported", + message: "Alternate icons are not supported on this device", + details: nil + ))) + return + } + UIApplication.shared.setAlternateIconName(iconName) { error in + if let error = error { + completion( + .failure( + PigeonError(code: "set_icon_failed", message: error.localizedDescription, details: nil) + )) + } else { + completion(.success(())) + } + } + } + } + + func getAppIcon() throws -> String { + guard let name = UIApplication.shared.alternateIconName else { return Self.defaultIcon } + return String(name.dropFirst(Self.assetPrefix.count)).lowercased() + } +} diff --git a/mobile/lib/constants/app_icons.dart b/mobile/lib/constants/app_icons.dart new file mode 100644 index 0000000000..8d10664167 --- /dev/null +++ b/mobile/lib/constants/app_icons.dart @@ -0,0 +1,21 @@ +/// The selectable launcher icons. The [name] of each variant is the id shared +/// with the native implementations (Android activity-aliases and iOS +/// alternate icon asset names), so renaming a value is a breaking change. +enum AppIconVariant { + classic, + midnight, + ocean, + sunset, + forest, + blossom, + ink, + neon, + gold; + + String get assetPath => 'assets/app-icons/app-icon-$name.svg'; + + String get translationKey => 'app_icon_$name'; + + static AppIconVariant fromId(String id) => + AppIconVariant.values.firstWhere((variant) => variant.name == id, orElse: () => AppIconVariant.classic); +} diff --git a/mobile/lib/platform/app_icon_api.g.dart b/mobile/lib/platform/app_icon_api.g.dart new file mode 100644 index 0000000000..41f13f8217 --- /dev/null +++ b/mobile/lib/platform/app_icon_api.g.dart @@ -0,0 +1,109 @@ +// 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? 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; +} + + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + default: + return super.readValueOfType(type, buffer); + } + } +} + +class AppIconApi { + /// Constructor for [AppIconApi]. 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. + AppIconApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future setAppIcon(String iconId) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.AppIconApi.setAppIcon$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([iconId]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } + + Future getAppIcon() async { + final pigeonVar_channelName = 'dev.flutter.pigeon.immich_mobile.AppIconApi.getAppIcon$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send(null); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + final Object? pigeonVar_replyValue = _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: false, + ) + ; + return pigeonVar_replyValue! as String; + } +} diff --git a/mobile/lib/providers/infrastructure/platform.provider.dart b/mobile/lib/providers/infrastructure/platform.provider.dart index 9f85235927..bbfc6537de 100644 --- a/mobile/lib/providers/infrastructure/platform.provider.dart +++ b/mobile/lib/providers/infrastructure/platform.provider.dart @@ -1,5 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:immich_mobile/domain/services/background_worker.service.dart'; +import 'package:immich_mobile/platform/app_icon_api.g.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'; @@ -21,6 +22,8 @@ final permissionApiProvider = Provider((_) => PermissionApi()); final connectivityApiProvider = Provider((_) => ConnectivityApi()); +final appIconApiProvider = Provider((_) => AppIconApi()); + final localImageApi = LocalImageApi(); final remoteImageApi = RemoteImageApi(); diff --git a/mobile/lib/widgets/settings/preference_settings/app_icon_setting.dart b/mobile/lib/widgets/settings/preference_settings/app_icon_setting.dart new file mode 100644 index 0000000000..55f6e3b62a --- /dev/null +++ b/mobile/lib/widgets/settings/preference_settings/app_icon_setting.dart @@ -0,0 +1,142 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_svg/flutter_svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/app_icons.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/providers/infrastructure/platform.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; +import 'package:immich_mobile/widgets/settings/setting_group_title.dart'; + +class AppIconSetting extends HookConsumerWidget { + const AppIconSetting({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final appIconApi = ref.watch(appIconApiProvider); + final selectedIcon = useState(AppIconVariant.classic); + + useEffect(() { + appIconApi + .getAppIcon() + .then((id) { + selectedIcon.value = AppIconVariant.fromId(id); + }) + .catchError((_) {}); + return null; + }, []); + + Future onIconSelected(AppIconVariant variant) async { + if (variant == selectedIcon.value) { + return; + } + + try { + await appIconApi.setAppIcon(variant.name); + selectedIcon.value = variant; + } catch (_) { + if (context.mounted) { + ImmichToast.show( + context: context, + msg: 'app_icon_change_failed'.t(context: context), + toastType: ToastType.error, + ); + } + } + } + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SettingGroupTitle( + title: "app_icon_title".t(context: context), + icon: Icons.apps_outlined, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + child: Text( + "app_icon_subtitle".t(context: context), + style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant), + ), + ), + GridView.count( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + crossAxisCount: 3, + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + mainAxisSpacing: 12, + crossAxisSpacing: 12, + children: [ + for (final variant in AppIconVariant.values) + _AppIconTile( + variant: variant, + isSelected: variant == selectedIcon.value, + onTap: () => onIconSelected(variant), + ), + ], + ), + ], + ); + } +} + +class _AppIconTile extends StatelessWidget { + const _AppIconTile({required this.variant, required this.isSelected, required this.onTap}); + + final AppIconVariant variant; + final bool isSelected; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + onTap: onTap, + borderRadius: const BorderRadius.all(Radius.circular(16)), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Stack( + children: [ + Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(16)), + border: Border.all( + color: isSelected ? context.colorScheme.primary : context.colorScheme.outlineVariant, + width: isSelected ? 3 : 1, + ), + ), + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(13)), + child: SvgPicture.asset(variant.assetPath, width: 64, height: 64), + ), + ), + if (isSelected) + Positioned( + right: 2, + bottom: 2, + child: DecoratedBox( + decoration: BoxDecoration(color: context.colorScheme.primary, shape: BoxShape.circle), + child: Padding( + padding: const EdgeInsets.all(2), + child: Icon(Icons.check, size: 12, color: context.colorScheme.onPrimary), + ), + ), + ), + ], + ), + const SizedBox(height: 6), + Text( + variant.translationKey.t(context: context), + style: context.textTheme.labelMedium?.copyWith( + fontWeight: isSelected ? FontWeight.bold : FontWeight.normal, + color: isSelected ? context.colorScheme.primary : null, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ); + } +} diff --git a/mobile/lib/widgets/settings/preference_settings/preference_setting.dart b/mobile/lib/widgets/settings/preference_settings/preference_setting.dart index afb3b478f0..ac01e61a09 100644 --- a/mobile/lib/widgets/settings/preference_settings/preference_setting.dart +++ b/mobile/lib/widgets/settings/preference_settings/preference_setting.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:immich_mobile/widgets/settings/preference_settings/app_icon_setting.dart'; import 'package:immich_mobile/widgets/settings/preference_settings/haptic_setting.dart'; import 'package:immich_mobile/widgets/settings/preference_settings/share_setting.dart'; import 'package:immich_mobile/widgets/settings/preference_settings/theme_setting.dart'; @@ -9,7 +10,7 @@ class PreferenceSetting extends StatelessWidget { @override Widget build(BuildContext context) { - const preferenceSettings = [ThemeSetting(), HapticSetting(), ShareSetting()]; + const preferenceSettings = [ThemeSetting(), AppIconSetting(), HapticSetting(), ShareSetting()]; return const SettingsSubPageScaffold(settings: preferenceSettings, showDivider: true); } diff --git a/mobile/pigeon/app_icon_api.dart b/mobile/pigeon/app_icon_api.dart new file mode 100644 index 0000000000..204fb81677 --- /dev/null +++ b/mobile/pigeon/app_icon_api.dart @@ -0,0 +1,20 @@ +import 'package:pigeon/pigeon.dart'; + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/app_icon_api.g.dart', + swiftOut: 'ios/Runner/Core/AppIcon.g.swift', + swiftOptions: SwiftOptions(includeErrorClass: false), + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/appicon/AppIcon.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.appicon'), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class AppIconApi { + @async + void setAppIcon(String iconId); + + String getAppIcon(); +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 945c6a6252..00f35a018a 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -124,6 +124,7 @@ flutter: uses-material-design: true assets: - assets/ + - assets/app-icons/ - assets/feature_message/ fonts: - family: GoogleSans diff --git a/mobile/scripts/generate_app_icons.py b/mobile/scripts/generate_app_icons.py new file mode 100644 index 0000000000..f8cd8f46d7 --- /dev/null +++ b/mobile/scripts/generate_app_icons.py @@ -0,0 +1,216 @@ +#!/usr/bin/env python3 +"""Generates the alternate app icon assets for Android and iOS. + +Recolors the five petals of the Immich logo for each icon variant and emits: + - assets/app-icons/app-icon-.svg (in-app previews) + - android res: drawable/ic_launcher_foreground_.xml, + mipmap-anydpi-v26/ic_launcher_.xml, + values/app_icon_colors.xml + - ios: Assets.xcassets/AppIcon.appiconset/{Contents.json,1024.png} + +The iOS PNGs are rendered with macOS `qlmanage`, so this script must run on +macOS. Run it from anywhere: `python3 mobile/scripts/generate_app_icons.py`. +""" + +import json +import re +import shutil +import struct +import subprocess +import sys +import tempfile +import zlib +from pathlib import Path + +MOBILE = Path(__file__).resolve().parent.parent + +# Original petal colors, in SVG document order: +# top (red), upper-left (pink), upper-right (yellow), +# lower-left (blue), lower-right (green) +BASE_PETALS = ["#FA2921", "#ED79B5", "#FFB400", "#1E83F7", "#18C249"] +BASE_BACKGROUND = "#FFFFFF" + +# id -> (asset suffix, background, petal colors in BASE_PETALS order). +# "classic" is the default icon and only gets a preview SVG; its native +# assets are the stock ic_launcher / AppIcon. +VARIANTS = { + "classic": (BASE_BACKGROUND, BASE_PETALS), + "midnight": ("#0F172A", BASE_PETALS), + "ocean": ("#FFFFFF", ["#0284C7", "#38BDF8", "#1D4ED8", "#0891B2", "#14B8A6"]), + "sunset": ("#1E1B4B", ["#F97316", "#7C3AED", "#FACC15", "#C026D3", "#F43F5E"]), + "forest": ("#FFFFFF", ["#16A34A", "#84CC16", "#4ADE80", "#15803D", "#65A30D"]), + "blossom": ("#FFF1F2", ["#E11D48", "#FB7185", "#F472B6", "#BE185D", "#F9A8D4"]), + "ink": ("#FFFFFF", ["#111827", "#6B7280", "#374151", "#9CA3AF", "#4B5563"]), + "neon": ("#0A0A12", ["#FF2D95", "#00E5FF", "#FFD600", "#7C4DFF", "#00FF9D"]), + "gold": ("#1C1917", ["#FBBF24", "#FDE68A", "#F59E0B", "#D97706", "#FCD34D"]), +} + + +def recolor(text: str, petals: list[str]) -> str: + for base, new in zip(BASE_PETALS, petals): + pattern = re.compile(re.escape(base), re.IGNORECASE) + text = pattern.sub(new, text) + return text + + +def petal_paths() -> str: + svg = (MOBILE / "assets" / "immich-logo.svg").read_text() + return "\n".join(re.findall(r"]*/>", svg)) + + +def icon_svg(background: str, petals: list[str], size: int) -> str: + # The flower spans the full 192px viewBox; scale it down so the icon + # keeps the same padding as the original iOS app icon. + scale = 0.78 + offset = 192 * (1 - scale) / 2 + paths = recolor(petal_paths(), petals) + return f""" + + +{paths} + + +""" + + +def write_previews() -> None: + out = MOBILE / "assets" / "app-icons" + out.mkdir(exist_ok=True) + for variant, (background, petals) in VARIANTS.items(): + (out / f"app-icon-{variant}.svg").write_text(icon_svg(background, petals, 192)) + print(f"previews: {out}") + + +def write_android() -> None: + res = MOBILE / "android" / "app" / "src" / "main" / "res" + foreground = (res / "drawable" / "ic_launcher_foreground.xml").read_text() + + colors = ['', ""] + for variant, (background, petals) in VARIANTS.items(): + if variant == "classic": + continue + (res / "drawable" / f"ic_launcher_foreground_{variant}.xml").write_text( + recolor(foreground, petals) + ) + colors.append(f' {background}') + (res / "mipmap-anydpi-v26" / f"ic_launcher_{variant}.xml").write_text( + f""" + + + + + +""" + ) + colors.append("") + (res / "values" / "app_icon_colors.xml").write_text("\n".join(colors) + "\n") + print(f"android res: {res}") + + +def strip_alpha(png: bytes) -> bytes: + """Re-encodes an RGBA PNG as opaque RGB (required for app icons).""" + assert png[:8] == b"\x89PNG\r\n\x1a\n" + pos, chunks, idat = 8, [], b"" + width = height = bit_depth = color_type = None + while pos < len(png): + (length,) = struct.unpack(">I", png[pos : pos + 4]) + kind = png[pos + 4 : pos + 8] + data = png[pos + 8 : pos + 8 + length] + pos += 12 + length + if kind == b"IHDR": + width, height, bit_depth, color_type = struct.unpack(">IIBB", data[:10]) + if kind == b"IDAT": + idat += data + else: + chunks.append((kind, data)) + if color_type != 6 or bit_depth != 8: + return png # already opaque + + raw = zlib.decompress(idat) + stride = width * 4 + rgb = bytearray() + previous = bytearray(width * 4) + for y in range(height): + row_start = y * (stride + 1) + filter_type = raw[row_start] + row = bytearray(raw[row_start + 1 : row_start + 1 + stride]) + for x in range(stride): + a = row[x - 4] if x >= 4 else 0 + b = previous[x] + c = previous[x - 4] if x >= 4 else 0 + if filter_type == 1: + row[x] = (row[x] + a) & 0xFF + elif filter_type == 2: + row[x] = (row[x] + b) & 0xFF + elif filter_type == 3: + row[x] = (row[x] + (a + b) // 2) & 0xFF + elif filter_type == 4: + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + pred = a if pa <= pb and pa <= pc else b if pb <= pc else c + row[x] = (row[x] + pred) & 0xFF + previous = row + rgb.append(0) + for x in range(width): + rgb += row[x * 4 : x * 4 + 3] + + out = bytearray(b"\x89PNG\r\n\x1a\n") + + def chunk(kind: bytes, data: bytes) -> None: + out.extend(struct.pack(">I", len(data))) + out.extend(kind) + out.extend(data) + out.extend(struct.pack(">I", zlib.crc32(kind + data))) + + chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)) + chunk(b"IDAT", zlib.compress(bytes(rgb), 9)) + chunk(b"IEND", b"") + return bytes(out) + + +def write_ios() -> None: + xcassets = MOBILE / "ios" / "Runner" / "Assets.xcassets" + with tempfile.TemporaryDirectory() as tmp_dir: + tmp = Path(tmp_dir) + for variant, (background, petals) in VARIANTS.items(): + if variant == "classic": + continue + svg = tmp / f"{variant}.svg" + svg.write_text(icon_svg(background, petals, 1024)) + subprocess.run( + ["qlmanage", "-t", "-s", "1024", "-o", tmp_dir, str(svg)], + check=True, + capture_output=True, + ) + rendered = tmp / f"{variant}.svg.png" + if not rendered.exists(): + sys.exit(f"qlmanage failed to render {svg}") + + iconset = xcassets / f"AppIcon{variant.capitalize()}.appiconset" + shutil.rmtree(iconset, ignore_errors=True) + iconset.mkdir() + (iconset / "1024.png").write_bytes(strip_alpha(rendered.read_bytes())) + (iconset / "Contents.json").write_text( + json.dumps( + { + "images": [ + { + "filename": "1024.png", + "idiom": "universal", + "platform": "ios", + "size": "1024x1024", + } + ], + "info": {"author": "xcode", "version": 1}, + }, + indent=2, + ) + + "\n" + ) + print(f"ios appiconsets: {xcassets}") + + +if __name__ == "__main__": + write_previews() + write_android() + write_ios()