Compare commits

..

14 Commits
main ... splits

  1. 86
      CLAUDE.md
  2. 32
      LaunchIntents/IntentHandler.swift
  3. 64
      LaunchWidget/LaunchWidgetLiveActivity.swift
  4. 6
      LaunchWidget/SingleTimerView.swift
  5. 98
      LeCountdown.xcodeproj/project.pbxproj
  6. 6
      LeCountdown/AppDelegate.swift
  7. 300
      LeCountdown/Conductor.swift
  8. 83
      LeCountdown/CountdownScheduler.swift
  9. 149
      LeCountdown/CountdownSequence.swift
  10. 10
      LeCountdown/Info.plist
  11. 91
      LeCountdown/LeCountdownApp.swift
  12. 7
      LeCountdown/Model/Fakes.swift
  13. 3
      LeCountdown/Model/Generation/AbstractSoundTimer+CoreDataProperties.swift
  14. 22
      LeCountdown/Model/Generation/Countdown+CoreDataProperties.swift
  15. 15
      LeCountdown/Model/Generation/Interval+CoreDataClass.swift
  16. 27
      LeCountdown/Model/Generation/Interval+CoreDataProperties.swift
  17. 15
      LeCountdown/Model/Generation/IntervalGroup+CoreDataClass.swift
  18. 44
      LeCountdown/Model/Generation/IntervalGroup+CoreDataProperties.swift
  19. 15
      LeCountdown/Model/Generation/Step+CoreDataClass.swift
  20. 29
      LeCountdown/Model/Generation/Step+CoreDataProperties.swift
  21. 2
      LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion
  22. 50
      LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.6.6.xcdatamodel/contents
  23. 1
      LeCountdown/Model/LiveTimer.swift
  24. 102
      LeCountdown/Model/Model+CSV.swift
  25. 79
      LeCountdown/Model/Model+Extensions.swift
  26. 27
      LeCountdown/Model/Model+SharedExtensions.swift
  27. 10
      LeCountdown/Model/NSManagedContext+Extensions.swift
  28. 4
      LeCountdown/Model/Persistence.swift
  29. 62
      LeCountdown/Patcher.swift
  30. 20
      LeCountdown/Sound/DelaySoundPlayer.swift
  31. 4
      LeCountdown/Sound/Sound.swift
  32. 2
      LeCountdown/TimerRouter.swift
  33. 2
      LeCountdown/Views/Alarm/NewAlarmView.swift
  34. 3
      LeCountdown/Views/Countdown/CountdownDialView.swift
  35. 117
      LeCountdown/Views/Countdown/CountdownFormView.swift
  36. 113
      LeCountdown/Views/Countdown/NewCountdownView.swift
  37. 95
      LeCountdown/Views/Countdown/StepFormView.swift
  38. 10
      LeCountdown/Views/DialView.swift
  39. 72
      LeCountdown/Views/LiveTimerListView.swift
  40. 82
      LeCountdown/Views/NewDataView.swift
  41. 37
      LeCountdown/Views/PresetsView.swift
  42. 5
      LeCountdown/Views/Reusable/MailView.swift
  43. 44
      LeCountdown/Views/Reusable/SoundFormView.swift
  44. 107
      LeCountdown/Views/StartView.swift
  45. 10
      LeCountdown/Views/Stopwatch/NewStopwatchView.swift
  46. 3
      LeCountdown/Views/Stopwatch/StopwatchFormView.swift
  47. 42
      LeCountdown/Views/TimerModel.swift
  48. 2
      LeCountdown/Views/TimersView.swift
  49. 3
      LeCountdown/Widget/LaunchWidgetAttributes.swift
  50. 5
      LeCountdown/en.lproj/Localizable.strings
  51. 7
      LeCountdown/fr.lproj/Localizable.strings

@ -1,86 +0,0 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
LeCountdown is a Swift-based iOS timer application built with SwiftUI that supports countdowns, stopwatches, and alarms. The app features live activities, widgets, CloudKit synchronization, and background execution capabilities.
## Build and Development Commands
### Building and Running
- **Build the app**: Use Xcode to build the main LeCountdown target
- **Run tests**: Execute tests through Xcode Test Navigator or use `⌘+U`
- **Build widget extension**: Build the LaunchWidgetExtension target
- **Build intents extension**: Build the LaunchIntents target
### Xcode Schemes
- `LeCountdown`: Main app target with Core Data logging disabled
- `LaunchWidgetExtension`: Widget and Live Activity extension
- `LaunchIntents`: Siri Shortcuts and App Intents extension
## Core Architecture
### Data Layer
- **Core Data**: Primary data persistence using `NSPersistentCloudKitContainer`
- **CloudKit Integration**: Automatic sync via `iCloud.LeCountdown` container
- **Shared App Group**: `group.com.staxriver.countdown` for widget/extension data sharing
- **Models**: Generated Core Data classes in `Model/Generation/`
- `AbstractTimer` (base class), `Countdown`, `Stopwatch`, `Alarm`
- `Activity`, `Record` (for statistics)
- `CustomSound`, `IntervalGroup`
### Core Components
- **Conductor**: Central coordinator (`Conductor.swift`) managing live timers, sound playback, and background tasks
- **LiveTimer**: Runtime timer state management
- **TimerRouter**: Handles timer actions and URL schemes
- **SoundPlayer**: Audio playback coordination
- **PersistenceController**: Core Data stack setup with CloudKit
### View Architecture
- **SwiftUI-based**: Modern declarative UI with environment objects
- **ContentView**: Generic main interface supporting different timer types
- **Specialized Views**: `CountdownDialView`, `StopwatchDialView`, `AlarmDialView`
- **Form Views**: `CountdownFormView`, `StopwatchFormView`, `AlarmFormView`
### Extensions and Widgets
- **Widget**: Home screen widgets showing active timers
- **Live Activities**: Dynamic Island and Lock Screen live updates
- **App Intents**: Siri integration for timer management
- **Background Processing**: Uses `BGAppRefreshTask` for background updates
### Key Utilities
- **Logger**: File-based logging system
- **Preferences**: UserDefaults wrapper with property wrappers
- **Sound Management**: Custom sound playback with delay support
- **Statistics**: Activity tracking and record management
## Important Implementation Notes
### Core Data Setup
- Uses shared App Group container for data sharing between app and extensions
- CloudKit container ID: `iCloud.LeCountdown`
- Merge policy: `NSMergeByPropertyStoreTrumpMergePolicy`
- Automatic migration disabled (manual migrations in data model versions)
### Background Execution
- Registers `BGAppRefreshTask` with identifier `com.staxriver.lecountdown.refresh`
- Stops audio players when app becomes inactive
- Restores sound players when app becomes active
### Widget and Extension Architecture
- Shared data access via App Group container
- Intent definitions for Siri integration
- Live Activities use `LaunchWidgetAttributes` for Dynamic Island
### Sound System
- Multiple sound categories: Nature, Relax, Shorts, Stephan_Bodzin
- Delay-based sound player for timer completion
- State-based audio management with memory warning handling
### Subscription Model
- `AppGuard` manages subscription state
- `Store` handles in-app purchases
- Subscription UI components integrated throughout
This codebase follows iOS app development best practices with clear separation between data, business logic, and presentation layers. The modular architecture supports extensions, widgets, and background processing while maintaining code clarity and testability.

