diff --git a/android/app/build.gradle b/android/app/build.gradle index 344813c67..cfd0c0644 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -49,7 +49,7 @@ android { applicationId 'com.exptech.dpip' minSdkVersion 26 targetSdkVersion 36 - versionCode 300104013 + versionCode 300104015 versionName flutterVersionName multiDexEnabled true resConfigs "en", "ko", "zh-rTW", "ja", "zh-rCN" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 1431169a6..7ce8f64cf 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -44,8 +44,10 @@ android:turnScreenOn="true"> + android:resource="@style/NormalTheme"/> + diff --git a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt index a751c415c..dbae82c6e 100644 --- a/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt +++ b/android/app/src/main/kotlin/com/exptech/dpip/MainActivity.kt @@ -1,5 +1,48 @@ package com.exptech.dpip +import android.content.Intent +import android.os.Bundle import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + + private val CHANNEL = "com.exptech.dpip/shortcut" + private var initialShortcut: String? = null + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "getInitialShortcut" -> { + result.success(initialShortcut) + initialShortcut = null + } + else -> result.notImplemented() + } + } + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + handleIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + handleIntent(intent) + } + + private fun handleIntent(intent: Intent?) { + intent ?: return + val shortcutId = intent.getStringExtra("android.intent.extra.shortcut.ID") + ?: intent.getStringExtra("shortcut") + if (!shortcutId.isNullOrEmpty()) { + initialShortcut = shortcutId + } + } +} diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml index bc44ee5a7..d6cd064cf 100644 --- a/android/app/src/main/res/values-ja/strings.xml +++ b/android/app/src/main/res/values-ja/strings.xml @@ -1,3 +1,5 @@ DPIP防災 + 強震モニター + 強震モニターを開く \ No newline at end of file diff --git a/android/app/src/main/res/values-ko/strings.xml b/android/app/src/main/res/values-ko/strings.xml index 2b2caaa2c..51e818be0 100644 --- a/android/app/src/main/res/values-ko/strings.xml +++ b/android/app/src/main/res/values-ko/strings.xml @@ -1,3 +1,5 @@ DPIP 재해 + 강진 모니터 + 강진 모니터 열기 \ No newline at end of file diff --git a/android/app/src/main/res/values-zh-rTW/strings.xml b/android/app/src/main/res/values-zh-rTW/strings.xml index 4ea4c6215..f3cff630d 100644 --- a/android/app/src/main/res/values-zh-rTW/strings.xml +++ b/android/app/src/main/res/values-zh-rTW/strings.xml @@ -1,3 +1,5 @@ DPIP 防災 + 強震監視器 + 打開強震監視器 \ No newline at end of file diff --git a/android/app/src/main/res/values-zh/strings.xml b/android/app/src/main/res/values-zh/strings.xml index 4ea4c6215..f3cff630d 100644 --- a/android/app/src/main/res/values-zh/strings.xml +++ b/android/app/src/main/res/values-zh/strings.xml @@ -1,3 +1,5 @@ DPIP 防災 + 強震監視器 + 打開強震監視器 \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 69de199fa..d853d60f9 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ DPIP + Earthquake Monitor + Open Earthquake Monitor \ No newline at end of file diff --git a/android/app/src/main/res/xml/shortcuts.xml b/android/app/src/main/res/xml/shortcuts.xml new file mode 100644 index 000000000..60d916fb1 --- /dev/null +++ b/android/app/src/main/res/xml/shortcuts.xml @@ -0,0 +1,15 @@ + + + + + + + diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3d2985742..bdbfb9fde 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -10,6 +10,7 @@ 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */ = {isa = PBXBuildFile; fileRef = 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */; }; 331C808B294A63AB00263BE5 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 331C807B294A618700263BE5 /* RunnerTests.swift */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; + 528548E22F06357A0027C627 /* Monitor.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 528548E12F06357A0027C627 /* Monitor.intentdefinition */; }; 529C27C82C93F7B200AAFAB6 /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 529C27C62C93F7B200AAFAB6 /* InfoPlist.strings */; }; 52FA5E152DC8A9EA0008FEB0 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 52FA5E142DC8A9EA0008FEB0 /* StoreKit.framework */; }; 6037C7C000DE0752E32B9E54 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C6838AADB908B55100C5691B /* Pods_Runner.framework */; }; @@ -66,6 +67,11 @@ 4B4531DD011F7A688ACAA691 /* Pods-RunnerTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.debug.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.debug.xcconfig"; sourceTree = ""; }; 5228AD5A2C2EE45D007635F5 /* Runner.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Runner.entitlements; sourceTree = ""; }; 523A5FD82EB21EC0006F93FC /* Profile.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Profile.xcconfig; path = Flutter/Profile.xcconfig; sourceTree = ""; }; + 526767E42F064A19003CE2E4 /* zh_TW */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = zh_TW; path = zh_TW.lproj/Monitor.intentdefinition; sourceTree = ""; }; + 526767E62F064A3B003CE2E4 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/Monitor.strings; sourceTree = ""; }; + 526767E82F064A3E003CE2E4 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/Monitor.strings; sourceTree = ""; }; + 526767EA2F064A47003CE2E4 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/Monitor.strings; sourceTree = ""; }; + 528548A52F06047F0027C627 /* Intents.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Intents.framework; path = System/Library/Frameworks/Intents.framework; sourceTree = SDKROOT; }; 529C27C92C93F7B900AAFAB6 /* zh */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh; path = zh.lproj/InfoPlist.strings; sourceTree = ""; }; 529C27CC2C93F7BC00AAFAB6 /* ko */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ko; path = ko.lproj/InfoPlist.strings; sourceTree = ""; }; 529C27CF2C947EFB00AAFAB6 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -205,6 +211,7 @@ 632125282C2EA17900A088F8 /* GoogleService-Info.plist */, 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */, 529C27C62C93F7B200AAFAB6 /* InfoPlist.strings */, + 528548E12F06357A0027C627 /* Monitor.intentdefinition */, ); path = Runner; sourceTree = ""; @@ -215,6 +222,7 @@ 52FA5E142DC8A9EA0008FEB0 /* StoreKit.framework */, C6838AADB908B55100C5691B /* Pods_Runner.framework */, 8A29CCFC3290C53FDF724288 /* Pods_RunnerTests.framework */, + 528548A52F06047F0027C627 /* Intents.framework */, ); name = Frameworks; sourceTree = ""; @@ -271,6 +279,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 2610; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -285,7 +294,7 @@ }; }; buildConfigurationList = 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */; - compatibilityVersion = "Xcode 9.3"; + compatibilityVersion = "Xcode 13.0"; developmentRegion = zh_TW; hasScannedForEncodings = 0; knownRegions = ( @@ -470,6 +479,7 @@ files = ( 74858FAF1ED2DC5600515810 /* AppDelegate.swift in Sources */, 1498D2341E8E89220040F4C2 /* GeneratedPluginRegistrant.m in Sources */, + 528548E22F06357A0027C627 /* Monitor.intentdefinition in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -484,6 +494,17 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ + 528548E12F06357A0027C627 /* Monitor.intentdefinition */ = { + isa = PBXVariantGroup; + children = ( + 526767E42F064A19003CE2E4 /* zh_TW */, + 526767E62F064A3B003CE2E4 /* en */, + 526767E82F064A3E003CE2E4 /* ja */, + 526767EA2F064A47003CE2E4 /* ko */, + ); + name = Monitor.intentdefinition; + sourceTree = ""; + }; 529C27C62C93F7B200AAFAB6 /* InfoPlist.strings */ = { isa = PBXVariantGroup; children = ( diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 9d977a1cd..6afac8e9a 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,6 +1,7 @@ import CoreLocation import Flutter import UIKit +import Intents import UserNotifications @UIApplicationMain @@ -16,11 +17,10 @@ class AppDelegate: FlutterAppDelegate, CLLocationManagerDelegate { private var backgroundTask: UIBackgroundTaskIdentifier = .invalid // MARK: - Application Lifecycle - + override func application( _ application: UIApplication, - didFinishLaunchingWithOptions launchOptions: [UIApplication - .LaunchOptionsKey: Any]? + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { GeneratedPluginRegistrant.register(with: self) setupFlutterChannels() @@ -34,11 +34,53 @@ class AppDelegate: FlutterAppDelegate, CLLocationManagerDelegate { } else if isLocationEnabled { startLocationUpdates() } - + return super.application( application, didFinishLaunchingWithOptions: launchOptions) } + // MARK: - Quick Action + override func application( + _ application: UIApplication, + performActionFor shortcutItem: UIApplicationShortcutItem, + completionHandler: @escaping (Bool) -> Void + ) { + handleShortcut(shortcutItem) + completionHandler(true) + } + + private func handleShortcut(_ shortcutItem: UIApplicationShortcutItem) { + UserDefaults.standard.set(shortcutItem.type, forKey: "initialShortcut") + notifyFlutterShortcut(shortcutItem.type) + } + + // MARK: - NSUserActivity + override func application( + _ application: UIApplication, + continue userActivity: NSUserActivity, + restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void + ) -> Bool { + if userActivity.activityType == "com.exptech.dpip.monitor" || + userActivity.activityType == "OpenMonitorIntentIntent" { + UserDefaults.standard.set("monitor", forKey: "initialShortcut") + notifyFlutterShortcut("monitor") + } + return super.application(application, continue: userActivity, restorationHandler: restorationHandler) + } + + private func notifyFlutterShortcut(_ value: String) { + guard let controller = + window?.rootViewController as? FlutterViewController + else { return } + + let channel = FlutterMethodChannel( + name: "com.exptech.dpip/shortcut", + binaryMessenger: controller.binaryMessenger + ) + channel.invokeMethod("onShortcut", arguments: value) + } + + // MARK: - Background Handling override func applicationDidEnterBackground(_ application: UIApplication) { startBackgroundTask() } @@ -59,6 +101,21 @@ class AppDelegate: FlutterAppDelegate, CLLocationManagerDelegate { locationChannel?.setMethodCallHandler { [weak self] (call, result) in self?.handleLocationChannelCall(call, result: result) } + + let shortcutChannel = FlutterMethodChannel( + name: "com.exptech.dpip/shortcut", + binaryMessenger: controller.binaryMessenger + ) + + shortcutChannel.setMethodCallHandler { call, result in + if call.method == "getInitialShortcut" { + let shortcut = UserDefaults.standard.string(forKey: "initialShortcut") + result(shortcut) + UserDefaults.standard.removeObject(forKey: "initialShortcut") + } else { + result(FlutterMethodNotImplemented) + } + } } private func setupLocationManager() { diff --git a/ios/Runner/Info.plist b/ios/Runner/Info.plist index 9130a9506..d19f8f31a 100644 --- a/ios/Runner/Info.plist +++ b/ios/Runner/Info.plist @@ -41,6 +41,10 @@ LSRequiresIPhoneOS + NSBonjourServices + + ${DART_DEBUG_BONJOUR_SERVICE} + NSLocationAlwaysAndWhenInUseUsageDescription DPIP 將利用你的位置資訊,用以自動設定所在地並在地震發生時能較準確地預估所在地的最大震度。 NSLocationAlwaysUsageDescription @@ -51,6 +55,22 @@ 用於儲存地震報告圖片。 NSPhotoLibraryUsageDescription 用於儲存地震報告圖片至您的相簿。 + NSUserActivityTypes + + OpenMonitorIntentIntent + com.exptech.dpip.monitor + + UIApplicationShortcutItems + + + UIApplicationShortcutItemIconType + UIApplicationShortcutIconTypeFavorite + UIApplicationShortcutItemTitle + 強震監視器 + UIApplicationShortcutItemType + monitor + + UIApplicationSupportsIndirectInputEvents UIBackgroundModes @@ -74,9 +94,5 @@ UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown - NSBonjourServices - - ${DART_DEBUG_BONJOUR_SERVICE} - diff --git a/ios/Runner/en.lproj/Monitor.strings b/ios/Runner/en.lproj/Monitor.strings new file mode 100644 index 000000000..0e3211aaf --- /dev/null +++ b/ios/Runner/en.lproj/Monitor.strings @@ -0,0 +1,4 @@ +"l2uH53" = "This shortcut opens the Earthquake Monitor screen directly"; + +"qno9IY" = "Open Earthquake Monitor"; + diff --git a/ios/Runner/ja.lproj/Monitor.strings b/ios/Runner/ja.lproj/Monitor.strings new file mode 100644 index 000000000..70cf9d093 --- /dev/null +++ b/ios/Runner/ja.lproj/Monitor.strings @@ -0,0 +1,4 @@ +"l2uH53" = "このショートカットは強震モニター画面を直接開きます"; + +"qno9IY" = "強震モニターを開く"; + diff --git a/ios/Runner/ko.lproj/Monitor.strings b/ios/Runner/ko.lproj/Monitor.strings new file mode 100644 index 000000000..30f3cab11 --- /dev/null +++ b/ios/Runner/ko.lproj/Monitor.strings @@ -0,0 +1,4 @@ +"l2uH53" = "이 단축키는 강진 모니터 화면을 직접 엽니다"; + +"qno9IY" = "강진 모니터 열기"; + diff --git a/ios/Runner/zh_TW.lproj/Monitor.intentdefinition b/ios/Runner/zh_TW.lproj/Monitor.intentdefinition new file mode 100644 index 000000000..2c126811c --- /dev/null +++ b/ios/Runner/zh_TW.lproj/Monitor.intentdefinition @@ -0,0 +1,79 @@ + + + + + INEnums + + INIntentDefinitionModelVersion + 1.2 + INIntentDefinitionNamespace + tuhqJ3 + INIntentDefinitionSystemVersion + 25B78 + INIntentDefinitionToolsBuildVersion + 17B100 + INIntentDefinitionToolsVersion + 26.1.1 + INIntents + + + INIntentCategory + information + INIntentConfigurable + + INIntentDescription + 此捷徑直接開啟強震監視器畫面 + INIntentDescriptionID + l2uH53 + INIntentManagedParameterCombinations + + + + INIntentParameterCombinationSupportsBackgroundExecution + + INIntentParameterCombinationUpdatesLinked + + + + INIntentName + OpenMonitorIntent + INIntentParameterCombinations + + + + INIntentParameterCombinationIsPrimary + + INIntentParameterCombinationSupportsBackgroundExecution + + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeName + failure + + + + INIntentTitle + 強震監視器 + INIntentTitleID + qno9IY + INIntentType + Custom + INIntentVerb + View + + + INTypes + + + diff --git a/lib/app.dart b/lib/app.dart index bc827c5aa..035556217 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -2,6 +2,8 @@ import 'dart:async'; import 'dart:io'; import 'package:dpip/app/welcome/4-permissions/page.dart'; +import 'package:dpip/app/map/_lib/utils.dart'; +import 'package:dpip/app/map/page.dart'; import 'package:dpip/core/notify.dart'; import 'package:dpip/core/preference.dart'; import 'package:dpip/core/providers.dart'; @@ -27,14 +29,15 @@ import 'main.dart'; /// infrastructure. class DpipApp extends StatefulWidget { /// Creates a new [DpipApp] instance. - const DpipApp({super.key}); + final String? initialShortcut; + const DpipApp({super.key, this.initialShortcut}); @override State createState() => _DpipAppState(); } class _DpipAppState extends State with WidgetsBindingObserver { - bool _hasHandledPendingNotification = false; + bool _hasHandledInitialShortcut = false; Future _checkUpdate() async { try { @@ -78,13 +81,25 @@ class _DpipAppState extends State with WidgetsBindingObserver { } } - void _handlePendingNotificationWhenReady() { - if (_hasHandledPendingNotification) return; + void _tryHandleInitialShortcut() { + if (_hasHandledInitialShortcut) return; + if (widget.initialShortcut == null) return; - final context = router.routerDelegate.navigatorKey.currentContext; - if (context != null) { - _hasHandledPendingNotification = true; - handlePendingNotificationNavigation(context); + final ctx = router.routerDelegate.navigatorKey.currentContext; + if (ctx == null) return; + + _hasHandledInitialShortcut = true; + + switch (widget.initialShortcut) { + case 'monitor': + ctx.push( + MapPage.route( + options: MapPageOptions( + initialLayers: {MapLayer.monitor}, + ), + ), + ); + break; } } @@ -101,12 +116,12 @@ class _DpipAppState extends State with WidgetsBindingObserver { WidgetsBinding.instance.addObserver(this); GlobalProviders.data.startFetching(); _checkNotificationPermission(); - router.routerDelegate.addListener(_handlePendingNotificationWhenReady); WidgetsBinding.instance.addPostFrameCallback((_) { Future.delayed(const Duration(milliseconds: 500), () { - _handlePendingNotificationWhenReady(); + handlePendingNotificationNavigation(context); }); + _tryHandleInitialShortcut(); }); } @@ -194,7 +209,6 @@ class _DpipAppState extends State with WidgetsBindingObserver { @override void dispose() { - router.routerDelegate.removeListener(_handlePendingNotificationWhenReady); WidgetsBinding.instance.removeObserver(this); super.dispose(); } diff --git a/lib/main.dart b/lib/main.dart index e4215233d..46310e5c1 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -29,6 +29,7 @@ void main() async { talker.log('--- 冷啟動偵測開始 ---'); talker.log('🔥 1. (main) 啟動時間: ${overallStartTime.toIso8601String()}'); WidgetsFlutterBinding.ensureInitialized(); + String? initialShortcut; if (Platform.isIOS) { // iOS 14 以下改回用 StoreKit1 InAppPurchaseStoreKitPlatform.enableStoreKit1(); @@ -61,6 +62,7 @@ void main() async { final isFirstLaunch = Preference.instance.getBool('isFirstLaunch') ?? true; GlobalProviders.init(); initializeTimeZones(); + initialShortcut = await getInitialShortcut(); talker.log('⏳ 3. 啟動 並行任務... (測量總耗時)'); final futureWaitStart = DateTime.now(); @@ -136,7 +138,7 @@ void main() async { ChangeNotifierProvider.value(value: GlobalProviders.notification), ChangeNotifierProvider.value(value: GlobalProviders.ui), ], - child: const DpipApp(), + child: DpipApp(initialShortcut: initialShortcut), ), ), ); @@ -172,6 +174,18 @@ void main() async { }); } +const platform = MethodChannel('com.exptech.dpip/shortcut'); + +Future getInitialShortcut() async { + try { + final result = await platform.invokeMethod('getInitialShortcut'); + return result; + } on PlatformException catch (e, st) { + talker.error('Failed to get initial shortcut', e, st); + return null; + } +} + Future _loggedTask(String taskName, Future future) async { final start = DateTime.now(); try {