Compare commits

...

14 Commits
main ... splits

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

@ -8,33 +8,6 @@
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
@ -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 {
let start = Date()
let end = start.addingTimeInterval(interval)
fileprivate func _schedulePlayers(countdown: Countdown) throws -> TimeInterval {
let countdownId = countdown.stringId
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
}
FileLogger.log("schedule countdown \(self._timerName(countdownId)) at \(end)")
fileprivate let idSeparator = "=&="
let sound = countdown.someSound
let soundPlayer = try DelaySoundPlayer(timerID: countdownId, sound: sound)
self._delayedSoundPlayers[countdownId] = soundPlayer
fileprivate func _scheduleSoundPlayer(countdown: Countdown, step: Step, in interval: TimeInterval) throws -> Date {
let start = Date()
let end = start.addingTimeInterval(interval)
FileLogger.log("schedule countdown \(step.name ?? "''") at \(end)")
Logger.log("schedule countdown \(step.name ?? "''") at \(end)")
try soundPlayer.start(in: interval,
repeatCount: Int(countdown.repeatCount))
let sound = step.someSound ?? countdown.someSound ?? Sound.default
let dateInterval = DateInterval(start: start, end: end)
self.currentCountdowns[countdownId] = dateInterval
let idComponents = [countdown.stringId, step.stringId, interval.debugDescription]
let playerId = idComponents.joined(separator: idSeparator)
let soundPlayer = try DelaySoundPlayer(sound: sound)
self._delayedSoundPlayers[playerId] = soundPlayer
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
}
let remainingTime = interval.end.timeIntervalSince(Date())
self.pausedCountdowns[id] = remainingTime
sequence.pauseDate = Date()
// 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)
}
}
}
}
}
}
}
@ -355,12 +394,64 @@ class Conductor: ObservableObject {
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)
}
}

@ -42,23 +42,23 @@ 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
}
}
}
}
}
@ -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 }
}
var playables: [any Playable] {
return playables(idList: self.playableIds)
}
extension StoresSound {
var confirmationPlayables: [any Playable] {
return playables(idList: self.confirmationSoundList)
var playables: [any Playable] {
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,9 +66,50 @@ 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
}
}
extension Stopwatch {
@ -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,10 +46,37 @@ 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
}
}
}
extension Stopwatch {

@ -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

@ -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,42 +16,97 @@ 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 hasRanges: Bool
@State var showRangeSheet = false
var intervalRepeatBinding: Binding<Int>? = nil
@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 {
@ -60,17 +115,29 @@ struct CountdownFormView : View {
}
struct CountdownFormView_Previews: PreviewProvider {
struct StepItemView: View {
var stepItem: StepItem
@Binding var selectedStepItem: StepItem?
var body: some View {
Button {
self.selectedStepItem = stepItem
} label: {
LabeledContent(self.stepItem.name ?? "",
value: self.stepItem.duration.hourMinuteSecond)
}
}
@FocusState static var textFieldIsFocused: Bool
}
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()
@ -48,12 +50,12 @@ struct CountdownEditView : View {
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
@ -69,14 +71,21 @@ struct CountdownEditView : View {
@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
if !self.nameString.isEmpty {
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,7 +67,7 @@ struct DialView: View {
}
.background(self._dialBackgroundColor)
.frame(width: frameSize, height: self._height)
.frame(width: self.frameSize, height: self._height)
.cornerRadius(20.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_Previews: PreviewProvider {
static var previews: some View {
NewDataView(isPresented: .constant(true))
.environment(\.managedObjectContext, PersistenceController.preview.container.viewContext)
}
}
//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)
// }
//}

@ -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)
}
}
.font(.footnote)
.frame(height: 80.0)
.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)
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)
.padding(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()
@ -150,10 +147,6 @@ 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,19 +8,57 @@
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)
}
}

@ -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