Add LatestFollowersWidget

This commit is contained in:
Marcus Kida 2023-02-06 11:39:40 +01:00
parent 8438bbc032
commit d685b9e365
No known key found for this signature in database
GPG Key ID: 19FF64E08013CA40
12 changed files with 397 additions and 27 deletions

View File

@ -49,7 +49,6 @@
2A728130297EA9D8004138C5 /* WidgetExtension.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 2A72812C297EA9D7004138C5 /* WidgetExtension.intentdefinition */; };
2A728131297EA9D8004138C5 /* WidgetExtension.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 2A72812C297EA9D7004138C5 /* WidgetExtension.intentdefinition */; };
2A728134297EA9D8004138C5 /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2A728120297EA9D7004138C5 /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; };
2A72813B297EC6F7004138C5 /* MastodonSDKDynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 2A72813A297EC6F7004138C5 /* MastodonSDKDynamic */; };
2A72813F297EC762004138C5 /* WidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A72813E297EC762004138C5 /* WidgetExtension.swift */; };
2A76F75C2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */; };
2A82294F29262EE000D2A1F7 /* AppContext+NextAccount.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */; };
@ -57,6 +56,9 @@
2A86A14929892B3A007F1062 /* MultiFollowersCountWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */; };
2A86A14B2989326E007F1062 /* MultiFollowersCountWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */; };
2A90A157296EEE500026C155 /* MastodonSDKDynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 2A90A156296EEE500026C155 /* MastodonSDKDynamic */; };
2A9D0664298C048800BF38CB /* LatestFollowersWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */; };
2A9D0666298C05A800BF38CB /* LatestFollowersWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */; };
2A9D066F298D0FD100BF38CB /* MastodonSDKDynamic in Frameworks */ = {isa = PBXBuildFile; productRef = 2A9D066E298D0FD100BF38CB /* MastodonSDKDynamic */; };
2AB12E4629362F27006BC925 /* DataSourceFacade+Translate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AB12E4529362F27006BC925 /* DataSourceFacade+Translate.swift */; };
2AE202AA297FE10B00F66E55 /* WidgetExtension.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 2A72812C297EA9D7004138C5 /* WidgetExtension.intentdefinition */; };
2AE202AC297FE19100F66E55 /* FollowersCountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */; };
@ -648,6 +650,8 @@
2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountIntentHandler.swift; sourceTree = "<group>"; };
2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidget.swift; sourceTree = "<group>"; };
2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidgetView.swift; sourceTree = "<group>"; };
2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidget.swift; sourceTree = "<group>"; };
2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidgetView.swift; sourceTree = "<group>"; };
2AB12E4529362F27006BC925 /* DataSourceFacade+Translate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Translate.swift"; sourceTree = "<group>"; };
2AE202A9297FDDF500F66E55 /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = "<group>"; };
2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountIntentHandler.swift; sourceTree = "<group>"; };
@ -1216,7 +1220,7 @@
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
files = (
2A72813B297EC6F7004138C5 /* MastodonSDKDynamic in Frameworks */,
2A9D066F298D0FD100BF38CB /* MastodonSDKDynamic in Frameworks */,
2A728124297EA9D7004138C5 /* SwiftUI.framework in Frameworks */,
2A728122297EA9D7004138C5 /* WidgetKit.framework in Frameworks */,
);
@ -1458,6 +1462,7 @@
children = (
2A86A14429892709007F1062 /* FollowersCount */,
2A86A14729892B1B007F1062 /* MultiFollowersCount */,
2A9D0662298C045000BF38CB /* LatestFollowers */,
);
path = Variants;
sourceTree = "<group>";
@ -1481,6 +1486,15 @@
path = MultiFollowersCount;
sourceTree = "<group>";
};
2A9D0662298C045000BF38CB /* LatestFollowers */ = {
isa = PBXGroup;
children = (
2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */,
2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */,
);
path = LatestFollowers;
sourceTree = "<group>";
};
2D152A8A25C295B8009AA50C /* Content */ = {
isa = PBXGroup;
children = (
@ -3019,7 +3033,7 @@
);
name = WidgetExtension;
packageProductDependencies = (
2A72813A297EC6F7004138C5 /* MastodonSDKDynamic */,
2A9D066E298D0FD100BF38CB /* MastodonSDKDynamic */,
);
productName = WidgetExtensionExtension;
productReference = 2A728120297EA9D7004138C5 /* WidgetExtension.appex */;
@ -3501,6 +3515,7 @@
2A33063729880835001D4C51 /* DataRepresentable.swift in Sources */,
2A33062D2987DBFA001D4C51 /* FollowersCountHistory.swift in Sources */,
2A33063829880835001D4C51 /* LineChart.swift in Sources */,
2A9D0666298C05A800BF38CB /* LatestFollowersWidgetView.swift in Sources */,
2A728130297EA9D8004138C5 /* WidgetExtension.intentdefinition in Sources */,
2A86A14B2989326E007F1062 /* MultiFollowersCountWidgetView.swift in Sources */,
2A72813F297EC762004138C5 /* WidgetExtension.swift in Sources */,
@ -3509,6 +3524,7 @@
2A33063629880835001D4C51 /* Math.swift in Sources */,
2A86A14929892B3A007F1062 /* MultiFollowersCountWidget.swift in Sources */,
2A33AB662982C4AF008A7FB1 /* FollowersCountWidgetView.swift in Sources */,
2A9D0664298C048800BF38CB /* LatestFollowersWidget.swift in Sources */,
2A728127297EA9D7004138C5 /* WidgetExtensionBundle.swift in Sources */,
2A72812B297EA9D7004138C5 /* FollowersCountWidget.swift in Sources */,
2A33063929880835001D4C51 /* CurvedChart.swift in Sources */,
@ -5315,11 +5331,11 @@
/* End XCConfigurationList section */
/* Begin XCSwiftPackageProductDependency section */
2A72813A297EC6F7004138C5 /* MastodonSDKDynamic */ = {
2A90A156296EEE500026C155 /* MastodonSDKDynamic */ = {
isa = XCSwiftPackageProductDependency;
productName = MastodonSDKDynamic;
};
2A90A156296EEE500026C155 /* MastodonSDKDynamic */ = {
2A9D066E298D0FD100BF38CB /* MastodonSDKDynamic */ = {
isa = XCSwiftPackageProductDependency;
productName = MastodonSDKDynamic;
};

View File

@ -63,6 +63,7 @@
<array>
<string>FollowersCountIntent</string>
<string>MultiFollowersCountIntent</string>
<string>LatestFollowersIntent</string>
<string>SendPostIntent</string>
</array>
<key>UIApplicationSceneManifest</key>

View File

@ -27,3 +27,9 @@ extension Collection where Iterator.Element: NSManagedObject {
}
}
}
extension Collection {
public subscript (safe index: Index) -> Element? {
return indices.contains(index) ? self[index] : nil
}
}

View File

@ -0,0 +1,23 @@
{
"images" : [
{
"filename" : "Logo.png",
"idiom" : "universal",
"scale" : "1x"
},
{
"filename" : "Logo@2x.png",
"idiom" : "universal",
"scale" : "2x"
},
{
"filename" : "Logo@3x.png",
"idiom" : "universal",
"scale" : "3x"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 576 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -94,9 +94,9 @@ class FollowersCountHistory {
let relevantDays = elapsedFollowersCountDateStrings()
let today = relevantDays.last!
let yesterday = relevantDays[relevantDays.count - 2]
let followersToday = history.first(where: { $0.dstring == today })?.count ?? account.followersCount
let followersYesterday = history.first(where: { $0.dstring == yesterday })?.count ?? account.followersCount
let followersYesterday = history[safe: history.count-2]?.count ?? account.followersCount
let followersChange = followersToday - followersYesterday

View File

@ -0,0 +1,175 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import WidgetKit
import SwiftUI
import Intents
import MastodonSDK
struct LatestFollowersWidgetProvider: IntentTimelineProvider {
func placeholder(in context: Context) -> LatestFollowersEntry {
.placeholder
}
func getSnapshot(for configuration: LatestFollowersIntent, in context: Context, completion: @escaping (LatestFollowersEntry) -> ()) {
guard !context.isPreview else {
return completion(.placeholder)
}
loadCurrentEntry(for: configuration, in: context, completion: completion)
}
func getTimeline(for configuration: LatestFollowersIntent, in context: Context, completion: @escaping (Timeline<LatestFollowersEntry>) -> ()) {
loadCurrentEntry(for: configuration, in: context) { entry in
completion(Timeline(entries: [entry], policy: .after(.now)))
}
}
}
struct LatestFollowersEntry: TimelineEntry {
let date: Date
let accounts: [LatestFollowersEntryAccountable]?
let configuration: LatestFollowersIntent
static var placeholder: Self {
LatestFollowersEntry(
date: .now,
accounts: [
LatestFollowersEntryAccount(
note: "Just another Mastodon user",
displayNameWithFallback: "Mastodon",
acct: "mastodon",
avatarImage: UIImage(named: "missingAvatar")!,
domain: "mastodon"
),
LatestFollowersEntryAccount(
note: "Yet another Mastodon user",
displayNameWithFallback: "Mastodon",
acct: "mastodon",
avatarImage: UIImage(named: "missingAvatar")!,
domain: "mastodon"
)
],
configuration: LatestFollowersIntent()
)
}
static var unconfigured: Self {
LatestFollowersEntry(
date: .now,
accounts: [],
configuration: LatestFollowersIntent()
)
}
}
struct LatestFollowersWidget: Widget {
private var availableFamilies: [WidgetFamily] {
return [.systemSmall, .systemMedium]
}
var body: some WidgetConfiguration {
IntentConfiguration(kind: "Latest followers", intent: LatestFollowersIntent.self, provider: LatestFollowersWidgetProvider()) { entry in
LatestFollowersWidgetView(entry: entry)
}
.configurationDisplayName("Latest followers")
.description("Show latest followers.")
.supportedFamilies(availableFamilies)
}
}
private extension LatestFollowersWidgetProvider {
func loadCurrentEntry(for configuration: LatestFollowersIntent, in context: Context, completion: @escaping (LatestFollowersEntry) -> Void) {
Task { @MainActor in
guard
let authBox = WidgetExtension.appContext
.authenticationService
.mastodonAuthenticationBoxes
.first
else {
return completion(.unconfigured)
}
// guard let desiredAccount: String = {
// guard let account = authBox.authenticationRecord.object(in: WidgetExtension.appContext.managedObjectContext)?.user.acct else {
// return nil
// }
// return account
// }() else {
// return completion(.unconfigured)
// }
var accounts = [LatestFollowersEntryAccountable]()
let followers = try await WidgetExtension.appContext
.apiService
.followers(userID: authBox.userID, maxID: nil, authenticationBox: authBox)
.value
.prefix(2) // X most recent followers
for follower in followers {
let imageData = try await URLSession.shared.data(from: follower.avatarImageURLWithFallback(domain: authBox.domain)).0
accounts.append(
LatestFollowersEntryAccount(
note: follower.note,
displayNameWithFallback: follower.displayNameWithFallback,
acct: follower.acct,
avatarImage: UIImage(data: imageData) ?? UIImage(named: "missingAvatar")!,
domain: authBox.domain
)
)
}
let entry = LatestFollowersEntry(
date: Date(),
accounts: accounts,
configuration: configuration
)
completion(entry)
// for desiredAccount in desiredAccounts {
// let resultingAccount = try await WidgetExtension.appContext
// .apiService
// .search(query: .init(q: desiredAccount, type: .accounts), authenticationBox: authBox)
// .value
// .accounts
// .first!
//
// let imageData = try await URLSession.shared.data(from: resultingAccount.avatarImageURLWithFallback(domain: authBox.domain)).0
//
// accounts.append(FollowersEntryAccount.from(
// mastodonAccount: resultingAccount,
// domain: authBox.domain,
// avatarImage: UIImage(data: imageData) ?? UIImage(named: "missingAvatar")!
// ))
// }
}
}
}
protocol LatestFollowersEntryAccountable {
var note: String { get }
var displayNameWithFallback: String { get }
var acct: String { get }
var avatarImage: UIImage { get }
var domain: String { get }
}
struct LatestFollowersEntryAccount: LatestFollowersEntryAccountable {
let note: String
let displayNameWithFallback: String
let acct: String
let avatarImage: UIImage
let domain: String
static func from(mastodonAccount: Mastodon.Entity.Account, domain: String, avatarImage: UIImage) -> Self {
LatestFollowersEntryAccount(
note: mastodonAccount.header,
displayNameWithFallback: mastodonAccount.displayNameWithFallback,
acct: mastodonAccount.acct,
avatarImage: avatarImage,
domain: domain
)
}
}

View File

@ -0,0 +1,132 @@
// Copyright © 2023 Mastodon gGmbH. All rights reserved.
import SwiftUI
import WidgetKit
import MastodonSDK
import MastodonAsset
import MastodonUI
struct LatestFollowersWidgetView: View {
private let dateFormatter: DateFormatter = {
let formatter = DateFormatter()
formatter.dateStyle = .none
formatter.timeStyle = .short
return formatter
}()
@Environment(\.widgetFamily) var family
var entry: LatestFollowersWidgetProvider.Entry
var body: some View {
if let accounts = entry.accounts {
switch family {
case .systemSmall:
viewForSmallWidget(accounts, lastUpdate: entry.date)
case .systemMedium:
viewForMediumWidget(accounts, lastUpdate: entry.date)
default:
Text("Sorry but this Widget family is unsupported.")
}
} else {
Text("Please open Mastodon to log in to an Account.")
.multilineTextAlignment(.center)
.font(.caption)
.padding(.all, 20)
}
}
private func viewForSmallWidget(_ accounts: [LatestFollowersEntryAccountable], lastUpdate: Date) -> some View {
VStack(alignment: .leading, spacing: 0) {
ForEach(accounts, id: \.acct) { account in
HStack {
if let avatarImage = account.avatarImage {
Image(uiImage: avatarImage)
.resizable()
.frame(width: 32, height: 32)
.cornerRadius(5)
}
VStack(alignment: .leading) {
Text(account.note)
.font(.caption)
.lineLimit(1)
.truncationMode(.tail)
Text("@\(account.acct)")
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
}
.padding(.leading, 20)
}
Spacer()
}
.padding(.vertical, 16)
}
private func viewForMediumWidget(_ accounts: [LatestFollowersEntryAccountable], lastUpdate: Date) -> some View {
VStack(alignment: .leading) {
HStack {
Text("Latest followers")
.font(.system(size: UIFontMetrics.default.scaledValue(for: 16)))
Spacer()
Image("BrandIconColored")
}
ForEach(accounts, id: \.acct) { account in
HStack {
if let avatarImage = account.avatarImage {
Image(uiImage: avatarImage)
.resizable()
.frame(width: 32, height: 32)
.cornerRadius(5)
}
VStack(alignment: .leading) {
HStack {
Text(account.displayNameWithFallback)
.font(.footnote.bold())
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
Text("@\(account.acct)")
.font(.caption2)
.foregroundColor(.secondary)
.lineLimit(1)
.truncationMode(.tail)
}
Text(account.noteWithoutHtmlTags!)
.font(.caption)
.lineLimit(1)
.truncationMode(.tail)
}
Spacer()
}
}
Spacer()
Text("Last update: \(dateFormatter.string(from: lastUpdate))")
.font(.caption2)
.foregroundColor(.secondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 16)
}
}
private extension LatestFollowersEntryAccountable {
var noteWithoutHtmlTags: String? {
do {
let regex = "<[^>]+>"
let expr = try NSRegularExpression(pattern: regex, options: NSRegularExpression.Options.caseInsensitive)
let result = expr.stringByReplacingMatches(in: note, options: [], range: NSMakeRange(0, note.count), withTemplate: "")
return result
} catch {
return nil
}
}
}

View File

@ -278,26 +278,6 @@
<key>INIntentParameterPromptDialogType</key>
<string>Primary</string>
</dict>
<dict>
<key>INIntentParameterPromptDialogCustom</key>
<true/>
<key>INIntentParameterPromptDialogFormatString</key>
<string>There are ${count} options matching ${accounts}.</string>
<key>INIntentParameterPromptDialogFormatStringID</key>
<string>3nWfxd</string>
<key>INIntentParameterPromptDialogType</key>
<string>DisambiguationIntroduction</string>
</dict>
<dict>
<key>INIntentParameterPromptDialogCustom</key>
<true/>
<key>INIntentParameterPromptDialogFormatString</key>
<string>Just to confirm, you wanted ${accounts}?</string>
<key>INIntentParameterPromptDialogFormatStringID</key>
<string>IP6ujX</string>
<key>INIntentParameterPromptDialogType</key>
<string>Confirmation</string>
</dict>
</array>
<key>INIntentParameterSupportsDynamicEnumeration</key>
<true/>
@ -359,6 +339,42 @@
<key>INIntentVerb</key>
<string>View</string>
</dict>
<dict>
<key>INIntentCategory</key>
<string>information</string>
<key>INIntentDescriptionID</key>
<string>5KZ2fm</string>
<key>INIntentEligibleForWidgets</key>
<true/>
<key>INIntentIneligibleForSuggestions</key>
<true/>
<key>INIntentName</key>
<string>LatestFollowers</string>
<key>INIntentResponse</key>
<dict>
<key>INIntentResponseCodes</key>
<array>
<dict>
<key>INIntentResponseCodeName</key>
<string>success</string>
<key>INIntentResponseCodeSuccess</key>
<true/>
</dict>
<dict>
<key>INIntentResponseCodeName</key>
<string>failure</string>
</dict>
</array>
</dict>
<key>INIntentTitle</key>
<string>Latest Followers</string>
<key>INIntentTitleID</key>
<string>ZLZ6sg</string>
<key>INIntentType</key>
<string>Custom</string>
<key>INIntentVerb</key>
<string>View</string>
</dict>
</array>
<key>INTypes</key>
<array/>

View File

@ -8,5 +8,6 @@ struct WidgetExtensionBundle: WidgetBundle {
var body: some Widget {
FollowersCountWidget()
MultiFollowersCountWidget()
LatestFollowersWidget()
}
}