@ -8,34 +8,7 @@
import Intents
class IntentHandler: INExtension, SelectTimerIntentHandling {
//
// // MARK: - SelectTimerIntentHandling
//
// func resolveTimer(for intent: LaunchTimerIntent) async -> TimerIdentifierResolutionResult {
// if let timer = intent.timer {
// print("resolveTimer(for intent: LaunchTimerIntent) success !")
// return .success(with: timer)
// }
// print("resolveTimer(for intent: LaunchTimerIntent) needsValue")
// return .needsValue()
// }
//
// func handle(intent: LaunchTimerIntent) async -> LaunchTimerIntentResponse {
// if let timerIdentifier = intent.timer,
// let timerId = timerIdentifier.identifier,
// let timer = IntentDataProvider.main.timer(id: timerId) {
// do {
// let _ = try await TimerRouter.performAction(timer: timer)
// print("handle(intent: LaunchTimerIntent) success !")
// return .success(timer: timerIdentifier)
// } catch {
// return LaunchTimerIntentResponse(code: LaunchTimerIntentResponseCode(rawValue: 1)!, userActivity: nil)
// }
// }
// print("handle(intent: LaunchTimerIntent) no timer")
// return LaunchTimerIntentResponse(code: LaunchTimerIntentResponseCode(rawValue: 1)!, userActivity: nil)
// }
// MARK: - SelectTimerIntentHandling
func resolveTimer(for intent: SelectTimerIntent) async -> [TimerPropertiesResolutionResult] {
@ -58,7 +31,7 @@ class IntentHandler: INExtension, SelectTimerIntentHandling {
let displayName: String
switch timer {
case let countdown as Countdown:
let formattedDuration = countdown.duration.hourMinuteSecond
let formattedDuration = countdown.formattedDuration
if let name = timer.activity?.name, !name.isEmpty {
displayName = "\(name) (\(formattedDuration))"
} else {
@ -86,7 +59,6 @@ class IntentHandler: INExtension, SelectTimerIntentHandling {
}
}
override func handler(for intent: INIntent) -> Any {
// This is the default implementation. If you want different objects to handle different intents,
// you can override this and return the handler you want for that particular intent.

@ -29,25 +29,34 @@ struct LiveActivityView: View {
struct LaunchWidgetLiveActivity: Widget {
fileprivate let now: Date = Date()
var body: some WidgetConfiguration {
ActivityConfiguration(for: LaunchWidgetAttributes.self) { context in
// Lock screen/banner UI goes here
VStack(alignment: .leading) {
// TimelineView(.periodic(from: self.now, by: 0.1)) { _ in
if context.attributes.isTimer {
let range = Date()...context.attributes.date
Text(timerInterval: range,
pauseTime: range.lowerBound)
.font(.title)
} else {
Text(context.attributes.date, style: .timer)
// Lock screen/banner UI goes here
VStack(alignment: .leading) {
let date: Date = self._date(context: context)
let name: String = self._name(context: context)
if context.attributes.isCountdown {
let range = Date()...date
Text(timerInterval: range,
pauseTime: range.lowerBound)
.font(.title)
} else {
Text(context.attributes.date, style: .timer)
.font(.title)
}
Text(name.uppercased())
.font(.callout)
}
Text(context.attributes.name.uppercased())
.font(.callout)
}
// }
.padding()
.monospaced()
.background(Color(white: 0.1))
@ -60,10 +69,12 @@ struct LaunchWidgetLiveActivity: Widget {
DynamicIslandExpandedRegion(.leading) {
Text(context.attributes.name.uppercased())
.monospaced()
.padding(.leading, 4.0)
}
DynamicIslandExpandedRegion(.trailing) {
Text(context.attributes.date, style: .timer)
.monospaced()
.padding(.trailing, 4.0)
}
DynamicIslandExpandedRegion(.bottom) {
Button {
@ -76,7 +87,7 @@ struct LaunchWidgetLiveActivity: Widget {
Text(context.attributes.name.uppercased())
} compactTrailing: {
Group {
if context.attributes.isTimer {
if context.attributes.isCountdown {
let range = Date()...context.attributes.date
Text(timerInterval: range,
pauseTime: range.lowerBound)
@ -85,7 +96,7 @@ struct LaunchWidgetLiveActivity: Widget {
}
}.multilineTextAlignment(.trailing)
} minimal: {
if context.attributes.isTimer {
if context.attributes.isCountdown {
let range = Date()...context.attributes.date
Text(timerInterval: range,
pauseTime: range.lowerBound)
@ -100,9 +111,30 @@ struct LaunchWidgetLiveActivity: Widget {
}
}
fileprivate func _name(context: ActivityViewContext<LaunchWidgetAttributes>) -> String {
if let sequence = context.state.sequence, sequence.steps.count > 1 {
return context.attributes.name
} else if let name = context.state.sequence?.currentStep.name {
return name
} else {
return context.attributes.name
}
}
fileprivate func _date(context: ActivityViewContext<LaunchWidgetAttributes>) -> Date {
if let sequence = context.state.sequence, sequence.steps.count > 1 {
return sequence.end
} else if let date = context.state.sequence?.currentStep.interval.end {
return date
} else {
return context.attributes.date
}
}
fileprivate func _stop() {
}
}
struct LaunchWidgetLiveActivity_Previews: PreviewProvider {
@ -110,7 +142,7 @@ struct LaunchWidgetLiveActivity_Previews: PreviewProvider {
static let attributes = LaunchWidgetAttributes(
id: "",
name: "Tea",
date: Date().addingTimeInterval(3600.0), isTimer: true)
date: Date().addingTimeInterval(3600.0), isCountdown: true)
static let contentState = LaunchWidgetAttributes.ContentState(ended: false)

@ -58,7 +58,7 @@ struct SingleTimerView: View {
VStack(alignment: .leading) {
Text(timer.displayName.uppercased())
if let countdown = timer as? Countdown {
Text(countdown.duration.hourMinuteSecond)
Text(countdown.formattedDuration)
}
}
Spacer()
@ -102,7 +102,7 @@ struct LockScreenCountdownView: View {
default:
Text(title)
if let countdown = self.timer as? Countdown {
Text(countdown.duration.hourMinuteSecond)
Text(countdown.formattedDuration)
.monospaced()
}
}
@ -175,7 +175,7 @@ struct MultiCountdownView: View {
Spacer()
Text(timer.displayName.uppercased()).lineLimit(1)
if let countdown = timer as? Countdown {
Text(countdown.duration.hourMinuteSecond)
Text(countdown.formattedDuration)
}
Spacer()
}

@ -98,6 +98,17 @@
C4556F7329E40EC200DEB40B /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4556F6E29E40BED00DEB40B /* FileUtils.swift */; };
C4556F7429E40EC500DEB40B /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4556F7029E40DCF00DEB40B /* Codable+Extensions.swift */; };
C4556F7629E411A400DEB40B /* LogsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4556F7529E411A400DEB40B /* LogsView.swift */; };
C45D6AB52B173DFD00A5B649 /* Patcher.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D6AB42B173DFD00A5B649 /* Patcher.swift */; };
C45D6AB92B17499200A5B649 /* Model+CSV.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D6AB82B17499200A5B649 /* Model+CSV.swift */; };
C45D6AC02B18A09900A5B649 /* Step+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D6ABD2B18A09900A5B649 /* Step+CoreDataClass.swift */; };
C45D6AC12B18A09900A5B649 /* Step+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D6ABE2B18A09900A5B649 /* Step+CoreDataProperties.swift */; };
C45D6AC22B18A13C00A5B649 /* Step+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D6ABD2B18A09900A5B649 /* Step+CoreDataClass.swift */; };
C45D6AC32B18A13C00A5B649 /* Step+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D6ABE2B18A09900A5B649 /* Step+CoreDataProperties.swift */; };
C45D6AC42B18A13C00A5B649 /* Step+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D6ABD2B18A09900A5B649 /* Step+CoreDataClass.swift */; };
C45D6AC52B18A13C00A5B649 /* Step+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D6ABE2B18A09900A5B649 /* Step+CoreDataProperties.swift */; };
C45D6AC82B18D02900A5B649 /* CountdownSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D6AC72B18D02900A5B649 /* CountdownSequence.swift */; };
C45D6ACA2B18D08000A5B649 /* CountdownSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D6AC72B18D02900A5B649 /* CountdownSequence.swift */; };
C45D6ACB2B18D08100A5B649 /* CountdownSequence.swift in Sources */ = {isa = PBXBuildFile; fileRef = C45D6AC72B18D02900A5B649 /* CountdownSequence.swift */; };
C4636D9C29AF46BD00994E31 /* ActivityKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C4636D9B29AF46BD00994E31 /* ActivityKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
C4636D9D29AF46D900994E31 /* WidgetKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = C438C7D02981216200BF3EF9 /* WidgetKit.framework */; settings = {ATTRIBUTES = (Weak, ); }; };
C46926BD29DDC49E0003E310 /* SubscriptionButtonView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C46926BC29DDC49E0003E310 /* SubscriptionButtonView.swift */; };
@ -131,6 +142,7 @@
C47C933629F01B6600C780E2 /* FileUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4556F6E29E40BED00DEB40B /* FileUtils.swift */; };
C47C933729F01B7A00C780E2 /* Codable+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4556F7029E40DCF00DEB40B /* Codable+Extensions.swift */; };
C47C933929F13BD100C780E2 /* AppleMusicPickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C47C933829F13BD100C780E2 /* AppleMusicPickerView.swift */; };
C48920672B0E57C900F6F4D8 /* StepFormView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C48920662B0E57C900F6F4D8 /* StepFormView.swift */; };
C48940DE2AC307860086F4FA /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C48940DD2AC307860086F4FA /* GoogleService-Info.plist */; };
C48940DF2AC307860086F4FA /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C48940DD2AC307860086F4FA /* GoogleService-Info.plist */; };
C48940E02AC307860086F4FA /* GoogleService-Info.plist in Resources */ = {isa = PBXBuildFile; fileRef = C48940DD2AC307860086F4FA /* GoogleService-Info.plist */; };
@ -191,18 +203,6 @@
C4BA2AFD299A3A3700CB4FBA /* AppleMusicPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2AFC299A3A3700CB4FBA /* AppleMusicPlayer.swift */; };
C4BA2B04299A42EF00CB4FBA /* NewDataView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B03299A42EF00CB4FBA /* NewDataView.swift */; };
C4BA2B06299A8F8D00CB4FBA /* PresetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B05299A8F8D00CB4FBA /* PresetsView.swift */; };
C4BA2B0F299BE61E00CB4FBA /* Interval+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0A299BE61E00CB4FBA /* Interval+CoreDataClass.swift */; };
C4BA2B10299BE61E00CB4FBA /* Interval+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0B299BE61E00CB4FBA /* Interval+CoreDataProperties.swift */; };
C4BA2B11299BE61E00CB4FBA /* IntervalGroup+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0C299BE61E00CB4FBA /* IntervalGroup+CoreDataClass.swift */; };
C4BA2B13299BE61E00CB4FBA /* Countdown+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0E299BE61E00CB4FBA /* Countdown+CoreDataProperties.swift */; };
C4BA2B15299BE6A000CB4FBA /* Interval+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0B299BE61E00CB4FBA /* Interval+CoreDataProperties.swift */; };
C4BA2B16299BE6A000CB4FBA /* IntervalGroup+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0C299BE61E00CB4FBA /* IntervalGroup+CoreDataClass.swift */; };
C4BA2B18299BE6A000CB4FBA /* Interval+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0A299BE61E00CB4FBA /* Interval+CoreDataClass.swift */; };
C4BA2B19299BE6A000CB4FBA /* Countdown+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0E299BE61E00CB4FBA /* Countdown+CoreDataProperties.swift */; };
C4BA2B1B299BE6A100CB4FBA /* Interval+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0B299BE61E00CB4FBA /* Interval+CoreDataProperties.swift */; };
C4BA2B1C299BE6A100CB4FBA /* IntervalGroup+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0C299BE61E00CB4FBA /* IntervalGroup+CoreDataClass.swift */; };
C4BA2B1E299BE6A100CB4FBA /* Interval+CoreDataClass.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0A299BE61E00CB4FBA /* Interval+CoreDataClass.swift */; };
C4BA2B1F299BE6A100CB4FBA /* Countdown+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B0E299BE61E00CB4FBA /* Countdown+CoreDataProperties.swift */; };
C4BA2B22299BE82E00CB4FBA /* AbstractSoundTimer+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2AEF2996A11900CB4FBA /* AbstractSoundTimer+CoreDataProperties.swift */; };
C4BA2B23299BE82E00CB4FBA /* AbstractSoundTimer+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2AEF2996A11900CB4FBA /* AbstractSoundTimer+CoreDataProperties.swift */; };
C4BA2B25299D35C100CB4FBA /* HomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B24299D35C100CB4FBA /* HomeView.swift */; };
@ -217,9 +217,6 @@
C4BA2B3C299F838000CB4FBA /* Model+SharedExtensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B39299F838000CB4FBA /* Model+SharedExtensions.swift */; };
C4BA2B3E299FC86800CB4FBA /* StatisticsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B3D299FC86800CB4FBA /* StatisticsView.swift */; };
C4BA2B43299FCB2B00CB4FBA /* RecordsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B42299FCB2B00CB4FBA /* RecordsView.swift */; };
C4BA2B49299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B47299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift */; };
C4BA2B4A299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B47299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift */; };
C4BA2B4B299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B47299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift */; };
C4BA2B4C299FCE0C00CB4FBA /* Stopwatch+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B48299FCE0C00CB4FBA /* Stopwatch+CoreDataProperties.swift */; };
C4BA2B4D299FCE0C00CB4FBA /* Stopwatch+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B48299FCE0C00CB4FBA /* Stopwatch+CoreDataProperties.swift */; };
C4BA2B4E299FCE0C00CB4FBA /* Stopwatch+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B48299FCE0C00CB4FBA /* Stopwatch+CoreDataProperties.swift */; };
@ -236,6 +233,9 @@
C4BA2B7329A60CF000CB4FBA /* Shortcut.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B7229A60CF000CB4FBA /* Shortcut.swift */; };
C4BA2B7929A65C1400CB4FBA /* UIDevice+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4BA2B7829A65C1400CB4FBA /* UIDevice+Extensions.swift */; };
C4BCABB92A040B97009FFB0A /* QP01 0023 Surf moderate sandy.wav in Resources */ = {isa = PBXBuildFile; fileRef = C4BCABB82A040B97009FFB0A /* QP01 0023 Surf moderate sandy.wav */; };
C4C8266C2B0E41D20036C666 /* Countdown+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C826682B0E41D20036C666 /* Countdown+CoreDataProperties.swift */; };
C4C8266D2B0E41D20036C666 /* Countdown+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C826682B0E41D20036C666 /* Countdown+CoreDataProperties.swift */; };
C4C8266E2B0E41D20036C666 /* Countdown+CoreDataProperties.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4C826682B0E41D20036C666 /* Countdown+CoreDataProperties.swift */; };
C4E5D66629B73AED008E7465 /* StartTimerIntent.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5D66429B73AED008E7465 /* StartTimerIntent.swift */; };
C4E5D66729B73AED008E7465 /* TimerIdentifierAppEntity.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5D66529B73AED008E7465 /* TimerIdentifierAppEntity.swift */; };
C4E5D66A29B73FC6008E7465 /* TimerShortcuts.swift in Sources */ = {isa = PBXBuildFile; fileRef = C4E5D66929B73FC6008E7465 /* TimerShortcuts.swift */; };
@ -423,6 +423,11 @@
C4556F6E29E40BED00DEB40B /* FileUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FileUtils.swift; sourceTree = "<group>"; };
C4556F7029E40DCF00DEB40B /* Codable+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Codable+Extensions.swift"; sourceTree = "<group>"; };
C4556F7529E411A400DEB40B /* LogsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LogsView.swift; sourceTree = "<group>"; };
C45D6AB42B173DFD00A5B649 /* Patcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Patcher.swift; sourceTree = "<group>"; };
C45D6AB82B17499200A5B649 /* Model+CSV.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Model+CSV.swift"; sourceTree = "<group>"; };
C45D6ABD2B18A09900A5B649 /* Step+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Step+CoreDataClass.swift"; sourceTree = "<group>"; };
C45D6ABE2B18A09900A5B649 /* Step+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Step+CoreDataProperties.swift"; sourceTree = "<group>"; };
C45D6AC72B18D02900A5B649 /* CountdownSequence.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CountdownSequence.swift; sourceTree = "<group>"; };
C4636D9B29AF46BD00994E31 /* ActivityKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = ActivityKit.framework; path = System/Library/Frameworks/ActivityKit.framework; sourceTree = SDKROOT; };
C46926BC29DDC49E0003E310 /* SubscriptionButtonView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubscriptionButtonView.swift; sourceTree = "<group>"; };
C473C2F829A8DC0A0056B38A /* LaunchWidgetAttributes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LaunchWidgetAttributes.swift; sourceTree = "<group>"; };
@ -438,6 +443,7 @@
C47A9AF22AD1B32C00618A50 /* URLs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = URLs.swift; sourceTree = "<group>"; };
C47C933829F13BD100C780E2 /* AppleMusicPickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppleMusicPickerView.swift; sourceTree = "<group>"; };
C47C933C29F13DBD00C780E2 /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
C48920662B0E57C900F6F4D8 /* StepFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StepFormView.swift; sourceTree = "<group>"; };
C48940DD2AC307860086F4FA /* GoogleService-Info.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; path = "GoogleService-Info.plist"; sourceTree = "<group>"; };
C498E59E298D4DEA00E90DE0 /* LiveTimerListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTimerListView.swift; sourceTree = "<group>"; };
C498E5A0298D543900E90DE0 /* LiveTimer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveTimer.swift; sourceTree = "<group>"; };
@ -472,10 +478,6 @@
C4BA2B03299A42EF00CB4FBA /* NewDataView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NewDataView.swift; sourceTree = "<group>"; };
C4BA2B05299A8F8D00CB4FBA /* PresetsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PresetsView.swift; sourceTree = "<group>"; };
C4BA2B07299BDAE000CB4FBA /* LeCountdown.0.6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.6.xcdatamodel; sourceTree = "<group>"; };
C4BA2B0A299BE61E00CB4FBA /* Interval+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Interval+CoreDataClass.swift"; sourceTree = "<group>"; };
C4BA2B0B299BE61E00CB4FBA /* Interval+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Interval+CoreDataProperties.swift"; sourceTree = "<group>"; };
C4BA2B0C299BE61E00CB4FBA /* IntervalGroup+CoreDataClass.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IntervalGroup+CoreDataClass.swift"; sourceTree = "<group>"; };
C4BA2B0E299BE61E00CB4FBA /* Countdown+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Countdown+CoreDataProperties.swift"; sourceTree = "<group>"; };
C4BA2B24299D35C100CB4FBA /* HomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomeView.swift; sourceTree = "<group>"; };
C4BA2B2C299E2DEE00CB4FBA /* Preferences.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Preferences.swift; sourceTree = "<group>"; };
C4BA2B2E299E69A000CB4FBA /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = "<group>"; };
@ -485,7 +487,6 @@
C4BA2B3D299FC86800CB4FBA /* StatisticsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StatisticsView.swift; sourceTree = "<group>"; };
C4BA2B42299FCB2B00CB4FBA /* RecordsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RecordsView.swift; sourceTree = "<group>"; };
C4BA2B46299FCD8B00CB4FBA /* LeCountdown.0.6.1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.6.1.xcdatamodel; sourceTree = "<group>"; };
C4BA2B47299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "IntervalGroup+CoreDataProperties.swift"; sourceTree = "<group>"; };
C4BA2B48299FCE0C00CB4FBA /* Stopwatch+CoreDataProperties.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Stopwatch+CoreDataProperties.swift"; sourceTree = "<group>"; };
C4BA2B56299FFA4F00CB4FBA /* AppGuard.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = AppGuard.swift; sourceTree = "<group>"; };
C4BA2B5A299FFAB000CB4FBA /* Logger.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = Logger.swift; sourceTree = "<group>"; };
@ -499,6 +500,8 @@
C4BA2B7229A60CF000CB4FBA /* Shortcut.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shortcut.swift; sourceTree = "<group>"; };
C4BA2B7829A65C1400CB4FBA /* UIDevice+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIDevice+Extensions.swift"; sourceTree = "<group>"; };
C4BCABB82A040B97009FFB0A /* QP01 0023 Surf moderate sandy.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = "QP01 0023 Surf moderate sandy.wav"; sourceTree = "<group>"; };
C4C8265A2B0E40350036C666 /* LeCountdown.0.6.6.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = LeCountdown.0.6.6.xcdatamodel; sourceTree = "<group>"; };
C4C826682B0E41D20036C666 /* Countdown+CoreDataProperties.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "Countdown+CoreDataProperties.swift"; sourceTree = "<group>"; };
C4E5D66429B73AED008E7465 /* StartTimerIntent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StartTimerIntent.swift; sourceTree = "<group>"; };
C4E5D66529B73AED008E7465 /* TimerIdentifierAppEntity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerIdentifierAppEntity.swift; sourceTree = "<group>"; };
C4E5D66929B73FC6008E7465 /* TimerShortcuts.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TimerShortcuts.swift; sourceTree = "<group>"; };
@ -595,6 +598,7 @@
C438C7F329812BB200BF3EF9 /* LaunchIntents */,
C438C7CF2981216200BF3EF9 /* Frameworks */,
C4060DBD297AE73B003FAB80 /* Products */,
C48920632B0E422E00F6F4D8 /* Recovered References */,
);
sourceTree = "<group>";
};
@ -615,8 +619,10 @@
children = (
C438C7C829803CA000BF3EF9 /* AppDelegate.swift */,
C4060DBF297AE73B003FAB80 /* LeCountdownApp.swift */,
C45D6AB42B173DFD00A5B649 /* Patcher.swift */,
C438C7C02980228B00BF3EF9 /* CountdownScheduler.swift */,
C4F8B15629891271005C86A5 /* Conductor.swift */,
C45D6AC72B18D02900A5B649 /* CountdownSequence.swift */,
C4F8B1D7298C0727005C86A5 /* TimerRouter.swift */,
C438C80B2981DE2E00BF3EF9 /* Views */,
C438C8092981DDF800BF3EF9 /* Model */,
@ -752,6 +758,7 @@
C438C80C2982847300BF3EF9 /* CoreDataRequests.swift */,
C49C346829DECA4400AAC6FC /* LiveStopWatch.swift */,
C498E5A0298D543900E90DE0 /* LiveTimer.swift */,
C45D6AB82B17499200A5B649 /* Model+CSV.swift */,
C438C806298195E600BF3EF9 /* Model+Extensions.swift */,
C4BA2B39299F838000CB4FBA /* Model+SharedExtensions.swift */,
C438C7C4298024E900BF3EF9 /* NSManagedContext+Extensions.swift */,
@ -804,6 +811,7 @@
C4E5D68929BB7953008E7465 /* SettingsView.swift */,
C4286EB62A1B98420070D075 /* StartView.swift */,
C4E5D68529BB369E008E7465 /* TimersView.swift */,
C4BA2ADA299549BC00CB4FBA /* TimerModel.swift */,
);
path = Views;
sourceTree = "<group>";
@ -830,6 +838,13 @@
path = Sound_Assets;
sourceTree = "<group>";
};
C48920632B0E422E00F6F4D8 /* Recovered References */ = {
isa = PBXGroup;
children = (
);
name = "Recovered References";
sourceTree = "<group>";
};
C4A16DBC29D1A69200143D5E /* Shorts */ = {
isa = PBXGroup;
children = (
@ -912,15 +927,13 @@
C4F8B170298AC234005C86A5 /* Alarm+CoreDataClass.swift */,
C4F8B1AA298AC3A0005C86A5 /* Alarm+CoreDataProperties.swift */,
C4F8B16C298AC234005C86A5 /* Countdown+CoreDataClass.swift */,
C4BA2B0E299BE61E00CB4FBA /* Countdown+CoreDataProperties.swift */,
C4C826682B0E41D20036C666 /* Countdown+CoreDataProperties.swift */,
C4BA2AED2996A11900CB4FBA /* CustomSound+CoreDataClass.swift */,
C4BA2AEE2996A11900CB4FBA /* CustomSound+CoreDataProperties.swift */,
C4BA2B0A299BE61E00CB4FBA /* Interval+CoreDataClass.swift */,
C4BA2B0B299BE61E00CB4FBA /* Interval+CoreDataProperties.swift */,
C4BA2B0C299BE61E00CB4FBA /* IntervalGroup+CoreDataClass.swift */,
C4BA2B47299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift */,
C4F8B16E298AC234005C86A5 /* Record+CoreDataClass.swift */,
C4F8B16F298AC234005C86A5 /* Record+CoreDataProperties.swift */,
C45D6ABD2B18A09900A5B649 /* Step+CoreDataClass.swift */,
C45D6ABE2B18A09900A5B649 /* Step+CoreDataProperties.swift */,
C4286E9F2A1502FD0070D075 /* Stopwatch+CoreDataClass.swift */,
C4BA2B48299FCE0C00CB4FBA /* Stopwatch+CoreDataProperties.swift */,
);
@ -933,6 +946,7 @@
C4742B58298411E800D5D950 /* CountdownFormView.swift */,
C4F8B1B7298AC81D005C86A5 /* CountdownDialView.swift */,
C4060DF6297AFEF2003FAB80 /* NewCountdownView.swift */,
C48920662B0E57C900F6F4D8 /* StepFormView.swift */,
);
path = Countdown;
sourceTree = "<group>";
@ -973,7 +987,6 @@
C4F8B165298A9ABB005C86A5 /* SoundFormView.swift */,
C4BA2AD52993F62700CB4FBA /* SoundSelectionView.swift */,
C4286EA52A150A7E0070D075 /* TimePickerView.swift */,
C4BA2ADA299549BC00CB4FBA /* TimerModel.swift */,
C473C33829ACDBD70056B38A /* TipView.swift */,
C4BA2B2E299E69A000CB4FBA /* View+Extension.swift */,
C4742B5E2984205000D5D950 /* ViewModifiers.swift */,
@ -1275,7 +1288,9 @@
C4BA2B36299F82FB00CB4FBA /* Fakes.swift in Sources */,
C4556F7629E411A400DEB40B /* LogsView.swift in Sources */,
C498E5A1298D543900E90DE0 /* LiveTimer.swift in Sources */,
C48920672B0E57C900F6F4D8 /* StepFormView.swift in Sources */,
C4BA2B6329A3C34600CB4FBA /* Stat.swift in Sources */,
C4C8266C2B0E41D20036C666 /* Countdown+CoreDataProperties.swift in Sources */,
C415D3E229C0C0C20037B215 /* MailView.swift in Sources */,
C438C80F29828B8600BF3EF9 /* ActivitiesView.swift in Sources */,
C4E5D68029B8FD93008E7465 /* Store.swift in Sources */,
@ -1284,7 +1299,6 @@
C438C80D2982847300BF3EF9 /* CoreDataRequests.swift in Sources */,
C4742B5F2984205000D5D950 /* ViewModifiers.swift in Sources */,
C4F8B1D8298C0727005C86A5 /* TimerRouter.swift in Sources */,
C4BA2B13299BE61E00CB4FBA /* Countdown+CoreDataProperties.swift in Sources */,
C419EEE52AC5AC0200A66BBB /* ViewStyles.swift in Sources */,
C4F8B186298AC234005C86A5 /* Activity+CoreDataClass.swift in Sources */,
C4BA2B57299FFA4F00CB4FBA /* AppGuard.swift in Sources */,
@ -1292,6 +1306,7 @@
C4BA2B5F299FFC8400CB4FBA /* StoreView.swift in Sources */,
C4BA2B7929A65C1400CB4FBA /* UIDevice+Extensions.swift in Sources */,
C4F8B17E298AC234005C86A5 /* Alarm+CoreDataClass.swift in Sources */,
C45D6AB92B17499200A5B649 /* Model+CSV.swift in Sources */,
C42E970729E6EDF5005B1B8C /* StatisticsSubView.swift in Sources */,
C438C81129829EAF00BF3EF9 /* PropertyWrappers.swift in Sources */,
C4F8B1BF298ACA0B005C86A5 /* StopwatchDialView.swift in Sources */,
@ -1308,17 +1323,19 @@
C4E5D66629B73AED008E7465 /* StartTimerIntent.swift in Sources */,
C4286EA62A150A7E0070D075 /* TimePickerView.swift in Sources */,
C4BA2AD62993F62700CB4FBA /* SoundSelectionView.swift in Sources */,
C45D6AC02B18A09900A5B649 /* Step+CoreDataClass.swift in Sources */,
C498E5A5299152B400E90DE0 /* GreenCheckmarkView.swift in Sources */,
C4BA2B3E299FC86800CB4FBA /* StatisticsView.swift in Sources */,
C4F8B1B8298AC81D005C86A5 /* CountdownDialView.swift in Sources */,
C473C2F929A8DC0A0056B38A /* LaunchWidgetAttributes.swift in Sources */,
C445FA922987CC8A0054D761 /* Sound.swift in Sources */,
C4F8B1D0298BF2E2005C86A5 /* DialView.swift in Sources */,
C4BA2B11299BE61E00CB4FBA /* IntervalGroup+CoreDataClass.swift in Sources */,
C4286EA12A1502FD0070D075 /* Stopwatch+CoreDataClass.swift in Sources */,
C4BA2AFD299A3A3700CB4FBA /* AppleMusicPlayer.swift in Sources */,
C4BA2B3A299F838000CB4FBA /* Model+SharedExtensions.swift in Sources */,
C4BA2AF32996A11900CB4FBA /* AbstractSoundTimer+CoreDataProperties.swift in Sources */,
C45D6AC12B18A09900A5B649 /* Step+CoreDataProperties.swift in Sources */,
C45D6AB52B173DFD00A5B649 /* Patcher.swift in Sources */,
C4556F6B29E40B7800DEB40B /* FileLogger.swift in Sources */,
C4742B59298411E800D5D950 /* CountdownFormView.swift in Sources */,
C4BA2B25299D35C100CB4FBA /* HomeView.swift in Sources */,
@ -1351,7 +1368,6 @@
C4F8B184298AC234005C86A5 /* AbstractTimer+CoreDataClass.swift in Sources */,
C4F8B17A298AC234005C86A5 /* Countdown+CoreDataClass.swift in Sources */,
C42E970229E6B32B005B1B8C /* CalendarView.swift in Sources */,
C4BA2B0F299BE61E00CB4FBA /* Interval+CoreDataClass.swift in Sources */,
C47A9AF32AD1B32C00618A50 /* URLs.swift in Sources */,
C4F8B164298A9A92005C86A5 /* AlarmFormView.swift in Sources */,
C4286EB02A1B75AB0070D075 /* BoringContext.swift in Sources */,
@ -1363,7 +1379,6 @@
C4E5D68A29BB7953008E7465 /* SettingsView.swift in Sources */,
C4060DF7297AFEF2003FAB80 /* NewCountdownView.swift in Sources */,
C4060DCC297AE73D003FAB80 /* LeCountdown.xcdatamodeld in Sources */,
C4BA2B49299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift in Sources */,
C4F8B17C298AC234005C86A5 /* Record+CoreDataClass.swift in Sources */,
C4BA2B5B299FFAB000CB4FBA /* Logger.swift in Sources */,
C4E5D66729B73AED008E7465 /* TimerIdentifierAppEntity.swift in Sources */,
@ -1374,10 +1389,10 @@
C4A16D9529C4B06400143D5E /* StatePlayer.swift in Sources */,
C4BA2B6129A3C02400CB4FBA /* ActivityStatsView.swift in Sources */,
C4BA2B6A29A4BE1800CB4FBA /* Date+Extensions.swift in Sources */,
C4BA2B10299BE61E00CB4FBA /* Interval+CoreDataProperties.swift in Sources */,
C4F8B1AC298AC3A0005C86A5 /* Alarm+CoreDataProperties.swift in Sources */,
C498E59F298D4DEA00E90DE0 /* LiveTimerListView.swift in Sources */,
C4060DC0297AE73B003FAB80 /* LeCountdownApp.swift in Sources */,
C45D6AC82B18D02900A5B649 /* CountdownSequence.swift in Sources */,
C473C31829A926F50056B38A /* LaunchWidget.intentdefinition in Sources */,
C4E5D66A29B73FC6008E7465 /* TimerShortcuts.swift in Sources */,
C4742B5729840F6400D5D950 /* CoolPic.swift in Sources */,
@ -1411,13 +1426,12 @@
buildActionMask = 2147483647;
files = (
C4A16D9829C4B06400143D5E /* StatePlayer.swift in Sources */,
C4BA2B15299BE6A000CB4FBA /* Interval+CoreDataProperties.swift in Sources */,
C498E5A6299152C600E90DE0 /* GreenCheckmarkView.swift in Sources */,
C4BA2B3B299F838000CB4FBA /* Model+SharedExtensions.swift in Sources */,
C438C7EB2981266F00BF3EF9 /* SingleTimerView.swift in Sources */,
C47C933729F01B7A00C780E2 /* Codable+Extensions.swift in Sources */,
C45D6AC32B18A13C00A5B649 /* Step+CoreDataProperties.swift in Sources */,
C438C7D62981216200BF3EF9 /* LaunchWidgetBundle.swift in Sources */,
C4BA2B16299BE6A000CB4FBA /* IntervalGroup+CoreDataClass.swift in Sources */,
C4286EAE2A17753A0070D075 /* AppError.swift in Sources */,
C4F8B18B298AC288005C86A5 /* Record+CoreDataClass.swift in Sources */,
C4F8B195298AC288005C86A5 /* Activity+CoreDataClass.swift in Sources */,
@ -1426,9 +1440,9 @@
C4BA2AF72996A4EF00CB4FBA /* CustomSound+CoreDataClass.swift in Sources */,
C4F8B1AD298AC451005C86A5 /* AbstractSoundTimer+CoreDataClass.swift in Sources */,
C445FA87298448730054D761 /* CoolPic.swift in Sources */,
C4C8266D2B0E41D20036C666 /* Countdown+CoreDataProperties.swift in Sources */,
C47C933529F01B5E00C780E2 /* FileLogger.swift in Sources */,
C438C8162982BE1E00BF3EF9 /* LeCountdown.xcdatamodeld in Sources */,
C4BA2B4A299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift in Sources */,
C4BA2B4D299FCE0C00CB4FBA /* Stopwatch+CoreDataProperties.swift in Sources */,
C4F8B194298AC288005C86A5 /* Record+CoreDataProperties.swift in Sources */,
C438C8152982BD9000BF3EF9 /* IntentDataProvider.swift in Sources */,
@ -1437,17 +1451,17 @@
C473C2FA29A8DC1E0056B38A /* LaunchWidgetAttributes.swift in Sources */,
C4286EA42A1503330070D075 /* Stopwatch+CoreDataClass.swift in Sources */,
C438C7E82981255D00BF3EF9 /* TimeInterval+Extensions.swift in Sources */,
C45D6AC22B18A13C00A5B649 /* Step+CoreDataClass.swift in Sources */,
C4BA2B37299F82FF00CB4FBA /* Fakes.swift in Sources */,
C4F8B18F298AC288005C86A5 /* AbstractTimer+CoreDataClass.swift in Sources */,
C4BA2B19299BE6A000CB4FBA /* Countdown+CoreDataProperties.swift in Sources */,
C438C7D82981216200BF3EF9 /* LaunchWidgetLiveActivity.swift in Sources */,
C4F8B18C298AC288005C86A5 /* Alarm+CoreDataClass.swift in Sources */,
C438C8192982BFDB00BF3EF9 /* NSManagedContext+Extensions.swift in Sources */,
C438C7DA2981216200BF3EF9 /* LaunchWidget.swift in Sources */,
C45D6ACB2B18D08100A5B649 /* CountdownSequence.swift in Sources */,
C4BA2AF62996A4EF00CB4FBA /* CustomSound+CoreDataProperties.swift in Sources */,
C4F8B192298AC288005C86A5 /* Activity+CoreDataProperties.swift in Sources */,
C47C933629F01B6600C780E2 /* FileUtils.swift in Sources */,
C4BA2B18299BE6A000CB4FBA /* Interval+CoreDataClass.swift in Sources */,
C4F8B18E298AC288005C86A5 /* AbstractTimer+CoreDataProperties.swift in Sources */,
C4A16DC829D311C800143D5E /* Extensions.swift in Sources */,
C4F8B1AE298AC451005C86A5 /* Alarm+CoreDataProperties.swift in Sources */,
@ -1465,6 +1479,7 @@
C49C346A29DECC7100AAC6FC /* LiveStopWatch.swift in Sources */,
C473C2F329A8DA6F0056B38A /* LiveTimer.swift in Sources */,
C4F8B1C6298ACC1F005C86A5 /* SoundPlayer.swift in Sources */,
C45D6ACA2B18D08000A5B649 /* CountdownSequence.swift in Sources */,
C4F8B1A2298AC288005C86A5 /* Record+CoreDataProperties.swift in Sources */,
C4F8B1B2298AC451005C86A5 /* Alarm+CoreDataProperties.swift in Sources */,
C473C2F229A8DA1F0056B38A /* CountdownScheduler.swift in Sources */,
@ -1476,16 +1491,14 @@
C473C33D29ACEC4F0056B38A /* Tip.swift in Sources */,
C4F8B19C298AC288005C86A5 /* AbstractTimer+CoreDataProperties.swift in Sources */,
C4556F7429E40EC500DEB40B /* Codable+Extensions.swift in Sources */,
C4BA2B1C299BE6A100CB4FBA /* IntervalGroup+CoreDataClass.swift in Sources */,
C4BA2B1F299BE6A100CB4FBA /* Countdown+CoreDataProperties.swift in Sources */,
C4F8B1A3298AC288005C86A5 /* Activity+CoreDataClass.swift in Sources */,
C4F8B19A298AC288005C86A5 /* Alarm+CoreDataClass.swift in Sources */,
C438C80529813FB400BF3EF9 /* TimeInterval+Extensions.swift in Sources */,
C473C2F629A8DB1D0056B38A /* Sound.swift in Sources */,
C4BA2B1B299BE6A100CB4FBA /* Interval+CoreDataProperties.swift in Sources */,
C438C802298132B900BF3EF9 /* LeCountdown.xcdatamodeld in Sources */,
C4F8B1A0298AC288005C86A5 /* Activity+CoreDataProperties.swift in Sources */,
C42E96FE29E5B5CD005B1B8C /* Filter.swift in Sources */,
C45D6AC52B18A13C00A5B649 /* Step+CoreDataProperties.swift in Sources */,
C438C81A2982BFF100BF3EF9 /* NSManagedContext+Extensions.swift in Sources */,
C4F8B19D298AC288005C86A5 /* AbstractTimer+CoreDataClass.swift in Sources */,
C4BA2B3C299F838000CB4FBA /* Model+SharedExtensions.swift in Sources */,
@ -1496,14 +1509,14 @@
C438C8012981327600BF3EF9 /* Persistence.swift in Sources */,
C473C31A29A926F50056B38A /* LaunchWidget.intentdefinition in Sources */,
C4BA2B5D299FFAB000CB4FBA /* Logger.swift in Sources */,
C4C8266E2B0E41D20036C666 /* Countdown+CoreDataProperties.swift in Sources */,
C48ECC0929DAC47200DE5A66 /* AppGuard.swift in Sources */,
C473C2FB29A8DC3A0056B38A /* LaunchWidgetAttributes.swift in Sources */,
C4556F7329E40EC200DEB40B /* FileUtils.swift in Sources */,
C4BA2B4B299FCE0C00CB4FBA /* IntervalGroup+CoreDataProperties.swift in Sources */,
C4556F7229E40EBF00DEB40B /* FileLogger.swift in Sources */,
C4F8B1B1298AC451005C86A5 /* AbstractSoundTimer+CoreDataClass.swift in Sources */,
C473C2F129A8DA0B0056B38A /* Conductor.swift in Sources */,
C4BA2B1E299BE6A100CB4FBA /* Interval+CoreDataClass.swift in Sources */,
C45D6AC42B18A13C00A5B649 /* Step+CoreDataClass.swift in Sources */,
C473C2FC29A8DC4B0056B38A /* Date+Extensions.swift in Sources */,
C4286EA32A1503320070D075 /* Stopwatch+CoreDataClass.swift in Sources */,
C473C2F429A8DAE70056B38A /* Model+Extensions.swift in Sources */,
@ -2054,6 +2067,7 @@
C4060DCA297AE73D003FAB80 /* LeCountdown.xcdatamodeld */ = {
isa = XCVersionGroup;
children = (
C4C8265A2B0E40350036C666 /* LeCountdown.0.6.6.xcdatamodel */,
C454892C2A28D9610047D39E /* LeCountdown.0.6.5.xcdatamodel */,
C4A16DCA29D323CF00143D5E /* LeCountdown.0.6.4.xcdatamodel */,
C4A16DBD29D1C9DE00143D5E /* LeCountdown.0.6.3.xcdatamodel */,
@ -2068,7 +2082,7 @@
C418A14F298428CB00C22230 /* LeCountdown.0.1.xcdatamodel */,
C4060DCB297AE73D003FAB80 /* LeCountdown.xcdatamodel */,
);
currentVersion = C454892C2A28D9610047D39E /* LeCountdown.0.6.5.xcdatamodel */;
currentVersion = C4C8265A2B0E40350036C666 /* LeCountdown.0.6.6.xcdatamodel */;
path = LeCountdown.xcdatamodeld;
sourceTree = "<group>";
versionGroupType = wrapper.xcdatamodel;

@ -91,10 +91,10 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
print("didReceive notification")
Logger.log("didReceive notification")
FileLogger.log("userNotificationCenter didReceive > cancelling sound player")
if let timerId = self._timerId(notificationId: response.notification.request.identifier) {
Conductor.maestro.cancelSoundPlayer(id: timerId)
Conductor.maestro.cancelSoundPlayers(id: timerId)
}
}
@ -107,7 +107,7 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
}
fileprivate func _timerId(notificationId: String) -> TimerID? {
let components = notificationId.components(separatedBy: CountdownScheduler.notificationIdSeparator)
let components = notificationId.components(separatedBy: Conductor.notificationIdSeparator)
if components.count == 2 {
return components[0]
} else {

@ -39,8 +39,7 @@ class Conductor: ObservableObject {
fileprivate var beats: Timer? = nil
@UserDefault(PreferenceKey.countdowns.rawValue, defaultValue: [:]) static var savedCountdowns: [String : DateInterval]
@UserDefault(PreferenceKey.pausedCountdowns.rawValue, defaultValue: [:]) static var savedPausedCountdowns: [String : TimeInterval]
@UserDefault(PreferenceKey.countdowns.rawValue, defaultValue: [:]) static var savedCountdowns: [String : CountdownSequence]
@UserDefault(PreferenceKey.stopwatches.rawValue, defaultValue: [:]) static var savedStopwatches: [String : LiveStopWatch]
@Published private (set) var liveTimers: [LiveTimer] = []
@ -50,28 +49,18 @@ class Conductor: ObservableObject {
init() {
self.currentCountdowns = Conductor.savedCountdowns
self.currentStopwatches = Conductor.savedStopwatches
self.pausedCountdowns = Conductor.savedPausedCountdowns
self.beats = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true, block: { _ in
self.beats = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true, block: { _ in
self._cleanupCountdowns()
self._buildLiveTimers()
})
}
@Published var cancelledCountdowns: [String] = []
@Published var currentCountdowns: [String : DateInterval] = [:] {
@Published var currentCountdowns: [String : CountdownSequence] = [:] {
didSet {
Conductor.savedCountdowns = currentCountdowns
// Logger.log("**** currentCountdowns didSet, count = \(currentCountdowns.count)")
withAnimation {
self._buildLiveTimers()
}
}
}
@Published var pausedCountdowns: [String : TimeInterval] = [:] {
didSet {
Conductor.savedPausedCountdowns = pausedCountdowns
withAnimation {
self._buildLiveTimers()
}
@ -87,28 +76,14 @@ class Conductor: ObservableObject {
}
}
func removeLiveTimer(id: TimerID) {
// Logger.log("removeLiveTimer")
self.liveTimers.removeAll(where: { $0.id == id })
self.cancelledCountdowns.removeAll(where: { $0 == id })
self.currentStopwatches.removeValue(forKey: id)
self.pausedCountdowns.removeValue(forKey: id)
if let soundPlayer = self._delayedSoundPlayers[id] {
FileLogger.log("Stop sound player: \(self._timerName(id))")
soundPlayer.stop()
}
}
fileprivate func _cleanupLiveTimers() {
self.liveTimers.removeAll()
}
static let notificationIdSeparator: String = "||"
fileprivate func _buildLiveTimers() {
let liveCountdowns = self.currentCountdowns.map {
return LiveTimer(id: $0, date: $1.end)
let liveCountdowns: [LiveTimer] = self.currentCountdowns.map { id, sequence in
let currentStep = sequence.currentStep
return LiveTimer(id: id, name: currentStep.label, date: currentStep.end)
}
// add countdown if not present
for liveCountdown in liveCountdowns {
if let index = self.liveTimers.firstIndex(where: { $0.id == liveCountdown.id }) {
@ -133,6 +108,22 @@ class Conductor: ObservableObject {
}
func removeLiveTimer(id: TimerID) {
self.liveTimers.removeAll(where: { $0.id == id })
self.cancelledCountdowns.removeAll(where: { $0 == id })
self.currentStopwatches.removeValue(forKey: id)
if let soundPlayer = self._delayedSoundPlayers[id] {
FileLogger.log("Stop sound player: \(self._timerName(id))")
soundPlayer.stop()
}
}
fileprivate func _cleanupLiveTimers() {
self.liveTimers.removeAll()
}
func isCountdownCancelled(_ countdown: Countdown) -> Bool {
return self.cancelledCountdowns.contains(where: { $0 == countdown.stringId })
}
@ -140,9 +131,9 @@ class Conductor: ObservableObject {
fileprivate func _recordActivity(countdownId: String, cancelled: Bool) {
let context = PersistenceController.shared.container.viewContext
if let countdown: Countdown = context.object(stringId: countdownId),
let dateInterval = self.currentCountdowns[countdownId] {
let sequence: CountdownSequence = self.currentCountdowns[countdownId] {
do {
try CoreDataRequests.recordActivity(timer: countdown, dateInterval: dateInterval, cancelled: cancelled)
try CoreDataRequests.recordActivity(timer: countdown, dateInterval: sequence.dateInterval, cancelled: cancelled)
} catch {
Logger.error(error)
// TODO: show error to user
@ -154,11 +145,20 @@ class Conductor: ObservableObject {
func startCountdown(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) {
self.cancelCurrentNotifications(countdownId: countdown.stringId)
let countdownId = countdown.stringId
self._cleanupPreviousTimerIfNecessary(countdownId)
do {
let end = try self._scheduleSoundPlayer(countdown: countdown, in: countdown.duration)
let totalDuration = try self._schedulePlayers(countdown: countdown)
self._scheduleCountdownNotification(countdown: countdown, in: totalDuration, handler: handler)
let end = Date(timeIntervalSinceNow: totalDuration)
self._launchLiveActivity(timer: countdown, date: end)
if Preferences.playConfirmationSound {
self._playConfirmationSound(timer: countdown)
}
@ -171,25 +171,47 @@ class Conductor: ObservableObject {
}
fileprivate func _scheduleSoundPlayer(countdown: Countdown, in interval: TimeInterval) throws -> Date {
fileprivate func _schedulePlayers(countdown: Countdown) throws -> TimeInterval {
var totalDuration: TimeInterval = 0.0
var spans: [CountdownStep] = []
let now = Date()
for i in 0..<countdown.loops {
for step in countdown.sortedSteps() {
let start = now.addingTimeInterval(totalDuration)
totalDuration += step.duration
let end = try self._scheduleSoundPlayer(countdown: countdown, step: step, in: totalDuration)
let dateInterval = DateInterval(start: start, end: end)
let span = CountdownStep(interval: dateInterval, name: step.name, index: i, loopCount: countdown.loops, stepId: step.stringId)
spans.append(span)
}
}
let sequence = CountdownSequence(steps: spans)
self.currentCountdowns[countdown.stringId] = sequence
return totalDuration
}
fileprivate let idSeparator = "=&="
fileprivate func _scheduleSoundPlayer(countdown: Countdown, step: Step, in interval: TimeInterval) throws -> Date {
let start = Date()
let end = start.addingTimeInterval(interval)
let countdownId = countdown.stringId
FileLogger.log("schedule countdown \(step.name ?? "''") at \(end)")
Logger.log("schedule countdown \(step.name ?? "''") at \(end)")
FileLogger.log("schedule countdown \(self._timerName(countdownId)) at \(end)")
let sound = step.someSound ?? countdown.someSound ?? Sound.default
let sound = countdown.someSound
let soundPlayer = try DelaySoundPlayer(timerID: countdownId, sound: sound)
self._delayedSoundPlayers[countdownId] = soundPlayer
let idComponents = [countdown.stringId, step.stringId, interval.debugDescription]
let playerId = idComponents.joined(separator: idSeparator)
let soundPlayer = try DelaySoundPlayer(sound: sound)
self._delayedSoundPlayers[playerId] = soundPlayer
try soundPlayer.start(in: interval,
repeatCount: Int(countdown.repeatCount))
let dateInterval = DateInterval(start: start, end: end)
self.currentCountdowns[countdownId] = dateInterval
self._launchLiveActivity(timer: countdown, date: end)
try soundPlayer.start(in: interval)
return end
}
@ -205,14 +227,13 @@ class Conductor: ObservableObject {
func cancelCountdown(id: TimerID) {
FileLogger.log("Cancel \(self._timerName(id))")
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id)
self.cancelCurrentNotifications(countdownId: id)
self.currentCountdowns.removeValue(forKey: id)
self.removeLiveTimer(id: id)
self.cancelSoundPlayer(id: id)
self.cancelSoundPlayers(id: id)
self._recordAndRemoveCountdown(countdownId: id, cancel: true)
self.pausedCountdowns.removeValue(forKey: id)
if Preferences.playCancellationSound {
self._playCancellationSound()
@ -225,43 +246,54 @@ class Conductor: ObservableObject {
let id = countdown.stringId
if self.cancelledCountdowns.contains(id) {
return .cancelled
} else if self.pausedCountdowns[id] != nil {
} else if self.currentCountdowns[id]?.pauseDate != nil {
return .paused
} else if let interval = self.currentCountdowns[id], interval.end > Date() {
} else if let end = self.currentCountdowns[id]?.end, end > Date() {
return .inprogress
} else {
return .finished
}
}
// func isCountdownPaused(_ countdown: Countdown) -> Bool {
// return self.pausedCountdowns[countdown.stringId] != nil
// }
func remainingPausedCountdownTime(_ countdown: Countdown) -> TimeInterval? {
return self.pausedCountdowns[countdown.stringId]
guard let sequence = self.currentCountdowns[countdown.stringId] else {
return nil
}
return sequence.remainingPausedCountdownTime()
}
func pauseCountdown(id: TimerID) {
guard let interval = self.currentCountdowns[id] else {
Logger.log("Pause countdown")
guard let sequence = self.currentCountdowns[id] else {
return
}
sequence.pauseDate = Date()
let remainingTime = interval.end.timeIntervalSince(Date())
self.pausedCountdowns[id] = remainingTime
// cancel stuff
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id)
self.cancelSoundPlayer(id: id)
self.cancelCurrentNotifications(countdownId: id)
self.cancelSoundPlayers(id: id)
self._endLiveActivity(timerId: id)
}
func resumeCountdown(id: TimerID) throws {
let context = PersistenceController.shared.container.viewContext
if let countdown: Countdown = context.object(stringId: id),
let remainingTime = self.pausedCountdowns[id] {
_ = try self._scheduleSoundPlayer(countdown: countdown, in: remainingTime)
self.pausedCountdowns.removeValue(forKey: id)
let sequence = self.currentCountdowns[countdown.stringId],
let pauseDate = sequence.pauseDate {
for countdownStep in sequence.steps {
if countdownStep.end > pauseDate, let step = countdownStep.step(context: context) {
do {
let remainingTime = countdownStep.end.timeIntervalSince(pauseDate)
let _ = try self._scheduleSoundPlayer(countdown: countdown, step: step, in: remainingTime)
} catch {
Logger.error(error)
}
}
}
sequence.resume()
} else {
throw AppError.timerNotFound(id: id)
}
@ -318,21 +350,28 @@ class Conductor: ObservableObject {
func restoreSoundPlayers() {
for (countdownId, interval) in self.currentCountdowns {
if !self._delayedSoundPlayers.contains(where: { $0.key == countdownId }) {
for (countdownId, sequence) in self.currentCountdowns {
if !self._delayedSoundPlayers.contains(where: { $0.key.starts(with: countdownId) }) {
let context = PersistenceController.shared.container.viewContext
if let countdown: Countdown = context.object(stringId: countdownId) {
do {
let sound: Sound = countdown.someSound
let soundPlayer = try DelaySoundPlayer(timerID: countdownId, sound: sound)
self._delayedSoundPlayers[countdownId] = soundPlayer
try soundPlayer.restore(for: interval.end, repeatCount: Int(countdown.repeatCount))
FileLogger.log("Restored sound player for \(self._timerName(countdownId))")
} catch {
Logger.error(error)
let now = Date()
for countdownStep in sequence.steps {
if let step = countdownStep.step(context: context) {
let remainingTime = countdownStep.interval.end.timeIntervalSince(now)
if remainingTime > 0 {
do {
let _ = try self._scheduleSoundPlayer(countdown: countdown, step: step, in: remainingTime)
} catch {
Logger.error(error)
}
}
}
}
}
}
}
@ -351,16 +390,68 @@ class Conductor: ObservableObject {
self.cleanupLiveActivities()
}
}
fileprivate func _cleanupCountdowns() {
let now = Date()
for (key, value) in self.currentCountdowns {
if value.end < now || self.cancelledCountdowns.contains(key) {
if (value.pauseDate == nil && value.end < now) || self.cancelledCountdowns.contains(key) {
self._recordAndRemoveCountdown(countdownId: key, cancel: false)
}
}
}
// MARK: - Notifications
fileprivate func _scheduleCountdownNotification(countdown: Countdown, in duration: TimeInterval, handler: @escaping (Result<Date?, Error>) -> Void) {
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("It's time!", comment: "")
let body: String
if let name = countdown.activity?.name {
let timesup = NSLocalizedString("Time's up for %@!", comment: "")
body = String(format: timesup, name)
} else {
let timesup = NSLocalizedString("Your %@ countdown is over!", comment: "")
body = String(format: timesup, duration.hourMinuteSecond)
}
content.body = body
self._createNotification(countdown: countdown, in: duration, content: content, handler: handler)
// content.sound = UNNotificationSound.criticalSoundNamed(UNNotificationSoundName(rawValue: sound), withAudioVolume: 1.0)
// content.interruptionLevel = .critical
content.relevanceScore = 1.0
}
fileprivate func _createNotification(countdown: Countdown, in duration: TimeInterval, offset: TimeInterval = 0.0, content: UNMutableNotificationContent, handler: @escaping (Result<Date?, Error>) -> Void) {
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: duration + offset, repeats: false)
let identifier: String = [countdown.objectID.uriRepresentation().absoluteString, "\(offset)"].joined(separator: Conductor.notificationIdSeparator)
let request = UNNotificationRequest(identifier: identifier,
content: content,
trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
DispatchQueue.main.async {
if let error {
handler(.failure(error))
print("Scheduling error = \(error)")
}
}
}
}
func cancelCurrentNotifications(countdownId: String) {
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
let ids = requests.map { $0.identifier }.filter { $0.hasPrefix(countdownId) }
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
}
}
// MARK: - Sound
fileprivate func _playSound(timerId: String) {
@ -417,13 +508,22 @@ class Conductor: ObservableObject {
}
}
func cancelSoundPlayer(id: TimerID) {
if let soundPlayer = self._delayedSoundPlayers[id] {
soundPlayer.stop()
self._delayedSoundPlayers.removeValue(forKey: id)
FileLogger.log("cancelled sound player for \(self._timerName(id))")
fileprivate func _soundPlayers(id: TimerID) -> [TimerID : DelaySoundPlayer] {
return self._delayedSoundPlayers.filter { (key, value) in
key.starts(with: id)
}
}
func cancelSoundPlayers(id: TimerID) {
let players = self._soundPlayers(id: id)
for (key, player) in players {
player.stop()
self._delayedSoundPlayers.removeValue(forKey: key)
}
FileLogger.log("cancelled \(players.count) sound players for \(self._timerName(id))")
self.deactivateAudioSessionIfPossible()
}
@ -451,40 +551,18 @@ class Conductor: ObservableObject {
}
}
// MARK: - Intent
// fileprivate func _createTimerIntent(_ timer: AbstractTimer) {
// let intent = LaunchTimerIntent()
//
// let invocationPhrase = String(format: NSLocalizedString("Launch %@", comment: ""), timer.displayName)
// intent.suggestedInvocationPhrase = String(format: invocationPhrase, timer.displayName)
// intent.timer = TimerIdentifier(identifier: timer.stringId, display: timer.displayName)
//
// let interaction = INInteraction(intent: intent, response: nil)
// interaction.donate()
// }
fileprivate func _scheduleAppRefresh(countdown: Countdown) {
let request = BGAppRefreshTaskRequest(identifier: BGTaskIdentifier.refresh.rawValue)
request.earliestBeginDate = Date(timeIntervalSinceNow: countdown.duration)
do {
try BGTaskScheduler.shared.submit(request)
print("request submitted with date: \(String(describing: request.earliestBeginDate))")
} catch {
Logger.error(error)
}
}
// MARK: - Live Activity
fileprivate func _launchLiveActivity(timer: AbstractTimer, date: Date) {
guard let sequence = self.currentCountdowns[timer.stringId] else { return }
if #available(iOS 16.2, *) {
if ActivityAuthorizationInfo().areActivitiesEnabled {
let contentState = LaunchWidgetAttributes.ContentState(ended: false)
let attributes = LaunchWidgetAttributes(id: timer.stringId, name: timer.displayName, date: date, isTimer: timer is Countdown)
let contentState = LaunchWidgetAttributes.ContentState(ended: false, sequence: sequence)
let attributes = LaunchWidgetAttributes(id: timer.stringId, name: timer.displayName, date: date, isCountdown: timer is Countdown)
let activityContent = ActivityContent(state: contentState, staleDate: nil)
do {

@ -8,70 +8,19 @@
import Foundation
import UserNotifications
class CountdownScheduler {
static let master = CountdownScheduler()
static let notificationIdSeparator: String = "||"
func scheduleIfPossible(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) {
DispatchQueue.main.async {
self.cancelCurrentNotifications(countdownId: countdown.stringId)
Conductor.maestro.startCountdown(countdown: countdown, handler: handler)
self._scheduleCountdownNotification(countdown: countdown, handler: handler)
}
}
fileprivate func _scheduleCountdownNotification(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) {
let content = UNMutableNotificationContent()
content.title = NSLocalizedString("It's time!", comment: "")
let duration = countdown.duration
let body: String
if let name = countdown.activity?.name {
let timesup = NSLocalizedString("Time's up for %@!", comment: "")
body = String(format: timesup, name)
} else {
let timesup = NSLocalizedString("Your %@ countdown is over!", comment: "")
body = String(format: timesup, duration.hourMinuteSecond)
}
content.body = body
self._createNotification(countdown: countdown, content: content, handler: handler)
// content.sound = UNNotificationSound.criticalSoundNamed(UNNotificationSoundName(rawValue: sound), withAudioVolume: 1.0)
// content.interruptionLevel = .critical
content.relevanceScore = 1.0
}
fileprivate func _createNotification(countdown: Countdown, offset: TimeInterval = 0.0, content: UNMutableNotificationContent, handler: @escaping (Result<Date?, Error>) -> Void) {
let duration = countdown.duration
let trigger = UNTimeIntervalNotificationTrigger(timeInterval: duration + offset, repeats: false)
let identifier: String = [countdown.objectID.uriRepresentation().absoluteString, "\(offset)"].joined(separator: CountdownScheduler.notificationIdSeparator)
let request = UNNotificationRequest(identifier: identifier,
content: content,
trigger: trigger)
UNUserNotificationCenter.current().add(request) { error in
DispatchQueue.main.async {
if let error {
handler(.failure(error))
print("Scheduling error = \(error)")
}
}
}
}
func cancelCurrentNotifications(countdownId: String) {
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
let ids = requests.map { $0.identifier }.filter { $0.hasPrefix(countdownId) }
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
}
}
}
//class CountdownScheduler {
//
// static let master = CountdownScheduler()
//
// static let notificationIdSeparator: String = "||"
//
// func scheduleIfPossible(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) {
// DispatchQueue.main.async {
// self.cancelCurrentNotifications(countdownId: countdown.stringId)
// Conductor.maestro.startCountdown(countdown: countdown, handler: handler)
// self._scheduleCountdownNotification(countdown: countdown, handler: handler)
// }
// }
//
//
//}

@ -0,0 +1,149 @@
//
// CountdownSequence.swift
// LeCountdown
//
// Created by Laurent Morvillier on 30/11/2023.
//
import Foundation
import CoreData
class CountdownSequence: Codable, Equatable, Hashable {
var steps: [CountdownStep]
var pauseDate: Date? = nil
init(steps: [CountdownStep], pauseDate: Date? = nil) {
self.steps = steps
self.pauseDate = pauseDate
}
var currentStep: CountdownStep {
let referenceDate = self.pauseDate ?? Date()
let current: CountdownStep? = self.steps.first { span in
return span.interval.start < referenceDate && span.interval.end > referenceDate
}
return current ?? self.steps.last ?? CountdownSequence.defaultSpan
}
var currentEnd: Date {
return self.currentStep.interval.end
}
var dateInterval: DateInterval {
let firstSpan = self.steps.first ?? CountdownSequence.defaultSpan
let lastSpan = self.steps.last ?? CountdownSequence.defaultSpan
return DateInterval(start: firstSpan.start, end: lastSpan.end)
}
var end: Date {
if let lastSpan = self.steps.last {
return lastSpan.end
} else {
fatalError("no spans")
}
}
func remainingPausedCountdownTime() -> TimeInterval? {
if let pauseDate = self.pauseDate,
let currentEnd = self._endOfStep(for: pauseDate) {
return currentEnd.timeIntervalSince(pauseDate)
}
return nil
}
fileprivate func _endOfStep(for date: Date) -> Date? {
let step = self.steps.first { step in
return date > step.start && date < step.end
}
return step?.end
}
func resume() {
guard let pauseDate = self.pauseDate else { return }
let pauseDuration = Date().timeIntervalSince(pauseDate)
for step in self.steps {
step.pauseAdjustedStep(duration: pauseDuration)
}
self.pauseDate = nil
}
// MARK: - Equatable / Hashable
static func == (lhs: CountdownSequence, rhs: CountdownSequence) -> Bool {
return lhs.steps == rhs.steps && lhs.pauseDate == rhs.pauseDate
}
func hash(into hasher: inout Hasher) {
hasher.combine(steps)
hasher.combine(pauseDate)
}
private static let defaultSpan = CountdownStep(interval: DateInterval(start: Date(), end: Date()), name: "none", index: 0, loopCount: 1, stepId: "")
}
class CountdownStep: Codable, Equatable, Hashable {
var interval: DateInterval
var name: String?
var index: Int16
var loopCount: Int16
var stepId: String
init(interval: DateInterval, name: String? = nil, index: Int16, loopCount: Int16, stepId: String) {
self.interval = interval
self.name = name
self.index = index
self.loopCount = loopCount
self.stepId = stepId
}
var start: Date {
return self.interval.start
}
var end: Date {
return self.interval.end
}
var label: String {
var components: [String] = []
if let name {
components.append(name)
}
if loopCount > 1 {
components.append("#\(index + 1)")
}
return components.joined(separator: " ")
}
func step(context: NSManagedObjectContext) -> Step? {
return context.object(stringId: self.stepId)
}
func pauseAdjustedStep(duration: TimeInterval) {
let start = self.interval.start.addingTimeInterval(duration)
let end = self.interval.end.addingTimeInterval(duration)
self.interval = DateInterval(start: start, end: end)
}
// MARK: - Equatable / Hashable
static func == (lhs: CountdownStep, rhs: CountdownStep) -> Bool {
return lhs.interval == rhs.interval
&& lhs.name == rhs.name
&& lhs.index == rhs.index
&& lhs.loopCount == rhs.loopCount
&& lhs.stepId == rhs.stepId
}
func hash(into hasher: inout Hasher) {
hasher.combine(interval)
hasher.combine(name)
hasher.combine(index)
hasher.combine(loopCount)
hasher.combine(stepId)
}
}

@ -8,10 +8,12 @@
</array>
<key>INAlternativeAppNames</key>
<array>
<dict>
<key>INAlternativeAppName</key>
<string>Momo</string>
</dict>
<array>
<dict>
<key>INAlternativeAppName</key>
<string>Momo</string>
</dict>
</array>
<dict>
<key>INAlternativeAppName</key>
<string>Gogo</string>

@ -16,10 +16,10 @@ import CloudKit
struct LeCountdownApp: App {
let persistenceController = PersistenceController.shared
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) var scenePhase
@State var showStartView: Bool = false
init() {
@ -42,25 +42,25 @@ struct LeCountdownApp: App {
StartView(isPresented: $showStartView)
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
.onAppear {
self._onAppear()
}
.onChange(of: scenePhase) { newPhase in
switch newPhase {
case .inactive:
Conductor.maestro.stopMainPlayersIfPossible()
Conductor.maestro.memoryWarningReceived = false
case .active:
// Logger.log("onChange(of: scenePhase) active")
// Logger.log(Conductor.maestro.currentCountdowns.count)
Conductor.maestro.restoreSoundPlayers()
Conductor.maestro.cleanup()
default:
break
.onAppear {
self._onAppear()
}
.onChange(of: scenePhase) { newPhase in
switch newPhase {
case .inactive:
Conductor.maestro.stopMainPlayersIfPossible()
Conductor.maestro.memoryWarningReceived = false
case .active:
// Logger.log("onChange(of: scenePhase) active")
// Logger.log(Conductor.maestro.currentCountdowns.count)
Conductor.maestro.restoreSoundPlayers()
Conductor.maestro.cleanup()
default:
break
}
}
}
}
}
fileprivate func _shouldShowStartView() -> Bool {
@ -78,23 +78,23 @@ struct LeCountdownApp: App {
let containerAvailable = self.isICloudContainerAvailable()
Logger.log("isICloudContainerAvailable = \(containerAvailable)")
// let voices = AVSpeechSynthesisVoice.speechVoices()
// let grouped = Dictionary(grouping: voices, by: { $0.language })
// for language in grouped.keys {
// if let lvoices = grouped[language] {
// print("language = \(language)")
// for voice in lvoices {
// print("name = \(voice.name), gender = \(voice.gender)")
// }
// print("========")
// }
// }
// let voices = AVSpeechSynthesisVoice.speechVoices()
// let grouped = Dictionary(grouping: voices, by: { $0.language })
// for language in grouped.keys {
// if let lvoices = grouped[language] {
// print("language = \(language)")
// for voice in lvoices {
// print("name = \(voice.name), gender = \(voice.gender)")
// }
// print("========")
// }
// }
}
fileprivate func _registerBackgroundRefreshes() {
BGTaskScheduler.shared.register(forTaskWithIdentifier: BGTaskIdentifier.refresh.rawValue, using: nil) { task in
self._handleAppRefresh(task: task as! BGAppRefreshTask)
self._handleAppRefresh(task: task as! BGAppRefreshTask)
}
}
@ -102,30 +102,19 @@ struct LeCountdownApp: App {
print("_handleAppRefresh = \(task.description)")
// task.expirationHandler = {
// print("expired")
// }
//
// DispatchQueue.main.async {
// Conductor.maestro.updateLiveActivities()
// task.setTaskCompleted(success: true)
// }
// task.expirationHandler = {
// print("expired")
// }
//
// DispatchQueue.main.async {
// Conductor.maestro.updateLiveActivities()
// task.setTaskCompleted(success: true)
// }
}
fileprivate func _patch() {
let context = PersistenceController.shared.container.viewContext
do {
let records = try context.fetch(Record.fetchRequest())
for record in records {
record.preCompute()
}
try context.save()
} catch {
Logger.error(error)
}
Patcher.patch()
}
func isICloudContainerAvailable() -> Bool {

@ -12,7 +12,12 @@ extension Countdown {
static func fake(context: NSManagedObjectContext) -> Countdown {
let cd = Countdown(context: context)
cd.duration = 4 * 60.0
let step = Step(context: context)
step.duration = 5.0
step.name = "Infusion"
cd.addToSteps(step)
let activity = Activity(context: context)
activity.name = "Tea"
cd.activity = activity

@ -2,7 +2,7 @@
// AbstractSoundTimer+CoreDataProperties.swift
// LeCountdown
//
// Created by Laurent Morvillier on 17/05/2023.
// Created by Laurent Morvillier on 24/11/2023.
//
//
@ -18,6 +18,5 @@ extension AbstractSoundTimer {
@NSManaged public var confirmationSoundList: String?
@NSManaged public var playableIds: String?
@NSManaged public var repeatCount: Int16
}

@ -2,7 +2,7 @@
// Countdown+CoreDataProperties.swift
// LeCountdown
//
// Created by Laurent Morvillier on 14/02/2023.
// Created by Laurent Morvillier on 30/11/2023.
//
//
@ -16,7 +16,25 @@ extension Countdown {
return NSFetchRequest<Countdown>(entityName: "Countdown")
}
@NSManaged public var loops: Int16
@NSManaged public var duration: Double
@NSManaged public var group: IntervalGroup?
@NSManaged public var steps: NSSet?
}
// MARK: Generated accessors for steps
extension Countdown {
@objc(addStepsObject:)
@NSManaged public func addToSteps(_ value: Step)
@objc(removeStepsObject:)
@NSManaged public func removeFromSteps(_ value: Step)
@objc(addSteps:)
@NSManaged public func addToSteps(_ values: NSSet)
@objc(removeSteps:)
@NSManaged public func removeFromSteps(_ values: NSSet)
}

@ -1,15 +0,0 @@
//
// Interval+CoreDataClass.swift
// LeCountdown
//
// Created by Laurent Morvillier on 14/02/2023.
//
//
import Foundation
import CoreData
@objc(Interval)
public class Interval: NSManagedObject {
}

@ -1,27 +0,0 @@
//
// Interval+CoreDataProperties.swift
// LeCountdown
//
// Created by Laurent Morvillier on 14/02/2023.
//
//
import Foundation
import CoreData
extension Interval {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Interval> {
return NSFetchRequest<Interval>(entityName: "Interval")
}
@NSManaged public var duration: Double
@NSManaged public var soundList: String?
@NSManaged public var group: IntervalGroup?
}
extension Interval : Identifiable {
}

@ -1,15 +0,0 @@
//
// IntervalGroup+CoreDataClass.swift
// LeCountdown
//
// Created by Laurent Morvillier on 14/02/2023.
//
//
import Foundation
import CoreData
@objc(IntervalGroup)
public class IntervalGroup: NSManagedObject {
}

@ -1,44 +0,0 @@
//
// IntervalGroup+CoreDataProperties.swift
// LeCountdown
//
// Created by Laurent Morvillier on 17/02/2023.
//
//
import Foundation
import CoreData
extension IntervalGroup {
@nonobjc public class func fetchRequest() -> NSFetchRequest<IntervalGroup> {
return NSFetchRequest<IntervalGroup>(entityName: "IntervalGroup")
}
@NSManaged public var repeatCount: Int16
@NSManaged public var countdown: Countdown?
@NSManaged public var intervals: NSSet?
}
// MARK: Generated accessors for intervals
extension IntervalGroup {
@objc(addIntervalsObject:)
@NSManaged public func addToIntervals(_ value: Interval)
@objc(removeIntervalsObject:)
@NSManaged public func removeFromIntervals(_ value: Interval)
@objc(addIntervals:)
@NSManaged public func addToIntervals(_ values: NSSet)
@objc(removeIntervals:)
@NSManaged public func removeFromIntervals(_ values: NSSet)
}
extension IntervalGroup : Identifiable {
}

@ -0,0 +1,15 @@
//
// Step+CoreDataClass.swift
// LeCountdown
//
// Created by Laurent Morvillier on 30/11/2023.
//
//
import Foundation
import CoreData
@objc(Step)
public class Step: NSManagedObject {
}

@ -0,0 +1,29 @@
//
// Step+CoreDataProperties.swift
// LeCountdown
//
// Created by Laurent Morvillier on 04/12/2023.
//
//
import Foundation
import CoreData
extension Step {
@nonobjc public class func fetchRequest() -> NSFetchRequest<Step> {
return NSFetchRequest<Step>(entityName: "Step")
}
@NSManaged public var duration: Double
@NSManaged public var name: String?
@NSManaged public var order: Int16
@NSManaged public var playableIds: String?
@NSManaged public var countdown: Countdown?
}
extension Step : Identifiable {
}

@ -3,6 +3,6 @@
<plist version="1.0">
<dict>
<key>_XCCurrentVersionName</key>
<string>LeCountdown.0.6.5.xcdatamodel</string>
<string>LeCountdown.0.6.6.xcdatamodel</string>
</dict>
</plist>

@ -0,0 +1,50 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22G91" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
<entity name="AbstractSoundTimer" representedClassName="AbstractSoundTimer" isAbstract="YES" parentEntity="AbstractTimer" elementID="soundList" syncable="YES">
<attribute name="confirmationSoundList" optional="YES" attributeType="String"/>
<attribute name="playableIds" optional="YES" attributeType="String"/>
</entity>
<entity name="AbstractTimer" representedClassName="AbstractTimer" isAbstract="YES" syncable="YES">
<attribute name="image" optional="YES" attributeType="String"/>
<attribute name="order" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="activity" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Activity" inverseName="timers" inverseEntity="Activity"/>
</entity>
<entity name="Activity" representedClassName="Activity" syncable="YES">
<attribute name="name" attributeType="String" defaultValueString=""/>
<relationship name="records" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Record" inverseName="activity" inverseEntity="Record"/>
<relationship name="timers" optional="YES" toMany="YES" deletionRule="Nullify" destinationEntity="AbstractTimer" inverseName="activity" inverseEntity="AbstractTimer"/>
</entity>
<entity name="Alarm" representedClassName="Alarm" parentEntity="AbstractSoundTimer" syncable="YES">
<attribute name="fireDate" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
<entity name="Countdown" representedClassName="Countdown" parentEntity="AbstractSoundTimer" syncable="YES">
<attribute name="duration" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="loops" attributeType="Integer 16" defaultValueString="1" usesScalarValueType="YES"/>
<relationship name="steps" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Step" inverseName="countdown" inverseEntity="Step"/>
</entity>
<entity name="CustomSound" representedClassName="CustomSound" syncable="YES">
<attribute name="file" optional="YES" attributeType="String"/>
<attribute name="text" optional="YES" attributeType="String"/>
</entity>
<entity name="Record" representedClassName="Record" syncable="YES">
<attribute name="cancelled" attributeType="Boolean" defaultValueString="NO" usesScalarValueType="YES"/>
<attribute name="duration" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="end" attributeType="Date" defaultDateTimeInterval="696425400" usesScalarValueType="NO"/>
<attribute name="month" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
<attribute name="start" attributeType="Date" defaultDateTimeInterval="696425400" usesScalarValueType="NO"/>
<attribute name="year" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
<relationship name="activity" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Activity" inverseName="records" inverseEntity="Activity"/>
</entity>
<entity name="Step" representedClassName="Step" syncable="YES">
<attribute name="duration" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="name" optional="YES" attributeType="String"/>
<attribute name="order" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="playableIds" optional="YES" attributeType="String"/>
<relationship name="countdown" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Countdown" inverseName="steps" inverseEntity="Countdown"/>
</entity>
<entity name="Stopwatch" representedClassName="Stopwatch" parentEntity="AbstractSoundTimer" syncable="YES">
<attribute name="end" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
<attribute name="sound" optional="YES" attributeType="Integer 16" usesScalarValueType="YES"/>
<attribute name="start" optional="YES" attributeType="Date" usesScalarValueType="NO"/>
</entity>
</model>

@ -10,6 +10,7 @@ import CoreData
struct LiveTimer: Identifiable, Comparable {
var id: String
var name: String?
var date: Date
var endDate: Date?

@ -0,0 +1,102 @@
//
// Model+CSV.swift
// LeCountdown
//
// Created by Laurent Morvillier on 29/11/2023.
//
import Foundation
import CoreData
protocol CSVField {
associatedtype T: CSVRepresentable
var header: String { get }
}
protocol CSVRepresentable: NSFetchRequestResult {
associatedtype F: CSVField
func toCSV() -> String
static var fields: [F] { get }
func value(field: F) -> String?
}
extension CSVRepresentable {
static func toCSV() -> String {
var csv: String = ""
let context: NSManagedObjectContext = PersistenceController.shared.container.viewContext
let request = NSFetchRequest<Self>(entityName: String(describing: self))
csv = self.fields.map { "\"\($0.header)\"" }.joined(separator: ",")
csv.append("\n")
do {
let entities = try context.fetch(request)
for entity in entities {
let entityCSV = entity.toCSV()
csv.append("\(entityCSV)\n")
}
} catch {
Logger.error(error)
}
return csv
}
func toCSV() -> String {
let values: [String?] = Self.fields.map { field in
self.value(field: field)
}
return values.map { "\"\($0 ?? "")\"" }.joined(separator: ",")
}
}
extension Record : CSVRepresentable {
static var fields: [RecordCSVField] {
return RecordCSVField.allCases
}
func value(field: RecordCSVField) -> String? {
switch field {
case .activity:
return self.activity?.name
case .start:
return self.start?.formattedDateTime
case .end:
return self.end?.formattedDateTime
case .cancelled:
return self.cancelled.description
}
}
}
enum RecordCSVField: CSVField, CaseIterable {
typealias T = Record
case activity
case start
case end
case cancelled
var header: String {
switch self {
case .activity: return "Activity"
case .start: return "Start"
case .end: return "End"
case .cancelled: return "Cancelled"
}
}
}

@ -9,14 +9,15 @@ import Foundation
import SwiftUI
import CoreData
extension AbstractSoundTimer {
protocol StoresSound {
var playableIds: String? { get }
var stringId: String { get }
}
extension StoresSound {
var playables: [any Playable] {
return playables(idList: self.playableIds)
}
var confirmationPlayables: [any Playable] {
return playables(idList: self.confirmationSoundList)
return self.playables(idList: self.playableIds)
}
func playables(idList: String?) -> [any Playable] {
@ -40,18 +41,7 @@ extension AbstractSoundTimer {
return self.playables.reduce(Set<Sound>()) { $0.union($1.soundList) }
}
var confirmationSounds: Set<Sound> {
if let confirmationSoundList {
return Set(confirmationSoundList.enumItems())
}
return []
}
func setConfirmationSounds(_ sounds: Set<Sound>) {
self.confirmationSoundList = sounds.stringRepresentation
}
var someSound: Sound {
var someSound: Sound? {
var sounds: Set<Sound> = self.allSounds
if !AppGuard.main.isSubscriber {
sounds = sounds.filter { !$0.isRestricted }
@ -76,8 +66,49 @@ extension AbstractSoundTimer {
return random
}
return Sound.default
return nil
}
}
extension AbstractSoundTimer: StoresSound {
// var playables: [any Playable] {
// return playables(idList: self.playableIds)
// }
var confirmationPlayables: [any Playable] {
return playables(idList: self.confirmationSoundList)
}
// func playables(idList: String?) -> [any Playable] {
// if let idList {
// var playables: [any Playable] = []
// let ids: [String] = idList.components(separatedBy: idSeparator)
// for id in ids {
// if let intId = numberFormatter.number(from: id)?.intValue,
// let sound = Sound(rawValue: intId) {
// playables.append(sound)
// } else if let playlist = Playlist(rawValue: id) {
// playables.append(playlist)
// }
// }
// return playables
// }
// return []
// }
var confirmationSounds: Set<Sound> {
if let confirmationSoundList {
return Set(confirmationSoundList.enumItems())
}
return []
}
func setConfirmationSounds(_ sounds: Set<Sound>) {
self.confirmationSoundList = sounds.stringRepresentation
}
}
@ -158,3 +189,13 @@ extension CustomSound : Localized {
}
extension Step: StoresSound {
static func fake(context: NSManagedObjectContext) -> Step {
let step = Step(context: context)
step.duration = 30.0
step.name = "Pause"
return step
}
}

@ -46,9 +46,36 @@ extension AbstractTimer {
extension Countdown {
func sortedSteps() -> [Step] {
guard let steps = self.steps as? Set<Step> else {
return []
}
return steps.sorted(using: SortDescriptor(\Step.order, order: .forward))
}
override var defaultName: String {
return NSLocalizedString("Countdown", comment: "")
}
var stepCount: Int {
return self.steps?.count ?? 0
}
var formattedDuration: String {
let durations: [String] = self.sortedSteps().map { $0.duration.hourMinuteSecond }
var formatted: String
if durations.count > 1 {
formatted = "\(durations.count) \(NSLocalizedString("Steps", comment: ""))"
} else {
formatted = durations.first ?? "none"
}
if self.loops > 1 {
return "\(loops) * \(formatted)"
} else {
return formatted
}
}
}

@ -63,7 +63,13 @@ extension NSManagedObjectContext {
}
extension NSManagedObject {
protocol ManagedObject {
var isTemporary: Bool { get }
var stringId: String { get }
static var entityName: String { get }
}
extension NSManagedObject : ManagedObject {
var isTemporary: Bool {
return self.objectID.isTemporaryID
@ -76,5 +82,5 @@ extension NSManagedObject {
static var entityName: String {
return self.entity().managedObjectClassName
}
}

@ -66,8 +66,8 @@ struct PersistenceController {
let storeURL = URL.storeURL(for: "group.com.staxriver.countdown", databaseName: "group.com.staxriver.countdown")
let storeDescription = NSPersistentStoreDescription(url: storeURL)
// storeDescription.shouldMigrateStoreAutomatically = true
// storeDescription.shouldInferMappingModelAutomatically = true
storeDescription.shouldMigrateStoreAutomatically = true
storeDescription.shouldInferMappingModelAutomatically = true
let id = "iCloud.LeCountdown"
let options = NSPersistentCloudKitContainerOptions(containerIdentifier: id)

@ -0,0 +1,62 @@
//
// Patcher.swift
// LeCountdown
//
// Created by Laurent Morvillier on 29/11/2023.
//
import Foundation
import CoreData
enum Patch: String, CaseIterable {
case interval
var key: String {
return "patch." + self.rawValue
}
}
class Patcher {
static func patch() {
let context = PersistenceController.shared.container.viewContext
for patch in Patch.allCases {
// if true {
if !UserDefaults.standard.bool(forKey: patch.key) {
Logger.log("PATCH!!!")
do {
switch patch {
case .interval:
try self._patchIntervals(context: context)
}
try context.save()
UserDefaults.standard.set(true, forKey: patch.key)
} catch {
Logger.error(error)
}
}
}
}
static fileprivate func _patchIntervals(context: NSManagedObjectContext) throws {
let countdowns: [Countdown] = try context.fetch(Countdown.fetchRequest())
for countdown in countdowns {
if let steps = countdown.steps, steps.count == 0 {
let step = Step(context: context)
step.duration = countdown.duration
step.name = countdown.name
step.order = 0
countdown.addToSteps(step)
}
}
}
}

@ -12,12 +12,12 @@ import AVFoundation
fileprivate var _player: AVAudioPlayer
fileprivate var _timerID: TimerID
// fileprivate var _playerId: String
fileprivate var _timer: Timer? = nil
init(timerID: TimerID, sound: Sound) throws {
self._timerID = timerID
init(sound: Sound) throws {
// self._playerId = playerId
let soundFile = try sound.soundFile()
@ -28,25 +28,25 @@ import AVFoundation
self._player = try AVAudioPlayer(contentsOf: url)
}
func restore(for playDate: Date, repeatCount: Int) throws {
func restore(for playDate: Date) throws {
let timeLeft = playDate.timeIntervalSinceNow
try self._play(in: timeLeft, repeatCount: repeatCount)
try self._play(in: timeLeft)
}
func start(in duration: TimeInterval, repeatCount: Int) throws {
try self._play(in: duration, repeatCount: repeatCount)
func start(in duration: TimeInterval) throws {
try self._play(in: duration)
}
fileprivate func _play(in duration: TimeInterval, repeatCount: Int) throws {
fileprivate func _play(in duration: TimeInterval) throws {
Conductor.maestro.activateAudioSession()
self._player.prepareToPlay()
self._player.volume = 1.0
self._player.delegate = self
self._player.numberOfLoops = repeatCount
// self._player.numberOfLoops = repeatCount
Logger.log("self._player.deviceCurrentTime = \(self._player.deviceCurrentTime)")
// Logger.log("self._player.deviceCurrentTime = \(self._player.deviceCurrentTime)")
let time: TimeInterval = self._player.deviceCurrentTime + duration
let result = self._player.play(atTime: time)
FileLogger.log("play \(String(describing: self._player.url)) >atTime: \(time.timeFormatted), result = \(result), isMainThread = \(Thread.isMainThread)")

@ -12,7 +12,7 @@ protocol Playable: StringRepresentable, Equatable, Hashable {
var soundList: Set<Sound> { get }
}
extension Playlist : Playable {
extension Playlist: Playable {
var stringValue: String { self.rawValue }
var soundList: Set<Sound> {
return Set(SoundCatalog.main.sounds(for: self))
@ -56,7 +56,7 @@ enum Catalog {
var playlists: [Playlist] {
switch self {
case .ring: return [.stephanBodzin, .nature, .relax]
case .ring: return [.stephanBodzin, .nature, .relax, .shorts]
case .confirmation: return [.shorts]
}
}

@ -59,7 +59,7 @@ class TimerRouter {
return
}
CountdownScheduler.master.scheduleIfPossible(countdown: countdown) { result in
Conductor.maestro.startCountdown(countdown: countdown) { result in
switch result {
case .success:
handler(.success(Void()))

@ -183,7 +183,7 @@ struct AlarmEditView: View {
// a.setSounds(self.sounds)
// a.setPlaylists(self.playlists)
a.repeatCount = self.soundRepeatCount
// a.repeatCount = self.soundRepeatCount
if !self.nameString.isEmpty {

@ -26,9 +26,10 @@ struct CountdownDialView: View, DialStyle {
Text(countdown.activity?.name?.uppercased() ?? "")
.foregroundColor(self._titleColor)
.multilineTextAlignment(.leading)
Text(countdown.duration.hourMinuteSecond)
Text(countdown.formattedDuration)
.fontWeight(.semibold)
.foregroundColor(self._durationColor)
.multilineTextAlignment(.leading)
}
Spacer()
}.font(.system(size: self.dialFontSize))

@ -16,61 +16,128 @@ enum CountdownField: Int, Hashable {
struct CountdownFormView : View {
@Environment(\.managedObjectContext) private var viewContext
@FocusState var focusedField: CountdownField?
@EnvironmentObject var model: TimerModel
var nameBinding: Binding<String>
var durationBinding: Binding<TimeInterval>
var imageBinding: Binding<CoolPic>
var repeatCountBinding: Binding<Int16>
var intervalRepeatBinding: Binding<Int>? = nil
var hasRanges: Bool
@State var showRangeSheet = false
@State var selectedStepItem: StepItem? = nil
var body: some View {
Form {
Section(header: Text("Name for tracking the activity")) {
TextField("name", text: nameBinding)
.focused($focusedField, equals: .name)
.submitLabel(.continue)
.onSubmit {
self.focusedField = nil
if self.hasRanges {
Section(header: Text("Name")) {
TextField("name", text: nameBinding)
.focused($focusedField, equals: .name)
.submitLabel(.continue)
.onSubmit {
self.focusedField = nil
}
}
Section {
ForEach(self.model.stepItems) { stepItem in
Button {
self.selectedStepItem = stepItem
} label: {
LabeledContent(stepItem.name ?? "",
value: stepItem.duration.hourMinuteSecond)
}
}.onDelete { indexSet in
self.model.deleteStep(indexSet: indexSet)
}
}
}
Section {
Button {
self._addInterval()
} label: {
HStack {
Image(systemName: "plus.circle")
Text("Add interval")
}
}
}
Section {
HStack {
Stepper("Repeat Count", value: self.$model.loops, in: 1...100)
Spacer()
Text(self.model.loops.formatted())
.frame(width: 24.0)
}
}
} else {
Section(header: Text("Name")) {
TextField("name", text: nameBinding)
.focused($focusedField, equals: .name)
.submitLabel(.continue)
.onSubmit {
self.focusedField = nil
}
}
Section {
TimePickerView(duration: self.durationBinding)
} header: {
LabeledContent("Duration", value: self.duration().hourMinuteSecond).font(.footnote)
}
Section {
TimePickerView(duration: self.durationBinding)
} header: {
LabeledContent("Duration", value: self.duration().hourMinuteSecond).font(.footnote)
}
SoundFormView(
model: self.model,
imageBinding: self.imageBinding,
repeatCountBinding: self.repeatCountBinding)
SoundFormView(model: self.model)
}
.sheet(item: self.$selectedStepItem) { item in
StepFormView(stepItem: item)
}
}
fileprivate func _addInterval() {
self.selectedStepItem = self.model.addStepItem()
}
func duration() -> TimeInterval {
return self.durationBinding.wrappedValue
}
}
struct CountdownFormView_Previews: PreviewProvider {
struct StepItemView: View {
var stepItem: StepItem
@Binding var selectedStepItem: StepItem?
@FocusState static var textFieldIsFocused: Bool
var body: some View {
Button {
self.selectedStepItem = stepItem
} label: {
LabeledContent(self.stepItem.name ?? "",
value: self.stepItem.duration.hourMinuteSecond)
}
}
}
struct CountdownFormView_Previews: PreviewProvider {
static var previews: some View {
CountdownFormView(
nameBinding: .constant(""),
durationBinding: .constant(0.0),
imageBinding: .constant(.pic3),
repeatCountBinding: .constant(2),
intervalRepeatBinding: .constant(2))
hasRanges: true)
.environmentObject(TimerModel())
}
}

@ -15,16 +15,18 @@ struct NewCountdownView : View {
@Environment(\.managedObjectContext) private var viewContext
@Binding var isPresented: Bool
var hasRanges: Bool
var userActivity: NSUserActivity
init(isPresented: Binding<Bool>) {
init(isPresented: Binding<Bool>, hasRanges: Bool) {
_isPresented = isPresented
self.hasRanges = hasRanges
self.userActivity = Shortcut.newCountdown.userActivity
}
var body: some View {
NavigationStack {
CountdownEditView(isPresented: $isPresented)
CountdownEditView(isPresented: $isPresented, hasRanges: self.hasRanges)
.environment(\.managedObjectContext, viewContext)
.onAppear {
self.userActivity.becomeCurrent()
@ -43,22 +45,22 @@ struct CountdownEditView : View {
@Environment(\.dismiss) private var dismiss
@StateObject var model: TimerModel = TimerModel()
var countdown: Countdown? = nil
var preset: Preset? = nil
@Binding var isPresented: Bool
var hasRanges: Bool
@State var nameString: String = ""
@State var duration: TimeInterval = 0.0
@State var soundRepeatCount: Int16 = 0
@State var image: CoolPic = .pic1
// @State var soundRepeatCount: Int16 = 0
@State var deleteConfirmationShown: Bool = false
@State var activityNameConfirmationShown: Bool = false
@State fileprivate var _rename: Bool? = nil
@State var errorShown: Bool = false
@State var error: Error? = nil
@ -66,17 +68,24 @@ struct CountdownEditView : View {
@State var _hasLoaded = false
@Environment(\.isPresented) var envIsPresented
@FocusState private var focusedField: CountdownField?
init(isPresented: Binding<Bool>, countdown: Countdown? = nil) {
init(isPresented: Binding<Bool>, hasRanges: Bool) {
_isPresented = isPresented
self.hasRanges = hasRanges
}
init(isPresented: Binding<Bool>, countdown: Countdown) {
_isPresented = isPresented
self.countdown = countdown
self.hasRanges = countdown.stepCount > 1
}
init(isPresented: Binding<Bool>, preset: Preset) {
_isPresented = isPresented
self.preset = preset
self.hasRanges = preset.intervalGroup.intervals.count > 1
}
var body: some View {
@ -96,8 +105,7 @@ struct CountdownEditView : View {
focusedField: _focusedField,
nameBinding: $nameString,
durationBinding: $duration,
imageBinding: $image,
repeatCountBinding: $soundRepeatCount)
hasRanges: self.hasRanges)
.environmentObject(self.model)
.toolbar {
@ -186,13 +194,21 @@ struct CountdownEditView : View {
fileprivate func _loadPreset(_ preset: Preset) {
self.nameString = preset.localizedName
self.duration = preset.duration
self.model.group = preset.intervalGroup
self.model.stepItems = preset.stepItems()
self.model.soundModel.loadPreset(preset)
}
fileprivate func _loadCountdown(_ countdown: Countdown) {
self.duration = countdown.duration
let steps: [Step] = countdown.sortedSteps()
self.model.stepItems = steps.map { $0.item }
if let range = steps.first {
self.duration = range.duration
self.nameString = range.name ?? ""
}
if let name = countdown.activity?.name, !name.isEmpty {
self.nameString = name
}
@ -200,12 +216,8 @@ struct CountdownEditView : View {
self.model.soundModel.setPlayables(countdown.playables)
self.model.confirmationSoundModel.setPlayables(countdown.confirmationPlayables)
self.soundRepeatCount = countdown.repeatCount
self.model.loops = countdown.loops
if let image = countdown.image,
let coolpic = CoolPic(rawValue: image) {
self.image = coolpic
}
}
fileprivate func _cancel() {
@ -222,8 +234,6 @@ struct CountdownEditView : View {
cd = Countdown(context: viewContext)
}
cd.duration = self.duration
if self._isNewCountdown {
let max: Int16
do {
@ -241,32 +251,33 @@ struct CountdownEditView : View {
cd.order = max
}
cd.image = self.image.rawValue
cd.playableIds = self.model.soundModel.playableIds
cd.setConfirmationSounds(self.model.confirmationSoundModel.sounds)
cd.repeatCount = self.soundRepeatCount
cd.loops = self.model.loops
for step in cd.sortedSteps() {
viewContext.delete(step)
}
if self.model.stepItems.count > 0 {
for (index, stepItem) in self.model.stepItems.enumerated() {
let step = stepItem.step(context: viewContext)
step.order = Int16(index)
step.duration = stepItem.duration
step.name = stepItem.name
cd.addToSteps(step)
}
} else {
let step = Step(context: viewContext)
step.duration = self.duration
step.name = self.nameString
cd.addToSteps(step)
}
if !self.nameString.isEmpty {
let trimmed = self.nameString.trimmingCharacters(in: .whitespacesAndNewlines)
cd.activity = CoreDataRequests.getOrCreateActivity(name: trimmed)
// if let activity = cd.activity, let currentActivityName = activity.name, trimmed != currentActivityName {
//
// switch self._rename {
// case .none:
// self.activityNameConfirmationShown = true
// return
// case .some(let rename):
// if rename {
// activity.name = trimmed
// } else {
// cd.activity = CoreDataRequests.getOrCreateActivity(name: trimmed)
// }
// }
// } else {
// cd.activity = CoreDataRequests.getOrCreateActivity(name: trimmed)
// }
}
self._saveContext()
@ -310,9 +321,21 @@ struct CountdownEditView : View {
}
fileprivate extension Step {
var item: StepItem {
return StepItem(name: self.name, duration: self.duration, playableIds: self.playableIds)
}
}
struct NewCountdownView_Previews: PreviewProvider {
static var previews: some View {
NewCountdownView(isPresented: .constant(true))
NewCountdownView(isPresented: .constant(true), hasRanges: false)
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
// NewCountdownView(isPresented: .constant(true), hasRanges: true)
// .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}

@ -0,0 +1,95 @@
//
// RangeFormView.swift
// LeCountdown
//
// Created by Laurent Morvillier on 22/11/2023.
//
import SwiftUI
struct StepFormView: View {
@Environment(\.dismiss) private var dismiss
@ObservedObject var stepItem: StepItem
@State var namePlaceholder = "name"
@State var name: String = ""
@State var duration: TimeInterval = 0.0
var soundModel: SoundModel = SoundModel()
@EnvironmentObject var model: TimerModel
var body: some View {
NavigationStack {
Form {
Section(header: Text("Name")) {
TextField(self.namePlaceholder, text: self.$name)
}
Section {
TimePickerView(duration: self.$duration)
} header: {
LabeledContent("Duration", value: self.duration.hourMinuteSecond)
.font(.footnote)
}
Section {
SoundLightLinkView(soundModel: self.soundModel,
catalog: .ring,
title: NSLocalizedString("Sound", comment: "") )
}
Section {
Button {
self._doneHandler()
} label: {
HStack {
Spacer()
Text("Done").fontWeight(.bold)
Spacer()
}
}
}
}
}.onAppear {
if let name = self.stepItem.name, !name.isEmpty {
self.namePlaceholder = name
}
self.duration = self.stepItem.duration
self.soundModel.setPlayables(self.stepItem.playables)
}
}
fileprivate func _doneHandler() {
if self.name.isEmpty {
self.stepItem.name = self.namePlaceholder
} else {
self.stepItem.name = self.name
}
self.stepItem.duration = self.duration
self.stepItem.playableIds = self.soundModel.playableIds
self.model.objectWillChange.send()
self.dismiss()
}
}
struct RangeFormView_Previews: PreviewProvider {
static var previews: some View {
Text("tilting crash")
// RangeFormView(
// timeRange: TimeRange.fake(context: PersistenceController.preview.container.viewContext),
// isPresented: .constant(true))
// .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}

@ -67,10 +67,10 @@ struct DialView: View {
}
.background(self._dialBackgroundColor)
.frame(width: frameSize, height: self._height)
.frame(width: self.frameSize, height: self._height)
.cornerRadius(20.0)
}
fileprivate var _height: CGFloat {
return UIDevice.isPhoneIdiom ? 80.0 : 200.0
}
@ -94,13 +94,15 @@ struct DialView: View {
Group {
switch self.timer {
case let countdown as Countdown:
CountdownDialView(countdown: countdown, isEditing: self.isEditingBinding.wrappedValue)
CountdownDialView(countdown: countdown,
isEditing: self.isEditingBinding.wrappedValue)
.environmentObject(Conductor.maestro)
case let alarm as Alarm:
AlarmDialView(alarm: alarm)
.environmentObject(Conductor.maestro)
case let stopwatch as Stopwatch:
StopwatchDialView(stopwatch: stopwatch, isEditing: self.isEditingBinding.wrappedValue)
StopwatchDialView(stopwatch: stopwatch,
isEditing: self.isEditingBinding.wrappedValue)
.environmentObject(Conductor.maestro)
default:
Text("missing dial view")

@ -38,7 +38,10 @@ struct LiveTimerListView: View {
VStack {
ForEach(conductor.liveTimers) { liveTimer in
if let timer: AbstractTimer = liveTimer.timer(context: self.viewContext) {
LiveTimerView(timer: timer, date: liveTimer.date, endDate: liveTimer.endDate)
LiveTimerView(timer: timer,
date: liveTimer.date,
endDate: liveTimer.endDate,
name: liveTimer.name)
}
}
@ -71,12 +74,15 @@ struct LiveTimerView: View {
var timer: AbstractTimer
var date: Date
var endDate: Date?
var name: String?
var body: some View {
switch self.timer {
case let cd as Countdown:
LiveCountdownView(countdown: cd, date: self.date)
LiveCountdownView(countdown: cd,
date: self.date,
name: self.name)
case let sw as Stopwatch:
LiveStopwatchView(stopwatch: sw, date: self.date, endDate: self.endDate)
default:
@ -166,6 +172,7 @@ struct LiveCountdownView: View {
@State var countdown: Countdown
var date: Date
var name: String?
@State var showCancelConfirmationPopup: Bool = false
@ -176,15 +183,10 @@ struct LiveCountdownView: View {
TimelineView(.periodic(from: self.date, by: 0.01)) { context in
let state = Conductor.maestro.countdownState(self.countdown)
// let remainingTime: TimeInterval? = Conductor.maestro.remainingPausedCountdownTime(self.countdown)
// let cancelled = Conductor.maestro.isCountdownCancelled(self.countdown)
let state: CountdownState = Conductor.maestro.countdownState(self.countdown)
HStack {
// let running = self.date > context.date
VStack(alignment: .leading) {
switch state {
@ -200,17 +202,8 @@ struct LiveCountdownView: View {
TimeView(text: NSLocalizedString("Cancelled", comment: ""))
}
// if cancelled {
// TimeView(text: NSLocalizedString("Cancelled", comment: ""))
// } else if let remainingTime {
// TimeView(text: remainingTime.hourMinuteSecond)
// } else if running {
// TimeView(text: self._formattedDuration(date: context.date))
// } else {
// TimeView(text: self.date.formatted(date: .omitted, time: .shortened))
// }
Text(self.countdown.displayName.uppercased())
let name = self._nameForState(state: state)
Text(name.uppercased())
.foregroundColor(self.colorScheme == .dark ? .white : .black)
}
@ -237,25 +230,6 @@ struct LiveCountdownView: View {
EmptyView()
}
// if !cancelled && (self.date > context.date && remainingTime != nil) { // pause / resume
// if remainingTime != nil {
// Button {
// self._resume()
// } label: {
// Image(systemName: "play.circle")
// .foregroundColor(.accentColor)
// }
// } else {
// Button {
// self._pause()
// } label: {
// Image(systemName: "pause.circle")
// .foregroundColor(.accentColor)
// }
// }
// }
switch state {
case .inprogress, .paused:
Button {
@ -269,19 +243,6 @@ struct LiveCountdownView: View {
case .cancelled:
Image(systemName: "xmark.circle").foregroundColor(.accentColor)
}
// if cancelled { // Cancelled image
// Image(systemName: "xmark.circle").foregroundColor(.accentColor)
// } else if !running && remainingTime == nil { // Ended
// GreenCheckmarkView()
// } else { // Cancel button
// Button {
// self.showCancelConfirmationPopup = true
// } label: {
// Image(systemName: "xmark.circle.fill")
// .foregroundColor(.accentColor)
// }
// }
}.font(.system(size: actionButtonFontSize))
}
@ -343,6 +304,15 @@ struct LiveCountdownView: View {
}
}
fileprivate func _nameForState(state: CountdownState) -> String {
switch state {
case .finished:
return self.countdown.displayName
default:
return self.name ?? self.countdown.displayName
}
}
fileprivate func _formattedDuration(date: Date) -> String {
let duration = self.date.timeIntervalSince(date)
return duration.hourMinuteSecond

@ -23,45 +23,45 @@ enum DataTab: Int, Identifiable, CaseIterable {
}
struct NewDataView: View {
@Environment(\.managedObjectContext) private var viewContext
@Binding var isPresented: Bool
@State var selection: Int = 0
var body: some View {
NavigationStack {
VStack {
Picker("", selection: $selection) {
ForEach(DataTab.allCases) { tab in
Text(tab.localizedString)
}
}
.pickerStyle(.segmented)
.padding(.horizontal)
TabView(selection: $selection) {
NewCountdownView(isPresented: $isPresented)
.tag(0)
.environment(\.managedObjectContext, viewContext)
NewStopwatchView(isPresented: $isPresented)
.tag(1)
.environment(\.managedObjectContext, viewContext)
}.tabViewStyle(.page(indexDisplayMode: .never))
}
}
}
}
//struct NewDataView: View {
//
// @Environment(\.managedObjectContext) private var viewContext
//
// @Binding var isPresented: Bool
//
// @State var selection: Int = 0
//
// var body: some View {
//
// NavigationStack {
//
// VStack {
//
// Picker("", selection: $selection) {
// ForEach(DataTab.allCases) { tab in
// Text(tab.localizedString)
// }
// }
// .pickerStyle(.segmented)
// .padding(.horizontal)
//
// TabView(selection: $selection) {
// NewCountdownView(isPresented: $isPresented)
// .tag(0)
// .environment(\.managedObjectContext, viewContext)
// NewStopwatchView(isPresented: $isPresented)
// .tag(1)
// .environment(\.managedObjectContext, viewContext)
// }.tabViewStyle(.page(indexDisplayMode: .never))
// }
// }
//
// }
//}
struct NewDataView_Previews: PreviewProvider {
static var previews: some View {
NewDataView(isPresented: .constant(true))
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
//struct NewDataView_Previews: PreviewProvider {
// static var previews: some View {
// NewDataView(isPresented: .constant(true))
// .environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
// }
//}

@ -6,6 +6,7 @@
//
import SwiftUI
import CoreData
class PresetModel : ObservableObject {
@ -137,7 +138,7 @@ struct PresetsView: View {
.environment(\.managedObjectContext, viewContext)
})
.sheet(isPresented: $isShowingNewCountdown, content: {
NewCountdownView(isPresented: $isShowingNewCountdown)
NewCountdownView(isPresented: $isShowingNewCountdown, hasRanges: false)
.environment(\.managedObjectContext, viewContext)
})
.sheet(isPresented: $isPresented, content: {
@ -235,6 +236,7 @@ struct CountdownIntervalGroup {
}
struct CountdownInterval {
var name: String?
var duration: TimeInterval
var sound: Sound?
}
@ -246,7 +248,7 @@ enum Preset: Int, Identifiable, CaseIterable {
case meditation
case nap
case workout
// case runningSplits
case runningSplits
case pasta
case rice
case blackTea
@ -268,7 +270,7 @@ enum Preset: Int, Identifiable, CaseIterable {
case .mediumBoiledEggs: return NSLocalizedString("Medium boiled eggs", comment: "")
case .meditation: return NSLocalizedString("Meditation", comment: "")
case .nap: return NSLocalizedString("Nap", comment: "")
// case .runningSplits: return NSLocalizedString("Running splits", comment: "")
case .runningSplits: return NSLocalizedString("Running splits", comment: "")
case .toothbrushing: return NSLocalizedString("Tooth brushing", comment: "")
case .blackTea: return NSLocalizedString("Black tea", comment: "")
case .greenTea: return NSLocalizedString("Green tea", comment: "")
@ -284,14 +286,18 @@ enum Preset: Int, Identifiable, CaseIterable {
}
var intervalGroup: CountdownIntervalGroup {
// switch self {
// case .runningSplits:
// let runInterval = CountdownInterval(duration: 30.0, sound: Sound.sbArpeggio_Loop_River)
// let breakInterval = CountdownInterval(duration: 30.0, sound: Sound.sbLoop_ToneSD_Boavista)
// return CountdownIntervalGroup(repeatCount: 8, intervals: [runInterval, breakInterval])
// default:
return CountdownIntervalGroup(repeatCount: 0, intervals: [CountdownInterval(duration: self.duration)])
// }
return CountdownIntervalGroup(repeatCount: 0, intervals: [CountdownInterval(duration: self.duration)])
}
func stepItems() -> [StepItem] {
switch self {
case .runningSplits:
return []
default:
let step = StepItem(name: self.localizedName)
step.duration = self.duration
return [step]
}
}
var duration: TimeInterval {
@ -301,7 +307,7 @@ enum Preset: Int, Identifiable, CaseIterable {
case .hardBoiledEggs: return 10 * 60
case .meditation: return 15 * 60
case .nap: return 20 * 60
// case .runningSplits: return 0.0
case .runningSplits: return 0.0
case .toothbrushing: return 2 * 60.0
case .greenTea: return 3 * 60.0
case .blackTea: return 4 * 60.0
@ -318,7 +324,7 @@ enum Preset: Int, Identifiable, CaseIterable {
var playlist: Playlist {
switch self {
case .softBoiled, .mediumBoiledEggs, .hardBoiledEggs, .pasta, .rice, .toothbrushing, .workout, .stretching, .work:
case .softBoiled, .mediumBoiledEggs, .hardBoiledEggs, .pasta, .rice, .toothbrushing, .workout, .stretching, .work, .runningSplits:
return .stephanBodzin
case .meditation, .blackTea, .greenTea, .writing, .reading:
return .relax
@ -327,11 +333,8 @@ enum Preset: Int, Identifiable, CaseIterable {
}
}
// var sounds: Set<Sound> {
// return Set(SoundCatalog.main.sounds(for: self.playlist))
// }
var formattedDuration: String {
let group = self.intervalGroup
let count = group.repeatCount.formatted()
let durations = group.intervals.map { $0.duration.hourMinuteSecond }

@ -46,6 +46,11 @@ struct MailView: UIViewControllerRepresentable {
if let logsData = content.data(using: .utf8) {
vc.addAttachmentData(logsData, mimeType: "text/plain", fileName: "logs.txt")
}
if let recordCSV = Record.toCSV().data(using: .utf8) {
vc.addAttachmentData(recordCSV, mimeType: "text/csv", fileName: "records.csv")
}
return vc
}

@ -11,12 +11,10 @@ struct SoundFormView : View {
var model: TimerModel
var imageBinding: Binding<CoolPic>
var repeatCountBinding: Binding<Int16>? = nil
// var repeatCountBinding: Binding<Int16>? = nil
var optionalSound: Binding<Bool>? = nil
@State var imageSelectionSheetShown: Bool = false
// @State var imageSelectionSheetShown: Bool = false
var body: some View {
@ -37,24 +35,15 @@ struct SoundFormView : View {
catalog: .ring,
title: NSLocalizedString("Sound", comment: "") )
if self.repeatCountBinding != nil {
Picker("Repeat Count", selection: self.repeatCountBinding!) {
ForEach(0..<6) {
let count = Int16($0)
Text("\(count)").tag(count)
}
}
}
SoundLinkView(soundModel: self.model.confirmationSoundModel,
catalog: .confirmation,
title: NSLocalizedString("Start Sound", comment: ""))
}.sheet(isPresented: self.$imageSelectionSheetShown) {
ImageSelectionView(showBinding: self.$imageSelectionSheetShown,
imageBinding: self.imageBinding)
}
// .sheet(isPresented: self.$imageSelectionSheetShown) {
// ImageSelectionView(showBinding: self.$imageSelectionSheetShown,
// imageBinding: self.imageBinding)
// }
}
}
@ -78,13 +67,28 @@ struct SoundLinkView: View {
}
struct SoundLightLinkView: View {
@StateObject var soundModel: SoundModel
var catalog: Catalog
var title: String
var body: some View {
NavigationLink {
PlaylistsView(model: self.soundModel,
catalog: self.catalog)
} label: {
LabeledContent(self.title, value: self.soundModel.soundSelection())
}
}
}
struct SoundImageFormView_Previews: PreviewProvider {
static var previews: some View {
Form {
SoundFormView(model: TimerModel(),
imageBinding: .constant(.pic1),
repeatCountBinding: .constant(2))
SoundFormView(model: TimerModel())
}
}
}

@ -15,13 +15,14 @@ struct StartView: View {
@Binding var isPresented: Bool
@State var showTimerScreen: Bool = false
@State var showMultiTimerScreen: Bool = false
@State var showStopwatchScreen: Bool = false
var body: some View {
VStack(spacing: 0.5) {
Text("Select some of the predefined timers and customize them, or create your own")
.font(.callout)
.fontWeight(.medium)
.frame(maxWidth: .infinity)
.padding()
.background(Color.accentColor)
@ -29,61 +30,52 @@ struct StartView: View {
PresetSelectionView(model: self.model).monospaced()
HStack(spacing: 4.0) {
VStack(spacing: 4.0) {
Button {
self.showTimerScreen = true
} label: {
HStack {
Image(systemName: "timer")//.font(.title)
Text("Create your own timer")
}
.multilineTextAlignment(.leading)
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(.white)
.background(Color.accentColor)
.cornerRadius(12.0)
ImageButton(stringKey: "Create your own timer", systemImage: "timer")
}.sheet(isPresented: self.$showTimerScreen) {
NewCountdownView(isPresented: $showTimerScreen)
NewCountdownView(isPresented: $showTimerScreen, hasRanges: false)
.environment(\.managedObjectContext, viewContext)
}
Button {
self.showMultiTimerScreen = true
} label: {
ImageButton(stringKey: "Create an advanced timer", detailsStringKey: "Steps & repeat", systemImage: "crown")
}
.sheet(isPresented: self.$showMultiTimerScreen) {
NewCountdownView(isPresented: $showMultiTimerScreen, hasRanges: true)
.environment(\.managedObjectContext, viewContext)
}
Button {
self.showStopwatchScreen = true
} label: {
HStack {
Image(systemName: "stopwatch")//.font(.title)
Text("Create your own stopwatch")
}
.multilineTextAlignment(.leading)
.padding()
.frame(maxWidth: .infinity, maxHeight: .infinity)
.foregroundColor(.white)
.background(Color.accentColor)
.cornerRadius(12.0)
ImageButton(stringKey: "Create your own stopwatch", systemImage: "stopwatch")
}.sheet(isPresented: self.$showStopwatchScreen) {
NewStopwatchView(isPresented: $showStopwatchScreen)
.environment(\.managedObjectContext, viewContext)
}
Button {
self._done()
} label: {
Text("Done")
.font(.title2).fontWeight(.semibold)
.padding()
.frame(maxWidth: .infinity)
.foregroundColor(.white)
.background(Color.accentColor)
.cornerRadius(12.0)
// .padding(.horizontal, 4.0)
}
}
.font(.footnote)
.frame(height: 80.0)
// .font(.footnote)
.padding(4.0)
Button {
self._done()
} label: {
Text("Done")
.font(.title2).fontWeight(.semibold)
.padding()
.frame(maxWidth: .infinity)
.foregroundColor(.white)
.background(Color.accentColor)
.cornerRadius(12.0)
.padding(.horizontal, 4.0)
}
}
}
@ -108,6 +100,34 @@ struct StartView: View {
}
struct ImageButton: View {
var stringKey: LocalizedStringKey
var detailsStringKey: LocalizedStringKey?
var systemImage: String
var body: some View {
HStack(spacing: 10.0) {
Image(systemName: self.systemImage)
.fontWeight(.medium)
.frame(width: 30.0)
VStack(alignment: .leading) {
Text(self.stringKey)
.fontWeight(.bold)
if let detailsStringKey {
Text(detailsStringKey).font(.footnote)
}
}
Spacer()
}
.padding()
.foregroundColor(.white)
.background(Color.accentColor)
.cornerRadius(12.0)
}
}
class Customization: ObservableObject {
var preset: Preset
@ -131,7 +151,12 @@ class Customization: ObservableObject {
let context = PersistenceController.shared.container.viewContext
let countdown = Countdown(context: context)
countdown.activity = CoreDataRequests.getOrCreateActivity(name: preset.localizedName)
countdown.duration = self.duration
for stepItem in preset.stepItems() {
stepItem.duration = self.duration
countdown.addToSteps(stepItem.step(context: context))
}
countdown.playableIds = self.timerModel.soundModel.playableIds
return countdown
}

@ -45,8 +45,6 @@ struct StopwatchEditView: View {
@State var playSound: Bool = false
@State var image: CoolPic = .pic1
@State var deleteConfirmationShown: Bool = false
@State var activityNameConfirmationShown: Bool = false
@State fileprivate var _rename: Bool? = nil
@ -77,7 +75,6 @@ struct StopwatchEditView: View {
}
}
StopwatchFormView(nameBinding: self.$nameString,
imageBinding: self.$image,
playSoundBinding: self.$playSound).environmentObject(self.model)
.onAppear {
self._onAppear()
@ -149,11 +146,7 @@ struct StopwatchEditView: View {
self.model.soundModel.setPlayables(stopwatch.playables)
self.model.confirmationSoundModel.setPlayables(stopwatch.confirmationPlayables)
if let image = stopwatch.image, let coolpic = CoolPic(rawValue: image) {
self.image = coolpic
}
self._hasLoaded = true
}
@ -185,7 +178,6 @@ struct StopwatchEditView: View {
sw.playableIds = self.model.soundModel.playableIds
sw.confirmationSoundList = self.model.confirmationSoundModel.playableIds
sw.image = self.image.rawValue
if !self.nameString.isEmpty {

@ -16,7 +16,6 @@ struct StopwatchFormView: View {
@FocusState private var focusedField: StopwatchField?
var nameBinding: Binding<String>
var imageBinding: Binding<CoolPic>
var playSoundBinding: Binding<Bool>
@EnvironmentObject var model: TimerModel
@ -34,7 +33,6 @@ struct StopwatchFormView: View {
}
SoundFormView(model: self.model,
imageBinding: imageBinding,
optionalSound: playSoundBinding)
}.toolbar {
@ -57,7 +55,6 @@ struct StopwatchFormView_Previews: PreviewProvider {
static var previews: some View {
StopwatchFormView(
nameBinding: .constant(""),
imageBinding: .constant(.pic1),
playSoundBinding: .constant(true))
.environmentObject(TimerModel())
}

@ -8,20 +8,58 @@
import Foundation
import SwiftUI
import Combine
import CoreData
protocol SoundHolder {
func selectSound(_ sound: Sound, selected: Bool)
func selectPlaylist(_ playlist: Playlist, selected: Bool)
}
class StepItem: Identifiable, ObservableObject, StoresSound {
let id: String = UUID().uuidString
var name: String? = nil
var duration: TimeInterval = 0.0
var playableIds: String? = nil
init(name: String? = nil, duration: TimeInterval = 0.0, playableIds: String? = nil) {
self.name = name
self.duration = duration
self.playableIds = playableIds
}
func step(context: NSManagedObjectContext) -> Step {
let step = Step(context: context)
step.name = self.name
step.duration = self.duration
step.playableIds = self.playableIds
return step
}
var stringId: String { return self.id }
}
class TimerModel: ObservableObject {
@Published var soundModel: SoundModel = SoundModel()
@Published var confirmationSoundModel: SoundModel = SoundModel()
@Published var group: CountdownIntervalGroup =
CountdownIntervalGroup(repeatCount: 0, intervals: [])
@Published var stepItems: [StepItem] = []
@Published var loops: Int16 = 1
func addStepItem() -> StepItem {
let index: String = (self.stepItems.count + 1).formatted()
let name = NSLocalizedString("Step", comment: "") + " " + index
let step = StepItem(name: name)
self.stepItems.append(step)
return step
}
func deleteStep(indexSet: IndexSet) {
self.stepItems.remove(atOffsets: indexSet)
}
}
class SoundModel: ObservableObject, SoundHolder {

@ -34,7 +34,7 @@ struct TimersView: View {
GeometryReader { reader in
let columns: [GridItem] = self._columns()
let width: CGFloat = reader.size.width / CGFloat(columns.count) - 5.0
let width: CGFloat = max(reader.size.width / CGFloat(columns.count) - 5.0, 0.0)
ScrollView {

@ -12,12 +12,13 @@ struct LaunchWidgetAttributes: ActivityAttributes {
public struct ContentState: Codable, Hashable {
// Dynamic stateful properties about your activity go here!
var ended: Bool
var sequence: CountdownSequence? = nil
}
// Fixed non-changing properties about your activity go here!
var id: String
var name: String
var date: Date
var isTimer: Bool
var isCountdown: Bool
}

@ -3,3 +3,8 @@
"Widget Tip" = "Quickly launch your timers with widgets. You can add them by modifying your home or lock screen.";
"Play confirmation sound" = "Play sound on start";
"Create your first timer or stopwatch!" = "Create your first timer or stopwatch!";
"Step" = "Step";
"Steps" = "steps";
"Create an advanced timer" = "Create an advanced timer";
"Steps & repeat" = "Steps & repeat";
"Add interval" = "Add step";

@ -161,7 +161,7 @@
"Rename" = "Renommer";
/* No comment provided by engineer. */
"Repeat Count" = "Nombre de répétitions";
"Repeat Count" = "Nombre de boucles";
/* No comment provided by engineer. */
"Save" = "Sauvegarder";
@ -292,3 +292,8 @@
"tap to copy email" = "Tapez pour copier l'email";
"Write a review!" = "Écrivez un avis !";
"Adjust volume on launch" = "Ajuster le volume au lancement";
"Step" = "Étape";
"Steps" = "étapes";
"Create an advanced timer" = "Créer un minuteur avancé";
"Steps & repeat" = "Intervalles et répétitions";
"Add interval" = "Ajouter une étape";

Loading…
Cancel
Save