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);