Compare commits
2 Commits
fix/health
...
b0d364eca2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0d364eca2 | ||
| 7f5ea9c6e9 |
@@ -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 */
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import ActivityKit
|
||||
|
||||
struct MusicActivityAttributes: ActivityAttributes {
|
||||
public struct ContentState: Codable, Hashable {
|
||||
var title: String
|
||||
var artist: String
|
||||
var isPlaying: Bool
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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é"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ─────────────────────────────────────────────
|
||||
|
||||
@@ -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 ────────────────────────────────────────
|
||||
|
||||
@@ -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(
|
||||
|
||||
131
tabatago-swift/TabataGo/Views/Components/ZoneHighlightIcon.swift
Normal file
131
tabatago-swift/TabataGo/Views/Components/ZoneHighlightIcon.swift
Normal 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
31
tabatago-swift/TabataGoWidget/Info.plist
Normal file
31
tabatago-swift/TabataGoWidget/Info.plist
Normal 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>
|
||||
135
tabatago-swift/TabataGoWidget/MusicLiveActivity.swift
Normal file
135
tabatago-swift/TabataGoWidget/MusicLiveActivity.swift
Normal 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]
|
||||
10
tabatago-swift/TabataGoWidget/TabataGoWidgetBundle.swift
Normal file
10
tabatago-swift/TabataGoWidget/TabataGoWidgetBundle.swift
Normal file
@@ -0,0 +1,10 @@
|
||||
import WidgetKit
|
||||
import SwiftUI
|
||||
|
||||
@main
|
||||
struct TabataGoWidgetBundle: WidgetBundle {
|
||||
var body: some Widget {
|
||||
WorkoutLiveActivity()
|
||||
MusicLiveActivity()
|
||||
}
|
||||
}
|
||||
155
tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift
Normal file
155
tabatago-swift/TabataGoWidget/WorkoutLiveActivity.swift
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user