diff --git a/tabatago-swift/TabataGo.xcodeproj/project.pbxproj b/tabatago-swift/TabataGo.xcodeproj/project.pbxproj index 4a32f6d..7001977 100644 --- a/tabatago-swift/TabataGo.xcodeproj/project.pbxproj +++ b/tabatago-swift/TabataGo.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ 192F8CFFE1888005ABF339E8 /* WorkoutSession.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBBBFC7FC6A52DE9908EE4A6 /* WorkoutSession.swift */; }; 1955D0D74D9B09D10705104C /* WorkoutProgram.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6E64CFB210A549AC85F878D /* WorkoutProgram.swift */; }; 20FD0BC9A6E01E8EA182E030 /* Supabase in Frameworks */ = {isa = PBXBuildFile; productRef = 2A66A0F120927A9EBC548828 /* Supabase */; }; + 22669D283A2B7C8D5F4FE19F /* ActivityRingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = ACA8445E547E41DF784C3D2F /* ActivityRingView.swift */; }; 29DA1C9905E244CDC316D5AA /* CompletionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44A6F4FB39AE902BCED1C2D5 /* CompletionView.swift */; }; 2D5CE02211FB67CD2CFDAA11 /* SupabaseService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4EDF0ED76A8476BF1F80EF8C /* SupabaseService.swift */; }; 367B00BF0E8537F9BA15530F /* MusicTrack.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C6156C6E0E1A543DAC87A90 /* MusicTrack.swift */; }; @@ -21,9 +22,11 @@ 3E2E78027B1973F72E05D8D2 /* PlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8206D4F904F61E5685DE369E /* PlayerViewModel.swift */; }; 3F0F63E163BBA968C4CEFF81 /* TabataGoWatch.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 484865AEFA8CCD26C4AE7F73 /* TabataGoWatch.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 3FAAAAC1576A7861AB833E39 /* ProfileTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B5DFE227FDB6400C8D7A4A4 /* ProfileTab.swift */; }; + 41096438BB67B60480460156 /* WatchL10n.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5ACBDB7D81F575AC5370E82F /* WatchL10n.swift */; }; 4371D8DD5F2638905606513A /* WatchActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D8CA90001C65B27E3B7BE34 /* WatchActivityView.swift */; }; 53FDC12EFCD8159045C105C0 /* HealthKitService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0499FCA348FF1A127C8E4FAE /* HealthKitService.swift */; }; 556620C10FA0BC85E1BDE529 /* WatchIdleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 495E38AB3B412E296F8C3649 /* WatchIdleView.swift */; }; + 583A4EB0B1D20521D4A37D60 /* GlobalStatsCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = D66750D77B59FCF4F321B36E /* GlobalStatsCard.swift */; }; 59B482DEBAA43EE5F24B883D /* HomeTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB04FA5E81BD1E52DEFB3AC2 /* HomeTab.swift */; }; 5A25DA9A1B21F5EED15BA370 /* ProgramDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6BFF744890571DE314540E16 /* ProgramDetailView.swift */; }; 5A402D7E31059AB7107B625C /* MusicPlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5505FBD6E001AE3AFD413ADA /* MusicPlayerViewModel.swift */; }; @@ -32,6 +35,7 @@ 6060D95D485E4188EAABDDED /* WatchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEC37E6361DC4C7AE326139 /* WatchRootView.swift */; }; 61BD8C313424F89F13FDE92E /* PurchaseViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = B8E005D62F3B3B80A2A53C2C /* PurchaseViewModel.swift */; }; 66E87ABBDC5C3B36B3E932FB /* WatchConnectivityManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 802638FA5E5FDB5B278123AC /* WatchConnectivityManager.swift */; }; + 6C11E1F61F3ABADC7922C383 /* WeeklySection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 75C8C8AEF5C3E8432CB07861 /* WeeklySection.swift */; }; 70C2DAC704F628494A59EF56 /* Theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 61E5AA44513F793EA7FEBA00 /* Theme.swift */; }; 70DEC8E97218C774A46F7CEA /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = CDFE1E10182972315386F9D7 /* Assets.xcassets */; }; 725EBACF4CF7BC23D2C476AA /* TabataGoApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = D09EB765FCE6A3EE95E86EB3 /* TabataGoApp.swift */; }; @@ -44,6 +48,7 @@ 90728D374B15A38DD9A75E5B /* WatchPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DBC464C4D17B88E57FB5477C /* WatchPlayerView.swift */; }; 93457B73C62C4BEA4329BD4C /* TabataGoWatchWidget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 255972F9906563A0921C47C0 /* TabataGoWatchWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; 9633A730F910E47C28A288AC /* WatchConnectivityTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = D8425C668A3901B0F12DBFCD /* WatchConnectivityTypes.swift */; }; + 996E613C0A9906AB88D2AEB6 /* WorkoutCalendarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FAEBB2380DAC8F565F556D41 /* WorkoutCalendarView.swift */; }; 9F9695303EEC1516B1845417 /* TabataGoWatchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9006191EE89D06E6558786E3 /* TabataGoWatchApp.swift */; }; AA17AD2E25DF408ECE100F99 /* PreviewData.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7482C05380DE017FF582C28B /* PreviewData.swift */; }; B4CFD4E752EF66F6535AD173 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 815C7C1CC22063B7E27F2F9B /* SettingsView.swift */; }; @@ -57,6 +62,7 @@ D422758C736D40CB0E9C4063 /* NowPlayingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 31B4E4F3DA1047ACD980F581 /* NowPlayingView.swift */; }; D65673484CBB4DDA03C23225 /* RootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F8242C26A4F51BE7AA779840 /* RootView.swift */; }; D665638A80E06A7C42019782 /* PlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E93E214AAB0E1CB61B89EC75 /* PlayerView.swift */; }; + D708AE54AD57CE932F9880DA /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = C4C127C41584515D4EF95CB0 /* Localizable.xcstrings */; }; DBFB6F75F59367A957B8F9B9 /* UserProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7FE34000653EE789117CE9D9 /* UserProfile.swift */; }; E21B9936D15D2111807AAAE9 /* OnboardingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 28A422BCE1A702F8A10951FC /* OnboardingView.swift */; }; E4ED0B8CABBD3502EA468F21 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1AF33C89F42294599C369A /* HomeViewModel.swift */; }; @@ -145,6 +151,7 @@ 525C7E8EC6EF89E00D34672E /* PolicyViews.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PolicyViews.swift; sourceTree = ""; }; 5505FBD6E001AE3AFD413ADA /* MusicPlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicPlayerViewModel.swift; sourceTree = ""; }; 58DEACB2D18F636B35BB2C48 /* TabataGoSchema.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabataGoSchema.swift; sourceTree = ""; }; + 5ACBDB7D81F575AC5370E82F /* WatchL10n.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchL10n.swift; sourceTree = ""; }; 5F5D3568A736B7A326874677 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; 61E5AA44513F793EA7FEBA00 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = ""; }; 63599808389B70FC2F6A43C3 /* HealthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthViewModel.swift; sourceTree = ""; }; @@ -152,6 +159,7 @@ 6BFF744890571DE314540E16 /* ProgramDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProgramDetailView.swift; sourceTree = ""; }; 6D558DAFE1AD94786AA674A4 /* TabataGoUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabataGoUITests.swift; sourceTree = ""; }; 7482C05380DE017FF582C28B /* PreviewData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviewData.swift; sourceTree = ""; }; + 75C8C8AEF5C3E8432CB07861 /* WeeklySection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WeeklySection.swift; sourceTree = ""; }; 7FE34000653EE789117CE9D9 /* UserProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UserProfile.swift; sourceTree = ""; }; 802638FA5E5FDB5B278123AC /* WatchConnectivityManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchConnectivityManager.swift; sourceTree = ""; }; 815C7C1CC22063B7E27F2F9B /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; @@ -165,18 +173,21 @@ 9EC19129CD3C493C8B2AEFA8 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; A7C07E8AF566483359CE2FEC /* TabataGoTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = TabataGoTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; A84A0F7F17713D5D0A679122 /* PurchaseService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseService.swift; sourceTree = ""; }; + ACA8445E547E41DF784C3D2F /* ActivityRingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityRingView.swift; sourceTree = ""; }; AD1AF33C89F42294599C369A /* HomeViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeViewModel.swift; sourceTree = ""; }; B6E64CFB210A549AC85F878D /* WorkoutProgram.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutProgram.swift; sourceTree = ""; }; B7EDA5BF7F25E3279A4B1A61 /* TabataGoUITests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = TabataGoUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; B8E005D62F3B3B80A2A53C2C /* PurchaseViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PurchaseViewModel.swift; sourceTree = ""; }; BBBBFC7FC6A52DE9908EE4A6 /* WorkoutSession.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutSession.swift; sourceTree = ""; }; BD3DF875E3461305DADB554A /* Secrets.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Secrets.xcconfig; sourceTree = ""; }; + C4C127C41584515D4EF95CB0 /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; C4C9BB1EEE2291A9A23B5F3C /* MusicService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicService.swift; sourceTree = ""; }; CDA5D50FD057EF30BE7915F5 /* TabataGoComplication.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabataGoComplication.swift; sourceTree = ""; }; CDFE1E10182972315386F9D7 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; D09EB765FCE6A3EE95E86EB3 /* TabataGoApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabataGoApp.swift; sourceTree = ""; }; D168B973B16C94426A15766A /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; D593D23B6A2F633DFA166D91 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + D66750D77B59FCF4F321B36E /* GlobalStatsCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GlobalStatsCard.swift; sourceTree = ""; }; D8425C668A3901B0F12DBFCD /* WatchConnectivityTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = WatchConnectivityTypes.swift; path = ../../TabataGo/Services/WatchConnectivityTypes.swift; sourceTree = ""; }; D8A69F6B8DC5329436762B50 /* TabataGo.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = TabataGo.app; sourceTree = BUILT_PRODUCTS_DIR; }; D983B6DEDE62A8F0E9E09E66 /* TabataGoWatch.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = TabataGoWatch.entitlements; sourceTree = ""; }; @@ -185,6 +196,7 @@ E93E214AAB0E1CB61B89EC75 /* PlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerView.swift; sourceTree = ""; }; F1DE8A4DAD846A879B8ED379 /* HealthSnapshot.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthSnapshot.swift; sourceTree = ""; }; F8242C26A4F51BE7AA779840 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = ""; }; + FAEBB2380DAC8F565F556D41 /* WorkoutCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutCalendarView.swift; sourceTree = ""; }; FB04FA5E81BD1E52DEFB3AC2 /* HomeTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTab.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -245,6 +257,7 @@ 1F1136D2B7FEBD67D18C7679 /* Resources */ = { isa = PBXGroup; children = ( + C4C127C41584515D4EF95CB0 /* Localizable.xcstrings */, D983B6DEDE62A8F0E9E09E66 /* TabataGoWatch.entitlements */, ); path = Resources; @@ -312,14 +325,27 @@ 0FB4710828254C8629904415 /* Complications */, 1F1136D2B7FEBD67D18C7679 /* Resources */, F514B75119B8194DB1791B95 /* Services */, + CE5E34713E694FAEED0F3480 /* Utilities */, 4B4FB2F47AD5A34A21679372 /* Views */, ); path = TabataGoWatch; sourceTree = ""; }; + 850B47F4DD96E9CD7D6F412A /* Activity */ = { + isa = PBXGroup; + children = ( + ACA8445E547E41DF784C3D2F /* ActivityRingView.swift */, + D66750D77B59FCF4F321B36E /* GlobalStatsCard.swift */, + 75C8C8AEF5C3E8432CB07861 /* WeeklySection.swift */, + FAEBB2380DAC8F565F556D41 /* WorkoutCalendarView.swift */, + ); + path = Activity; + sourceTree = ""; + }; 8B90DED418BDA3697748C37D /* Tabs */ = { isa = PBXGroup; children = ( + 850B47F4DD96E9CD7D6F412A /* Activity */, 84123E854DE0BF3E0D4F0912 /* ActivityTab.swift */, FB04FA5E81BD1E52DEFB3AC2 /* HomeTab.swift */, 12715936CAA6BD90A7FBE9D7 /* MainTabView.swift */, @@ -413,6 +439,14 @@ path = ViewModels; sourceTree = ""; }; + CE5E34713E694FAEED0F3480 /* Utilities */ = { + isa = PBXGroup; + children = ( + 5ACBDB7D81F575AC5370E82F /* WatchL10n.swift */, + ); + path = Utilities; + sourceTree = ""; + }; D72E5B5E6AA55AA4DD27D119 /* App */ = { isa = PBXGroup; children = ( @@ -495,6 +529,7 @@ buildConfigurationList = E706A289AD3B8CCB1F3310BE /* Build configuration list for PBXNativeTarget "TabataGoWatch" */; buildPhases = ( 688CFA91471FB46E21B9EFB2 /* Sources */, + ABE017E9BF179D97311AB485 /* Resources */, 97F207A5CEE6835FA097805C /* Embed Foundation Extensions */, ); buildRules = ( @@ -641,6 +676,14 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + ABE017E9BF179D97311AB485 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + D708AE54AD57CE932F9880DA /* Localizable.xcstrings in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -669,6 +712,7 @@ 66E87ABBDC5C3B36B3E932FB /* WatchConnectivityManager.swift in Sources */, B5591230E8B61A2B18F5DD87 /* WatchConnectivityTypes.swift in Sources */, 556620C10FA0BC85E1BDE529 /* WatchIdleView.swift in Sources */, + 41096438BB67B60480460156 /* WatchL10n.swift in Sources */, 850C700B060F46134C2D4569 /* WatchPlayerEngine.swift in Sources */, 90728D374B15A38DD9A75E5B /* WatchPlayerView.swift in Sources */, 6060D95D485E4188EAABDDED /* WatchRootView.swift in Sources */, @@ -679,6 +723,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 22669D283A2B7C8D5F4FE19F /* ActivityRingView.swift in Sources */, 60503F963221C7FCF719C493 /* ActivityTab.swift in Sources */, CCCCEFD2D61ED1D7DDB9040C /* AnalyticsService.swift in Sources */, EE6C591611D52C36ED5E03C6 /* AppState.swift in Sources */, @@ -686,6 +731,7 @@ CDFA9A56DB6DA111B728FF48 /* BodyZoneView.swift in Sources */, 29DA1C9905E244CDC316D5AA /* CompletionView.swift in Sources */, C2CB48B999939D3550A50936 /* Environment.swift in Sources */, + 583A4EB0B1D20521D4A37D60 /* GlobalStatsCard.swift in Sources */, 53FDC12EFCD8159045C105C0 /* HealthKitService.swift in Sources */, 5CE2F2210BEF17AC304F2AC2 /* HealthSnapshot.swift in Sources */, FE14257B8CFFDC47C72AE079 /* HealthViewModel.swift in Sources */, @@ -717,6 +763,8 @@ 70C2DAC704F628494A59EF56 /* Theme.swift in Sources */, DBFB6F75F59367A957B8F9B9 /* UserProfile.swift in Sources */, 9633A730F910E47C28A288AC /* WatchConnectivityTypes.swift in Sources */, + 6C11E1F61F3ABADC7922C383 /* WeeklySection.swift in Sources */, + 996E613C0A9906AB88D2AEB6 /* WorkoutCalendarView.swift in Sources */, 1955D0D74D9B09D10705104C /* WorkoutProgram.swift in Sources */, 192F8CFFE1888005ABF339E8 /* WorkoutSession.swift in Sources */, ); diff --git a/tabatago-swift/TabataGo/Views/Tabs/Activity/ActivityRingView.swift b/tabatago-swift/TabataGo/Views/Tabs/Activity/ActivityRingView.swift new file mode 100644 index 0000000..5c8538e --- /dev/null +++ b/tabatago-swift/TabataGo/Views/Tabs/Activity/ActivityRingView.swift @@ -0,0 +1,81 @@ +import SwiftUI + +struct ActivityRingView: View { + let moveValue: Double + let moveGoal: Double + let exerciseValue: Double + let exerciseGoal: Double + let standValue: Double + let standGoal: Double + + @State private var animatedProgress: Bool = false + + private let ringWidth: CGFloat = 14 + private let spacing: CGFloat = 8 + private let size: CGFloat = 160 + + var body: some View { + VStack(spacing: 16) { + ZStack { + moveRing + exerciseRing + standRing + } + .frame(width: size, height: size) + .onAppear { withAnimation(.easeInOut(duration: 1.2)) { animatedProgress = true } } + + HStack(spacing: 0) { + ringLegend(value: Int(moveValue), unit: "kcal", label: L10n.health.move, color: .red) + ringLegend(value: Int(exerciseValue), unit: "min", label: L10n.health.exercise, color: .green) + ringLegend(value: Int(standValue), unit: "hrs", label: L10n.health.stand, color: .cyan) + } + } + } + + private var moveRing: some View { + ringTrack(progress: animatedProgress ? min(moveValue / max(moveGoal, 1), 1.0) : 0, + color: .red, + radius: size / 2) + } + + private var exerciseRing: some View { + ringTrack(progress: animatedProgress ? min(exerciseValue / max(exerciseGoal, 1), 1.0) : 0, + color: .green, + radius: size / 2 - ringWidth - spacing) + } + + private var standRing: some View { + ringTrack(progress: animatedProgress ? min(standValue / max(standGoal, 1), 1.0) : 0, + color: .cyan, + radius: size / 2 - 2 * (ringWidth + spacing)) + } + + private func ringTrack(progress: Double, color: Color, radius: CGFloat) -> some View { + Circle() + .trim(from: 0, to: progress) + .stroke(color, style: StrokeStyle(lineWidth: ringWidth, lineCap: .round)) + .rotationEffect(.degrees(-90)) + .frame(width: radius * 2, height: radius * 2) + .background( + Circle() + .stroke(color.opacity(0.15), lineWidth: ringWidth) + .frame(width: radius * 2, height: radius * 2) + ) + } + + private func ringLegend(value: Int, unit: String, label: LocalizedStringResource, color: Color) -> some View { + VStack(spacing: 2) { + Text("\(value)") + .font(.system(size: 18, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(color) + Text(unit) + .font(.caption2.weight(.semibold)) + .foregroundStyle(color.opacity(0.7)) + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } +} diff --git a/tabatago-swift/TabataGo/Views/Tabs/Activity/GlobalStatsCard.swift b/tabatago-swift/TabataGo/Views/Tabs/Activity/GlobalStatsCard.swift new file mode 100644 index 0000000..cb588f4 --- /dev/null +++ b/tabatago-swift/TabataGo/Views/Tabs/Activity/GlobalStatsCard.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct GlobalStatsCard: View { + let totalSessions: Int + let totalMinutes: Int + let totalCalories: Double + let currentStreak: Int + let longestStreak: Int + + var body: some View { + VStack(spacing: 16) { + HStack(spacing: 0) { + globalStat(value: "\(totalSessions)", label: L10n.activity.workouts, color: Theme.brand, icon: "flame.fill") + globalStat(value: "\(totalMinutes)", label: L10n.activity.minutes, color: Theme.rest, icon: "clock.fill") + globalStat(value: "\(Int(totalCalories))", label: "kcal", color: Theme.success, icon: "bolt.fill") + } + + Divider().opacity(0.3) + + HStack(spacing: 0) { + streakStat(value: currentStreak, label: L10n.activity.currentStreak, color: Theme.brand) + streakStat(value: longestStreak, label: L10n.activity.bestStreak, color: Theme.success) + } + } + .padding(16) + .glassCard() + } + + private func globalStat(value: String, label: LocalizedStringResource, color: Color, icon: String) -> some View { + VStack(spacing: 4) { + Image(systemName: icon) + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(color) + Text(value) + .font(.system(size: 22, weight: .bold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(.primary) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity) + } + + private func streakStat(value: Int, label: LocalizedStringResource, color: Color) -> some View { + HStack(spacing: 8) { + Image(systemName: "flame.fill") + .font(.system(size: 20)) + .foregroundStyle(color) + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .lastTextBaseline, spacing: 2) { + Text("\(value)") + .font(.system(size: 24, weight: .black, design: .rounded)) + .monospacedDigit() + .foregroundStyle(color) + Text(L10n.activity.days) + .font(.caption) + .foregroundStyle(.secondary) + } + Text(label) + .font(.caption2) + .foregroundStyle(.secondary) + } + Spacer(minLength: 0) + } + .frame(maxWidth: .infinity) + } +} diff --git a/tabatago-swift/TabataGo/Views/Tabs/Activity/WeeklySection.swift b/tabatago-swift/TabataGo/Views/Tabs/Activity/WeeklySection.swift new file mode 100644 index 0000000..3859f30 --- /dev/null +++ b/tabatago-swift/TabataGo/Views/Tabs/Activity/WeeklySection.swift @@ -0,0 +1,33 @@ +import SwiftUI + +struct WeeklySection: View { + let snapshot: HealthSnapshot? + let weeklyWorkouts: Int + let weeklyMinutes: Int + let weeklyCalories: Double + + var body: some View { + VStack(alignment: .leading, spacing: 14) { + Text(L10n.home.thisWeek) + .font(.title3.weight(.bold)) + .padding(.horizontal) + + ActivityRingView( + moveValue: snapshot?.activeCaloricBurn ?? weeklyCalories, + moveGoal: 600, + exerciseValue: snapshot?.exerciseMinutes ?? Double(weeklyMinutes), + exerciseGoal: 30, + standValue: Double(snapshot?.standHours ?? 0), + standGoal: 12 + ) + .frame(maxWidth: .infinity) + + HStack(spacing: 12) { + StatBadge(label: L10n.activity.workouts, value: "\(weeklyWorkouts)", color: Theme.brand, icon: "flame.fill") + StatBadge(label: L10n.activity.minutes, value: "\(weeklyMinutes)", color: Theme.rest, icon: "clock.fill") + StatBadge(label: "kcal", value: "\(Int(weeklyCalories))", color: Theme.success, icon: "bolt.fill") + } + .padding(.horizontal) + } + } +} diff --git a/tabatago-swift/TabataGo/Views/Tabs/Activity/WorkoutCalendarView.swift b/tabatago-swift/TabataGo/Views/Tabs/Activity/WorkoutCalendarView.swift new file mode 100644 index 0000000..20a4593 --- /dev/null +++ b/tabatago-swift/TabataGo/Views/Tabs/Activity/WorkoutCalendarView.swift @@ -0,0 +1,128 @@ +import SwiftUI + +struct WorkoutCalendarView: View { + let activeDays: Set + + @State private var displayedMonth = Date() + + private let calendar = Calendar.current + private let columns = Array(repeating: GridItem(.flexible(), spacing: 4), count: 7) + + var body: some View { + VStack(spacing: 12) { + monthHeader + weekdayHeaders + dayGrid + } + .padding(16) + .glassCard() + } + + private var monthHeader: some View { + HStack { + Button { withAnimation { displayedMonth = calendar.date(byAdding: .month, value: -1, to: displayedMonth) ?? displayedMonth } } label: { + Image(systemName: "chevron.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + } + + Spacer() + + Text(monthName) + .font(.headline) + .foregroundStyle(.primary) + + Spacer() + + Button { withAnimation { displayedMonth = calendar.date(byAdding: .month, value: 1, to: displayedMonth) ?? displayedMonth } } label: { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(.secondary) + } + } + } + + private var weekdayHeaders: some View { + HStack(spacing: 4) { + ForEach(weekdaySymbols, id: \.self) { symbol in + Text(symbol) + .font(.caption2.weight(.semibold)) + .foregroundStyle(.secondary) + .frame(maxWidth: .infinity) + } + } + } + + private var dayGrid: some View { + let days = daysInMonth() + let prefix = firstWeekdayOffset() + + return LazyVGrid(columns: columns, spacing: 4) { + ForEach(0.. some View { + let isToday = calendar.isDateInToday(day) + let isActive = activeDays.contains(calendar.dateComponents([.year, .month, .day], from: day)) + let dayNum = calendar.component(.day, from: day) + + return ZStack { + if isActive { + Circle() + .fill(Theme.brand.gradient) + .frame(width: 32, height: 32) + } else if isToday { + Circle() + .stroke(Theme.brand.opacity(0.5), lineWidth: 1.5) + .frame(width: 32, height: 32) + } + + Text("\(dayNum)") + .font(.system(size: 14, weight: isActive ? .bold : .regular, design: .rounded)) + .monospacedDigit() + .foregroundStyle(isActive ? .white : (isToday ? Theme.brand : .primary)) + } + .frame(height: 36) + } + + private var monthName: String { + let formatter = DateFormatter() + formatter.locale = Locale.current + formatter.dateFormat = "MMMM yyyy" + let str = formatter.string(from: displayedMonth) + return str.prefix(1).uppercased() + str.dropFirst() + } + + private var weekdaySymbols: [String] { + let formatter = DateFormatter() + formatter.locale = Locale.current + let symbols = formatter.veryShortWeekdaySymbols ?? [] + let first = calendar.firstWeekday + let reordered = Array(symbols[(first - 1).. [Date] { + guard let interval = calendar.dateInterval(of: .month, for: displayedMonth) else { return [] } + var days: [Date] = [] + var current = interval.start + while current < interval.end { + days.append(current) + current = calendar.date(byAdding: .day, value: 1, to: current) ?? current + } + return days + } + + private func firstWeekdayOffset() -> Int { + guard let interval = calendar.dateInterval(of: .month, for: displayedMonth), + let firstDay = calendar.dateInterval(of: .weekOfYear, for: interval.start)?.start else { return 0 } + let diff = calendar.dateComponents([.day], from: firstDay, to: interval.start).day ?? 0 + return max(0, diff) + } +} diff --git a/tabatago-swift/TabataGo/Views/Tabs/ActivityTab.swift b/tabatago-swift/TabataGo/Views/Tabs/ActivityTab.swift index 8e528ee..e3d0563 100644 --- a/tabatago-swift/TabataGo/Views/Tabs/ActivityTab.swift +++ b/tabatago-swift/TabataGo/Views/Tabs/ActivityTab.swift @@ -1,7 +1,6 @@ import SwiftUI import SwiftData -/// Activity tab — streak, workout history, HealthKit rings summary. struct ActivityTab: View { @Query(sort: \WorkoutSession.completedAt, order: .reverse) private var sessions: [WorkoutSession] @@ -12,37 +11,35 @@ struct ActivityTab: View { private var streak: (current: Int, longest: Int) { computeStreak(from: sessions) } private var snapshot: HealthSnapshot? { snapshots.first } private var weeklyCount: Int { countThisWeek(sessions) } + private var activeDays: Set { + let calendar = Calendar.current + return Set(sessions.map { calendar.dateComponents([.year, .month, .day], from: $0.completedAt) }) + } var body: some View { NavigationStack { ScrollView { - VStack(spacing: 20) { + VStack(spacing: 24) { - // ── Streak Banner ────────────────────────────── - StreakBanner(current: streak.current, longest: streak.longest) + GlobalStatsCard( + totalSessions: sessions.count, + totalMinutes: totalMinutes, + totalCalories: totalCalories, + currentStreak: streak.current, + longestStreak: streak.longest + ) + .padding(.horizontal) + + WeeklySection( + snapshot: snapshot, + weeklyWorkouts: weeklyCount, + weeklyMinutes: weeklyMinutes, + weeklyCalories: weeklyCalories + ) + + WorkoutCalendarView(activeDays: activeDays) .padding(.horizontal) - // ── HealthKit Rings ──────────────────────────── - if let snap = snapshot { - HealthRingsCard(snapshot: snap) - .padding(.horizontal) - } - - // ── Weekly Summary ───────────────────────────── - VStack(alignment: .leading, spacing: 12) { - Text(L10n.home.thisWeek) - .font(.title3.weight(.bold)) - .padding(.horizontal) - - HStack(spacing: 12) { - StatBadge(label: L10n.activity.workouts, value: "\(weeklyCount)", color: Theme.brand, icon: "flame.fill") - StatBadge(label: L10n.activity.minutes, value: "\(weeklyMinutes)", color: Theme.rest, icon: "clock.fill") - StatBadge(label: L10n.activity.calories, value: "\(Int(weeklyCalories))", color: Theme.success, icon: "bolt.fill") - } - .padding(.horizontal) - } - - // ── Workout History ──────────────────────────── if !sessions.isEmpty { VStack(alignment: .leading, spacing: 12) { Text(L10n.activity.history) @@ -72,6 +69,14 @@ struct ActivityTab: View { } } + private var totalMinutes: Int { + sessions.reduce(0) { $0 + $1.durationSeconds / 60 } + } + + private var totalCalories: Double { + sessions.reduce(0) { $0 + $1.caloriesBurned } + } + private var weeklyMinutes: Int { countThisWeek(sessions, value: { $0.durationSeconds / 60 }) } @@ -82,139 +87,13 @@ struct ActivityTab: View { } } -// ─── Sub-components ─────────────────────────────────────────────── - -struct StreakBanner: View { - let current: Int - let longest: Int - - var body: some View { - HStack(spacing: 0) { - // Current streak - VStack(spacing: 4) { - HStack(alignment: .lastTextBaseline, spacing: 4) { - Text("\(current)") - .font(.system(size: 52, weight: .black, design: .rounded)) - .monospacedDigit() - .foregroundStyle(Theme.brand) - Text(L10n.activity.days) - .font(.headline) - .foregroundStyle(.secondary) - } - Text(L10n.activity.currentStreak) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - - Divider().frame(height: 50) - - // Longest streak - VStack(spacing: 4) { - HStack(alignment: .lastTextBaseline, spacing: 4) { - Text("\(longest)") - .font(.system(size: 52, weight: .black, design: .rounded)) - .monospacedDigit() - .foregroundStyle(Theme.success) - Text(L10n.activity.days) - .font(.headline) - .foregroundStyle(.secondary) - } - Text(L10n.activity.bestStreak) - .font(.subheadline) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - } - .padding(.vertical, 16) - .glassCard() - } -} - -struct HealthRingsCard: View { - let snapshot: HealthSnapshot - - var body: some View { - VStack(alignment: .leading, spacing: 14) { - HStack { - Image(systemName: "heart.fill") - .foregroundStyle(.red) - Text(L10n.health.appleHealth) - .font(.headline.weight(.semibold)) - Spacer() - Text(L10n.health.today) - .font(.caption) - .foregroundStyle(.secondary) - } - - HStack(spacing: 12) { - HealthRingStat( - label: L10n.health.move, - value: "\(Int(snapshot.activeCaloricBurn))", - unit: "kcal", - color: .red - ) - HealthRingStat( - label: L10n.health.exercise, - value: "\(Int(snapshot.exerciseMinutes))", - unit: "min", - color: .green - ) - HealthRingStat( - label: L10n.health.stand, - value: "\(snapshot.standHours)", - unit: "hrs", - color: .cyan - ) - } - - if let hr = snapshot.restingHeartRate { - Divider() - HStack { - Label("\(Int(hr)) bpm", systemImage: "waveform.path.ecg") - .font(.subheadline) - .foregroundStyle(.secondary) - Spacer() - Text(L10n.health.restingHR) - .font(.caption) - .foregroundStyle(.tertiary) - } - } - } - .padding(16) - .glassCard() - } -} - -struct HealthRingStat: View { - let label: LocalizedStringResource - let value: String - let unit: String - let color: Color - - var body: some View { - VStack(spacing: 4) { - Text(value) - .font(.system(size: 24, weight: .bold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(color) - Text(unit) - .font(.caption2.weight(.semibold)) - .foregroundStyle(color.opacity(0.7)) - Text(label) - .font(.caption2) - .foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity) - } -} +// ─── Sub-components (kept for history list) ───────────────────── struct SessionHistoryRow: View { let session: WorkoutSession var body: some View { HStack(spacing: 14) { - // Zone indicator Circle() .fill(Theme.zoneColor(session.bodyZone)) .frame(width: 10, height: 10)