diff --git a/.github/workflows/appstore.yml b/.github/workflows/appstore.yml new file mode 100644 index 0000000..67f619e --- /dev/null +++ b/.github/workflows/appstore.yml @@ -0,0 +1,62 @@ +# Appstore 자동 배포 워크플로우 +name: 앱스토어 배포 🏃 + +on: + pull_request: + branches: + - release + + +jobs: + upload-appstore: + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + + - name: Set up SSH + uses: shimataro/ssh-key-action@v2 + with: + key: ${{ secrets.SSH_PRIVATE_KEY }} + known_hosts: ${{ secrets.KNOWN_HOSTS}} + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1' + + - name: Select Xcode version + run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer + + - name: Check Xcode version + run: xcodebuild -version + + - name: Install Bundler + run: gem install bundler + + - name: Install Fastlane + run: brew install fastlane + + - name: Check Fastlane + run: fastlane --version + + - name: Install Dependencies + run: bundle install + + - name: Upload to Appstore 🚀 + env: + APP_STORE_CONNECT_KEY: ${{ secrets.APP_STORE_CONNECT_KEY}} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + DISCORD_URL: ${{ secrets.DISCORD_URL }} + run: bundle exec fastlane upload_appstore + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: ipa-and-dsym + path: | + ./Run\ Mile.ipa + ./Run\ Mile.app.dSYM.zip + diff --git a/.github/workflows/testflight.yml b/.github/workflows/testflight.yml new file mode 100644 index 0000000..89129bb --- /dev/null +++ b/.github/workflows/testflight.yml @@ -0,0 +1,66 @@ +name: 테스트 플라이트 배포 🚀 + +on: + # main 브랜치로 PR 시 테스트 플라이트 업로드 + # CD 성공 여부로 워크플로우 수정 진행 + pull_request: + branches: + - main + +jobs: + build-upload-testflight: + runs-on: macos-15 + + steps: + - uses: actions/checkout@v4 + + - name: Set up SSH + uses: shimataro/ssh-key-action@v2 + with: + key: ${{ secrets.SSH_PRIVATE_KEY }} + known_hosts: ${{ secrets.KNOWN_HOSTS }} + + - name: Set up RUBY + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.1' + + # - name: Set up Xcode + # uses: maxim-lobanov/setup-xcode@v1.6.0 + # with: + # xcode-version: '16.3' + + - name: Select Xcode version + run: sudo xcode-select -s /Applications/Xcode_16.3.app/Contents/Developer + + - name: Check Xcode version + run: xcodebuild -version + + - name: Install Bundler + run: gem install bundler + + - name: Install Fastlane + run: brew install fastlane + + - name: Check Fastlane + run: fastlane --version + + - name: Install Dependencies + run: bundle install + + - name: Upload to Testflight ✈️ + env: + APP_STORE_CONNECT_KEY: ${{ secrets.APP_STORE_CONNECT_KEY}} + APP_STORE_CONNECT_KEY_ID: ${{ secrets.APP_STORE_CONNECT_KEY_ID }} + APP_STORE_CONNECT_ISSUER_ID: ${{ secrets.APP_STORE_CONNECT_ISSUER_ID }} + MATCH_PASSWORD: ${{ secrets.MATCH_PASSWORD }} + DISCORD_URL: ${{ secrets.DISCORD_URL }} + run: bundle exec fastlane upload_testflight + + - name: Upload Artifacts + uses: actions/upload-artifact@v4 + with: + name: ipa-and-dsym + path: | + ./Run\ Mile.ipa + ./Run\ Mile.app.dSYM.zip diff --git a/Gemfile b/Gemfile new file mode 100644 index 0000000..cdd3a6b --- /dev/null +++ b/Gemfile @@ -0,0 +1,6 @@ +source "https://rubygems.org" + +gem "fastlane" + +plugins_path = File.join(File.dirname(__FILE__), 'fastlane', 'Pluginfile') +eval_gemfile(plugins_path) if File.exist?(plugins_path) diff --git a/Gemfile.lock b/Gemfile.lock new file mode 100644 index 0000000..6219db7 --- /dev/null +++ b/Gemfile.lock @@ -0,0 +1,263 @@ +GEM + remote: https://rubygems.org/ + specs: + CFPropertyList (3.0.7) + base64 + nkf + rexml + addressable (2.8.7) + public_suffix (>= 2.0.2, < 7.0) + artifactory (3.0.17) + atomos (0.1.3) + aws-eventstream (1.4.0) + aws-partitions (1.1116.0) + aws-sdk-core (3.225.2) + aws-eventstream (~> 1, >= 1.3.0) + aws-partitions (~> 1, >= 1.992.0) + aws-sigv4 (~> 1.9) + base64 + jmespath (~> 1, >= 1.6.1) + logger + aws-sdk-kms (1.105.0) + aws-sdk-core (~> 3, >= 3.225.0) + aws-sigv4 (~> 1.5) + aws-sdk-s3 (1.189.1) + aws-sdk-core (~> 3, >= 3.225.0) + aws-sdk-kms (~> 1) + aws-sigv4 (~> 1.5) + aws-sigv4 (1.12.1) + aws-eventstream (~> 1, >= 1.0.2) + babosa (1.0.4) + base64 (0.3.0) + claide (1.1.0) + colored (1.2) + colored2 (3.1.2) + commander (4.6.0) + highline (~> 2.0.0) + declarative (0.0.20) + digest-crc (0.7.0) + rake (>= 12.0.0, < 14.0.0) + discordrb (3.3.0) + discordrb-webhooks (~> 3.3.0) + ffi (>= 1.9.24) + opus-ruby + rbnacl (~> 3.4.0) + rest-client (>= 2.1.0.rc1) + websocket-client-simple (>= 0.3.0) + discordrb-webhooks (3.3.0) + rest-client (>= 2.1.0.rc1) + domain_name (0.6.20240107) + dotenv (2.8.1) + emoji_regex (3.2.3) + event_emitter (0.2.6) + excon (0.112.0) + faraday (1.10.4) + faraday-em_http (~> 1.0) + faraday-em_synchrony (~> 1.0) + faraday-excon (~> 1.1) + faraday-httpclient (~> 1.0) + faraday-multipart (~> 1.0) + faraday-net_http (~> 1.0) + faraday-net_http_persistent (~> 1.0) + faraday-patron (~> 1.0) + faraday-rack (~> 1.0) + faraday-retry (~> 1.0) + ruby2_keywords (>= 0.0.4) + faraday-cookie_jar (0.0.7) + faraday (>= 0.8.0) + http-cookie (~> 1.0.0) + faraday-em_http (1.0.0) + faraday-em_synchrony (1.0.0) + faraday-excon (1.1.0) + faraday-httpclient (1.0.1) + faraday-multipart (1.1.0) + multipart-post (~> 2.0) + faraday-net_http (1.0.2) + faraday-net_http_persistent (1.2.0) + faraday-patron (1.0.0) + faraday-rack (1.0.0) + faraday-retry (1.0.3) + faraday_middleware (1.2.1) + faraday (~> 1.0) + fastimage (2.4.0) + fastlane (2.228.0) + CFPropertyList (>= 2.3, < 4.0.0) + addressable (>= 2.8, < 3.0.0) + artifactory (~> 3.0) + aws-sdk-s3 (~> 1.0) + babosa (>= 1.0.3, < 2.0.0) + bundler (>= 1.12.0, < 3.0.0) + colored (~> 1.2) + commander (~> 4.6) + dotenv (>= 2.1.1, < 3.0.0) + emoji_regex (>= 0.1, < 4.0) + excon (>= 0.71.0, < 1.0.0) + faraday (~> 1.0) + faraday-cookie_jar (~> 0.0.6) + faraday_middleware (~> 1.0) + fastimage (>= 2.1.0, < 3.0.0) + fastlane-sirp (>= 1.0.0) + gh_inspector (>= 1.1.2, < 2.0.0) + google-apis-androidpublisher_v3 (~> 0.3) + google-apis-playcustomapp_v1 (~> 0.1) + google-cloud-env (>= 1.6.0, < 2.0.0) + google-cloud-storage (~> 1.31) + highline (~> 2.0) + http-cookie (~> 1.0.5) + json (< 3.0.0) + jwt (>= 2.1.0, < 3) + mini_magick (>= 4.9.4, < 5.0.0) + multipart-post (>= 2.0.0, < 3.0.0) + naturally (~> 2.2) + optparse (>= 0.1.1, < 1.0.0) + plist (>= 3.1.0, < 4.0.0) + rubyzip (>= 2.0.0, < 3.0.0) + security (= 0.1.5) + simctl (~> 1.6.3) + terminal-notifier (>= 2.0.0, < 3.0.0) + terminal-table (~> 3) + tty-screen (>= 0.6.3, < 1.0.0) + tty-spinner (>= 0.8.0, < 1.0.0) + word_wrap (~> 1.0.0) + xcodeproj (>= 1.13.0, < 2.0.0) + xcpretty (~> 0.4.1) + xcpretty-travis-formatter (>= 0.0.3, < 2.0.0) + fastlane-plugin-discord_notifier (0.1.7) + discordrb (~> 3.3.0) + fastlane-sirp (1.0.0) + sysrandom (~> 1.0) + ffi (1.17.2) + ffi (1.17.2-arm64-darwin) + gh_inspector (1.1.3) + google-apis-androidpublisher_v3 (0.54.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-core (0.11.3) + addressable (~> 2.5, >= 2.5.1) + googleauth (>= 0.16.2, < 2.a) + httpclient (>= 2.8.1, < 3.a) + mini_mime (~> 1.0) + representable (~> 3.0) + retriable (>= 2.0, < 4.a) + rexml + google-apis-iamcredentials_v1 (0.17.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-playcustomapp_v1 (0.13.0) + google-apis-core (>= 0.11.0, < 2.a) + google-apis-storage_v1 (0.31.0) + google-apis-core (>= 0.11.0, < 2.a) + google-cloud-core (1.8.0) + google-cloud-env (>= 1.0, < 3.a) + google-cloud-errors (~> 1.0) + google-cloud-env (1.6.0) + faraday (>= 0.17.3, < 3.0) + google-cloud-errors (1.5.0) + google-cloud-storage (1.47.0) + addressable (~> 2.8) + digest-crc (~> 0.4) + google-apis-iamcredentials_v1 (~> 0.1) + google-apis-storage_v1 (~> 0.31.0) + google-cloud-core (~> 1.6) + googleauth (>= 0.16.2, < 2.a) + mini_mime (~> 1.0) + googleauth (1.8.1) + faraday (>= 0.17.3, < 3.a) + jwt (>= 1.4, < 3.0) + multi_json (~> 1.11) + os (>= 0.9, < 2.0) + signet (>= 0.16, < 2.a) + highline (2.0.3) + http-accept (1.7.0) + http-cookie (1.0.8) + domain_name (~> 0.5) + httpclient (2.9.0) + mutex_m + jmespath (1.6.2) + json (2.12.2) + jwt (2.10.1) + base64 + logger (1.7.0) + mime-types (3.7.0) + logger + mime-types-data (~> 3.2025, >= 3.2025.0507) + mime-types-data (3.2025.0624) + mini_magick (4.13.2) + mini_mime (1.1.5) + multi_json (1.15.0) + multipart-post (2.4.1) + mutex_m (0.3.0) + nanaimo (0.4.0) + naturally (2.3.0) + netrc (0.11.0) + nkf (0.2.0) + optparse (0.6.0) + opus-ruby (1.0.1) + ffi + os (1.1.4) + plist (3.7.2) + public_suffix (6.0.2) + rake (13.3.0) + rbnacl (3.4.0) + ffi + representable (3.2.0) + declarative (< 0.1.0) + trailblazer-option (>= 0.1.1, < 0.2.0) + uber (< 0.2.0) + rest-client (2.1.0) + http-accept (>= 1.7.0, < 2.0) + http-cookie (>= 1.0.2, < 2.0) + mime-types (>= 1.16, < 4.0) + netrc (~> 0.8) + retriable (3.1.2) + rexml (3.4.1) + rouge (3.28.0) + ruby2_keywords (0.0.5) + rubyzip (2.4.1) + security (0.1.5) + signet (0.20.0) + addressable (~> 2.8) + faraday (>= 0.17.5, < 3.a) + jwt (>= 1.5, < 3.0) + multi_json (~> 1.10) + simctl (1.6.10) + CFPropertyList + naturally + sysrandom (1.0.5) + terminal-notifier (2.0.0) + terminal-table (3.0.2) + unicode-display_width (>= 1.1.1, < 3) + trailblazer-option (0.1.2) + tty-cursor (0.7.1) + tty-screen (0.8.2) + tty-spinner (0.9.3) + tty-cursor (~> 0.7) + uber (0.1.0) + unicode-display_width (2.6.0) + websocket (1.2.11) + websocket-client-simple (0.9.0) + base64 + event_emitter + mutex_m + websocket + word_wrap (1.0.0) + xcodeproj (1.27.0) + CFPropertyList (>= 2.3.3, < 4.0) + atomos (~> 0.1.3) + claide (>= 1.0.2, < 2.0) + colored2 (~> 3.1) + nanaimo (~> 0.4.0) + rexml (>= 3.3.6, < 4.0) + xcpretty (0.4.1) + rouge (~> 3.28.0) + xcpretty-travis-formatter (1.0.1) + xcpretty (~> 0.2, >= 0.0.7) + +PLATFORMS + arm64-darwin-24 + ruby + +DEPENDENCIES + fastlane + fastlane-plugin-discord_notifier + +BUNDLED WITH + 2.6.9 diff --git a/README.md b/README.md new file mode 100644 index 0000000..f58cffc --- /dev/null +++ b/README.md @@ -0,0 +1,355 @@ +# 🏃👟 Run Mile / 런 마일 +러너들에게 가장 중요한 것은 러닝화👟 + +아이폰에 등록되어있는 운동 기록으로 손 쉽게 러닝화 마일리지를 추적해줍니다!🔥🔥 + +|상태|앱스토어 배포 완료 및 유지보수 진행 중(v1.0.0)| +|:--|:--| +|기술 스택|SwiftUI, HealthKit, Realm, Fastlane, UserNotifications, Vision(이미지 배경 제거)| +|앱스토어|[Run Mile](https://apps.apple.com/kr/app/run-mile/id6747099791)| +|이메일 문의|dlsqja567@naver.com| + +### 신발 관리를 위한 3 Stpes +1. 신발을 등록하고 +2. 운동을 추가하세요! +3. 자동 등록을 통해 더욱 편리하게! + +![Group 1](https://github.com/user-attachments/assets/2f46e30f-c99c-4f34-be48-795c76cc6c74) + +
+ + +## 개발 일지 +|이름|링크| +|:--|:--| +|**0. 사이드 프로젝트 Run Mile 앱 개발기**|https://velog.io/@mooninbeom/0.-사이드-프로젝트-Run-Mile-앱-개발기| +|**1. HealthKit 데이터 사용(with Continuation)**|https://velog.io/@mooninbeom/1.-HealthKit-데이터-사용with-Continuation| +|**2. 백그라운드에서 HealthKit 활용하기**|https://velog.io/@mooninbeom/2.-백그라운드에서-HealthKit-활용하기-o2p1gg9l| +|**3. Fastlane으로 Testflight 배포 자동화**|https://velog.io/@mooninbeom/3.-Fastlane으로-Testflight-배포-자동화| + +
+ + + +## 이번 프로젝트에서 내가 배운 것 + +### 클린 아키텍쳐 적용 + +기존에는 State와 Binding을 사용해 SwiftUI에서 제공하는 프로퍼티 래퍼를 활용해 상태관리를 했습니다. + +또한 `@FetchRequest`를 활용해 View에서 바로 저장된 데이터를 불러와 사용했습니다. + +하지만 동일한 데이터를 불러오는 뷰들이 여러개 생기면서 중복된 코드들이 늘어났고, 뷰 하나에 다양한 목적의 코드들이 생기다보니 뷰 하나가 너무 커져버리는 상황이 생겼습니다. + +이러다보니 어느 한 부분에서 문제가 일어나면 그 코드를 찾기 위해 난해한 View의 코드 속에서 필요한 부분을 찾기가 어려워졌고 유지보수성이 낮아졌습니다. + +각자의 관심사에 맞게 코드를 분리해야 할 필요성을 느끼고 이번 프로젝트에서 이런 아키텍쳐의 대표격이라고 할 수 있는 **클린 아키텍쳐**를 적용했습니다. + +클린 아키텍쳐에서 해당 프로젝트에 제일 중요하다고 생각한 부분은 아래와 같습니다. +* 데이터를 불러오는 부분(Repository)와 사용하는 부분(UseCase)의 분리 +* 핵심 비즈니스 로직을 UseCase를 통해 주입 +* View의 목적성(Presentation, 화면에 보여주는 것만 넣기)을 확실히 하기 위해 View와 ViewModel로 분리 + +
+ +**데이터를 불러오는 부분(Repository)와 사용하는 부분(UseCase)의 분리** + +관심사 분리를 위해 데이터를 불러오는 부분을 **Repository 패턴**을 사용해 분리했습니다. + +추후 테스트 용이성을 증진시키고 UseCase가 Repository를 의존하는 형태를 피하기 위해 프로토콜을 활용했습니다. + +또한 데이터 무결성을 만들기 위해 Swift Concurrency에서 지원해는 `actor`를 사용했습니다. + +이번 프로젝트에서 사용된 Repository는 총 2개 입니다. +* WorkoutDataRepository(HealthKit으로 운동 데이터를 가져오는 레포지토리) +* ShoesDataRepository(Realm으로 관리되는 신발 데이터를 CRUD하는 레포지토리) + +
+ +**핵심 비즈니스 로직을 UseCase를 통해 주입** + +위와 비슷하게 추후 유닛 테스트를 추가할 경우 프로젝트의 비즈니스 로직의 테스트 용의성을 증진시키기 위해 UseCase로 비즈니스 로직 구현했습니다. + +비슷한 기능을 필요로 하는 여러개의 뷰에서 하나의 UseCase를 재활용할 수 있어 생산성 향상과 일관성을 유지할 수 있다고 생각합니다. + +하지만 아직 개인적인 생각은 볼륨이 크지 않은 프로젝트라서 UseCase라는 한단계 더 거쳐 로직을 실행 시키는 것이 오히려 불필요한 과정이라고 느꼈습니다. + +프로젝트의 규모에 따라 부분적인 적용이 필요해 보였습니다. + +
+ +**View의 목적성(Presentation, 화면에 보여주는 것만 넣기)을 확실히 하기 위해 View와 ViewModel로 분리** + +View는 말 **그대로 보여지는 것** 이기 때문에 보여지는대 필요한 프로퍼티를 제외한 상태관리 변수들과 메소드를 모두 ViewModel로 분리했습니다. + +또한 View의 action으로 실행되는 메소드의 네이밍을 `000ButtonTapped`와 같이 작성하여 View에서 특정 액션이 어떤 기능을 수행하는지를 최대한 숨겼습니다. + +이를 통해 View의 목적성인 **보여지는 것**을 최대한 지킬 수 있었다고 생각합니다. + +하지만 위와 비슷하게 볼륨이 작은 프로젝트에서는 소위 말하는 이런 MVVM 패턴이 SwiftUI 프로젝트라면 필요한 가? 의문점이 들었습니다. 오히려 오버엔지니어링 이라는 느낌이 들었죠. + +SwiftUI가 지원하는 상태관리 기능들을 보면 MVVM 패턴 보단 View 하나에서 모든 것을 해결 할 수 있도록 구성을 해 놓은대는 이유가 있을 것이라 생각합니다. + +제가 느낀 점은 둘 다 장단이 있는 만큼 각자의 사용성을 이해하고 상황에 맞게 디자인 패턴을 채택할 수 있는 능력을 기르는 것이 좋아보였습니다. + +

