Repo created

This commit is contained in:
Fr4nz D13trich 2025-11-22 13:58:55 +01:00
parent 4af19165ec
commit 68073add76
12458 changed files with 12350765 additions and 2 deletions

View file

@ -0,0 +1,76 @@
import ActivityKit
#if canImport(ActivityKit)
private let kCurrentTrackRecordingLiveActivityIDKey = "kCurrentTrackRecordingLiveActivityIDKey"
protocol TrackRecordingActivityManager {
func start(with info: TrackInfo) throws
func update(_ info: TrackInfo)
func stop()
}
@available(iOS 16.2, *)
final class TrackRecordingLiveActivityManager {
static let shared = TrackRecordingLiveActivityManager()
private var activity: Activity<TrackRecordingLiveActivityAttributes>?
private init() {}
}
// MARK: - TrackRecordingActivityManager
@available(iOS 16.2, *)
extension TrackRecordingLiveActivityManager: TrackRecordingActivityManager {
func start(with info: TrackInfo) throws {
stop()
let state = TrackRecordingLiveActivityAttributes.ContentState(trackInfo: info)
let content = ActivityContent<TrackRecordingLiveActivityAttributes.ContentState>(state: state, staleDate: nil)
let attributes = TrackRecordingLiveActivityAttributes()
let activity = try LiveActivityManager.startActivity(attributes, content: content)
self.activity = activity
UserDefaults.standard.set(activity.id, forKey: kCurrentTrackRecordingLiveActivityIDKey)
}
func update(_ info: TrackInfo) {
guard let activity = activity ?? fetchCurrentActivity() else {
LOG(.warning, "No active TrackRecordingLiveActivity found to update.")
return
}
let state = TrackRecordingLiveActivityAttributes.ContentState(trackInfo: info)
let content = ActivityContent<TrackRecordingLiveActivityAttributes.ContentState>(state: state, staleDate: nil)
self.activity = activity
LiveActivityManager.update(activity, content: content)
}
func stop() {
let activities = Activity<TrackRecordingLiveActivityAttributes>.activities
activities.forEach(LiveActivityManager.stop)
activity = nil
UserDefaults.standard.removeObject(forKey: kCurrentTrackRecordingLiveActivityIDKey)
}
private func fetchCurrentActivity() -> Activity<TrackRecordingLiveActivityAttributes>? {
guard let id = UserDefaults.standard.string(forKey: kCurrentTrackRecordingLiveActivityIDKey) else { return nil }
let activities = Activity<TrackRecordingLiveActivityAttributes>.activities
return activities.first(where: { $0.id == id })
}
}
// MARK: - Wrap TrackRecordingInfo to TrackRecordingLiveActivityAttributes.ContentState
private extension TrackRecordingLiveActivityAttributes.ContentState {
init(trackInfo: TrackInfo) {
self.distance = StatisticsViewModel(key: "", value: trackInfo.distance)
self.duration = StatisticsViewModel(key: "", value: trackInfo.duration)
self.maxElevation = StatisticsViewModel(key: L("elevation_profile_max_elevation"), value: trackInfo.maxElevation)
self.minElevation = StatisticsViewModel(key: L("elevation_profile_min_elevation"), value: trackInfo.minElevation)
self.ascent = StatisticsViewModel(key: L("elevation_profile_ascent"), value: trackInfo.ascent)
self.descent = StatisticsViewModel(key: L("elevation_profile_descent"), value: trackInfo.descent)
}
}
#endif

View file

