From 8bdaf05223e368ad57cf6bef41afcb32d998dad2 Mon Sep 17 00:00:00 2001 From: LittleSheep Date: Sun, 15 Dec 2024 00:52:42 +0800 Subject: [PATCH] :sparkles: Post recommendation widget --- ios/Podfile | 7 - ios/Runner.xcodeproj/project.pbxproj | 36 ++- ios/Runner/Data/Post.swift | 38 +++ ios/Runner/Service/Attachment.swift | 14 ++ .../NotificationService.swift | 5 - ios/SolarWidget/CheckInWidget.swift | 28 +-- ios/SolarWidget/FeaturedPostWidget.swift | 234 ++++++++++++++++++ ios/SolarWidget/SolarWidgetBundle.swift | 3 +- lib/providers/widget.dart | 18 +- lib/screens/home.dart | 3 +- pubspec.yaml | 2 +- 11 files changed, 343 insertions(+), 45 deletions(-) create mode 100644 ios/Runner/Data/Post.swift create mode 100644 ios/Runner/Service/Attachment.swift diff --git a/ios/Podfile b/ios/Podfile index e25c293..ba9304f 100644 --- a/ios/Podfile +++ b/ios/Podfile @@ -37,13 +37,6 @@ target 'Runner' do end end -target 'SolarWidget' do - use_frameworks! - use_modular_headers! - - pod 'home_widget', :path => '.symlinks/plugins/home_widget/ios' -end - post_install do |installer| installer.pods_project.targets.each do |target| flutter_additional_ios_build_settings(target) diff --git a/ios/Runner.xcodeproj/project.pbxproj b/ios/Runner.xcodeproj/project.pbxproj index 37b2c07..8689587 100644 --- a/ios/Runner.xcodeproj/project.pbxproj +++ b/ios/Runner.xcodeproj/project.pbxproj @@ -50,7 +50,7 @@ /* Begin PBXCopyFilesBuildPhase section */ 73DA8A022D05C7620024A03E /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; - buildActionMask = 2147483647; + buildActionMask = 12; dstPath = ""; dstSubfolderSpec = 13; files = ( @@ -113,20 +113,28 @@ ); target = 738C1EAA2D0D76A400A215F3 /* SolarWidgetExtension */; }; - 738C1F502D0D91D000A215F3 /* Exceptions for "Data" folder in "Runner" target */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - User.swift, - ); - target = 97C146ED1CF9000F007C117D /* Runner */; - }; 738C1F512D0D91D000A215F3 /* Exceptions for "Data" folder in "SolarWidgetExtension" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( + Post.swift, User.swift, ); target = 738C1EAA2D0D76A400A215F3 /* SolarWidgetExtension */; }; + 73BC73712D0DDF6300956BE0 /* Exceptions for "Service" folder in "SolarNotifyService" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Attachment.swift, + ); + target = 73DA89F92D05C7620024A03E /* SolarNotifyService */; + }; + 73BC73722D0DDF6300956BE0 /* Exceptions for "Service" folder in "SolarWidgetExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Attachment.swift, + ); + target = 738C1EAA2D0D76A400A215F3 /* SolarWidgetExtension */; + }; 73DA8A062D05C7620024A03E /* Exceptions for "SolarNotifyService" folder in "SolarNotifyService" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -148,12 +156,20 @@ 738C1F4F2D0D91CC00A215F3 /* Data */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( - 738C1F502D0D91D000A215F3 /* Exceptions for "Data" folder in "Runner" target */, 738C1F512D0D91D000A215F3 /* Exceptions for "Data" folder in "SolarWidgetExtension" target */, ); path = Data; sourceTree = ""; }; + 73BC736C2D0DDF5600956BE0 /* Service */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + 73BC73712D0DDF6300956BE0 /* Exceptions for "Service" folder in "SolarNotifyService" target */, + 73BC73722D0DDF6300956BE0 /* Exceptions for "Service" folder in "SolarWidgetExtension" target */, + ); + path = Service; + sourceTree = ""; + }; 73DA89FB2D05C7620024A03E /* SolarNotifyService */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( @@ -260,6 +276,7 @@ 97C146F01CF9000F007C117D /* Runner */ = { isa = PBXGroup; children = ( + 73BC736C2D0DDF5600956BE0 /* Service */, 738C1F4F2D0D91CC00A215F3 /* Data */, 73111C212CEE3D5E004CF4B3 /* Runner.entitlements */, 97C146FA1CF9000F007C117D /* Main.storyboard */, @@ -378,6 +395,7 @@ ); fileSystemSynchronizedGroups = ( 738C1F4F2D0D91CC00A215F3 /* Data */, + 73BC736C2D0DDF5600956BE0 /* Service */, ); name = Runner; productName = Runner; diff --git a/ios/Runner/Data/Post.swift b/ios/Runner/Data/Post.swift new file mode 100644 index 0000000..870c087 --- /dev/null +++ b/ios/Runner/Data/Post.swift @@ -0,0 +1,38 @@ +// +// SolarPost.swift +// Runner +// +// Created by LittleSheep on 2024/12/14. +// + + +import Foundation + +struct SolarPost : Codable { + let id: Int + let body: SolarPostBody + let publisher: SolarPublisher + let publisherId: Int + let createdAt: Date + let updatedAt: Date + let editedAt: Date? + let publishedAt: Date? +} + +struct SolarPostBody : Codable { + let content: String? + let title: String? + let description: String? + let attachments: [String]? +} + +struct SolarPublisher : Codable { + let id: Int + let name: String + let nick: String + let description: String? + let avatar: String? + let banner: String? + let createdAt: Date + let updatedAt: Date +} diff --git a/ios/Runner/Service/Attachment.swift b/ios/Runner/Service/Attachment.swift new file mode 100644 index 0000000..f31d2fd --- /dev/null +++ b/ios/Runner/Service/Attachment.swift @@ -0,0 +1,14 @@ +// +// Attachment.swift +// Runner +// +// Created by LittleSheep on 2024/12/14. +// + +import Foundation + +func getAttachmentUrl(for identifier: String) -> String { + let serverBaseUrl = "https://api.sn.solsynth.dev" + + return identifier.starts(with: "http") ? identifier : "\(serverBaseUrl)/cgi/uc/attachments/\(identifier)" +} diff --git a/ios/SolarNotifyService/NotificationService.swift b/ios/SolarNotifyService/NotificationService.swift index 765ada1..bb89595 100644 --- a/ios/SolarNotifyService/NotificationService.swift +++ b/ios/SolarNotifyService/NotificationService.swift @@ -17,11 +17,6 @@ class NotificationService: UNNotificationServiceExtension { private var contentHandler: ((UNNotificationContent) -> Void)? private var bestAttemptContent: UNMutableNotificationContent? - private let serverBaseUrl = "https://api.sn.solsynth.dev" - - private func getAttachmentUrl(for identifier: String) -> String { - identifier.starts(with: "http") ? identifier : "\(serverBaseUrl)/cgi/uc/attachments/\(identifier)" - } private func fetchAvatarImage(from url: String, completion: @escaping (INImage?) -> Void) { guard let imageURL = URL(string: url) else { diff --git a/ios/SolarWidget/CheckInWidget.swift b/ios/SolarWidget/CheckInWidget.swift index dc4c63e..491169e 100644 --- a/ios/SolarWidget/CheckInWidget.swift +++ b/ios/SolarWidget/CheckInWidget.swift @@ -8,12 +8,12 @@ import WidgetKit import SwiftUI -struct Provider: TimelineProvider { - func placeholder(in context: Context) -> SimpleEntry { - SimpleEntry(date: Date(), user: nil, checkIn: nil) +struct CheckInProvider: TimelineProvider { + func placeholder(in context: Context) -> CheckInEntry { + CheckInEntry(date: Date(), user: nil, checkIn: nil) } - func getSnapshot(in context: Context, completion: @escaping (SimpleEntry) -> ()) { + func getSnapshot(in context: Context, completion: @escaping (CheckInEntry) -> ()) { let prefs = UserDefaults(suiteName: "group.solsynth.solian") let dateFormatter = DateFormatter() @@ -35,7 +35,7 @@ struct Provider: TimelineProvider { checkIn = try! jsonDecoder.decode(SolarCheckInRecord.self, from: checkInRaw.data(using: .utf8)!) } - let entry = SimpleEntry( + let entry = CheckInEntry( date: Date(), user: user, checkIn: checkIn @@ -51,14 +51,14 @@ struct Provider: TimelineProvider { } } -struct SimpleEntry: TimelineEntry { +struct CheckInEntry: TimelineEntry { let date: Date let user: SolarUser? let checkIn: SolarCheckInRecord? } -struct SolarWidgetEntryView : View { - var entry: Provider.Entry +struct CheckInWidgetEntryView : View { + var entry: CheckInProvider.Entry private let resultTierSymbols: [String] = ["大凶", "凶", "中平", "大吉", "吉"] @@ -110,15 +110,15 @@ struct SolarWidgetEntryView : View { } struct CheckInWidget: Widget { - let kind: String = "SolarWidget" + let kind: String = "SolarCheckInWidget" var body: some WidgetConfiguration { - StaticConfiguration(kind: kind, provider: Provider()) { entry in + StaticConfiguration(kind: kind, provider: CheckInProvider()) { entry in if #available(iOS 17.0, *) { - SolarWidgetEntryView(entry: entry) + CheckInWidgetEntryView(entry: entry) .containerBackground(.fill.tertiary, for: .widget) } else { - SolarWidgetEntryView(entry: entry) + CheckInWidgetEntryView(entry: entry) .padding() .background() } @@ -132,8 +132,8 @@ struct CheckInWidget: Widget { #Preview(as: .systemSmall) { CheckInWidget() } timeline: { - SimpleEntry(date: .now, user: nil, checkIn: nil) - SimpleEntry( + CheckInEntry(date: .now, user: nil, checkIn: nil) + CheckInEntry( date: .now, user: SolarUser(id: 1, name: "demo", nick: "Deemo"), checkIn: SolarCheckInRecord(id: 1, resultTier: 1, resultExperience: 100, createdAt: Date.now) diff --git a/ios/SolarWidget/FeaturedPostWidget.swift b/ios/SolarWidget/FeaturedPostWidget.swift index 7dd3973..c5c8528 100644 --- a/ios/SolarWidget/FeaturedPostWidget.swift +++ b/ios/SolarWidget/FeaturedPostWidget.swift @@ -5,3 +5,237 @@ // Created by LittleSheep on 2024/12/14. // +import SwiftUI +import WidgetKit + +struct FeaturedPostProvider: TimelineProvider { + func placeholder(in context: Context) -> FeaturedPostEntry { + FeaturedPostEntry(date: Date(), user: nil, featuredPost: nil, family: .systemMedium) + } + + func getSnapshot(in context: Context, completion: @escaping (FeaturedPostEntry) -> ()) { + let prefs = UserDefaults(suiteName: "group.solsynth.solian") + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'" + + let jsonDecoder = JSONDecoder() + jsonDecoder.dateDecodingStrategy = .formatted(dateFormatter) + jsonDecoder.keyDecodingStrategy = .convertFromSnakeCase + + let userRaw = prefs?.string(forKey: "user") + var user: SolarUser? + if let userRaw = userRaw { + user = try! jsonDecoder.decode(SolarUser.self, from: userRaw.data(using: .utf8)!) + } + + let featuredPostRaw = prefs?.string(forKey: "post_featured") + var featuredPosts: [SolarPost]? + if let featuredPostRaw = featuredPostRaw { + featuredPosts = try! jsonDecoder.decode([SolarPost].self, from: featuredPostRaw.data(using: .utf8)!) + } + + let entry = FeaturedPostEntry( + date: Date(), + user: user, + featuredPost: featuredPosts?.first, + family: context.family + ) + completion(entry) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> ()) { + getSnapshot(in: context) { (entry) in + let timeline = Timeline(entries: [entry], policy: .atEnd) + completion(timeline) + } + } +} + +struct FeaturedPostEntry: TimelineEntry { + let date: Date + let user: SolarUser? + let featuredPost: SolarPost? + + let family: WidgetFamily +} + +struct FeaturedPostWidgetEntryView : View { + var entry: FeaturedPostProvider.Entry + + private let resultTierSymbols: [String] = ["大凶", "凶", "中平", "大吉", "吉"] + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + if let featuredPost = entry.featuredPost { + HStack(alignment: .center) { + if let avatar = featuredPost.publisher.avatar { + let avatarUrl = getAttachmentUrl(for: avatar) + let size: CGFloat = 24 + + AsyncImage(url: URL(string: avatarUrl)) { image in + image.resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + .cornerRadius(size / 2) + .overlay( + Circle() + .stroke(Color.white, lineWidth: 4) + .frame(width: size, height: size) + ) + .shadow(radius: 10) + .frame(width: 24, height: 24, alignment: .center) + } placeholder: { + ProgressView().frame(width: 24, height: 24, alignment: .center) + } + } + + Text("@\(featuredPost.publisher.name)") + .font(.system(size: 13, design: .monospaced)) + .opacity(0.9) + + Spacer() + }.frame(maxWidth: .infinity).padding(.bottom, 12) + + if featuredPost.body.title != nil || featuredPost.body.description != nil { + VStack(alignment: .leading) { + if let title = featuredPost.body.title { + Text(title) + .font(.system(size: 17)) + } + if let description = featuredPost.body.description { + Text(description) + .font(.system(size: 15)) + } + }.padding(.bottom, 8) + } + + if let content = featuredPost.body.content { + if (featuredPost.body.title == nil && featuredPost.body.description == nil) || entry.family == .systemLarge || entry.family == .systemExtraLarge { + Text( + (entry.family == .systemLarge || entry.family == .systemExtraLarge) ? content : content.replacingOccurrences(of: "\n", with: " ") + ) + .font(.system(size: 15)) + } else { + Text("\(Image(systemName: "plus")) total \(content.count) characters") + .font(.system(size: 11, design: .monospaced)) + .opacity(0.75) + .padding(.top, 1) + } + } + + if let attachment = featuredPost.body.attachments { + if attachment.count == 1 { + Text("\(Image(systemName: "document.fill")) \(attachment.count) attachment") + .font(.system(size: 11, design: .monospaced)) + .opacity(0.75) + .padding(.top, 1) + } else if attachment.count > 1 { + Text("\(Image(systemName: "document.fill")) \(attachment.count) attachments") + .font(.system(size: 11, design: .monospaced)) + .opacity(0.75) + .padding(.top, 1) + } + } + + Spacer() + + Text(featuredPost.publishedAt!, format: .dateTime) + .font(.system(size: 11)) + Text("Solar Network Featured Posts") + .font(.system(size: 9)) + } else { + VStack(alignment: .center) { + Text("No Recommendations").font(.system(size: 19, weight: .bold)) + Text("Click the widget to open the app to load featured posts") + .font(.system(size: 15)) + .multilineTextAlignment(.center) + }.frame(alignment: .center) + } + }.padding(8).frame(maxWidth: .infinity) + } +} + +struct FeaturedPostWidget: Widget { + let kind: String = "SolarFeaturedPostWidget" + + var body: some WidgetConfiguration { + StaticConfiguration(kind: kind, provider: FeaturedPostProvider()) { entry in + if #available(iOS 17.0, *) { + FeaturedPostWidgetEntryView(entry: entry) + .containerBackground(.fill.tertiary, for: .widget) + } else { + FeaturedPostWidgetEntryView(entry: entry) + .padding() + .background() + } + } + .configurationDisplayName("Featured Posts") + .description("View the featured posts on the Solar Network") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge]) + } +} + +#Preview(as: .systemSmall) { + FeaturedPostWidget() +} timeline: { + FeaturedPostEntry(date: Date.now, user: nil, featuredPost: nil, family: .systemLarge) + FeaturedPostEntry( + date: .now, + user: SolarUser(id: 1, name: "demo", nick: "Deemo"), + featuredPost: SolarPost( + id: 1, + body: SolarPostBody( + content: "Hello, World", + title: nil, + description: nil, + attachments: ["zb2hiUEmYcnpHfVN"] + ), + publisher: SolarPublisher( + id: 1, + name: "demo", + nick: "Deemo", + description: nil, + avatar: "IZxCFkJUPKRijFCx", + banner: nil, + createdAt: .now, + updatedAt: .now + ), + publisherId: 1, + createdAt: .now, + updatedAt: .now, + editedAt: nil, + publishedAt: .now + ), + family: .systemSmall + ) + FeaturedPostEntry( + date: .now, + user: SolarUser(id: 1, name: "demo", nick: "Deemo"), + featuredPost: SolarPost( + id: 1, + body: SolarPostBody( + content: "Hello, World\nOh wow", + title: "Title", + description: "Description", + attachments: ["zb2hiUEmYcnpHfVN"] + ), + publisher: SolarPublisher( + id: 1, + name: "demo", + nick: "Deemo", + description: nil, + avatar: "IZxCFkJUPKRijFCx", + banner: nil, + createdAt: .now, + updatedAt: .now + ), + publisherId: 1, + createdAt: .now, + updatedAt: .now, + editedAt: nil, + publishedAt: .now + ), + family: .systemLarge + ) +} diff --git a/ios/SolarWidget/SolarWidgetBundle.swift b/ios/SolarWidget/SolarWidgetBundle.swift index 6ef649d..d6fe3b8 100644 --- a/ios/SolarWidget/SolarWidgetBundle.swift +++ b/ios/SolarWidget/SolarWidgetBundle.swift @@ -11,6 +11,7 @@ import SwiftUI @main struct SolarWidgetBundle: WidgetBundle { var body: some Widget { - CheckInWidget() + // CheckInWidget() + FeaturedPostWidget() } } diff --git a/lib/providers/widget.dart b/lib/providers/widget.dart index d916325..72c29fb 100644 --- a/lib/providers/widget.dart +++ b/lib/providers/widget.dart @@ -15,18 +15,22 @@ class HomeWidgetProvider { } } - Future saveWidgetData(String id, dynamic data) async { + Future saveWidgetData(String id, dynamic data, {bool update = true}) async { if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return; await HomeWidget.saveWidgetData(id, jsonEncode(data)); + if (update) await updateWidget(); } Future updateWidget() async { if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return; - await HomeWidget.updateWidget( - name: "SolarWidget", - iOSName: "SolarWidget", - androidName: "com.solsynth.solian.SolarWidget", - qualifiedAndroidName: "group.solsynth.solian.SolarWidget", - ); + const widgets = ["SolarFeaturedPostWidget", "SolarCheckInWidget"]; + for(final widget in widgets) { + await HomeWidget.updateWidget( + name: widget, + iOSName: widget, + androidName: "com.solsynth.solian.$widget", + qualifiedAndroidName: "group.solsynth.solian.$widget", + ); + } } } diff --git a/lib/screens/home.dart b/lib/screens/home.dart index 800cabb..c4a99f0 100644 --- a/lib/screens/home.dart +++ b/lib/screens/home.dart @@ -6,7 +6,6 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; import 'package:gap/gap.dart'; import 'package:go_router/go_router.dart'; import 'package:google_fonts/google_fonts.dart'; -import 'package:home_widget/home_widget.dart'; import 'package:material_symbols_icons/symbols.dart'; import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; @@ -428,7 +427,9 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati setState(() => _isBusy = true); try { final pt = context.read(); + final home = context.read(); _posts = await pt.listRecommendations(); + home.saveWidgetData('post_featured', _posts!.map((e) => e.toJson()).toList()); } catch (err) { if (!mounted) return; context.showErrorDialog(err); diff --git a/pubspec.yaml b/pubspec.yaml index 9d0e9ec..af9d28d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -16,7 +16,7 @@ publish_to: "none" # Remove this line if you wish to publish to pub.dev # https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html # In Windows, build-name is used as the major, minor, and patch parts # of the product and file versions while build-number is used as the build suffix. -version: 2.0.1+25 +version: 2.0.1+27 environment: sdk: ^3.5.4