+ +### HealthKit 활용 +이번 프로젝트의 핵심은 기기 내에 있는 운동 데이터를 가져오는 것 입니다. +때문에 **HealthKit**을 활용해 원하는 다양한 기능들을 구현했습니다. + +**1️⃣ 권한 설정** + +기본적으로 건강 데이터는 Privacy 영역이기 때문에 사용자의 권한 허용이 필요하고 다양한 데이터의 종류 중 필요한 영역만 추가해 요청을 해야합니다. +또한 읽기(Read), 쓰기(Share) 영역이 나누어져 있기 때문에 앱에서 필요한 부분만 고려해 필요한 타입만 추가해 요청해야 합니다. +Run Mile에서는 쓰기는 현재 사용하지 않고 운동 타입에 있어서 읽기 부분만 권한을 요청하고 있습니다. + +Run Mile에서는 2개의 스텝으로 권한을 요청합니다. + +
+ +**1. 권한 요청 확인** + +권한 요청이 진행된 상태인지 확인하고 요청이 되지 않았을 경우 요청을 진행합니다. + +권한 요청의 경우 허용/허용안함 여부에 상관없이 한 번 요청이 진행되고 나면 다시 앱에서는 요청을 할 수 없습니다. + +그래서 불필요한 요청을 방지하기 위해 요청 전 요청 여부를 확인합니다. + +만약 이미 요청된 상태이면 사용자가 직접 설정에서 바꾸어야 하기 때문에 해당 방향으로 유도하는 UX가 필요합니다. + +```swift +/// in HealthDataUseCase.swift + +/// Health 데이터 사용 권한 요청이 이루어졌는지 확인합니다. +private func checkAuthorizationStatus() async throws -> Bool { + return try await withCheckedThrowingContinuation { continuation in + store.getRequestStatusForAuthorization( + toShare: Set(), + read: Set([.workoutType()]) + ) { status, error in + if let _ = error { + continuation.resume(throwing: HealthError.unknownError) + } + + switch status { + case .shouldRequest: + continuation.resume(returning: true) + default: + continuation.resume(returning: false) + } + } + } +} +``` + +
+ +**2. 권한 요청** + +권한 요청을 진행합니다. + +요청을 진행하기 전 Health 데이터가 있는 기기인지 여부를 판별합니다. + +```swift +/// in HealthDataUseCase.swift + +/// Health 데이터 사용 권한을 요청합니다. +private func requestAuthorization() async throws { + if HKHealthStore.isHealthDataAvailable() { + try await store.requestAuthorization( + toShare: Set(), + read: Set([.workoutType()]) + ) + } else { + throw HealthError.notAvailableDevice + } +} +``` + +**3. info.plist 수정** + +요청 시 나오는 메시지를 작성합니다. + +해당 과정은 필수적이기 때문에 반드시 추가가 필요합니다. + +만약 누락이 되어있거나 권한이 필요한 자세한 이유를 서술해놓지 않은 경우 출시 심사 시 리젝 사유가 될 수 있습니다. + +실제로 건강 데이터 쪽은 아니지만 카메라 사용 관련 메시지에 `사진을 찍기 위해 카메라 허용이 필요합니다.`라고 적어놓으니 구체적이지 않다고 리젝을 당했습니다. + +또한 앱을 올리는 과정에서 저희 앱은 읽기 부분만 사용하기에 해당 info 만 업데이트 했지만 쓰기 부분이 누락되어 있다고 업로드에 실패했습니다. + +결과적으로 info에 Privacy와 관련된 해당 내용을 추가할 때 가능한 구체적인 작성이 필요하고 Health의 경우 Update, Share 둘 다 작성할 필요가 있습니다. + +스크린샷 2025-06-16 오후 4 12 16 + + + + +

