iOS notification widget

This commit is contained in:
2026-01-04 01:04:49 +08:00
parent 8cf03683dc
commit 6abee8d8bd
12 changed files with 1127 additions and 187 deletions

View File

@@ -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";

View File

@@ -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<T: Codable>(
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")

View File

@@ -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<Entry>) -> ()) {
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

View File

@@ -12,5 +12,6 @@ import SwiftUI
struct SolianWidgetExtensionBundle: WidgetBundle {
var body: some Widget {
SolianCheckInWidget()
SolianNotificationWidget()
}
}

View File

@@ -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<T: Codable>(
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)
}
}
}

View File

@@ -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";

View File

@@ -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.";

View File

@@ -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" = "サーバーデータの読み込みに失敗しました。";

View File

@@ -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" = "서버 데이터 읽기에 실패했습니다.";

View File

@@ -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" = "未读";

View File

@@ -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" = "讀取伺服器數據失敗。";

View File

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