From 6abee8d8bd01eeb4916c49e6bc102421d520756f Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 4 Jan 2026 01:04:49 +0800 Subject: [PATCH] :sparkles: iOS notification widget --- .../Base.lproj/Localizable.strings | 17 - .../SolianCheckInWidget.swift | 218 +---- .../SolianNotificationWidget.swift | 838 ++++++++++++++++++ .../SolianWidgetExtensionBundle.swift | 1 + .../WidgetNetworking.swift | 136 +++ .../en.lproj/Localizable.strings | 18 + .../es.lproj/Localizable.strings | 14 + .../ja.lproj/Localizable.strings | 14 + .../ko.lproj/Localizable.strings | 14 + .../zh-Hans.lproj/Localizable.strings | 18 + .../zh-Hant.lproj/Localizable.strings | 14 + lib/widgets/app_wrapper.dart | 12 +- 12 files changed, 1127 insertions(+), 187 deletions(-) delete mode 100644 ios/SolianWidgetExtension/Base.lproj/Localizable.strings create mode 100644 ios/SolianWidgetExtension/SolianNotificationWidget.swift create mode 100644 ios/SolianWidgetExtension/WidgetNetworking.swift diff --git a/ios/SolianWidgetExtension/Base.lproj/Localizable.strings b/ios/SolianWidgetExtension/Base.lproj/Localizable.strings deleted file mode 100644 index 80424263..00000000 --- a/ios/SolianWidgetExtension/Base.lproj/Localizable.strings +++ /dev/null @@ -1,17 +0,0 @@ -/* 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"; -"footer" = "Solian Journal"; diff --git a/ios/SolianWidgetExtension/SolianCheckInWidget.swift b/ios/SolianWidgetExtension/SolianCheckInWidget.swift index 75a1044e..5a065a71 100644 --- a/ios/SolianWidgetExtension/SolianCheckInWidget.swift +++ b/ios/SolianWidgetExtension/SolianCheckInWidget.swift @@ -88,134 +88,6 @@ struct NotableDay: Codable { } } -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 - - print("[WidgetKit] [Network] Requesting: \(baseURL)\(path)") - - let (data, response) = try await session.data(for: request) - - guard let httpResponse = response as? HTTPURLResponse else { - throw RemoteError.invalidResponse - } - - print("[WidgetKit] [Network] Status: \(httpResponse.statusCode), Data length: \(data.count)") - - if let jsonString = String(data: data, encoding: .utf8) { - print("[WidgetKit] [Network] Response: \(jsonString.prefix(500))") - } - - switch httpResponse.statusCode { - case 200...299: - let decoder = JSONDecoder() - do { - let result = try decoder.decode(T.self, from: data) - print("[WidgetKit] [Network] Successfully decoded response") - return result - } catch { - print("[WidgetKit] [Network] Decoding error: \(error.localizedDescription)") - print("[WidgetKit] [Network] Expected type: \(String(describing: T.self))") - throw RemoteError.decodingError - } - case 404: - print("[WidgetKit] [Network] Resource not found (404)") - return nil - default: - print("[WidgetKit] [Network] HTTP Error: \(httpResponse.statusCode)") - throw RemoteError.httpError(httpResponse.statusCode) - } - } -} - class CheckInService { private let networkService = WidgetNetworkService() @@ -694,52 +566,60 @@ struct CheckInWidgetEntryView: View { } } +struct CheckInWidgetRootView: View { + var entry: Provider.Entry + @Environment(\.colorScheme) var colorScheme + + var body: some View { + if #available(iOS 17.0, *) { + ZStack { + CheckInWidgetEntryView(entry: entry) + + if entry.result != nil || entry.notableDay != nil { + GeometryReader { geometry in + Image(colorScheme == .dark ? "CloudyLambDark" : "CloudyLamb") + .resizable() + .aspectRatio(contentMode: .fit) + .frame( + width: geometry.size.width * 0.9, + height: geometry.size.width * 0.9 + ) + .opacity(0.18) + .mask( + LinearGradient( + gradient: Gradient(colors: [ + Color.white, + Color.white, + Color.clear + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .position( + x: geometry.size.width * 0.9, + y: 20 + ) + } + .allowsHitTesting(false) + } + } + .containerBackground(.fill.tertiary, for: .widget) + .padding(.vertical, 8) + } else { + CheckInWidgetEntryView(entry: entry) + .padding() + .background() + } + } +} + struct SolianCheckInWidget: Widget { let kind: String = "SolianCheckInWidget" var body: some WidgetConfiguration { StaticConfiguration(kind: kind, provider: Provider()) { entry in - @Environment(\.colorScheme) var colorScheme - if #available(iOS 17.0, *) { - ZStack { - CheckInWidgetEntryView(entry: entry) - - if entry.result != nil || entry.notableDay != nil { - GeometryReader { geometry in - Image(colorScheme == .dark ? "CloudyLambDark" : "CloudyLamb") - .resizable() - .aspectRatio(contentMode: .fit) - .frame( - width: geometry.size.width * 0.9, - height: geometry.size.width * 0.9 - ) - .opacity(0.18) - .mask( - LinearGradient( - gradient: Gradient(colors: [ - Color.white, - Color.white, - Color.clear - ]), - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .position( - x: geometry.size.width * 0.9, - y: 20 - ) - } - .allowsHitTesting(false) - } - } - .containerBackground(.fill.tertiary, for: .widget) - .padding(.vertical, 8) - } else { - CheckInWidgetEntryView(entry: entry) - .padding() - .background() - } + CheckInWidgetRootView(entry: entry) } .configurationDisplayName("Check In") .description("View your daily check-in status") diff --git a/ios/SolianWidgetExtension/SolianNotificationWidget.swift b/ios/SolianWidgetExtension/SolianNotificationWidget.swift new file mode 100644 index 00000000..447ab8d9 --- /dev/null +++ b/ios/SolianWidgetExtension/SolianNotificationWidget.swift @@ -0,0 +1,838 @@ +// +// SolianNotificationWidget.swift +// Runner +// +// Created by LittleSheep on 2026/1/4. +// + +import Foundation +import WidgetKit +import SwiftUI + +// MARK: - Notification Widget + +struct NotificationMeta: Codable { + let pfp: String? + let images: [String]? + let actionUri: String? + + enum CodingKeys: String, CodingKey { + case pfp + case images + case actionUri = "action_uri" + } +} + +struct SnNotification: Codable, Identifiable { + let id: String + let topic: String + let title: String + let subtitle: String + let content: String + let meta: NotificationMeta? + let priority: Int + let viewedAt: String? + let accountId: String + let createdAt: String + let updatedAt: String + let deletedAt: String? + + enum CodingKeys: String, CodingKey { + case id + case topic + case title + case subtitle + case content + case meta + case priority + case viewedAt = "viewed_at" + case accountId = "account_id" + case createdAt = "created_at" + case updatedAt = "updated_at" + case deletedAt = "deleted_at" + } + + var createdDate: Date? { + ISO8601DateFormatter().date(from: createdAt) + } + + var isUnread: Bool { + return viewedAt == nil + } + + func getTopicIcon() -> String { + switch topic { + case "post.replies": + return "arrow.uturn.backward" + case "wallet.transactions": + return "wallet.pass" + case "relationships.friends.request": + return "person.badge.plus" + case "invites.chat": + return "message.badge" + case "invites.realm": + return "globe" + case "auth.login": + return "arrow.right.square" + case "posts.new": + return "doc.badge.plus" + case "wallet.orders.paid": + return "baggage" + case "posts.reactions.new": + return "face.smiling" + default: + return "bell" + } + } +} + +struct NotificationEntry: TimelineEntry { + let date: Date + let notifications: [SnNotification]? + let unreadCount: Int + let error: String? + let isLoading: Bool + + static func placeholder() -> NotificationEntry { + NotificationEntry(date: Date(), notifications: nil, unreadCount: 0, error: nil, isLoading: true) + } +} + +class NotificationService { + private let networkService = WidgetNetworkService() + + private lazy var session: URLSession = { + let configuration = URLSessionConfiguration.ephemeral + configuration.timeoutIntervalForRequest = 10.0 + configuration.timeoutIntervalForResource = 10.0 + configuration.waitsForConnectivity = false + return URLSession(configuration: configuration) + }() + + func fetchRecentNotifications(take: Int = 5) async throws -> [SnNotification] { + guard let token = networkService.token else { + throw RemoteError.missingCredentials + } + + let baseURL = networkService.baseURL + guard let url = URL(string: "\(baseURL)/ring/notifications?unmark=true&take=\(take)") else { + throw RemoteError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + 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() + let notifications = try decoder.decode([SnNotification].self, from: data) + return notifications + case 404: + return [] + default: + throw RemoteError.httpError(httpResponse.statusCode) + } + } + + func fetchUnreadCount() async throws -> Int { + guard let token = networkService.token else { + throw RemoteError.missingCredentials + } + + let baseURL = networkService.baseURL + guard let url = URL(string: "\(baseURL)/ring/notifications/count") else { + throw RemoteError.invalidURL + } + + var request = URLRequest(url: url) + request.httpMethod = "GET" + request.setValue("AtField \(token)", forHTTPHeaderField: "Authorization") + request.setValue("application/json", forHTTPHeaderField: "Accept") + 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: + if let count = try? JSONSerialization.jsonObject(with: data) as? Int { + return count + } else if let count = try? JSONSerialization.jsonObject(with: data) as? Double { + return Int(count) + } + return 0 + case 404: + return 0 + default: + throw RemoteError.httpError(httpResponse.statusCode) + } + } +} + +struct NotificationProvider: TimelineProvider { + private let notificationService = NotificationService() + + func placeholder(in context: Context) -> NotificationEntry { + NotificationEntry.placeholder() + } + + func getSnapshot(in context: Context, completion: @escaping (NotificationEntry) -> ()) { + Task { + print("[WidgetKit] [NotificationProvider] Getting snapshot...") + async let notifications = try? await notificationService.fetchRecentNotifications(take: 5) + async let unreadCount = try? await notificationService.fetchUnreadCount() + + let notifs = try? await notifications + let unread = (try? await unreadCount) ?? 0 + + print("[WidgetKit] [NotificationProvider] Snapshot - Notifications: \(notifs?.count ?? 0), Unread: \(unread)") + + let entry = NotificationEntry(date: Date(), notifications: notifs, unreadCount: unread, error: nil, isLoading: false) + completion(entry) + } + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + Task { + let currentDate = Date() + print("[WidgetKit] [NotificationProvider] Getting timeline at \(currentDate)...") + + do { + let takeLimit: Int + switch context.family { + case .systemSmall: + takeLimit = 3 + case .systemMedium: + takeLimit = 5 + case .systemLarge: + takeLimit = 10 + default: + takeLimit = 5 + } + + async let notifications = try await notificationService.fetchRecentNotifications(take: takeLimit) + async let unreadCount = try await notificationService.fetchUnreadCount() + + let notifs = try await notifications + let unread = try await unreadCount + + print("[WidgetKit] [NotificationProvider] Timeline - Notifications: \(notifs.count), Unread: \(unread)") + + let entry = NotificationEntry(date: currentDate, notifications: notifs, unreadCount: unread, error: nil, isLoading: false) + + let nextUpdate = Calendar.current.date(byAdding: .minute, value: 15, to: currentDate)! + print("[WidgetKit] [NotificationProvider] Next update at: \(nextUpdate)") + let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) + completion(timeline) + } catch { + print("[WidgetKit] [NotificationProvider] Error in getTimeline: \(error.localizedDescription)") + let entry = NotificationEntry(date: currentDate, notifications: nil, unreadCount: 0, error: error.localizedDescription, isLoading: false) + let nextUpdate = Calendar.current.date(byAdding: .minute, value: 5, to: currentDate)! + let timeline = Timeline(entries: [entry], policy: .after(nextUpdate)) + completion(timeline) + } + } + } +} + +struct NotificationWidgetEntryView: View { + var entry: NotificationProvider.Entry + @Environment(\.widgetFamily) var family + + var body: some View { + if let notifications = entry.notifications, !notifications.isEmpty { + HasNotificationsView(notifications: notifications, unreadCount: entry.unreadCount) + } else if entry.isLoading { + LoadingView() + } else if let error = entry.error { + ErrorView(error: error) + } else { + EmptyView() + } + } + + private var isCompact: Bool { + family == .systemSmall || isAccessory + } + + private var isAccessory: Bool { + if #available(iOS 16.0, *) { + if case .accessoryRectangular = family { + return true + } + } + return false + } + + @ViewBuilder + private func HasNotificationsView(notifications: [SnNotification], unreadCount: Int) -> some View { + Link(destination: URL(string: "solian://notifications")!) { + if isCompact { + if isAccessory { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: "bell.fill") + .font(.caption2) + .foregroundColor(.orange) + .padding(.leading, 1.5) + + Text(NSLocalizedString("notifications", comment: "Notifications")) + .font(.caption2) + .fontWeight(.bold) + + Spacer() + } + + if unreadCount > 0 { + HStack(spacing: 4) { + Text("\(unreadCount)") + .font(.caption2) + .fontWeight(.bold) + .padding(.horizontal, 6) + .background( + Capsule() + .fill(Color.blue.opacity(0.5)) + ) + + Text(NSLocalizedString("unread", comment: "unread")) + .font(.caption2) + } + } + + Text("on the Solar Network") + .font(.caption2) + .foregroundColor(.secondary) + .padding(.horizontal, 1.5) + } + } else { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "bell.fill") + .font(.subheadline) + .foregroundColor(.orange) + + Text(NSLocalizedString("notifications", comment: "Notifications")) + .font(.headline) + .fontWeight(.bold) + + Spacer() + }.padding(.bottom, 8) + + Spacer(minLength: 2) + + if unreadCount > 0 { + VStack(alignment: .leading, spacing: 2) { + Text("\(unreadCount)") + .font(.system(.largeTitle, design: .rounded)) + .fontWeight(.bold) + + + } + } + + Spacer(minLength: 2) + + if unreadCount > 0 { + Text(NSLocalizedString("unreadNotifications", comment: "unread notifications")) + .font(.caption) + .foregroundColor(.secondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + .fixedSize(horizontal: false, vertical: true) + } else { + Text(NSLocalizedString("noUnreadNotifications", comment: "no unread notifications")) + .font(.caption) + .foregroundColor(.secondary) + } + } + .padding(12) + } + } else { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 6) { + Image(systemName: "bell.fill") + .font(.subheadline) + .foregroundColor(.orange) + + Text(NSLocalizedString("notifications", comment: "Notifications")) + .font(.headline) + .fontWeight(.bold) + + Spacer() + + if unreadCount > 0 { + Text("\(unreadCount)") + .font(.caption) + .fontWeight(.bold) + .foregroundColor(.white) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(.blue) + .clipShape(Capsule()) + } + }.padding(.bottom, 8) + + let displayCount = family == .systemMedium ? 1 : 3 + let displayNotifications = Array(notifications.prefix(displayCount)) + + VStack(alignment: .leading, spacing: 4) { + ForEach(displayNotifications) { notification in + NotificationItemView(notification: notification, compact: false) + } + } + + if family == .systemMedium { + Spacer() + } else { + Spacer() + Text(NSLocalizedString("tapToViewAll", comment: "Tap to view all notifications")) + .font(.caption) + .foregroundColor(.secondary) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(12) + } + } + } + + @ViewBuilder + private func NotificationItemView(notification: SnNotification, compact: Bool) -> some View { + HStack(alignment: .top, spacing: compact ? 6 : 12) { + if compact { + Image(systemName: notification.getTopicIcon()) + .font(.caption) + .foregroundColor(.secondary) + } else { + ZStack { + Circle() + .fill(Color.gray.opacity(0.2)) + .frame(width: 32, height: 32) + + Image(systemName: notification.getTopicIcon()) + .font(.caption) + .foregroundColor(.primary) + } + } + + VStack(alignment: .leading, spacing: 2) { + Text(notification.title) + .font(compact ? .caption : .subheadline) + .fontWeight(notification.isUnread ? .semibold : .regular) + .lineLimit(1) + + if !compact && !notification.subtitle.isEmpty { + Text(notification.subtitle) + .font(.caption) + .lineLimit(1) + } + + if let createdDate = notification.createdDate { + Text(formatRelativeTime(createdDate)) + .font(.caption2) + .foregroundColor(.secondary) + .padding(.top, 2) + } + } + + Spacer() + + if notification.isUnread { + Circle() + .fill(Color.blue) + .frame(width: compact ? 6 : 8, height: compact ? 6 : 8) + .padding(.trailing, 6) + .padding(.top, 8) + } + } + .padding(.vertical, compact ? 2 : 4) + } + + @ViewBuilder + private func NotificationCompactItem(notification: SnNotification) -> some View { + HStack(spacing: 4) { + Image(systemName: notification.getTopicIcon()) + .font(.caption2) + .foregroundColor(.secondary) + + Text(notification.title) + .font(.caption2) + .lineLimit(1) + .fontWeight(notification.isUnread ? .semibold : .regular) + + Spacer() + } + } + + @ViewBuilder + private func EmptyView() -> some View { + Link(destination: URL(string: "solian://notifications")!) { + VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) { + HStack(spacing: 6) { + Image(systemName: "bell") + .font(isAccessory ? .caption : .title3) + .foregroundColor(.secondary) + + Text(NSLocalizedString("notifications", comment: "Notifications")) + .font(isAccessory ? .caption2 : .headline) + .fontWeight(.bold) + + Spacer() + } + + if !isAccessory { + Text(NSLocalizedString("noNotifications", comment: "No notifications yet")) + .font(.caption) + .foregroundColor(.secondary) + + Spacer() + } + } + .padding(isAccessory ? 4 : 12) + } + } + + @ViewBuilder + private func LoadingView() -> some View { + VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) { + HStack(spacing: 6) { + ProgressView() + .scaleEffect(isAccessory ? 0.6 : 0.8) + Text(NSLocalizedString("loading", comment: "Loading...")) + .font(isAccessory ? .caption2 : .caption) + .foregroundColor(.secondary) + Spacer() + } + + if !isAccessory { + Spacer() + } + } + .padding(isAccessory ? 4 : 12) + } + + @ViewBuilder + private func ErrorView(error: String) -> some View { + Link(destination: URL(string: "solian://notifications")!) { + VStack(alignment: .leading, spacing: isAccessory ? 4 : 8) { + HStack(spacing: 6) { + 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() + } + } + .padding(isAccessory ? 4 : 12) + } + } + + private func formatRelativeTime(_ date: Date) -> String { + let now = Date() + let interval = now.timeIntervalSince(date) + + if interval < 60 { + return NSLocalizedString("justNow", comment: "Just now") + } else if interval < 3600 { + let minutes = Int(interval / 60) + return String(format: NSLocalizedString("minutesAgo", comment: "%d min ago"), minutes) + } else if interval < 86400 { + let hours = Int(interval / 3600) + return String(format: NSLocalizedString("hoursAgo", comment: "%d hr ago"), hours) + } else { + let days = Int(interval / 86400) + return String(format: NSLocalizedString("daysAgo", comment: "%d d ago"), days) + } + } +} + +struct NotificationWidgetRootView: View { + var entry: NotificationProvider.Entry + @Environment(\.colorScheme) var colorScheme + + var body: some View { + if #available(iOS 17.0, *) { + ZStack { + NotificationWidgetEntryView(entry: entry) + + if let notifications = entry.notifications, !notifications.isEmpty { + GeometryReader { geometry in + Image(colorScheme == .dark ? "CloudyLambDark" : "CloudyLamb") + .resizable() + .aspectRatio(contentMode: .fit) + .frame( + width: geometry.size.width * 0.9, + height: geometry.size.width * 0.9 + ) + .opacity(0.12) + .mask( + LinearGradient( + gradient: Gradient(colors: [ + Color.white, + Color.white, + Color.clear + ]), + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .position( + x: geometry.size.width * 0.85, + y: 20 + ) + } + .allowsHitTesting(false) + } + } + .containerBackground(.fill.tertiary, for: .widget) + .padding(.vertical, 8) + } else { + NotificationWidgetEntryView(entry: entry) + .padding() + .background() + } + } +} + +struct SolianNotificationWidget: Widget { + let kind: String = "SolianNotificationWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: NotificationProvider()) { entry in + NotificationWidgetRootView(entry: entry) + } + .configurationDisplayName("Notifications") + .description("View your recent notifications") + .supportedFamilies(supportedFamilies) + } + + private var supportedFamilies: [WidgetFamily] { +#if os(iOS) + return [.systemSmall, .systemMedium, .systemLarge, .accessoryRectangular] +#else + return [.systemSmall, .systemMedium, .systemLarge] +#endif + } +} + +#Preview(as: .accessoryRectangular) { + SolianNotificationWidget() +} timeline: { + NotificationEntry( + date: .now, + notifications: [ + SnNotification( + id: "1", + topic: "post.replies", + title: "New reply to your post", + subtitle: "Someone replied to your message", + content: "This is notification content", + meta: nil, + priority: 0, + viewedAt: nil, + accountId: "acc-1", + createdAt: ISO8601DateFormatter().string(from: Date()), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ), + SnNotification( + id: "2", + topic: "relationships.friends.request", + title: "New friend request", + subtitle: "You have a pending friend request", + content: "Someone wants to be your friend", + meta: nil, + priority: 0, + viewedAt: ISO8601DateFormatter().string(from: Date()), + accountId: "acc-1", + createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-3600)), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ) + ], + unreadCount: 1, + error: nil, + isLoading: false + ) +} + +#Preview(as: .systemSmall) { + SolianNotificationWidget() +} timeline: { + NotificationEntry( + date: .now, + notifications: [ + SnNotification( + id: "1", + topic: "post.replies", + title: "New reply to your post", + subtitle: "Someone replied to your message", + content: "This is notification content", + meta: nil, + priority: 0, + viewedAt: nil, + accountId: "acc-1", + createdAt: ISO8601DateFormatter().string(from: Date()), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ), + SnNotification( + id: "2", + topic: "relationships.friends.request", + title: "New friend request", + subtitle: "You have a pending friend request", + content: "Someone wants to be your friend", + meta: nil, + priority: 0, + viewedAt: ISO8601DateFormatter().string(from: Date()), + accountId: "acc-1", + createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-3600)), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ) + ], + unreadCount: 1, + error: nil, + isLoading: false + ) +} + +#Preview(as: .systemMedium) { + SolianNotificationWidget() +} timeline: { + NotificationEntry( + date: .now, + notifications: [ + SnNotification( + id: "1", + topic: "post.replies", + title: "New reply to your post", + subtitle: "Someone replied to your message", + content: "This is notification content", + meta: nil, + priority: 0, + viewedAt: nil, + accountId: "acc-1", + createdAt: ISO8601DateFormatter().string(from: Date()), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ), + SnNotification( + id: "2", + topic: "relationships.friends.request", + title: "New friend request", + subtitle: "You have a pending friend request", + content: "Someone wants to be your friend", + meta: nil, + priority: 0, + viewedAt: nil, + accountId: "acc-1", + createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-3600)), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ), + SnNotification( + id: "3", + topic: "invites.chat", + title: "New chat invite", + subtitle: "You've been invited to a chat", + content: "Join the conversation", + meta: nil, + priority: 0, + viewedAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-86400)), + accountId: "acc-1", + createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-86400)), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ) + ], + unreadCount: 2, + error: nil, + isLoading: false + ) +} + +#if os(iOS) +#Preview(as: .systemLarge) { + SolianNotificationWidget() +} timeline: { + NotificationEntry( + date: .now, + notifications: [ + SnNotification( + id: "1", + topic: "post.replies", + title: "New reply", + subtitle: "Someone replied", + content: "Content", + meta: nil, + priority: 0, + viewedAt: nil, + accountId: "acc-1", + createdAt: ISO8601DateFormatter().string(from: Date()), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ), + SnNotification( + id: "2", + topic: "relationships.friends.request", + title: "New friend request", + subtitle: "You have a pending friend request", + content: "Someone wants to be your friend", + meta: nil, + priority: 0, + viewedAt: nil, + accountId: "acc-1", + createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-3600)), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ), + SnNotification( + id: "3", + topic: "invites.chat", + title: "New chat invite", + subtitle: "You've been invited to a chat", + content: "Join the conversation", + meta: nil, + priority: 0, + viewedAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-86400)), + accountId: "acc-1", + createdAt: ISO8601DateFormatter().string(from: Date().addingTimeInterval(-86400)), + updatedAt: ISO8601DateFormatter().string(from: Date()), + deletedAt: nil + ) + ], + unreadCount: 3, + error: nil, + isLoading: false + ) +} +#endif diff --git a/ios/SolianWidgetExtension/SolianWidgetExtensionBundle.swift b/ios/SolianWidgetExtension/SolianWidgetExtensionBundle.swift index 927951f0..518a18fd 100644 --- a/ios/SolianWidgetExtension/SolianWidgetExtensionBundle.swift +++ b/ios/SolianWidgetExtension/SolianWidgetExtensionBundle.swift @@ -12,5 +12,6 @@ import SwiftUI struct SolianWidgetExtensionBundle: WidgetBundle { var body: some Widget { SolianCheckInWidget() + SolianNotificationWidget() } } diff --git a/ios/SolianWidgetExtension/WidgetNetworking.swift b/ios/SolianWidgetExtension/WidgetNetworking.swift new file mode 100644 index 00000000..ddfeeb14 --- /dev/null +++ b/ios/SolianWidgetExtension/WidgetNetworking.swift @@ -0,0 +1,136 @@ +// +// Networking.swift +// SolianWidgetExtensionExtension +// +// Created by LittleSheep on 2026/1/4. +// + +import Foundation + +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 + + print("[WidgetKit] [Network] Requesting: \(baseURL)\(path)") + + let (data, response) = try await session.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse else { + throw RemoteError.invalidResponse + } + + print("[WidgetKit] [Network] Status: \(httpResponse.statusCode), Data length: \(data.count)") + + if let jsonString = String(data: data, encoding: .utf8) { + print("[WidgetKit] [Network] Response: \(jsonString.prefix(500))") + } + + switch httpResponse.statusCode { + case 200...299: + let decoder = JSONDecoder() + do { + let result = try decoder.decode(T.self, from: data) + print("[WidgetKit] [Network] Successfully decoded response") + return result + } catch { + print("[WidgetKit] [Network] Decoding error: \(error.localizedDescription)") + print("[WidgetKit] [Network] Expected type: \(String(describing: T.self))") + throw RemoteError.decodingError + } + case 404: + print("[WidgetKit] [Network] Resource not found (404)") + return nil + default: + print("[WidgetKit] [Network] HTTP Error: \(httpResponse.statusCode)") + throw RemoteError.httpError(httpResponse.statusCode) + } + } +} diff --git a/ios/SolianWidgetExtension/en.lproj/Localizable.strings b/ios/SolianWidgetExtension/en.lproj/Localizable.strings index 7287037f..f5c4518c 100644 --- a/ios/SolianWidgetExtension/en.lproj/Localizable.strings +++ b/ios/SolianWidgetExtension/en.lproj/Localizable.strings @@ -21,3 +21,21 @@ "notableDayIs" = "%@ is %@"; "notableDayUpcoming" = "Upcoming"; "today" = "Today"; + +/* Notification Widget Strings */ +"notifications" = "Notifications"; +"noNotifications" = "No notifications yet"; +"tapToViewAll" = "Tap to view all notifications"; +"justNow" = "Just now"; +"minutesAgo" = "%d min ago"; +"hoursAgo" = "%d hr ago"; +"daysAgo" = "%d d ago"; +"pleaseSignIn" = "Please open the app to sign in."; +"invalidServerConfig" = "Invalid server configuration."; +"invalidResponse" = "Server returned an invalid response."; +"httpError" = "Server error (%d)."; +"decodingError" = "Failed to read server data."; +"unreadNotifications" = "Notification(s) unread"; +"noNotifications" = "No notifications"; +"noUnreadNotifications" = "All notifications are read"; +"unread" = "unread"; diff --git a/ios/SolianWidgetExtension/es.lproj/Localizable.strings b/ios/SolianWidgetExtension/es.lproj/Localizable.strings index e412fb36..f689618b 100644 --- a/ios/SolianWidgetExtension/es.lproj/Localizable.strings +++ b/ios/SolianWidgetExtension/es.lproj/Localizable.strings @@ -20,3 +20,17 @@ "notableDayToday" = "%@ es hoy!"; "notableDayIs" = "%@ es %@"; "today" = "Hoy"; + +/* Notification Widget Strings */ +"notifications" = "Notificaciones"; +"noNotifications" = "Aún no hay notificaciones"; +"tapToViewAll" = "Toca para ver todas las notificaciones"; +"justNow" = "Ahora mismo"; +"minutesAgo" = "hace %d min"; +"hoursAgo" = "hace %d hr"; +"daysAgo" = "hace %d días"; +"pleaseSignIn" = "Por favor, abre la aplicación para iniciar sesión."; +"invalidServerConfig" = "Configuración del servidor no válida."; +"invalidResponse" = "El servidor devolvió una respuesta no válida."; +"httpError" = "Error del servidor (%d)."; +"decodingError" = "Error al leer los datos del servidor."; diff --git a/ios/SolianWidgetExtension/ja.lproj/Localizable.strings b/ios/SolianWidgetExtension/ja.lproj/Localizable.strings index f1901b02..08cf46ee 100644 --- a/ios/SolianWidgetExtension/ja.lproj/Localizable.strings +++ b/ios/SolianWidgetExtension/ja.lproj/Localizable.strings @@ -20,3 +20,17 @@ "notableDayToday" = "%@は今日です!"; "notableDayIs" = "%@は%@です"; "today" = "今日"; + +/* Notification Widget Strings */ +"notifications" = "通知"; +"noNotifications" = "まだ通知はありません"; +"tapToViewAll" = "タップしてすべての通知を表示"; +"justNow" = "たった今"; +"minutesAgo" = "%d分前"; +"hoursAgo" = "%d時間前"; +"daysAgo" = "%d日前"; +"pleaseSignIn" = "アプリを開いてサインインしてください。"; +"invalidServerConfig" = "サーバー設定が無効です。"; +"invalidResponse" = "サーバーから無効な応答が返されました。"; +"httpError" = "サーバーエラー (%d)。"; +"decodingError" = "サーバーデータの読み込みに失敗しました。"; diff --git a/ios/SolianWidgetExtension/ko.lproj/Localizable.strings b/ios/SolianWidgetExtension/ko.lproj/Localizable.strings index dd1e0971..ef874d67 100644 --- a/ios/SolianWidgetExtension/ko.lproj/Localizable.strings +++ b/ios/SolianWidgetExtension/ko.lproj/Localizable.strings @@ -20,3 +20,17 @@ "notableDayToday" = "%@ 오늘입니다!"; "notableDayIs" = "%@ 은/는 %@"; "today" = "오늘"; + +/* Notification Widget Strings */ +"notifications" = "알림"; +"noNotifications" = "아직 알림이 없습니다"; +"tapToViewAll" = "탭하여 모든 알림 보기"; +"justNow" = "방금"; +"minutesAgo" = "%d분 전"; +"hoursAgo" = "%d시간 전"; +"daysAgo" = "%d일 전"; +"pleaseSignIn" = "앱을 열어 로그인하세요."; +"invalidServerConfig" = "서버 구성이 올바르지 않습니다."; +"invalidResponse" = "서버에서 잘못된 응답을 받았습니다."; +"httpError" = "서버 오류 (%d)."; +"decodingError" = "서버 데이터 읽기에 실패했습니다."; diff --git a/ios/SolianWidgetExtension/zh-Hans.lproj/Localizable.strings b/ios/SolianWidgetExtension/zh-Hans.lproj/Localizable.strings index 4c71e100..4846d99a 100644 --- a/ios/SolianWidgetExtension/zh-Hans.lproj/Localizable.strings +++ b/ios/SolianWidgetExtension/zh-Hans.lproj/Localizable.strings @@ -21,3 +21,21 @@ "notableDayIs" = "%@ 是 %@"; "notableDayUpcoming" = "接下来"; "today" = "今天"; + +/* Notification Widget Strings */ +"notifications" = "通知"; +"noNotifications" = "还没有通知"; +"tapToViewAll" = "点击查看所有通知"; +"justNow" = "刚刚"; +"minutesAgo" = "%d分钟前"; +"hoursAgo" = "%d小时前"; +"daysAgo" = "%d天前"; +"pleaseSignIn" = "请打开应用登录。"; +"invalidServerConfig" = "服务器配置无效。"; +"invalidResponse" = "服务器返回了无效响应。"; +"httpError" = "服务器错误 (%d)。"; +"decodingError" = "读取服务器数据失败。"; +"unreadNotifications" = "条通知未读"; +"noNotifications" = "没有通知"; +"noUnreadNotifications" = "没有未读通知"; +"unread" = "未读"; diff --git a/ios/SolianWidgetExtension/zh-Hant.lproj/Localizable.strings b/ios/SolianWidgetExtension/zh-Hant.lproj/Localizable.strings index 28390d17..d719f200 100644 --- a/ios/SolianWidgetExtension/zh-Hant.lproj/Localizable.strings +++ b/ios/SolianWidgetExtension/zh-Hant.lproj/Localizable.strings @@ -20,3 +20,17 @@ "notableDayToday" = "%@是今天!"; "notableDayIs" = "%@ 是 %@"; "today" = "今天"; + +/* Notification Widget Strings */ +"notifications" = "通知"; +"noNotifications" = "還沒有通知"; +"tapToViewAll" = "點擊查看所有通知"; +"justNow" = "剛剛"; +"minutesAgo" = "%d分鐘前"; +"hoursAgo" = "%d小時前"; +"daysAgo" = "%d天前"; +"pleaseSignIn" = "請打開應用登錄。"; +"invalidServerConfig" = "伺服器配置無效。"; +"invalidResponse" = "伺服器返回了無效響應。"; +"httpError" = "伺服器錯誤 (%d)。"; +"decodingError" = "讀取伺服器數據失敗。"; diff --git a/lib/widgets/app_wrapper.dart b/lib/widgets/app_wrapper.dart index e4f1f2c0..f9faef6c 100644 --- a/lib/widgets/app_wrapper.dart +++ b/lib/widgets/app_wrapper.dart @@ -269,10 +269,20 @@ class AppWrapper extends HookConsumerWidget { return; } + if (path == '/notifications') { + eventBus.fire(ShowNotificationSheetEvent()); + return; + } + final router = ref.read(routerProvider); + if (path == '/dashboard') { + router.go('/'); + return; + } + if (uri.queryParameters.isNotEmpty) { path = Uri.parse( - path == '/dashboard' ? '/' : path, + path, ).replace(queryParameters: uri.queryParameters).toString(); } router.push(path);