// // 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? { let formatter = ISO8601DateFormatter() formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] if let date = formatter.date(from: createdAt) { return date } // Fallback for timestamps without fractional seconds formatter.formatOptions = [.withInternetDateTime] return formatter.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] { if take == 0 { return [] } 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 = 0 case .systemMedium: takeLimit = 1 case .systemLarge: takeLimit = 3 default: takeLimit = 0 } 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 } if case .accessoryCircular = family { return true } } return false } private var isCircular: Bool { if #available(iOS 16.0, *) { if case .accessoryCircular = 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 { if isCircular { ZStack { Image(systemName: "bell.fill") .font(.system(size: 20)) .foregroundColor(unreadCount > 0 ? .orange : .gray) if unreadCount > 0 { Text("\(min(unreadCount, 99))") .font(.system(size: 10, weight: .bold)) .foregroundColor(.white) .padding(4) .background(Circle().fill(Color.blue)) .offset(x: 12, y: -12) } } } else { 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 : 5 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 !compact && !notification.content.isEmpty { Text(notification.content) .font(.caption2) .lineLimit(2) .multilineTextAlignment(.leading) } 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")!) { if isCircular { ZStack { Image(systemName: "bell") .font(.system(size: 20)) .foregroundColor(.gray) } } else { 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 { if isCircular { ProgressView() .scaleEffect(0.8) } else { 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")!) { if isCircular { ZStack { Image(systemName: "exclamationmark.triangle") .font(.system(size: 20)) .foregroundColor(.red) } } else { 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, .accessoryCircular] #else return [.systemSmall, .systemMedium, .systemLarge] #endif } } #if os(iOS) #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 ) } #endif #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 ) } #Preview(as: .accessoryCircular) { 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 ) ], unreadCount: 5, error: nil, isLoading: false ) } #endif