Skip to content

sunkeydokey/RunningTime

Repository files navigation

RunningTime

RunningTime은 집중 타이머를 실제 이동 기록처럼 시각화하는 iOS 앱입니다. 사용자는 직접 만든 경로 또는 MapKit 도보 경로를 선택하고, 설정한 시간 동안 타이머를 실행합니다. 앱은 경로 위의 현재 위치, 속도, 남은 시간, Look Around 또는 3D 지도 화면을 보여 주며, 완료된 세션은 Core Data에 저장되어 홈과 저널에서 다시 확인할 수 있습니다.

핵심 요약

  • UIKit 기반 iOS 앱이며, 위젯과 Live Activity는 WidgetKit/SwiftUI/ActivityKit을 사용합니다.
  • 화면 상태 관리는 ReactorKit + RxSwift 패턴을 사용합니다.
  • 화면 이동은 AppCoordinator, WalkCoordinator, JournalCoordinator 중심으로 분리되어 있습니다.
  • 경로 생성은 MapKit MKDirections 도보 경로와 사용자가 직접 그리는 드로잉 경로를 모두 지원합니다.
  • 경로 형상은 Core Data에 직접 넣지 않고 Documents/trails/{UUID}.geojson 파일로 저장합니다.
  • 세션, 코스 메타데이터, 즐겨찾기 상태는 Core Data에 저장합니다.
  • 위젯과 Live Activity 복원 데이터는 App Group UserDefaults와 App Group 파일 컨테이너를 사용합니다.
  • 타이머 진행은 실제 GPS 추적이 아니라, 경과 시간을 경로 waypoint 비율에 매핑하는 가상 진행 방식입니다.

기술 스택

영역 사용 기술
언어 Swift
메인 UI UIKit
위젯/Live Activity UI SwiftUI, WidgetKit, ActivityKit
상태 관리 ReactorKit 3.2.0, RxSwift 6.10.2, RxCocoa
레이아웃 SnapKit 5.7.1
지도/경로 MapKit, CoreLocation, MKDirections, MKLookAroundSceneRequest
저장소 Core Data, FileManager, App Group UserDefaults
스크린타임 FamilyControls, ManagedSettings
프로젝트 Xcode project, Swift Package Manager

xcodebuild -list -project RunningTime.xcodeproj 기준으로 RunningTime, RunningTimeTests, RunningTimeUITests, FavoriteCourseWidgetExtension 타깃과 RunningTime, FavoriteCourseWidgetExtension 스킴이 있습니다.

앱 구조

RunningTime
├── App
│   ├── AppCoordinator
│   ├── AppDIContainer
│   ├── CustomTabBarController
│   └── Coordinators
├── DesignSystem
│   ├── Colors / Typography / Spacing / Animation
│   └── Components
├── Features
│   ├── Home
│   ├── RouteBuilder
│   ├── PlaceSearch
│   ├── CourseDetail
│   ├── TimerSetting
│   ├── WalkTimer
│   ├── SessionReport
│   └── Journal
├── Shared
│   ├── Services
│   ├── db
│   ├── AppGroup
│   ├── Models
│   └── Utils
FavoriteCourseWidget
└── Widget / Control Widget / Live Activity

아키텍처

Coordinator

앱 루트는 AppCoordinator가 소유합니다.

Walk 탭은 WalkCoordinator가 Home, RouteBuilder, CourseDetail, WalkTimer, SessionReport, PlaceSearch 이동을 관리합니다. Journal 탭은 JournalCoordinator가 Journal 목록과 세션 상세 화면 이동을 관리합니다.

지원하는 딥링크는 다음과 같습니다.

  • runningtime://home
  • runningtime://course?id={UUID}
  • runningtime://resume-timer

DI Container

AppDIContainer는 화면과 Reactor 조합을 생성하고 앱 수명 동안 공유되는 서비스를 보관합니다.

  • PersistenceController.shared
  • LookAroundService
  • LookAroundPlaceService
  • SessionSaveService

WalkTimerReactorLookAroundServiceProtocol, LookAroundPlaceServiceProtocol, SessionSaveServiceProtocol, ScreenTimeServiceProtocol을 생성자 주입으로 받습니다.

ReactorKit / RxSwift

각 주요 화면은 Reactor의 Action -> Mutation -> State 흐름을 따릅니다. 화면 이동처럼 한 번만 소비되어야 하는 이벤트는 ReactorKit의 @Pulse를 사용합니다.

