mirror of
https://github.com/mastodon/mastodon-ios
synced 2025-04-11 22:58:02 +02:00
feat(Widget): Implement Followers Widget chart
This commit is contained in:
parent
1558579a86
commit
e2fe1263a4
@ -24,8 +24,14 @@
|
|||||||
27D701F5292FC2D60031BCBB /* DataSourceFacade+URL.swift in Sources */ = {isa = PBXBuildFile; fileRef = 27D701F4292FC2D60031BCBB /* DataSourceFacade+URL.swift */; };
|
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 */; };
|
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 */; };
|
2A1FE47E2938C11200784BF1 /* Collection+IsNotEmpty.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */; };
|
||||||
|
2A33062D2987DBFA001D4C51 /* FollowersCountHistory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33062C2987DBFA001D4C51 /* FollowersCountHistory.swift */; };
|
||||||
|
2A33063629880835001D4C51 /* Math.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33062F29880834001D4C51 /* Math.swift */; };
|
||||||
|
2A33063729880835001D4C51 /* DataRepresentable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33063029880834001D4C51 /* DataRepresentable.swift */; };
|
||||||
|
2A33063829880835001D4C51 /* LineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33063229880834001D4C51 /* LineChart.swift */; };
|
||||||
|
2A33063929880835001D4C51 /* CurvedChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33063329880834001D4C51 /* CurvedChart.swift */; };
|
||||||
|
2A33063A29880835001D4C51 /* LightChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33063429880834001D4C51 /* LightChart.swift */; };
|
||||||
|
2A33063B29880835001D4C51 /* ChartType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33063529880834001D4C51 /* ChartType.swift */; };
|
||||||
2A33AB662982C4AF008A7FB1 /* FollowCountWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33AB652982C4AF008A7FB1 /* FollowCountWidgetView.swift */; };
|
2A33AB662982C4AF008A7FB1 /* FollowCountWidgetView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A33AB652982C4AF008A7FB1 /* FollowCountWidgetView.swift */; };
|
||||||
2A33AB6D2987C2B3008A7FB1 /* LightChart in Frameworks */ = {isa = PBXBuildFile; productRef = 2A33AB6C2987C2B3008A7FB1 /* LightChart */; };
|
|
||||||
2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; };
|
2A3F6FE3292ECB5E002E6DA7 /* FollowedTagsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */; };
|
||||||
2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */; };
|
2A3F6FE5292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A3F6FE4292F6E44002E6DA7 /* FollowedTagsTableViewCell.swift */; };
|
||||||
2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; };
|
2A506CF4292CD85800059C37 /* FollowedTagsViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A506CF3292CD85800059C37 /* FollowedTagsViewController.swift */; };
|
||||||
@ -606,6 +612,13 @@
|
|||||||
27D701F4292FC2D60031BCBB /* DataSourceFacade+URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+URL.swift"; sourceTree = "<group>"; };
|
27D701F4292FC2D60031BCBB /* DataSourceFacade+URL.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DataSourceFacade+URL.swift"; sourceTree = "<group>"; };
|
||||||
2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowedTagsViewModel+DiffableDataSource.swift"; sourceTree = "<group>"; };
|
2A1FE47B2938BB2600784BF1 /* FollowedTagsViewModel+DiffableDataSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FollowedTagsViewModel+DiffableDataSource.swift"; sourceTree = "<group>"; };
|
||||||
2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+IsNotEmpty.swift"; sourceTree = "<group>"; };
|
2A1FE47D2938C11200784BF1 /* Collection+IsNotEmpty.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Collection+IsNotEmpty.swift"; sourceTree = "<group>"; };
|
||||||
|
2A33062C2987DBFA001D4C51 /* FollowersCountHistory.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowersCountHistory.swift; sourceTree = "<group>"; };
|
||||||
|
2A33062F29880834001D4C51 /* Math.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Math.swift; sourceTree = "<group>"; };
|
||||||
|
2A33063029880834001D4C51 /* DataRepresentable.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = DataRepresentable.swift; sourceTree = "<group>"; };
|
||||||
|
2A33063229880834001D4C51 /* LineChart.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LineChart.swift; sourceTree = "<group>"; };
|
||||||
|
2A33063329880834001D4C51 /* CurvedChart.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = CurvedChart.swift; sourceTree = "<group>"; };
|
||||||
|
2A33063429880834001D4C51 /* LightChart.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = LightChart.swift; sourceTree = "<group>"; };
|
||||||
|
2A33063529880834001D4C51 /* ChartType.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = ChartType.swift; sourceTree = "<group>"; };
|
||||||
2A33625329759B4200481A90 /* OpenInActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenInActionExtension.entitlements; sourceTree = "<group>"; };
|
2A33625329759B4200481A90 /* OpenInActionExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = OpenInActionExtension.entitlements; sourceTree = "<group>"; };
|
||||||
2A33AB652982C4AF008A7FB1 /* FollowCountWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowCountWidgetView.swift; sourceTree = "<group>"; };
|
2A33AB652982C4AF008A7FB1 /* FollowCountWidgetView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowCountWidgetView.swift; sourceTree = "<group>"; };
|
||||||
2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = "<group>"; };
|
2A3F6FE2292ECB5E002E6DA7 /* FollowedTagsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FollowedTagsViewModel.swift; sourceTree = "<group>"; };
|
||||||
@ -1197,7 +1210,6 @@
|
|||||||
isa = PBXFrameworksBuildPhase;
|
isa = PBXFrameworksBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
2A33AB6D2987C2B3008A7FB1 /* LightChart in Frameworks */,
|
|
||||||
2A72813B297EC6F7004138C5 /* MastodonSDKDynamic in Frameworks */,
|
2A72813B297EC6F7004138C5 /* MastodonSDKDynamic in Frameworks */,
|
||||||
2A728124297EA9D7004138C5 /* SwiftUI.framework in Frameworks */,
|
2A728124297EA9D7004138C5 /* SwiftUI.framework in Frameworks */,
|
||||||
2A728122297EA9D7004138C5 /* WidgetKit.framework in Frameworks */,
|
2A728122297EA9D7004138C5 /* WidgetKit.framework in Frameworks */,
|
||||||
@ -1376,6 +1388,27 @@
|
|||||||
path = Pods;
|
path = Pods;
|
||||||
sourceTree = "<group>";
|
sourceTree = "<group>";
|
||||||
};
|
};
|
||||||
|
2A33062E29880834001D4C51 /* LightChart */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2A33063129880834001D4C51 /* Charts */,
|
||||||
|
2A33062F29880834001D4C51 /* Math.swift */,
|
||||||
|
2A33063029880834001D4C51 /* DataRepresentable.swift */,
|
||||||
|
2A33063429880834001D4C51 /* LightChart.swift */,
|
||||||
|
2A33063529880834001D4C51 /* ChartType.swift */,
|
||||||
|
);
|
||||||
|
path = LightChart;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
2A33063129880834001D4C51 /* Charts */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2A33063229880834001D4C51 /* LineChart.swift */,
|
||||||
|
2A33063329880834001D4C51 /* CurvedChart.swift */,
|
||||||
|
);
|
||||||
|
path = Charts;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
2A33AB642982C4A3008A7FB1 /* WidgetViews */ = {
|
2A33AB642982C4A3008A7FB1 /* WidgetViews */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
@ -1410,11 +1443,13 @@
|
|||||||
2A728125297EA9D7004138C5 /* WidgetExtension */ = {
|
2A728125297EA9D7004138C5 /* WidgetExtension */ = {
|
||||||
isa = PBXGroup;
|
isa = PBXGroup;
|
||||||
children = (
|
children = (
|
||||||
|
2A33062E29880834001D4C51 /* LightChart */,
|
||||||
2A33AB642982C4A3008A7FB1 /* WidgetViews */,
|
2A33AB642982C4A3008A7FB1 /* WidgetViews */,
|
||||||
2AE202A9297FDDF500F66E55 /* WidgetExtension.entitlements */,
|
2AE202A9297FDDF500F66E55 /* WidgetExtension.entitlements */,
|
||||||
2A72813E297EC762004138C5 /* WidgetExtension.swift */,
|
2A72813E297EC762004138C5 /* WidgetExtension.swift */,
|
||||||
2A728126297EA9D7004138C5 /* WidgetExtensionBundle.swift */,
|
2A728126297EA9D7004138C5 /* WidgetExtensionBundle.swift */,
|
||||||
2A72812A297EA9D7004138C5 /* FollowersWidgetExtension.swift */,
|
2A72812A297EA9D7004138C5 /* FollowersWidgetExtension.swift */,
|
||||||
|
2A33062C2987DBFA001D4C51 /* FollowersCountHistory.swift */,
|
||||||
2A72812C297EA9D7004138C5 /* WidgetExtension.intentdefinition */,
|
2A72812C297EA9D7004138C5 /* WidgetExtension.intentdefinition */,
|
||||||
2A72812D297EA9D8004138C5 /* Assets.xcassets */,
|
2A72812D297EA9D8004138C5 /* Assets.xcassets */,
|
||||||
2A72812F297EA9D8004138C5 /* Info.plist */,
|
2A72812F297EA9D8004138C5 /* Info.plist */,
|
||||||
@ -2960,7 +2995,6 @@
|
|||||||
name = WidgetExtension;
|
name = WidgetExtension;
|
||||||
packageProductDependencies = (
|
packageProductDependencies = (
|
||||||
2A72813A297EC6F7004138C5 /* MastodonSDKDynamic */,
|
2A72813A297EC6F7004138C5 /* MastodonSDKDynamic */,
|
||||||
2A33AB6C2987C2B3008A7FB1 /* LightChart */,
|
|
||||||
);
|
);
|
||||||
productName = WidgetExtensionExtension;
|
productName = WidgetExtensionExtension;
|
||||||
productReference = 2A728120297EA9D7004138C5 /* WidgetExtension.appex */;
|
productReference = 2A728120297EA9D7004138C5 /* WidgetExtension.appex */;
|
||||||
@ -3174,7 +3208,6 @@
|
|||||||
);
|
);
|
||||||
mainGroup = DB427DC925BAA00100D1B89D;
|
mainGroup = DB427DC925BAA00100D1B89D;
|
||||||
packageReferences = (
|
packageReferences = (
|
||||||
2A33AB6B2987C2B3008A7FB1 /* XCRemoteSwiftPackageReference "LightChart" */,
|
|
||||||
);
|
);
|
||||||
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
|
productRefGroup = DB427DD325BAA00100D1B89D /* Products */;
|
||||||
projectDirPath = "";
|
projectDirPath = "";
|
||||||
@ -3440,11 +3473,18 @@
|
|||||||
isa = PBXSourcesBuildPhase;
|
isa = PBXSourcesBuildPhase;
|
||||||
buildActionMask = 2147483647;
|
buildActionMask = 2147483647;
|
||||||
files = (
|
files = (
|
||||||
|
2A33063729880835001D4C51 /* DataRepresentable.swift in Sources */,
|
||||||
|
2A33062D2987DBFA001D4C51 /* FollowersCountHistory.swift in Sources */,
|
||||||
|
2A33063829880835001D4C51 /* LineChart.swift in Sources */,
|
||||||
2A728130297EA9D8004138C5 /* WidgetExtension.intentdefinition in Sources */,
|
2A728130297EA9D8004138C5 /* WidgetExtension.intentdefinition in Sources */,
|
||||||
2A72813F297EC762004138C5 /* WidgetExtension.swift in Sources */,
|
2A72813F297EC762004138C5 /* WidgetExtension.swift in Sources */,
|
||||||
|
2A33063A29880835001D4C51 /* LightChart.swift in Sources */,
|
||||||
|
2A33063B29880835001D4C51 /* ChartType.swift in Sources */,
|
||||||
|
2A33063629880835001D4C51 /* Math.swift in Sources */,
|
||||||
2A33AB662982C4AF008A7FB1 /* FollowCountWidgetView.swift in Sources */,
|
2A33AB662982C4AF008A7FB1 /* FollowCountWidgetView.swift in Sources */,
|
||||||
2A728127297EA9D7004138C5 /* WidgetExtensionBundle.swift in Sources */,
|
2A728127297EA9D7004138C5 /* WidgetExtensionBundle.swift in Sources */,
|
||||||
2A72812B297EA9D7004138C5 /* FollowersWidgetExtension.swift in Sources */,
|
2A72812B297EA9D7004138C5 /* FollowersWidgetExtension.swift in Sources */,
|
||||||
|
2A33063929880835001D4C51 /* CurvedChart.swift in Sources */,
|
||||||
);
|
);
|
||||||
runOnlyForDeploymentPostprocessing = 0;
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
};
|
};
|
||||||
@ -5246,23 +5286,7 @@
|
|||||||
};
|
};
|
||||||
/* End XCConfigurationList section */
|
/* End XCConfigurationList section */
|
||||||
|
|
||||||
/* Begin XCRemoteSwiftPackageReference section */
|
|
||||||
2A33AB6B2987C2B3008A7FB1 /* XCRemoteSwiftPackageReference "LightChart" */ = {
|
|
||||||
isa = XCRemoteSwiftPackageReference;
|
|
||||||
repositoryURL = "https://github.com/pichukov/LightChart.git";
|
|
||||||
requirement = {
|
|
||||||
kind = upToNextMajorVersion;
|
|
||||||
minimumVersion = 1.0.0;
|
|
||||||
};
|
|
||||||
};
|
|
||||||
/* End XCRemoteSwiftPackageReference section */
|
|
||||||
|
|
||||||
/* Begin XCSwiftPackageProductDependency section */
|
/* Begin XCSwiftPackageProductDependency section */
|
||||||
2A33AB6C2987C2B3008A7FB1 /* LightChart */ = {
|
|
||||||
isa = XCSwiftPackageProductDependency;
|
|
||||||
package = 2A33AB6B2987C2B3008A7FB1 /* XCRemoteSwiftPackageReference "LightChart" */;
|
|
||||||
productName = LightChart;
|
|
||||||
};
|
|
||||||
2A72813A297EC6F7004138C5 /* MastodonSDKDynamic */ = {
|
2A72813A297EC6F7004138C5 /* MastodonSDKDynamic */ = {
|
||||||
isa = XCSwiftPackageProductDependency;
|
isa = XCSwiftPackageProductDependency;
|
||||||
productName = MastodonSDKDynamic;
|
productName = MastodonSDKDynamic;
|
||||||
|
@ -73,15 +73,6 @@
|
|||||||
"version": "4.2.2"
|
"version": "4.2.2"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"package": "LightChart",
|
|
||||||
"repositoryURL": "https://github.com/pichukov/LightChart.git",
|
|
||||||
"state": {
|
|
||||||
"branch": null,
|
|
||||||
"revision": "206fe7ab50620891c89531e2598e36e965678a1a",
|
|
||||||
"version": "1.0.3"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"package": "MetaTextKit",
|
"package": "MetaTextKit",
|
||||||
"repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git",
|
"repositoryURL": "https://github.com/TwidereProject/MetaTextKit.git",
|
||||||
|
112
WidgetExtension/FollowersCountHistory.swift
Normal file
112
WidgetExtension/FollowersCountHistory.swift
Normal file
@ -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.first(where: { $0.dstring == yesterday })?.count ?? account.followersCount
|
||||||
|
|
||||||
|
let followersChange = followersToday - followersYesterday
|
||||||
|
|
||||||
|
switch followersChange {
|
||||||
|
case ..<0:
|
||||||
|
return "\(followersChange)"
|
||||||
|
case 0:
|
||||||
|
return nil
|
||||||
|
default:
|
||||||
|
return "+\(followersChange)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -6,6 +6,8 @@ import Intents
|
|||||||
import MastodonSDK
|
import MastodonSDK
|
||||||
|
|
||||||
struct FollowersProvider: IntentTimelineProvider {
|
struct FollowersProvider: IntentTimelineProvider {
|
||||||
|
private let followersHistory = FollowersCountHistory.shared
|
||||||
|
|
||||||
func placeholder(in context: Context) -> FollowersEntry {
|
func placeholder(in context: Context) -> FollowersEntry {
|
||||||
.placeholder
|
.placeholder
|
||||||
}
|
}
|
||||||
@ -36,7 +38,8 @@ struct FollowersEntry: TimelineEntry {
|
|||||||
followersCount: 99_900,
|
followersCount: 99_900,
|
||||||
displayNameWithFallback: "Mastodon",
|
displayNameWithFallback: "Mastodon",
|
||||||
acct: "mastodon",
|
acct: "mastodon",
|
||||||
avatarImage: UIImage(named: "missingAvatar")!
|
avatarImage: UIImage(named: "missingAvatar")!,
|
||||||
|
domain: "mastodon"
|
||||||
),
|
),
|
||||||
configuration: FollowersCountIntent()
|
configuration: FollowersCountIntent()
|
||||||
)
|
)
|
||||||
@ -103,10 +106,17 @@ private extension FollowersProvider {
|
|||||||
date: Date(),
|
date: Date(),
|
||||||
account: FollowersEntryAccount.from(
|
account: FollowersEntryAccount.from(
|
||||||
mastodonAccount: resultingAccount,
|
mastodonAccount: resultingAccount,
|
||||||
|
domain: authBox.domain,
|
||||||
avatarImage: UIImage(data: imageData) ?? UIImage(named: "missingAvatar")!
|
avatarImage: UIImage(data: imageData) ?? UIImage(named: "missingAvatar")!
|
||||||
),
|
),
|
||||||
configuration: configuration
|
configuration: configuration
|
||||||
)
|
)
|
||||||
|
|
||||||
|
followersHistory.updateFollowersTodayCount(
|
||||||
|
account: entry.account!,
|
||||||
|
count: resultingAccount.followersCount
|
||||||
|
)
|
||||||
|
|
||||||
completion(entry)
|
completion(entry)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -117,6 +127,7 @@ protocol FollowersEntryAccountable {
|
|||||||
var displayNameWithFallback: String { get }
|
var displayNameWithFallback: String { get }
|
||||||
var acct: String { get }
|
var acct: String { get }
|
||||||
var avatarImage: UIImage { get }
|
var avatarImage: UIImage { get }
|
||||||
|
var domain: String { get }
|
||||||
}
|
}
|
||||||
|
|
||||||
struct FollowersEntryAccount: FollowersEntryAccountable {
|
struct FollowersEntryAccount: FollowersEntryAccountable {
|
||||||
@ -124,13 +135,15 @@ struct FollowersEntryAccount: FollowersEntryAccountable {
|
|||||||
let displayNameWithFallback: String
|
let displayNameWithFallback: String
|
||||||
let acct: String
|
let acct: String
|
||||||
let avatarImage: UIImage
|
let avatarImage: UIImage
|
||||||
|
let domain: String
|
||||||
|
|
||||||
static func from(mastodonAccount: Mastodon.Entity.Account, avatarImage: UIImage) -> Self {
|
static func from(mastodonAccount: Mastodon.Entity.Account, domain: String, avatarImage: UIImage) -> Self {
|
||||||
FollowersEntryAccount(
|
FollowersEntryAccount(
|
||||||
followersCount: mastodonAccount.followersCount,
|
followersCount: mastodonAccount.followersCount,
|
||||||
displayNameWithFallback: mastodonAccount.displayNameWithFallback,
|
displayNameWithFallback: mastodonAccount.displayNameWithFallback,
|
||||||
acct: mastodonAccount.acct,
|
acct: mastodonAccount.acct,
|
||||||
avatarImage: avatarImage
|
avatarImage: avatarImage,
|
||||||
|
domain: domain
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
25
WidgetExtension/LightChart/ChartType.swift
Normal file
25
WidgetExtension/LightChart/ChartType.swift
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
//
|
||||||
|
// ChartType.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Alexey Pichukov on 19.08.2020.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public enum ChartType {
|
||||||
|
case line
|
||||||
|
case curved
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum ChartVisualType {
|
||||||
|
case outline(color: Color, lineWidth: CGFloat)
|
||||||
|
case filled(color: Color, lineWidth: CGFloat)
|
||||||
|
case customFilled(color: Color, lineWidth: CGFloat, fillGradient: LinearGradient)
|
||||||
|
}
|
||||||
|
|
||||||
|
public enum CurrentValueLineType {
|
||||||
|
case none
|
||||||
|
case line(color: Color, lineWidth: CGFloat)
|
||||||
|
case dash(color: Color, lineWidth: CGFloat, dash: [CGFloat])
|
||||||
|
}
|
170
WidgetExtension/LightChart/Charts/CurvedChart.swift
Normal file
170
WidgetExtension/LightChart/Charts/CurvedChart.swift
Normal file
@ -0,0 +1,170 @@
|
|||||||
|
//
|
||||||
|
// File.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Alexey Pichukov on 20.08.2020.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct CurvedChart: View {
|
||||||
|
|
||||||
|
private let data: [Double]
|
||||||
|
private let frame: CGRect
|
||||||
|
private let offset: Double
|
||||||
|
private let type: ChartVisualType
|
||||||
|
private let currentValueLineType: CurrentValueLineType
|
||||||
|
private var points: [CGPoint] = []
|
||||||
|
|
||||||
|
/// Creates a new `CurvedChart`
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - data: A data set that should be presented on the chart
|
||||||
|
/// - frame: A frame from the parent view
|
||||||
|
/// - visualType: A type of chart, `.outline` by default
|
||||||
|
/// - offset: An offset for the chart, a space below the chart in percentage (0 - 1)
|
||||||
|
/// For example `offset: 0.2` means that the chart will occupy 80% of the upper
|
||||||
|
/// part of the view
|
||||||
|
/// - currentValueLineType: A type of current value line (`none` for no line on chart)
|
||||||
|
public init(data: [Double],
|
||||||
|
frame: CGRect,
|
||||||
|
visualType: ChartVisualType = .outline(color: .red, lineWidth: 2),
|
||||||
|
offset: Double = 0,
|
||||||
|
currentValueLineType: CurrentValueLineType = .none) {
|
||||||
|
self.data = data
|
||||||
|
self.frame = frame
|
||||||
|
self.type = visualType
|
||||||
|
self.offset = offset
|
||||||
|
self.currentValueLineType = currentValueLineType
|
||||||
|
self.points = points(forData: data,
|
||||||
|
frame: frame,
|
||||||
|
offset: offset,
|
||||||
|
lineWidth: lineWidth(visualType: visualType))
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
ZStack {
|
||||||
|
chart
|
||||||
|
.rotationEffect(.degrees(180), anchor: .center)
|
||||||
|
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||||
|
.drawingGroup()
|
||||||
|
line
|
||||||
|
.rotationEffect(.degrees(180), anchor: .center)
|
||||||
|
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||||
|
.drawingGroup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chart: some View {
|
||||||
|
switch type {
|
||||||
|
case .outline(let color, let lineWidth):
|
||||||
|
return AnyView(curvedPath(points: points)
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)))
|
||||||
|
case .filled(let color, let lineWidth):
|
||||||
|
return AnyView(ZStack {
|
||||||
|
curvedPathGradient(points: points)
|
||||||
|
.fill(LinearGradient(
|
||||||
|
gradient: .init(colors: [color.opacity(0.2), color.opacity(0.02)]),
|
||||||
|
startPoint: .init(x: 0.5, y: 1),
|
||||||
|
endPoint: .init(x: 0.5, y: 0)
|
||||||
|
))
|
||||||
|
curvedPath(points: points)
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round))
|
||||||
|
})
|
||||||
|
case .customFilled(let color, let lineWidth, let fillGradient):
|
||||||
|
return AnyView(ZStack {
|
||||||
|
curvedPathGradient(points: points)
|
||||||
|
.fill(fillGradient)
|
||||||
|
curvedPath(points: points)
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var line: some View {
|
||||||
|
switch currentValueLineType {
|
||||||
|
case .none:
|
||||||
|
return AnyView(EmptyView())
|
||||||
|
case .line(let color, let lineWidth):
|
||||||
|
return AnyView(
|
||||||
|
currentValueLinePath(points: points)
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: lineWidth))
|
||||||
|
)
|
||||||
|
case .dash(let color, let lineWidth, let dash):
|
||||||
|
return AnyView(
|
||||||
|
currentValueLinePath(points: points)
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, dash: dash))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: private functions
|
||||||
|
|
||||||
|
private func curvedPath(points: [CGPoint]) -> Path {
|
||||||
|
func mid(_ point1: CGPoint, _ point2: CGPoint) -> CGPoint {
|
||||||
|
return CGPoint(x: (point1.x + point2.x) / 2, y:(point1.y + point2.y) / 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func control(_ point1: CGPoint, _ point2: CGPoint) -> CGPoint {
|
||||||
|
var controlPoint = mid(point1, point2)
|
||||||
|
let delta = abs(point2.y - controlPoint.y)
|
||||||
|
|
||||||
|
if point1.y < point2.y {
|
||||||
|
controlPoint.y += delta
|
||||||
|
} else if point1.y > point2.y {
|
||||||
|
controlPoint.y -= delta
|
||||||
|
}
|
||||||
|
|
||||||
|
return controlPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
var path = Path()
|
||||||
|
guard points.count > 1 else {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
var startPoint = points[0]
|
||||||
|
path.move(to: startPoint)
|
||||||
|
|
||||||
|
guard points.count > 2 else {
|
||||||
|
path.addLine(to: points[1])
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
for i in 1..<points.count {
|
||||||
|
let currentPoint = points[i]
|
||||||
|
let midPoint = mid(startPoint, currentPoint)
|
||||||
|
|
||||||
|
path.addQuadCurve(to: midPoint, control: control(midPoint, startPoint))
|
||||||
|
path.addQuadCurve(to: currentPoint, control: control(midPoint, currentPoint))
|
||||||
|
|
||||||
|
startPoint = currentPoint
|
||||||
|
}
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private func curvedPathGradient(points: [CGPoint]) -> Path {
|
||||||
|
var path = curvedPath(points: points)
|
||||||
|
guard let lastPoint = points.last else {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
path.addLine(to: CGPoint(x: lastPoint.x, y: 0))
|
||||||
|
path.addLine(to: CGPoint(x: 0, y: 0))
|
||||||
|
path.addLine(to: CGPoint(x: 0, y: points[0].y))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentValueLinePath(points: [CGPoint]) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
guard let lastPoint = points.last else {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
path.move(to: CGPoint(x: 0, y: lastPoint.y))
|
||||||
|
path.addLine(to: lastPoint)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension CurvedChart: DataRepresentable { }
|
138
WidgetExtension/LightChart/Charts/LineChart.swift
Normal file
138
WidgetExtension/LightChart/Charts/LineChart.swift
Normal file
@ -0,0 +1,138 @@
|
|||||||
|
//
|
||||||
|
// LineChart.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Alexey Pichukov on 19.08.2020.
|
||||||
|
//
|
||||||
|
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct LineChart: View {
|
||||||
|
|
||||||
|
private let data: [Double]
|
||||||
|
private let frame: CGRect
|
||||||
|
private let offset: Double
|
||||||
|
private let type: ChartVisualType
|
||||||
|
private let currentValueLineType: CurrentValueLineType
|
||||||
|
private var points: [CGPoint] = []
|
||||||
|
|
||||||
|
/// Creates a new `LineChart`
|
||||||
|
///
|
||||||
|
/// - Parameters:
|
||||||
|
/// - data: A data set that should be presented on the chart
|
||||||
|
/// - frame: A frame from the parent view
|
||||||
|
/// - visualType: A type of chart, `.outline` by default
|
||||||
|
/// - offset: An offset for the chart, a space below the chart in percentage (0 - 1)
|
||||||
|
/// For example `offset: 0.2` means that the chart will occupy 80% of the upper
|
||||||
|
/// part of the view
|
||||||
|
/// - currentValueLineType: A type of current value line (`none` for no line on chart)
|
||||||
|
public init(data: [Double],
|
||||||
|
frame: CGRect,
|
||||||
|
visualType: ChartVisualType = .outline(color: .red, lineWidth: 2),
|
||||||
|
offset: Double = 0,
|
||||||
|
currentValueLineType: CurrentValueLineType = .none) {
|
||||||
|
self.data = data
|
||||||
|
self.frame = frame
|
||||||
|
self.type = visualType
|
||||||
|
self.offset = offset
|
||||||
|
self.currentValueLineType = currentValueLineType
|
||||||
|
self.points = points(forData: data,
|
||||||
|
frame: frame,
|
||||||
|
offset: offset,
|
||||||
|
lineWidth: lineWidth(visualType: visualType))
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
ZStack {
|
||||||
|
chart
|
||||||
|
.rotationEffect(.degrees(180), anchor: .center)
|
||||||
|
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||||
|
.drawingGroup()
|
||||||
|
line
|
||||||
|
.rotationEffect(.degrees(180), anchor: .center)
|
||||||
|
.rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0))
|
||||||
|
.drawingGroup()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var chart: some View {
|
||||||
|
switch type {
|
||||||
|
case .outline(let color, let lineWidth):
|
||||||
|
return AnyView(linePath(points: points)
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round)))
|
||||||
|
case .filled(let color, let lineWidth):
|
||||||
|
return AnyView(ZStack {
|
||||||
|
linePathGradient(points: points)
|
||||||
|
.fill(LinearGradient(
|
||||||
|
gradient: .init(colors: [color.opacity(0.2), color.opacity(0.02)]),
|
||||||
|
startPoint: .init(x: 0.5, y: 1),
|
||||||
|
endPoint: .init(x: 0.5, y: 0)
|
||||||
|
))
|
||||||
|
linePath(points: points)
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round))
|
||||||
|
})
|
||||||
|
case .customFilled(let color, let lineWidth, let fillGradient):
|
||||||
|
return AnyView(ZStack {
|
||||||
|
linePathGradient(points: points)
|
||||||
|
.fill(fillGradient)
|
||||||
|
linePath(points: points)
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineJoin: .round))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var line: some View {
|
||||||
|
switch currentValueLineType {
|
||||||
|
case .none:
|
||||||
|
return AnyView(EmptyView())
|
||||||
|
case .line(let color, let lineWidth):
|
||||||
|
return AnyView(
|
||||||
|
currentValueLinePath(points: points)
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: lineWidth))
|
||||||
|
)
|
||||||
|
case .dash(let color, let lineWidth, let dash):
|
||||||
|
return AnyView(
|
||||||
|
currentValueLinePath(points: points)
|
||||||
|
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, dash: dash))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: private functions
|
||||||
|
|
||||||
|
private func linePath(points: [CGPoint]) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
guard points.count > 1 else {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
path.move(to: points[0])
|
||||||
|
for i in 1..<points.count {
|
||||||
|
path.addLine(to: points[i])
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private func linePathGradient(points: [CGPoint]) -> Path {
|
||||||
|
var path = linePath(points: points)
|
||||||
|
guard let lastPoint = points.last else {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
path.addLine(to: CGPoint(x: lastPoint.x, y: 0))
|
||||||
|
path.addLine(to: CGPoint(x: 0, y: 0))
|
||||||
|
path.addLine(to: CGPoint(x: 0, y: points[0].y))
|
||||||
|
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
private func currentValueLinePath(points: [CGPoint]) -> Path {
|
||||||
|
var path = Path()
|
||||||
|
guard let lastPoint = points.last else {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
path.move(to: CGPoint(x: 0, y: lastPoint.y))
|
||||||
|
path.addLine(to: lastPoint)
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
extension LineChart: DataRepresentable { }
|
42
WidgetExtension/LightChart/DataRepresentable.swift
Normal file
42
WidgetExtension/LightChart/DataRepresentable.swift
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
//
|
||||||
|
// DataRepresentable.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Alexey Pichukov on 19.08.2020.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
protocol DataRepresentable {
|
||||||
|
func points(forData data: [Double], frame: CGRect, offset: Double, lineWidth: CGFloat) -> [CGPoint]
|
||||||
|
func lineWidth(visualType: ChartVisualType) -> CGFloat
|
||||||
|
}
|
||||||
|
|
||||||
|
extension DataRepresentable {
|
||||||
|
|
||||||
|
func points(forData data: [Double], frame: CGRect, offset: Double, lineWidth: CGFloat) -> [CGPoint] {
|
||||||
|
var vector = Math.stretchOut(Math.norm(data))
|
||||||
|
if offset != 0 {
|
||||||
|
vector = Math.stretchIn(vector, offset: offset)
|
||||||
|
}
|
||||||
|
var points: [CGPoint] = []
|
||||||
|
for i in 0..<vector.count {
|
||||||
|
let x = frame.size.width / CGFloat(vector.count - 1) * CGFloat(i)
|
||||||
|
let y = (frame.size.height - lineWidth) * CGFloat(vector[i]) + lineWidth / 2
|
||||||
|
points.append(CGPoint(x: x, y: y))
|
||||||
|
}
|
||||||
|
return points
|
||||||
|
}
|
||||||
|
|
||||||
|
func lineWidth(visualType: ChartVisualType) -> CGFloat {
|
||||||
|
switch visualType {
|
||||||
|
case .outline(_, let lineWidth):
|
||||||
|
return lineWidth
|
||||||
|
case .filled(_, let lineWidth):
|
||||||
|
return lineWidth
|
||||||
|
case .customFilled(_, let lineWidth, _):
|
||||||
|
return lineWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
52
WidgetExtension/LightChart/LightChart.swift
Normal file
52
WidgetExtension/LightChart/LightChart.swift
Normal file
@ -0,0 +1,52 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
public struct LightChartView: View {
|
||||||
|
|
||||||
|
private let data: [Double]
|
||||||
|
private let type: ChartType
|
||||||
|
private let visualType: ChartVisualType
|
||||||
|
private let offset: Double
|
||||||
|
private let currentValueLineType: CurrentValueLineType
|
||||||
|
|
||||||
|
public init(data: [Double],
|
||||||
|
type: ChartType = .line,
|
||||||
|
visualType: ChartVisualType = .outline(color: .red, lineWidth: 2),
|
||||||
|
offset: Double = 0,
|
||||||
|
currentValueLineType: CurrentValueLineType = .none) {
|
||||||
|
self.data = data
|
||||||
|
self.type = type
|
||||||
|
self.visualType = visualType
|
||||||
|
self.offset = offset
|
||||||
|
self.currentValueLineType = currentValueLineType
|
||||||
|
}
|
||||||
|
|
||||||
|
public var body: some View {
|
||||||
|
GeometryReader { reader in
|
||||||
|
chart(withFrame: CGRect(x: 0,
|
||||||
|
y: 0,
|
||||||
|
width: reader.frame(in: .local).width ,
|
||||||
|
height: reader.frame(in: .local).height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func chart(withFrame frame: CGRect) -> AnyView {
|
||||||
|
switch type {
|
||||||
|
case .line:
|
||||||
|
return AnyView(
|
||||||
|
LineChart(data: data,
|
||||||
|
frame: frame,
|
||||||
|
visualType: visualType,
|
||||||
|
offset: offset,
|
||||||
|
currentValueLineType: currentValueLineType)
|
||||||
|
)
|
||||||
|
case .curved:
|
||||||
|
return AnyView(
|
||||||
|
CurvedChart(data: data,
|
||||||
|
frame: frame,
|
||||||
|
visualType: visualType,
|
||||||
|
offset: offset,
|
||||||
|
currentValueLineType: currentValueLineType)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
34
WidgetExtension/LightChart/Math.swift
Normal file
34
WidgetExtension/LightChart/Math.swift
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
//
|
||||||
|
// Math.swift
|
||||||
|
//
|
||||||
|
//
|
||||||
|
// Created by Alexey Pichukov on 19.08.2020.
|
||||||
|
//
|
||||||
|
|
||||||
|
import Foundation
|
||||||
|
import CoreGraphics
|
||||||
|
|
||||||
|
struct Math {
|
||||||
|
|
||||||
|
static func norm(_ vector: [Double]) -> [Double] {
|
||||||
|
let norm = sqrt(Double(vector.reduce(0) { $0 + $1 * $1 }))
|
||||||
|
return norm == 0 ? vector : vector.map { $0 / norm }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stretchOut(_ vector: [Double]) -> [Double] {
|
||||||
|
guard let min = vector.min(),
|
||||||
|
let rawMax = vector.max() else {
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
let max = rawMax - min
|
||||||
|
return vector.map { ($0 - min) / (max != 0 ? max : 1) }
|
||||||
|
}
|
||||||
|
|
||||||
|
static func stretchIn(_ vector: [Double], offset: Double) -> [Double] {
|
||||||
|
guard let max = vector.max() else {
|
||||||
|
return vector
|
||||||
|
}
|
||||||
|
let newMax = max - offset
|
||||||
|
return vector.map { $0 * newMax + offset }
|
||||||
|
}
|
||||||
|
}
|
@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import SwiftUI
|
import SwiftUI
|
||||||
import WidgetKit
|
import WidgetKit
|
||||||
import LightChart
|
|
||||||
import MastodonAsset
|
import MastodonAsset
|
||||||
|
|
||||||
struct FollowCountWidgetView: View {
|
struct FollowCountWidgetView: View {
|
||||||
|
private let followersHistory = FollowersCountHistory.shared
|
||||||
|
|
||||||
@Environment(\.widgetFamily) var family
|
@Environment(\.widgetFamily) var family
|
||||||
|
|
||||||
var entry: FollowersProvider.Entry
|
var entry: FollowersProvider.Entry
|
||||||
@ -93,21 +94,25 @@ struct FollowCountWidgetView: View {
|
|||||||
.padding(.leading, 20)
|
.padding(.leading, 20)
|
||||||
|
|
||||||
ZStack {
|
ZStack {
|
||||||
LightChartView(
|
if let account = entry.account {
|
||||||
data: [200, 205, 208, 213, 210, 211, 212],
|
LightChartView(
|
||||||
type: .line,
|
data: followersHistory.chartValues(for: account),
|
||||||
visualType: .filled(color: Asset.Colors.Brand.blurple.swiftUIColor, lineWidth: 2),
|
type: .line,
|
||||||
offset: 0.8 /// this is the positive offset from the bottom edge of the graph (~80% above bottom level)
|
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 {
|
HStack {
|
||||||
VStack(alignment: .leading, spacing: 0) {
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
Spacer()
|
Spacer()
|
||||||
Text("+4 followers today")
|
if let increaseCount = followersHistory.increaseCountString(for: account) {
|
||||||
.font(.system(size: 12))
|
Text("\(increaseCount) followers today")
|
||||||
.foregroundColor(.secondary)
|
.font(.system(size: 12))
|
||||||
.lineLimit(1)
|
.foregroundColor(.secondary)
|
||||||
.truncationMode(.tail)
|
.lineLimit(1)
|
||||||
|
.truncationMode(.tail)
|
||||||
|
}
|
||||||
|
|
||||||
Text(account.followersCount.asAbbreviatedCountString())
|
Text(account.followersCount.asAbbreviatedCountString())
|
||||||
.font(.largeTitle)
|
.font(.largeTitle)
|
||||||
|
Loading…
x
Reference in New Issue
Block a user