diff --git a/Threaded.xcodeproj/project.pbxproj b/Threaded.xcodeproj/project.pbxproj index 513de48..e2ba9b1 100644 --- a/Threaded.xcodeproj/project.pbxproj +++ b/Threaded.xcodeproj/project.pbxproj @@ -46,6 +46,7 @@ B999DE5C2B76F8CB00509868 /* ContactsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B999DE5B2B76F8CB00509868 /* ContactsView.swift */; }; B999DE5E2B76F9D100509868 /* Message.swift in Sources */ = {isa = PBXBuildFile; fileRef = B999DE5D2B76F9D100509868 /* Message.swift */; }; B999DE602B76FB3E00509868 /* ContactRow.swift in Sources */ = {isa = PBXBuildFile; fileRef = B999DE5F2B76FB3E00509868 /* ContactRow.swift */; }; + B9B469B02B9A275F00AD5585 /* FollowGoalWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B469AF2B9A275F00AD5585 /* FollowGoalWidget.swift */; }; B9B63B212B442D1500BBC82D /* DynamicTextEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B202B442D1500BBC82D /* DynamicTextEditor.swift */; }; B9B63B232B447B8000BBC82D /* PostCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B222B447B8000BBC82D /* PostCardView.swift */; }; B9B63B252B44997400BBC82D /* QuotePostView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9B63B242B44997400BBC82D /* QuotePostView.swift */; }; @@ -181,6 +182,7 @@ B999DE5B2B76F8CB00509868 /* ContactsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactsView.swift; sourceTree = ""; }; B999DE5D2B76F9D100509868 /* Message.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Message.swift; sourceTree = ""; }; B999DE5F2B76FB3E00509868 /* ContactRow.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContactRow.swift; sourceTree = ""; }; + B9B469AF2B9A275F00AD5585 /* FollowGoalWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowGoalWidget.swift; sourceTree = ""; }; B9B63B202B442D1500BBC82D /* DynamicTextEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicTextEditor.swift; sourceTree = ""; }; B9B63B222B447B8000BBC82D /* PostCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PostCardView.swift; sourceTree = ""; }; B9B63B242B44997400BBC82D /* QuotePostView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = QuotePostView.swift; sourceTree = ""; }; @@ -326,6 +328,7 @@ B9C20D582B923CDD004DC9B3 /* ThreadedWidgetsExtension.entitlements */, B9C20D0F2B921C78004DC9B3 /* ThreadedWidgetsBundle.swift */, B9C20D112B921C78004DC9B3 /* FollowCountWidget.swift */, + B9B469AF2B9A275F00AD5585 /* FollowGoalWidget.swift */, B9C20D132B921C78004DC9B3 /* AppIntent.swift */, B9C20D602B949AD7004DC9B3 /* Redeclarations.swift */, B9C20D152B921C7B004DC9B3 /* Assets.xcassets */, @@ -684,6 +687,7 @@ B9C20D142B921C78004DC9B3 /* AppIntent.swift in Sources */, B9C20D372B9229EC004DC9B3 /* AppInfo.swift in Sources */, B9C20D3D2B9229EC004DC9B3 /* Emoji.swift in Sources */, + B9B469B02B9A275F00AD5585 /* FollowGoalWidget.swift in Sources */, B9C20D342B9229EC004DC9B3 /* Client.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/ThreadedWidgets/AppIntent.swift b/ThreadedWidgets/AppIntent.swift index 9c65da5..3389762 100644 --- a/ThreadedWidgets/AppIntent.swift +++ b/ThreadedWidgets/AppIntent.swift @@ -5,15 +5,26 @@ import SwiftData import WidgetKit import AppIntents -/// Widgets that require to select an account will use this `ConfigurationIntent` +/// Widgets that require to select only an account will use this `ConfigurationIntent` struct AccountAppIntent: WidgetConfigurationIntent { static var title: LocalizedStringResource = "widget.follow-count" static var description = IntentDescription("widget.follow-count.description") - + @Parameter(title: "widget.select-account") var account: AccountEntity? } +struct AccountGoalAppIntent: WidgetConfigurationIntent { + static var title: LocalizedStringResource = "widget.follow-goal" + static var description = IntentDescription("widget.follow-goal.description") + + @Parameter(title: "widget.select-account") + var account: AccountEntity? + + @Parameter(title: "widget.set-goal", default: 1_000) + var goal: Int +} + struct AccountEntity: AppEntity { let client: Client let id: String diff --git a/ThreadedWidgets/FollowCountWidget.swift b/ThreadedWidgets/FollowCountWidget.swift index c08de36..6f5468b 100644 --- a/ThreadedWidgets/FollowCountWidget.swift +++ b/ThreadedWidgets/FollowCountWidget.swift @@ -4,67 +4,9 @@ import WidgetKit import SwiftUI import SwiftData -struct Provider: AppIntentTimelineProvider { - func placeholder(in context: Context) -> SimpleEntry { - let placeholder: UIImage = UIImage(systemName: "person.crop.circle") ?? UIImage() - placeholder.withTintColor(UIColor.label) - return SimpleEntry(date: Date(), pfp: placeholder, followers: 38, configuration: AccountAppIntent()) - } - - func snapshot(for configuration: AccountAppIntent, in context: Context) async -> SimpleEntry { - let data = await getData(configuration: configuration) - return SimpleEntry(date: Date(), pfp: data.0, followers: data.1, configuration: configuration) - } - - func timeline(for configuration: AccountAppIntent, in context: Context) async -> Timeline { - var entries: [SimpleEntry] = [] - - let data = await getData(configuration: configuration) - - // Generate a timeline consisting of two entries an hour apart, starting from the current date. - let currentDate = Date() - for hourOffset in 0 ..< 2 { - let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! - let entry = SimpleEntry(date: entryDate, pfp: data.0, followers: data.1, configuration: configuration) - entries.append(entry) - } - - return Timeline(entries: entries, policy: .atEnd) - } - - private func getData(configuration: AccountAppIntent) async -> (UIImage, Int) { - var pfp: UIImage = UIImage(systemName: "person.crop.circle") ?? UIImage() - pfp.withTintColor(UIColor.label) - if let account = configuration.account { - do { - let acc = try await account.client.getString(endpoint: Accounts.verifyCredentials, forceVersion: .v1) - - if let serialized: [String : Any] = try JSONSerialization.jsonObject(with: acc.data(using: String.Encoding.utf8) ?? Data()) as? [String : Any] { - let avatar: String = serialized["avatar"] as! String - let task = try await URLSession.shared.data(from: URL(string: avatar)!) - pfp = UIImage(data: task.0) ?? UIImage() - - let followers: Int = serialized["followers_count"] as! Int - return (pfp, followers) - } - } catch { - print(error) - } - } - return (pfp, 0) - } -} - -struct SimpleEntry: TimelineEntry { - let date: Date - let pfp: UIImage - let followers: Int - let configuration: AccountAppIntent -} - struct FollowCountWidgetView: View { @Environment(\.widgetFamily) private var family: WidgetFamily - var entry: Provider.Entry + var entry: FollowCountWidget.Provider.Entry var body: some View { if let account = entry.configuration.account { @@ -157,6 +99,7 @@ struct FollowCountWidgetView: View { .multilineTextAlignment(.leading) .font(.system(size: 32, weight: .bold).monospacedDigit()) .contentTransition(.numericText()) + .redacted(reason: .privacy) } .padding(.horizontal, 7.5) @@ -183,4 +126,62 @@ struct FollowCountWidget: Widget { .description("widget.follow-count.description") .supportedFamilies([.systemSmall, .systemMedium, .accessoryRectangular]) } + + struct Provider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> SimpleEntry { + let placeholder: UIImage = UIImage(systemName: "person.crop.circle") ?? UIImage() + placeholder.withTintColor(UIColor.label) + return SimpleEntry(date: Date(), pfp: placeholder, followers: 38, configuration: AccountAppIntent()) + } + + func snapshot(for configuration: AccountAppIntent, in context: Context) async -> SimpleEntry { + let data = await getData(configuration: configuration) + return SimpleEntry(date: Date(), pfp: data.0, followers: data.1, configuration: configuration) + } + + func timeline(for configuration: AccountAppIntent, in context: Context) async -> Timeline { + var entries: [SimpleEntry] = [] + + let data = await getData(configuration: configuration) + + // Generate a timeline consisting of two entries an hour apart, starting from the current date. + let currentDate = Date() + for hourOffset in 0 ..< 2 { + let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! + let entry = SimpleEntry(date: entryDate, pfp: data.0, followers: data.1, configuration: configuration) + entries.append(entry) + } + + return Timeline(entries: entries, policy: .atEnd) + } + + private func getData(configuration: AccountAppIntent) async -> (UIImage, Int) { + var pfp: UIImage = UIImage(systemName: "person.crop.circle") ?? UIImage() + pfp.withTintColor(UIColor.label) + if let account = configuration.account { + do { + let acc = try await account.client.getString(endpoint: Accounts.verifyCredentials, forceVersion: .v1) + + if let serialized: [String : Any] = try JSONSerialization.jsonObject(with: acc.data(using: String.Encoding.utf8) ?? Data()) as? [String : Any] { + let avatar: String = serialized["avatar"] as! String + let task = try await URLSession.shared.data(from: URL(string: avatar)!) + pfp = UIImage(data: task.0) ?? UIImage() + + let followers: Int = serialized["followers_count"] as! Int + return (pfp, followers) + } + } catch { + print(error) + } + } + return (pfp, 0) + } + } + + struct SimpleEntry: TimelineEntry { + let date: Date + let pfp: UIImage + let followers: Int + let configuration: AccountAppIntent + } } diff --git a/ThreadedWidgets/FollowGoalWidget.swift b/ThreadedWidgets/FollowGoalWidget.swift new file mode 100644 index 0000000..98a62ff --- /dev/null +++ b/ThreadedWidgets/FollowGoalWidget.swift @@ -0,0 +1,198 @@ +//Made by Lumaa + +import WidgetKit +import SwiftUI +import SwiftData + +struct FollowGoalWidgetView: View { + @Environment(\.widgetFamily) private var family: WidgetFamily + var entry: FollowGoalWidget.Provider.Entry + + var maxGauge: Double { + return entry.followers >= entry.configuration.goal ? Double(entry.followers) : Double(entry.configuration.goal) + } + + var body: some View { + if let account = entry.configuration.account { + ZStack { + if family == WidgetFamily.systemMedium { + medium + } else if family == WidgetFamily.accessoryRectangular { + rectangular + } else if family == WidgetFamily.accessoryCircular { + circular + } else { + Text(String("Unsupported WidgetFamily")) + .font(.caption) + } + } + .modelContainer(for: [LoggedAccount.self]) + } else { + Text("widget.select-account") + .font(.caption) + } + } + + var medium: some View { + VStack(alignment: .center) { + HStack(alignment: .center, spacing: 7.5) { + Image(uiImage: entry.pfp) + .resizable() + .scaledToFit() + .frame(width: 30, height: 30) + .foregroundStyle(Color.white) + .clipShape(Circle()) + + Text("@\(entry.configuration.account!.username)") + .redacted(reason: .privacy) + .font(.caption.bold()) + .lineLimit(1) + + Spacer() + } + .padding(.horizontal, 7.5) + + + HStack(alignment: .center, spacing: 7.5) { + Text(entry.followers, format: .number.notation(.compactName)) + .font(.title.monospacedDigit().bold()) + .contentTransition(.numericText()) + Text("widget.followers") + .font(.caption) + .foregroundStyle(Color.gray) + + Spacer() + } + .padding(.horizontal, 7.5) + + Gauge(value: Double(entry.followers), in: 0...maxGauge) { + EmptyView() + } currentValueLabel: { + EmptyView() + } minimumValueLabel: { + Text(0, format: .number.notation(.compactName)) + } maximumValueLabel: { + Text(entry.configuration.goal, format: .number.notation(.compactName)) + } + .gaugeStyle(.linearCapacity) + .tint(Double(entry.followers) >= maxGauge ? Color.green : Color.blue) + .labelsHidden() + } + } + + var rectangular: some View { + Gauge(value: Double(entry.followers), in: 0...maxGauge) { + Text("@\(entry.configuration.account!.username)") + .multilineTextAlignment(.leading) + .font(.caption) + .opacity(0.7) + } currentValueLabel: { + HStack { + Text(entry.followers, format: .number.notation(.compactName)) + .font(.caption.monospacedDigit().bold()) + .contentTransition(.numericText()) + Text("widget.followers") + .font(.caption) + .foregroundStyle(Color.gray) + } + } minimumValueLabel: { + Text(0, format: .number.notation(.compactName)) + } maximumValueLabel: { + Text(entry.configuration.goal, format: .number.notation(.compactName)) + } + .gaugeStyle(.accessoryLinearCapacity) + } + + var circular: some View { + Gauge(value: Double(entry.followers), in: 0...maxGauge) { + EmptyView() + } currentValueLabel: { + Text(entry.followers, format: .number.notation(.compactName)) + .multilineTextAlignment(.center) + } minimumValueLabel: { + EmptyView() + } maximumValueLabel: { + EmptyView() + } + .gaugeStyle(.accessoryCircularCapacity) + .tint(Double(entry.followers) >= maxGauge ? Color.green : Color.blue) + } +} + +struct FollowGoalWidget: Widget { + let kind: String = "FollowGoalWidget" + let modelContainer: ModelContainer + + init() { + guard let modelContainer: ModelContainer = try? .init(for: LoggedAccount.self, configurations: ModelConfiguration(isStoredInMemoryOnly: true)) else { fatalError("Couldn't get LoggedAccounts") } + self.modelContainer = modelContainer + } + + var body: some WidgetConfiguration { + AppIntentConfiguration(kind: kind, intent: AccountGoalAppIntent.self, provider: Provider()) { entry in + FollowGoalWidgetView(entry: entry) + .containerBackground(Color("WidgetBackground"), for: .widget) + } + .configurationDisplayName("widget.follow-goal") + .description("widget.follow-goal.description") + .supportedFamilies([.systemMedium, .accessoryRectangular, .accessoryCircular]) + } + + struct Provider: AppIntentTimelineProvider { + func placeholder(in context: Context) -> SimpleEntry { + let placeholder: UIImage = UIImage(systemName: "person.crop.circle") ?? UIImage() + placeholder.withTintColor(UIColor.label) + return SimpleEntry(date: Date(), pfp: placeholder, followers: 38, configuration: AccountGoalAppIntent()) + } + + func snapshot(for configuration: AccountGoalAppIntent, in context: Context) async -> SimpleEntry { + let data = await getData(configuration: configuration) + return SimpleEntry(date: Date(), pfp: data.0, followers: data.1, configuration: configuration) + } + + func timeline(for configuration: AccountGoalAppIntent, in context: Context) async -> Timeline { + var entries: [SimpleEntry] = [] + + let data = await getData(configuration: configuration) + + // Generate a timeline consisting of two entries an hour apart, starting from the current date. + let currentDate = Date() + for hourOffset in 0 ..< 2 { + let entryDate = Calendar.current.date(byAdding: .hour, value: hourOffset, to: currentDate)! + let entry = SimpleEntry(date: entryDate, pfp: data.0, followers: data.1, configuration: configuration) + entries.append(entry) + } + + return Timeline(entries: entries, policy: .atEnd) + } + + private func getData(configuration: AccountGoalAppIntent) async -> (UIImage, Int) { + var pfp: UIImage = UIImage(systemName: "person.crop.circle") ?? UIImage() + pfp.withTintColor(UIColor.label) + if let account = configuration.account { + do { + let acc = try await account.client.getString(endpoint: Accounts.verifyCredentials, forceVersion: .v1) + + if let serialized: [String : Any] = try JSONSerialization.jsonObject(with: acc.data(using: String.Encoding.utf8) ?? Data()) as? [String : Any] { + let avatar: String = serialized["avatar"] as! String + let task = try await URLSession.shared.data(from: URL(string: avatar)!) + pfp = UIImage(data: task.0) ?? UIImage() + + let followers: Int = serialized["followers_count"] as! Int + return (pfp, followers) + } + } catch { + print(error) + } + } + return (pfp, 0) + } + } + + struct SimpleEntry: TimelineEntry { + let date: Date + let pfp: UIImage + let followers: Int + let configuration: AccountGoalAppIntent + } +} diff --git a/ThreadedWidgets/Localizable.xcstrings b/ThreadedWidgets/Localizable.xcstrings index f9a6fdb..ac81bb9 100644 --- a/ThreadedWidgets/Localizable.xcstrings +++ b/ThreadedWidgets/Localizable.xcstrings @@ -34,6 +34,26 @@ } } }, + "widget.follow-goal" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Follower Goal" + } + } + } + }, + "widget.follow-goal.description" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Set a follower goal and see the progress" + } + } + } + }, "widget.followers" : { "localizations" : { "en" : { @@ -53,6 +73,16 @@ } } } + }, + "widget.set-goal" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Goal" + } + } + } } }, "version" : "1.0" diff --git a/ThreadedWidgets/ThreadedWidgetsBundle.swift b/ThreadedWidgets/ThreadedWidgetsBundle.swift index f2e563f..f9ce166 100644 --- a/ThreadedWidgets/ThreadedWidgetsBundle.swift +++ b/ThreadedWidgets/ThreadedWidgetsBundle.swift @@ -8,5 +8,6 @@ import UIKit struct ThreadedWidgetsBundle: WidgetBundle { var body: some Widget { FollowCountWidget() + FollowGoalWidget() } }