diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index f95d1e5a3..86800856e 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -77,6 +77,107 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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..0d65246ba 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,62 @@ package com.exptech.dpip +import android.appwidget.AppWidgetManager +import android.content.ComponentName +import android.content.Context +import android.content.Intent 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 WIDGET_CHANNEL = "com.exptech.dpip/widget" + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, WIDGET_CHANNEL).setMethodCallHandler { call, result -> + when (call.method) { + "updateWidgets" -> { + try { + updateAllWidgets() + result.success(true) + } catch (e: Exception) { + result.error("UPDATE_ERROR", "Failed to update widgets: ${e.message}", null) + } + } + else -> { + result.notImplemented() + } + } + } + } + + /** + * 手動觸發所有 widget 實例的更新 + * 發送 APPWIDGET_UPDATE broadcast 來觸發 onUpdate 方法 + */ + private fun updateAllWidgets() { + val context = applicationContext + + // 更新標準版 widget + val standardManager = AppWidgetManager.getInstance(context) + val standardComponent = ComponentName(context, WeatherWidgetProvider::class.java) + val standardIds = standardManager.getAppWidgetIds(standardComponent) + if (standardIds.isNotEmpty()) { + val intent = Intent(context, WeatherWidgetProvider::class.java) + intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, standardIds) + context.sendBroadcast(intent) + } + + // 更新小方形版 widget + val smallComponent = ComponentName(context, WeatherWidgetSmallProvider::class.java) + val smallIds = standardManager.getAppWidgetIds(smallComponent) + if (smallIds.isNotEmpty()) { + val intent = Intent(context, WeatherWidgetSmallProvider::class.java) + intent.action = AppWidgetManager.ACTION_APPWIDGET_UPDATE + intent.putExtra(AppWidgetManager.EXTRA_APPWIDGET_IDS, smallIds) + context.sendBroadcast(intent) + } + } +} diff --git a/android/app/src/main/kotlin/com/exptech/dpip/WeatherWidgetProvider.kt b/android/app/src/main/kotlin/com/exptech/dpip/WeatherWidgetProvider.kt new file mode 100644 index 000000000..b4f801f6b --- /dev/null +++ b/android/app/src/main/kotlin/com/exptech/dpip/WeatherWidgetProvider.kt @@ -0,0 +1,138 @@ +package com.exptech.dpip + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.widget.RemoteViews +import es.antonborri.home_widget.HomeWidgetPlugin + +/** + * DPIP 天氣桌面小部件 + * 顯示即時天氣資訊 + */ +class WeatherWidgetProvider : AppWidgetProvider() { + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + // 更新所有小部件實例 + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId) + } + } + + override fun onEnabled(context: Context) { + // 第一次添加小部件時呼叫 + } + + override fun onDisabled(context: Context) { + // 最後一個小部件被移除時呼叫 + } + + companion object { + /** + * 更新單個小部件 + */ + internal fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { + // 從 SharedPreferences 讀取資料 + val widgetData = HomeWidgetPlugin.getData(context) + val views = RemoteViews(context.packageName, R.layout.weather_widget) + + // 檢查是否有錯誤或沒有資料 + val hasError = widgetData.getBoolean("has_error", false) + val hasData = widgetData.contains("temperature") + + if (hasError || !hasData) { + val errorMessage = widgetData.getString("error_message", "無法載入天氣") + views.setTextViewText(R.id.weather_status, errorMessage) + views.setTextViewText(R.id.temperature, "--°") + } else { + // 天氣狀態 + val weatherStatus = widgetData.getString("weather_status", "晴天") + views.setTextViewText(R.id.weather_status, weatherStatus) + + // 溫度 + val temperature = widgetData.readIntValue("temperature") ?: 0 + views.setTextViewText(R.id.temperature, "${temperature}°") + + // 體感溫度 + val feelsLike = widgetData.readIntValue("feels_like") ?: 0 + views.setTextViewText(R.id.feels_like, "體感 ${feelsLike}°") + + // 濕度 + val humidity = widgetData.readIntValue("humidity") ?: 0 + views.setTextViewText(R.id.humidity, "${humidity}%") + + // 風速 + val windSpeed = widgetData.readNumber("wind_speed") ?: 0.0 + views.setTextViewText(R.id.wind_speed, String.format("%.1fm/s", windSpeed)) + + // 風向 + val windDirection = widgetData.getString("wind_direction", "-") + views.setTextViewText(R.id.wind_direction, windDirection) + + // 降雨 + val rain = widgetData.readNumber("rain") ?: 0.0 + views.setTextViewText(R.id.rain, String.format("%.1fmm", rain)) + + // 氣象站 + val stationName = widgetData.getString("station_name", "") + val stationDistance = widgetData.readNumber("station_distance") ?: 0.0 + views.setTextViewText( + R.id.station_info, + "${stationName}氣象站 · ${String.format("%.1f", stationDistance)}km" + ) + + // 更新時間 + val updateTime = widgetData.readTimestampMillis("update_time") + if (updateTime != null && updateTime > 0) { + val calendar = java.util.Calendar.getInstance() + calendar.timeInMillis = updateTime + val timeStr = String.format( + "%02d:%02d", + calendar.get(java.util.Calendar.HOUR_OF_DAY), + calendar.get(java.util.Calendar.MINUTE) + ) + views.setTextViewText(R.id.update_time, timeStr) + } + + // 天氣圖示 (根據 weatherCode 設定) + val weatherCode = widgetData.getInt("weather_code", 1) + val iconRes = getWeatherIcon(weatherCode) + views.setImageViewResource(R.id.weather_icon, iconRes) + } + + // 點擊小部件開啟 App + val pendingIntent = es.antonborri.home_widget.HomeWidgetLaunchIntent.getActivity( + context, + MainActivity::class.java + ) + views.setOnClickPendingIntent(R.id.widget_container, pendingIntent) + + // 更新小部件 + appWidgetManager.updateAppWidget(appWidgetId, views) + } + + /** + * 根據天氣代碼返回對應圖示 + * 對應到 Flutter 的 WeatherIcons.getWeatherIcon + */ + fun getWeatherIcon(code: Int): Int { + return when (code) { + 1 -> android.R.drawable.ic_menu_day // 晴天 + 2, 3 -> android.R.drawable.ic_partial_secure // 多雲 + 4, 5, 6, 7 -> android.R.drawable.ic_dialog_alert // 陰天/霧 + 8, 9, 10, 11, 12, 13, 14 -> android.R.drawable.ic_dialog_info // 雨天 + 15, 16, 17, 18 -> android.R.drawable.ic_lock_power_off // 雷雨 + else -> android.R.drawable.ic_menu_day + } + // 注意: 實際使用時應該使用自訂圖示,這裡使用系統圖示作為範例 + } + } +} diff --git a/android/app/src/main/kotlin/com/exptech/dpip/WeatherWidgetSmallProvider.kt b/android/app/src/main/kotlin/com/exptech/dpip/WeatherWidgetSmallProvider.kt new file mode 100644 index 000000000..7065fc994 --- /dev/null +++ b/android/app/src/main/kotlin/com/exptech/dpip/WeatherWidgetSmallProvider.kt @@ -0,0 +1,106 @@ +package com.exptech.dpip + +import android.appwidget.AppWidgetManager +import android.appwidget.AppWidgetProvider +import android.content.Context +import android.widget.RemoteViews +import es.antonborri.home_widget.HomeWidgetPlugin + +/** + * DPIP 天氣桌面小部件 (小方形版) + * 2x2 尺寸的緊湊版本 + */ +class WeatherWidgetSmallProvider : AppWidgetProvider() { + + override fun onUpdate( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetIds: IntArray + ) { + // 更新所有小部件實例 + for (appWidgetId in appWidgetIds) { + updateAppWidget(context, appWidgetManager, appWidgetId) + } + } + + override fun onEnabled(context: Context) { + // 第一次添加小部件時呼叫 + } + + override fun onDisabled(context: Context) { + // 最後一個小部件被移除時呼叫 + } + + companion object { + /** + * 更新單個小部件 (小方形版) + */ + internal fun updateAppWidget( + context: Context, + appWidgetManager: AppWidgetManager, + appWidgetId: Int + ) { + // 從 SharedPreferences 讀取資料 + val widgetData = HomeWidgetPlugin.getData(context) + val views = RemoteViews(context.packageName, R.layout.weather_widget_small) + + // 檢查是否有錯誤或沒有資料 + val hasError = widgetData.getBoolean("has_error", false) + val hasData = widgetData.contains("temperature") + + if (hasError || !hasData) { + val errorMessage = widgetData.getString("error_message", "無法載入") + views.setTextViewText(R.id.weather_status, errorMessage) + views.setTextViewText(R.id.temperature, "--°") + } else { + // 天氣狀態 + val weatherStatus = widgetData.getString("weather_status", "晴天") + views.setTextViewText(R.id.weather_status, weatherStatus) + + // 溫度 + val temperature = widgetData.readIntValue("temperature") ?: 0 + views.setTextViewText(R.id.temperature, "${temperature}°") + + // 體感溫度 + val feelsLike = widgetData.readIntValue("feels_like") ?: 0 + views.setTextViewText(R.id.feels_like, "體感 ${feelsLike}°") + + // 濕度 + val humidity = widgetData.readIntValue("humidity") ?: 0 + views.setTextViewText(R.id.humidity, "${humidity}%") + + // 風速 + val windSpeed = widgetData.readNumber("wind_speed") ?: 0.0 + views.setTextViewText(R.id.wind_speed, String.format("%.1fm/s", windSpeed)) + + // 更新時間 + val updateTime = widgetData.readTimestampMillis("update_time") + if (updateTime != null && updateTime > 0) { + val calendar = java.util.Calendar.getInstance() + calendar.timeInMillis = updateTime + val timeStr = String.format( + "%02d:%02d", + calendar.get(java.util.Calendar.HOUR_OF_DAY), + calendar.get(java.util.Calendar.MINUTE) + ) + views.setTextViewText(R.id.update_time, timeStr) + } + + // 天氣圖示 + val weatherCode = widgetData.getInt("weather_code", 1) + val iconRes = WeatherWidgetProvider.getWeatherIcon(weatherCode) + views.setImageViewResource(R.id.weather_icon, iconRes) + } + + // 點擊小部件開啟 App + val pendingIntent = es.antonborri.home_widget.HomeWidgetLaunchIntent.getActivity( + context, + MainActivity::class.java + ) + views.setOnClickPendingIntent(R.id.widget_container_small, pendingIntent) + + // 更新小部件 + appWidgetManager.updateAppWidget(appWidgetId, views) + } + } +} diff --git a/android/app/src/main/kotlin/com/exptech/dpip/WidgetDataExtensions.kt b/android/app/src/main/kotlin/com/exptech/dpip/WidgetDataExtensions.kt new file mode 100644 index 000000000..c55b50459 --- /dev/null +++ b/android/app/src/main/kotlin/com/exptech/dpip/WidgetDataExtensions.kt @@ -0,0 +1,37 @@ +package com.exptech.dpip + +import android.content.SharedPreferences +import kotlin.math.roundToInt + +private const val DOUBLE_FLAG_PREFIX = "home_widget.double." + +/** + * 從 SharedPreferences 讀取數值型資料,並自動處理由 home_widget + * 以 Long 形式儲存的 Double。 + */ +fun SharedPreferences.readNumber(key: String): Double? { + val raw = all[key] ?: return null + return when (raw) { + is Int -> raw.toDouble() + is Float -> raw.toDouble() + is Long -> + if (getBoolean("$DOUBLE_FLAG_PREFIX$key", false)) { + java.lang.Double.longBitsToDouble(raw) + } else { + raw.toDouble() + } + is Double -> raw + is String -> raw.toDoubleOrNull() + else -> null + } +} + +fun SharedPreferences.readIntValue(key: String): Int? = readNumber(key)?.roundToInt() + +fun SharedPreferences.readFloatValue(key: String): Float? = readNumber(key)?.toFloat() + +fun SharedPreferences.readTimestampMillis(key: String): Long? { + val value = readNumber(key)?.toLong() ?: return null + return if (value < 1_000_000_000_000L) value * 1000L else value +} + diff --git a/android/app/src/main/res/drawable/feels_like_background.xml b/android/app/src/main/res/drawable/feels_like_background.xml new file mode 100644 index 000000000..a22d2e19f --- /dev/null +++ b/android/app/src/main/res/drawable/feels_like_background.xml @@ -0,0 +1,9 @@ + + + + + + + + diff --git a/android/app/src/main/res/drawable/widget_background.xml b/android/app/src/main/res/drawable/widget_background.xml new file mode 100644 index 000000000..f2e440e17 --- /dev/null +++ b/android/app/src/main/res/drawable/widget_background.xml @@ -0,0 +1,22 @@ + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/weather_widget.xml b/android/app/src/main/res/layout/weather_widget.xml new file mode 100644 index 000000000..679c1f2bf --- /dev/null +++ b/android/app/src/main/res/layout/weather_widget.xml @@ -0,0 +1,210 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/layout/weather_widget_small.xml b/android/app/src/main/res/layout/weather_widget_small.xml new file mode 100644 index 000000000..7b39a6612 --- /dev/null +++ b/android/app/src/main/res/layout/weather_widget_small.xml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 69de199fa..fd969dabe 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,3 +1,5 @@ DPIP + 顯示所在地即時天氣資訊 + 緊湊的天氣小部件 \ No newline at end of file diff --git a/android/app/src/main/res/xml/weather_widget_info.xml b/android/app/src/main/res/xml/weather_widget_info.xml new file mode 100644 index 000000000..fabd73cd2 --- /dev/null +++ b/android/app/src/main/res/xml/weather_widget_info.xml @@ -0,0 +1,14 @@ + + + diff --git a/android/app/src/main/res/xml/weather_widget_small_info.xml b/android/app/src/main/res/xml/weather_widget_small_info.xml new file mode 100644 index 000000000..0f378422f --- /dev/null +++ b/android/app/src/main/res/xml/weather_widget_small_info.xml @@ -0,0 +1,14 @@ + + + diff --git a/ios/Flutter/AppFrameworkInfo.plist b/ios/Flutter/AppFrameworkInfo.plist index 1dc6cf765..163000d85 100644 --- a/ios/Flutter/AppFrameworkInfo.plist +++ b/ios/Flutter/AppFrameworkInfo.plist @@ -21,6 +21,6 @@ CFBundleVersion 1.0 MinimumOSVersion - 13.0 + 14.0 diff --git a/ios/Podfile b/ios/Podfile index 5bc37a053..5be9ced56 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -1,5 +1,5 @@ # Uncomment this line to define a global platform for your project -platform :ios, '13.0' +platform :ios, '14.0' # CocoaPods analytics sends network stats synchronously affecting flutter build latency. ENV['COCOAPODS_DISABLE_STATS'] = 'true' diff --git a/ios/Podfile.lock b/ios/Podfile.lock index 424784b87..56330b6b7 100644 --- a/ios/Podfile.lock +++ b/ios/Podfile.lock @@ -125,6 +125,8 @@ PODS: - GoogleUtilities/UserDefaults (8.1.0): - GoogleUtilities/Logger - GoogleUtilities/Privacy + - home_widget (0.0.1): + - Flutter - in_app_purchase_storekit (0.0.1): - Flutter - FlutterMacOS @@ -163,6 +165,8 @@ PODS: - FlutterMacOS - url_launcher_ios (0.0.1): - Flutter + - workmanager_apple (0.0.1): + - Flutter DEPENDENCIES: - awesome_notifications (from `.symlinks/plugins/awesome_notifications/ios`) @@ -176,6 +180,7 @@ DEPENDENCIES: - fluttertoast (from `.symlinks/plugins/fluttertoast/ios`) - gal (from `.symlinks/plugins/gal/darwin`) - geolocator_apple (from `.symlinks/plugins/geolocator_apple/darwin`) + - home_widget (from `.symlinks/plugins/home_widget/ios`) - in_app_purchase_storekit (from `.symlinks/plugins/in_app_purchase_storekit/darwin`) - maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`) - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`) @@ -185,6 +190,7 @@ DEPENDENCIES: - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) - sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`) - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) + - workmanager_apple (from `.symlinks/plugins/workmanager_apple/ios`) SPEC REPOS: trunk: @@ -229,6 +235,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/gal/darwin" geolocator_apple: :path: ".symlinks/plugins/geolocator_apple/darwin" + home_widget: + :path: ".symlinks/plugins/home_widget/ios" in_app_purchase_storekit: :path: ".symlinks/plugins/in_app_purchase_storekit/darwin" maplibre_gl: @@ -247,6 +255,8 @@ EXTERNAL SOURCES: :path: ".symlinks/plugins/sqflite_darwin/darwin" url_launcher_ios: :path: ".symlinks/plugins/url_launcher_ios/ios" + workmanager_apple: + :path: ".symlinks/plugins/workmanager_apple/ios" SPEC CHECKSUMS: awesome_notifications: 0f432b28098d193920b11a44cfa9d2d9313a3888 @@ -271,6 +281,7 @@ SPEC CHECKSUMS: geolocator_apple: ab36aa0e8b7d7a2d7639b3b4e48308394e8cef5e GoogleDataTransport: aae35b7ea0c09004c3797d53c8c41f66f219d6a7 GoogleUtilities: 00c88b9a86066ef77f0da2fab05f65d7768ed8e1 + home_widget: f169fc41fd807b4d46ab6615dc44d62adbf9f64f in_app_purchase_storekit: 22cca7d08eebca9babdf4d07d0baccb73325d3c8 IosAwnCore: 653786a911089012092ce831f2945cd339855a89 IosAwnFcmCore: 1bdb9054b2e00187d00f1ffcfbb1855949a7b82f @@ -286,7 +297,8 @@ SPEC CHECKSUMS: shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0 url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b + workmanager_apple: 904529ae31e97fc5be632cf628507652294a0778 -PODFILE CHECKSUM: 3d88bce62bfe048ac33ca00d3fb1bc02caeda4d3 +PODFILE CHECKSUM: 8497909ca3d416990a4d0537da60f192f3ac56a8 COCOAPODS: 1.16.2 diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 3d2985742..6b715bb9a 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -12,8 +12,10 @@ 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 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 */; }; 632125292C2EA17900A088F8 /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = 632125282C2EA17900A088F8 /* GoogleService-Info.plist */; }; + 63D34C722ECDB4FC0007BD42 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63D34C712ECDB4FC0007BD42 /* WidgetKit.framework */; }; + 63D34C742ECDB4FC0007BD42 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 63D34C732ECDB4FC0007BD42 /* SwiftUI.framework */; }; + 63D34C832ECDB4FC0007BD42 /* WeatherWidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 63D34C702ECDB4FC0007BD42 /* WeatherWidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 63F1FCBE2C8D48D300693F0C /* update.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 63F1FCBB2C8D48D300693F0C /* update.aiff */; }; 63F1FCBF2C8D48D300693F0C /* rain.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 63F1FCB82C8D48D300693F0C /* rain.aiff */; }; 63F1FCC02C8D48D300693F0C /* eew.aiff in Resources */ = {isa = PBXBuildFile; fileRef = 63F1FCB22C8D48D300693F0C /* eew.aiff */; }; @@ -30,7 +32,8 @@ 97C146FC1CF9000F007C117D /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FA1CF9000F007C117D /* Main.storyboard */; }; 97C146FE1CF9000F007C117D /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FD1CF9000F007C117D /* Assets.xcassets */; }; 97C147011CF9000F007C117D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 97C146FF1CF9000F007C117D /* LaunchScreen.storyboard */; }; - B3C0548ABD2F128E6EE128FD /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8A29CCFC3290C53FDF724288 /* Pods_RunnerTests.framework */; }; + C9F33FFEA952CCCCD4060245 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 636E797A6D7770211BDECF3B /* Pods_RunnerTests.framework */; }; + FD7D076CB1AC5B6DAA2B2107 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A5CB2A3AC234172E4E1A2989 /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -41,9 +44,27 @@ remoteGlobalIDString = 97C146ED1CF9000F007C117D; remoteInfo = Runner; }; + 63D34C812ECDB4FC0007BD42 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 97C146E61CF9000F007C117D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 63D34C6F2ECDB4FC0007BD42; + remoteInfo = WeatherWidgetExtension; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 63D34C842ECDB4FC0007BD42 /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 63D34C832ECDB4FC0007BD42 /* WeatherWidgetExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 9705A1C41CF9048500538489 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -57,13 +78,14 @@ /* End PBXCopyFilesBuildPhase section */ /* Begin PBXFileReference section */ + 017380C769F48CBC9977E490 /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; 1498D2321E8E86230040F4C2 /* GeneratedPluginRegistrant.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = GeneratedPluginRegistrant.h; sourceTree = ""; }; 1498D2331E8E89220040F4C2 /* GeneratedPluginRegistrant.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GeneratedPluginRegistrant.m; sourceTree = ""; }; - 17F64C367FDB32D5C770A605 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 331C807B294A618700263BE5 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; 331C8081294A63A400263BE5 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 37FA8D23CEE08B979348D11D /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; - 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 = ""; }; + 45A64791FD9DF576EA95E92D /* 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 = ""; }; 529C27C92C93F7B900AAFAB6 /* zh */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = zh; path = zh.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -73,6 +95,11 @@ 52FA5E142DC8A9EA0008FEB0 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; 632125282C2EA17900A088F8 /* GoogleService-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = ""; }; 6321252A2C2EA20700A088F8 /* RunnerProfile.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = RunnerProfile.entitlements; sourceTree = ""; }; + 636E797A6D7770211BDECF3B /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + 63D34C702ECDB4FC0007BD42 /* WeatherWidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WeatherWidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 63D34C712ECDB4FC0007BD42 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 63D34C732ECDB4FC0007BD42 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 63D34C922ECDB5A10007BD42 /* WeatherWidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WeatherWidgetExtension.entitlements; sourceTree = ""; }; 63F1FCB22C8D48D300693F0C /* eew.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = eew.aiff; sourceTree = ""; }; 63F1FCB32C8D48D300693F0C /* eew_alert.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = eew_alert.aiff; sourceTree = ""; }; 63F1FCB42C8D48D300693F0C /* eq.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = eq.aiff; sourceTree = ""; }; @@ -85,12 +112,10 @@ 63F1FCBB2C8D48D300693F0C /* update.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = update.aiff; sourceTree = ""; }; 63F1FCBC2C8D48D300693F0C /* warn.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = warn.aiff; sourceTree = ""; }; 63F1FCBD2C8D48D300693F0C /* weather.aiff */ = {isa = PBXFileReference; lastKnownFileType = audio.aiff; path = weather.aiff; sourceTree = ""; }; + 653D9CBB41B0381966E5B4DA /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; 74858FAD1ED2DC5600515810 /* Runner-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Runner-Bridging-Header.h"; sourceTree = ""; }; 74858FAE1ED2DC5600515810 /* AppDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; name = Release.xcconfig; path = Flutter/Release.xcconfig; sourceTree = ""; }; - 7F429ED347E85746A9B0DD8B /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; - 86D7674C61D44CF5E96872D5 /* Pods-RunnerTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.release.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.release.xcconfig"; sourceTree = ""; }; - 8A29CCFC3290C53FDF724288 /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Debug.xcconfig; path = Flutter/Debug.xcconfig; sourceTree = ""; }; 9740EEB31CF90195004384FC /* Generated.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; name = Generated.xcconfig; path = Flutter/Generated.xcconfig; sourceTree = ""; }; 97C146EE1CF9000F007C117D /* Runner.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Runner.app; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -98,17 +123,51 @@ 97C146FD1CF9000F007C117D /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 97C147001CF9000F007C117D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = ""; }; 97C147021CF9000F007C117D /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 9A85FE1E2D76EB310CAE72FD /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; - C6838AADB908B55100C5691B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; - DF6CF45C0AFEE964FC43297A /* Pods-RunnerTests.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-RunnerTests.profile.xcconfig"; path = "Target Support Files/Pods-RunnerTests/Pods-RunnerTests.profile.xcconfig"; sourceTree = ""; }; + A5CB2A3AC234172E4E1A2989 /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; + E765C58D8150E00566EE7372 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + FDC71F4A7B7E218D2DB6F3E1 /* Pods-Runner.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.release.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 63D34C882ECDB4FC0007BD42 /* Exceptions for "WeatherWidget" folder in "WeatherWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 63D34C6F2ECDB4FC0007BD42 /* WeatherWidgetExtension */; + }; +/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 63D34C752ECDB4FC0007BD42 /* WeatherWidget */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 63D34C882ECDB4FC0007BD42 /* Exceptions for "WeatherWidget" folder in "WeatherWidgetExtension" target */, + ); + explicitFileTypes = { + }; + explicitFolders = ( + ); + path = WeatherWidget; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ 0166E768B450CE02B1DB74B0 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - B3C0548ABD2F128E6EE128FD /* Pods_RunnerTests.framework in Frameworks */, + C9F33FFEA952CCCCD4060245 /* Pods_RunnerTests.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 63D34C6D2ECDB4FC0007BD42 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 63D34C742ECDB4FC0007BD42 /* SwiftUI.framework in Frameworks */, + 63D34C722ECDB4FC0007BD42 /* WidgetKit.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -117,7 +176,7 @@ buildActionMask = 2147483647; files = ( 52FA5E152DC8A9EA0008FEB0 /* StoreKit.framework in Frameworks */, - 6037C7C000DE0752E32B9E54 /* Pods_Runner.framework in Frameworks */, + FD7D076CB1AC5B6DAA2B2107 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -135,12 +194,12 @@ 5437AE1A0D322629A5F820A0 /* Pods */ = { isa = PBXGroup; children = ( - 7F429ED347E85746A9B0DD8B /* Pods-Runner.debug.xcconfig */, - 9A85FE1E2D76EB310CAE72FD /* Pods-Runner.release.xcconfig */, - 17F64C367FDB32D5C770A605 /* Pods-Runner.profile.xcconfig */, - 4B4531DD011F7A688ACAA691 /* Pods-RunnerTests.debug.xcconfig */, - 86D7674C61D44CF5E96872D5 /* Pods-RunnerTests.release.xcconfig */, - DF6CF45C0AFEE964FC43297A /* Pods-RunnerTests.profile.xcconfig */, + E765C58D8150E00566EE7372 /* Pods-Runner.debug.xcconfig */, + FDC71F4A7B7E218D2DB6F3E1 /* Pods-Runner.release.xcconfig */, + 37FA8D23CEE08B979348D11D /* Pods-Runner.profile.xcconfig */, + 45A64791FD9DF576EA95E92D /* Pods-RunnerTests.debug.xcconfig */, + 653D9CBB41B0381966E5B4DA /* Pods-RunnerTests.release.xcconfig */, + 017380C769F48CBC9977E490 /* Pods-RunnerTests.profile.xcconfig */, ); path = Pods; sourceTree = ""; @@ -160,8 +219,10 @@ 97C146E51CF9000F007C117D = { isa = PBXGroup; children = ( + 63D34C922ECDB5A10007BD42 /* WeatherWidgetExtension.entitlements */, 9740EEB11CF90186004384FC /* Flutter */, 97C146F01CF9000F007C117D /* Runner */, + 63D34C752ECDB4FC0007BD42 /* WeatherWidget */, 97C146EF1CF9000F007C117D /* Products */, 331C8082294A63A400263BE5 /* RunnerTests */, 5437AE1A0D322629A5F820A0 /* Pods */, @@ -174,6 +235,7 @@ children = ( 97C146EE1CF9000F007C117D /* Runner.app */, 331C8081294A63A400263BE5 /* RunnerTests.xctest */, + 63D34C702ECDB4FC0007BD42 /* WeatherWidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -213,8 +275,10 @@ isa = PBXGroup; children = ( 52FA5E142DC8A9EA0008FEB0 /* StoreKit.framework */, - C6838AADB908B55100C5691B /* Pods_Runner.framework */, - 8A29CCFC3290C53FDF724288 /* Pods_RunnerTests.framework */, + 63D34C712ECDB4FC0007BD42 /* WidgetKit.framework */, + 63D34C732ECDB4FC0007BD42 /* SwiftUI.framework */, + A5CB2A3AC234172E4E1A2989 /* Pods_Runner.framework */, + 636E797A6D7770211BDECF3B /* Pods_RunnerTests.framework */, ); name = Frameworks; sourceTree = ""; @@ -226,7 +290,7 @@ isa = PBXNativeTarget; buildConfigurationList = 331C8087294A63A400263BE5 /* Build configuration list for PBXNativeTarget "RunnerTests" */; buildPhases = ( - 745AFA5621F4079D8F5F66F5 /* [CP] Check Pods Manifest.lock */, + 8AD09EF76284A1643F17A971 /* [CP] Check Pods Manifest.lock */, 331C807D294A63A400263BE5 /* Sources */, 331C807F294A63A400263BE5 /* Resources */, 0166E768B450CE02B1DB74B0 /* Frameworks */, @@ -241,23 +305,45 @@ productReference = 331C8081294A63A400263BE5 /* RunnerTests.xctest */; productType = "com.apple.product-type.bundle.unit-test"; }; + 63D34C6F2ECDB4FC0007BD42 /* WeatherWidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 63D34C892ECDB4FC0007BD42 /* Build configuration list for PBXNativeTarget "WeatherWidgetExtension" */; + buildPhases = ( + 63D34C6C2ECDB4FC0007BD42 /* Sources */, + 63D34C6D2ECDB4FC0007BD42 /* Frameworks */, + 63D34C6E2ECDB4FC0007BD42 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 63D34C752ECDB4FC0007BD42 /* WeatherWidget */, + ); + name = WeatherWidgetExtension; + productName = WeatherWidgetExtension; + productReference = 63D34C702ECDB4FC0007BD42 /* WeatherWidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; 97C146ED1CF9000F007C117D /* Runner */ = { isa = PBXNativeTarget; buildConfigurationList = 97C147051CF9000F007C117D /* Build configuration list for PBXNativeTarget "Runner" */; buildPhases = ( - 6BBD6C8E8A651C2033EB74CF /* [CP] Check Pods Manifest.lock */, + DBDD4869969CBE55CEEF60A7 /* [CP] Check Pods Manifest.lock */, 9740EEB61CF901F6004384FC /* Run Script */, 97C146EA1CF9000F007C117D /* Sources */, 97C146EB1CF9000F007C117D /* Frameworks */, 97C146EC1CF9000F007C117D /* Resources */, + 90F524049F0F0D728ACA776F /* [CP] Embed Pods Frameworks */, 9705A1C41CF9048500538489 /* Embed Frameworks */, + 63D34C842ECDB4FC0007BD42 /* Embed Foundation Extensions */, + 139FACA711B6678C67D7D4CA /* [CP] Copy Pods Resources */, 3B06AD1E1E4923F5004D2608 /* Thin Binary */, - 127FFB7A93668EDC7C84088F /* [CP] Embed Pods Frameworks */, - 81A7F11822CA9F8CECB2FD48 /* [CP] Copy Pods Resources */, ); buildRules = ( ); dependencies = ( + 63D34C822ECDB4FC0007BD42 /* PBXTargetDependency */, ); name = Runner; productName = Runner; @@ -271,6 +357,7 @@ isa = PBXProject; attributes = { BuildIndependentTargetsInParallel = YES; + LastSwiftUpdateCheck = 2600; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -278,6 +365,9 @@ CreatedOnToolsVersion = 14.0; TestTargetID = 97C146ED1CF9000F007C117D; }; + 63D34C6F2ECDB4FC0007BD42 = { + CreatedOnToolsVersion = 26.0; + }; 97C146ED1CF9000F007C117D = { CreatedOnToolsVersion = 7.3.1; LastSwiftMigration = 1100; @@ -304,6 +394,7 @@ targets = ( 97C146ED1CF9000F007C117D /* Runner */, 331C8080294A63A400263BE5 /* RunnerTests */, + 63D34C6F2ECDB4FC0007BD42 /* WeatherWidgetExtension */, ); }; /* End PBXProject section */ @@ -316,6 +407,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 63D34C6E2ECDB4FC0007BD42 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EC1CF9000F007C117D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -344,21 +442,21 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - 127FFB7A93668EDC7C84088F /* [CP] Embed Pods Frameworks */ = { + 139FACA711B6678C67D7D4CA /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; showEnvVarsInLog = 0; }; 3B06AD1E1E4923F5004D2608 /* Thin Binary */ = { @@ -377,7 +475,7 @@ shellPath = /bin/sh; shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" embed_and_thin"; }; - 6BBD6C8E8A651C2033EB74CF /* [CP] Check Pods Manifest.lock */ = { + 8AD09EF76284A1643F17A971 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -392,66 +490,66 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 745AFA5621F4079D8F5F66F5 /* [CP] Check Pods Manifest.lock */ = { + 90F524049F0F0D728ACA776F /* [CP] Embed Pods Frameworks */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - "${PODS_PODFILE_DIR_PATH}/Podfile.lock", - "${PODS_ROOT}/Manifest.lock", - ); - name = "[CP] Check Pods Manifest.lock"; + name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( - ); - outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-RunnerTests-checkManifestLockResult.txt", + "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; showEnvVarsInLog = 0; }; - 81A7F11822CA9F8CECB2FD48 /* [CP] Copy Pods Resources */ = { + 9740EEB61CF901F6004384FC /* Run Script */ = { isa = PBXShellScriptBuildPhase; + alwaysOutOfDate = 1; 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", + name = "Run Script"; + outputPaths = ( ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; - showEnvVarsInLog = 0; + shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; }; - 9740EEB61CF901F6004384FC /* Run Script */ = { + DBDD4869969CBE55CEEF60A7 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; - alwaysOutOfDate = 1; buildActionMask = 2147483647; files = ( ); + inputFileListPaths = ( + ); inputPaths = ( + "${PODS_PODFILE_DIR_PATH}/Podfile.lock", + "${PODS_ROOT}/Manifest.lock", + ); + name = "[CP] Check Pods Manifest.lock"; + outputFileListPaths = ( ); - name = "Run Script"; outputPaths = ( + "$(DERIVED_FILE_DIR)/Pods-Runner-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "/bin/sh \"$FLUTTER_ROOT/packages/flutter_tools/bin/xcode_backend.sh\" build\n"; + shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; + showEnvVarsInLog = 0; }; /* End PBXShellScriptBuildPhase section */ @@ -464,6 +562,13 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 63D34C6C2ECDB4FC0007BD42 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; 97C146EA1CF9000F007C117D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -481,6 +586,11 @@ target = 97C146ED1CF9000F007C117D /* Runner */; targetProxy = 331C8085294A63A400263BE5 /* PBXContainerItemProxy */; }; + 63D34C822ECDB4FC0007BD42 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 63D34C6F2ECDB4FC0007BD42 /* WeatherWidgetExtension */; + targetProxy = 63D34C812ECDB4FC0007BD42 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -559,7 +669,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = NO; SDKROOT = iphoneos; SUPPORTED_PLATFORMS = iphoneos; @@ -590,7 +700,7 @@ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "x86_64 i386"; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = DPIP; - IPHONEOS_DEPLOYMENT_TARGET = 13; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -606,13 +716,14 @@ }; 331C8088294A63A400263BE5 /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 4B4531DD011F7A688ACAA691 /* Pods-RunnerTests.debug.xcconfig */; + baseConfigurationReference = 45A64791FD9DF576EA95E92D /* Pods-RunnerTests.debug.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 98Q7JARYZF; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -625,13 +736,14 @@ }; 331C8089294A63A400263BE5 /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 86D7674C61D44CF5E96872D5 /* Pods-RunnerTests.release.xcconfig */; + baseConfigurationReference = 653D9CBB41B0381966E5B4DA /* Pods-RunnerTests.release.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 98Q7JARYZF; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -642,13 +754,14 @@ }; 331C808A294A63A400263BE5 /* Profile */ = { isa = XCBuildConfiguration; - baseConfigurationReference = DF6CF45C0AFEE964FC43297A /* Pods-RunnerTests.profile.xcconfig */; + baseConfigurationReference = 017380C769F48CBC9977E490 /* Pods-RunnerTests.profile.xcconfig */; buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 98Q7JARYZF; GENERATE_INFOPLIST_FILE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 14; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.RunnerTests; PRODUCT_NAME = "$(TARGET_NAME)"; @@ -657,6 +770,140 @@ }; name = Profile; }; + 63D34C852ECDB4FC0007BD42 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WeatherWidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 98Q7JARYZF; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WeatherWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WeatherWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.WeatherWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 63D34C862ECDB4FC0007BD42 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WeatherWidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 98Q7JARYZF; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WeatherWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WeatherWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.WeatherWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 63D34C872ECDB4FC0007BD42 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CODE_SIGN_ENTITLEMENTS = WeatherWidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + COPY_PHASE_STRIP = NO; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 98Q7JARYZF; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WeatherWidget/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WeatherWidget; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 18; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = com.exptech.dpip.dpip.WeatherWidget; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; 97C147031CF9000F007C117D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -708,7 +955,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; SDKROOT = iphoneos; @@ -762,7 +1009,7 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 13.0; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LLVM_LTO = YES_THIN; MACOSX_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; @@ -798,7 +1045,7 @@ GCC_SYMBOLS_PRIVATE_EXTERN = NO; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = DPIP; - IPHONEOS_DEPLOYMENT_TARGET = 13; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -837,7 +1084,7 @@ "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = "x86_64 i386"; INFOPLIST_FILE = Runner/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = DPIP; - IPHONEOS_DEPLOYMENT_TARGET = 13; + IPHONEOS_DEPLOYMENT_TARGET = 14.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -864,6 +1111,16 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 63D34C892ECDB4FC0007BD42 /* Build configuration list for PBXNativeTarget "WeatherWidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 63D34C852ECDB4FC0007BD42 /* Debug */, + 63D34C862ECDB4FC0007BD42 /* Release */, + 63D34C872ECDB4FC0007BD42 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; 97C146E91CF9000F007C117D /* Build configuration list for PBXProject "Runner" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 9d977a1cd..ab5f32e96 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -2,6 +2,7 @@ import CoreLocation import Flutter import UIKit import UserNotifications +import WidgetKit @UIApplicationMain @objc @@ -9,6 +10,7 @@ class AppDelegate: FlutterAppDelegate, CLLocationManagerDelegate { // MARK: - Properties private var locationChannel: FlutterMethodChannel? + private var widgetChannel: FlutterMethodChannel? private var locationManager: CLLocationManager! private var lastSentLocation: CLLocation? private var isLocationEnabled: Bool = false @@ -59,6 +61,14 @@ class AppDelegate: FlutterAppDelegate, CLLocationManagerDelegate { locationChannel?.setMethodCallHandler { [weak self] (call, result) in self?.handleLocationChannelCall(call, result: result) } + + widgetChannel = FlutterMethodChannel( + name: "com.exptech.dpip/widget", + binaryMessenger: controller.binaryMessenger) + + widgetChannel?.setMethodCallHandler { [weak self] (call, result) in + self?.handleWidgetChannelCall(call, result: result) + } } private func setupLocationManager() { @@ -102,6 +112,16 @@ class AppDelegate: FlutterAppDelegate, CLLocationManagerDelegate { result("Location toggled") } + private func handleWidgetChannelCall(_ call: FlutterMethodCall, result: @escaping FlutterResult) { + switch call.method { + case "reloadWidgetTimeline": + reloadWidgetTimeline() + result(true) + default: + result(FlutterMethodNotImplemented) + } + } + // MARK: - Location Management private func toggleLocation(isEnabled: Bool) { @@ -176,9 +196,13 @@ class AppDelegate: FlutterAppDelegate, CLLocationManagerDelegate { private func sendLocationToServer(location: CLLocation) { guard isLocationEnabled else { return } guard let token = apnsToken else { return } - + let latitude = location.coordinate.latitude let longitude = location.coordinate.longitude + + // 保存座標到 widget shared UserDefaults + saveLocationToWidget(latitude: latitude, longitude: longitude) + let appVersion = Bundle.main.object( forInfoDictionaryKey: "CFBundleShortVersionString") as? String @@ -207,6 +231,31 @@ class AppDelegate: FlutterAppDelegate, CLLocationManagerDelegate { task.resume() } + + // MARK: - Widget Data Management + + /// 保存位置資訊到 Widget + private func saveLocationToWidget(latitude: Double, longitude: Double) { + guard let sharedDefaults = UserDefaults(suiteName: "group.com.exptech.dpip") else { + print("Failed to get shared UserDefaults for widget") + return + } + + sharedDefaults.set(latitude, forKey: "widget_latitude") + sharedDefaults.set(longitude, forKey: "widget_longitude") + sharedDefaults.synchronize() + + print("Widget location saved: \(latitude), \(longitude)") + } + + /// 重新載入 Widget Timeline + /// 主動請求系統重新載入 widget 的時間線,確保 widget 顯示最新資料 + private func reloadWidgetTimeline() { + if #available(iOS 14.0, *) { + WidgetCenter.shared.reloadTimelines(ofKind: "WeatherWidget") + print("Widget timeline reload requested") + } + } // MARK: - Background Task Management diff --git a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json index 2a160d6b9..dd5100db0 100644 --- a/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json +++ b/ios/Runner/Assets.xcassets/LaunchImage.imageset/Contents.json @@ -1,5 +1,13 @@ { "images" : [ + { + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, { "filename" : "LaunchImage@3x.png", "idiom" : "universal", diff --git a/ios/Runner/Runner.entitlements b/ios/Runner/Runner.entitlements index be4adcbe1..20704ecc5 100644 --- a/ios/Runner/Runner.entitlements +++ b/ios/Runner/Runner.entitlements @@ -6,5 +6,9 @@ development com.apple.developer.usernotifications.critical-alerts + com.apple.security.application-groups + + group.com.exptech.dpip + diff --git a/ios/Runner/RunnerProfile.entitlements b/ios/Runner/RunnerProfile.entitlements index be4adcbe1..20704ecc5 100644 --- a/ios/Runner/RunnerProfile.entitlements +++ b/ios/Runner/RunnerProfile.entitlements @@ -6,5 +6,9 @@ development com.apple.developer.usernotifications.critical-alerts + com.apple.security.application-groups + + group.com.exptech.dpip + diff --git a/ios/WeatherWidget/Assets.xcassets/AccentColor.colorset/Contents.json b/ios/WeatherWidget/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/ios/WeatherWidget/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/WeatherWidget/Assets.xcassets/AppIcon.appiconset/Contents.json b/ios/WeatherWidget/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..230588010 --- /dev/null +++ b/ios/WeatherWidget/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,35 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/WeatherWidget/Assets.xcassets/Contents.json b/ios/WeatherWidget/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/ios/WeatherWidget/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/WeatherWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json b/ios/WeatherWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/ios/WeatherWidget/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/ios/WeatherWidget/Info.plist b/ios/WeatherWidget/Info.plist new file mode 100644 index 000000000..0f118fb75 --- /dev/null +++ b/ios/WeatherWidget/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/ios/WeatherWidget/WeatherWidget.swift b/ios/WeatherWidget/WeatherWidget.swift new file mode 100644 index 000000000..1e1a342cb --- /dev/null +++ b/ios/WeatherWidget/WeatherWidget.swift @@ -0,0 +1,393 @@ +// +// WeatherWidget.swift +// WeatherWidget +// +// Created by YuYu 1015 on 11/19/R7. +// DPIP 天氣桌面小部件 +// + +import WidgetKit +import SwiftUI + +// MARK: - 天氣資料模型 +struct WeatherData { + let weatherStatus: String + let weatherCode: Int + let temperature: Double + let feelsLike: Double + let humidity: Double + let windSpeed: Double + let windDirection: String + let rain: Double + let stationName: String + let stationDistance: Double + let updateTime: Int + let hasError: Bool + let errorMessage: String + + static var placeholder: WeatherData { + WeatherData( + weatherStatus: "晴天", + weatherCode: 1, + temperature: 25.0, + feelsLike: 23.0, + humidity: 65.0, + windSpeed: 2.5, + windDirection: "東北", + rain: 0.0, + stationName: "中央氣象站", + stationDistance: 2.5, + updateTime: Int(Date().timeIntervalSince1970), + hasError: false, + errorMessage: "" + ) + } +} + +// MARK: - Timeline Provider +struct WeatherProvider: TimelineProvider { + func placeholder(in context: Context) -> WeatherEntry { + WeatherEntry(date: Date(), weather: .placeholder) + } + + func getSnapshot(in context: Context, completion: @escaping (WeatherEntry) -> ()) { + let entry = WeatherEntry(date: Date(), weather: loadWeatherData()) + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + let currentDate = Date() + let weather = loadWeatherData() + let entry = WeatherEntry(date: currentDate, weather: weather) + + // 設定下次更新時間 (15分鐘後 - iOS WidgetKit 建議的最小更新間隔) + let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)! + let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) + + completion(timeline) + } + + // 從 UserDefaults 讀取天氣資料 + private func loadWeatherData() -> WeatherData { + let sharedDefaults = UserDefaults(suiteName: "group.com.exptech.dpip") + + guard let defaults = sharedDefaults else { + return WeatherData.placeholder + } + + let hasError = defaults.bool(forKey: "has_error") + + if hasError { + let errorMessage = defaults.string(forKey: "error_message") ?? "無法載入天氣" + return WeatherData( + weatherStatus: errorMessage, + weatherCode: 0, + temperature: 0, + feelsLike: 0, + humidity: 0, + windSpeed: 0, + windDirection: "-", + rain: 0, + stationName: "", + stationDistance: 0, + updateTime: 0, + hasError: true, + errorMessage: errorMessage + ) + } + + let temperature = defaults.numberValue(forKey: "temperature") ?? 0 + let feelsLike = defaults.numberValue(forKey: "feels_like") ?? 0 + let humidity = defaults.numberValue(forKey: "humidity") ?? 0 + let windSpeed = defaults.numberValue(forKey: "wind_speed") ?? 0 + let rain = defaults.numberValue(forKey: "rain") ?? 0 + let stationDistance = defaults.numberValue(forKey: "station_distance") ?? 0 + let updateRaw = defaults.numberValue(forKey: "update_time") ?? 0 + let updateSeconds = updateRaw >= 1_000_000_000_000 ? updateRaw / 1000 : updateRaw + + return WeatherData( + weatherStatus: defaults.string(forKey: "weather_status") ?? "晴天", + weatherCode: defaults.integer(forKey: "weather_code"), + temperature: temperature, + feelsLike: feelsLike, + humidity: humidity, + windSpeed: windSpeed, + windDirection: defaults.string(forKey: "wind_direction") ?? "-", + rain: rain, + stationName: defaults.string(forKey: "station_name") ?? "", + stationDistance: stationDistance, + updateTime: Int(updateSeconds), + hasError: false, + errorMessage: "" + ) + } +} + +private extension UserDefaults { + func numberValue(forKey key: String) -> Double? { + if let number = value(forKey: key) as? NSNumber { + return number.doubleValue + } + if let string = string(forKey: key), let value = Double(string) { + return value + } + return nil + } +} + +// MARK: - Timeline Entry +struct WeatherEntry: TimelineEntry { + let date: Date + let weather: WeatherData +} + +// MARK: - Widget View +struct WeatherWidgetEntryView : View { + var entry: WeatherProvider.Entry + + @Environment(\.widgetFamily) var widgetFamily + + var body: some View { + contentView() + } + + @ViewBuilder + private func contentView() -> some View { + if entry.weather.hasError { + errorView() + } else { + switch widgetFamily { + case .systemSmall: + smallLayout() + default: + mediumLayout() + } + } + } + + @ViewBuilder + private func errorView() -> some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 32)) + .foregroundColor(.white.opacity(0.7)) + + Text(entry.weather.errorMessage) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.white) + .multilineTextAlignment(.center) + .lineLimit(2) + .minimumScaleFactor(0.8) + } + } + + @ViewBuilder + private func mediumLayout() -> some View { + VStack(alignment: .leading, spacing: 6) { + // 頂部:天氣狀態和時間 + HStack(spacing: 8) { + Image(systemName: getWeatherIcon(code: entry.weather.weatherCode)) + .font(.system(size: 22)) + .foregroundColor(.white) + + Text(entry.weather.weatherStatus) + .font(.system(size: 15, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + + Spacer() + + Text(formatTime(timestamp: entry.weather.updateTime)) + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.8)) + } + + Spacer(minLength: 0) + + // 中間:溫度資訊 + VStack(spacing: 4) { + Text("\(Int(entry.weather.temperature))°") + .font(.system(size: 40, weight: .thin)) + .foregroundColor(.white) + .minimumScaleFactor(0.7) + + Text("體感 \(Int(entry.weather.feelsLike))°") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white.opacity(0.9)) + .padding(.horizontal, 10) + .padding(.vertical, 3) + .background(Color.white.opacity(0.2)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .frame(maxWidth: .infinity) + + Spacer(minLength: 0) + + // 底部:詳細資訊 + HStack(spacing: 6) { + InfoItem(label: "濕度", value: "\(Int(entry.weather.humidity))%") + InfoItem(label: "風速", value: String(format: "%.1fm/s", entry.weather.windSpeed)) + InfoItem(label: "風向", value: entry.weather.windDirection) + InfoItem(label: "降雨", value: String(format: "%.1fmm", entry.weather.rain)) + } + + // 氣象站資訊 + if !entry.weather.stationName.isEmpty { + Text("\(entry.weather.stationName)氣象站 · \(String(format: "%.1f", entry.weather.stationDistance))km") + .font(.system(size: 9)) + .foregroundColor(.white.opacity(0.7)) + .frame(maxWidth: .infinity, alignment: .center) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + } + + @ViewBuilder + private func smallLayout() -> some View { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 6) { + Image(systemName: getWeatherIcon(code: entry.weather.weatherCode)) + .font(.system(size: 20)) + .foregroundColor(.white) + + Text(entry.weather.weatherStatus) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + + Text("\(Int(entry.weather.temperature))°") + .font(.system(size: 38, weight: .thin)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.6) + + Text("體感 \(Int(entry.weather.feelsLike))°") + .font(.system(size: 11)) + .foregroundColor(.white.opacity(0.9)) + .lineLimit(1) + .minimumScaleFactor(0.8) + + HStack(spacing: 8) { + MiniInfoItem(label: "濕度", value: "\(Int(entry.weather.humidity))%") + MiniInfoItem(label: "風速", value: String(format: "%.1fm/s", entry.weather.windSpeed)) + } + + Spacer(minLength: 2) + + Text(formatTime(timestamp: entry.weather.updateTime)) + .font(.system(size: 10)) + .foregroundColor(.white.opacity(0.8)) + .frame(maxWidth: .infinity, alignment: .trailing) + } + } + + // 詳細資訊項目 + private func InfoItem(label: String, value: String) -> some View { + VStack(spacing: 2) { + Text(label) + .font(.system(size: 9)) + .foregroundColor(.white.opacity(0.7)) + + Text(value) + .font(.system(size: 11, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + .frame(maxWidth: .infinity) + } + + private func MiniInfoItem(label: String, value: String) -> some View { + VStack(spacing: 1) { + Text(label) + .font(.system(size: 8)) + .foregroundColor(.white.opacity(0.7)) + .lineLimit(1) + + Text(value) + .font(.system(size: 10, weight: .semibold)) + .foregroundColor(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + .frame(maxWidth: .infinity) + } + + // 取得天氣圖示 + private func getWeatherIcon(code: Int) -> String { + switch code { + case 1: return "sun.max.fill" // 晴天 + case 2, 3: return "cloud.sun.fill" // 多雲 + case 4, 5, 6, 7: return "cloud.fill" // 陰天/霧 + case 8, 9, 10, 11, 12, 13, 14: return "cloud.rain.fill" // 雨天 + case 15, 16, 17, 18: return "cloud.bolt.rain.fill" // 雷雨 + default: return "sun.max.fill" + } + } + + // 格式化時間 + private func formatTime(timestamp: Int) -> String { + let date = Date(timeIntervalSince1970: TimeInterval(timestamp)) + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + formatter.timeZone = TimeZone.current + return formatter.string(from: date) + } +} + +// MARK: - Widget Configuration +struct WeatherWidget: Widget { + let kind: String = "WeatherWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: WeatherProvider()) { entry in + if #available(iOS 17.0, *) { + WeatherWidgetEntryView(entry: entry) + .padding(16) + .containerBackground(for: .widget) { + WeatherWidget.backgroundGradient + } + } else { + WeatherWidgetEntryView(entry: entry) + .padding(16) + .background(WeatherWidget.backgroundGradient) + } + } + .configurationDisplayName("即時天氣") + .description("顯示所在地即時天氣資訊") + .supportedFamilies([.systemSmall, .systemMedium]) + } + + private static var backgroundGradient: LinearGradient { + LinearGradient( + gradient: Gradient(colors: [ + Color(red: 0.12, green: 0.53, blue: 0.90), + Color(red: 0.10, green: 0.46, blue: 0.82), + Color(red: 0.08, green: 0.40, blue: 0.75) + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } +} + +// MARK: - Preview +#if DEBUG +@available(iOS 17.0, *) +#Preview(as: .systemSmall) { + WeatherWidget() +} timeline: { + WeatherEntry(date: .now, weather: .placeholder) +} + +@available(iOS 17.0, *) +#Preview(as: .systemMedium) { + WeatherWidget() +} timeline: { + WeatherEntry(date: .now, weather: .placeholder) +} +#endif diff --git a/ios/WeatherWidget/WeatherWidgetBundle.swift b/ios/WeatherWidget/WeatherWidgetBundle.swift new file mode 100644 index 000000000..b58960d52 --- /dev/null +++ b/ios/WeatherWidget/WeatherWidgetBundle.swift @@ -0,0 +1,18 @@ +// +// WeatherWidgetBundle.swift +// WeatherWidget +// +// Created by YuYu 1015 on 11/19/R7. +// + +import WidgetKit +import SwiftUI + +@main +struct WeatherWidgetBundle: WidgetBundle { + var body: some Widget { + WeatherWidget() + WeatherWidgetControl() + WeatherWidgetLiveActivity() + } +} diff --git a/ios/WeatherWidget/WeatherWidgetControl.swift b/ios/WeatherWidget/WeatherWidgetControl.swift new file mode 100644 index 000000000..0af535e1c --- /dev/null +++ b/ios/WeatherWidget/WeatherWidgetControl.swift @@ -0,0 +1,54 @@ +// +// WeatherWidgetControl.swift +// WeatherWidget +// +// Created by YuYu 1015 on 11/19/R7. +// + +import AppIntents +import SwiftUI +import WidgetKit + +struct WeatherWidgetControl: ControlWidget { + var body: some ControlWidgetConfiguration { + StaticControlConfiguration( + kind: "com.exptech.dpip.dpip.WeatherWidget", + provider: Provider() + ) { value in + ControlWidgetToggle( + "Start Timer", + isOn: value, + action: StartTimerIntent() + ) { isRunning in + Label(isRunning ? "On" : "Off", systemImage: "timer") + } + } + .displayName("Timer") + .description("A an example control that runs a timer.") + } +} + +extension WeatherWidgetControl { + struct Provider: ControlValueProvider { + var previewValue: Bool { + false + } + + func currentValue() async throws -> Bool { + let isRunning = true // Check if the timer is running + return isRunning + } + } +} + +struct StartTimerIntent: SetValueIntent { + static let title: LocalizedStringResource = "Start a timer" + + @Parameter(title: "Timer is running") + var value: Bool + + func perform() async throws -> some IntentResult { + // Start / stop the timer based on `value`. + return .result() + } +} diff --git a/ios/WeatherWidget/WeatherWidgetLiveActivity.swift b/ios/WeatherWidget/WeatherWidgetLiveActivity.swift new file mode 100644 index 000000000..071ab422a --- /dev/null +++ b/ios/WeatherWidget/WeatherWidgetLiveActivity.swift @@ -0,0 +1,80 @@ +// +// WeatherWidgetLiveActivity.swift +// WeatherWidget +// +// Created by YuYu 1015 on 11/19/R7. +// + +import ActivityKit +import WidgetKit +import SwiftUI + +struct WeatherWidgetAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + // Dynamic stateful properties about your activity go here! + var emoji: String + } + + // Fixed non-changing properties about your activity go here! + var name: String +} + +struct WeatherWidgetLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: WeatherWidgetAttributes.self) { context in + // Lock screen/banner UI goes here + VStack { + Text("Hello \(context.state.emoji)") + } + .activityBackgroundTint(Color.cyan) + .activitySystemActionForegroundColor(Color.black) + + } dynamicIsland: { context in + DynamicIsland { + // Expanded UI goes here. Compose the expanded UI through + // various regions, like leading/trailing/center/bottom + DynamicIslandExpandedRegion(.leading) { + Text("Leading") + } + DynamicIslandExpandedRegion(.trailing) { + Text("Trailing") + } + DynamicIslandExpandedRegion(.bottom) { + Text("Bottom \(context.state.emoji)") + // more content + } + } compactLeading: { + Text("L") + } compactTrailing: { + Text("T \(context.state.emoji)") + } minimal: { + Text(context.state.emoji) + } + .widgetURL(URL(string: "http://www.apple.com")) + .keylineTint(Color.red) + } + } +} + +extension WeatherWidgetAttributes { + fileprivate static var preview: WeatherWidgetAttributes { + WeatherWidgetAttributes(name: "World") + } +} + +extension WeatherWidgetAttributes.ContentState { + fileprivate static var smiley: WeatherWidgetAttributes.ContentState { + WeatherWidgetAttributes.ContentState(emoji: "😀") + } + + fileprivate static var starEyes: WeatherWidgetAttributes.ContentState { + WeatherWidgetAttributes.ContentState(emoji: "🤩") + } +} + +#Preview("Notification", as: .content, using: WeatherWidgetAttributes.preview) { + WeatherWidgetLiveActivity() +} contentStates: { + WeatherWidgetAttributes.ContentState.smiley + WeatherWidgetAttributes.ContentState.starEyes +} diff --git a/ios/WeatherWidgetExtension.entitlements b/ios/WeatherWidgetExtension.entitlements new file mode 100644 index 000000000..b194b1d1d --- /dev/null +++ b/ios/WeatherWidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.exptech.dpip + + + diff --git a/lib/app/home/page.dart b/lib/app/home/page.dart index 6552e0f47..c2522f6e0 100644 --- a/lib/app/home/page.dart +++ b/lib/app/home/page.dart @@ -26,6 +26,8 @@ import 'package:dpip/core/gps_location.dart'; import 'package:dpip/core/i18n.dart'; import 'package:dpip/core/preference.dart'; import 'package:dpip/core/providers.dart'; +import 'package:dpip/core/widget_background.dart'; +import 'package:dpip/core/widget_service.dart'; import 'package:dpip/global.dart'; import 'package:dpip/utils/constants.dart'; import 'package:dpip/models/settings/ui.dart'; @@ -72,11 +74,23 @@ class _HomePageState extends State with WidgetsBindingObserver { void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - WidgetsBinding.instance.addPostFrameCallback((_) => _checkVersion()); + WidgetsBinding.instance.addPostFrameCallback((_) { + _checkVersion(); + _initializeWidget(); + }); GlobalProviders.location.$code.addListener(_refresh); _refresh(); } + /// 初始化桌面小部件 + Future _initializeWidget() async { + // 註冊週期性背景更新 (每15分鐘 - Android WorkManager 最小值) + await WidgetBackground.registerPeriodicUpdate(frequencyMinutes: 15); + + // 立即更新一次小部件 + await WidgetService.updateWidget(); + } + @override void dispose() { WidgetsBinding.instance.removeObserver(this); @@ -130,7 +144,13 @@ class _HomePageState extends State with WidgetsBindingObserver { _refreshIndicatorKey.currentState?.show(); - await Future.wait([_fetchWeather(code), _fetchRealtimeRegion(code), _fetchHistory(code, isOutOfService)]); + await Future.wait([ + _fetchWeather(code), + _fetchRealtimeRegion(code), + _fetchHistory(code, isOutOfService), + // 同時更新桌面小部件 + WidgetService.updateWidget(), + ]); if (mounted) { setState(() => _isLoading = false); diff --git a/lib/app/settings/location/select/[city]/page.dart b/lib/app/settings/location/select/[city]/page.dart index 667faa159..2d7f2eeed 100644 --- a/lib/app/settings/location/select/[city]/page.dart +++ b/lib/app/settings/location/select/[city]/page.dart @@ -10,6 +10,7 @@ import 'package:dpip/api/exptech.dart'; import 'package:dpip/app/settings/location/page.dart'; import 'package:dpip/core/i18n.dart'; import 'package:dpip/core/preference.dart'; +import 'package:dpip/core/widget_service.dart'; import 'package:dpip/global.dart'; import 'package:dpip/models/settings/location.dart'; import 'package:dpip/utils/extensions/build_context.dart'; @@ -18,6 +19,7 @@ import 'package:dpip/utils/toast.dart'; import 'package:dpip/widgets/list/list_section.dart'; import 'package:dpip/widgets/list/list_tile.dart'; import 'package:dpip/widgets/ui/loading_icon.dart'; +import 'package:home_widget/home_widget.dart'; class SettingsLocationSelectCityPage extends StatefulWidget { final String city; @@ -77,7 +79,14 @@ class _SettingsLocationSelectCityPageState extends State('widget_latitude', town.lat); + await HomeWidget.saveWidgetData('widget_longitude', town.lng); + + // 5. 更新 widget + await WidgetService.updateWidget(); + + // 6. 返回所在地設定頁面 if (!context.mounted) return; context.popUntil(SettingsLocationPage.route); } catch (e, s) { diff --git a/lib/core/service.dart b/lib/core/service.dart index 48b3ece32..c6adf9f4d 100644 --- a/lib/core/service.dart +++ b/lib/core/service.dart @@ -14,6 +14,7 @@ import 'package:dpip/utils/log.dart'; import 'package:flutter/services.dart'; import 'package:geojson_vi/geojson_vi.dart'; import 'package:geolocator/geolocator.dart'; +import 'package:home_widget/home_widget.dart'; import 'package:maplibre_gl/maplibre_gl.dart'; import 'package:package_info_plus/package_info_plus.dart'; @@ -409,5 +410,22 @@ class LocationService { Preference.locationCode = result?.code; Preference.locationLatitude = position?.latitude; Preference.locationLongitude = position?.longitude; + + // 保存座標到 widget 用的 SharedPreferences + if (position != null) { + await _$saveLocationToWidget(position.latitude, position.longitude); + } + } + + /// 保存位置資訊到 Widget + @pragma('vm:entry-point') + static Future _$saveLocationToWidget(double latitude, double longitude) async { + try { + await HomeWidget.saveWidgetData('widget_latitude', latitude); + await HomeWidget.saveWidgetData('widget_longitude', longitude); + TalkerManager.instance.debug('Widget location saved: $latitude, $longitude'); + } catch (e, s) { + TalkerManager.instance.error('Failed to save location to widget', e, s); + } } } diff --git a/lib/core/widget_background.dart b/lib/core/widget_background.dart new file mode 100644 index 000000000..c5887b7de --- /dev/null +++ b/lib/core/widget_background.dart @@ -0,0 +1,137 @@ +import 'dart:io'; +import 'package:dpip/core/widget_service.dart'; +import 'package:dpip/global.dart'; +import 'package:dpip/utils/log.dart'; +import 'package:workmanager/workmanager.dart'; + +final talker = TalkerManager.instance; + +/// 背景任務處理器 +/// 由 Workmanager 呼叫,在背景執行小部件更新 +@pragma('vm:entry-point') +void callbackDispatcher() { + Workmanager().executeTask((task, inputData) async { + talker.debug('[WidgetBackground] 執行背景任務: $task'); + + try { + switch (task) { + case WidgetBackground.taskUpdateWidget: + // 初始化必要的全域資料 + await Global.init(); + + // 更新小部件 + await WidgetService.updateWidget(); + break; + + default: + talker.warning('[WidgetBackground] 未知任務: $task'); + } + + return Future.value(true); + } catch (e, stack) { + talker.error('[WidgetBackground] 背景任務失敗', e, stack); + return Future.value(false); + } + }); +} + +/// 小部件背景更新管理 +class WidgetBackground { + static const String taskUpdateWidget = 'widget_update_weather'; + + /// 初始化背景任務 + static Future initialize() async { + // 只有 Android 需要初始化 Workmanager + // iOS 使用 WidgetKit 的內建 Timeline 機制 + if (!Platform.isAndroid) { + talker.info('[WidgetBackground] iOS 使用 WidgetKit Timeline,無需額外初始化'); + return; + } + + try { + await Workmanager().initialize( + callbackDispatcher, + isInDebugMode: false, // 設為 true 可查看詳細日誌 + ); + + talker.info('[WidgetBackground] Workmanager 初始化成功'); + } catch (e, stack) { + talker.error('[WidgetBackground] Workmanager 初始化失敗', e, stack); + } + } + + /// 註冊週期性更新任務 + /// + /// [frequencyMinutes] - 更新頻率(分鐘),最小值為15分鐘 (Android WorkManager 系統限制) + /// iOS 不需要註冊週期性任務,使用 WidgetKit Timeline + static Future registerPeriodicUpdate({int frequencyMinutes = 15}) async { + // iOS 使用 WidgetKit 的 Timeline,在 Swift 端自動處理 + if (!Platform.isAndroid) { + talker.info('[WidgetBackground] iOS 使用 WidgetKit Timeline (${frequencyMinutes}分鐘自動更新)'); + return; + } + + try { + // 確保頻率不低於15分鐘 (Android WorkManager 限制) + final frequency = frequencyMinutes < 15 ? 15 : frequencyMinutes; + + await Workmanager().registerPeriodicTask( + taskUpdateWidget, + taskUpdateWidget, + frequency: Duration(minutes: frequency), + constraints: Constraints( + networkType: NetworkType.connected, // 需要網路連線 + requiresBatteryNotLow: false, // 電量低時也執行 + requiresCharging: false, // 不需要充電 + requiresDeviceIdle: false, // 不需要裝置閒置 + requiresStorageNotLow: false, // 不需要儲存空間充足 + ), + existingWorkPolicy: ExistingPeriodicWorkPolicy.replace, // 替換現有任務 + backoffPolicy: BackoffPolicy.exponential, // 失敗後的重試策略 + backoffPolicyDelay: Duration(minutes: 5), // 重試延遲 + ); + + talker.info('[WidgetBackground] 已註冊週期性更新,頻率: $frequency 分鐘'); + } catch (e, stack) { + talker.error('[WidgetBackground] 註冊週期性更新失敗', e, stack); + } + } + + /// 註冊一次性立即更新 + static Future registerImmediateUpdate() async { + try { + await Workmanager().registerOneOffTask( + '${taskUpdateWidget}_immediate', + taskUpdateWidget, + constraints: Constraints( + networkType: NetworkType.connected, + ), + existingWorkPolicy: ExistingWorkPolicy.replace, + ); + + talker.debug('[WidgetBackground] 已註冊立即更新任務'); + } catch (e, stack) { + talker.error('[WidgetBackground] 註冊立即更新失敗', e, stack); + } + } + + /// 取消所有背景任務 + static Future cancelAll() async { + try { + await Workmanager().cancelAll(); + talker.info('[WidgetBackground] 已取消所有背景任務'); + } catch (e, stack) { + talker.error('[WidgetBackground] 取消背景任務失敗', e, stack); + } + } + + /// 取消特定任務 + static Future cancelTask(String taskName) async { + try { + await Workmanager().cancelByUniqueName(taskName); + talker.info('[WidgetBackground] 已取消任務: $taskName'); + } catch (e, stack) { + talker.error('[WidgetBackground] 取消任務失敗: $taskName', e, stack); + } + } +} diff --git a/lib/core/widget_service.dart b/lib/core/widget_service.dart new file mode 100644 index 000000000..06df699b8 --- /dev/null +++ b/lib/core/widget_service.dart @@ -0,0 +1,221 @@ +import 'dart:io'; +import 'dart:math'; +import 'package:dpip/api/exptech.dart'; +import 'package:dpip/api/model/weather_schema.dart'; +import 'package:dpip/core/gps_location.dart'; +import 'package:dpip/core/preference.dart'; +import 'package:dpip/global.dart'; +import 'package:dpip/utils/log.dart'; +import 'package:flutter/services.dart'; +import 'package:home_widget/home_widget.dart'; + +final talker = TalkerManager.instance; + +/// 天氣桌面小部件服務 +/// 負責獲取天氣資料並更新桌面小部件 +class WidgetService { + static const String _widgetNameAndroid = 'WeatherWidgetProvider'; + static const String _widgetNameAndroidSmall = 'WeatherWidgetSmallProvider'; + static const String _widgetNameIOS = 'WeatherWidget'; + + /// 更新小部件資料 + static Future updateWidget() async { + try { + // iOS 需要設定 App Group + if (Platform.isIOS) { + await HomeWidget.setAppGroupId('group.com.exptech.dpip'); + } + + talker.debug('[WidgetService] 開始更新小部件'); + + // 1. 取得位置資訊 + await _ensureLocationData(); + + final lat = Preference.locationLatitude; + final lon = Preference.locationLongitude; + + if (lat == null || lon == null) { + talker.warning('[WidgetService] 位置資訊不可用'); + await _saveErrorState('位置未設定'); + return; + } + + // 2. 獲取天氣資料 + final weather = await _fetchWeatherData(lat, lon); + + if (weather == null) { + talker.warning('[WidgetService] 無法獲取天氣資料'); + await _saveErrorState('無法獲取天氣'); + return; + } + + // 3. 計算體感溫度 + final feelsLike = _calculateFeelsLike( + weather.data.temperature, + weather.data.humidity, + weather.data.wind.speed, + ); + + // 4. 儲存資料到 SharedPreferences/UserDefaults + await _saveWidgetData(weather, feelsLike); + + // 5. 觸發小部件更新 (更新所有小部件變體) + if (Platform.isAndroid) { + // 更新標準版和小方形版 + // 注意:在背景更新時,需要確保真正觸發 widget UI 更新 + await HomeWidget.updateWidget(androidName: _widgetNameAndroid); + await HomeWidget.updateWidget(androidName: _widgetNameAndroidSmall); + + // 額外確保:手動觸發 widget 更新(用於背景更新場景) + // 這會發送 APPWIDGET_UPDATE broadcast 來觸發 onUpdate 方法 + await _forceAndroidWidgetUpdate(); + } else { + await HomeWidget.updateWidget(iOSName: _widgetNameIOS); + + // iOS: 主動請求 Timeline 重新載入 + await _reloadIOSTimeline(); + } + + talker.info('[WidgetService] 小部件更新成功'); + } catch (e, stack) { + talker.error('[WidgetService] 更新失敗', e, stack); + await _saveErrorState('更新失敗'); + } + } + + /// 確保位置資料可用 + static Future _ensureLocationData() async { + await Preference.reload(); + + // 如果是自動定位模式,更新GPS位置 + if (Preference.locationAuto == true) { + await updateLocationFromGPS(); + } else { + // 使用手動設定的位置 + final code = Preference.locationCode; + if (code != null) { + final location = Global.location[code]; + if (location != null) { + Preference.locationLatitude = location.lat; + Preference.locationLongitude = location.lng; + } + } + } + } + + /// 獲取天氣資料 + static Future _fetchWeatherData(double lat, double lon) async { + try { + final response = await ExpTech().getWeatherRealtimeByCoords(lat, lon); + return response; + } catch (e) { + talker.error('[WidgetService] 獲取天氣資料失敗', e); + return null; + } + } + + /// 計算體感溫度 (與 weather_header.dart 相同邏輯) + static double _calculateFeelsLike(double temperature, double humidity, double windSpeed) { + final e = humidity / 100 * 6.105 * exp(17.27 * temperature / (temperature + 237.3)); + return temperature + 0.33 * e - 0.7 * windSpeed - 4.0; + } + + /// 儲存小部件資料 + static Future _saveWidgetData(RealtimeWeather weather, double feelsLike) async { + // 基本天氣資訊 + await HomeWidget.saveWidgetData('weather_status', weather.data.weather); + await HomeWidget.saveWidgetData('weather_code', weather.data.weatherCode); + await HomeWidget.saveWidgetData('temperature', weather.data.temperature); + await HomeWidget.saveWidgetData('feels_like', feelsLike); + + // 詳細氣象資料 + await HomeWidget.saveWidgetData('humidity', weather.data.humidity); + await HomeWidget.saveWidgetData('wind_speed', weather.data.wind.speed); + await HomeWidget.saveWidgetData('wind_direction', weather.data.wind.direction); + await HomeWidget.saveWidgetData('wind_beaufort', weather.data.wind.beaufort); + await HomeWidget.saveWidgetData('pressure', weather.data.pressure); + await HomeWidget.saveWidgetData('rain', weather.data.rain); + await HomeWidget.saveWidgetData('visibility', weather.data.visibility); + + // 陣風資料 (選用) + if (weather.data.gust.speed > 0) { + await HomeWidget.saveWidgetData('gust_speed', weather.data.gust.speed); + await HomeWidget.saveWidgetData('gust_beaufort', weather.data.gust.beaufort); + } + + // 日照時數 (選用) + if (weather.data.sunshine >= 0) { + await HomeWidget.saveWidgetData('sunshine', weather.data.sunshine); + } + + // 氣象站資訊 + await HomeWidget.saveWidgetData('station_name', weather.station.name); + await HomeWidget.saveWidgetData('station_distance', weather.station.distance); + + // 更新時間 + await HomeWidget.saveWidgetData('update_time', weather.time); + + // 狀態標記 + await HomeWidget.saveWidgetData('has_error', false); + await HomeWidget.saveWidgetData('error_message', ''); + } + + /// 儲存錯誤狀態 + static Future _saveErrorState(String message) async { + await HomeWidget.saveWidgetData('has_error', true); + await HomeWidget.saveWidgetData('error_message', message); + + if (Platform.isAndroid) { + await HomeWidget.updateWidget(androidName: _widgetNameAndroid); + await HomeWidget.updateWidget(androidName: _widgetNameAndroidSmall); + await _forceAndroidWidgetUpdate(); + } else { + await HomeWidget.updateWidget(iOSName: _widgetNameIOS); + await _reloadIOSTimeline(); + } + } + + /// 清除小部件資料 (用於登出或重置) + static Future clearWidget() async { + await HomeWidget.saveWidgetData('has_error', true); + await HomeWidget.saveWidgetData('error_message', '已清除'); + + if (Platform.isAndroid) { + await HomeWidget.updateWidget(androidName: _widgetNameAndroid); + await HomeWidget.updateWidget(androidName: _widgetNameAndroidSmall); + await _forceAndroidWidgetUpdate(); + } else { + await HomeWidget.updateWidget(iOSName: _widgetNameIOS); + await _reloadIOSTimeline(); + } + } + + /// 強制觸發 Android widget 更新(用於背景更新場景) + /// 發送 APPWIDGET_UPDATE broadcast 來確保 widget UI 真正更新 + static Future _forceAndroidWidgetUpdate() async { + try { + const platform = MethodChannel('com.exptech.dpip/widget'); + await platform.invokeMethod('updateWidgets'); + talker.debug('[WidgetService] 已手動觸發 Android widget 更新'); + } catch (e) { + // 如果方法通道不存在,使用備用方案:再次調用 updateWidget + talker.debug('[WidgetService] 方法通道不可用,使用備用更新方式: $e'); + // 備用方案:再次調用 updateWidget 確保更新 + await HomeWidget.updateWidget(androidName: _widgetNameAndroid); + await HomeWidget.updateWidget(androidName: _widgetNameAndroidSmall); + } + } + + /// 重新載入 iOS widget Timeline + /// 使用 WidgetCenter 主動請求系統重新載入 Timeline + static Future _reloadIOSTimeline() async { + try { + const platform = MethodChannel('com.exptech.dpip/widget'); + await platform.invokeMethod('reloadWidgetTimeline'); + talker.debug('[WidgetService] 已請求 iOS widget Timeline 重新載入'); + } catch (e) { + talker.warning('[WidgetService] 無法重新載入 iOS Timeline: $e'); + // iOS Timeline 會自動在設定的時間更新,這裡只是主動請求 + } + } +} diff --git a/lib/main.dart b/lib/main.dart index 85265e6f4..28c53a6e3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -10,6 +10,7 @@ import 'package:dpip/core/preference.dart'; import 'package:dpip/core/providers.dart'; import 'package:dpip/core/service.dart'; import 'package:dpip/core/update.dart'; +import 'package:dpip/core/widget_background.dart'; import 'package:dpip/global.dart'; import 'package:dpip/utils/log.dart'; import 'package:firebase_crashlytics/firebase_crashlytics.dart'; @@ -59,6 +60,7 @@ void main() async { _loggedTask('AppLocalizations.load', AppLocalizations.load()), _loggedTask('LocationNameLocalizations.load', LocationNameLocalizations.load()), _loggedTask('WeatherStationLocalizations.load', WeatherStationLocalizations.load()), + _loggedTask('WidgetBackground.initialize', WidgetBackground.initialize()), ]); final futureWaitEnd = DateTime.now(); diff --git a/pubspec.lock b/pubspec.lock index e4539b58d..2aaec0d09 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -592,6 +592,14 @@ packages: url: "https://pub.dev" source: hosted version: "5.3.4" + home_widget: + dependency: "direct main" + description: + name: home_widget + sha256: "908d033514a981f829fd98213909e11a428104327be3b422718aa643ac9d084a" + url: "https://pub.dev" + source: hosted + version: "0.8.1" http: dependency: "direct main" description: @@ -1485,6 +1493,39 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + workmanager: + dependency: "direct main" + description: + path: workmanager + ref: main + resolved-ref: "7f4f870d593f858b0f55bd344c9863a6099cb30f" + url: "https://github.com/fluttercommunity/flutter_workmanager.git" + source: git + version: "0.9.0+3" + workmanager_android: + dependency: transitive + description: + name: workmanager_android + sha256: "9ae744db4ef891f5fcd2fb8671fccc712f4f96489a487a1411e0c8675e5e8cb7" + url: "https://pub.dev" + source: hosted + version: "0.9.0+2" + workmanager_apple: + dependency: transitive + description: + name: workmanager_apple + sha256: "1cc12ae3cbf5535e72f7ba4fde0c12dd11b757caf493a28e22d684052701f2ca" + url: "https://pub.dev" + source: hosted + version: "0.9.1+2" + workmanager_platform_interface: + dependency: transitive + description: + name: workmanager_platform_interface + sha256: f40422f10b970c67abb84230b44da22b075147637532ac501729256fcea10a47 + url: "https://pub.dev" + source: hosted + version: "0.9.1+1" xdg_directories: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 1319e6bef..fd1aadacc 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -69,6 +69,12 @@ dependencies: talker_flutter: ^5.0.2 timezone: ^0.10.0 url_launcher: ^6.3.1 + home_widget: ^0.8.1 + workmanager: + git: + url: https://github.com/fluttercommunity/flutter_workmanager.git + path: workmanager + ref: main dev_dependencies: build_runner: ^2.10.0