diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index 591faa3..179b3bc 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -12,15 +12,17 @@ permissions: jobs: build-and-test: name: Build and test - runs-on: macos-26 + runs-on: xcode-27 timeout-minutes: 30 steps: - name: Check out repository uses: actions/checkout@v7 - - name: Select Xcode 26.6 - run: sudo xcode-select -s /Applications/Xcode_26.6.app/Contents/Developer + - name: Verify Xcode 27 toolchain + run: | + xcodebuild -version + xcodebuild -showsdks - name: Select an available iPhone simulator shell: bash @@ -33,23 +35,34 @@ jobs: runtimes = json.load(sys.stdin)["devices"] iphones = [ device - for devices in runtimes.values() + for runtime, devices in runtimes.items() + if "iOS-27" in runtime for device in devices if device["name"].startswith("iPhone") and device["isAvailable"] ] if not iphones: - raise SystemExit("No available iPhone simulator found") + raise SystemExit("No available iOS 27 iPhone simulator found") print(iphones[0]["udid"]) ' )" echo "SIMULATOR_ID=$simulator_id" >> "$GITHUB_ENV" - - name: Build and run unit tests + - name: Build and run unit and interaction tests run: | xcodebuild test \ -project DotoryCount.xcodeproj \ -scheme DotoryCount \ -destination "platform=iOS Simulator,id=$SIMULATOR_ID" \ -only-testing:DotoryCountTests \ + -only-testing:DotoryCountUITests/DotoryCountUITests \ + -resultBundlePath TestResults.xcresult \ -parallel-testing-enabled NO \ CODE_SIGNING_ALLOWED=NO + + - name: Preserve test results and screenshots + if: always() + uses: actions/upload-artifact@v7 + with: + name: ios-test-results + path: TestResults.xcresult + retention-days: 7 diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b7d509..0b8478b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ All notable changes to DotoryCount are recorded in this file. +## [0.6.0] - 2026-10-04 + +- Refine the app around a photographic jar exhibit, warm paper surfaces, + editorial typography, and quiet supporting day counts. +- Reserve Liquid Glass for interactive controls; add adaptive dark appearance + and scrollable layouts for larger text. +- Redesign anniversary editing with a live jar preview, calendar/direct date + entry, and quick date presets without automatically opening the keyboard. +- Share the exact calendar calculation and jar artwork between app and widget; + schedule seven midnight widget updates for accurate future day counts. +- Preserve existing local records, 36 fill stages, golden milestones, and replay. +- Build with Xcode 27 / iOS 27 SDK; app and widget version 0.6.0 (build 12). + ## [0.5.0] - 2026-09-26 - Apply Liquid Glass to the dashboard action and information panel on iOS 26+, diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 0000000..6eaffdd --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,45 @@ +# DotoryCount design direction + +## 시간을 담는 작은 오브제 + +DotoryCount의 중심은 날짜 숫자가 아니라 유리병을 보고 도토리를 담는 경험이다. +앱 아이콘의 사진형 유리병과 도토리를 시각 언어의 기준으로 삼는다. 화면은 +따뜻한 종이 위에 하나의 오브제를 전시한 듯 조용하고 여유 있게 구성한다. + +### 시각 원칙 + +- 따뜻한 아이보리, 잉크 같은 갈색, 황금도토리의 금색을 사용한다. +- 사진형 병은 화면에서 가장 큰 요소다. 장식용 도형과 큰 카드로 둘러싸지 않는다. +- 기념일 이름은 부드러운 세리프, 날짜 숫자는 가독성 좋은 라운드 숫자로 표현한다. +- 본문은 시스템 글꼴과 Dynamic Type을 사용한다. 큰 글자는 자연스럽게 줄바꿈하고 스크롤한다. +- Liquid Glass는 편집, 재생, 저장 같은 조작부에 사용한다. 읽는 정보는 무광 표면에 둔다. +- 밝은 화면과 어두운 화면 모두 따뜻한 색조와 읽기 쉬운 대비를 유지한다. + +### 화면과 흐름 + +1. 첫 시작: 빈 유리병, 짧은 설명, 한 개의 기념일 만들기 버튼. +2. 홈: 이름과 시작일 → 병과 낙하 → 다시 담기 → 작은 D+ 숫자 → 다음 황금도토리. +3. 편집: 이름과 기준일을 각각 명확한 입력 영역에 놓고 아래에서 결과를 미리 확인한다. +4. 날짜 선택: 선택된 날짜를 먼저 보여주고 달력·직접 입력·빠른 선택을 제공한다. +5. 황금도토리: 이미지, 기념일, 날짜, 짧은 설명에 집중한다. +6. 홈 화면 위젯: 투명 여백을 잘라낸 큰 병과 아래의 작은 D+ 숫자. +7. 잠금화면 위젯: 이름과 숫자만 간결하게 읽히는 직사각형 구성. + +### 만듦새 기준 + +- 병의 바닥·유리 반사·도토리 그림자는 앱과 위젯에서 같은 렌더링을 사용한다. +- 도토리는 입구를 통해 떨어지고, 바닥 위에 놓이며, 재생할 때 회전과 튀는 방향이 달라진다. +- 병과 조작 영역을 독립된 레이아웃으로 만들어 이름이나 숫자가 병 위에 겹치지 않는다. +- 기념일 날짜 계산, 윤년, 주년, 황금도토리는 앱과 위젯에서 동일한 계산기를 사용한다. +- 위젯에는 앞으로 7일의 자정 표시를 미리 제공한다. +- 동작 줄이기에서는 정지된 병을 표시하고 VoiceOver로 병·날짜·조작을 설명한다. + +### 플랫폼 검증 + +개발 및 시뮬레이터 빌드는 Xcode 27 / iOS 27 SDK를 사용한다. 2026-10-04에 +iPhone Air의 iOS 27.0.1 (24A446)에서 설치·실행과 다크 모드 홈 화면 배치를 +확인했다. 기기의 패치 버전과 SDK 버전은 따로 기록한다. 자동 테스트 결과와 +실기기에서 직접 확인한 항목은 구분한다. + +이전 지원 범위인 iOS 17 이상은 유지한다. iOS 26 이상은 시스템 Liquid Glass, +그 이전은 표준 소재·버튼을 사용한다. diff --git a/DotoryCount.xcodeproj/project.pbxproj b/DotoryCount.xcodeproj/project.pbxproj index b7921c8..7268d69 100644 --- a/DotoryCount.xcodeproj/project.pbxproj +++ b/DotoryCount.xcodeproj/project.pbxproj @@ -40,6 +40,7 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + D06000000000000000000001 /* Shared */ = {isa = PBXFileSystemSynchronizedRootGroup; path = Shared; sourceTree = ""; }; D05000000000000000000003 /* DotoryCountWidget */ = {isa = PBXFileSystemSynchronizedRootGroup; path = DotoryCountWidget; sourceTree = ""; }; A607FF973055937D00D23132 /* DotoryCount */ = { isa = PBXFileSystemSynchronizedRootGroup; @@ -89,6 +90,7 @@ children = ( A607FF973055937D00D23132 /* DotoryCount */, D05000000000000000000003 /* DotoryCountWidget */, + D06000000000000000000001 /* Shared */, A607FFA53055937F00D23132 /* DotoryCountTests */, A607FFAF3055937F00D23132 /* DotoryCountUITests */, A607FF963055937D00D23132 /* Products */, @@ -115,7 +117,7 @@ buildPhases = (D05000000000000000000006 /* Sources */, D05000000000000000000004 /* Frameworks */, D05000000000000000000007 /* Resources */); buildRules = (); dependencies = (); - fileSystemSynchronizedGroups = (D05000000000000000000003 /* DotoryCountWidget */); + fileSystemSynchronizedGroups = (D05000000000000000000003 /* DotoryCountWidget */, D06000000000000000000001 /* Shared */); name = DotoryCountWidget; packageProductDependencies = (); productName = DotoryCountWidget; @@ -136,6 +138,7 @@ dependencies = (D0500000000000000000000A /* PBXTargetDependency */); fileSystemSynchronizedGroups = ( A607FF973055937D00D23132 /* DotoryCount */, + D06000000000000000000001 /* Shared */, ); name = DotoryCount; packageProductDependencies = ( @@ -305,12 +308,12 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = DotoryCountWidget/DotoryCountWidget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = W395S42ZJ9; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Configuration/WidgetInfo.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 0.5.0; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.dotoryman.dotorycount.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -321,12 +324,12 @@ APPLICATION_EXTENSION_API_ONLY = YES; CODE_SIGN_ENTITLEMENTS = DotoryCountWidget/DotoryCountWidget.entitlements; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = W395S42ZJ9; GENERATE_INFOPLIST_FILE = NO; INFOPLIST_FILE = Configuration/WidgetInfo.plist; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 0.5.0; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.dotoryman.dotorycount.widget; PRODUCT_NAME = "$(TARGET_NAME)"; SKIP_INSTALL = YES; @@ -461,7 +464,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = W395S42ZJ9; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -474,7 +477,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.5.0; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.dotoryman.dotorycount; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -494,7 +497,7 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 11; + CURRENT_PROJECT_VERSION = 12; DEVELOPMENT_TEAM = W395S42ZJ9; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; @@ -507,7 +510,7 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 0.5.0; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.dotoryman.dotorycount; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = YES; @@ -525,10 +528,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 0.4.2; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.dotoryman.dotorycount.tests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -546,10 +549,10 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 17.0; - MARKETING_VERSION = 0.4.2; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.dotoryman.dotorycount.tests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -566,9 +569,9 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.4.2; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.dotoryman.dotorycount.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; @@ -585,9 +588,9 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 10; + CURRENT_PROJECT_VERSION = 12; GENERATE_INFOPLIST_FILE = YES; - MARKETING_VERSION = 0.4.2; + MARKETING_VERSION = 0.6.0; PRODUCT_BUNDLE_IDENTIFIER = com.dotoryman.dotorycount.uitests; PRODUCT_NAME = "$(TARGET_NAME)"; STRING_CATALOG_GENERATE_SYMBOLS = NO; diff --git a/DotoryCount/DesignSystem/AppTheme.swift b/DotoryCount/DesignSystem/AppTheme.swift index 723a1e6..a14475f 100644 --- a/DotoryCount/DesignSystem/AppTheme.swift +++ b/DotoryCount/DesignSystem/AppTheme.swift @@ -3,16 +3,16 @@ import UIKit enum AppTheme { static let background = adaptive( - light: UIColor(red: 0.97, green: 0.95, blue: 0.90, alpha: 1), - dark: UIColor(red: 0.10, green: 0.085, blue: 0.07, alpha: 1) + light: UIColor(red: 0.982, green: 0.971, blue: 0.947, alpha: 1), + dark: UIColor(red: 0.105, green: 0.094, blue: 0.081, alpha: 1) ) static let surface = adaptive( - light: UIColor(white: 1, alpha: 0.72), - dark: UIColor(red: 0.18, green: 0.16, blue: 0.13, alpha: 0.92) + light: UIColor(red: 1, green: 0.992, blue: 0.977, alpha: 1), + dark: UIColor(red: 0.17, green: 0.151, blue: 0.125, alpha: 1) ) static let accent = adaptive( - light: UIColor(red: 0.43, green: 0.27, blue: 0.13, alpha: 1), - dark: UIColor(red: 0.83, green: 0.64, blue: 0.40, alpha: 1) + light: UIColor(red: 0.37, green: 0.25, blue: 0.16, alpha: 1), + dark: UIColor(red: 0.89, green: 0.73, blue: 0.53, alpha: 1) ) static let acorn = adaptive( light: UIColor(red: 0.55, green: 0.32, blue: 0.13, alpha: 1), @@ -35,10 +35,19 @@ enum AppTheme { light: UIColor(red: 0.78, green: 0.90, blue: 0.90, alpha: 1), dark: UIColor(red: 0.43, green: 0.62, blue: 0.64, alpha: 1) ) - static let secondaryText = Color.primary.opacity(0.62) + static let ink = adaptive( + light: UIColor(red: 0.24, green: 0.20, blue: 0.16, alpha: 1), + dark: UIColor(red: 0.95, green: 0.91, blue: 0.85, alpha: 1) + ) + static let secondaryText = adaptive( + light: UIColor(red: 0.49, green: 0.44, blue: 0.38, alpha: 1), + dark: UIColor(red: 0.71, green: 0.66, blue: 0.58, alpha: 1) + ) + static let divider = ink.opacity(0.10) + static let gold = Color(red: 0.67, green: 0.47, blue: 0.19) static let cardCornerRadius: CGFloat = 24 - static let horizontalPadding: CGFloat = 20 + static let horizontalPadding: CGFloat = 26 private static func adaptive(light: UIColor, dark: UIColor) -> Color { Color(uiColor: UIColor { traits in diff --git a/DotoryCount/DesignSystem/EditorialComponents.swift b/DotoryCount/DesignSystem/EditorialComponents.swift new file mode 100644 index 0000000..a1d2238 --- /dev/null +++ b/DotoryCount/DesignSystem/EditorialComponents.swift @@ -0,0 +1,60 @@ +import SwiftUI + +struct PaperBackdrop: View { + var body: some View { + AppTheme.background + .overlay(alignment: .top) { + LinearGradient(colors: [AppTheme.surface.opacity(0.65), .clear], + startPoint: .top, endPoint: .bottom) + .frame(height: 460) + } + .ignoresSafeArea() + } +} + +struct BrandWordmark: View { + var body: some View { + HStack(spacing: 7) { + Image("AcornSprite").resizable().scaledToFit().frame(width: 24, height: 24) + .accessibilityHidden(true) + Text("DOTORY COUNT") + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .tracking(2.3) + .foregroundStyle(AppTheme.accent) + } + .accessibilityLabel("도토리 카운트") + } +} + +struct EditorialEyebrow: View { + let title: String + var body: some View { + Text(title) + .font(.caption.weight(.medium)) + .foregroundStyle(AppTheme.secondaryText) + } +} + +struct PaperSurface: ViewModifier { + var cornerRadius: CGFloat = 24 + func body(content: Content) -> some View { + content + .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: cornerRadius)) + .overlay { + RoundedRectangle(cornerRadius: cornerRadius) + .strokeBorder(AppTheme.divider.opacity(0.55), lineWidth: 0.5) + } + } +} + +/// Glass is a functional layer. The photograph and reading surfaces remain matte. +struct GlassControlGroup: View { + @ViewBuilder var content: () -> Content + var body: some View { + if #available(iOS 26.0, *) { + GlassEffectContainer(spacing: 16) { content() } + } else { + content() + } + } +} diff --git a/DotoryCount/Features/Editor/AnniversaryEditorView.swift b/DotoryCount/Features/Editor/AnniversaryEditorView.swift index af2e606..aa18632 100644 --- a/DotoryCount/Features/Editor/AnniversaryEditorView.swift +++ b/DotoryCount/Features/Editor/AnniversaryEditorView.swift @@ -24,63 +24,88 @@ struct AnniversaryEditorView: View { var body: some View { NavigationStack { - Form { - Section { - TextField("이름", text: $title, prompt: Text("우리의 시작")) - .textInputAutocapitalization(.sentences) - .submitLabel(.done) - .focused($isTitleFocused) - .accessibilityIdentifier("anniversary-title-field") + ScrollView { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 10) { + Image("AcornSprite").resizable().scaledToFit() + .frame(width: 44, height: 44).accessibilityHidden(true) + Text(anniversary == nil ? "어떤 날을 기억할까요?" : "우리의 시간을 다듬어요") + .font(.system(.title2, design: .serif).weight(.medium)) + Text("이름과 시작한 날만 정하면 준비가 끝나요.") + .font(.subheadline) + .foregroundStyle(AppTheme.secondaryText) + } + .padding(.top, 8) + + VStack(alignment: .leading, spacing: 12) { + EditorialEyebrow(title: "기념일 이름") + TextField("이름", text: $title, prompt: Text("우리의 시작")) + .font(.title3.weight(.medium)) + .textInputAutocapitalization(.sentences) + .submitLabel(.done) + .focused($isTitleFocused) + .onSubmit { isTitleFocused = false } + .accessibilityIdentifier("anniversary-title-field") + HStack { + Text("짧고 소중한 이름을 붙여주세요.") + Spacer() + Text("\(title.count)/\(maximumTitleLength)") + .monospacedDigit() + .foregroundStyle(title.count > maximumTitleLength ? .red : AppTheme.secondaryText) + } + .font(.caption2) + .foregroundStyle(AppTheme.secondaryText) + } + .padding(20) + .modifier(PaperSurface()) Button { isTitleFocused = false dateSheet = .choose } label: { - HStack { - Text("기준일") - .foregroundStyle(.primary) - Spacer() - Text(startDate.formatted(date: .abbreviated, time: .omitted)) - .foregroundStyle(AppTheme.secondaryText) + HStack(spacing: 14) { + Image(systemName: "calendar") + .font(.title3) + .foregroundStyle(AppTheme.accent) + VStack(alignment: .leading, spacing: 6) { + EditorialEyebrow(title: "처음 시작한 날") + Text(startDate.formatted(date: .long, time: .omitted)) + .font(.subheadline.weight(.medium)) + .foregroundStyle(AppTheme.ink) + } + Spacer(minLength: 4) Image(systemName: "chevron.right") .font(.caption.weight(.semibold)) .foregroundStyle(AppTheme.secondaryText) } - .contentShape(Rectangle()) + .padding(20) + .frame(minHeight: 90) + .modifier(PaperSurface()) + .contentShape(RoundedRectangle(cornerRadius: 24)) } .buttonStyle(.plain) .accessibilityIdentifier("anniversary-date-picker") - } header: { - Text("기념일") - } footer: { - HStack { - Text("이름은 30자까지 입력할 수 있어요.") - Spacer() - Text("\(title.count)/\(maximumTitleLength)") - .monospacedDigit() - .foregroundStyle(title.count > maximumTitleLength ? .red : AppTheme.secondaryText) - } - } - Section { - Text("입력한 날짜부터 하루마다 도토리가 하나씩 쌓입니다. 1주년마다 가득 찬 병을 보관하고 새 병을 시작해요.") - .font(.footnote) - .foregroundStyle(AppTheme.secondaryText) - } + draftPreview - if anniversary != nil { - Section { + if anniversary != nil { Button("기념일 삭제", role: .destructive) { presentedAlert = .deleteConfirmation } + .font(.subheadline) + .frame(maxWidth: .infinity, minHeight: 44) .accessibilityIdentifier("delete-anniversary-button") .accessibilityHint("저장된 기념일을 삭제하기 전에 확인합니다") } } + .padding(.horizontal, AppTheme.horizontalPadding) + .padding(.bottom, 30) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) } - .scrollContentBackground(.hidden) - .background(AppTheme.background) .scrollDismissesKeyboard(.interactively) + .background { PaperBackdrop() } + .foregroundStyle(AppTheme.ink) .navigationTitle(anniversary == nil ? "기념일 만들기" : "기념일 수정") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -93,11 +118,6 @@ struct AnniversaryEditorView: View { .accessibilityIdentifier("save-anniversary-button") } } - .task { - if anniversary == nil { - isTitleFocused = true - } - } .sheet(item: $dateSheet) { _ in StartDateSelectionSheet(date: $startDate) } @@ -121,6 +141,29 @@ struct AnniversaryEditorView: View { } } + private var draftPreview: some View { + let progress = AnniversaryCalculator.progress(from: startDate) + return HStack(spacing: 18) { + JarArtworkView(progress: progress, viewport: .widget) + .frame(width: 74, height: 94) + VStack(alignment: .leading, spacing: 6) { + EditorialEyebrow(title: "이 병에 담길 시간") + Text(progress.counterText) + .font(.system(.title2, design: .rounded).weight(.medium)) + .monospacedDigit() + Text(progress.isWaitingToStart + ? "시작일까지 \(abs(progress.elapsedDays))일 남았어요." + : "하루마다 하나씩, \(progress.completedJars + 1)번째 병에 담겨요.") + .font(.caption) + .foregroundStyle(AppTheme.secondaryText) + .fixedSize(horizontal: false, vertical: true) + } + Spacer(minLength: 0) + } + .padding(.vertical, 6) + .accessibilityElement(children: .combine) + } + private var trimmedTitle: String { title.trimmingCharacters(in: .whitespacesAndNewlines) } diff --git a/DotoryCount/Features/Editor/StartDateSelectionSheet.swift b/DotoryCount/Features/Editor/StartDateSelectionSheet.swift index bd1cd5a..133312c 100644 --- a/DotoryCount/Features/Editor/StartDateSelectionSheet.swift +++ b/DotoryCount/Features/Editor/StartDateSelectionSheet.swift @@ -63,57 +63,80 @@ struct StartDateSelectionSheet: View { var body: some View { NavigationStack { - Form { - Section("빠른 선택") { - HStack(spacing: 10) { - preset("오늘", daysAgo: 0) - preset("어제", daysAgo: 1) - preset("1년 전", yearsAgo: 1) + ScrollView { + VStack(alignment: .leading, spacing: 22) { + VStack(alignment: .leading, spacing: 8) { + EditorialEyebrow(title: "처음 시작한 날") + Text(selectedDate?.formatted(date: .long, time: .omitted) ?? "날짜를 확인해 주세요") + .font(.system(.title2, design: .serif).weight(.medium)) + .fixedSize(horizontal: false, vertical: true) + Text("지난 날짜도, 앞으로 찾아올 날짜도 좋아요.") + .font(.caption) + .foregroundStyle(AppTheme.secondaryText) } - .modifier(GlassSecondaryActionStyle()) - .listRowBackground(Color.clear) - } + .padding(.top, 12) - Section { - Picker("선택 방법", selection: $mode) { - ForEach(DateChoiceMode.allCases, id: \.self) { choice in - Text(choice.rawValue).tag(choice) + VStack(spacing: 16) { + Picker("선택 방법", selection: $mode) { + ForEach(DateChoiceMode.allCases, id: \.self) { choice in + Text(choice.rawValue).tag(choice) + } + } + .pickerStyle(.segmented) + + if mode == .calendar { + DatePicker("기준일", selection: $draftDate, displayedComponents: .date) + .datePickerStyle(.graphical) + .labelsHidden() + .accessibilityIdentifier("anniversary-graphical-date-picker") + } else { + VStack(alignment: .leading, spacing: 12) { + TextField("YYYY.MM.DD", text: $dateText) + .font(.system(.title2, design: .monospaced)) + .keyboardType(.numbersAndPunctuation) + .submitLabel(.done) + .focused($isDateTextFocused) + .onSubmit { isDateTextFocused = false } + .accessibilityIdentifier("anniversary-direct-date-field") + Divider() + Text("예: 2020.09.24 또는 20200924") + .font(.caption) + .foregroundStyle(AppTheme.secondaryText) + if !dateText.isEmpty && StartDateInput.parse(dateText) == nil { + Label("달력에 있는 날짜를 입력해 주세요.", systemImage: "exclamationmark.circle") + .font(.caption) + .foregroundStyle(.red) + } + } + .padding(.vertical, 12) + .padding(.horizontal, 4) } } - .pickerStyle(.segmented) - - if mode == .calendar { - DatePicker("기준일", selection: $draftDate, displayedComponents: .date) - .datePickerStyle(.graphical) - .labelsHidden() - .accessibilityIdentifier("anniversary-graphical-date-picker") - } else { - TextField("YYYY.MM.DD", text: $dateText) - .keyboardType(.numbersAndPunctuation) - .textContentType(.none) - .focused($isDateTextFocused) - .accessibilityIdentifier("anniversary-direct-date-field") - Text("예: 2020.09.24 또는 20200924") - .font(.footnote) - .foregroundStyle(AppTheme.secondaryText) - if !dateText.isEmpty && StartDateInput.parse(dateText) == nil { - Text("실제 달력에 있는 날짜를 입력해 주세요.") - .font(.footnote) - .foregroundStyle(.red) + .padding(16) + .modifier(PaperSurface()) + + VStack(alignment: .leading, spacing: 12) { + EditorialEyebrow(title: "빠른 선택") + GlassControlGroup { + HStack(spacing: 10) { + preset("오늘", daysAgo: 0) + preset("어제", daysAgo: 1) + preset("1년 전", yearsAgo: 1) + } + .font(.subheadline.weight(.medium)) + .modifier(GlassSecondaryActionStyle()) + .buttonBorderShape(.capsule) } } - } header: { - Text("날짜 선택") - } - - Section { - Text("선택한 날짜 · \(selectedDate.map { StartDateInput.format($0) } ?? "확인 필요")") - .font(.subheadline.weight(.medium)) - .monospacedDigit() } + .padding(.horizontal, AppTheme.horizontalPadding) + .padding(.bottom, 30) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity) } - .scrollContentBackground(.hidden) - .background(AppTheme.background) + .scrollDismissesKeyboard(.interactively) + .background { PaperBackdrop() } + .foregroundStyle(AppTheme.ink) .navigationTitle("기준일 선택") .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -131,7 +154,10 @@ struct StartDateSelectionSheet: View { } .onChange(of: mode) { _, newValue in if newValue == .direct { isDateTextFocused = true } - else { isDateTextFocused = false } + else { + if let parsed = StartDateInput.parse(dateText) { draftDate = parsed } + isDateTextFocused = false + } } } } diff --git a/DotoryCount/Features/Home/CinematicAnniversaryDashboard.swift b/DotoryCount/Features/Home/CinematicAnniversaryDashboard.swift index b6cdbe2..832b7fb 100644 --- a/DotoryCount/Features/Home/CinematicAnniversaryDashboard.swift +++ b/DotoryCount/Features/Home/CinematicAnniversaryDashboard.swift @@ -1,6 +1,6 @@ import SwiftUI -/// The jar is the primary scene; dates and milestones stay as supporting detail. +/// An object-first home: one photograph, one replay action, one quiet day count. struct CinematicAnniversaryDashboard: View { let anniversary: Anniversary let replayToken: Int @@ -8,7 +8,9 @@ struct CinematicAnniversaryDashboard: View { @Environment(\.scenePhase) private var scenePhase @Environment(\.accessibilityReduceMotion) private var reduceMotion - @State private var selectedMilestone: AnniversaryMilestone? + @Environment(\.dynamicTypeSize) private var dynamicType + @ScaledMetric(relativeTo: .largeTitle) private var counterSize = 42 + @State private var milestoneSheet: MilestoneSheet? @State private var replayID = 0 @State private var isDropping = false @State private var dropTask: Task? @@ -18,51 +20,40 @@ struct CinematicAnniversaryDashboard: View { } var body: some View { - ZStack { - Color(red: 1, green: 0.977, blue: 0.937) - .ignoresSafeArea() - - PhotographicJarScene( - progress: progress, - replayID: replayID, - reduceMotion: reduceMotion - ) - .allowsHitTesting(false) - - VStack(spacing: 0) { - header - .padding(.horizontal, 26) - .padding(.top, 16) - - Spacer(minLength: 0) - - footer - .padding(.horizontal, 26) - .padding(.bottom, 16) - } - - GeometryReader { geometry in - Button(action: replay) { - Rectangle() - .fill(.clear) - .frame( - width: geometry.size.width, - height: max(160, min(geometry.size.height - 290, geometry.size.width * 1.12)) - ) - .contentShape(Rectangle()) + GeometryReader { geometry in + TimelineView(.periodic(from: .now, by: 60)) { _ in + ScrollView { + VStack(spacing: 0) { + topBar + .padding(.bottom, 14) + introduction + jarExhibit(height: dynamicType.isAccessibilitySize ? 280 : + max(270, min(410, geometry.size.height - 340))) + replayButton + .padding(.top, 2) + daySummary + .padding(.top, 18) + .padding(.bottom, 22) + milestoneCard + .padding(.horizontal, AppTheme.horizontalPadding) + .padding(.bottom, 22) + } + .frame(maxWidth: 520) + .frame(maxWidth: .infinity, minHeight: geometry.size.height) } - .buttonStyle(.plain) - .position(x: geometry.size.width / 2, y: geometry.size.height / 2 - 12) - .accessibilityLabel("유리병 속 도토리") - .accessibilityValue("함께한 날 \(progress.acornsInCurrentJar)일" + (isDropping ? ", 도토리 떨어지는 중" : "")) - .accessibilityIdentifier("acorn-jar") + .scrollIndicators(.hidden) } - } + .background { PaperBackdrop() } + .foregroundStyle(AppTheme.ink) .toolbar(.hidden, for: .navigationBar) - .preferredColorScheme(.light) - .sheet(item: $selectedMilestone) { milestone in - MilestoneDetailView(milestone: milestone) + .sheet(item: $milestoneSheet) { destination in + switch destination { + case .celebration(let milestone): + MilestoneDetailView(milestone: milestone) + case .upcoming(let milestone): + UpcomingMilestoneView(milestone: milestone) + } } .onAppear { replay() } .onChange(of: replayToken) { _, _ in replay() } @@ -70,71 +61,138 @@ struct CinematicAnniversaryDashboard: View { guard previous != .active, current == .active else { return } replay() } - .onChange(of: selectedMilestone?.id) { previous, current in + .onChange(of: milestoneSheet?.id) { previous, current in guard previous != nil, current == nil else { return } replay() } .onDisappear { dropTask?.cancel() } } - private var header: some View { - HStack(alignment: .top) { - VStack(alignment: .leading, spacing: 5) { - Text("DOTORY COUNT") - .font(.system(size: 11, weight: .bold, design: .rounded)) - .tracking(3) - .foregroundStyle(AppTheme.accent) - Text(anniversary.title) - .font(.system(size: 27, weight: .semibold, design: .serif)) - .lineLimit(2) - Text(anniversary.startDate.formatted(date: .long, time: .omitted)) - .font(.system(size: 13)) - .foregroundStyle(AppTheme.secondaryText) + private var topBar: some View { + GlassControlGroup { + HStack { + BrandWordmark() + Spacer() + Button(action: editAction) { + Image(systemName: "slider.horizontal.3") + .font(.system(size: 16, weight: .medium)) + .frame(width: 44, height: 44) + } + .modifier(GlassActionStyle()) + .accessibilityLabel("기념일 수정") + .accessibilityIdentifier("edit-anniversary-button") } - Spacer(minLength: 12) - Button(action: editAction) { - Image(systemName: "ellipsis") - .font(.system(size: 19, weight: .medium)) - .frame(width: 42, height: 42) + } + .padding(.horizontal, AppTheme.horizontalPadding) + .padding(.top, 8) + } + + private var introduction: some View { + VStack(spacing: 8) { + Text(anniversary.title) + .font(.system(.title, design: .serif).weight(.medium)) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + Text("\(anniversary.startDate.formatted(.dateTime.year().month(.twoDigits).day(.twoDigits)))부터") + .font(.caption) + .foregroundStyle(AppTheme.secondaryText) + } + .padding(.horizontal, 30) + .padding(.bottom, 6) + } + + private func jarExhibit(height: CGFloat) -> some View { + Button(action: replay) { + PhotographicJarScene(progress: progress, replayID: replayID, reduceMotion: reduceMotion) + .frame(height: height) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel("유리병 속 도토리") + .accessibilityValue("함께한 날 \(progress.acornsInCurrentJar)일" + + (isDropping ? ", 도토리 떨어지는 중" : "")) + .accessibilityHint("탭하면 오늘의 도토리가 병 입구로 떨어집니다") + .accessibilityIdentifier("acorn-jar") + } + + private var replayButton: some View { + GlassControlGroup { + Button(action: replay) { + Label("한 번 더 담아보기", systemImage: "arrow.clockwise") + .font(.caption.weight(.medium)) + .padding(.horizontal, 8) + .padding(.vertical, 7) } - .modifier(GlassActionStyle()) - .accessibilityLabel("기념일 수정") - .accessibilityIdentifier("edit-anniversary-button") + .modifier(GlassSecondaryActionStyle()) + .buttonBorderShape(.capsule) + .disabled(progress.isWaitingToStart || progress.acornsInCurrentJar == 0) + .accessibilityIdentifier("replay-acorn-button") } } - private var footer: some View { - VStack(alignment: .leading, spacing: 9) { - HStack(alignment: .firstTextBaseline, spacing: 12) { - Text(progress.counterText) - .font(.system(size: 36, weight: .light, design: .rounded)) - .monospacedDigit() - .accessibilityIdentifier("day-counter") - Text("\(progress.completedJars + 1)번째 병 · \(progress.acornsInCurrentJar)일") - .font(.system(size: 13, weight: .medium)) + private var daySummary: some View { + VStack(spacing: 7) { + Text(progress.counterText) + .font(.system(size: counterSize, weight: .light, design: .rounded)) + .monospacedDigit() + .contentTransition(.numericText()) + .accessibilityIdentifier("day-counter") + if progress.isWaitingToStart { + Text("첫 도토리까지 \(abs(progress.elapsedDays))일") + .font(.caption) .foregroundStyle(AppTheme.secondaryText) - Spacer(minLength: 0) + } else { + Text("\(progress.completedJars + 1)번째 병 · \(progress.acornsInCurrentJar) / \(progress.currentJarCapacity)일") + .font(.caption) + .foregroundStyle(AppTheme.secondaryText) + ProgressView(value: Double(progress.acornsInCurrentJar), + total: Double(progress.currentJarCapacity)) + .tint(AppTheme.accent.opacity(0.65)) + .frame(width: 100) + .scaleEffect(y: 0.6) + .accessibilityLabel("현재 병에 담긴 시간") } - HStack(spacing: 8) { - Image(systemName: "sparkle") - .foregroundStyle(AppTheme.accent) - Text(progress.todayMilestone.map { "오늘은 \($0.title)" } ?? "다음 황금도토리 · \(progress.nextMilestone.title)") - .font(.system(size: 13, weight: .semibold)) - Spacer(minLength: 0) - if let milestone = progress.todayMilestone { - Button("황금도토리 \(milestone.title)") { - selectedMilestone = milestone - } - .font(.system(size: 12, weight: .semibold)) - .accessibilityIdentifier("milestone-celebration") + } + } + + private var milestoneCard: some View { + Button { + if let milestone = progress.todayMilestone { + milestoneSheet = .celebration(milestone) + } else { + milestoneSheet = .upcoming(progress.nextMilestone) + } + } label: { + HStack(spacing: 12) { + Image("GoldenAcornSprite") + .resizable().scaledToFit().frame(width: 39, height: 44) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 4) { + EditorialEyebrow(title: progress.todayMilestone == nil ? "다음 황금도토리" : "특별한 오늘") + Text(progress.todayMilestone.map { "오늘은 \($0.title)" } ?? progress.nextMilestone.title) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(AppTheme.ink) } + Spacer(minLength: 4) + if progress.todayMilestone == nil { + Text("\(progress.nextMilestone.daysRemaining)일 후") + .font(.caption.weight(.medium)) + .foregroundStyle(AppTheme.secondaryText) + } + Image(systemName: "chevron.right") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(AppTheme.secondaryText) } - Text("병을 탭하면 오늘의 도토리가 다시 떨어져요") - .font(.system(size: 11)) - .foregroundStyle(AppTheme.secondaryText) + .padding(.horizontal, 16) + .padding(.vertical, 14) + .modifier(PaperSurface()) + .contentShape(RoundedRectangle(cornerRadius: 24)) } - .padding(15) - .modifier(GlassInformationPanel()) + .buttonStyle(.plain) + .accessibilityLabel(progress.todayMilestone.map { "황금도토리 \($0.title)" } + ?? "다음 황금도토리 \(progress.nextMilestone.title), \(progress.nextMilestone.daysRemaining)일 후") + .accessibilityIdentifier(progress.todayMilestone == nil ? "next-milestone-button" : "milestone-celebration") } private func replay() { @@ -152,3 +210,15 @@ struct CinematicAnniversaryDashboard: View { } } } + +private enum MilestoneSheet: Identifiable { + case celebration(AnniversaryMilestone) + case upcoming(AnniversaryProgress.Milestone) + + var id: String { + switch self { + case .celebration(let milestone): "celebration-\(milestone.id)" + case .upcoming(let milestone): "upcoming-\(milestone.date.timeIntervalSince1970)" + } + } +} diff --git a/DotoryCount/Features/Home/EmptyAnniversaryView.swift b/DotoryCount/Features/Home/EmptyAnniversaryView.swift new file mode 100644 index 0000000..11fe2fc --- /dev/null +++ b/DotoryCount/Features/Home/EmptyAnniversaryView.swift @@ -0,0 +1,62 @@ +import SwiftUI + +struct EmptyAnniversaryView: View { + let createAction: () -> Void + + var body: some View { + GeometryReader { geometry in + ScrollView { + VStack(spacing: 0) { + HStack { BrandWordmark(); Spacer() } + .padding(.top, 18) + .padding(.bottom, 36) + VStack(spacing: 12) { + Text("소중한 날을\n하나씩 담아요") + .font(.system(.largeTitle, design: .serif).weight(.medium)) + .multilineTextAlignment(.center) + Text("하루에 도토리 하나.\n우리의 시간이 한 병 가득 쌓여요.") + .font(.subheadline) + .lineSpacing(4) + .foregroundStyle(AppTheme.secondaryText) + .multilineTextAlignment(.center) + } + JarArtworkView(progress: AnniversaryCalculator.progress(from: .now)) + .frame(height: max(250, min(350, geometry.size.height - 365))) + .padding(.vertical, 12) + GlassControlGroup { + Button(action: createAction) { + HStack { + Text("기념일 만들기") + Spacer() + Image(systemName: "arrow.right") + } + .font(.headline) + .padding(.horizontal, 16) + .frame(minHeight: 50) + } + .modifier(GlassPrimaryActionStyle()) + .buttonBorderShape(.capsule) + .accessibilityIdentifier("create-anniversary-button") + } + Text("100일과 주년에는 황금도토리가 반짝여요.") + .font(.caption) + .foregroundStyle(AppTheme.secondaryText) + .multilineTextAlignment(.center) + .padding(.top, 18) + .padding(.bottom, 28) + } + .padding(.horizontal, AppTheme.horizontalPadding) + .frame(maxWidth: 520) + .frame(maxWidth: .infinity, minHeight: geometry.size.height) + } + .scrollIndicators(.hidden) + } + .background { PaperBackdrop() } + .foregroundStyle(AppTheme.ink) + .toolbar(.hidden, for: .navigationBar) + } +} + +#Preview("첫 시작") { + EmptyAnniversaryView(createAction: {}) +} diff --git a/DotoryCount/Features/Home/HomeView.swift b/DotoryCount/Features/Home/HomeView.swift index afb0372..e34774a 100644 --- a/DotoryCount/Features/Home/HomeView.swift +++ b/DotoryCount/Features/Home/HomeView.swift @@ -60,301 +60,3 @@ private enum AnniversarySheet: Identifiable { } } } - -private struct EmptyAnniversaryView: View { - let createAction: () -> Void - - var body: some View { - VStack(spacing: 24) { - Spacer() - - ZStack { - Circle() - .fill(AppTheme.surface) - .frame(width: 150, height: 150) - - Image(systemName: "leaf.fill") - .font(.system(size: 58, weight: .light)) - .foregroundStyle(AppTheme.accent) - .accessibilityHidden(true) - } - - VStack(spacing: 8) { - Text("소중한 날을 담아보세요") - .font(.title2.bold()) - Text("하루마다 유리병에 도토리 하나가 쌓여요.") - .font(.body) - .foregroundStyle(AppTheme.secondaryText) - .multilineTextAlignment(.center) - } - - Button(action: createAction) { - Label("기념일 만들기", systemImage: "plus") - .font(.headline) - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - } - .modifier(GlassPrimaryActionStyle()) - .buttonBorderShape(.roundedRectangle(radius: 16)) - .accessibilityIdentifier("create-anniversary-button") - - Spacer() - } - .padding(.horizontal, AppTheme.horizontalPadding) - .navigationTitle("DotoryCount") - } -} - -private struct AnniversaryDashboard: View { - let anniversary: Anniversary - let replayToken: Int - let editAction: () -> Void - - @Environment(\.scenePhase) private var scenePhase - @State private var latestAcornPresentation: LatestAcornPresentation = .hidden - @State private var selectedMilestone: AnniversaryMilestone? - @State private var animationTask: Task? - - private var progress: AnniversaryProgress { - AnniversaryCalculator.progress(from: anniversary.startDate) - } - - var body: some View { - ScrollView { - VStack(spacing: 18) { - VStack(spacing: 6) { - Text(anniversary.title) - .font(.title3.weight(.semibold)) - Text(progress.counterText) - .font(.system(size: 52, weight: .bold, design: .rounded)) - .contentTransition(.numericText()) - .accessibilityIdentifier("day-counter") - Text(anniversary.startDate.formatted(date: .long, time: .omitted)) - .font(.subheadline) - .foregroundStyle(AppTheme.secondaryText) - } - .padding(.top, 12) - - if let milestone = progress.todayMilestone { - celebrationBanner(milestone) - } - - jarCard - milestoneCard - } - .padding(.horizontal, AppTheme.horizontalPadding) - .padding(.bottom, 28) - } - .navigationTitle("나의 도토리") - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("수정", systemImage: "slider.horizontal.3", action: editAction) - .accessibilityIdentifier("edit-anniversary-button") - } - } - .sheet(item: $selectedMilestone) { milestone in - MilestoneDetailView(milestone: milestone) - } - .contentShape(Rectangle()) - .simultaneousGesture( - TapGesture().onEnded { - replayLatestAcorn() - } - ) - .onAppear { - replayLatestAcorn(after: .milliseconds(350)) - } - .onChange(of: scenePhase) { previousPhase, currentPhase in - guard previousPhase != .active, currentPhase == .active else { return } - replayLatestAcorn(after: .milliseconds(250)) - } - .onChange(of: replayToken) { _, _ in - replayLatestAcorn(after: .milliseconds(180)) - } - .onChange(of: selectedMilestone?.id) { previousMilestone, currentMilestone in - guard previousMilestone != nil, currentMilestone == nil else { return } - replayLatestAcorn(after: .milliseconds(180)) - } - .onDisappear { - animationTask?.cancel() - } - } - - private var jarCard: some View { - VStack(spacing: 16) { - AcornJarView( - acornCount: progress.acornsInCurrentJar, - capacity: progress.currentJarCapacity, - milestones: progress.jarMilestones, - latestAcornPresentation: latestAcornPresentation, - celebratingMilestone: progress.todayMilestone, - onMilestoneTapped: { selectedMilestone = $0 } - ) - .frame(width: 250, height: 320) - - if progress.isWaitingToStart { - Text("시작일까지 \(abs(progress.elapsedDays))일 남았어요") - .font(.headline) - } else { - VStack(spacing: 5) { - Text("이번 병에 담긴 시간 \(progress.acornsInCurrentJar)일") - .font(.headline) - Text("\(jarFillPercentage)% 채움 · 완성한 병 \(progress.completedJars)개") - .font(.subheadline) - .foregroundStyle(AppTheme.secondaryText) - } - .multilineTextAlignment(.center) - } - } - .frame(maxWidth: .infinity) - .padding(24) - .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) - } - - private var jarFillPercentage: Int { - guard progress.currentJarCapacity > 0 else { return 0 } - return min( - Int((Double(progress.acornsInCurrentJar) / Double(progress.currentJarCapacity) * 100).rounded()), - 100 - ) - } - - private func celebrationBanner(_ milestone: AnniversaryMilestone) -> some View { - HStack(spacing: 13) { - Image("GoldenAcornSprite") - .resizable() - .scaledToFit() - .frame(width: 46, height: 52) - .shadow(color: Color.yellow.opacity(0.34), radius: 8) - .accessibilityHidden(true) - - VStack(alignment: .leading, spacing: 3) { - Text("오늘은 \(milestone.title)") - .font(.headline) - Text("황금도토리가 반짝이는 특별한 날이에요.") - .font(.subheadline) - .foregroundStyle(AppTheme.secondaryText) - } - - Spacer(minLength: 0) - } - .padding(16) - .background( - LinearGradient( - colors: [Color.yellow.opacity(0.22), AppTheme.surface], - startPoint: .topLeading, - endPoint: .bottomTrailing - ), - in: RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius) - ) - .overlay { - RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius) - .stroke(Color.yellow.opacity(0.30), lineWidth: 1) - } - .accessibilityElement(children: .combine) - .accessibilityIdentifier("milestone-celebration") - } - - private var milestoneCard: some View { - HStack(spacing: 14) { - Image(systemName: "calendar.badge.clock") - .font(.title2) - .foregroundStyle(AppTheme.accent) - - VStack(alignment: .leading, spacing: 3) { - Text("다음 기념일") - .font(.caption) - .foregroundStyle(AppTheme.secondaryText) - Text(progress.nextMilestone.title) - .font(.headline) - Text(progress.nextMilestone.date.formatted(date: .abbreviated, time: .omitted)) - .font(.subheadline) - .foregroundStyle(AppTheme.secondaryText) - } - - Spacer() - - Text("\(progress.nextMilestone.daysRemaining)일") - .font(.headline.monospacedDigit()) - } - .padding(18) - .background(AppTheme.surface, in: RoundedRectangle(cornerRadius: AppTheme.cardCornerRadius)) - } - - private func replayLatestAcorn(after delay: Duration = .milliseconds(60)) { - guard progress.elapsedDays > 0, progress.acornsInCurrentJar > 0 else { - latestAcornPresentation = .static - return - } - - animationTask?.cancel() - latestAcornPresentation = .hidden - let animationDuration: Duration = ProcessInfo.processInfo.arguments.contains("--ui-testing-slow-animation") - ? .seconds(3) - : .seconds(1.1) - - animationTask = Task { @MainActor in - try? await Task.sleep(for: delay) - guard !Task.isCancelled else { return } - latestAcornPresentation = .falling - - try? await Task.sleep(for: animationDuration) - guard !Task.isCancelled else { return } - latestAcornPresentation = .static - } - } -} - -struct MilestoneDetailView: View { - let milestone: AnniversaryMilestone - - @Environment(\.dismiss) private var dismiss - - var body: some View { - VStack(spacing: 20) { - Spacer() - - ZStack { - Circle() - .fill(Color.yellow.opacity(0.16)) - .frame(width: 170, height: 170) - .blur(radius: 2) - - Image("GoldenAcornSprite") - .resizable() - .scaledToFit() - .frame(width: 138, height: 150) - .shadow(color: Color.yellow.opacity(0.34), radius: 16, y: 6) - } - .accessibilityHidden(true) - - VStack(spacing: 8) { - Text(milestone.title) - .font(.largeTitle.bold()) - Text(milestone.date.formatted(date: .long, time: .omitted)) - .font(.headline) - .foregroundStyle(AppTheme.secondaryText) - Text(milestone.detail) - .font(.body) - .multilineTextAlignment(.center) - .padding(.top, 4) - } - - Spacer() - - Button("닫기") { - dismiss() - } - .font(.headline) - .buttonStyle(.borderedProminent) - .buttonBorderShape(.roundedRectangle(radius: 16)) - .frame(maxWidth: .infinity) - } - .padding(28) - .background(AppTheme.background.ignoresSafeArea()) - .presentationDetents([.medium]) - .accessibilityElement(children: .contain) - .accessibilityIdentifier("milestone-detail") - } -} diff --git a/DotoryCount/Features/Home/MilestoneDetailView.swift b/DotoryCount/Features/Home/MilestoneDetailView.swift new file mode 100644 index 0000000..43a6ab0 --- /dev/null +++ b/DotoryCount/Features/Home/MilestoneDetailView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct MilestoneDetailView: View { + let milestone: AnniversaryMilestone + + var body: some View { + GoldenMilestonePresentation(eyebrow: "오늘의 황금도토리", title: milestone.title, + date: milestone.date, message: milestone.detail) + .accessibilityIdentifier("milestone-detail") + } +} + +struct UpcomingMilestoneView: View { + let milestone: AnniversaryProgress.Milestone + + var body: some View { + GoldenMilestonePresentation(eyebrow: "다음에 찾아올 특별한 날", title: milestone.title, + date: milestone.date, + message: "\(milestone.daysRemaining)일 뒤, 새로운 황금도토리가 병에 담겨요.") + } +} + +private struct GoldenMilestonePresentation: View { + let eyebrow: String + let title: String + let date: Date + let message: String + @Environment(\.dismiss) private var dismiss + + var body: some View { + NavigationStack { + ScrollView { + VStack(spacing: 16) { + ZStack { + Circle().fill(AppTheme.gold.opacity(0.10)) + .frame(width: 156, height: 156) + Image("GoldenAcornSprite") + .resizable().scaledToFit().frame(width: 124, height: 124) + .shadow(color: AppTheme.gold.opacity(0.18), radius: 16, y: 7) + } + .accessibilityHidden(true) + EditorialEyebrow(title: eyebrow) + Text(title) + .font(.system(.largeTitle, design: .serif).weight(.medium)) + Text(date.formatted(date: .long, time: .omitted)) + .font(.subheadline) + .foregroundStyle(AppTheme.secondaryText) + Text(message) + .font(.subheadline) + .lineSpacing(4) + .multilineTextAlignment(.center) + .padding(.top, 3) + } + .padding(28) + .frame(maxWidth: .infinity) + } + .background { PaperBackdrop() } + .foregroundStyle(AppTheme.ink) + .toolbar { + ToolbarItem(placement: .confirmationAction) { + Button("닫기") { dismiss() } + } + } + } + .presentationDetents([.medium, .large]) + .presentationDragIndicator(.visible) + } +} diff --git a/DotoryCount/Features/Home/PhotographicJarScene.swift b/DotoryCount/Features/Home/PhotographicJarScene.swift index 19783e3..77fb745 100644 --- a/DotoryCount/Features/Home/PhotographicJarScene.swift +++ b/DotoryCount/Features/Home/PhotographicJarScene.swift @@ -1,102 +1,12 @@ import SwiftUI + enum JarDropTiming { static var duration: TimeInterval { ProcessInfo.processInfo.arguments.contains("--ui-testing-slow-animation") ? 3.5 : 2.25 } } -/// The exact day count stays in AnniversaryProgress. Thirty-six visual additions -/// give the photographic jar a readable, steadily growing silhouette. -enum PhotographicJarStages { - static let maximumStage = 36 - static let exactDays = 8 - - static func stage(for day: Int, capacity: Int) -> Int { - let day = min(max(day, 0), max(capacity - 1, 0)) - guard day > exactDays else { return day } - let remainingDays = max(capacity - exactDays - 1, 1) - let remainingStages = maximumStage - exactDays - return min( - exactDays + 1 + (day - exactDays - 1) * remainingStages / remainingDays, - maximumStage - ) - } - - struct Placement: Equatable { - let x: CGFloat - let y: CGFloat - let radius: CGFloat - let size: CGFloat - let angle: Double - let variant: Int - let depth: CGFloat - } - - // Drop differently sized disks into a shallow curved jar floor. Each disk - // rests on the floor or a previously settled disk, rather than a fixed row. - // The glass is 941 × 1672; all coordinates use that artboard. - static let placements: [Placement] = { - var result: [Placement] = [] - for index in 0.. bestScore { - bestScore = score - bestX = x - bestY = y - } - } - - let variant = min(Int((noise(index, salt: 153) + 1) * 1.5), 2) - let depth = noise(index, salt: 79) - result.append(Placement( - x: bestX, - y: bestY, - radius: radius, - size: variant == 0 ? 190 : 148, - angle: Double(noise(index, salt: 83) * 155), - variant: variant, - depth: depth - )) - } - return result - }() - - private static func noise(_ index: Int, salt: UInt64) -> CGFloat { - var value = UInt64(index + 1) &* 6_364_136_223_846_793_005 &+ salt - value ^= value >> 30 - value &*= 1_379_878_784_942_060_787 - value ^= value >> 27 - return CGFloat(value % 10_000) / 4_999.5 - 1 - } -} - struct PhotographicJarScene: View { let progress: AnniversaryProgress let replayID: Int @@ -108,141 +18,24 @@ struct PhotographicJarScene: View { @State private var motionVariant = 0 private var stage: Int { - PhotographicJarStages.stage( - for: progress.acornsInCurrentJar, - capacity: progress.currentJarCapacity - ) + PhotographicJarStages.stage(for: progress.acornsInCurrentJar, capacity: progress.currentJarCapacity) } var body: some View { - GeometryReader { geometry in - let width = min( - geometry.size.width * 1.12, - max(geometry.size.height - 310, 230) * 941 / 880 - ) - let scale = width / 941 - let height = 1672 * scale - - jarArtboard(width: width, height: height, scale: scale) - .position( - x: geometry.size.width / 2, - y: geometry.size.height / 2 - 12 - 134 * scale - ) + TimelineView(.animation(minimumInterval: 1.0 / 60, paused: !isAnimating)) { timeline in + let elapsed = timeline.date.timeIntervalSince(dropStartedAt) + let motion = isAnimating ? min(max(elapsed / JarDropTiming.duration, 0), 1) : 1 + let offset = stage > 0 + ? fallingOffset(PhotographicJarStages.placements[stage - 1], progress: motion) + : .zero + JarArtworkView(progress: progress, latestOffset: offset, + latestRotation: fallingRotation(progress: motion)) } .accessibilityHidden(true) .onChange(of: replayID) { _, _ in replay() } .onDisappear { finishTask?.cancel() } } - private func jarArtboard(width: CGFloat, height: CGFloat, scale: CGFloat) -> some View { - let settledIndices = (0.. 0 { - let placement = PhotographicJarStages.placements[stage - 1] - TimelineView(.animation(minimumInterval: 1.0 / 60, paused: !isAnimating)) { timeline in - let elapsed = timeline.date.timeIntervalSince(dropStartedAt) - let motion = isAnimating ? min(max(elapsed / JarDropTiming.duration, 0), 1) : 1 - let offset = fallingOffset(placement, progress: motion) - acorn(at: placement, - golden: isGolden(index: stage - 1), - scale: scale, - offset: offset, - extraRotation: fallingRotation(progress: motion)) - } - } - } - .frame(width: width, height: height) - - Image("JarGlassFront") - .resizable() - .frame(width: width, height: height) - .mask( - JarForegroundGlassMask() - .fill(style: FillStyle(eoFill: true)) - .blur(radius: 14 * scale) - .frame(width: width, height: height) - ) - .allowsHitTesting(false) - - LinearGradient( - colors: [.clear, .white.opacity(0.035), .cyan.opacity(0.07)], - startPoint: .top, - endPoint: .bottom - ) - .frame(width: 625 * scale, height: 570 * scale) - .clipShape(RoundedRectangle(cornerRadius: 72 * scale)) - .position(x: 470 * scale, y: 1000 * scale) - .allowsHitTesting(false) - } - .frame(width: width, height: height) - } - - private func acorn( - at placement: PhotographicJarStages.Placement, - golden: Bool, - scale: CGFloat, - offset: CGSize = .zero, - extraRotation: Double = 0 - ) -> some View { - let centerX = (placement.x + offset.width) * scale - let centerY = (placement.y + offset.height) * scale - let imageName = golden ? "GoldenAcornSprite" : - (placement.variant == 0 ? "AcornSprite" : placement.variant == 1 ? "AcornSide" : "AcornBack") - - return ZStack(alignment: .topLeading) { - Ellipse() - .fill(.black.opacity(0.19 + 0.08 * Double(placement.depth + 1) / 2)) - .frame(width: placement.radius * 2.3 * scale, height: placement.radius * 0.9 * scale) - .blur(radius: 9 * scale) - .position(x: centerX + 6 * scale, y: centerY + 24 * scale) - - Image(imageName) - .resizable() - .scaledToFit() - .frame( - width: (golden ? 160 : placement.size) * scale, - height: (golden ? 160 : placement.size) * scale - ) - .rotationEffect(.degrees(placement.angle + extraRotation)) - .brightness(Double(placement.depth) * 0.045) - .shadow(color: .black.opacity(0.30), radius: 5 * scale, x: 3 * scale, y: 6 * scale) - .position(x: centerX, y: centerY) - } - } - - private func isGolden(index: Int) -> Bool { - progress.jarMilestones.contains { milestone in - PhotographicJarStages.stage( - for: milestone.dayInJar, - capacity: progress.currentJarCapacity - ) == index + 1 - } - } - private func fallingOffset( _ placement: PhotographicJarStages.Placement, progress: Double @@ -302,21 +95,3 @@ struct PhotographicJarScene: View { } } } - -/// The photographed bottom is already in JarEmptyBase behind the acorns. Keep -/// the front rim and side refractions, but leave the central inner floor clear -/// so the nuts sit visibly *on* that surface instead of behind another copy. -private struct JarForegroundGlassMask: Shape { - func path(in rect: CGRect) -> Path { - let scale = rect.width / 941 - var path = Path() - path.addRect(rect) - path.addEllipse(in: CGRect( - x: 165 * scale, - y: 1215 * scale, - width: 610 * scale, - height: 170 * scale - )) - return path - } -} diff --git a/DotoryCountUITests/DotoryCountUITests.swift b/DotoryCountUITests/DotoryCountUITests.swift index 1d1cb06..7455396 100644 --- a/DotoryCountUITests/DotoryCountUITests.swift +++ b/DotoryCountUITests/DotoryCountUITests.swift @@ -59,14 +59,16 @@ final class DotoryCountUITests: XCTestCase { let jar = app.buttons["acorn-jar"] XCTAssertTrue(jar.waitForExistence(timeout: 3)) XCTAssertTrue((jar.value as? String)?.contains("함께한 날 22일") == true) + let screenshot = XCTAttachment(screenshot: app.screenshot()) + screenshot.name = "Object-first dashboard" + screenshot.lifetime = .keepAlways + add(screenshot) } @MainActor func testHundredDayMilestoneShowsGoldenAcornDetail() throws { let app = launchApp(additionalArguments: ["--ui-testing-100-day-anniversary"]) - XCTAssertTrue(app.staticTexts["오늘은 100일"].waitForExistence(timeout: 3)) - let goldenAcorn = app.buttons["황금도토리 100일"] XCTAssertTrue(goldenAcorn.waitForExistence(timeout: 3)) goldenAcorn.tap() diff --git a/DotoryCountWidget/Assets.xcassets/AcornBack.imageset/Contents.json b/DotoryCountWidget/Assets.xcassets/AcornBack.imageset/Contents.json new file mode 100644 index 0000000..67c713e --- /dev/null +++ b/DotoryCountWidget/Assets.xcassets/AcornBack.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [{ "filename" : "acorn-back.png", "idiom" : "universal" }], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/DotoryCountWidget/Assets.xcassets/AcornBack.imageset/acorn-back.png b/DotoryCountWidget/Assets.xcassets/AcornBack.imageset/acorn-back.png new file mode 100644 index 0000000..6ec565d Binary files /dev/null and b/DotoryCountWidget/Assets.xcassets/AcornBack.imageset/acorn-back.png differ diff --git a/DotoryCountWidget/DotoryCountWidget.swift b/DotoryCountWidget/DotoryCountWidget.swift index a17e76b..d461dad 100644 --- a/DotoryCountWidget/DotoryCountWidget.swift +++ b/DotoryCountWidget/DotoryCountWidget.swift @@ -6,59 +6,15 @@ private struct DayEntry: TimelineEntry { let title: String? let startDate: Date? - var days: Int? { - guard let startDate else { return nil } - return Calendar.autoupdatingCurrent.dateComponents( - [.day], - from: Calendar.autoupdatingCurrent.startOfDay(for: startDate), - to: Calendar.autoupdatingCurrent.startOfDay(for: date) - ).day - } - - var counter: String { - guard let days else { return "D+000" } - return days >= 0 ? "D+\(days)" : "D\(days)" - } - - var jarDays: Int { - guard let startDate, let days, days >= 0 else { return 0 } - let calendar = Calendar.autoupdatingCurrent - let start = calendar.startOfDay(for: startDate) - let today = calendar.startOfDay(for: date) - let years = max(calendar.dateComponents([.year], from: start, to: today).year ?? 0, 0) - let jarStart = calendar.date(byAdding: .year, value: years, to: start) ?? start - return max(calendar.dateComponents([.day], from: jarStart, to: today).day ?? 0, 0) - } - - var capacity: Int { - guard let startDate else { return 365 } - let calendar = Calendar.autoupdatingCurrent - let start = calendar.startOfDay(for: startDate) - let today = calendar.startOfDay(for: date) - let years = max(calendar.dateComponents([.year], from: start, to: today).year ?? 0, 0) - let jarStart = calendar.date(byAdding: .year, value: years, to: start) ?? start - let next = calendar.date(byAdding: .year, value: 1, to: jarStart) ?? today - return max(calendar.dateComponents([.day], from: jarStart, to: next).day ?? 365, 1) - } - - var goldenDaysInJar: Set { - guard let days, days >= 0, jarDays > 0 else { return [] } - let jarStartElapsedDay = days - jarDays - var result = Set() - if days >= 100 { - for milestone in stride(from: 100, through: days, by: 100) { - let dayInJar = milestone - jarStartElapsedDay - if dayInJar > 0, dayInJar <= jarDays { result.insert(dayInJar) } - } - } - if jarStartElapsedDay > 0 { result.insert(1) } - return result + var progress: AnniversaryProgress { + AnniversaryCalculator.progress(from: startDate ?? date, to: date) } } private struct DayProvider: TimelineProvider { func placeholder(in context: Context) -> DayEntry { - DayEntry(date: .now, title: "우리의 시작", startDate: Calendar.current.date(byAdding: .day, value: -120, to: .now)) + DayEntry(date: .now, title: "우리의 시작", + startDate: Calendar.current.date(byAdding: .day, value: -120, to: .now)) } func getSnapshot(in context: Context, completion: @escaping (DayEntry) -> Void) { @@ -66,22 +22,22 @@ private struct DayProvider: TimelineProvider { } func getTimeline(in context: Context, completion: @escaping (Timeline) -> Void) { - let entry = currentEntry() - let nextDay = Calendar.autoupdatingCurrent.nextDate( - after: entry.date, - matching: DateComponents(hour: 0, minute: 1), - matchingPolicy: .nextTime - ) ?? entry.date.addingTimeInterval(86_400) - completion(Timeline(entries: [entry], policy: .after(nextDay))) + let current = currentEntry() + let calendar = Calendar.autoupdatingCurrent + let today = calendar.startOfDay(for: current.date) + var entries = [current] + // Supply future midnight entries, so a delayed refresh doesn't freeze D+. + for day in 1...7 { + guard let date = calendar.date(byAdding: .day, value: day, to: today) else { continue } + entries.append(DayEntry(date: date, title: current.title, startDate: current.startDate)) + } + completion(Timeline(entries: entries, policy: .atEnd)) } private func currentEntry() -> DayEntry { let defaults = UserDefaults(suiteName: "group.com.dotoryman.dotorycount") - return DayEntry( - date: .now, - title: defaults?.string(forKey: "widget.title"), - startDate: defaults?.object(forKey: "widget.startDate") as? Date - ) + return DayEntry(date: .now, title: defaults?.string(forKey: "widget.title"), + startDate: defaults?.object(forKey: "widget.startDate") as? Date) } } @@ -89,22 +45,24 @@ private struct LockWidgetView: View { let entry: DayEntry var body: some View { - HStack(spacing: 8) { + HStack(spacing: 9) { Image(systemName: "leaf.fill") - .font(.system(size: 21, weight: .medium)) + .font(.system(size: 19, weight: .medium)) .widgetAccentable() - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: 3) { Text(entry.title ?? "도토리 카운트") - .font(.system(size: 11, weight: .semibold)) + .font(.system(size: 11, weight: .medium)) .lineLimit(1) - Text(entry.startDate == nil ? "날짜를 설정해 주세요" : entry.counter) - .font(.system(size: 19, weight: .bold, design: .rounded)) + Text(entry.startDate == nil ? "기념일을 설정해 주세요" : entry.progress.counterText) + .font(.system(size: 22, weight: .semibold, design: .rounded)) .monospacedDigit() .lineLimit(1) + .minimumScaleFactor(0.7) } Spacer(minLength: 0) } .containerBackground(for: .widget) { Color.clear } + .accessibilityElement(children: .combine) } } @@ -112,118 +70,13 @@ private struct HomeWidgetView: View { let entry: DayEntry var body: some View { - GeometryReader { geometry in - VStack(spacing: 0) { - Spacer(minLength: 0) - WidgetJarArt(days: entry.jarDays, capacity: entry.capacity, - goldenDays: entry.goldenDaysInJar) - .frame(width: min(geometry.size.width * 0.68, geometry.size.height * 0.65 * 941 / 1672), - height: geometry.size.height * 0.65) - Spacer(minLength: 0) - Text(entry.counter) - .font(.system(size: 15, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(Color(red: 0.31, green: 0.20, blue: 0.12)) - .minimumScaleFactor(0.8) - Text(entry.title ?? "기념일을 설정해 주세요") - .font(.system(size: 9, weight: .medium)) - .foregroundStyle(.secondary) - .lineLimit(1) - .padding(.top, 2) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - } - .containerBackground(for: .widget) { - Color(red: 1, green: 0.977, blue: 0.937) - } - } -} - -private struct WidgetJarArt: View { - let days: Int - let capacity: Int - let goldenDays: Set - - private var stage: Int { - let day = min(max(days, 0), max(capacity - 1, 0)) - if day <= 8 { return day } - return min(9 + (day - 9) * 28 / max(capacity - 9, 1), 36) - } - - private func stage(for day: Int) -> Int { - if day <= 8 { return max(day, 0) } - return min(9 + (day - 9) * 28 / max(capacity - 9, 1), 36) - } - - private var goldenIndices: Set { - Set(goldenDays.map { stage(for: $0) - 1 }) - } - - private static let placements: [(x: CGFloat, y: CGFloat, size: CGFloat, angle: Double)] = { - var result: [(x: CGFloat, y: CGFloat, radius: CGFloat, size: CGFloat, angle: Double)] = [] - for index in 0..<36 { - let radius: CGFloat = 36 + CGFloat((index * 37) % 5) - let preferredX = CGFloat(470 + ((index * 173) % 451) - 225) - var bestX: CGFloat = 470 - var bestY: CGFloat = 0 - var bestScore = -CGFloat.infinity - for step in 0...102 { - let x = CGFloat(215 + step * 5) - let dx = x - 470 - var y = 1290 - 0.00065 * dx * dx - radius - for previous in result { - let separation = x - previous.x - let clearance = radius + previous.radius - if abs(separation) < clearance { - y = min(y, previous.y - sqrt(clearance * clearance - separation * separation)) - } - } - let score = y - abs(x - preferredX) * 0.045 - if score > bestScore { - bestScore = score - bestX = x - bestY = y - } - } - result.append((bestX, bestY, radius, index % 3 == 0 ? 190 : 148, - Double((index * 67) % 150 - 75))) - } - return result.map { ($0.x, $0.y, $0.size, $0.angle) } - }() - - var body: some View { - GeometryReader { geometry in - let scale = geometry.size.width / 941 - ZStack(alignment: .topLeading) { - Image("JarEmptyBase") - .resizable() - .frame(width: geometry.size.width, height: geometry.size.height) - - ForEach(0.. some View { + let width = 941 * scale + let height = 1672 * scale + let settled = (0.. 0 ? 0.28 : 0.09)) + .frame(width: 510 * scale, height: 82 * scale) + .blur(radius: 18 * scale) + .position(x: 470 * scale, y: 1282 * scale) + + ForEach(settled, id: \.self) { index in + acorn(index: index, golden: golden.contains(index), scale: scale) + } + if stage > 0 { + acorn(index: stage - 1, golden: golden.contains(stage - 1), scale: scale, + offset: latestOffset, rotation: latestRotation) + } + + Image("JarGlassFront") + .resizable().frame(width: width, height: height) + .mask { + JarForegroundGlassMask() + .fill(style: FillStyle(eoFill: true)) + .blur(radius: 14 * scale) + } + + LinearGradient(colors: [.clear, .white.opacity(0.035), .cyan.opacity(0.06)], + startPoint: .top, endPoint: .bottom) + .frame(width: 625 * scale, height: 570 * scale) + .clipShape(RoundedRectangle(cornerRadius: 72 * scale)) + .position(x: 470 * scale, y: 1000 * scale) + } + .frame(width: width, height: height) + } + + private func acorn(index: Int, golden: Bool, scale: CGFloat, + offset: CGSize = .zero, rotation: Double = 0) -> some View { + let placement = PhotographicJarStages.placements[index] + let x = (placement.x + offset.width) * scale + let y = (placement.y + offset.height) * scale + let name = golden ? "GoldenAcornSprite" : + (placement.variant == 0 ? "AcornSprite" : placement.variant == 1 ? "AcornSide" : "AcornBack") + let size = (golden ? 160 : placement.size) * scale + + return ZStack(alignment: .topLeading) { + Ellipse().fill(.black.opacity(0.22)) + .frame(width: placement.radius * 2.3 * scale, height: placement.radius * 0.9 * scale) + .blur(radius: 9 * scale) + .position(x: x + 6 * scale, y: y + 24 * scale) + Image(name).resizable().scaledToFit() + .frame(width: size, height: size) + .rotationEffect(.degrees(placement.angle + rotation)) + .brightness(Double(placement.depth) * 0.045) + .shadow(color: .black.opacity(0.27), radius: 5 * scale, x: 3 * scale, y: 6 * scale) + .position(x: x, y: y) + } + } +} + +private struct JarForegroundGlassMask: Shape { + func path(in rect: CGRect) -> Path { + let scale = rect.width / 941 + var path = Path() + path.addRect(rect) + path.addEllipse(in: CGRect(x: 165 * scale, y: 1215 * scale, + width: 610 * scale, height: 170 * scale)) + return path + } +} diff --git a/Shared/JarVisualLayout.swift b/Shared/JarVisualLayout.swift new file mode 100644 index 0000000..a7d5ada --- /dev/null +++ b/Shared/JarVisualLayout.swift @@ -0,0 +1,93 @@ +import SwiftUI + +/// The exact day count stays in AnniversaryProgress. Thirty-six visual additions +/// give the photographic jar a readable, steadily growing silhouette. +enum PhotographicJarStages { + static let maximumStage = 36 + static let exactDays = 8 + + static func stage(for day: Int, capacity: Int) -> Int { + let day = min(max(day, 0), max(capacity - 1, 0)) + guard day > exactDays else { return day } + let remainingDays = max(capacity - exactDays - 1, 1) + let remainingStages = maximumStage - exactDays + return min( + exactDays + 1 + (day - exactDays - 1) * remainingStages / remainingDays, + maximumStage + ) + } + + struct Placement: Equatable { + let x: CGFloat + let y: CGFloat + let radius: CGFloat + let size: CGFloat + let angle: Double + let variant: Int + let depth: CGFloat + } + + // Drop differently sized disks into a shallow curved jar floor. Each disk + // rests on the floor or a previously settled disk, rather than a fixed row. + // The glass is 941 × 1672; all coordinates use that artboard. + static let placements: [Placement] = { + var result: [Placement] = [] + for index in 0.. bestScore { + bestScore = score + bestX = x + bestY = y + } + } + + let variant = min(Int((noise(index, salt: 153) + 1) * 1.5), 2) + let depth = noise(index, salt: 79) + result.append(Placement( + x: bestX, + y: bestY, + radius: radius, + size: variant == 0 ? 190 : 148, + angle: Double(noise(index, salt: 83) * 155), + variant: variant, + depth: depth + )) + } + return result + }() + + private static func noise(_ index: Int, salt: UInt64) -> CGFloat { + var value = UInt64(index + 1) &* 6_364_136_223_846_793_005 &+ salt + value ^= value >> 30 + value &*= 1_379_878_784_942_060_787 + value ^= value >> 27 + return CGFloat(value % 10_000) / 4_999.5 - 1 + } +} + diff --git a/Shared/JarWidgetContent.swift b/Shared/JarWidgetContent.swift new file mode 100644 index 0000000..5c21670 --- /dev/null +++ b/Shared/JarWidgetContent.swift @@ -0,0 +1,38 @@ +import Foundation +import SwiftUI + +struct JarWidgetContent: View { + let progress: AnniversaryProgress + var isConfigured = true + + private var counter: String { + guard isConfigured else { return "D+000" } + let days = progress.elapsedDays + return String(format: days >= 0 ? "D+%03d" : "D-%03d", abs(days)) + } + + var body: some View { + GeometryReader { geometry in + VStack(spacing: 5) { + JarArtworkView(progress: progress, viewport: .widget) + .frame(maxWidth: .infinity) + .frame(height: max(geometry.size.height - (isConfigured ? 35 : 48), 30)) + Text(counter) + .font(.system(size: 14, weight: .medium, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color(red: 0.24, green: 0.20, blue: 0.16)) + .lineLimit(1) + .minimumScaleFactor(0.7) + if !isConfigured { + Text("기념일을 설정해 주세요") + .font(.system(size: 9)) + .foregroundStyle(Color(red: 0.42, green: 0.38, blue: 0.33)) + .lineLimit(1) + } + } + .padding(.horizontal, 12) + .padding(.top, 5) + .frame(maxWidth: .infinity) + } + } +}