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. 자동 등록을 통해 더욱 편리하게!
+
+
+
+
+
+
+## 개발 일지
+|이름|링크|
+|:--|:--|
+|**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 둘 다 작성할 필요가 있습니다.
+
+
+
+
+
+
+
+
+**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'