From ea31cda3c2ab510e709e8c96a98306517cee3f4f Mon Sep 17 00:00:00 2001 From: Thomas Ricouard Date: Sun, 5 May 2024 19:31:28 +0200 Subject: [PATCH] Widget: Add Hashtag widget --- IceCubesApp.xcodeproj/project.pbxproj | 50 ++++- .../HashtagPostsWidget.swift | 81 +++++++ .../HashtagPostsWidgetConfiguration.swift | 22 ++ .../IceCubesAppWidgetsExtensionBundle.swift | 1 + .../LatestPosts/LatestPostsWidget.swift | 100 +++++++++ .../LatestPostsWidgetConfiguration.swift} | 8 +- .../LatestPostsWidget.swift | 210 ------------------ .../Shared/LastestPostsUI.swift | 97 ++++++++ .../Shared/SharedUtils.swift | 55 +++++ 9 files changed, 405 insertions(+), 219 deletions(-) create mode 100644 IceCubesAppWidgetsExtension/HashtagPostsWidget/HashtagPostsWidget.swift create mode 100644 IceCubesAppWidgetsExtension/HashtagPostsWidget/HashtagPostsWidgetConfiguration.swift create mode 100644 IceCubesAppWidgetsExtension/LatestPosts/LatestPostsWidget.swift rename IceCubesAppWidgetsExtension/{IceCubesWidgetConfigurationIntent.swift => LatestPosts/LatestPostsWidgetConfiguration.swift} (67%) delete mode 100644 IceCubesAppWidgetsExtension/LatestPostsWidget.swift create mode 100644 IceCubesAppWidgetsExtension/Shared/LastestPostsUI.swift create mode 100644 IceCubesAppWidgetsExtension/Shared/SharedUtils.swift diff --git a/IceCubesApp.xcodeproj/project.pbxproj b/IceCubesApp.xcodeproj/project.pbxproj index 648ab394..c0ccb757 100644 --- a/IceCubesApp.xcodeproj/project.pbxproj +++ b/IceCubesApp.xcodeproj/project.pbxproj @@ -71,7 +71,7 @@ 9F7788C92BE652B1004E6BEF /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 9F7788C82BE652B1004E6BEF /* SwiftUI.framework */; }; 9F7788CC2BE652B1004E6BEF /* IceCubesAppWidgetsExtensionBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7788CB2BE652B1004E6BEF /* IceCubesAppWidgetsExtensionBundle.swift */; }; 9F7788CE2BE652B1004E6BEF /* LatestPostsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7788CD2BE652B1004E6BEF /* LatestPostsWidget.swift */; }; - 9F7788D02BE652B1004E6BEF /* IceCubesWidgetConfigurationIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7788CF2BE652B1004E6BEF /* IceCubesWidgetConfigurationIntent.swift */; }; + 9F7788D02BE652B1004E6BEF /* LatestPostsWidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9F7788CF2BE652B1004E6BEF /* LatestPostsWidgetConfiguration.swift */; }; 9F7788D22BE652B2004E6BEF /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 9F7788D12BE652B2004E6BEF /* Assets.xcassets */; }; 9F7788D62BE652B2004E6BEF /* IceCubesAppWidgetsExtensionExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 9F7788C52BE652B1004E6BEF /* IceCubesAppWidgetsExtensionExtension.appex */; platformFilters = (ios, maccatalyst, ); settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 9F7788DE2BE6543D004E6BEF /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 9F7788DD2BE6543D004E6BEF /* Account */; }; @@ -120,6 +120,10 @@ 9FE4CCAB2B4C848A00DA5F13 /* GiphyUISDK in Frameworks */ = {isa = PBXBuildFile; platformFilters = (ios, maccatalyst, ); productRef = 9FE4CCAA2B4C848A00DA5F13 /* GiphyUISDK */; }; 9FE4CCAD2B4C849F00DA5F13 /* GiphyUISDK in Frameworks */ = {isa = PBXBuildFile; productRef = 9FE4CCAC2B4C849F00DA5F13 /* GiphyUISDK */; }; 9FE6A42E2BD043A90055D388 /* RevenueCat in Frameworks */ = {isa = PBXBuildFile; productRef = 9FE6A42D2BD043A90055D388 /* RevenueCat */; }; + 9FF2FB622BE7F5D5001560CE /* HashtagPostsWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF2FB602BE7F5A7001560CE /* HashtagPostsWidget.swift */; }; + 9FF2FB632BE7F5D9001560CE /* HashtagPostsWidgetConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF2FB5E2BE7F56F001560CE /* HashtagPostsWidgetConfiguration.swift */; }; + 9FF2FB672BE7F816001560CE /* LastestPostsUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF2FB652BE7F805001560CE /* LastestPostsUI.swift */; }; + 9FF2FB6A2BE7F84E001560CE /* SharedUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9FF2FB682BE7F842001560CE /* SharedUtils.swift */; }; 9FFF677C299B7B2C00FE700A /* Notifications in Frameworks */ = {isa = PBXBuildFile; productRef = 9FFF677B299B7B2C00FE700A /* Notifications */; }; 9FFF6780299B7D2B00FE700A /* DesignSystem in Frameworks */ = {isa = PBXBuildFile; productRef = 9FFF677F299B7D2B00FE700A /* DesignSystem */; }; 9FFF6782299B7D3A00FE700A /* Account in Frameworks */ = {isa = PBXBuildFile; productRef = 9FFF6781299B7D3A00FE700A /* Account */; }; @@ -256,7 +260,7 @@ 9F7788C82BE652B1004E6BEF /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; 9F7788CB2BE652B1004E6BEF /* IceCubesAppWidgetsExtensionBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IceCubesAppWidgetsExtensionBundle.swift; sourceTree = ""; }; 9F7788CD2BE652B1004E6BEF /* LatestPostsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestPostsWidget.swift; sourceTree = ""; }; - 9F7788CF2BE652B1004E6BEF /* IceCubesWidgetConfigurationIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IceCubesWidgetConfigurationIntent.swift; sourceTree = ""; }; + 9F7788CF2BE652B1004E6BEF /* LatestPostsWidgetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestPostsWidgetConfiguration.swift; sourceTree = ""; }; 9F7788D12BE652B2004E6BEF /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 9F7788D32BE652B2004E6BEF /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; 9F7788D72BE652B2004E6BEF /* IceCubesAppWidgetsExtensionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = IceCubesAppWidgetsExtensionExtension.entitlements; sourceTree = ""; }; @@ -290,6 +294,10 @@ 9FE0346A2ADD59AC00529EA8 /* MediaUI */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = MediaUI; path = Packages/MediaUI; sourceTree = ""; }; 9FE151A5293C90F900E9683D /* IconSelectorView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IconSelectorView.swift; sourceTree = ""; }; 9FE3DB55296FEF5800628CB0 /* AppAccount */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = AppAccount; path = Packages/AppAccount; sourceTree = ""; }; + 9FF2FB5E2BE7F56F001560CE /* HashtagPostsWidgetConfiguration.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagPostsWidgetConfiguration.swift; sourceTree = ""; }; + 9FF2FB602BE7F5A7001560CE /* HashtagPostsWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagPostsWidget.swift; sourceTree = ""; }; + 9FF2FB652BE7F805001560CE /* LastestPostsUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LastestPostsUI.swift; sourceTree = ""; }; + 9FF2FB682BE7F842001560CE /* SharedUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SharedUtils.swift; sourceTree = ""; }; B0BAB49E29B3D7A9008F54D7 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/InfoPlist.strings"; sourceTree = ""; }; C4CBB90B298A0DA3007E1707 /* en-GB */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "en-GB"; path = "en-GB.lproj/InfoPlist.strings"; sourceTree = ""; }; C4FBCF6F298FD88A0015DF22 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; @@ -471,10 +479,11 @@ 9F7788CA2BE652B1004E6BEF /* IceCubesAppWidgetsExtension */ = { isa = PBXGroup; children = ( + 9FF2FB642BE7F7FA001560CE /* Shared */, + 9FF2FB5D2BE7F559001560CE /* HashtagPostsWidget */, + 9FF2FB5C2BE7F549001560CE /* LatestPosts */, 9F7788D72BE652B2004E6BEF /* IceCubesAppWidgetsExtensionExtension.entitlements */, 9F7788CB2BE652B1004E6BEF /* IceCubesAppWidgetsExtensionBundle.swift */, - 9F7788CD2BE652B1004E6BEF /* LatestPostsWidget.swift */, - 9F7788CF2BE652B1004E6BEF /* IceCubesWidgetConfigurationIntent.swift */, 9F7788D12BE652B2004E6BEF /* Assets.xcassets */, 9F7788D32BE652B2004E6BEF /* Info.plist */, ); @@ -632,6 +641,33 @@ path = Settings; sourceTree = ""; }; + 9FF2FB5C2BE7F549001560CE /* LatestPosts */ = { + isa = PBXGroup; + children = ( + 9F7788CD2BE652B1004E6BEF /* LatestPostsWidget.swift */, + 9F7788CF2BE652B1004E6BEF /* LatestPostsWidgetConfiguration.swift */, + ); + path = LatestPosts; + sourceTree = ""; + }; + 9FF2FB5D2BE7F559001560CE /* HashtagPostsWidget */ = { + isa = PBXGroup; + children = ( + 9FF2FB5E2BE7F56F001560CE /* HashtagPostsWidgetConfiguration.swift */, + 9FF2FB602BE7F5A7001560CE /* HashtagPostsWidget.swift */, + ); + path = HashtagPostsWidget; + sourceTree = ""; + }; + 9FF2FB642BE7F7FA001560CE /* Shared */ = { + isa = PBXGroup; + children = ( + 9FF2FB652BE7F805001560CE /* LastestPostsUI.swift */, + 9FF2FB682BE7F842001560CE /* SharedUtils.swift */, + ); + path = Shared; + sourceTree = ""; + }; E9B576C029743F2A00BCE646 /* Localization */ = { isa = PBXGroup; children = ( @@ -945,11 +981,15 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 9FF2FB622BE7F5D5001560CE /* HashtagPostsWidget.swift in Sources */, 9F7788EA2BE65585004E6BEF /* AppAccountEntity.swift in Sources */, + 9FF2FB6A2BE7F84E001560CE /* SharedUtils.swift in Sources */, 9F7788CE2BE652B1004E6BEF /* LatestPostsWidget.swift in Sources */, 9F7788EE2BE78D7B004E6BEF /* TimelineFilterEntity.swift in Sources */, 9F7788CC2BE652B1004E6BEF /* IceCubesAppWidgetsExtensionBundle.swift in Sources */, - 9F7788D02BE652B1004E6BEF /* IceCubesWidgetConfigurationIntent.swift in Sources */, + 9F7788D02BE652B1004E6BEF /* LatestPostsWidgetConfiguration.swift in Sources */, + 9FF2FB672BE7F816001560CE /* LastestPostsUI.swift in Sources */, + 9FF2FB632BE7F5D9001560CE /* HashtagPostsWidgetConfiguration.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/IceCubesAppWidgetsExtension/HashtagPostsWidget/HashtagPostsWidget.swift b/IceCubesAppWidgetsExtension/HashtagPostsWidget/HashtagPostsWidget.swift new file mode 100644 index 00000000..fdf8ea9d --- /dev/null +++ b/IceCubesAppWidgetsExtension/HashtagPostsWidget/HashtagPostsWidget.swift @@ -0,0 +1,81 @@ +import WidgetKit +import SwiftUI +import Network +import DesignSystem +import Models +import Timeline + +struct HashtagPostsWidgetProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> LatestPostWidgetEntry { + .init(date: Date(), + timeline: .hashtag(tag: "Mastodon", accountId: nil), + statuses: [.placeholder()], + images: [:]) + } + + func snapshot(for configuration: HashtagPostsWidgetConfiguration, in context: Context) async -> LatestPostWidgetEntry { + if let entry = await timeline(for: configuration, context: context).entries.first { + return entry + } + return .init(date: Date(), + timeline: .hashtag(tag: "Mastodon", accountId: nil), + statuses: [], + images: [:]) + } + + func timeline(for configuration: HashtagPostsWidgetConfiguration, in context: Context) async -> Timeline { + await timeline(for: configuration, context: context) + } + + private func timeline(for configuration: HashtagPostsWidgetConfiguration, context: Context) async -> Timeline { + guard let account = configuration.account, let hashgtag = configuration.hashgtag else { + return Timeline(entries: [.init(date: Date(), + timeline: .hashtag(tag: "Mastodon", accountId: nil), + statuses: [], + images: [:])], + policy: .atEnd) + } + do { + let statuses = await loadStatuses(for: .hashtag(tag: hashgtag, accountId: nil), + account: account, + widgetFamily: context.family) + let images = try await loadImages(urls: statuses.map{ $0.account.avatar } ) + return Timeline(entries: [.init(date: Date(), + timeline: .hashtag(tag: hashgtag, accountId: nil), + statuses: statuses, + images: images)], policy: .atEnd) + } catch { + return Timeline(entries: [.init(date: Date(), + timeline: .hashtag(tag: "Mastodon", accountId: nil), + statuses: [], + images: [:])], + policy: .atEnd) + } + } +} + +struct HashtagPostsWidget: Widget { + let kind: String = "HashtagPostsWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: HashtagPostsWidgetConfiguration.self, + provider: HashtagPostsWidgetProvider()) { entry in + LatestPostsWidgetView(entry: entry) + .containerBackground(Color("WidgetBackground").gradient, for: .widget) + } + .configurationDisplayName("Hashtag timeline") + .description("Show the latest post for the selected hashtag") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge]) + } +} + + +#Preview(as: .systemMedium) { + HashtagPostsWidget() +} timeline: { + LatestPostWidgetEntry(date: .now, + timeline: .hashtag(tag: "Matodon", accountId: nil), + statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()], + images: [:]) +} diff --git a/IceCubesAppWidgetsExtension/HashtagPostsWidget/HashtagPostsWidgetConfiguration.swift b/IceCubesAppWidgetsExtension/HashtagPostsWidget/HashtagPostsWidgetConfiguration.swift new file mode 100644 index 00000000..71804530 --- /dev/null +++ b/IceCubesAppWidgetsExtension/HashtagPostsWidget/HashtagPostsWidgetConfiguration.swift @@ -0,0 +1,22 @@ +import WidgetKit +import AppIntents + +struct HashtagPostsWidgetConfiguration: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "Configuration" + static let description = IntentDescription("Choose the account and hashtag for this widget") + + @Parameter(title: "Account") + var account: AppAccountEntity? + + @Parameter(title: "Hashtag") + var hashgtag: String? +} + +extension HashtagPostsWidgetConfiguration { + static var previewAccount: HashtagPostsWidgetConfiguration { + let intent = HashtagPostsWidgetConfiguration() + intent.account = .init(account: .init(server: "Test", accountName: "Test account")) + intent.hashgtag = "Mastodon" + return intent + } +} diff --git a/IceCubesAppWidgetsExtension/IceCubesAppWidgetsExtensionBundle.swift b/IceCubesAppWidgetsExtension/IceCubesAppWidgetsExtensionBundle.swift index fe7d3461..b950fdb1 100644 --- a/IceCubesAppWidgetsExtension/IceCubesAppWidgetsExtensionBundle.swift +++ b/IceCubesAppWidgetsExtension/IceCubesAppWidgetsExtensionBundle.swift @@ -5,5 +5,6 @@ import SwiftUI struct IceCubesAppWidgetsExtensionBundle: WidgetBundle { var body: some Widget { LatestPostsWidget() + HashtagPostsWidget() } } diff --git a/IceCubesAppWidgetsExtension/LatestPosts/LatestPostsWidget.swift b/IceCubesAppWidgetsExtension/LatestPosts/LatestPostsWidget.swift new file mode 100644 index 00000000..3ecd9283 --- /dev/null +++ b/IceCubesAppWidgetsExtension/LatestPosts/LatestPostsWidget.swift @@ -0,0 +1,100 @@ +import WidgetKit +import SwiftUI +import Network +import DesignSystem +import Models +import Timeline + +struct LatestPostsWidgetProvider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> LatestPostWidgetEntry { + .init(date: Date(), + timeline: .home, + statuses: [.placeholder()], + images: [:]) + } + + func snapshot(for configuration: LatestPostsWidgetConfiguration, in context: Context) async -> LatestPostWidgetEntry { + if let entry = await timeline(for: configuration, context: context).entries.first { + return entry + } + return .init(date: Date(), + timeline: .home, statuses: [], + images: [:]) + } + + func timeline(for configuration: LatestPostsWidgetConfiguration, in context: Context) async -> Timeline { + await timeline(for: configuration, context: context) + } + + private func timeline(for configuration: LatestPostsWidgetConfiguration, context: Context) async -> Timeline { + guard let account = configuration.account, let timeline = configuration.timeline else { + return Timeline(entries: [.init(date: Date(), + timeline: .home, + statuses: [], + images: [:])], + policy: .atEnd) + } + + do { + let statuses = await loadStatuses(for: timeline.timeline, + account: account, + widgetFamily: context.family) + let images = try await loadImages(urls: statuses.map{ $0.account.avatar } ) + return Timeline(entries: [.init(date: Date(), + timeline: timeline.timeline, + statuses: statuses, + images: images)], policy: .atEnd) + } catch { + return Timeline(entries: [.init(date: Date(), + timeline: .home, + statuses: [], + images: [:])], + policy: .atEnd) + } + } + + private func loadImages(urls: [URL]) async throws -> [URL: UIImage] { + try await withThrowingTaskGroup(of: (URL, UIImage?).self) { group in + for url in urls { + group.addTask { + let response = try await URLSession.shared.data(from: url) + return (url, UIImage(data: response.0)) + } + } + + var images: [URL: UIImage] = [:] + + for try await (url, image) in group { + images[url] = image + } + + return images + } + } +} + +struct LatestPostsWidget: Widget { + let kind: String = "LatestPostsWidget" + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, + intent: LatestPostsWidgetConfiguration.self, + provider: LatestPostsWidgetProvider()) { entry in + LatestPostsWidgetView(entry: entry) + .containerBackground(Color("WidgetBackground").gradient, for: .widget) + } + .configurationDisplayName("Latest posts") + .description("Show the latest post for the selected timeline") + .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge]) + } +} + + +#Preview(as: .systemMedium) { + LatestPostsWidget() +} timeline: { + LatestPostWidgetEntry(date: .now, + timeline: .home, + statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()], + images: [:]) +} diff --git a/IceCubesAppWidgetsExtension/IceCubesWidgetConfigurationIntent.swift b/IceCubesAppWidgetsExtension/LatestPosts/LatestPostsWidgetConfiguration.swift similarity index 67% rename from IceCubesAppWidgetsExtension/IceCubesWidgetConfigurationIntent.swift rename to IceCubesAppWidgetsExtension/LatestPosts/LatestPostsWidgetConfiguration.swift index 924bf169..ac6c3818 100644 --- a/IceCubesAppWidgetsExtension/IceCubesWidgetConfigurationIntent.swift +++ b/IceCubesAppWidgetsExtension/LatestPosts/LatestPostsWidgetConfiguration.swift @@ -1,7 +1,7 @@ import WidgetKit import AppIntents -struct IceCubesWidgetConfigurationIntent: WidgetConfigurationIntent { +struct LatestPostsWidgetConfiguration: WidgetConfigurationIntent { static let title: LocalizedStringResource = "Configuration" static let description = IntentDescription("Choose the account and timeline for this widget") @@ -12,9 +12,9 @@ struct IceCubesWidgetConfigurationIntent: WidgetConfigurationIntent { var timeline: TimelineFilterEntity? } -extension IceCubesWidgetConfigurationIntent { - static var previewAccount: IceCubesWidgetConfigurationIntent { - let intent = IceCubesWidgetConfigurationIntent() +extension LatestPostsWidgetConfiguration { + static var previewAccount: LatestPostsWidgetConfiguration { + let intent = LatestPostsWidgetConfiguration() intent.account = .init(account: .init(server: "Test", accountName: "Test account")) intent.timeline = .init(timeline: .home) return intent diff --git a/IceCubesAppWidgetsExtension/LatestPostsWidget.swift b/IceCubesAppWidgetsExtension/LatestPostsWidget.swift deleted file mode 100644 index 47131b2f..00000000 --- a/IceCubesAppWidgetsExtension/LatestPostsWidget.swift +++ /dev/null @@ -1,210 +0,0 @@ -import WidgetKit -import SwiftUI -import Network -import DesignSystem -import Models -import Timeline - -struct LatestPostsWidgetProvider: AppIntentTimelineProvider { - func placeholder(in context: Context) -> LatestPostWidgetEntry { - .init(date: Date(), - configuration: IceCubesWidgetConfigurationIntent(), - timeline: .home, - statuses: [.placeholder()], - images: [:]) - } - - func snapshot(for configuration: IceCubesWidgetConfigurationIntent, in context: Context) async -> LatestPostWidgetEntry { - if let entry = await timeline(for: configuration, context: context).entries.first { - return entry - } - return .init(date: Date(), - configuration: configuration, - timeline: .home, statuses: [], - images: [:]) - } - - func timeline(for configuration: IceCubesWidgetConfigurationIntent, in context: Context) async -> Timeline { - await timeline(for: configuration, context: context) - } - - private func timeline(for configuration: IceCubesWidgetConfigurationIntent, context: Context) async -> Timeline { - guard let account = configuration.account, let timeline = configuration.timeline else { - return Timeline(entries: [.init(date: Date(), - configuration: configuration, - timeline: .home, - statuses: [], - images: [:])], - policy: .atEnd) - } - let client = Client(server: account.account.server, oauthToken: account.account.oauthToken) - do { - var statuses: [Status] = try await client.get(endpoint: timeline.timeline.endpoint(sinceId: nil, - maxId: nil, - minId: nil, - offset: nil)) - statuses = statuses.filter{ $0.reblog == nil && !$0.content.asRawText.isEmpty } - switch context.family { - case .systemSmall, .systemMedium: - if statuses.count >= 1 { - statuses = statuses.prefix(upTo: 1).map{ $0 } - } - case .systemLarge, .systemExtraLarge: - if statuses.count >= 4 { - statuses = statuses.prefix(upTo: 4).map{ $0 } - } - default: - break - } - let images = try await loadImages(urls: statuses.map{ $0.account.avatar }) - return Timeline(entries: [.init(date: Date(), configuration: configuration, - timeline: timeline.timeline, - statuses: statuses, - images: images)], policy: .atEnd) - } catch { - return Timeline(entries: [.init(date: Date(), - configuration: configuration, - timeline: .home, - statuses: [], - images: [:])], policy: .atEnd) - } - } - - private func loadImages(urls: [URL]) async throws -> [URL: UIImage] { - try await withThrowingTaskGroup(of: (URL, UIImage?).self) { group in - for url in urls { - group.addTask { - let response = try await URLSession.shared.data(from: url) - return (url, UIImage(data: response.0)) - } - } - - var images: [URL: UIImage] = [:] - - for try await (url, image) in group { - images[url] = image - } - - return images - } - } -} - -struct LatestPostWidgetEntry: TimelineEntry { - let date: Date - let configuration: IceCubesWidgetConfigurationIntent - let timeline: TimelineFilter - let statuses: [Status] - let images: [URL: UIImage] -} - -struct LatestPostsWidgetView : View { - var entry: LatestPostsWidgetProvider.Entry - - @Environment(\.widgetFamily) var family - @Environment(\.redactionReasons) var redacted - - var contentLineLimit: Int { - switch family { - case .systemSmall, .systemMedium: - return 4 - default: - return 2 - } - } - var body: some View { - VStack(alignment: .leading, spacing: 8) { - headerView - VStack(alignment: .leading, spacing: 16) { - ForEach(entry.statuses) { status in - makeStatusView(status) - } - } - Spacer() - } - .frame(maxWidth: .infinity) - } - - private var headerView: some View { - HStack { - Text(entry.timeline.title) - Spacer() - Image(systemName: "cube") - } - .font(.subheadline) - .fontWeight(.bold) - .foregroundStyle(Color("AccentColor")) - } - - @ViewBuilder - private func makeStatusView(_ status: Status) -> some View { - if let url = URL(string: status.url ?? "") { - Link(destination: url, label: { - VStack(alignment: .leading, spacing: 2) { - makeStatusHeaderView(status) - Text(status.content.asSafeMarkdownAttributedString) - .font(.body) - .lineLimit(contentLineLimit) - .fixedSize(horizontal: false, vertical: true) - .padding(.leading, 20) - } - }) - } - } - - private func makeStatusHeaderView(_ status: Status) -> some View { - HStack(alignment: .center, spacing: 4) { - if let image = entry.images[status.account.avatar] { - Image(uiImage: image) - .resizable() - .frame(width: 16, height: 16) - .clipShape(Circle()) - } else { - Circle() - .foregroundStyle(.secondary) - .frame(width: 16, height: 16) - } - HStack(spacing: 0) { - Text(status.account.safeDisplayName) - .foregroundStyle(.primary) - if family != .systemSmall { - Text(" @") - .foregroundStyle(.tertiary) - Text(status.account.username) - .foregroundStyle(.tertiary) - } - Spacer() - } - .font(.subheadline) - .fontWeight(.semibold) - .lineLimit(1) - } - } -} - -struct LatestPostsWidget: Widget { - let kind: String = "LatestPostsWidget" - - var body: some WidgetConfiguration { - AppIntentConfiguration(kind: kind, - intent: IceCubesWidgetConfigurationIntent.self, - provider: LatestPostsWidgetProvider()) { entry in - LatestPostsWidgetView(entry: entry) - .containerBackground(Color("WidgetBackground").gradient, for: .widget) - } - .configurationDisplayName("Latest posts") - .description("Show the latest post for the selected timeline") - .supportedFamilies([.systemSmall, .systemMedium, .systemLarge, .systemExtraLarge]) - } -} - - -#Preview(as: .systemMedium) { - LatestPostsWidget() -} timeline: { - LatestPostWidgetEntry(date: .now, - configuration: .previewAccount, - timeline: .home, - statuses: [.placeholder(), .placeholder(), .placeholder(), .placeholder()], - images: [:]) -} diff --git a/IceCubesAppWidgetsExtension/Shared/LastestPostsUI.swift b/IceCubesAppWidgetsExtension/Shared/LastestPostsUI.swift new file mode 100644 index 00000000..78634d98 --- /dev/null +++ b/IceCubesAppWidgetsExtension/Shared/LastestPostsUI.swift @@ -0,0 +1,97 @@ +import WidgetKit +import SwiftUI +import Network +import DesignSystem +import Models +import Timeline + +struct LatestPostWidgetEntry: TimelineEntry { + let date: Date + let timeline: TimelineFilter + let statuses: [Status] + let images: [URL: UIImage] +} + +struct LatestPostsWidgetView : View { + var entry: LatestPostsWidgetProvider.Entry + + @Environment(\.widgetFamily) var family + @Environment(\.redactionReasons) var redacted + + var contentLineLimit: Int { + switch family { + case .systemSmall, .systemMedium: + return 4 + default: + return 2 + } + } + var body: some View { + VStack(alignment: .leading, spacing: 8) { + headerView + VStack(alignment: .leading, spacing: 16) { + ForEach(entry.statuses) { status in + makeStatusView(status) + } + } + Spacer() + } + .frame(maxWidth: .infinity) + } + + private var headerView: some View { + HStack { + Text(entry.timeline.title) + Spacer() + Image(systemName: "cube") + } + .font(.subheadline) + .fontWeight(.bold) + .foregroundStyle(Color("AccentColor")) + } + + @ViewBuilder + private func makeStatusView(_ status: Status) -> some View { + if let url = URL(string: status.url ?? "") { + Link(destination: url, label: { + VStack(alignment: .leading, spacing: 2) { + makeStatusHeaderView(status) + Text(status.content.asSafeMarkdownAttributedString) + .font(.body) + .lineLimit(contentLineLimit) + .fixedSize(horizontal: false, vertical: true) + .padding(.leading, 20) + } + }) + } + } + + private func makeStatusHeaderView(_ status: Status) -> some View { + HStack(alignment: .center, spacing: 4) { + if let image = entry.images[status.account.avatar] { + Image(uiImage: image) + .resizable() + .frame(width: 16, height: 16) + .clipShape(Circle()) + } else { + Circle() + .foregroundStyle(.secondary) + .frame(width: 16, height: 16) + } + HStack(spacing: 0) { + Text(status.account.safeDisplayName) + .foregroundStyle(.primary) + if family != .systemSmall { + Text(" @") + .foregroundStyle(.tertiary) + Text(status.account.username) + .foregroundStyle(.tertiary) + } + Spacer() + } + .font(.subheadline) + .fontWeight(.semibold) + .lineLimit(1) + } + } +} diff --git a/IceCubesAppWidgetsExtension/Shared/SharedUtils.swift b/IceCubesAppWidgetsExtension/Shared/SharedUtils.swift new file mode 100644 index 00000000..477b01f0 --- /dev/null +++ b/IceCubesAppWidgetsExtension/Shared/SharedUtils.swift @@ -0,0 +1,55 @@ +import StatusKit +import WidgetKit +import Timeline +import Foundation +import UIKit +import AppAccount +import Models +import Network + +func loadStatuses(for timeline: TimelineFilter, + account: AppAccountEntity, + widgetFamily: WidgetFamily) async -> [Status] { + let client = Client(server: account.account.server, oauthToken: account.account.oauthToken) + do { + var statuses: [Status] = try await client.get(endpoint: timeline.endpoint(sinceId: nil, + maxId: nil, + minId: nil, + offset: nil)) + statuses = statuses.filter{ $0.reblog == nil && !$0.content.asRawText.isEmpty } + switch widgetFamily { + case .systemSmall, .systemMedium: + if statuses.count >= 1 { + statuses = statuses.prefix(upTo: 1).map{ $0 } + } + case .systemLarge, .systemExtraLarge: + if statuses.count >= 4 { + statuses = statuses.prefix(upTo: 4).map{ $0 } + } + default: + break + } + return statuses + } catch { + return [] + } +} + +func loadImages(urls: [URL]) async throws -> [URL: UIImage] { + try await withThrowingTaskGroup(of: (URL, UIImage?).self) { group in + for url in urls { + group.addTask { + let response = try await URLSession.shared.data(from: url) + return (url, UIImage(data: response.0)) + } + } + + var images: [URL: UIImage] = [:] + + for try await (url, image) in group { + images[url] = image + } + + return images + } +}