✨ iOS notification widget
This commit is contained in:
@@ -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";
|
|
||||||
@@ -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 {
|
class CheckInService {
|
||||||
private let networkService = WidgetNetworkService()
|
private let networkService = WidgetNetworkService()
|
||||||
|
|
||||||
@@ -694,12 +566,11 @@ struct CheckInWidgetEntryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SolianCheckInWidget: Widget {
|
struct CheckInWidgetRootView: View {
|
||||||
let kind: String = "SolianCheckInWidget"
|
var entry: Provider.Entry
|
||||||
|
|
||||||
var body: some WidgetConfiguration {
|
|
||||||
StaticConfiguration(kind: kind, provider: Provider()) { entry in
|
|
||||||
@Environment(\.colorScheme) var colorScheme
|
@Environment(\.colorScheme) var colorScheme
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
if #available(iOS 17.0, *) {
|
if #available(iOS 17.0, *) {
|
||||||
ZStack {
|
ZStack {
|
||||||
CheckInWidgetEntryView(entry: entry)
|
CheckInWidgetEntryView(entry: entry)
|
||||||
@@ -741,6 +612,15 @@ struct SolianCheckInWidget: Widget {
|
|||||||
.background()
|
.background()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SolianCheckInWidget: Widget {
|
||||||
|
let kind: String = "SolianCheckInWidget"
|
||||||
|
|
||||||
|
var body: some WidgetConfiguration {
|
||||||
|
StaticConfiguration(kind: kind, provider: Provider()) { entry in
|
||||||
|
CheckInWidgetRootView(entry: entry)
|
||||||
|
}
|
||||||
.configurationDisplayName("Check In")
|
.configurationDisplayName("Check In")
|
||||||
.description("View your daily check-in status")
|
.description("View your daily check-in status")
|
||||||
.supportedFamilies(supportedFamilies)
|
.supportedFamilies(supportedFamilies)
|
||||||
|
|||||||
838
ios/SolianWidgetExtension/SolianNotificationWidget.swift
Normal file
838
ios/SolianWidgetExtension/SolianNotificationWidget.swift
Normal 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
|
||||||
@@ -12,5 +12,6 @@ import SwiftUI
|
|||||||
struct SolianWidgetExtensionBundle: WidgetBundle {
|
struct SolianWidgetExtensionBundle: WidgetBundle {
|
||||||
var body: some Widget {
|
var body: some Widget {
|
||||||
SolianCheckInWidget()
|
SolianCheckInWidget()
|
||||||
|
SolianNotificationWidget()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
136
ios/SolianWidgetExtension/WidgetNetworking.swift
Normal file
136
ios/SolianWidgetExtension/WidgetNetworking.swift
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,3 +21,21 @@
|
|||||||
"notableDayIs" = "%@ is %@";
|
"notableDayIs" = "%@ is %@";
|
||||||
"notableDayUpcoming" = "Upcoming";
|
"notableDayUpcoming" = "Upcoming";
|
||||||
"today" = "Today";
|
"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";
|
||||||
|
|||||||
@@ -20,3 +20,17 @@
|
|||||||
"notableDayToday" = "%@ es hoy!";
|
"notableDayToday" = "%@ es hoy!";
|
||||||
"notableDayIs" = "%@ es %@";
|
"notableDayIs" = "%@ es %@";
|
||||||
"today" = "Hoy";
|
"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.";
|
||||||
|
|||||||
@@ -20,3 +20,17 @@
|
|||||||
"notableDayToday" = "%@は今日です!";
|
"notableDayToday" = "%@は今日です!";
|
||||||
"notableDayIs" = "%@は%@です";
|
"notableDayIs" = "%@は%@です";
|
||||||
"today" = "今日";
|
"today" = "今日";
|
||||||
|
|
||||||
|
/* Notification Widget Strings */
|
||||||
|
"notifications" = "通知";
|
||||||
|
"noNotifications" = "まだ通知はありません";
|
||||||
|
"tapToViewAll" = "タップしてすべての通知を表示";
|
||||||
|
"justNow" = "たった今";
|
||||||
|
"minutesAgo" = "%d分前";
|
||||||
|
"hoursAgo" = "%d時間前";
|
||||||
|
"daysAgo" = "%d日前";
|
||||||
|
"pleaseSignIn" = "アプリを開いてサインインしてください。";
|
||||||
|
"invalidServerConfig" = "サーバー設定が無効です。";
|
||||||
|
"invalidResponse" = "サーバーから無効な応答が返されました。";
|
||||||
|
"httpError" = "サーバーエラー (%d)。";
|
||||||
|
"decodingError" = "サーバーデータの読み込みに失敗しました。";
|
||||||
|
|||||||
@@ -20,3 +20,17 @@
|
|||||||
"notableDayToday" = "%@ 오늘입니다!";
|
"notableDayToday" = "%@ 오늘입니다!";
|
||||||
"notableDayIs" = "%@ 은/는 %@";
|
"notableDayIs" = "%@ 은/는 %@";
|
||||||
"today" = "오늘";
|
"today" = "오늘";
|
||||||
|
|
||||||
|
/* Notification Widget Strings */
|
||||||
|
"notifications" = "알림";
|
||||||
|
"noNotifications" = "아직 알림이 없습니다";
|
||||||
|
"tapToViewAll" = "탭하여 모든 알림 보기";
|
||||||
|
"justNow" = "방금";
|
||||||
|
"minutesAgo" = "%d분 전";
|
||||||
|
"hoursAgo" = "%d시간 전";
|
||||||
|
"daysAgo" = "%d일 전";
|
||||||
|
"pleaseSignIn" = "앱을 열어 로그인하세요.";
|
||||||
|
"invalidServerConfig" = "서버 구성이 올바르지 않습니다.";
|
||||||
|
"invalidResponse" = "서버에서 잘못된 응답을 받았습니다.";
|
||||||
|
"httpError" = "서버 오류 (%d).";
|
||||||
|
"decodingError" = "서버 데이터 읽기에 실패했습니다.";
|
||||||
|
|||||||
@@ -21,3 +21,21 @@
|
|||||||
"notableDayIs" = "%@ 是 %@";
|
"notableDayIs" = "%@ 是 %@";
|
||||||
"notableDayUpcoming" = "接下来";
|
"notableDayUpcoming" = "接下来";
|
||||||
"today" = "今天";
|
"today" = "今天";
|
||||||
|
|
||||||
|
/* Notification Widget Strings */
|
||||||
|
"notifications" = "通知";
|
||||||
|
"noNotifications" = "还没有通知";
|
||||||
|
"tapToViewAll" = "点击查看所有通知";
|
||||||
|
"justNow" = "刚刚";
|
||||||
|
"minutesAgo" = "%d分钟前";
|
||||||
|
"hoursAgo" = "%d小时前";
|
||||||
|
"daysAgo" = "%d天前";
|
||||||
|
"pleaseSignIn" = "请打开应用登录。";
|
||||||
|
"invalidServerConfig" = "服务器配置无效。";
|
||||||
|
"invalidResponse" = "服务器返回了无效响应。";
|
||||||
|
"httpError" = "服务器错误 (%d)。";
|
||||||
|
"decodingError" = "读取服务器数据失败。";
|
||||||
|
"unreadNotifications" = "条通知未读";
|
||||||
|
"noNotifications" = "没有通知";
|
||||||
|
"noUnreadNotifications" = "没有未读通知";
|
||||||
|
"unread" = "未读";
|
||||||
|
|||||||
@@ -20,3 +20,17 @@
|
|||||||
"notableDayToday" = "%@是今天!";
|
"notableDayToday" = "%@是今天!";
|
||||||
"notableDayIs" = "%@ 是 %@";
|
"notableDayIs" = "%@ 是 %@";
|
||||||
"today" = "今天";
|
"today" = "今天";
|
||||||
|
|
||||||
|
/* Notification Widget Strings */
|
||||||
|
"notifications" = "通知";
|
||||||
|
"noNotifications" = "還沒有通知";
|
||||||
|
"tapToViewAll" = "點擊查看所有通知";
|
||||||
|
"justNow" = "剛剛";
|
||||||
|
"minutesAgo" = "%d分鐘前";
|
||||||
|
"hoursAgo" = "%d小時前";
|
||||||
|
"daysAgo" = "%d天前";
|
||||||
|
"pleaseSignIn" = "請打開應用登錄。";
|
||||||
|
"invalidServerConfig" = "伺服器配置無效。";
|
||||||
|
"invalidResponse" = "伺服器返回了無效響應。";
|
||||||
|
"httpError" = "伺服器錯誤 (%d)。";
|
||||||
|
"decodingError" = "讀取伺服器數據失敗。";
|
||||||
|
|||||||
@@ -269,10 +269,20 @@ class AppWrapper extends HookConsumerWidget {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (path == '/notifications') {
|
||||||
|
eventBus.fire(ShowNotificationSheetEvent());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final router = ref.read(routerProvider);
|
final router = ref.read(routerProvider);
|
||||||
|
if (path == '/dashboard') {
|
||||||
|
router.go('/');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (uri.queryParameters.isNotEmpty) {
|
if (uri.queryParameters.isNotEmpty) {
|
||||||
path = Uri.parse(
|
path = Uri.parse(
|
||||||
path == '/dashboard' ? '/' : path,
|
path,
|
||||||
).replace(queryParameters: uri.queryParameters).toString();
|
).replace(queryParameters: uri.queryParameters).toString();
|
||||||
}
|
}
|
||||||
router.push(path);
|
router.push(path);
|
||||||
|
|||||||
Reference in New Issue
Block a user