diff --git a/.gitignore b/.gitignore index 017cfd6fa..907bc4d97 100644 --- a/.gitignore +++ b/.gitignore @@ -131,3 +131,6 @@ env/**/** ## Ruby ### vendor/ .bundle/ + +## IntelliJ IDEA ## +.idea \ No newline at end of file diff --git a/Localization/app.json b/Localization/app.json index 2c25c89e3..61c4a7fa0 100644 --- a/Localization/app.json +++ b/Localization/app.json @@ -816,5 +816,31 @@ "open_in": { "invalid_link_error": "This doesn't seem to be a valid Mastodon link." } + }, + "widget": { + "common": { + "unsupported_widget_family": "Sorry but this Widget family is unsupported.", + "user_not_logged_in": "Please open Mastodon to log in to an Account." + }, + "followers_count": { + "configuration_display_name": "Followers", + "configuration_description": "Show number of followers.", + "title": "FOLLOWERS", + "followers_today": "%s followers today" + }, + "multiple_followers": { + "configuration_display_name": "Multiple followers", + "configuration_description": "Show number of followers for multiple accounts.", + "mock_user": { + "display_name": "Another follower", + "account_name": "another@follower.social" + } + }, + "latest_followers": { + "configuration_display_name": "Latest followers", + "configuration_description": "Show latest followers.", + "title": "Latest followers", + "last_update": "Last update: %s" + } } } diff --git a/Mastodon.xcodeproj/project.pbxproj b/Mastodon.xcodeproj/project.pbxproj index 4caf3ebe5..4f75ca4e2 100644 --- a/Mastodon.xcodeproj/project.pbxproj +++ b/Mastodon.xcodeproj/project.pbxproj @@ -24,6 +24,8 @@ 27D701F5292FC2D60031BCBB /* DataSourceFacade+URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27D701F4292FC2D60031BCBB /* DataSourceFacade+URL.swift */; }; 2A1FE47C2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */; }; 2A1FE47E2938C11200784BF1 /* Collection+IsNotEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */; }; + 2A33062D2987DBFA001D4C51 /* FollowersCountHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33062C2987DBFA001D4C51 /* FollowersCountHistory.swift */; }; + 2A33AB662982C4AF008A7FB1 /* FollowersCountWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33AB652982C4AF008A7FB1 /* FollowersCountWidgetView.swift */; }; 2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; }; 2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */; }; 2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; }; @@ -33,10 +35,29 @@ 2A71F541296DBDA80049F54A /* Media.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A71F53D296DBDA80049F54A /* Media.xcassets */; }; 2A71F542296DBDA80049F54A /* Action.js in Resources */ = {isa = PBXBuildFile; fileRef = 2A71F53E296DBDA80049F54A /* Action.js */; }; 2A71F543296DBDA80049F54A /* ActionRequestHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A71F53F296DBDA80049F54A /* ActionRequestHandler.swift */; }; + 2A728122297EA9D7004138C5 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A728121297EA9D7004138C5 /* WidgetKit.framework */; }; + 2A728124297EA9D7004138C5 /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2A728123297EA9D7004138C5 /* SwiftUI.framework */; }; + 2A728127297EA9D7004138C5 /* WidgetExtensionBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A728126297EA9D7004138C5 /* WidgetExtensionBundle.swift */; }; + 2A72812B297EA9D7004138C5 /* FollowersCountWidget.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A72812A297EA9D7004138C5 /* FollowersCountWidget.swift */; }; + 2A72812E297EA9D8004138C5 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 2A72812D297EA9D8004138C5 /* Assets.xcassets */; }; + 2A728134297EA9D8004138C5 /* WidgetExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2A728120297EA9D7004138C5 /* WidgetExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 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 */; }; + 2A86A14629892944007F1062 /* MultiFollowersCountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */; }; + 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 */; }; + 2AB5011B2992322500346092 /* LightChart in Frameworks */ = {isa = PBXBuildFile; productRef = 2AB5011A2992322500346092 /* LightChart */; }; + 2AB5011C299243FB00346092 /* WidgetExtension.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 2AB50120299243FB00346092 /* WidgetExtension.intentdefinition */; }; + 2AB5011D299243FB00346092 /* WidgetExtension.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 2AB50120299243FB00346092 /* WidgetExtension.intentdefinition */; }; + 2AB5011E299243FB00346092 /* WidgetExtension.intentdefinition in Sources */ = {isa = PBXBuildFile; fileRef = 2AB50120299243FB00346092 /* WidgetExtension.intentdefinition */; }; + 2AE202AC297FE19100F66E55 /* FollowersCountIntentHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */; }; + 2AE202AD297FE1CD00F66E55 /* WidgetExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A72813E297EC762004138C5 /* WidgetExtension.swift */; }; 2AE244482927831100BDBF7C /* UIImage+SFSymbols.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */; }; 2D198643261BF09500F0B013 /* SearchResultItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198642261BF09500F0B013 /* SearchResultItem.swift */; }; 2D198649261C0B8500F0B013 /* SearchResultSection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D198648261C0B8500F0B013 /* SearchResultSection.swift */; }; @@ -467,6 +488,13 @@ remoteGlobalIDString = 2A64515C29642A8A00CD8B8A; remoteInfo = FollowActionExtension; }; + 2A728132297EA9D8004138C5 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; + proxyType = 1; + remoteGlobalIDString = 2A72811F297EA9D7004138C5; + remoteInfo = WidgetExtensionExtension; + }; DB427DE925BAA00100D1B89D /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = DB427DCA25BAA00100D1B89D /* Project object */; @@ -505,6 +533,16 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 2A72813D297EC6F7004138C5 /* Embed Frameworks */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 10; + files = ( + ); + name = "Embed Frameworks"; + runOnlyForDeploymentPostprocessing = 0; + }; 2A90A159296EEE500026C155 /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -544,6 +582,7 @@ dstSubfolderSpec = 13; files = ( DB8FABCE26AEC7B2008E5AF4 /* MastodonIntent.appex in Embed Foundation Extensions */, + 2A728134297EA9D8004138C5 /* WidgetExtension.appex in Embed Foundation Extensions */, 2A64516929642A8B00CD8B8A /* OpenInActionExtension.appex in Embed Foundation Extensions */, DBC6461C26A170AB00B0E31B /* ShareActionExtension.appex in Embed Foundation Extensions */, DBF8AE1A263293E400C9C23C /* NotificationService.appex in Embed Foundation Extensions */, @@ -573,7 +612,9 @@ 27D701F4292FC2D60031BCBB /* DataSourceFacade+URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+URL.swift"; sourceTree = ""; }; 2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowedTagsViewModel+DiffableDataSource.swift"; sourceTree = ""; }; 2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+IsNotEmpty.swift"; sourceTree = ""; }; + 2A33062C2987DBFA001D4C51 /* FollowersCountHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountHistory.swift; sourceTree = ""; }; 2A33625329759B4200481A90 /* OpenInActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenInActionExtension.entitlements; sourceTree = ""; }; + 2A33AB652982C4AF008A7FB1 /* FollowersCountWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountWidgetView.swift; sourceTree = ""; }; 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = ""; }; 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsTableViewCell.swift; sourceTree = ""; }; 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewController.swift; sourceTree = ""; }; @@ -584,9 +625,51 @@ 2A71F53E296DBDA80049F54A /* Action.js */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.javascript; path = Action.js; sourceTree = ""; }; 2A71F53F296DBDA80049F54A /* ActionRequestHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ActionRequestHandler.swift; sourceTree = ""; }; 2A71F540296DBDA80049F54A /* Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 2A728120297EA9D7004138C5 /* WidgetExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = WidgetExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 2A728121297EA9D7004138C5 /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 2A728123297EA9D7004138C5 /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 2A728126297EA9D7004138C5 /* WidgetExtensionBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetExtensionBundle.swift; sourceTree = ""; }; + 2A72812A297EA9D7004138C5 /* FollowersCountWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountWidget.swift; sourceTree = ""; }; + 2A72812D297EA9D8004138C5 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 2A72812F297EA9D8004138C5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; + 2A72813E297EC762004138C5 /* WidgetExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WidgetExtension.swift; sourceTree = ""; }; 2A76F75B2930D94700B3388D /* HashtagTimelineHeaderViewActionButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HashtagTimelineHeaderViewActionButton.swift; sourceTree = ""; }; 2A82294E29262EE000D2A1F7 /* AppContext+NextAccount.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "AppContext+NextAccount.swift"; sourceTree = ""; }; + 2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountIntentHandler.swift; sourceTree = ""; }; + 2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidget.swift; sourceTree = ""; }; + 2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiFollowersCountWidgetView.swift; sourceTree = ""; }; + 2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidget.swift; sourceTree = ""; }; + 2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LatestFollowersWidgetView.swift; sourceTree = ""; }; 2AB12E4529362F27006BC925 /* DataSourceFacade+Translate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+Translate.swift"; sourceTree = ""; }; + 2AB5011F299243FB00346092 /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.intentdefinition; name = Base; path = Base.lproj/WidgetExtension.intentdefinition; sourceTree = ""; }; + 2AB501222992440200346092 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501242992443100346092 /* vi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = vi; path = vi.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501262992443100346092 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501282992443200346092 /* th */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = th; path = th.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB5012A2992443200346092 /* sv */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sv; path = sv.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB5012C2992443300346092 /* es-AR */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "es-AR"; path = "es-AR.lproj/WidgetExtension.strings"; sourceTree = ""; }; + 2AB5012E2992443300346092 /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501302992443400346092 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501322992443400346092 /* gd */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = gd; path = gd.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501342992443500346092 /* ru */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ru; path = ru.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501362992443600346092 /* ckb */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ckb; path = ckb.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501382992443600346092 /* ku */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ku; path = ku.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB5013A2992443700346092 /* kab */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = kab; path = kab.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB5013C2992443700346092 /* ja */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ja; path = ja.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB5013E2992443800346092 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501402992443800346092 /* de */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = de; path = de.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501422992443900346092 /* gl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = gl; path = gl.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501442992443900346092 /* fr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fr; path = fr.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501462992443900346092 /* fi */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = fi; path = fi.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501482992443A00346092 /* nl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = nl; path = nl.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB5014A2992443A00346092 /* cs */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = cs; path = cs.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB5014C2992443B00346092 /* zh-Hant */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant"; path = "zh-Hant.lproj/WidgetExtension.strings"; sourceTree = ""; }; + 2AB5014E2992443B00346092 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/WidgetExtension.strings"; sourceTree = ""; }; + 2AB501502992443C00346092 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501522992443C00346092 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AB501542992443D00346092 /* ar */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ar; path = ar.lproj/WidgetExtension.strings; sourceTree = ""; }; + 2AE202A9297FDDF500F66E55 /* WidgetExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = WidgetExtension.entitlements; sourceTree = ""; }; + 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountIntentHandler.swift; sourceTree = ""; }; 2AE244472927831100BDBF7C /* UIImage+SFSymbols.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIImage+SFSymbols.swift"; sourceTree = ""; }; 2D198642261BF09500F0B013 /* SearchResultItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultItem.swift; sourceTree = ""; }; 2D198648261C0B8500F0B013 /* SearchResultSection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SearchResultSection.swift; sourceTree = ""; }; @@ -1148,6 +1231,17 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2A72811D297EA9D7004138C5 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 2AB5011B2992322500346092 /* LightChart in Frameworks */, + 2A9D066F298D0FD100BF38CB /* MastodonSDKDynamic in Frameworks */, + 2A728124297EA9D7004138C5 /* SwiftUI.framework in Frameworks */, + 2A728122297EA9D7004138C5 /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB427DCF25BAA00100D1B89D /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -1343,6 +1437,58 @@ path = OpenInActionExtension; sourceTree = ""; }; + 2A728125297EA9D7004138C5 /* WidgetExtension */ = { + isa = PBXGroup; + children = ( + 2A86A14329892700007F1062 /* Variants */, + 2AE202A9297FDDF500F66E55 /* WidgetExtension.entitlements */, + 2A72813E297EC762004138C5 /* WidgetExtension.swift */, + 2A728126297EA9D7004138C5 /* WidgetExtensionBundle.swift */, + 2AB50120299243FB00346092 /* WidgetExtension.intentdefinition */, + 2A72812D297EA9D8004138C5 /* Assets.xcassets */, + 2A72812F297EA9D8004138C5 /* Info.plist */, + ); + path = WidgetExtension; + sourceTree = ""; + }; + 2A86A14329892700007F1062 /* Variants */ = { + isa = PBXGroup; + children = ( + 2A86A14429892709007F1062 /* FollowersCount */, + 2A86A14729892B1B007F1062 /* MultiFollowersCount */, + 2A9D0662298C045000BF38CB /* LatestFollowers */, + ); + path = Variants; + sourceTree = ""; + }; + 2A86A14429892709007F1062 /* FollowersCount */ = { + isa = PBXGroup; + children = ( + 2A33062C2987DBFA001D4C51 /* FollowersCountHistory.swift */, + 2A72812A297EA9D7004138C5 /* FollowersCountWidget.swift */, + 2A33AB652982C4AF008A7FB1 /* FollowersCountWidgetView.swift */, + ); + path = FollowersCount; + sourceTree = ""; + }; + 2A86A14729892B1B007F1062 /* MultiFollowersCount */ = { + isa = PBXGroup; + children = ( + 2A86A14829892B3A007F1062 /* MultiFollowersCountWidget.swift */, + 2A86A14A2989326E007F1062 /* MultiFollowersCountWidgetView.swift */, + ); + path = MultiFollowersCount; + sourceTree = ""; + }; + 2A9D0662298C045000BF38CB /* LatestFollowers */ = { + isa = PBXGroup; + children = ( + 2A9D0663298C048800BF38CB /* LatestFollowersWidget.swift */, + 2A9D0665298C05A800BF38CB /* LatestFollowersWidgetView.swift */, + ); + path = LatestFollowers; + sourceTree = ""; + }; 2D152A8A25C295B8009AA50C /* Content */ = { isa = PBXGroup; children = ( @@ -1562,6 +1708,8 @@ DB8FAB9E26AEC3A2008E5AF4 /* Intents.framework */, DB8FABA926AEC3A2008E5AF4 /* IntentsUI.framework */, 2A6451022964223800CD8B8A /* UniformTypeIdentifiers.framework */, + 2A728121297EA9D7004138C5 /* WidgetKit.framework */, + 2A728123297EA9D7004138C5 /* SwiftUI.framework */, ); name = Frameworks; sourceTree = ""; @@ -1880,6 +2028,7 @@ DBC6461326A170AB00B0E31B /* ShareActionExtension */, DB8FABC826AEC7B2008E5AF4 /* MastodonIntent */, 2A71F53C296DBDA80049F54A /* OpenInActionExtension */, + 2A728125297EA9D7004138C5 /* WidgetExtension */, DB427DD325BAA00100D1B89D /* Products */, 1EBA4F56E920856A3FC84ACB /* Pods */, 3FE14AD363ED19AE7FF210A6 /* Frameworks */, @@ -1898,6 +2047,7 @@ DBC6461226A170AB00B0E31B /* ShareActionExtension.appex */, DB8FABC626AEC7B2008E5AF4 /* MastodonIntent.appex */, 2A64515D29642A8A00CD8B8A /* OpenInActionExtension.appex */, + 2A728120297EA9D7004138C5 /* WidgetExtension.appex */, ); name = Products; sourceTree = ""; @@ -2164,6 +2314,8 @@ isa = PBXGroup; children = ( DBB8AB4526AECDE200F6D281 /* SendPostIntentHandler.swift */, + 2AE202AB297FE19100F66E55 /* FollowersCountIntentHandler.swift */, + 2A86A14529892944007F1062 /* MultiFollowersCountIntentHandler.swift */, ); path = Handler; sourceTree = ""; @@ -2860,6 +3012,28 @@ productReference = 2A64515D29642A8A00CD8B8A /* OpenInActionExtension.appex */; productType = "com.apple.product-type.app-extension"; }; + 2A72811F297EA9D7004138C5 /* WidgetExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 2A728139297EA9D8004138C5 /* Build configuration list for PBXNativeTarget "WidgetExtension" */; + buildPhases = ( + 2A72811C297EA9D7004138C5 /* Sources */, + 2A72811D297EA9D7004138C5 /* Frameworks */, + 2A72811E297EA9D7004138C5 /* Resources */, + 2A72813D297EC6F7004138C5 /* Embed Frameworks */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = WidgetExtension; + packageProductDependencies = ( + 2A9D066E298D0FD100BF38CB /* MastodonSDKDynamic */, + 2AB5011A2992322500346092 /* LightChart */, + ); + productName = WidgetExtensionExtension; + productReference = 2A728120297EA9D7004138C5 /* WidgetExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; DB427DD125BAA00100D1B89D /* Mastodon */ = { isa = PBXNativeTarget; buildConfigurationList = DB427DFC25BAA00100D1B89D /* Build configuration list for PBXNativeTarget "Mastodon" */; @@ -2882,6 +3056,7 @@ DBC6461B26A170AB00B0E31B /* PBXTargetDependency */, DB8FABCD26AEC7B2008E5AF4 /* PBXTargetDependency */, 2A64516829642A8B00CD8B8A /* PBXTargetDependency */, + 2A728133297EA9D8004138C5 /* PBXTargetDependency */, ); name = Mastodon; packageProductDependencies = ( @@ -3006,6 +3181,9 @@ 2A64515C29642A8A00CD8B8A = { CreatedOnToolsVersion = 14.2; }; + 2A72811F297EA9D7004138C5 = { + CreatedOnToolsVersion = 14.2; + }; DB427DD125BAA00100D1B89D = { CreatedOnToolsVersion = 12.4; LastSwiftMigration = 1300; @@ -3064,6 +3242,7 @@ ); mainGroup = DB427DC925BAA00100D1B89D; packageReferences = ( + 2AB501192992322500346092 /* XCRemoteSwiftPackageReference "LightChart" */, ); productRefGroup = DB427DD325BAA00100D1B89D /* Products */; projectDirPath = ""; @@ -3076,6 +3255,7 @@ DBC6461126A170AB00B0E31B /* ShareActionExtension */, DB8FABC526AEC7B2008E5AF4 /* MastodonIntent */, 2A64515C29642A8A00CD8B8A /* OpenInActionExtension */, + 2A72811F297EA9D7004138C5 /* WidgetExtension */, ); }; /* End PBXProject section */ @@ -3090,6 +3270,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2A72811E297EA9D7004138C5 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A72812E297EA9D8004138C5 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB427DD025BAA00100D1B89D /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -3316,6 +3504,23 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 2A72811C297EA9D7004138C5 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 2A33062D2987DBFA001D4C51 /* FollowersCountHistory.swift in Sources */, + 2A9D0666298C05A800BF38CB /* LatestFollowersWidgetView.swift in Sources */, + 2AB5011E299243FB00346092 /* WidgetExtension.intentdefinition in Sources */, + 2A86A14B2989326E007F1062 /* MultiFollowersCountWidgetView.swift in Sources */, + 2A72813F297EC762004138C5 /* WidgetExtension.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 */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; DB427DCE25BAA00100D1B89D /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -3610,6 +3815,7 @@ DB98EB5627B0FF1B0082E365 /* ReportViewControllerAppearance.swift in Sources */, DB3EA8E6281B79E200598866 /* DiscoveryCommunityViewController.swift in Sources */, 2D206B8625F5FB0900143C56 /* Double.swift in Sources */, + 2AB5011C299243FB00346092 /* WidgetExtension.intentdefinition in Sources */, DB9F58F126EF512300E7BBE9 /* AccountListTableViewCell.swift in Sources */, 2D76319F25C1521200929FB9 /* StatusSection.swift in Sources */, DB938F0326240EA300E5B6C1 /* CachedThreadViewModel.swift in Sources */, @@ -3745,9 +3951,13 @@ buildActionMask = 2147483647; files = ( DB0009A726AEE5DC009B9D2D /* Intents.intentdefinition in Sources */, + 2AE202AD297FE1CD00F66E55 /* WidgetExtension.swift in Sources */, DBB8AB4626AECDE200F6D281 /* SendPostIntentHandler.swift in Sources */, + 2AB5011D299243FB00346092 /* WidgetExtension.intentdefinition in Sources */, + 2AE202AC297FE19100F66E55 /* FollowersCountIntentHandler.swift in Sources */, DB64BA452851F23000ADF1B7 /* MastodonAuthentication+Fetch.swift in Sources */, DB64BA482851F29300ADF1B7 /* Account+Fetch.swift in Sources */, + 2A86A14629892944007F1062 /* MultiFollowersCountIntentHandler.swift in Sources */, DB8FABCA26AEC7B2008E5AF4 /* IntentHandler.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -3781,6 +3991,11 @@ target = 2A64515C29642A8A00CD8B8A /* OpenInActionExtension */; targetProxy = 2A64516729642A8B00CD8B8A /* PBXContainerItemProxy */; }; + 2A728133297EA9D8004138C5 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 2A72811F297EA9D7004138C5 /* WidgetExtension */; + targetProxy = 2A728132297EA9D8004138C5 /* PBXContainerItemProxy */; + }; DB427DEA25BAA00100D1B89D /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = DB427DD125BAA00100D1B89D /* Mastodon */; @@ -3809,6 +4024,40 @@ /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ + 2AB50120299243FB00346092 /* WidgetExtension.intentdefinition */ = { + isa = PBXVariantGroup; + children = ( + 2AB5011F299243FB00346092 /* Base */, + 2AB501222992440200346092 /* en */, + 2AB501242992443100346092 /* vi */, + 2AB501262992443100346092 /* tr */, + 2AB501282992443200346092 /* th */, + 2AB5012A2992443200346092 /* sv */, + 2AB5012C2992443300346092 /* es-AR */, + 2AB5012E2992443300346092 /* es */, + 2AB501302992443400346092 /* sl */, + 2AB501322992443400346092 /* gd */, + 2AB501342992443500346092 /* ru */, + 2AB501362992443600346092 /* ckb */, + 2AB501382992443600346092 /* ku */, + 2AB5013A2992443700346092 /* kab */, + 2AB5013C2992443700346092 /* ja */, + 2AB5013E2992443800346092 /* it */, + 2AB501402992443800346092 /* de */, + 2AB501422992443900346092 /* gl */, + 2AB501442992443900346092 /* fr */, + 2AB501462992443900346092 /* fi */, + 2AB501482992443A00346092 /* nl */, + 2AB5014A2992443A00346092 /* cs */, + 2AB5014C2992443B00346092 /* zh-Hant */, + 2AB5014E2992443B00346092 /* zh-Hans */, + 2AB501502992443C00346092 /* ca */, + 2AB501522992443C00346092 /* eu */, + 2AB501542992443D00346092 /* ar */, + ); + name = WidgetExtension.intentdefinition; + sourceTree = ""; + }; DB0009A926AEE5DC009B9D2D /* Intents.intentdefinition */ = { isa = PBXVariantGroup; children = ( @@ -4057,6 +4306,131 @@ }; name = "Release Snapshot"; }; + 2A728135297EA9D8004138C5 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEBUG_INFORMATION_FORMAT = dwarf; + DEVELOPMENT_TEAM = 5Z4GVSS33P; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.WidgetExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 2A728136297EA9D8004138C5 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5Z4GVSS33P; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.WidgetExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Profile; + }; + 2A728137297EA9D8004138C5 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5Z4GVSS33P; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.WidgetExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 2A728138297EA9D8004138C5 /* Release Snapshot */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CODE_SIGN_ENTITLEMENTS = WidgetExtension/WidgetExtension.entitlements; + CODE_SIGN_IDENTITY = "Apple Development"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5Z4GVSS33P; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = WidgetExtension/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = WidgetExtension; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = org.joinmastodon.app.WidgetExtension; + PRODUCT_NAME = "$(TARGET_NAME)"; + SKIP_INSTALL = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = "Release Snapshot"; + }; DB427DFA25BAA00100D1B89D /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -4890,6 +5264,17 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 2A728139297EA9D8004138C5 /* Build configuration list for PBXNativeTarget "WidgetExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 2A728135297EA9D8004138C5 /* Debug */, + 2A728136297EA9D8004138C5 /* Profile */, + 2A728137297EA9D8004138C5 /* Release */, + 2A728138297EA9D8004138C5 /* Release Snapshot */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; DB427DCD25BAA00100D1B89D /* Build configuration list for PBXProject "Mastodon" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -4969,11 +5354,31 @@ }; /* End XCConfigurationList section */ +/* Begin XCRemoteSwiftPackageReference section */ + 2AB501192992322500346092 /* XCRemoteSwiftPackageReference "LightChart" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Bearologics/LightChart.git"; + requirement = { + branch = master; + kind = branch; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + /* Begin XCSwiftPackageProductDependency section */ 2A90A156296EEE500026C155 /* MastodonSDKDynamic */ = { isa = XCSwiftPackageProductDependency; productName = MastodonSDKDynamic; }; + 2A9D066E298D0FD100BF38CB /* MastodonSDKDynamic */ = { + isa = XCSwiftPackageProductDependency; + productName = MastodonSDKDynamic; + }; + 2AB5011A2992322500346092 /* LightChart */ = { + isa = XCSwiftPackageProductDependency; + package = 2AB501192992322500346092 /* XCRemoteSwiftPackageReference "LightChart" */; + productName = LightChart; + }; 357FEEAE29523D470021C9DC /* MastodonSDKDynamic */ = { isa = XCSwiftPackageProductDependency; productName = MastodonSDKDynamic; diff --git a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved index cd7395c7e..232ec9c87 100644 --- a/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Mastodon.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -73,6 +73,15 @@ "version": "4.2.2" } }, + { + "package": "LightChart", + "repositoryURL": "https://github.com/Bearologics/LightChart.git", + "state": { + "branch": "master", + "revision": "a7e724e9ec3cdcaa2d0840b95780e66b870dbf1e", + "version": null + } + }, { "package": "MetaTextKit", "repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git", diff --git a/Mastodon/Info.plist b/Mastodon/Info.plist index df221e66d..0e0deb2cc 100644 --- a/Mastodon/Info.plist +++ b/Mastodon/Info.plist @@ -61,6 +61,9 @@ NSUserActivityTypes + FollowersCountIntent + MultiFollowersCountIntent + LatestFollowersIntent SendPostIntent UIApplicationSceneManifest diff --git a/MastodonIntent/Handler/FollowersCountIntentHandler.swift b/MastodonIntent/Handler/FollowersCountIntentHandler.swift new file mode 100644 index 000000000..fa5b53bc2 --- /dev/null +++ b/MastodonIntent/Handler/FollowersCountIntentHandler.swift @@ -0,0 +1,35 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import Intents +import MastodonCore +import MastodonSDK +import MastodonLocalization + +class FollowersCountIntentHandler: INExtension, FollowersCountIntentHandling { + func resolveShowChart(for intent: FollowersCountIntent) async -> INBooleanResolutionResult { + return .success(with: intent.showChart?.boolValue ?? false) + } + + func resolveAccount(for intent: FollowersCountIntent) async -> INStringResolutionResult { + .confirmationRequired(with: intent.account) + } + + func provideAccountOptionsCollection(for intent: FollowersCountIntent, searchTerm: String?) async throws -> INObjectCollection { + guard + let searchTerm = searchTerm, + let authenticationBox = WidgetExtension.appContext + .authenticationService + .mastodonAuthenticationBoxes + .first + else { + return INObjectCollection(items: []) + } + + let results = try await WidgetExtension.appContext + .apiService + .search(query: .init(q: searchTerm), authenticationBox: authenticationBox) + + return INObjectCollection(items: results.value.accounts.map { $0.acctWithDomainIfMissing(authenticationBox.domain) as NSString }) + } +} diff --git a/MastodonIntent/Handler/MultiFollowersCountIntentHandler.swift b/MastodonIntent/Handler/MultiFollowersCountIntentHandler.swift new file mode 100644 index 000000000..f49398630 --- /dev/null +++ b/MastodonIntent/Handler/MultiFollowersCountIntentHandler.swift @@ -0,0 +1,27 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import Intents +import MastodonCore +import MastodonSDK +import MastodonLocalization + +class MultiFollowersCountIntentHandler: INExtension, MultiFollowersCountIntentHandling { + func provideAccountsOptionsCollection(for intent: MultiFollowersCountIntent, searchTerm: String?) async throws -> INObjectCollection { + guard + let searchTerm = searchTerm, + let authenticationBox = WidgetExtension.appContext + .authenticationService + .mastodonAuthenticationBoxes + .first + else { + return INObjectCollection(items: []) + } + + let results = try await WidgetExtension.appContext + .apiService + .search(query: .init(q: searchTerm), authenticationBox: authenticationBox) + + return INObjectCollection(items: results.value.accounts.map { $0.acctWithDomainIfMissing(authenticationBox.domain) as NSString }) + } +} diff --git a/MastodonIntent/Info.plist b/MastodonIntent/Info.plist index 3c4a6e453..06804cb31 100644 --- a/MastodonIntent/Info.plist +++ b/MastodonIntent/Info.plist @@ -30,6 +30,8 @@ IntentsSupported + FollowersCountIntent + MultiFollowersCountIntent SendPostIntent diff --git a/MastodonIntent/IntentHandler.swift b/MastodonIntent/IntentHandler.swift index cb8588d67..c8fb67d4c 100644 --- a/MastodonIntent/IntentHandler.swift +++ b/MastodonIntent/IntentHandler.swift @@ -15,6 +15,10 @@ class IntentHandler: INExtension { switch intent { case is SendPostIntent: return SendPostIntentHandler() + case is FollowersCountIntent: + return FollowersCountIntentHandler() + case is MultiFollowersCountIntent: + return MultiFollowersCountIntentHandler() default: return self } diff --git a/MastodonSDK/Sources/CoreDataStack/Extension/Collection.swift b/MastodonSDK/Sources/CoreDataStack/Extension/Collection.swift index a57737d1a..b97e6cce8 100644 --- a/MastodonSDK/Sources/CoreDataStack/Extension/Collection.swift +++ b/MastodonSDK/Sources/CoreDataStack/Extension/Collection.swift @@ -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 + } +} diff --git a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift index c2006ec42..200b835e4 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift +++ b/MastodonSDK/Sources/MastodonLocalization/Generated/Strings.swift @@ -1479,6 +1479,50 @@ public enum L10n { public static let newInMastodon = L10n.tr("Localizable", "Scene.Wizard.NewInMastodon", fallback: "New in Mastodon") } } + public enum Widget { + public enum Common { + /// Sorry but this Widget family is unsupported. + public static let unsupportedWidgetFamily = L10n.tr("Localizable", "Widget.Common.UnsupportedWidgetFamily", fallback: "Sorry but this Widget family is unsupported.") + /// Please open Mastodon to log in to an Account. + public static let userNotLoggedIn = L10n.tr("Localizable", "Widget.Common.UserNotLoggedIn", fallback: "Please open Mastodon to log in to an Account.") + } + public enum FollowersCount { + /// Show number of followers. + public static let configurationDescription = L10n.tr("Localizable", "Widget.FollowersCount.ConfigurationDescription", fallback: "Show number of followers.") + /// Followers + public static let configurationDisplayName = L10n.tr("Localizable", "Widget.FollowersCount.ConfigurationDisplayName", fallback: "Followers") + /// %@ followers today + public static func followersToday(_ p1: Any) -> String { + return L10n.tr("Localizable", "Widget.FollowersCount.FollowersToday", String(describing: p1), fallback: "%@ followers today") + } + /// FOLLOWERS + public static let title = L10n.tr("Localizable", "Widget.FollowersCount.Title", fallback: "FOLLOWERS") + } + public enum LatestFollowers { + /// Show latest followers. + public static let configurationDescription = L10n.tr("Localizable", "Widget.LatestFollowers.ConfigurationDescription", fallback: "Show latest followers.") + /// Latest followers + public static let configurationDisplayName = L10n.tr("Localizable", "Widget.LatestFollowers.ConfigurationDisplayName", fallback: "Latest followers") + /// Last update: %@ + public static func lastUpdate(_ p1: Any) -> String { + return L10n.tr("Localizable", "Widget.LatestFollowers.LastUpdate", String(describing: p1), fallback: "Last update: %@") + } + /// Latest followers + public static let title = L10n.tr("Localizable", "Widget.LatestFollowers.Title", fallback: "Latest followers") + } + public enum MultipleFollowers { + /// Show number of followers for multiple accounts. + public static let configurationDescription = L10n.tr("Localizable", "Widget.MultipleFollowers.ConfigurationDescription", fallback: "Show number of followers for multiple accounts.") + /// Multiple followers + public static let configurationDisplayName = L10n.tr("Localizable", "Widget.MultipleFollowers.ConfigurationDisplayName", fallback: "Multiple followers") + public enum MockUser { + /// another@follower.social + public static let accountName = L10n.tr("Localizable", "Widget.MultipleFollowers.MockUser.AccountName", fallback: "another@follower.social") + /// Another follower + public static let displayName = L10n.tr("Localizable", "Widget.MultipleFollowers.MockUser.DisplayName", fallback: "Another follower") + } + } + } public enum A11y { public enum Plural { public enum Count { diff --git a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings index 5421a6cdd..0cffc5e14 100644 --- a/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings +++ b/MastodonSDK/Sources/MastodonLocalization/Resources/Base.lproj/Localizable.strings @@ -518,3 +518,17 @@ You can’t go wrong with any of our recommend servers, so regardless of which o "Scene.Privacy.Policy.Ios" = "Privacy Policy - Mastodon for iOS"; "Scene.Privacy.Policy.Server" = "Privacy Policy - %@"; "Extension.OpenIn.InvalidLinkError" = "This doesn't seem to be a valid Mastodon link."; +"Widget.Common.UnsupportedWidgetFamily" = "Sorry but this Widget family is unsupported."; +"Widget.Common.UserNotLoggedIn" = "Please open Mastodon to log in to an Account."; +"Widget.FollowersCount.ConfigurationDisplayName" = "Followers"; +"Widget.FollowersCount.ConfigurationDescription" = "Show number of followers."; +"Widget.FollowersCount.Title" = "FOLLOWERS"; +"Widget.FollowersCount.FollowersToday" = "%@ followers today"; +"Widget.MultipleFollowers.ConfigurationDisplayName" = "Multiple followers"; +"Widget.MultipleFollowers.ConfigurationDescription" = "Show number of followers for multiple accounts."; +"Widget.MultipleFollowers.MockUser.DisplayName" = "Another follower"; +"Widget.MultipleFollowers.MockUser.AccountName" = "another@follower.social"; +"Widget.LatestFollowers.ConfigurationDisplayName" = "Latest followers"; +"Widget.LatestFollowers.ConfigurationDescription" = "Show latest followers."; +"Widget.LatestFollowers.Title" = "Latest followers"; +"Widget.LatestFollowers.LastUpdate" = "Last update: %@"; diff --git a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift index 13f3c0a71..0e90a5ccd 100644 --- a/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift +++ b/MastodonSDK/Sources/MastodonSDK/Entity/Mastodon+Entity+Account.swift @@ -82,6 +82,13 @@ extension Mastodon.Entity { case muteExpiresAt = "mute_expires_at" } } - - +} + +extension Mastodon.Entity.Account { + public func acctWithDomainIfMissing(_ localDomain: String) -> String { + guard acct.contains("@") else { + return "\(acct)@\(localDomain)" + } + return acct + } } diff --git a/WidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json b/WidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/WidgetExtension/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WidgetExtension/Assets.xcassets/AppIcon.appiconset/Contents.json b/WidgetExtension/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..13613e3ee --- /dev/null +++ b/WidgetExtension/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WidgetExtension/Assets.xcassets/BrandIcon.imageset/Contents.json b/WidgetExtension/Assets.xcassets/BrandIcon.imageset/Contents.json new file mode 100644 index 000000000..ec6e92dd6 --- /dev/null +++ b/WidgetExtension/Assets.xcassets/BrandIcon.imageset/Contents.json @@ -0,0 +1,22 @@ +{ + "images" : [ + { + "filename" : "Logo.svg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "filename" : "Logo 1.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WidgetExtension/Assets.xcassets/BrandIcon.imageset/Logo 1.svg b/WidgetExtension/Assets.xcassets/BrandIcon.imageset/Logo 1.svg new file mode 100644 index 000000000..1a5328cd2 --- /dev/null +++ b/WidgetExtension/Assets.xcassets/BrandIcon.imageset/Logo 1.svg @@ -0,0 +1,3 @@ + + + diff --git a/WidgetExtension/Assets.xcassets/BrandIcon.imageset/Logo.svg b/WidgetExtension/Assets.xcassets/BrandIcon.imageset/Logo.svg new file mode 100644 index 000000000..74dc7b51e --- /dev/null +++ b/WidgetExtension/Assets.xcassets/BrandIcon.imageset/Logo.svg @@ -0,0 +1,3 @@ + + + diff --git a/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Contents.json b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Contents.json new file mode 100644 index 000000000..17f8857ac --- /dev/null +++ b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo.svg b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo.svg new file mode 100644 index 000000000..79432c27c --- /dev/null +++ b/WidgetExtension/Assets.xcassets/BrandIconColored.imageset/Logo.svg @@ -0,0 +1,4 @@ + + + + diff --git a/WidgetExtension/Assets.xcassets/Contents.json b/WidgetExtension/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/WidgetExtension/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json b/WidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 000000000..eb8789700 --- /dev/null +++ b/WidgetExtension/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WidgetExtension/Assets.xcassets/missingAvatar.imageset/Contents.json b/WidgetExtension/Assets.xcassets/missingAvatar.imageset/Contents.json new file mode 100644 index 000000000..c48e46bbf --- /dev/null +++ b/WidgetExtension/Assets.xcassets/missingAvatar.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "missing.png", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/WidgetExtension/Assets.xcassets/missingAvatar.imageset/missing.png b/WidgetExtension/Assets.xcassets/missingAvatar.imageset/missing.png new file mode 100644 index 000000000..781370782 Binary files /dev/null and b/WidgetExtension/Assets.xcassets/missingAvatar.imageset/missing.png differ diff --git a/WidgetExtension/Base.lproj/WidgetExtension.intentdefinition b/WidgetExtension/Base.lproj/WidgetExtension.intentdefinition new file mode 100644 index 000000000..b4f48b63f --- /dev/null +++ b/WidgetExtension/Base.lproj/WidgetExtension.intentdefinition @@ -0,0 +1,382 @@ + + + + + INEnums + + INIntentDefinitionModelVersion + 1.2 + INIntentDefinitionNamespace + 88xZPY + INIntentDefinitionSystemVersion + 22D49 + INIntentDefinitionToolsBuildVersion + 14C18 + INIntentDefinitionToolsVersion + 14.2 + INIntents + + + INIntentCategory + information + INIntentDescription + Followers + INIntentDescriptionID + tVvJ9c + INIntentEligibleForWidgets + + INIntentIneligibleForSuggestions + + INIntentLastParameterTag + 7 + INIntentName + FollowersCount + INIntentParameters + + + INIntentParameterConfigurable + + INIntentParameterDisplayName + Account + INIntentParameterDisplayNameID + OL6lkx + INIntentParameterDisplayPriority + 1 + INIntentParameterMetadata + + INIntentParameterMetadataCapitalization + Sentences + INIntentParameterMetadataDefaultValueID + 2V4PKr + + INIntentParameterName + account + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Enter follower Username + INIntentParameterPromptDialogFormatStringID + sOLUtG + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterSupportsSearch + + INIntentParameterTag + 5 + INIntentParameterType + String + + + INIntentParameterConfigurable + + INIntentParameterDisplayName + Show chart + INIntentParameterDisplayNameID + xVtyec + INIntentParameterDisplayPriority + 2 + INIntentParameterMetadata + + INIntentParameterMetadataFalseDisplayName + No + INIntentParameterMetadataFalseDisplayNameID + jg9D5P + INIntentParameterMetadataTrueDisplayName + Yes + INIntentParameterMetadataTrueDisplayNameID + 82L4Nj + + INIntentParameterName + showChart + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Should the Widget show a chart? + INIntentParameterPromptDialogFormatStringID + zeJo4f + INIntentParameterPromptDialogType + Primary + + + INIntentParameterSupportsResolution + + INIntentParameterTag + 7 + INIntentParameterType + Boolean + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeName + failure + + + INIntentResponseLastParameterTag + 4 + INIntentResponseOutput + username + INIntentResponseParameters + + + INIntentResponseParameterDisplayName + Username + INIntentResponseParameterDisplayNameID + BFppgH + INIntentResponseParameterDisplayPriority + 1 + INIntentResponseParameterName + username + INIntentResponseParameterTag + 4 + INIntentResponseParameterType + String + + + + INIntentTitle + Followers Count + INIntentTitleID + gpCwrM + INIntentType + Custom + INIntentVerb + View + + + INIntentCategory + information + INIntentDescriptionID + B9KyhZ + INIntentEligibleForWidgets + + INIntentIneligibleForSuggestions + + INIntentLastParameterTag + 6 + INIntentName + MultiFollowersCount + INIntentParameters + + + INIntentParameterArraySizes + + + INIntentParameterArraySizeSize + 3 + INIntentParameterArraySizeSizeClass + Small + + + INIntentParameterArraySizeSize + 6 + INIntentParameterArraySizeSizeClass + Medium + + + INIntentParameterArraySizeSize + 6 + INIntentParameterArraySizeSizeClass + Large + + + INIntentParameterArraySizeSize + 6 + INIntentParameterArraySizeSizeClass + ExtraLarge + + + INIntentParameterArraySizeSize + 1 + INIntentParameterArraySizeSizeClass + AccessoryInline + + + INIntentParameterArraySizeSize + 1 + INIntentParameterArraySizeSizeClass + AccessoryCorner + + + INIntentParameterArraySizeSize + 1 + INIntentParameterArraySizeSizeClass + AccessoryCircular + + + INIntentParameterArraySizeSize + 1 + INIntentParameterArraySizeSizeClass + AccessoryRectangular + + + INIntentParameterConfigurable + + INIntentParameterDisplayName + Accounts + INIntentParameterDisplayNameID + fovmPX + INIntentParameterDisplayPriority + 1 + INIntentParameterFixedSizeArray + 1 + INIntentParameterMetadata + + INIntentParameterMetadataCapitalization + Sentences + INIntentParameterMetadataDefaultValueID + SNXOJo + + INIntentParameterName + accounts + INIntentParameterPromptDialogs + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogFormatString + Enter username + INIntentParameterPromptDialogFormatStringID + 3d6HSO + INIntentParameterPromptDialogType + Configuration + + + INIntentParameterPromptDialogCustom + + INIntentParameterPromptDialogType + Primary + + + INIntentParameterSupportsDynamicEnumeration + + INIntentParameterSupportsMultipleValues + + INIntentParameterSupportsSearch + + INIntentParameterTag + 6 + INIntentParameterType + String + + + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeName + failure + + + INIntentResponseLastParameterTag + 4 + INIntentResponseOutput + username + INIntentResponseParameters + + + INIntentResponseParameterDisplayName + Username + INIntentResponseParameterDisplayNameID + 7DZrRA + INIntentResponseParameterDisplayPriority + 1 + INIntentResponseParameterName + username + INIntentResponseParameterSupportsMultipleValues + + INIntentResponseParameterTag + 4 + INIntentResponseParameterType + Object + + + + INIntentTitle + Multi Followers Count + INIntentTitleID + e0W2wo + INIntentType + Custom + INIntentVerb + View + + + INIntentCategory + information + INIntentDescriptionID + 5KZ2fm + INIntentEligibleForWidgets + + INIntentIneligibleForSuggestions + + INIntentName + LatestFollowers + INIntentResponse + + INIntentResponseCodes + + + INIntentResponseCodeName + success + INIntentResponseCodeSuccess + + + + INIntentResponseCodeName + failure + + + + INIntentTitle + Latest Followers + INIntentTitleID + ZLZ6sg + INIntentType + Custom + INIntentVerb + View + + + INTypes + + + diff --git a/WidgetExtension/Info.plist b/WidgetExtension/Info.plist new file mode 100644 index 000000000..0f118fb75 --- /dev/null +++ b/WidgetExtension/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/WidgetExtension/Variants/FollowersCount/FollowersCountHistory.swift b/WidgetExtension/Variants/FollowersCount/FollowersCountHistory.swift new file mode 100644 index 000000000..cc38ec180 --- /dev/null +++ b/WidgetExtension/Variants/FollowersCount/FollowersCountHistory.swift @@ -0,0 +1,112 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import Foundation +import MastodonSDK + +struct FollowersCountHistoryDay: Codable { + let dstring: String + let day: Int + let count: Int + + func copy(count: Int) -> Self { + FollowersCountHistoryDay(dstring: dstring, day: day, count: count) + } +} + +class FollowersCountHistory { + + static let shared = FollowersCountHistory() + + private let userDefaults = UserDefaults.standard + private let calendar = Calendar.current + private let followersCountCacheDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.dateFormat = "yyyyMMdd" + return formatter + }() + + private func elapsedFollowersCountDateStrings() -> [String] { + (-7...0).map { elapsedDay in + let date = calendar.date(byAdding: .day, value: elapsedDay, to: .now)! + return followersCountCacheDateFormatter.string(from: date) + } + } + + private func userDefaultsKey(for account: FollowersEntryAccountable) -> String { + if account.acct.contains("@") { + return account.acct + } + return "\(account.acct)@\(account.domain)" + } + + private func emptyHistoricDataForToday(for account: FollowersEntryAccountable) -> [FollowersCountHistoryDay] { + elapsedFollowersCountDateStrings().enumerated().map { FollowersCountHistoryDay(dstring: $0.element, day: $0.offset, count: account.followersCount) } + } + + private func followersHistorySorted(for account: FollowersEntryAccountable) -> [FollowersCountHistoryDay] { + guard + let jsonData = userDefaults.string(forKey: userDefaultsKey(for: account))?.data(using: .utf8), + let jsonObject = try? JSONDecoder().decode([FollowersCountHistoryDay].self, from: jsonData) + else { + return emptyHistoricDataForToday(for: account) + } + return jsonObject + } + + func updateFollowersTodayCount(account: FollowersEntryAccountable, count: Int) { + let relevantDays = elapsedFollowersCountDateStrings() + let existingHistory = followersHistorySorted(for: account) + var newHistory = existingHistory + + /// first we're going to update the existing day and remove legacy days (older than 7) + existingHistory.forEach { existingDay in + if !relevantDays.contains(where: { $0 == existingDay.dstring }) { + /// remove legacy data/ + newHistory.removeAll(where: { $0.dstring == existingDay.dstring }) + } + } + + relevantDays.enumerated().forEach { index, day in + if !newHistory.contains(where: { $0.dstring == day }) { + newHistory.insert( + FollowersCountHistoryDay(dstring: day, day: index, count: account.followersCount), + at: index + ) + } + } + + /// then we're going to update the history dataset with new value, if this is the first encounter + if let last = newHistory.popLast()?.copy(count: count) { + newHistory.append(last) + } + + if let jsonData = try? JSONEncoder().encode(newHistory), let jsonString = String(data: jsonData, encoding: .utf8) { + userDefaults.set(jsonString, forKey: userDefaultsKey(for: account)) + } + } + + func chartValues(for account: FollowersEntryAccountable) -> [Double] { + followersHistorySorted(for: account).map { Double($0.count) } + } + + func increaseCountString(for account: FollowersEntryAccountable) -> String? { + let history = followersHistorySorted(for: account) + 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[safe: history.count-2]?.count ?? account.followersCount + + let followersChange = followersToday - followersYesterday + + switch followersChange { + case ..<0: + return "\(followersChange)" + case 0: + return nil + default: + return "+\(followersChange)" + } + } +} diff --git a/WidgetExtension/Variants/FollowersCount/FollowersCountWidget.swift b/WidgetExtension/Variants/FollowersCount/FollowersCountWidget.swift new file mode 100644 index 000000000..c8e53fb1c --- /dev/null +++ b/WidgetExtension/Variants/FollowersCount/FollowersCountWidget.swift @@ -0,0 +1,153 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import WidgetKit +import SwiftUI +import Intents +import MastodonSDK +import MastodonLocalization + +struct FollowersCountWidgetProvider: IntentTimelineProvider { + private let followersHistory = FollowersCountHistory.shared + + func placeholder(in context: Context) -> FollowersCountEntry { + .placeholder + } + + func getSnapshot(for configuration: FollowersCountIntent, in context: Context, completion: @escaping (FollowersCountEntry) -> ()) { + loadCurrentEntry(for: configuration, in: context, completion: completion) + } + + func getTimeline(for configuration: FollowersCountIntent, in context: Context, completion: @escaping (Timeline) -> ()) { + loadCurrentEntry(for: configuration, in: context) { entry in + completion(Timeline(entries: [entry], policy: .after(.now))) + } + } +} + +struct FollowersCountEntry: TimelineEntry { + let date: Date + let account: FollowersEntryAccountable? + let configuration: FollowersCountIntent + + static var placeholder: Self { + FollowersCountEntry( + date: .now, + account: FollowersEntryAccount( + followersCount: 99_900, + displayNameWithFallback: "Mastodon", + acct: "mastodon", + avatarImage: UIImage(named: "missingAvatar")!, + domain: "mastodon" + ), + configuration: FollowersCountIntent() + ) + } + + static var unconfigured: Self { + FollowersCountEntry( + date: .now, + account: nil, + configuration: FollowersCountIntent() + ) + } +} + +struct FollowersCountWidget: Widget { + private var availableFamilies: [WidgetFamily] { + if #available(iOS 16, *) { + return [.systemSmall, .accessoryRectangular, .accessoryCircular] + } + return [.systemSmall] + } + + var body: some WidgetConfiguration { + IntentConfiguration(kind: "Followers", intent: FollowersCountIntent.self, provider: FollowersCountWidgetProvider()) { entry in + FollowersCountWidgetView(entry: entry) + } + .configurationDisplayName(L10n.Widget.FollowersCount.configurationDisplayName) + .description(L10n.Widget.FollowersCount.configurationDescription) + .supportedFamilies(availableFamilies) + } +} + +private extension FollowersCountWidgetProvider { + func loadCurrentEntry(for configuration: FollowersCountIntent, in context: Context, completion: @escaping (FollowersCountEntry) -> Void) { + Task { + guard + let authBox = WidgetExtension.appContext + .authenticationService + .mastodonAuthenticationBoxes + .first + else { + guard !context.isPreview else { + return completion(.placeholder) + } + return completion(.unconfigured) + } + + guard + let desiredAccount = configuration.account ?? authBox.authenticationRecord.object( + in: WidgetExtension.appContext.managedObjectContext + )?.user.acctWithDomain + else { + return completion(.unconfigured) + } + + guard + let resultingAccount = try await WidgetExtension.appContext + .apiService + .search(query: .init(q: desiredAccount, type: .accounts), authenticationBox: authBox) + .value + .accounts + .first(where: { $0.acctWithDomainIfMissing(authBox.domain) == desiredAccount }) + else { + return completion(.unconfigured) + } + + let imageData = try await URLSession.shared.data(from: resultingAccount.avatarImageURLWithFallback(domain: authBox.domain)).0 + + let entry = FollowersCountEntry( + date: Date(), + account: FollowersEntryAccount.from( + mastodonAccount: resultingAccount, + domain: authBox.domain, + avatarImage: UIImage(data: imageData) ?? UIImage(named: "missingAvatar")! + ), + configuration: configuration + ) + + followersHistory.updateFollowersTodayCount( + account: entry.account!, + count: resultingAccount.followersCount + ) + + completion(entry) + } + } +} + +protocol FollowersEntryAccountable { + var followersCount: Int { get } + var displayNameWithFallback: String { get } + var acct: String { get } + var avatarImage: UIImage { get } + var domain: String { get } +} + +struct FollowersEntryAccount: FollowersEntryAccountable { + let followersCount: Int + let displayNameWithFallback: String + let acct: String + let avatarImage: UIImage + let domain: String + + static func from(mastodonAccount: Mastodon.Entity.Account, domain: String, avatarImage: UIImage) -> Self { + FollowersEntryAccount( + followersCount: mastodonAccount.followersCount, + displayNameWithFallback: mastodonAccount.displayNameWithFallback, + acct: mastodonAccount.acct, + avatarImage: avatarImage, + domain: domain + ) + } +} diff --git a/WidgetExtension/Variants/FollowersCount/FollowersCountWidgetView.swift b/WidgetExtension/Variants/FollowersCount/FollowersCountWidgetView.swift new file mode 100644 index 000000000..2f5b93610 --- /dev/null +++ b/WidgetExtension/Variants/FollowersCount/FollowersCountWidgetView.swift @@ -0,0 +1,168 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import SwiftUI +import WidgetKit +import MastodonAsset +import MastodonLocalization +import LightChart + +struct FollowersCountWidgetView: View { + private let followersHistory = FollowersCountHistory.shared + + @Environment(\.widgetFamily) var family + + var entry: FollowersCountWidgetProvider.Entry + + var body: some View { + if let account = entry.account { + switch family { + case .systemSmall: + if let showChart = entry.configuration.showChart?.boolValue, showChart { + viewForSmallWidgetYesChart(account) + } else { + viewForSmallWidgetNoChart(account) + } + case .accessoryRectangular: + viewForAccessoryRectangular(account) + case .accessoryCircular: + viewForAccessoryCircular(account) + default: + Text(L10n.Widget.Common.unsupportedWidgetFamily) + } + } else { + Text(L10n.Widget.Common.userNotLoggedIn) + .multilineTextAlignment(.center) + .font(.caption) + .padding(.all, 20) + } + } + + private func viewForSmallWidgetNoChart(_ account: FollowersEntryAccountable) -> some View { + HStack { + VStack(alignment: .leading, spacing: 0) { + if let avatarImage = account.avatarImage { + Image(uiImage: avatarImage) + .resizable() + .frame(width: 50, height: 50) + .cornerRadius(12) + .padding(.bottom, 8) + } + + Text(account.followersCount.asAbbreviatedCountString()) + .font(.largeTitle) + .lineLimit(1) + .truncationMode(.tail) + + Text(account.displayNameWithFallback) + .font(.system(size: UIFontMetrics.default.scaledValue(for: 13))) + .lineLimit(1) + .truncationMode(.tail) + + Text("@\(account.acct)") + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + .padding(.leading, 20) + .padding(.vertical, 16) + Spacer() + } + } + + private func viewForSmallWidgetYesChart(_ account: FollowersEntryAccountable) -> some View { + VStack(alignment: .leading, spacing: 0) { + HStack { + if let avatarImage = account.avatarImage { + Image(uiImage: avatarImage) + .resizable() + .frame(width: 23, height: 23) + .cornerRadius(5) + } + VStack(alignment: .leading) { + Text(account.displayNameWithFallback) + .font(.caption) + .lineLimit(1) + .truncationMode(.tail) + + Text("@\(account.acct)") + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer() + } + .padding(.leading, 20) + + ZStack { + if let account = entry.account { + LightChartView( + data: followersHistory.chartValues(for: account), + type: .line, + visualType: .filled(color: Asset.Colors.Brand.blurple.swiftUIColor, lineWidth: 2), + offset: 0.8 /// this is the positive offset from the bottom edge of the graph (~80% above bottom level) + ) + } + + HStack { + VStack(alignment: .leading, spacing: 0) { + Spacer() + if let increaseCount = followersHistory.increaseCountString(for: account) { + Text(L10n.Widget.FollowersCount.followersToday(increaseCount)) + .font(.system(size: UIFontMetrics.default.scaledValue(for: 12))) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + + Text(account.followersCount.asAbbreviatedCountString()) + .font(.largeTitle) + .lineLimit(1) + .truncationMode(.tail) + + } + Spacer() + } + .padding(.bottom, 16) + .padding(.leading, 20) + } + } + .padding(.top, 16) + } + + private func viewForAccessoryRectangular(_ account :FollowersEntryAccountable) -> some View { + HStack(spacing: 0) { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .center) { + Image("BrandIcon") + Text(L10n.Widget.FollowersCount.title) + .font(.system(size: UIFontMetrics.default.scaledValue(for: 15), weight: .semibold)) + } + .padding(.top, 6) + + Text(account.followersCount.asAbbreviatedCountString()) + .font(.system(size: UIFontMetrics.default.scaledValue(for: 43))) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer() + } + } + + private func viewForAccessoryCircular(_ account :FollowersEntryAccountable) -> some View { + ZStack { + if #available(iOS 16, *) { + AccessoryWidgetBackground() + } + VStack { + Image("BrandIcon") + + Text(account.followersCount.asAbbreviatedCountString()) + .font(.system(size: UIFontMetrics.default.scaledValue(for: 15))) + .lineLimit(1) + .truncationMode(.tail) + } + } + } +} diff --git a/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidget.swift b/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidget.swift new file mode 100644 index 000000000..d2bf73b5a --- /dev/null +++ b/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidget.swift @@ -0,0 +1,149 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import WidgetKit +import SwiftUI +import Intents +import MastodonSDK +import MastodonLocalization + +struct LatestFollowersWidgetProvider: IntentTimelineProvider { + func placeholder(in context: Context) -> LatestFollowersEntry { + .placeholder + } + + func getSnapshot(for configuration: LatestFollowersIntent, in context: Context, completion: @escaping (LatestFollowersEntry) -> ()) { + loadCurrentEntry(for: configuration, in: context, completion: completion) + } + + func getTimeline(for configuration: LatestFollowersIntent, in context: Context, completion: @escaping (Timeline) -> ()) { + 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: nil, + 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(L10n.Widget.LatestFollowers.configurationDisplayName) + .description(L10n.Widget.LatestFollowers.configurationDescription) + .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 { + guard !context.isPreview else { + return completion(.placeholder) + } + 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) + } + } +} + +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 + ) + } +} diff --git a/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidgetView.swift b/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidgetView.swift new file mode 100644 index 000000000..3bdc38a77 --- /dev/null +++ b/WidgetExtension/Variants/LatestFollowers/LatestFollowersWidgetView.swift @@ -0,0 +1,144 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import SwiftUI +import WidgetKit +import MastodonSDK +import MastodonAsset +import MastodonUI +import MastodonLocalization + +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(L10n.Widget.Common.unsupportedWidgetFamily) + } + } else { + Text(L10n.Widget.Common.userNotLoggedIn) + .multilineTextAlignment(.center) + .font(.caption) + .padding(.all, 20) + } + } + + private func viewForSmallWidget(_ accounts: [LatestFollowersEntryAccountable], lastUpdate: Date) -> some View { + VStack(alignment: .leading) { + Text(L10n.Widget.LatestFollowers.title) + .font(.system(size: UIFontMetrics.default.scaledValue(for: 16))) + + 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.displayNameWithFallback) + .font(.footnote.bold()) + .lineLimit(1) + .truncationMode(.tail) + + Text("@\(account.acct)") + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + + } + Spacer() + } + } + Spacer() + Text(L10n.Widget.LatestFollowers.lastUpdate(dateFormatter.string(from: lastUpdate))) + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } + + private func viewForMediumWidget(_ accounts: [LatestFollowersEntryAccountable], lastUpdate: Date) -> some View { + VStack(alignment: .leading) { + HStack { + Text(L10n.Widget.LatestFollowers.title) + .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()) + .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(L10n.Widget.LatestFollowers.lastUpdate(dateFormatter.string(from: lastUpdate))) + .font(.caption2) + .foregroundColor(.secondary) + } + .padding(.horizontal, 20) + .padding(.vertical, 16) + } +} + +/// This code is used to strip HTML tags from the bio description as the widgets currently dont support +/// rich text rendering due to the lack of SwiftUI-only components for this purpose. +/// todo: Implement rich text rendering for bio description and remove this code +/// https://github.com/mastodon/mastodon-ios/issues/921 +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 + } + } +} diff --git a/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidget.swift b/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidget.swift new file mode 100644 index 000000000..9049a5a79 --- /dev/null +++ b/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidget.swift @@ -0,0 +1,167 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import WidgetKit +import SwiftUI +import Intents +import MastodonSDK +import MastodonLocalization + +struct MultiFollowersCountWidgetProvider: IntentTimelineProvider { + func placeholder(in context: Context) -> MultiFollowersCountEntry { + .placeholder + } + + func getSnapshot(for configuration: MultiFollowersCountIntent, in context: Context, completion: @escaping (MultiFollowersCountEntry) -> ()) { + loadCurrentEntry(for: configuration, in: context, completion: completion) + } + + func getTimeline(for configuration: MultiFollowersCountIntent, in context: Context, completion: @escaping (Timeline) -> ()) { + loadCurrentEntry(for: configuration, in: context) { entry in + completion(Timeline(entries: [entry], policy: .after(.now))) + } + } +} + +struct MultiFollowersCountEntry: TimelineEntry { + let date: Date + let accounts: [MultiFollowersEntryAccountable]? + let configuration: MultiFollowersCountIntent + + static var placeholder: Self { + MultiFollowersCountEntry( + date: .now, + accounts: [ + MultiFollowersEntryAccount( + followersCount: 99_900, + displayNameWithFallback: "Mastodon", + acct: "mastodon", + avatarImage: UIImage(named: "missingAvatar")!, + domain: "mastodon" + ) + ], + configuration: MultiFollowersCountIntent() + ) + } + + static var unconfigured: Self { + MultiFollowersCountEntry( + date: .now, + accounts: nil, + configuration: MultiFollowersCountIntent() + ) + } +} + +struct MultiFollowersCountWidget: Widget { + private var availableFamilies: [WidgetFamily] { + return [.systemSmall, .systemMedium] + } + + var body: some WidgetConfiguration { + IntentConfiguration(kind: "Multiple followers", intent: MultiFollowersCountIntent.self, provider: MultiFollowersCountWidgetProvider()) { entry in + MultiFollowersCountWidgetView(entry: entry) + } + .configurationDisplayName(L10n.Widget.MultipleFollowers.configurationDisplayName) + .description(L10n.Widget.MultipleFollowers.configurationDescription) + .supportedFamilies(availableFamilies) + } +} + +private extension MultiFollowersCountWidgetProvider { + func loadCurrentEntry(for configuration: MultiFollowersCountIntent, in context: Context, completion: @escaping (MultiFollowersCountEntry) -> Void) { + Task { + guard + let authBox = WidgetExtension.appContext + .authenticationService + .mastodonAuthenticationBoxes + .first + else { + guard !context.isPreview else { + return completion(.placeholder) + } + return completion(.unconfigured) + } + + let desiredAccounts: [String] + + if let configuredAccounts = configuration.accounts?.compactMap({ $0 }) { + desiredAccounts = configuredAccounts + } else if let currentlyLoggedInAccount = authBox.authenticationRecord.object( + in: WidgetExtension.appContext.managedObjectContext + )?.user.acctWithDomain { + desiredAccounts = [currentlyLoggedInAccount] + } else { + return completion(.unconfigured) + } + + var accounts = [MultiFollowersEntryAccountable]() + + for desiredAccount in desiredAccounts { + guard + let resultingAccount = try await WidgetExtension.appContext + .apiService + .search(query: .init(q: desiredAccount, type: .accounts), authenticationBox: authBox) + .value + .accounts + .first(where: { $0.acctWithDomainIfMissing(authBox.domain) == desiredAccount }) + else { + continue + } + + let imageData = try await URLSession.shared.data(from: resultingAccount.avatarImageURLWithFallback(domain: authBox.domain)).0 + + accounts.append(MultiFollowersEntryAccount.from( + mastodonAccount: resultingAccount, + domain: authBox.domain, + avatarImage: UIImage(data: imageData) ?? UIImage(named: "missingAvatar")! + )) + } + + if context.isPreview { + accounts.append( + MultiFollowersEntryAccount( + followersCount: 1_200, + displayNameWithFallback: L10n.Widget.MultipleFollowers.MockUser.displayName, + acct: L10n.Widget.MultipleFollowers.MockUser.accountName, + avatarImage: UIImage(named: "missingAvatar")!, + domain: authBox.domain + ) + ) + } + + let entry = MultiFollowersCountEntry( + date: Date(), + accounts: accounts, + configuration: configuration + ) + + completion(entry) + } + } +} + +protocol MultiFollowersEntryAccountable { + var followersCount: Int { get } + var displayNameWithFallback: String { get } + var acct: String { get } + var avatarImage: UIImage { get } + var domain: String { get } +} + +struct MultiFollowersEntryAccount: MultiFollowersEntryAccountable { + let followersCount: Int + let displayNameWithFallback: String + let acct: String + let avatarImage: UIImage + let domain: String + + static func from(mastodonAccount: Mastodon.Entity.Account, domain: String, avatarImage: UIImage) -> Self { + MultiFollowersEntryAccount( + followersCount: mastodonAccount.followersCount, + displayNameWithFallback: mastodonAccount.displayNameWithFallback, + acct: mastodonAccount.acct, + avatarImage: avatarImage, + domain: domain + ) + } +} diff --git a/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidgetView.swift b/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidgetView.swift new file mode 100644 index 000000000..bfc0f8495 --- /dev/null +++ b/WidgetExtension/Variants/MultiFollowersCount/MultiFollowersCountWidgetView.swift @@ -0,0 +1,97 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import SwiftUI +import WidgetKit +import MastodonAsset +import MastodonLocalization + +struct MultiFollowersCountWidgetView: View { + @Environment(\.widgetFamily) var family + + var entry: MultiFollowersCountWidgetProvider.Entry + + var body: some View { + if let accounts = entry.accounts { + switch family { + case .systemSmall: + viewForSmallWidget(accounts) + case .systemMedium: + viewForMediumWidget(accounts) + default: + Text(L10n.Widget.Common.unsupportedWidgetFamily) + } + } else { + Text(L10n.Widget.Common.userNotLoggedIn) + .multilineTextAlignment(.center) + .font(.caption) + .padding(.all, 20) + } + } + + private func viewForSmallWidget(_ accounts: [MultiFollowersEntryAccountable]) -> 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.followersCount.asAbbreviatedCountString()) + .font(.title2) + .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: [MultiFollowersEntryAccountable]) -> some View { + VStack(alignment: .leading, spacing: 0) { + LazyVGrid(columns: [ + GridItem(.flexible()), + GridItem(.flexible()) + ]) { + 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.followersCount.asAbbreviatedCountString()) + .font(.title2) + .lineLimit(1) + .truncationMode(.tail) + + Text("@\(account.acct)") + .font(.caption2) + .foregroundColor(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } + Spacer() + } + .padding(.leading, 20) + } + } + Spacer() + } + .padding(.vertical, 16) + } +} diff --git a/WidgetExtension/WidgetExtension.entitlements b/WidgetExtension/WidgetExtension.entitlements new file mode 100644 index 000000000..c3bc3f816 --- /dev/null +++ b/WidgetExtension/WidgetExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.org.joinmastodon.app + + + diff --git a/WidgetExtension/WidgetExtension.swift b/WidgetExtension/WidgetExtension.swift new file mode 100644 index 000000000..43dd95b6d --- /dev/null +++ b/WidgetExtension/WidgetExtension.swift @@ -0,0 +1,9 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import MastodonCore +import MastodonSDK +import MastodonLocalization + +enum WidgetExtension { + static let appContext = AppContext() +} diff --git a/WidgetExtension/WidgetExtensionBundle.swift b/WidgetExtension/WidgetExtensionBundle.swift new file mode 100644 index 000000000..61e383660 --- /dev/null +++ b/WidgetExtension/WidgetExtensionBundle.swift @@ -0,0 +1,13 @@ +// Copyright © 2023 Mastodon gGmbH. All rights reserved. + +import WidgetKit +import SwiftUI + +@main +struct WidgetExtensionBundle: WidgetBundle { + var body: some Widget { + FollowersCountWidget() + MultiFollowersCountWidget() + LatestFollowersWidget() + } +} diff --git a/WidgetExtension/ar.lproj/WidgetExtension.strings b/WidgetExtension/ar.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/ar.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/ca.lproj/WidgetExtension.strings b/WidgetExtension/ca.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/ca.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/ckb.lproj/WidgetExtension.strings b/WidgetExtension/ckb.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/ckb.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/cs.lproj/WidgetExtension.strings b/WidgetExtension/cs.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/cs.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/de.lproj/WidgetExtension.strings b/WidgetExtension/de.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/de.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/en.lproj/WidgetExtension.strings b/WidgetExtension/en.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/en.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/es-AR.lproj/WidgetExtension.strings b/WidgetExtension/es-AR.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/es-AR.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/es.lproj/WidgetExtension.strings b/WidgetExtension/es.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/es.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/eu.lproj/WidgetExtension.strings b/WidgetExtension/eu.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/eu.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/fi.lproj/WidgetExtension.strings b/WidgetExtension/fi.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/fi.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/fr.lproj/WidgetExtension.strings b/WidgetExtension/fr.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/fr.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/gd.lproj/WidgetExtension.strings b/WidgetExtension/gd.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/gd.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/gl.lproj/WidgetExtension.strings b/WidgetExtension/gl.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/gl.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/it.lproj/WidgetExtension.strings b/WidgetExtension/it.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/it.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/ja.lproj/WidgetExtension.strings b/WidgetExtension/ja.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/ja.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/kab.lproj/WidgetExtension.strings b/WidgetExtension/kab.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/kab.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/ku.lproj/WidgetExtension.strings b/WidgetExtension/ku.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/ku.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/nl.lproj/WidgetExtension.strings b/WidgetExtension/nl.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/nl.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/ru.lproj/WidgetExtension.strings b/WidgetExtension/ru.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/ru.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/sl.lproj/WidgetExtension.strings b/WidgetExtension/sl.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/sl.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/sv.lproj/WidgetExtension.strings b/WidgetExtension/sv.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/sv.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/th.lproj/WidgetExtension.strings b/WidgetExtension/th.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/th.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/tr.lproj/WidgetExtension.strings b/WidgetExtension/tr.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/tr.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/vi.lproj/WidgetExtension.strings b/WidgetExtension/vi.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/vi.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/zh-Hans.lproj/WidgetExtension.strings b/WidgetExtension/zh-Hans.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/zh-Hans.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; + diff --git a/WidgetExtension/zh-Hant.lproj/WidgetExtension.strings b/WidgetExtension/zh-Hant.lproj/WidgetExtension.strings new file mode 100644 index 000000000..37a67e62d --- /dev/null +++ b/WidgetExtension/zh-Hant.lproj/WidgetExtension.strings @@ -0,0 +1,28 @@ +"3d6HSO" = "Enter username"; + +"7DZrRA" = "Username"; + +"82L4Nj" = "Yes"; + +"BFppgH" = "Username"; + +"OL6lkx" = "Account"; + +"ZLZ6sg" = "Latest Followers"; + +"e0W2wo" = "Multi Followers Count"; + +"fovmPX" = "Accounts"; + +"gpCwrM" = "Followers Count"; + +"jg9D5P" = "No"; + +"sOLUtG" = "Enter follower Username"; + +"tVvJ9c" = "Followers"; + +"xVtyec" = "Show chart"; + +"zeJo4f" = "Should the Widget show a chart?"; +