From b0d364eca2f96e80a19c14ebe4578ae0cd2cf4df Mon Sep 17 00:00:00 2001 From: Millian Lamiaux Date: Sat, 25 Apr 2026 23:51:46 +0200 Subject: [PATCH] feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift ## What changed ### Player Redesign (video-first layout) - New compact timer ring (110pt) with phase label, replaces 240pt ring - Auto-hide top bar with block progress dots (3s auto-dismiss) - Expandable now-playing music pill with skip control - Bottom control bar with heart rate, play/pause, and skip - Exercise caption with 'Next' preview during rest phases - Compact round counter (capsule dots) ### Dynamic Island & Live Activities - WorkoutLiveActivity widget: expanded, compact, and minimal views - Phase-colored timers with Text(timerInterval:) countdown - Shows exercise name, round progress, heart rate, music track - MusicLiveActivity: standalone music now-playing widget - LiveActivityMusicBars animated component - Deep link from Dynamic Island back to app ### Timer Drift Fix (critical) - Store a stable phaseEndDate once per phase instead of recalculating Date() + timeRemaining on every update - Prevents dynamic island countdown from rubber-banding due to 5-second periodic update recalculation drift - Reset phaseEndDate on phase change and resume from pause - Guard Live Activity updates behind vm.isRunning to prevent premature creation when music track loads before workout start - Fixes timer showing 0 in Dynamic Island when expanding from home screen ### New PlayerViewModel timer engine - Full phase support: prep, warmup, work, rest, interBlockRest, cooldown, complete - 1-second countdown with audio cues at 3-2-1 - Phase transitions with spring animation and haptics - HealthKit live session integration - Workout session recording with completion ### Music Service - New MusicPlayerViewModel with vibe-based playlist loading - Track info exposed for Dynamic Island display - Skip track support from Dynamic Island notification action - Automatic play/pause based on phase and running state ### Additional - ZoneHighlightIcon component for HomeTab zone cards - Updated watchOS localizations with complication strings - Info.plist updated for widget extension --- .../TabataGo.xcodeproj/project.pbxproj | 162 +++- tabatago-swift/TabataGo/App/TabataGoApp.swift | 9 + .../Models/MusicActivityAttributes.swift | 9 + .../Models/WorkoutActivityAttributes.swift | 16 + tabatago-swift/TabataGo/Resources/Info.plist | 11 + .../TabataGo/Resources/Localizable.xcstrings | 33 +- .../TabataGo/Services/MusicService.swift | 2 +- tabatago-swift/TabataGo/Theme/Theme.swift | 3 + .../TabataGo/ViewModels/PlayerViewModel.swift | 11 +- .../Views/Components/ZoneHighlightIcon.swift | 131 +++ .../TabataGo/Views/Player/PlayerView.swift | 654 +++++++++++---- .../TabataGo/Views/Tabs/HomeTab.swift | 13 +- .../Resources/Localizable.xcstrings | 789 ++++++++++++++---- tabatago-swift/TabataGoWidget/Info.plist | 31 + .../TabataGoWidget/MusicLiveActivity.swift | 135 +++ .../TabataGoWidget/TabataGoWidgetBundle.swift | 10 + .../TabataGoWidget/WorkoutLiveActivity.swift | 155 ++++ 17 files changed, 1797 insertions(+), 377 deletions(-) create mode 100644 tabatago-swift/TabataGo/Models/MusicActivityAttributes.swift create mode 100644 tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift create mode 100644 tabatago-swift/TabataGo/Views/Components/ZoneHighlightIcon.swift create mode 100644 tabatago-swift/TabataGoWidget/Info.plist create mode 100644 tabatago-swift/TabataGoWidget/MusicLiveActivity.swift create mode 100644 tabatago-swift/TabataGoWidget/TabataGoWidgetBundle.swift create mode 100644 tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift diff --git a/tabatago-swift/TabataGo.xcodeproj/project.pbxproj b/tabatago-swift/TabataGo.xcodeproj/project.pbxproj index 5e921c2..c957a5b 100644 --- a/tabatago-swift/TabataGo.xcodeproj/project.pbxproj +++ b/tabatago-swift/TabataGo.xcodeproj/project.pbxproj @@ -30,6 +30,8 @@ 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 */; }; + 5B01ABC32F9B8FFD006E707D /* MusicActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B01ABC22F9B8FFD006E707D /* MusicActivityAttributes.swift */; }; + 5B01ABC82F9B90AF006E707D /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 5B01ABC62F9B909E006E707D /* ActivityKit.framework */; }; 5CE2F2210BEF17AC304F2AC2 /* HealthSnapshot.swift in Sources */ = {isa = PBXBuildFile; fileRef = F1DE8A4DAD846A879B8ED379 /* HealthSnapshot.swift */; }; 60503F963221C7FCF719C493 /* ActivityTab.swift in Sources */ = {isa = PBXBuildFile; fileRef = 84123E854DE0BF3E0D4F0912 /* ActivityTab.swift */; }; 6060D95D485E4188EAABDDED /* WatchRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8AEC37E6361DC4C7AE326139 /* WatchRootView.swift */; }; @@ -68,9 +70,17 @@ E4ED0B8CABBD3502EA468F21 /* HomeViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = AD1AF33C89F42294599C369A /* HomeViewModel.swift */; }; EDAFF4CD2ACE82CC2C097B3C /* PaywallView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DA21206AD91A5F95926EEA05 /* PaywallView.swift */; }; EE6C591611D52C36ED5E03C6 /* AppState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9EC19129CD3C493C8B2AEFA8 /* AppState.swift */; }; + F2E1D0C9B8A7F6E5D4C3B2A1 /* ZoneHighlightIcon.swift in Sources */ = {isa = PBXBuildFile; fileRef = A1B2C3D4E5F6A7B8C9D0E1F2 /* ZoneHighlightIcon.swift */; }; F80248DC6213339BC8F9C9A2 /* TabataGoComplication.swift in Sources */ = {isa = PBXBuildFile; fileRef = CDA5D50FD057EF30BE7915F5 /* TabataGoComplication.swift */; }; FD47EC832E23E0AF1D6FFE47 /* PolicyViews.swift in Sources */ = {isa = PBXBuildFile; fileRef = 525C7E8EC6EF89E00D34672E /* PolicyViews.swift */; }; FE14257B8CFFDC47C72AE079 /* HealthViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63599808389B70FC2F6A43C3 /* HealthViewModel.swift */; }; + AAA001AAA001AAA001AAA001 /* MusicActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5B01ABC22F9B8FFD006E707D /* MusicActivityAttributes.swift */; }; + AAA002AAA002AAA002AAA002 /* MusicLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB001BBB001BBB001BBB001 /* MusicLiveActivity.swift */; }; + AAA003AAA003AAA003AAA003 /* TabataGoWidgetBundle.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB002BBB002BBB002BBB002 /* TabataGoWidgetBundle.swift */; }; + AAA004AAA004AAA004AAA004 /* TabataGoWidget.appex in Embed App Extensions */ = {isa = PBXBuildFile; fileRef = BBB004BBB004BBB004BBB004 /* TabataGoWidget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + AAA005AAA005AAA005AAA005 /* WorkoutActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB005BBB005BBB005BBB005 /* WorkoutActivityAttributes.swift */; }; + AAA006AAA006AAA006AAA006 /* WorkoutActivityAttributes.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB005BBB005BBB005BBB005 /* WorkoutActivityAttributes.swift */; }; + AAA007AAA007AAA007AAA007 /* WorkoutLiveActivity.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBB006BBB006BBB006BBB006 /* WorkoutLiveActivity.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -102,9 +112,26 @@ remoteGlobalIDString = 92991789C3A5B2A5FACF07A1; remoteInfo = TabataGo; }; + FFF001FFF001FFF001FFF001 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 5D5CB9093007DF74EBDE3C98 /* Project object */; + proxyType = 1; + remoteGlobalIDString = EEE001EEE001EEE001EEE001; + remoteInfo = TabataGoWidget; + }; /* End PBXContainerItemProxy section */ -/* Begin PBXCopyFilesBuildPhase section */ + DDD002DDD002DDD002DDD002 /* Embed App Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + AAA004AAA004AAA004AAA004 /* TabataGoWidget.appex in Embed App Extensions */, + ); + name = "Embed App Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; 76FE977236B376F31232D242 /* Embed Watch Content */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -152,6 +179,8 @@ 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 = ""; }; + 5B01ABC22F9B8FFD006E707D /* MusicActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicActivityAttributes.swift; sourceTree = ""; }; + 5B01ABC62F9B909E006E707D /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS26.4.sdk/System/Library/Frameworks/ActivityKit.framework; sourceTree = DEVELOPER_DIR; }; 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 = ""; }; @@ -171,6 +200,7 @@ 9B5DFE227FDB6400C8D7A4A4 /* ProfileTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTab.swift; sourceTree = ""; }; 9CE731C42C570A89F2C6F613 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = ""; }; 9EC19129CD3C493C8B2AEFA8 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = ""; }; + A1B2C3D4E5F6A7B8C9D0E1F2 /* ZoneHighlightIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoneHighlightIcon.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 = ""; }; @@ -198,6 +228,12 @@ 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 = ""; }; + BBB001BBB001BBB001BBB001 /* MusicLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicLiveActivity.swift; sourceTree = ""; }; + BBB002BBB002BBB002BBB002 /* TabataGoWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabataGoWidgetBundle.swift; sourceTree = ""; }; + BBB003BBB003BBB003BBB003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + BBB004BBB004BBB004BBB004 /* TabataGoWidget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = TabataGoWidget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + BBB005BBB005BBB005BBB005 /* WorkoutActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutActivityAttributes.swift; sourceTree = ""; }; + BBB006BBB006BBB006BBB006 /* WorkoutLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutLiveActivity.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -207,6 +243,7 @@ files = ( 20FD0BC9A6E01E8EA182E030 /* Supabase in Frameworks */, CA45F50F953886A372F22AA4 /* RevenueCat in Frameworks */, + 5B01ABC82F9B90AF006E707D /* ActivityKit.framework in Frameworks */, 80214BDEB93076416728E9BD /* PostHog in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -270,6 +307,8 @@ DB98CF3F29FCFCDE4D54B1A8 /* TabataGoTests */, 479C55D953F3D9AA136DE1BA /* TabataGoUITests */, 66E9DD477B9F90EF36226076 /* TabataGoWatch */, + CCC001CCC001CCC001CCC001 /* TabataGoWidget */, + 5B01ABC52F9B909E006E707D /* Frameworks */, F992A53DB1C399DCFE3C8BF2 /* Products */, ); sourceTree = ""; @@ -317,6 +356,14 @@ path = Onboarding; sourceTree = ""; }; + 5B01ABC52F9B909E006E707D /* Frameworks */ = { + isa = PBXGroup; + children = ( + 5B01ABC62F9B909E006E707D /* ActivityKit.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; 66E9DD477B9F90EF36226076 /* TabataGoWatch */ = { isa = PBXGroup; children = ( @@ -379,6 +426,7 @@ A4BD547C912B1970BB98FC73 /* Player */, C9F93E0937EB534BC24EF2FA /* Programs */, EEAEB608A9FC8E5981B57A89 /* Settings */, + AA11BB22CC33DD44EE55FF66 /* Components */, 8B90DED418BDA3697748C37D /* Tabs */, ); path = Views; @@ -402,6 +450,14 @@ path = Utilities; sourceTree = ""; }; + AA11BB22CC33DD44EE55FF66 /* Components */ = { + isa = PBXGroup; + children = ( + A1B2C3D4E5F6A7B8C9D0E1F2 /* ZoneHighlightIcon.swift */, + ); + path = Components; + sourceTree = ""; + }; BD69946901F21DE2BEE0D8D9 /* TabataGo */ = { isa = PBXGroup; children = ( @@ -438,6 +494,17 @@ path = ViewModels; sourceTree = ""; }; + CCC001CCC001CCC001CCC001 /* TabataGoWidget */ = { + isa = PBXGroup; + children = ( + BBB003BBB003BBB003BBB003 /* Info.plist */, + BBB001BBB001BBB001BBB001 /* MusicLiveActivity.swift */, + BBB002BBB002BBB002BBB002 /* TabataGoWidgetBundle.swift */, + BBB006BBB006BBB006BBB006 /* WorkoutLiveActivity.swift */, + ); + path = TabataGoWidget; + sourceTree = ""; + }; CE5E34713E694FAEED0F3480 /* Utilities */ = { isa = PBXGroup; children = ( @@ -485,6 +552,8 @@ 7FE34000653EE789117CE9D9 /* UserProfile.swift */, B6E64CFB210A549AC85F878D /* WorkoutProgram.swift */, BBBBFC7FC6A52DE9908EE4A6 /* WorkoutSession.swift */, + 5B01ABC22F9B8FFD006E707D /* MusicActivityAttributes.swift */, + BBB005BBB005BBB005BBB005 /* WorkoutActivityAttributes.swift */, ); path = Models; sourceTree = ""; @@ -516,6 +585,7 @@ B7EDA5BF7F25E3279A4B1A61 /* TabataGoUITests.xctest */, 484865AEFA8CCD26C4AE7F73 /* TabataGoWatch.app */, 255972F9906563A0921C47C0 /* TabataGoWatchWidget.appex */, + BBB004BBB004BBB004BBB004 /* TabataGoWidget.appex */, ); name = Products; sourceTree = ""; @@ -560,6 +630,23 @@ productReference = 255972F9906563A0921C47C0 /* TabataGoWatchWidget.appex */; productType = "com.apple.product-type.app-extension"; }; + EEE001EEE001EEE001EEE001 /* TabataGoWidget */ = { + isa = PBXNativeTarget; + buildConfigurationList = III001III001III001III001 /* Build configuration list for PBXNativeTarget "TabataGoWidget" */; + buildPhases = ( + DDD001DDD001DDD001DDD001 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = TabataGoWidget; + packageProductDependencies = ( + ); + productName = TabataGoWidget; + productReference = BBB004BBB004BBB004BBB004 /* TabataGoWidget.appex */; + productType = "com.apple.product-type.app-extension"; + }; 92991789C3A5B2A5FACF07A1 /* TabataGo */ = { isa = PBXNativeTarget; buildConfigurationList = D920067B8306F17FB19B987C /* Build configuration list for PBXNativeTarget "TabataGo" */; @@ -568,11 +655,13 @@ 3D4690E104FE866070533A03 /* Resources */, 078CF2C46E747BF4F8A74030 /* Frameworks */, 76FE977236B376F31232D242 /* Embed Watch Content */, + DDD002DDD002DDD002DDD002 /* Embed App Extensions */, ); buildRules = ( ); dependencies = ( 08E32451A0A32FD65422174D /* PBXTargetDependency */, + GGG001GGG001GGG001GGG001 /* PBXTargetDependency */, ); name = TabataGo; packageProductDependencies = ( @@ -661,6 +750,7 @@ D77CBB3569E06BDB4239862D /* TabataGoUITests */, 3945C3998B4B66F30759718C /* TabataGoWatch */, 90BAF2DB5D7456CD45975E26 /* TabataGoWatchWidget */, + EEE001EEE001EEE001EEE001 /* TabataGoWidget */, ); }; /* End PBXProject section */ @@ -734,8 +824,10 @@ 53FDC12EFCD8159045C105C0 /* HealthKitService.swift in Sources */, 5CE2F2210BEF17AC304F2AC2 /* HealthSnapshot.swift in Sources */, FE14257B8CFFDC47C72AE079 /* HealthViewModel.swift in Sources */, + F2E1D0C9B8A7F6E5D4C3B2A1 /* ZoneHighlightIcon.swift in Sources */, 59B482DEBAA43EE5F24B883D /* HomeTab.swift in Sources */, E4ED0B8CABBD3502EA468F21 /* HomeViewModel.swift in Sources */, + 5B01ABC32F9B8FFD006E707D /* MusicActivityAttributes.swift in Sources */, 14EC768D950BC071AFBEFDF2 /* MainTabView.swift in Sources */, 5A402D7E31059AB7107B625C /* MusicPlayerViewModel.swift in Sources */, 09285D4F326731E9A27827B2 /* MusicService.swift in Sources */, @@ -766,6 +858,19 @@ 996E613C0A9906AB88D2AEB6 /* WorkoutCalendarView.swift in Sources */, 1955D0D74D9B09D10705104C /* WorkoutProgram.swift in Sources */, 192F8CFFE1888005ABF339E8 /* WorkoutSession.swift in Sources */, + AAA005AAA005AAA005AAA005 /* WorkoutActivityAttributes.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + DDD001DDD001DDD001DDD001 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AAA001AAA001AAA001AAA001 /* MusicActivityAttributes.swift in Sources */, + AAA002AAA002AAA002AAA002 /* MusicLiveActivity.swift in Sources */, + AAA003AAA003AAA003AAA003 /* TabataGoWidgetBundle.swift in Sources */, + AAA006AAA006AAA006AAA006 /* WorkoutActivityAttributes.swift in Sources */, + AAA007AAA007AAA007AAA007 /* WorkoutLiveActivity.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -800,6 +905,11 @@ target = 90BAF2DB5D7456CD45975E26 /* TabataGoWatchWidget */; targetProxy = D329F349FC6AF0E2D3C89FD3 /* PBXContainerItemProxy */; }; + GGG001GGG001GGG001GGG001 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = EEE001EEE001EEE001EEE001 /* TabataGoWidget */; + targetProxy = FFF001FFF001FFF001FFF001 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -1102,6 +1212,47 @@ }; /* End XCBuildConfiguration section */ +/* Begin XCConfigurationList section */ + HHH001HHH001HHH001HHH001 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = 2MJF39L8VY; + INFOPLIST_FILE = TabataGoWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.tabatago.app.widget; + SKIP_INSTALL = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + HHH002HHH002HHH002HHH002 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_IDENTITY = "iPhone Developer"; + DEVELOPMENT_TEAM = 2MJF39L8VY; + INFOPLIST_FILE = TabataGoWidget/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 26.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.tabatago.app.widget; + SKIP_INSTALL = YES; + SDKROOT = iphoneos; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + /* Begin XCConfigurationList section */ 569F5B8F2ACCAC8356B6D8A0 /* Build configuration list for PBXNativeTarget "TabataGoWatchWidget" */ = { isa = XCConfigurationList; @@ -1157,6 +1308,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + III001III001III001III001 /* Build configuration list for PBXNativeTarget "TabataGoWidget" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + HHH001HHH001HHH001HHH001 /* Debug */, + HHH002HHH002HHH002HHH002 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ /* Begin XCRemoteSwiftPackageReference section */ diff --git a/tabatago-swift/TabataGo/App/TabataGoApp.swift b/tabatago-swift/TabataGo/App/TabataGoApp.swift index cfdb0f9..4339fd7 100644 --- a/tabatago-swift/TabataGo/App/TabataGoApp.swift +++ b/tabatago-swift/TabataGo/App/TabataGoApp.swift @@ -1,6 +1,10 @@ import SwiftUI import SwiftData +extension Notification.Name { + static let skipTrackFromActivity = Notification.Name("skipTrackFromActivity") +} + @main struct TabataGoApp: App { @@ -14,6 +18,11 @@ struct TabataGoApp: App { .task { await appState.bootstrap() } + .onOpenURL { url in + if url.scheme == "tabatago", url.host == "skipTrack" { + NotificationCenter.default.post(name: .skipTrackFromActivity, object: nil) + } + } } } } diff --git a/tabatago-swift/TabataGo/Models/MusicActivityAttributes.swift b/tabatago-swift/TabataGo/Models/MusicActivityAttributes.swift new file mode 100644 index 0000000..fcd0473 --- /dev/null +++ b/tabatago-swift/TabataGo/Models/MusicActivityAttributes.swift @@ -0,0 +1,9 @@ +import ActivityKit + +struct MusicActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var title: String + var artist: String + var isPlaying: Bool + } +} diff --git a/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift b/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift new file mode 100644 index 0000000..91c65c9 --- /dev/null +++ b/tabatago-swift/TabataGo/Models/WorkoutActivityAttributes.swift @@ -0,0 +1,16 @@ +import ActivityKit +import Foundation + +struct WorkoutActivityAttributes: ActivityAttributes { + public struct ContentState: Codable, Hashable { + var exerciseName: String + var phase: String + var phaseEndDate: Date + var roundCurrent: Int + var roundTotal: Int + var heartRate: Double + var trackTitle: String + var trackArtist: String + var isPlaying: Bool + } +} diff --git a/tabatago-swift/TabataGo/Resources/Info.plist b/tabatago-swift/TabataGo/Resources/Info.plist index bb6a0dc..ca35efb 100644 --- a/tabatago-swift/TabataGo/Resources/Info.plist +++ b/tabatago-swift/TabataGo/Resources/Info.plist @@ -20,12 +20,23 @@ 1.0 CFBundleVersion 1 + CFBundleURLTypes + + + CFBundleURLSchemes + + tabatago + + + NSHealthShareUsageDescription TabataGo reads your health data to show fitness stats and personalize your workouts. NSHealthUpdateUsageDescription TabataGo saves your Tabata workouts to Apple Health to track calories, heart rate, and contribute to your Activity Rings. NSMotionUsageDescription TabataGo uses motion data to improve calorie estimates during workouts. + NSSupportsLiveActivities + POSTHOG_API_KEY $(POSTHOG_API_KEY) REVENUECAT_API_KEY diff --git a/tabatago-swift/TabataGo/Resources/Localizable.xcstrings b/tabatago-swift/TabataGo/Resources/Localizable.xcstrings index 9b71cdf..1cdc43c 100644 --- a/tabatago-swift/TabataGo/Resources/Localizable.xcstrings +++ b/tabatago-swift/TabataGo/Resources/Localizable.xcstrings @@ -9,9 +9,6 @@ }, "%@ / year — save 40%%" : { - }, - "%@ bpm" : { - }, "%@ kcal" : { @@ -2202,6 +2199,9 @@ } } } + }, + "Next" : { + }, "No workouts yet" : { "extractionState" : "manual", @@ -5725,6 +5725,9 @@ } } } + }, + "Skip" : { + }, "Subscription purchases are handled by Apple's App Store and RevenueCat. We do not store your payment information." : { @@ -6284,25 +6287,25 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Ganzkörpertraining von Kopf bis Fuß" + "value" : "Zielt auf alle großen Muskelgruppen — das ultimative Ganzkörper-Tabata-Workout" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Total body burn, head to toe" + "value" : "Targets every major muscle group — the ultimate full-body Tabata burn" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Quema total del cuerpo, de pies a cabeza" + "value" : "Dirige a todos los grupos musculares principales — la quema Tabata de cuerpo completo definitiva" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Brûlure totale du corps, de la tête aux pieds" + "value" : "Cible tous les groupes musculaires — le brûleur Tabata corps entier ultime" } } } @@ -6342,25 +6345,25 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Beine, Gesäß und Körpermitte" + "value" : "Zielt auf Quads, Gesäß, Beinbeuger und Waden mit explosiven Intervallen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Legs, glutes & core stability" + "value" : "Targets quads, glutes, hamstrings & calves with explosive intervals" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Piernas, glúteos y estabilidad del core" + "value" : "Dirige a cuádriceps, glúteos, isquiotibiales y pantorrillas con intervalos explosivos" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Jambes, fessiers et gainage" + "value" : "Cible quadriceps, fessiers, ischio-jambiers et mollets avec des intervals explosifs" } } } @@ -6400,25 +6403,25 @@ "de" : { "stringUnit" : { "state" : "translated", - "value" : "Arme, Brust, Schultern und Rücken" + "value" : "Zielt auf Bizeps, Schultern, Brust und Rücken mit High-Intensity-Intervallen" } }, "en" : { "stringUnit" : { "state" : "translated", - "value" : "Arms, chest, shoulders & back" + "value" : "Targets biceps, shoulders, chest & back with high-intensity intervals" } }, "es" : { "stringUnit" : { "state" : "translated", - "value" : "Brazos, pecho, hombros y espalda" + "value" : "Dirige a bíceps, hombros, pecho y espalda con intervalos de alta intensidad" } }, "fr" : { "stringUnit" : { "state" : "translated", - "value" : "Bras, poitrine, épaules et dos" + "value" : "Cible biceps, épaules, poitrine et dos avec des intervals haute intensité" } } } diff --git a/tabatago-swift/TabataGo/Services/MusicService.swift b/tabatago-swift/TabataGo/Services/MusicService.swift index 7319ff0..957c2f0 100644 --- a/tabatago-swift/TabataGo/Services/MusicService.swift +++ b/tabatago-swift/TabataGo/Services/MusicService.swift @@ -110,7 +110,7 @@ actor MusicService { let parts = raw.components(separatedBy: " - ").map { $0.trimmingCharacters(in: .whitespaces) } return (parts[0], parts.dropFirst().joined(separator: " - ")) } - return ("YouTube Music", raw) + return ("", raw) } // ─── Mock Tracks ───────────────────────────────────────────── diff --git a/tabatago-swift/TabataGo/Theme/Theme.swift b/tabatago-swift/TabataGo/Theme/Theme.swift index 8524fe6..6918baf 100644 --- a/tabatago-swift/TabataGo/Theme/Theme.swift +++ b/tabatago-swift/TabataGo/Theme/Theme.swift @@ -111,8 +111,11 @@ enum Theme { .monospacedDigit() static let timerSmallFont = Font.system(size: 60, weight: .bold, design: .rounded) .monospacedDigit() + static let timerCompactFont = Font.system(size: 42, weight: .bold, design: .rounded) + .monospacedDigit() static let roundFont = Font.system(size: 22, weight: .semibold, design: .rounded) static let phaseFont = Font.system(size: 18, weight: .bold, design: .rounded) + static let phaseCompactFont = Font.system(size: 11, weight: .bold, design: .rounded) } // ─── Glass Effect Modifier ──────────────────────────────────────── diff --git a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift index 0d53161..45fe3a6 100644 --- a/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift +++ b/tabatago-swift/TabataGo/ViewModels/PlayerViewModel.swift @@ -56,6 +56,15 @@ final class PlayerViewModel: ObservableObject { return program.blocks[currentBlockIndex] } + var blockProgress: Double { + guard !program.blocks.isEmpty else { return 1 } + return Double(currentBlockIndex + 1) / Double(program.blocks.count) + } + + var isRestPhase: Bool { + phase == .rest || phase == .interBlockRest + } + private let audio = AudioService.shared private let haptics = UIImpactFeedbackGenerator(style: .rigid) private let softHaptics = UIImpactFeedbackGenerator(style: .soft) @@ -107,13 +116,13 @@ final class PlayerViewModel: ObservableObject { // Start HealthKit live session Task { try? await HealthKitService.shared.requestAuthorization() - try? await liveSession.start(startDate: startedAt!) liveSession.onHeartRateUpdate = { [weak self] hr in Task { @MainActor in self?.heartRate = hr } } liveSession.onCaloriesUpdate = { [weak self] cal in Task { @MainActor in self?.liveCalories = cal } } + try? await liveSession.start(startDate: startedAt!) } AnalyticsService.shared.workoutStarted( diff --git a/tabatago-swift/TabataGo/Views/Components/ZoneHighlightIcon.swift b/tabatago-swift/TabataGo/Views/Components/ZoneHighlightIcon.swift new file mode 100644 index 0000000..cec9253 --- /dev/null +++ b/tabatago-swift/TabataGo/Views/Components/ZoneHighlightIcon.swift @@ -0,0 +1,131 @@ +import SwiftUI + +struct BodySilhouetteShape: Shape { + func path(in rect: CGRect) -> Path { + let w = rect.width + let h = rect.height + let mx = w * 0.5 + var p = Path() + + let headCY = h * 0.09 + let headR = w * 0.12 + p.addEllipse(in: CGRect( + x: mx - headR, y: headCY - headR, + width: headR * 2, height: headR * 2 + )) + + let neckW = w * 0.06 + let neckTop = headCY + headR + let shoulderY = h * 0.20 + p.addRect(CGRect( + x: mx - neckW, y: neckTop, + width: neckW * 2, height: shoulderY - neckTop + )) + + let shoulderHW = w * 0.42 + let armOuterBotY = h * 0.44 + let armInnerBotY = h * 0.44 + let armOuterX = w * 0.06 + let armInnerX = w * 0.12 + + p.move(to: CGPoint(x: mx - neckW, y: shoulderY)) + p.addLine(to: CGPoint(x: mx - shoulderHW, y: shoulderY)) + p.addLine(to: CGPoint(x: mx - armOuterX, y: armOuterBotY)) + p.addLine(to: CGPoint(x: mx - armInnerX, y: armInnerBotY)) + p.closeSubpath() + + p.move(to: CGPoint(x: mx + neckW, y: shoulderY)) + p.addLine(to: CGPoint(x: mx + shoulderHW, y: shoulderY)) + p.addLine(to: CGPoint(x: mx + armOuterX, y: armOuterBotY)) + p.addLine(to: CGPoint(x: mx + armInnerX, y: armInnerBotY)) + p.closeSubpath() + + let torsoHW = w * 0.18 + let waistY = h * 0.46 + let waistHW = w * 0.15 + p.move(to: CGPoint(x: mx - torsoHW, y: shoulderY)) + p.addLine(to: CGPoint(x: mx + torsoHW, y: shoulderY)) + p.addLine(to: CGPoint(x: mx + waistHW, y: waistY)) + p.addLine(to: CGPoint(x: mx - waistHW, y: waistY)) + p.closeSubpath() + + let hipHW = w * 0.22 + let hipY = h * 0.52 + p.move(to: CGPoint(x: mx - waistHW, y: waistY)) + p.addLine(to: CGPoint(x: mx + waistHW, y: waistY)) + p.addLine(to: CGPoint(x: mx + hipHW, y: hipY)) + p.addLine(to: CGPoint(x: mx - hipHW, y: hipY)) + p.closeSubpath() + + let gap = w * 0.02 + let legBotY = h * 0.92 + let footH = h * 0.05 + let footExtra = w * 0.04 + + p.move(to: CGPoint(x: mx - hipHW, y: hipY)) + p.addLine(to: CGPoint(x: mx - gap, y: hipY)) + p.addLine(to: CGPoint(x: mx - gap, y: legBotY)) + p.addLine(to: CGPoint(x: mx - hipHW - footExtra, y: legBotY + footH)) + p.addLine(to: CGPoint(x: mx - hipHW, y: legBotY)) + p.closeSubpath() + + p.move(to: CGPoint(x: mx + gap, y: hipY)) + p.addLine(to: CGPoint(x: mx + hipHW, y: hipY)) + p.addLine(to: CGPoint(x: mx + hipHW, y: legBotY)) + p.addLine(to: CGPoint(x: mx + hipHW + footExtra, y: legBotY + footH)) + p.addLine(to: CGPoint(x: mx + gap, y: legBotY)) + p.closeSubpath() + + return p + } +} + +struct ZoneHighlightIcon: View { + let zone: String + + private var waistFraction: CGFloat { 0.50 } + + var body: some View { + let shape = BodySilhouetteShape() + ZStack { + shape + .fill(.white.opacity(0.12)) + + shape + .fill(zoneGradient) + .mask(zoneMask) + } + .frame(width: 56, height: 80) + } + + private var zoneGradient: LinearGradient { + switch zone { + case "upper-body": + LinearGradient(colors: [.orange, .red.opacity(0.8)], startPoint: .top, endPoint: .bottom) + case "lower-body": + LinearGradient(colors: [.blue, .purple.opacity(0.8)], startPoint: .top, endPoint: .bottom) + case "full-body": + LinearGradient(colors: [Theme.brand, .purple], startPoint: .top, endPoint: .bottom) + default: + LinearGradient(colors: [.gray, .secondary], startPoint: .top, endPoint: .bottom) + } + } + + @ViewBuilder + private var zoneMask: some View { + switch zone { + case "upper-body": + Rectangle() + .frame(height: 80 * waistFraction) + .frame(maxHeight: .infinity, alignment: .top) + case "lower-body": + Rectangle() + .frame(height: 80 * waistFraction) + .frame(maxHeight: .infinity, alignment: .bottom) + case "full-body": + Rectangle() + default: + Rectangle() + } + } +} diff --git a/tabatago-swift/TabataGo/Views/Player/PlayerView.swift b/tabatago-swift/TabataGo/Views/Player/PlayerView.swift index 242d2c5..176a0c6 100644 --- a/tabatago-swift/TabataGo/Views/Player/PlayerView.swift +++ b/tabatago-swift/TabataGo/Views/Player/PlayerView.swift @@ -1,6 +1,7 @@ import SwiftUI +@preconcurrency import ActivityKit -/// Full-screen Tabata workout player with Liquid Glass timer. +/// Full-screen Tabata workout player — video-first layout with overlay timer. struct PlayerView: View { let program: WorkoutProgram @StateObject private var vm: PlayerViewModel @@ -8,6 +9,12 @@ struct PlayerView: View { @Environment(\.dismiss) private var dismiss @Environment(\.modelContext) private var context + @State private var topBarVisible = true + @State private var nowPlayingExpanded = false + @State private var autoHideTask: Task? + @State private var workoutActivity: Activity? + @State private var phaseEndDate: Date? + init(program: WorkoutProgram) { self.program = program _vm = StateObject(wrappedValue: PlayerViewModel(program: program)) @@ -18,125 +25,255 @@ struct PlayerView: View { var body: some View { NavigationStack { ZStack { - // ── Animated Phase Background ────────────────────────── - PhaseBackground(phase: vm.phase) - .ignoresSafeArea() + // ══════════════════════════════════════════════════ + // Layer 0 — Full-screen background (video placeholder) + // ══════════════════════════════════════════════════ + PhaseBackground(phase: vm.phase) + .ignoresSafeArea() - // ── Content ──────────────────────────────────────────── - VStack(spacing: 0) { - PlayerTopBar( - title: program.titleEn, - block: vm.currentBlockIndex + 1, - totalBlocks: program.blocks.count, - onClose: { vm.showExitConfirmation = true } - ) - - Spacer() - - // ── Exercise Label ───────────────────────────────── - if let exercise = vm.currentExercise { - Text(exercise.nameEn) - .font(.system(size: 32, weight: .bold, design: .rounded)) - .foregroundStyle(.white) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - .transition(.opacity.combined(with: .scale(scale: 0.9))) + // ══════════════════════════════════════════════════ + // Layer 1 — Bottom gradient sheen for legibility + // ══════════════════════════════════════════════════ + VStack { + Spacer() + LinearGradient( + colors: [.clear, .black.opacity(0.65)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 280) + .allowsHitTesting(false) } - // ── Phase Badge ──────────────────────────────────── - Text(Theme.phaseLabel(vm.phase)) - .font(Theme.phaseFont) - .foregroundStyle(.white.opacity(0.85)) - .padding(.top, 8) + // ══════════════════════════════════════════════════ + // Layer 2 — Tap to reveal top bar + // ══════════════════════════════════════════════════ + Color.clear + .contentShape(Rectangle()) + .onTapGesture { showTopBar() } + .allowsHitTesting(!topBarVisible) - Spacer() - - // ── Timer Ring ───────────────────────────────────── - TimerRing( - timeRemaining: vm.timeRemaining, - total: vm.totalPhaseTime, - phase: vm.phase - ) - - Spacer() - - // ── Round Counter ────────────────────────────────── - RoundCounter( - current: vm.currentRound, - total: vm.totalRoundsInBlock, - phase: vm.phase - ) - - // ── Live Stats (HealthKit) ───────────────────────── - if vm.heartRate > 0 || vm.liveCalories > 0 { - LiveStatsBar(heartRate: vm.heartRate, calories: vm.liveCalories) - .padding(.top, 12) + // ══════════════════════════════════════════════════ + // Layer 3 — Auto-hide top bar + // ══════════════════════════════════════════════════ + if topBarVisible { + VStack { + AutoHideTopBar( + title: program.titleEn, + block: vm.currentBlockIndex + 1, + totalBlocks: program.blocks.count, + onClose: { vm.showExitConfirmation = true } + ) + .transition(.move(edge: .top).combined(with: .opacity)) + Spacer() + } } - // ── Now Playing (Music) ─────────────────────────── - NowPlayingView( - track: musicVM.currentTrack, - isReady: musicVM.isReady, - onSkip: { musicVM.skipTrack() } - ) - .padding(.horizontal, 32) - .padding(.top, 8) + // ══════════════════════════════════════════════════ + // Layer 4 — Compact timer ring (top-right corner) + // ══════════════════════════════════════════════════ + VStack { + HStack { + Spacer() + CompactTimerRing( + timeRemaining: vm.timeRemaining, + total: vm.totalPhaseTime, + phase: vm.phase + ) + .padding(.top, 60) + .padding(.trailing, 16) + } + Spacer() + } - Spacer() - - // ── Controls ─────────────────────────────────────── - PlayerControls( - isRunning: vm.isRunning, - isPaused: vm.isPaused, - onStartPause: { vm.togglePlayPause() }, - onSkip: { vm.skipPhase() } - ) - .padding(.bottom, 40) + // ══════════════════════════════════════════════════ + // Layer 5 — Bottom overlay: caption, pips, music, controls + // ══════════════════════════════════════════════════ + VStack { + Spacer() + VStack(spacing: 6) { + ExerciseCaption( + name: vm.currentExercise?.nameEn, + phase: vm.phase, + isRestPhase: vm.isRestPhase + ) + CompactRoundCounter( + current: vm.currentRound, + total: vm.totalRoundsInBlock, + phase: vm.phase + ) + .padding(.top, 8) + if let track = musicVM.currentTrack, musicVM.isReady { + ExpandableNowPlayingPill( + track: track, + isExpanded: $nowPlayingExpanded, + onSkip: { musicVM.skipTrack() } + ) + .padding(.horizontal, 16) + } + BottomControlBar( + heartRate: vm.heartRate, + isRunning: vm.isRunning, + isPaused: vm.isPaused, + phase: vm.phase, + onStartPause: { vm.togglePlayPause() }, + onSkip: { vm.skipPhase() } + ) + .frame(maxWidth: .infinity) + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom) + } + } + } + .navigationBarHidden(true) + .statusBarHidden(true) + .preferredColorScheme(.dark) + .onAppear { + vm.setup(modelContext: context) + UIApplication.shared.isIdleTimerDisabled = true + Task { await musicVM.load() } + showTopBar() + PhoneConnectivityManager.shared.onHeartRateUpdate = { [weak vm] hr in + vm?.heartRate = hr + } + } + .onDisappear { + autoHideTask?.cancel() + UIApplication.shared.isIdleTimerDisabled = false + musicVM.stop() + endWorkoutActivity() + } + .onChange(of: vm.isRunning) { _, running in + let musicPhase = vm.phase == .work || vm.phase == .rest + let shouldPlay = running && !vm.isPaused && musicPhase + musicVM.setPlaying(shouldPlay) + if running { phaseEndDate = nil } + updateWorkoutActivity() + } + .onChange(of: vm.isPaused) { _, paused in + let musicPhase = vm.phase == .work || vm.phase == .rest + let shouldPlay = vm.isRunning && !paused && musicPhase + musicVM.setPlaying(shouldPlay) + if !paused { phaseEndDate = nil } + updateWorkoutActivity() + if paused { showTopBar() } + } + .onChange(of: vm.phase) { _, phase in + let musicPhase = phase == .work || phase == .rest + let shouldPlay = vm.isRunning && !vm.isPaused && musicPhase + musicVM.setPlaying(shouldPlay) + phaseEndDate = nil + updateWorkoutActivity() + showTopBar() + } + .onChange(of: musicVM.currentTrack) { _, _ in + updateWorkoutActivity() + } + .onReceive(Timer.publish(every: 5, on: .main, in: .common).autoconnect()) { _ in + updateWorkoutActivity() + } + .onReceive(NotificationCenter.default.publisher(for: .skipTrackFromActivity)) { _ in + musicVM.skipTrack() + } + .navigationDestination(isPresented: $vm.isComplete) { + CompletionView(session: vm.completedSession, program: program, onDone: { dismiss() }) + .navigationBarBackButtonHidden() + } + .alert(String(localized: L10n.player.endWorkout), isPresented: $vm.showExitConfirmation) { + Button(String(localized: L10n.player.endWorkout), role: .destructive) { + vm.abandonWorkout() + dismiss() + } + Button(String(localized: L10n.player.keepGoing), role: .cancel) {} + } message: { + Text(L10n.player.endWorkoutMessage) } } - .navigationBarHidden(true) - .statusBarHidden(true) - .preferredColorScheme(.dark) - .onAppear { - vm.setup(modelContext: context) - UIApplication.shared.isIdleTimerDisabled = true - Task { await musicVM.load() } + } + + // ─── Auto-hide logic ───────────────────────────────────────── + + private func showTopBar() { + autoHideTask?.cancel() + withAnimation(.easeOut(duration: 0.25)) { topBarVisible = true } + autoHideTask = Task { + try? await Task.sleep(nanoseconds: 3_000_000_000) + guard !Task.isCancelled else { return } + withAnimation(.easeOut(duration: 0.6)) { topBarVisible = false } } - .onDisappear { - UIApplication.shared.isIdleTimerDisabled = false - musicVM.stop() + } + + // ─── Dynamic Island ─────────────────────────────────────────── + + private var dynamicIslandAvailable: Bool { + #if targetEnvironment(simulator) + true + #else + ActivityAuthorizationInfo().areActivitiesEnabled + #endif + } + + @MainActor + private func updateWorkoutActivity() { + guard dynamicIslandAvailable else { return } + guard vm.isRunning else { return } + let phaseEnd: Date + if let stored = phaseEndDate { + phaseEnd = stored + } else { + let calculated = Date().addingTimeInterval(Double(vm.timeRemaining)) + phaseEndDate = calculated + phaseEnd = calculated } - .onChange(of: vm.isRunning) { _, running in - let musicPhase = vm.phase != .prep && vm.phase != .warmup && vm.phase != .complete - musicVM.setPlaying(running && !vm.isPaused && musicPhase) + let isPlaying = (vm.phase == .work || vm.phase == .rest) && vm.isRunning && !vm.isPaused + let track = musicVM.currentTrack + + let state = WorkoutActivityAttributes.ContentState( + exerciseName: vm.currentExercise?.nameEn ?? "", + phase: vm.phase.rawValue, + phaseEndDate: phaseEnd, + roundCurrent: vm.currentRound, + roundTotal: vm.totalRoundsInBlock, + heartRate: vm.heartRate, + trackTitle: track?.title ?? "", + trackArtist: track?.artist ?? "", + isPlaying: isPlaying + ) + + if let existing = workoutActivity { + Task { await existing.update(using: state) } + } else { + let attrs = WorkoutActivityAttributes() + workoutActivity = try? Activity.request(attributes: attrs, contentState: state, pushType: nil) } - .onChange(of: vm.isPaused) { _, paused in - let musicPhase = vm.phase != .prep && vm.phase != .warmup && vm.phase != .complete - musicVM.setPlaying(vm.isRunning && !paused && musicPhase) + } + + @MainActor + private func endWorkoutActivity() { + guard let activity = workoutActivity else { return } + let state = WorkoutActivityAttributes.ContentState( + exerciseName: activity.contentState.exerciseName, + phase: activity.contentState.phase, + phaseEndDate: activity.contentState.phaseEndDate, + roundCurrent: activity.contentState.roundCurrent, + roundTotal: activity.contentState.roundTotal, + heartRate: activity.contentState.heartRate, + trackTitle: activity.contentState.trackTitle, + trackArtist: activity.contentState.trackArtist, + isPlaying: false + ) + Task { + await activity.end(using: state, dismissalPolicy: .immediate) + workoutActivity = nil } - .onChange(of: vm.phase) { _, phase in - let musicPhase = phase != .prep && phase != .warmup && phase != .complete - musicVM.setPlaying(vm.isRunning && !vm.isPaused && musicPhase) - } - .navigationDestination(isPresented: $vm.isComplete) { - CompletionView(session: vm.completedSession, program: program, onDone: { dismiss() }) - .navigationBarBackButtonHidden() - } - .alert(String(localized: L10n.player.endWorkout), isPresented: $vm.showExitConfirmation) { - Button(String(localized: L10n.player.endWorkout), role: .destructive) { - vm.abandonWorkout() - dismiss() - } - Button(String(localized: L10n.player.keepGoing), role: .cancel) {} - } message: { - Text(L10n.player.endWorkoutMessage) - } - } // NavigationStack } } // ─── Sub-components ─────────────────────────────────────────────── +// MARK: - PhaseBackground + struct PhaseBackground: View { let phase: TimerPhase @State private var animating = false @@ -158,46 +295,186 @@ struct PhaseBackground: View { } } -struct PlayerTopBar: View { +// MARK: - AutoHideTopBar + +struct AutoHideTopBar: View { let title: String let block: Int let totalBlocks: Int let onClose: () -> Void var body: some View { - HStack { + HStack(spacing: 0) { Button(action: onClose) { Image(systemName: "xmark") .font(.system(size: 17, weight: .semibold)) .foregroundStyle(.white) - .padding(10) + .frame(width: 37, height: 37) .background(.ultraThinMaterial) .clipShape(Circle()) } Spacer() - VStack(spacing: 2) { + VStack(spacing: 4) { Text(title) .font(.subheadline.weight(.semibold)) .foregroundStyle(.white) .lineLimit(1) - Text(String(format: String(localized: L10n.programDetail.blockOfFmt), block, totalBlocks)) - .font(.caption) - .foregroundStyle(.white.opacity(0.7)) + BlockProgressDots(current: block, total: totalBlocks) } Spacer() - // Placeholder for symmetry Color.clear.frame(width: 37, height: 37) } .padding(.horizontal, 20) .padding(.top, 16) + .background( + LinearGradient( + colors: [.black.opacity(0.55), .clear], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 120) + .offset(y: -60) + .allowsHitTesting(false) + ) } } -struct TimerRing: View { +struct BlockProgressDots: View { + let current: Int + let total: Int + + var body: some View { + HStack(spacing: 6) { + ForEach(1...max(total, 1), id: \.self) { i in + Circle() + .fill(i <= current ? .white : .white.opacity(0.3)) + .frame(width: 6, height: 6) + } + } + } +} + +// MARK: - ExpandableNowPlayingPill + +struct ExpandableNowPlayingPill: View { + let track: MusicTrack + @Binding var isExpanded: Bool + let onSkip: () -> Void + + @State private var collapseTask: Task? + + var body: some View { + VStack(spacing: 0) { + HStack(spacing: 6) { + Image(systemName: "music.note") + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(Theme.success) + + Text(track.title) + .font(.caption2.weight(.medium)) + .foregroundStyle(.white.opacity(0.8)) + .lineLimit(1) + + MusicBars() + .padding(.leading, 8) + } + .contentShape(Rectangle()) + .onTapGesture { toggle() } + + if isExpanded { + HStack(spacing: 8) { + Text(track.artist) + .font(.caption2) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + + Spacer() + + Button(action: onSkip) { + HStack(spacing: 3) { + Image(systemName: "forward.fill") + .font(.system(size: 9, weight: .semibold)) + Text("Skip") + .font(.caption2.weight(.medium)) + } + .foregroundStyle(.white.opacity(0.6)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.white.opacity(0.1)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + } + .padding(.top, 6) + .transition(.move(edge: .top).combined(with: .opacity)) + } + } + .padding(.vertical, isExpanded ? 10 : 7) + .padding(.horizontal, 16) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: isExpanded ? 16 : 24, style: .continuous)) + .animation(.spring(duration: 0.3), value: isExpanded) + .onChange(of: isExpanded) { _, expanded in + if expanded { scheduleCollapse() } + } + } + + private func toggle() { + collapseTask?.cancel() + if isExpanded { + isExpanded = false + } else { + isExpanded = true + scheduleCollapse() + } + } + + private func scheduleCollapse() { + collapseTask?.cancel() + collapseTask = Task { + try? await Task.sleep(nanoseconds: 4_000_000_000) + guard !Task.isCancelled else { return } + withAnimation(.spring(duration: 0.3)) { isExpanded = false } + } + } +} + +// MARK: - MusicBars + +struct MusicBars: View { + private let heights: [CGFloat] = [10, 14, 8, 12] + private let durations: [Double] = [0.4, 0.55, 0.35, 0.5] + + var body: some View { + HStack(spacing: 2) { + ForEach(0..<4, id: \.self) { i in + AnimatedBar(height: heights[i], duration: durations[i]) + } + } + } +} + +struct AnimatedBar: View { + let height: CGFloat + let duration: Double + @State private var trigger = false + + var body: some View { + Capsule() + .fill(Theme.success) + .frame(width: 2, height: trigger ? height : 4) + .animation(.easeInOut(duration: duration).repeatForever(autoreverses: true), value: trigger) + .onAppear { trigger = true } + } +} + +// MARK: - CompactTimerRing + +struct CompactTimerRing: View { let timeRemaining: Int let total: Int let phase: TimerPhase @@ -209,126 +486,141 @@ struct TimerRing: View { var body: some View { ZStack { - // Background ring Circle() - .stroke(.white.opacity(0.1), lineWidth: 16) - .frame(width: 240, height: 240) + .stroke(.white.opacity(0.1), lineWidth: 10) + .frame(width: 110, height: 110) - // Progress ring Circle() .trim(from: 0, to: progress) .stroke( Theme.phaseColor(phase), - style: StrokeStyle(lineWidth: 16, lineCap: .round) + style: StrokeStyle(lineWidth: 10, lineCap: .round) ) - .frame(width: 240, height: 240) + .frame(width: 110, height: 110) .rotationEffect(.degrees(-90)) .animation(.linear(duration: 1), value: progress) - // Glass disc Circle() .fill(.ultraThinMaterial) - .frame(width: 200, height: 200) + .frame(width: 90, height: 90) - // Timer digits - Text("\(timeRemaining)") - .font(Theme.timerFont) - .foregroundStyle(.white) - .monospacedDigit() - .contentTransition(.numericText(countsDown: true)) - .animation(.spring(duration: 0.3), value: timeRemaining) + VStack(spacing: 0) { + Text("\(timeRemaining)") + .font(Theme.timerCompactFont) + .foregroundStyle(.white) + .monospacedDigit() + .contentTransition(.numericText(countsDown: true)) + .animation(.spring(duration: 0.3), value: timeRemaining) + + Text(Theme.phaseLabel(phase)) + .font(Theme.phaseCompactFont) + .foregroundStyle(.white.opacity(0.8)) + } } } } -struct RoundCounter: View { +// MARK: - ExerciseCaption + +struct ExerciseCaption: View { + let name: String? + let phase: TimerPhase + let isRestPhase: Bool + + var body: some View { + if let name { + VStack(spacing: 4) { + if isRestPhase { + Text("Next") + .font(.caption.weight(.semibold)) + .foregroundStyle(Theme.rest.opacity(0.85)) + } + Text(name) + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .multilineTextAlignment(.center) + } + .padding(.horizontal, 40) + .id(name) + .transition(.opacity.combined(with: .scale(scale: 0.9))) + .animation(.spring(duration: 0.4), value: name) + } + } +} + +// MARK: - CompactRoundCounter + +struct CompactRoundCounter: View { let current: Int let total: Int let phase: TimerPhase var body: some View { - HStack(spacing: 8) { + HStack(spacing: 6) { ForEach(1...max(total, 1), id: \.self) { i in Capsule() .fill(i < current ? Theme.phaseColor(phase) : i == current ? .white : - .white.opacity(0.25)) - .frame(width: i == current ? 24 : 8, height: 8) + .white.opacity(0.2)) + .frame(width: i == current ? 20 : 6, height: 6) .animation(.spring(duration: 0.3), value: current) } } - .padding(.vertical, 8) + .padding(.vertical, 6) } } -struct LiveStatsBar: View { +// MARK: - BottomControlBar + +struct BottomControlBar: View { let heartRate: Double - let calories: Double - - var body: some View { - HStack(spacing: 24) { - if heartRate > 0 { - HStack(spacing: 6) { - Image(systemName: "heart.fill") - .foregroundStyle(.red) - Text("\(Int(heartRate)) bpm") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) - .monospacedDigit() - } - } - if calories > 0 { - HStack(spacing: 6) { - Image(systemName: "flame.fill") - .foregroundStyle(Theme.brand) - Text("\(Int(calories)) kcal") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.white) - .monospacedDigit() - } - } - } - .padding(.horizontal, 24) - .padding(.vertical, 10) - .background(.ultraThinMaterial) - .clipShape(Capsule()) - } -} - -struct PlayerControls: View { let isRunning: Bool let isPaused: Bool + let phase: TimerPhase let onStartPause: () -> Void let onSkip: () -> Void var body: some View { - HStack(spacing: 40) { - // Skip button - Button(action: onSkip) { - Image(systemName: "forward.end.fill") - .font(.system(size: 22, weight: .semibold)) - .foregroundStyle(.white.opacity(0.8)) - .padding(16) - .background(.ultraThinMaterial) - .clipShape(Circle()) + HStack(spacing: 0) { + HStack(spacing: 4) { + Image(systemName: "heart.fill") + .foregroundStyle(heartRate > 0 ? .red : .red.opacity(0.4)) + .font(.system(size: 12)) + Text(heartRate > 0 ? "\(Int(heartRate))" : "--") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(heartRate > 0 ? .white : .white.opacity(0.4)) + .monospacedDigit() } + Spacer() + // Play / Pause Button(action: onStartPause) { Image(systemName: isRunning && !isPaused ? "pause.fill" : "play.fill") - .font(.system(size: 32, weight: .bold)) + .font(.system(size: 24, weight: .bold)) .foregroundStyle(.white) - .frame(width: 72, height: 72) - .background(Theme.phaseColor(.work)) + .frame(width: 56, height: 56) + .background(Theme.phaseColor(phase)) .clipShape(Circle()) - .shadow(color: Theme.brand.opacity(0.4), radius: 16, y: 6) + .shadow(color: Theme.phaseColor(phase).opacity(0.4), radius: 12, y: 4) } - .scaleEffect(isRunning ? 1.0 : 1.05) + .scaleEffect(isRunning && !isPaused ? 1.0 : 1.05) .animation(.spring(duration: 0.3), value: isRunning) - // Spacer for symmetry - Color.clear.frame(width: 54, height: 54) + Spacer() + + // Skip + Button(action: onSkip) { + Image(systemName: "forward.end.fill") + .font(.system(size: 18, weight: .semibold)) + .foregroundStyle(.white.opacity(0.7)) + .frame(width: 44, height: 44) + } } + .padding(.vertical, 10) + .padding(.horizontal, 20) + .background(.ultraThinMaterial) + .clipShape(Capsule()) } } diff --git a/tabatago-swift/TabataGo/Views/Tabs/HomeTab.swift b/tabatago-swift/TabataGo/Views/Tabs/HomeTab.swift index cf8c335..b9cd187 100644 --- a/tabatago-swift/TabataGo/Views/Tabs/HomeTab.swift +++ b/tabatago-swift/TabataGo/Views/Tabs/HomeTab.swift @@ -238,9 +238,7 @@ struct ZoneCard: View { Spacer() - Image(systemName: zoneIcon) - .font(.system(size: 44, weight: .semibold)) - .foregroundStyle(.white.opacity(0.25)) + ZoneHighlightIcon(zone: zone) } .padding(20) } @@ -260,14 +258,7 @@ struct ZoneCard: View { L10n.zone.description(for: zone) } - private var zoneIcon: String { - switch zone { - case "upper-body": return "figure.arms.open" - case "lower-body": return "figure.walk" - case "full-body": return "figure.highintensity.intervaltraining" - default: return "figure.run" - } - } + } struct LevelBadge: View { diff --git a/tabatago-swift/TabataGoWatch/Resources/Localizable.xcstrings b/tabatago-swift/TabataGoWatch/Resources/Localizable.xcstrings index ab98a17..2a55511 100644 --- a/tabatago-swift/TabataGoWatch/Resources/Localizable.xcstrings +++ b/tabatago-swift/TabataGoWatch/Resources/Localizable.xcstrings @@ -1,207 +1,662 @@ { "sourceLanguage" : "en", "strings" : { + "%@" : { - "watch.phase.getReady" : { + }, + "%@ day%@" : { "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "BEREIT" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "GET READY" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "LISTO" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "PRÊT" } } + "en" : { + "stringUnit" : { + "state" : "new", + "value" : "%1$@ day%2$@" + } + } } }, + "TabataGo" : { - "watch.phase.warmUp" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "AUFWÄRMEN" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "WARM UP" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "CALENTAMIENTO" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "ÉCHAUFFEMENT" } } - } }, - - "watch.phase.work" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "ARBEIT" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "WORK" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "TRABAJO" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "TRAVAIL" } } - } - }, - - "watch.phase.rest" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "PAUSE" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "REST" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "DESCANSO" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "REPOS" } } - } - }, - - "watch.phase.break" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "PAUSE" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "BREAK" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "PAUSA" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "PAUSE" } } - } - }, - - "watch.phase.coolDown" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "ABKÜHLEN" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "COOL DOWN" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "ENFRIAMIENTO" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "RÉCUPÉRATION" } } - } - }, - - "watch.phase.done" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "FERTIG" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "DONE" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "HECHO" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "TERMINÉ" } } - } - }, - - "watch.idle.startOnPhone" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Workout auf\ndeinem iPhone starten" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Start a workout\non your iPhone" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Inicia un entrenamiento\nen tu iPhone" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Démarrer un entraînement\nsur votre iPhone" } } - } - }, - - "watch.idle.connected" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Verbunden" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Connected" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Conectado" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Connecté" } } - } - }, - - "watch.idle.noPhone" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Kein Telefon" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "No phone" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Sin teléfono" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Pas de téléphone" } } - } - }, - - "watch.activity.today" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Heute" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Today" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Hoy" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Aujourd'hui" } } - } - }, - - "watch.activity.move" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Bewegung" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Move" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Mover" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Bouger" } } - } - }, - "watch.activity.exercise" : { + "extractionState" : "stale", "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Sport" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Exercise" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Ejercicio" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Exercice" } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sport" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exercise" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ejercicio" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Exercice" + } + } + } + }, + "watch.activity.move" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bewegung" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Move" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mover" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bouger" + } + } } }, - "watch.activity.stand" : { + "extractionState" : "stale", "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Stehen" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Stand" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Estar de pie" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Debout" } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stehen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Stand" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Estar de pie" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Debout" + } + } } }, - "watch.activity.streak" : { + "extractionState" : "stale", "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Serie" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "streak" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "racha" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "série" } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Serie" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "streak" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "racha" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "série" + } + } } }, - - "watch.complication.notStarted" : { + "watch.activity.today" : { + "extractionState" : "stale", "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Nicht begonnen" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Not started" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "No iniciado" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Pas commencé" } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heute" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoy" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aujourd'hui" + } + } } }, - - "watch.complication.today" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Heute" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Today" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Hoy" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Aujourd'hui" } } - } - }, - - "watch.complication.yesterday" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Gestern" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Yesterday" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Ayer" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Hier" } } - } - }, - "watch.complication.daysAgoFmt" : { "comment" : "printf format string — %d = number of days", + "extractionState" : "stale", "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "vor %d Tagen" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "%d days ago" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "hace %d días" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "il y a %d jours" } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "vor %d Tagen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d days ago" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "hace %d días" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "il y a %d jours" + } + } } }, - "watch.complication.dayStreakFmt" : { "comment" : "printf format string — %d = streak count", + "extractionState" : "stale", "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "%d Tage in Folge" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "%d day streak" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "%d días seguidos" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "%d jours de suite" } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d Tage in Folge" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d day streak" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d días seguidos" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "%d jours de suite" + } + } } }, - - "watch.complication.openApp" : { - "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "TabataGo öffnen →" } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Open TabataGo →" } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Abrir TabataGo →" } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Ouvrir TabataGo →" } } - } - }, - "watch.complication.description" : { + "extractionState" : "stale", "localizations" : { - "de" : { "stringUnit" : { "state" : "translated", "value" : "Dein aktueller Workout-Streak." } }, - "en" : { "stringUnit" : { "state" : "translated", "value" : "Your current workout streak." } }, - "es" : { "stringUnit" : { "state" : "translated", "value" : "Tu racha de entrenamiento actual." } }, - "fr" : { "stringUnit" : { "state" : "translated", "value" : "Votre série d'entraînements actuelle." } } + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Dein aktueller Workout-Streak." + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Your current workout streak." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tu racha de entrenamiento actual." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Votre série d'entraînements actuelle." + } + } + } + }, + "watch.complication.notStarted" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Nicht begonnen" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Not started" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "No iniciado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas commencé" + } + } + } + }, + "watch.complication.openApp" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "TabataGo öffnen →" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Open TabataGo →" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Abrir TabataGo →" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ouvrir TabataGo →" + } + } + } + }, + "watch.complication.today" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Heute" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Today" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hoy" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Aujourd'hui" + } + } + } + }, + "watch.complication.yesterday" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Gestern" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Yesterday" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ayer" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Hier" + } + } + } + }, + "watch.idle.connected" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Verbunden" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connected" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conectado" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Connecté" + } + } + } + }, + "watch.idle.noPhone" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kein Telefon" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "No phone" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Sin teléfono" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Pas de téléphone" + } + } + } + }, + "watch.idle.startOnPhone" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Workout auf\ndeinem iPhone starten" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Start a workout\non your iPhone" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Inicia un entrenamiento\nen tu iPhone" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Démarrer un entraînement\nsur votre iPhone" + } + } + } + }, + "watch.phase.break" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAUSE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "BREAK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAUSA" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAUSE" + } + } + } + }, + "watch.phase.coolDown" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ABKÜHLEN" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "COOL DOWN" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "ENFRIAMIENTO" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "RÉCUPÉRATION" + } + } + } + }, + "watch.phase.done" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "FERTIG" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "DONE" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "HECHO" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "TERMINÉ" + } + } + } + }, + "watch.phase.getReady" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "BEREIT" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "GET READY" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "LISTO" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "PRÊT" + } + } + } + }, + "watch.phase.rest" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "PAUSE" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "REST" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "DESCANSO" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "REPOS" + } + } + } + }, + "watch.phase.warmUp" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "AUFWÄRMEN" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "WARM UP" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "CALENTAMIENTO" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "ÉCHAUFFEMENT" + } + } + } + }, + "watch.phase.work" : { + "extractionState" : "stale", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "ARBEIT" + } + }, + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "WORK" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "TRABAJO" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "TRAVAIL" + } + } } } - }, "version" : "1.0" -} +} \ No newline at end of file diff --git a/tabatago-swift/TabataGoWidget/Info.plist b/tabatago-swift/TabataGoWidget/Info.plist new file mode 100644 index 0000000..b658455 --- /dev/null +++ b/tabatago-swift/TabataGoWidget/Info.plist @@ -0,0 +1,31 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleDisplayName + TabataGoWidget + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + XPC! + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + NSSupportsLiveActivities + + + diff --git a/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift b/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift new file mode 100644 index 0000000..e725e1b --- /dev/null +++ b/tabatago-swift/TabataGoWidget/MusicLiveActivity.swift @@ -0,0 +1,135 @@ +import ActivityKit +import WidgetKit +import SwiftUI +import AppIntents + +struct SkipTrackIntent: AppIntent { + static let title: LocalizedStringResource = "Skip Track" + + @MainActor + func perform() async throws -> some IntentResult & OpensIntent { + let url = URL(string: "tabatago://skipTrack")! + return .result(opensIntent: OpenURLIntent(url)) + } +} + +struct MusicLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: MusicActivityAttributes.self) { context in + // ── Lock Screen / Banner ─────────────────── + VStack(spacing: 0) { + HStack(spacing: 10) { + Image(systemName: "music.note") + .font(.title3.weight(.semibold)) + .foregroundStyle(.green) + + VStack(alignment: .leading, spacing: 2) { + Text(context.state.title) + .font(.headline) + .foregroundStyle(.primary) + .lineLimit(1) + Text(context.state.artist) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + + Spacer() + + if context.state.isPlaying { + LiveActivityMusicBars() + } + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + } + .activityBackgroundTint(.black.opacity(0.9)) + .activitySystemActionForegroundColor(.white) + + } dynamicIsland: { context in + DynamicIsland { + // ── Expanded ──────────────────────────── + DynamicIslandExpandedRegion(.leading) { + HStack(spacing: 6) { + Image(systemName: "music.note") + .foregroundStyle(.green) + Text(context.state.title) + .font(.caption.weight(.semibold)) + .lineLimit(1) + if context.state.isPlaying { + LiveActivityMusicBars() + } + } + .padding(.leading, 8) + } + DynamicIslandExpandedRegion(.center) { + Text(context.state.artist) + .font(.caption2) + .foregroundStyle(.secondary) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.trailing) { + Button(intent: SkipTrackIntent()) { + HStack(spacing: 3) { + Image(systemName: "forward.fill") + .font(.system(size: 10, weight: .semibold)) + Text("Skip") + .font(.caption2.weight(.medium)) + } + .foregroundStyle(.white.opacity(0.6)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .background(.white.opacity(0.15)) + .clipShape(Capsule()) + } + .buttonStyle(.plain) + .padding(.trailing, 8) + } + } compactLeading: { + // ── Compact Leading ───────────────────── + Image(systemName: "music.note") + .foregroundStyle(.green) + .font(.system(size: 14)) + } compactTrailing: { + // ── Compact Trailing ──────────────────── + HStack(spacing: 3) { + Image(systemName: "music.note") + .font(.system(size: 10)) + .foregroundStyle(.green) + Text(context.state.title) + .font(.system(size: 10, weight: .medium)) + .lineLimit(1) + } + } minimal: { + // ── Minimal ───────────────────────────── + Image(systemName: "music.note") + .foregroundStyle(.green) + .font(.system(size: 13)) + } + .widgetURL(URL(string: "tabatago://music")!) + } + } +} + +// MARK: - LiveActivityMusicBars + +struct LiveActivityMusicBars: View { + var body: some View { + TimelineView(.periodic(from: .now, by: 0.45 / Double(barSpeeds.count))) { context in + HStack(spacing: 2) { + ForEach(0..<4, id: \.self) { i in + let t = context.date.timeIntervalSinceReferenceDate + let phase = t * barSpeeds[i] * 2 * .pi + let h = barMin + (barMaxHeights[i] - barMin) * abs(sin(phase)) + Capsule() + .fill(.green) + .frame(width: 2, height: h) + } + } + } + } +} + +private let barMaxHeights: [CGFloat] = [8, 14, 6, 12] +private let barMin: CGFloat = 3 +private let barSpeeds: [Double] = [2.2, 1.8, 2.8, 1.5] diff --git a/tabatago-swift/TabataGoWidget/TabataGoWidgetBundle.swift b/tabatago-swift/TabataGoWidget/TabataGoWidgetBundle.swift new file mode 100644 index 0000000..8ce0a80 --- /dev/null +++ b/tabatago-swift/TabataGoWidget/TabataGoWidgetBundle.swift @@ -0,0 +1,10 @@ +import WidgetKit +import SwiftUI + +@main +struct TabataGoWidgetBundle: WidgetBundle { + var body: some Widget { + WorkoutLiveActivity() + MusicLiveActivity() + } +} diff --git a/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift b/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift new file mode 100644 index 0000000..70bc023 --- /dev/null +++ b/tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift @@ -0,0 +1,155 @@ +import ActivityKit +import WidgetKit +import SwiftUI + +struct WorkoutLiveActivity: Widget { + var body: some WidgetConfiguration { + ActivityConfiguration(for: WorkoutActivityAttributes.self) { context in + let phaseColor = Self.colorForPhase(context.state.phase) + let phaseLabel = context.state.phase.capitalized + + VStack(spacing: 0) { + HStack(alignment: .center, spacing: 12) { + RoundedRectangle(cornerRadius: 4, style: .continuous) + .fill(phaseColor) + .frame(width: 4, height: 38) + + VStack(alignment: .leading, spacing: 2) { + Text(context.state.exerciseName) + .font(.headline) + .foregroundStyle(.primary) + .minimumScaleFactor(0.7) + Text(phaseLabel) + .font(.caption.weight(.semibold)) + .foregroundStyle(phaseColor) + } + .layoutPriority(1) + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true) + .font(.title2.weight(.bold).monospacedDigit()) + .foregroundStyle(.primary) + .contentTransition(.numericText(countsDown: true)) + Text("Round \(context.state.roundCurrent)/\(context.state.roundTotal)") + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.top, 12) + + Divider() + .padding(.horizontal, 16) + .padding(.vertical, 6) + + HStack(spacing: 10) { + Image(systemName: "music.note") + .font(.caption.weight(.semibold)) + .foregroundStyle(.green) + + Text(context.state.trackTitle) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + + Spacer() + + if context.state.heartRate > 0 { + HStack(spacing: 3) { + Image(systemName: "heart.fill") + .font(.system(size: 10)) + .foregroundStyle(.red) + Text("\(Int(context.state.heartRate))") + .font(.caption.monospacedDigit()) + .foregroundStyle(.secondary) + } + } + + if context.state.isPlaying { + LiveActivityMusicBars() + } + } + .padding(.horizontal, 16) + .padding(.bottom, 12) + } + .activityBackgroundTint(.black.opacity(0.9)) + .activitySystemActionForegroundColor(.white) + + } dynamicIsland: { context in + let phaseColor = Self.colorForPhase(context.state.phase) + + return DynamicIsland { + DynamicIslandExpandedRegion(.leading) { + VStack(spacing: 2) { + HStack(spacing: 4) { + Circle() + .fill(phaseColor) + .frame(width: 6, height: 6) + Text("\(context.state.roundCurrent)/\(context.state.roundTotal)") + .font(.caption2.weight(.semibold)) + } + if context.state.heartRate > 0 { + Text("\(Int(context.state.heartRate)) bpm") + .font(.system(size: 9)) + .foregroundStyle(.red) + } + } + .padding(.leading, 8) + } + DynamicIslandExpandedRegion(.center) { + Text(context.state.exerciseName) + .font(.caption.weight(.semibold)) + .lineLimit(1) + } + DynamicIslandExpandedRegion(.trailing) { + Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true) + .font(.system(size: 20, weight: .bold).monospacedDigit()) + .contentTransition(.numericText(countsDown: true)) + .padding(.trailing, 8) + } + DynamicIslandExpandedRegion(.bottom) { + HStack(spacing: 6) { + Image(systemName: "music.note") + .font(.system(size: 10)) + .foregroundStyle(.green) + Text(context.state.trackTitle) + .font(.caption2) + .lineLimit(1) + .foregroundStyle(.secondary) + if context.state.isPlaying { + LiveActivityMusicBars() + } + } + .padding(.horizontal, 8) + .padding(.bottom, 4) + } + } compactLeading: { + Circle() + .fill(phaseColor) + .frame(width: 8, height: 8) + } compactTrailing: { + Text(timerInterval: Date()...context.state.phaseEndDate, countsDown: true) + .font(.system(size: 12, weight: .medium).monospacedDigit()) + .contentTransition(.numericText(countsDown: true)) + } minimal: { + Circle() + .fill(phaseColor) + .frame(width: 6, height: 6) + } + .widgetURL(URL(string: "tabatago://workout")!) + } + } + + static func colorForPhase(_ phase: String) -> Color { + switch phase { + case "prep": return Color(red: 1.0, green: 0.58, blue: 0.0) + case "work": return Color(red: 1.0, green: 0.42, blue: 0.21) + case "rest", "interBlockRest": return Color(red: 0.35, green: 0.78, blue: 0.98) + case "cooldown": return Color(red: 0.35, green: 0.78, blue: 0.98) + case "complete": return Color(red: 0.19, green: 0.82, blue: 0.35) + default: return .gray + } + } +}