diff --git a/.github/funding.yml b/.github/funding.yml new file mode 100644 index 00000000..45cf11b0 --- /dev/null +++ b/.github/funding.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: CubeLabsNZ # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: cubetime +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: # Replace with a single Liberapay username +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] diff --git a/.periphery.yml b/.periphery.yml new file mode 100644 index 00000000..8d71a08e --- /dev/null +++ b/.periphery.yml @@ -0,0 +1,6 @@ +clean_build: true +project: CubeTime.xcodeproj +schemes: +- CubeTime +targets: +- CubeTime diff --git a/CubeTime.xcodeproj/project.pbxproj b/CubeTime.xcodeproj/project.pbxproj index 6fee2678..d6ceb992 100644 --- a/CubeTime.xcodeproj/project.pbxproj +++ b/CubeTime.xcodeproj/project.pbxproj @@ -10,29 +10,36 @@ 231827E729B5E86B00A40C78 /* FloatingPanel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231827E629B5E86B00A40C78 /* FloatingPanel.swift */; }; 2319F8B9276872F200644EB3 /* TimeTrend.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2319F8B8276872F200644EB3 /* TimeTrend.swift */; }; 231B2E2E29BDECF9007E5DC2 /* HelperButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231B2E2D29BDECF9007E5DC2 /* HelperButtons.swift */; }; + 231F37C52B81FD290016A137 /* ImportExport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 231F37C42B81FD290016A137 /* ImportExport.swift */; }; 2326436028C4927600F6322E /* TimerHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2326435F28C4927600F6322E /* TimerHeader.swift */; }; - 2326436228C4929700F6322E /* PrevSolvesDisplay.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2326436128C4929700F6322E /* PrevSolvesDisplay.swift */; }; - 23329E9029C31CA60017AE99 /* libz.1.1.3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 23329E8F29C31CA60017AE99 /* libz.1.1.3.tbd */; }; + 23329E9029C31CA60017AE99 /* libz.1.1.3.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 23329E8F29C31CA60017AE99 /* libz.1.1.3.tbd */; settings = {ATTRIBUTES = (Required, ); }; }; 233FE7B6274F109600C6F1DF /* Helper.swift in Sources */ = {isa = PBXBuildFile; fileRef = 233FE7B5274F109600C6F1DF /* Helper.swift */; }; + 23418F592BB2A8C900398EE2 /* StoreKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 23418F582BB2A8C900398EE2 /* StoreKit.framework */; }; 2359689327A367A0003ED9E1 /* StopwatchManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2359689227A367A0003ED9E1 /* StopwatchManager.swift */; }; + 235CD3022BB37C2200F6095A /* TNoodle.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = E5B1FCEC2BAD892A007C1688 /* TNoodle.xcframework */; }; + 235CD3032BB37C2200F6095A /* TNoodle.xcframework in Embed Frameworks */ = {isa = PBXBuildFile; fileRef = E5B1FCEC2BAD892A007C1688 /* TNoodle.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; 235CDA0327A786D800F48C89 /* Updates.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235CDA0227A786D800F48C89 /* Updates.swift */; }; 235D6A6729C2C060002D90D8 /* Models.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235D6A6629C2C060002D90D8 /* Models.swift */; }; 235D6A6929C2C0D3002D90D8 /* HelperShare.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235D6A6829C2C0D3002D90D8 /* HelperShare.swift */; }; 235D6A6E29C2C34E002D90D8 /* ManualInputTextField.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235D6A6D29C2C34E002D90D8 /* ManualInputTextField.swift */; }; 235D6A7029C2CE09002D90D8 /* CloudkitStatusManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 235D6A6F29C2CE09002D90D8 /* CloudkitStatusManager.swift */; }; + 236850DD2C54D5CA00548BEA /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 236850DC2C54D5CA00548BEA /* Localizable.xcstrings */; }; 2377418829A9A05A00A4F917 /* HelperUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2377418729A9A05A00A4F917 /* HelperUI.swift */; }; 237B35502761995F00CC39BF /* SettingsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 237B354F2761995F00CC39BF /* SettingsCard.swift */; }; 238FB42029B8175C008EC1D4 /* GradientManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 238FB41F29B8175C008EC1D4 /* GradientManager.swift */; }; 239560A629C3056E00735035 /* SplashPad.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 239560A529C3056E00735035 /* SplashPad.storyboard */; }; 2397131F2791032300268DFA /* AveragePhases.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2397131E2791032300268DFA /* AveragePhases.swift */; }; - 239C7E702781511300943AF2 /* Onboarding.swift in Sources */ = {isa = PBXBuildFile; fileRef = 239C7E6F2781511300943AF2 /* Onboarding.swift */; }; + 239AC69A2CF853340061DD1D /* Confetti.swift in Sources */ = {isa = PBXBuildFile; fileRef = 239AC6922CF853340061DD1D /* Confetti.swift */; }; + 239AC6A02CF85EF50061DD1D /* TimeCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 239AC69F2CF85EE80061DD1D /* TimeCardView.swift */; }; + 239AC6A62CF884D90061DD1D /* Rubik.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 239AC6A52CF884D90061DD1D /* Rubik.ttf */; }; + 239AC6A72CF884D90061DD1D /* Rubik.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 239AC6A52CF884D90061DD1D /* Rubik.ttf */; }; + 239AC6A82CF884D90061DD1D /* Rubik.ttf in Resources */ = {isa = PBXBuildFile; fileRef = 239AC6A52CF884D90061DD1D /* Rubik.ttf */; }; 23A1CDBD29399FE000F0895D /* SwiftfulLoadingIndicators in Frameworks */ = {isa = PBXBuildFile; productRef = 23A1CDBC29399FE000F0895D /* SwiftfulLoadingIndicators */; }; - 23AAF99629B0219200C5C1FF /* ScrollableLineChart.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AAF99529B0219200C5C1FF /* ScrollableLineChart.swift */; }; + 23AAF99629B0219200C5C1FF /* TimeTrendViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AAF99529B0219200C5C1FF /* TimeTrendViewController.swift */; }; 23AAF99829B072F900C5C1FF /* StatsDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AAF99729B072F900C5C1FF /* StatsDetailView.swift */; }; 23AB648429385D310049993B /* NewSessionRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23AB648329385D310049993B /* NewSessionRootView.swift */; }; 23AB648C293867EA0049993B /* 12sec-audio.wav in Resources */ = {isa = PBXBuildFile; fileRef = 23AB648A293867EA0049993B /* 12sec-audio.wav */; }; 23AB648D293867EA0049993B /* 8sec-audio.wav in Resources */ = {isa = PBXBuildFile; fileRef = 23AB648B293867EA0049993B /* 8sec-audio.wav */; }; - 23B45E7229A8560400BDF49E /* TNoodleLibNative.xcframework in Frameworks */ = {isa = PBXBuildFile; fileRef = 23B45E6F29A8553000BDF49E /* TNoodleLibNative.xcframework */; }; 23B7AC3A29BAB2E300B5C9B4 /* TimerTools.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B7AC3929BAB2E300B5C9B4 /* TimerTools.swift */; }; 23B7AC3C29BB049F00B5C9B4 /* CalculatorTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23B7AC3B29BB049F00B5C9B4 /* CalculatorTool.swift */; }; 23C85EAC29388C9C00C7C3EC /* StatsSubviews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23C85EAB29388C9C00C7C3EC /* StatsSubviews.swift */; }; @@ -61,6 +68,7 @@ 23E81E73275D8DEA00B741E3 /* AboutSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23E81E72275D8DEA00B741E3 /* AboutSettings.swift */; }; 23EB8A1E2779B6D3000F6663 /* TimeBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23EB8A1D2779B6D3000F6663 /* TimeBar.swift */; }; 23F4113829ADDA77002C4E23 /* ToolsViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23F4113729ADDA77002C4E23 /* ToolsViewModel.swift */; }; + 23F445812B46552D002B7B81 /* TimeTrendDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 23F445802B46552D002B7B81 /* TimeTrendDetailView.swift */; }; 7D2B805E279422BD0006AEDD /* AppDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D2B805D279422BD0006AEDD /* AppDelegate.swift */; }; 7D3D3154278D3CA400BBDF55 /* TimerTouchView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D3D3153278D3CA400BBDF55 /* TimerTouchView.swift */; }; 7D6159F92751B53E00DC9A4C /* TimeCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D6159F82751B53E00DC9A4C /* TimeCard.swift */; }; @@ -73,7 +81,7 @@ 7D7E2012275DD52600ADF01D /* Licenses.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D7E2011275DD52600ADF01D /* Licenses.swift */; }; 7D7E7C63279A69F1004C9A4E /* ScrambleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D7E7C62279A69F0004C9A4E /* ScrambleView.swift */; }; AF0CF61D29AC34B300E8CB95 /* StopwatchManagerStatsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF0CF61C29AC34B300E8CB95 /* StopwatchManagerStatsTests.swift */; }; - AF649FB429B7ED320099A839 /* TimeListViewInner.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF649FB329B7ED320099A839 /* TimeListViewInner.swift */; }; + AF649FB429B7ED320099A839 /* TimeListViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF649FB329B7ED320099A839 /* TimeListViewController.swift */; }; AF6EAC2029ADDA1C00171F73 /* TimerMenu.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF6EAC1F29ADDA1C00171F73 /* TimerMenu.swift */; }; AF7697DC293C1EFA00FF1C50 /* SVGView in Frameworks */ = {isa = PBXBuildFile; productRef = AF7697DB293C1EFA00FF1C50 /* SVGView */; }; AF7697DE293C5A9C00FF1C50 /* RecMonoVariable.ttf in Resources */ = {isa = PBXBuildFile; fileRef = AF7697DD293C537900FF1C50 /* RecMonoVariable.ttf */; }; @@ -83,10 +91,12 @@ AF8874C129AAC8C0001E49D0 /* ToolsList.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF8874C029AAC8C0001E49D0 /* ToolsList.swift */; }; AF8874C329AB0BE7001E49D0 /* TimerController.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF8874C229AB0BE7001E49D0 /* TimerController.swift */; }; AF8874C529AB3736001E49D0 /* ScrambleGeneratorTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF8874C429AB3736001E49D0 /* ScrambleGeneratorTool.swift */; }; - AF89931A29C6B0EF0079EA8D /* TNoodleLibNative.xcframework in Embed Libraries */ = {isa = PBXBuildFile; fileRef = 23B45E6F29A8553000BDF49E /* TNoodleLibNative.xcframework */; settings = {ATTRIBUTES = (CodeSignOnCopy, RemoveHeadersOnCopy, ); }; }; AF8A157429AF15B500F6818E /* ScrambleOnlyTool.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF8A157329AF15B500F6818E /* ScrambleOnlyTool.swift */; }; AF8B305B29B5C7010023D23B /* SettingsManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = AF8B305A29B5C7010023D23B /* SettingsManager.swift */; }; AFA444A229C4290500795434 /* SettingsWidgets.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFA444A129C4290400795434 /* SettingsWidgets.swift */; }; + E5A559602B83456F004093B3 /* ExportViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5A5595F2B83456F004093B3 /* ExportViewModel.swift */; }; + E5A742D42BA8184100321D24 /* ZIPFoundation in Frameworks */ = {isa = PBXBuildFile; productRef = E5A742D32BA8184100321D24 /* ZIPFoundation */; }; + E5B3B1532BA5B68600179BDC /* LZString in Frameworks */ = {isa = PBXBuildFile; productRef = E5B3B1522BA5B68600179BDC /* LZString */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -107,15 +117,15 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ - 23B45E5929A83C1100BDF49E /* Embed Libraries */ = { + 235CD3042BB37C2200F6095A /* Embed Frameworks */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; dstPath = ""; dstSubfolderSpec = 10; files = ( - AF89931A29C6B0EF0079EA8D /* TNoodleLibNative.xcframework in Embed Libraries */, + 235CD3032BB37C2200F6095A /* TNoodle.xcframework in Embed Frameworks */, ); - name = "Embed Libraries"; + name = "Embed Frameworks"; runOnlyForDeploymentPostprocessing = 0; }; /* End PBXCopyFilesBuildPhase section */ @@ -124,10 +134,12 @@ 231827E629B5E86B00A40C78 /* FloatingPanel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = FloatingPanel.swift; path = CubeTime/Tabs/FloatingPanel.swift; sourceTree = SOURCE_ROOT; }; 2319F8B8276872F200644EB3 /* TimeTrend.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeTrend.swift; sourceTree = ""; }; 231B2E2D29BDECF9007E5DC2 /* HelperButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperButtons.swift; sourceTree = ""; }; + 231F37C42B81FD290016A137 /* ImportExport.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImportExport.swift; sourceTree = ""; }; 2326435F28C4927600F6322E /* TimerHeader.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TimerHeader.swift; sourceTree = ""; }; - 2326436128C4929700F6322E /* PrevSolvesDisplay.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = PrevSolvesDisplay.swift; sourceTree = ""; }; 23329E8F29C31CA60017AE99 /* libz.1.1.3.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libz.1.1.3.tbd; path = usr/lib/libz.1.1.3.tbd; sourceTree = SDKROOT; }; 233FE7B5274F109600C6F1DF /* Helper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Helper.swift; sourceTree = ""; }; + 23418F582BB2A8C900398EE2 /* StoreKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = StoreKit.framework; path = System/Library/Frameworks/StoreKit.framework; sourceTree = SDKROOT; }; + 23418F5A2BB2ADE500398EE2 /* CTDonations.storekit */ = {isa = PBXFileReference; lastKnownFileType = text; name = CTDonations.storekit; path = CubeTime/CTDonations.storekit; sourceTree = SOURCE_ROOT; }; 2354882227642E2800FE8D7A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 2359689227A367A0003ED9E1 /* StopwatchManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopwatchManager.swift; sourceTree = ""; }; 235CDA0227A786D800F48C89 /* Updates.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Updates.swift; sourceTree = ""; }; @@ -135,20 +147,22 @@ 235D6A6829C2C0D3002D90D8 /* HelperShare.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperShare.swift; sourceTree = ""; }; 235D6A6D29C2C34E002D90D8 /* ManualInputTextField.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ManualInputTextField.swift; sourceTree = ""; }; 235D6A6F29C2CE09002D90D8 /* CloudkitStatusManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CloudkitStatusManager.swift; sourceTree = ""; }; + 236850DC2C54D5CA00548BEA /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 2377418729A9A05A00A4F917 /* HelperUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelperUI.swift; sourceTree = ""; }; 23783DA429AB55750036FE2B /* getBestAverage.h++ */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.h; path = "getBestAverage.h++"; sourceTree = ""; }; 237B354F2761995F00CC39BF /* SettingsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsCard.swift; sourceTree = ""; }; 238FB41F29B8175C008EC1D4 /* GradientManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GradientManager.swift; sourceTree = ""; }; 239560A529C3056E00735035 /* SplashPad.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = SplashPad.storyboard; sourceTree = ""; }; 2397131E2791032300268DFA /* AveragePhases.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AveragePhases.swift; sourceTree = ""; }; - 239C7E6F2781511300943AF2 /* Onboarding.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Onboarding.swift; sourceTree = ""; }; - 23AAF99529B0219200C5C1FF /* ScrollableLineChart.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrollableLineChart.swift; sourceTree = ""; }; + 239AC6922CF853340061DD1D /* Confetti.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Confetti.swift; sourceTree = ""; }; + 239AC69F2CF85EE80061DD1D /* TimeCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeCardView.swift; sourceTree = ""; }; + 239AC6A52CF884D90061DD1D /* Rubik.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = Rubik.ttf; sourceTree = ""; }; + 23AAF99529B0219200C5C1FF /* TimeTrendViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeTrendViewController.swift; sourceTree = ""; }; 23AAF99729B072F900C5C1FF /* StatsDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsDetailView.swift; sourceTree = ""; }; 23AB648329385D310049993B /* NewSessionRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewSessionRootView.swift; sourceTree = ""; }; 23AB648A293867EA0049993B /* 12sec-audio.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "12sec-audio.wav"; sourceTree = ""; }; 23AB648B293867EA0049993B /* 8sec-audio.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "8sec-audio.wav"; sourceTree = ""; }; 23B45E6E29A8552000BDF49E /* 3rdparty-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "3rdparty-Bridging-Header.h"; sourceTree = ""; }; - 23B45E6F29A8553000BDF49E /* TNoodleLibNative.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = TNoodleLibNative.xcframework; sourceTree = ""; }; 23B7AC3929BAB2E300B5C9B4 /* TimerTools.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerTools.swift; sourceTree = ""; }; 23B7AC3B29BB049F00B5C9B4 /* CalculatorTool.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CalculatorTool.swift; sourceTree = ""; }; 23C85EAB29388C9C00C7C3EC /* StatsSubviews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatsSubviews.swift; sourceTree = ""; }; @@ -180,6 +194,7 @@ 23E81E72275D8DEA00B741E3 /* AboutSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutSettings.swift; sourceTree = ""; }; 23EB8A1D2779B6D3000F6663 /* TimeBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeBar.swift; sourceTree = ""; }; 23F4113729ADDA77002C4E23 /* ToolsViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ToolsViewModel.swift; sourceTree = ""; }; + 23F445802B46552D002B7B81 /* TimeTrendDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeTrendDetailView.swift; sourceTree = ""; }; 73B7136529BA3FF00064E02F /* CubeTime.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = CubeTime.entitlements; sourceTree = ""; }; 7D2B805D279422BD0006AEDD /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; 7D3D3153278D3CA400BBDF55 /* TimerTouchView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerTouchView.swift; sourceTree = ""; }; @@ -195,7 +210,7 @@ 7D7E2011275DD52600ADF01D /* Licenses.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Licenses.swift; sourceTree = ""; }; 7D7E7C62279A69F0004C9A4E /* ScrambleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScrambleView.swift; sourceTree = ""; }; AF0CF61C29AC34B300E8CB95 /* StopwatchManagerStatsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StopwatchManagerStatsTests.swift; sourceTree = ""; }; - AF649FB329B7ED320099A839 /* TimeListViewInner.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeListViewInner.swift; sourceTree = ""; }; + AF649FB329B7ED320099A839 /* TimeListViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimeListViewController.swift; sourceTree = ""; }; AF6EAC1F29ADDA1C00171F73 /* TimerMenu.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerMenu.swift; sourceTree = ""; }; AF7697DD293C537900FF1C50 /* RecMonoVariable.ttf */ = {isa = PBXFileReference; lastKnownFileType = file; path = RecMonoVariable.ttf; sourceTree = ""; }; AF81757629B07712004838B9 /* FontManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FontManager.swift; sourceTree = ""; }; @@ -206,6 +221,8 @@ AF8B305A29B5C7010023D23B /* SettingsManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsManager.swift; sourceTree = ""; }; AF977E5B29BEE9E100A246C0 /* CubeTimeData.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = CubeTimeData.xcdatamodel; sourceTree = ""; }; AFA444A129C4290400795434 /* SettingsWidgets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsWidgets.swift; sourceTree = ""; }; + E5A5595F2B83456F004093B3 /* ExportViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ExportViewModel.swift; sourceTree = ""; }; + E5B1FCEC2BAD892A007C1688 /* TNoodle.xcframework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcframework; path = TNoodle.xcframework; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -214,9 +231,12 @@ buildActionMask = 2147483647; files = ( 23329E9029C31CA60017AE99 /* libz.1.1.3.tbd in Frameworks */, - 23B45E7229A8560400BDF49E /* TNoodleLibNative.xcframework in Frameworks */, + 235CD3022BB37C2200F6095A /* TNoodle.xcframework in Frameworks */, + 23418F592BB2A8C900398EE2 /* StoreKit.framework in Frameworks */, AF7697DC293C1EFA00FF1C50 /* SVGView in Frameworks */, 23A1CDBD29399FE000F0895D /* SwiftfulLoadingIndicators in Frameworks */, + E5B3B1532BA5B68600179BDC /* LZString in Frameworks */, + E5A742D42BA8184100321D24 /* ZIPFoundation in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -240,28 +260,46 @@ 23034727278C31AE00553A7D /* Fonts */ = { isa = PBXGroup; children = ( + 239AC6A52CF884D90061DD1D /* Rubik.ttf */, AF7697DD293C537900FF1C50 /* RecMonoVariable.ttf */, ); path = Fonts; sourceTree = ""; }; + 23418F5D2BB2C39700398EE2 /* TimeTrend */ = { + isa = PBXGroup; + children = ( + 2319F8B8276872F200644EB3 /* TimeTrend.swift */, + 23F445802B46552D002B7B81 /* TimeTrendDetailView.swift */, + 23AAF99529B0219200C5C1FF /* TimeTrendViewController.swift */, + ); + path = TimeTrend; + sourceTree = ""; + }; 235D6A6429C2BEFA002D90D8 /* Helper */ = { isa = PBXGroup; children = ( - 235D6A6629C2C060002D90D8 /* Models.swift */, 233FE7B5274F109600C6F1DF /* Helper.swift */, - 235D6A6D29C2C34E002D90D8 /* ManualInputTextField.swift */, 2377418729A9A05A00A4F917 /* HelperUI.swift */, 231B2E2D29BDECF9007E5DC2 /* HelperButtons.swift */, 235D6A6829C2C0D3002D90D8 /* HelperShare.swift */, + 235D6A6D29C2C34E002D90D8 /* ManualInputTextField.swift */, ); path = Helper; sourceTree = ""; }; + 239AC69D2CF85EBC0061DD1D /* TimeListViewInner */ = { + isa = PBXGroup; + children = ( + 239AC69F2CF85EE80061DD1D /* TimeCardView.swift */, + AF649FB329B7ED320099A839 /* TimeListViewController.swift */, + ); + path = TimeListViewInner; + sourceTree = ""; + }; 23A73F5C276B0AA100153748 /* Timer */ = { isa = PBXGroup; children = ( - 23CA8A0229AAF894006676BF /* StopwatchManager */, 23DE3BDC274DDBE200254D29 /* TimerView.swift */, 23B7AC3929BAB2E300B5C9B4 /* TimerTools.swift */, 2326435F28C4927600F6322E /* TimerHeader.swift */, @@ -269,7 +307,7 @@ 7D3D3153278D3CA400BBDF55 /* TimerTouchView.swift */, AF6EAC1F29ADDA1C00171F73 /* TimerMenu.swift */, 7D7E7C62279A69F0004C9A4E /* ScrambleView.swift */, - 2326436128C4929700F6322E /* PrevSolvesDisplay.swift */, + 239AC6922CF853340061DD1D /* Confetti.swift */, ); path = Timer; sourceTree = ""; @@ -286,11 +324,11 @@ 23A73F5E276B0AED00153748 /* TimeList */ = { isa = PBXGroup; children = ( + 239AC69D2CF85EBC0061DD1D /* TimeListViewInner */, 23D2FC4C274E12DE00D7071A /* TimeListView.swift */, 7D6159F82751B53E00DC9A4C /* TimeCard.swift */, 23EB8A1D2779B6D3000F6663 /* TimeBar.swift */, 23CB6D0D29AC3DFB00C60EF8 /* TimeDetailView.swift */, - AF649FB329B7ED320099A839 /* TimeListViewInner.swift */, ); path = TimeList; sourceTree = ""; @@ -302,6 +340,7 @@ 23CE093327A76D610013E227 /* SessionCard.swift */, 23AB648329385D310049993B /* NewSessionRootView.swift */, 23D7722329B1E3540044AFBA /* NewSessionView.swift */, + 231F37C42B81FD290016A137 /* ImportExport.swift */, AF8874BF29AAC8AD001E49D0 /* Tools */, ); path = Sessions; @@ -328,8 +367,9 @@ 23B45E7129A855F400BDF49E /* Frameworks */ = { isa = PBXGroup; children = ( + 23418F582BB2A8C900398EE2 /* StoreKit.framework */, + E5B1FCEC2BAD892A007C1688 /* TNoodle.xcframework */, 23329E8F29C31CA60017AE99 /* libz.1.1.3.tbd */, - 23B45E6F29A8553000BDF49E /* TNoodleLibNative.xcframework */, ); path = Frameworks; sourceTree = ""; @@ -347,13 +387,23 @@ AF81757629B07712004838B9 /* FontManager.swift */, 238FB41F29B8175C008EC1D4 /* GradientManager.swift */, 235D6A6F29C2CE09002D90D8 /* CloudkitStatusManager.swift */, + E5A5595F2B83456F004093B3 /* ExportViewModel.swift */, ); path = StopwatchManager; sourceTree = ""; }; + 23DC2F8829C7EAD00040A5F5 /* Onboarding */ = { + isa = PBXGroup; + children = ( + 235CDA0227A786D800F48C89 /* Updates.swift */, + ); + path = Onboarding; + sourceTree = ""; + }; 23DE3BCE274DDBE200254D29 = { isa = PBXGroup; children = ( + 236850DC2C54D5CA00548BEA /* Localizable.xcstrings */, 23B45E7129A855F400BDF49E /* Frameworks */, 23DE3BD9274DDBE200254D29 /* CubeTime */, 23DE3BEF274DDBE400254D29 /* CubeTimeTests */, @@ -376,21 +426,22 @@ 23DE3BD9274DDBE200254D29 /* CubeTime */ = { isa = PBXGroup; children = ( - 73B7136529BA3FF00064E02F /* CubeTime.entitlements */, 23DE3BDE274DDBE300254D29 /* Assets.xcassets */, - 2354882227642E2800FE8D7A /* Info.plist */, - 23AB6488293867CB0049993B /* Ext */, 23DE3BDA274DDBE200254D29 /* CubeTimeApp.swift */, - 23DE3BE3274DDBE300254D29 /* Persistence.swift */, 7D2B805D279422BD0006AEDD /* AppDelegate.swift */, - 239C7E6F2781511300943AF2 /* Onboarding.swift */, - 235CDA0227A786D800F48C89 /* Updates.swift */, - 235D6A6429C2BEFA002D90D8 /* Helper */, + 23DE3BE3274DDBE300254D29 /* Persistence.swift */, + 235D6A6629C2C060002D90D8 /* Models.swift */, + 7D7D205E274DE13A0016D804 /* txmerdata.xcdatamodeld */, + 73B7136529BA3FF00064E02F /* CubeTime.entitlements */, 23D01FE227A4AA5D0019FCE5 /* Splash.storyboard */, 239560A529C3056E00735035 /* SplashPad.storyboard */, 23B45E6E29A8552000BDF49E /* 3rdparty-Bridging-Header.h */, - 7D7D205E274DE13A0016D804 /* txmerdata.xcdatamodeld */, + 2354882227642E2800FE8D7A /* Info.plist */, + 23AB6488293867CB0049993B /* Ext */, + 23DC2F8829C7EAD00040A5F5 /* Onboarding */, + 235D6A6429C2BEFA002D90D8 /* Helper */, 23A73F5D276B0AC800153748 /* Tabs */, + 23CA8A0229AAF894006676BF /* StopwatchManager */, 23A73F5C276B0AA100153748 /* Timer */, 23A73F5E276B0AED00153748 /* TimeList */, 23E81E75275D917000B741E3 /* Stats */, @@ -439,13 +490,14 @@ 23E81E6D275D8DB500B741E3 /* Settings */ = { isa = PBXGroup; children = ( + AF8B305A29B5C7010023D23B /* SettingsManager.swift */, 7D7A9EC5274F396E00C2E125 /* SettingsView.swift */, 237B354F2761995F00CC39BF /* SettingsCard.swift */, 23E81E6B275D8DAE00B741E3 /* GeneralSettings.swift */, 23E81E6E275D8DCA00B741E3 /* AppearanceSettings.swift */, 23E81E72275D8DEA00B741E3 /* AboutSettings.swift */, + 23418F5A2BB2ADE500398EE2 /* CTDonations.storekit */, 7D7E2011275DD52600ADF01D /* Licenses.swift */, - AF8B305A29B5C7010023D23B /* SettingsManager.swift */, AFA444A129C4290400795434 /* SettingsWidgets.swift */, ); path = Settings; @@ -457,8 +509,7 @@ 7D7A9EBF274F38CD00C2E125 /* StatsView.swift */, 23C85EAB29388C9C00C7C3EC /* StatsSubviews.swift */, 23AAF99729B072F900C5C1FF /* StatsDetailView.swift */, - 2319F8B8276872F200644EB3 /* TimeTrend.swift */, - 23AAF99529B0219200C5C1FF /* ScrollableLineChart.swift */, + 23418F5D2BB2C39700398EE2 /* TimeTrend */, 23E647C52775B2AC00561780 /* TimeDistribution.swift */, 23CA845F2783EBA1003901FB /* ReachedTargets.swift */, 2397131E2791032300268DFA /* AveragePhases.swift */, @@ -489,7 +540,7 @@ 23DE3BD3274DDBE200254D29 /* Sources */, 23DE3BD4274DDBE200254D29 /* Frameworks */, 23DE3BD5274DDBE200254D29 /* Resources */, - 23B45E5929A83C1100BDF49E /* Embed Libraries */, + 235CD3042BB37C2200F6095A /* Embed Frameworks */, ); buildRules = ( ); @@ -499,6 +550,8 @@ packageProductDependencies = ( 23A1CDBC29399FE000F0895D /* SwiftfulLoadingIndicators */, AF7697DB293C1EFA00FF1C50 /* SVGView */, + E5B3B1522BA5B68600179BDC /* LZString */, + E5A742D32BA8184100321D24 /* ZIPFoundation */, ); productName = txmer; productReference = 23DE3BD7274DDBE200254D29 /* CubeTime.app */; @@ -548,7 +601,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1310; - LastUpgradeCheck = 1310; + LastUpgradeCheck = 1540; TargetAttributes = { 23DE3BD6274DDBE200254D29 = { CreatedOnToolsVersion = 13.1; @@ -570,11 +623,14 @@ knownRegions = ( en, Base, + "zh-Hans", ); mainGroup = 23DE3BCE274DDBE200254D29; packageReferences = ( 23A1CDBB29399FE000F0895D /* XCRemoteSwiftPackageReference "SwiftfulLoadingIndicators" */, AF7697DA293C1EFA00FF1C50 /* XCRemoteSwiftPackageReference "SVGView" */, + E5B3B1512BA5B68600179BDC /* XCRemoteSwiftPackageReference "lzstring-swift" */, + E5A742D22BA8184100321D24 /* XCRemoteSwiftPackageReference "ZIPFoundation" */, ); productRefGroup = 23DE3BD8274DDBE200254D29 /* Products */; projectDirPath = ""; @@ -594,8 +650,10 @@ files = ( 23DE3BE2274DDBE300254D29 /* Preview Assets.xcassets in Resources */, 23DE3BDF274DDBE300254D29 /* Assets.xcassets in Resources */, + 236850DD2C54D5CA00548BEA /* Localizable.xcstrings in Resources */, 239560A629C3056E00735035 /* SplashPad.storyboard in Resources */, 23AB648C293867EA0049993B /* 12sec-audio.wav in Resources */, + 239AC6A72CF884D90061DD1D /* Rubik.ttf in Resources */, 23AB648D293867EA0049993B /* 8sec-audio.wav in Resources */, AF7697DE293C5A9C00FF1C50 /* RecMonoVariable.ttf in Resources */, 23D01FE327A4AA5D0019FCE5 /* Splash.storyboard in Resources */, @@ -607,6 +665,7 @@ buildActionMask = 2147483647; files = ( AF7697DF293C5A9C00FF1C50 /* RecMonoVariable.ttf in Resources */, + 239AC6A82CF884D90061DD1D /* Rubik.ttf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -615,6 +674,7 @@ buildActionMask = 2147483647; files = ( AF7697E0293C5A9D00FF1C50 /* RecMonoVariable.ttf in Resources */, + 239AC6A62CF884D90061DD1D /* Rubik.ttf in Resources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -630,8 +690,10 @@ 231827E729B5E86B00A40C78 /* FloatingPanel.swift in Sources */, 23CA8A0829AAF96C006676BF /* StopwatchManager+Stats.swift in Sources */, 23DE3BE4274DDBE300254D29 /* Persistence.swift in Sources */, - 23AAF99629B0219200C5C1FF /* ScrollableLineChart.swift in Sources */, + 23AAF99629B0219200C5C1FF /* TimeTrendViewController.swift in Sources */, 7D3D3154278D3CA400BBDF55 /* TimerTouchView.swift in Sources */, + 231F37C52B81FD290016A137 /* ImportExport.swift in Sources */, + E5A559602B83456F004093B3 /* ExportViewModel.swift in Sources */, 7D2B805E279422BD0006AEDD /* AppDelegate.swift in Sources */, 238FB42029B8175C008EC1D4 /* GradientManager.swift in Sources */, AF6EAC2029ADDA1C00171F73 /* TimerMenu.swift in Sources */, @@ -642,6 +704,7 @@ 23EB8A1E2779B6D3000F6663 /* TimeBar.swift in Sources */, AF8874C129AAC8C0001E49D0 /* ToolsList.swift in Sources */, 235D6A6929C2C0D3002D90D8 /* HelperShare.swift in Sources */, + 23F445812B46552D002B7B81 /* TimeTrendDetailView.swift in Sources */, AF8B305B29B5C7010023D23B /* SettingsManager.swift in Sources */, AF8A157429AF15B500F6818E /* ScrambleOnlyTool.swift in Sources */, 2359689327A367A0003ED9E1 /* StopwatchManager.swift in Sources */, @@ -658,11 +721,12 @@ 23B7AC3A29BAB2E300B5C9B4 /* TimerTools.swift in Sources */, 7D7E2012275DD52600ADF01D /* Licenses.swift in Sources */, 237B35502761995F00CC39BF /* SettingsCard.swift in Sources */, - AF649FB429B7ED320099A839 /* TimeListViewInner.swift in Sources */, + AF649FB429B7ED320099A839 /* TimeListViewController.swift in Sources */, 7D78FF8E276087AF008BB82E /* TabBar.swift in Sources */, 23B7AC3C29BB049F00B5C9B4 /* CalculatorTool.swift in Sources */, 23CB6D0E29AC3DFB00C60EF8 /* TimeDetailView.swift in Sources */, 231B2E2E29BDECF9007E5DC2 /* HelperButtons.swift in Sources */, + 239AC69A2CF853340061DD1D /* Confetti.swift in Sources */, 23DE3BDB274DDBE200254D29 /* CubeTimeApp.swift in Sources */, 23D7722429B1E3540044AFBA /* NewSessionView.swift in Sources */, 7D7DDF2327955AFF00362DE9 /* PenButton.swift in Sources */, @@ -679,13 +743,12 @@ 2326436028C4927600F6322E /* TimerHeader.swift in Sources */, 23F4113829ADDA77002C4E23 /* ToolsViewModel.swift in Sources */, 23CA84602783EBA1003901FB /* ReachedTargets.swift in Sources */, - 2326436228C4929700F6322E /* PrevSolvesDisplay.swift in Sources */, - 239C7E702781511300943AF2 /* Onboarding.swift in Sources */, 23AB648429385D310049993B /* NewSessionRootView.swift in Sources */, 23CE093427A76D610013E227 /* SessionCard.swift in Sources */, 23C85EAC29388C9C00C7C3EC /* StatsSubviews.swift in Sources */, 235D6A6729C2C060002D90D8 /* Models.swift in Sources */, 2377418829A9A05A00A4F917 /* HelperUI.swift in Sources */, + 239AC6A02CF85EF50061DD1D /* TimeCardView.swift in Sources */, 23E5643129AB4DBB00C910D6 /* getBestAverage.c++ in Sources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -728,6 +791,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -761,6 +825,7 @@ DEBUG_INFORMATION_FORMAT = dwarf; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -787,6 +852,7 @@ isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++17"; @@ -820,6 +886,7 @@ DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; GCC_C_LANGUAGE_STANDARD = gnu11; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -845,7 +912,7 @@ CODE_SIGN_ENTITLEMENTS = CubeTime/CubeTime.entitlements; CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"CubeTime/Preview Content\""; DEVELOPMENT_TEAM = 52VS5QG4YD; ENABLE_BITCODE = NO; @@ -873,7 +940,7 @@ ); LIBRARY_SEARCH_PATHS = ""; LLVM_LTO = YES_THIN; - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 3.0.0; ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.cubetime.cubetime; @@ -894,7 +961,7 @@ ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = accent; CODE_SIGN_ENTITLEMENTS = CubeTime/CubeTime.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEVELOPMENT_ASSET_PATHS = "\"CubeTime/Preview Content\""; DEVELOPMENT_TEAM = 52VS5QG4YD; ENABLE_BITCODE = NO; @@ -922,7 +989,7 @@ ); LIBRARY_SEARCH_PATHS = ""; LLVM_LTO = YES_THIN; - MARKETING_VERSION = 2.0; + MARKETING_VERSION = 3.0.0; ONLY_ACTIVE_ARCH = YES; OTHER_LDFLAGS = ""; PRODUCT_BUNDLE_IDENTIFIER = com.cubetime.cubetime; @@ -1089,6 +1156,22 @@ minimumVersion = 1.0.0; }; }; + E5A742D22BA8184100321D24 /* XCRemoteSwiftPackageReference "ZIPFoundation" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/weichsel/ZIPFoundation.git"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 0.9.18; + }; + }; + E5B3B1512BA5B68600179BDC /* XCRemoteSwiftPackageReference "lzstring-swift" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/Ibrahimhass/lzstring-swift"; + requirement = { + branch = main; + kind = branch; + }; + }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ @@ -1102,6 +1185,16 @@ package = AF7697DA293C1EFA00FF1C50 /* XCRemoteSwiftPackageReference "SVGView" */; productName = SVGView; }; + E5A742D32BA8184100321D24 /* ZIPFoundation */ = { + isa = XCSwiftPackageProductDependency; + package = E5A742D22BA8184100321D24 /* XCRemoteSwiftPackageReference "ZIPFoundation" */; + productName = ZIPFoundation; + }; + E5B3B1522BA5B68600179BDC /* LZString */ = { + isa = XCSwiftPackageProductDependency; + package = E5B3B1512BA5B68600179BDC /* XCRemoteSwiftPackageReference "lzstring-swift" */; + productName = LZString; + }; /* End XCSwiftPackageProductDependency section */ /* Begin XCVersionGroup section */ diff --git a/CubeTime.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/CubeTime.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index b0831d3c..56f2ab88 100644 --- a/CubeTime.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/CubeTime.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,12 +1,22 @@ { + "originHash" : "bd0066f47568b67feb1deaee757eb87a190ba2aad49ef359417423e2412468a4", "pins" : [ + { + "identity" : "lzstring-swift", + "kind" : "remoteSourceControl", + "location" : "https://github.com/Ibrahimhass/lzstring-swift", + "state" : { + "branch" : "main", + "revision" : "4b6d24b0e3eff2d8ca37bfd31170cbecd77fa09a" + } + }, { "identity" : "svgview", "kind" : "remoteSourceControl", "location" : "https://github.com/exyte/SVGView", "state" : { - "revision" : "18bf73efc2f2dede5dfa73f517ddf4e054061294", - "version" : "1.0.4" + "revision" : "6465962facdd25cb96eaebc35603afa2f15d2c0d", + "version" : "1.0.6" } }, { @@ -17,7 +27,16 @@ "revision" : "85858c0246dcd781228301f9928519f75ce89758", "version" : "0.0.4" } + }, + { + "identity" : "zipfoundation", + "kind" : "remoteSourceControl", + "location" : "https://github.com/weichsel/ZIPFoundation.git", + "state" : { + "revision" : "02b6abe5f6eef7e3cbd5f247c5cc24e246efcfe0", + "version" : "0.9.19" + } } ], - "version" : 2 + "version" : 3 } diff --git a/CubeTime.xcodeproj/xcshareddata/xcschemes/CubeTime.xcscheme b/CubeTime.xcodeproj/xcshareddata/xcschemes/CubeTime.xcscheme index f419f083..952742c6 100644 --- a/CubeTime.xcodeproj/xcshareddata/xcschemes/CubeTime.xcscheme +++ b/CubeTime.xcodeproj/xcshareddata/xcschemes/CubeTime.xcscheme @@ -1,6 +1,6 @@ - - + + + LastUpgradeVersion = "1540" + version = "1.7"> + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> - - + shouldUseLaunchSchemeArgsEnv = "YES" + shouldAutocreateTestPlan = "YES"> CloudKit - com.apple.developer.ubiquity-kvstore-identifier - $(TeamIdentifierPrefix)$(CFBundleIdentifier) + com.apple.developer.ubiquity-kvstore-identifier + $(TeamIdentifierPrefix)$(CFBundleIdentifier) diff --git a/CubeTime/CubeTimeApp.swift b/CubeTime/CubeTimeApp.swift index 0d3def55..8847c6bc 100644 --- a/CubeTime/CubeTimeApp.swift +++ b/CubeTime/CubeTimeApp.swift @@ -49,11 +49,13 @@ struct CubeTime: App { let newVersion = (Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String) let currentVersion = UserDefaults.standard.string(forKey: "currentVersion") - self._showUpdates = State(initialValue: currentVersion != newVersion && !showOnboarding) +// self._showUpdates = State(initialValue: currentVersion != newVersion && !showOnboarding) + self._showUpdates = State(initialValue: currentVersion != newVersion) UserDefaults.standard.set(newVersion, forKey: "currentVersion") setupNavbarAppearance() +// setupNavTitleAppearance() for future possibly, but doesn't look great .. setupColourScheme(overrideSystemAppearance ? (darkMode ? .dark : .light) : nil) setupAudioSession(with: inspectionAlertFollowsSilent ? .ambient : .playback) @@ -83,10 +85,11 @@ struct CubeTime: App { .if(dynamicTypeSize != DynamicTypeSize.large) { view in view .alert(isPresented: $showUpdates) { - Alert(title: Text("DynamicType Detected"), message: Text("CubeTime only supports standard DyanmicType sizes. Accessibility DynamicType modes are currently not supported, so layouts may not be rendered correctly."), dismissButton: .default(Text("Got it!"))) + Alert(title: Text("DynamicType Detected"), message: Text("CubeTime only supports standard DynamicType sizes. Accessibility DynamicType modes are currently not supported, so layouts may not be rendered correctly."), dismissButton: .default(Text("Got it!"))) } } .environment(\.managedObjectContext, moc) + // .environment(\.font, Font(FontManager.fontFor(size: 17, weight: 400, font: .recursive))) .environmentObject(stopwatchManager) .environmentObject(fontManager) .environmentObject(tabRouter) @@ -147,6 +150,7 @@ struct MainView: View { switch tabRouter.currentTab { case .timer: TimerView() +// TimeTrendDetail() .onAppear { UIApplication.shared.isIdleTimerDisabled = true } .environmentObject(stopwatchManager.timerController) .environmentObject(stopwatchManager.scrambleController) diff --git a/CubeTime/Ext/Fonts/Rubik.ttf b/CubeTime/Ext/Fonts/Rubik.ttf new file mode 100644 index 00000000..bbab349a Binary files /dev/null and b/CubeTime/Ext/Fonts/Rubik.ttf differ diff --git a/CubeTime/Helper/Helper.swift b/CubeTime/Helper/Helper.swift index 9192e63a..5b755a33 100644 --- a/CubeTime/Helper/Helper.swift +++ b/CubeTime/Helper/Helper.swift @@ -4,6 +4,7 @@ import UIKit import Combine import CoreData import AVFoundation +import ZIPFoundation // MARK: - GLOBAL LETS let sessionTypeForID: [SessionType: Session.Type] = [ @@ -11,38 +12,22 @@ let sessionTypeForID: [SessionType: Session.Type] = [ .compsim: CompSimSession.self ] -let sessionDescriptions: [SessionType: String] = [ - .multiphase: "A multiphase session gives you the ability to breakdown your solves into sections, such as memo/exec stages in blindfolded solving or stages in 3x3 solves.\n\nTap anywhere on the timer during a solve to record a phase lap. You can access your breakdown statistics in each time card and view overall statistics in the Stats view.", - .playground: "A playground session allows you to quickly change the scramble type within a session without having to specify a scramble type for the whole session.", - .compsim: "A comp sim (Competition Simulation) session mimics a competition scenario better by recording a non-rolling session. Your solves will be split up into averages of 5 that can be accessed in your times and statistics view.\n\nStart by choosing a target to reach." -] - - -let iconNamesForType: [SessionType: String] = [ - .standard: "timer.square", - .algtrainer: "command.square", - .multiphase: "square.stack", - .playground: "square.on.square", - .compsim: "globe.asia.australia" -] - - -let puzzleTypes: [PuzzleType] = [ - PuzzleType(name: "2x2"), - PuzzleType(name: "3x3"), - PuzzleType(name: "4x4"), - PuzzleType(name: "5x5"), - PuzzleType(name: "6x6"), - PuzzleType(name: "7x7"), - PuzzleType(name: "Square-1"), - PuzzleType(name: "Megaminx"), - PuzzleType(name: "Pyraminx"), - PuzzleType(name: "Clock"), - PuzzleType(name: "Skewb"), - PuzzleType(name: "3x3 OH"), - PuzzleType(name: "3x3 BLD"), - PuzzleType(name: "4x4 BLD"), - PuzzleType(name: "5x5 BLD"), +let PUZZLE_TYPES: [PuzzleType] = [ + PuzzleType(name: String(localized: "2x2"), cstimerName: "222so", imageName: "puzzle-2x2"), + PuzzleType(name: String(localized: "3x3"), cstimerName: "333", imageName: "puzzle-3x3"), + PuzzleType(name: String(localized: "4x4"), cstimerName: "444wca", imageName: "puzzle-4x4"), + PuzzleType(name: String(localized: "5x5"), cstimerName: "555wca", imageName: "puzzle-5x5"), + PuzzleType(name: String(localized: "6x6"), cstimerName: "666wca", imageName: "puzzle-6x6"), + PuzzleType(name: String(localized: "7x7"), cstimerName: "777wca", imageName: "puzzle-7x7"), + PuzzleType(name: String(localized: "Square-1"), cstimerName: "sqrs", imageName: "puzzle-square-1"), + PuzzleType(name: String(localized: "Megaminx"), cstimerName: "mgmp", imageName: "puzzle-megaminx"), + PuzzleType(name: String(localized: "Pyraminx"), cstimerName: "pyrso", imageName: "puzzle-pyraminx"), + PuzzleType(name: String(localized: "Clock"), cstimerName: "clkwca", imageName: "puzzle-clock"), + PuzzleType(name: String(localized: "Skewb"), cstimerName: "skbso", imageName: "puzzle-skewb"), + PuzzleType(name: String(localized: "3x3 OH"), cstimerName: "333oh", imageName: "puzzle-3x3-oh"), + PuzzleType(name: String(localized: "3x3 BLD"), cstimerName: "333bld", imageName: "puzzle-3x3-bld"), + PuzzleType(name: String(localized: "4x4 BLD"), cstimerName: "444bld", imageName: "puzzle-4x4-bld"), + PuzzleType(name: String(localized: "5x5 BLD"), cstimerName: "555bld", imageName: "puzzle-5x5-bld"), ] @@ -63,6 +48,13 @@ extension Array where Element: Equatable { } } +extension Collection { + /// Returns the element at the specified index if it is within bounds, otherwise nil. + subscript (safe index: Index) -> Element? { + return indices.contains(index) ? self[index] : nil + } +} + extension RandomAccessCollection where Element: Comparable { func insertionIndex(of value: Element) -> Index { var slice : SubSequence = self[...] @@ -95,6 +87,13 @@ extension RandomAccessCollection where Element: Solve { } } +extension Archive { + func addEntry(with: String, data: Data) throws { + try addEntry(with: with, type: .file, uncompressedSize: Int64(data.count), provider: { (position: Int64, size) -> Data in + return data.subdata(in: Int(position).. String { - if secs < 60 { - return String(format: "%.\(dp)f", secs); #warning("TODO: set DP") - } else { - var secs = round(secs * 100) / 100.0 - let mins: Int = Int((secs / 60).rounded(.down)) - secs = secs.truncatingRemainder(dividingBy: 60) - - return String(format: "%d:%0\(dp + 3).\(dp)f", mins, secs) +// source https://stackoverflow.com/a/62687023/17569741 +extension UIFont { + static func preferredFont(for style: TextStyle, weight: Weight, italic: Bool = false) -> UIFont { + + // Get the style's default pointSize + let traits = UITraitCollection(preferredContentSizeCategory: .large) + let desc = UIFontDescriptor.preferredFontDescriptor(withTextStyle: style, compatibleWith: traits) + + // Get the font at the default size and preferred weight + var font = UIFont.systemFont(ofSize: desc.pointSize, weight: weight) + if italic == true { + font = font.with([.traitItalic]) + } + + // Setup the font to be auto-scalable + let metrics = UIFontMetrics(forTextStyle: style) + return metrics.scaledFont(for: font) + } + + private func with(_ traits: UIFontDescriptor.SymbolicTraits...) -> UIFont { + guard let descriptor = fontDescriptor.withSymbolicTraits(UIFontDescriptor.SymbolicTraits(traits).union(fontDescriptor.symbolicTraits)) else { + return self + } + return UIFont(descriptor: descriptor, size: 0) } } -func formatSolveTime(secs: Double, penType: Penalty? = Penalty.none) -> String { - if penType == Penalty.dnf { + +// MARK: - FUNCS +// all formatting funcs +func formatSolveTime(secs: Double, dp: Int = SettingsManager.standard.displayDP, penalty: Penalty? = Penalty.none) -> String { + if penalty == .dnf { return "DNF" } - let dp = SettingsManager.standard.displayDP - let secsfmt = penType == .plustwo ? ".\(dp)f+" : ".\(dp)f" + let ratio = pow(10.0, Double(dp)) + let formatString = penalty == .plustwo ? ".\(dp)f+" : ".\(dp)f" if secs < 60 { - return String(format: "%\(secsfmt)", secs); #warning("TODO: set DP") + return String(format: "%\(formatString)", floor(secs * ratio) / ratio) } else { - var secs = round(secs * 100) / 100.0 - let mins: Int = Int((secs / 60).rounded(.down)) - secs = secs.truncatingRemainder(dividingBy: 60) + let mins = Int(secs / 60) + let secs = (floor(secs * ratio) / ratio) - Double(mins * 60) - return String(format: "%d:%0\(dp + 3)\(secsfmt)", mins, secs) + let offset = dp == 0 ? 2 : 3 + return String(format: "%d:%0\(dp + offset)\(formatString)", mins, secs) } } @@ -212,10 +227,23 @@ func formatLegendTime(secs: Double, dp: Int) -> String { } } +func jsonSerialize(obj: Any) throws -> Data { + var error: NSError? + let exportStream = OutputStream(toMemory: ()) + exportStream.open() + JSONSerialization.writeJSONObject(obj, to: exportStream, error: &error) + exportStream.close() + if let error { + throw error + } + return (exportStream.property(forKey: .dataWrittenToMemoryStreamKey) as! NSData) as Data + +} + // MARK: - MANUAL ENTRY FUNCS + VIEW MODIFIERS // formatting funcs @inline(__always) func filteredStrFromTime(_ time: Double?) -> String { - return time == nil ? "" : formatSolveTime(secs: time!, dp: 2) + return time == nil ? "" : formatSolveTime(secs: time!) } func timeFromStr(_ formattedTime: String) -> Double? { @@ -267,7 +295,7 @@ func getAvgOfSolveGroup(_ compsimsolvegroup: CompSimSolveGroup) -> CalculatedAve let trim = 1 - guard let solves = compsimsolvegroup.solves!.allObjects as? [Solve] else {return nil} + guard let solves = compsimsolvegroup.solves?.allObjects as? [Solve] else {return nil} if solves.count < 5 { return nil @@ -277,7 +305,7 @@ func getAvgOfSolveGroup(_ compsimsolvegroup: CompSimSolveGroup) -> CalculatedAve let trimmedSolves: [Solve] = sorted.prefix(trim) + sorted.suffix(trim) return CalculatedAverage( - name: "Comp Sim", + name: "Compsim", average: sorted.dropFirst(trim).dropLast(trim) .reduce(0, { $0 + $1.timeIncPen }) / Double(3), accountedSolves: sorted, @@ -290,11 +318,19 @@ func getAvgOfSolveGroup(_ compsimsolvegroup: CompSimSolveGroup) -> CalculatedAve // MARK: - Override func offsetImage(image: UIImage, offsetX: CGFloat=0, offsetY: CGFloat=0) -> UIImage? { - UIGraphicsBeginImageContextWithOptions(CGSize(width: image.size.width + abs(offsetX), height: image.size.height + abs(offsetY)), false, 0) - image.draw(in: CGRect(x: offsetX, y: offsetY, width: image.size.width, height: image.size.height)) + let format: UIGraphicsImageRendererFormat = UIGraphicsImageRendererFormat.default() + format.opaque = false + format.scale = UIScreen.main.scale + + let renderer = UIGraphicsImageRenderer(size: CGSize(width: image.size.width + abs(offsetX), height: image.size.height + abs(offsetY)), format: format) + + let newImage = renderer.image { ctx in + image.draw(in: CGRect(x: offsetX, y: offsetY, width: image.size.width, height: image.size.height)) + } + - let newImage = UIGraphicsGetImageFromCurrentImageContext() - UIGraphicsEndImageContext() +// let newImage = UIGraphicsGetImageFromCurrentImageContext() +// UIGraphicsEndImageContext() return newImage } @@ -311,6 +347,21 @@ func setupNavbarAppearance() -> Void { // navBarAppearance.backgroundColor = UIColor(named: "indent") } +func setupNavTitleAppearance() -> Void { + let navBarAppearance = UINavigationBar.appearance() + + let variations = [2003265652: 550.0] + + let uiFontDesc = UIFontDescriptor(fontAttributes: [ + .name: "Rubik", + kCTFontVariationAttribute as UIFontDescriptor.AttributeName: variations + ]) + + let metrics = UIFontMetrics(forTextStyle: .largeTitle) + let font = metrics.scaledFont(for: UIFont(descriptor: uiFontDesc, size: 32)) + + navBarAppearance.largeTitleTextAttributes = [NSAttributedString.Key.font: font] +} #warning("todo: fix; doesn't seem to be working ios 15?") func setupColourScheme(_ mode: UIUserInterfaceStyle?) -> Void { @@ -338,3 +389,42 @@ func setupAudioSession(with category: AVAudioSession.Category = .playback) { #endif } } + + +// ignore... doesn't work with splash screen +/* +@IBDesignable +class SplashShadowView: UIImageView { + @IBInspectable var shadowColor: UIColor = UIColor(named: "accent3")! { + didSet { + self.updateView() + } + } + + @IBInspectable var shadowOpacity: Float = 0.5 { + didSet { + self.updateView() + } + } + + @IBInspectable var shadowOffset = CGSize(width: 0, height: 0) { + didSet { + self.updateView() + } + } + + @IBInspectable var shadowRadius: CGFloat = 12.0 { + didSet { + self.updateView() + } + } + + + func updateView() { + self.layer.shadowColor = shadowColor.cgColor + self.layer.shadowOpacity = shadowOpacity + self.layer.shadowOffset = shadowOffset + self.layer.shadowRadius = shadowRadius + } +} +*/ diff --git a/CubeTime/Helper/HelperButtons.swift b/CubeTime/Helper/HelperButtons.swift index 634e8f14..81e0134f 100644 --- a/CubeTime/Helper/HelperButtons.swift +++ b/CubeTime/Helper/HelperButtons.swift @@ -12,7 +12,7 @@ final class ShareButtonUIViewController: UIViewController { init(toShare: String, buttonText: String) { super.init(nibName: nil, bundle: nil) - self.hostingController = UIHostingController(rootView: CTButton(type: .coloured, size: .large, expandWidth: true, onTapRun: { [weak self] in + self.hostingController = UIHostingController(rootView: CTButton(type: .coloured(nil), size: .large, expandWidth: true, onTapRun: { [weak self] in guard let self = self else { return } let activityViewController = UIActivityViewController(activityItems: [toShare], applicationActivities: nil) activityViewController.isModalInPresentation = !UIDevice.deviceIsPad @@ -60,13 +60,13 @@ struct CTShareButton: UIViewControllerRepresentable { // MARK: - Copy Button struct CTCopyButton: View { let toCopy: String - let buttonText: String + let buttonText: LocalizedStringKey @Environment(\.dynamicTypeSize) private var dynamicTypeSize @State private var offsetValue: CGFloat = -25 var body: some View { - CTButton(type: .coloured, size: .large, expandWidth: true, onTapRun: { + CTButton(type: .coloured(nil), size: .large, expandWidth: true, onTapRun: { UIPasteboard.general.string = toCopy withAnimation(Animation.customSlowSpring.delay(0.25)) { @@ -105,11 +105,15 @@ struct CTCopyButton: View { // MARK: - Hierarchical Button enum CTButtonType { - case mono, coloured, halfcoloured, disabled, red, green + case mono + case coloured(Color?) + case halfcoloured(Color?) + case lightMono + case disabled } enum CTButtonSize { - case small, medium, large, ultraLarge + case bubble, small, medium, large, ultraLarge } struct CTButtonStyle: ButtonStyle { @@ -121,20 +125,8 @@ struct CTButtonStyle: ButtonStyle { } } -struct CTButton: View { - let type: CTButtonType - let size: CTButtonSize - - let outlined: Bool - let square: Bool - - let hasShadow: Bool - let hasBackground: Bool - - let expandWidth: Bool - - let onTapRun: () -> Void - @ViewBuilder let content: () -> V +struct CTButton: View { + let button: Button> init(type: CTButtonType, size: CTButtonSize, @@ -142,51 +134,49 @@ struct CTButton: View { square: Bool=false, hasShadow: Bool=true, hasBackground: Bool=true, + hasMaterial: Bool=true, + supportsDynamicResizing: Bool=true, expandWidth: Bool=false, onTapRun: @escaping () -> Void, - @ViewBuilder _ content: @escaping () -> V) { - self.type = type - self.size = size - - self.outlined = outlined - self.square = square - - self.hasShadow = hasShadow - self.hasBackground = hasBackground + @ViewBuilder _ content: @escaping () -> Base) { - self.expandWidth = expandWidth - - self.onTapRun = onTapRun - self.content = content - } - - var body: some View { - Button { - self.onTapRun() + self.button = Button { + DispatchQueue.main.async { + onTapRun() + } } label: { - CTButtonBase(type: self.type, - size: self.size, - outlined: self.outlined, - square: self.square, - hasShadow: self.hasShadow, - hasBackground: self.hasBackground, - expandWidth: expandWidth, - content: self.content) + CTBubble(type: type, + size: size, + outlined: outlined, + square: square, + hasShadow: hasShadow, + hasBackground: hasBackground, + hasMaterial: hasMaterial, + supportsDynamicResizing: supportsDynamicResizing, + expandWidth: expandWidth, + content: content) } - .buttonStyle(CTButtonStyle()) } + + var body: some View { self.button.buttonStyle(CTButtonStyle()) } } -struct CTButtonBase: View { + +#warning("todo: set image scale here instead of per button -> inconsistent!") +struct CTBubble: View { let content: V + let size: CTButtonSize + let colourBg: Color let colourFg: Color let colourShadow: Color - @ScaledMetric var frameHeight: CGFloat + @ScaledMetric var dynamicHeight: CGFloat = 0 + var staticHeight: CGFloat = 0 @Environment(\.dynamicTypeSize) private var dynamicTypeSize + @Environment(\.colorScheme) private var colourScheme let horizontalPadding: CGFloat let fontType: Font @@ -195,74 +185,111 @@ struct CTButtonBase: View { let hasShadow: Bool let hasBackground: Bool + let hasMaterial: Bool + + let supportsDynamicResizing: Bool let expandWidth: Bool + var cornerRadius: CGFloat = 6 + @State private var hovering: Bool = false init(type: CTButtonType, size: CTButtonSize, - outlined: Bool, - square: Bool, - hasShadow: Bool, - hasBackground: Bool, - expandWidth: Bool, + outlined: Bool=false, + square: Bool=false, + hasShadow: Bool=true, + hasBackground: Bool=true, + hasMaterial: Bool=true, + supportsDynamicResizing: Bool=true, + expandWidth: Bool=false, content: @escaping () -> V) { + switch (type) { - case .halfcoloured: + case .mono: self.colourBg = Color("overlay0") - self.colourFg = Color("accent") + self.colourFg = Color("dark") self.colourShadow = Color.black.opacity(0.07) - case .coloured: - self.colourBg = Color("accent").opacity(0.20) - self.colourFg = Color("accent") - self.colourShadow = Color("accent").opacity(0.08) - - case .mono: + case .halfcoloured(let colour): self.colourBg = Color("overlay0") - self.colourFg = Color("dark") + self.colourFg = colour ?? Color("accent") self.colourShadow = Color.black.opacity(0.07) + case .coloured(let colour): + self.colourBg = colour?.opacity(0.25) ?? Color("accent").opacity(0.22) + self.colourFg = colour ?? Color("accent") + self.colourShadow = colour?.opacity(0.16) ?? Color("accent").opacity(0.08) + case .disabled: self.colourBg = Color("grey").opacity(0.15) self.colourFg = Color("grey") self.colourShadow = Color.clear - case .red: - self.colourBg = Color("red").opacity(0.25) - self.colourFg = Color("red") - self.colourShadow = Color("red").opacity(0.16) - - case .green: - self.colourBg = Color("green").opacity(0.25) - self.colourFg = Color("green") - self.colourShadow = Color("green").opacity(0.16) - + case .lightMono: + self.colourBg = Color("indent0").opacity(0.28) + self.colourFg = Color("dark") + self.colourShadow = Color.clear } + - + self.supportsDynamicResizing = supportsDynamicResizing + + self.size = size switch (size) { + case .bubble: + if (supportsDynamicResizing) { + self._dynamicHeight = ScaledMetric(wrappedValue: 20, relativeTo: .caption2) + } else { + self.staticHeight = 20 + } + + self.cornerRadius = 4 + + self.horizontalPadding = 3 + self.fontType = Font.caption2.weight(.medium) + case .small: - self._frameHeight = ScaledMetric(wrappedValue: 28, relativeTo: .callout) + if (supportsDynamicResizing) { + self._dynamicHeight = ScaledMetric(wrappedValue: 28, relativeTo: .callout) + } else { + self.staticHeight = 28 + } + self.horizontalPadding = 8 self.fontType = Font.callout.weight(.medium) case .medium: - self._frameHeight = ScaledMetric(wrappedValue: 32, relativeTo: .body) + if (supportsDynamicResizing) { + self._dynamicHeight = ScaledMetric(wrappedValue: 32, relativeTo: .body) + } else { + self.staticHeight = 32 + } + self.horizontalPadding = 10 self.fontType = Font.body.weight(.medium) case .large: - self._frameHeight = ScaledMetric(wrappedValue: 35, relativeTo: .body) + if (supportsDynamicResizing) { + self._dynamicHeight = ScaledMetric(wrappedValue: 35, relativeTo: .body) + } else { + self.staticHeight = 35 + } + self.horizontalPadding = 12 self.fontType = Font.body.weight(.medium) case .ultraLarge: - self._frameHeight = ScaledMetric(wrappedValue: 48, relativeTo: .title3) + if (supportsDynamicResizing) { + self._dynamicHeight = ScaledMetric(wrappedValue: 48, relativeTo: .title3) + } else { + self.staticHeight = 48 + } + self.horizontalPadding = 16 self.fontType = Font.title3.weight(.semibold) @@ -272,6 +299,7 @@ struct CTButtonBase: View { self.hasShadow = hasShadow self.hasBackground = hasBackground + self.hasMaterial = hasMaterial self.expandWidth = expandWidth self.content = content() @@ -279,21 +307,26 @@ struct CTButtonBase: View { var body: some View { ZStack { - if (self.hasBackground) { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Material.thinMaterial) - .frame(width: square ? self.frameHeight : nil, height: self.frameHeight) + let frameHeight: CGFloat = (self.supportsDynamicResizing ? self.dynamicHeight : self.staticHeight) + + if (self.hasBackground && colourScheme == .light && self.hasMaterial) { + RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous) + .fill(Material.regularMaterial) + .frame(width: square ? frameHeight : nil, height: frameHeight) } - RoundedRectangle(cornerRadius: 6, style: .continuous) + RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous) .fill(self.hasBackground ? self.colourBg.opacity(0.92) : Color.white.opacity(0.001)) - .frame(width: square ? self.frameHeight : nil, height: self.frameHeight) - .shadow(color: self.hasShadow - ? self.colourShadow - : Color.clear, - radius: self.hasShadow ? 4 : 0, - x: 0, - y: self.hasShadow ? 1 : 0) + .frame(width: square ? frameHeight : nil, height: frameHeight) + .if(colourScheme == .light) { view in + view + .shadow(color: self.hasShadow + ? self.colourShadow + : Color.clear, + radius: self.hasShadow ? 4 : 0, + x: 0, + y: self.hasShadow ? 1 : 0) + } Group { if (dynamicTypeSize > .xLarge) { @@ -308,8 +341,11 @@ struct CTButtonBase: View { .foregroundColor(self.colourFg) .font(self.fontType) .padding(.horizontal, square ? 0 : self.horizontalPadding) + .if(self.size == .bubble) { view in + view.padding(.trailing, 1) + } } - .contentShape(RoundedRectangle(cornerRadius: 6, style: .continuous)) + .contentShape(RoundedRectangle(cornerRadius: self.cornerRadius, style: .continuous)) .fixedSize(horizontal: !expandWidth, vertical: true) } } @@ -318,17 +354,26 @@ struct CTButtonBase: View { // MARK: - Close Button struct CTCloseButton: View { let hasBackgroundShadow: Bool + let supportsDynamicResizing: Bool let onTapRun: () -> Void - init(hasBackgroundShadow: Bool=false, onTapRun: @escaping () -> Void) { + init(hasBackgroundShadow: Bool=false, supportsDynamicResizing: Bool=true, onTapRun: @escaping () -> Void) { self.hasBackgroundShadow = hasBackgroundShadow + self.supportsDynamicResizing = supportsDynamicResizing self.onTapRun = onTapRun } var body: some View { - CTButton(type: .mono, size: .medium, square: true, hasShadow: hasBackgroundShadow, hasBackground: hasBackgroundShadow, onTapRun: self.onTapRun) { - Image(systemName: "xmark") - .imageScale(.medium) + CTButton(type: .mono, size: .medium, square: true, hasShadow: hasBackgroundShadow, hasBackground: hasBackgroundShadow, supportsDynamicResizing: supportsDynamicResizing, onTapRun: self.onTapRun) { + if (supportsDynamicResizing) { + Image(systemName: "xmark") + .imageScale(.medium) + } else { + Image(systemName: "xmark") + .font(.system(size: 16, weight: .medium)) + } + + } } } @@ -373,14 +418,15 @@ struct ContextMenuButton: View { } var body: some View { - Button(role: title == "Delete Session" ? .destructive : nil, action: delayedAction) { + Button(role: systemImage == "trash" ? .destructive : nil, action: delayedAction) { HStack { Text(title) if image != nil { Image(uiImage: image!) } } - }.disabled(disableButton ?? false) + } + .disabled(disableButton ?? false) } private var image: UIImage? { @@ -419,22 +465,25 @@ struct SessionPickerMenu: View { let unpinnedidx = sessions.firstIndex(where: {!$0.pinned}) ?? sessions.count let pinned = sessions[0.. Void { - let str = "Generated by CubeTime.\n" + scramble + let str = "Generated by CubeTime\n" + scramble UIPasteboard.general.string = str } func getShareStr(solve: Solve) -> String { - var str = "Generated by CubeTime.\n" + var str = "Generated by CubeTime\n" let scramble = solve.scramble ?? "Retrieving scramble failed." let time = solve.timeText @@ -21,8 +21,9 @@ func getShareStr(solve: Solve) -> String { } func getShareStr(solves: Set) -> String { - var str = "Generated by CubeTime.\n" - for solve in solves { + var str = "Generated by CubeTime\n" + #warning("i force unwrapped") + for solve in solves.sorted(by: { $0.date! > $1.date! }) { let scramble = solve.scramble ?? "Retrieving scramble failed." let time = solve.timeText @@ -36,7 +37,7 @@ func getShareStr(solve: Solve, phases: Array?) -> String { let scramble = solve.scramble ?? "Retrieving scramble failed." let time = solve.timeText - var str = "Generated by CubeTime.\n\(time):\t\(scramble)" + var str = "Generated by CubeTime\n\(time):\t\(scramble)" if let phases = phases { @@ -58,24 +59,31 @@ func getShareStr(solve: Solve, phases: Array?) -> String { } func getShareStr(solves: CalculatedAverage) -> String { - var str = "Generated by CubeTime.\n" + var str = "Generated by CubeTime\n" str += "\(solves.name)" if let avg = solves.average { - str+=": \(formatSolveTime(secs: avg, penType: solves.totalPen))" + str+=": \(formatSolveTime(secs: avg, penalty: solves.totalPen))" } str += "\n\n" + + guard let accountedSolves = solves.accountedSolves else { + return str + "No times to show." + } + str += "Time List:" - for pair in zip(solves.accountedSolves!.indices, solves.accountedSolves!) { + let sortedAccountedSolves = accountedSolves.sorted(by: { $0.date ?? .distantPast > $1.date ?? .distantPast }) + + for pair in zip(sortedAccountedSolves.indices, sortedAccountedSolves) { str += "\n\(pair.0 + 1). " - let formattedTime = formatSolveTime(secs: pair.1.time, penType: Penalty(rawValue: pair.1.penalty)) + let formattedTime = formatSolveTime(secs: pair.1.time, penalty: Penalty(rawValue: pair.1.penalty)) if solves.trimmedSolves!.contains(pair.1) { str += "(" + formattedTime + ")" } else { str += formattedTime } - str += ":\t"+pair.1.scramble! + str += ":\t"+(pair.1.scramble ?? "Retrieving scramble failed.") } return str diff --git a/CubeTime/Helper/HelperUI.swift b/CubeTime/Helper/HelperUI.swift index 37346148..6cfe0f78 100644 --- a/CubeTime/Helper/HelperUI.swift +++ b/CubeTime/Helper/HelperUI.swift @@ -147,7 +147,7 @@ extension View { // MARK: - Other Views -struct ThemedDivider: View { +struct CTDivider: View { let isHorizontal: Bool init(isHorizontal: Bool = true) { @@ -156,7 +156,7 @@ struct ThemedDivider: View { var body: some View { Capsule() - .fill(Color("indent0")) + .fill(Color("dark").opacity(0.16)) .frame(width: isHorizontal ? nil : 1.15, height: isHorizontal ? 1.15 : nil) } } @@ -214,15 +214,76 @@ struct GlobalGeometryGetter: View { var body: some View { return GeometryReader { geometry in - self.makeView(geometry: geometry) + DispatchQueue.main.async { + self.rect = geometry.frame(in: .global) + } + + return Rectangle().fill(Color.clear) } } +} + - func makeView(geometry: GeometryProxy) -> some View { - DispatchQueue.main.async { - self.rect = geometry.frame(in: .global) +// MARK: - BUBBLE +struct CTSessionBubble: View { + let session: Session + + let hasMultiple: Bool + + + init(session: Session) { + self.session = session + + self.hasMultiple = [SessionType.compsim, SessionType.multiphase].contains(SessionType(rawValue: session.sessionType)) + } + + var body: some View { + CTBubble(type: .lightMono, size: .bubble) { + HStack(spacing: 4) { + session.icon(size: 14) + + Text(session.typeName) + } + } + + if (hasMultiple) { + CTPuzzleBubble(scrambleType: Int(session.scrambleType)) } + } +} - return Rectangle().fill(Color.clear) +struct CTPuzzleBubble: View { + @ScaledMetric private var iconSize: CGFloat = 11 + + let icon: Image + let text: String + + init(scrambleType: Int) { + icon = Image(PUZZLE_TYPES[scrambleType].imageName) + text = PUZZLE_TYPES[scrambleType].name + } + + init(scrambleType: PuzzleType) { + icon = Image(scrambleType.imageName) + text = scrambleType.name + } + + init(session: Session) { + icon = session.icon() as! Image + text = session.typeName + } + + var body: some View { + CTBubble(type: .lightMono, size: .bubble) { + HStack(spacing: 4) { + icon + .resizable() + .scaledToFit() + .frame(maxWidth: 14, maxHeight: 14) + .font(.system(size: iconSize, weight: .semibold, design: .default)) + + Text(text) + } + } } } diff --git a/CubeTime/Info.plist b/CubeTime/Info.plist index 69abe459..455cbc83 100644 --- a/CubeTime/Info.plist +++ b/CubeTime/Info.plist @@ -15,6 +15,7 @@ UIAppFonts + Rubik.ttf RecMonoVariable.ttf UIApplicationSceneManifest diff --git a/CubeTime/Helper/Models.swift b/CubeTime/Models.swift similarity index 50% rename from CubeTime/Helper/Models.swift rename to CubeTime/Models.swift index 8a8c7723..cf18f0c1 100644 --- a/CubeTime/Helper/Models.swift +++ b/CubeTime/Models.swift @@ -50,7 +50,7 @@ struct Average: Identifiable, Comparable { extension Solve { var timeText: String { get { - return formatSolveTime(secs: self.time, penType: Penalty(rawValue: self.penalty)!) + return formatSolveTime(secs: self.time, penalty: Penalty(rawValue: self.penalty)!) } } } @@ -59,35 +59,35 @@ extension Solve { extension Session { var typeName: String { get { - switch (SessionType(rawValue: sessionType)!) { - case .standard: - return "Standard Session" - case .algtrainer: - return "Alg trainer" - case .multiphase: - return "Multiphase" - case .playground: - return "Playground" - case .compsim: - return "Comp Sim" + if SessionType(rawValue: sessionType) == .standard { + return PUZZLE_TYPES[Int(scrambleType)].name + } else { + return SessionType(rawValue: sessionType)!.name() } } } + @ViewBuilder func icon(size: CGFloat = 24) -> some View { + if SessionType(rawValue: sessionType)! == .standard { + Image(PUZZLE_TYPES[Int(scrambleType)].imageName) + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: size, height: size) + } else { + SessionType(rawValue: sessionType)!.icon(size: size) + } + } + var shortcutName: String { get { - let scrname = puzzleTypes[Int(scrambleType)].name + let scrambleName = PUZZLE_TYPES[Int(scrambleType)].name switch (SessionType(rawValue: sessionType)!) { case .standard: - return scrname - case .algtrainer: - return self.typeName + " - " + scrname - case .multiphase: - return self.typeName + " - " + scrname - case .playground: + return scrambleName + case .multiphase, .algtrainer, .compsim: + return self.typeName + "[\(scrambleName)]" + case .playground, .timerOnly: return self.typeName - case .compsim: - return self.typeName + " - " + scrname } } } @@ -100,7 +100,7 @@ extension CompSimSolveGroup { } var avg: CalculatedAverage? { - return StopwatchManager.getCalculatedAverage(forSolves: self.solves!.allObjects as! [Solve], name: "Comp Sim Group", isCompsim: true) + return StopwatchManager.getCalculatedAverage(forSolves: self.solves!.allObjects as! [Solve], name: String(localized: "Compsim Group"), isCompsim: true) } } @@ -134,20 +134,90 @@ enum Penalty: Int16, Hashable { case none case plustwo case dnf + + func exportName() -> String? { + return switch self { + case .plustwo: + "PlusTwo" + case .dnf: + "DNF" + default: + nil + } + } } +#warning("NEVER CHANGE ORDER OR WILL BRICK") enum SessionType: Int16 { case standard case algtrainer case multiphase case playground case compsim + case timerOnly + + func name() -> String { + switch self { + case .standard: + "Standard" + case .algtrainer: + "Algorithm Trainer" + case .multiphase: + "Multiphase" + case .playground: + "Playground" + case .compsim: + "Compsim" + case .timerOnly: + "Timer Only" + } + } + + func description() -> String { + switch self { + case .standard: + return String(localized: "Standard session has one scramble type that cannot be changed later on.") + case .algtrainer: + return String(localized: "Algorithm trainer, to train a specific subset of algorithms.") + case .multiphase: + return String(localized: "A multiphase session gives you the ability to breakdown your solves into sections, such as memo/exec stages in blindfolded solving or stages in 3x3 solves.\n\nTap anywhere on the timer during a solve to record a phase lap. You can access your breakdown statistics in each time card and view overall statistics in the Stats view.") + case .playground: + return String(localized: "A playground session allows you to quickly change the scramble type within a session without having to specify a scramble type for the whole session.") + case .compsim: + return String(localized: "A compsim (Competition Simulation) session mimics a competition scenario better by recording a non-rolling session. Your solves will be split up into averages of 5 that can be accessed in your times and statistics view.\n\nStart by choosing a target to reach.") + case .timerOnly: + return String(localized: "Timer only sessions do not have a scramble type, but no scrambles are associated with solves.") + } + } + + func iconName() -> String { + switch self { + case .standard: + "timer.square" + case .algtrainer: + "command.square" + case .multiphase: + "square.stack" + case .playground: + "square.on.square" + case .compsim: + "globe.asia.australia" + case .timerOnly: + "timer" + } + } + + @ViewBuilder func icon(size: CGFloat = 24) -> some View { + Image(systemName: self.iconName()).font(.system(size: size * 0.88)) + } } // MARK: - Wrappers struct PuzzleType { let name: String + let cstimerName: String + let imageName: String // let puzzle: OrgWorldcubeassociationTnoodleScramblesPuzzleRegistry } @@ -163,13 +233,13 @@ struct AppZoom: RawRepresentable, Identifiable { ] static private let appZoomNames: [DynamicTypeSize: String] = [ - DynamicTypeSize.xSmall: "Extra Small", - DynamicTypeSize.small: "Small", - DynamicTypeSize.medium: "Medium", - DynamicTypeSize.large: "Large (Default)", - DynamicTypeSize.xLarge: "Extra Large", - DynamicTypeSize.xxLarge: "Extra Extra Large", - DynamicTypeSize.xxxLarge: "Extra Extra Extra Large", + DynamicTypeSize.xSmall: String(localized: "Extra Small"), + DynamicTypeSize.small: String(localized: "Small"), + DynamicTypeSize.medium: String(localized: "Medium"), + DynamicTypeSize.large: String(localized: "Large (Default)"), + DynamicTypeSize.xLarge: String(localized: "Extra Large"), + DynamicTypeSize.xxLarge: String(localized: "Extra Extra Large"), + DynamicTypeSize.xxxLarge: String(localized: "Extra Extra Extra Large"), ] typealias RawValue = Int @@ -198,5 +268,4 @@ struct SessionTypeIcon { var size: CGFloat = 26 var iconName: String = "" var padding: (leading: CGFloat, trailing: CGFloat) = (8, 4) - var weight: Font.Weight = .regular } diff --git a/CubeTime/Onboarding.swift b/CubeTime/Onboarding.swift deleted file mode 100644 index e80f2d77..00000000 --- a/CubeTime/Onboarding.swift +++ /dev/null @@ -1,479 +0,0 @@ -// -// Onboarding.swift -// CubeTime -// -// Created by Tim Xie on 2/01/22. -// - -import SwiftUI -import Foundation - -#if false -struct OnboardingView: View { - @AppStorage("onboarding") var showOnboarding: Bool = true - @Environment(\.dismiss) var dismiss - - @Binding var pageIndex: Int - - @Namespace var namespaceOB - - var body: some View { - ZStack { - TabView (selection: $pageIndex) { - PageOne(pageIndex: $pageIndex).tag(0) - PageTwo(pageIndex: $pageIndex).tag(1) - PageThree(pageIndex: $pageIndex).tag(2) - PageFour(pageIndex: $pageIndex).tag(3) - PageFive(pageIndex: $pageIndex).tag(4) - PageSix(pageIndex: $pageIndex).tag(5) - PageSeven(pageIndex: $pageIndex).tag(6) - } - .tabViewStyle(PageTabViewStyle(indexDisplayMode: .never)) - - VStack { - HStack { - Spacer() - - CloseButton { - dismiss() - showOnboarding = false - } - .padding([.top, .trailing]) - } - - Spacer() - } - - if pageIndex == 0 { - VStack { - Spacer() - - ZStack { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .frame(height: 55) - .matchedGeometryEffect(id: "button-background", in: namespaceOB) - .padding() - - Text("Take me on a short tour!") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.white) - } - .onTapGesture(perform: { - withAnimation(.spring()) { - pageIndex += 1 - } - }) - - } - } else if pageIndex == 6 { - VStack { - Spacer() - - ZStack { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .frame(height: 55) - .matchedGeometryEffect(id: "button-background", in: namespaceOB) - .padding() - - Text("Get started") - .font(.body) - .fontWeight(.medium) - .foregroundColor(.white) - } - .onTapGesture { - - dismiss() - showOnboarding = false - } - - } - - } else { - VStack { - Spacer() - - HStack { - HStack (spacing: 10) { - ForEach(0..<5, id: \.self) { dot in - Circle() - .fill(Color(uiColor: dot == pageIndex-1 ? (colourScheme == .light ? .black : .white) : (colourScheme == .light ? .systemGray : .systemGray3))) - .frame(width: 8, height: 8) - } - } - .padding(.leading) - - Spacer() - - ZStack { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .frame(width: 55, height: 55) - .matchedGeometryEffect(id: "button-background", in: namespaceOB) - - - Image(systemName: "arrow.forward") - .font(.system(size: 22, weight: .bold)) - .foregroundColor(.white) - } - .onTapGesture(perform: { - withAnimation(.spring()) { - pageIndex += 1 - } - }) - } - .padding() - } - } - } - } -} - -struct PageOne: View { - @Binding var pageIndex: Int - @Environment(\.sizeCategory) var sizeCategory - - var body: some View { - ZStack { - VStack(spacing: 0) { - Text("Welcome") - .scaledCustomFont(name: "SFPro", size: 34, sf: true, weight: Font.Weight.bold) - .padding(.top, smallPhone ? 50 : 75) - .multilineTextAlignment(.center) - .padding(.bottom, 0) - - HStack { - Text("to") - .scaledCustomFont(name: "SFPro", size: 34, sf: true, weight: Font.Weight.bold) - Text("CubeTime.") - .scaledCustomFont(name: "RecursiveSansLnrSt-Regular", size: 34, sf: false, weight: Font.Weight.regular) - .foregroundColor(Color("accent")) - } - .multilineTextAlignment(.center) - .padding(.bottom, 0) - - Image("PageOne - Icon") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 110) - .padding(.vertical, smallPhone ? 36 : 50) - .shadow(color: .black.opacity(0.32), radius: 12, x: 0, y: 2) - - - Text("This app brings your cubing\nutilities together - all in one place.") - .font(.title2).fontWeight(.medium) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Spacer() - } - } - } -} - -struct PageTwo: View { - @Binding var pageIndex: Int - var body: some View { - ZStack { - VStack(spacing: 0) { - Text("Timer") - .font(.title).fontWeight(.bold) - .padding(.top, smallPhone ? 48 : 60) - .multilineTextAlignment(.center) - - Text("The timer view.") - .font(.title2).fontWeight(.medium) - .if(smallPhone) { view in - view.padding(.bottom, 18) - } - .if(!smallPhone) { view in - view.padding(.vertical, 24) - } - - Image("1-timer") - .resizable() - .aspectRatio(contentMode: .fit) - .padding(.horizontal, 95) - - - Spacer() - } - } - } -} - -struct PageThree: View { - @Binding var pageIndex: Int - var body: some View { - ZStack { - VStack(spacing: 0) { - Text("Gestures") - .font(.title).fontWeight(.bold) - .padding(.top, smallPhone ? 48 : 60) - .multilineTextAlignment(.center) - - Text("We feature many intuitive gestures,\nlike the ones shown below:") - .font(.title2).fontWeight(.medium) - .multilineTextAlignment(.center) - .if(smallPhone) { view in - view.padding(.bottom, 18) - } - .if(!smallPhone) { view in - view.padding(.vertical, 24) - } - - Image("2-gesture") - .resizable() - .aspectRatio(contentMode: .fit) - .padding(.horizontal, 50) - - - Spacer() - } - } - } -} - -struct PageFour: View { - @Binding var pageIndex: Int - var body: some View { - ZStack { - VStack(spacing: 0) { - Text("Session Times") - .font(.title).fontWeight(.bold) - .padding(.top, smallPhone ? 48 : 60) - .multilineTextAlignment(.center) - - Text("All your solves will be shown\nin the solves tab for each session.") - .font(.title2).fontWeight(.medium) - .multilineTextAlignment(.center) - .if(smallPhone) { view in - view.padding(.bottom, 18) - } - .if(!smallPhone) { view in - view.padding(.vertical, 24) - } - - Image("3-timeslist") - .resizable() - .aspectRatio(contentMode: .fit) - .padding(.horizontal, 50) - - - Spacer() - } - } - } -} - -struct PageFive: View { - @Binding var pageIndex: Int - var body: some View { - ZStack { - VStack(spacing: 0) { - Text("Statistics") - .font(.title).fontWeight(.bold) - .padding(.top, smallPhone ? 48 : 60) - .multilineTextAlignment(.center) - - Text("All your solve statistics are shown\nboth numerically and graphically.") - .font(.title2).fontWeight(.medium) - .multilineTextAlignment(.center) - .if(smallPhone) { view in - view.padding(.bottom, 18) - } - .if(!smallPhone) { view in - view.padding(.vertical, 24) - } - - Image("4-stats") - .resizable() - .aspectRatio(contentMode: .fit) - .padding(.horizontal, 50) - - - Spacer() - } - } - } -} - -struct PageSix: View { - @Environment(\.globalGeometrySize) var globalGeometrySize - - @Binding var pageIndex: Int - var body: some View { - ScrollView { - VStack(spacing: 0) { - Text("Sessions") - .font(.title).fontWeight(.bold) - .padding(.top, smallPhone ? 48 : 60) - .multilineTextAlignment(.center) - - Text("We have a variety of session types.\nHere’s a brief overview:") - .font(.title2).fontWeight(.medium) - .multilineTextAlignment(.center) - .if(!smallPhone) { view in - view.padding(.top, 18).padding(.bottom, 8) - } - - VStack (alignment: .leading, spacing: 28) { - HStack { - Image(systemName: "timer.square") - .font(.system(size: 36, weight: .regular)) - .foregroundColor(Color("accent")) - .frame(width: 50) - - VStack (alignment: .leading) { - Text("Standard") - .font(.body).fontWeight(.medium) - - Text("The default normal session. Set to a chosen puzzle type.") - .font(.body) - .foregroundColor(Color("grey")) - } - } - - /* - HStack { - Image(systemName: "command.square") - .font(.system(size: 36, weight: .regular)) - .foregroundColor(Color("accent")) - .frame(width: 50) - - VStack (alignment: .leading) { - Text("Algorithm Trainer") - .font(.system(size: 17, weight: .medium)) - - Text("Train yourself on a set of algorithms.") - .font(.system(size: 17, weight: .regular)) - .foregroundColor(Color("grey")) - } - } - */ - - HStack { - Image(systemName: "square.stack") - .font(.system(size: 36, weight: .regular)) - .foregroundColor(Color("accent")) - .frame(width: 50) - - VStack (alignment: .leading) { - Text("Multiphase") - .font(.body).fontWeight(.medium) - - Text("Be able to time phases during a solve. Tap during a solve to record phases.") - .font(.body) - .foregroundColor(Color("grey")) - } - } - - HStack { - Image(systemName: "square.on.square") - .font(.system(size: 36, weight: .regular)) - .foregroundColor(Color("accent")) - .frame(width: 50) - - VStack (alignment: .leading) { - Text("Playground") - .font(.body).fontWeight(.medium) - - Text("A versatile session. You can change the scramble type within the session.") - .font(.body) - .foregroundColor(Color("grey")) - } - } - - HStack { - Image(systemName: "globe.asia.australia") - .font(.system(size: 36, weight: .medium)) - .foregroundColor(Color("accent")) - .frame(width: 50) - - VStack (alignment: .leading) { - Text("Comp Sim") - .font(.body).fontWeight(.medium) - - Text("Record non-rolling averages of 5. Simulates a competition.") - .font(.body) - .foregroundColor(Color("grey")) - } - } - } - .frame(maxWidth: globalGeometrySize.width - 32) - .padding(.horizontal, 32) - .padding(.top, 18) - - - - - Spacer() - } - .safeAreaInset(edge: .bottom) { - Rectangle() - .fill(.clear) - .frame(height: 60) - - } - } - } -} - -struct PageSeven: View { - @Binding var pageIndex: Int - var body: some View { - VStack(spacing: 0) { - Text("Thanks!") - .font(.largeTitle).fontWeight(.bold) - .font(.system(size: 36, weight: .bold)) - .padding(.top, smallPhone ? 50 : 72) - .multilineTextAlignment(.center) - .padding(.bottom) - - - Text("We hope you enjoy using this app.") - .font(.body).fontWeight(.medium) - .multilineTextAlignment(.center) - .padding(.horizontal) - .padding(.bottom, 36) - - Text("This app is brought to you by") - .font(.title3).fontWeight(.medium) - .multilineTextAlignment(.center) - .padding(.horizontal) - - Text("[speedcube.co.nz](https://www.speedcube.co.nz/)") - .font(.title3).fontWeight(.medium) - .multilineTextAlignment(.center) - .padding(.horizontal) - .padding(.bottom, 48) - - Image("speedcube") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: 180) - - Spacer() - - - Text("CubeTime is open-source and GPLv3 licensed.\n") - .font(.subheadline).fontWeight(.medium) - .multilineTextAlignment(.center) - .foregroundColor(Color("grey")) - .padding(.horizontal) - - Text("You can view our source code at\nhttps://github.com/CubeStuffs/CubeTime") - .font(.subheadline).fontWeight(.light) - .multilineTextAlignment(.center) - .foregroundColor(Color("grey")) - .padding(.horizontal) - .padding(.bottom) - } - .safeAreaInset(edge: .bottom) { - Rectangle() - .fill(.clear) - .frame(height: 55) - .padding(.bottom) - } - } -} -#endif diff --git a/CubeTime/Onboarding/Updates.swift b/CubeTime/Onboarding/Updates.swift new file mode 100644 index 00000000..1d89b1ed --- /dev/null +++ b/CubeTime/Onboarding/Updates.swift @@ -0,0 +1,327 @@ +// +// Updates.swift +// CubeTime +// +// Created by Tim Xie on 31/01/22. +// + +import SwiftUI + + +struct ListPoint: Equatable, Hashable { + let depth: Int + let text: String + + func hash(into hasher: inout Hasher) { + hasher.combine(depth) + hasher.combine(text) + } + + init(_ depth: Int, _ text: String) { + self.depth = depth + self.text = text + } + + static func == (lhs: ListPoint, rhs: ListPoint) -> Bool { + return lhs.depth == rhs.depth && lhs.text == rhs.text + } +} + +struct ListLine: View { + let depth: Int + let text: LocalizedStringKey + + init(_ depth: Int = 1, _ text: LocalizedStringKey) { + self.depth = depth + self.text = text + } + + var body: some View { + HStack(alignment: .top, spacing: 8) { + Text("–") + .recursiveMono(size: 17, weight: .regular) + + Text(text) + } + .padding(.leading, CGFloat(20 * (depth-1))) + } +} + +let updatesList: [String: (majorAdditions: [ListPoint]?, + minorAdditions: [ListPoint]?, + bugFixes: [ListPoint]?)] = [ +"3.0.0": ( + majorAdditions: [ + ListPoint(1, "export functionality added"), + ListPoint(2, "Export options to CSV, JSON (csTimer), ODT / Excel"), + ListPoint(1, "interactive time trend graph added"), + ListPoint(2, "View time trend in more detail, and hover on points to view solve"), + ListPoint(1, "new icon!"), + ], + minorAdditions: [ + ListPoint(1, "many UI improvements throughout"), + ListPoint(1, "new rounded WCA icons"), + ListPoint(1, "added preference for last x solves in summarised time trend view"), + ListPoint(1, "added ability to select specific phase to view in time list"), + ListPoint(1, "added ability to search comments in time list"), + ListPoint(1, "added button to reset settings"), + ListPoint(1, "added 0s freeze time option"), + ], + bugFixes: [ + ListPoint(1, "many crashes fixed"), + ListPoint(1, "fixed rounding vs truncating of times"), + ListPoint(1, "fixed incorrect timer display when using 0 d.p"), + ListPoint(1, "fixed bug where regular solve could be moved to multiphase session") + ] +), + + +"2.1.2": ( + majorAdditions: nil, + minorAdditions: nil, + bugFixes: [ + ListPoint(1, "fixed timelist column count on iPhone 11 Pro Max"), + ] +), + + +"2.1.1": ( + majorAdditions: nil, + minorAdditions: nil, + bugFixes: [ + ListPoint(1, "fixed time trend graph bug"), + ] +), + + +"2.1": ( + majorAdditions: [ + ListPoint(1, "added settings sync between devices"), + ListPoint(1, "added ability to two-finger swipe on iPad trackpad to resize floating panel") + ], + minorAdditions: [ + ListPoint(1, "made 3-solve display show current average in compsim"), + ListPoint(1, "added toggle to play voice alert through ringer, following mute toggle"), + ListPoint(1, "allow audio alerts to play alongside background audio, eg: music"), + ListPoint(1, "added zen mode on iPhone"), + + ], + bugFixes: [ + ListPoint(1, "fixed various dynamic type bugs"), + ListPoint(1, "fixed calculator tool bugs"), + ListPoint(1, "fixed compsim crashing"), + ListPoint(1, "fixed time distribution crashing"), + ListPoint(1, "fixed inverted iPad trackpad gesture to show penalty bar"), + ListPoint(1, "fixed select all only selected shown solves"), + ListPoint(1, "fixed button text wrapping"), + ListPoint(1, "fixed stats not updating"), + ListPoint(1, "fixed text cutting off on smaller devices"), + ListPoint(1, "fixed long times wrapping in time detail"), + ] +), + +"2.0": ( + majorAdditions: [ + ListPoint(1, "fresh new UI design"), + ListPoint(2, "TONS of UI fixes throughout the app"), + ListPoint(2, "improved design consistency"), + ListPoint(1 , "dynamic type support!"), + ListPoint(2, "all major UI elements now conform to Apple's DynamicType accessbility font sizes"), + ListPoint(2, "this is the first version, if you do notice anything out of place, please open an issue and tag it with 'DynamicType' on our Github page."), + ListPoint(1, "changed tnoodle compatibility layer"), + ListPoint(2, "**30x faster scramble generation**"), + ListPoint(2, "**20x less memory usage**"), + ListPoint(3, "fixes OOM crashes on older phones"), + ListPoint(3, "fixes launch crash on iPod 7th Gen"), + ListPoint(1, "improved stats engine"), + ListPoint(2, "**over 100x faster**"), + ListPoint(1, "iPad Mode is here!"), + ListPoint(2, "iPad mode supports many keyboard shortcuts, along with **trackpad gestures**"), + ListPoint(3, "you can two-finger swipe on your trackpad, just like using a finger"), + ListPoint(2, "new design with a floating panel"), + ListPoint(2, "to see your times, drag down on the panel handle"), + ListPoint(1, "added tools!"), + ListPoint(2, "scramble generator: batch generate multiple scrambles to use or share"), + ListPoint(2, "timer and scramble only mode: for use at comps"), + ListPoint(2, "average calculator: to quickly calculate averages!"), + ListPoint(1, "added voice alerts for inspection"), + ListPoint(1, "added manual entry mode"), + ListPoint(2, "you can switch to entering times by typing instead of a timer in General Settings > Timer Settings > Timer Mode > Typing"), + ListPoint(1, "quick actions!"), + ListPoint(2, "long press on icon to quickly go to your recently used sessions"), + ListPoint(1, "cleaned up to make the app run smoother!"), + ListPoint(2, "we've written over 20,000 lines of code for this update!")], + + minorAdditions: [ + ListPoint(1, "added rotations added for blind scrambles"), + ListPoint(1, "using WCA-complient random state 4x4 scrambles"), + ListPoint(1, "new dynamic gradient option that changes the gradient throughout the day"), + ListPoint(1, "added share sheets to share your times or averages"), + ListPoint(1, "added copy scramble on timer screen"), + ListPoint(1, "added lock scramble feature"), + ListPoint(2, "long press on the scramble text to bring up a menu, where you can lock or unlock the current scramble"), + ListPoint(1, "move solves between session"), + ListPoint(1, "using more modern SVG renderer, faster draw scrambles"), + ListPoint(1, "more solve selection functions:"), + ListPoint(2, "copy multiple solves"), + ListPoint(2, "add penalty to multiple solves"), + ListPoint(1, "show +2 and DNF time in brackets"), + ListPoint(1, "swapped around delete and copy button in individual solve cards"), + ListPoint(1, "added ability to delete currently selected session"), + ListPoint(1, "added multiphase details to solve copy"), + ListPoint(1, "improved various timing-related functions"), + ListPoint(1, "added ability to stop inspection"), + ListPoint(1, "improved user accessibility"), + ListPoint(1, "added inspection alerts"), + ListPoint(1, "added voice inspection"), + ListPoint(1, "added ability to cancel inspection"), + ListPoint(1, "added ability to switch between voice based or beep based alerts"), + ListPoint(1, "added ability to toggle session name in timer view"), + ListPoint(1, "batch select solves to penalty"), + ListPoint(1, "batch select solves to change sessions"), + ListPoint(1, "added ability to clear all solves in a session"), + ListPoint(1, "added many more filter options, such as filtering solves with comments, with penalties, and more"), + ListPoint(1, "time trend now only displays last 80 solves"), + ListPoint(2, "an interactive time trend is coming in the next update"), + ListPoint(1, "added multiphase graph to individual time details"), + ListPoint(1, "added fully customisable fonts for timer and scramble")], + bugFixes: [ + ListPoint(1, "fixed stretched scramble image on smaller devices"), + ListPoint(1, "fixed time distribution graph labels"), + ListPoint(1, "fixed multiphase stats UI"), + ListPoint(1, "fixed many UI bugs with context menus and more"), + ListPoint(1, "fixed stats crashing"), + ListPoint(1, "fixed stats tools not updating when deleting solve")] +)] + + + +struct Updates: View { + @Environment(\.dismiss) var dismiss + + @Binding var showUpdates: Bool + + let currentVersion: String = "\((Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String))" + + var body: some View { + NavigationView { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text("CubeTime v3.0.0 is here!") + .foregroundStyle(GradientManager.getGradient(gradientSelected: 0, isStaticGradient: true)) + .recursiveMono(size: 21, weight: .semibold) + .frame(maxWidth: .infinity, alignment: .center) + .padding(.vertical, 12) + + Group { + Update("3.0.0") + + Text("Thanks for using this app! If you have any feedback (good or bad!), please open an issue on [our github page](https://github.com/CubeStuffs/CubeTime/issues) or [contact me personally](https://tim-xie.com/contact).") + .font(.body).fontWeight(.medium) + .accentColor(Color("accent")) + + Text("Older changes") + .padding(.top, 64) + .font(.title3.weight(.semibold)) + + Update("2.1.2") + + Update("2.1.1") + + Update("2.0") + } + + Spacer() + } + .padding(.horizontal) + } + .navigationBarTitle("What's New!") + .toolbar { + ToolbarItemGroup(placement: .navigationBarTrailing) { + CTCloseButton { + dismiss() + showUpdates = false + } + } + } + } + } +} + + +struct Update: View { + var majorAdditions: [ListPoint]? + var minorAdditions: [ListPoint]? + var bugFixes: [ListPoint]? + + let version: String + + init(_ version: String) { + self.version = version + + if let update = updatesList[version] { + self.majorAdditions = update.majorAdditions + self.minorAdditions = update.minorAdditions + self.bugFixes = update.bugFixes + } + } + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + Group { + Text("v\(version) changes:") + .recursiveMono(size: 15, weight: .semibold) + .padding(.top) + + CTDivider() + .padding(.top, 4) + .padding(.bottom, 8) + + if let majorAdditions = majorAdditions { + Text("Major Additions: ") + .font(.title3).fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 3) { + ForEach(majorAdditions, id: \.self) { point in + ListLine(point.depth, .init(stringLiteral: point.text)) + .if(point.depth == 1) { body in + body.font(.body.weight(.semibold)) + } + } + } + .padding(.bottom) + } + + if let minorAdditions = minorAdditions { + Text("Minor Additions: ") + .font(.title3).fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 3) { + ForEach(minorAdditions, id: \.self) { point in + ListLine(point.depth, .init(stringLiteral: point.text)) + } + } + .padding(.bottom) + } + + if let bugFixes = bugFixes { + Text("Bug Fixes: ") + .font(.title3).fontWeight(.semibold) + + VStack(alignment: .leading, spacing: 3) { + ForEach(bugFixes, id: \.self) { point in + ListLine(point.depth, .init(stringLiteral: point.text)) + } + } + .padding(.bottom) + .font(.callout) + } + } + } + .padding(.bottom) + } +} + +#Preview { + Updates(showUpdates: .constant(true)) +} diff --git a/CubeTime/Sessions/ImportExport.swift b/CubeTime/Sessions/ImportExport.swift new file mode 100644 index 00000000..a49ab710 --- /dev/null +++ b/CubeTime/Sessions/ImportExport.swift @@ -0,0 +1,216 @@ +// +// ImportExport.swift +// CubeTime +// +// Created by Tim Xie on 18/02/24. +// + +import SwiftUI + +struct ImportFlow: View { + var body: some View { + Text("IMPORT") + } +} + +struct ExportFlowPickSessions: View { + @EnvironmentObject var stopwatchManager: StopwatchManager + @EnvironmentObject var tabRouter: TabRouter + @EnvironmentObject var exportViewModel: ExportViewModel + + @Environment(\.horizontalSizeClass) var hSizeClass + + @State private var showNext = false + + let sessions: FetchedResults + + var body: some View { + ZStack { + BackgroundColour() + .ignoresSafeArea() + + VStack(spacing: 4) { + Text("Select Sessions for Export") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .bottomLeading) + .padding(.top, 32) + .padding(.leading) + + VStack { + ScrollView { + ForEach(sessions) { session in + let selected = exportViewModel.selectedSessions.contains(session) + + SessionCardBase(item: session, + pinned: false, + sessionType: SessionType(rawValue: session.sessionType)!, + name: session.name ?? "Unknown session name", + scrambleType: Int(session.scrambleType), + solveCount: 0, + selected: selected, + forExportUse: true) + .onTapGesture { + withAnimation(Animation.customDampedSpring) { + if selected { + exportViewModel.selectedSessions.remove(session) + } else { + exportViewModel.selectedSessions.insert(session) + } + } + } + .padding(.horizontal) + } + } + } + } + .navigationTitle("Export Sessions") + .navigationBarTitleDisplayMode(.inline) + .overlay(alignment: .bottomTrailing) { + NavigationLink(destination: ExportFlowPickFormats().environmentObject(exportViewModel), isActive: $showNext) { EmptyView() } + + CTButton(type: exportViewModel.selectedSessions.count == 0 ? .disabled : .coloured(nil), size: .large, onTapRun: { exportViewModel.exportFlowState = .pickingFormats + showNext = true + }) { + HStack { + Text("Continue") + + Image(systemName: "arrow.forward") + .font(.subheadline) + } + } + .padding(.horizontal) + .padding(.bottom, UIDevice.deviceIsPad && hSizeClass == .regular ? 8 : (UIDevice.hasBottomBar ? 0 : nil)) + } + } + } +} + + +struct ExportFlowPickFormats: View { + @EnvironmentObject var exportViewModel: ExportViewModel + @State var showFilePathSave = false + + @State private var showNext = false + + @Environment(\.horizontalSizeClass) var hSizeClass + + + var body: some View { + let zippedArray = Array(zip(exportViewModel.allFormats.indices, exportViewModel.allFormats)) + + ZStack { + BackgroundColour() + .ignoresSafeArea() + + VStack(spacing: 4) { + Text("Select Export Types") + .font(.headline) + .frame(maxWidth: .infinity, alignment: .bottomLeading) + .padding(.top, 32) + .padding(.leading) + + VStack { + ForEach(zippedArray, id: \.0) { (_, format) in + let indexInSelected = exportViewModel.selectedFormats.firstIndex(where: {$0 === format}) + + HStack { + Text(format.getName()) + .font(.title3.weight(.semibold)) + .foregroundColor(Color("dark")) + + Spacer() + + if indexInSelected != nil { + Image(systemName: "checkmark.circle.fill") + .font(.body.weight(.semibold)) + .foregroundStyle(Color("accent"), Color("overlay0")) + .padding(.trailing, 8) + } + } + .padding(10) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(indexInSelected != nil ? Color("indent1") : Color("overlay0")) + ) + .padding(.horizontal) + + .onTapGesture { + withAnimation(Animation.customDampedSpring) { + if let indexInSelected { + exportViewModel.selectedFormats.remove(at: indexInSelected) + } else { + exportViewModel.selectedFormats.append(format) + } + } + } + } + + Spacer() + } + } + .overlay(alignment: .bottomTrailing) { + if case let ExportFlowState.finished(result) = exportViewModel.exportFlowState { + NavigationLink(destination: ExportFlowFinished(result: result).environmentObject(exportViewModel), isActive: Binding.constant(true)) { EmptyView() } + } + + + CTButton(type: exportViewModel.selectedFormats.count == 0 ? .disabled : .coloured(nil), size: .large, onTapRun: { + showFilePathSave = true + showNext = true + }) { + HStack { + Text("Confirm Export") + + Image(systemName: "arrow.forward") + } + } + .padding(.horizontal) + .padding(.bottom, UIDevice.deviceIsPad && hSizeClass == .regular ? 8 : (UIDevice.hasBottomBar ? 0 : nil)) + } + } + .fileExporter(isPresented: $showFilePathSave, documents: exportViewModel.selectedFormats, contentType: .data ,onCompletion: { result in + exportViewModel.finishExport(result: result) + }) + } +} + + +struct ExportFlowFinished: View { + @EnvironmentObject var exportViewModel: ExportViewModel + + var result: Result<[URL], Error> + + var body: some View { + ZStack { + GradientManager.getGradient(gradientSelected: 0, isStaticGradient: true) + .opacity(0.64) + .ignoresSafeArea() + + VStack(spacing: 32) { + Group { + switch result { + case .success: + Text("Successfully exported!") + .font(.title2.weight(.semibold)) + + case .failure(let failure): + VStack(spacing: 8) { + Text("Oops! Export failed...") + .font(.title2.weight(.semibold)) + + Text("\(failure.localizedDescription)") + .font(.body.weight(.medium)) + } + } + } + .foregroundColor(.white) + + CTButton(type: .mono, size: .large, onTapRun: { exportViewModel.showExport = false }) { + Text("Take me home") + } + } + } + .navigationBarTitleDisplayMode(.inline) + .navigationBarBackButtonHidden() + } +} diff --git a/CubeTime/Sessions/NewSessionRootView.swift b/CubeTime/Sessions/NewSessionRootView.swift index 9a29a211..1e586829 100644 --- a/CubeTime/Sessions/NewSessionRootView.swift +++ b/CubeTime/Sessions/NewSessionRootView.swift @@ -9,7 +9,7 @@ struct NewSessionTypeCard: View { HStack { Group { Image(systemName: icon.iconName) - .font(.system(size: icon.size, weight: icon.weight)) + .font(.system(size: icon.size)) .padding(.leading, icon.padding.leading) .padding(.trailing, icon.padding.trailing) .padding(.vertical, 8) @@ -84,29 +84,36 @@ struct NewSessionRootView: View { VStack(alignment: .leading, spacing: 48) { - NewSessionTypeCardGroup(title: "Normal Sessions") { + NewSessionTypeCardGroup(title: String(localized: "Normal Sessions")) { NavigationLink(destination: NewSessionView(sessionType: SessionType.standard, typeName: "Standard", showNewSessionPopUp: $showNewSessionPopUp)) { - NewSessionTypeCard(name: "Standard Session", icon: SessionTypeIcon(iconName: "timer.square")) + NewSessionTypeCard(name: String(localized: "Standard Session"), icon: SessionTypeIcon(iconName: "timer.square")) } - ThemedDivider() + CTDivider() .padding(.leading, 48) NavigationLink(destination: NewSessionView(sessionType: SessionType.multiphase, typeName: "Multiphase", showNewSessionPopUp: $showNewSessionPopUp)) { - NewSessionTypeCard(name: "Multiphase", icon: SessionTypeIcon(size: 24, iconName: "square.stack", padding: (10, 6))) + NewSessionTypeCard(name: String(localized: "Multiphase"), icon: SessionTypeIcon(size: 24, iconName: "square.stack", padding: (10, 6))) } - ThemedDivider() + CTDivider() .padding(.leading, 48) NavigationLink(destination: NewSessionView(sessionType: SessionType.playground, typeName: "Playground", showNewSessionPopUp: $showNewSessionPopUp)) { - NewSessionTypeCard(name: "Playground", icon: SessionTypeIcon(size: 24, iconName: "square.on.square")) + NewSessionTypeCard(name: String(localized: "Playground"), icon: SessionTypeIcon(size: 24, iconName: "square.on.square")) } } - NewSessionTypeCardGroup(title: "Other Sessions") { - NavigationLink(destination: NewSessionView(sessionType: SessionType.compsim, typeName: "Comp Sim", showNewSessionPopUp: $showNewSessionPopUp)) { - NewSessionTypeCard(name: "Comp Sim", icon: SessionTypeIcon(iconName: "globe.asia.australia", weight: .medium)) + NewSessionTypeCardGroup(title: String(localized: "Other Sessions")) { + NavigationLink(destination: NewSessionView(sessionType: SessionType.compsim, typeName: "Compsim", showNewSessionPopUp: $showNewSessionPopUp)) { + NewSessionTypeCard(name: String(localized: "Compsim"), icon: SessionTypeIcon(size: 24, iconName: "globe.asia.australia")) + } + + CTDivider() + .padding(.leading, 48) + + NavigationLink(destination: NewSessionView(sessionType: SessionType.timerOnly, typeName: "Timer Only", showNewSessionPopUp: $showNewSessionPopUp)) { + NewSessionTypeCard(name: String(localized: "Timer Only"), icon: SessionTypeIcon(size: 24, iconName: "timer")) } } } diff --git a/CubeTime/Sessions/NewSessionView.swift b/CubeTime/Sessions/NewSessionView.swift index 9d2e8aa2..839e213c 100644 --- a/CubeTime/Sessions/NewSessionView.swift +++ b/CubeTime/Sessions/NewSessionView.swift @@ -35,21 +35,21 @@ struct NewSessionView: View { ScrollView { VStack (spacing: 16) { VStack (alignment: .center, spacing: 0) { - if sessionType != SessionType.playground { - PuzzleHeaderImage(imageName: puzzleTypes[Int(sessionEventType)].name) + if sessionType != SessionType.playground && sessionType != SessionType.timerOnly { + PuzzleHeaderImage(imageName: PUZZLE_TYPES[Int(sessionEventType)].imageName) } SessionNameField(name: $name) - .if(sessionType == SessionType.playground) { view in + .if(sessionType == SessionType.playground || sessionType == SessionType.timerOnly) { view in view.padding(.top) } - if let sessionDescription = sessionDescriptions[sessionType] { - Text(sessionDescription) - .multilineTextAlignment(.leading) - .foregroundColor(Color("grey")) - .padding([.horizontal, .bottom]) - } + + Text(sessionType.description()) + .font(.callout) + .multilineTextAlignment(.leading) + .foregroundColor(Color("grey")) + .padding([.horizontal, .bottom]) } .modifier(CardBlockBackground()) .frame(minHeight: otherBigFrameHeight) @@ -74,7 +74,7 @@ struct NewSessionView: View { CompSimTargetEntry(targetStr: $targetStr) } - if sessionType != .playground { + if sessionType != .playground && sessionType != .timerOnly { EventPicker(sessionEventType: $sessionEventType) } diff --git a/CubeTime/Sessions/SessionCard.swift b/CubeTime/Sessions/SessionCard.swift index c5451a04..3614f33a 100644 --- a/CubeTime/Sessions/SessionCard.swift +++ b/CubeTime/Sessions/SessionCard.swift @@ -1,21 +1,15 @@ import SwiftUI import Foundation -struct SessionCard: View { + +struct SessionCardBase: View { @Environment(\.globalGeometrySize) var globalGeometrySize - @Environment(\.managedObjectContext) var managedObjectContext - @EnvironmentObject var stopwatchManager: StopwatchManager - @State private var isShowingDeleteDialog = false - @State private var isShowingCustomizeDialog = false - @ScaledMetric private var pinnedSessionHeight: CGFloat = 110 @ScaledMetric private var regularSessionHeight: CGFloat = 65 - var item: Session - var allSessions: FetchedResults let pinned: Bool let sessionType: SessionType @@ -23,125 +17,130 @@ struct SessionCard: View { let scrambleType: Int let solveCount: Int - @Namespace var namespace + let forExportUse: Bool - init (item: Session, allSessions: FetchedResults) { + var selected: Bool + + init(item: Session, pinned: Bool, sessionType: SessionType, name: String, scrambleType: Int, solveCount: Int, selected: Bool, forExportUse: Bool=false) { self.item = item - self.allSessions = allSessions - // Copy out the things so that it won't change to null coalesced defaults on deletion - self.pinned = item.pinned - self.sessionType = SessionType(rawValue: item.sessionType)! - self.name = item.name ?? "Unknown session name" - self.scrambleType = Int(item.scrambleType) - self.solveCount = item.solves?.count ?? -1 + self.pinned = pinned + self.sessionType = sessionType + self.name = name + self.scrambleType = scrambleType + self.solveCount = solveCount + + self.selected = selected + + self.forExportUse = forExportUse } + @Namespace var namespace + var body: some View { HStack { VStack(alignment: .leading) { - HStack(alignment: .center, spacing: 0) { - Group { - switch sessionType { - case .algtrainer: - Image(systemName: "command.square") - .font(.system(size: 26, weight: .semibold)) - - case .playground: - Image(systemName: "square.on.square") - .font(.system(size: 22, weight: .semibold)) - - case .multiphase: - Image(systemName: "square.stack") - .font(.system(size: 22, weight: .semibold)) - - case .compsim: - Image(systemName: "globe.asia.australia") - .font(.system(size: 26, weight: .bold)) - - default: - EmptyView() - } - } - .foregroundColor(Color("accent")) - .padding(.trailing, 12) - .background( Group { - if sessionType != .standard { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color("accent").opacity(0.33)) - .frame(width: 40, height: 40) - .padding(.trailing, 12) - } - }) - - - VStack(alignment: .leading, spacing: 0) { - Text(name) - .font(.title2.weight(.semibold)) - .foregroundColor(Color("dark")) - - Group { - switch sessionType { - case .standard: - Text(puzzleTypes[scrambleType].name) - case .playground: - Text("Playground") - case .multiphase: - Text("Multiphase - \(puzzleTypes[scrambleType].name)") - case .compsim: - Text("Comp Sim - \(puzzleTypes[scrambleType].name)") - default: - EmptyView() - } - } - .font(.subheadline.weight(.regular)) + VStack(alignment: .leading, spacing: 0) { + Text(name) + .font(.title2.weight(.semibold)) .foregroundColor(Color("dark")) - .offset(y: pinned ? 0 : -2) + + HStack(spacing: 4) { + CTSessionBubble(session: item) } } if pinned { Spacer() - Text("\(solveCount) Solves") - .font(.subheadline.weight(.semibold)) - .foregroundColor(Color("grey")) - .padding(.bottom, 4) + CTBubble(type: .coloured(nil), size: .bubble, hasMaterial: false) { + Text("\(solveCount) Solves") + .padding(.horizontal, 2) + } } } - .offset(x: stopwatchManager.currentSession == item ? 10 : 0) + .padding(.leading, self.selected && !self.forExportUse ? 24 : 10) + .padding(.vertical, 10) + .offset(y: -1) Spacer() - if (sessionType != .playground) { - Image(puzzleTypes[scrambleType].name) - .resizable() - .frame(width: item.pinned ? nil : 40, height: item.pinned ? nil : 40) - .aspectRatio(contentMode: .fit) - .foregroundColor(Color("dark")) - .padding(.trailing, item.pinned ? 12 : 8) - .padding(.vertical, item.pinned ? 6 : 0) + if !forExportUse { + if sessionType != .timerOnly { + Image(PUZZLE_TYPES[scrambleType].imageName) + .resizable() + .frame(width: 45, height: 45) + .aspectRatio(contentMode: .fit) + .foregroundColor(Color("dark")) + .padding([.vertical, .trailing], 10) + .frame(maxHeight: .infinity, alignment: .topTrailing) + } + } else if selected { + Image(systemName: "checkmark.circle.fill") + .font(.body.weight(.semibold)) + .foregroundStyle(Color("accent"), Color("overlay0")) + .padding(.trailing, 24) } } - .padding(.leading) - .padding(.trailing, pinned ? 6 : 4) - .padding(.vertical, pinned ? 12 : 8) - .frame(height: pinned ? pinnedSessionHeight : regularSessionHeight) - .background( Group { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color("indent1")) - .frame(height: pinned ? pinnedSessionHeight : regularSessionHeight) - - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color("overlay0")) - .frame(width: stopwatchManager.currentSession == item ? 16 : nil, - height: item.pinned ? pinnedSessionHeight : regularSessionHeight) - .frame(maxWidth: .infinity, alignment: .leading) - - }) + .frame(height: pinned ? pinnedSessionHeight : regularSessionHeight, alignment: .center) + + .background( + Group { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color("indent1")) + .frame(height: pinned ? pinnedSessionHeight : regularSessionHeight) + + if (forExportUse) { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color("overlay0")) + .frame(width: nil, + height: item.pinned ? pinnedSessionHeight : regularSessionHeight) + .opacity(selected ? 0 : 1) + .frame(maxWidth: .infinity, alignment: .leading) + } else { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(Color("overlay0")) + .frame(width: selected ? 16 : nil, + height: item.pinned ? pinnedSessionHeight : regularSessionHeight) + .frame(maxWidth: .infinity, alignment: .leading) + .shadowDark(x: 1, y: 0) + } + } + ) .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) + .contentShape(.contextMenuPreview, RoundedRectangle(cornerRadius: 12, style: .continuous)) + } +} + + +struct SessionCard: View { + @EnvironmentObject var stopwatchManager: StopwatchManager + + @Environment(\.managedObjectContext) var managedObjectContext + + @State private var isShowingDeleteDialog = false + @State private var isShowingCustomizeDialog = false + + + var item: Session + var allSessions: FetchedResults + + init (item: Session, allSessions: FetchedResults) { + self.item = item + self.allSessions = allSessions + } + + var body: some View { + SessionCardBase(item: item, + pinned: item.pinned, + sessionType: SessionType(rawValue: item.sessionType)!, + name: item.name ?? "Unknown session name", + scrambleType: Int(item.scrambleType), + solveCount: item.solves?.count ?? -1, + selected: item == stopwatchManager.currentSession) + .onTapGesture { withAnimation(Animation.customDampedSpring) { if stopwatchManager.currentSession != item { @@ -151,13 +150,12 @@ struct SessionCard: View { } - .contentShape(.contextMenuPreview, RoundedRectangle(cornerRadius: 12, style: .continuous)) .contextMenu(menuItems: { ContextMenuButton(delay: false, action: { isShowingCustomizeDialog = true }, - title: "Customise", + title: String(localized: "Customise"), systemImage: "pencil", disableButton: false); ContextMenuButton(delay: true, @@ -167,13 +165,13 @@ struct SessionCard: View { try! managedObjectContext.save() } }, - title: item.pinned ? "Unpin" : "Pin", + title: item.pinned ? String(localized: "Unpin") : String(localized: "Pin"), systemImage: item.pinned ? "pin.slash" : "pin", disableButton: false); Divider() ContextMenuButton(delay: false, action: { isShowingDeleteDialog = true }, - title: "Delete Session", + title: String(localized: "Delete Session"), systemImage: "trash", disableButton: allSessions.count <= 1) .foregroundColor(Color.red) @@ -185,7 +183,7 @@ struct SessionCard: View { .tint(Color("accent")) } - .confirmationDialog(String("Are you sure you want to delete \"\(name)\"? All solves will be deleted and this cannot be undone."), isPresented: $isShowingDeleteDialog, titleVisibility: .visible) { + .confirmationDialog(String(localized: "Are you sure you want to delete \"\(self.item.name ?? "this session")\"? All solves will be deleted and this cannot be undone."), isPresented: $isShowingDeleteDialog, titleVisibility: .visible) { Button("Confirm", role: .destructive) { if item == stopwatchManager.currentSession { var next: Session? = nil diff --git a/CubeTime/Sessions/SessionsView.swift b/CubeTime/Sessions/SessionsView.swift index 8bf31ca0..2d530f6d 100644 --- a/CubeTime/Sessions/SessionsView.swift +++ b/CubeTime/Sessions/SessionsView.swift @@ -7,6 +7,11 @@ import SwiftfulLoadingIndicators struct SessionsView: View { @Environment(\.managedObjectContext) var managedObjectContext @Environment(\.horizontalSizeClass) var hSizeClass + + @StateObject var exportViewModel: ExportViewModel = ExportViewModel() + + + @EnvironmentObject var tabRouter: TabRouter @State var showNewSessionPopUp = false @@ -21,39 +26,100 @@ struct SessionsView: View { NSSortDescriptor(keyPath: \Session.name, ascending: true) ] ) var sessions: FetchedResults - + var body: some View { NavigationView { GeometryReader { geo in ScrollView { VStack (spacing: 10) { - if let status = cloudkitStatusManager.currentStatus { + HStack { + Menu { + Button() { + exportViewModel.showExport = true + } label: { + Label("Export Sessions", systemImage: "square.and.arrow.up") + .labelStyle(.titleAndIcon) + .imageScale(.small) + } + + /* + Button() { + showImport = true + } label: { + Label("Import Sessions", systemImage: "square.and.arrow.down") + .labelStyle(.titleAndIcon) + .imageScale(.small) + } + */ + } label: { + CTBubble(type: .coloured(nil), size: .small, outlined: false, square: false, hasShadow: true, hasBackground: true, supportsDynamicResizing: true, expandWidth: false) { +// Label("Import & Export", systemImage: "square.and.arrow.up.on.square") + Label("Export", systemImage: "square.and.arrow.up.on.square") + .labelStyle(.titleAndIcon) + .imageScale(.small) + } + } + .background( + Group { + NavigationLink(destination: ExportFlowPickSessions(sessions: sessions).environmentObject(exportViewModel), isActive: $exportViewModel.showExport) { EmptyView() } + NavigationLink(destination: ImportFlow(), isActive: $exportViewModel.showImport) { EmptyView() } + } + ) + .onChange(of: exportViewModel.showExport) { newValue in + tabRouter.hideTabBar = newValue + } + .onChange(of: exportViewModel.showImport) { newValue in + tabRouter.hideTabBar = newValue + } + + Spacer() + Group { - switch (status) { - case 0: - Text("Synced to iCloud") - .foregroundColor(Color("accent")) - - case 1: - Text("Sync to iCloud failed") - .foregroundColor(Color("grey")) - - case 2: - Text("iCloud unavailable") - .foregroundColor(Color("grey")) - - default: - EmptyView() + if let status = cloudkitStatusManager.currentStatus { + Group { + switch (status) { + case 0: + Text("Synced to iCloud") + .foregroundColor(Color("accent")) + case 1: + Text("Sync to iCloud failed") + .foregroundColor(Color("grey")) + default: + Text("iCloud unavailable") + .foregroundColor(Color("grey")) + } + } + .font(.subheadline.weight(.medium)) + .frame(height: height) + } else { + LoadingIndicator(animation: .bar, color: Color("accent"), size: .small, speed: .normal) + .frame(height: height) } + + } - .font(.subheadline.weight(.medium)) - .frame(height: height) - } else { - LoadingIndicator(animation: .bar, color: Color("accent"), size: .small, speed: .normal) - .frame(height: height) + .frame(maxWidth: .infinity, alignment: .trailing) + } + .padding(.horizontal) + + if ((sessions.firstIndex(where: { !$0.pinned }) ?? 0) != 0) { + Text("PINNED SESSIONS") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading) + .offset(y: 4) + .padding(.top, 4) } - ForEach(sessions) { item in + ForEach(Array(zip(sessions.indices, sessions)), id: \.0) { index, item in + if (index == (sessions.firstIndex(where: { !$0.pinned }) ?? 0)) { + Text("SESSIONS") + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.leading) + .offset(y: 4) + } + SessionCard(item: item, allSessions: sessions) } } @@ -64,7 +130,7 @@ struct SessionsView: View { BackgroundColour(isSessions: true) ) .overlay(alignment: .bottomLeading) { - CTButton(type: .coloured, size: .large, onTapRun: { + CTButton(type: .coloured(nil), size: .large, onTapRun: { showNewSessionPopUp = true }) { HStack(spacing: 6) { @@ -91,7 +157,7 @@ struct SessionsView: View { ToolbarItem(placement: .navigationBarTrailing) { if !(UIDevice.deviceIsPad && hSizeClass == .regular) { NavigationLink(destination: ToolsList()) { - CTButtonBase(type: .coloured, size: .small, outlined: false, square: false, hasShadow: true, hasBackground: true, expandWidth: false) { + CTBubble(type: .coloured(nil), size: .small, outlined: false, square: false, hasShadow: true, hasBackground: true, hasMaterial: true, supportsDynamicResizing: true, expandWidth: false) { Label("Tools", systemImage: "wrench.and.screwdriver") .labelStyle(.titleAndIcon) .imageScale(.small) @@ -155,7 +221,7 @@ struct CustomiseSessionView: View { ScrollView { VStack(spacing: 16) { VStack(alignment: .center, spacing: 0) { - PuzzleHeaderImage(imageName: puzzleTypes[Int(sessionEventType)].name) + PuzzleHeaderImage(imageName: PUZZLE_TYPES[Int(sessionEventType)].imageName) SessionNameField(name: $name) } @@ -235,36 +301,36 @@ struct EventPicker: View { Menu { Picker("", selection: $sessionEventType) { - ForEach(Array(puzzleTypes.enumerated()), id: \.offset) {index, element in + ForEach(Array(PUZZLE_TYPES.enumerated()), id: \.offset) {index, element in Text(element.name).tag(Int32(index)) .font(.body) } } } label: { - Text(puzzleTypes[Int(sessionEventType)].name) + Text(PUZZLE_TYPES[Int(sessionEventType)].name) .font(.body) .frame(maxWidth: 120, alignment: .trailing) - + } } .frame(maxWidth: .infinity) .padding([.horizontal, .top]) - ThemedDivider() + CTDivider() .padding(.horizontal) LazyVGrid(columns: [GridItem(.adaptive(minimum: spacing), spacing: 8)], spacing: 8) { - ForEach(Array(zip(puzzleTypes.indices, puzzleTypes)), id: \.0) { index, element in - CTButton(type: (index == sessionEventType) ? .halfcoloured : .mono, - size: .ultraLarge, - square: true, - onTapRun: { + ForEach(Array(zip(PUZZLE_TYPES.indices, PUZZLE_TYPES)), id: \.0) { index, element in + CTButton(type: (index == sessionEventType) ? .halfcoloured(nil) : .mono, + size: .ultraLarge, + square: true, + onTapRun: { sessionEventType = Int32(index) }) { - Image(element.name) + Image(element.imageName) .renderingMode(.template) .resizable() - .frame(width: imageSize, height: imageSize) + .frame(width: imageSize * 1.2, height: imageSize * 1.2) } } } diff --git a/CubeTime/Sessions/Tools/CalculatorTool.swift b/CubeTime/Sessions/Tools/CalculatorTool.swift index 986b6401..88162c27 100644 --- a/CubeTime/Sessions/Tools/CalculatorTool.swift +++ b/CubeTime/Sessions/Tools/CalculatorTool.swift @@ -33,10 +33,11 @@ struct SimpleSolve: Comparable, Hashable { struct CalculatorTool: View { @Environment(\.managedObjectContext) var managedObjectContext - + @Namespace private var namespace @State private var calculatorType: CalculatorType = .average + @State private var numberOfSolves: Int = 5 @State private var currentTime: String = "" @State private var solves: [SimpleSolve] = [] @@ -52,108 +53,119 @@ struct CalculatorTool: View { .ignoresSafeArea() VStack { - ToolHeader(name: tools[3].name, image: tools[3].iconName, content: { - Picker("", selection: $calculatorType) { - ForEach(CalculatorType.allCases, id: \.self) { t in - Text(t.rawValue) - .tag(t) + ToolHeader(name: tools[3].name, image: tools[3].iconName) { + HStack(spacing: 4) { + Picker("", selection: $calculatorType) { + ForEach(CalculatorType.allCases, id: \.self) { t in + Text(t.rawValue) + .tag(t) + } } + + TextField("", value: $numberOfSolves, format: .number) + .fixedSize() + .textFieldStyle(.plain) + .keyboardType(.numberPad) + .foregroundColor(Color("accent")) + .padding(.trailing, 12) } - }) + } .frame(maxWidth: .infinity) VStack { if (solves.count > 0) { - VStack(spacing: 8) { - ForEach(Array(zip(solves.indices, solves)), id: \.0) { index, solve in - HStack { - if (solves.count > 3 && (index == solves.firstIndex(of: solves.min() ?? SimpleSolve(time: 0.00, penalty: .none)) || index == solves.lastIndex(of: solves.max() ?? SimpleSolve(time: 0.00, penalty: .none)))) { - Text("(" + formatSolveTime(secs: solve.time, penType: solve.penalty) + ")") - .font(.body.weight(.semibold)) - .foregroundColor(Color("grey")) - } else { - Text(formatSolveTime(secs: solve.time, penType: solve.penalty)) - .font(.body.weight(.semibold)) - .foregroundColor(Color("dark")) - } - - Spacer() - - ZStack { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color("overlay0")) - .frame(width: showEditFor != nil && showEditFor == index ? nil : 35, height: 35) + ScrollView { + VStack(spacing: 8) { + ForEach(Array(zip(solves.indices, solves)), id: \.0) { index, solve in + HStack { + if (calculatorType == .average && solves.count > 3 && (solve.timeIncPen <= solves.sorted()[StopwatchManager.getTrimSizeEachEnd(numberOfSolves) - 1].timeIncPen || solve.timeIncPen >= solves.sorted()[solves.count - StopwatchManager.getTrimSizeEachEnd(numberOfSolves)].timeIncPen)) { + Text("(" + formatSolveTime(secs: solve.time, penalty: solve.penalty) + ")") + .font(.body.weight(.semibold)) + .foregroundColor(Color("grey")) + } else { + Text(formatSolveTime(secs: solve.time, penalty: solve.penalty)) + .font(.body.weight(.semibold)) + .foregroundColor(Color("dark")) + } - HStack { - if (showEditFor != nil && showEditFor == index) { - CTButton(type: .mono, size: .medium, square: true, hasShadow: false, onTapRun: { - self.solves[index].penalty = .plustwo - showEditFor = nil - }) { - Image("+2.label") - .imageScale(.medium) - } - - CTButton(type: .mono, size: .medium, square: true, hasShadow: false, onTapRun: { - self.solves[index].penalty = .dnf - showEditFor = nil - }) { - Image(systemName: "xmark.circle") - .imageScale(.medium) + Spacer() + + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color("overlay0")) + .frame(width: showEditFor != nil && showEditFor == index ? nil : 35, height: 35) + + HStack { + if (showEditFor != nil && showEditFor == index) { + CTButton(type: .mono, size: .medium, square: true, hasShadow: false, onTapRun: { + self.solves[index].penalty = .plustwo + showEditFor = nil + }) { + Image("+2.label") + .imageScale(.medium) + } + + CTButton(type: .mono, size: .medium, square: true, hasShadow: false, onTapRun: { + self.solves[index].penalty = .dnf + showEditFor = nil + }) { + Image(systemName: "xmark.circle") + .imageScale(.medium) + } + + CTButton(type: .mono, size: .medium, square: true, hasShadow: false, onTapRun: { + self.solves[index].penalty = .none + showEditFor = nil + }) { + Image(systemName: "checkmark.circle") + .imageScale(.medium) + } + + CTDivider(isHorizontal: false) + .padding(.vertical, 8) + + CTButton(type: .coloured(Color("red")), size: .medium, square: true, hasShadow: false, hasBackground: false, onTapRun: { + self.solves.remove(at: index) + showEditFor = nil + }) { + Image(systemName: "trash") + .imageScale(.medium) + } } CTButton(type: .mono, size: .medium, square: true, hasShadow: false, onTapRun: { - self.solves[index].penalty = .none - showEditFor = nil - }) { - Image(systemName: "checkmark.circle") - .imageScale(.medium) - } - - ThemedDivider(isHorizontal: false) - .padding(.vertical, 8) - - CTButton(type: .red, size: .medium, square: true, hasShadow: false, hasBackground: false, onTapRun: { - self.solves.remove(at: index) - showEditFor = nil - }) { - Image(systemName: "trash") - .imageScale(.medium) - } - } - - CTButton(type: .mono, size: .medium, square: true, hasShadow: false, onTapRun: { - - if (showEditFor != nil && showEditFor == index) { - editNumber = index - showEditFor = nil - } else { - if (self.showEditFor == index) { - self.showEditFor = nil + if (showEditFor != nil && showEditFor == index) { + editNumber = index + showEditFor = nil } else { - self.showEditFor = index + if (self.showEditFor == index) { + self.showEditFor = nil + } else { + self.showEditFor = index + } } + }) { + Image(systemName: "pencil") + .imageScale(.medium) } - }) { - Image(systemName: "pencil") - .imageScale(.medium) } + .padding(.horizontal, 2) } - .padding(.horizontal, 2) + .clipped() + .animation(.customDampedSpring, value: showEditFor) + .fixedSize() } - .clipped() - .animation(.customDampedSpring, value: showEditFor) - .fixedSize() } + .clipped() } - .clipped() + .padding(10) } - .padding(10) .background( RoundedRectangle(cornerRadius: 8, style: .continuous) .fill(Color("overlay1")) ) .padding(.top) + .padding(.bottom, 4) } Spacer() @@ -163,61 +175,40 @@ struct CalculatorTool: View { if let editNumber = editNumber { Text("EDITING SOLVE \(editNumber + 1)") } else { - if (solves.count < 5) { + if (solves.count < numberOfSolves) { Text("SOLVE \(solves.count + 1)") } else { - Text("= " + formatSolveTime(secs: StopwatchManager.calculateAverage(forSortedSolves: solves.sorted(), count: 5, trim: 1), - penType: solves.sorted()[3].penalty == .dnf ? .dnf : .none )) - .font(.largeTitle.weight(.bold)) - .foregroundStyle(getGradient(gradientSelected: 0, isStaticGradient: true)) + Group { + if (calculatorType == .average) { + Text("= " + formatSolveTime(secs: StopwatchManager.calculateAverage(forSortedSolves: solves.sorted(), count: numberOfSolves, trim: 1), + penalty: solves.sorted()[3].penalty == .dnf ? Penalty.dnf : Penalty.none )) + } else { + let mean: Average = StopwatchManager.calculateMean(of: numberOfSolves, for: solves) + + Text("= " + formatSolveTime(secs: mean.average, penalty: mean.penalty)) + } + } + .font(.title.weight(.bold)) } } } - .foregroundStyle(getGradient(gradientSelected: 0, isStaticGradient: true)) - .font(.body.weight(.semibold)) + .foregroundStyle(Color("dark")) + .font(.callout.weight(.semibold)) .padding(.top, 10) - + Group { - if (solves.count < 5 || editNumber != nil) { + if (solves.count < numberOfSolves || editNumber != nil) { TextField("0.00", text: $currentTime) .focused($focused) .padding(.vertical, 6) - .recursiveMono(fontSize: 20, weight: .semibold) + .recursiveMono(size: 20, weight: .semibold) .multilineTextAlignment(.center) .foregroundColor(Color("dark")) .background(Color("indent1")) .cornerRadius(6) .modifier(ManualInputTextField(text: $currentTime)) - .toolbar { - ToolbarItemGroup(placement: .keyboard) { - HStack { - Button("Cancel") { - focused = false - self.editNumber = nil - } - .keyboardShortcut(KeyboardShortcut.cancelAction) - - Spacer() - - Button("Done") { - if let time = timeFromStr(currentTime) { - if let editNumber = editNumber { - self.solves[editNumber].time = time - } else { - let solve = SimpleSolve(time: time, penalty: Penalty.none) - self.solves.append(solve) - } - - self.editNumber = nil - currentTime = "" - } - } - .keyboardShortcut(KeyboardShortcut.defaultAction) - } - } - } } else { - CTButton(type: .coloured, size: .medium, expandWidth: true, onTapRun: { + CTButton(type: .coloured(nil), size: .medium, expandWidth: true, onTapRun: { self.solves = [] }) { Label("Start Over", systemImage: "arrow.clockwise") @@ -235,6 +226,34 @@ struct CalculatorTool: View { } .padding(.horizontal) .padding(.bottom, geo.size.height / 2.25) + .toolbar { + ToolbarItem(placement: .keyboard) { + HStack { + Button("Cancel") { + focused = false + self.editNumber = nil + } + .keyboardShortcut(KeyboardShortcut.cancelAction) + + Spacer() + + Button("Done") { + if let time = timeFromStr(currentTime) { + if let editNumber = editNumber { + self.solves[editNumber].time = time + } else { + let solve = SimpleSolve(time: time, penalty: Penalty.none) + self.solves.append(solve) + } + + self.editNumber = nil + currentTime = "" + } + } + .keyboardShortcut(KeyboardShortcut.defaultAction) + } + } + } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) } diff --git a/CubeTime/Sessions/Tools/ScrambleGeneratorTool.swift b/CubeTime/Sessions/Tools/ScrambleGeneratorTool.swift index f29d12ce..5898940d 100644 --- a/CubeTime/Sessions/Tools/ScrambleGeneratorTool.swift +++ b/CubeTime/Sessions/Tools/ScrambleGeneratorTool.swift @@ -43,7 +43,6 @@ class ScrambleThread: Thread { #endif -// graal_create_isolate(nil, &isolate, &thread) graal_attach_thread(isolate, &thread) while (true) { let s = String(cString: tnoodle_lib_scramble(thread, scrType)) @@ -63,7 +62,6 @@ class ScrambleThread: Thread { } } -// graal_tear_down_isolate(thread); graal_detach_thread(thread) waiter.leave() @@ -104,7 +102,7 @@ class ScrambleGenerator: ObservableObject { graal_create_isolate(nil, &isolate, &thread) self.threads = (0.. 0 && (scrambleGenerator.scrambles?.count == nil)) { - CTButton(type: .coloured, size: .large, onTapRun: { + CTButton(type: .coloured(nil), size: .large, onTapRun: { scrambleGenerator.generate() }) { Text("Generate!") @@ -192,7 +190,7 @@ struct ScrambleGeneratorTool: View { .font(.body.weight(.semibold)) .foregroundColor(Color("accent")) - CTShareButton(toShare: "Generated by CubeTime.\n" + scrambleGenerator.scrambles!.joined(separator: "\n"), buttonText: "Share") + CTShareButton(toShare: "Generated by CubeTime\n" + scrambleGenerator.scrambles!.joined(separator: "\n"), buttonText: "Share") .frame(height: 35) ScrollView { @@ -205,7 +203,7 @@ struct ScrambleGeneratorTool: View { .offset(y: 1) Text(scramble) - .recursiveMono(fontSize: 17, weight: .medium) + .recursiveMono(size: 17, weight: .medium) .textSelection(.enabled) .padding(.bottom, 6) } diff --git a/CubeTime/Sessions/Tools/ScrambleOnlyTool.swift b/CubeTime/Sessions/Tools/ScrambleOnlyTool.swift index a8fddc40..57c4b0b1 100644 --- a/CubeTime/Sessions/Tools/ScrambleOnlyTool.swift +++ b/CubeTime/Sessions/Tools/ScrambleOnlyTool.swift @@ -16,7 +16,7 @@ struct ScrambleOnlyTool: View { if let scr = scrambleController.scrambleStr { Text(scr) .multilineTextAlignment(.center) - .recursiveMono(fontSize: 24, weight: .medium) + .recursiveMono(size: 24, weight: .medium) .padding(.horizontal) } else { LoadingIndicator(animation: .circleRunner, color: Color("accent"), size: .small, speed: .fast) @@ -30,7 +30,7 @@ struct ScrambleOnlyTool: View { ToolHeader(name: tools[1].name, image: tools[1].iconName, content: { Picker("", selection: $scrambleController.scrambleType) { - ForEach(Array(zip(puzzleTypes.indices, puzzleTypes)), id: \.0) { index, element in + ForEach(Array(zip(PUZZLE_TYPES.indices, PUZZLE_TYPES)), id: \.0) { index, element in Text(element.name).tag(Int32(index)) .font(.system(size: 15, weight: .regular)) } diff --git a/CubeTime/Sessions/Tools/TimerOnlyTool.swift b/CubeTime/Sessions/Tools/TimerOnlyTool.swift index 2c81f9cb..ad4a7064 100644 --- a/CubeTime/Sessions/Tools/TimerOnlyTool.swift +++ b/CubeTime/Sessions/Tools/TimerOnlyTool.swift @@ -8,7 +8,7 @@ import SwiftUI struct TimerOnlyTool: View { - @StateObject var timerController: TimerContoller = TimerContoller() + @StateObject var timerController: TimerController = TimerController() var body: some View { ZStack { diff --git a/CubeTime/Sessions/Tools/ToolsList.swift b/CubeTime/Sessions/Tools/ToolsList.swift index c9f35820..81d210aa 100644 --- a/CubeTime/Sessions/Tools/ToolsList.swift +++ b/CubeTime/Sessions/Tools/ToolsList.swift @@ -5,21 +5,28 @@ class ToolsViewModel: ObservableObject { } +enum ToolType: Identifiable { + case timerOnly, scrambleOnly, scrambleGenerator, calculator + + var id: ToolType { self } +} + struct Tool: Identifiable, Equatable { - var id: String { - get { return name } + var id: ToolType { + get { return self.toolType } } let name: String + let toolType: ToolType let iconName: String let description: String } let tools: [Tool] = [ - Tool(name: "Timer Only", iconName: "stopwatch", description: "Just a timer. No scrambles are shown. Your solves are **not** recorded and are not saved to a session."), - Tool(name: "Scramble Only", iconName: "cube", description: "Displays one scramble at a time. A timer is not shown. Tap to generate the next scramble."), - Tool(name: "Scramble Generator", iconName: "server.rack", description: "Generate multiple scrambles at once, to share, save or use."), - Tool(name: "Calculator", iconName: "function", description: "Simple average and mean calculator."), + Tool(name: String(localized: "Timer Only"), toolType: .timerOnly, iconName: "stopwatch", description: String(localized: "Just a timer. No scrambles are shown. Your solves are **not** recorded and are not saved to a session.")), + Tool(name: String(localized: "Scramble Only"), toolType: .scrambleOnly, iconName: "cube", description: String(localized: "Displays one scramble at a time. A timer is not shown. Tap to generate the next scramble.")), + Tool(name: String(localized: "Scramble Generator"), toolType: .scrambleGenerator, iconName: "server.rack", description: String(localized: "Generate multiple scrambles at once, to share, save or use.")), + Tool(name: String(localized: "Calculator"), toolType: .calculator, iconName: "function", description: String(localized: "Simple average and mean calculator.")), /* Tool(name: "Tracker", iconName: "scope", description: "Track someone's average at a comp. Calculates times needed for a chance for a target, BPA, WPA, and more."), Tool(name: "Scorecard Generator", iconName: "printer", description: "Export scorecards for use at meetups (or comps!)."), @@ -76,27 +83,19 @@ struct ToolsList: View { Group { if let tool = toolsViewModel.currentTool { - switch (tool.name) { - case "Timer Only": + switch (tool.toolType) { + case .timerOnly: TimerOnlyTool() - case "Scramble Only": + case .scrambleOnly: ScrambleOnlyTool() - case "Scramble Generator": + case .scrambleGenerator: ScrambleGeneratorTool() - case "Calculator": + case .calculator: CalculatorTool() - /* - case "Tracker": - EmptyView() - - case "Scorecard Generator": - EmptyView() - */ - default: EmptyView() } diff --git a/CubeTime/Settings/AboutSettings.swift b/CubeTime/Settings/AboutSettings.swift index c8207bfd..d0c4e64a 100644 --- a/CubeTime/Settings/AboutSettings.swift +++ b/CubeTime/Settings/AboutSettings.swift @@ -1,4 +1,11 @@ import SwiftUI +import StoreKit + +class CTDonation { + static let fiveDonationIdentifier = "com.cubetime.cubetime.5donation" + static let tenDonationIdentifier = "com.cubetime.cubetime.10donation" + static let fiftyDonationIdentifity = "com.cubetime.cubetime.50donation" +} enum ProjectLicense: String { @@ -6,14 +13,18 @@ enum ProjectLicense: String { case tnoodle = "TNoodle" case chartview = "ChartView" case icons = "WCA Icons (Cubing Icons & Fonts)" + case confetti = "ConfettiSwiftUI" case recursivefont = "Recursive Font" case privacypolicy = "CubeTime Privacy Policy" } struct LicensePopUpView: View { - @Environment(\.dismiss) var dismiss - @Binding var projectLicense: ProjectLicense? + var projectLicense: ProjectLicense? + + init(for projectLicense: ProjectLicense?) { + self.projectLicense = projectLicense + } var body: some View { ScrollView { @@ -26,10 +37,13 @@ struct LicensePopUpView: View { ChartViewLicense() case .icons: CubingIconsLicense() + case .confetti: + ConfettiLicense() case .recursivefont: RecursiveLicense() case .privacypolicy: PrivacyPolicy() + default: Text("Could not get license for project") } @@ -40,46 +54,27 @@ struct LicensePopUpView: View { } -struct LicensesPopUpView: View { +struct LicensesView: View { @Environment(\.dismiss) var dismiss - @State var showLicense = false - @Binding var showLicenses: Bool - @State var projectLicense: ProjectLicense? var body: some View { NavigationView { - ZStack { - NavigationLink("", destination: LicensePopUpView(projectLicense: $projectLicense), isActive: $showLicense) + List { + Section("Licenses") { + NavigationLink(ProjectLicense.cubetime.rawValue, destination: LicensePopUpView(for: .cubetime)) + NavigationLink(ProjectLicense.tnoodle.rawValue, destination: LicensePopUpView(for: .tnoodle)) + NavigationLink(ProjectLicense.chartview.rawValue, destination: LicensePopUpView(for: .chartview)) + NavigationLink(ProjectLicense.icons.rawValue, destination: LicensePopUpView(for: .icons)) + NavigationLink(ProjectLicense.confetti.rawValue, destination: LicensePopUpView(for: .confetti)) + NavigationLink(ProjectLicense.recursivefont.rawValue, destination: LicensePopUpView(for: .recursivefont)) + } - List { - Button(ProjectLicense.cubetime.rawValue) { - projectLicense = .cubetime - showLicense = true - } - Button(ProjectLicense.tnoodle.rawValue) { - projectLicense = .tnoodle - showLicense = true - } - Button(ProjectLicense.chartview.rawValue) { - projectLicense = .chartview - showLicense = true - } - Button(ProjectLicense.icons.rawValue) { - projectLicense = .icons - showLicense = true - } - Button(ProjectLicense.recursivefont.rawValue) { - projectLicense = .recursivefont - showLicense = true - } - Button(ProjectLicense.privacypolicy.rawValue) { - projectLicense = .privacypolicy - showLicense = true - } + Section("Privacy Policy") { + NavigationLink(ProjectLicense.privacypolicy.rawValue, destination: LicensePopUpView(for: .privacypolicy)) } } .listStyle(.insetGrouped) - .navigationTitle("Licenses") + .navigationTitle("the boring stuff") .toolbar { ToolbarItem(placement: .navigationBarTrailing) { CTDoneButton(onTapRun: { @@ -94,14 +89,15 @@ struct LicensesPopUpView: View { struct AboutSettingsView: View { @AppStorage("onboarding") var showOnboarding = false @State var showLicenses = false + @State private var showUpdates = false @ScaledMetric(relativeTo: .largeTitle) var iconSize: CGFloat = 60 let parentGeo: GeometryProxy private let versionString: String = Bundle.main.infoDictionary!["CFBundleShortVersionString"] as! String var body: some View { - VStack (alignment: .leading, spacing: 2) { + VStack (alignment: .leading) { HStack(alignment: .bottom) { - Image("about-icon") + Image("launchImage") .resizable() .frame(width: iconSize, height: iconSize) .aspectRatio(contentMode: .fit) @@ -109,14 +105,16 @@ struct AboutSettingsView: View { .padding(.trailing, 6) VStack(alignment: .leading, spacing: 0) { - Text("CubeTime.") - .recursiveMono(fontSize: 28) + Text("CubeTime") + .recursiveMono(size: 28) .foregroundColor(Color("dark")) Text("v" + versionString) - .recursiveMono(fontSize: 15, weight: .semibold) - .foregroundStyle(getGradient(gradientSelected: 0, isStaticGradient: true)) + .recursiveMono(size: 15, weight: .semibold) + .foregroundStyle(GradientManager.getGradient(gradientSelected: 0, isStaticGradient: true)) } + + Spacer() } .frame(height: iconSize) .padding(.bottom, 12) @@ -126,55 +124,63 @@ struct AboutSettingsView: View { #endif - Text("CubeTime is licensed under the GNU GPL v3 license, and uses open source projects and libraries.\n\nClick below for more info on source licenses and our privacy policy:") + Text("CubeTime is open source and licensed under the GNU GPL v3 license.\n\nWe use many open source projects and libraries, and you can view their respective licenses, along with our privacy policy below:") .multilineTextAlignment(.leading) .fixedSize(horizontal: false, vertical: true) - - Button("Open licenses") { + + CTButton(type: .halfcoloured(nil), size: .medium, onTapRun: { showLicenses = true + }) { + Label("Open Licenses & Privacy Policy", systemImage: "arrow.up.forward.square") + .imageScale(.medium) } + .padding(.top, -6) - Text("\nThis project is made possible by [speedcube.co.nz](https://www.speedcube.co.nz/).") - .fixedSize(horizontal: false, vertical: true) Text("\nSupport us directly by donating on Ko-Fi:") .fixedSize(horizontal: false, vertical: true) Button { - guard let kofiLink = URL(string: "https://ko-fi.com/cubetime"), - UIApplication.shared.canOpenURL(kofiLink) else { - return - } - UIApplication.shared.open(kofiLink, - options: [:], - completionHandler: nil) + guard let kofiLink = URL(string: "https://ko-fi.com/cubetime"), UIApplication.shared.canOpenURL(kofiLink) else { return } + UIApplication.shared.open(kofiLink, options: [:], completionHandler: nil) } label: { - HStack { - Spacer() - - Image("kofiButton") - .resizable() - .aspectRatio(contentMode: .fit) - .frame(width: parentGeo.size.width * 0.618) - .frame(maxHeight: 40) - - Spacer() - } + Image("kofiButton") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(width: parentGeo.size.width * 0.618) + .frame(maxHeight: 40) } + .padding(.top, -8) Text("\nIf you run into any issues, please visit our GitHub page and submit an issue. \nhttps://github.com/CubeStuffs/CubeTime/issues") .fixedSize(horizontal: false, vertical: true) + CTButton(type: .halfcoloured(nil), size: .medium, onTapRun: { + self.showUpdates = true + }) { + Text("Show Updates List") + } + .padding(.top, 6) + + #if false Text("\nIf you need a refresher on the primary features, you can see the welcome page again.") Button("Show welcome page") { showOnboarding = true } #endif + + Text("© 2021–2024 Tim Xie & Reagan Bohan.") + .font(.system(size: 13)) + .foregroundColor(Color("grey").opacity(0.36)) + .padding(.top, 64) } .padding(.horizontal) .sheet(isPresented: $showLicenses) { - LicensesPopUpView(showLicenses: $showLicenses) + LicensesView() .tint(Color("accent")) } + .sheet(isPresented: $showUpdates) { + Updates(showUpdates: .constant(true)) + } } } diff --git a/CubeTime/Settings/AppearanceSettings.swift b/CubeTime/Settings/AppearanceSettings.swift index 490dcc32..4417a621 100644 --- a/CubeTime/Settings/AppearanceSettings.swift +++ b/CubeTime/Settings/AppearanceSettings.swift @@ -9,7 +9,8 @@ struct AppearanceSettingsView: View { @Preference(\.isStaticGradient) private var isStaticGradient @Preference(\.graphGlow) private var graphGlow @Preference(\.graphAnimation) private var graphAnimation - + @Preference(\.showConfetti) private var showConfetti + // system settings (appearance) @Preference(\.overrideDM) private var overrideSystemAppearance @Preference(\.dmBool) private var darkMode @@ -25,10 +26,11 @@ struct AppearanceSettingsView: View { @EnvironmentObject var fontManager: FontManager @EnvironmentObject var gradientManager: GradientManager + @State private var showResetDialog = false + var body: some View { VStack(spacing: 16) { - SettingsGroup(Label("Colours", systemImage: "paintbrush.pointed.fill")) { - + SettingsGroup(Label("General Appearance", systemImage: "paintbrush.pointed.fill")) { DescribedSetting(description: "Customise the gradients used in stats. By default, the gradient is set to \"Static\". You can choose to set it to \"Dynamic\", where the gradient will change throughout the day.") { HStack() { Text("Gradient") @@ -50,17 +52,17 @@ struct AppearanceSettingsView: View { } .padding(.top) - if showThemeOptions { + ConditionalSetting(showIf: showThemeOptions) { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 16) { VStack(spacing: 4) { Text("STATIC GRADIENT") - .foregroundColor(Color("grey")) + .foregroundColor(Color("dark")) .font(.footnote.weight(.semibold)) ZStack { RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: true)) + .fill(GradientManager.getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: true)) .frame(height: 50) .onTapGesture { isStaticGradient = true @@ -76,14 +78,14 @@ struct AppearanceSettingsView: View { VStack(spacing: 2) { Text("DYNAMIC GRADIENT") - .foregroundColor(Color("grey")) + .foregroundColor(Color("dark")) .font(.footnote.weight(.semibold)) ZStack { HStack(spacing: 0) { ForEach(0..<10, id: \.self) { index in Rectangle() - .fill(getGradient(gradientSelected: index, isStaticGradient: false)) + .fill(GradientManager.getGradient(gradientSelected: index, isStaticGradient: false)) .frame(height: 175) } } @@ -104,35 +106,43 @@ struct AppearanceSettingsView: View { } } } + .padding(.bottom, 4) + .padding(.top, 8) } - ThemedDivider() + CTDivider() DescribedSetting(description: "Turn on/off the glow effect on graphs.", { - SettingsToggle("Graph Glow", $graphGlow) + SettingsToggle(String(localized: "Graph Glow"), $graphGlow) }) DescribedSetting(description: "Turn on/off the line animation for the time trend graph.", { - SettingsToggle("Graph Animation", $graphAnimation) + SettingsToggle(String(localized: "Graph Animation"), $graphAnimation) + }) + + DescribedSetting(description: "Show a confetti animation when a new best time is recorded.", { + SettingsToggle(String(localized: "Show Confetti"), $showConfetti) }) } + .clipped() SettingsGroup(Label("System Settings", systemImage: "command")) { - SettingsToggle("Override System Appearance", $overrideSystemAppearance) + SettingsToggle(String(localized: "Override System Appearance"), $overrideSystemAppearance) if overrideSystemAppearance { - SettingsToggle("Dark Mode", $darkMode) + SettingsToggle(String(localized: "Dark Mode"), $darkMode) } } SettingsGroup(Label("Font Settings", systemImage: "textformat")) { - SettingsStepper(text: "Scramble Size: ", format: "%d", value: $scrambleSize, in: 15...36, step: 1) + SettingsStepper(text: String(localized: "Scramble Size: "), format: "%d", value: $scrambleSize, in: 15...36, step: 1) VStack(alignment: .leading, spacing: 0) { Text("Preview") .modifier(SettingsFootnote()) VStack { Text("L' D R2 B2 D2 F2 R2 B2 D R2 D R2 U B' R F2 R U' F L2 D'") + .fixedSize(horizontal: false, vertical: true) .font(fontManager.ctFontScramble) .padding() @@ -155,12 +165,30 @@ struct AppearanceSettingsView: View { } } - SettingsDragger(text: "Font Weight", value: $fontWeight, in: 300...800) - SettingsDragger(text: "Font Casualness", value: $fontCasual, in: 0...1) - SettingsToggle("Cursive Font", $fontCursive) + SettingsDragger(text: String(localized: "Font Weight"), value: $fontWeight, in: 300...800) + SettingsDragger(text: String(localized: "Font Casualness"), value: $fontCasual, in: 0...1) + SettingsToggle(String(localized: "Cursive Font"), $fontCursive) } + + CTButton(type: .halfcoloured(Color("red")), size: .large, expandWidth: true, onTapRun: { + showResetDialog = true + }) { + HStack { + Image(systemName: "clock.arrow.circlepath") + + Text("Reset Appearance Settings") + } + } + .padding(.top, 12) } .padding(.horizontal) + .confirmationDialog("Are you sure you want to reset all appearance settings? Your solves and sessions will be kept.", + isPresented: $showResetDialog, + titleVisibility: .visible) { + Button("Reset", role: .destructive) { + SettingsManager.standard.resetAppearanceSettings() + } + } } } diff --git a/CubeTime/Settings/GeneralSettings.swift b/CubeTime/Settings/GeneralSettings.swift index 1e4c05f4..02a65802 100644 --- a/CubeTime/Settings/GeneralSettings.swift +++ b/CubeTime/Settings/GeneralSettings.swift @@ -1,8 +1,10 @@ import SwiftUI -enum InputMode: String, Codable, CaseIterable { +enum InputMode: LocalizedStringKey, Codable, CaseIterable, Identifiable { case timer = "Timer" case typing = "Typing" + + var id: InputMode { self } /*, stackmat, smartcube, virtual*/ } @@ -37,6 +39,7 @@ struct GeneralSettingsView: View { // timer tools @Preference(\.showScramble) private var showScramble @Preference(\.showStats) private var showStats + @Preference(\.showZenMode) private var showZenMode // accessibility @Preference(\.hapticEnabled) private var hapticFeedback @@ -46,37 +49,39 @@ struct GeneralSettingsView: View { @Preference(\.gestureDistance) private var gestureActivationDistance @Preference(\.gestureDistanceTrackpad) private var gestureDistanceTrackpad - // show previous time after delete + // timer interface & solves @Preference(\.showPrevTime) private var showPrevTime - + @Preference(\.promptDelete) private var promptDelete + // statistics @Preference(\.displayDP) private var displayDP + @Preference(\.timeTrendSolves) private var timeTrendSolves - - + @State private var showResetDialog = false + @EnvironmentObject var stopwatchManager: StopwatchManager var body: some View { VStack(spacing: 16) { SettingsGroup(Label("Timer Settings", systemImage: "timer")) { - SettingsToggle("Use Inspection", $inspectionTime) + SettingsToggle(String(localized: "Use Inspection"), $inspectionTime) - if inspectionTime { - SettingsToggle("Inspection Counts Down", $insCountDown) + ConditionalSetting(showIf: inspectionTime) { + SettingsToggle(String(localized: "Inspection Counts Down"), $insCountDown) DescribedSetting(description: "Display a cancel inspection button when inspecting.") { - SettingsToggle("Show Cancel Inspection", $showCancelInspection) + SettingsToggle(String(localized: "Show Cancel Inspection"), $showCancelInspection) } DescribedSetting(description: "Play an audible alert when 8 or 12 seconds is reached.") { - SettingsToggle("Inpsection Alert", $inspectionAlert) + SettingsToggle(String(localized: "Inspection Alert"), $inspectionAlert) } if inspectionAlert { DescribedSetting(description: "To use the 'Boop' option, your phone must not be muted. 'Boop' plays a system sound, requiring your ringer to be unmuted.") { - SettingsPicker(text: "Inspection Alert Type", selection: $inspectionAlertType, maxWidth: 120) { + SettingsPicker(text: String(localized: "Inspection Alert Type"), selection: $inspectionAlertType, maxWidth: 120) { Text("Voice").tag(0) Text("Boop").tag(1) } @@ -84,7 +89,7 @@ struct GeneralSettingsView: View { } DescribedSetting(description: "With this setting on, the **Voice** alert type will play through system audio, respecting your phone's ringer mute state.") { - SettingsToggle("Inspection Alert Follows System Silent Mode", $inspectionAlertFollowsSilent) + SettingsToggle(String(localized: "Inspection Alert Follows System Silent Mode"), $inspectionAlertFollowsSilent) } .onChange(of: inspectionAlertFollowsSilent, perform: { _ in setupAudioSession(with: inspectionAlertFollowsSilent ? .ambient : .playback) @@ -92,49 +97,64 @@ struct GeneralSettingsView: View { } } - ThemedDivider() + CTDivider() - SettingsStepper(text: "Hold Down Time: ", format: "%.2fs", value: $holdDownTime, in: 0.05...1.0, step: 0.05) + SettingsStepper(text: String(localized: "Hold Down Time: "), format: "%.2fs", value: $holdDownTime, in: 0.00...1.0, step: 0.05) - ThemedDivider() + CTDivider() - SettingsPicker(text: "Timer Mode", selection: $inputMode) { - ForEach(Array(InputMode.allCases), id: \.self) { mode in - Text(mode.rawValue) + VStack(spacing: 4) { + SettingsPicker(text: String(localized: "Timer Mode"), selection: $inputMode) { + ForEach(Array(InputMode.allCases), id: \.self) { mode in + Text(mode.rawValue) + } } - } - .pickerStyle(.menu) - + .pickerStyle(.menu) - if inputMode == .timer { - SettingsPicker(text: "Timer Update", selection: $timerDP) { - Text("None") - .tag(-1) - ForEach(0...3, id: \.self) { - Text("\($0) d.p") + + if inputMode == .timer { + SettingsPicker(text: String(localized: "Timer Update"), selection: $timerDP) { + Text("None") + .tag(-1) + ForEach(0...3, id: \.self) { + Text("\($0) d.p") + } } + .pickerStyle(.menu) } - .pickerStyle(.menu) } } SettingsGroup(Label("Timer Tools", systemImage: "wrench")) { - DescribedSetting(description: "Show draw scramble or statistics on the timer screen.") { - SettingsToggle("Show draw scramble on timer", $showScramble) - SettingsToggle("Show stats on timer", $showStats) + DescribedSetting(description: "Show tools on the timer screen.") { + SettingsToggle(String(localized: "Show draw scramble on timer"), $showScramble) + if !UIDevice.deviceIsPad { + SettingsToggle(String(localized: "Show stats on timer"), $showStats) + } + } + var desc = String(localized: "Show a button in the top right corner to enter zen mode.") + if UIDevice.deviceIsPad { + let _ = {desc += String(localized: " Note that this is only applicable in compact UI mode (i.e. when the floating panel is not shown).")}() + } + DescribedSetting(description: LocalizedStringKey.init(desc)) { + SettingsToggle(String(localized: "Show zen mode button"), $showZenMode) } } - SettingsGroup(Label("Timer Interface", systemImage: "rectangle.and.pencil.and.ellipsis")) { + SettingsGroup(Label("Timer Interface & Solves", systemImage: "rectangle.and.pencil.and.ellipsis")) { DescribedSetting(description: "Show the previous time after a solve is deleted by swipe gesture. With this option off, the default time of 0.00 or 0.000 will be shown instead.") { - SettingsToggle("Show Previous Time", $showPrevTime) + SettingsToggle(String(localized: "Show Previous Time"), $showPrevTime) } + + DescribedSetting(description: "Display a prompt before deleting solves.", { + SettingsToggle(String(localized: "Show Solve Deletion Prompt"), $promptDelete) + }) } SettingsGroup(Label("Accessibility", systemImage: "eye")) { - SettingsToggle("Haptic Feedback", $hapticFeedback) - if hapticFeedback { - SettingsPicker(text: "Haptic Intensity", selection: $feedbackType) { + SettingsToggle(String(localized: "Haptic Feedback"), $hapticFeedback) + ConditionalSetting(showIf: hapticFeedback) { + SettingsPicker(text: String(localized: "Haptic Intensity"), selection: $feedbackType) { ForEach(Array(UIImpactFeedbackGenerator.FeedbackStyle.allCases), id: \.self) { mode in Text(hapticNames[mode]!) } @@ -142,29 +162,70 @@ struct GeneralSettingsView: View { .pickerStyle(.menu) } - ThemedDivider() + CTDivider() - SettingsDragger(text: UIDevice.deviceIsPad ? "Touch Gesture Activation Distance" : "Gesture Activation Distance", value: $gestureActivationDistance, in: 20...300) + SettingsDragger(text: UIDevice.deviceIsPad ? String(localized: "Touch Gesture Activation Distance") : String(localized: "Gesture Activation Distance"), value: $gestureActivationDistance, in: 20...300) if UIDevice.deviceIsPad { - SettingsDragger(text: "Trackpad Gesture Activation Distance", value: $gestureActivationDistance, in: 100...1000) + SettingsDragger(text: "Trackpad Gesture Activation Distance", value: $gestureDistanceTrackpad, in: 100...1000) } } SettingsGroup(Label("Statistics", systemImage: "chart.bar.xaxis")) { - SettingsPicker(text: "Times Displayed To: ", selection: $displayDP) { + SettingsPicker(text: String(localized: "Times Displayed To: "), selection: $displayDP) { ForEach(2...3, id: \.self) { Text("\($0) d.p") .tag($0) } } .pickerStyle(.menu) + + SettingsPicker(text: String(localized: "Time Trend Show: "), selection: $timeTrendSolves) { + ForEach([25, 50, 80, 100, 200], id: \.self) { + Text("\($0) Solves") + .tag($0) + } + } + } + + SettingsGroup(Label("Language & Localisation", systemImage: "globe")) { + HStack { + Text("Language") + .font(.body.weight(.medium)) + + Spacer() + + CTButton(type: .halfcoloured(nil), size: .medium, onTapRun: { + UIApplication.shared.open(URL(string: UIApplication.openSettingsURLString)!) + }) { + Text("Open settings") + } + } + } + + CTButton(type: .halfcoloured(Color("red")), size: .large, expandWidth: true, onTapRun: { + showResetDialog = true + }) { + HStack { + Image(systemName: "clock.arrow.circlepath") + + Text("Reset General Settings") + } } + .padding(.top, 12) } .padding(.horizontal) .animation(Animation.customSlowSpring, value: inspectionTime) + .animation(Animation.customSlowSpring, value: inspectionAlert) .animation(Animation.customSlowSpring, value: hapticFeedback) + .confirmationDialog("Are you sure you want to reset all general settings? Your solves and sessions will be kept.", + isPresented: $showResetDialog, + titleVisibility: .visible) { + Button("Reset", role: .destructive) { + SettingsManager.standard.resetGeneralSettings() + } + } } } diff --git a/CubeTime/Settings/Licenses.swift b/CubeTime/Settings/Licenses.swift index 04097717..b80d000a 100644 --- a/CubeTime/Settings/Licenses.swift +++ b/CubeTime/Settings/Licenses.swift @@ -3,7 +3,7 @@ import SwiftUI struct CubeTimeLicense: View { var body: some View { VStack { - Text("© 2021-2022 Tim Xie and Reagan Bohan\nCubeTime is licensed under the GNU GPL v3 license, which is shown below.") + Text(verbatim: "© 2021–2024 Tim Xie & Reagan Bohan.\nCubeTime is licensed under the GNU GPL v3 license, which is shown below.") Divider() GPLLicense() @@ -16,7 +16,7 @@ struct CubeTimeLicense: View { struct tnoodleLicense: View { var body: some View { VStack { - Text("tnoodle's scrambler (tnoodle-lib), the official wca scrambler, is used in CubeTime and is licensed under the GNU GPL v3 license.") + Text(verbatim: "tnoodle's scrambler (tnoodle-lib), the official wca scrambler, is used in CubeTime and is licensed under the GNU GPL v3 license.") Divider() GPLLicense() } @@ -28,7 +28,7 @@ struct tnoodleLicense: View { struct ChartViewLicense: View { var body: some View { VStack { - Text("A derivative of ChartView is used in the stats view of CubeTime.") + Text("A derivative of [ChartView](https://github.com/AppPear/ChartView) is used in the stats view of CubeTime.") Divider() MITLicense(copyrightInfo: "2019 Andras Samu") } @@ -39,7 +39,7 @@ struct ChartViewLicense: View { struct CubingIconsLicense: View { var body: some View { VStack { - Text("A derivative of \"Cubing's\" icons are used throughout CubeTime.") + Text(verbatim: "A derivative of \"Cubing's\" icons are used throughout CubeTime.") Divider() MITLicense(copyrightInfo: "2015 Devin Corr-Robinett") } @@ -48,10 +48,23 @@ struct CubingIconsLicense: View { } } + +struct ConfettiLicense: View { + var body: some View { + VStack { + Text("A derivative of \"simibac's\" [ConfettiSwiftUI](https://github.com/simibac/ConfettiSwiftUI) library is used in CubeTime.") + Divider() + MITLicense(copyrightInfo: "2020 Simon Bachmann") + } + .padding(.horizontal) + + } +} + struct RecursiveLicense: View { var body: some View { VStack { - Text("The recursive font is used in logos of CubeTime.") + Text(verbatim: "The recursive font is used within CubeTime and in marketing materials.") Divider() OpenFontLicense(prefix: "Copyright 2020 The Recursive Project Authors (https://github.com/arrowtype/recursive)") } @@ -62,7 +75,7 @@ struct RecursiveLicense: View { struct MITLicense: View { let copyrightInfo: String var body: some View { - Text(""" + Text(verbatim: """ Copyright (c) \(copyrightInfo) Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: @@ -76,18 +89,23 @@ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLI } struct PrivacyPolicy: View { + let privacyPolicy: LocalizedStringKey = """ + CubeTime itself does not collect any data, "personal" or otherwise. + Your solves (data not comprising of personal information including but not limited to the "scramble" used, the date of the solve, the time taken to complete the solve) and your sessions (data including but not limited to named groups of solves, again with no personal data) are stored locally on your device only, unless you are signed into an iCloud account *and* choose to sync your sessions and solves between iCloud-enabled devices. + + **Optionally**, CubeTime will use Apple iCloud to store your sessions. CubeTime's data that is stored on iCloud can be viewed and deleted in the CubeTime app. This data is handled by Apple's CloudKit service, which is encypted by an account-based key. Your data is stored in a privacy database and we do **not** have acccess to this data. Additionally, we do **not** have access to your Apple ID. + The Apple iCloud privacy policy is available at https://www.apple.com/legal/privacy/, and further Apple platform security can be viewed [here](https://support.apple.com/en-is/guide/security/welcome/web). + + The source code of CubeTime is publically viewable at https://github.com/CubeStuffs/CubeTime for independent verification. + """ + var body: some View { - Text("CubeTime privacy policy") + Text(verbatim: "CubeTime Privacy Policy") .font(.title) .padding(.horizontal) Divider() .padding(.horizontal) - Text(""" - CubeTime itself does not collect any data, "personal" or otherwise. - Your solves (data not comprising of personal information including but not limited to the "scramble" used, the date of the solve, the time taken to complete the solve) and your sessions (data including but not limited to named groups of solves, again with no personal data) are stored locally on your device only. - - The source code of CubeTime is publically viewable at https://github.com/CubeStuffs/CubeTime for independent verification. - """) + Text(privacyPolicy) .padding(.horizontal) } } @@ -96,35 +114,35 @@ struct OpenFontLicense: View { let prefix: String var body: some View { Group { - Text(prefix) + Text(verbatim: prefix) .font(Font.system(.body, design: .serif)) - Text("This Font Software is licensed under the SIL Open Font License, Version 1.1.\nThis license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL") + Text(verbatim: "This Font Software is licensed under the SIL Open Font License, Version 1.1.\nThis license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL") .font(Font.system(.body, design: .serif)) Divider() - Text("SIL OPEN FONT LICENSE") + Text(verbatim: "SIL OPEN FONT LICENSE") .font(Font.system(.title, design: .serif).weight(.bold)) .multilineTextAlignment(.center) - Text("Version 1.1 - 26 February 2007") + Text(verbatim: "Version 1.1 - 26 February 2007") .font(Font.system(.body, design: .serif)) - Text("PREAMBLE") + Text(verbatim: "PREAMBLE") .font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: """ The goals of the Open Font License (OFL) are to stimulate worldwide development of collaborative font projects, to support the font creation efforts of academic and linguistic communities, and to provide a free and open framework in which fonts may be shared and improved in partnership with others. The OFL allows the licensed fonts to be used, studied, modified and redistributed freely as long as they are not sold by themselves. The fonts, including any derivative works, can be bundled, embedded, redistributed and/or sold with any software provided that any reserved names are not used by derivative works. The fonts and derivatives, however, cannot be released under any other type of license. The requirement for fonts to remain under this license does not apply to any document created using the fonts or their derivatives. """).font(Font.system(.body, design: .serif)) - Text("DEFINITIONS") + Text(verbatim: "DEFINITIONS") .font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: """ "Font Software" refers to the set of files released by the Copyright Holder(s) under this license and clearly marked as such. This may include source files, build scripts and documentation. @@ -137,12 +155,12 @@ include source files, build scripts and documentation. "Author" refers to any designer, engineer, programmer, technical writer or other person who contributed to the Font Software. """).font(Font.system(.body, design: .serif)) - Text("PERMISSION & CONDITIONS") + Text(verbatim: "PERMISSION & CONDITIONS") .font(Font.system(.title3, design: .serif).italic()) } Group { - Text(""" + Text(verbatim: """ Permission is hereby granted, free of charge, to any person obtaining a copy of the Font Software, to use, study, copy, merge, embed, modify, redistribute, and sell modified and unmodified copies of the Font Software, subject to the following conditions: 1) Neither the Font Software nor any of its individual components, in Original or Modified Versions, may be sold by itself. @@ -156,16 +174,16 @@ Permission is hereby granted, free of charge, to any person obtaining a copy of 5) The Font Software, modified or unmodified, in part or in whole, must be distributed entirely under this license, and must not be distributed under any other license. The requirement for fonts to remain under this license does not apply to any document created using the Font Software. """).font(Font.system(.body, design: .serif)) - Text("TERMINATION") + Text(verbatim: "TERMINATION") .font(Font.system(.title3, design: .serif).italic()) - Text("This license becomes null and void if any of the above conditions are not met.") + Text(verbatim: "This license becomes null and void if any of the above conditions are not met.") .font(Font.system(.body, design: .serif)) - Text("DISCLAIMER") + Text(verbatim: "DISCLAIMER") .font(Font.system(.title3, design: .serif).italic()) - Text("THE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.") + Text(verbatim: "THE FONT SOFTWARE IS PROVIDED \"AS IS\", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM OTHER DEALINGS IN THE FONT SOFTWARE.") .font(Font.system(.body, design: .serif)) } } @@ -176,18 +194,18 @@ struct GPLLicense: View { var body: some View { Group { - Text("GNU GENERAL PUBLIC LICENSE") + Text(verbatim: "GNU GENERAL PUBLIC LICENSE") .font(Font.system(.title, design: .serif).weight(.bold)) .multilineTextAlignment(.center) - Text(""" + Text(verbatim: """ Version 3, 29 June 2007 Copyright © 2007 Free Software Foundation, Inc. Everyone is permitted to copy and distribute verbatim copies of this license document, but changing it is not allowed. """) .font(Font.system(.body, design: .serif)) - Text("Premable") + Text(verbatim: "Premable") .font(Font.system(.title2, design: .serif)) - Text(""" + Text(verbatim: """ The GNU General Public License is a free, copyleft license for software and other kinds of works. The licenses for most software and other practical works are designed to take away your freedom to share and change the works. By contrast, the GNU General Public License is intended to guarantee your freedom to share and change all versions of a program--to make sure it remains free software for all its users. We, the Free Software Foundation, use the GNU General Public License for most of our software; it applies also to any other work released this way by its authors. You can apply it to your programs, too. When we speak of free software, we are referring to freedom, not price. Our General Public Licenses are designed to make sure that you have the freedom to distribute copies of free software (and charge for them if you wish), that you receive source code or can get it if you want it, that you can change the software or use pieces of it in new free programs, and that you know you can do these things. @@ -200,11 +218,11 @@ Finally, every program is threatened constantly by software patents. States shou The precise terms and conditions for copying, distribution and modification follow. """).font(Font.system(.body, design: .serif)) .font(Font.system(.body, design: .serif)) - Text("TERMS AND CONDITIONS") + Text(verbatim: "TERMS AND CONDITIONS") .font(Font.system(.title2, design: .serif)) - Text("0. Definitions.") + Text(verbatim: "0. Definitions.") .font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: """ “This License” refers to version 3 of the GNU General Public License. “Copyright” also means copyright-like laws that apply to other kinds of works, such as semiconductor masks. “The Program” refers to any copyrightable work licensed under this License. Each licensee is addressed as “you”. “Licensees” and “recipients” may be individuals or organizations. @@ -214,8 +232,8 @@ To “propagate” a work means to do anything with it that, without permission, To “convey” a work means any kind of propagation that enables other parties to make or receive copies. Mere interaction with a user through a computer network, with no transfer of a copy, is not conveying. An interactive user interface displays “Appropriate Legal Notices” to the extent that it includes a convenient and prominently visible feature that (1) displays an appropriate copyright notice, and (2) tells the user that there is no warranty for the work (except to the extent that warranties are provided), that licensees may convey the work under this License, and how to view a copy of this License. If the interface presents a list of user commands or options, such as a menu, a prominent item in the list meets this criterion. """).font(Font.system(.body, design: .serif)) - Text("1. Source Code.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "1. Source Code.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ The “source code” for a work means the preferred form of the work for making modifications to it. “Object code” means any non-source form of a work. A “Standard Interface” means an interface that either is an official standard defined by a recognized standards body, or, in the case of interfaces specified for a particular programming language, one that is widely used among developers working in that language. The “System Libraries” of an executable work include anything, other than the work as a whole, that (a) is included in the normal form of packaging a Major Component, but which is not part of that Major Component, and (b) serves only to enable use of the work with that Major Component, or to implement a Standard Interface for which an implementation is available to the public in source code form. A “Major Component”, in this context, means a major essential component (kernel, window system, and so on) of the specific operating system (if any) on which the executable work runs, or a compiler used to produce the work, or an object code interpreter used to run it. @@ -223,26 +241,26 @@ The “Corresponding Source” for a work in object code form means all the sour The Corresponding Source need not include anything that users can regenerate automatically from other parts of the Corresponding Source. The Corresponding Source for a work in source code form is that same work. """).font(Font.system(.body, design: .serif)) - Text("2. Basic Permissions.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: "2. Basic Permissions.").font(Font.system(.title3, design: .serif).italic()) } Group { - Text(""" + Text(verbatim: """ All rights granted under this License are granted for the term of copyright on the Program, and are irrevocable provided the stated conditions are met. This License explicitly affirms your unlimited permission to run the unmodified Program. The output from running a covered work is covered by this License only if the output, given its content, constitutes a covered work. This License acknowledges your rights of fair use or other equivalent, as provided by copyright law. You may make, run and propagate covered works that you do not convey, without conditions so long as your license otherwise remains in force. You may convey covered works to others for the sole purpose of having them make modifications exclusively for you, or provide you with facilities for running those works, provided that you comply with the terms of this License in conveying all material for which you do not control copyright. Those thus making or running the covered works for you must do so exclusively on your behalf, under your direction and control, on terms that prohibit them from making any copies of your copyrighted material outside their relationship with you. Conveying under any other circumstances is permitted solely under the conditions stated below. Sublicensing is not allowed; section 10 makes it unnecessary. """).font(Font.system(.body, design: .serif)) - Text("3. Protecting Users' Legal Rights From Anti-Circumvention Law.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "3. Protecting Users' Legal Rights From Anti-Circumvention Law.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ No covered work shall be deemed part of an effective technological measure under any applicable law fulfilling obligations under article 11 of the WIPO copyright treaty adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention of such measures. When you convey a covered work, you waive any legal power to forbid circumvention of technological measures to the extent such circumvention is effected by exercising rights under this License with respect to the covered work, and you disclaim any intention to limit operation or modification of the work as a means of enforcing, against the work's users, your or third parties' legal rights to forbid circumvention of technological measures. """).font(Font.system(.body, design: .serif)) - Text("4. Conveying Verbatim Copies.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "4. Conveying Verbatim Copies.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ You may convey verbatim copies of the Program's source code as you receive it, in any medium, provided that you conspicuously and appropriately publish on each copy an appropriate copyright notice; keep intact all notices stating that this License and any non-permissive terms added in accord with section 7 apply to the code; keep intact all notices of the absence of any warranty; and give all recipients a copy of this License along with the Program. You may charge any price or no price for each copy that you convey, and you may offer support or warranty protection for a fee. """).font(Font.system(.body, design: .serif)) - Text("5. Conveying Modified Source Versions.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "5. Conveying Modified Source Versions.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ You may convey a work based on the Program, or the modifications to produce it from the Program, in the form of source code under the terms of section 4, provided that you also meet all of these conditions: • a) The work must carry prominent notices stating that you modified it, and giving a relevant date. • b) The work must carry prominent notices stating that it is released under this License and any conditions added under section 7. This requirement modifies the requirement in section 4 to “keep intact all notices”. @@ -250,8 +268,8 @@ You may convey a work based on the Program, or the modifications to produce it f • d) If the work has interactive user interfaces, each must display Appropriate Legal Notices; however, if the Program has interactive interfaces that do not display Appropriate Legal Notices, your work need not make them do so. A compilation of a covered work with other separate and independent works, which are not by their nature extensions of the covered work, and which are not combined with it such as to form a larger program, in or on a volume of a storage or distribution medium, is called an “aggregate” if the compilation and its resulting copyright are not used to limit the access or legal rights of the compilation's users beyond what the individual works permit. Inclusion of a covered work in an aggregate does not cause this License to apply to the other parts of the aggregate. """).font(Font.system(.body, design: .serif)) - Text("6. Conveying Non-Source Forms.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "6. Conveying Non-Source Forms.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ You may convey a covered work in object code form under the terms of sections 4 and 5, provided that you also convey the machine-readable Corresponding Source under the terms of this License, in one of these ways: • a) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by the Corresponding Source fixed on a durable physical medium customarily used for software interchange. • b) Convey the object code in, or embodied in, a physical product (including a physical distribution medium), accompanied by a written offer, valid for at least three years and valid for as long as you offer spare parts or customer support for that product model, to give anyone who possesses the object code either (1) a copy of the Corresponding Source for all the software in the product that is covered by this License, on a durable physical medium customarily used for software interchange, for a price no more than your reasonable cost of physically performing this conveying of source, or (2) access to copy the Corresponding Source from a network server at no charge. @@ -265,10 +283,10 @@ If you convey an object code work under this section in, or with, or specificall The requirement to provide Installation Information does not include a requirement to continue to provide support service, warranty, or updates for a work that has been modified or installed by the recipient, or for the User Product in which it has been modified or installed. Access to a network may be denied when the modification itself materially and adversely affects the operation of the network or violates the rules and protocols for communication across the network. Corresponding Source conveyed, and Installation Information provided, in accord with this section must be in a format that is publicly documented (and with an implementation available to the public in source code form), and must require no special password or key for unpacking, reading or copying. """).font(Font.system(.body, design: .serif)) - Text("7. Additional Terms.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: "7. Additional Terms.").font(Font.system(.title3, design: .serif).italic()) } Group { - Text(""" + Text(verbatim: """ “Additional permissions” are terms that supplement the terms of this License by making exceptions from one or more of its conditions. Additional permissions that are applicable to the entire Program shall be treated as though they were included in this License, to the extent that they are valid under applicable law. If additional permissions apply only to part of the Program, that part may be used separately under those permissions, but the entire Program remains governed by this License without regard to the additional permissions. When you convey a copy of a covered work, you may at your option remove any additional permissions from that copy, or from any part of it. (Additional permissions may be written to require their own removal in certain cases when you modify the work.) You may place additional permissions on material, added by you to a covered work, for which you have or can give appropriate copyright permission. Notwithstanding any other provision of this License, for material you add to a covered work, you may (if authorized by the copyright holders of that material) supplement the terms of this License with terms: @@ -282,25 +300,25 @@ All other non-permissive additional terms are considered “further restrictions If you add terms to a covered work in accord with this section, you must place, in the relevant source files, a statement of the additional terms that apply to those files, or a notice indicating where to find the applicable terms. Additional terms, permissive or non-permissive, may be stated in the form of a separately written license, or stated as exceptions; the above requirements apply either way. """).font(Font.system(.body, design: .serif)) - Text("8. Termination.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "8. Termination.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ You may not propagate or modify a covered work except as expressly provided under this License. Any attempt otherwise to propagate or modify it is void, and will automatically terminate your rights under this License (including any patent licenses granted under the third paragraph of section 11). However, if you cease all violation of this License, then your license from a particular copyright holder is reinstated (a) provisionally, unless and until the copyright holder explicitly and finally terminates your license, and (b) permanently, if the copyright holder fails to notify you of the violation by some reasonable means prior to 60 days after the cessation. Moreover, your license from a particular copyright holder is reinstated permanently if the copyright holder notifies you of the violation by some reasonable means, this is the first time you have received notice of violation of this License (for any work) from that copyright holder, and you cure the violation prior to 30 days after your receipt of the notice. Termination of your rights under this section does not terminate the licenses of parties who have received copies or rights from you under this License. If your rights have been terminated and not permanently reinstated, you do not qualify to receive new licenses for the same material under section 10. """).font(Font.system(.body, design: .serif)) - Text("9. Acceptance Not Required for Having Copies.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "9. Acceptance Not Required for Having Copies.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ You are not required to accept this License in order to receive or run a copy of the Program. Ancillary propagation of a covered work occurring solely as a consequence of using peer-to-peer transmission to receive a copy likewise does not require acceptance. However, nothing other than this License grants you permission to propagate or modify any covered work. These actions infringe copyright if you do not accept this License. Therefore, by modifying or propagating a covered work, you indicate your acceptance of this License to do so. """).font(Font.system(.body, design: .serif)) - Text("10. Automatic Licensing of Downstream Recipients.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "10. Automatic Licensing of Downstream Recipients.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ Each time you convey a covered work, the recipient automatically receives a license from the original licensors, to run, modify and propagate that work, subject to this License. You are not responsible for enforcing compliance by third parties with this License. An “entity transaction” is a transaction transferring control of an organization, or substantially all assets of one, or subdividing an organization, or merging organizations. If propagation of a covered work results from an entity transaction, each party to that transaction who receives a copy of the work also receives whatever licenses to the work the party's predecessor in interest had or could give under the previous paragraph, plus a right to possession of the Corresponding Source of the work from the predecessor in interest, if the predecessor has it or can get it with reasonable efforts. You may not impose any further restrictions on the exercise of the rights granted or affirmed under this License. For example, you may not impose a license fee, royalty, or other charge for exercise of rights granted under this License, and you may not initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging that any patent claim is infringed by making, using, selling, offering for sale, or importing the Program or any portion of it. """).font(Font.system(.body, design: .serif)) - Text("11. Patents.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "11. Patents.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ A “contributor” is a copyright holder who authorizes use under this License of the Program or a work on which the Program is based. The work thus licensed is called the contributor's “contributor version”. A contributor's “essential patent claims” are all patent claims owned or controlled by the contributor, whether already acquired or hereafter acquired, that would be infringed by some manner, permitted by this License, of making, using, or selling its contributor version, but do not include claims that would be infringed only as a consequence of further modification of the contributor version. For purposes of this definition, “control” includes the right to grant patent sublicenses in a manner consistent with the requirements of this License. Each contributor grants you a non-exclusive, worldwide, royalty-free patent license under the contributor's essential patent claims, to make, use, sell, offer for sale, import and otherwise run, modify and propagate the contents of its contributor version. @@ -310,48 +328,48 @@ If, pursuant to or in connection with a single transaction or arrangement, you c A patent license is “discriminatory” if it does not include within the scope of its coverage, prohibits the exercise of, or is conditioned on the non-exercise of one or more of the rights that are specifically granted under this License. You may not convey a covered work if you are a party to an arrangement with a third party that is in the business of distributing software, under which you make payment to the third party based on the extent of your activity of conveying the work, and under which the third party grants, to any of the parties who would receive the covered work from you, a discriminatory patent license (a) in connection with copies of the covered work conveyed by you (or copies made from those copies), or (b) primarily for and in connection with specific products or compilations that contain the covered work, unless you entered into that arrangement, or that patent license was granted, prior to 28 March 2007. Nothing in this License shall be construed as excluding or limiting any implied license or other defenses to infringement that may otherwise be available to you under applicable patent law. """).font(Font.system(.body, design: .serif)) - Text("12. No Surrender of Others' Freedom.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: "12. No Surrender of Others' Freedom.").font(Font.system(.title3, design: .serif).italic()) }.multilineTextAlignment(.leading) Group { - Text(""" + Text(verbatim: """ If conditions are imposed on you (whether by court order, agreement or otherwise) that contradict the conditions of this License, they do not excuse you from the conditions of this License. If you cannot convey a covered work so as to satisfy simultaneously your obligations under this License and any other pertinent obligations, then as a consequence you may not convey it at all. For example, if you agree to terms that obligate you to collect a royalty for further conveying from those to whom you convey the Program, the only way you could satisfy both those terms and this License would be to refrain entirely from conveying the Program. """).font(Font.system(.body, design: .serif)) - Text("13. Use with the GNU Affero General Public License.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "13. Use with the GNU Affero General Public License.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ Notwithstanding any other provision of this License, you have permission to link or combine any covered work with a work licensed under version 3 of the GNU Affero General Public License into a single combined work, and to convey the resulting work. The terms of this License will continue to apply to the part which is the covered work, but the special requirements of the GNU Affero General Public License, section 13, concerning interaction through a network will apply to the combination as such. """).font(Font.system(.body, design: .serif)) - Text("14. Revised Versions of this License.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "14. Revised Versions of this License.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ The Free Software Foundation may publish revised and/or new versions of the GNU General Public License from time to time. Such new versions will be similar in spirit to the present version, but may differ in detail to address new problems or concerns. Each version is given a distinguishing version number. If the Program specifies that a certain numbered version of the GNU General Public License “or any later version” applies to it, you have the option of following the terms and conditions either of that numbered version or of any later version published by the Free Software Foundation. If the Program does not specify a version number of the GNU General Public License, you may choose any version ever published by the Free Software Foundation. If the Program specifies that a proxy can decide which future versions of the GNU General Public License can be used, that proxy's public statement of acceptance of a version permanently authorizes you to choose that version for the Program. Later license versions may give you additional or different permissions. However, no additional obligations are imposed on any author or copyright holder as a result of your choosing to follow a later version. """).font(Font.system(.body, design: .serif)) - Text("15. Disclaimer of Warranty.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "15. Disclaimer of Warranty.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. """).font(Font.system(.body, design: .serif)) - Text("16. Limitation of Liability.").font(Font.system(.title3, design: .serif).italic()) - Text(""" + Text(verbatim: "16. Limitation of Liability.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: """ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF SUCH DAMAGES. """).font(Font.system(.body, design: .serif)) - Text("17. Interpretation of Sections 15 and 16.").font(Font.system(.title3, design: .serif).italic()) + Text(verbatim: "17. Interpretation of Sections 15 and 16.").font(Font.system(.title3, design: .serif).italic()) } Group { - Text(""" + Text(verbatim: """ If the disclaimer of warranty and limitation of liability provided above cannot be given local legal effect according to their terms, reviewing courts shall apply local law that most closely approximates an absolute waiver of all civil liability in connection with the Program, unless a warranty or assumption of liability accompanies a copy of the Program in return for a fee. END OF TERMS AND CONDITIONS """).font(Font.system(.body, design: .serif)) - Text("\n\nHow to Apply These Terms to Your New Programs").font(Font.system(.title2, design: .serif)) - Text(""" + Text(verbatim: "\n\nHow to Apply These Terms to Your New Programs").font(Font.system(.title2, design: .serif)) + Text(verbatim: """ If you develop a new program, and you want it to be of the greatest possible use to the public, the best way to achieve this is to make it free software which everyone can redistribute and change under these terms. To do so, attach the following notices to the program. It is safest to attach them to the start of each source file to most effectively state the exclusion of warranty; and each file should have at least the “copyright” line and a pointer to where the full notice is found. """).font(Font.system(.body, design: .serif)) - Text(""" + Text(verbatim: """ Copyright (C) @@ -368,17 +386,17 @@ To do so, attach the following notices to the program. It is safest to attach th You should have received a copy of the GNU General Public License along with this program. If not, see . """).font(.system(.body, design: .monospaced)) - Text(""" + Text(verbatim: """ Also add information on how to contact you by electronic and paper mail. If the program does terminal interaction, make it output a short notice like this when it starts in an interactive mode: """).font(Font.system(.body, design: .serif)) - Text(""" + Text(verbatim: """ Copyright (C) This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. This is free software, and you are welcome to redistribute it under certain conditions; type `show c' for details. """).font(.system(.body, design: .monospaced)) - Text(""" + Text(verbatim: """ The hypothetical commands `show w' and `show c' should show the appropriate parts of the General Public License. Of course, your program's commands might be different; for a GUI interface, you would use an “about box”. You should also get your employer (if you work as a programmer) or school, if any, to sign a “copyright disclaimer” for the program, if necessary. For more information on this, and how to apply and follow the GNU GPL, see . The GNU General Public License does not permit incorporating your program into proprietary programs. If your program is a subroutine library, you may consider it more useful to permit linking proprietary applications with the library. If this is what you want to do, use the GNU Lesser General Public License instead of this License. But first, please read . diff --git a/CubeTime/Settings/SettingsCard.swift b/CubeTime/Settings/SettingsCard.swift index 7fb57540..6de847f3 100644 --- a/CubeTime/Settings/SettingsCard.swift +++ b/CubeTime/Settings/SettingsCard.swift @@ -3,15 +3,19 @@ import SwiftUI let defaultIconStyle: Font = .system(size: 44, weight: .light) let specialIconStyle: Font = .system(size: 32, weight: .regular) +enum SettingsType { + case general, appearance, help +} + struct SettingsCardInfo: Hashable { var name: String + var id: SettingsType var icon: String var iconStyle: Font } var settingsCards: [SettingsCardInfo] = [ - SettingsCardInfo(name: "General", icon: "gearshape.2", iconStyle: defaultIconStyle), - SettingsCardInfo(name: "Appearance", icon: "paintpalette", iconStyle: defaultIconStyle), - SettingsCardInfo(name: "Import &\nExport", icon: "square.and.arrow.up.on.square", iconStyle: specialIconStyle), - SettingsCardInfo(name: "Help &\nAbout Us", icon: "info", iconStyle: defaultIconStyle), + SettingsCardInfo(name: String(localized: "General"), id: .general, icon: "gearshape.2", iconStyle: defaultIconStyle), + SettingsCardInfo(name: String(localized: "Appearance"), id: .appearance, icon: "paintpalette", iconStyle: defaultIconStyle), + SettingsCardInfo(name: String(localized: "Help &\nAbout Us"), id: .help, icon: "info", iconStyle: defaultIconStyle), ] diff --git a/CubeTime/Settings/SettingsManager.swift b/CubeTime/Settings/SettingsManager.swift index c163c001..10b95aef 100644 --- a/CubeTime/Settings/SettingsManager.swift +++ b/CubeTime/Settings/SettingsManager.swift @@ -6,27 +6,64 @@ final class SettingsManager { static let standard = SettingsManager(userDefaults: .default) fileprivate let userDefaults: NSUbiquitousKeyValueStore + fileprivate var keys: [String: AnyKeyPath] = [:] + var preferencesChangedSubject = PassthroughSubject() @objc func ubiquitousKeyValueStoreDidChange(notification: NSNotification) { - NSLog("EXTERNAL CHANGE") - if let changeReason = notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] { - NSLog("change: \(changeReason)") + if let changeReason = (notification.userInfo?[NSUbiquitousKeyValueStoreChangedKeysKey] as? NSArray)?.firstObject as? NSString, + let keyPath = self.keys[String(changeReason)] { + preferencesChangedSubject.send(keyPath) } } + // do not look at the functions below, unsafe for consumption + func resetGeneralSettings() { + holdDownTime = 0.5 + inspection = false + inspectionCountsDown = false + showCancelInspection = true + inspectionAlert = true + inspectionAlertType = 0 + inspectionAlertFollowsSilent = false + inputMode = .timer + timeDpWhenRunning = 3 + showSessionType = false + hapticEnabled = true + hapticType = .rigid + gestureDistance = 50 + gestureDistanceTrackpad = 500 + showScramble = true + showStats = true + forceAppZoom = false + appZoom = AppZoom(rawValue: 3) + showPrevTime = false + displayDP = 3 + } + + func resetAppearanceSettings() { + overrideDM = false + dmBool = false + isStaticGradient = true + graphGlow = true + graphAnimation = true + scrambleSize = UIDevice.deviceIsPad ? 26 : 18 + fontWeight = 516.0 + fontCasual = 0.0 + fontCursive = false + showZenMode = false + promptDelete = false + } + init(userDefaults: NSUbiquitousKeyValueStore) { self.userDefaults = userDefaults let ret = userDefaults.synchronize() - #if DEBUG - NSLog("Syncronize returned: \(ret)") - #endif + NotificationCenter.default.addObserver(self, selector: #selector( ubiquitousKeyValueStoreDidChange), name: NSUbiquitousKeyValueStore.didChangeExternallyNotification, object: self.userDefaults) - } // MARK: - General Settings @@ -91,6 +128,9 @@ final class SettingsManager { @UserDefault("displayDP") var displayDP: Int = 3 + @UserDefault("timeTrendSolves") + var timeTrendSolves: Int = 80 + // MARK: - Appearance Settings @@ -109,6 +149,9 @@ final class SettingsManager { @UserDefault("graphAnimation") var graphAnimation: Bool = true + @UserDefault("showConfetti") + var showConfetti: Bool = true + @UserDefault("scrambleSize") var scrambleSize: Int = UIDevice.deviceIsPad ? 26 : 18 @@ -120,6 +163,12 @@ final class SettingsManager { @UserDefault("fontCursive") var fontCursive: Bool = false + + @UserDefault("showZenMode") + var showZenMode: Bool = false + + @UserDefault("promptDelete") + var promptDelete: Bool = false } @propertyWrapper @@ -156,6 +205,9 @@ struct UserDefault { guard let pref = container.object(forKey: key) as? FeedbackType.RawValue else { return defaultValue } return FeedbackType(rawValue: pref) as! Value? ?? defaultValue } + + instance.keys[key] = wrappedKeyPath + return container.object(forKey: key) as? Value ?? defaultValue } set { @@ -175,7 +227,9 @@ final class PublisherObservableObject: ObservableObject { var subscriber: AnyCancellable? init(publisher: AnyPublisher) { - subscriber = publisher.sink(receiveValue: { [weak self] _ in + subscriber = publisher + .receive(on: DispatchQueue.main) + .sink(receiveValue: { [weak self] _ in self?.objectWillChange.send() }) } diff --git a/CubeTime/Settings/SettingsView.swift b/CubeTime/Settings/SettingsView.swift index 89f27446..f7fe0b69 100644 --- a/CubeTime/Settings/SettingsView.swift +++ b/CubeTime/Settings/SettingsView.swift @@ -30,14 +30,8 @@ struct SettingsViewInner: View { SettingsCard(currentCard: $currentCard, info: settingsCards[0], namespace: namespace) SettingsCard(currentCard: $currentCard, info: settingsCards[1], namespace: namespace) } - /* bring this back (the 4 grid) once importexport added - HStack (spacing: 16) { - SettingsCard(currentCard: $currentCard, info: settingsCards[2], namespace: namespace) -// SettingsCard(currentCard: $currentCard, info: settingsCards[3], namespace: namespace) - } - */ - SettingsCard(currentCard: $currentCard, info: settingsCards[3], namespace: namespace) + SettingsCard(currentCard: $currentCard, info: settingsCards[2], namespace: namespace) Spacer() @@ -64,12 +58,13 @@ struct SettingsViewInner: View { struct SettingsView: View { @State var currentCard: SettingsCardInfo? + @Namespace var namespace var body: some View { ZStack { NavigationView { - SettingsViewInner(currentCard: self.$currentCard, namespace: namespace) + SettingsViewInner(currentCard: $currentCard, namespace: namespace) } .navigationViewStyle(StackNavigationViewStyle()) .overlay( @@ -91,7 +86,7 @@ struct SettingsCard: View { // this if statement is temporary for when there are only 3 blocks // keep ONLY the top statement (for general and appearance) to apply to all // once import and export is added - if info.name == "General" || info.name == "Appearance" { + if info.id == .general || info.id == .appearance { Button { withAnimation(Animation.customSlowSpring) { currentCard = info @@ -100,20 +95,20 @@ struct SettingsCard: View { ZStack { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color("overlay0")) - .matchedGeometryEffect(id: "bg " + info.name, in: namespace) + .matchedGeometryEffect(id: "bg \(info.id)", in: namespace) .frame(height: globalGeometrySize.height/3.5, alignment: .center) .shadowLight(x: 0, y: 3) VStack { HStack { Text(info.name) - .matchedGeometryEffect(id: info.name, in: namespace) + .matchedGeometryEffect(id: info.id, in: namespace) .minimumScaleFactor(0.75) - .lineLimit(info.name == "Appearance" ? 1 : 2) + .lineLimit(info.id == .appearance ? 1 : 2) .allowsTightening(true) .font(.title2.weight(.bold)) - .padding(.horizontal, info.name == "Appearance" ? 14 : nil) - .padding(.top, info.name == "Appearance" ? 15 : 12) + .padding(.horizontal, info.id == .appearance ? 14 : nil) + .padding(.top, info.id == .appearance ? 15 : 12) Spacer() @@ -144,20 +139,20 @@ struct SettingsCard: View { ZStack { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color("overlay0")) - .matchedGeometryEffect(id: "bg " + info.name, in: namespace) + .matchedGeometryEffect(id: "bg \(info.id)", in: namespace) .frame(height: globalGeometrySize.height/7, alignment: .center) .shadowLight(x: 0, y: 3) VStack { HStack { Text(info.name) - .matchedGeometryEffect(id: info.name, in: namespace) + .matchedGeometryEffect(id: info.id, in: namespace) .minimumScaleFactor(0.75) - .lineLimit(info.name == "Appearance" ? 1 : 2) + .lineLimit(info.id == .appearance ? 1 : 2) .allowsTightening(true) .font(.title2.weight(.bold)) - .padding(.horizontal, info.name == "Appearance" ? 14 : nil) - .padding(.top, info.name == "Appearance" ? 15 : 12) + .padding(.horizontal, info.id == .appearance ? 14 : nil) + .padding(.top, info.id == .appearance ? 15 : 12) Spacer() @@ -184,23 +179,19 @@ struct SettingsDetail: View { var namespace: Namespace.ID var body: some View { - if currentCard != nil { + if let currentCard { GeometryReader { geo in ZStack { BackgroundColour() .zIndex(0) ScrollView { - #warning("TODO: use an enum for better i18n support") - switch currentCard!.name { - case "General": + switch currentCard.id { + case .general: GeneralSettingsView() - case "Appearance": + case .appearance: AppearanceSettingsView() - case "Import &\nExport": - EmptyView() - // ImportExportSettingsView() - case "Help &\nAbout Us": + case .help: AboutSettingsView(parentGeo: geo) default: EmptyView() @@ -218,7 +209,7 @@ struct SettingsDetail: View { ZStack { RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(Color("overlay0")) - .matchedGeometryEffect(id: "bg " + currentCard!.name, in: namespace) + .matchedGeometryEffect(id: "bg \(currentCard.id)", in: namespace) .ignoresSafeArea() .shadowLight(x: 0, y: 3) @@ -226,20 +217,20 @@ struct SettingsDetail: View { Spacer() HStack(alignment: .center) { - Text(currentCard!.name) - .matchedGeometryEffect(id: currentCard!.name, in: namespace) + Text(currentCard.name) + .matchedGeometryEffect(id: currentCard.id, in: namespace) .minimumScaleFactor(0.75) // .lineLimit(1) - .lineLimit(currentCard!.name == "Appearance" ? 1 : 2) + .lineLimit(currentCard.id == .appearance ? 1 : 2) .allowsTightening(true) .font(.title2.weight(.bold)) Spacer() - Image(systemName: currentCard!.icon) - .matchedGeometryEffect(id: currentCard!.icon, in: namespace) - .font(currentCard!.iconStyle) + Image(systemName: currentCard.icon) + .matchedGeometryEffect(id: currentCard.icon, in: namespace) + .font(currentCard.iconStyle) } .padding() } @@ -257,7 +248,7 @@ struct SettingsDetail: View { CTCloseButton { withAnimation(Animation.customSlowSpring) { - currentCard = nil + self.currentCard = nil } } .padding([.horizontal, .bottom]) diff --git a/CubeTime/Settings/SettingsWidgets.swift b/CubeTime/Settings/SettingsWidgets.swift index 66ac0ae4..b75d4841 100644 --- a/CubeTime/Settings/SettingsWidgets.swift +++ b/CubeTime/Settings/SettingsWidgets.swift @@ -22,18 +22,17 @@ struct SettingsGroup: View { self.content = content } - - var body: some View { VStack(alignment: .leading) { label .labelStyle(SettingsHeaderLabelStyle()) .padding([.horizontal, .top], 10) + VStack(alignment: .leading, content: content) .padding(.horizontal) } .padding(.bottom, 12) - .background(Color("overlay0").clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))) + .background(Color("overlay1").clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))) } } @@ -58,7 +57,7 @@ struct DescribedSetting: View { } var body: some View { - VStack { + VStack(spacing: 6) { content() Text(description) @@ -68,6 +67,31 @@ struct DescribedSetting: View { } } +struct ConditionalSetting: View { + @ViewBuilder let content: () -> V + let condition: Bool + + init(showIf condition: Bool, @ViewBuilder _ content: @escaping () -> V) { + self.condition = condition + self.content = content + } + + var body: some View { + VStack { + if condition { + content() + } else { + Spacer() + .frame(maxHeight: 10) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 2) + .clipped() + .padding(.horizontal, -2) + } +} + struct SettingsToggle: View { let text: String @Binding var isOn: Bool @@ -105,7 +129,7 @@ struct SettingsPicker: View { Spacer() - Picker("Test", selection: $selection) { + Picker("", selection: $selection) { content() } .frame(maxWidth: maxWidth) diff --git a/CubeTime/Splash.storyboard b/CubeTime/Splash.storyboard index 429e5260..2572a16f 100644 --- a/CubeTime/Splash.storyboard +++ b/CubeTime/Splash.storyboard @@ -1,9 +1,9 @@ - + - + diff --git a/CubeTime/Stats/AveragePhases.swift b/CubeTime/Stats/AveragePhases.swift index 7a1d6121..3c755082 100644 --- a/CubeTime/Stats/AveragePhases.swift +++ b/CubeTime/Stats/AveragePhases.swift @@ -7,8 +7,6 @@ import SwiftUI -let phaseColours: [Color] = [.red, .orange, .yellow, .green, .mint, .teal, .cyan, .blue, Color("accent")] - /// source: https://stackoverflow.com/a/46729248/17569741 extension UIColor { func toColor(_ color: UIColor, percentage: CGFloat) -> UIColor { @@ -39,7 +37,7 @@ struct AveragePhases: View { let count: Int var body: some View { - let selectedGradient = getGradientColours(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient) + let selectedGradient = GradientManager.getGradientColours(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient) let gradientStart: UIColor = UIColor(selectedGradient[0]) let gradientEnd: UIColor = UIColor(selectedGradient[1]) @@ -72,11 +70,11 @@ struct AveragePhases: View { HStack(spacing: 12) { Text(formatSolveTime(secs: phase)) - .recursiveMono(fontSize: 16, weight: .medium) + .recursiveMono(size: 16, weight: .medium) Text("("+String(format: "%.1f", (phase / phaseTimes.reduce(0, +))*100)+"%)") .foregroundColor(Color("grey")) - .recursiveMono(fontSize: 15, weight: .regular) + .recursiveMono(size: 15, weight: .regular) } Spacer() @@ -89,7 +87,7 @@ struct AveragePhases: View { .padding(.bottom, 12) } else { Text("not enough solves to\ndisplay graph") - .recursiveMono(fontSize: 17, weight: .medium) + .recursiveMono(size: 17, weight: .medium) .multilineTextAlignment(.center) .foregroundColor(Color("grey")) .offset(y: 5) diff --git a/CubeTime/Stats/ScrollableLineChart.swift b/CubeTime/Stats/ScrollableLineChart.swift deleted file mode 100644 index f5b4eb13..00000000 --- a/CubeTime/Stats/ScrollableLineChart.swift +++ /dev/null @@ -1,247 +0,0 @@ -// -// ScrollableLineChart.swift -// CubeTime -// -// Created by Tim Xie on 2/03/23. -// -import UIKit -import SwiftUI - -private let dotDiameter: CGFloat = 6 - -struct LineChartPoint { - var point: CGPoint - var rawValue: Double - - init(value: Double, position: Double, min: Double, max: Double, boundsHeight: CGFloat) { - self.rawValue = value - self.point = CGPoint() - self.point.y = getStandardisedYLocation(value: value, min: min, max: max, boundsHeight: boundsHeight) - self.point.x = position - } - - func pointIn(_ other: CGPoint) -> Bool { - let rect = CGRect(x: point.x - dotDiameter / 2, y: point.y - dotDiameter / 2, width: dotDiameter, height: dotDiameter) - return rect.contains(other) - } -} - -func getStandardisedYLocation(value: Double, min: Double, max: Double, boundsHeight: CGFloat) -> CGFloat { - return ((value - min) / (max - min)) * boundsHeight -} - -func getRandomData() -> [Double] { - var temp: [Double] = [] - for _ in 0..<10000 { - temp.append(Double.random(in: 0...2)) - } - - return temp -} - -func makeData(_ data: [Double], _ limits: (min: Double, max: Double), _ boundsHeight: CGFloat=UIScreen.screenHeight*0.618) -> [LineChartPoint] { - return data.enumerated().map({ (i, e) in - return LineChartPoint(value: e, position: Double(i*30), min: limits.min, max: limits.max, boundsHeight: boundsHeight) - }) -} - - -class TimeDistViewController: UIViewController { - let points: [LineChartPoint] - let gapDelta: Int - let averageValue: Double - - let limits: (min: Double, max: Double) - - - - private let dotSize: CGFloat = 6 - - init(points: [LineChartPoint], gapDelta: Int, averageValue: Double, limits: (min: Double, max: Double)) { - self.points = points - self.gapDelta = gapDelta - self.averageValue = averageValue - self.limits = limits - super.init(nibName: nil, bundle: nil) - } - - required init?(coder: NSCoder) { - fatalError("init(coder:) has not been implemented") - } - - var imageView: UIImageView! - - override func viewDidLoad() { -// let textView = UITextView() -// textView.text = formatSolveTime(secs: limits.min) -// let size = textView.sizeThatFits(CGSize(width: Double.infinity, height: .infinity)) -// textView.frame = CGRect(x: self.view.frame.width - size.width, y: UIScreen.screenHeight*0.618 - size.height / 2, width: size.width, height: size.height) -// -// textView.backgroundColor = .white -// textView.layer.zPosition = 2 -// -// self.view.addSubview(textView) -// -// NSLog("FRAME TEXT \(textView.frame)") - - let rawImageHeight = UIScreen.screenHeight*0.618 - let imageHeight: CGFloat = rawImageHeight + 10 - let imagePadding: CGFloat = 5 - - let imageSize = CGSize(width: CGFloat(points.count * gapDelta), - height: imageHeight) - - UIGraphicsBeginImageContextWithOptions(imageSize, false, 0) - let context = UIGraphicsGetCurrentContext()! - -// let height = getStandardisedYLocation(value: averageValue, -// min: limits.min, -// max: limits.max, -// boundsHeight: rawImageHeight) - - context.move(to: CGPoint(x: 0, y: imageHeight - imagePadding)) - - context.setLineDash(phase: 0, lengths: [12, 6]) - context.setStrokeColor(UIColor(Color("grey")).cgColor) - context.setLineWidth(2) - context.setLineJoin(.round) - context.setLineCap(.round) - context.addLine(to: CGPoint(x: 1200, y: imageHeight - imagePadding)) - - context.drawPath(using: .stroke) - - context.setLineDash(phase: 0, lengths: []) - context.setStrokeColor(UIColor(Color("accent")).cgColor) - - let trendLine: UIBezierPath = UIBezierPath() - - for i in 0 ..< points.count { - let p = points[i].point - if (trendLine.isEmpty) { - trendLine.move(to: CGPointMake(p.x + dotSize/2, p.y + imagePadding)) - } else { - trendLine.addLine(to: CGPointMake(p.x, p.y + imagePadding)) -// let a = points[i-1].point -// let b = points[i].point -// let mid = CGPoint(x: (a.x + b.x) / 2, y: (a.y + b.y) / 2) -// trendLine.addQuadCurve(to: points[i].point, controlPoint: mid) - } - } - - trendLine.lineWidth = 3 - trendLine.lineCapStyle = .round - trendLine.lineJoinStyle = .round - - trendLine.stroke() - - trendLine.addLine(to: CGPoint(x: points.last!.point.x, y: imageHeight - imagePadding)) - trendLine.addLine(to: CGPoint(x: 0, y: imageHeight - imagePadding)) - - - let grad = CGGradient(colorsSpace: .none, colors: [UIColor(Color("accent").opacity(0.6)).cgColor, UIColor(Color("accent").opacity(0.2)).cgColor] as CFArray, locations: [0, 1])! - -// context.saveGState() - trendLine.addClip() - - context.drawLinearGradient(grad, start: CGPoint(x: 0, y: 0), end: CGPoint(x: 0, y: imageHeight - imagePadding), options: [] ) - - - context.resetClip() - - for p in points { - let circlePoint = CGRect(x: p.point.x - dotSize/2, - y: p.point.y - dotSize/2 + imagePadding, - width: dotSize, - height: dotSize) - - context.setFillColor(UIColor.red.cgColor) - context.fillEllipse(in: circlePoint) - } - - - - let newImage = UIGraphicsGetImageFromCurrentImageContext()! - - let scrollView = UIScrollView() - imageView = UIImageView(image: newImage) -// imageView.contentMode = .scaleToFill -// - let fr = imageView.frame -// fr.size = newImage.size - let newY = (self.view.frame.height - fr.height) / 2 - imageView.frame = CGRect(x: fr.minX, y: newY, width: fr.width, height: fr.height) - - - -// self.view.addSubview(scrollView) - self.view.addSubview(scrollView) - scrollView.addSubview(imageView) - scrollView.frame = view.frame - scrollView.contentSize = newImage.size - - imageView.isUserInteractionEnabled = true - imageView.addGestureRecognizer( - UITapGestureRecognizer(target: self, action: #selector(clicked(g: ))) - ) - } - - @objc func clicked(g: UITapGestureRecognizer) { - var p = g.location(in: imageView) - p.y += 5 - let pointWhere = points.first(where: {$0.pointIn(p)}) - } -} - - -struct DetailTimeTrendBase: UIViewControllerRepresentable { - let points: [LineChartPoint] - let gapDelta: Int - let averageValue: Double - - let limits: (min: Double, max: Double) - - init(rawDataPoints: [Double], limits: (min: Double, max: Double), averageValue: Double, gapDelta: Int = 30) { - self.points = makeData(rawDataPoints, limits) - self.averageValue = averageValue - self.limits = limits - self.gapDelta = gapDelta - } - - init(premadePoints: [LineChartPoint], limits: (min: Double, max: Double), averageValue: Double, gapDelta: Int = 30) { - self.points = premadePoints - self.averageValue = averageValue - self.limits = limits - self.gapDelta = gapDelta - } - - func makeUIViewController(context: Context) -> some UIViewController { - let view = TimeDistViewController(points: points, gapDelta: gapDelta, averageValue: averageValue, limits: limits) - return view - } - - func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { - - } -} - - -struct DetailTimeTrend: View { - @Environment(\.dismiss) var dismiss - - let rawDataPoints: [Double] - let limits: (min: Double, max: Double) - let averageValue: Double - - var body: some View { - NavigationView { - DetailTimeTrendBase(rawDataPoints: rawDataPoints, limits: limits, averageValue: averageValue) - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - CTDoneButton(onTapRun: { - dismiss() - }) - } - } - } - } -} diff --git a/CubeTime/Stats/StatsDetailView.swift b/CubeTime/Stats/StatsDetailView.swift index 73ef3317..b9c47893 100644 --- a/CubeTime/Stats/StatsDetailView.swift +++ b/CubeTime/Stats/StatsDetailView.swift @@ -18,31 +18,33 @@ struct StatsTimeList: View { VStack(spacing: 0) { HStack(alignment: .bottom) { Text("\(index+1).") - .font(.callout.weight(.bold)) + .font(.callout.weight(.semibold)) .foregroundColor(Color("accent")) - if isTrimmed { - Text("(" + solve.timeText + ")") - .offset(y: 1) - .font(.title3.weight(.bold)) - .foregroundColor(Color("grey")) - - } else { - Text(solve.timeText) - .offset(y: 1) - .font(.title3.weight(.bold)) + + Group { + if isTrimmed { + Text("(" + solve.timeText + ")") + .offset(y: 1) + .foregroundColor(Color("grey")) + + } else { + Text(solve.timeText) + .offset(y: 1) + } } + .recursiveMono(style: .title3, weight: .bold) Spacer() if let date = solve.date { Text(date, formatter: getSolveDateFormatter(date)) - .recursiveMono(fontSize: 15, weight: .regular) + .font(.subheadline) .foregroundColor(Color("grey")) } else { Text("...") - .recursiveMono(fontSize: 15, weight: .regular) + .font(.subheadline) .foregroundColor(Color("grey")) } } @@ -62,7 +64,8 @@ struct StatsTimeList: View { Spacer() } - .recursiveMono(fontSize: 17, weight: .regular) + .recursiveMono(size: 16, weight: .regular) + .padding(.top, 4) .padding(.bottom, (index != calculatedAverage.accountedSolves!.indices.last!) ? 8 : 0) } .onTapGesture { @@ -90,50 +93,49 @@ struct StatsDetailView: View { VStack { VStack(spacing: 4) { HStack(alignment: .bottom) { - Text(formatSolveTime(secs: solves.average!, penType: solves.totalPen)) - .font(.largeTitle.weight(.bold)) + Text(formatSolveTime(secs: solves.average!, penalty: solves.totalPen)) + .recursiveMono(style: .largeTitle, weight: .bold) Spacer() - - // if playground, show playground, otherwise show puzzle type - - HStack(alignment: .center) { + } + + CTDivider() + + HStack { + HStack(alignment: .center, spacing: 4) { if (SessionType(rawValue: session.sessionType)! == .playground) { Text("Playground") - .font(.title3.weight(.semibold)) Image(systemName: "square.on.square") .resizable() - .frame(width: 22, height: 22) + .frame(width: 16, height: 16) } else { - Text(puzzleTypes[Int(session.scrambleType)].name) - .font(.title3.weight(.semibold)) - - Image(puzzleTypes[Int(session.scrambleType)].name) + Image(PUZZLE_TYPES[Int(session.scrambleType)].imageName) .resizable() - .frame(width: 22, height: 22) + .frame(width: 16, height: 16) + Text(PUZZLE_TYPES[Int(session.scrambleType)].name) } - } - .offset(y: -4) + + Text("|") + .offset(y: -1) // slight offset of bar + + Text(solves.name == "Compsim Solve" ? "compsim" : solves.name.lowercased()) + + + Spacer() } - - ThemedDivider() - - Text(solves.name == "Comp Sim Solve" ? "COMPSIM" : solves.name.uppercased()) - .recursiveMono(fontSize: 15, weight: .regular) - .foregroundColor(Color("grey")) - .frame(maxWidth: .infinity, alignment: .leading) + .font(.subheadline.weight(.medium)) } .padding(12) .background(RoundedRectangle(cornerRadius: 8, style: .continuous).fill(Color("overlay1"))) .padding(.top) - Text("CubeTime.") - .recursiveMono(fontSize: 13) - .foregroundColor(Color("indent1")) + Text("CubeTime") + .recursiveMono(size: 13) + .foregroundColor(Color("grey").opacity(0.36)) .frame(maxWidth: .infinity, alignment: .trailing) .padding(.vertical, -4) @@ -142,7 +144,7 @@ struct StatsDetailView: View { HStack(spacing: 8) { CTCopyButton(toCopy: shareStr, buttonText: "Copy Average") - CTShareButton(toShare: shareStr, buttonText: "Share Average") + CTShareButton(toShare: shareStr, buttonText: String(localized: "Share Average")) } .padding(.top, 16) .padding(.bottom, 4) @@ -151,7 +153,7 @@ struct StatsDetailView: View { Text("TIMES") .font(.subheadline.weight(.semibold)) - ThemedDivider() + CTDivider() StatsTimeList(solveDetail: $solveDetail, calculatedAverage: solves) @@ -177,7 +179,7 @@ struct StatsDetailView: View { } } .navigationBarTitleDisplayMode(.inline) - .navigationTitle(solves.name == "Comp Sim Solve" ? "Comp Sim" : solves.name) + .navigationTitle("Stats Detail") .sheet(item: $solveDetail) { item in TimeDetailView(for: item, currentSolve: $solveDetail) diff --git a/CubeTime/Stats/StatsSubviews.swift b/CubeTime/Stats/StatsSubviews.swift index 71df8c59..9e905244 100644 --- a/CubeTime/Stats/StatsSubviews.swift +++ b/CubeTime/Stats/StatsSubviews.swift @@ -32,12 +32,12 @@ struct StatsBlock: View { switch (background) { case .default: self.background = (AnyView(isTappable - ? Color("overlay0") + ? Color("overlay0") : Color("overlay1")), .default) - + case .coloured: self.background = (AnyView(Color.red), .coloured) - + case .clear: self.background = (AnyView(Color.white.opacity(0.0001)), .clear) } @@ -65,7 +65,7 @@ struct StatsBlock: View { .padding(.horizontal, 12) .background( (self.background.1 == .coloured - ? AnyView(getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient)) + ? AnyView(GradientManager.getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient)) : self.background.0) .clipShape(RoundedRectangle(cornerRadius: 12)) ) @@ -79,7 +79,7 @@ struct StatsBlockText: View { @Environment(\.globalGeometrySize) var globalGeometrySize @EnvironmentObject var gradientManager: GradientManager @Preference(\.isStaticGradient) private var isStaticGradient - + let displayText: String let colouredText: Bool @@ -110,7 +110,7 @@ struct StatsBlockText: View { Group { if (colouredText) { Text(displayText) - .foregroundStyle(getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient)) + .foregroundStyle(GradientManager.getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient)) } else { Text(displayText) .foregroundColor( @@ -120,11 +120,11 @@ struct StatsBlockText: View { ) } } - .font(.largeTitle.weight(.bold)) + .recursiveMono(style: .largeTitle, weight: .bold) .modifier(DynamicText()) } else { Text("-") - .font(.title.weight(.medium)) + .recursiveMono(style: .title, weight: .medium) .foregroundColor(colouredBlock ? Color(hex: 0xF6F7FC) // hardcoded : Color("grey")) @@ -148,10 +148,10 @@ struct StatsBlockDetailText: View { ForEach(calculatedAverage.accountedSolves!, id: \.self) { solve in let discarded = calculatedAverage.trimmedSolves!.contains(solve) - let time = formatSolveTime(secs: solve.time, penType: Penalty(rawValue: solve.penalty)!) + let time = formatSolveTime(secs: solve.time, penalty: Penalty(rawValue: solve.penalty)!) Text(discarded ? "("+time+")" : time) - .font(.body) + .recursiveMono(style: .body) .foregroundColor( discarded ? colouredBlock @@ -174,7 +174,7 @@ struct StatsBlockDetailText: View { struct StatsBlockSmallText: View { @ScaledMetric private var spacing: CGFloat = -4 - + let titles: [String] let data: [CalculatedAverage?] @Binding var presentedAvg: CalculatedAverage? @@ -191,42 +191,40 @@ struct StatsBlockSmallText: View { } var body: some View { - VStack(alignment: .leading) { - ForEach(Array(zip(titles.indices, titles)), id: \.0) { index, title in - HStack { - VStack(alignment: .leading, spacing: spacing) { - Text(title) - .font(.footnote.weight(.medium)) - .foregroundColor(Color("grey")) - - if let datum = data[index] { - Text(formatSolveTime(secs: datum.average ?? 0, penType: datum.totalPen)) - .font(.title2.weight(.bold)) - .foregroundColor(Color("dark")) - .modifier(DynamicText()) - } else { - Text("-") - .font(.title3.weight(.medium)) + GeometryReader { proxy in + VStack(alignment: .leading, spacing: 0) { + ForEach(Array(zip(titles.indices, titles)), id: \.0) { index, title in + HStack { + VStack(alignment: .leading, spacing: spacing) { + Text(title) + .font(.footnote.weight(.medium)) .foregroundColor(Color("grey")) + + if let datum = data[index] { + Text(formatSolveTime(secs: datum.average ?? 0, penalty: datum.totalPen)) + .recursiveMono(style: .title2, weight: .bold) + .foregroundColor(Color("dark")) + .modifier(DynamicText()) + } else { + Text("-") + .recursiveMono(style: .title3, weight: .medium) + .foregroundColor(Color("grey")) + } } + + Spacer() } - - Spacer() - } - .contentShape(Rectangle()) - .onTapGesture { - if data[index] != nil { - presentedAvg = data[index] + .contentShape(Rectangle()) + .onTapGesture { + if data[index] != nil { + presentedAvg = data[index] + } } - } - - if (index < titles.count-1) { - Spacer(minLength: 0) + .frame(height: (proxy.size.height - CGFloat((titles.count - 1) * 8)) / CGFloat(titles.count) + 8) } } } - .frame(height: blockHeight-28-20) .padding(.top, 28) - .padding(.bottom, 20) + .padding(.bottom, 12) } } diff --git a/CubeTime/Stats/StatsView.swift b/CubeTime/Stats/StatsView.swift index d6194e01..641d1737 100644 --- a/CubeTime/Stats/StatsView.swift +++ b/CubeTime/Stats/StatsView.swift @@ -1,5 +1,6 @@ import SwiftUI import CoreData +import Charts struct StatsView: View { @Environment(\.managedObjectContext) var managedObjectContext @@ -20,12 +21,13 @@ struct StatsView: View { @ScaledMetric var blockHeightGraphEmpty = 150 @ScaledMetric(relativeTo: .body) var monospacedFontSizeBody: CGFloat = 17 - @State private var presentedAvg: CalculatedAverage? @State private var showBestSinglePopup = false @State private var showTimeTrendModal = false + @Preference(\.timeTrendSolves) private var timeTrendSolves + var body: some View { NavigationView { @@ -37,7 +39,7 @@ struct StatsView: View { VStack { #if DEBUG Button { - for _ in 0..<3140 { + for _ in 0..<10000 { let solve: Solve = Solve(context: managedObjectContext) solve.time = Double.random(in: 0...10) solve.scramble = "sdlfkjsdlfksdjf" @@ -45,9 +47,9 @@ struct StatsView: View { solve.scrambleType = 2 solve.penalty = Penalty.none.rawValue solve.session = stopwatchManager.currentSession - - try! managedObjectContext.save() } + + try! managedObjectContext.save() NSLog("finished") } label: { Text("generate") @@ -56,24 +58,24 @@ struct StatsView: View { SessionHeader() .padding(.horizontal) - + let compsim: Bool = SessionType(rawValue: stopwatchManager.currentSession.sessionType)! == .compsim /// everything VStack(spacing: 10) { if !compsim { HStack(spacing: 10) { - StatsBlock(title: "CURRENT STATS", blockHeight: blockHeightLarge) { + StatsBlock(title: String(localized: "CURRENT STATS"), blockHeight: blockHeightLarge) { StatsBlockSmallText(titles: ["AO5", "AO12", "AO100"], data: [stopwatchManager.currentAo5, stopwatchManager.currentAo12, stopwatchManager.currentAo100], presentedAvg: $presentedAvg, blockHeight: blockHeightLarge) } .frame(minWidth: 0, maxWidth: .infinity) VStack(spacing: 10) { - StatsBlock(title: "SOLVE COUNT", blockHeight: blockHeightSmall, isTappable: false) { + StatsBlock(title: String(localized: "SOLVE COUNT"), blockHeight: blockHeightSmall, isTappable: false) { StatsBlockText(displayText: "\(stopwatchManager.getNumberOfSolves())", nilCondition: true) } - StatsBlock(title: "SESSION MEAN", blockHeight: blockHeightSmall, isTappable: false) { + StatsBlock(title: String(localized: "SESSION MEAN"), blockHeight: blockHeightSmall, isTappable: false) { if let sessionMean = stopwatchManager.sessionMean { StatsBlockText(displayText: formatSolveTime(secs: sessionMean), nilCondition: true) } else { @@ -84,48 +86,49 @@ struct StatsView: View { .frame(minWidth: 0, maxWidth: .infinity) } .padding(.horizontal) - .padding(.bottom, 8) - HStack(spacing: 10) { VStack (spacing: 10) { - StatsBlock(title: "BEST SINGLE", blockHeight: blockHeightSmall, background: .coloured) { - if let bestSingle = stopwatchManager.bestSingle { - StatsBlockText(displayText: formatSolveTime(secs: bestSingle.time, penType: Penalty(rawValue: bestSingle.penalty)!), colouredBlock: true, displayDetail: false, nilCondition: true) - } else { - StatsBlockText(displayText: "", colouredBlock: true, nilCondition: false) - } - } - .onTapGesture { + Button { if stopwatchManager.bestSingle != nil { showBestSinglePopup = true } + } label: { + StatsBlock(title: String(localized: "BEST SINGLE"), blockHeight: blockHeightSmall, background: .coloured) { + if let bestSingle = stopwatchManager.bestSingle { + StatsBlockText(displayText: formatSolveTime(secs: bestSingle.time, penalty: Penalty(rawValue: bestSingle.penalty)!), colouredBlock: true, displayDetail: false, nilCondition: true) + } else { + StatsBlockText(displayText: "", colouredBlock: true, nilCondition: false) + } + } } + .buttonStyle(CTButtonStyle()) - StatsBlock(title: "BEST STATS", blockHeight: blockHeightMedium) { + StatsBlock(title: String(localized: "BEST STATS"), blockHeight: blockHeightMedium) { StatsBlockSmallText(titles: ["AO12", "AO100"], data: [stopwatchManager.bestAo12, stopwatchManager.bestAo100], presentedAvg: $presentedAvg, blockHeight: blockHeightMedium) } } .frame(minWidth: 0, maxWidth: .infinity) + #warning("TODO: FIX ALL OF THESE CHANGE TO BUTTON WITH CUSTOM BUTTON STYLE") ZStack(alignment: .topLeading) { if let bestAo5 = stopwatchManager.bestAo5 { - StatsBlock(title: "", blockHeight: blockHeightExtraLarge) { + StatsBlock(title: String(localized: ""), blockHeight: blockHeightExtraLarge) { StatsBlockDetailText(calculatedAverage: bestAo5, colouredBlock: false) } - StatsBlock(title: "BEST AO5", blockHeight: blockHeightSmall) { - StatsBlockText(displayText: formatSolveTime(secs: bestAo5.average ?? 0, penType: bestAo5.totalPen), colouredText: true, displayDetail: true, nilCondition: true, blockHeight: blockHeightSmall) + StatsBlock(title: String(localized: "BEST AO5"), blockHeight: blockHeightSmall) { + StatsBlockText(displayText: formatSolveTime(secs: bestAo5.average ?? 0, penalty: bestAo5.totalPen), colouredText: true, displayDetail: true, nilCondition: true, blockHeight: blockHeightSmall) } } else { - StatsBlock(title: "", blockHeight: blockHeightExtraLarge) { + StatsBlock(title: String(localized: ""), blockHeight: blockHeightExtraLarge) { HStack { Text("") Spacer() } } - StatsBlock(title: "BEST AO5", blockHeight: blockHeightSmall) { + StatsBlock(title: String(localized: "BEST AO5"), blockHeight: blockHeightSmall) { StatsBlockText(displayText: "", nilCondition: false) } } @@ -138,12 +141,11 @@ struct StatsView: View { .frame(minWidth: 0, maxWidth: .infinity) } .padding(.horizontal) - .padding(.bottom, 8) if SessionType(rawValue: stopwatchManager.currentSession.sessionType)! == .multiphase { - StatsBlock(title: "AVERAGE PHASES", blockHeight: stopwatchManager.solvesNoDNFs.count == 0 ? blockHeightGraphEmpty : nil, isBigBlock: true, isTappable: false) { - AveragePhases(phaseTimes: stopwatchManager.phases!, count: stopwatchManager.solvesNoDNFsbyDate.count) + StatsBlock(title: String(localized: "AVERAGE PHASES"), blockHeight: stopwatchManager.solvesNoDNFs.count == 0 ? blockHeightGraphEmpty : nil, isBigBlock: true, isTappable: false) { + AveragePhases(phaseTimes: stopwatchManager.phases!, count: stopwatchManager.solvesNoDNFsbyDate.count) } } } else { @@ -151,25 +153,25 @@ struct StatsView: View { VStack(spacing: 10) { ZStack(alignment: .topLeading) { if let currentCompsimAverage = stopwatchManager.currentCompsimAverage { - StatsBlock(title: "", blockHeight: blockHeightExtraLarge) { + StatsBlock(title: String(localized: ""), blockHeight: blockHeightExtraLarge) { StatsBlockDetailText(calculatedAverage: currentCompsimAverage, colouredBlock: false) } - StatsBlock(title: "CURRENT", blockHeight: blockHeightSmall) { - StatsBlockText(displayText: formatSolveTime(secs: currentCompsimAverage.average ?? 0, penType: currentCompsimAverage.totalPen), displayDetail: true, nilCondition: true, blockHeight: blockHeightSmall) + StatsBlock(title: String(localized: "CURRENT"), blockHeight: blockHeightSmall) { + StatsBlockText(displayText: formatSolveTime(secs: currentCompsimAverage.average ?? 0, penalty: currentCompsimAverage.totalPen), displayDetail: true, nilCondition: true, blockHeight: blockHeightSmall) } } else { - StatsBlock(title: "", blockHeight: blockHeightExtraLarge) { + StatsBlock(title: String(localized: ""), blockHeight: blockHeightExtraLarge) { HStack { Text("") Spacer() } } - StatsBlock(title: "CURRENT", blockHeight: blockHeightSmall) { + StatsBlock(title: String(localized: "CURRENT"), blockHeight: blockHeightSmall) { StatsBlockText(displayText: "", nilCondition: false) } - + } } .onTapGesture { @@ -181,37 +183,14 @@ struct StatsView: View { - - - - - - - - - - - - - - - - - - - - - - - - StatsBlock(title: "AVERAGES", blockHeight: blockHeightSmall, isTappable: false) { + StatsBlock(title: String(localized: "AVERAGES"), blockHeight: blockHeightSmall, isTappable: false) { StatsBlockText(displayText: String(describing: stopwatchManager.compSimCount!), nilCondition: true) } } .frame(minWidth: 0, maxWidth: .infinity) VStack(spacing: 10) { - StatsBlock(title: "BEST SINGLE", blockHeight: blockHeightSmall) { + StatsBlock(title: String(localized: "BEST SINGLE"), blockHeight: blockHeightSmall) { if let bestSingle = stopwatchManager.bestSingle { StatsBlockText(displayText: formatSolveTime(secs: bestSingle.time), colouredText: true, nilCondition: true) } else { @@ -225,40 +204,27 @@ struct StatsView: View { } - - - - - - - - - - - - - ZStack(alignment: .topLeading) { if let bestCompsimAverage = stopwatchManager.bestCompsimAverage { - StatsBlock(title: "", blockHeight: blockHeightExtraLarge, background: .coloured) { + StatsBlock(title: String(localized: ""), blockHeight: blockHeightExtraLarge, background: .coloured) { StatsBlockDetailText(calculatedAverage: bestCompsimAverage, colouredBlock: true) } - StatsBlock(title: "BEST", blockHeight: blockHeightSmall, background: .clear) { - StatsBlockText(displayText: formatSolveTime(secs: bestCompsimAverage.average ?? 0, penType: bestCompsimAverage.totalPen), colouredBlock: true, displayDetail: true, nilCondition: true, blockHeight: blockHeightSmall) + StatsBlock(title: String(localized: "BEST"), blockHeight: blockHeightSmall, background: .clear) { + StatsBlockText(displayText: formatSolveTime(secs: bestCompsimAverage.average ?? 0, penalty: bestCompsimAverage.totalPen), colouredBlock: true, displayDetail: true, nilCondition: true, blockHeight: blockHeightSmall) } } else { - StatsBlock(title: "", blockHeight: blockHeightExtraLarge) { + StatsBlock(title: String(localized: ""), blockHeight: blockHeightExtraLarge) { HStack { Text("") Spacer() } } - StatsBlock(title: "BEST", blockHeight: blockHeightSmall) { + StatsBlock(title: String(localized: "BEST"), blockHeight: blockHeightSmall) { StatsBlockText(displayText: "", nilCondition: false) } - + } } .onTapGesture { @@ -270,37 +236,35 @@ struct StatsView: View { .frame(minWidth: 0, maxWidth: .infinity) } .padding(.horizontal) - .padding(.bottom, 8) HStack(spacing: 10) { - StatsBlock(title: "TARGET", blockHeight: blockHeightSmall, isTappable: false) { + StatsBlock(title: String(localized: "TARGET"), blockHeight: blockHeightSmall, isTappable: false) { StatsBlockText(displayText: formatSolveTime(secs: (stopwatchManager.currentSession as! CompSimSession).target, dp: 2), nilCondition: true) } - .frame(minWidth: 0, maxWidth: .infinity) + .frame(minWidth: 0, maxWidth: .infinity) - StatsBlock(title: "REACHED", blockHeight: blockHeightSmall, isTappable: false) { + StatsBlock(title: String(localized: "REACHED"), blockHeight: blockHeightSmall, isTappable: false) { if (stopwatchManager.compSimCount == 0) { StatsBlockText(displayText: "", nilCondition: false) } else { StatsBlockText(displayText: String(describing: stopwatchManager.reachedTargets!) + "/" + String(describing: stopwatchManager.compSimCount!), nilCondition: (stopwatchManager.bestSingle != nil)) } } - .frame(minWidth: 0, maxWidth: .infinity) + .frame(minWidth: 0, maxWidth: .infinity) } .padding(.horizontal) - StatsBlock(title: "REACHED TARGETS", blockHeight: blockHeightReachedTargets, isBigBlock: true, isTappable: false) { + StatsBlock(title: String(localized: "REACHED TARGETS"), blockHeight: blockHeightReachedTargets, isBigBlock: true, isTappable: false) { ReachedTargets(reachedCount: stopwatchManager.reachedTargets, totalCount: stopwatchManager.compSimCount) } - .padding(.bottom, 8) HStack(spacing: 10) { - StatsBlock(title: "CURRENT MO10 AO5", blockHeight: blockHeightSmall, isTappable: false) { + StatsBlock(title: String(localized: "CURRENT MO10 AO5"), blockHeight: blockHeightSmall, isTappable: false) { if let currentMeanOfTen = stopwatchManager.currentMeanOfTen { - StatsBlockText(displayText: formatSolveTime(secs: currentMeanOfTen, penType: ((currentMeanOfTen == -1) ? .dnf : Penalty.none)), nilCondition: true) + StatsBlockText(displayText: formatSolveTime(secs: currentMeanOfTen, penalty: ((currentMeanOfTen == -1) ? .dnf : Penalty.none)), nilCondition: true) StatsBlockText(displayText: "", nilCondition: false) } else { StatsBlockText(displayText: "", nilCondition: false) @@ -308,9 +272,9 @@ struct StatsView: View { } .frame(minWidth: 0, maxWidth: .infinity) - StatsBlock(title: "BEST MO10 AO5", blockHeight: blockHeightSmall, isTappable: false) { + StatsBlock(title: String(localized: "BEST MO10 AO5"), blockHeight: blockHeightSmall, isTappable: false) { if let bestMeanOfTen = stopwatchManager.bestMeanOfTen { - StatsBlockText(displayText: formatSolveTime(secs: bestMeanOfTen, penType: ((bestMeanOfTen == -1) ? .dnf : Penalty.none)), nilCondition: true) + StatsBlockText(displayText: formatSolveTime(secs: bestMeanOfTen, penalty: ((bestMeanOfTen == -1) ? .dnf : Penalty.none)), nilCondition: true) } else { StatsBlockText(displayText: "", nilCondition: false) } @@ -318,7 +282,6 @@ struct StatsView: View { .frame(minWidth: 0, maxWidth: .infinity) } .padding(.horizontal) - .padding(.bottom, 8) } let allCompsimAveragesByDate: [CalculatedAverage] = stopwatchManager.getBestCompsimAverageAndArrayOfCompsimAverages().1 @@ -332,18 +295,14 @@ struct StatsView: View { : stopwatchManager.solvesNoDNFs.map { $0.timeIncPen }) - #warning("TODO: add settings customisation to choose how many solves to show") - StatsBlock(title: "TIME TREND", blockHeight: (timeTrendData.count < 2 ? blockHeightGraphEmpty : 310), isBigBlock: true, isTappable: false) { - TimeTrend(data: Array(timeTrendData.prefix(80)), title: nil) + StatsBlock(title: String(localized: "TIME TREND"), blockHeight: (timeTrendData.count < 2 ? blockHeightGraphEmpty : 310), isBigBlock: true, isTappable: true) { + TimeTrend(data: Array(timeTrendData.suffix(timeTrendSolves)), title: nil) .drawingGroup() } - #warning("TODO: enable for v2.1") -// .onTapGesture { -// self.showTimeTrendModal = true -// } - + .onTapGesture { self.showTimeTrendModal = true } - StatsBlock(title: "TIME DISTRIBUTION", blockHeight: (timeDistributionData.count < 4 ? blockHeightGraphEmpty : 300), isBigBlock: true, isTappable: false) { + + StatsBlock(title: String(localized: "TIME DISTRIBUTION"), blockHeight: (timeDistributionData.count < 4 ? blockHeightGraphEmpty : 300), isBigBlock: true, isTappable: false) { TimeDistribution(solves: timeDistributionData) .drawingGroup() .frame(height: timeDistributionData.count < 4 ? blockHeightGraphEmpty : 300) @@ -378,12 +337,25 @@ struct StatsView: View { TimeDetailView(for: stopwatchManager.bestSingle!, currentSolve: nil) .tint(Color("accent")) } - .sheet(isPresented: self.$showTimeTrendModal) { - let timesOnly = stopwatchManager.solvesNoDNFsbyDate.map { $0.timeIncPen } - DetailTimeTrend(rawDataPoints: timesOnly, - limits: (timesOnly.max()!, timesOnly.min()!), - averageValue: stopwatchManager.sessionMean!) - .tint(Color("accent")) + .sheet(isPresented: $showTimeTrendModal) { + NavigationView { + ZStack { + Color("base") + .ignoresSafeArea() + + TimeTrendDetail() + .environmentObject(stopwatchManager) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + CTDoneButton(onTapRun: { + showTimeTrendModal = false + }) + } + } + } + .navigationTitle("Time Trend") + .navigationBarTitleDisplayMode(.inline) + } } } } diff --git a/CubeTime/Stats/TimeDistribution.swift b/CubeTime/Stats/TimeDistribution.swift index b8042a3b..3a3f7dd0 100644 --- a/CubeTime/Stats/TimeDistribution.swift +++ b/CubeTime/Stats/TimeDistribution.swift @@ -108,7 +108,7 @@ struct TimeDistribution: View { path.move(to: CGPoint(x: 0, y: height < 4 ? 220/4*CGFloat(height) : 226)) path.addLine(to: CGPoint(x: geometry.size.width, y: height < 4 ? 220/4*CGFloat(height) : 226)) } - .stroke(Color("grey"), style: StrokeStyle(lineWidth: 1, lineCap: .round, dash: [5, height == 4 ? 0 : 10])) + .stroke(Color("indent0"), style: StrokeStyle(lineWidth: 1, lineCap: .round, dash: [5, height == 4 ? 0 : 10])) } } } @@ -122,7 +122,7 @@ struct TimeDistribution: View { let medianxmax = (divs*CGFloat((count < 8 ? count : 8))+20) let medianxmin = (divs+20) - let medianxloc = (medianxmax - medianxmin) * CGFloat(stopwatchManager.normalMedian.1!) + medianxmin + let medianxloc = (medianxmax - medianxmin) * CGFloat(stopwatchManager.normalMedian.1 ?? 0) + medianxmin Path { path in @@ -135,7 +135,7 @@ struct TimeDistribution: View { let xloc: CGFloat = (geometry.size.width / (count < 8 ? CGFloat(count+2) : 10)) * CGFloat(datum) ZStack { Rectangle() - .fill(getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient)) + .fill(GradientManager.getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient)) .frame(width: geometry.size.width, height: 260) .mask { Path { path in @@ -146,14 +146,14 @@ struct TimeDistribution: View { } Rectangle() - .fill(getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient)) + .fill(GradientManager.getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient)) .frame(width: geometry.size.width, height: 260) .offset(x: -20, y: -20) .mask { Text("\(data[datum].1)") .position(x: xloc, y: (220 - max_height * CGFloat(data[datum].1)) - 10) .multilineTextAlignment(.center) - .recursiveMono(fontSize: 10, weight: .semibold) + .recursiveMono(size: 10, weight: .semibold) } .if(graphGlow) { view in view.colouredGlow(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient) @@ -161,7 +161,7 @@ struct TimeDistribution: View { Text((datum == 0 ? "<" : (datum == data.count-1 ? ">" : ""))+formatLegendTime(secs: data[datum].0, dp: 1)+(datum != 0 && datum != data.count-1 ? "+" : "")) .foregroundColor(Color("grey")) - .recursiveMono(fontSize: 10, weight: .regular) + .recursiveMono(size: 10, weight: .regular) .position(x: xloc, y: 240) } .padding(.horizontal) @@ -173,7 +173,7 @@ struct TimeDistribution: View { .foregroundColor(Color("dark")) Text(formatSolveTime(secs: stopwatchManager.normalMedian.0!)) - .recursiveMono(fontSize: 11, weight: .semibold) + .recursiveMono(size: 11, weight: .semibold) .foregroundColor(Color("dark")) } .position(x: medianxloc, y: -16) @@ -183,7 +183,7 @@ struct TimeDistribution: View { } } else { Text("not enough solves to\ndisplay graph") - .recursiveMono(fontSize: 17, weight: .medium) + .recursiveMono(size: 17, weight: .medium) .multilineTextAlignment(.center) .foregroundColor(Color("grey")) .offset(y: 5) diff --git a/CubeTime/Stats/TimeTrend.swift b/CubeTime/Stats/TimeTrend/TimeTrend.swift similarity index 93% rename from CubeTime/Stats/TimeTrend.swift rename to CubeTime/Stats/TimeTrend/TimeTrend.swift index cb0699ae..34512df7 100644 --- a/CubeTime/Stats/TimeTrend.swift +++ b/CubeTime/Stats/TimeTrend/TimeTrend.swift @@ -268,21 +268,6 @@ extension CGPoint { return sqrt((pow(self.x - point.x, 2) + pow(self.y - point.y, 2))) } - static func midPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint { - return CGPoint(x:(p1.x + p2.x) / 2,y: (p1.y + p2.y) / 2) - } - - static func controlPointForPoints(p1:CGPoint, p2:CGPoint) -> CGPoint { - var controlPoint = CGPoint.midPointForPoints(p1:p1, p2:p2) - let diffY = abs(p2.y - controlPoint.y) - - if (p1.y < p2.y){ - controlPoint.y += diffY - } else if (p1.y > p2.y) { - controlPoint.y -= diffY - } - return controlPoint - } } @@ -290,7 +275,7 @@ extension View { func colouredGlow(gradientSelected: Int, isStaticGradient: Bool) -> some View { ForEach(0..<2) { i in Rectangle() - .fill(getGradient(gradientSelected: gradientSelected, isStaticGradient: isStaticGradient).opacity(0.5)) + .fill(GradientManager.getGradient(gradientSelected: gradientSelected, isStaticGradient: isStaticGradient).opacity(0.5)) .mask(self.blur(radius: 20)) .overlay(self.blur(radius: 5 - CGFloat(i * 5))) } @@ -348,7 +333,7 @@ struct Line: View { var body: some View { self.path .trim(from: 0, to: self.showFull ? 1 : 0) - .stroke(getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient), style: StrokeStyle(lineWidth: 3, lineJoin: .round)) + .stroke(GradientManager.getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient), style: StrokeStyle(lineWidth: 3, lineJoin: .round)) .rotationEffect(.degrees(180), anchor: .center) .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) .animation(.easeInOut(duration: graphAnimation ? 1.2 : 0), value: self.showFull) @@ -399,7 +384,7 @@ struct Legend: View { Text(formatLegendTime(secs: self.getYLegendSafe(height: height), dp: 1)) .offset(x: 2, y: self.getYposition(height: height)) .foregroundColor(Color("grey")) - .recursiveMono(fontSize: 10, weight: .regular) + .recursiveMono(size: 10, weight: .regular) } .offset(y: 3) @@ -408,7 +393,7 @@ struct Legend: View { withAnimation(.easeOut(duration: 0.2)) { self.line(atHeight: self.getYLegendSafe(height: height), width: self.frame.width) - .stroke(Color("grey"), style: StrokeStyle(lineWidth: 1, lineCap: .round, dash: [5,height == 0 ? 0 : 10])) + .stroke(Color("indent0"), style: StrokeStyle(lineWidth: 1, lineCap: .round, dash: [5,height == 0 ? 0 : 10])) .rotationEffect(.degrees(180), anchor: .center) .rotation3DEffect(.degrees(180), axis: (x: 0, y: 1, z: 0)) .clipped() @@ -513,7 +498,7 @@ struct TimeTrend: View { .offset(y: 6) } else { Text("not enough solves to\ndisplay graph") - .recursiveMono(fontSize: 17, weight: .medium) + .recursiveMono(size: 17, weight: .medium) .multilineTextAlignment(.center) .foregroundColor(Color("grey")) .offset(y: 5) diff --git a/CubeTime/Stats/TimeTrend/TimeTrendDetailView.swift b/CubeTime/Stats/TimeTrend/TimeTrendDetailView.swift new file mode 100644 index 00000000..a6c2d3df --- /dev/null +++ b/CubeTime/Stats/TimeTrend/TimeTrendDetailView.swift @@ -0,0 +1,131 @@ +// +// TimeTrendDetail.swift +// CubeTime +// +// Created by Tim Xie on 4/01/24. +// + +import Foundation +import SwiftUI +import Charts + +struct LegendLabel: View { + let colour: Color + let label: String + let symbol: String? + + init(colour: Color, label: String, symbol: String? = nil) { + self.colour = colour + self.label = label + self.symbol = symbol + } + + var body: some View { + HStack(spacing: 8) { + if let symbol = self.symbol { + ZStack { + RoundedRectangle(cornerRadius: 1) + .fill(Color("indent0")) + .frame(width: 32, height: 2, alignment: .leading) + + Image(systemName: symbol) + .foregroundStyle(colour) + .font(.caption.bold()) + } + } else { + RoundedRectangle(cornerRadius: 1) + .fill(colour) + .frame(width: 32, height: 2, alignment: .leading) + } + + Text(label) + .font(.caption2) + .foregroundStyle(colour) + } + + } +} + +struct TimeTrendDetail: View { + @EnvironmentObject var stopwatchManager: StopwatchManager + +#warning("TODO: remove when we readd the lines") + // @State var selectedLines = [true, false, false, false] + @State var selectedLines = [true] + + let labels: [(label: String, type: CTButtonType)] = [ + (String(localized: "time"), .halfcoloured(nil)), + // ("ao5", .green), + // ("ao12", .red), + // ("ao100", .orange) + ] + + @State var interval: Int = 30 + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: 12) + .fill(Color("overlay1")) + + VStack { + HStack(spacing: 8) { + CTButton(type: .mono, size: .large, square: true, onTapRun: { + self.interval = max(2, self.interval - (self.interval / 2)) + }) { + Image(systemName: "minus.magnifyingglass") + } + + CTButton(type: .mono, size: .large, square: true, onTapRun: { + self.interval = min(100, self.interval + (self.interval / 2)) + }) { + Image(systemName: "plus.magnifyingglass") + } + + Spacer() + + HStack(spacing: 0) { + ForEach(Array(zip(self.selectedLines.indices, self.selectedLines)), id: \.0) { index, selected in + CTButton(type: selected ? self.labels[index].type : .disabled, size: .large, hasBackground: false, onTapRun: { + if (self.selectedLines.lazy.filter({ $0 == true }).count != 1 || + self.selectedLines.lazy.filter({ $0 == true }).count == 1 && self.selectedLines[index] == false) { + self.selectedLines[index].toggle() + } + }) { + Text(self.labels[index].label) + } + } + } + .background( + RoundedRectangle(cornerRadius: 6) + .fill(Color("overlay0")) + ) + + } + +#warning("TODO URGENT FIX: READD COLOURFORBUTTONTYPE") + LazyVGrid(columns: Array(repeating: GridItem(.flexible(minimum: 36, maximum: 100), spacing: 16), count: 3), alignment: .leading) { + ForEach(self.labels, id: \.0) { label, type in + LegendLabel(colour: .accentColor, label: label) + } + + LegendLabel(colour: Color("grey"), label: "DNF", symbol: "xmark") + } + .padding(8) + + GeometryReader { proxy in + if proxy.size.height > 0 { + TimeTrendViewRepresentable(rawDataPoints: stopwatchManager.solvesByDate, + limits: (stopwatchManager.solvesByDate.min(by: { $0.timeIncPen < $1.timeIncPen })!.timeIncPen, stopwatchManager.solvesByDate.max(by: { $0.timeIncPen < $1.timeIncPen })!.timeIncPen), + averageValue: 5, interval: interval, + proxy: proxy) + } + } + .padding(.top, 8) + + } + .padding(8) + } + .padding() + .padding(.bottom, 32) + } +} diff --git a/CubeTime/Stats/TimeTrend/TimeTrendViewController.swift b/CubeTime/Stats/TimeTrend/TimeTrendViewController.swift new file mode 100644 index 00000000..b8089665 --- /dev/null +++ b/CubeTime/Stats/TimeTrend/TimeTrendViewController.swift @@ -0,0 +1,697 @@ +// +// ScrollableLineChart.swift +// CubeTime +// +// Created by Tim Xie on 2/03/23. +// + +import UIKit +import SwiftUI + + +fileprivate let CHART_BOTTOM_PADDING: CGFloat = 50 // Allow for x axis +fileprivate let CHART_TOP_PADDING: CGFloat = 100 + +fileprivate let AXIS_LABEL_FONT = FontManager.fontFor(size: 10, weight: 400) + +fileprivate let DOT_DIAMETER: CGFloat = 6 // possible to modify for accessibility purposes + + +extension CGPoint { + static func midPointForPoints(p1: CGPoint, p2: CGPoint) -> CGPoint { + return CGPoint(x: (p1.x + p2.x) / 2, + y: (p1.y + p2.y) / 2) + } + + static func controlPointForPoints(p1: CGPoint, p2: CGPoint) -> CGPoint { + var controlPoint = CGPoint.midPointForPoints(p1: p1, p2: p2) + + let diffY = abs(p2.y - controlPoint.y) + + if (p1.y < p2.y){ + controlPoint.y += diffY + } else if (p1.y > p2.y) { + controlPoint.y -= diffY + } + + return controlPoint + } +} + + + +struct TimeTrendPoint { + var min: CGFloat + var max: CGFloat + + var idx: Int + + var solve: Solve + + init(solve: Solve, + idx: Int, + min: Double, max: Double) { + self.solve = solve + self.idx = idx + self.min = min + self.max = max + } + + + func getPointFor(interval: Int, imageHeight: CGFloat) -> CGPoint { + return CGPoint(x: CGFloat(interval * self.idx), + y: imageHeight - (((CGFloat(solve.timeIncPen) - min) / (max - min)) * (imageHeight - 2) + 1)) + } + + + func pointIn(interval: Int, imageHeight: CGFloat, other: CGPoint) -> Bool { + let point = getPointFor(interval: interval, imageHeight: imageHeight) + let rect = CGRect(x: point.x - DOT_DIAMETER / 2, y: point.y - DOT_DIAMETER / 2, width: DOT_DIAMETER, height: DOT_DIAMETER) + return rect.contains(other) + } +} + + + + +// MARK: - HOVER VIEWS +class TimeTrendHoverPointView: UIView { + let path = UIBezierPath(ovalIn: CGRect(x: 2, y: 2, width: 8, height: 8)) + + var isRegular = true { + didSet { + self.setNeedsDisplay() + } + } + + init(at loc: CGPoint) { + super.init(frame: CGRect(origin: loc, size: CGSize(width: 12, height: 12))) + } + + required init?(coder: NSCoder) { + fatalError("not implemented") + } + + override func draw(_ rect: CGRect) { + UIColor(Color("overlay0")).setFill() + UIColor(Color(self.isRegular ? "accent" : "grey")).setStroke() + + path.lineWidth = 4 + path.stroke() + path.fill() + + } +} + + +class TimeTrendHoverCardView: UIStackView { + var solve: Solve? + + lazy var iconView: UIImageView = { + var iconView = UIImageView() + + iconView = UIImageView(image: UIImage(named: PUZZLE_TYPES[Int(solve?.scrambleType ?? 0)].imageName)) + iconView.frame = CGRect(x: 0, y: 0, width: 24, height: 24) + iconView.tintColor = UIColor(Color("dark")) + iconView.translatesAutoresizingMaskIntoConstraints = false + + return iconView + }() + + lazy var chevron: UIImageView = { + var chevron = UIImageView() + + chevron = UIImageView(image: UIImage(systemName: "chevron.right")) + chevron.tintColor = UIColor(Color("dark")) + + chevron.preferredSymbolConfiguration = UIImage.SymbolConfiguration(font: .preferredFont(for: .footnote, weight: .medium)) + chevron.translatesAutoresizingMaskIntoConstraints = false + + return chevron + + }() + + lazy var timeLabel: UILabel = { + var timeLabel = UILabel() + + timeLabel.text = self.solve?.timeText ?? "" + timeLabel.font = FontManager.fontFor(size: 15, weight: 600) + timeLabel.adjustsFontForContentSizeCategory = true + + return timeLabel + }() + + lazy var dateLabel: UILabel = { + var dateLabel = UILabel() + + if let date = self.solve?.date { + dateLabel.text = getSolveDateFormatter(date).string(from: date) + } else { + dateLabel.text = "Unknown Date" + } + + dateLabel.font = .preferredFont(forTextStyle: .caption1) + dateLabel.textColor = UIColor(Color("grey")) + dateLabel.adjustsFontForContentSizeCategory = true + + return dateLabel + }() + + lazy var infoStack: UIStackView = { + var infoStack = UIStackView(frame: .zero) + + infoStack.axis = .vertical + infoStack.alignment = .leading + infoStack.distribution = .fill + infoStack.spacing = -2 + infoStack.addArrangedSubview(self.timeLabel) + infoStack.addArrangedSubview(self.dateLabel) + infoStack.translatesAutoresizingMaskIntoConstraints = false + + return infoStack + }() + + init(solve: Solve?) { + self.solve = solve + + super.init(frame: .zero) + + self.setupCard() + + + self.addArrangedSubview(self.iconView) + self.addArrangedSubview(self.infoStack) + self.addArrangedSubview(self.chevron) + + + + NSLayoutConstraint.activate([ + self.heightAnchor.constraint(equalToConstant: 44), + + self.iconView.widthAnchor.constraint(equalToConstant: 24), + self.iconView.heightAnchor.constraint(equalTo: self.iconView.widthAnchor), + ]) + } + + required init(coder: NSCoder) { + fatalError("not implemented") + } + + private func setupCard() { + self.layer.cornerRadius = 6 + self.layer.cornerCurve = .continuous + self.backgroundColor = UIColor(Color("overlay0")) + + self.layer.shadowColor = UIColor.black.cgColor + self.layer.shadowOpacity = 0.04 + self.layer.shadowRadius = 6 + self.layer.shadowOffset = CGSize(width: 0.0, height: -2.0) + + self.translatesAutoresizingMaskIntoConstraints = false + + self.distribution = .fill + self.alignment = .center + self.spacing = 10 + + self.layoutMargins = UIEdgeInsets(top: 0, left: 10, bottom: 0, right: 10) + self.isLayoutMarginsRelativeArrangement = true + + self.setCustomSpacing(16, after: self.infoStack) + } + + func updateLabel(with solve: Solve) { + self.timeLabel.text = solve.timeText + + if let date = solve.date { + self.dateLabel.text = getSolveDateFormatter(date).string(from: date) + } else { + self.dateLabel.text = "Unknown Date" + } + } +} + + +class TimeTrendLineChartScrollView: UIScrollView { + static private let dotSize: CGFloat = 6 + + var interval: Int + var points: [TimeTrendPoint] + + + init(frame: CGRect, interval: Int, points: [TimeTrendPoint]) { + self.interval = interval + self.points = points + + super.init(frame: frame) + self.backgroundColor = .clear + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + private func createDNFPoint() -> UIImage { + let config = UIImage.SymbolConfiguration(font: .preferredFont(for: .caption1, weight: .bold), scale: .large) + + return UIImage(systemName: "xmark", withConfiguration: config)! + } + + static let drawCountAround = 100 + static let redrawDistance = 20 + + override func draw(_ rect: CGRect) { + var dnfedIndices: [Int] = [] + + /// draw line + let trendLine = UIBezierPath() + let gradientLine = UIBezierPath() + + let context = UIGraphicsGetCurrentContext()! + + let xAxis = UIBezierPath() + + let leftX = rect.minX + let rightX = leftX + rect.width + + /// x axis + xAxis.move(to: CGPoint(x: leftX, y: self.frame.height - CHART_BOTTOM_PADDING - 0.5)) + xAxis.lineWidth = 1 + xAxis.addLine(to: CGPoint(x: rightX, y: self.frame.height - CHART_BOTTOM_PADDING - 0.5)) + context.setStrokeColor(UIColor(Color("dark").opacity(0.16)).cgColor) + xAxis.stroke() + + + /// graph line + let graphLineColor = UIColor(Color("accent")).cgColor + context.setStrokeColor(graphLineColor) + + trendLine.lineWidth = 2 + trendLine.lineCapStyle = .round + trendLine.lineJoinStyle = .round + + + let pointsSubset = points[max(Int(leftX) / interval - 1, 0)...min(Int(rightX) / interval + 1, points.count - 1)] + + + let padded_height = self.frame.height - CHART_TOP_PADDING - CHART_BOTTOM_PADDING + + let intervalLine = UIBezierPath() + intervalLine.lineWidth = 1 + + for i in pointsSubset.indices { + let prev = points[i - 1 >= 0 ? i - 1 : 0] + let cur = points[i] + + var prevcgpoint = prev.getPointFor(interval: interval, imageHeight: padded_height) + var curcgpoint = cur.getPointFor(interval: interval, imageHeight: padded_height) + curcgpoint.y += CHART_TOP_PADDING + prevcgpoint.y += CHART_TOP_PADDING + + let drawText = (i % (Int(self.bounds.width) / (interval * 2))) == 0 && i != 0 + + if drawText { + context.saveGState() + let string = "\(i + 1)" as NSString + + let attributes = [ + NSAttributedString.Key.font : AXIS_LABEL_FONT, + NSAttributedString.Key.foregroundColor : UIColor(Color("grey")) + ] + + // Get the width and height that the text will occupy. + let stringSize = string.size(withAttributes: attributes) + + string.draw( + in: CGRectMake( + CGFloat(i) * CGFloat(interval) - stringSize.width / 2, + padded_height + CHART_TOP_PADDING, + stringSize.width, + stringSize.height + ), + withAttributes: attributes + ) + + intervalLine.move(to: CGPoint(x: CGFloat(i * interval), y: self.frame.height - CHART_BOTTOM_PADDING - 0.5)) + intervalLine.lineWidth = 1 + intervalLine.setLineDash([6, 6], count: 2, phase: 0) + intervalLine.addLine(to: CGPoint(x: CGFloat(i * interval), y: CHART_TOP_PADDING + 0.5)) + context.setStrokeColor(UIColor(Color("indent1")).cgColor) + intervalLine.stroke() + + + // String drawing changes color + context.restoreGState() + } + + + if (trendLine.isEmpty) { + trendLine.move(to: CGPoint(x: 0, y: curcgpoint.y)) + gradientLine.move(to: CGPoint(x: 0, y: curcgpoint.y)) + continue + } + + let mid = CGPoint.midPointForPoints(p1: prevcgpoint, p2: curcgpoint) + + trendLine.addQuadCurve(to: mid, + controlPoint: CGPoint.controlPointForPoints(p1: mid, p2: prevcgpoint)) + gradientLine.addQuadCurve(to: mid, + controlPoint: CGPoint.controlPointForPoints(p1: mid, p2: prevcgpoint)) + + + if Penalty(rawValue: cur.solve.penalty) == .dnf { + trendLine.stroke() + trendLine.removeAllPoints() + trendLine.move(to: mid) + context.setStrokeColor(UIColor(Color("dark").opacity(0.16)).cgColor) + } else if Penalty(rawValue: prev.solve.penalty) == .dnf { + trendLine.stroke() + trendLine.removeAllPoints() + trendLine.move(to: mid) + context.setStrokeColor(UIColor(Color("accent")).cgColor) + } + + + trendLine.addQuadCurve(to: curcgpoint, + controlPoint: CGPoint.controlPointForPoints(p1: mid, p2: curcgpoint)) + gradientLine.addQuadCurve(to: curcgpoint, + controlPoint: CGPoint.controlPointForPoints(p1: mid, p2: curcgpoint)) + + if Penalty(rawValue: cur.solve.penalty) == .dnf { + dnfedIndices.append(i) + } + } + + let lastcgpoint = points.last!.getPointFor(interval: interval, imageHeight: padded_height + CHART_TOP_PADDING) + let firstcgpoint = points.first!.getPointFor(interval: interval, imageHeight: padded_height + CHART_TOP_PADDING) + + gradientLine.addLine(to: CGPoint(x: lastcgpoint.x, y: padded_height + CHART_TOP_PADDING)) + gradientLine.addLine(to: CGPoint(x: 0, y: padded_height + CHART_TOP_PADDING)) + gradientLine.addLine(to: CGPoint(x: 0, y: firstcgpoint.y)) + + gradientLine.close() + + gradientLine.addClip() + + context.drawLinearGradient(CGGradient(colorsSpace: .none, + colors: [ + UIColor(GradientManager.STATIC_GRADIENT[0].opacity(0.6)).cgColor, + UIColor(GradientManager.STATIC_GRADIENT[1].opacity(0.2)).cgColor, + UIColor(GradientManager.STATIC_GRADIENT[1].opacity(0.01)).cgColor + ] as CFArray, + locations: [0.0, 0.4, 1.0])!, + start: CGPoint(x: 0, y: 0), + end: CGPoint(x: 0, y: padded_height + CHART_TOP_PADDING), + options: []) + + context.resetClip() + + trendLine.stroke() + + + UIColor(Color("grey")).set() + + /// draw dnf crosses + for i in dnfedIndices { + let image = createDNFPoint() + + var cgpoint = points[i].getPointFor(interval: interval, imageHeight: padded_height) + cgpoint.y += CHART_TOP_PADDING + + let imageRect = CGRect(x: cgpoint.x - 4, y: cgpoint.y - 4, width: 8, height: 8) + + context.clip(to: imageRect, mask: image.cgImage!) + + context.addRect(imageRect) + context.drawPath(using: .fill) + + context.resetClip() + } + } +} + + +class TimeTrendViewController: UIViewController, UIScrollViewDelegate { + var points: [TimeTrendPoint] + var interval: Int { + didSet { + self.drawYAxisValues() + self.scrollView.interval = interval + recalculateScrollViewSize() + self.scrollView.setNeedsDisplay() + } + } + + let stopwatchManager: StopwatchManager + + let averageValue: Double + + let limits: (min: Double, max: Double) + + var scrollView: TimeTrendLineChartScrollView! + var highlightedPoint: TimeTrendHoverPointView! + var highlightedCard: TimeTrendHoverCardView! + + var yAxis: UIView! + + var imageWidthConstraint: NSLayoutConstraint! + + var lastSelectedSolve: Solve! + + private let dotSize: CGFloat = 6 + + init(stopwatchManager: StopwatchManager, points: [TimeTrendPoint], interval: Int, averageValue: Double, limits: (min: Double, max: Double)) { + self.stopwatchManager = stopwatchManager + self.points = points + self.interval = interval + self.averageValue = averageValue + self.limits = limits + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + self.scrollView = TimeTrendLineChartScrollView(frame: .zero, interval: interval, points: points) + self.scrollView.showsHorizontalScrollIndicator = true + + self.view.clipsToBounds = true + + self.view.addSubview(scrollView) + + self.scrollView.frame = self.view.frame + self.scrollView.isUserInteractionEnabled = true + self.scrollView.delegate = self + + let tapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(tapped)) + + let longPressGestureRecognizer = UILongPressGestureRecognizer(target: self, action: #selector(panning)) + longPressGestureRecognizer.minimumPressDuration = 0.25 + tapGestureRecognizer.require(toFail: longPressGestureRecognizer) + + scrollView.addGestureRecognizer(tapGestureRecognizer) + scrollView.addGestureRecognizer(longPressGestureRecognizer) + + + self.highlightedPoint = TimeTrendHoverPointView(at: .zero) + + self.highlightedPoint.backgroundColor = .clear + + self.highlightedPoint.frame = CGRect(x: 0, + y: 0, + width: 12, height: 12) + self.highlightedPoint.layer.opacity = 1 + + self.scrollView.addSubview(self.highlightedPoint) + self.scrollView.isUserInteractionEnabled = true + + + self.highlightedCard = TimeTrendHoverCardView(solve: nil) + let cardTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(solveCardTapped)) + self.highlightedCard.addGestureRecognizer(cardTapGestureRecognizer) + self.highlightedCard.layer.opacity = 1 + + self.scrollView.addSubview(self.highlightedCard) + + tapGestureRecognizer.shouldRequireFailure(of: cardTapGestureRecognizer) + + self.yAxis = drawYAxisValues() + + self.scrollView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + self.scrollView.leadingAnchor.constraint(equalTo: self.yAxis.trailingAnchor), + self.scrollView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.scrollView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + self.scrollView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + ]) + + recalculateScrollViewSize() + + } + + func recalculateScrollViewSize() { + self.scrollView.contentSize.width = CGFloat((points.count - 1) * interval) + } + + private func drawYAxisValues() -> UIView { + let range = self.limits.max - self.limits.min + let view = UIStackView(frame: .zero) + + let stackView = UIStackView(frame: .zero) + stackView.distribution = .equalSpacing + stackView.axis = .vertical + stackView.translatesAutoresizingMaskIntoConstraints = false + + view.addArrangedSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: view.topAnchor), + stackView.bottomAnchor.constraint(equalTo: view.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor), + ]) + + stackView.sizeToFit() + + view.alignment = .center + view.axis = .horizontal + view.spacing = 4 + + view.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(view) + + for i in (0 ..< 6).reversed() { + let label = UILabel(frame: .zero) + label.translatesAutoresizingMaskIntoConstraints = false + label.adjustsFontSizeToFitWidth = true + label.font = AXIS_LABEL_FONT + label.textColor = UIColor(Color("grey")) + + label.text = formatSolveTime(secs: self.limits.min + Double(i) * range / Double(5), dp: 2) + + stackView.addArrangedSubview(label) + + label.sizeToFit() + } + + let lineView = UIView() + lineView.backgroundColor = UIColor(Color("dark").opacity(0.16)) + + view.spacing = 6 + view.addArrangedSubview(lineView) + + NSLayoutConstraint.activate([ + view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + view.topAnchor.constraint(equalTo: self.scrollView.topAnchor, constant: CHART_TOP_PADDING), + view.bottomAnchor.constraint(equalTo: self.scrollView.bottomAnchor, constant: -CHART_BOTTOM_PADDING), + + lineView.widthAnchor.constraint(equalToConstant: 0.5), + lineView.topAnchor.constraint(equalTo: self.scrollView.topAnchor, constant: CHART_TOP_PADDING), + lineView.bottomAnchor.constraint(equalTo: self.scrollView.bottomAnchor, constant: -CHART_BOTTOM_PADDING) + ]) + + + return view + } + + func updateGap(interval: Int, points: [TimeTrendPoint]) { + self.points = points + self.interval = interval + + self.removeSelectedPoint() + } + + private func removeSelectedPoint(animated: Bool = true) { + if animated { + UIView.animate(withDuration: 0.28, delay: 0, options: .curveEaseOut, animations: { + self.highlightedCard.layer.opacity = 0 + self.highlightedPoint.layer.opacity = 0 + }) + } else { + self.highlightedCard.layer.opacity = 0 + self.highlightedPoint.layer.opacity = 0 + } + } + + @objc func tapped(_ g: UITapGestureRecognizer) { + self.removeSelectedPoint() + } + + @objc func panning(_ pgr: UILongPressGestureRecognizer) { + let closestIndex = max(0, min(self.points.count - 1, Int((pgr.location(in: self.scrollView).x + 6) / CGFloat(self.interval)))) + let closestPoint = self.points[closestIndex] + var closestCGPoint = closestPoint.getPointFor(interval: interval, imageHeight: self.scrollView.frame.height - CHART_TOP_PADDING - CHART_BOTTOM_PADDING) + closestCGPoint.y += CHART_TOP_PADDING + + self.highlightedCard.updateLabel(with: closestPoint.solve) + + self.highlightedPoint.isRegular = Penalty(rawValue: closestPoint.solve.penalty) != .dnf + + self.highlightedPoint.frame.origin = CGPoint(x: closestCGPoint.x - 6, y: closestCGPoint.y - 6) + + + self.lastSelectedSolve = closestPoint.solve + + self.highlightedCard.frame.origin = CGPoint( + x: min(max(self.scrollView.contentOffset.x, + closestCGPoint.x - (self.highlightedCard.frame.width / 2)), + self.scrollView.frame.width - self.highlightedCard.frame.width + self.scrollView.contentOffset.x), + y: closestCGPoint.y - 80) + + self.highlightedPoint.layer.opacity = 1 + self.highlightedCard.layer.opacity = 1 + + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + removeSelectedPoint(animated: false) + scrollView.setNeedsDisplay() + } + + @objc func solveCardTapped(_ g: UITapGestureRecognizer) { + let solveSheet = UIHostingController(rootView: TimeDetailView(for: self.lastSelectedSolve, currentSolve: .constant(self.lastSelectedSolve)).environmentObject(stopwatchManager)) + +#warning("BUG: the toolbar doesn't display") + self.present(solveSheet, animated: true, completion: { self.removeSelectedPoint() }) + } +} + + +struct TimeTrendViewRepresentable: UIViewControllerRepresentable { + typealias UIViewControllerType = TimeTrendViewController + + @EnvironmentObject var stopwatchManager: StopwatchManager + + let points: [TimeTrendPoint] + let interval: Int + let averageValue: Double + let proxy: GeometryProxy + + let limits: (min: Double, max: Double) + + init(rawDataPoints: [Solve], limits: (min: Double, max: Double), averageValue: Double, interval: Int, proxy: GeometryProxy) { + self.points = rawDataPoints.enumerated().map({ (i, e) in + return TimeTrendPoint(solve: e, + idx: i, + min: limits.min, max: limits.max) + }) + self.averageValue = averageValue + self.limits = limits + self.interval = interval + self.proxy = proxy + } + + func makeUIViewController(context: Context) -> TimeTrendViewController { + let timeDistViewController = TimeTrendViewController(stopwatchManager: stopwatchManager, points: points, interval: interval, averageValue: averageValue, limits: limits) + + return timeDistViewController + } + + func updateUIViewController(_ uiViewController: TimeTrendViewController, context: Context) { + uiViewController.updateGap(interval: interval, points: points) + } +} diff --git a/CubeTime/Timer/StopwatchManager/CloudkitStatusManager.swift b/CubeTime/StopwatchManager/CloudkitStatusManager.swift similarity index 100% rename from CubeTime/Timer/StopwatchManager/CloudkitStatusManager.swift rename to CubeTime/StopwatchManager/CloudkitStatusManager.swift diff --git a/CubeTime/StopwatchManager/ExportViewModel.swift b/CubeTime/StopwatchManager/ExportViewModel.swift new file mode 100644 index 00000000..27444cb8 --- /dev/null +++ b/CubeTime/StopwatchManager/ExportViewModel.swift @@ -0,0 +1,383 @@ +// +// ExportViewModel.swift +// CubeTime +// +// Created by rgn on 2/19/24. +// + +import SwiftUI +import Foundation +import UniformTypeIdentifiers +import Combine +import ZIPFoundation +import libxml2 + +enum ExportFlowState { + case pickingSessions + case pickingFormats + case finished(Result<[URL], Error>) +} +/* +protocol ExportFormat: ReferenceFileDocument { +// protocol ExportFormat: ReferenceFileDocument where Snapshot == Set { + + static var name: String { get } + static var supportsMultiSession: Bool { get } + var selectedSessions: Set { get set } + init() +} + */ + +// Don't even talk to me right now + +class ExportFormat: ReferenceFileDocument { + func getName() -> String { + fatalError() + } + + // This is a problem for when I do import :( + static var readableContentTypes: [UTType] = [.data] + + required init(configuration: ReadConfiguration) throws { + fatalError() + } + + init() {} + + func fileWrapper(snapshot: Set, configuration: WriteConfiguration) throws -> FileWrapper { + fatalError() + } + + var selectedSessions: Set = [] + + func snapshot(contentType: UTType) throws -> Set { + return selectedSessions + } + + +} + +class CSVExportFormat: ExportFormat { + override func getName() -> String { + return String(localized: "CSV (generic)") + } + + static var _readableContentTypes: [UTType] = [.commaSeparatedText] + + override func fileWrapper(snapshot: Set, configuration: WriteConfiguration) throws -> FileWrapper { + func doCSV(session: Session) -> FileWrapper { + var csv = "Time,Comment,Scramble,Date" + for case let solve as Solve in session.solves ?? [] { + csv += "\n\(solve.time),\"\(solve.comment?.replacingOccurrences(of: "\"", with: "\"\"") ?? "")\",\(solve.scramble ?? ""),\(solve.date?.description ?? "")" + } + let wrapper = FileWrapper(regularFileWithContents: csv.data(using: .utf8)!) + wrapper.preferredFilename = "CubeTime - \(session.name!).csv" + return wrapper + } + + if self.selectedSessions.count == 1 { + return doCSV(session: self.selectedSessions.first!) + } else { + var wrappers: [String: FileWrapper] = [:] + + func getName(name: String) -> String { + var name = "\(name).csv" + var counter = 0 + while wrappers[name] != nil { + counter += 1 + name = "\(name) (\(counter)).csv" + } + return name + } + + for session in selectedSessions { + let csv = doCSV(session: session) + wrappers["\(session.name!).csv"] = csv + } + let wrapper = FileWrapper(directoryWithFileWrappers: wrappers) + wrapper.preferredFilename = "CubeTime Export (CSV)" + return wrapper + } + } +} + +#warning("TODO: somehow minifiy without killing readability") + +let metaInf = ##""" + + + + + + +"""##.data(using: .utf8)! + +let styles = ##""" + +' + + + + / + + / + + + + : + + + + + + +"""##.data(using: .utf8)! + +/* + + + +*/ + +/* + */ + + +class ODSExportFormat: ExportFormat { + override func getName() -> String { + return String(localized: "ODF (MS Excel/Google Sheets)") + } + + static var _readableContentTypes: [UTType] = [.zip] + + override func fileWrapper(snapshot: Set, configuration: WriteConfiguration) throws -> FileWrapper { + // TODO: thumbnail + let numberFormatter = NumberFormatter() + numberFormatter.numberStyle = .decimal + + let dateFormatter = DateFormatter() + dateFormatter.locale = Locale(identifier: "en_US_POSIX") + dateFormatter.dateFormat = "yyyy-MM-dd'T'HH:mm:ss" + + // Keep in sync with style above! + let dateFormatterPretty = DateFormatter() + dateFormatterPretty.locale = Locale(identifier: "en_US_POSIX") + dateFormatterPretty.dateFormat = "M/d/yyyy h:mm a" + + + let archive = try Archive(accessMode: .create) + let mimetype = "application/vnd.oasis.opendocument.spreadsheet" + try archive.addEntry(with: "mimetype", data: mimetype.data(using: .utf8)!) + let buf = libxml2.xmlBufferCreate()! + + func writeCell(writer: xmlTextWriterPtr, content: String?, type: String = "string") { + libxml2.xmlTextWriterStartElement(writer, "table:table-cell") + libxml2.xmlTextWriterWriteAttribute(writer, "office:value-type", type) + if let content, type == "float" { + libxml2.xmlTextWriterWriteAttribute(writer, "office:value", content) + } + libxml2.xmlTextWriterWriteAttribute(writer, "calcext:value-type", type) + if let content { + libxml2.xmlTextWriterWriteElement(writer, "text:p", content) + } + libxml2.xmlTextWriterEndElement(writer) + } + + func writeCell(writer: xmlTextWriterPtr, date: Date) { + libxml2.xmlTextWriterStartElement(writer, "table:table-cell") + libxml2.xmlTextWriterWriteAttribute(writer, "table:style-name", "CELL_DATE") + libxml2.xmlTextWriterWriteAttribute(writer, "office:value-type", "date") + libxml2.xmlTextWriterWriteAttribute(writer, "office:date-value", dateFormatter.string(from: date)) + libxml2.xmlTextWriterWriteAttribute(writer, "calcext:value-type", "date") + libxml2.xmlTextWriterWriteElement(writer, "text:p", dateFormatterPretty.string(from: date)) + libxml2.xmlTextWriterEndElement(writer) + } + + + + let writer = libxml2.xmlNewTextWriterMemory(buf, 0)! + libxml2.xmlTextWriterStartDocument(writer, "1.0", "UTF-8", nil) + libxml2.xmlTextWriterStartElement(writer, "office:document-content") + libxml2.xmlTextWriterWriteAttribute(writer, "xmlns:office", "urn:oasis:names:tc:opendocument:xmlns:office:1.0") + libxml2.xmlTextWriterWriteAttribute(writer, "xmlns:text", "urn:oasis:names:tc:opendocument:xmlns:text:1.0") + libxml2.xmlTextWriterWriteAttribute(writer, "xmlns:table", "urn:oasis:names:tc:opendocument:xmlns:table:1.0") + libxml2.xmlTextWriterWriteAttribute(writer, "xmlns:calcext", "urn:org:documentfoundation:names:experimental:calc:xmlns:calcext:1.0") + libxml2.xmlTextWriterWriteAttribute(writer, "xmlns:style", "urn:oasis:names:tc:opendocument:xmlns:style:1.0") + libxml2.xmlTextWriterWriteAttribute(writer, "office:version", "1.2") + + + libxml2.xmlTextWriterStartElement(writer, "office:automatic-styles") + + libxml2.xmlTextWriterStartElement(writer, "style:style") + libxml2.xmlTextWriterWriteAttribute(writer, "style:name", "COL_DATE") + libxml2.xmlTextWriterWriteAttribute(writer, "style:family", "table-column") + libxml2.xmlTextWriterStartElement(writer, "style:table-column-properties") + libxml2.xmlTextWriterWriteAttribute(writer, "style:column-width", "100.00pt") + libxml2.xmlTextWriterEndElement(writer) + libxml2.xmlTextWriterEndElement(writer) + + libxml2.xmlTextWriterStartElement(writer, "style:style") + libxml2.xmlTextWriterWriteAttribute(writer, "style:name", "COL_SCR") + libxml2.xmlTextWriterWriteAttribute(writer, "style:family", "table-column") + libxml2.xmlTextWriterStartElement(writer, "style:table-column-properties") + libxml2.xmlTextWriterWriteAttribute(writer, "style:column-width", "200.00pt") + libxml2.xmlTextWriterEndElement(writer) + libxml2.xmlTextWriterEndElement(writer) + + libxml2.xmlTextWriterStartElement(writer, "style:style") + libxml2.xmlTextWriterWriteAttribute(writer, "style:name", "CELL_DATE") + libxml2.xmlTextWriterWriteAttribute(writer, "style:family", "table-cell") + libxml2.xmlTextWriterWriteAttribute(writer, "style:data-style-name", "NUMFMT_DATE") + libxml2.xmlTextWriterEndElement(writer) + + + libxml2.xmlTextWriterEndElement(writer) + + + libxml2.xmlTextWriterStartElement(writer, "office:body") + libxml2.xmlTextWriterStartElement(writer, "office:spreadsheet") + + // Shut up MS Excel warning + libxml2.xmlTextWriterStartElement(writer, "table:calculation-settings") + libxml2.xmlTextWriterWriteAttribute(writer, "table:use-regular-expressions", "false") + libxml2.xmlTextWriterEndElement(writer) + + for session in self.selectedSessions { + libxml2.xmlTextWriterStartElement(writer, "table:table") + libxml2.xmlTextWriterWriteAttribute(writer, "table:name", "\(session.name!)") + + libxml2.xmlTextWriterStartElement(writer, "table:table-column") + libxml2.xmlTextWriterWriteAttribute(writer, "table:number-columns-repeated", "3") + libxml2.xmlTextWriterEndElement(writer) + + libxml2.xmlTextWriterStartElement(writer, "table:table-column") + libxml2.xmlTextWriterWriteAttribute(writer, "table:style-name", "COL_SCR") + libxml2.xmlTextWriterEndElement(writer) + + libxml2.xmlTextWriterStartElement(writer, "table:table-column") + libxml2.xmlTextWriterWriteAttribute(writer, "table:style-name", "COL_DATE") + libxml2.xmlTextWriterEndElement(writer) + + + for solve in session.solves?.allObjects as! [Solve] { + libxml2.xmlTextWriterStartElement(writer, "table:table-row") + + let t = numberFormatter.string(from: NSNumber(value: solve.time))! + + writeCell(writer: writer, content: t, type: "float") + #warning("TODO: maybe make the penalty cell a dropdown?") + writeCell(writer: writer, content: Penalty(rawValue: solve.penalty)!.exportName()) + writeCell(writer: writer, content: solve.comment) + writeCell(writer: writer, content: solve.scramble!) + writeCell(writer: writer, date: solve.date!) + + libxml2.xmlTextWriterEndElement(writer) + } + + + libxml2.xmlTextWriterEndElement(writer) + + } + libxml2.xmlTextWriterEndElement(writer) + libxml2.xmlTextWriterEndElement(writer) + libxml2.xmlTextWriterEndElement(writer) + libxml2.xmlTextWriterEndDocument(writer) + + let content = Data(bytes: buf.pointee.content, count: Int(buf.pointee.use)) + libxml2.xmlFreeTextWriter(writer) + + + libxml2.xmlBufferFree(buf) + try archive.addEntry(with: "content.xml", data: content) + // Yes, MS Excel really needs this, even though it's empty. + try archive.addEntry(with: "styles.xml", data: styles) + try archive.addEntry(with: "META-INF/manifest.xml", data: metaInf) + + let wrapper = FileWrapper(regularFileWithContents: archive.data!) + wrapper.preferredFilename = "CubeTime Export.ods" + return wrapper + } +} + + + +class CSTimerExportFormat: ExportFormat { + override func getName() -> String { + return String(localized: "JSON (csTimer)") + } + + static var _readableContentTypes: [UTType] = [.commaSeparatedText] + + override func fileWrapper(snapshot: Set, configuration: WriteConfiguration) throws -> FileWrapper { + var exportData: [String: Any] = [:] + var properties: [String: Any] = [:] + var sessionData: [String: [String: Any]] = [:] + + for (idx, session) in self.selectedSessions.enumerated() { + let solvesCSTimer = (session.solves?.allObjects as? [Solve] ?? []).map { solve in + let pen = switch Penalty(rawValue: solve.penalty)! { + case .none: 0 + case .dnf: -1 + case .plustwo: 2000 + } + let time = Int(solve.time * 1000.0) + // Yes, the format is really like this. + return [[pen, time], solve.scramble ?? "", solve.comment ?? "", Int(solve.date!.timeIntervalSince1970)] as [any Encodable] + } + + exportData["session\(idx+1)"] = solvesCSTimer + + sessionData["\(idx+1)"] = [ + "name": "CubeTime Export - \(session.name ?? "")", + "scrType": PUZZLE_TYPES[Int(session.scrambleType)].cstimerName + ] + } + + + + let sessionDataJson = try jsonSerialize(obj: sessionData) + + properties["sessionData"] = String(data: sessionDataJson, encoding: .utf8) + + + exportData["properties"] = properties + + + let exportDataJson = try jsonSerialize(obj: exportData) + + let wrapper = FileWrapper(regularFileWithContents: exportDataJson) + wrapper.preferredFilename = "CubeTime Export (csTimer).json" + return wrapper + } +} + +class ExportViewModel: ObservableObject { + let allFormats: [ExportFormat] = [CSVExportFormat(), ODSExportFormat(), CSTimerExportFormat()] + + @Published var exportFlowState: ExportFlowState = .pickingSessions + + @Published var selectedSessions = Set() + @Published var selectedFormats: [ExportFormat] = [] + + @Published var showImport = false + @Published var showExport = false + + private var cancellables: Set = [] + + init() { + $selectedSessions + .sink(receiveValue: { newValue in + for format in self.allFormats { + format.selectedSessions = newValue + } + }) + .store(in: &cancellables) + } + + func finishExport(result: Result<[URL], Error>) { + self.exportFlowState = .finished(result) + + self.selectedFormats.removeAll() + self.selectedSessions.removeAll() + } +} diff --git a/CubeTime/StopwatchManager/FontManager.swift b/CubeTime/StopwatchManager/FontManager.swift new file mode 100644 index 00000000..1aa2ea06 --- /dev/null +++ b/CubeTime/StopwatchManager/FontManager.swift @@ -0,0 +1,183 @@ +// +// FontManager.swift +// CubeTime +// +// Created by trainz-are-kul on 2/03/23. +// + +import Foundation +import SwiftUI +import Combine +import CoreText + +class FontManager: ObservableObject { + let settingsManager = SettingsManager.standard + + @Published var ctFontScramble: Font! + @Published var ctFontDescBold: CTFontDescriptor! + @Published var ctFontDesc: CTFontDescriptor! + + static func fontFor(size: CGFloat, weight: Int, font: CTCustomFontType = .recursive) -> CTFont { + var desc: CTFontDescriptor! + + switch font { + case .recursive: + desc = CTFontDescriptorCreateWithAttributes([ + kCTFontNameAttribute: "RecursiveSansLinearLightMonospace-Regular", + kCTFontVariationAttribute: [2003265652: weight, + 1128354636: 0, + 1129468758: 0] + ] as! CFDictionary) + + case .rubik: + desc = CTFontDescriptorCreateWithAttributes([ + kCTFontNameAttribute: "Rubik", + kCTFontVariationAttribute: [2003265652: weight] + ] as! CFDictionary) + } + + return CTFontCreateWithFontDescriptor(desc, size, nil) + } + + let changeOnKeys: [PartialKeyPath] = [\.fontWeight, \.fontCursive, \.fontCasual, \.scrambleSize] + + var subscriber: AnyCancellable? + + init() { + subscriber = settingsManager.preferencesChangedSubject + .filter { item in + (self.changeOnKeys as [AnyKeyPath]).contains(item) + } + .sink(receiveValue: { [weak self] i in + self?.updateFont() + }) + updateFont() + } + + private func updateFont() { + // weight, casual, cursive + let variations = [2003265652: settingsManager.fontWeight, 1128354636: settingsManager.fontCasual, 1129468758: settingsManager.fontCursive ? 1 : 0] + let variationsTimer = [2003265652: settingsManager.fontWeight + 200, 1128354636: settingsManager.fontCasual, 1129468758: settingsManager.fontCursive ? 1 : 0] + + ctFontDesc = CTFontDescriptorCreateWithAttributes([ + kCTFontNameAttribute: "RecursiveSansLinearLightMonospace-Regular", + kCTFontVariationAttribute: variations + ] as! CFDictionary) + + ctFontDescBold = CTFontDescriptorCreateWithAttributes([ + kCTFontNameAttribute: "RecursiveSansLinearLightMonospace-Regular", + kCTFontVariationAttribute: variationsTimer + ] as! CFDictionary) + + ctFontScramble = Font(CTFontCreateWithFontDescriptor(ctFontDesc, CGFloat(settingsManager.scrambleSize), nil)) + } +} + + +enum CTCustomFontType { + case recursive + case rubik +} + +struct CTCustomFont: ViewModifier { + @ScaledMetric var size: CGFloat + + let weight: Int + let font: CTCustomFontType + + init(size: CGFloat, weight: Int, font: CTCustomFontType) { + self._size = ScaledMetric(wrappedValue: size) + self.weight = weight + self.font = font + } + + func body(content: Content) -> some View { + content + .font(Font(FontManager.fontFor(size: size, weight: weight, font: self.font))) + } + + static func weightToValue(weight: Font.Weight) -> Int { + switch weight { + case .regular: + return 400 + case .medium: + return 500 + case .semibold: + return 600 + case .bold: + return 700 + + default: + return 400 + } + } + + static func styleToValue(style: Font.TextStyle) -> CGFloat { + // using default (Large) dynamic type sizes + switch style { + case .largeTitle: + return 34 + case .title: + return 28 + case .title2: + return 22 + case .title3: + return 20 + + case .body: + return 17 + case .callout: + return 16 + case .subheadline: + return 16 + + case .footnote: + return 13 + case .caption: + return 12 + case .caption2: + return 11 + + default: + return 17 + } + } +} + +extension View { + func recursiveMono(size: CGFloat, weight: Font.Weight=Font.Weight.regular) -> some View { + modifier(CTCustomFont(size: size, + weight: CTCustomFont.weightToValue(weight: weight), + font: .recursive)) + } + + func recursiveMono(style: Font.TextStyle, weight: Font.Weight=Font.Weight.regular) -> some View { + modifier(CTCustomFont(size: CTCustomFont.styleToValue(style: style), + weight: CTCustomFont.weightToValue(weight: weight), + font: .recursive)) + } + + func recursiveMono(style: Font.TextStyle, weightValue: Int) -> some View { + modifier(CTCustomFont(size: CTCustomFont.styleToValue(style: style), + weight: weightValue, + font: .recursive)) + } +} + + +//extension Font { +// static func system(size: CGFloat, weight: Font.Weight? = nil, design: Font.Design? = nil) -> Font { +// return Font(FontManager.fontFor(size: size, weight: CTCustomFont.weightToValue(weight: weight ?? Font.Weight.regular) + 100, font: .recursive)) +// } +// +// static let caption = Font(FontManager.fontFor(size: 11, weight: CTCustomFont.weightToValue(weight: .regular), font: .recursive)) +// static let caption2 = Font(FontManager.fontFor(size: 12, weight: CTCustomFont.weightToValue(weight: .regular), font: .recursive)) +// static let footnote = Font(FontManager.fontFor(size: 13, weight: CTCustomFont.weightToValue(weight: .regular), font: .recursive)) +// static let subheadline = Font(FontManager.fontFor(size: 15, weight: CTCustomFont.weightToValue(weight: .semibold), font: .recursive)) +// static let callout = Font(FontManager.fontFor(size: 17, weight: CTCustomFont.weightToValue(weight: .regular), font: .recursive)) +// static let body = Font(FontManager.fontFor(size: 17, weight: CTCustomFont.weightToValue(weight: .regular), font: .recursive)) +// static let title3 = Font(FontManager.fontFor(size: 20, weight: CTCustomFont.weightToValue(weight: .regular), font: .recursive)) +// static let title2 = Font(FontManager.fontFor(size: 22, weight: CTCustomFont.weightToValue(weight: .regular), font: .recursive)) +// static let title = Font(FontManager.fontFor(size: 28, weight: CTCustomFont.weightToValue(weight: .regular), font: .recursive)) +// static let largeTitle = Font(FontManager.fontFor(size: 34, weight: CTCustomFont.weightToValue(weight: .regular), font: .recursive)) +//} diff --git a/CubeTime/Timer/StopwatchManager/GradientManager.swift b/CubeTime/StopwatchManager/GradientManager.swift similarity index 50% rename from CubeTime/Timer/StopwatchManager/GradientManager.swift rename to CubeTime/StopwatchManager/GradientManager.swift index 1f96fb32..296c21ea 100644 --- a/CubeTime/Timer/StopwatchManager/GradientManager.swift +++ b/CubeTime/StopwatchManager/GradientManager.swift @@ -9,38 +9,6 @@ import Foundation import Combine import SwiftUI -func getGradient(gradientSelected: Int, isStaticGradient: Bool) -> LinearGradient { - return isStaticGradient - ? LinearGradient(gradient: Gradient(colors: staticGradient), - startPoint: .topLeading, - endPoint: .bottomTrailing) - - : LinearGradient(gradient: Gradient(colors: dynamicGradients[gradientSelected]), - startPoint: .topLeading, - endPoint: .bottomTrailing) -} - -func getGradientColours(gradientSelected: Int, isStaticGradient: Bool) -> [Color] { - return isStaticGradient ? staticGradient : dynamicGradients[gradientSelected] -} - -let dynamicGradients: [[Color]] = [ - [Color(hex: 0x05537a), Color(hex: 0x0093c1)], // light blue - dark blue - [Color(hex: 0x007caa), Color(hex: 0x52c8cd)], // aqua - light blue - [Color(hex: 0x3ec4d0), Color(hex: 0xe6e29a)], // pale yellow/white ish - aqua - [Color(hex: 0x94d7be), Color(hex: 0xffd325)], // yellow - green - [Color(hex: 0xffd63c), Color(hex: 0xff9e45)], // pale orange-yellow - - [Color(hex: 0xffc337), Color(hex: 0xfc7018)], // darker orange - yellow - [Color(hex: 0xff9528), Color(hex: 0xfb5b5c)], // pink-orange - [Color(hex: 0xf77d4f), Color(hex: 0xd35082)], // magenta-orange - [Color(hex: 0xd95378), Color(hex: 0x8548ba)], // purple-pink - [Color(hex: 0x702f86), Color(hex: 0x3f248f)], // dark blue-purple -] - -let staticGradient: [Color] = [Color(hex: 0x91B0FF), Color(hex: 0x365DEB)] - - class GradientManager: ObservableObject { @Published var appGradient: Int! @@ -48,6 +16,23 @@ class GradientManager: ObservableObject { var timer = Timer.publish(every: 3600, on: .current, in: .common).autoconnect() var subscriber: AnyCancellable? + static let DYNAMIC_GRADIENTS: [[Color]] = [ + [Color(hex: 0x05537a), Color(hex: 0x0093c1)], // light blue - dark blue + [Color(hex: 0x007caa), Color(hex: 0x52c8cd)], // aqua - light blue + [Color(hex: 0x3ec4d0), Color(hex: 0xe6e29a)], // pale yellow/white ish - aqua + [Color(hex: 0x94d7be), Color(hex: 0xffd325)], // yellow - green + [Color(hex: 0xffd63c), Color(hex: 0xff9e45)], // pale orange-yellow + + [Color(hex: 0xffc337), Color(hex: 0xfc7018)], // darker orange - yellow + [Color(hex: 0xff9528), Color(hex: 0xfb5b5c)], // pink-orange + [Color(hex: 0xf77d4f), Color(hex: 0xd35082)], // magenta-orange + [Color(hex: 0xd95378), Color(hex: 0x8548ba)], // purple-pink + [Color(hex: 0x702f86), Color(hex: 0x3f248f)], // dark blue-purple + ] + + static let STATIC_GRADIENT: [Color] = [Color(hex: 0x91B0FF), Color(hex: 0x365DEB)] + + init() { changeGradient(newTime: getSecondsUpTillNow(from: Date())) @@ -87,4 +72,20 @@ class GradientManager: ObservableObject { self.appGradient = 9 } } + + + public static func getGradient(gradientSelected: Int, isStaticGradient: Bool) -> LinearGradient { + return isStaticGradient + ? LinearGradient(gradient: Gradient(colors: STATIC_GRADIENT), + startPoint: .topLeading, + endPoint: .bottomTrailing) + + : LinearGradient(gradient: Gradient(colors: DYNAMIC_GRADIENTS[gradientSelected]), + startPoint: .topLeading, + endPoint: .bottomTrailing) + } + + public static func getGradientColours(gradientSelected: Int, isStaticGradient: Bool) -> [Color] { + return isStaticGradient ? STATIC_GRADIENT : DYNAMIC_GRADIENTS[gradientSelected] + } } diff --git a/CubeTime/Timer/StopwatchManager/ScrambleController.swift b/CubeTime/StopwatchManager/ScrambleController.swift similarity index 74% rename from CubeTime/Timer/StopwatchManager/ScrambleController.swift rename to CubeTime/StopwatchManager/ScrambleController.swift index 1e0f8244..1f989f3b 100644 --- a/CubeTime/Timer/StopwatchManager/ScrambleController.swift +++ b/CubeTime/StopwatchManager/ScrambleController.swift @@ -29,14 +29,15 @@ class ScrambleController: ObservableObject { var isolate: OpaquePointer? = nil var thread: OpaquePointer? = nil +// graal_create_isolate(nil, &isolate, &thread) +// +// let s = String(cString: tnoodle_lib_scramble(thread, scrambleType)) +// +// graal_tear_down_isolate(thread); +// +// return s - graal_create_isolate(nil, &isolate, &thread) - - let s = String(cString: tnoodle_lib_scramble(thread, scrambleType)) - - graal_tear_down_isolate(thread); - - return s + return "" } @@ -62,15 +63,17 @@ class ScrambleController: ObservableObject { - graal_create_isolate(nil, &isolate, &thread) - - var svg: String! - - scramble.withCString { s in - svg = String(cString: tnoodle_lib_draw_scramble(thread, scrTypeAtWorkStart, s)) - } +// graal_create_isolate(nil, &isolate, &thread) +// +// var svg: String! +// +// scramble.withCString { s in +// svg = String(cString: tnoodle_lib_draw_scramble(thread, scrTypeAtWorkStart, s)) +// } +// +// graal_tear_down_isolate(thread); - graal_tear_down_isolate(thread); + let svg = "" DispatchQueue.main.async { diff --git a/CubeTime/Timer/StopwatchManager/StopwatchManager+PenaltyController.swift b/CubeTime/StopwatchManager/StopwatchManager+PenaltyController.swift similarity index 95% rename from CubeTime/Timer/StopwatchManager/StopwatchManager+PenaltyController.swift rename to CubeTime/StopwatchManager/StopwatchManager+PenaltyController.swift index 6242489e..09c44551 100644 --- a/CubeTime/Timer/StopwatchManager/StopwatchManager+PenaltyController.swift +++ b/CubeTime/StopwatchManager/StopwatchManager+PenaltyController.swift @@ -16,9 +16,9 @@ extension StopwatchManager { timeListReloadSolve?(solveItem) if Penalty(rawValue: solveItem.penalty)! == .plustwo { - timerController.secondsStr = formatSolveTime(secs: timerController.secondsElapsed, penType: Penalty(rawValue: solveItem.penalty)!) + timerController.secondsStr = formatSolveTime(secs: timerController.secondsElapsed, penalty: Penalty(rawValue: solveItem.penalty)!) } else { - timerController.secondsStr = formatSolveTime(secs: timerController.secondsElapsed, penType: Penalty(rawValue: solveItem.penalty)!) + timerController.secondsStr = formatSolveTime(secs: timerController.secondsElapsed, penalty: Penalty(rawValue: solveItem.penalty)!) } solves.remove(object: solveItem) @@ -79,6 +79,7 @@ extension StopwatchManager { bestSingle = getMin() phases = getAveragePhases() + sessionMean = getSessionMean() if (solvesByDate.suffix(100).contains(solve)) { self.currentAo100 = getCurrentAverage(of: 100) @@ -108,6 +109,7 @@ extension StopwatchManager { guard let solveItem else {return} delete(solve: solveItem) timerController.secondsElapsed = 0 + if !SettingsManager.standard.showPrevTime || currentSession is CompSimSession { if currentSession is CompSimSession { statsGetFromCache() @@ -116,6 +118,7 @@ extension StopwatchManager { } else { self.solveItem = solvesByDate.last } + timerController.secondsStr = formatSolveTime(secs: self.solveItem?.time ?? 0) tryUpdateCurrentSolveth() } diff --git a/CubeTime/Timer/StopwatchManager/StopwatchManager+Stats.swift b/CubeTime/StopwatchManager/StopwatchManager+Stats.swift similarity index 89% rename from CubeTime/Timer/StopwatchManager/StopwatchManager+Stats.swift rename to CubeTime/StopwatchManager/StopwatchManager+Stats.swift index 3fcdddda..a97f880f 100644 --- a/CubeTime/Timer/StopwatchManager/StopwatchManager+Stats.swift +++ b/CubeTime/StopwatchManager/StopwatchManager+Stats.swift @@ -108,6 +108,10 @@ extension StopwatchManager { } else { + // I don't know why this is needed + if !solves.indices.contains(cnt/2) { + return (nil, nil) + } let median = Double(solves[(cnt/2)].timeIncPen) if let truncatedMin = truncatedValues.0, let truncatedMax = truncatedValues.1 { @@ -126,7 +130,7 @@ extension StopwatchManager { return nil } - return Self.getCalculatedAverage(forSolves: solvesByDate.suffix(period), name: "Current ao", isCompsim: false) + return Self.getCalculatedAverage(forSolves: solvesByDate.suffix(period), name: "current ao", isCompsim: false) } } @@ -145,7 +149,21 @@ extension StopwatchManager { if timeListFilter.isEmpty { timeListSolvesFiltered = timeListSolves } else { - timeListSolvesFiltered = timeListSolves.filter{ formatSolveTime(secs: $0.time).hasPrefix(timeListFilter) } + + + timeListSolvesFiltered = timeListSolves.filter{ + let time = if let multiphaseSolve = $0 as? MultiphaseSolve, + let phase = timeListShownPhase, + let phases = multiphaseSolve.phases, + let time = ([0] + phases).chunked().map({ $0[1] - $0[0] })[safe: Int(phase)] { + time + } else { + $0.time + } + + return formatSolveTime(secs: time).hasPrefix(timeListFilter) || + ($0.comment ?? "").lowercased().contains(timeListFilter.lowercased()) + } } if hasPenaltyOnly || hasCommentOnly { @@ -220,6 +238,7 @@ extension StopwatchManager { bestSingle = getMin() // Get min is super fast anyway phases = getAveragePhases() + sessionMean = getSessionMean() if recalcAO100 { self.currentAo100 = getCurrentAverage(of: 100) @@ -235,6 +254,14 @@ extension StopwatchManager { self.bestAo12 = getBestAverage(of: 12) self.bestAo100 = getBestAverage(of: 100) + + if currentSession is CompSimSession { + let bpaWpa = getBpaWpa() + self.bpa = bpaWpa.bpa + self.wpa = bpaWpa.wpa + self.timeNeededForTarget = getTimeNeededForTarget() + } + try! managedObjectContext.save() timerController.secondsStr = formatSolveTime(secs: SettingsManager.standard.showPrevTime ? (self.solvesByDate.last?.time ?? 0) : 0) @@ -373,7 +400,7 @@ extension StopwatchManager { ) || (currentAo5.totalPen, bestAo5!.totalPen) == (Penalty.none, Penalty.dnf) { // current is none and best is dnf self.bestAo5 = currentAo5 - self.bestAo5?.name = "Best ao5" + self.bestAo5?.name = "best ao5" #warning("TODO: unhardcode") } } @@ -385,7 +412,7 @@ extension StopwatchManager { ) || (currentAo12.totalPen, bestAo12!.totalPen) == (Penalty.none, Penalty.dnf) { // current is none and best is dnf self.bestAo12 = currentAo12 - self.bestAo12?.name = "Best ao12" + self.bestAo12?.name = "best ao12" #warning("TODO: unhardcode") } } @@ -397,7 +424,7 @@ extension StopwatchManager { ) || (currentAo100.totalPen, bestAo100!.totalPen) == (Penalty.none, Penalty.dnf) { // current is none and best is dnf self.bestAo100 = currentAo100 - self.bestAo100?.name = "Best ao100" + self.bestAo100?.name = "best ao100" #warning("TODO: unhardcode") } } @@ -411,8 +438,7 @@ extension StopwatchManager { extension StopwatchManager { func getAveragePhases() -> [Double]? { if currentSession is MultiphaseSession { - let times = (solvesNoDNFs as! [MultiphaseSolve]).map({ $0.phases! }) - + let times = solvesNoDNFs.compactMap({ ($0 as? MultiphaseSolve)?.phases }) var summedPhases = [Double](repeating: 0, count: phaseCount) @@ -481,7 +507,7 @@ extension StopwatchManager { if groupLastSolve.count != 5 { return nil } else { - return Self.getCalculatedAverage(forSolves: groupLastSolve, name: "Current Comp Sim", isCompsim: true) + return Self.getCalculatedAverage(forSolves: groupLastSolve, name: "current compsim", isCompsim: true) } } else { @@ -491,10 +517,10 @@ extension StopwatchManager { if lastInGroup.count == 5 { - return Self.getCalculatedAverage(forSolves: lastInGroup, name: "Current Comp Sim", isCompsim: true) + return Self.getCalculatedAverage(forSolves: lastInGroup, name: "current compsim", isCompsim: true) } else { - return Self.getCalculatedAverage(forSolves: (groupLastTwoSolves.last!.solves!.allObjects as! [Solve]), name: "Current Comp Sim", isCompsim: true) + return Self.getCalculatedAverage(forSolves: (groupLastTwoSolves.last!.solves!.allObjects as! [Solve]), name: "current compsim", isCompsim: true) } } } else { @@ -514,13 +540,13 @@ extension StopwatchManager { return (nil, []) } else { var bestAverage: CalculatedAverage? -// var bestAverage: CalculatedAverage = calculateAverage(((compsimSession.solvegroups!.firstObject as! CompSimSolveGroup).solves!.array as! [Solves]), "Best Comp Sim", true)! +// var bestAverage: CalculatedAverage = calculateAverage(((compsimSession.solvegroups!.firstObject as! CompSimSolveGroup).solves!.array as! [Solves]), "Best Compsim", true)! for solvegroup in compsimSolveGroups { if solvegroup.solves!.allObjects.count == 5 { - let currentAvg = Self.getCalculatedAverage(forSolves: solvegroup.solves!.allObjects as! [Solve], name: "Best Comp Sim", isCompsim: true) + let currentAvg = Self.getCalculatedAverage(forSolves: solvegroup.solves!.allObjects as! [Solve], name: "best compsim", isCompsim: true) if currentAvg?.totalPen == .dnf { continue @@ -600,13 +626,23 @@ extension StopwatchManager { return nil } - func calculateMean(of count: Int, for solves: [Solve]) -> Average { + static func calculateMean(of count: Int, for solves: [Solve]) -> Average { let penalty: Penalty = solves.contains(where: { Penalty(rawValue: $0.penalty) == .dnf }) ? .dnf : .none let average: Double = solves.reduce(0, { $0 + $1.timeIncPen }) / Double(count) return Average(average: average, penalty: penalty) } + #warning("TODO: get rid of this simplesolve") + + static func calculateMean(of count: Int, for solves: [SimpleSolve]) -> Average { + let penalty: Penalty = solves.contains(where: { $0.penalty == .dnf }) ? .dnf : .none + let average: Double = solves.reduce(0, { $0 + $1.timeIncPen }) / Double(count) + + return Average(average: average, penalty: penalty) + } + + func getBpaWpa() -> (bpa: Average?, wpa: Average?) { if !(currentSession is CompSimSession) { return (nil, nil) } @@ -620,8 +656,8 @@ extension StopwatchManager { if (lastGroupSolves.count == 4) { let sortedGroup = lastGroupSolves.sorted(by: Self.sortWithDNFsLast) - let bpa = calculateMean(of: 3, for: Array(sortedGroup.dropLast())) - let wpa = calculateMean(of: 3, for: Array(sortedGroup.dropFirst())) + let bpa = StopwatchManager.calculateMean(of: 3, for: Array(sortedGroup.dropLast())) + let wpa = StopwatchManager.calculateMean(of: 3, for: Array(sortedGroup.dropFirst())) return (bpa, wpa) } @@ -634,20 +670,18 @@ extension StopwatchManager { if let compsimSession = currentSession as? CompSimSession { let solveGroups = compsimSolveGroups! - if solveGroups.count == 0 { return nil } else { - let lastGroupSolves = (solveGroups.first!.solves!.allObjects as! [Solve]) - if lastGroupSolves.count == 4 { + let target = compsimSession.target + + if let bpa = self.bpa, let wpa = self.wpa { + if wpa.average < target && wpa.penalty != .dnf { + return .guaranteed + } else if bpa.average > target { + return .notPossible + } else { + let lastGroupSolves = (solveGroups.first!.solves!.allObjects as! [Solve]) let sortedGroup = lastGroupSolves.sorted(by: Self.sortWithDNFsLast) - let timeNeededForTarget = (compsimSession as CompSimSession).target * 3 - (sortedGroup.dropFirst().dropLast().reduce(0) {$0 + $1.timeIncPen}) - - if timeNeededForTarget < sortedGroup.last!.time { - return .notPossible - } else if timeNeededForTarget > sortedGroup.first!.time && !sortedGroup.contains(where: {$0.penalty == Penalty.dnf.rawValue}) { - return .guaranteed - } else { - return .value(timeNeededForTarget) - } + return .value(timeNeededForTarget) } } } else { return nil } @@ -683,13 +717,13 @@ extension StopwatchManager { let allSolves = trimmedSolves + countedSolvesIndices.map({ solvesByDate[Int($0)] }) if (bestAverage == .infinity) { - return CalculatedAverage(name: "Best ao \(width)", + return CalculatedAverage(name: "best ao \(width)", average: bestAverage, accountedSolves: allSolves, totalPen: .dnf, trimmedSolves: trimmedSolves) } else { - return CalculatedAverage(name: "Best ao\(width)", + return CalculatedAverage(name: "best ao\(width)", average: bestAverage, accountedSolves: allSolves, totalPen: .none, diff --git a/CubeTime/Timer/StopwatchManager/StopwatchManager.swift b/CubeTime/StopwatchManager/StopwatchManager.swift similarity index 91% rename from CubeTime/Timer/StopwatchManager/StopwatchManager.swift rename to CubeTime/StopwatchManager/StopwatchManager.swift index 0471fa6c..9bd2492c 100644 --- a/CubeTime/Timer/StopwatchManager/StopwatchManager.swift +++ b/CubeTime/StopwatchManager/StopwatchManager.swift @@ -92,7 +92,7 @@ class StopwatchManager: ObservableObject { """, currentSession) let results = try! managedObjectContext.fetch(req) - sessionsCanMoveToPlayground = Array(repeating: [], count: puzzleTypes.count) + sessionsCanMoveToPlayground = Array(repeating: [], count: PUZZLE_TYPES.count) for result in results { if result.sessionType == SessionType.playground.rawValue { @@ -128,6 +128,10 @@ class StopwatchManager: ObservableObject { @Published var showUnlockScrambleConfirmation = false @Published var showPenOptions = false + /* confetti */ + @Published var confetti: Int = 0 + @Published var confettiLocation: CGPoint? + @Published var currentSolveth: Int? @Published var solveItem: Solve! @@ -249,6 +253,12 @@ class StopwatchManager: ObservableObject { } } + @Published var timeListShownPhase: Int16? = nil { + didSet { + changedTimeListSort() + } + } + var timeListReloadSolve: ((Solve) -> ())? var timeListSelectAll: (() -> ())? @@ -278,7 +288,7 @@ class StopwatchManager: ObservableObject { localizedTitle: session.name ?? "Unknown Session", localizedSubtitle: session.shortcutName, icon: UIApplicationShortcutIcon( - systemImageName: iconNamesForType[SessionType(rawValue: session.sessionType)!]! + systemImageName: SessionType(rawValue: session.sessionType)!.iconName() ), userInfo: ["id": session.objectID.uriRepresentation().absoluteString as NSString] ) @@ -311,7 +321,7 @@ class StopwatchManager: ObservableObject { let sessionToSave = Session(context: moc) // Must use this variable else didset will fire prematurely sessionToSave.scrambleType = 1 sessionToSave.sessionType = SessionType.playground.rawValue - sessionToSave.name = "Default Session" + sessionToSave.name = String(localized: "Default Session") currentSession = sessionToSave try! moc.save() } @@ -320,7 +330,7 @@ class StopwatchManager: ObservableObject { } var scrambleController: ScrambleController! - var timerController: TimerContoller! + var timerController: TimerController! init (currentSession: Session?, managedObjectContext: NSManagedObjectContext) { #if DEBUG @@ -334,7 +344,7 @@ class StopwatchManager: ObservableObject { self.playgroundScrambleType = -1 // Get the compiler to shut up about not initialized, cannot be optional for picker - self.timerController = TimerContoller( + self.timerController = TimerController( onStartInspection: { self.penType = .none /* reset penType from last solve */ }, onInspectionSecondsChange: { inspectionSecs in if inspectionSecs == inspectionPlusTwoTime { @@ -387,6 +397,22 @@ class StopwatchManager: ObservableObject { } self.updateStats() + + DispatchQueue.global(qos: .userInitiated).asyncAfter(deadline: .now() + 0.05) { + DispatchQueue.main.async { + if let best = self.bestSingle { + if secondsElapsed == best.timeIncPen { + self.confetti += 1 + + if let loc = self.confettiLocation, #available(iOS 17.5, *) { + UINotificationFeedbackGenerator().notificationOccurred(UINotificationFeedbackGenerator.FeedbackType.success, at: loc) + } else { + UINotificationFeedbackGenerator().notificationOccurred(UINotificationFeedbackGenerator.FeedbackType.success) + } + } + } + } + } }, onTouchUp: { if self.showPenOptions { diff --git a/CubeTime/Timer/StopwatchManager/TimerController.swift b/CubeTime/StopwatchManager/TimerController.swift similarity index 93% rename from CubeTime/Timer/StopwatchManager/TimerController.swift rename to CubeTime/StopwatchManager/TimerController.swift index 797ea339..24f5c150 100644 --- a/CubeTime/Timer/StopwatchManager/TimerController.swift +++ b/CubeTime/StopwatchManager/TimerController.swift @@ -15,7 +15,7 @@ import Combine let inspectionDnfTime = 17 let inspectionPlusTwoTime = 15 -class TimerContoller: ObservableObject { +class TimerController: ObservableObject { let sm = SettingsManager.standard let onStartInspection: (() -> ())? @@ -51,11 +51,10 @@ class TimerContoller: ObservableObject { } .sink(receiveValue: { [weak self] _ in guard let self else { return } - self.secondsStr = formatSolveTime(secs: self.secondsElapsed, dp: self.sm.displayDP) + self.secondsStr = formatSolveTime(secs: self.secondsElapsed) }) } - @Published var secondsStr = formatSolveTime(secs: 0) @Published var inspectionSecs = 0 { @@ -103,7 +102,7 @@ class TimerContoller: ObservableObject { private var timer: Timer? - private var timerStartTime: Date? + private var timerStartTime: UInt64? var secondsElapsed = 0.0 @@ -183,8 +182,6 @@ class TimerContoller: ObservableObject { } - - func start() { #if DEBUG NSLog("TC: Starting") @@ -195,24 +192,30 @@ class TimerContoller: ObservableObject { phaseTimes = [] } - mode = .running + withAnimation(Animation.customBouncySpring) { + mode = .running + } timer?.invalidate() // Stop possibly running inspections secondsElapsed = 0 secondsStr = formatSolveTime(secs: 0) - timerStartTime = Date() + timerStartTime = clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) if sm.timeDpWhenRunning == -1 { self.secondsStr = "..." } else { timer = Timer.scheduledTimer(withTimeInterval: 1/60, repeats: true) { [self] timer in - self.secondsElapsed = -timerStartTime!.timeIntervalSinceNow + self.secondsElapsed = getElapsedTime() self.secondsStr = formatSolveTime(secs: self.secondsElapsed, dp: sm.timeDpWhenRunning) } } } + private func getElapsedTime() -> Double { + return Double(clock_gettime_nsec_np(CLOCK_MONOTONIC_RAW) - timerStartTime!) / 1e9 + } + func stop(_ time: Double?) { #if DEBUG @@ -225,12 +228,14 @@ class TimerContoller: ObservableObject { self.secondsElapsed = time } else { prevDownStoppedTimer = true - self.secondsElapsed = -timerStartTime!.timeIntervalSinceNow + self.secondsElapsed = getElapsedTime() } self.secondsStr = formatSolveTime(secs: self.secondsElapsed) - mode = .stopped + withAnimation(Animation.customFastSpring) { + mode = .stopped + } onStop?(time, secondsElapsed, phaseTimes) } @@ -325,7 +330,7 @@ class TimerContoller: ObservableObject { // multiphase func lap() { currentMPCount += 1 - phaseTimes.append(-timerStartTime!.timeIntervalSinceNow) + phaseTimes.append(getElapsedTime()) feedbackStyle?.impactOccurred(intensity: 0.5) } } diff --git a/CubeTime/Timer/StopwatchManager/getBestAverage.c++ b/CubeTime/StopwatchManager/getBestAverage.c++ similarity index 100% rename from CubeTime/Timer/StopwatchManager/getBestAverage.c++ rename to CubeTime/StopwatchManager/getBestAverage.c++ diff --git a/CubeTime/Timer/StopwatchManager/getBestAverage.h++ b/CubeTime/StopwatchManager/getBestAverage.h++ similarity index 100% rename from CubeTime/Timer/StopwatchManager/getBestAverage.h++ rename to CubeTime/StopwatchManager/getBestAverage.h++ diff --git a/CubeTime/Tabs/FloatingPanel.swift b/CubeTime/Tabs/FloatingPanel.swift index 7f01d938..0115fffd 100644 --- a/CubeTime/Tabs/FloatingPanel.swift +++ b/CubeTime/Tabs/FloatingPanel.swift @@ -1,33 +1,36 @@ import SwiftUI +import UIKit struct FloatingPanel: View { @State private var height: CGFloat + @State private var beginHeight: CGFloat @Binding var stage: Int @State var oldStage: Int @State var isPressed: Bool = false - private let minHeight: CGFloat = 0 - private var maxHeight: CGFloat + private let minHeight: CGFloat + private let maxHeight: CGFloat var stages: [CGFloat] let views: [AnyView] -#warning("TODO: use that one func for each tupleview type when making a real package") init( currentStage: Binding, - maxHeight: CGFloat, stages: [CGFloat], @ViewBuilder content: @escaping () -> TupleView<(A, B, C)>) { - self.maxHeight = maxHeight + self.maxHeight = stages.last ?? 300 + self.minHeight = stages.first ?? 0 + self.stages = stages self._stage = currentStage self._oldStage = State(initialValue: currentStage.wrappedValue) self._height = State(initialValue: stages[currentStage.wrappedValue]) + self._beginHeight = State(initialValue: stages[currentStage.wrappedValue]) let c = content().value @@ -35,84 +38,150 @@ struct FloatingPanel: View { } var body: some View { - ZStack(alignment: .topLeading) { - ZStack(alignment: .bottom) { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color("overlay1")) - .frame(width: 360, height: height + 18) - .shadowDark(x: 0, y: 3) - .zIndex(1) - - ZStack { - Capsule() - .fill(isPressed ? Color("indent0") : Color("indent1")) - .scaleEffect(isPressed ? 1.12 : 1.00) - .frame(width: 36, height: 6) - } - .frame(width: 360, height: 18, alignment: .center) - .background( - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color("overlay1")) - .frame(width: 360, height: 18) - ) - .zIndex(2) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - self.isPressed = true - // Just follow touch within bounds + VStack(spacing: 0) { + views[stage] + .frame(width: 360, height: height, alignment: .top) + .clipped() + .animation(.none, value: stage) + - let newh = height + value.translation.height - if newh > maxHeight { - height = maxHeight - } else if newh < minHeight { - height = minHeight - } else { - height = newh - } - let nearest = stages.nearest(to: height)!.0 - if (nearest != oldStage) { - withAnimation(.customSlowSpring) { - stage = nearest - } - oldStage = nearest - } + DraggerView(onUpdate: { (heightDelta, projectedDelta, state) in + if (state == .began) { + self.beginHeight = self.height + + } else if (state == .changed) { + self.height = max(min(self.beginHeight + heightDelta, self.maxHeight), self.minHeight) + + let nearest = stages.nearest(to: height)!.0 + if (nearest != oldStage) { + withAnimation(.customSlowSpring) { + stage = nearest } - .onEnded() { value in - withAnimation(.customSlowSpring) { - self.isPressed = false - let n = stages.nearest(to: height + value.predictedEndTranslation.height)! - stage = n.0 - height = Double(n.1) - } - } - ) - } - .frame(width: 360, height: height + 18) - - - // view - ZStack(alignment: .top) { - Rectangle() - .fill(Color("overlay1")) - .frame(width: 360, height: height) - .cornerRadius(6, corners: [.topLeft, .topRight]) - - views[stage] - .frame(width: 360, height: height, alignment: .top) - .clipped() - .animation(.none, value: stage) - .zIndex(100) - } - .zIndex(3) + oldStage = nearest + } + } else if (state == .ended) { + let n = stages.nearest(to: self.height + projectedDelta)! + + withAnimation(.customSlowSpring) { + stage = n.0 + height = max(min(Double(n.1), self.maxHeight), self.minHeight) + } + } + }) + .frame(width: 360, height: 18) } + .clipped() .frame(width: 360) + .background ( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color("overlay1")) + .shadowDark(x: 0, y: 3) + ) .onChange(of: stages) { newValue in height = newValue[stage] } } } +struct DraggerView: UIViewControllerRepresentable { + let onUpdate: (CGFloat, + CGFloat, + UIPanGestureRecognizer.State) -> Void + + typealias UIViewControllerType = DraggerViewController + + func makeUIViewController(context: Context) -> DraggerViewController { + return DraggerViewController(changeHeight: onUpdate) + } + + func updateUIViewController(_ uiViewController: DraggerViewController, context: Context) { } +} + +class DraggerViewController: UIViewController { + var draggerCapsuleView = UIView(frame: .zero) + + var drag: UIPanGestureRecognizer! + + let onUpdate: (CGFloat, + CGFloat, + UIPanGestureRecognizer.State) -> () + + init(changeHeight: @escaping (CGFloat, + CGFloat, + UIPanGestureRecognizer.State) -> ()) { + self.onUpdate = changeHeight + + super.init(nibName: nil, bundle: nil) + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + setupView() + setupGestures() + } + + @objc private func panPanel(_ gestureRecogniser: UIPanGestureRecognizer) { + let d = gestureRecogniser.translation(in: self.view).y + let state = gestureRecogniser.state + let v = gestureRecogniser.velocity(in: self.view).y + let a = 1<<13 + + let dProj = (v*v) / CGFloat(a) * (d > 0 ? 1.0 : -1.0) + + + if (state == .began) { + self.draggerCapsuleView.backgroundColor = UIColor(named: "indent0")! + UIView.animate(withDuration: 0.45, delay: 0, usingSpringWithDamping: 0.76, initialSpringVelocity: 5, options: .curveEaseInOut, animations: { + self.draggerCapsuleView.transform = CGAffineTransformScale(CGAffineTransformIdentity, 1.12, 1.12); + }) + } else if (state == .ended) { + self.draggerCapsuleView.backgroundColor = UIColor(named: "indent1")! + UIView.animate(withDuration: 0.45, delay: 0, usingSpringWithDamping: 0.76, initialSpringVelocity: 5, options: .curveEaseInOut, animations: { + self.draggerCapsuleView.transform = CGAffineTransformScale(CGAffineTransformIdentity, 1.0, 1.0); + }) + } + + onUpdate(d, dProj, state) + } + + private func setupGestures() { + self.drag = UIPanGestureRecognizer(target: self, action: #selector(self.panPanel)) + drag.allowedScrollTypesMask = .all + drag.maximumNumberOfTouches = 1 + drag.minimumNumberOfTouches = 1 + + view.addGestureRecognizer(drag) + view.isUserInteractionEnabled = true + } + + private func setupView() { + self.view = UIView(frame: .zero) + view.backgroundColor = UIColor(named: "overlay1")! + view.layer.cornerRadius = 6 + view.layer.cornerCurve = .continuous + + self.draggerCapsuleView = UIView(frame: .zero) + draggerCapsuleView.layer.cornerRadius = 3 + draggerCapsuleView.layer.cornerCurve = .continuous + draggerCapsuleView.backgroundColor = UIColor(named: "indent1") + + self.view.addSubview(draggerCapsuleView) + self.draggerCapsuleView.translatesAutoresizingMaskIntoConstraints = false + + NSLayoutConstraint.activate([ + draggerCapsuleView.centerXAnchor.constraint(equalTo: view.centerXAnchor), + draggerCapsuleView.centerYAnchor.constraint(equalTo: view.centerYAnchor), + draggerCapsuleView.widthAnchor.constraint(equalToConstant: 36), + draggerCapsuleView.heightAnchor.constraint(equalToConstant: 6), + ]) + } +} + private extension Collection { subscript (safe index: Index?) -> Element? { guard let index = index else { return nil } @@ -127,3 +196,32 @@ private extension Array where Element: (Comparable & SignedNumeric) { }) } } + + +struct PrevSolvesDisplay: View { + @EnvironmentObject var stopwatchManager: StopwatchManager + var count: Int? + + @State var solve: Solve? = nil + + var body: some View { + if (SessionType(rawValue: stopwatchManager.currentSession.sessionType) == .compsim) { + if let currentSolveGroup = stopwatchManager.compsimSolveGroups.first { + TimeBar(solvegroup: currentSolveGroup, currentCalculatedAverage: .constant(nil), isSelectMode: .constant(false), current: true) + .frame(height: 55) + } + } else { + HStack { + ForEach((count != nil) + ? stopwatchManager.solvesByDate.suffix(count!) + : stopwatchManager.solvesByDate, id: \.self) { solve in + + TimeCard(solve: solve, currentSolve: $solve) + } + } + .sheet(item: self.$solve) { item in + TimeDetailView(for: item, currentSolve: $solve) + } + } + } +} diff --git a/CubeTime/Tabs/TabBar.swift b/CubeTime/Tabs/TabBar.swift index 2ae39aea..7c297871 100644 --- a/CubeTime/Tabs/TabBar.swift +++ b/CubeTime/Tabs/TabBar.swift @@ -132,6 +132,9 @@ struct TabIcon: View { var namespace: Namespace.ID let hasLittleGuy: Bool + @Preference(\.isStaticGradient) private var isStaticGradient + @EnvironmentObject var gradientManager: GradientManager + init(currentTab: Binding, assignedTab: Tab, systemIconName: String, systemIconNameSelected: String, namespace: Namespace.ID, hasLittleGuy: Bool = true) { self._currentTab = currentTab self.assignedTab = assignedTab @@ -141,23 +144,20 @@ struct TabIcon: View { self.hasLittleGuy = hasLittleGuy } + var body: some View { ZStack { if (hasLittleGuy && currentTab == assignedTab) { Capsule() - .fill(currentTab == .timer - ? colourScheme == .dark - ? Color("accent") - : Color("accent2") - : Color("dark")) + .fill(currentTab == .timer ? AnyShapeStyle(GradientManager.getGradient(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient).opacity(0.8)) : AnyShapeStyle(Color("dark"))) .matchedGeometryEffect(id: "littleguy", in: namespace, properties: .frame) .shadow(color: currentTab == .timer - ? Color("accent3") + ? Color("accent2") : colourScheme == .dark ? Color.clear : Color("indent0"), - radius: 2, - x: 0, y: 0.5) + radius: 3, + x: 0, y: 0) .frame(width: 32, height: 2.25) .offset(y: 47.75 - 48/2) } diff --git a/CubeTime/TimeList/TimeBar.swift b/CubeTime/TimeList/TimeBar.swift index ba31cc16..7ecc170b 100644 --- a/CubeTime/TimeList/TimeBar.swift +++ b/CubeTime/TimeList/TimeBar.swift @@ -21,7 +21,7 @@ struct TimeBar: View { @State var isSelected = false - @ScaledMetric(wrappedValue: 17, relativeTo: .body) private var attributedStringSize: CGFloat + @ScaledMetric(wrappedValue: 16, relativeTo: .body) private var attributedStringSize: CGFloat let current: Bool @@ -52,8 +52,8 @@ struct TimeBar: View { VStack(spacing: 0) { if let calculatedAverage = calculatedAverage { HStack { - Text(formatSolveTime(secs: calculatedAverage.average!, penType: calculatedAverage.totalPen)) - .font(.title2.weight(.bold)) + Text(formatSolveTime(secs: calculatedAverage.average!, penalty: calculatedAverage.totalPen)) + .recursiveMono(style: .title2, weight: .bold) Spacer() } @@ -61,16 +61,23 @@ struct TimeBar: View { let displayText: NSMutableAttributedString = { let finalStr = NSMutableAttributedString(string: "") - let grey: [NSAttributedString.Key: Any] = [.foregroundColor: UIColor.systemGray, .font: UIFont.systemFont(ofSize: attributedStringSize, weight: .medium)] - let normal: [NSAttributedString.Key: Any] = [.font: UIFont.systemFont(ofSize: attributedStringSize, weight: .medium)] + let grey: [NSAttributedString.Key: Any] = [ + .foregroundColor: UIColor.systemGray, + .font: FontManager.fontFor(size: attributedStringSize, weight: 500) + ] + + let normal: [NSAttributedString.Key: Any] = [ + .font: FontManager.fontFor(size: attributedStringSize, weight: 500) + ] + if let ar = solvegroup.solves { // CSTODO for (index, solve) in Array((ar.allObjects as! [Solve]).enumerated()) { if calculatedAverage.trimmedSolves!.contains(solve) { - finalStr.append(NSMutableAttributedString(string: "(" + formatSolveTime(secs: solve.time, penType: Penalty(rawValue: solve.penalty)) + ")", attributes: grey)) + finalStr.append(NSMutableAttributedString(string: "(" + formatSolveTime(secs: solve.time, penalty: Penalty(rawValue: solve.penalty)) + ")", attributes: grey)) } else { - finalStr.append(NSMutableAttributedString(string: formatSolveTime(secs: solve.time, penType: Penalty(rawValue: solve.penalty)), attributes: normal)) + finalStr.append(NSMutableAttributedString(string: formatSolveTime(secs: solve.time, penalty: Penalty(rawValue: solve.penalty)), attributes: normal)) } if index < solvegroup.solves!.count-1 { finalStr.append(NSMutableAttributedString(string: ", ")) @@ -88,10 +95,10 @@ struct TimeBar: View { Spacer() } } else { - if solvegroup.solves!.count < 5 { + if let solves = solvegroup.solves, solves.count < 5 { HStack { Text("Current Average") - .font(.title2.weight(.bold)) + .recursiveMono(style: .title2, weight: .bold) Spacer() } @@ -100,12 +107,12 @@ struct TimeBar: View { let finalStr = NSMutableAttributedString(string: "") let normal: [NSAttributedString.Key: Any] = [ - .font: UIFont.preferredFont(forTextStyle: .body), + .font: FontManager.fontFor(size: attributedStringSize, weight: 500) ] // CSTODO for (index, solve) in Array((solvegroup.solves!.allObjects as! [Solve]).enumerated()) { - finalStr.append(NSMutableAttributedString(string: formatSolveTime(secs: solve.time, penType: Penalty(rawValue: solve.penalty)), attributes: normal)) + finalStr.append(NSMutableAttributedString(string: formatSolveTime(secs: solve.time, penalty: Penalty(rawValue: solve.penalty)), attributes: normal)) if index < solvegroup.solves!.count - 1 { finalStr.append(NSMutableAttributedString(string: ", ")) @@ -116,10 +123,6 @@ struct TimeBar: View { }() - - - - HStack(spacing: 0) { Text(AttributedString(displayText)) @@ -128,7 +131,7 @@ struct TimeBar: View { } else { HStack { Text("Loading...") - .font(.title2.weight(.bold)) + .recursiveMono(style: .title2, weight: .bold) Spacer() } diff --git a/CubeTime/TimeList/TimeCard.swift b/CubeTime/TimeList/TimeCard.swift index d44434e9..c90c6204 100644 --- a/CubeTime/TimeList/TimeCard.swift +++ b/CubeTime/TimeList/TimeCard.swift @@ -7,7 +7,7 @@ struct TimeCard: View { @Environment(\.managedObjectContext) var managedObjectContext @EnvironmentObject var stopwatchManager: StopwatchManager - + var solve: Solve let formattedTime: String @@ -39,7 +39,7 @@ struct TimeCard: View { init(solve: Solve, currentSolve: Binding) { self.solve = solve - self.formattedTime = formatSolveTime(secs: solve.time, penType: Penalty(rawValue: solve.penalty)!) + self.formattedTime = formatSolveTime(secs: solve.time, penalty: Penalty(rawValue: solve.penalty)!) self.pen = Penalty(rawValue: solve.penalty)! self._currentSolve = currentSolve } @@ -53,16 +53,12 @@ struct TimeCard: View { VStack { Text(formattedTime) - .font(.body.weight(.bold)) + .recursiveMono(style: .body, weightValue: 650) } } .frame(maxWidth: cardWidth, minHeight: cardHeight, maxHeight: cardHeight) - .onTapGesture { - currentSolve = solve - } - .onLongPressGesture { - UIImpactFeedbackGenerator(style: .heavy).impactOccurred() - } + .onTapGesture { currentSolve = solve } + .onLongPressGesture { UIImpactFeedbackGenerator(style: .heavy).impactOccurred() } .contentShape(.contextMenuPreview, RoundedRectangle(cornerRadius: 8, style: .continuous)) .contextMenu { @@ -70,7 +66,7 @@ struct TimeCard: View { copySolve(solve: solve) } label: { Label { - Text("Copy") + Text(String(localized: "Copy")) } icon: { Image(systemName: "doc.on.doc") } @@ -80,7 +76,7 @@ struct TimeCard: View { Button { stopwatchManager.changePen(solve: self.solve, pen: .none) } label: { - Label("No Penalty", systemImage: "checkmark.circle") + Label(String(localized: "No Penalty"), systemImage: "checkmark.circle") } Button { @@ -95,7 +91,7 @@ struct TimeCard: View { Label("DNF", systemImage: "xmark.circle") } } label: { - Label("Penalty", systemImage: "exclamationmark.triangle") + Label(String(localized: "Penalty"), systemImage: "exclamationmark.triangle") } if sessionType != SessionType.compsim.rawValue { @@ -120,7 +116,7 @@ struct TimeCard: View { } } label: { Label { - Text("Delete") + Text(String(localized: "Delete")) } icon: { Image(systemName: "trash") } diff --git a/CubeTime/TimeList/TimeDetailView.swift b/CubeTime/TimeList/TimeDetailView.swift index 82ab35c1..c11955ad 100644 --- a/CubeTime/TimeList/TimeDetailView.swift +++ b/CubeTime/TimeList/TimeDetailView.swift @@ -3,12 +3,14 @@ import SwiftUI func getSolveDateFormatter(_ date: Date) -> DateFormatter { let dateFormatter: DateFormatter = DateFormatter() - dateFormatter.locale = Locale(identifier: "en_NZ") + dateFormatter.locale = Locale.current if (Calendar.current.isDateInToday(date)) { - dateFormatter.dateFormat = "h:mm a" + dateFormatter.dateStyle = .none + dateFormatter.timeStyle = .medium } else { - dateFormatter.dateFormat = "dd/MM/yy" + dateFormatter.dateStyle = .short + dateFormatter.timeStyle = .none } return dateFormatter @@ -28,22 +30,25 @@ struct TimeDetailView: View { private let date: Date private let time: String - private let puzzle_type: PuzzleType + private let puzzleType: PuzzleType private let scramble: String private let phases: Array? @State private var userComment: String + @State private var showAlert = false @Binding var currentSolve: Solve? @FocusState private var commentFocus: Bool + @Preference(\.promptDelete) private var promptDelete + init(for solve: Solve, currentSolve: Binding?, sessionsCanMoveTo: Binding<[Session]?>? = nil, sessionsCanMoveTo_playground: Binding<[Session]?>? = nil) { self.solve = solve self.date = solve.date ?? Date(timeIntervalSince1970: 0) - self.time = formatSolveTime(secs: solve.time, penType: Penalty(rawValue: solve.penalty)!) - self.puzzle_type = puzzleTypes[Int(solve.scrambleType)] + self.time = formatSolveTime(secs: solve.time, penalty: Penalty(rawValue: solve.penalty)!) + self.puzzleType = PUZZLE_TYPES[Int(solve.scrambleType)] self.scramble = solve.scramble ?? "Retrieving scramble failed." if let multiphaseSolve = (solve as? MultiphaseSolve), let phases = multiphaseSolve.phases { @@ -79,7 +84,7 @@ struct TimeDetailView: View { if (dynamicTypeSize <= .xLarge) { Text("(\(formatSolveTime(secs: solve.time)))") - .font(.title3.weight(.semibold)) + .recursiveMono(style: .title3, weight: .semibold) .foregroundColor(Color("grey")) .padding(.leading, 8) .offset(y: -4) @@ -87,12 +92,12 @@ struct TimeDetailView: View { case Penalty.plustwo.rawValue: Text("\(formatSolveTime(secs: (solve.time + 2)))") - .font(.largeTitle.weight(.bold)) + .recursiveMono(style: .largeTitle, weight: .bold) .modifier(DynamicText()) if (dynamicTypeSize <= .xLarge) { Text("(\(time))") - .font(.title3.weight(.semibold)) + .recursiveMono(style: .title3, weight: .semibold) .foregroundColor(Color("grey")) .padding(.leading, 8) .modifier(DynamicText()) @@ -100,36 +105,38 @@ struct TimeDetailView: View { } default: Text(time) - .font(.largeTitle.weight(.bold)) + .recursiveMono(style: .largeTitle, weight: .bold) .modifier(DynamicText()) } Spacer() - - HStack(alignment: .center) { - Text(puzzle_type.name) - .font(.title3.weight(.semibold)) - - Image(puzzle_type.name) - .resizable() - .frame(width: 22, height: 22) - } - .offset(y: -4) } - ThemedDivider() + CTDivider() HStack { + HStack(alignment: .center, spacing: 4) { + Image(puzzleType.imageName) + .resizable() + .frame(width: 16, height: 16) + + Text(puzzleType.name) + } + + Text("|") + .offset(y: -1) // slight offset of bar + Text(date, formatter: getSolveDateFormatter(date)) - .recursiveMono(fontSize: 15, weight: .regular) - .foregroundColor(Color("grey")) + Spacer() } + .font(.subheadline.weight(.medium)) + .frame(maxWidth: .infinity, alignment: .leading) Group { - if puzzle_type.name == "Megaminx" { + if puzzleType.name == "Megaminx" { Text(scramble) .fixedSize(horizontal: true, vertical: false) .multilineTextAlignment(.leading) @@ -140,7 +147,7 @@ struct TimeDetailView: View { Text(scramble) } } - .recursiveMono(fontSize: 17, weight: .medium) + .recursiveMono(size: 17, weight: .semibold) .padding(.top, 28) AsyncSVGView(puzzle: solve.scrambleType, scramble: scramble) @@ -152,13 +159,13 @@ struct TimeDetailView: View { HStack(spacing: 6) { Spacer() - CTButton(type: solve.penalty == Penalty.none.rawValue ? .halfcoloured : .mono, size: .medium, onTapRun: { + CTButton(type: solve.penalty == Penalty.none.rawValue ? .halfcoloured(nil) : .mono, size: .medium, onTapRun: { stopwatchManager.changePen(solve: self.solve, pen: .none) }) { Label("OK", systemImage: "checkmark.circle") } - CTButton(type: solve.penalty == Penalty.plustwo.rawValue ? .halfcoloured : .mono, size: .medium, onTapRun: { + CTButton(type: solve.penalty == Penalty.plustwo.rawValue ? .halfcoloured(nil) : .mono, size: .medium, onTapRun: { stopwatchManager.changePen(solve: self.solve, pen: .plustwo) }) { Label(title: { @@ -169,7 +176,7 @@ struct TimeDetailView: View { }) } - CTButton(type: solve.penalty == Penalty.dnf.rawValue ? .halfcoloured : .mono, size: .medium, onTapRun: { + CTButton(type: solve.penalty == Penalty.dnf.rawValue ? .halfcoloured(nil) : .mono, size: .medium, onTapRun: { stopwatchManager.changePen(solve: self.solve, pen: .dnf) }) { Label("DNF", systemImage: "xmark.circle") @@ -184,9 +191,9 @@ struct TimeDetailView: View { HStack { Spacer() - Text("CubeTime.") - .recursiveMono(fontSize: 13) - .foregroundColor(Color("indent1")) + Text("CubeTime") + .recursiveMono(size: 13) + .foregroundColor(Color("grey").opacity(0.36)) } .padding(.vertical, -4) @@ -199,26 +206,37 @@ struct TimeDetailView: View { CTCopyButton(toCopy: getShareStr(solve: solve, phases: (solve as? MultiphaseSolve)?.phases), buttonText: "Copy Solve") - CTShareButton(toShare: getShareStr(solve: solve, phases: (solve as? MultiphaseSolve)?.phases), buttonText: "Share Solve") + CTShareButton(toShare: getShareStr(solve: solve, phases: (solve as? MultiphaseSolve)?.phases), buttonText: String(localized: "Share Solve")) - CTButton(type: .red, size: .large, square: true, onTapRun: { - if currentSolve == nil { - dismiss() + CTButton(type: .coloured(Color("red")), size: .large, square: true, onTapRun: { + if(promptDelete){ + showAlert = true } - - currentSolve = nil - - withAnimation { - stopwatchManager.delete(solve: solve) + else{ + deleteSolve() } }) { Image(systemName: "trash") } - .frame(width: 35) } .padding(.top, 16) .padding(.bottom, 4) + .alert(isPresented: $showAlert){ + Alert( + title: Text("Are you sure you want to delete this solve?"), + primaryButton: .cancel( + Text("Cancel"), + action: { } + ), + secondaryButton: .destructive( + Text("Delete Solve"), + action: { + deleteSolve() + } + ) + ) + } // END BUTTONS @@ -228,7 +246,7 @@ struct TimeDetailView: View { Text("SESSION") .font(.subheadline.weight(.semibold)) - ThemedDivider() + CTDivider() HStack { Image(systemName: "square.on.square") @@ -245,7 +263,7 @@ struct TimeDetailView: View { currentSolve = nil dismiss() } label: { - CTButtonBase(type: .mono, size: .medium, outlined: false, square: false, hasShadow: true, hasBackground: true, expandWidth: false) { + CTBubble(type: .mono, size: .medium, outlined: false, square: false, hasShadow: true, hasBackground: true, hasMaterial: true, supportsDynamicResizing: true, expandWidth: false) { Label("Move to…", systemImage: "arrow.up.right") } } @@ -261,7 +279,7 @@ struct TimeDetailView: View { Text("PHASES") .font(.subheadline.weight(.semibold)) - ThemedDivider() + CTDivider() AveragePhases(phaseTimes: phases, count: phases.count) .padding(.top, -24) @@ -277,7 +295,7 @@ struct TimeDetailView: View { Text("COMMENT") .font(.subheadline.weight(.semibold)) - ThemedDivider() + CTDivider() ZStack { Group { @@ -353,5 +371,16 @@ struct TimeDetailView: View { } } } + + func deleteSolve() -> Void{ + if currentSolve == nil { + dismiss() + } + currentSolve = nil + + withAnimation { + stopwatchManager.delete(solve: solve) + } + } } diff --git a/CubeTime/TimeList/TimeListView.swift b/CubeTime/TimeList/TimeListView.swift index 98e4e56d..67191ed3 100644 --- a/CubeTime/TimeList/TimeListView.swift +++ b/CubeTime/TimeList/TimeListView.swift @@ -25,6 +25,17 @@ struct SortByMenu: View { var body: some View { Menu { + if let phaseCount = (stopwatchManager.currentSession as? MultiphaseSession)?.phaseCount { + Menu("Phase") { + Picker("", selection: $stopwatchManager.timeListShownPhase) { + Text("All phases").tag(Optional.none) + + ForEach(0...some(idx)) + } + } + } + } #warning("TODO: headers not working") Section("Sort by") { Picker("", selection: $stopwatchManager.timeListSortBy) { @@ -54,7 +65,7 @@ struct SortByMenu: View { Menu("Puzzle Type") { Picker("", selection: $stopwatchManager.scrambleTypeFilter) { Text("All Puzzles").tag(-1) - ForEach(Array(zip(puzzleTypes.indices, puzzleTypes)), id: \.0) { index, element in + ForEach(Array(zip(PUZZLE_TYPES.indices, PUZZLE_TYPES)), id: \.0) { index, element in Label(element.name, image: element.name).tag(index) } } @@ -62,9 +73,10 @@ struct SortByMenu: View { } } } label: { - CTButtonBase(type: .halfcoloured, size: .large, outlined: false, square: true, hasShadow: hasShadow, hasBackground: true, expandWidth: true) { + CTBubble(type: .halfcoloured(nil), size: .large, outlined: false, square: true, hasShadow: hasShadow, hasBackground: true, hasMaterial: true, supportsDynamicResizing: true, expandWidth: true) { Image(systemName: "line.3.horizontal.decrease") .matchedGeometryEffect(id: "label", in: animation) + .font(.body.weight(.medium)) } .animation(Animation.customEaseInOut, value: self.hasShadow) .frame(width: frameHeight, height: frameHeight) @@ -79,20 +91,22 @@ struct SessionHeader: View { @EnvironmentObject var stopwatchManager: StopwatchManager var body: some View { - HStack { + HStack(spacing: 0) { SessionIconView(session: stopwatchManager.currentSession) - Text(stopwatchManager.currentSession.name ?? "Unknown Session Name") - .font(.body.weight(.medium)) - - Spacer() - if (SessionType(rawValue: stopwatchManager.currentSession.sessionType) != .playground) { - Text(puzzleTypes[Int(stopwatchManager.currentSession.scrambleType)].name) + Text(PUZZLE_TYPES[Int(stopwatchManager.currentSession.scrambleType)].name) .font(.body.weight(.medium)) - .padding(.trailing) } + + CTDivider(isHorizontal: false) + .padding([.vertical, .trailing], 8) + .padding(.leading, SessionType(rawValue: stopwatchManager.currentSession.sessionType) != .playground ? 8 : 2) + + Text(stopwatchManager.currentSession.name ?? "") + .font(.body.weight(.medium)) } + .frame(maxWidth: .infinity, alignment: .leading) .frame(height: frameHeight) .background( Color("overlay1") @@ -123,23 +137,27 @@ struct TimeListHeader: View { } if (stopwatchManager.currentSession.sessionType != SessionType.compsim.rawValue) { + // search bar ZStack { RoundedRectangle(cornerRadius: 6, style: .continuous) .fill(Color("overlay0")) .shadowDark(x: 0, y: 1) - HStack { + + HStack(spacing: 4) { Image(systemName: "magnifyingglass") - .padding(.horizontal, searchExpanded ? 9 : 0) + .padding(.leading, searchExpanded ? 8 : 0) + .padding(.trailing, searchExpanded ? 4 : 0) .foregroundColor(Color("accent")) .font(.body.weight(.medium)) #warning("todo make search bar search for comments too?") if searchExpanded { - TextField("Search for a time...", text: $stopwatchManager.timeListFilter) - .frame(maxWidth: .infinity) + TextField("Search…", text: $stopwatchManager.timeListFilter) + .recursiveMono(style: stopwatchManager.timeListFilter.isEmpty ? .callout : .body, weight: .medium) .foregroundColor(Color(stopwatchManager.timeListFilter.isEmpty ? "grey" : "dark")) - + .frame(maxWidth: .infinity) + Button { withAnimation(Animation.customEaseInOut) { stopwatchManager.timeListFilter = "" @@ -148,7 +166,7 @@ struct TimeListHeader: View { } label: { Image(systemName: "xmark") } - .font(.body) + .font(.body.weight(.medium)) .buttonStyle(CTButtonStyle()) .foregroundColor(searchExpanded ? Color("accent") : Color.clear) .padding(.horizontal, 8) @@ -186,7 +204,7 @@ struct TimeListHeader: View { // sort by menu SortByMenu(hasShadow: !searchExpanded, animation: animation) - .offset(x: searchExpanded ? -43 : 0) + .offset(x: searchExpanded ? -36 : 0) } } .padding(.horizontal) @@ -234,7 +252,7 @@ struct CompSimTimeListInner: View { } if groups.count > 1 { - ThemedDivider() + CTDivider() .padding(.horizontal, 8) } } else { @@ -271,6 +289,10 @@ struct TimeListView: View { @State var isCleaningSession = false + @State var showAlert = false + + @Preference(\.promptDelete) private var promptDelete + var body: some View { let sessionType = stopwatchManager.currentSession.sessionType NavigationView { @@ -311,7 +333,7 @@ struct TimeListView: View { stopwatchManager.timeListSolvesSelected.removeAll() } label: { - Label("Copy", systemImage: "doc.on.doc") + Label(String(localized: "Copy"), systemImage: "doc.on.doc") } Menu { @@ -322,7 +344,7 @@ struct TimeListView: View { stopwatchManager.timeListSolvesSelected.removeAll() } label: { - Label("No Penalty", systemImage: "checkmark.circle") + Label(String(localized: "No Penalty"), systemImage: "checkmark.circle") } Button { @@ -342,12 +364,13 @@ struct TimeListView: View { stopwatchManager.timeListSolvesSelected.removeAll() } label: { - Label("DNF", systemImage: "xmark.circle") + Label(String(localized: "DNF"), systemImage: "xmark.circle") } } label: { - Label("Penalty", systemImage: "exclamationmark.triangle") + Label(String(localized: "Penalty"), systemImage: "exclamationmark.triangle") } + if stopwatchManager.currentSession.sessionType != SessionType.compsim.rawValue { SessionPickerMenu(sessions: sessionType == SessionType.playground.rawValue ? sessionsCanMoveToPlaygroundContextMenu : stopwatchManager.sessionsCanMoveTo) { session in for object in stopwatchManager.timeListSolvesSelected { @@ -361,19 +384,19 @@ struct TimeListView: View { Divider() - Button(role: .destructive) { - isSelectMode = false - for object in stopwatchManager.timeListSolvesSelected { - stopwatchManager.delete(solve: object) + Button(role: .destructive, action: { + if(promptDelete){ + showAlert = true } - - stopwatchManager.timeListSolvesSelected.removeAll() - } label: { - Label("Delete", systemImage: "trash") + else{ + deleteSolves() + } + }) { + Label(String(localized: "Delete"), systemImage: "trash") } } } label: { - CTButtonBase(type: .coloured, size: .small, outlined: false, square: true, hasShadow: true, hasBackground: true, expandWidth: true) { + CTBubble(type: .coloured(nil), size: .small, outlined: false, square: true, hasShadow: true, hasBackground: true, hasMaterial: true, supportsDynamicResizing: true, expandWidth: true) { Image(systemName: "ellipsis") .frame(width: 28, height: 28) .imageScale(.medium) @@ -387,7 +410,7 @@ struct TimeListView: View { ToolbarItemGroup(placement: .navigationBarTrailing) { if stopwatchManager.currentSession.sessionType != SessionType.compsim.rawValue { if isSelectMode { - CTButton(type: .coloured, size: .small, onTapRun: { + CTButton(type: .coloured(nil), size: .small, onTapRun: { withAnimation(Animation.customDampedSpring) { stopwatchManager.timeListSelectAll?() } @@ -403,7 +426,7 @@ struct TimeListView: View { Text("Cancel") } } else { - CTButton(type: .coloured, size: .small, onTapRun: { + CTButton(type: .coloured(nil), size: .small, onTapRun: { isSelectMode = true }) { Text("Select") @@ -426,6 +449,13 @@ struct TimeListView: View { Text("Are you sure you want to continue? This will delete every solve in this session!") } + .confirmationDialog(String(localized: "Are you sure you want to delete the selected solves? This cannot be undone."), isPresented: $showAlert, titleVisibility: .visible) { + Button("Confirm", role: .destructive) { + deleteSolves() + } + Button("Cancel", role: .cancel) {} + } + .sheet(item: $solve) { item in TimeDetailView(for: item, currentSolve: $solve) .tint(Color("accent")) @@ -456,4 +486,14 @@ struct TimeListView: View { } } } + + func deleteSolves() -> Void{ + isSelectMode = false + for object in stopwatchManager.timeListSolvesSelected { + stopwatchManager.delete(solve: object) + } + + stopwatchManager.timeListSolvesSelected.removeAll() + } } + diff --git a/CubeTime/TimeList/TimeListViewInner/TimeCardView.swift b/CubeTime/TimeList/TimeListViewInner/TimeCardView.swift new file mode 100644 index 00000000..2fb396ac --- /dev/null +++ b/CubeTime/TimeList/TimeListViewInner/TimeCardView.swift @@ -0,0 +1,91 @@ +import Foundation +import UIKit + +let checkboxUIImage = UIImage(systemName: "checkmark.circle.fill")! + +class TimeCardView: UIStackView { + let checkbox = UIImageView(image: checkboxUIImage) + +// private let gradientBorderLayer = CAGradientLayer() +// private let borderLayer = CAShapeLayer() + + required init(coder: NSCoder) { + fatalError("error") + } + + override init(frame: CGRect) { + super.init(frame: frame) + + setupCheckbox() +// setupGradient() + + self.axis = .vertical + self.alignment = .center + self.distribution = .equalCentering + self.spacing = 0 + + self.addArrangedSubview(UIView()) + self.addArrangedSubview(checkbox) + self.addArrangedSubview(UIView()) + } + + private func setupCheckbox() { + checkbox.contentMode = .scaleAspectFit + checkbox.isHidden = true + + var config = UIImage.SymbolConfiguration(paletteColors: [UIColor(named: "accent")!, UIColor(named: "overlay0")!]) + config = config.applying(UIImage.SymbolConfiguration(weight: .semibold)) + checkbox.preferredSymbolConfiguration = config + checkbox.layer.shadowColor = UIColor.black.cgColor + checkbox.layer.shadowOffset = .init(width: 0, height: 2) + checkbox.layer.shadowRadius = 8 + checkbox.layer.shadowOpacity = 0.12 + } + +// private func setupGradient() { +// gradientBorderLayer.colors = [ +// UIColor.systemRed.cgColor, +// UIColor.systemBlue.cgColor +// ] +// gradientBorderLayer.startPoint = CGPoint(x: 0, y: 0) +// gradientBorderLayer.endPoint = CGPoint(x: 1, y: 1) +// gradientBorderLayer.cornerRadius = 8 +// gradientBorderLayer.mask = borderLayer +// +// layer.insertSublayer(gradientBorderLayer, at: 0) +// } +// +// override func layoutSubviews() { +// super.layoutSubviews() +// +// gradientBorderLayer.frame = bounds +// borderLayer.path = UIBezierPath(roundedRect: bounds, cornerRadius: 8).cgPath +// borderLayer.lineWidth = 5 +// borderLayer.fillColor = UIColor.clear.cgColor +// borderLayer.strokeColor = UIColor.black.cgColor +// } +} + + +class TimeCardLabel: UILabel { + required init() { + super.init(frame: CGRect.zero) + } + + required init?(coder: NSCoder) { + fatalError("error") + } + + override func drawText(in rect: CGRect) { + let insets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 4) + super.drawText(in: rect.inset(by: insets)) + } + + override var intrinsicContentSize: CGSize { + get { + var contentSize = super.intrinsicContentSize + contentSize.width += 8 + return contentSize + } + } +} diff --git a/CubeTime/TimeList/TimeListViewInner.swift b/CubeTime/TimeList/TimeListViewInner/TimeListViewController.swift similarity index 74% rename from CubeTime/TimeList/TimeListViewInner.swift rename to CubeTime/TimeList/TimeListViewInner/TimeListViewController.swift index db4497fb..39052c46 100644 --- a/CubeTime/TimeList/TimeListViewInner.swift +++ b/CubeTime/TimeList/TimeListViewInner/TimeListViewController.swift @@ -3,60 +3,6 @@ import UIKit import SwiftUI import Combine -let checkboxUIImage = UIImage(systemName: "checkmark.circle.fill")! - -class TimeCardView: UIStackView { - let checkbox = UIImageView(image: checkboxUIImage) - - required init(coder: NSCoder) { - fatalError("error") - } - - override init(frame: CGRect) { - super.init(frame: frame) - - checkbox.contentMode = .scaleAspectFit - checkbox.isHidden = true - - var config = UIImage.SymbolConfiguration(paletteColors: [UIColor(named: "accent")!, UIColor(named: "overlay0")!]) - config = config.applying(UIImage.SymbolConfiguration(weight: .semibold)) - checkbox.preferredSymbolConfiguration = config - - self.axis = .vertical - self.alignment = .center - self.distribution = .equalCentering - self.spacing = 0 - - self.addArrangedSubview(UIView()) - self.addArrangedSubview(checkbox) - self.addArrangedSubview(UIView()) - } -} - - -class TimeCardTextLabel: UILabel { - required init() { - super.init(frame: CGRect.zero) - } - - required init?(coder: NSCoder) { - fatalError("error") - } - - override func drawText(in rect: CGRect) { - let insets = UIEdgeInsets(top: 0, left: 4, bottom: 0, right: 4) - super.drawText(in: rect.inset(by: insets)) - } - - override var intrinsicContentSize: CGSize { - get { - var contentSize = super.intrinsicContentSize - contentSize.width += 8 - return contentSize - } - } -} - class TimeCardCell: UICollectionViewCell { lazy var timeCardView: TimeCardView = { @@ -64,7 +10,7 @@ class TimeCardCell: UICollectionViewCell { }() var item: Solve! - let label = TimeCardTextLabel() + let label = TimeCardLabel() weak var viewController: TimeListViewController? var gesture: UITapGestureRecognizer! var switchedToStackView = false @@ -74,11 +20,9 @@ class TimeCardCell: UICollectionViewCell { } override init(frame: CGRect) { - super.init(frame: frame) self.layer.backgroundColor = UIColor(named: isSelected ? "indent0" : "overlay0")!.cgColor -// self.timeCardLabel.checkbox.isHidden = !self.isSelected self.layer.cornerRadius = 8 self.layer.cornerCurve = .continuous @@ -86,7 +30,7 @@ class TimeCardCell: UICollectionViewCell { self.label.textAlignment = .center - self.label.font = UIFont.systemFont(ofSize: UIFont.preferredFont(forTextStyle: .body).pointSize, weight: .bold) + self.label.font = FontManager.fontFor(size: 17, weight: 650) self.label.isUserInteractionEnabled = false self.label.numberOfLines = 1 @@ -94,7 +38,6 @@ class TimeCardCell: UICollectionViewCell { self.label.adjustsFontSizeToFitWidth = true self.contentView.addSubview(label) - } override func updateConfiguration(using state: UICellConfigurationState) { @@ -102,15 +45,18 @@ class TimeCardCell: UICollectionViewCell { switchedToStackView = true label.removeFromSuperview() self.contentView.addSubview(timeCardView) + + timeCardView.frame = contentView.bounds timeCardView.insertArrangedSubview(label, at: 1) } - UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.82, initialSpringVelocity: 1) { [ weak self] in + UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.82, initialSpringVelocity: 1) { [weak self] in guard let self else { return } self.layer.backgroundColor = UIColor(named: self.isSelected ? "indent0" : "overlay0")!.cgColor } + if switchedToStackView && (self.timeCardView.checkbox.isHidden != !self.isSelected) { - UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.82, initialSpringVelocity: 1) { [ weak self] in + UIView.animate(withDuration: 0.3, delay: 0, usingSpringWithDamping: 0.82, initialSpringVelocity: 1) { [weak self] in guard let self else {return} self.timeCardView.checkbox.isHidden = !self.isSelected } @@ -122,7 +68,10 @@ final class TimeListViewController: UICollectionViewController, UICollectionView typealias DataSource = UICollectionViewDiffableDataSource typealias Snapshot = NSDiffableDataSourceSnapshot private let timeResuseIdentifier = "TimeCard" + @Preference(\.promptDelete) private var promptDelete + + #warning("TODO: subscribe to changes of dynamic type?") private lazy var cardHeight: CGFloat = { if traitCollection.preferredContentSizeCategory > UIContentSizeCategory.extraLarge { return 60 @@ -144,9 +93,9 @@ final class TimeListViewController: UICollectionViewController, UICollectionView }() - var mySelecting = false { + var isSelecting = false { didSet { - if mySelecting == false { + if isSelecting == false { collectionView.indexPathsForSelectedItems?.forEach { indexPath in collectionView.deselectItem(at: indexPath, animated: true) if let solve = dataSource.itemIdentifier(for: indexPath) { // For some reason .removeAll causes hang.. @@ -155,15 +104,16 @@ final class TimeListViewController: UICollectionViewController, UICollectionView } } - collectionView.allowsSelection = mySelecting - collectionView.allowsMultipleSelection = mySelecting + collectionView.allowsSelection = isSelecting + collectionView.allowsMultipleSelection = isSelecting } } let stopwatchManager: StopwatchManager let onClickSolve: (Solve) -> () - var subscriber: AnyCancellable? + var subscribers: Set = [] + var shownPhase: Int16? = nil lazy var dataSource = makeDataSource() @@ -176,10 +126,18 @@ final class TimeListViewController: UICollectionViewController, UICollectionView super.init(collectionViewLayout: layout) - subscriber = stopwatchManager.$timeListSolvesFiltered + stopwatchManager.$timeListSolvesFiltered .sink(receiveValue: { [weak self] i in self?.applySnapshot(i) }) + .store(in: &subscribers) + + stopwatchManager.$timeListShownPhase + .sink(receiveValue: { [weak self] newValue in + self?.shownPhase = newValue + self?.collectionView.reloadData() + }) + .store(in: &subscribers) } required init?(coder: NSCoder) { @@ -196,7 +154,14 @@ final class TimeListViewController: UICollectionViewController, UICollectionView let solveCellRegistration = UICollectionView.CellRegistration { [weak self] cell, _, item in guard let self else { return } - cell.label.text = item.timeText + if let multiphaseSolve = item as? MultiphaseSolve, + let phase = shownPhase, + let phases = multiphaseSolve.phases, + let time = ([0] + phases).chunked().map({ $0[1] - $0[0] })[safe: Int(phase)] { + cell.label.text = formatSolveTime(secs: time) + } else { + cell.label.text = item.timeText + } cell.label.frame = CGRect(origin: .zero, size: cell.frame.size) cell.item = item @@ -285,12 +250,12 @@ final class TimeListViewController: UICollectionViewController, UICollectionView override func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let solve = dataSource.itemIdentifier(for: indexPath) else { return UIContextMenuConfiguration() } - let copy = UIAction(title: "Copy", image: UIImage(systemName: "doc.on.doc")) { _ in + let copy = UIAction(title: NSLocalizedString("Copy", comment: "locale"), image: UIImage(systemName: "doc.on.doc")) { _ in copySolve(solve: solve) } let pen = Penalty(rawValue: solve.penalty)! - let penNone = UIAction(title: "No Penalty", image: UIImage(systemName: "checkmark.circle"), state: pen == .none ? .on : .off) { _ in + let penNone = UIAction(title: NSLocalizedString("No Penalty", comment: "locale"), image: UIImage(systemName: "checkmark.circle"), state: pen == .none ? .on : .off) { _ in self.stopwatchManager.changePen(solve: solve, pen: .none) } let penPlusTwo = UIAction(title: "+2", image: UIImage(named: "+2.label"), state: pen == .plustwo ? .on : .off) { _ in @@ -300,7 +265,7 @@ final class TimeListViewController: UICollectionViewController, UICollectionView self.stopwatchManager.changePen(solve: solve, pen: .dnf) } - let penaltyMenu = UIMenu(title: "Penalty", image: UIImage(systemName: "exclamationmark.triangle"), options: .singleSelection, children: [penNone, penPlusTwo, penDNF]) + let penaltyMenu = UIMenu(title: NSLocalizedString("Penalty", comment: "locale"), image: UIImage(systemName: "exclamationmark.triangle"), options: .singleSelection, children: [penNone, penPlusTwo, penDNF]) let sessions = (stopwatchManager.currentSession.sessionType == SessionType.playground.rawValue ? @@ -312,37 +277,55 @@ final class TimeListViewController: UICollectionViewController, UICollectionView let unpinned = sessions[unpinnedidx.. 0 { - moveToChildren.append(UIMenu(title: "Pinned Sessions", options: .displayInline, children: pinnedMenuItems)) + moveToChildren.append(UIMenu(title: NSLocalizedString("Pinned Sessions", comment: "locale"), options: .displayInline, children: pinnedMenuItems)) } if unpinnedMenuItems.count > 0 { - moveToChildren.append(UIMenu(title: "Other Sessions", options: .displayInline, children: unpinnedMenuItems)) + moveToChildren.append(UIMenu(title: NSLocalizedString("Other Sessions", comment: "locale") , options: .displayInline, children: unpinnedMenuItems)) } - let moveToMenu = UIMenu(title: "Move To", image: UIImage(systemName: "arrow.up.right"), children: moveToChildren) + let moveToMenu = UIMenu(title: NSLocalizedString("Move To", comment: "locale"), image: UIImage(systemName: "arrow.up.right"), children: moveToChildren) let delete = UIMenu(options: [.destructive, .displayInline], children: [ // For empty divide line - UIAction(title: "Delete", image: UIImage(systemName: "trash"), attributes: .destructive) {_ in - self.stopwatchManager.delete(solve: solve) + UIAction(title: NSLocalizedString("Delete", comment: "locale"), image: UIImage(systemName: "trash"), attributes: .destructive) {_ in + + if(self.promptDelete){ + let alert = UIAlertController(title: NSLocalizedString("Confirm Delete", comment: "locale"), message: "Are you sure you want to delete this solve?", preferredStyle: .actionSheet) + + alert.addAction(UIAlertAction(title: NSLocalizedString("Delete", comment: "locale"), style: .destructive, handler: { _ in + // Perform the delete action if confirmed + self.stopwatchManager.delete(solve: solve) + })) + + alert.addAction(UIAlertAction(title: NSLocalizedString("Cancel", comment: "locale"), style: .cancel, handler: nil)) + + if let viewController = self.navigationController?.visibleViewController{ + viewController.present(alert, animated: true, completion: nil) + } + } + else{ + self.stopwatchManager.delete(solve: solve) + } } ]) + let menuConfig = UIMenu(children: [copy, penaltyMenu, moveToMenu, delete]) return UIContextMenuConfiguration(identifier: indexPath as NSCopying, previewProvider: nil, actionProvider: { _ in return menuConfig @@ -350,7 +333,7 @@ final class TimeListViewController: UICollectionViewController, UICollectionView } override func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { - if mySelecting { + if isSelecting { self.stopwatchManager.timeListSolvesSelected.insert(self.stopwatchManager.timeListSolvesFiltered[indexPath.item]) } else { onClickSolve(stopwatchManager.timeListSolvesFiltered[indexPath.item]) @@ -358,14 +341,14 @@ final class TimeListViewController: UICollectionViewController, UICollectionView } override func collectionView(_ collectionView: UICollectionView, didDeselectItemAt indexPath: IndexPath) { - if mySelecting { + if isSelecting { self.stopwatchManager.timeListSolvesSelected.remove(self.stopwatchManager.timeListSolvesFiltered[indexPath.item]) } } func collectionView(_ collectionView: UICollectionView, layout collectionViewLayout: UICollectionViewLayout, sizeForItemAt indexPath: IndexPath) -> CGSize { - let width: CGFloat = (collectionView.frame.width - 32 - 10*2)/columnCount - return CGSize(width: width, height: cardHeight) + let width: CGFloat = (collectionView.frame.width - 32 - 10*(columnCount-1))/columnCount + return CGSize(width: width.rounded(.down), height: cardHeight) } } @@ -389,6 +372,8 @@ struct TimeListInner: UIViewControllerRepresentable { } func updateUIViewController(_ uiViewController: UIViewControllerType, context: Context) { - uiViewController.mySelecting = isSelectMode + DispatchQueue.main.async { + uiViewController.isSelecting = isSelectMode + } } } diff --git a/CubeTime/Timer/Confetti.swift b/CubeTime/Timer/Confetti.swift new file mode 100644 index 00000000..bbb59b3a --- /dev/null +++ b/CubeTime/Timer/Confetti.swift @@ -0,0 +1,325 @@ +// +// ConfettiView.swift +// Confetti +// +// Created by Simon Bachmann on 24.11.20. +// Created by Abdullah Alhaider on 24/03/2022. +// + +import SwiftUI + +public enum ConfettiType: Hashable { + case text(String) + case sfSymbol(symbolName: String) + case image(String) + + public var view: AnyView { + switch self { + case let .text(text): + return AnyView(Text(text)) + case .sfSymbol(let symbolName): + return AnyView(Image(systemName: symbolName)) + case .image(let image): + return AnyView(Image(image).resizable()) + default: + return AnyView(Circle()) + } + } +} + +@available(iOS 14.0, macOS 11.0, watchOS 7, tvOS 14.0, *) +public struct ConfettiCannon: View { + @Binding var counter:Int + @StateObject private var confettiConfig:ConfettiConfig + + @State var animate:[Bool] = [] + @State var finishedAnimationCounter = 0 + @State var firstAppear = false + @State var error = "" + + public init(counter:Binding, + num:Int = 20, + confettis:[ConfettiType], + colors:[Color] = [.blue, .red, .green, .yellow, .pink, .purple, .orange], + confettiSize:CGFloat = 10.0, + rainHeight: CGFloat = 600.0, + fadesOut:Bool = true, + opacity:Double = 1.0, + openingAngle:Angle = .degrees(60), + closingAngle:Angle = .degrees(120), + radius:CGFloat = 300, + repetitions:Int = 0, + repetitionInterval:Double = 1.0 + ) { + self._counter = counter + var shapes = [AnyView]() + + for confetti in confettis{ + for color in colors{ + switch confetti { + case .image(_): + shapes.append(AnyView(confetti.view.frame(maxWidth:confettiSize, maxHeight: confettiSize))) + default: + shapes.append(AnyView(confetti.view.foregroundColor(color).font(.system(size: confettiSize)))) + } + } + } + + _confettiConfig = StateObject(wrappedValue: ConfettiConfig( + num: num, + shapes: shapes, + colors: colors, + confettiSize: confettiSize, + rainHeight: rainHeight, + fadesOut: fadesOut, + opacity: opacity, + openingAngle: openingAngle, + closingAngle: closingAngle, + radius: radius, + repetitions: repetitions, + repetitionInterval: repetitionInterval + )) + } + + public var body: some View { + ZStack{ + ForEach(finishedAnimationCounter.. 0 && value < animate.count){ + animate[value-1].toggle() + } + } + } + } + } + } +} + +@available(iOS 14.0, macOS 11.0, watchOS 7, tvOS 14.0, *) +struct ConfettiContainer: View { + @Binding var finishedAnimationCounter:Int + @StateObject var confettiConfig:ConfettiConfig + @State var firstAppear = true + + var body: some View{ + ZStack{ + ForEach(0...confettiConfig.num-1, id:\.self){_ in + ConfettiView(confettiConfig: confettiConfig) + } + } + .onAppear(){ + if firstAppear{ + DispatchQueue.main.asyncAfter(deadline: .now() + confettiConfig.animationDuration) { + self.finishedAnimationCounter += 1 + } + firstAppear = false + } + } + } +} + +@available(iOS 14.0, macOS 11.0, watchOS 7, tvOS 14.0, *) +struct ConfettiView: View{ + @State var location:CGPoint = CGPoint(x: 0, y: 0) + @State var opacity:Double = 0.0 + @StateObject var confettiConfig:ConfettiConfig + + func getShape() -> AnyView { + return confettiConfig.shapes.randomElement()! + } + + func getColor() -> Color { + return confettiConfig.colors.randomElement()! + } + + func getSpinDirection() -> CGFloat { + let spinDirections:[CGFloat] = [-1.0, 1.0] + return spinDirections.randomElement()! + } + + func getRandomExplosionTimeVariation() -> CGFloat { + CGFloat((0...999).randomElement()!) / 2100 + } + + func getAnimationDuration() -> CGFloat { + return 0.2 + confettiConfig.explosionAnimationDuration + getRandomExplosionTimeVariation() + } + + func getAnimation() -> Animation { + return Animation.timingCurve(0.1, 0.8, 0, 1, duration: getAnimationDuration()) + } + + func getDistance() -> CGFloat { + return pow(CGFloat.random(in: 0.01...1), 2.0/7.0) * confettiConfig.radius + } + + func getDelayBeforeRainAnimation() -> TimeInterval { + confettiConfig.explosionAnimationDuration * 0.1 + } + + var body: some View{ + ConfettiAnimationView(shape:getShape(), color:getColor(), spinDirX: getSpinDirection(), spinDirZ: getSpinDirection()) + .offset(x: location.x, y: location.y) + .opacity(opacity) + .onAppear(){ + withAnimation(getAnimation()) { + opacity = confettiConfig.opacity + + let randomAngle:CGFloat + if confettiConfig.openingAngle.degrees <= confettiConfig.closingAngle.degrees{ + randomAngle = CGFloat.random(in: CGFloat(confettiConfig.openingAngle.degrees)...CGFloat(confettiConfig.closingAngle.degrees)) + }else{ + randomAngle = CGFloat.random(in: CGFloat(confettiConfig.openingAngle.degrees)...CGFloat(confettiConfig.closingAngle.degrees + 360)).truncatingRemainder(dividingBy: 360) + } + + let distance = getDistance() + + location.x = distance * cos(deg2rad(randomAngle)) + location.y = -distance * sin(deg2rad(randomAngle)) + } + + DispatchQueue.main.asyncAfter(deadline: .now() + getDelayBeforeRainAnimation()) { + withAnimation(Animation.timingCurve(0.12, 0, 0.39, 0, duration: confettiConfig.rainAnimationDuration)) { + location.y += confettiConfig.rainHeight + opacity = confettiConfig.fadesOut ? 0 : confettiConfig.opacity + } + } + } + } + + func deg2rad(_ number: CGFloat) -> CGFloat { + return number * CGFloat.pi / 180 + } + +} + +struct ConfettiAnimationView: View { + @State var shape: AnyView + @State var color: Color + @State var spinDirX: CGFloat + @State var spinDirZ: CGFloat + @State var firstAppear = true + + + @State var move = false + @State var xSpeed:Double = Double.random(in: 0.501...2.201) + + @State var zSpeed = Double.random(in: 0.501...2.201) + @State var anchor = CGFloat.random(in: 0...1).rounded() + + var body: some View { + shape + .foregroundColor(color) + .rotation3DEffect(.degrees(move ? 360:0), axis: (x: spinDirX, y: 0, z: 0)) + .animation(Animation.linear(duration: xSpeed).repeatCount(10, autoreverses: false), value: move) + .rotation3DEffect(.degrees(move ? 360:0), axis: (x: 0, y: 0, z: spinDirZ), anchor: UnitPoint(x: anchor, y: anchor)) + .animation(Animation.linear(duration: zSpeed).repeatForever(autoreverses: false), value: move) + .onAppear() { + if firstAppear { + move = true + firstAppear = true + } + } + } +} + +class ConfettiConfig: ObservableObject { + internal init(num: Int, shapes: [AnyView], colors: [Color], confettiSize: CGFloat, rainHeight: CGFloat, fadesOut: Bool, opacity: Double, openingAngle:Angle, closingAngle:Angle, radius:CGFloat, repetitions:Int, repetitionInterval:Double) { + self.num = num + self.shapes = shapes + self.colors = colors + self.confettiSize = confettiSize + self.rainHeight = rainHeight + self.fadesOut = fadesOut + self.opacity = opacity + self.openingAngle = openingAngle + self.closingAngle = closingAngle + self.radius = radius + self.repetitions = repetitions + self.repetitionInterval = repetitionInterval + self.explosionAnimationDuration = Double(radius / 1300) + self.rainAnimationDuration = Double((rainHeight + radius) / 200) + } + + @Published var num:Int + @Published var shapes:[AnyView] + @Published var colors:[Color] + @Published var confettiSize:CGFloat + @Published var rainHeight:CGFloat + @Published var fadesOut:Bool + @Published var opacity:Double + @Published var openingAngle:Angle + @Published var closingAngle:Angle + @Published var radius:CGFloat + @Published var repetitions:Int + @Published var repetitionInterval:Double + @Published var explosionAnimationDuration:Double + @Published var rainAnimationDuration:Double + + + var animationDuration:Double{ + return explosionAnimationDuration + rainAnimationDuration + } + + var openingAngleRad:CGFloat{ + return CGFloat(openingAngle.degrees) * 180 / .pi + } + + var closingAngleRad:CGFloat{ + return CGFloat(closingAngle.degrees) * 180 / .pi + } +} + + +public extension View { + @ViewBuilder func confettiCannon( + counter: Binding, + num: Int = 20, + confettis: [ConfettiType], + colors: [Color] = [.blue, .red, .green, .yellow, .pink, .purple, .orange], + confettiSize: CGFloat = 10.0, + rainHeight: CGFloat = 600.0, + fadesOut: Bool = true, + opacity: Double = 1.0, + openingAngle: Angle = .degrees(60), + closingAngle: Angle = .degrees(120), + radius: CGFloat = 300, + repetitions: Int = 0, + repetitionInterval: Double = 1.0, + position: CGPoint = .zero + ) -> some View { + ZStack { + self + + ConfettiCannon( + counter: counter, + num: num, + confettis: confettis, + colors: colors, + confettiSize: confettiSize, + rainHeight: rainHeight, + fadesOut: fadesOut, + opacity: opacity, + openingAngle: openingAngle, + closingAngle: closingAngle, + radius: radius, + repetitions: repetitions, + repetitionInterval: repetitionInterval + ) + .position(position) + } + } +} diff --git a/CubeTime/Timer/PrevSolvesDisplay.swift b/CubeTime/Timer/PrevSolvesDisplay.swift deleted file mode 100644 index 7f63750f..00000000 --- a/CubeTime/Timer/PrevSolvesDisplay.swift +++ /dev/null @@ -1,29 +0,0 @@ -import SwiftUI - -struct PrevSolvesDisplay: View { - @EnvironmentObject var stopwatchManager: StopwatchManager - var count: Int? - - @State var solve: Solve? = nil - - var body: some View { - if (SessionType(rawValue: stopwatchManager.currentSession.sessionType) == .compsim) { - if let currentSolveGroup = stopwatchManager.compsimSolveGroups.first { - TimeBar(solvegroup: currentSolveGroup, currentCalculatedAverage: .constant(nil), isSelectMode: .constant(false), current: true) - .frame(height: 55) - } - } else { - HStack { - ForEach((count != nil) - ? stopwatchManager.solvesByDate.suffix(count!) - : stopwatchManager.solvesByDate, id: \.self) { solve in - - TimeCard(solve: solve, currentSolve: $solve) - } - } - .sheet(item: self.$solve) { item in - TimeDetailView(for: item, currentSolve: $solve) - } - } - } -} diff --git a/CubeTime/Timer/ScrambleView.swift b/CubeTime/Timer/ScrambleView.swift index 2581bd63..0544a24f 100644 --- a/CubeTime/Timer/ScrambleView.swift +++ b/CubeTime/Timer/ScrambleView.swift @@ -26,22 +26,24 @@ struct AsyncSVGView: View { - graal_create_isolate(nil, &isolate, &thread) +// graal_create_isolate(nil, &isolate, &thread) +// +// var svg: String! +// +// #warning("todo: infinite loading if :boom:") +// scramble.withCString { s in +// if let drawnSvg = tnoodle_lib_draw_scramble(thread, puzzle, s) { +// svg = String(cString: drawnSvg) +// } else { +// svg = nil +// } +// } +// +// graal_tear_down_isolate(thread); +// +// return svg - var svg: String! - - #warning("todo: infinite loading if :boom:") - scramble.withCString { s in - if let drawnSvg = tnoodle_lib_draw_scramble(thread, puzzle, s) { - svg = String(cString: drawnSvg) - } else { - svg = nil - } - } - - graal_tear_down_isolate(thread); - - return svg + return "" } let result = await task.result svg = try? result.get() diff --git a/CubeTime/Timer/StopwatchManager/FontManager.swift b/CubeTime/Timer/StopwatchManager/FontManager.swift deleted file mode 100644 index ad504ef9..00000000 --- a/CubeTime/Timer/StopwatchManager/FontManager.swift +++ /dev/null @@ -1,100 +0,0 @@ -// -// FontManager.swift -// CubeTime -// -// Created by trainz-are-kul on 2/03/23. -// - -import Foundation -import SwiftUI -import Combine -import CoreText - -class FontManager: ObservableObject { - let sm = SettingsManager.standard - - @Published var ctFontScramble: Font! - @Published var ctFontDescBold: CTFontDescriptor! - @Published var ctFontDesc: CTFontDescriptor! - - static func fontFor(size: CGFloat, weight: Int) -> Font { - let desc = CTFontDescriptorCreateWithAttributes([ - kCTFontNameAttribute: "RecursiveSansLinearLightMonospace-Regular", - kCTFontVariationAttribute: [2003265652: weight, - 1128354636: 0, - 1129468758: 0] - ] as! CFDictionary) - - return Font(CTFontCreateWithFontDescriptor(desc, size, nil)) - } - - let changeOnKeys: [PartialKeyPath] = [\.fontWeight, \.fontCursive, \.fontCasual, \.scrambleSize] - - var subscriber: AnyCancellable? - - init() { - subscriber = sm.preferencesChangedSubject - .filter { item in - (self.changeOnKeys as [AnyKeyPath]).contains(item) - } - .sink(receiveValue: { [weak self] i in - self?.updateFont() - }) - updateFont() - } - - private func updateFont() { - // weight, casual, cursive - let variations = [2003265652: sm.fontWeight, 1128354636: sm.fontCasual, 1129468758: sm.fontCursive ? 1 : 0] - let variationsTimer = [2003265652: sm.fontWeight + 200, 1128354636: sm.fontCasual, 1129468758: sm.fontCursive ? 1 : 0] - - ctFontDesc = CTFontDescriptorCreateWithAttributes([ - kCTFontNameAttribute: "RecursiveSansLinearLightMonospace-Regular", - kCTFontVariationAttribute: variations - ] as! CFDictionary) - - ctFontDescBold = CTFontDescriptorCreateWithAttributes([ - kCTFontNameAttribute: "RecursiveSansLinearLightMonospace-Regular", - kCTFontVariationAttribute: variationsTimer - ] as! CFDictionary) - - ctFontScramble = Font(CTFontCreateWithFontDescriptor(ctFontDesc, CGFloat(sm.scrambleSize), nil)) - } -} - -struct RecursiveMono: ViewModifier { - @ScaledMetric var fontSize: CGFloat - let weight: Int - - init(fontSize: CGFloat, weight: Int) { - self._fontSize = ScaledMetric(wrappedValue: fontSize) - self.weight = weight - } - - func body(content: Content) -> some View { - content - .font(FontManager.fontFor(size: fontSize, weight: weight)) - } -} - -extension View { - func recursiveMono(fontSize: CGFloat, weight: Int) -> some View { - modifier(RecursiveMono(fontSize: fontSize, weight: weight)) - } - - func recursiveMono(fontSize: CGFloat, weight: Font.Weight=Font.Weight.regular) -> some View { - switch (weight) { - case .regular: - return modifier(RecursiveMono(fontSize: fontSize, weight: 400)) - case .medium: - return modifier(RecursiveMono(fontSize: fontSize, weight: 500)) - case .semibold: - return modifier(RecursiveMono(fontSize: fontSize, weight: 600)) - case .bold: - return modifier(RecursiveMono(fontSize: fontSize, weight: 700)) - - default: - return modifier(RecursiveMono(fontSize: fontSize, weight: 400)) - } - } -} diff --git a/CubeTime/Timer/TimerHeader.swift b/CubeTime/Timer/TimerHeader.swift index 915b4272..3b9058e9 100644 --- a/CubeTime/Timer/TimerHeader.swift +++ b/CubeTime/Timer/TimerHeader.swift @@ -1,10 +1,8 @@ import SwiftUI struct SessionIconView: View { - @ScaledMetric(wrappedValue: 35, relativeTo: .body) private var size: CGFloat - @ScaledMetric(wrappedValue: 22, relativeTo: .body) private var iconSmall: CGFloat - @ScaledMetric(wrappedValue: 26, relativeTo: .body) private var iconLarge: CGFloat - + @ScaledMetric(wrappedValue: 35, relativeTo: .body) private var bgSize: CGFloat + let isDynamicType: Bool let session: Session @@ -17,27 +15,11 @@ struct SessionIconView: View { ZStack(alignment: .center) { Rectangle() .fill(Color.clear) - .frame(width: isDynamicType ? size : 35, height: isDynamicType ? size : 35) + .frame(width: isDynamicType ? bgSize : 35, height: isDynamicType ? bgSize : 35) - switch SessionType(rawValue: session.sessionType)! { - case .standard: - Image(systemName: "timer.square") - .font(.system(size: isDynamicType ? iconLarge : 26, weight: .regular)) - case .algtrainer: - Image(systemName: "command.square") - .font(.system(size: isDynamicType ? iconLarge : 26, weight: .regular)) - case .multiphase: - Image(systemName: "square.stack") - .font(.system(size: isDynamicType ? iconSmall : 22, weight: .regular)) - case .playground: - Image(systemName: "square.on.square") - .font(.system(size: isDynamicType ? iconSmall : 22, weight: .regular)) - case .compsim: - Image(systemName: "globe.asia.australia") - .font(.system(size: isDynamicType ? iconSmall : 22, weight: .medium)) - } + session.icon(size: 26) } - .frame(width: isDynamicType ? size : 35, height: isDynamicType ? size : 35) + .frame(width: isDynamicType ? bgSize : 35, height: isDynamicType ? bgSize : 35) } } @@ -78,7 +60,7 @@ struct TimerHeader: View { } } - Text("PREVIEW") + Text("Preview") .font(.system(size: 17, weight: .medium)) .padding(.trailing) } @@ -109,7 +91,7 @@ struct TimerHeader: View { if !showSessionType { Text(stopwatchManager.currentSession.name ?? "Unknown Session Name") .font(.system(size: 17, weight: .medium)) - .padding(.trailing, sessionType == .standard ? nil : 4) + .padding(.trailing, sessionType == .standard ? nil : (sessionType == .timerOnly ? 8 : 4)) } switch sessionType { @@ -117,21 +99,23 @@ struct TimerHeader: View { // i hate swiftui i hate apple i hate everything if #available(iOS 16, *) { Picker("", selection: $stopwatchManager.playgroundScrambleType) { - ForEach(Array(zip(puzzleTypes.indices, puzzleTypes)), id: \.0) { index, element in - Text(element.name).tag(Int32(index)) + ForEach(Array(zip(PUZZLE_TYPES.indices, PUZZLE_TYPES)), id: \.0) { index, element in + Text(element.name).tag(Int32(index)).font(.body) } } .pickerStyle(.menu) + .font(.body) .scaleEffect(17/scale) .frame(maxHeight: .infinity) } else { Picker("", selection: $stopwatchManager.playgroundScrambleType) { - ForEach(Array(zip(puzzleTypes.indices, puzzleTypes)), id: \.0) { index, element in - Text(element.name).tag(Int32(index)) + ForEach(Array(zip(PUZZLE_TYPES.indices, PUZZLE_TYPES)), id: \.0) { index, element in + Text(element.name).tag(Int32(index)).font(.body) } } .pickerStyle(.menu) .accentColor(Color("accent")) + .font(.body) .frame(maxHeight: .infinity) .padding(.trailing, 8) } @@ -146,13 +130,13 @@ struct TimerHeader: View { } .padding(.trailing) case .compsim: - let solveth: Int = stopwatchManager.currentSolveth!+1 + let solveth: Int = 1 + (stopwatchManager.currentSolveth ?? 0) Text("SOLVE \(solveth == 6 ? 1 : solveth)") .font(.system(size: 15, weight: .regular)) .padding(.horizontal, 2) - ThemedDivider(isHorizontal: false) + CTDivider(isHorizontal: false) .padding(.vertical, 6) HStack (spacing: 10) { @@ -162,7 +146,16 @@ struct TimerHeader: View { ZStack { Text(stopwatchManager.targetStr == "" ? "0.00" : stopwatchManager.targetStr) - .background(GlobalGeometryGetter(rect: $textRect)) + .font(.system(size: 17)) + .background( + GeometryReader { geo in + let _ = DispatchQueue.main.async { + self.textRect = geo.frame(in: .global) + } + + Rectangle().fill(Color.clear) + } + ) .layoutPriority(1) .opacity(0) diff --git a/CubeTime/Timer/TimerMenu.swift b/CubeTime/Timer/TimerMenu.swift index e37af78b..1582f966 100644 --- a/CubeTime/Timer/TimerMenu.swift +++ b/CubeTime/Timer/TimerMenu.swift @@ -44,7 +44,7 @@ struct TimerMenu: View { .padding(.top, 2) VStack(spacing: 8) { - CTButton(type: .coloured, size: .large, expandWidth: true, onTapRun: { + CTButton(type: .coloured(nil), size: .large, expandWidth: true, onTapRun: { withAnimation(.customEaseInOut) { stopwatchManager.currentPadFloatingStage = 1 stopwatchManager.zenMode = true @@ -60,10 +60,10 @@ struct TimerMenu: View { .frame(maxWidth: .infinity, alignment: .leading) } - ThemedDivider() + CTDivider() .padding(.horizontal, 4) - CTButton(type: .halfcoloured, size: .large, expandWidth: true, onTapRun: { + CTButton(type: .halfcoloured(nil), size: .large, expandWidth: true, onTapRun: { showTools = true }) { Label(title: { @@ -75,7 +75,7 @@ struct TimerMenu: View { }) .frame(maxWidth: .infinity, alignment: .leading) } - CTButton(type: .halfcoloured, size: .large, expandWidth: true, onTapRun: { + CTButton(type: .halfcoloured(nil), size: .large, expandWidth: true, onTapRun: { showSettings = true }) { Label(title: { diff --git a/CubeTime/Timer/TimerTools.swift b/CubeTime/Timer/TimerTools.swift index 0ff144a4..34fa37e5 100644 --- a/CubeTime/Timer/TimerTools.swift +++ b/CubeTime/Timer/TimerTools.swift @@ -23,7 +23,7 @@ struct BottomTools: View { Spacer(minLength: 0) - if showStats { + if showStats || UIDevice.deviceIsPad { BottomToolContainer { if stopwatchManager.currentSession.sessionType == SessionType.compsim.rawValue { TimerStatsCompSim() @@ -83,7 +83,7 @@ struct TimerDrawScramble: View { if let svg = scrambleController.scrambleSVG { if let scr = scrambleController.scrambleStr { SVGView(string: svg) - .padding(2) + .padding(4) .frame(width: geo.size.width, height: geo.size.height) .aspectRatio(contentMode: .fit) .onTapGesture { @@ -111,12 +111,12 @@ struct TimerStatRaw: View { if let value = value { Text(value) - .font(.system(size: 24, weight: .bold)) + .recursiveMono(size: 22, weight: .bold) .modifier(DynamicText()) } else { Text(placeholderText) - .font(.system(size: 24, weight: .medium, design: .default)) + .recursiveMono(size: 22, weight: .medium) .foregroundColor(Color("grey")) } } @@ -138,7 +138,7 @@ struct TimerStat: View { self.hasIndividualGesture = hasIndividualGesture self._presentedAvg = presentedAvg if let average = average { - self.value = formatSolveTime(secs: average.average!, penType: average.totalPen) + self.value = formatSolveTime(secs: average.average!, penalty: average.totalPen) } else { self.value = nil } @@ -172,7 +172,7 @@ struct TimerStatsStandard: View { } .frame(maxHeight: .infinity) - ThemedDivider() + CTDivider() .padding(.horizontal, 18) HStack(spacing: 6) { @@ -201,7 +201,7 @@ struct TimerStatsPad: View { } .frame(maxHeight: .infinity) - ThemedDivider() + CTDivider() .padding(.horizontal, 18) @@ -246,13 +246,13 @@ struct TimerStatsCompSim: View { VStack(spacing: 0) { HStack(spacing: 6) { - TimerStatRaw(name: "BPA", value: stopwatchManager.bpa == nil ? nil : formatSolveTime(secs: stopwatchManager.bpa!.average, penType: stopwatchManager.bpa!.penalty), placeholderText: "...") + TimerStatRaw(name: "BPA", value: stopwatchManager.bpa == nil ? nil : formatSolveTime(secs: stopwatchManager.bpa!.average, penalty: stopwatchManager.bpa!.penalty), placeholderText: "...") .frame(maxWidth: .infinity, maxHeight: .infinity) - TimerStatRaw(name: "WPA", value: stopwatchManager.wpa == nil ? nil : formatSolveTime(secs: stopwatchManager.wpa!.average, penType: stopwatchManager.wpa!.penalty), placeholderText: "...") + TimerStatRaw(name: "WPA", value: stopwatchManager.wpa == nil ? nil : formatSolveTime(secs: stopwatchManager.wpa!.average, penalty: stopwatchManager.wpa!.penalty), placeholderText: "...") .frame(maxWidth: .infinity, maxHeight: .infinity) } - ThemedDivider() + CTDivider() .padding(.horizontal, 18) TimerStatRaw(name: "TO REACH TARGET", value: timeNeededText, placeholderText: "...") diff --git a/CubeTime/Timer/TimerTouchView.swift b/CubeTime/Timer/TimerTouchView.swift index 01db20be..0ddbc2d8 100644 --- a/CubeTime/Timer/TimerTouchView.swift +++ b/CubeTime/Timer/TimerTouchView.swift @@ -4,11 +4,11 @@ import UIKit class TimerUIView: UIViewController { - let timerController: TimerContoller + let timerController: TimerController let stopwatchManager: StopwatchManager - required init(timerController: TimerContoller, stopwatchManager: StopwatchManager, userHoldTime: Double) { + required init(timerController: TimerController, stopwatchManager: StopwatchManager, userHoldTime: Double) { self.timerController = timerController self.stopwatchManager = stopwatchManager self.userHoldTime = userHoldTime @@ -23,6 +23,11 @@ class TimerUIView: UIViewController { override func touchesBegan(_ touches: Set, with event: UIEvent?) { super.touchesBegan(touches, with: event) UIApplication.shared.isIdleTimerDisabled = true + + if timerController.mode == .running { + stopwatchManager.confettiLocation = touches.first!.location(in: nil) + } + timerController.touchDown() } override func touchesEnded(_ touches: Set, with event: UIEvent?) { @@ -173,7 +178,7 @@ class TimerUIView: UIViewController { struct TimerTouchView: UIViewControllerRepresentable { - @EnvironmentObject var timerController: TimerContoller + @EnvironmentObject var timerController: TimerController @EnvironmentObject var stopwatchManager: StopwatchManager @Preference(\.holdDownTime) private var holdDownTime @@ -184,66 +189,41 @@ struct TimerTouchView: UIViewControllerRepresentable { func makeUIViewController(context: UIViewControllerRepresentableContext) -> TimerUIView { - let v = TimerUIView(timerController: timerController, stopwatchManager: stopwatchManager, userHoldTime: holdDownTime) + let timerUIView = TimerUIView(timerController: timerController, stopwatchManager: stopwatchManager, userHoldTime: holdDownTime) let longPressGesture = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.longPress)) longPressGesture.allowableMovement = gestureThreshold longPressGesture.minimumPressDuration = holdDownTime - // longPressGesture.requiresExclusiveTouchType = ? - let pan = UIPanGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.pan)) pan.allowedScrollTypesMask = .all pan.allowedTouchTypes = [NSNumber(value: UITouch.TouchType.indirectPointer.rawValue)] - v.view.addGestureRecognizer(pan) + timerUIView.view.addGestureRecognizer(pan) for direction in [UISwipeGestureRecognizer.Direction.up, UISwipeGestureRecognizer.Direction.down, UISwipeGestureRecognizer.Direction.left, UISwipeGestureRecognizer.Direction.right] { let gesture = UISwipeGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.swipe)) gesture.direction = direction gesture.require(toFail: longPressGesture) - v.view.addGestureRecognizer(gesture) + timerUIView.view.addGestureRecognizer(gesture) } + timerUIView.view.addGestureRecognizer(longPressGesture) - - - v.view.addGestureRecognizer(longPressGesture) - - - return v + return timerUIView } func updateUIViewController(_ uiView: TimerUIView, context: UIViewControllerRepresentableContext) { uiView.userHoldTime = holdDownTime - /* - if stopwatchManager.scrambleStr == nil { - for gesture in uiView.gestureRecognizers! { - /* - if gesture is UISwipeGestureRecognizer && (gesture as! UISwipeGestureRecognizer).direction == UISwipeGestureRecognizer.Direction.right { - uiView.removeGestureRecognizer(gesture) - } else */ - if gesture is UILongPressGestureRecognizer { - uiView.removeGestureRecognizer(gesture) - } - } - } else { - let longPressGesture = UILongPressGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.longPress)) - longPressGesture.allowableMovement = gestureThreshold - longPressGesture.minimumPressDuration = userHoldTime - - uiView.addGestureRecognizer(longPressGesture) - } - */ } class Coordinator: NSObject { - let timerController: TimerContoller + let timerController: TimerController let sm = SettingsManager.standard private var panHasTriggeredGesture = false - init(timerController: TimerContoller) { + init(timerController: TimerController) { self.timerController = timerController } diff --git a/CubeTime/Timer/TimerView.swift b/CubeTime/Timer/TimerView.swift index 63684e92..6d04f983 100644 --- a/CubeTime/Timer/TimerView.swift +++ b/CubeTime/Timer/TimerView.swift @@ -13,7 +13,7 @@ struct SheetStrWrapper: Identifiable { struct AvoidFloatingPanel: ViewModifier { @EnvironmentObject var stopwatchManager: StopwatchManager - @EnvironmentObject var timerController: TimerContoller + @EnvironmentObject var timerController: TimerController @Environment(\.horizontalSizeClass) private var hSizeClass @@ -34,7 +34,7 @@ struct AvoidFloatingPanel: ViewModifier { struct TimerTime: View { @EnvironmentObject var stopwatchManager: StopwatchManager @EnvironmentObject var fontManager: FontManager - @EnvironmentObject var timerController: TimerContoller + @EnvironmentObject var timerController: TimerController @Environment(\.horizontalSizeClass) private var hSizeClass var body: some View { @@ -42,11 +42,11 @@ struct TimerTime: View { ? timerController.mode == .running ? 88 : 66 : timerController.mode == .running ? 70 : 56 - HStack{ + HStack { Text(timerController.secondsStr) .modifier(DynamicText()) - // for smaller phones (iPhoneSE and test sim), disable animation to larger text - // to prevent text clipping and other UI problems + // for smaller phones (iPhoneSE and test sim), disable animation to larger text + // to prevent text clipping and other UI problems .ifelse (UIDevice.deviceModelName == "iPhoneSE") { view in return view .font(Font(CTFontCreateWithFontDescriptor(fontManager.ctFontDescBold, 54, nil))) @@ -54,7 +54,7 @@ struct TimerTime: View { return view .modifier(AnimatingFontSize(font: fontManager.ctFontDescBold, fontSize: fontSize)) } - + Group { if (timerController.mode == .inspecting) { if (15..<17 ~= timerController.inspectionSecs) { @@ -66,7 +66,6 @@ struct TimerTime: View { } .modifier(AnimatingFontSize(font: fontManager.ctFontDescBold, fontSize: fontSize - 32)) } - .animation(Animation.customBouncySpring, value: timerController.mode == .running) .foregroundColor(timerController.timerColour) .padding(.horizontal) .modifier(AvoidFloatingPanel()) @@ -138,12 +137,12 @@ struct ScrambleText: View { struct TimerView: View { @EnvironmentObject var stopwatchManager: StopwatchManager - @EnvironmentObject var timerController: TimerContoller + @EnvironmentObject var timerController: TimerController @EnvironmentObject var fontManager: FontManager @EnvironmentObject var scrambleController: ScrambleController @EnvironmentObject var tabRouter: TabRouter - @StateObject var gm = GradientManager() + @StateObject var gradientManager = GradientManager() @Environment(\.managedObjectContext) var managedObjectContext @Environment(\.globalGeometrySize) var globalGeometrySize @@ -157,8 +156,12 @@ struct TimerView: View { @Preference(\.showCancelInspection) private var showCancelInspection @Preference(\.scrambleSize) private var scrambleSize @Preference(\.showPrevTime) private var showPrevTime + @Preference(\.showConfetti) private var showConfetti @Preference(\.inputMode) private var inputMode - + @Preference(\.showZenMode) private var showZenMode + @Preference(\.isStaticGradient) private var isStaticGradient + + // FOCUS STATES @FocusState private var targetFocused: Bool @@ -271,8 +274,8 @@ struct TimerView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .center) .ignoresSafeArea() .onSubmit { - if (!typingMode) { - if manualInputTime != "" { + if manualInputTime != "" { + if (!typingMode) { // record entered time time timerController.stop(timeFromStr(manualInputTime)) @@ -280,17 +283,18 @@ struct TimerView: View { showInputField = false manualInputFocused = false manualInputTime = "" + } else { + timerController.stop(timeFromStr(manualInputTime)) + + + // remove focus and reset time + manualInputFocused = false + justManuallyInput = true + + stopwatchManager.displayPenOptions() + + showManualInputFormattedText = true } - } else { - timerController.stop(timeFromStr(manualInputTime)) - - // remove focus and reset time - manualInputFocused = false - justManuallyInput = true - - stopwatchManager.displayPenOptions() - - showManualInputFormattedText = true } } } @@ -303,14 +307,12 @@ struct TimerView: View { // 50 for tab + 8 for padding + 16/0 for bottom bar gap - let stageMaxHeight: CGFloat = geo.size.height-CGFloat(50) - let stages: [CGFloat] = [35, 35+16+55, stageMaxHeight] - + let stages: [CGFloat] = [35, 35+16+55, geo.size.height-CGFloat(50)] #warning("todo: combine these into one hstack, doesn't need to be separate...") if (UIDevice.deviceIsPad && hSizeClass == .regular) { HStack(alignment: .top) { - FloatingPanel(currentStage: $stopwatchManager.currentPadFloatingStage, maxHeight: stageMaxHeight, stages: stages) { + FloatingPanel(currentStage: $stopwatchManager.currentPadFloatingStage, stages: stages) { PadTimerHeader(targetFocused: self.$targetFocused, showSessions: nil) VStack(alignment: .leading, spacing: 8) { @@ -363,19 +365,32 @@ struct TimerView: View { Spacer() - if (stopwatchManager.isScrambleLocked) { - Image(systemName: "lock.rotation") - .font(.system(size: 17, weight: .medium, design: .default)) - .imageScale(.medium) - .frame(width: 35, height: 35, alignment: .center) - .onTapGesture { - stopwatchManager.showUnlockScrambleConfirmation = true + VStack { + if showZenMode { + CTButton(type: .halfcoloured(nil), size: .large, square: true, supportsDynamicResizing: false, expandWidth: false, onTapRun: { + withAnimation(.customEaseInOut) { + stopwatchManager.zenMode = true + } + }) { + Label("Zen Mode", systemImage: "moon.stars") + .labelStyle(.iconOnly) + .font(.system(size: 17, weight: .semibold)) } - } else { - LoadingIndicator(animation: .circleRunner, color: Color("accent"), size: .small, speed: .fast) - .frame(maxHeight: 35) - .padding(.top, UIDevice.hasBottomBar ? 0 : tabRouter.hideTabBar ? nil : 8) - .opacity(scrambleController.scrambleStr == nil ? 1 : 0) + } + if (stopwatchManager.isScrambleLocked) { + Image(systemName: "lock.rotation") + .font(.system(size: 17, weight: .medium, design: .default)) + .imageScale(.medium) + .frame(width: 35, height: 35, alignment: .center) + .onTapGesture { + stopwatchManager.showUnlockScrambleConfirmation = true + } + } else { + LoadingIndicator(animation: .circleRunner, color: Color("accent"), size: .small, speed: .fast) + .frame(maxHeight: 35) + .padding(.top, UIDevice.hasBottomBar ? 0 : tabRouter.hideTabBar ? nil : 8) + .opacity(scrambleController.scrambleStr == nil ? 1 : 0) + } } } .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .top) @@ -386,13 +401,13 @@ struct TimerView: View { } if stopwatchManager.zenMode { - CTCloseButton(hasBackgroundShadow: true, onTapRun: { - withAnimation(.customEaseInOut) { - stopwatchManager.zenMode = false - } + CTCloseButton(hasBackgroundShadow: true, supportsDynamicResizing: false, onTapRun: { + stopwatchManager.zenMode = false }) - .padding([.trailing, .top]) + .frame(width: 35, height: 35) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topTrailing) + .padding(.trailing) + .transition(.asymmetric(insertion: .opacity.animation(.easeIn(duration: 0.10)), removal: .identity)) } @@ -426,7 +441,7 @@ struct TimerView: View { PenaltyButton(penType: .none, penSymbol: "checkmark.circle", imageSymbol: false, canType: false, colour: Color("green")) if (showPlus) { - ThemedDivider(isHorizontal: false) + CTDivider(isHorizontal: false) .padding(.vertical, 6) } } @@ -521,6 +536,21 @@ struct TimerView: View { .statusBar(hidden: stopwatchManager.hideUI) .ignoresSafeArea(.keyboard) + .if (showConfetti) { view in + view + .confettiCannon( + counter: $stopwatchManager.confetti, + num: 80, + confettis: PUZZLE_TYPES.map { .image($0.imageName) }, + colors: GradientManager.getGradientColours(gradientSelected: gradientManager.appGradient, isStaticGradient: isStaticGradient), + confettiSize: 16, + fadesOut: true, + openingAngle: Angle(degrees: 0), + closingAngle: Angle(degrees: 360), + radius: 200, + position: stopwatchManager.confettiLocation ?? .zero + ) + } } } diff --git a/CubeTime/Updates.swift b/CubeTime/Updates.swift deleted file mode 100644 index 763a913d..00000000 --- a/CubeTime/Updates.swift +++ /dev/null @@ -1,226 +0,0 @@ -// -// Updates.swift -// CubeTime -// -// Created by Tim Xie on 31/01/22. -// - -import SwiftUI - - -struct ListPoint: Equatable, Hashable { - let depth: Int - let text: String - - func hash(into hasher: inout Hasher) { - hasher.combine(depth) - hasher.combine(text) - } - - init(_ depth: Int, _ text: String) { - self.depth = depth - self.text = text - } - - static func == (lhs: ListPoint, rhs: ListPoint) -> Bool { - return lhs.depth == rhs.depth && lhs.text == rhs.text - } -} - -struct ListLine: View { - let depth: Int - let text: LocalizedStringKey - - init(_ depth: Int = 1, _ text: LocalizedStringKey) { - self.depth = depth - self.text = text - } - - var body: some View { - HStack(alignment: .top, spacing: 8) { - Text("–") - .recursiveMono(fontSize: 17, weight: .medium) - - Text(text) - } - .padding(.leading, CGFloat(20 * (depth-1))) - } -} - -let updatesList: [String: (majorAdditions: [ListPoint]?, - minorAdditions: [ListPoint]?, - bugFixes: [ListPoint]?)] = [ - "2.0": ( - majorAdditions: [ - ListPoint(1, "**Fresh new UI design**"), - ListPoint(2, "TONS of UI fixes throughout the app"), - ListPoint(2, "improved design consistency"), - ListPoint(1 , "**Dynamic type support!**"), - ListPoint(2, "all major UI elements now conform to Apple's DynamicType accessbility font sizes"), - ListPoint(2, "this is the first version, if you do notice anything out of place, please open an issue and tag it with 'DynamicType' on our Github page."), - ListPoint(1, "**Changed tnoodle compatibility layer**"), - ListPoint(2, "**30x faster scramble generation**"), - ListPoint(2, "**20x less memory usage**"), - ListPoint(3, "fixes OOM crashes on older phones"), - ListPoint(3, "fixes launch crash on iPod 7th Gen"), - ListPoint(1, "**Improved stats engine**"), - ListPoint(2, "over 100x faster speeds"), - ListPoint(1, "**iPad Mode is here!**"), - ListPoint(2, "iPad mode supports many keyboard shortcuts, along with **trackpad gestures**"), - ListPoint(3, "you can two-finger swipe on your trackpad, just like using a finger"), - ListPoint(2, "new design with a floating panel"), - ListPoint(2, "to see your times, drag down on the panel handle"), - ListPoint(1, "**Added tools!**"), - ListPoint(2, "scramble generator: batch generate multiple scrambles to use or share"), - ListPoint(2, "timer and scramble only mode: for use at comps"), - ListPoint(2, "average calculator: to quickly calculate averages!"), - ListPoint(1, "**Added voice alerts for inspection**"), - ListPoint(1, "Added manual entry mode"), - ListPoint(2, "you can switch to entering times by typing instead of a timer in General Settings > Timer Settings > Timer Mode > Typing"), - ListPoint(1, "Quick actions!"), - ListPoint(2, "long press on icon to quickly go to your recently used sessions"), - ListPoint(1, "Cleaned up to make the app run smoother!"), - ListPoint(2, "we've written over 20,000 lines of code for this update!")], - - minorAdditions: [ - ListPoint(1, "added rotations added for blind scrambles"), - ListPoint(1, "using WCA-complient random state 4x4 scrambles"), - ListPoint(1, "new dynamic gradient option that changes the gradient throughout the day"), - ListPoint(1, "added share sheets to share your times or averages"), - ListPoint(1, "added copy scramble on timer screen"), - ListPoint(1, "added lock scramble feature"), - ListPoint(2, "long press on the scramble text to bring up a menu, where you can lock or unlock the current scramble"), - ListPoint(1, "move solves between session"), - ListPoint(1, "using more modern SVG renderer, faster draw scrambles"), - ListPoint(1, "more solve selection functions:"), - ListPoint(2, "copy multiple solves"), - ListPoint(2, "add penalty to multiple solves"), - ListPoint(1, "show +2 and DNF time in brackets"), - ListPoint(1, "swapped around delete and copy button in individual solve cards"), - ListPoint(1, "added ability to delete currently selected session"), - ListPoint(1, "added multiphase details to solve copy"), - ListPoint(1, "improved various timing-related functions"), - ListPoint(1, "added ability to stop inspection"), - ListPoint(1, "improved user accessibility"), - ListPoint(1, "added inspection alerts"), - ListPoint(1, "added voice inspection"), - ListPoint(1, "added ability to cancel inspection"), - ListPoint(1, "added ability to switch between voice based or beep based alerts"), - ListPoint(1, "added ability to toggle session name in timer view"), - ListPoint(1, "batch select solves to penalty"), - ListPoint(1, "batch select solves to change sessions"), - ListPoint(1, "added ability to clear all solves in a session"), - ListPoint(1, "added many more filter options, such as filtering solves with comments, with penalties, and more"), - ListPoint(1, "time trend now only displays last 80 solves"), - ListPoint(2, "an interactive time trend is coming in the next update"), - ListPoint(1, "added multiphase graph to individual time details"), - ListPoint(1, "added fully customisable fonts for timer and scramble")], - bugFixes: [ - ListPoint(1, "fixed stretched scramble image on smaller devices"), - ListPoint(1, "fixed time distribution graph labels"), - ListPoint(1, "fixed multiphase stats UI"), - ListPoint(1, "fixed many UI bugs with context menus and more"), - ListPoint(1, "fixed stats crashing"), - ListPoint(1, "fixed stats tools not updating when deleting solve")] - ) -] - - - -struct Updates: View { - @Environment(\.dismiss) var dismiss - - @Binding var showUpdates: Bool - - let currentVersion: String = "\((Bundle.main.infoDictionary?["CFBundleShortVersionString"] as! String))" - - var body: some View { - NavigationView { - ScrollView { - VStack(alignment: .leading, spacing: 0) { - Group { - Update(update: updatesList["2.0"]!) - - Text("Again, thanks for using this app! If anything goes wrong, like if the app crashes, please message me on discord (tim#0911) or open an issue on our github page (https://github.com/CubeStuffs/CubeTime/issues).") - .font(.body).fontWeight(.medium) - } - - Spacer() - } - .padding(.horizontal) - } - .navigationBarTitle("What's New!") - .toolbar { - ToolbarItemGroup(placement: .navigationBarTrailing) { - CTCloseButton { - dismiss() - showUpdates = false - } - } - } - } - } -} - - -struct Update: View { - var majorAdditions: [ListPoint]? - var minorAdditions: [ListPoint]? - var bugFixes: [ListPoint]? - - init(update: (majorAdditions: [ListPoint]?, - minorAdditions: [ListPoint]?, - bugFixes: [ListPoint]?)) { - self.majorAdditions = update.majorAdditions - self.minorAdditions = update.minorAdditions - self.bugFixes = update.bugFixes - } - - var body: some View { - VStack(alignment: .leading, spacing: 0) { - Text("v2.0 is here!!") - .foregroundStyle(getGradient(gradientSelected: 0, isStaticGradient: true)) - .recursiveMono(fontSize: 21, weight: .semibold) - .frame(maxWidth: .infinity, alignment: .center) - .padding(.vertical, 12) - - if let majorAdditions = majorAdditions { - Text("Major Additions: ") - .font(.title3).fontWeight(.semibold) - - VStack(alignment: .leading, spacing: 2) { - ForEach(majorAdditions, id: \.self) { point in - ListLine(point.depth, .init(stringLiteral: point.text)) - } - } - .padding(.bottom) - } - - if let minorAdditions = minorAdditions { - Text("Minor Additions: ") - .font(.title3).fontWeight(.semibold) - - VStack(alignment: .leading, spacing: 2) { - ForEach(minorAdditions, id: \.self) { point in - ListLine(point.depth, .init(stringLiteral: point.text)) - } - } - .padding(.bottom) - } - - if let bugFixes = bugFixes { - Text("Bug Fixes: ") - .font(.title3).fontWeight(.semibold) - - VStack(alignment: .leading, spacing: 2) { - ForEach(bugFixes, id: \.self) { point in - ListLine(point.depth, .init(stringLiteral: point.text)) - } - } - .padding(.bottom) - .font(.callout) - } - } - .padding(.bottom) - } -} diff --git a/CubeTime/launchScreenImage.png b/CubeTime/launchScreenImage.png deleted file mode 100644 index bace9431..00000000 Binary files a/CubeTime/launchScreenImage.png and /dev/null differ diff --git a/CubeTimeTests/StopwatchManagerStatsTests.swift b/CubeTimeTests/StopwatchManagerStatsTests.swift index c3e8dd59..fad90c25 100644 --- a/CubeTimeTests/StopwatchManagerStatsTests.swift +++ b/CubeTimeTests/StopwatchManagerStatsTests.swift @@ -27,7 +27,6 @@ struct TestSolveWrapper { solveItem.scramble = "Scramble" solveItem.session = session solveItem.scrambleType = 1 - solveItem.scrambleSubtype = 0 return solveItem } } diff --git a/Localizable.xcstrings b/Localizable.xcstrings new file mode 100644 index 00000000..f84af323 --- /dev/null +++ b/Localizable.xcstrings @@ -0,0 +1,2902 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "" + } + } + } + }, + "\nIf you run into any issues, please visit our GitHub page and submit an issue. \nhttps://github.com/CubeStuffs/CubeTime/issues" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n如果遇到任何问题,请访问我们的 GitHub 页面并提交问题。\nhttps://github.com/CubeStuffs/CubeTime/issues" + } + } + } + }, + "\nSupport us directly by donating on Ko-Fi:" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "\n直接通过 Ko-Fi 支持我们:" + } + } + } + }, + " Note that this is only applicable in compact UI mode (i.e. when the floating panel is not shown)." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "本设置只有在使用紧凑界面时有效" + } + } + } + }, + "-" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "-" + } + } + } + }, + "–" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "–" + } + } + } + }, + "..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "..." + } + } + } + }, + "(%@)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "(%@)" + } + } + } + }, + "[+2]" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "[+2]" + } + } + } + }, + "[DNF]" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "[DNF]" + } + } + } + }, + "%@" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%@" + } + } + } + }, + "%lld" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld" + } + } + } + }, + "%lld d.p" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld位数" + } + } + } + }, + "%lld Solves" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld次还原" + } + } + } + }, + "%lld." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "%lld." + } + } + } + }, + "© 2021–2024 Tim Xie & Reagan Bohan." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "© 2021–2024 Tim Xie & Reagan Bohan." + } + } + } + }, + "+2" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "+2" + } + } + } + }, + "|" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "|" + } + } + } + }, + "0.00" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "0.00" + } + } + } + }, + "0.000" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "0.000" + } + } + } + }, + "2x2" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "二阶" + } + } + } + }, + "3x3" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "三阶" + } + } + } + }, + "3x3 BLD" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "三阶盲拧" + } + } + } + }, + "3x3 OH" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "三阶单手" + } + } + } + }, + "4x4" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "四阶" + } + } + } + }, + "4x4 BLD" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "四阶盲拧" + } + } + } + }, + "5x5" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "五阶" + } + } + } + }, + "5x5 BLD" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "五阶盲拧" + } + } + } + }, + "6x6" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "六阶" + } + } + } + }, + "7x7" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "七阶" + } + } + } + }, + "A compsim (Competition Simulation) session mimics a competition scenario better by recording a non-rolling session. Your solves will be split up into averages of 5 that can be accessed in your times and statistics view.\n\nStart by choosing a target to reach." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "比赛模拟分组通过记录一个非滚动分组来更好地模拟比赛场景。您的还原成绩将被分成 5 次的平均值,可以在时间和统计视图中查看。\n\n首先选择一个模拟目标。" + } + } + } + }, + "A derivative of \"simibac's\" [ConfettiSwiftUI](https://github.com/simibac/ConfettiSwiftUI) library is used in CubeTime." : { + "shouldTranslate" : false + }, + "A derivative of [ChartView](https://github.com/AppPear/ChartView) is used in the stats view of CubeTime." : { + "shouldTranslate" : false + }, + "A multiphase session gives you the ability to breakdown your solves into sections, such as memo/exec stages in blindfolded solving or stages in 3x3 solves.\n\nTap anywhere on the timer during a solve to record a phase lap. You can access your breakdown statistics in each time card and view overall statistics in the Stats view." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "多阶段分组模式允许您将还原分解为多个阶段,例如盲拧中的各个阶段。\n\n在还原计时中,点击计时器上的任意位置以记录一个新的阶段时间。您可以在每个时间卡中查看阶段统计数据,并在统计视图中查看总体统计数据。" + } + } + } + }, + "A playground session allows you to quickly change the scramble type within a session without having to specify a scramble type for the whole session." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "灵活分组模式允许您在分组中快速更改打乱程序。" + } + } + } + }, + "Accessibility" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "辅助功能" + } + } + } + }, + "Add New Session" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add new session" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建分组" + } + } + } + }, + "Algorithm Trainer" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "算法训练模式" + } + } + } + }, + "Algorithm trainer, to train a specific subset of algorithms." : { + + }, + "All phases" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有阶段" + } + } + } + }, + "All Puzzles" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "All puzzles" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "所有魔方" + } + } + } + }, + "Appearance" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "外观" + } + } + } + }, + "Are you sure you want to continue? This will delete every solve in this session!" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确认删除该分组所有还原成绩?" + } + } + } + }, + "Are you sure you want to delete \"%@\"? All solves will be deleted and this cannot be undone." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确定删除\"%@\"?此操作会删除分组里所有还原成绩。" + } + } + } + }, + "Are you sure you want to delete the selected solves? This cannot be undone." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确认删除所选还原?" + } + } + } + }, + "Are you sure you want to delete this solve?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确认删除此还原?" + } + } + } + }, + "Are you sure you want to reset all appearance settings? Your solves and sessions will be kept." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确认重置所有外观设置?" + } + } + } + }, + "Are you sure you want to reset all general settings? Your solves and sessions will be kept." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确认重置所有通用设置?" + } + } + } + }, + "Ascending" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "顺序" + } + } + } + }, + "AVERAGE PHASES" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "平均阶段成绩" + } + } + } + }, + "AVERAGES" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "5次平均次数" + } + } + } + }, + "BEST" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最快AO5" + } + } + } + }, + "BEST AO5" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最快AO5" + } + } + } + }, + "BEST MO10 AO5" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最快MO10 AO5" + } + } + } + }, + "BEST SINGLE" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最快单次成绩" + } + } + } + }, + "BEST STATS" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最快成绩" + } + } + } + }, + "Boop" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "嘭" + } + } + } + }, + "Bug Fixes: " : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "修复的问题:" + } + } + } + }, + "Calculator" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计算器" + } + } + } + }, + "Cancel" : { + "comment" : "locale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消" + } + } + } + }, + "Clear Session" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "清除分组" + } + } + } + }, + "Clear session?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确认清除分组" + } + } + } + }, + "Clock" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "魔表" + } + } + } + }, + "Colours" : { + "extractionState" : "stale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "颜色" + } + } + } + }, + "Comment" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "备注" + } + } + } + }, + "COMMENT" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "备注" + } + } + } + }, + "Comment something…" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "备注..." + } + } + } + }, + "Compsim" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Compsim" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "比赛模拟" + } + } + } + }, + "Compsim Group" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "比赛模拟还原组" + } + } + } + }, + "Confirm" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确认" + } + } + } + }, + "Confirm Delete" : { + "comment" : "locale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确认删除" + } + } + } + }, + "Confirm Export" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "确认导出" + } + } + } + }, + "Continue" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "继续" + } + } + } + }, + "Copy" : { + "comment" : "locale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拷贝" + } + } + } + }, + "Copy Average" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拷贝平均" + } + } + } + }, + "Copy Scramble" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拷贝打乱" + } + } + } + }, + "Copy Solve" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "拷贝还原" + } + } + } + }, + "Could not get license for project" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Could not get license for project" + } + } + } + }, + "Create" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建" + } + } + } + }, + "CSV (generic)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CSV (通用)" + } + } + } + }, + "CubeTime" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CubeTime" + } + } + } + }, + "CubeTime is open source and licensed under the GNU GPL v3 license.\n\nWe use many open source projects and libraries, and you can view their respective licenses, along with our privacy policy below:" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CubeTime 是开源软件,采用 GNU GPL v3 许可证。\n\n我们使用了许多开源项目和库,您可以在下面查看它们各自的许可证以及我们的隐私政策:" + } + } + } + }, + "CubeTime itself does not collect any data, \"personal\" or otherwise.\nYour solves (data not comprising of personal information including but not limited to the \"scramble\" used, the date of the solve, the time taken to complete the solve) and your sessions (data including but not limited to named groups of solves, again with no personal data) are stored locally on your device only, unless you are signed into an iCloud account *and* choose to sync your sessions and solves between iCloud-enabled devices.\n\n**Optionally**, CubeTime will use Apple iCloud to store your sessions. CubeTime's data that is stored on iCloud can be viewed and deleted in the CubeTime app. This data is handled by Apple's CloudKit service, which is encypted by an account-based key. Your data is stored in a privacy database and we do **not** have acccess to this data. Additionally, we do **not** have access to your Apple ID.\nThe Apple iCloud privacy policy is available at https://www.apple.com/legal/privacy/, and further Apple platform security can be viewed [here](https://support.apple.com/en-is/guide/security/welcome/web).\n\nThe source code of CubeTime is publically viewable at https://github.com/CubeStuffs/CubeTime for independent verification." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CubeTime 本身不会收集任何数据,无论是「个人」数据还是其他数据。您除非登录了 iCloud 账户并选择在启用 iCloud 的设备间同步数据,所有的还原数据和数据分组仅存储在您的设备本地,\n\nCubeTime 可以使用 Apple iCloud 存储您的分组。存储在 iCloud 的 CubeTime 数据可以在 CubeTime 应用中查看和删除。这些数据由 Apple 的 CloudKit 服务处理,采用基于账户的密钥加密。您的数据存储在隐私数据库中,我们不有权访问这些数据。此外,我们不有权访问您的 Apple ID。\nApple iCloud 隐私政策可在 https://www.apple.com/legal/privacy/ 上查看,更多 Apple 平台的安全信息请见[此处](https://support.apple.com/en-is/guide/security/welcome/web)。\n\nCubeTime 的源代码可以在 https://github.com/CubeStuffs/CubeTime 上公开查看,以供独立验证。" + } + } + } + }, + "CubeTime only supports standard DynamicType sizes. Accessibility DynamicType modes are currently not supported, so layouts may not be rendered correctly." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CubeTime 仅支持标准动态类型尺寸。目前不支持辅助功能动态类型模式,因此布局可能无法正确显示。" + } + } + } + }, + "CubeTime v3.0.0 is here!" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "CubeTime v3.0.0 来啦!" + } + } + } + }, + "CURRENT" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前" + } + } + } + }, + "Current Average" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前平均" + } + } + } + }, + "CURRENT MO10 AO5" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前MO10 AO5" + } + } + } + }, + "CURRENT STATS" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前成绩" + } + } + } + }, + "Cursive Font" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "斜体字体" + } + } + } + }, + "Customise" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑" + } + } + } + }, + "Customise Session" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑分组" + } + } + } + }, + "Customise the gradients used in stats. By default, the gradient is set to \"Static\". You can choose to set it to \"Dynamic\", where the gradient will change throughout the day." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑统计数据中使用的渐变色。默认颜色设置为「静态」。您可以选择设置为「动态」,使渐变在一天中变化。" + } + } + } + }, + "Dark Mode" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "暗黑模式" + } + } + } + }, + "Date" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "日期" + } + } + } + }, + "DEBUG BUILD" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "DEBUG" + } + } + } + }, + "Default Session" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "默认分组" + } + } + } + }, + "Delete" : { + "comment" : "locale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除" + } + } + } + }, + "Delete Session" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除分组" + } + } + } + }, + "Delete Solve" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除还原" + } + } + } + }, + "Delete Solve Group" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除还原组" + } + } + } + }, + "Descending" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "逆序" + } + } + } + }, + "Display a cancel inspection button when inspecting." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在观察时显示「取消」建" + } + } + } + }, + "Display a prompt before deleting solves." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "删除还原前显示提示。" + } + } + } + }, + "Displays one scramble at a time. A timer is not shown. Tap to generate the next scramble." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "专门生成单次打乱。没有计时器。点击屏幕更换打乱。" + } + } + } + }, + "DNF" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "DNF" + } + } + } + }, + "Done" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "完成" + } + } + } + }, + "DYNAMIC GRADIENT" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "动态渐变色" + } + } + } + }, + "DynamicType Detected" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "检测到动态类型" + } + } + } + }, + "EDITING SOLVE %lld" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "编辑第%lld次还原" + } + } + } + }, + "Export" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出" + } + } + } + }, + "Export Sessions" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导出还原分组" + } + } + } + }, + "Extra Extra Extra Large" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加加加大号" + } + } + } + }, + "Extra Extra Large" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加加大号" + } + } + } + }, + "Extra Large" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加大号" + } + } + } + }, + "Extra Small" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加小号" + } + } + } + }, + "Filters" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "过滤条件" + } + } + } + }, + "Font Casualness" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "字体随意性" + } + } + } + }, + "Font Settings" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "字体设置" + } + } + } + }, + "Font Weight" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "字体粗细" + } + } + } + }, + "General" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "通用" + } + } + } + }, + "General Appearance" : { + + }, + "generate" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "GENERATE" + } + } + } + }, + "Generate multiple scrambles at once, to share, save or use." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "一次生成多个打乱,以供共享、保存或直接使用。" + } + } + } + }, + "Generate!" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "生成" + } + } + } + }, + "Gesture Activation Distance" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手势激活距离" + } + } + } + }, + "Got it!" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "哦,明白" + } + } + } + }, + "Gradient" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "渐变色" + } + } + } + }, + "Graph Animation" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图标动画" + } + } + } + }, + "Graph Glow" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "图表光晕效果" + } + } + } + }, + "Haptic Feedback" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "使用触感反馈" + } + } + } + }, + "Haptic Intensity" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "触感反馈模式" + } + } + } + }, + "Has Comment" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "有备注" + } + } + } + }, + "Has Penalty" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "有惩罚" + } + } + } + }, + "Help &\nAbout Us" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "帮助" + } + } + } + }, + "Hold Down Time: " : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器启动延时:" + } + } + } + }, + "iCloud unavailable" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "当前iCloud不可使用" + } + } + } + }, + "IMPORT" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "导入" + } + } + } + }, + "Inspection Alert" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "观察语音提示" + } + } + } + }, + "Inspection Alert Follows System Silent Mode" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "观察语音提示使用系统静音设置" + } + } + } + }, + "Inspection Alert Type" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "观察语音提示模式" + } + } + } + }, + "Inspection Counts Down" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "观察计时倒数" + } + } + } + }, + "JSON (csTimer)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "JSON (csTimer)" + } + } + } + }, + "Just a timer. No scrambles are shown. Your solves are **not** recorded and are not saved to a session." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "只有计时器。不会显示打乱。 你的还原时间是**不会**被记录的。" + } + } + } + }, + "L' D R2 B2 D2 F2 R2 B2 D R2 D R2 U B' R F2 R U' F L2 D'" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "L' D R2 B2 D2 F2 R2 B2 D R2 D R2 U B' R F2 R U' F L2 D'" + } + } + } + }, + "Language" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "语言" + } + } + } + }, + "Language & Localisation" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "语言与地区设置" + } + } + } + }, + "Large (Default)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "大号(默认)" + } + } + } + }, + "Licenses" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "许可证" + } + } + } + }, + "Loading..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "加载中..." + } + } + } + }, + "Lock Scramble" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "锁定当前打乱" + } + } + } + }, + "Major Additions: " : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主要更新:" + } + } + } + }, + "MAX" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最大" + } + } + } + }, + "MEDIAN: " : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "中位数:" + } + } + } + }, + "Medium" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "中号" + } + } + } + }, + "Megaminx" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "五魔方" + } + } + } + }, + "MIN" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最小" + } + } + } + }, + "Minor Additions: " : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "次要更新:" + } + } + } + }, + "Move To" : { + "comment" : "locale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移到" + } + } + } + }, + "Move to…" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "移到..." + } + } + } + }, + "Multiphase" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "多阶段分组" + } + } + } + }, + "New %@ Session" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建%@分组" + } + } + } + }, + "New Session" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新建分组" + } + } + } + }, + "No Penalty" : { + "comment" : "locale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无惩罚" + } + } + } + }, + "None" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "None" + } + } + } + }, + "Normal Sessions" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "普通分组" + } + } + } + }, + "not enough solves to\ndisplay graph" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还原次数不够... 再转几次显示统计图!" + } + } + } + }, + "Number of Scrambles..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打乱次数..." + } + } + } + }, + "ODF (MS Excel/Google Sheets)" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "ODF (MS Excel/Google Sheets)" + } + } + } + }, + "OK" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "OK" + } + } + } + }, + "Older changes" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "最近更新:" + } + } + } + }, + "Only compatible sessions are shown" : { + "comment" : "locale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "仅显示兼容的分组" + } + } + } + }, + "Oops! Export failed..." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "哎呀... 导出失败。请重试" + } + } + } + }, + "Open Licenses & Privacy Policy" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示许可证和隐私政策" + } + } + } + }, + "Open settings" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打开系统设置" + } + } + } + }, + "Order by" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "排序方式" + } + } + } + }, + "Other Sessions" : { + "comment" : "locale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "其他分组" + } + } + } + }, + "Override System Appearance" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "覆盖系统外观" + } + } + } + }, + "Penalty" : { + "comment" : "locale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "惩罚" + } + } + } + }, + "Phase" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "阶段" + } + } + } + }, + "Phase %d" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "第%d阶段" + } + } + } + }, + "PHASES" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "阶段" + } + } + } + }, + "Phases: " : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "阶段:" + } + } + } + }, + "PHASES: " : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "阶段:" + } + } + } + }, + "Pin" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "标星" + } + } + } + }, + "Pin Session?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "星标分组?" + } + } + } + }, + "Pinned Sessions" : { + "comment" : "locale", + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "星标分组" + } + } + } + }, + "PINNED SESSIONS" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "星标分组" + } + } + } + }, + "Play an audible alert when 8 or 12 seconds is reached." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在达到 8 秒或 12 秒时播放声音提醒。" + } + } + } + }, + "Playground" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "灵活分组" + } + } + } + }, + "Preview" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "预览" + } + } + } + }, + "Privacy Policy" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "隐私" + } + } + } + }, + "Puzzle Type" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "魔方项目" + } + } + } + }, + "Pyraminx" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "金字塔" + } + } + } + }, + "REACHED" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "达到目标" + } + } + } + }, + "REACHED TARGETS" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "达到目标" + } + } + } + }, + "Reset" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置" + } + } + } + }, + "Reset Appearance Settings" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置外观设置" + } + } + } + }, + "Reset General Settings" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重置通用设置" + } + } + } + }, + "Scramble" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打乱" + } + } + } + }, + "Scramble Generator" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打乱生成器" + } + } + } + }, + "Scramble Only" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打乱模式" + } + } + } + }, + "Scramble Size: " : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "打乱字体大小:" + } + } + } + }, + "Search…" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "搜索..." + } + } + } + }, + "Select" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择" + } + } + } + }, + "Select All" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全选" + } + } + } + }, + "Select Export Types" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择导出模式" + } + } + } + }, + "Select Sessions for Export" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择分组导出" + } + } + } + }, + "Select Solves" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "选择还原" + } + } + } + }, + "SESSION" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还原分组" + } + } + } + }, + "Session Event" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分组项目" + } + } + } + }, + "SESSION MEAN" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分组平均成绩" + } + } + } + }, + "Session Name" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分组名" + } + } + } + }, + "Sessions" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还原分组" + } + } + } + }, + "SESSIONS" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还原分组" + } + } + } + }, + "Settings" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "设置" + } + } + } + }, + "Share Average" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享平均" + } + } + } + }, + "Share Solve" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "分享还原" + } + } + } + }, + "Show a button in the top right corner to enter zen mode." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在计时器界面的右上角显示禅模式。" + } + } + } + }, + "Show a confetti animation when a new best time is recorded." : { + + }, + "Show Cancel Inspection" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show cancel inspection" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示取消观察键" + } + } + } + }, + "Show Confetti" : { + + }, + "Show draw scramble on timer" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主界面显示打乱图案" + } + } + } + }, + "Show Previous Time" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show previous time" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示上一个时间" + } + } + } + }, + "Show Solve Deletion Prompt" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Show solve deletion prompt" + } + }, + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示还原删除提示" + } + } + } + }, + "Show stats on timer" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "主界面显示统计数据" + } + } + } + }, + "Show the previous time after a solve is deleted by swipe gesture. With this option off, the default time of 0.00 or 0.000 will be shown instead." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "将滑动手势删除还原后显示之前的时间。如果关闭此选项,将显示默认时间 (0.00 或 0.000)。" + } + } + } + }, + "Show tools on the timer screen." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "在计时器界面上显示工具。" + } + } + } + }, + "Show Updates List" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示过去更新" + } + } + } + }, + "Show zen mode button" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "显示禅模式键" + } + } + } + }, + "Simple average and mean calculator." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计算平均成绩(去头尾和所有平均)" + } + } + } + }, + "Skewb" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "斜转魔方" + } + } + } + }, + "Small" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "小号" + } + } + } + }, + "SOLVE %lld" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "第%lld次还原" + } + } + } + }, + "SOLVE COUNT" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还原次数" + } + } + } + }, + "Sort by" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "排序方式" + } + } + } + }, + "Square-1" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "Square-1" + } + } + } + }, + "Standard Session" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "单项分组" + } + } + } + }, + "Standard session has one scramble type that cannot be changed later on." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "普通模式有一种固定的魔方打乱。" + } + } + } + }, + "Start Over" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "重新开始" + } + } + } + }, + "STATIC GRADIENT" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "静态渐变色" + } + } + } + }, + "Statistics" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计数据" + } + } + } + }, + "Stats" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计数据" + } + } + } + }, + "Stats Detail" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "统计数据" + } + } + } + }, + "Success!" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "成功!" + } + } + } + }, + "Successfully exported!" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "成功导出!" + } + } + } + }, + "Sync to iCloud failed" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "iCloud同步失败" + } + } + } + }, + "Synced to iCloud" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "已同步到 iCloud" + } + } + } + }, + "System Settings" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "系统设置" + } + } + } + }, + "Take me home" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "带我回首页!" + } + } + } + }, + "Tap for Fullscreen Preview" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "全屏预览" + } + } + } + }, + "Target" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标" + } + } + } + }, + "TARGET" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "目标" + } + } + } + }, + "Thanks for using this app! If you have any feedback (good or bad!), please open an issue on [our github page](https://github.com/CubeStuffs/CubeTime/issues) or [contact me personally](https://tim-xie.com/contact)." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "感谢您使用CubeTime!!如果您有任何反馈(无论好坏!),请在 [我们的 GitHub 页面](https://github.com/CubeStuffs/CubeTime/issues) 上提出问题,或 [直接联系我](https://tim-xie.com/contact)。" + } + } + } + }, + "the boring stuff" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "无聊的东西" + } + } + } + }, + "time" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "单次" + } + } + } + }, + "Time" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还原时间" + } + } + } + }, + "Time Detail" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还原数据" + } + } + } + }, + "TIME DISTRIBUTION" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "时间分布" + } + } + } + }, + "Time Trend" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "时间趋势" + } + } + } + }, + "TIME TREND" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "时间趋势" + } + } + } + }, + "Time Trend Show: " : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "时间趋势显示:" + } + } + } + }, + "Timer" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器" + } + } + } + }, + "Timer Interface & Solves" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器界面与还原" + } + } + } + }, + "Timer Mode" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器模式" + } + } + } + }, + "Timer Only" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器模式" + } + } + } + }, + "Timer only sessions do not have a scramble type, but no scrambles are associated with solves." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器模式没有打乱步骤,但还原会被记录并有统计数据。" + } + } + } + }, + "Timer Settings" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器设置" + } + } + } + }, + "Timer Tools" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器工具" + } + } + } + }, + "Timer Update" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "计时器更新频率" + } + } + } + }, + "Times" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还原成绩" + } + } + } + }, + "TIMES" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "还原成绩" + } + } + } + }, + "Times Displayed To: " : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "时间显示:" + } + } + } + }, + "To use the 'Boop' option, your phone must not be muted. 'Boop' plays a system sound, requiring your ringer to be unmuted." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "如果使用「嘭」选项,您的手机不能处于静音状态。「嘭」会播放系统声音,需要关闭铃声静音。" + } + } + } + }, + "Tools" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "工具" + } + } + } + }, + "Touch Gesture Activation Distance" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手势激活距离" + } + } + } + }, + "Turn on/off the glow effect on graphs." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开启/关闭图表的光晕效果。" + } + } + } + }, + "Turn on/off the line animation for the time trend graph." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开启/关闭时间趋势图的线条动画。" + } + } + } + }, + "Typing" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "手动输入" + } + } + } + }, + "Unlock Scramble" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "解锁当前打乱" + } + } + } + }, + "Unlock scramble?" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "解锁当前打乱?" + } + } + } + }, + "Unlock!" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "解锁" + } + } + } + }, + "Unpin" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "取消星标" + } + } + } + }, + "Use Inspection" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "观察模式" + } + } + } + }, + "v%@ changes:" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "v%@ 更新:" + } + } + } + }, + "Voice" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "语音" + } + } + } + }, + "What's New!" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "新的功能!" + } + } + } + }, + "With this setting on, the **Voice** alert type will play through system audio, respecting your phone's ringer mute state." : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "开启此设置后,**语音**提醒将通过系统音频播放,遵循手机的铃声静音状态。" + } + } + } + }, + "Zen Mode" : { + "localizations" : { + "zh-Hans" : { + "stringUnit" : { + "state" : "translated", + "value" : "禅模式" + } + } + } + } + }, + "version" : "1.0" +} \ No newline at end of file diff --git a/privacy.md b/privacy.md index bf515c78..af1d19f7 100644 --- a/privacy.md +++ b/privacy.md @@ -3,7 +3,7 @@ CubeTime itself does not collect any data, "personal" or otherwise. Your solves (data not comprising of personal information including but not limited to the "scramble" used, the date of the solve, the time taken to complete the solve) and your sessions (data including but not limited to named groups of solves, again with no personal data) are stored locally on your device only, unless you are signed into an iCloud account *and* choose to sync your sessions and solves between iCloud-enabled devices. -**Optionally**, CubeTime will use Apple iCloud to store your sessions. CubeTime's data that is stored on iCloud can be viewed and deleted in the CubeTime app. This data is handled by Apple's CloudKit service, which is encypted by an account-based key. Your data is stored in a privacy database and we do **not** have acccess to this data. Additionally, we do **not** have access to your Apple ID. +**Optionally**, CubeTime will use Apple iCloud to store your sessions. CubeTime's data that is stored on iCloud can be viewed and deleted in the CubeTime app. This data is handled by Apple's CloudKit service, which is encypted by an account-based key. Your data is stored in a privacy database and we do **not** have access to this data. Additionally, we do **not** have access to your Apple ID. The Apple iCloud privacy policy is available at https://www.apple.com/legal/privacy/, and further Apple platform security can be viewed [here](https://support.apple.com/en-is/guide/security/welcome/web). -The source code of CubeTime is publically viewable at https://github.com/CubeStuffs/CubeTime for independent verification. +The source code of CubeTime is publically viewable at https://github.com/CubeLabsNZ/CubeTime for independent verification. diff --git a/readme.md b/readme.md index ab1169e0..160b7c6d 100644 --- a/readme.md +++ b/readme.md @@ -1,36 +1,30 @@

CubeTime

- + + height="200">

-

SPEEDCUBING TIMER & UTILITY APP

+

a speedcubing timer & utility app

-

--- -[![Build](https://github.com/CubeStuffs/CubeTime/actions/workflows/ios.yml/badge.svg?branch=main)](https://github.com/CubeStuffs/CubeTime/actions/workflows/ios.yml) -[![App Store](https://img.shields.io/badge/dynamic/json?label=App%20Store&query=%24.results[0].averageUserRatingForCurrentVersion&suffix=%20%E2%98%85&url=http%3A%2F%2Fitunes.apple.com%2Flookup%3Fid%3D1600392245&color=0D96F6&logo=appstore&logoColor=white)](https://apps.apple.com/app/cubetime/id1600392245) -![SwiftUI](https://img.shields.io/badge/Built%20Using-SwifUI-F05138?logo=Swift&logoColor=white) -[![Scrambles](https://img.shields.io/badge/Scrambles-WCA%20TNoodle-yellow?logo=data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAA4AAAAOCAYAAAAfSC3RAAAACXBIWXMAAAC0AAAAtAHrlyx7AAAAGXRFWHRTb2Z0d2FyZQB3d3cuaW5rc2NhcGUub3Jnm+48GgAAAsBJREFUKJFVkltIkwEcxc//+/btkmtb2eZSSy1z3lop0R2s6EpFEHaTLm+llogIZogUGlYkGXQjH0J66CEKCwl9sCvahYwowkgC7zi3MVvf3OXbvv17EIXO8/nB4ZxDzIxZtfS25LkDU/eG/WNrgtGgvmW9CoGMYUFI7ZP0llK7vbp/1kuzYGP3tZae8feVoViYkhJsEbu01HXGESejYdgej3u1JCSwTreuOTm1tmYObHh59ebLoTeV1nmLFGdifm3wq7Pww1dXSVxl2lBgf1Z25NsHov7LcdWr1Ru2XE9Ora2hiaZLuU0rvN8nYlOqw33ohs/NZX5ZMStRFarKMM3XwqCT5OwMaj2w7XGFGDCL+hFHjliVuaJ93aOetJRtxV/aX8WODI769edOFmJVjhWrcm1Y6bDiUcdP3ZgrunH134V9ts8dS1g/P59Gdm4PEZi5tXBoOjia0/F2N3x+G8IRFZIkgOOMZNmF/b1tMGRlftdkjjkg6lSBPR4dbPZxBpPFUoySvaMo3tEFQgDs+4PjfQ9R/OMpJK8bYCYyJ7kQ8Bg0YAaIaaZkASQswJKUQlSU3IdweBgIh2FovY/InbsAETMTAYBANlsEk5PJAECIIRJ6gWn59ty2otMJxBnS0WMzoN9lh9EaEoSMjC+q22MQpywciXyE0VQHUZM3B2qcTgTr6xFuvg7ZaCYEvFpYl30SxLSU06bOLjUhvWy5JOXeCE63hTSaxTOxiaD+GgC0WsW7dd/NqqmC7ItKTUxMSi8Vm7tfe8JRxQCgSCNlr9FIRfVR5dMks5wndAYhpqV3PSg41db2Wzjv8YW0awvSmvacKHkydzmfz9dIxHUACCCFOeqqu/YOg66QfdI7rTUZdVy0NuVKQ/Wuuv++CgCyLGdFo8otImwigv5g+XMyGXWhZanmj9ZES/mFs5sHZr3/AICCMe4nisHSAAAAAElFTkSuQmCC)](https://github.com/thewca/tnoodle-lib) - - - # Contents 1. [Overview](#overview) 2. [Screenshots](#screenshots) 3. [Features](#features) -4. [User Guide](#user-guide) +4. [Introductory User Guide](#introductory-user-guide) 5. [Some final stuff](#some-final-stuff) ## Overview @@ -70,6 +64,8 @@ ### App Features - Built-in system haptics, and able to be changed to your liking +- Audio alerts in inspection + - All the basic timing functionalities, and fully customisable: * Customisable hold down time * Inspection time @@ -87,16 +83,17 @@ * pinnable sessions for easy access - Simple card design for viewing your times - * Searchable times, along with quick and easy to use sort functionality - to sort your times by date or by speed - * Batch select times for deletion, adding penalties, or to move to a different session + * Searchable times, along with quick and easy to use sort and filter functionality - to sort your times by date or by speed, and filter by comment, scramble type and penalty + * Batch select times for deletion, adding penalties, moving to a different session, or copying * Add comments for special solves + * Long-press menu for easy access to solve options - Extensive statistics and solve analysis: * Visual graphs for your sessions * Such as time trend, time distribution and other graphs - * All standard calculations, including best and current averages of 5, 12 and 100, session mean, median + * All standard calculations, including best and current averages of 5, 12 and 100, session mean, median, and many more -- Other tools, including special ones for Comp Sim, such as: +- Other stats and tools, including special ones for compsim, such as: * calculating your bpa and wpa * calculating time needed to secure certain averages * batch scramble generator @@ -107,7 +104,8 @@ * Trackpad support * Multitasking window support -- CloudKit® for iCloud® time syncing +- CloudKit® for iCloud® session and solve syncing +- iCloud® settings syncing – so all your settings are the same across devices ### Upcoming Features @@ -117,9 +115,9 @@ Here's an outline of some of the major upcoming features - Support for stackmats - Importing sessions and solves from common timers, such as ChaoTimer and csTimer - Easy to use export to save your sessions +- Algorithm Trainer and more to come... - -## User Guide +## Introductory User Guide ### Timer Press and hold until the timer turns green to start. You can change the hold time in settings. @@ -128,38 +126,42 @@ The default gestures are as follows: - swipe right to generate a new scramble - swipe down to add a penalty +On iPads, you can use your trackpad to two-finger swipe in the same way as your finger. + ### Time List All your solves in the currently selected session will be displayed in your time list. -You can sort your times by pressing "Sort by Date" or "Sort by Time". The button to the right will sort by ascending or descending order. Pressing the select button on the top right will enter selection mode, where you can batch select, delete, move or penalise solves. -Swiping down will reveal the search bar, where you can search for your times. +Pressing on the seach icon will reveal the search bar, where you can search for your times. +You can also filter for scramble types, penalties and comments, along with sorting your times. Searching while in select mode will preserve your current selection. Clicking on a solve will bring up the solve details, the time, date, event and scramble. You can add a comment if you wish by typing in the comment box. "Copy Solve" will copy the solve details to your clipboard. +"Share Solve" allows you to share it to other apps or save as a file. ### Stats The default stats view shows your current and best averages of 5, 12 and 100, the number of solves in the session, session mean and your best single. Clicking on each of the stats will bring up a detail view, such as the solves in your average. -You can customise the graphs by pressing and holding on the graph. - ### Sessions Create a new session by clicking the "New Session" button. You can select from different types of sessions: 1. Standard Session: is a normal session where the scramble is fixed to the session event -2. Algorithm Trainer: generates scrambles that allow you to train a certain algset -3. Multiphase: times many phases during your solve, useful in blind or analysing your solve breakdown -4. Playground: session with no fixed scramble type, you can change the scramble within the session -5. Comp Sim: non-rolling session that records solves in averages of x, instead of a big session +2. Multiphase: times many phases during your solve, useful in blind or analysing your solve breakdown +3. Playground: session with no fixed scramble type, you can change the scramble within the session +4. Comp Sim: non-rolling session that records solves in averages of x, instead of a big session. Simulates competitions -Pinning a session will make the session bigger, and you can pin a session when creating or by long pressing on a session to access the menu. +Pinning a session will make the session bigger and stickied at the top of sessions, and you can pin a session when creating or by long pressing on a session to access the menu. Deleting a session will delete *all* your solves in that session, so be careful! ### Settings You can customise almost all settings in the app and the appearance and themes. +### Tools +You can access the tools menu through settings on iPhone (or split view iPad) and through the menu icon on large iPad modes. CubeTime has basic timer and scramble only modes, along with a batch scramble generator and average calculator. + ## Some final stuff As we are using the official TNoodle scrambler library, please see our [transpiled tnoodle-lib-objc repo](https://github.com/CubeStuffs/tnoodle-lib-objc) for more information. + iPad and App Store are trademarks of Apple Inc., registered in the U.S. and other countries.