예시:

  • Home: 검색어, 저장된 코스 목록, RouteBuilder/CourseDetail 이동 신호
  • RouteBuilder: 출발지/도착지, 로딩, MapKit 경로, 드로잉 경로, 저장 상태, WalkTimer 이동 신호
  • WalkTimer: 타이머 경과 시간, 일시정지, 경로 진행률, Look Around 상태, Live Activity 상태, 저장 상태
  • Journal: 선택 날짜, 주/월 범위, 세션 목록, 통계

주요 기능

1. Home

Home은 Core Data에 저장된 Route를 불러와 즐겨찾기 코스와 일반 코스로 분리해 보여 줍니다.

구현된 동작:

  • isFavorite == true 코스와 isFavorite == false 코스를 별도 섹션으로 표시
  • 즐겨찾기는 이름순, 일반 코스는 생성일 역순 정렬
  • 검색어로 제목, 도시, 출발지, 도착지 필터링
  • 코스 선택 시 CourseDetail로 이동
  • 경로 만들기 버튼을 통해 RouteBuilder로 이동
  • Home 진입 후 RouteBuilder를 미리 생성해 MapKit 초기화 비용을 앞당기는 프리로드 로직

2. Place Search

Place Search는 MapKit 검색 API를 사용해 장소를 선택하는 화면입니다.

구현된 동작:

  • MKLocalSearchCompleter 기반 자동완성
  • 자동완성 선택 시 MKLocalSearch로 좌표와 주소 정보 확정
  • PlaceItem을 콜백으로 RouteBuilder에 전달
  • 뒤로가기 액션

3. Route Builder

RouteBuilder는 타이머에 사용할 경로를 만드는 화면입니다.

구현된 동작:

  • 출발지와 도착지 선택
  • MKDirections.Request.transportType = .walking 기반 도보 경로 계산
  • 출발/도착 annotation 드래그 후 reverse geocoding 및 경로 재계산
  • 사용자가 지도 위에 직접 경로를 그리는 Drawing Mode
  • 직접 그린 경로의 총 거리 계산
  • 그린 경로를 확정하면 도착 좌표를 reverse geocoding
  • 경로 결과에서 타이머 설정 화면으로 진입
  • tapWalk 시 현재 경로를 Course 객체로 변환해 WalkTimer로 전달

4. Timer Setting

TimerSetting 화면은 선택한 경로를 어떤 시간으로 진행할지 정하는 단계입니다.

구현된 동작:

  • 코스 거리와 예상 시간 표시
  • 사용자가 커스텀 타이머 시간을 선택
  • 선택한 시간을 customTimerMinutes로 WalkTimer에 전달

5. Course Detail

CourseDetail은 저장된 코스의 상세 화면입니다.

구현된 동작:

  • 코스 제목, 출발지, 도착지, 거리, 예상 시간 표시
  • 저장된 썸네일이 있으면 썸네일 사용
  • 썸네일이 없으면 MapKit 경로 polyline으로 지도 표시
  • 즐겨찾기 토글
  • 즐겨찾기 설정 시 위젯 데이터 갱신
  • 코스 삭제
  • 삭제 시 Core Data Route 삭제 및 GeoJSON 파일 삭제
  • 타이머 설정 화면으로 진입

6. Walk Timer

WalkTimer는 이 프로젝트의 핵심 화면입니다. 실제 위치를 추적하는 운동 기록이 아니라, 정해진 타이머 시간 동안 선택한 경로 위를 가상으로 이동하는 경험을 제공합니다.

구현된 동작:

  • DispatchSourceTimer를 0.1초 주기로 실행
  • 시작 시각 Date를 기준으로 경과 시간을 계산
  • 경과 시간 / 전체 시간 비율을 경로 waypoint 진행률로 변환
  • 현재 좌표, waypoint index, 진행률, bearing 계산
  • 1초 단위 Live Activity 갱신
  • 10초 단위 Look Around 또는 장소 정보 갱신
  • Look Around 지원 여부 확인
  • Look Around 미지원 또는 실패 시 3D MapKit 카메라 모드 사용
  • 일시정지, 재개, 완료, 취소
  • 타이머 시작 시 활성 세션 스냅샷 저장
  • 앱 강제 종료 후 Live Activity가 살아 있으면 runningtime://resume-timer로 세션 복원
  • 선택적으로 Screen Time 차단 기능 실행
  • 완료 시 SessionSaveService를 통해 세션 저장

