Post recommendation widget

This commit is contained in:
LittleSheep 2024-12-15 00:52:42 +08:00
parent e920bd954c
commit 8bdaf05223
11 changed files with 343 additions and 45 deletions

View File

@ -37,13 +37,6 @@ target 'Runner' do
end end
end end
target 'SolarWidget' do
use_frameworks!
use_modular_headers!
pod 'home_widget', :path => '.symlinks/plugins/home_widget/ios'
end
post_install do |installer| post_install do |installer|
installer.pods_project.targets.each do |target| installer.pods_project.targets.each do |target|
flutter_additional_ios_build_settings(target) flutter_additional_ios_build_settings(target)

View File

@ -50,7 +50,7 @@
/* Begin PBXCopyFilesBuildPhase section */ /* Begin PBXCopyFilesBuildPhase section */
73DA8A022D05C7620024A03E /* Embed Foundation Extensions */ = { 73DA8A022D05C7620024A03E /* Embed Foundation Extensions */ = {
isa = PBXCopyFilesBuildPhase; isa = PBXCopyFilesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 12;
dstPath = ""; dstPath = "";
dstSubfolderSpec = 13; dstSubfolderSpec = 13;
files = ( files = (
@ -113,20 +113,28 @@
); );
target = 738C1EAA2D0D76A400A215F3 /* SolarWidgetExtension */; 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 */ = { 738C1F512D0D91D000A215F3 /* Exceptions for "Data" folder in "SolarWidgetExtension" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
Post.swift,
User.swift, User.swift,
); );
target = 738C1EAA2D0D76A400A215F3 /* SolarWidgetExtension */; 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 */ = { 73DA8A062D05C7620024A03E /* Exceptions for "SolarNotifyService" folder in "SolarNotifyService" target */ = {
isa = PBXFileSystemSynchronizedBuildFileExceptionSet; isa = PBXFileSystemSynchronizedBuildFileExceptionSet;
membershipExceptions = ( membershipExceptions = (
@ -148,12 +156,20 @@
738C1F4F2D0D91CC00A215F3 /* Data */ = { 738C1F4F2D0D91CC00A215F3 /* Data */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
738C1F502D0D91D000A215F3 /* Exceptions for "Data" folder in "Runner" target */,
738C1F512D0D91D000A215F3 /* Exceptions for "Data" folder in "SolarWidgetExtension" target */, 738C1F512D0D91D000A215F3 /* Exceptions for "Data" folder in "SolarWidgetExtension" target */,
); );
path = Data; path = Data;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
73BC736C2D0DDF5600956BE0 /* Service */ = {
isa = PBXFileSystemSynchronizedRootGroup;
exceptions = (
73BC73712D0DDF6300956BE0 /* Exceptions for "Service" folder in "SolarNotifyService" target */,
73BC73722D0DDF6300956BE0 /* Exceptions for "Service" folder in "SolarWidgetExtension" target */,
);
path = Service;
sourceTree = "<group>";
};
73DA89FB2D05C7620024A03E /* SolarNotifyService */ = { 73DA89FB2D05C7620024A03E /* SolarNotifyService */ = {
isa = PBXFileSystemSynchronizedRootGroup; isa = PBXFileSystemSynchronizedRootGroup;
exceptions = ( exceptions = (
@ -260,6 +276,7 @@
97C146F01CF9000F007C117D /* Runner */ = { 97C146F01CF9000F007C117D /* Runner */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
73BC736C2D0DDF5600956BE0 /* Service */,
738C1F4F2D0D91CC00A215F3 /* Data */, 738C1F4F2D0D91CC00A215F3 /* Data */,
73111C212CEE3D5E004CF4B3 /* Runner.entitlements */, 73111C212CEE3D5E004CF4B3 /* Runner.entitlements */,
97C146FA1CF9000F007C117D /* Main.storyboard */, 97C146FA1CF9000F007C117D /* Main.storyboard */,
@ -378,6 +395,7 @@
); );
fileSystemSynchronizedGroups = ( fileSystemSynchronizedGroups = (
738C1F4F2D0D91CC00A215F3 /* Data */, 738C1F4F2D0D91CC00A215F3 /* Data */,
73BC736C2D0DDF5600956BE0 /* Service */,
); );
name = Runner; name = Runner;
productName = Runner; productName = Runner;

View File

@ -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
}

View File

@ -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)"
}

View File

@ -17,11 +17,6 @@ class NotificationService: UNNotificationServiceExtension {
private var contentHandler: ((UNNotificationContent) -> Void)? private var contentHandler: ((UNNotificationContent) -> Void)?
private var bestAttemptContent: UNMutableNotificationContent? 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) { private func fetchAvatarImage(from url: String, completion: @escaping (INImage?) -> Void) {
guard let imageURL = URL(string: url) else { guard let imageURL = URL(string: url) else {

View File

@ -8,12 +8,12 @@
import WidgetKit import WidgetKit
import SwiftUI import SwiftUI
struct Provider: TimelineProvider { struct CheckInProvider: TimelineProvider {
func placeholder(in context: Context) -> SimpleEntry { func placeholder(in context: Context) -> CheckInEntry {
SimpleEntry(date: Date(), user: nil, checkIn: nil) 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 prefs = UserDefaults(suiteName: "group.solsynth.solian")
let dateFormatter = DateFormatter() let dateFormatter = DateFormatter()
@ -35,7 +35,7 @@ struct Provider: TimelineProvider {
checkIn = try! jsonDecoder.decode(SolarCheckInRecord.self, from: checkInRaw.data(using: .utf8)!) checkIn = try! jsonDecoder.decode(SolarCheckInRecord.self, from: checkInRaw.data(using: .utf8)!)
} }
let entry = SimpleEntry( let entry = CheckInEntry(
date: Date(), date: Date(),
user: user, user: user,
checkIn: checkIn checkIn: checkIn
@ -51,14 +51,14 @@ struct Provider: TimelineProvider {
} }
} }
struct SimpleEntry: TimelineEntry { struct CheckInEntry: TimelineEntry {
let date: Date let date: Date
let user: SolarUser? let user: SolarUser?
let checkIn: SolarCheckInRecord? let checkIn: SolarCheckInRecord?
} }
struct SolarWidgetEntryView : View { struct CheckInWidgetEntryView : View {
var entry: Provider.Entry var entry: CheckInProvider.Entry
private let resultTierSymbols: [String] = ["大凶", "", "中平", "大吉", ""] private let resultTierSymbols: [String] = ["大凶", "", "中平", "大吉", ""]
@ -110,15 +110,15 @@ struct SolarWidgetEntryView : View {
} }
struct CheckInWidget: Widget { struct CheckInWidget: Widget {
let kind: String = "SolarWidget" let kind: String = "SolarCheckInWidget"
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
StaticConfiguration(kind: kind, provider: Provider()) { entry in StaticConfiguration(kind: kind, provider: CheckInProvider()) { entry in
if #available(iOS 17.0, *) { if #available(iOS 17.0, *) {
SolarWidgetEntryView(entry: entry) CheckInWidgetEntryView(entry: entry)
.containerBackground(.fill.tertiary, for: .widget) .containerBackground(.fill.tertiary, for: .widget)
} else { } else {
SolarWidgetEntryView(entry: entry) CheckInWidgetEntryView(entry: entry)
.padding() .padding()
.background() .background()
} }
@ -132,8 +132,8 @@ struct CheckInWidget: Widget {
#Preview(as: .systemSmall) { #Preview(as: .systemSmall) {
CheckInWidget() CheckInWidget()
} timeline: { } timeline: {
SimpleEntry(date: .now, user: nil, checkIn: nil) CheckInEntry(date: .now, user: nil, checkIn: nil)
SimpleEntry( CheckInEntry(
date: .now, date: .now,
user: SolarUser(id: 1, name: "demo", nick: "Deemo"), user: SolarUser(id: 1, name: "demo", nick: "Deemo"),
checkIn: SolarCheckInRecord(id: 1, resultTier: 1, resultExperience: 100, createdAt: Date.now) checkIn: SolarCheckInRecord(id: 1, resultTier: 1, resultExperience: 100, createdAt: Date.now)

View File

@ -5,3 +5,237 @@
// Created by LittleSheep on 2024/12/14. // 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<Entry>) -> ()) {
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
)
}

View File

@ -11,6 +11,7 @@ import SwiftUI
@main @main
struct SolarWidgetBundle: WidgetBundle { struct SolarWidgetBundle: WidgetBundle {
var body: some Widget { var body: some Widget {
CheckInWidget() // CheckInWidget()
FeaturedPostWidget()
} }
} }

View File

@ -15,18 +15,22 @@ class HomeWidgetProvider {
} }
} }
Future<void> saveWidgetData(String id, dynamic data) async { Future<void> saveWidgetData(String id, dynamic data, {bool update = true}) async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return; if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return;
await HomeWidget.saveWidgetData(id, jsonEncode(data)); await HomeWidget.saveWidgetData(id, jsonEncode(data));
if (update) await updateWidget();
} }
Future<void> updateWidget() async { Future<void> updateWidget() async {
if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return; if (kIsWeb || !(Platform.isAndroid || Platform.isIOS)) return;
const widgets = ["SolarFeaturedPostWidget", "SolarCheckInWidget"];
for(final widget in widgets) {
await HomeWidget.updateWidget( await HomeWidget.updateWidget(
name: "SolarWidget", name: widget,
iOSName: "SolarWidget", iOSName: widget,
androidName: "com.solsynth.solian.SolarWidget", androidName: "com.solsynth.solian.$widget",
qualifiedAndroidName: "group.solsynth.solian.SolarWidget", qualifiedAndroidName: "group.solsynth.solian.$widget",
); );
} }
} }
}

View File

@ -6,7 +6,6 @@ import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:gap/gap.dart'; import 'package:gap/gap.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart'; import 'package:google_fonts/google_fonts.dart';
import 'package:home_widget/home_widget.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart'; import 'package:styled_widget/styled_widget.dart';
@ -428,7 +427,9 @@ class _HomeDashRecommendationPostWidgetState extends State<_HomeDashRecommendati
setState(() => _isBusy = true); setState(() => _isBusy = true);
try { try {
final pt = context.read<SnPostContentProvider>(); final pt = context.read<SnPostContentProvider>();
final home = context.read<HomeWidgetProvider>();
_posts = await pt.listRecommendations(); _posts = await pt.listRecommendations();
home.saveWidgetData('post_featured', _posts!.map((e) => e.toJson()).toList());
} catch (err) { } catch (err) {
if (!mounted) return; if (!mounted) return;
context.showErrorDialog(err); context.showErrorDialog(err);

View File

@ -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 # 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 # 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. # 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: environment:
sdk: ^3.5.4 sdk: ^3.5.4