From 1758cc3a2567c1e8e17a95e9d3dfbd664a4bdbd1 Mon Sep 17 00:00:00 2001 From: songyc macbook Date: Fri, 10 Jul 2026 09:00:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(complications):=20=EC=9B=8C=EC=B9=98=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=8A=A4=20=EC=BB=B4=ED=94=8C=EB=A6=AC?= =?UTF-8?q?=EC=BC=80=EC=9D=B4=EC=85=98=203=EC=A2=85=20(CLAUDE.md=20=C2=A79?= =?UTF-8?q?.2)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 현재 현황: 측정 중 대표 행동 + 실시간 타이머 + 추가 측정 +N(없으면 생략), 미측정 시 '실행 중 아님' - 목표 달성률: 목표·기간(하루/주간/월간) 선택, 원형 게이지/진행바/인라인 - 다짐 달성률: rectangular는 세 기간 모두 표기, '이하 유지(한도형)' 다짐은 게이지 대신 한도 안/초과 상태로 별도 표현 - accessoryCircular/Rectangular/Inline/Corner 지원 + 페이스 갤러리 추천(recommendations) - 워치 앱이 캐시한 스냅숏 사용, 갱신 시 reloadTimelines로 실시간 리프레시 - pbxproj: WatchShared 3개 타깃 연결, 위젯·컴플리케이션 뷰를 앱 타깃에도 포함 (DEBUG 미리보기용, @main 제외), WATCHOS_DEPLOYMENT_TARGET 26.0 - DEBUG 미리보기: -complicationPreview YES, -complicationScroll YES Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013yKDMhuF39GVYy3FMcGHh7 --- .../Haru_Danim.xcodeproj/project.pbxproj | 382 ++++++++++++++++++ .../ComplicationPreviewScreen.swift | 80 ++++ .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 13 + .../Assets.xcassets/Contents.json | 6 + .../WidgetBackground.colorset/Contents.json | 11 + .../ComplicationSupport.swift | 125 ++++++ .../Haru_DanimWatchWidgetsBundle.swift | 18 + .../Haru_DanimWatchWidgets/Info.plist | 11 + .../RateComplications.swift | 315 +++++++++++++++ .../StatusComplication.swift | 150 +++++++ ...ru_DanimWatchWidgetsExtension.entitlements | 10 + 12 files changed, 1132 insertions(+) create mode 100644 myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift create mode 100644 myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/Contents.json create mode 100644 myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json create mode 100644 myApp/HaruDanim/Haru_DanimWatchWidgets/ComplicationSupport.swift create mode 100644 myApp/HaruDanim/Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsBundle.swift create mode 100644 myApp/HaruDanim/Haru_DanimWatchWidgets/Info.plist create mode 100644 myApp/HaruDanim/Haru_DanimWatchWidgets/RateComplications.swift create mode 100644 myApp/HaruDanim/Haru_DanimWatchWidgets/StatusComplication.swift create mode 100644 myApp/HaruDanim/Haru_DanimWatchWidgetsExtension.entitlements diff --git a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj index ea89755..97c3b4c 100644 --- a/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj +++ b/myApp/HaruDanim/Haru_Danim.xcodeproj/project.pbxproj @@ -7,10 +7,28 @@ objects = { /* Begin PBXBuildFile section */ + 7263D0EA3000433400EA47FC /* Haru_DanimWatch Watch App.app in Embed Watch Content */ = {isa = PBXBuildFile; fileRef = 7263D0E03000433300EA47FC /* Haru_DanimWatch Watch App.app */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + 7263D0F7300044E900EA47FC /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7263D0F6300044E900EA47FC /* WidgetKit.framework */; }; + 7263D0F9300044E900EA47FC /* SwiftUI.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7263D0F8300044E900EA47FC /* SwiftUI.framework */; }; + 7263D108300044EA00EA47FC /* Haru_DanimWatchWidgetsExtension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 7263D0F4300044E900EA47FC /* Haru_DanimWatchWidgetsExtension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; AA000000000000000000000E /* Haru_DanimWidgets.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = AA0000000000000000000004 /* Haru_DanimWidgets.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ + 7263D0E83000433400EA47FC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 725A9E7C2FFCC65000846FFB /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7263D0DF3000433300EA47FC; + remoteInfo = "Haru_DanimWatch Watch App"; + }; + 7263D106300044EA00EA47FC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 725A9E7C2FFCC65000846FFB /* Project object */; + proxyType = 1; + remoteGlobalIDString = 7263D0F3300044E900EA47FC; + remoteInfo = Haru_DanimWatchWidgetsExtension; + }; AA000000000000000000000B /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; containerPortal = 725A9E7C2FFCC65000846FFB /* Project object */; @@ -21,6 +39,28 @@ /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ + 7263D0EE3000433400EA47FC /* Embed Watch Content */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = "$(CONTENTS_FOLDER_PATH)/Watch"; + dstSubfolderSpec = 16; + files = ( + 7263D0EA3000433400EA47FC /* Haru_DanimWatch Watch App.app in Embed Watch Content */, + ); + name = "Embed Watch Content"; + runOnlyForDeploymentPostprocessing = 0; + }; + 7263D10D300044EA00EA47FC /* Embed Foundation Extensions */ = { + isa = PBXCopyFilesBuildPhase; + buildActionMask = 2147483647; + dstPath = ""; + dstSubfolderSpec = 13; + files = ( + 7263D108300044EA00EA47FC /* Haru_DanimWatchWidgetsExtension.appex in Embed Foundation Extensions */, + ); + name = "Embed Foundation Extensions"; + runOnlyForDeploymentPostprocessing = 0; + }; AA000000000000000000000D /* Embed Foundation Extensions */ = { isa = PBXCopyFilesBuildPhase; buildActionMask = 2147483647; @@ -36,10 +76,24 @@ /* Begin PBXFileReference section */ 725A9E842FFCC65000846FFB /* Haru_Danim.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Haru_Danim.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 7263D0DA3000415100EA47FC /* Haru_DanimWidgets.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Haru_DanimWidgets.entitlements; sourceTree = ""; }; + 7263D0DB3000417000EA47FC /* Haru_Danim.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Haru_Danim.entitlements; sourceTree = ""; }; + 7263D0E03000433300EA47FC /* Haru_DanimWatch Watch App.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Haru_DanimWatch Watch App.app"; sourceTree = BUILT_PRODUCTS_DIR; }; + 7263D0F4300044E900EA47FC /* Haru_DanimWatchWidgetsExtension.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Haru_DanimWatchWidgetsExtension.appex; sourceTree = BUILT_PRODUCTS_DIR; }; + 7263D0F6300044E900EA47FC /* WidgetKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = WidgetKit.framework; path = System/Library/Frameworks/WidgetKit.framework; sourceTree = SDKROOT; }; + 7263D0F8300044E900EA47FC /* SwiftUI.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = SwiftUI.framework; path = System/Library/Frameworks/SwiftUI.framework; sourceTree = SDKROOT; }; + 7263D10E300044F100EA47FC /* Haru_DanimWatchWidgetsExtension.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Haru_DanimWatchWidgetsExtension.entitlements; sourceTree = ""; }; AA0000000000000000000004 /* Haru_DanimWidgets.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Haru_DanimWidgets.appex; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ + 7263D109300044EA00EA47FC /* Exceptions for "Haru_DanimWatchWidgets" folder in "Haru_DanimWatchWidgetsExtension" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Info.plist, + ); + target = 7263D0F3300044E900EA47FC /* Haru_DanimWatchWidgetsExtension */; + }; AA000000000000000000000F /* Exceptions for "Widgets" folder in "Haru_DanimWidgets" target */ = { isa = PBXFileSystemSynchronizedBuildFileExceptionSet; membershipExceptions = ( @@ -47,6 +101,23 @@ ); target = AA0000000000000000000003 /* Haru_DanimWidgets */; }; + AA0000000000000000000010 /* Exceptions for "Widgets" folder in "Haru_Danim" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + HaruDanimWidgets.swift, + Info.plist, + ); + target = 725A9E832FFCC65000846FFB /* Haru_Danim */; + }; + AA0000000000000000000012 /* Exceptions for "Haru_DanimWatchWidgets" folder in "Haru_DanimWatch Watch App" target */ = { + isa = PBXFileSystemSynchronizedBuildFileExceptionSet; + membershipExceptions = ( + Assets.xcassets, + Haru_DanimWatchWidgetsBundle.swift, + Info.plist, + ); + target = 7263D0DF3000433300EA47FC /* Haru_DanimWatch Watch App */; + }; /* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ @@ -55,9 +126,24 @@ path = IOS; sourceTree = ""; }; + 7263D0E13000433300EA47FC /* Haru_DanimWatch Watch App */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = "Haru_DanimWatch Watch App"; + sourceTree = ""; + }; + 7263D0FA300044E900EA47FC /* Haru_DanimWatchWidgets */ = { + isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + AA0000000000000000000012 /* Exceptions for "Haru_DanimWatchWidgets" folder in "Haru_DanimWatch Watch App" target */, + 7263D109300044EA00EA47FC /* Exceptions for "Haru_DanimWatchWidgets" folder in "Haru_DanimWatchWidgetsExtension" target */, + ); + path = Haru_DanimWatchWidgets; + sourceTree = ""; + }; AA0000000000000000000001 /* Widgets */ = { isa = PBXFileSystemSynchronizedRootGroup; exceptions = ( + AA0000000000000000000010 /* Exceptions for "Widgets" folder in "Haru_Danim" target */, AA000000000000000000000F /* Exceptions for "Widgets" folder in "Haru_DanimWidgets" target */, ); path = Widgets; @@ -68,6 +154,11 @@ path = Shared; sourceTree = ""; }; + AA0000000000000000000011 /* WatchShared */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = WatchShared; + sourceTree = ""; + }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -78,6 +169,22 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7263D0DD3000433300EA47FC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7263D0F1300044E900EA47FC /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 7263D0F9300044E900EA47FC /* SwiftUI.framework in Frameworks */, + 7263D0F7300044E900EA47FC /* WidgetKit.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; AA0000000000000000000006 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -91,9 +198,16 @@ 725A9E7B2FFCC65000846FFB = { isa = PBXGroup; children = ( + 7263D10E300044F100EA47FC /* Haru_DanimWatchWidgetsExtension.entitlements */, + 7263D0DB3000417000EA47FC /* Haru_Danim.entitlements */, + 7263D0DA3000415100EA47FC /* Haru_DanimWidgets.entitlements */, 725A9E862FFCC65000846FFB /* IOS */, AA0000000000000000000002 /* Shared */, + AA0000000000000000000011 /* WatchShared */, AA0000000000000000000001 /* Widgets */, + 7263D0E13000433300EA47FC /* Haru_DanimWatch Watch App */, + 7263D0FA300044E900EA47FC /* Haru_DanimWatchWidgets */, + 7263D0F5300044E900EA47FC /* Frameworks */, 725A9E852FFCC65000846FFB /* Products */, ); sourceTree = ""; @@ -103,10 +217,21 @@ children = ( 725A9E842FFCC65000846FFB /* Haru_Danim.app */, AA0000000000000000000004 /* Haru_DanimWidgets.appex */, + 7263D0E03000433300EA47FC /* Haru_DanimWatch Watch App.app */, + 7263D0F4300044E900EA47FC /* Haru_DanimWatchWidgetsExtension.appex */, ); name = Products; sourceTree = ""; }; + 7263D0F5300044E900EA47FC /* Frameworks */ = { + isa = PBXGroup; + children = ( + 7263D0F6300044E900EA47FC /* WidgetKit.framework */, + 7263D0F8300044E900EA47FC /* SwiftUI.framework */, + ); + name = Frameworks; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -118,15 +243,19 @@ 725A9E812FFCC65000846FFB /* Frameworks */, 725A9E822FFCC65000846FFB /* Resources */, AA000000000000000000000D /* Embed Foundation Extensions */, + 7263D0EE3000433400EA47FC /* Embed Watch Content */, ); buildRules = ( ); dependencies = ( AA000000000000000000000C /* PBXTargetDependency */, + 7263D0E93000433400EA47FC /* PBXTargetDependency */, ); fileSystemSynchronizedGroups = ( 725A9E862FFCC65000846FFB /* IOS */, + AA0000000000000000000001 /* Widgets */, AA0000000000000000000002 /* Shared */, + AA0000000000000000000011 /* WatchShared */, ); name = Haru_Danim; packageProductDependencies = ( @@ -135,6 +264,55 @@ productReference = 725A9E842FFCC65000846FFB /* Haru_Danim.app */; productType = "com.apple.product-type.application"; }; + 7263D0DF3000433300EA47FC /* Haru_DanimWatch Watch App */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7263D0EB3000433400EA47FC /* Build configuration list for PBXNativeTarget "Haru_DanimWatch Watch App" */; + buildPhases = ( + 7263D0DC3000433300EA47FC /* Sources */, + 7263D0DD3000433300EA47FC /* Frameworks */, + 7263D0DE3000433300EA47FC /* Resources */, + 7263D10D300044EA00EA47FC /* Embed Foundation Extensions */, + ); + buildRules = ( + ); + dependencies = ( + 7263D107300044EA00EA47FC /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 7263D0E13000433300EA47FC /* Haru_DanimWatch Watch App */, + 7263D0FA300044E900EA47FC /* Haru_DanimWatchWidgets */, + AA0000000000000000000011 /* WatchShared */, + ); + name = "Haru_DanimWatch Watch App"; + packageProductDependencies = ( + ); + productName = "Haru_DanimWatch Watch App"; + productReference = 7263D0E03000433300EA47FC /* Haru_DanimWatch Watch App.app */; + productType = "com.apple.product-type.application"; + }; + 7263D0F3300044E900EA47FC /* Haru_DanimWatchWidgetsExtension */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7263D10A300044EA00EA47FC /* Build configuration list for PBXNativeTarget "Haru_DanimWatchWidgetsExtension" */; + buildPhases = ( + 7263D0F0300044E900EA47FC /* Sources */, + 7263D0F1300044E900EA47FC /* Frameworks */, + 7263D0F2300044E900EA47FC /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 7263D0FA300044E900EA47FC /* Haru_DanimWatchWidgets */, + AA0000000000000000000011 /* WatchShared */, + ); + name = Haru_DanimWatchWidgetsExtension; + packageProductDependencies = ( + ); + productName = Haru_DanimWatchWidgetsExtension; + productReference = 7263D0F4300044E900EA47FC /* Haru_DanimWatchWidgetsExtension.appex */; + productType = "com.apple.product-type.app-extension"; + }; AA0000000000000000000003 /* Haru_DanimWidgets */ = { isa = PBXNativeTarget; buildConfigurationList = AA000000000000000000000A /* Build configuration list for PBXNativeTarget "Haru_DanimWidgets" */; @@ -171,6 +349,12 @@ 725A9E832FFCC65000846FFB = { CreatedOnToolsVersion = 26.6; }; + 7263D0DF3000433300EA47FC = { + CreatedOnToolsVersion = 26.6; + }; + 7263D0F3300044E900EA47FC = { + CreatedOnToolsVersion = 26.6; + }; AA0000000000000000000003 = { CreatedOnToolsVersion = 26.6; }; @@ -194,6 +378,8 @@ targets = ( 725A9E832FFCC65000846FFB /* Haru_Danim */, AA0000000000000000000003 /* Haru_DanimWidgets */, + 7263D0DF3000433300EA47FC /* Haru_DanimWatch Watch App */, + 7263D0F3300044E900EA47FC /* Haru_DanimWatchWidgetsExtension */, ); }; /* End PBXProject section */ @@ -206,6 +392,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7263D0DE3000433300EA47FC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7263D0F2300044E900EA47FC /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; AA0000000000000000000007 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -223,6 +423,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7263D0DC3000433300EA47FC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7263D0F0300044E900EA47FC /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; AA0000000000000000000005 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -233,6 +447,16 @@ /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ + 7263D0E93000433400EA47FC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7263D0DF3000433300EA47FC /* Haru_DanimWatch Watch App */; + targetProxy = 7263D0E83000433400EA47FC /* PBXContainerItemProxy */; + }; + 7263D107300044EA00EA47FC /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 7263D0F3300044E900EA47FC /* Haru_DanimWatchWidgetsExtension */; + targetProxy = 7263D106300044EA00EA47FC /* PBXContainerItemProxy */; + }; AA000000000000000000000C /* PBXTargetDependency */ = { isa = PBXTargetDependency; target = AA0000000000000000000003 /* Haru_DanimWidgets */; @@ -365,6 +589,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Haru_Danim.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5GNS4Z2SYT; @@ -400,6 +625,7 @@ buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = Haru_Danim.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5GNS4Z2SYT; @@ -430,9 +656,146 @@ }; name = Release; }; + 7263D0EC3000433400EA47FC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5GNS4Z2SYT; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Haru_DanimWatch; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.yechan.HaruDanim; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + 7263D0ED3000433400EA47FC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_ENTITLEMENTS = "Haru_DanimWatch Watch App/Haru_DanimWatch Watch App.entitlements"; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5GNS4Z2SYT; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_CFBundleDisplayName = Haru_DanimWatch; + INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; + INFOPLIST_KEY_WKCompanionAppBundleIdentifier = com.yechan.HaruDanim; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.yechan.HaruDanim.watchkitapp; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; + 7263D10B300044EA00EA47FC /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgetsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5GNS4Z2SYT; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Haru_DanimWatchWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.yechan.HaruDanim.watchkitapp.Haru-DanimWatchWidgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Debug; + }; + 7263D10C300044EA00EA47FC /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + ASSETCATALOG_COMPILER_WIDGET_BACKGROUND_COLOR_NAME = WidgetBackground; + CODE_SIGN_ENTITLEMENTS = Haru_DanimWatchWidgetsExtension.entitlements; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + DEVELOPMENT_TEAM = 5GNS4Z2SYT; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_FILE = Haru_DanimWatchWidgets/Info.plist; + INFOPLIST_KEY_CFBundleDisplayName = Haru_DanimWatchWidgets; + INFOPLIST_KEY_NSHumanReadableCopyright = ""; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@executable_path/../../Frameworks", + "@executable_path/../../../../Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = "com.yechan.HaruDanim.watchkitapp.Haru-DanimWatchWidgets"; + PRODUCT_NAME = "$(TARGET_NAME)"; + SDKROOT = watchos; + SKIP_INSTALL = YES; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = 4; + WATCHOS_DEPLOYMENT_TARGET = 26.0; + }; + name = Release; + }; AA0000000000000000000008 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { + CODE_SIGN_ENTITLEMENTS = Haru_DanimWidgets.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5GNS4Z2SYT; @@ -458,6 +821,7 @@ AA0000000000000000000009 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { + CODE_SIGN_ENTITLEMENTS = Haru_DanimWidgets.entitlements; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; DEVELOPMENT_TEAM = 5GNS4Z2SYT; @@ -501,6 +865,24 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 7263D0EB3000433400EA47FC /* Build configuration list for PBXNativeTarget "Haru_DanimWatch Watch App" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7263D0EC3000433400EA47FC /* Debug */, + 7263D0ED3000433400EA47FC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7263D10A300044EA00EA47FC /* Build configuration list for PBXNativeTarget "Haru_DanimWatchWidgetsExtension" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7263D10B300044EA00EA47FC /* Debug */, + 7263D10C300044EA00EA47FC /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; AA000000000000000000000A /* Build configuration list for PBXNativeTarget "Haru_DanimWidgets" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift b/myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift new file mode 100644 index 0000000..5ceb72b --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatch Watch App/ComplicationPreviewScreen.swift @@ -0,0 +1,80 @@ +// +// ComplicationPreviewScreen.swift +// Haru_DanimWatch Watch App +// +// DEBUG 전용: 컴플리케이션 뷰를 워치 앱 안에서 렌더링해 검증하는 화면. +// 런치 인자 `-complicationPreview YES`로 진입. +// (워치 페이스에 컴플리케이션을 자동으로 추가할 방법이 없어 만든 검증 도구) +// + +#if DEBUG +import SwiftUI +import WidgetKit + +struct ComplicationPreviewScreen: View { + @State private var status: StatusEntry? + @State private var goalRate: GoalRateEntry? + @State private var questRate: QuestRateEntry? + + var body: some View { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 10) { + if let status { + label("현재 현황") + HStack(spacing: 8) { + circularBox { StatusComplicationView(previewFamily: .accessoryCircular, entry: status) } + rectBox { StatusComplicationView(previewFamily: .accessoryRectangular, entry: status) } + } + } + if let goalRate { + label("목표 달성률") + HStack(spacing: 8) { + circularBox { GoalRateComplicationView(previewFamily: .accessoryCircular, entry: goalRate) } + rectBox { GoalRateComplicationView(previewFamily: .accessoryRectangular, entry: goalRate) } + } + } + if let questRate { + label("다짐 달성률") + HStack(spacing: 8) { + circularBox { QuestRateComplicationView(previewFamily: .accessoryCircular, entry: questRate) } + rectBox { QuestRateComplicationView(previewFamily: .accessoryRectangular, entry: questRate) } + } + .id("quest") + } + } + } + .onAppear { + status = StatusProvider.makeEntry() + goalRate = GoalRateProvider.makeEntry(GoalRateConfigIntent()) + questRate = QuestRateProvider.makeEntry(QuestRateConfigIntent()) + // -complicationScroll YES → 다짐 섹션까지 자동 스크롤 + if UserDefaults.standard.bool(forKey: "complicationScroll") { + DispatchQueue.main.asyncAfter(deadline: .now() + 1.5) { + withAnimation { proxy.scrollTo("quest", anchor: .bottom) } + } + } + } + } + } + + private func label(_ text: String) -> some View { + Text(text) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(.secondary) + } + + private func circularBox(@ViewBuilder content: () -> some View) -> some View { + content() + .frame(width: 50, height: 50) + .background(.white.opacity(0.12), in: Circle()) + } + + private func rectBox(@ViewBuilder content: () -> some View) -> some View { + content() + .padding(4) + .frame(width: 120, height: 54) + .background(.white.opacity(0.12), in: RoundedRectangle(cornerRadius: 8)) + } +} +#endif diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/AccentColor.colorset/Contents.json b/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json b/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..49c81cd --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,13 @@ +{ + "images" : [ + { + "idiom" : "universal", + "platform" : "watchos", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/Contents.json b/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json b/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/Assets.xcassets/WidgetBackground.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/ComplicationSupport.swift b/myApp/HaruDanim/Haru_DanimWatchWidgets/ComplicationSupport.swift new file mode 100644 index 0000000..90e03ae --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/ComplicationSupport.swift @@ -0,0 +1,125 @@ +// +// ComplicationSupport.swift +// Haru_DanimWatchWidgetsExtension +// +// 컴플리케이션 공용 (CLAUDE.md §9.2) +// - 워치 앱이 App Group defaults에 캐시한 최신 스냅숏을 읽는다 +// - 목표/다짐 선택 엔티티와 기간 옵션 정의 +// + +import AppIntents +import Foundation +import SwiftUI +import WidgetKit + +// MARK: - 스냅숏 로드 + +nonisolated enum ComplicationStore { + static var defaults: UserDefaults { + UserDefaults(suiteName: "group.com.yechan.HaruDanim") ?? .standard + } + + static func snapshot() -> WatchSnapshot? { + guard let data = defaults.data(forKey: WatchSync.cachedSnapshotKey) else { return nil } + return WatchSnapshot.decode(data) + } +} + +// MARK: - 기간 옵션 + +enum WatchSpanOption: String, AppEnum { + case day, week, month + + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "기간") + static let caseDisplayRepresentations: [WatchSpanOption: DisplayRepresentation] = [ + .day: "하루", + .week: "주간", + .month: "월간", + ] + + var label: String { + switch self { + case .day: return "하루" + case .week: return "주간" + case .month: return "월간" + } + } +} + +// MARK: - 목표/다짐 엔티티 (캐시된 스냅숏 기반) + +struct WatchGoalEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "목표") + static let defaultQuery = WatchGoalEntityQuery() + + let id: UUID + let title: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(title)") + } +} + +struct WatchGoalEntityQuery: EntityQuery { + func entities(for identifiers: [UUID]) async throws -> [WatchGoalEntity] { + (ComplicationStore.snapshot()?.goals ?? []) + .filter { identifiers.contains($0.id) } + .map { WatchGoalEntity(id: $0.id, title: $0.title) } + } + + func suggestedEntities() async throws -> [WatchGoalEntity] { + (ComplicationStore.snapshot()?.goals ?? []) + .map { WatchGoalEntity(id: $0.id, title: $0.title) } + } +} + +struct WatchQuestEntity: AppEntity { + static let typeDisplayRepresentation = TypeDisplayRepresentation(name: "다짐") + static let defaultQuery = WatchQuestEntityQuery() + + let id: UUID + let title: String + + var displayRepresentation: DisplayRepresentation { + DisplayRepresentation(title: "\(title)") + } +} + +struct WatchQuestEntityQuery: EntityQuery { + private func all() -> [WatchQuestEntity] { + (ComplicationStore.snapshot()?.goals ?? []).flatMap { goal in + goal.quests.map { WatchQuestEntity(id: $0.id, title: "\(goal.title) · \($0.name)") } + } + } + + func entities(for identifiers: [UUID]) async throws -> [WatchQuestEntity] { + all().filter { identifiers.contains($0.id) } + } + + func suggestedEntities() async throws -> [WatchQuestEntity] { + all() + } +} + +// MARK: - 공용 뷰 + +struct ComplicationLockedView: View { + var body: some View { + VStack(spacing: 2) { + Image(systemName: "crown.fill") + Text("프리미엄") + .font(.system(size: 10)) + } + } +} + +struct ComplicationEmptyView: View { + let message: String + + var body: some View { + Text(message) + .font(.system(size: 10)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsBundle.swift b/myApp/HaruDanim/Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsBundle.swift new file mode 100644 index 0000000..570e8c4 --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/Haru_DanimWatchWidgetsBundle.swift @@ -0,0 +1,18 @@ +// +// Haru_DanimWatchWidgetsBundle.swift +// Haru_DanimWatchWidgetsExtension +// +// 워치 페이스 컴플리케이션 번들 (CLAUDE.md §9.2) +// + +import SwiftUI +import WidgetKit + +@main +struct Haru_DanimWatchWidgetsBundle: WidgetBundle { + var body: some Widget { + StatusComplication() + GoalRateComplication() + QuestRateComplication() + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/Info.plist b/myApp/HaruDanim/Haru_DanimWatchWidgets/Info.plist new file mode 100644 index 0000000..0f118fb --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/Info.plist @@ -0,0 +1,11 @@ + + + + + NSExtension + + NSExtensionPointIdentifier + com.apple.widgetkit-extension + + + diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/RateComplications.swift b/myApp/HaruDanim/Haru_DanimWatchWidgets/RateComplications.swift new file mode 100644 index 0000000..8ff8fec --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/RateComplications.swift @@ -0,0 +1,315 @@ +// +// RateComplications.swift +// Haru_DanimWatchWidgetsExtension +// +// 목표/다짐 달성률 컴플리케이션 (CLAUDE.md §9.2-2, §9.2-3) +// - 목표: 선택한 목표의 하루/주간/월간 달성률 +// - 다짐: 선택한 다짐의 달성률. rectangular는 세 기간을 모두 표기. +// '이하 유지(넘기면 안 되는)' 다짐은 한도 대비 상태로 다르게 표현 +// + +import AppIntents +import SwiftUI +import WidgetKit + +// MARK: - 목표 달성률 + +struct GoalRateConfigIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "목표 달성률" + static let description = IntentDescription("표시할 목표와 기간을 선택하세요.") + + @Parameter(title: "목표") + var goal: WatchGoalEntity? + + @Parameter(title: "기간", default: .day) + var span: WatchSpanOption +} + +struct GoalRateEntry: TimelineEntry { + let date: Date + let locked: Bool + let title: String? + let symbolName: String + let spanLabel: String + let ratio: Double +} + +struct GoalRateProvider: AppIntentTimelineProvider { + static func makeEntry(_ configuration: GoalRateConfigIntent) -> GoalRateEntry { + guard let snapshot = ComplicationStore.snapshot() else { + return GoalRateEntry(date: .now, locked: false, title: nil, symbolName: "flag.checkered", + spanLabel: configuration.span.label, ratio: 0) + } + guard snapshot.isPremium else { + return GoalRateEntry(date: .now, locked: true, title: nil, symbolName: "flag.checkered", + spanLabel: "", ratio: 0) + } + let goal = snapshot.goals.first { $0.id == configuration.goal?.id } ?? snapshot.goals.first + return GoalRateEntry( + date: .now, + locked: false, + title: goal?.title, + symbolName: goal?.symbolName ?? "flag.checkered", + spanLabel: configuration.span.label, + ratio: goal?.ratio(forSpanRaw: configuration.span.rawValue) ?? 0 + ) + } + + func placeholder(in context: Context) -> GoalRateEntry { + GoalRateEntry(date: .now, locked: false, title: "목표", symbolName: "flag.checkered", + spanLabel: "하루", ratio: 0.6) + } + + func snapshot(for configuration: GoalRateConfigIntent, in context: Context) async -> GoalRateEntry { + Self.makeEntry(configuration) + } + + func timeline(for configuration: GoalRateConfigIntent, in context: Context) async -> Timeline { + Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60))) + } + + /// 워치 페이스 갤러리에 노출할 추천 구성 (하루/주간/월간) + func recommendations() -> [AppIntentRecommendation] { + WatchSpanOption.allCases.map { span in + let intent = GoalRateConfigIntent() + intent.span = span + return AppIntentRecommendation(intent: intent, description: "목표 달성률 (\(span.label))") + } + } +} + +struct GoalRateComplicationView: View { + @Environment(\.widgetFamily) private var envFamily + /// 워치 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용 + var previewFamily: WidgetFamily? = nil + private var family: WidgetFamily { previewFamily ?? envFamily } + let entry: GoalRateEntry + + private var percent: Int { Int((entry.ratio * 100).rounded()) } + + var body: some View { + Group { + if entry.locked { + ComplicationLockedView() + } else if let title = entry.title { + switch family { + case .accessoryInline: + Text("\(title) \(percent)%") + case .accessoryRectangular: + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: entry.symbolName) + .font(.system(size: 11)) + Text(title) + .font(.headline) + .lineLimit(1) + } + Gauge(value: min(max(entry.ratio, 0), 1)) { EmptyView() } + .gaugeStyle(.accessoryLinearCapacity) + Text("\(entry.spanLabel) \(percent)%") + .font(.caption2) + .foregroundStyle(.secondary) + } + default: + Gauge(value: min(max(entry.ratio, 0), 1)) { + Image(systemName: entry.symbolName) + } currentValueLabel: { + Text("\(percent)") + .font(.system(size: 13, weight: .bold).monospacedDigit()) + } + .gaugeStyle(.accessoryCircular) + } + } else { + ComplicationEmptyView(message: "목표 없음") + } + } + .containerBackground(.clear, for: .widget) + } +} + +struct GoalRateComplication: Widget { + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: "HaruWatchGoalRate", + intent: GoalRateConfigIntent.self, + provider: GoalRateProvider() + ) { entry in + GoalRateComplicationView(entry: entry) + } + .configurationDisplayName("목표 달성률") + .description("선택한 목표의 하루/주간/월간 달성률을 표시해요. (프리미엄)") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner]) + } +} + +// MARK: - 다짐 달성률 + +struct QuestRateConfigIntent: WidgetConfigurationIntent { + static let title: LocalizedStringResource = "다짐 달성률" + static let description = IntentDescription("표시할 다짐과 기간을 선택하세요.") + + @Parameter(title: "다짐") + var quest: WatchQuestEntity? + + @Parameter(title: "기간", default: .day) + var span: WatchSpanOption +} + +struct QuestRateEntry: TimelineEntry { + let date: Date + let locked: Bool + let quest: WatchQuestInfo? + let spanRaw: String + let spanLabel: String +} + +struct QuestRateProvider: AppIntentTimelineProvider { + static func makeEntry(_ configuration: QuestRateConfigIntent) -> QuestRateEntry { + guard let snapshot = ComplicationStore.snapshot() else { + return QuestRateEntry(date: .now, locked: false, quest: nil, + spanRaw: configuration.span.rawValue, spanLabel: configuration.span.label) + } + guard snapshot.isPremium else { + return QuestRateEntry(date: .now, locked: true, quest: nil, + spanRaw: configuration.span.rawValue, spanLabel: "") + } + let quests = snapshot.goals.flatMap(\.quests) + let quest = quests.first { $0.id == configuration.quest?.id } ?? quests.first + return QuestRateEntry( + date: .now, + locked: false, + quest: quest, + spanRaw: configuration.span.rawValue, + spanLabel: configuration.span.label + ) + } + + func placeholder(in context: Context) -> QuestRateEntry { + QuestRateEntry(date: .now, locked: false, quest: nil, spanRaw: "day", spanLabel: "하루") + } + + func snapshot(for configuration: QuestRateConfigIntent, in context: Context) async -> QuestRateEntry { + Self.makeEntry(configuration) + } + + func timeline(for configuration: QuestRateConfigIntent, in context: Context) async -> Timeline { + Timeline(entries: [Self.makeEntry(configuration)], policy: .after(.now.addingTimeInterval(15 * 60))) + } + + /// 워치 페이스 갤러리에 노출할 추천 구성 (하루/주간/월간) + func recommendations() -> [AppIntentRecommendation] { + WatchSpanOption.allCases.map { span in + let intent = QuestRateConfigIntent() + intent.span = span + return AppIntentRecommendation(intent: intent, description: "다짐 달성률 (\(span.label))") + } + } +} + +struct QuestRateComplicationView: View { + @Environment(\.widgetFamily) private var envFamily + /// 워치 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용 + var previewFamily: WidgetFamily? = nil + private var family: WidgetFamily { previewFamily ?? envFamily } + let entry: QuestRateEntry + + var body: some View { + Group { + if entry.locked { + ComplicationLockedView() + } else if let quest = entry.quest { + switch family { + case .accessoryInline: + Text("\(quest.name) \(inlineText(quest))") + case .accessoryRectangular: + // 크기가 큰 패밀리는 하루/주간/월간 세 개를 모두 표기 (스펙 §9.2-3) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Image(systemName: quest.symbolName) + .font(.system(size: 11)) + Text(quest.name) + .font(.headline) + .lineLimit(1) + if quest.isAtMost { + Text("한도") + .font(.system(size: 9, weight: .bold)) + .padding(.horizontal, 3) + .background(.secondary.opacity(0.3), in: Capsule()) + } + } + HStack(spacing: 8) { + spanGauge(quest, raw: "day", label: "하루") + spanGauge(quest, raw: "week", label: "주간") + spanGauge(quest, raw: "month", label: "월간") + } + } + default: + circular(quest) + } + } else { + ComplicationEmptyView(message: "다짐 없음") + } + } + .containerBackground(.clear, for: .widget) + } + + /// '이하 유지' 다짐은 게이지 대신 한도 상태로 표현 (스펙 §9.2-3) + @ViewBuilder + private func circular(_ quest: WatchQuestInfo) -> some View { + if quest.isAtMost { + VStack(spacing: 1) { + Image(systemName: quest.achieved(forSpanRaw: entry.spanRaw) + ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 16)) + Text(quest.achieved(forSpanRaw: entry.spanRaw) ? "한도 안" : "초과") + .font(.system(size: 9)) + } + } else { + Gauge(value: min(max(quest.ratio(forSpanRaw: entry.spanRaw), 0), 1)) { + Image(systemName: quest.symbolName) + } currentValueLabel: { + Text("\(Int((quest.ratio(forSpanRaw: entry.spanRaw) * 100).rounded()))") + .font(.system(size: 13, weight: .bold).monospacedDigit()) + } + .gaugeStyle(.accessoryCircular) + } + } + + private func inlineText(_ quest: WatchQuestInfo) -> String { + if quest.isAtMost { + return quest.achieved(forSpanRaw: entry.spanRaw) ? "한도 안" : "한도 초과" + } + return "\(Int((quest.ratio(forSpanRaw: entry.spanRaw) * 100).rounded()))%" + } + + private func spanGauge(_ quest: WatchQuestInfo, raw: String, label: String) -> some View { + VStack(spacing: 1) { + Text(label) + .font(.system(size: 8)) + .foregroundStyle(.secondary) + if quest.isAtMost { + Image(systemName: quest.achieved(forSpanRaw: raw) ? "checkmark.circle.fill" : "exclamationmark.triangle.fill") + .font(.system(size: 12)) + } else { + Text("\(Int((quest.ratio(forSpanRaw: raw) * 100).rounded()))%") + .font(.system(size: 11, weight: .semibold).monospacedDigit()) + } + } + .frame(maxWidth: .infinity) + } +} + +struct QuestRateComplication: Widget { + var body: some WidgetConfiguration { + AppIntentConfiguration( + kind: "HaruWatchQuestRate", + intent: QuestRateConfigIntent.self, + provider: QuestRateProvider() + ) { entry in + QuestRateComplicationView(entry: entry) + } + .configurationDisplayName("다짐 달성률") + .description("선택한 다짐의 달성률을 표시해요. 한도형 다짐은 상태로 표시돼요. (프리미엄)") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner]) + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgets/StatusComplication.swift b/myApp/HaruDanim/Haru_DanimWatchWidgets/StatusComplication.swift new file mode 100644 index 0000000..e911b28 --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatchWidgets/StatusComplication.swift @@ -0,0 +1,150 @@ +// +// StatusComplication.swift +// Haru_DanimWatchWidgetsExtension +// +// 현재 현황 컴플리케이션 (CLAUDE.md §9.2-1) +// - 측정 중인 대표 행동(다이나믹 아일랜드와 동일 기준) + 추가 측정 개수 +// - 실행 중인 것이 없으면 "실행 중 아님" +// + +import SwiftUI +import WidgetKit + +struct StatusEntry: TimelineEntry { + let date: Date + let locked: Bool + let runningName: String? + let runningSymbol: String? + let runningStartAt: Date? + let extraCount: Int +} + +struct StatusProvider: TimelineProvider { + static func makeEntry() -> StatusEntry { + guard let snapshot = ComplicationStore.snapshot() else { + return StatusEntry(date: .now, locked: false, runningName: nil, runningSymbol: nil, + runningStartAt: nil, extraCount: 0) + } + guard snapshot.isPremium else { + return StatusEntry(date: .now, locked: true, runningName: nil, runningSymbol: nil, + runningStartAt: nil, extraCount: 0) + } + return StatusEntry( + date: .now, + locked: false, + runningName: snapshot.runningName, + runningSymbol: snapshot.runningSymbol, + runningStartAt: snapshot.runningStartAt, + extraCount: snapshot.extraRunningCount + ) + } + + func placeholder(in context: Context) -> StatusEntry { + StatusEntry(date: .now, locked: false, runningName: "독서", runningSymbol: "book.fill", + runningStartAt: .now, extraCount: 0) + } + + func getSnapshot(in context: Context, completion: @escaping (StatusEntry) -> Void) { + completion(Self.makeEntry()) + } + + func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { + completion(Timeline(entries: [Self.makeEntry()], policy: .after(.now.addingTimeInterval(15 * 60)))) + } +} + +struct StatusComplicationView: View { + @Environment(\.widgetFamily) private var envFamily + /// 워치 앱 내 DEBUG 미리보기에서 패밀리를 강제할 때 사용 + var previewFamily: WidgetFamily? = nil + private var family: WidgetFamily { previewFamily ?? envFamily } + let entry: StatusEntry + + var body: some View { + Group { + if entry.locked { + ComplicationLockedView() + } else if let name = entry.runningName { + switch family { + case .accessoryInline: + if entry.extraCount > 0 { + Text("\(name) +\(entry.extraCount)") + } else { + Text(name) + } + case .accessoryRectangular: + VStack(alignment: .leading, spacing: 1) { + HStack(spacing: 4) { + Image(systemName: entry.runningSymbol ?? "record.circle") + .font(.system(size: 11)) + Text(name) + .font(.headline) + .lineLimit(1) + // 추가 측정이 있을 때만 개수 표시 (스펙 §9.2-1) + if entry.extraCount > 0 { + Text("+\(entry.extraCount)") + .font(.caption2.weight(.bold)) + .foregroundStyle(.secondary) + } + } + if let start = entry.runningStartAt { + Text(start, style: .timer) + .font(.system(.body, design: .rounded).monospacedDigit()) + } + Text("측정 중") + .font(.caption2) + .foregroundStyle(.secondary) + } + default: + VStack(spacing: 1) { + Image(systemName: entry.runningSymbol ?? "record.circle") + .font(.system(size: 14, weight: .semibold)) + if let start = entry.runningStartAt { + Text(start, style: .timer) + .font(.system(size: 11).monospacedDigit()) + .multilineTextAlignment(.center) + } + if entry.extraCount > 0 { + Text("+\(entry.extraCount)") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.secondary) + } + } + } + } else { + switch family { + case .accessoryInline: + Text("실행 중 아님") + case .accessoryRectangular: + VStack(alignment: .leading, spacing: 1) { + Text("하루다님") + .font(.headline) + Text("실행 중 아님") + .font(.caption2) + .foregroundStyle(.secondary) + } + default: + VStack(spacing: 2) { + Image(systemName: "pause.circle") + .font(.system(size: 16)) + Text("없음") + .font(.system(size: 9)) + .foregroundStyle(.secondary) + } + } + } + } + .containerBackground(.clear, for: .widget) + } +} + +struct StatusComplication: Widget { + var body: some WidgetConfiguration { + StaticConfiguration(kind: "HaruWatchStatus", provider: StatusProvider()) { entry in + StatusComplicationView(entry: entry) + } + .configurationDisplayName("현재 현황") + .description("측정 중인 행동과 경과 시간을 표시해요. (프리미엄)") + .supportedFamilies([.accessoryCircular, .accessoryRectangular, .accessoryInline, .accessoryCorner]) + } +} diff --git a/myApp/HaruDanim/Haru_DanimWatchWidgetsExtension.entitlements b/myApp/HaruDanim/Haru_DanimWatchWidgetsExtension.entitlements new file mode 100644 index 0000000..64b9130 --- /dev/null +++ b/myApp/HaruDanim/Haru_DanimWatchWidgetsExtension.entitlements @@ -0,0 +1,10 @@ + + + + + com.apple.security.application-groups + + group.com.yechan.HaruDanim + + +