타이머 복원 방식:

  • 진행 중 세션은 App Group UserDefaults의 activeSession에 저장됩니다.
  • 저장 내용에는 코스 ID, 이름, 출발/도착지, 거리, 총 시간, waypoint lat/lng 배열, 중심 좌표, 시작 시각, 일시정지 여부, 진입 출처, 썸네일 경로, 즐겨찾기 여부, Look Around 지원 여부, Screen Time 활성 여부가 포함됩니다.
  • courseDetail에서 시작한 세션은 Core Data Route를 다시 조회해 복원합니다.
  • routeBuilder에서 시작한 세션은 아직 DB에 없을 수 있어 snapshot waypoint로 Course를 재구성합니다.
  • snapshot은 waypoint를 최대 500개로 줄여 저장합니다.

7. Session Report

SessionReport는 타이머 완료 후 보여 주는 요약 화면입니다.

구현된 동작:

  • 코스명, 출발지, 도착지, 날짜 표시
  • 타이머 시간, 거리, 페이스 표시
  • 경로 시각화
  • 닫기 액션을 통해 홈으로 이동

SessionSummaryViewController를 기반 클래스로 두고 JournalDetail과 공통 UI를 공유합니다.

8. Journal

Journal은 완료된 세션을 캘린더와 목록으로 확인하는 화면입니다.

구현된 동작:

  • 주간/월간 캘린더 scope 전환
  • 이전/다음 달 이동
  • 세션이 있는 날짜 dot 표시
  • 선택한 날짜의 세션만 필터링
  • 선택 범위의 총 집중 시간, 세션 수, 총 거리 계산
  • 세션 행 선택 시 상세 화면 이동
  • JournalDetail에서 과거 세션 요약과 썸네일 표시

통계 계산 방식:

  • 집중 시간: 세션 elapsedSeconds 합계
  • 세션 수: 필터링된 SessionEntity 개수
  • 거리: 연결된 route.distanceMeters 합계

9. Widget

FavoriteCourseWidgetExtension은 세 가지 WidgetBundle 항목을 포함합니다.

  • FavoriteCourseWidget
  • FavoriteCourseWidgetControl
  • FavoriteCourseWidgetLiveActivity

FavoriteCourseWidget:

  • App Group UserDefaults에서 즐겨찾기 코스 또는 최근 저장 코스를 읽습니다.
  • 우선순위는 favorite course, 그다음 saved course입니다.
  • timeline refresh policy는 1시간 후 갱신입니다.
  • 소형/중형 위젯을 지원합니다.
  • 코스가 있으면 썸네일 배경, 그라디언트, 제목, 거리, 출발/도착지를 표시합니다.
  • 코스가 없으면 EmptyWidgetContentView를 표시합니다.
  • 위젯 탭 시 코스가 있으면 runningtime://course?id={UUID}, 없으면 runningtime://home으로 이동합니다.
  • Scene 연결 시 App Group UserDefaults가 비어 있으면 Core Data에서 즐겨찾기/최근 저장 코스를 동기화하고 Widget timeline을 reload합니다.

Live Activity:

  • Lock Screen/Banner와 Dynamic Island compact/minimal/expanded UI를 구현합니다.
  • 시작 위치, 도착 위치, 도시, 남은 시간, 일시정지 시간, 완료 상태를 표시합니다.
  • 탭 URL은 runningtime://resume-timer입니다.

Control Widget:

  • FavoriteCourseWidgetControl 파일은 존재하지만 현재 currentValue()가 항상 true를 반환하고 StartTimerIntent.perform()도 실제 타이머 시작/정지 로직 없이 .result()만 반환합니다.
  • 따라서 현재 README에서는 완성 기능이 아니라 placeholder 성격의 코드로 분류합니다.

데이터 모델

Core Data 모델은 RouteEntitySessionEntity 두 개입니다.

RouteEntity

필드 설명
id Route UUID
name 코스 이름
originName 출발지 이름
destinationName 도착지 이름
distanceMeters 경로 거리
geoJSONPath Documents/trails/... 형식의 GeoJSON 상대 경로
thumbnailPath Documents 기준 썸네일 상대 경로
isFavorite 즐겨찾기 여부
completedSessionCount 완료 세션 수
createdAt 생성일
lastSessionDate 마지막 세션 날짜
sessions 연결된 SessionEntity 목록

SessionEntity

필드 설명
id Session UUID
targetDurationSeconds 목표 시간
elapsedSeconds 실제 경과 시간
isCompleted 완료 여부
createdAt 생성일
route 연결된 RouteEntity