+ +**2️⃣ 데이터 불러오기** + +`HKSampleQuery`를 통해 일반적인 데이터를 가져올 수 있는 쿼리문을 만들 수 있습니다. + +해당 쿼리의 파라미터로 원하는 데이터를 필터링 할 수 있습니다. + +Run Mile 에서는 운동 데이터만 필요하므로 workoutType으로 제한하고 날짜 내림차순으로 정렬을 진행했습니다. + +쿼리 사용시 유의할 점은 반환 타입으로 나오는 `HKSample`은 다양한 건강 데이터들의 추상화 타입이기 때문에 변환하지 않으면 정보 접근이 제한 됩니다.(시간 관련 정보만 제공) + +때문에 제대로 사용하기 위해서는 특정 타입에 맞추어 타입 캐스팅을 꼭 해야 합니다!(해당 프로젝트에서는 운동 데이터를 들고 오므로 거기에 맞는 `HKWorkout` 타입 캐스팅 진행) + +```swift +/// in WorkoutDataRepositoryImpl.swift + +private let store = HKHealthStore() + +public func fetchWorkoutData() async throws -> [RunningData] { + let predicate = HKQuery.predicateForWorkouts(with: .running) + let descriptor = [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)] + + let result: [HKWorkout] = try await store.fetchData( + sampleType: .workoutType(), + predicate: predicate, + limit: HKObjectQueryNoLimit, + sortDescriptors: descriptor + ) + + return convertToRunningData(result) +} +``` + +구현부에서 간단하고 범용성 있는 사용을 위해 제네릭 메소드 사용 +```swift +// in HealthKit+.swift + +public func fetchData( + sampleType: HKSampleType, + predicate: NSPredicate? = nil, + limit: Int, + sortDescriptors: [NSSortDescriptor]? = nil +) async throws -> [T] { + let predicate = HKQuery.predicateForWorkouts(with: .running) + + let data = try await withCheckedThrowingContinuation { (continuation: CheckedContinuation<[HKSample], any Error>) in + let query = HKSampleQuery( + sampleType: sampleType, + predicate: predicate, + limit: limit, + sortDescriptors: sortDescriptors) { query, samples, error in + if let _ = error { + continuation.resume(with: .failure(HealthError.failedToLoadWorkoutData)) + } + + guard let samples = samples else { + continuation.resume(with: .failure(HealthError.failedToLoadWorkoutData)) + return + } + + continuation.resume(with: .success(samples)) + } + self.execute(query) + } + + guard let result = data as? [T] else { throw HealthError.failedToLoadWorkoutData } + + return result +} +``` + +
+ +**3️⃣ 백그라운드 업데이트 적용** + +대표적인 운동 기록 앱 `Strava`의 경우 새로운 운동 데이터가 생기면 앱으로 자동 업데이트를 시켜줍니다. + +이 기능에 대해 궁금증과 호기심이 생겨 공부 후 프로젝트에 도입했습니다. + +`HKHealthStore`의 `enableBackgroundDelivery()`메소드를 통해 특정 타입의 건강 데이터가 업데이트 되었을 때를 트리거로 앱을 깨워 Background Task 를 진행시킬 수 있습니다. + +유의할 점은 해당 기능은 특정 데이터의 업데이트 여부만 알려주기 때문에 업데이트된 데이터를 사용하기 위해서는 Background Task 안에서 새로운 Sample Query로 데이터를 fetch해야 합니다. + +또한 앱이 완전 종료(suspended)되면 background 등록도 같이 종료되기 때문에 운동이 언제 진행될지 알 수 없는 특성 상 항상 트리거가 실행될 수 있도록 앱 실행 시(AppDelegate)에 등록되도록 구현했습니다. + +Run Mile에서 해당 기능으로 구현하고자 한 feature는 + +새로운 운동(러닝)이 추가되었을 때 +* 자동 등록 기능이 켜져 있을 경우 -> 신발에 운동 추가 후, 추가 완료 noti 생성 +* 자동 등록 기능 x -> 운동 완료 noti 생성(앱으로 유도하기 위함) + +입니다. + +```swift +/// in AppDelegate.swift + +public static func setHealthBackgroundTask() async { + let store = HKHealthStore() + + do { + try await store.enableBackgroundDelivery(for: .workoutType(), frequency: .immediate) + let query = HKObserverQuery( + sampleType: .workoutType(), + predicate: nil + ) { query, completionHandler, error in + if let error = error { + print(error) + return + } + + let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) + let sampleQuery = HKSampleQuery(queryDescriptors: [.init(sampleType: .workoutType(), predicate: nil)], limit: 1, sortDescriptors: [sort]) { _, samples, error in + + ... + + guard let workout = samples?.first as? HKWorkout else { + return + } + + guard case .running = workout.workoutActivityType else { + return + } + + let workoutId = workout.uuid.uuidString + let currentId = UserDefaults.standard.recentWorkoutID + + if !UserDefaults.standard.isFirstLaunch { + UserDefaults.standard.recentWorkoutID = workoutId + } else { + if workoutId != currentId { + let distance = workout.getKilometerDistance() + if !UserDefaults.standard.selectedShoesID.isEmpty { + + UNUserNotificationCenter.requestNotification( + title: String(format: "%.2fkm 러닝 완료 🔥🔥", distance!), + body: distance == nil + ? "신발에 자동 등록이 완료되었습니다!" + : String(format: "신발에 자동 등록이 완료되었습니다. 러닝 후 스트레칭 꼭 잊지 마세요!", distance!) + ) + + autoRegisterShoes(workout: workout) + } else { + UNUserNotificationCenter.requestNotification( + title: String(format: "%.2fkm 러닝 완료 🔥🔥", distance!), + body: distance == nil + ? "신발 마일리지를 등록할 준비가 완료되었습니다. 등록하러 가볼까요?" + : String(format: "%.2fkm, 잊지 말고 마일리지를 등록하러 오세요!", distance!) + ) + } + UserDefaults.standard.recentWorkoutID = workoutId + } + } + } + + store.execute(sampleQuery) + + completionHandler() + } + + store.execute(query) + } catch { + print(error) + } +} +``` + diff --git a/ReleaseNote/v1.0.1.md b/ReleaseNote/v1.0.1.md new file mode 100644 index 0000000..c8fe435 --- /dev/null +++ b/ReleaseNote/v1.0.1.md @@ -0,0 +1,4 @@ +# What's new +- 운동 등록 알람을 눌렀을 때 바로 등록할 수 있는 기능을 추가했습니다. +- 알람이 여러번 오는 버그를 수정했습니다. +- 그 외 작은 여러가지 버그를 수정했습니다. diff --git a/Run Mile.xcodeproj/project.pbxproj b/Run Mile.xcodeproj/project.pbxproj index 0d85d63..d25d9f0 100644 --- a/Run Mile.xcodeproj/project.pbxproj +++ b/Run Mile.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 2C079FBF2DB2505700B1EE0D /* RealmSwift in Frameworks */ = {isa = PBXBuildFile; productRef = 2C079FBE2DB2505700B1EE0D /* RealmSwift */; }; 2C079FC02DB2507700B1EE0D /* RealmSwift in Embed Frameworks */ = {isa = PBXBuildFile; productRef = 2C079FBE2DB2505700B1EE0D /* RealmSwift */; settings = {ATTRIBUTES = (CodeSignOnCopy, ); }; }; 2CE46E212DAE78D7008C6FBC /* .gitignore in Resources */ = {isa = PBXBuildFile; fileRef = 2CE46E202DAE78D7008C6FBC /* .gitignore */; }; - 2CF57F5C2DF9483100F59463 /* PrivacyPolicy.md in Resources */ = {isa = PBXBuildFile; fileRef = 2CF57F5B2DF9483100F59463 /* PrivacyPolicy.md */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -47,6 +46,9 @@ /* Begin PBXFileReference section */ 2C079A562DAF9F7F00B1EE0D /* Run_MileApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Run_MileApp.swift; sourceTree = ""; }; + 2C7633922E0049A30005D988 /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = ""; }; + 2C7635B02E0051AD0005D988 /* Gemfile */ = {isa = PBXFileReference; lastKnownFileType = text; path = Gemfile; sourceTree = ""; }; + 2C7635B12E0051AD0005D988 /* Gemfile.lock */ = {isa = PBXFileReference; lastKnownFileType = text; path = Gemfile.lock; sourceTree = ""; }; 2CE46DF32DAE7780008C6FBC /* Run Mile.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Run Mile.app"; sourceTree = BUILT_PRODUCTS_DIR; }; 2CE46E002DAE7782008C6FBC /* Run MileTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Run MileTests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; 2CE46E0A2DAE7782008C6FBC /* Run MileUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = "Run MileUITests.xctest"; sourceTree = BUILT_PRODUCTS_DIR; }; @@ -55,6 +57,16 @@ /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedRootGroup section */ + 2C7635AF2E0051AD0005D988 /* fastlane */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = fastlane; + sourceTree = ""; + }; + 2CA9201C2E103B150049926D /* ReleaseNote */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ReleaseNote; + sourceTree = ""; + }; 2CE46DF52DAE7780008C6FBC /* Run Mile */ = { isa = PBXFileSystemSynchronizedRootGroup; path = "Run Mile"; @@ -101,6 +113,11 @@ 2CE46DEA2DAE7780008C6FBC = { isa = PBXGroup; children = ( + 2CA9201C2E103B150049926D /* ReleaseNote */, + 2C7635AF2E0051AD0005D988 /* fastlane */, + 2C7635B02E0051AD0005D988 /* Gemfile */, + 2C7635B12E0051AD0005D988 /* Gemfile.lock */, + 2C7633922E0049A30005D988 /* README.md */, 2CF57F5B2DF9483100F59463 /* PrivacyPolicy.md */, 2C079A562DAF9F7F00B1EE0D /* Run_MileApp.swift */, 2CE46E202DAE78D7008C6FBC /* .gitignore */, @@ -246,7 +263,6 @@ isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( - 2CF57F5C2DF9483100F59463 /* PrivacyPolicy.md in Resources */, 2CE46E212DAE78D7008C6FBC /* .gitignore in Resources */, ); runOnlyForDeploymentPostprocessing = 0; @@ -433,9 +449,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Run Mile/Run Mile.entitlements"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 202506113; - DEVELOPMENT_TEAM = VF8R3A969C; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 202506231; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = VF8R3A969C; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Run-Mile-Info.plist"; @@ -447,7 +466,7 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UIStatusBarStyle = ""; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UIUserInterfaceStyle = Dark; @@ -456,9 +475,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.mooni.Run-Mile"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.mooni.Run-Mile"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -475,9 +496,12 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CODE_SIGN_ENTITLEMENTS = "Run Mile/Run Mile.entitlements"; - CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 202506113; - DEVELOPMENT_TEAM = VF8R3A969C; + CODE_SIGN_IDENTITY = "Apple Development"; + "CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Distribution"; + CODE_SIGN_STYLE = Manual; + CURRENT_PROJECT_VERSION = 202506231; + DEVELOPMENT_TEAM = ""; + "DEVELOPMENT_TEAM[sdk=iphoneos*]" = VF8R3A969C; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = "Run-Mile-Info.plist"; @@ -489,7 +513,7 @@ INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; INFOPLIST_KEY_UILaunchScreen_Generation = YES; - INFOPLIST_KEY_UIStatusBarStyle = ""; + INFOPLIST_KEY_UIStatusBarStyle = UIStatusBarStyleDarkContent; INFOPLIST_KEY_UISupportedInterfaceOrientations = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown"; INFOPLIST_KEY_UIUserInterfaceStyle = Dark; @@ -498,9 +522,11 @@ "$(inherited)", "@executable_path/Frameworks", ); - MARKETING_VERSION = 1.0.0; + MARKETING_VERSION = 1.0.1; PRODUCT_BUNDLE_IDENTIFIER = "com.mooni.Run-Mile"; PRODUCT_NAME = "$(TARGET_NAME)"; + PROVISIONING_PROFILE_SPECIFIER = ""; + "PROVISIONING_PROFILE_SPECIFIER[sdk=iphoneos*]" = "match AppStore com.mooni.Run-Mile"; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; @@ -516,7 +542,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 202506231; DEVELOPMENT_TEAM = VF8R3A969C; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -535,7 +561,7 @@ buildSettings = { BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 202506231; DEVELOPMENT_TEAM = VF8R3A969C; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; @@ -553,7 +579,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 202506231; DEVELOPMENT_TEAM = VF8R3A969C; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -570,7 +596,7 @@ isa = XCBuildConfiguration; buildSettings = { CODE_SIGN_STYLE = Automatic; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 202506231; DEVELOPMENT_TEAM = VF8R3A969C; GENERATE_INFOPLIST_FILE = YES; MARKETING_VERSION = 1.0; @@ -629,8 +655,8 @@ isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/realm/realm-swift.git"; requirement = { - branch = master; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 10.54.5; }; }; /* End XCRemoteSwiftPackageReference section */ diff --git a/Run Mile.xcodeproj/xcshareddata/xcdebugger/Breakpoints_v2.xcbkptlist b/Run Mile.xcodeproj/xcshareddata/xcdebugger/Breakpoints_v2.xcbkptlist new file mode 100644 index 0000000..a375fdc --- /dev/null +++ b/Run Mile.xcodeproj/xcshareddata/xcdebugger/Breakpoints_v2.xcbkptlist @@ -0,0 +1,6 @@ + + + diff --git a/Run Mile/Data/DTO/WorkoutDTO.swift b/Run Mile/Data/DTO/WorkoutDTO.swift index 8e7ea3e..9bd3d94 100644 --- a/Run Mile/Data/DTO/WorkoutDTO.swift +++ b/Run Mile/Data/DTO/WorkoutDTO.swift @@ -13,4 +13,6 @@ final class WorkoutDTO: Object { @Persisted(primaryKey: true) public var id: UUID @Persisted public var date: Date? @Persisted public var distance: Double + /// 역관계 + @Persisted(originProperty: "workouts") public var shoes: LinkingObjects } diff --git a/Run Mile/Data/Health/WorkoutDataRepositoryImpl.swift b/Run Mile/Data/Health/WorkoutDataRepositoryImpl.swift index 5abc71c..d384e59 100644 --- a/Run Mile/Data/Health/WorkoutDataRepositoryImpl.swift +++ b/Run Mile/Data/Health/WorkoutDataRepositoryImpl.swift @@ -5,6 +5,7 @@ // Created by 문인범 on 4/15/25. // +import RealmSwift import Foundation import HealthKit @@ -12,7 +13,7 @@ import HealthKit actor WorkoutDataRepositoryImpl: WorkoutDataRepository { private let store = HKHealthStore() - public func fetchWorkoutData() async throws -> [RunningData] { + public func fetchAllWorkoutData() async throws -> [Workout] { let predicate = HKQuery.predicateForWorkouts(with: .running) let descriptor = [NSSortDescriptor(key: HKSampleSortIdentifierStartDate, ascending: false)] @@ -23,34 +24,31 @@ actor WorkoutDataRepositoryImpl: WorkoutDataRepository { sortDescriptors: descriptor ) - return convertToRunningData(result) + return result.map { $0.toEntity } } -} - - -extension WorkoutDataRepositoryImpl { - /// HKWorkout 을 RunningData 엔티티로 변환합니다. - private func convertToRunningData(_ workouts: [HKWorkout]) -> [RunningData] { - var resultArray = [RunningData]() - workouts.forEach { - if let statistics = $0.statistics(for: HKQuantityType(.distanceWalkingRunning)), - let sumDistance = statistics.sumQuantity()?.doubleValue(for: .meter()) { - let localStartDate = Calendar.current.date( - byAdding: .second, - value: TimeZone.current.secondsFromGMT(), - to: $0.startDate - ) - - let result = RunningData( - id: $0.uuid, - distance: sumDistance, - date: localStartDate - ) - - resultArray.append(result) - } + + public func fetchUnsavedWorkoutData() async throws -> [Workout] { + let savedWorkouts = try await fetchSavedWorkoutData() + let entireWorkouts = try await fetchAllWorkoutData() + + let result = entireWorkouts.filter { first in + !savedWorkouts.contains(where: { $0.id == first.id }) + } + + return result + } + + public func fetchSavedWorkoutData() async throws -> [Workout] { + let realm = try await Realm.open() + let fetchedResult = realm.objects(WorkoutDTO.self) + var result = [Workout]() + + fetchedResult.forEach { + result.append( + .init(id: $0.id, distance: $0.distance, date: $0.date) + ) } - return resultArray + return result } } diff --git a/Run Mile/Data/Shoes/ShoesDataRepositoryImpl.swift b/Run Mile/Data/Shoes/ShoesDataRepositoryImpl.swift index d2efe16..12f16a9 100644 --- a/Run Mile/Data/Shoes/ShoesDataRepositoryImpl.swift +++ b/Run Mile/Data/Shoes/ShoesDataRepositoryImpl.swift @@ -7,7 +7,6 @@ import Foundation import RealmSwift -import UserNotifications actor ShoesDataRepositoryImpl: ShoesDataRepository { @@ -38,10 +37,10 @@ actor ShoesDataRepositoryImpl: ShoesDataRepository { let fetchedResult = realm.object(ofType: ShoesDTO.self, forPrimaryKey: id) if let result = fetchedResult { - var workouts = [RunningData]() + var workouts = [Workout]() result.workouts.forEach { workout in workouts.append( - RunningData( + Workout( id: workout.id, distance: workout.distance, date: workout.date @@ -104,7 +103,7 @@ actor ShoesDataRepositoryImpl: ShoesDataRepository { dto.workouts = list if !shoes.isGradutate, shoes.isOverGoal { - UNUserNotificationCenter.requestNotification( + UserNotificationsManager.requestNotification( title: "\(shoes.nickname)의 목표 마일리지를 달성했습니다!", body: "축하드립니다! 이제 명예의 전당으로 갈 일만 남았습니다. 가보실까요?" ) @@ -135,10 +134,10 @@ extension ShoesDataRepositoryImpl { private func toEntities(_ dto: Results) -> [Shoes] { var resultArray: [Shoes] = [] dto.forEach { - var workouts = [RunningData]() + var workouts = [Workout]() $0.workouts.forEach { workout in workouts.append( - RunningData( + Workout( id: workout.id, distance: workout.distance, date: workout.date diff --git a/Run Mile/Domain/Entities/RunningData.swift b/Run Mile/Domain/Entities/RunningData.swift index a844c2b..5ddc1d3 100644 --- a/Run Mile/Domain/Entities/RunningData.swift +++ b/Run Mile/Domain/Entities/RunningData.swift @@ -7,7 +7,7 @@ import Foundation -public struct RunningData: Sendable, Identifiable, Hashable { +public struct Workout: Sendable, Identifiable, Hashable { public let id: UUID public let distance: Double public let date: Date? diff --git a/Run Mile/Domain/Entities/Shoes.swift b/Run Mile/Domain/Entities/Shoes.swift index 77b2209..e7aacd2 100644 --- a/Run Mile/Domain/Entities/Shoes.swift +++ b/Run Mile/Domain/Entities/Shoes.swift @@ -16,7 +16,7 @@ struct Shoes: Sendable, Identifiable, Hashable { let goalMileage: Double // 목표 마일리지 let currentMileage: Double // 초기 마일리지 let isCurrentShoes: Bool // 현재 자동등록이 선택된 신발인지 여부 - var workouts: [RunningData] // 신발에 등록된 운동기록 + var workouts: [Workout] // 신발에 등록된 운동기록 let isGradutate: Bool // 신발 졸업 여부 init(id: UUID, @@ -25,7 +25,7 @@ struct Shoes: Sendable, Identifiable, Hashable { nickname: String, goalMileage: Double, currentMileage: Double, - workouts: [RunningData], + workouts: [Workout], isGraduate: Bool = false ) { self.id = id diff --git a/Run Mile/Domain/Interfaces/Repositories/WorkoutDataRepository.swift b/Run Mile/Domain/Interfaces/Repositories/WorkoutDataRepository.swift index a062250..785820c 100644 --- a/Run Mile/Domain/Interfaces/Repositories/WorkoutDataRepository.swift +++ b/Run Mile/Domain/Interfaces/Repositories/WorkoutDataRepository.swift @@ -11,5 +11,11 @@ import HealthKit protocol WorkoutDataRepository: Sendable { /// 운동(러닝) 데이터를 불러옵니다. - func fetchWorkoutData() async throws -> [RunningData] + func fetchAllWorkoutData() async throws -> [Workout] + + /// + func fetchUnsavedWorkoutData() async throws -> [Workout] + + /// 저장된 운동(러닝) 데이터를 불러옵니다. + func fetchSavedWorkoutData() async throws -> [Workout] } diff --git a/Run Mile/Domain/UseCases/ChooseShoesUseCase.swift b/Run Mile/Domain/UseCases/ChooseShoesUseCase.swift index 8bf6e76..06df50f 100644 --- a/Run Mile/Domain/UseCases/ChooseShoesUseCase.swift +++ b/Run Mile/Domain/UseCases/ChooseShoesUseCase.swift @@ -10,7 +10,7 @@ import Foundation protocol ChooseShoesUseCase: Sendable { func fetchShoesList() async throws -> [Shoes] - func registerWorkouts(shoes: Shoes, workouts: [RunningData]) async throws + func registerWorkouts(shoes: Shoes, workouts: [Workout]) async throws } @@ -26,21 +26,73 @@ final class DefaultChooseShoesUseCase: ChooseShoesUseCase { try await repository.fetchCurrentShoes() } - public func registerWorkouts(shoes: Shoes, workouts: [RunningData]) async throws { + public func registerWorkouts(shoes: Shoes, workouts: [Workout]) async throws { + let currentWorkouts = shoes.workouts + var newWorkouts = shoes.workouts - newWorkouts.append(contentsOf: workouts) - let newShoes = Shoes( - id: shoes.id, - image: shoes.image, - shoesName: shoes.shoesName, - nickname: shoes.nickname, - goalMileage: shoes.goalMileage, - currentMileage: shoes.currentMileage, - workouts: newWorkouts - ) + var isDuplicated = false + + for currentWorkout in currentWorkouts { + if workouts.contains(where: { $0.id == currentWorkout.id }) { + isDuplicated = true + break + } + } - try await repository.updateShoes(shoes: newShoes) + if isDuplicated { + if workouts.count == 1 { + await NavigationCoordinator.shared.push( + .init( + title: "중복된 운동 데이터입니다.", + message: "다시 시도해주세요!", + firstButton: .cancel(title: "확인", action: {}), + secondButton: nil + ) + ) + } else { + workouts.forEach { workout in + if !currentWorkouts.contains(where: { $0.id == workout.id }) { + newWorkouts.append(workout) + } + } + + let newShoes = Shoes( + id: shoes.id, + image: shoes.image, + shoesName: shoes.shoesName, + nickname: shoes.nickname, + goalMileage: shoes.goalMileage, + currentMileage: shoes.currentMileage, + workouts: newWorkouts + ) + + try await repository.updateShoes(shoes: newShoes) + + await NavigationCoordinator.shared.push( + .init( + title: "중복된 운동 데이터가 포함되어 있습니다.", + message: "해당 데이터를 제외한 나머지 운동 데이터만 저장 완료 했습니다.", + firstButton: .cancel(title: "확인", action: {}), + secondButton: nil + ) + ) + } + } else { + newWorkouts.append(contentsOf: workouts) + + let newShoes = Shoes( + id: shoes.id, + image: shoes.image, + shoesName: shoes.shoesName, + nickname: shoes.nickname, + goalMileage: shoes.goalMileage, + currentMileage: shoes.currentMileage, + workouts: newWorkouts + ) + + try await repository.updateShoes(shoes: newShoes) + } } diff --git a/Run Mile/Domain/UseCases/HealthDataUseCase.swift b/Run Mile/Domain/UseCases/HealthDataUseCase.swift index 3fd5c35..a50e6a9 100644 --- a/Run Mile/Domain/UseCases/HealthDataUseCase.swift +++ b/Run Mile/Domain/UseCases/HealthDataUseCase.swift @@ -14,7 +14,7 @@ protocol HealthDataUseCase { @discardableResult func checkHealthAuthorization() async throws -> Bool - func fetchWorkoutData() async throws -> [RunningData] + func fetchWorkoutData() async throws -> [Workout] } @@ -43,15 +43,8 @@ final class DefaultHealthDataUseCase: HealthDataUseCase { return true } - public func fetchWorkoutData() async throws -> [RunningData] { - let fetchedResult = try await workoutDataRepository.fetchWorkoutData() - let shoes = try await shoesDataRepository.fetchAllShoes() - - let registeredId = Set(shoes.flatMap { $0.workouts.map { $0.id } }) - - return fetchedResult.filter ({ workout in - !registeredId.contains(workout.id) - }) + public func fetchWorkoutData() async throws -> [Workout] { + try await workoutDataRepository.fetchUnsavedWorkoutData() } } diff --git a/Run Mile/Helper/Delegate/AppDelegate.swift b/Run Mile/Helper/Delegate/AppDelegate.swift index e6806e7..582e8fb 100644 --- a/Run Mile/Helper/Delegate/AppDelegate.swift +++ b/Run Mile/Helper/Delegate/AppDelegate.swift @@ -6,9 +6,9 @@ // import UIKit -import HealthKit import SwiftUI -import UserNotifications +import HealthKit +import RealmSwift final class AppDelegate: NSObject, UIApplicationDelegate { @@ -16,10 +16,12 @@ final class AppDelegate: NSObject, UIApplicationDelegate { _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil ) -> Bool { + self.realmMigration() Task { await self.userNotificationAuthorize() - await AppDelegate.setHealthBackgroundTask() + await Self.setBackgroundDelivery() + self.setHealthBackgroundQueryTask() } return true @@ -38,6 +40,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate { extension AppDelegate { + /// UserNotification 권한 허용 private func userNotificationAuthorize() async { let notiCenter = UNUserNotificationCenter.current() @@ -54,82 +57,89 @@ extension AppDelegate { } } - public static func setHealthBackgroundTask() async { + /// 백그라운드에서 Health 데이터 사용 업데이트 설정 + public static func setBackgroundDelivery() async { let store = HKHealthStore() do { try await store.enableBackgroundDelivery(for: .workoutType(), frequency: .immediate) - let query = HKObserverQuery( - sampleType: .workoutType(), - predicate: nil - ) { query, completionHandler, error in - if let error = error { - print(error) - return - } - - let sort = NSSortDescriptor(key: HKSampleSortIdentifierEndDate, ascending: false) - let sampleQuery = HKSampleQuery(queryDescriptors: [.init(sampleType: .workoutType(), predicate: nil)], limit: 1, sortDescriptors: [sort]) { _, samples, error in - if let error = error { - print(error) - return - } - - defer { - UserDefaults.standard.isFirstLaunch = true - } - - guard let workout = samples?.first as? HKWorkout else { - return - } - - guard case .running = workout.workoutActivityType else { - return - } - - let workoutId = workout.uuid.uuidString - let currentId = UserDefaults.standard.recentWorkoutID - - if !UserDefaults.standard.isFirstLaunch { - UserDefaults.standard.recentWorkoutID = workoutId - } else { - if workoutId != currentId { - let distance = workout.getKilometerDistance() - if !UserDefaults.standard.selectedShoesID.isEmpty { - - UNUserNotificationCenter.requestNotification( - title: String(format: "%.2fkm 러닝 완료 🔥🔥", distance!), - body: distance == nil - ? "신발에 자동 등록이 완료되었습니다!" - : String(format: "신발에 자동 등록이 완료되었습니다. 러닝 후 스트레칭 꼭 잊지 마세요!", distance!) - ) - - autoRegisterShoes(workout: workout) - } else { - UNUserNotificationCenter.requestNotification( - title: String(format: "%.2fkm 러닝 완료 🔥🔥", distance!), - body: distance == nil - ? "신발 마일리지를 등록할 준비가 완료되었습니다. 등록하러 가볼까요?" - : String(format: "%.2fkm, 잊지 말고 마일리지를 등록하러 오세요!", distance!) - ) - } - UserDefaults.standard.recentWorkoutID = workoutId - } - } - } - - store.execute(sampleQuery) - - completionHandler() + } catch { + print(error.localizedDescription) + } + } + + /// 백그라운드에서 사용할 HealthKit Query 설정 + private func setHealthBackgroundQueryTask() { + let store = HKHealthStore() + + let anchoredQuery = HKAnchoredObjectQuery( + type: .workoutType(), + predicate: nil, + anchor: UserDefaults.standard.lastAnchor, + limit: HKObjectQueryNoLimit + ) { query, samples, deletedObjects, anchor, error in + if UserDefaults.standard.lastAnchor != anchor { + UserDefaults.standard.lastAnchor = anchor } + } + + anchoredQuery.updateHandler = { [weak self] query, samples, deletedObjects, anchor, error in + let currentAnchor = UserDefaults.standard.lastAnchor - store.execute(query) - } catch { - print(error) + if currentAnchor != anchor { + UserDefaults.standard.lastAnchor = anchor + } else { + return + } + + if let error = error { + print(error) + return + } + + guard let samples = samples as? [HKWorkout], + !samples.isEmpty + else { + print(#function) + return + } + + guard let workout = samples.first else { + return + } + + guard case .running = workout.workoutActivityType else { + return + } + + let distance = workout.getKilometerDistance() + + if !UserDefaults.standard.selectedShoesID.isEmpty { + UserNotificationsManager.requestNotification( + category: .autoRegister(workout.toEntity), + title: String(format: "%.2fkm 러닝 완료 🔥🔥", distance!), + body: distance == nil + ? "신발에 자동 등록이 완료되었습니다!" + : String(format: "신발에 자동 등록이 완료되었습니다. 러닝 후 스트레칭 꼭 잊지 마세요!", distance!) + ) + + self?.autoRegisterShoes(workout: workout) + } else { + UserNotificationsManager.requestNotification( + category: .manualRegister(workout.toEntity), + title: String(format: "%.2fkm 러닝 완료 🔥🔥", distance!), + body: distance == nil + ? "신발 마일리지를 등록할 준비가 완료되었습니다. 등록하러 가볼까요?" + : String(format: "%.2fkm, 잊지 말고 마일리지를 등록하러 오세요!", distance!) + ) + } } + + store.execute(anchoredQuery) } - private static func autoRegisterShoes(workout: HKWorkout) { + /// 업데이트된 운동 자동 등록 메소드 + private func autoRegisterShoes(workout: HKWorkout) { let shoesDataRepository: ShoesDataRepository = ShoesDataRepositoryImpl() Task { @@ -137,7 +147,7 @@ extension AppDelegate { let shoesID = UUID(uuidString: UserDefaults.standard.selectedShoesID)! let shoes = try await shoesDataRepository.fetchSingleShoes(id: shoesID) var workouts = shoes.workouts - workouts.append(workout.toEntity()) + workouts.append(workout.toEntity) let newShoes = Shoes( id: shoes.id, @@ -151,13 +161,35 @@ extension AppDelegate { try await shoesDataRepository.updateShoes(shoes: newShoes) } catch { - UNUserNotificationCenter.requestNotification( + UserNotificationsManager.requestNotification( + category: .manualRegister(workout.toEntity), title: "마일리지 자동 등록에 실패했습니다.", body: "앱에서 수동으로 등록 부탁드립니다." ) } } } + + /// Realm 스키마 마이그레이션 + private func realmMigration() { + let config = Realm.Configuration( + schemaVersion: 1, + migrationBlock: { migration, oldSchemaVersion in + /// Version 1 + /// WorkoutDTO : 역관계 추가 <-> ShoesDTO + if oldSchemaVersion < 1 { + + } + } + ) + + Realm.Configuration.defaultConfiguration = config + + #if DEBUG + // Debug용 print + print(Realm.Configuration.defaultConfiguration.fileURL ?? "알 수 없음") + #endif + } } extension AppDelegate: UNUserNotificationCenterDelegate { @@ -167,6 +199,30 @@ extension AppDelegate: UNUserNotificationCenterDelegate { didReceive response: UNNotificationResponse, withCompletionHandler completionHandler: @escaping () -> Void ) { + let userInfo = response.notification.request.content.userInfo + + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + + + if let category = userInfo["category"] as? String, + category == "ManualRegister", + let uuidString = userInfo["id"] as? String, + let uuid = UUID(uuidString: uuidString), + let dateString = userInfo["date"] as? String, + let date = dateFormatter.date(from: dateString), + let distanceString = userInfo["distance"] as? String, + let distance = Double(distanceString) + { + let runningData = Workout( + id: uuid, + distance: distance, + date: date + ) + + NavigationCoordinator.shared.push(.chooseShoes([runningData], {})) + } + completionHandler() } diff --git a/Run Mile/Helper/HealthKit+.swift b/Run Mile/Helper/HealthKit+.swift index 227bc6b..3f13bbe 100644 --- a/Run Mile/Helper/HealthKit+.swift +++ b/Run Mile/Helper/HealthKit+.swift @@ -8,7 +8,7 @@ import HealthKit -extension HKHealthStore { +extension HKHealthStore: Sendable { public func fetchData( sampleType: HKSampleType, predicate: NSPredicate? = nil, @@ -52,10 +52,17 @@ extension HKWorkout { return nil } - public func toEntity() -> RunningData { + public var toEntity: Workout { let statistics = self.statistics(for: HKQuantityType(.distanceWalkingRunning))! let sumDistance = statistics.sumQuantity()! + // 실기기 검증 필요 +// let localStartDate = Calendar.current.date( +// byAdding: .second, +// value: TimeZone.current.secondsFromGMT(), +// to: self.startDate +// ) + return .init( id: self.uuid, distance: sumDistance.doubleValue(for: .meter()), diff --git a/Run Mile/Helper/UNUserNotificationCenter+.swift b/Run Mile/Helper/UNUserNotificationCenter+.swift index 4f6c22c..34e0c89 100644 --- a/Run Mile/Helper/UNUserNotificationCenter+.swift +++ b/Run Mile/Helper/UNUserNotificationCenter+.swift @@ -8,17 +8,54 @@ import UserNotifications -extension UNUserNotificationCenter { - static func requestNotification(id: String = UUID().uuidString, title: String, body: String) { - let center = self.current() +public enum UserNotificationsManager { + static func requestNotification( + category: NotificationCategory = .none, + id: String = UUID().uuidString, + title: String, + body: String + ) { + let center = UNUserNotificationCenter.current() let content = UNMutableNotificationContent() content.title = title content.body = body content.sound = .default + switch category { + case let .manualRegister(runningData): + let formatter = DateFormatter() + + formatter.dateFormat = "yyyy-MM-dd HH:mm:ss" + content.userInfo = [ + "id": runningData.id.uuidString, + "date": formatter.string(from: runningData.date ?? .now), + "distance": "\(runningData.distance)", + "category": category.rawValue + ] + case .autoRegister, .none: + break + } + let request = UNNotificationRequest(identifier: id, content: content, trigger: nil) center.add(request) } + + public enum NotificationCategory: Hashable { + case autoRegister(Workout) + case manualRegister(Workout) + case none + + public var rawValue: String { + switch self { + case .autoRegister: + "AutoRegister" + case .manualRegister: + "ManualRegister" + case .none: + "None" + } + } + } } diff --git a/Run Mile/Helper/UserDefaults+.swift b/Run Mile/Helper/UserDefaults+.swift index 8f8d1e8..bc75e3f 100644 --- a/Run Mile/Helper/UserDefaults+.swift +++ b/Run Mile/Helper/UserDefaults+.swift @@ -6,33 +6,30 @@ // import Foundation +import HealthKit extension UserDefaults { - public var isFirstLaunch: Bool { - get { - self.bool(forKey: "isFirstLaunch") - } - set { - self.set(newValue, forKey: "isFirstLaunch") - } - } - - public var recentWorkoutID: String { + public var selectedShoesID: String { get { - self.string(forKey: "recentWorkoutID") ?? "" + self.string(forKey: "selectedShoesID") ?? "" } set { - self.set(newValue, forKey: "recentWorkoutID") + self.set(newValue, forKey: "selectedShoesID") } } - public var selectedShoesID: String { + public var lastAnchor: HKQueryAnchor? { get { - self.string(forKey: "selectedShoesID") ?? "" + self.data(forKey: "anchor").flatMap { + try? NSKeyedUnarchiver.unarchivedObject(ofClass: HKQueryAnchor.self, from: $0) + } } set { - self.set(newValue, forKey: "selectedShoesID") + if let anchor = newValue, + let data = try? NSKeyedArchiver.archivedData(withRootObject: anchor, requiringSecureCoding: true) { + self.set(data, forKey: "anchor") + } } } } diff --git a/Run Mile/Presentation/0.Main/NavigationCoordinator.swift b/Run Mile/Presentation/0.Main/NavigationCoordinator.swift index 5bbac0c..7190fe7 100644 --- a/Run Mile/Presentation/0.Main/NavigationCoordinator.swift +++ b/Run Mile/Presentation/0.Main/NavigationCoordinator.swift @@ -156,7 +156,7 @@ extension NavigationCoordinator { var id: Self { self } case addShoes(() -> Void) - case chooseShoes([RunningData], () -> Void) + case chooseShoes([Workout], () -> Void) case automaticRegister } } diff --git a/Run Mile/Presentation/1.Shoes/1-1.AddShoes/AddShoesView.swift b/Run Mile/Presentation/1.Shoes/1-1.AddShoes/AddShoesView.swift index 20a8c99..de41290 100644 --- a/Run Mile/Presentation/1.Shoes/1-1.AddShoes/AddShoesView.swift +++ b/Run Mile/Presentation/1.Shoes/1-1.AddShoes/AddShoesView.swift @@ -69,6 +69,32 @@ struct AddShoesView: View { .onDisappear { dismissAction() } + .toolbar { + ToolbarItemGroup(placement: .keyboard) { + HStack { + Button { + viewModel.keyboardToolbarUpButtonTapped(&textFieldFocus) + } label: { + Image(systemName: "chevron.up") + } + .disabled(textFieldFocus?.isUpButtonDisabled ?? false) + + Button { + viewModel.keyboardToolbarDownButtonTapped(&textFieldFocus) + } label: { + Image(systemName: "chevron.down") + } + .disabled(textFieldFocus?.isDownButtonDisabled ?? false) + + + Spacer() + + Button("완료") { + viewModel.keyboardToolbarCompleteButtonTapped(&textFieldFocus) + } + } + } + } } } diff --git a/Run Mile/Presentation/1.Shoes/1-1.AddShoes/AddShoesViewModel.swift b/Run Mile/Presentation/1.Shoes/1-1.AddShoes/AddShoesViewModel.swift index 67c49af..6ba1f39 100644 --- a/Run Mile/Presentation/1.Shoes/1-1.AddShoes/AddShoesViewModel.swift +++ b/Run Mile/Presentation/1.Shoes/1-1.AddShoes/AddShoesViewModel.swift @@ -50,7 +50,9 @@ final class AddShoesViewModel { init(useCase: AddShoesUseCase) { self.useCase = useCase } - +} + +extension AddShoesViewModel { enum TextFieldCategory: Hashable { case name case nickname @@ -70,6 +72,50 @@ final class AddShoesViewModel { return "주행 마일리지(Optional)" } } + + public var isUpButtonDisabled: Bool { + switch self { + case .name: + true + default: + false + } + } + + public var isDownButtonDisabled: Bool { + switch self { + case .runMileage: + true + default: + false + } + } + + public var next: Self { + switch self { + case .name: + .nickname + case .nickname: + .goalMileage + case .goalMileage: + .runMileage + case .runMileage: + .runMileage + } + } + + public var previous: Self { + switch self { + case .name: + .name + case .nickname: + .name + case .goalMileage: + .nickname + case .runMileage: + .goalMileage + } + } } } @@ -132,6 +178,25 @@ extension AddShoesViewModel { } } + @MainActor + public func keyboardToolbarUpButtonTapped(_ textField: inout TextFieldCategory?) { + if let _ = textField { + textField = textField?.previous + } + } + + @MainActor + public func keyboardToolbarDownButtonTapped(_ textField: inout TextFieldCategory?) { + if let _ = textField { + textField = textField?.next + } + } + + @MainActor + public func keyboardToolbarCompleteButtonTapped(_ textField: inout TextFieldCategory?) { + textField = nil + } + public func saveButtonTapped() { let shoes = Shoes( id: .init(), diff --git a/Run Mile/Presentation/1.Shoes/1-2.ShoesDetail/ShoesDetailViewModel.swift b/Run Mile/Presentation/1.Shoes/1-2.ShoesDetail/ShoesDetailViewModel.swift index 1981a12..ff579e4 100644 --- a/Run Mile/Presentation/1.Shoes/1-2.ShoesDetail/ShoesDetailViewModel.swift +++ b/Run Mile/Presentation/1.Shoes/1-2.ShoesDetail/ShoesDetailViewModel.swift @@ -99,7 +99,7 @@ extension ShoesDetailViewModel { } @MainActor - public func workoutCellTapped(_ workout: RunningData) { + public func workoutCellTapped(_ workout: Workout) { switch self.viewStatus { case .workouts: if selectedWorkouts.contains(workout.id) { diff --git a/Run Mile/Presentation/1.Shoes/ShoesListView.swift b/Run Mile/Presentation/1.Shoes/ShoesListView.swift index 79f6135..f9e49f7 100644 --- a/Run Mile/Presentation/1.Shoes/ShoesListView.swift +++ b/Run Mile/Presentation/1.Shoes/ShoesListView.swift @@ -67,6 +67,9 @@ private struct CurrentShoesListView: View { } .padding(.horizontal, 20) } + .refreshable { + viewModel.onAppear() + } } } diff --git a/Run Mile/Presentation/2.Workout/2-1.ChooseShoes/ChooseShoesView.swift b/Run Mile/Presentation/2.Workout/2-1.ChooseShoes/ChooseShoesView.swift index bae151c..f73e717 100644 --- a/Run Mile/Presentation/2.Workout/2-1.ChooseShoes/ChooseShoesView.swift +++ b/Run Mile/Presentation/2.Workout/2-1.ChooseShoes/ChooseShoesView.swift @@ -14,7 +14,7 @@ struct ChooseShoesView: View { let dismiss: () -> Void init( - workouts: [RunningData], + workouts: [Workout], dismiss: @escaping () -> Void ) { self.viewModel = .init( diff --git a/Run Mile/Presentation/2.Workout/2-1.ChooseShoes/ChooseShoesViewModel.swift b/Run Mile/Presentation/2.Workout/2-1.ChooseShoes/ChooseShoesViewModel.swift index a261099..d7c6ad1 100644 --- a/Run Mile/Presentation/2.Workout/2-1.ChooseShoes/ChooseShoesViewModel.swift +++ b/Run Mile/Presentation/2.Workout/2-1.ChooseShoes/ChooseShoesViewModel.swift @@ -10,13 +10,13 @@ import Foundation @Observable final class ChooseShoesViewModel { private let useCase: ChooseShoesUseCase - private let workouts: [RunningData] + private let workouts: [Workout] public var shoes: [Shoes] = [] init( useCase: ChooseShoesUseCase, - workouts: [RunningData] + workouts: [Workout] ) { self.useCase = useCase self.workouts = workouts diff --git a/Run Mile/Presentation/2.Workout/WorkoutListView.swift b/Run Mile/Presentation/2.Workout/WorkoutListView.swift index 90bcca8..3ee11c3 100644 --- a/Run Mile/Presentation/2.Workout/WorkoutListView.swift +++ b/Run Mile/Presentation/2.Workout/WorkoutListView.swift @@ -21,16 +21,20 @@ struct WorkoutListView: View { WorkoutNavigationView(viewModel: viewModel) switch viewModel.viewStatus { - case .none, .selection: + case .none, .selection, .loading: WorkoutScrollView(viewModel: $viewModel) - case .loading: - WorkoutLoadingView() case .empty: WorkoutEmptyView( viewModel: viewModel ) } } + .overlay { + if viewModel.viewStatus == .loading { + ProgressView() + .progressViewStyle(.circular) + } + } .task { await viewModel.onAppear() } @@ -110,17 +114,10 @@ private struct WorkoutScrollView: View { } .padding(.horizontal, 20) } - } -} - - -private struct WorkoutLoadingView: View { - var body: some View { - Group { - Spacer() - ProgressView() - .progressViewStyle(.circular) - Spacer() + .refreshable { + Task { + await viewModel.onAppear() + } } } } diff --git a/Run Mile/Presentation/2.Workout/WorkoutListViewModel.swift b/Run Mile/Presentation/2.Workout/WorkoutListViewModel.swift index 923ac95..b7aab59 100644 --- a/Run Mile/Presentation/2.Workout/WorkoutListViewModel.swift +++ b/Run Mile/Presentation/2.Workout/WorkoutListViewModel.swift @@ -13,7 +13,7 @@ final class WorkoutListViewModel { private let useCase: HealthDataUseCase public var dateHeaders: [String] = [] - public var workouts: [[RunningData]] = [] + public var workouts: [[Workout]] = [] public var viewStatus: ViewStatus = .none public var selectedWorkout: Set = [] @@ -49,7 +49,7 @@ extension WorkoutListViewModel { self.classifyWorkoutsByDate(workouts: workouts) - await AppDelegate.setHealthBackgroundTask() + await AppDelegate.setBackgroundDelivery() } catch { if let error = error as? HealthError, error == .unknownError || error == .notAvailableDevice { @@ -76,7 +76,7 @@ extension WorkoutListViewModel { } @MainActor - public func workoutCellTapped(workout: RunningData) { + public func workoutCellTapped(workout: Workout) { if case .selection = self.viewStatus { let id = workout.id @@ -149,18 +149,18 @@ extension WorkoutListViewModel { self.viewStatus = workouts.isEmpty ? .empty : .none } - public func isSelectedWorkout(_ workout: RunningData) -> Bool { + public func isSelectedWorkout(_ workout: Workout) -> Bool { self.selectedWorkout.contains(workout.id) } } extension WorkoutListViewModel { - private func classifyWorkoutsByDate(workouts: [RunningData]) { + private func classifyWorkoutsByDate(workouts: [Workout]) { self.workouts.removeAll() self.dateHeaders.removeAll() - var resultWorkouts = [RunningData]() + var resultWorkouts = [Workout]() for workout in workouts { if dateHeaders.isEmpty { diff --git a/Run Mile/Presentation/Components/WorkoutCell.swift b/Run Mile/Presentation/Components/WorkoutCell.swift index 7c499cd..d8c897e 100644 --- a/Run Mile/Presentation/Components/WorkoutCell.swift +++ b/Run Mile/Presentation/Components/WorkoutCell.swift @@ -9,7 +9,7 @@ import SwiftUI struct WorkoutCell: View { - let workout: RunningData + let workout: Workout let action: () -> Void diff --git a/fastlane/Appfile b/fastlane/Appfile new file mode 100644 index 0000000..4ace4da --- /dev/null +++ b/fastlane/Appfile @@ -0,0 +1,8 @@ +app_identifier("com.mooni.Run-Mile") # The bundle identifier of your app +apple_id("dlsqja567@naver.com") # Your Apple Developer Portal username + +itc_team_id("126391461") # App Store Connect Team ID +team_id("VF8R3A969C") # Developer Portal Team ID + +# For more information about the Appfile, see: +# https://docs.fastlane.tools/advanced/#appfile diff --git a/fastlane/Fastfile b/fastlane/Fastfile new file mode 100644 index 0000000..ccdaa31 --- /dev/null +++ b/fastlane/Fastfile @@ -0,0 +1,245 @@ +# This file contains the fastlane.tools configuration +# You can find the documentation at https://docs.fastlane.tools +# +# For a list of all available actions, check out +# +# https://docs.fastlane.tools/actions +# +# For a list of all available plugins, check out +# +# https://docs.fastlane.tools/plugins/available-plugins +# + +# Uncomment the line if you want fastlane to automatically update itself +# update_fastlane + +default_platform(:ios) + +platform :ios do + + desc "Testflight 자동 배포 lane" + lane :upload_testflight do + + app_store_connect_api_key( + key_id: "#{ENV["APP_STORE_CONNECT_KEY_ID"]}", + issuer_id: "#{ENV["APP_STORE_CONNECT_ISSUER_ID"]}", + key_content: "#{ENV["APP_STORE_CONNECT_KEY"]}", + is_key_content_base64: true + ) + + today = Time.now.strftime("%Y%m%d") + + current_build_number = "#{latest_testflight_build_number}" + + if current_build_number.length == 9 + current_date = current_build_number[0,8] # 앞 8자리가 날짜 + current_count = current_build_number[8].to_i # 마지막 1자리가 숫자 + else + # 빌드 넘버가 없거나 형식이 다르면 초기화 + current_date = "" + current_count = 0 + end + + if current_date == today + next_count = current_count + 1 + else + next_count = 1 + end + + new_build_number = "#{today}#{next_count}" + + increment_build_number( + xcodeproj: "Run Mile.xcodeproj", + build_number: new_build_number + ) + + discord_notifier( + webhook_url: "#{ENV["DISCORD_URL"]}", + title: "[Testflight] Run Mile 배포 준비", + description: "[Testflight] Build Number: #{new_build_number} 배포를 위해 준비중입니다." + ) + + setup_ci + + match(type: "appstore") + + discord_notifier( + webhook_url: "#{ENV["DISCORD_URL"]}", + title: "[Testflight] Run Mile 인증서 완료", + description: "[Testflight] Build Number: #{new_build_number} 인증서 준비가 완료되었습니다." + ) + + build_app( + scheme: "Run Mile", + xcodebuild_formatter: "xcpretty" + ) + + discord_notifier( + webhook_url: "#{ENV["DISCORD_URL"]}", + title: "[Testflight] Run Mile 아카이브 완료", + description: "[Testflight] Build Number: #{new_build_number} 아카이브가 완료되었습니다." + ) + + upload_to_testflight + + discord_notifier( + webhook_url: "#{ENV["DISCORD_URL"]}", + title: "[Testflight] Run Mile Testflight 배포 완료", + description: "[Testflight] Build Number: #{new_build_number} 테스트 플라이트 배포가 완료되었습니다." + ) + end + + + desc "Appstore 자동 배포 lane" + lane :upload_appstore do + app_store_connect_api_key( + key_id: "#{ENV["APP_STORE_CONNECT_KEY_ID"]}", + issuer_id: "#{ENV["APP_STORE_CONNECT_ISSUER_ID"]}", + key_content: "#{ENV["APP_STORE_CONNECT_KEY"]}", + is_key_content_base64: true + ) + + today = Time.now.strftime("%Y%m%d") + + current_build_number = "#{latest_testflight_build_number}" + + if current_build_number.length == 9 + current_date = current_build_number[0,8] # 앞 8자리가 날짜 + current_count = current_build_number[8].to_i # 마지막 1자리가 숫자 + else + # 빌드 넘버가 없거나 형식이 다르면 초기화 + current_date = "" + current_count = 0 + end + + if current_date == today + next_count = current_count + 1 + else + next_count = 1 + end + + new_build_number = "#{today}#{next_count}" + + increment_build_number( + xcodeproj: "Run Mile.xcodeproj", + build_number: new_build_number + ) + + current_marketing_version = get_version_number + current_release_note_path = "../ReleaseNote/v#{current_marketing_version}.md" + current_release_note = "# Whats new" + + if File.exists?(current_release_note_path) + current_release_note = File.read(current_release_note_path) + end + + puts "Version: #{current_marketing_version}" + puts "Release Notes\n#{current_release_note}" + + discord_notifier( + webhook_url: "#{ENV["DISCORD_URL"]}", + title: "[Appstore] Run Mile 배포 준비", + description: "[Appstore] Build Number: #{new_build_number} 배포를 위해 준비중입니다." + ) + + setup_ci + + match(type: "appstore") + + discord_notifier( + webhook_url: "#{ENV["DISCORD_URL"]}", + title: "[Appstore] Run Mile 인증서 완료", + description: "[Appstore] Build Number: #{new_build_number} 인증서 준비가 완료되었습니다." + ) + + build_app( + scheme: "Run Mile", + xcodebuild_formatter: "xcpretty" + ) + + discord_notifier( + webhook_url: "#{ENV["DISCORD_URL"]}", + title: "[Appstore] Run Mile 아카이브 완료", + description: "[Appstore] Build Number: #{new_build_number} 아카이브가 완료되었습니다." + ) + + upload_to_app_store( + languages: ["ko"], + force: true, + submit_for_review: true, + skip_screenshots: true, + skip_metadata: false, + automatic_release: true, + precheck_include_in_app_purchases: false, + copyright: "© #{Time.now.year} Mooninbeom", + release_notes: { + "ko" => current_release_note + }, + submission_information: { + add_id_info_uses_idfa: false, + export_compliance_encryption_updated: false, + export_compliance_uses_encryption: false, + content_rights_contains_third_party_content: false + } + ) + + discord_notifier( + webhook_url: "#{ENV["DISCORD_URL"]}", + title: "[Appstore] Run Mile Appstore 배포 완료", + description: "[Appstore] Build Number: #{new_build_number} 앱스토어 배포가 완료되었습니다." + ) + end + + + desc "로컬 테스트용 lane" + lane :local do + app_store_connect_api_key( + key_id: "", + issuer_id: "", + key_content: "" + ) + + today = Time.now.strftime("%Y%m%d") + + current_build_number = "#{latest_testflight_build_number}" + + if current_build_number.length == 9 + current_date = current_build_number[0,8] # 앞 8자리가 날짜 + current_count = current_build_number[8].to_i # 마지막 1자리가 숫자 + else + # 빌드 넘버가 없거나 형식이 다르면 초기화 + current_date = "" + current_count = 0 + end + + if current_date == today + next_count = current_count + 1 + else + next_count = 1 + end + + new_build_number = "#{today}#{next_count}" + + increment_build_number( + xcodeproj: "Run Mile.xcodeproj", + build_number: new_build_number + ) + + match(type: "appstore") + + build_app( + scheme: "Run Mile", + xcodebuild_formatter: "xcpretty" + ) + + upload_to_testflight + end + + error do |lane, exception, options| + discord_notifier( + webhook_url: "#{ENV["DISCORD_URL"]}", + title: "Testflight 배포 중 오류가 발생했습니다.", + description: "에러 메시지\n#{exception}" + ) + end +end diff --git a/fastlane/Matchfile b/fastlane/Matchfile new file mode 100644 index 0000000..3da1c80 --- /dev/null +++ b/fastlane/Matchfile @@ -0,0 +1,13 @@ +git_url("git@github.com:mooninbeom/fastlane-match.git") + +storage_mode("git") + +type("appstore") # The default type, can be: appstore, adhoc, enterprise or development + +app_identifier("com.mooni.Run-Mile") +username("dlsqja567@naver.com") # Your Apple Developer Portal username + +# For all available options run `fastlane match --help` +# Remove the # in the beginning of the line to enable the other options + +# The docs are available on https://docs.fastlane.tools/actions/match diff --git a/fastlane/Pluginfile b/fastlane/Pluginfile new file mode 100644 index 0000000..7ef2b02 --- /dev/null +++ b/fastlane/Pluginfile @@ -0,0 +1,5 @@ +# Autogenerated by fastlane +# +# Ensure this file is checked in to source control! + +gem 'fastlane-plugin-discord_notifier'