diff --git a/assets/i18n/zh-CN.json b/assets/i18n/zh-CN.json index 0d017ae2..4d6d04d2 100644 --- a/assets/i18n/zh-CN.json +++ b/assets/i18n/zh-CN.json @@ -455,6 +455,7 @@ "checkInResultT2": "中平", "checkInResultT3": "吉", "checkInResultT4": "大吉", + "checkInResultT5": "特殊", "accountProfileView": "查看个人资料", "unspecified": "未指定", "added": "已添加", diff --git a/assets/i18n/zh-OG.json b/assets/i18n/zh-OG.json index 0041bed9..ed320c61 100644 --- a/assets/i18n/zh-OG.json +++ b/assets/i18n/zh-OG.json @@ -455,6 +455,7 @@ "checkInResultT2": "Mid", "checkInResultT3": "Good", "checkInResultT4": "Best", + "checkInResultT5": "Special", "accountProfileView": "View Profile", "unspecified": "Unspecified", "added": "Added", diff --git a/assets/i18n/zh-TW.json b/assets/i18n/zh-TW.json index 02709123..0d6a5035 100644 --- a/assets/i18n/zh-TW.json +++ b/assets/i18n/zh-TW.json @@ -455,6 +455,7 @@ "checkInResultT2": "中平", "checkInResultT3": "吉", "checkInResultT4": "大吉", + "checkInResultT5": "特殊", "accountProfileView": "查看個人資料", "unspecified": "未指定", "added": "已添加", diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 64aed007..39f5dec2 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -467,8 +467,6 @@ 7301DB062F08D99C008390F3 /* SolianWidgetExtension */, ); name = SolianWidgetExtensionExtension; - packageProductDependencies = ( - ); productName = SolianWidgetExtensionExtension; productReference = 7301DB012F08D99C008390F3 /* SolianWidgetExtensionExtension.appex */; productType = "com.apple.product-type.app-extension"; @@ -632,6 +630,7 @@ knownRegions = ( en, Base, + "zh-Hans", ); mainGroup = 97C146E51CF9000F007C117D; preferredProjectObjectVersion = 77; @@ -1203,7 +1202,7 @@ INFOPLIST_FILE = SolianWidgetExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = SolianWidgetExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1249,7 +1248,7 @@ INFOPLIST_FILE = SolianWidgetExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = SolianWidgetExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", @@ -1292,7 +1291,7 @@ INFOPLIST_FILE = SolianWidgetExtension/Info.plist; INFOPLIST_KEY_CFBundleDisplayName = SolianWidgetExtension; INFOPLIST_KEY_NSHumanReadableCopyright = ""; - IPHONEOS_DEPLOYMENT_TARGET = 26.2; + IPHONEOS_DEPLOYMENT_TARGET = 17.0; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/Frameworks", diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/Solian Watch App.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Solian Watch App.xcscheme new file mode 100644 index 00000000..b4634195 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/Solian Watch App.xcscheme @@ -0,0 +1,78 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianBroadcastExtension.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianBroadcastExtension.xcscheme new file mode 100644 index 00000000..6453e400 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianBroadcastExtension.xcscheme @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianNotificationService.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianNotificationService.xcscheme new file mode 100644 index 00000000..8ddd9116 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianNotificationService.xcscheme @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianShareExtension.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianShareExtension.xcscheme new file mode 100644 index 00000000..3d28553a --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianShareExtension.xcscheme @@ -0,0 +1,96 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianWidgetExtensionExtension.xcscheme b/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianWidgetExtensionExtension.xcscheme new file mode 100644 index 00000000..0d898864 --- /dev/null +++ b/ios/Runner.xcodeproj/xcshareddata/xcschemes/SolianWidgetExtensionExtension.xcscheme @@ -0,0 +1,128 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/ios/Runner/AppDelegate.swift b/ios/Runner/AppDelegate.swift index 06cc5129..c54ff4b7 100644 --- a/ios/Runner/AppDelegate.swift +++ b/ios/Runner/AppDelegate.swift @@ -1,4 +1,5 @@ import Flutter +import WidgetKit import UIKit import WatchConnectivity @@ -12,6 +13,7 @@ import WatchConnectivity didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { syncDefaultsToGroup() + WidgetCenter.shared.reloadAllTimelines() UNUserNotificationCenter.current().delegate = notifyDelegate @@ -31,6 +33,9 @@ import WatchConnectivity GeneratedPluginRegistrant.register(with: self) + // Setup widget sync method channel + setupWidgetSyncChannel() + // Always initialize and retain a strong reference if WCSession.isSupported() { AppDelegate.sharedWatchConnectivityService = WatchConnectivityService.shared @@ -40,6 +45,30 @@ import WatchConnectivity return super.application(application, didFinishLaunchingWithOptions: launchOptions) } + + private func setupWidgetSyncChannel() { + let controller = window?.rootViewController as? FlutterViewController + let channel = FlutterMethodChannel(name: "dev.solsynth.solian/widget", binaryMessenger: controller!.binaryMessenger) + + channel.setMethodCallHandler { [weak self] (call, result) in + if call.method == "syncToWidget" { + syncDefaultsToGroup() + WidgetCenter.shared.reloadAllTimelines() + result(true) + } else { + result(FlutterMethodNotImplemented) + } + } + } + + override func applicationDidEnterBackground(_ application: UIApplication) { + syncDefaultsToGroup() + WidgetCenter.shared.reloadAllTimelines() + } + + override func applicationWillTerminate(_ application: UIApplication) { + syncDefaultsToGroup() + } } final class WatchConnectivityService: NSObject, WCSessionDelegate { diff --git a/ios/Runner/Services/GroupDefaultSync.swift b/ios/Runner/Services/GroupDefaultSync.swift index 1c4759a6..4afaa3e8 100644 --- a/ios/Runner/Services/GroupDefaultSync.swift +++ b/ios/Runner/Services/GroupDefaultSync.swift @@ -10,26 +10,32 @@ import Foundation private let flutterKeyPrefix = "flutter." private let flutterKeysToSync: [String] = [ - "dyn_user_tk" + "dyn_user_tk", + "app_server_url" ] func syncDefaultsToGroup() { + print("[iOS] syncDefaultsToGroup() called") + let standard = UserDefaults.standard - let shared = UserDefaults(suiteName: "dev.solsynth.solian") - + let shared = UserDefaults(suiteName: "group.solsynth.solian") + guard let shared else { print("[iOS] App Group UserDefaults not available") return } - + for key in flutterKeysToSync { - guard key.hasPrefix(flutterKeyPrefix) else { continue } - - if let value = standard.object(forKey: key) { - print("[iOS] Syncing key to App Group: \(key)") - shared.set(value, forKey: key) + let prefixedKey = key.starts(with: flutterKeyPrefix) ? key : flutterKeyPrefix + key + + if let value = standard.object(forKey: prefixedKey) { + print("[iOS] Syncing key to App Group: \(prefixedKey)") + shared.set(value, forKey: prefixedKey) + } else { + print("[iOS] Key \(prefixedKey) was not found in the app data, skipping...") } } - + shared.synchronize() + print("[iOS] Sync completed") } diff --git a/ios/SolianWidgetExtension/Base.lproj/Localizable.strings b/ios/SolianWidgetExtension/Base.lproj/Localizable.strings new file mode 100644 index 00000000..d8c7ae4b --- /dev/null +++ b/ios/SolianWidgetExtension/Base.lproj/Localizable.strings @@ -0,0 +1,16 @@ +/* Check In Level Names */ +"checkInResultT0" = "Worst"; +"checkInResultT1" = "Poor"; +"checkInResultT2" = "Mid"; +"checkInResultT3" = "Good"; +"checkInResultT4" = "Best"; +"checkInResultT5" = "Special"; + +/* Widget UI Strings */ +"checkIn" = "Check In"; +"tapToCheckIn" = "Tap to check in today"; +"error" = "Error"; +"openAppToRefresh" = "Open app to refresh"; +"loading" = "Loading..."; +"rewardPoints" = "%d"; +"rewardExperience" = "%d XP"; diff --git a/ios/SolianWidgetExtension/SolianWidgetExtension.swift b/ios/SolianWidgetExtension/SolianWidgetExtension.swift index cda50ed6..8b3b871b 100644 --- a/ios/SolianWidgetExtension/SolianWidgetExtension.swift +++ b/ios/SolianWidgetExtension/SolianWidgetExtension.swift @@ -8,51 +8,483 @@ import WidgetKit import SwiftUI +struct CheckInTip: Codable { + let isPositive: Bool + let title: String + let content: String + + enum CodingKeys: String, CodingKey { + case isPositive = "is_positive" + case title + case content + } +} + +struct CheckInAccount: Codable { + let id: String + let nick: String? + let profile: CheckInProfile? +} + +struct CheckInProfile: Codable { + let picture: String? +} + +struct CheckInResult: Codable { + let id: String + let level: Int + let rewardPoints: Int + let rewardExperience: Int + let tips: [CheckInTip] + let accountId: String + let account: CheckInAccount? + let createdAt: String + let updatedAt: String + let deletedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case level + case rewardPoints = "reward_points" + case rewardExperience = "reward_experience" + case tips + case accountId = "account_id" + case account + case createdAt = "created_at" + case updatedAt = "updated_at" + case deletedAt = "deleted_at" + } + + var createdDate: Date? { + ISO8601DateFormatter().date(from: createdAt) + } +} + +enum RemoteError: Error { + case missingCredentials + case invalidURL + case invalidResponse + case httpError(Int) + case decodingError +} + +extension RemoteError: LocalizedError { + var errorDescription: String? { + switch self { + case .missingCredentials: + return "Please open the app to sign in." + case .invalidURL: + return "Invalid server configuration." + case .invalidResponse: + return "Server returned an invalid response." + case .httpError(let code): + return "Server error (\(code))." + case .decodingError: + return "Failed to read server data." + } + } +} + +struct TokenData: Codable { + let token: String +} + +class WidgetNetworkService { + private let appGroup = "group.solsynth.solian" + private let tokenKey = "flutter.dyn_user_tk" + private let urlKey = "flutter.app_server_url" + + private lazy var session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 10.0 + configuration.timeoutIntervalForResource = 10.0 + configuration.waitsForConnectivity = false + return URLSession(configuration: configuration) + }() + + private var userDefaults: UserDefaults? { + UserDefaults(suiteName: appGroup) + } + + var token: String? { + guard let tokenString = userDefaults?.string(forKey: tokenKey) else { + return nil + } + + guard let data = tokenString.data(using: .utf8) else { + return nil + } + + do { + let tokenData = try JSONDecoder().decode(TokenData.self, from: data) + return tokenData.token + } catch { + print("[WidgetKit] Failed to decode token: \(error)") + return nil + } + } + + var baseURL: String { + return userDefaults?.string(forKey: urlKey) ?? "https://api.solian.app" + } + + func makeRequest( + path: String, + method: String = "GET", + headers: [String: String] = [:] + ) async throws -> T? { + guard let token = token else { + throw RemoteError.missingCredentials + } + + guard let url = URL(string: "\(baseURL)\(path)") else { + throw RemoteError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = method + request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + + for (key, value) in headers { + request.setValue(value, forHTTPHeaderField: key) + } + + request.timeoutInterval = 10.0 + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw RemoteError.invalidResponse + } + + switch httpResponse.statusCode { + case 200...299: + let decoder = JSONDecoder() + return try decoder.decode(T.self, from: data) + case 404: + return nil + default: + throw RemoteError.httpError(httpResponse.statusCode) + } + } +} + +class CheckInService { + private let networkService = WidgetNetworkService() + + func fetchCheckInResult() async throws -> CheckInResult? { + return try await networkService.makeRequest(path: "/pass/accounts/me/check-in") + } +} + +struct CheckInEntry: TimelineEntry { + let date: Date + let result: CheckInResult? + let error: String? + let isLoading: Bool + + static func placeholder() -> CheckInEntry { + CheckInEntry(date: Date(), result: nil, error: nil, isLoading: true) + } +} + struct Provider: TimelineProvider { - func placeholder(in context: Context) -> SimpleEntry { - SimpleEntry(date: Date(), emoji: "😀") + private let apiService = CheckInService() + + func placeholder(in context: Context) -> CheckInEntry { + CheckInEntry.placeholder() } - func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { - let entry = SimpleEntry(date: Date(), emoji: "😀") - completion(entry) + func getSnapshot(in context: Context, completion: @escaping (CheckInEntry) -> ()) { + Task { + let result = try? await apiService.fetchCheckInResult() + let entry = CheckInEntry(date: Date(), result: result, error: nil, isLoading: false) + completion(entry) + } } func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { - var entries: [SimpleEntry] = [] - - // Generate a timeline consisting of five entries an hour apart, starting from the current date. - let currentDate = Date() - for hourOffset in 0 ..< 5 { - let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! - let entry = SimpleEntry(date: entryDate, emoji: "😀") - entries.append(entry) + Task { + let currentDate = Date() + + do { + let result = try await apiService.fetchCheckInResult() + let entry = CheckInEntry(date: currentDate, result: result, error: nil, isLoading: false) + + let nextUpdateDate: Date + if let result = result, let createdDate = result.createdDate { + let calendar = Calendar.current + if let tomorrow = calendar.date(byAdding: .day, value: 1, to: createdDate) { + nextUpdateDate = min(tomorrow, calendar.date(byAdding: .hour, value: 1, to: currentDate)!) + } else { + nextUpdateDate = calendar.date(byAdding: .hour, value: 1, to: currentDate)! + } + } else { + nextUpdateDate = Calendar.current.date(byAdding: .minute, value: 30, to: currentDate)! + } + + let timeline = Timeline(entries: [entry], policy: .after(nextUpdateDate)) + completion(timeline) + } catch { + let entry = CheckInEntry(date: currentDate, result: nil, error: error.localizedDescription, isLoading: false) + let nextUpdate = Calendar.current.date(byAdding: .minute, value: 10, to: currentDate)! + let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) + completion(timeline) + } } - - let timeline = Timeline(entries: entries, policy: .atEnd) - completion(timeline) } - -// func relevances() async -> WidgetRelevances { -// // Generate a list containing the contexts this widget is relevant in. -// } } -struct SimpleEntry: TimelineEntry { - let date: Date - let emoji: String -} - -struct SolianWidgetExtensionEntryView : View { +struct CheckInWidgetEntryView: View { var entry: Provider.Entry + @Environment(\.widgetFamily) var family var body: some View { - VStack { - Text("Time:") - Text(entry.date, style: .time) - - Text("Emoji:") - Text(entry.emoji) + if let result = entry.result { + CheckedInView(result: result) + } else if entry.isLoading { + LoadingView() + } else if let error = entry.error { + ErrorView(error: error) + } else { + NotCheckedInView() + } + } + + private func getLevelName(for level: Int) -> String { + let key = "checkInResultT\(level)" + return NSLocalizedString(key, comment: "Check-in result level name") + } + + @ViewBuilder + private func CheckedInView(result: CheckInResult) -> some View { + Link(destination: URL(string: "solian://dashboard")!) { + VStack(alignment: .leading, spacing: isAccessory ? 2 : 8) { + HStack(spacing: 4) { + Image(systemName: "flame.fill") + .foregroundColor(.secondary) + .font(isAccessory ? .caption : .title3) + Text(getLevelName(for: result.level)) + .font(isAccessory ? .caption2 : .headline) + .fontWeight(.bold) + Spacer() + } + + if !result.tips.isEmpty { + if isAccessory { + let positiveTips = result.tips.filter { $0.isPositive } + let negativeTips = result.tips.filter { !$0.isPositive } + + VStack(alignment: .leading, spacing: 1) { + if let positiveTip = positiveTips.first { + HStack(spacing: 2) { + Image(systemName: "hand.thumbsup.fill") + .font(.caption2) + .foregroundColor(.secondary) + Text(positiveTip.title) + .font(.caption2) + .foregroundColor(.primary) + .lineLimit(1) + } + } + if let negativeTip = negativeTips.first { + HStack(spacing: 2) { + Image(systemName: "hand.thumbsdown.fill") + .font(.caption2) + .foregroundColor(.secondary) + Text(negativeTip.title) + .font(.caption2) + .foregroundColor(.primary) + .lineLimit(1) + } + } + } + } else if family == .systemSmall { + let positiveTips = result.tips.filter { $0.isPositive } + let negativeTips = result.tips.filter { !$0.isPositive } + + VStack(alignment: .leading, spacing: 4) { + if let positiveTip = positiveTips.first { + HStack(spacing: 4) { + Image(systemName: "hand.thumbsup.fill") + .font(.caption) + .foregroundColor(.secondary) + Text(positiveTip.title) + .font(.caption) + .foregroundColor(.primary) + Spacer() + } + } + if let negativeTip = negativeTips.first { + HStack(spacing: 4) { + Image(systemName: "hand.thumbsdown.fill") + .font(.caption) + .foregroundColor(.secondary) + Text(negativeTip.title) + .font(.caption) + .foregroundColor(.primary) + Spacer() + } + } + } + } else { + let positiveTips = result.tips.filter { $0.isPositive } + let negativeTips = result.tips.filter { !$0.isPositive } + + VStack(alignment: .leading, spacing: 4) { + if !positiveTips.isEmpty { + HStack(spacing: 6) { + Image(systemName: "hand.thumbsup.fill") + .font(.caption) + .foregroundColor(.secondary) + ForEach(Array(positiveTips.prefix(3)), id: \.title) { tip in + Text(tip.title) + .font(.caption) + .foregroundColor(.primary) + if tip.title != positiveTips.last?.title { + Text("•") + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + } + } + + if !negativeTips.isEmpty { + HStack(spacing: 6) { + Image(systemName: "hand.thumbsdown.fill") + .font(.caption) + .foregroundColor(.secondary) + ForEach(Array(negativeTips.prefix(3)), id: \.title) { tip in + Text(tip.title) + .font(.caption) + .foregroundColor(.primary) + if tip.title != negativeTips.last?.title { + Text("•") + .font(.caption) + .foregroundColor(.secondary) + } + } + Spacer() + } + } + } + } + } else if !isAccessory && family != .systemSmall { + Text("No fortune today") + .font(.caption) + .foregroundColor(.secondary) + } + + if !isAccessory { + Spacer() + WidgetFooter() + } + } + .padding(isAccessory ? 0 : (family == .systemSmall ? 6 : 12)) + } + } + + @ViewBuilder + private func WidgetFooter() -> some View { + HStack { + Text("Solian") + .font(.caption2) + .fontWeight(.medium) + .foregroundColor(.secondary) + Spacer() + } + } + + private var isAccessory: Bool { + if #available(iOS 16.0, *) { + if case .accessoryRectangular = family { + return true + } + } + return false + } + + @ViewBuilder + private func NotCheckedInView() -> some View { + Link(destination: URL(string: "solian://dashboard")!) { + VStack(alignment: .leading, spacing: isAccessory ? 2 : 8) { + HStack(spacing: 4) { + Image(systemName: "flame") + .foregroundColor(.secondary) + .font(isAccessory ? .caption : .title3) + Text(NSLocalizedString("checkIn", comment: "Check In")) + .font(isAccessory ? .caption2 : .headline) + .fontWeight(.bold) + Spacer() + } + + if !isAccessory { + Text(NSLocalizedString("tapToCheckIn", comment: "Tap to check in today")) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + + WidgetFooter() + } + } + .padding(isAccessory ? 0 : (family == .systemSmall ? 6 : 12)) + } + } + + @ViewBuilder + private func LoadingView() -> some View { + VStack(alignment: .leading, spacing: isAccessory ? 2 : 8) { + HStack(spacing: 4) { + ProgressView() + .scaleEffect(isAccessory ? 0.6 : 0.8) + Text(NSLocalizedString("loading", comment: "Loading...")) + .font(isAccessory ? .caption2 : .caption) + .foregroundColor(.secondary) + Spacer() + } + + if !isAccessory { + Spacer() + WidgetFooter() + } + } + .padding(isAccessory ? 0 : 12) + } + + @ViewBuilder + private func ErrorView(error: String) -> some View { + Link(destination: URL(string: "solian://dashboard")!) { + VStack(alignment: .leading, spacing: isAccessory ? 2 : 8) { + HStack(spacing: 4) { + Image(systemName: "exclamationmark.triangle") + .foregroundColor(.secondary) + .font(isAccessory ? .caption : .title3) + Text(NSLocalizedString("error", comment: "Error")) + .font(isAccessory ? .caption2 : .headline) + Spacer() + } + + if !isAccessory { + Text(NSLocalizedString("openAppToRefresh", comment: "Open app to refresh")) + .font(.caption) + .foregroundColor(.secondary) + + Text(error) + .font(.footnote) + .foregroundStyle(.secondary) + .lineLimit(nil) + .multilineTextAlignment(.leading) + + Spacer() + + WidgetFooter() + } + } + .padding(isAccessory ? 0 : (family == .systemSmall ? 6 : 12)) } } } @@ -63,22 +495,83 @@ struct SolianWidgetExtension: Widget { var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: Provider()) { entry in if #available(iOS 17.0, *) { - SolianWidgetExtensionEntryView(entry: entry) + CheckInWidgetEntryView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } else { - SolianWidgetExtensionEntryView(entry: entry) + CheckInWidgetEntryView(entry: entry) .padding() .background() } } - .configurationDisplayName("My Widget") - .description("This is an example widget.") + .configurationDisplayName("Check In") + .description("View your daily check-in status") + .supportedFamilies(supportedFamilies) + } + + private var supportedFamilies: [WidgetFamily] { + #if os(iOS) + return [.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular] + #else + return [.systemSmall, .systemMedium, .systemLarge] + #endif } } #Preview(as: .systemSmall) { SolianWidgetExtension() } timeline: { - SimpleEntry(date: .now, emoji: "😀") - SimpleEntry(date: .now, emoji: "🤩") + CheckInEntry(date: .now, result: nil, error: nil, isLoading: false) } + +#Preview(as: .systemMedium) { + SolianWidgetExtension() +} timeline: { + CheckInEntry( + date: .now, + result: CheckInResult( + id: "test-id", + level: 2, + rewardPoints: 10, + rewardExperience: 100, + tips: [ + CheckInTip(isPositive: true, title: "Good Luck", content: "Great day"), + CheckInTip(isPositive: true, title: "Creative", content: "Inspiration"), + CheckInTip(isPositive: false, title: "Shopping", content: "Expensive") + ], + accountId: "account-id", + account: nil, + createdAt: ISO8601DateFormatter().string(from: Date()), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ), + error: nil, + isLoading: false + ) +} + +#if os(iOS) +#Preview(as: .accessoryRectangular) { + SolianWidgetExtension() +} timeline: { + CheckInEntry( + date: .now, + result: CheckInResult( + id: "test-id", + level: 4, + rewardPoints: 50, + rewardExperience: 500, + tips: [ + CheckInTip(isPositive: true, title: "Lucky", content: "Great fortune"), + CheckInTip(isPositive: true, title: "Success", content: "Opportunity") + ], + accountId: "account-id", + account: nil, + createdAt: ISO8601DateFormatter().string(from: Date()), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ), + error: nil, + isLoading: false + ) +} +#endif diff --git a/ios/SolianWidgetExtension/zh-Hans.lproj/Localizable.strings b/ios/SolianWidgetExtension/zh-Hans.lproj/Localizable.strings new file mode 100644 index 00000000..319b7dce --- /dev/null +++ b/ios/SolianWidgetExtension/zh-Hans.lproj/Localizable.strings @@ -0,0 +1,16 @@ +/* Check In Level Names */ +"checkInResultT0" = "大凶"; +"checkInResultT1" = "凶"; +"checkInResultT2" = "中平"; +"checkInResultT3" = "吉"; +"checkInResultT4" = "大吉"; +"checkInResultT5" = "特殊"; + +/* Widget UI Strings */ +"checkIn" = "打卡"; +"tapToCheckIn" = "点击今日打卡"; +"error" = "错误"; +"openAppToRefresh" = "打开应用以刷新"; +"loading" = "加载中..."; +"rewardPoints" = "%d"; +"rewardExperience" = "%d 经验值"; diff --git a/ios/SolianWidgetExtensionExtension.entitlements b/ios/SolianWidgetExtensionExtension.entitlements index 7121c32b..b9042704 100644 --- a/ios/SolianWidgetExtensionExtension.entitlements +++ b/ios/SolianWidgetExtensionExtension.entitlements @@ -6,5 +6,7 @@ group.solsynth.solian + com.apple.security.network.client + diff --git a/lib/main.dart b/lib/main.dart index f524dcaf..ead2f22c 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -20,6 +20,7 @@ import 'package:island/pods/userinfo.dart'; import 'package:island/pods/websocket.dart'; import 'package:island/route.dart'; import 'package:island/services/notify.dart'; +import 'package:island/services/widget_sync_service.dart'; import 'package:island/services/timezone.dart'; import 'package:island/widgets/alert.dart'; import 'package:island/widgets/app_scaffold.dart'; @@ -282,6 +283,11 @@ class IslandApp extends HookConsumerWidget { ref.listen(websocketStateProvider, (_, state) { talker.info('[WebSocket] $state'); }); + ref.listen(userInfoProvider, (_, user) { + if (user.value != null) { + WidgetSyncService().syncToWidget(); + } + }); Future(() { userNotifier.fetchUser().then((_) { final user = ref.watch(userInfoProvider); diff --git a/lib/services/widget_sync_service.dart b/lib/services/widget_sync_service.dart new file mode 100644 index 00000000..fca37e92 --- /dev/null +++ b/lib/services/widget_sync_service.dart @@ -0,0 +1,24 @@ +import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; + +class WidgetSyncService { + static const _channel = MethodChannel('dev.solsynth.solian/widget'); + static final _instance = WidgetSyncService._internal(); + + factory WidgetSyncService() => _instance; + + WidgetSyncService._internal(); + + bool get _isSupported => !kIsWeb && (Platform.isAndroid || Platform.isIOS); + + Future syncToWidget() async { + if (!_isSupported) return; + + try { + await _channel.invokeMethod('syncToWidget'); + } catch (e) { + debugPrint('Failed to sync to widget: $e'); + } + } +} diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart index 9133bdf5..e4f1f2c0 100644 --- a/lib/widgets/app_wrapper.dart +++ b/lib/widgets/app_wrapper.dart @@ -272,7 +272,7 @@ class AppWrapper extends HookConsumerWidget { final router = ref.read(routerProvider); if (uri.queryParameters.isNotEmpty) { path = Uri.parse( - path, + path == '/dashboard' ? '/' : path, ).replace(queryParameters: uri.queryParameters).toString(); } router.push(path);