Route 삭제 시 연결된 Session은 cascade 삭제됩니다. Session에서 Route로의 관계 삭제 규칙은 nullify입니다.

파일 저장 방식

GeoJSON

경로 좌표는 Core Data가 아니라 GeoJSON 파일로 저장합니다.

저장 위치:

Documents/trails/{UUID}.geojson

GeoJSON 좌표 순서는 표준에 맞춰 [longitude, latitude]입니다. 읽을 때는 다시 CLLocationCoordinate2D(latitude:longitude:)로 변환합니다.

RouteGeoJSONEncoder.loadCoordinates(from:)Documents/ prefix가 있는 경로만 처리합니다. 다른 상대 경로 형식은 빈 waypoint 배열로 처리됩니다.

Thumbnail

세션 완료 시 썸네일이 있으면 다음 위치에 저장합니다.

Documents/thumbnails/{routeID}.jpg

위젯 표시용 썸네일은 App Group 컨테이너의 widget-thumbnails 디렉터리에 600px 이하 JPEG로 복사됩니다.

App Group 데이터 계약

App Group ID:

group.com.sunkeydokey.RunningTime

UserDefaults keys:

Key 내용
widget.favoriteCourse 위젯에 표시할 즐겨찾기 코스
widget.savedCourse 최근 저장 코스
activeSession 진행 중인 타이머 복원 snapshot

위젯 코스 엔트리는 다음 정보를 포함합니다.

  • id
  • title
  • city
  • durationMinutes
  • distanceMeters
  • origin
  • destination
  • sharedThumbnailPath

decoder는 과거 데이터와의 호환을 위해 distanceMeters, origin, destination, sharedThumbnailPath 누락을 허용합니다.

Screen Time 기능

DefaultScreenTimeService는 FamilyControls와 ManagedSettings를 사용합니다.

구현된 동작:

  • AuthorizationCenter.shared.requestAuthorization(for: .individual)로 권한 요청
  • 승인 시 ManagedSettingsStore로 전체 앱 카테고리, 웹 도메인 카테고리, 앱 설치를 차단
  • 타이머 종료 또는 해제 시 clearAllSettings()
  • 권한 실패 시 타이머 자체는 계속 진행

현재 구현은 사용자가 특정 앱을 고르는 방식이 아니라 device-wide shield에 가깝습니다.

테스트

현재 RunningTimeTests에는 GeoManagerTests가 있습니다.

검증 범위:

  • GeoJSON top-level 구조
  • GeoJSON 좌표 순서가 [longitude, latitude]인지 확인
  • 20m 기본 보간 간격
  • 짧은 segment는 보간하지 않는지 확인
  • 보간 결과가 시작/끝 좌표를 보존하는지 확인
  • 보간 좌표가 segment bounds 안에 있는지 확인
  • 빈 배열, 단일 좌표 edge case
  • bearing north/south/east/west 및 0..<360 정규화
  • GeoJSON 파일 저장 및 JSON 파싱
  • CLGeocoder reverse geocoding 통합 테스트
  • MKDirections walking route 통합 테스트
  • 같은 좌표 경로 요청 edge case

테스트 실행 예시:

xcodebuild test -project RunningTime.xcodeproj -scheme RunningTime -destination 'platform=iOS Simulator,name=iPhone 16'

시뮬레이터 이름은 로컬 Xcode 환경에 맞게 바꿔야 합니다.

빌드 및 실행

요구 조건:

  • Xcode
  • iOS 18 이상 앱/위젯 타깃
  • App Group capability
  • Family Controls entitlement
  • WidgetKit/ActivityKit 지원 환경

프로젝트 정보 확인:

xcodebuild -list -project RunningTime.xcodeproj

빌드 예시:

xcodebuild build -project RunningTime.xcodeproj -scheme RunningTime -destination 'generic/platform=iOS Simulator'

앱 타깃과 위젯 타깃의 deployment target은 프로젝트 파일 기준 iOS 18.0입니다. 테스트 타깃에는 iOS 26.2 deployment target 설정이 들어 있습니다.

위젯 전용 스킴 FavoriteCourseWidgetExtension은 SpringBoard를 통해 위젯 실행 환경을 구성하며 _XCWidgetFamily=systemMedium, _XCWidgetKind=FavoriteCourseWidget 값을 사용합니다.

About

No description or website provided.

Topics

Resources

Stars

Watchers

Forks

Packages

 
 
 

Contributors

Languages