feat: redesign player with Dynamic Island, compact timer, and fix Live Activity timer drift #2

Merged
millianlmx merged 18 commits from revamp-timer-video-layout into main 2026-05-23 12:24:34 +02:00
17 changed files with 1797 additions and 377 deletions
Showing only changes of commit b0d364eca2 - Show all commits

View File

@@ -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 = "<group>"; };
58DEACB2D18F636B35BB2C48 /* TabataGoSchema.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabataGoSchema.swift; sourceTree = "<group>"; };
5ACBDB7D81F575AC5370E82F /* WatchL10n.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WatchL10n.swift; sourceTree = "<group>"; };
5B01ABC22F9B8FFD006E707D /* MusicActivityAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicActivityAttributes.swift; sourceTree = "<group>"; };
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 = "<group>"; };
61E5AA44513F793EA7FEBA00 /* Theme.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Theme.swift; sourceTree = "<group>"; };
63599808389B70FC2F6A43C3 /* HealthViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HealthViewModel.swift; sourceTree = "<group>"; };
@@ -171,6 +200,7 @@
9B5DFE227FDB6400C8D7A4A4 /* ProfileTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ProfileTab.swift; sourceTree = "<group>"; };
9CE731C42C570A89F2C6F613 /* Strings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Strings.swift; sourceTree = "<group>"; };
9EC19129CD3C493C8B2AEFA8 /* AppState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppState.swift; sourceTree = "<group>"; };
A1B2C3D4E5F6A7B8C9D0E1F2 /* ZoneHighlightIcon.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ZoneHighlightIcon.swift; sourceTree = "<group>"; };
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 = "<group>"; };
ACA8445E547E41DF784C3D2F /* ActivityRingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityRingView.swift; sourceTree = "<group>"; };
@@ -198,6 +228,12 @@
F8242C26A4F51BE7AA779840 /* RootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RootView.swift; sourceTree = "<group>"; };
FAEBB2380DAC8F565F556D41 /* WorkoutCalendarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutCalendarView.swift; sourceTree = "<group>"; };
FB04FA5E81BD1E52DEFB3AC2 /* HomeTab.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeTab.swift; sourceTree = "<group>"; };
BBB001BBB001BBB001BBB001 /* MusicLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MusicLiveActivity.swift; sourceTree = "<group>"; };
BBB002BBB002BBB002BBB002 /* TabataGoWidgetBundle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabataGoWidgetBundle.swift; sourceTree = "<group>"; };
BBB003BBB003BBB003BBB003 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
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 = "<group>"; };
BBB006BBB006BBB006BBB006 /* WorkoutLiveActivity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WorkoutLiveActivity.swift; sourceTree = "<group>"; };
/* 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 = "<group>";
@@ -317,6 +356,14 @@
path = Onboarding;
sourceTree = "<group>";
};
5B01ABC52F9B909E006E707D /* Frameworks */ = {
isa = PBXGroup;
children = (
5B01ABC62F9B909E006E707D /* ActivityKit.framework */,
);
name = Frameworks;
sourceTree = "<group>";
};
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 = "<group>";
};
AA11BB22CC33DD44EE55FF66 /* Components */ = {
isa = PBXGroup;
children = (
A1B2C3D4E5F6A7B8C9D0E1F2 /* ZoneHighlightIcon.swift */,
);
path = Components;
sourceTree = "<group>";
};
BD69946901F21DE2BEE0D8D9 /* TabataGo */ = {
isa = PBXGroup;
children = (
@@ -438,6 +494,17 @@
path = ViewModels;
sourceTree = "<group>";
};
CCC001CCC001CCC001CCC001 /* TabataGoWidget */ = {
isa = PBXGroup;
children = (
BBB003BBB003BBB003BBB003 /* Info.plist */,
BBB001BBB001BBB001BBB001 /* MusicLiveActivity.swift */,
BBB002BBB002BBB002BBB002 /* TabataGoWidgetBundle.swift */,
BBB006BBB006BBB006BBB006 /* WorkoutLiveActivity.swift */,
);
path = TabataGoWidget;
sourceTree = "<group>";
};
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 = "<group>";
@@ -516,6 +585,7 @@
B7EDA5BF7F25E3279A4B1A61 /* TabataGoUITests.xctest */,
484865AEFA8CCD26C4AE7F73 /* TabataGoWatch.app */,
255972F9906563A0921C47C0 /* TabataGoWatchWidget.appex */,
BBB004BBB004BBB004BBB004 /* TabataGoWidget.appex */,
);
name = Products;
sourceTree = "<group>";
@@ -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 */

View File

@@ -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)
}
}
}
}
}

View File

@@ -0,0 +1,9 @@
import ActivityKit
struct MusicActivityAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
var title: String
var artist: String
var isPlaying: Bool
}
}

View File

@@ -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
}
}

View File

@@ -20,12 +20,23 @@
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array>
<string>tabatago</string>
</array>
</dict>
</array>
<key>NSHealthShareUsageDescription</key>
<string>TabataGo reads your health data to show fitness stats and personalize your workouts.</string>
<key>NSHealthUpdateUsageDescription</key>
<string>TabataGo saves your Tabata workouts to Apple Health to track calories, heart rate, and contribute to your Activity Rings.</string>
<key>NSMotionUsageDescription</key>
<string>TabataGo uses motion data to improve calorie estimates during workouts.</string>
<key>NSSupportsLiveActivities</key>
<true/>
<key>POSTHOG_API_KEY</key>
<string>$(POSTHOG_API_KEY)</string>
<key>REVENUECAT_API_KEY</key>

View File

@@ -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é"
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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()
}
}
}

View File

@@ -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<Void, Never>?
@State private var workoutActivity: Activity<WorkoutActivityAttributes>?
@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<Void, Never>?
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())
}
}

View File

@@ -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 {

View File

@@ -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" : "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"
}
}

View File

@@ -0,0 +1,31 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key>
<string>TabataGoWidget</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>$(PRODUCT_NAME)</string>
<key>CFBundlePackageType</key>
<string>XPC!</string>
<key>CFBundleShortVersionString</key>
<string>1.0</string>
<key>CFBundleVersion</key>
<string>1</string>
<key>NSExtension</key>
<dict>
<key>NSExtensionPointIdentifier</key>
<string>com.apple.widgetkit-extension</string>
</dict>
<key>NSSupportsLiveActivities</key>
<true/>
</dict>
</plist>

View File

@@ -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]

View File

@@ -0,0 +1,10 @@
import WidgetKit
import SwiftUI
@main
struct TabataGoWidgetBundle: WidgetBundle {
var body: some Widget {
WorkoutLiveActivity()
MusicLiveActivity()
}
}

View File

@@ -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
}
}
}