@ -0,0 +1,236 @@
@objc
enum TrackRecordingState: Int, Equatable {
case inactive
case active
}
enum TrackRecordingAction {
case start
case stopAndSave(name: String)
}
enum LocationError: Error {
case locationIsProhibited
}
enum StartTrackRecordingResult {
case success
case failure(Error)
}
enum StopTrackRecordingResult {
case success
case trackIsEmpty
}
@objc
protocol TrackRecordingObservable: AnyObject {
var recordingState: TrackRecordingState { get }
var trackRecordingInfo: TrackInfo { get }
var trackRecordingElevationProfileData: ElevationProfileData { get }
func addObserver(_ observer: AnyObject, recordingIsActiveDidChangeHandler: @escaping TrackRecordingStateHandler)
func removeObserver(_ observer: AnyObject)
func contains(_ observer: AnyObject) -> Bool
}
/// A handler type for extracting elevation profile data on demand.
typealias ElevationProfileDataExtractionHandler = () -> ElevationProfileData
/// A callback type that notifies observers about track recording state changes.
/// - Parameters:
/// - state: The current recording state.
/// - info: The current track recording info.
/// - elevationProfileExtractor: A closure to fetch elevation profile data lazily.
typealias TrackRecordingStateHandler = (TrackRecordingState, TrackInfo, ElevationProfileDataExtractionHandler?) -> Void
@objcMembers
final class TrackRecordingManager: NSObject {
fileprivate struct Observation {
weak var observer: AnyObject?
var recordingStateDidChangeHandler: TrackRecordingStateHandler?
}
static let shared: TrackRecordingManager = {
let trackRecorder = FrameworkHelper.self
let locationManager = LocationManager.self
var activityManager: TrackRecordingActivityManager? = nil
#if canImport(ActivityKit)
if #available(iOS 16.2, *), !ProcessInfo.processInfo.isiOSAppOnMac {
activityManager = TrackRecordingLiveActivityManager.shared
}
#endif
return TrackRecordingManager(trackRecorder: trackRecorder,
locationService: locationManager,
activityManager: activityManager)
}()
private let trackRecorder: TrackRecorder.Type
private var locationService: LocationService.Type
private var activityManager: TrackRecordingActivityManager?
private var observations: [Observation] = []
private(set) var trackRecordingInfo: TrackInfo = .empty()
var trackRecordingElevationProfileData: ElevationProfileData {
FrameworkHelper.trackRecordingElevationInfo()
}
var recordingState: TrackRecordingState {
trackRecorder.isTrackRecordingEnabled() ? .active : .inactive
}
init(trackRecorder: TrackRecorder.Type,
locationService: LocationService.Type,
activityManager: TrackRecordingActivityManager?) {
self.trackRecorder = trackRecorder
self.locationService = locationService
self.activityManager = activityManager
super.init()
self.subscribeOnTheAppLifecycleEvents()
}
// MARK: - Public methods
@objc
func setup() {
do {
try checkIsLocationEnabled()
switch recordingState {
case .inactive:
break
case .active:
subscribeOnTrackRecordingProgressUpdates()
}
} catch {
handleError(error)
}
}
@objc
func isActive() -> Bool {
recordingState == .active
}
func start(completion: ((StartTrackRecordingResult) -> Void)? = nil) {
do {
switch recordingState {
case .inactive:
try checkIsLocationEnabled()
subscribeOnTrackRecordingProgressUpdates()
trackRecorder.startTrackRecording()
notifyObservers()
do {
try activityManager?.start(with: trackRecordingInfo)
} catch {
LOG(.warning, "Failed to start activity manager")
handleError(error)
}
case .active:
break
}
completion?(.success)
} catch {
handleError(error)
completion?(.failure(error))
}
}
func stop(completion: ((StopTrackRecordingResult) -> Void)? = nil) {
unsubscribeFromTrackRecordingProgressUpdates()
trackRecorder.stopTrackRecording()
trackRecordingInfo = .empty()
activityManager?.stop()
notifyObservers()
completion?(.trackIsEmpty)
}
func stopAndSave(withName name: String = "", completion: ((StopTrackRecordingResult) -> Void)? = nil) {
unsubscribeFromTrackRecordingProgressUpdates()
trackRecorder.stopTrackRecording()
trackRecordingInfo = .empty()
activityManager?.stop()
notifyObservers()
guard !trackRecorder.isTrackRecordingEmpty() else {
Toast.show(withText: L("track_recording_toast_nothing_to_save"))
completion?(.trackIsEmpty)
return
}
trackRecorder.saveTrackRecording(withName: name)
completion?(.success)
}
// MARK: - Private methods
private func subscribeOnTheAppLifecycleEvents() {
NotificationCenter.default.addObserver(self,
selector: #selector(notifyObservers),
name: UIApplication.didBecomeActiveNotification,
object: nil)
}
private func checkIsLocationEnabled() throws {
if locationService.isLocationProhibited() {
throw LocationError.locationIsProhibited
}
}
private func subscribeOnTrackRecordingProgressUpdates() {
trackRecorder.setTrackRecordingUpdateHandler { [weak self] info in
guard let self else { return }
self.trackRecordingInfo = info
self.notifyObservers()
self.activityManager?.update(info)
}
}
private func unsubscribeFromTrackRecordingProgressUpdates() {
trackRecorder.setTrackRecordingUpdateHandler(nil)
}
private func handleError(_ error: Error) {
switch error {
case LocationError.locationIsProhibited:
// Show alert to enable location
locationService.checkLocationStatus()
default:
LOG(.error, error.localizedDescription)
break
}
}
}
// MARK: - TrackRecordingObserver
extension TrackRecordingManager: TrackRecordingObservable {
@objc
func addObserver(_ observer: AnyObject, recordingIsActiveDidChangeHandler: @escaping TrackRecordingStateHandler) {
guard !observations.contains(where: { $0.observer === observer }) else { return }
let observation = Observation(observer: observer, recordingStateDidChangeHandler: recordingIsActiveDidChangeHandler)
observations.append(observation)
recordingIsActiveDidChangeHandler(recordingState, trackRecordingInfo) {
self.trackRecordingElevationProfileData
}
}
@objc
func removeObserver(_ observer: AnyObject) {
observations.removeAll { $0.observer === observer }
}
@objc
func contains(_ observer: AnyObject) -> Bool {
observations.contains { $0.observer === observer }
}
@objc
private func notifyObservers() {
observations.removeAll { $0.observer == nil }
observations.forEach {
$0.recordingStateDidChangeHandler?(recordingState, trackRecordingInfo, { self.trackRecordingElevationProfileData })
}
}
}