diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 78a490b..80f4e1d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -15,11 +15,11 @@ jobs: - name: Checkout uses: actions/checkout@v4 - - name: Select Xcode 16.1 - run: sudo xcode-select -s /Applications/Xcode_16.1.app + - name: Select Xcode 16.4 + run: sudo xcode-select -s /Applications/Xcode_16.4.app - - name: Install SwiftGen - run: brew install swiftgen + - name: Install SwiftGen if needed + run: command -v swiftgen || brew install swiftgen - name: Setup Ruby uses: ruby/setup-ruby@v1 diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index e8bdb1c..4ad6e0f 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -27,8 +27,8 @@ jobs: - name: Select Xcode 16.1 run: sudo xcode-select -s /Applications/Xcode_16.1.app - - name: Install SwiftGen - run: brew install swiftgen + - name: Install SwiftGen if needed + run: command -v swiftgen || brew install swiftgen - name: Setup Ruby uses: ruby/setup-ruby@v1 diff --git a/TimerStamp.xcodeproj/project.pbxproj b/TimerStamp.xcodeproj/project.pbxproj index e13516a..4b8db41 100644 --- a/TimerStamp.xcodeproj/project.pbxproj +++ b/TimerStamp.xcodeproj/project.pbxproj @@ -177,7 +177,6 @@ B8B32C182DB13B2600CD65B3 /* Frameworks */, B8B32C192DB13B2600CD65B3 /* Resources */, B82186412DCDED46002B370E /* Embed Foundation Extensions */, - B80DB4F72DFAFDFA008BFBA2 /* ShellScript */, B86F23C62E3BAE6300320FE4 /* ShellScript */, ); buildRules = ( @@ -327,23 +326,6 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ - B80DB4F72DFAFDFA008BFBA2 /* ShellScript */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 12; - files = ( - ); - inputFileListPaths = ( - ); - inputPaths = ( - ); - outputFileListPaths = ( - ); - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "# Type a script or drag a script file from your workspace to insert its path.\n\nif [ \"${ACTION}\" = \"archive\" ]; then\n buildNumber=$(/usr/libexec/PlistBuddy -c \"Print CFBundleVersion\" \"${PROJECT_DIR}/${INFOPLIST_FILE}\")\n buildNumber=$(($buildNumber + 1))\n /usr/libexec/PlistBuddy -c \"Set :CFBundleVersion $buildNumber\" \"${PROJECT_DIR}/${INFOPLIST_FILE}\"\n echo \"Updated build number to $buildNumber\"\nfi\n"; - }; B86F23C62E3BAE6300320FE4 /* ShellScript */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; diff --git a/TimerStamp/Domains/Services/ImageSaveService.swift b/TimerStamp/Domains/Services/ImageSaveService.swift new file mode 100644 index 0000000..c19de64 --- /dev/null +++ b/TimerStamp/Domains/Services/ImageSaveService.swift @@ -0,0 +1,21 @@ +// +// ImageSaveService.swift +// TimerStamp +// + +import UIKit + +final class ImageSaveService: NSObject { + static let shared = ImageSaveService() + + @objc func didFinishSaving(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { + DispatchQueue.main.async { + let success = (error == nil) + NotificationCenter.default.post(name: .imageSaveCompleted, object: success) + } + } +} + +extension Notification.Name { + static let imageSaveCompleted = Notification.Name("imageSaveCompleted") +} diff --git a/TimerStamp/Domains/ViewModels/PhotoSelectionViewMdoel.swift b/TimerStamp/Domains/ViewModels/PhotoSelectionViewModel.swift similarity index 63% rename from TimerStamp/Domains/ViewModels/PhotoSelectionViewMdoel.swift rename to TimerStamp/Domains/ViewModels/PhotoSelectionViewModel.swift index 7a663c7..6ef7f3b 100644 --- a/TimerStamp/Domains/ViewModels/PhotoSelectionViewMdoel.swift +++ b/TimerStamp/Domains/ViewModels/PhotoSelectionViewModel.swift @@ -1,5 +1,5 @@ // -// PhotoSelectionViewMdoel.swift +// PhotoSelectionViewModel.swift // TimerStamp // // Created by 이예슬 on 5/6/25. @@ -17,27 +17,27 @@ class PhotoSelectionViewModel: ObservableObject { @Published var isShowingImagePicker = false @Published var isShowingModal = false @Published var isShowingSourceDialog = false - @Published var sourceType: UIImagePickerController.SourceType = .photoLibrary - - + @Published private(set) var selectedSourceType: PhotoSourceType = .photoLibrary + + /// 인증사진 플로우 완료(모달 닫기) 시 호출됩니다. + /// TimerViewModel.reset 등을 주입해 View 레이어의 협력 로직을 제거합니다. + var onCertificationCompleted: (() -> Void)? + func didSelectImage(_ image: UIImage?) { selectedImage = image if image != nil { isShowingModal = true } } - + func dismissModal() { isShowingModal = false selectedImage = nil + onCertificationCompleted?() } + func selectSource(_ source: PhotoSourceType) { - switch source { - case .camera: - sourceType = .camera - case .photoLibrary: - sourceType = .photoLibrary - } + selectedSourceType = source isShowingImagePicker = true } } diff --git a/TimerStamp/Presentations/Certification/CertificationImageRenderer.swift b/TimerStamp/Presentations/Certification/CertificationImageRenderer.swift new file mode 100644 index 0000000..703ec53 --- /dev/null +++ b/TimerStamp/Presentations/Certification/CertificationImageRenderer.swift @@ -0,0 +1,81 @@ +// +// CertificationImageRenderer.swift +// TimerStamp +// + +import SwiftUI +import UIKit + +enum CertificationImageRenderer { + @MainActor + static func render(image: UIImage, minutes: Int) -> UIImage? { + let view = ZStack { + GeometryReader { geo in + let timerSize = 400.0 + let radius = timerSize / 3.4 + let margin: CGFloat = 60 + + // 1. 원본 이미지 + Image(uiImage: image) + .resizable() + .scaledToFill() + .frame(width: geo.size.width, height: geo.size.height) + + // 2. 그라데이션 오버레이 + LinearGradient( + gradient: Gradient(colors: [ + Color.black.opacity(0), + Color.black.opacity(0.3) + ]), + startPoint: UnitPoint(x: 0.5, y: 0.7), + endPoint: .bottom + ) + .frame(width: geo.size.width, height: geo.size.height) + + // 3. 절대 위치로 하단 정렬 + ZStack(alignment: .bottom) { + Color.clear + .frame(width: geo.size.width, height: geo.size.height) + + HStack(alignment: .bottom, spacing: 0) { + // 좌측: 타이머 + VStack(alignment: .leading) { + SimpleTimerView(minutes: minutes, radius: radius) + .opacity(0.74) + .frame(maxHeight: .infinity, alignment: .bottom) + .padding(66) + } + + Spacer(minLength: 20) + + VStack(alignment: .trailing) { + Text("✨ \(L10n.focusComplete(minutes))") + .font(.system(size: 66, weight: .heavy)) + .fontWidth(.expanded) + .kerning(-2) + .foregroundColor(.white) + .multilineTextAlignment(.trailing) + .lineLimit(nil) + .padding(.bottom, 6) + + Text(DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short)) + .font(.system(size: 58, weight: .light)) + .foregroundColor(.white.opacity(0.95)) + .padding(.bottom, 10) + } + .frame(maxHeight: .infinity, alignment: .bottom) + } + .padding(.horizontal, margin) + .padding(.bottom, margin) + } + } + .frame(width: 1080, height: 1920) + } + .background(Color.clear) + + let renderer = ImageRenderer(content: view) + renderer.proposedSize = .init(CGSize(width: 1080, height: 1920)) + renderer.isOpaque = true + return renderer.uiImage + } +} diff --git a/TimerStamp/Presentations/Screens/CertificationModalView.swift b/TimerStamp/Presentations/Screens/CertificationModalView.swift index a49c494..8321c1a 100644 --- a/TimerStamp/Presentations/Screens/CertificationModalView.swift +++ b/TimerStamp/Presentations/Screens/CertificationModalView.swift @@ -15,13 +15,13 @@ struct CertificationModalView: View { @State private var composedImage: UIImage? @State private var showShareSheet = false @State private var showSaveConfirmation = false - + var body: some View { VStack(spacing: 24) { Text(L10n.certTitle) .font(.title2) .foregroundStyle(.mainText) - + if let image = composedImage { Image(uiImage: image) .resizable() @@ -32,7 +32,7 @@ struct CertificationModalView: View { ProgressView(L10n.certRendering) .frame(height: 300) } - + HStack(spacing: 16) { Button(action: onDismiss) { Text(L10n.certClose) @@ -42,7 +42,7 @@ struct CertificationModalView: View { .background(Color.mainButton) .cornerRadius(10) } - + Button(action: saveImage) { Text(L10n.certSaveImage) .foregroundColor(.mainButtonText) @@ -52,7 +52,7 @@ struct CertificationModalView: View { .cornerRadius(10) } .disabled(composedImage == nil) - + Button(action: { showShareSheet = true }) { @@ -67,7 +67,7 @@ struct CertificationModalView: View { } .frame(height: 44) .padding(.horizontal) - + Spacer() } .padding() @@ -77,7 +77,7 @@ struct CertificationModalView: View { if !hasRendered { hasRendered = true DispatchQueue.main.async { - if let image = ComposedImageRenderer.render(image: baseImage, minutes: minutes) { + if let image = CertificationImageRenderer.render(image: baseImage, minutes: minutes) { DispatchQueue.main.async { composedImage = image } @@ -92,7 +92,7 @@ struct CertificationModalView: View { } } .onReceive(NotificationCenter.default.publisher(for: .imageSaveCompleted)) { notification in - if let success = notification.object as? Bool { + if notification.object is Bool { showSaveConfirmation = true } } @@ -103,107 +103,15 @@ struct CertificationModalView: View { } .background(Color.mainBackground) } - - func saveImage() { - guard let image = composedImage else { return } - UIImageWriteToSavedPhotosAlbum(image, ImageSaveHelper.shared, #selector(ImageSaveHelper.didFinishSaving(_:didFinishSavingWithError:contextInfo:)), nil) - } - - var formattedDate: String { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter.string(from: Date()) - } -} - -class ImageSaveHelper: NSObject { - static let shared = ImageSaveHelper() - var onSaveCompletion: ((Bool) -> Void)? - - @objc func didFinishSaving(_ image: UIImage, didFinishSavingWithError error: Error?, contextInfo: UnsafeRawPointer) { - DispatchQueue.main.async { - let success = (error == nil) - NotificationCenter.default.post(name: .imageSaveCompleted, object: success) - } - } -} - -extension Notification.Name { - static let imageSaveCompleted = Notification.Name("imageSaveCompleted") -} -enum ComposedImageRenderer { - @MainActor - static func render(image: UIImage, minutes: Int) -> UIImage? { - let view = ZStack { - GeometryReader { geo in - let timerSize = 400.0 - let radius = timerSize / 3.4 - let margin: CGFloat = 60 - - // 1. 원본 이미지 - Image(uiImage: image) - .resizable() - .scaledToFill() - .frame(width: geo.size.width, height: geo.size.height) - - // 2. 그라데이션 오버레이 - LinearGradient( - gradient: Gradient(colors: [ - Color.black.opacity(0), - Color.black.opacity(0.3) - ]), - startPoint: UnitPoint(x: 0.5, y: 0.7), - endPoint: .bottom - ) - .frame(width: geo.size.width, height: geo.size.height) - - // 3. 절대 위치로 하단 정렬 - ZStack(alignment: .bottom) { - Color.clear - .frame(width: geo.size.width, height: geo.size.height) - - HStack(alignment: .bottom, spacing: 0) { - // 좌측: 타이머 - VStack(alignment: .leading) { - SimpleTimerView(minutes: minutes, radius: radius) - .opacity(0.74) - .frame(maxHeight: .infinity, alignment: .bottom) - .padding(66) - } - - Spacer(minLength: 20) - - VStack(alignment: .trailing) { - Text("✨ \(L10n.focusComplete(minutes))") - .font(.system(size: 66, weight: .heavy)) - .fontWidth(.expanded) - .kerning(-2) - .foregroundColor(.white) - .multilineTextAlignment(.trailing) - .lineLimit(nil) - .padding(.bottom, 6) - - Text(DateFormatter.localizedString(from: Date(), dateStyle: .medium, timeStyle: .short)) - .font(.system(size: 58, weight: .light)) - .foregroundColor(.white.opacity(0.95)) - .padding(.bottom, 10) - } - .frame(maxHeight: .infinity, alignment: .bottom) - } - .padding(.horizontal, margin) - .padding(.bottom, margin) - } - } - .frame(width: 1080, height: 1920) - } - .background(Color.clear) - - let renderer = ImageRenderer(content: view) - renderer.proposedSize = .init(CGSize(width: 1080, height: 1920)) - renderer.isOpaque = true - return renderer.uiImage + private func saveImage() { + guard let image = composedImage else { return } + UIImageWriteToSavedPhotosAlbum( + image, + ImageSaveService.shared, + #selector(ImageSaveService.didFinishSaving(_:didFinishSavingWithError:contextInfo:)), + nil + ) } } @@ -216,21 +124,5 @@ enum ComposedImageRenderer { ) .environment(\.locale, .init(identifier: "en")) .previewDisplayName("English") - -// CertificationModalView( -// baseImage: UIImage(named: "s2") ?? UIImage(), -// minutes: 45, -// onDismiss: {} -// ) -// .environment(\.locale, .init(identifier: "ko")) -// .previewDisplayName("한국어") -// -// CertificationModalView( -// baseImage: UIImage(named: "s2") ?? UIImage(), -// minutes: 45, -// onDismiss: {} -// ) -// .environment(\.locale, .init(identifier: "ja")) -// .previewDisplayName("日本語") } } diff --git a/TimerStamp/Presentations/Screens/TimerScreen.swift b/TimerStamp/Presentations/Screens/TimerScreen.swift index 3a2ea6c..26e2aea 100644 --- a/TimerStamp/Presentations/Screens/TimerScreen.swift +++ b/TimerStamp/Presentations/Screens/TimerScreen.swift @@ -33,6 +33,7 @@ struct TimerScreen: View { } private func handleViewAppear() { + photoViewModel.onCertificationCompleted = timerViewModel.reset if hasLaunchedBefore == false { showOnboarding = true hasLaunchedBefore = true diff --git a/TimerStamp/Presentations/Timer/Extensions/View+PhotoSelection.swift b/TimerStamp/Presentations/Timer/Extensions/View+PhotoSelection.swift index 6011bc5..65dfbc0 100644 --- a/TimerStamp/Presentations/Timer/Extensions/View+PhotoSelection.swift +++ b/TimerStamp/Presentations/Timer/Extensions/View+PhotoSelection.swift @@ -14,35 +14,42 @@ extension View { ) -> some View { self .confirmationDialog(L10n.photoSourceTitle, isPresented: $photoViewModel.isShowingSourceDialog, titleVisibility: .visible) { - Button(L10n.photoTakeNew) { - photoViewModel.selectSource(.camera) - } - Button(L10n.photoChooseLibrary) { - photoViewModel.selectSource(.photoLibrary) - } - Button(L10n.cancel, role: .cancel) {} - } + Button(L10n.photoTakeNew) { + photoViewModel.selectSource(.camera) + } + Button(L10n.photoChooseLibrary) { + photoViewModel.selectSource(.photoLibrary) + } + Button(L10n.cancel, role: .cancel) {} + } .sheet(isPresented: $photoViewModel.isShowingImagePicker) { - ImagePicker( - image: Binding( - get: { photoViewModel.selectedImage }, - set: { photoViewModel.didSelectImage($0) } - ), - sourceType: photoViewModel.sourceType - ) - } - // ✅ CertificationModalView로 이미지 전달 - .sheet(isPresented: $photoViewModel.isShowingModal) { - if let image = photoViewModel.selectedImage { - CertificationModalView( - baseImage: image, - minutes: timerViewModel.durationMinutes, - onDismiss: { - photoViewModel.dismissModal() - timerViewModel.reset() - } - ) + ImagePicker( + image: Binding( + get: { photoViewModel.selectedImage }, + set: { photoViewModel.didSelectImage($0) } + ), + sourceType: photoViewModel.selectedSourceType.uiKitType + ) + } + .sheet(isPresented: $photoViewModel.isShowingModal) { + if let image = photoViewModel.selectedImage { + CertificationModalView( + baseImage: image, + minutes: timerViewModel.durationMinutes, + onDismiss: { + photoViewModel.dismissModal() } - } + ) + } + } + } +} + +private extension PhotoSourceType { + var uiKitType: UIImagePickerController.SourceType { + switch self { + case .camera: return .camera + case .photoLibrary: return .photoLibrary + } } } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 87be98c..32347fa 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -22,7 +22,7 @@ platform :ios do lane :test do run_tests( scheme: "TimerStamp", - destination: "platform=iOS Simulator,name=iPhone 17", + destination: "platform=iOS Simulator,name=iPhone 16,OS=18.5", clean: true, result_bundle: true, output_directory: "fastlane/test_output" @@ -71,7 +71,7 @@ platform :ios do ) end - desc "Build and submit to App Store" + desc "Submit existing TestFlight build to App Store review" lane :release do setup_ci if ENV["CI"] @@ -82,24 +82,27 @@ platform :ios do is_key_content_base64: true ) - match( - type: "appstore", - api_key: api_key + version = get_version_number( + xcodeproj: "TimerStamp.xcodeproj", + target: "TimerStamp" ) - gym( - scheme: "TimerStamp_Release", - export_method: "app-store", - clean: true, - output_directory: "fastlane/build", - output_name: "TimerStamp.ipa" + build = latest_testflight_build_number( + api_key: api_key, + version: version, + initial_build_number: 0 ) upload_to_app_store( api_key: api_key, + app_version: version, + build_number: build.to_s, submit_for_review: false, automatic_release: false, - force: true + force: true, + skip_binary_upload: true, + skip_screenshots: true, + skip_metadata: true ) end end diff --git a/fastlane/README.md b/fastlane/README.md index 78ccfc7..f4f66d9 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -15,6 +15,14 @@ For _fastlane_ installation instructions, see [Installing _fastlane_](https://do ## iOS +### ios test_build_number + +```sh +[bundle exec] fastlane ios test_build_number +``` + +Verify build number logic without uploading + ### ios sync_certificates ```sh