Compare commits

..

No commits in common. 'main' and 'release' have entirely different histories.

  1. 86
      CLAUDE.md
  2. 58
      LaunchIntents/IntentHandler.swift
  3. 4
      LaunchWidget/Base.lproj/LaunchWidget.intentdefinition
  4. 7
      LaunchWidget/DefaultView.swift
  5. 8
      LaunchWidget/LaunchWidget.swift
  6. 64
      LaunchWidget/LaunchWidgetLiveActivity.swift
  7. 78
      LaunchWidget/SingleTimerView.swift
  8. 2
      LaunchWidget/fr.lproj/LaunchWidget.strings
  9. 572
      LeCountdown.xcodeproj/project.pbxproj
  10. 113
      LeCountdown.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
  11. 2
      LeCountdown.xcodeproj/xcshareddata/xcschemes/LaunchIntents.xcscheme
  12. 2
      LeCountdown.xcodeproj/xcshareddata/xcschemes/LaunchWidgetExtension.xcscheme
  13. 16
      LeCountdown.xcodeproj/xcshareddata/xcschemes/LeCountdown.xcscheme
  14. 79
      LeCountdown/AppDelegate.swift
  15. BIN
      LeCountdown/Assets.xcassets/AppIcon.appiconset/icon.png
  16. 214
      LeCountdown/Base.lproj/SiriIntents.intentdefinition
  17. 412
      LeCountdown/Conductor.swift
  18. 40
      LeCountdown/CountdownScheduler.swift
  19. 34
      LeCountdown/GoogleService-Info.plist
  20. 17
      LeCountdown/Info.plist
  21. 70
      LeCountdown/Intent/StartTimerIntent.swift
  22. 46
      LeCountdown/Intent/TimerIdentifierAppEntity.swift
  23. 25
      LeCountdown/Intent/TimerShortcuts.swift
  24. 102
      LeCountdown/LeCountdownApp.swift
  25. 80
      LeCountdown/Model/CoreDataRequests.swift
  26. 5
      LeCountdown/Model/Generation/AbstractSoundTimer+CoreDataProperties.swift
  27. 4
      LeCountdown/Model/Generation/AbstractTimer+CoreDataProperties.swift
  28. 5
      LeCountdown/Model/Generation/Record+CoreDataProperties.swift
  29. 5
      LeCountdown/Model/Generation/Stopwatch+CoreDataClass.swift
  30. 2
      LeCountdown/Model/Generation/Stopwatch+CoreDataProperties.swift
  31. 2
      LeCountdown/Model/LeCountdown.xcdatamodeld/.xccurrentversion
  32. 52
      LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.6.3.xcdatamodel/contents
  33. 52
      LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.6.4.xcdatamodel/contents
  34. 53
      LeCountdown/Model/LeCountdown.xcdatamodeld/LeCountdown.0.6.5.xcdatamodel/contents
  35. 38
      LeCountdown/Model/LiveStopWatch.swift
  36. 4
      LeCountdown/Model/LiveTimer.swift
  37. 88
      LeCountdown/Model/Model+Extensions.swift
  38. 4
      LeCountdown/Model/Model+SharedExtensions.swift
  39. 33
      LeCountdown/Model/NSManagedContext+Extensions.swift
  40. 22
      LeCountdown/Model/Persistence.swift
  41. 85
      LeCountdown/Sound/DelaySoundPlayer.swift
  42. 173
      LeCountdown/Sound/Sound.swift
  43. 87
      LeCountdown/Sound/SoundPlayer.swift
  44. 82
      LeCountdown/Sound/StatePlayer.swift
  45. BIN
      LeCountdown/Sound_Assets/Nature/QP01 0011 Rain soft.wav
  46. BIN
      LeCountdown/Sound_Assets/Nature/QP01 0017 Stream sparkling.wav
  47. BIN
      LeCountdown/Sound_Assets/Nature/QP01 0018 Stream moderate.wav
  48. BIN
      LeCountdown/Sound_Assets/Nature/QP01 0023 Surf moderate sandy.wav
  49. BIN
      LeCountdown/Sound_Assets/Nature/QP01 0028 Insect crickets isolated.wav
  50. BIN
      LeCountdown/Sound_Assets/Nature/QP01 0037 Tropical forest morning.wav
  51. BIN
      LeCountdown/Sound_Assets/Nature/QP01 0075 Ocean shore waves delicate birds.wav
  52. BIN
      LeCountdown/Sound_Assets/Nature/QP01 0096 Wetland lake early morning.wav
  53. BIN
      LeCountdown/Sound_Assets/Nature/QP01 0118 Riparian Zone thrush.wav
  54. BIN
      LeCountdown/Sound_Assets/Nature/QP01 0130 Desert morning bird chorus.wav
  55. BIN
      LeCountdown/Sound_Assets/Relax/EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm.wav
  56. BIN
      LeCountdown/Sound_Assets/Relax/EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am.wav
  57. BIN
      LeCountdown/Sound_Assets/Relax/EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am.wav
  58. BIN
      LeCountdown/Sound_Assets/Relax/EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab.wav
  59. BIN
      LeCountdown/Sound_Assets/Relax/EX_ATSM_Bell_Binaural_Flam_Eb.wav
  60. BIN
      LeCountdown/Sound_Assets/Relax/EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm.wav
  61. BIN
      LeCountdown/Sound_Assets/Relax/FF_SH_bowl_drone_tap_hold_E.wav
  62. BIN
      LeCountdown/Sound_Assets/Relax/FF_SH_bowl_drone_tapping_C.wav
  63. BIN
      LeCountdown/Sound_Assets/Relax/FF_SH_flute_melody_ambient_stacked_profound_Dmin.wav
  64. BIN
      LeCountdown/Sound_Assets/Relax/trancoso_bowl1.mp3
  65. BIN
      LeCountdown/Sound_Assets/Shorts/ESM_Ambient_Game_Menu_Soft_Wood_Confirm_1_Notification_Button_Settings_UI.wav
  66. BIN
      LeCountdown/Sound_Assets/Shorts/ESM_One_Shot_FX_Interface_Glitch_Spaceship_Console_18_Interface_Button_Alert_System_Cm.wav
  67. BIN
      LeCountdown/Sound_Assets/Shorts/MRKRSTPHR_synth_one_shot_bleep_G.wav
  68. BIN
      LeCountdown/Sound_Assets/Shorts/PVP_Stab_Oneshot_Bleep_Em.wav
  69. BIN
      LeCountdown/Sound_Assets/Stephan_Bodzin/Arpeggio_Loop_River.wav
  70. BIN
      LeCountdown/Sound_Assets/Stephan_Bodzin/Clave_Loop_LLL.wav
  71. BIN
      LeCountdown/Sound_Assets/Stephan_Bodzin/HighChords_Loop_River.wav
  72. BIN
      LeCountdown/Sound_Assets/Stephan_Bodzin/SquareArp_Loop_River.wav
  73. BIN
      LeCountdown/Sound_Assets/Stephan_Bodzin/rose1.mp3
  74. BIN
      LeCountdown/Sound_Assets/forest_stream.mp3
  75. BIN
      LeCountdown/Sound_Assets/train_horn.mp3
  76. 13
      LeCountdown/Stats/Context+Calculations.swift
  77. 6
      LeCountdown/Stats/Filter.swift
  78. 20
      LeCountdown/Stats/Stat.swift
  79. 54
      LeCountdown/Subscription/AppGuard.swift
  80. 102
      LeCountdown/Subscription/Store.swift
  81. 207
      LeCountdown/Subscription/StoreView.swift
  82. 39
      LeCountdown/Subscription/SubscriptionButtonView.swift
  83. 36
      LeCountdown/TimerRouter.swift
  84. 39
      LeCountdown/Utils/AppError.swift
  85. 37
      LeCountdown/Utils/AppleMusicPlayer.swift
  86. 31
      LeCountdown/Utils/BoringContext.swift
  87. 60
      LeCountdown/Utils/Codable+Extensions.swift
  88. 22
      LeCountdown/Utils/Date+Extensions.swift
  89. 57
      LeCountdown/Utils/Extensions.swift
  90. 83
      LeCountdown/Utils/FileLogger.swift
  91. 54
      LeCountdown/Utils/FileUtils.swift
  92. 2
      LeCountdown/Utils/Logger.swift
  93. 24
      LeCountdown/Utils/Preferences.swift
  94. 2
      LeCountdown/Utils/Shortcut.swift
  95. 5
      LeCountdown/Utils/TextToSpeechRecorder.swift
  96. 18
      LeCountdown/Utils/TimeInterval+Extensions.swift
  97. 14
      LeCountdown/Utils/Tip.swift
  98. 11
      LeCountdown/Utils/UIDevice+Extensions.swift
  99. 13
      LeCountdown/Utils/URLs.swift
  100. 288
      LeCountdown/Views/ContentView.swift
  101. Some files were not shown because too many files have changed in this diff Show More

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

@ -7,34 +7,34 @@
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)
// }
class IntentHandler: INExtension, SelectTimerIntentHandling, LaunchTimerIntentHandling {
// 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 +58,7 @@ class IntentHandler: INExtension, SelectTimerIntentHandling {
let displayName: String
switch timer {
case let countdown as Countdown:
let formattedDuration = countdown.duration.hourMinuteSecond
let formattedDuration = countdown.duration.minuteSecond
if let name = timer.activity?.name, !name.isEmpty {
displayName = "\(name) (\(formattedDuration))"
} else {

@ -11,9 +11,9 @@
<key>INIntentDefinitionSystemVersion</key>
<string>22A400</string>
<key>INIntentDefinitionToolsBuildVersion</key>
<string>14E222b</string>
<string>14C18</string>
<key>INIntentDefinitionToolsVersion</key>
<string>14.3</string>
<string>14.2</string>
<key>INIntents</key>
<array>
<dict>

@ -14,7 +14,6 @@ struct DefaultView: View {
var body: some View {
Group {
switch family {
case .accessoryCorner, .accessoryCircular, .accessoryRectangular, .accessoryInline:
VStack {
@ -26,13 +25,7 @@ struct DefaultView: View {
Text("Tea".uppercased()).monospaced()
Text("4:00").monospaced()
}
// .frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(0.5))
.foregroundColor(.white)
// .background(Color.white.opacity(0.5))
}

@ -81,14 +81,13 @@ struct LaunchWidgetEntryView : View {
var entry: Provider.Entry
let backgroundOpacity = 0.3
@ViewBuilder
var body: some View {
switch family {
case .systemSmall, .accessoryInline:
if let timer = entry.timers.first {
CountdownSimpleWidgetView(timer: timer)
.background(Image(timer.imageName))
} else {
DefaultView()
}
@ -101,8 +100,7 @@ struct LaunchWidgetEntryView : View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white.opacity(backgroundOpacity))
.cornerRadius(16.0)
.background(Color.white.opacity(0.1))
case .accessoryRectangular:
Group {
if let timer = entry.timers.first {
@ -112,7 +110,7 @@ struct LaunchWidgetEntryView : View {
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white.opacity(backgroundOpacity))
.background(Color.white.opacity(0.1))
.cornerRadius(16.0)
default:
MultiCountdownView(timers: entry.timers)

@ -16,9 +16,9 @@ struct LiveActivityView: View {
var body: some View {
HStack {
Text(self.name)
Text(name)
Spacer()
Text(self.endDate, style: .timer)
Text(endDate, style: .timer)
.monospaced()
}.padding()
.font(.title)
@ -32,27 +32,33 @@ struct LaunchWidgetLiveActivity: Widget {
var body: some WidgetConfiguration {
ActivityConfiguration(for: LaunchWidgetAttributes.self) { context in
// Lock screen/banner UI goes here
VStack(alignment: .leading) {
// let range = Date()...context.attributes.date
if context.attributes.isTimer {
let range = Date()...context.attributes.date
Text(timerInterval: range,
pauseTime: range.lowerBound)
.font(.title)
} else {
// Lock screen/banner UI goes here
HStack {
Text(context.attributes.name.uppercased())
Spacer()
Text(context.attributes.date, style: .timer)
.font(.title)
}
Text(context.attributes.name.uppercased())
.font(.callout)
}
.padding()
// if Date() < context.attributes.date {
// Text(context.attributes.date, style: .timer)
// } else {
// GreenCheckmarkView()
// }
// if context.attributes.endDate > self.model.now {
// Text(context.attributes.endDate, style: .timer)
// .monospaced()
// } else {
// Text("It's time!")
// }
}.padding()
.monospaced()
.background(Color(white: 0.1))
.foregroundColor(.white)
.activityBackgroundTint(Color(white: 0.2))
.activitySystemActionForegroundColor(.white)
} dynamicIsland: { context in
DynamicIsland {
// Expanded UI goes here. Compose the expanded UI through
@ -73,29 +79,13 @@ struct LaunchWidgetLiveActivity: Widget {
}
}
} compactLeading: {
Text(context.attributes.name.uppercased())
Text("L")
} compactTrailing: {
Group {
if context.attributes.isTimer {
let range = Date()...context.attributes.date
Text(timerInterval: range,
pauseTime: range.lowerBound)
} else {
Text(context.attributes.date, style: .timer)
}
}.multilineTextAlignment(.trailing)
Text("T")
} minimal: {
if context.attributes.isTimer {
let range = Date()...context.attributes.date
Text(timerInterval: range,
pauseTime: range.lowerBound)
.font(Font.system(size: 11.0))
} else {
Text(context.attributes.date, style: .timer)
.font(Font.system(size: 11.0))
}
Text("Min")
}
// .widgetURL(URL(string: context.attributes.id))
.widgetURL(URL(string: context.attributes.id))
.keylineTint(Color.red)
}
}
@ -110,7 +100,7 @@ struct LaunchWidgetLiveActivity_Previews: PreviewProvider {
static let attributes = LaunchWidgetAttributes(
id: "",
name: "Tea",
date: Date().addingTimeInterval(3600.0), isTimer: true)
date: Date().addingTimeInterval(3600.0))
static let contentState = LaunchWidgetAttributes.ContentState(ended: false)

@ -9,43 +9,6 @@ import SwiftUI
import WidgetKit
import CoreData
struct GradientView: View {
private static let backgroundGradientColors: [Color] = [.red, .purple]
var body: some View {
ZStack {
GeometryReader { reader in
let gradient: Gradient = Gradient(colors: GradientView.backgroundGradientColors)
RadialGradient(gradient: gradient,
center: .init(x: 0.0, y: 0.0),
startRadius: 0, endRadius: reader.size.width)
}
}
}
}
extension View {
var linearGradient: LinearGradient {
LinearGradient(colors: [Color(red: 255, green: 128, blue: 223), Color(red: 255, green: 180, blue: 78)],
startPoint: .topLeading, endPoint: .bottomTrailing)
}
var radialGradient: some View {
RadialGradient(colors: [Color(red: 255, green: 128, blue: 223),
Color(red: 255, green: 86, blue: 61),
Color(red: 255, green: 180, blue: 78)],
center: .bottomLeading,
startRadius: Angle.degrees(0).degrees,
endRadius: Angle.degrees(90).degrees)
}
}
struct SingleTimerView: View {
@Environment(\.widgetFamily) var family: WidgetFamily
@ -58,7 +21,7 @@ struct SingleTimerView: View {
VStack(alignment: .leading) {
Text(timer.displayName.uppercased())
if let countdown = timer as? Countdown {
Text(countdown.duration.hourMinuteSecond)
Text(countdown.duration.minuteSecond)
}
}
Spacer()
@ -67,9 +30,7 @@ struct SingleTimerView: View {
}
.padding()
.monospaced()
.foregroundColor(.white)
.background(GradientView())
// .background(.white.opacity(0.5))
.foregroundColor(Color.white)
.font(self.font)
.widgetURL(timer.url)
}
@ -95,18 +56,12 @@ struct LockScreenCountdownView: View {
var body: some View {
VStack {
let title = self.timer.displayName.uppercased()
switch self.family {
case .accessoryCircular:
Text(title)
default:
Text(title)
Text(activityName.uppercased())
if let countdown = self.timer as? Countdown {
Text(countdown.duration.hourMinuteSecond)
Text(countdown.duration.minuteSecond)
.monospaced()
}
}
}
.multilineTextAlignment(.center)
.foregroundColor(Color.white)
.font(self.font)
@ -129,9 +84,7 @@ struct LockScreenCountdownView: View {
}
private var font: Font {
return .system(size: 14.0, weight: .medium)
// return .body
return .body
// switch self.family {
// case .systemSmall, .systemMedium, .systemLarge, .systemExtraLarge:
// return .body
@ -173,31 +126,24 @@ struct MultiCountdownView: View {
VStack(alignment: .leading) {
Spacer()
Text(timer.displayName.uppercased()).lineLimit(1)
Text(timer.displayName.uppercased())
if let countdown = timer as? Countdown {
Text(countdown.duration.hourMinuteSecond)
Text(countdown.duration.minuteSecond)
}
Spacer()
}
Spacer()
}
.padding(.horizontal, 12.0)
.padding(.vertical, 4.0)
.font(.footnote)
.background(Color.white.opacity(0.1))
.padding(.horizontal)
.font(.callout)
.background(Image(timer.imageName))
.foregroundColor(.white)
.monospaced()
.cornerRadius(16.0)
}
}
}
.padding(.horizontal, 12.0)
.padding(.vertical, 16.0)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(GradientView())
// .background(.white.opacity(0.5))
}.padding()
}
}
@ -217,9 +163,9 @@ struct CountdownView_Previews: PreviewProvider {
static var previews: some View {
SingleTimerView(timer: Countdown.fake(context: PersistenceController.preview.container.viewContext)).previewContext(WidgetPreviewContext(family: .systemSmall)).background(.black)
MultiCountdownView(timers: self.countdowns(context: PersistenceController.preview.container.viewContext)).previewContext(WidgetPreviewContext(family: .systemMedium))
LockScreenCountdownView(timer: Countdown.fake(context: PersistenceController.preview.container.viewContext)).previewContext(WidgetPreviewContext(family: .accessoryRectangular))
LockScreenCountdownView(timer: Countdown.fake(context: PersistenceController.preview.container.viewContext)).previewContext(WidgetPreviewContext(family: .accessoryCircular))
MultiCountdownView(timers: self.countdowns(context: PersistenceController.preview.container.viewContext)).previewContext(WidgetPreviewContext(family: .systemMedium))
}
static func countdowns(context: NSManagedObjectContext) -> [Countdown] {

@ -1,2 +0,0 @@
"SelectTimer" = "Choisissez un minuteur";

File diff suppressed because it is too large Load Diff

@ -1,113 +0,0 @@
{
"pins" : [
{
"identity" : "abseil-cpp-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/abseil-cpp-binary.git",
"state" : {
"revision" : "bfc0b6f81adc06ce5121eb23f628473638d67c5c",
"version" : "1.2022062300.0"
}
},
{
"identity" : "firebase-ios-sdk",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/firebase-ios-sdk",
"state" : {
"revision" : "8a8ec57a272e0d31480fb0893dda0cf4f769b57e",
"version" : "10.15.0"
}
},
{
"identity" : "googleappmeasurement",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleAppMeasurement.git",
"state" : {
"revision" : "03b9beee1a61f62d32c521e172e192a1663a5e8b",
"version" : "10.13.0"
}
},
{
"identity" : "googledatatransport",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleDataTransport.git",
"state" : {
"revision" : "f6b558e3f801f2cac336b04f615ce111fa9ddaa0",
"version" : "9.2.1"
}
},
{
"identity" : "googleutilities",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/GoogleUtilities.git",
"state" : {
"revision" : "0543562f85620b5b7c510c6bcbef75b562a5127b",
"version" : "7.11.0"
}
},
{
"identity" : "grpc-binary",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/grpc-binary.git",
"state" : {
"revision" : "f1b366129d1125be7db83247e003fc333104b569",
"version" : "1.50.2"
}
},
{
"identity" : "gtm-session-fetcher",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/gtm-session-fetcher.git",
"state" : {
"revision" : "5ccda3981422a84186387dbb763ba739178b529c",
"version" : "2.3.0"
}
},
{
"identity" : "interop-ios-for-google-sdks",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/interop-ios-for-google-sdks.git",
"state" : {
"revision" : "2d12673670417654f08f5f90fdd62926dc3a2648",
"version" : "100.0.0"
}
},
{
"identity" : "leveldb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/leveldb.git",
"state" : {
"revision" : "0706abcc6b0bd9cedfbb015ba840e4a780b5159b",
"version" : "1.22.2"
}
},
{
"identity" : "nanopb",
"kind" : "remoteSourceControl",
"location" : "https://github.com/firebase/nanopb.git",
"state" : {
"revision" : "819d0a2173aff699fb8c364b6fb906f7cdb1a692",
"version" : "2.30909.0"
}
},
{
"identity" : "promises",
"kind" : "remoteSourceControl",
"location" : "https://github.com/google/promises.git",
"state" : {
"revision" : "ec957ccddbcc710ccc64c9dcbd4c7006fcf8b73a",
"version" : "2.2.0"
}
},
{
"identity" : "swift-protobuf",
"kind" : "remoteSourceControl",
"location" : "https://github.com/apple/swift-protobuf.git",
"state" : {
"revision" : "0af9125c4eae12a4973fb66574c53a54962a9e1e",
"version" : "1.21.0"
}
}
],
"version" : 2
}

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
LastUpgradeVersion = "1420"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
LastUpgradeVersion = "1420"
wasCreatedForAppExtension = "YES"
version = "2.0">
<BuildAction

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
LastUpgradeVersion = "1500"
LastUpgradeVersion = "1420"
version = "1.3">
<BuildAction
parallelizeBuildables = "YES"
@ -50,20 +50,6 @@
ReferencedContainer = "container:LeCountdown.xcodeproj">
</BuildableReference>
</BuildableProductRunnable>
<CommandLineArguments>
<CommandLineArgument
argument = "-com.apple.CoreData.Logging.stderr 0"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = "-com.apple.CoreData.CloudKitDebug 0"
isEnabled = "YES">
</CommandLineArgument>
<CommandLineArgument
argument = " -com.apple.CoreData.ConcurrencyDebug 1"
isEnabled = "YES">
</CommandLineArgument>
</CommandLineArguments>
</LaunchAction>
<ProfileAction
buildConfiguration = "Release"

@ -7,84 +7,26 @@
import Foundation
import UIKit
import AVFoundation
import Firebase
class AppDelegate : NSObject, UIApplicationDelegate {
override init() {
super.init()
NotificationCenter.default.addObserver(self, selector: #selector(_contextDidChange), name: NSNotification.Name.NSManagedObjectContextDidSave, object: nil)
}
func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey : Any]? = nil) -> Bool {
FirebaseApp.configure()
UNUserNotificationCenter.current().delegate = self
self._initSchemaIfNeeded()
Conductor.maestro.activateAudioSession()
Sound.computeSoundDurationsIfNecessary()
Conductor.maestro.cleanup()
if Preferences.installDate == nil {
Preferences.installDate = Date()
}
return true
}
func applicationWillEnterForeground(_ application: UIApplication) {
// self._activateAudioSession()
}
func applicationWillTerminate(_ application: UIApplication) {
Logger.log("applicationWillTerminate")
FileLogger.log("applicationWillTerminate")
Conductor.removeLiveActivities()
// Conductor.maestro.removeLiveActivities()
}
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
Conductor.maestro.memoryWarningReceived = true
Logger.log("applicationDidReceiveMemoryWarning")
FileLogger.log("applicationDidReceiveMemoryWarning")
}
fileprivate func _initSchemaIfNeeded() {
if !Preferences.cloudKitSchemaInitialized {
do {
try PersistenceController.shared.container.initializeCloudKitSchema()
Preferences.cloudKitSchemaInitialized = true
} catch {
print("initializeCloudKitSchema error: \(error)")
}
}
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
if userActivity.interaction == nil {
Logger.log("restorationHandler called! interaction is nil")
return false
}
return true
}
@objc fileprivate func _contextDidChange(_ notification: Notification) {
TimerShortcuts.updateAppShortcutParameters()
}
deinit {
NotificationCenter.default.removeObserver(self)
}
}
extension AppDelegate: UNUserNotificationCenterDelegate {
@ -92,28 +34,27 @@ extension AppDelegate: UNUserNotificationCenterDelegate {
func userNotificationCenter(_ center: UNUserNotificationCenter, didReceive response: UNNotificationResponse) async {
print("didReceive notification")
FileLogger.log("userNotificationCenter didReceive > cancelling sound player")
if let timerId = self._timerId(notificationId: response.notification.request.identifier) {
Conductor.maestro.cancelSoundPlayer(id: timerId)
}
let timerId = self._timerId(notificationId: response.notification.request.identifier)
Conductor.maestro.cancelCountdown(id: timerId)
}
func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
print("willPresent notification")
// completionHandler([.sound])
// let timerId = self._timerId(notificationId: notification.request.identifier)
// Conductor.maestro.notifyUser(countdownId: timerId)
let timerId = self._timerId(notificationId: notification.request.identifier)
Conductor.maestro.notifyUser(countdownId: timerId)
}
fileprivate func _timerId(notificationId: String) -> TimerID? {
fileprivate func _timerId(notificationId: String) -> String {
let components = notificationId.components(separatedBy: CountdownScheduler.notificationIdSeparator)
if components.count == 2 {
return components[0]
} else {
FileLogger.log("Couldn't parse notification Id: \(notificationId)")
return nil
fatalError("bad notification format : \(notificationId)")
}
}
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 834 KiB

After

Width:  |  Height:  |  Size: 16 KiB

@ -0,0 +1,214 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>INEnums</key>
<array/>
<key>INIntentDefinitionModelVersion</key>
<string>1.2</string>
<key>INIntentDefinitionNamespace</key>
<string>ggxqDx</string>
<key>INIntentDefinitionSystemVersion</key>
<string>22A400</string>
<key>INIntentDefinitionToolsBuildVersion</key>
<string>14C18</string>
<key>INIntentDefinitionToolsVersion</key>
<string>14.2</string>
<key>INIntents</key>
<array>
<dict>
<key>INIntentCategory</key>
<string>start</string>
<key>INIntentDescription</key>
<string>Launch timers and stopwatches</string>
<key>INIntentDescriptionID</key>
<string>NdKydA</string>
<key>INIntentLastParameterTag</key>
<integer>2</integer>
<key>INIntentName</key>
<string>LaunchTimer</string>
<key>INIntentParameterCombinations</key>
<dict>
<key>timer</key>
<dict>
<key>INIntentParameterCombinationIsPrimary</key>
<true/>
<key>INIntentParameterCombinationSubtitle</key>
<string>Starts immediately</string>
<key>INIntentParameterCombinationSubtitleID</key>
<string>r4Ikzx</string>
<key>INIntentParameterCombinationSupportsBackgroundExecution</key>
<true/>
<key>INIntentParameterCombinationTitle</key>
<string>Launch ${timer}</string>
<key>INIntentParameterCombinationTitleID</key>
<string>JfqtH6</string>
</dict>
</dict>
<key>INIntentParameters</key>
<array>
<dict>
<key>INIntentParameterConfigurable</key>
<true/>
<key>INIntentParameterDisplayName</key>
<string>Timer</string>
<key>INIntentParameterDisplayNameID</key>
<string>wU1mYs</string>
<key>INIntentParameterDisplayPriority</key>
<integer>1</integer>
<key>INIntentParameterName</key>
<string>timer</string>
<key>INIntentParameterObjectType</key>
<string>TimerIdentifier</string>
<key>INIntentParameterObjectTypeNamespace</key>
<string>ggxqDx</string>
<key>INIntentParameterPromptDialogs</key>
<array>
<dict>
<key>INIntentParameterPromptDialogCustom</key>
<true/>
<key>INIntentParameterPromptDialogType</key>
<string>Configuration</string>
</dict>
<dict>
<key>INIntentParameterPromptDialogCustom</key>
<true/>
<key>INIntentParameterPromptDialogFormatString</key>
<string>This is the mandatory prompt for your ${timer}</string>
<key>INIntentParameterPromptDialogFormatStringID</key>
<string>6ZcaR8</string>
<key>INIntentParameterPromptDialogType</key>
<string>Primary</string>
</dict>
</array>
<key>INIntentParameterSupportsResolution</key>
<true/>
<key>INIntentParameterTag</key>
<integer>2</integer>
<key>INIntentParameterType</key>
<string>Object</string>
</dict>
</array>
<key>INIntentResponse</key>
<dict>
<key>INIntentResponseCodes</key>
<array>
<dict>
<key>INIntentResponseCodeFormatString</key>
<string>${timer} has been launched</string>
<key>INIntentResponseCodeFormatStringID</key>
<string>E3Sz5n</string>
<key>INIntentResponseCodeName</key>
<string>success</string>
<key>INIntentResponseCodeSuccess</key>
<true/>
</dict>
<dict>
<key>INIntentResponseCodeName</key>
<string>failure</string>
</dict>
</array>
<key>INIntentResponseLastParameterTag</key>
<integer>2</integer>
<key>INIntentResponseOutput</key>
<string>timer</string>
<key>INIntentResponseParameters</key>
<array>
<dict>
<key>INIntentResponseParameterDisplayName</key>
<string>Timer</string>
<key>INIntentResponseParameterDisplayNameID</key>
<string>Vfpf1t</string>
<key>INIntentResponseParameterDisplayPriority</key>
<integer>1</integer>
<key>INIntentResponseParameterName</key>
<string>timer</string>
<key>INIntentResponseParameterObjectType</key>
<string>TimerIdentifier</string>
<key>INIntentResponseParameterObjectTypeNamespace</key>
<string>ggxqDx</string>
<key>INIntentResponseParameterTag</key>
<integer>2</integer>
<key>INIntentResponseParameterType</key>
<string>Object</string>
</dict>
</array>
</dict>
<key>INIntentTitle</key>
<string>Launch Timer</string>
<key>INIntentTitleID</key>
<string>nrTIGB</string>
<key>INIntentType</key>
<string>Custom</string>
<key>INIntentVerb</key>
<string>Start</string>
</dict>
</array>
<key>INTypes</key>
<array>
<dict>
<key>INTypeDisplayName</key>
<string>Timer Identifier</string>
<key>INTypeDisplayNameID</key>
<string>02RXTq</string>
<key>INTypeLastPropertyTag</key>
<integer>99</integer>
<key>INTypeName</key>
<string>TimerIdentifier</string>
<key>INTypeProperties</key>
<array>
<dict>
<key>INTypePropertyDefault</key>
<true/>
<key>INTypePropertyDisplayPriority</key>
<integer>1</integer>
<key>INTypePropertyName</key>
<string>identifier</string>
<key>INTypePropertyTag</key>
<integer>1</integer>
<key>INTypePropertyType</key>
<string>String</string>
</dict>
<dict>
<key>INTypePropertyDefault</key>
<true/>
<key>INTypePropertyDisplayPriority</key>
<integer>2</integer>
<key>INTypePropertyName</key>
<string>displayString</string>
<key>INTypePropertyTag</key>
<integer>2</integer>
<key>INTypePropertyType</key>
<string>String</string>
</dict>
<dict>
<key>INTypePropertyDefault</key>
<true/>
<key>INTypePropertyDisplayPriority</key>
<integer>3</integer>
<key>INTypePropertyName</key>
<string>pronunciationHint</string>
<key>INTypePropertyTag</key>
<integer>3</integer>
<key>INTypePropertyType</key>
<string>String</string>
</dict>
<dict>
<key>INTypePropertyDefault</key>
<true/>
<key>INTypePropertyDisplayPriority</key>
<integer>4</integer>
<key>INTypePropertyName</key>
<string>alternativeSpeakableMatches</string>
<key>INTypePropertySupportsMultipleValues</key>
<true/>
<key>INTypePropertyTag</key>
<integer>4</integer>
<key>INTypePropertyType</key>
<string>SpeakableString</string>
</dict>
</array>
</dict>
</array>
</dict>
</plist>

@ -11,50 +11,25 @@ import SwiftUI
import Intents
import AudioToolbox
import ActivityKit
import AVFoundation
enum BGTaskIdentifier : String {
case refresh = "com.staxriver.lecountdown.refresh"
}
fileprivate enum Const: String {
case confirmationSound = "ESM_Ambient_Game_Menu_Soft_Wood_Confirm_1_Notification_Button_Settings_UI.wav"
case cancellationSound = "MRKRSTPHR_synth_one_shot_bleep_G.wav"
}
enum CountdownState {
case inprogress
case paused
case finished
case cancelled
}
class Conductor: ObservableObject {
static let maestro: Conductor = Conductor()
@ObservedObject var soundPlayer: SoundPlayer = SoundPlayer()
fileprivate var _delayedSoundPlayers: [TimerID : DelaySoundPlayer] = [:]
fileprivate var beats: Timer? = nil
@Published var soundPlayer: SoundPlayer? = nil
@UserDefault(PreferenceKey.countdowns.rawValue, defaultValue: [:]) static var savedCountdowns: [String : DateInterval]
@UserDefault(PreferenceKey.pausedCountdowns.rawValue, defaultValue: [:]) static var savedPausedCountdowns: [String : TimeInterval]
@UserDefault(PreferenceKey.stopwatches.rawValue, defaultValue: [:]) static var savedStopwatches: [String : LiveStopWatch]
@UserDefault(PreferenceKey.stopwatches.rawValue, defaultValue: [:]) static var savedStopwatches: [String : Date]
@Published private (set) var liveTimers: [LiveTimer] = []
@Published var memoryWarningReceived: Bool = false
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._cleanupCountdowns()
})
}
@Published var cancelledCountdowns: [String] = []
@ -62,23 +37,14 @@ class Conductor: ObservableObject {
@Published var currentCountdowns: [String : DateInterval] = [:] {
didSet {
Conductor.savedCountdowns = currentCountdowns
// Logger.log("**** currentCountdowns didSet, count = \(currentCountdowns.count)")
Logger.log("**** currentCountdowns didSet, count = \(currentCountdowns.count)")
withAnimation {
self._buildLiveTimers()
}
}
}
@Published var pausedCountdowns: [String : TimeInterval] = [:] {
didSet {
Conductor.savedPausedCountdowns = pausedCountdowns
withAnimation {
self._buildLiveTimers()
}
}
}
@Published var currentStopwatches: [String : LiveStopWatch] = [:] {
@Published var currentStopwatches: [String : Date] = [:] {
didSet {
Conductor.savedStopwatches = currentStopwatches
withAnimation {
@ -87,24 +53,15 @@ class Conductor: ObservableObject {
}
}
func removeLiveTimer(id: TimerID) {
// Logger.log("removeLiveTimer")
func removeLiveTimer(id: String) {
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()
}
fileprivate func _buildLiveTimers() {
Logger.log("_buildLiveTimers")
let liveCountdowns = self.currentCountdowns.map {
return LiveTimer(id: $0, date: $1.end)
}
@ -119,8 +76,8 @@ class Conductor: ObservableObject {
}
}
let liveStopwatches: [LiveTimer] = self.currentStopwatches.map {
return LiveTimer(id: $0, date: $1.start, endDate: $1.end)
let liveStopwatches = self.currentStopwatches.map {
return LiveTimer(id: $0, date: $1)
}
for liveStopwatch in liveStopwatches {
if let index = self.liveTimers.firstIndex(where: { $0.id == liveStopwatch.id }) {
@ -133,16 +90,17 @@ class Conductor: ObservableObject {
}
func isCountdownCancelled(_ countdown: Countdown) -> Bool {
return self.cancelledCountdowns.contains(where: { $0 == countdown.stringId })
func notifyUser(countdownId: String) {
self._playSound(timerId: countdownId)
self._endCountdown(countdownId: countdownId, cancel: false)
}
fileprivate func _recordActivity(countdownId: String, cancelled: Bool) {
fileprivate func _recordActivity(countdownId: String) {
let context = PersistenceController.shared.container.viewContext
if let countdown: Countdown = context.object(stringId: countdownId),
if let countdown = context.object(stringId: countdownId) as? Countdown,
let dateInterval = self.currentCountdowns[countdownId] {
do {
try CoreDataRequests.recordActivity(timer: countdown, dateInterval: dateInterval, cancelled: cancelled)
try CoreDataRequests.recordActivity(timer: countdown, dateInterval: dateInterval)
} catch {
Logger.error(error)
// TODO: show error to user
@ -152,211 +110,81 @@ class Conductor: ObservableObject {
// MARK: - Countdown
func startCountdown(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) {
let countdownId = countdown.stringId
self._cleanupPreviousTimerIfNecessary(countdownId)
do {
let end = try self._scheduleSoundPlayer(countdown: countdown, in: countdown.duration)
if Preferences.playConfirmationSound {
self._playConfirmationSound(timer: countdown)
}
handler(.success(end))
} catch {
FileLogger.log("start error : \(error.localizedDescription)")
Logger.error(error)
handler(.failure(error))
}
}
fileprivate func _scheduleSoundPlayer(countdown: Countdown, in interval: TimeInterval) throws -> Date {
let start = Date()
let end = start.addingTimeInterval(interval)
let countdownId = countdown.stringId
FileLogger.log("schedule countdown \(self._timerName(countdownId)) at \(end)")
let sound = countdown.someSound
let soundPlayer = try DelaySoundPlayer(timerID: countdownId, sound: sound)
self._delayedSoundPlayers[countdownId] = soundPlayer
try soundPlayer.start(in: interval,
repeatCount: Int(countdown.repeatCount))
func startCountdown(_ date: Date, countdown: Countdown) {
// DispatchQueue.main.async {
let dateInterval = DateInterval(start: start, end: end)
self.currentCountdowns[countdownId] = dateInterval
Logger.log("Starts countdown: \(countdown.displayName)")
self._launchLiveActivity(timer: countdown, date: end)
// cleanup existing countdowns
self.removeLiveTimer(id: countdown.stringId)
return end
}
// self._cleanupTimers.removeValue(forKey: countdown.stringId)
fileprivate func _cleanupPreviousTimerIfNecessary(_ timerId: TimerID) {
self.removeLiveTimer(id: timerId)
if let player = self._delayedSoundPlayers[timerId] {
player.stop() // release resources
}
self._endLiveActivity(timerId: timerId)
}
let dateInterval = DateInterval(start: Date(), end: date)
self.currentCountdowns[countdown.stringId] = dateInterval
func cancelCountdown(id: TimerID) {
FileLogger.log("Cancel \(self._timerName(id))")
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id)
self.currentCountdowns.removeValue(forKey: id)
// self._launchLiveActivity(countdown: countdown, endDate: date)
self.removeLiveTimer(id: id)
self.cancelSoundPlayer(id: id)
self._createTimerIntent(countdown)
self._recordAndRemoveCountdown(countdownId: id, cancel: true)
self.pausedCountdowns.removeValue(forKey: id)
if Preferences.playCancellationSound {
self._playCancellationSound()
}
self._endLiveActivity(timerId: id)
}
Logger.log("countdowns count = \(self.currentCountdowns.count)")
func countdownState(_ countdown: Countdown) -> CountdownState {
let id = countdown.stringId
if self.cancelledCountdowns.contains(id) {
return .cancelled
} else if self.pausedCountdowns[id] != nil {
return .paused
} else if let interval = self.currentCountdowns[id], interval.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]
}
func pauseCountdown(id: TimerID) {
guard let interval = self.currentCountdowns[id] else {
return
}
let remainingTime = interval.end.timeIntervalSince(Date())
self.pausedCountdowns[id] = remainingTime
// cancel stuff
func cancelCountdown(id: String) {
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id)
self.cancelSoundPlayer(id: id)
self._endLiveActivity(timerId: id)
self.stopSoundIfPossible()
self.cancelledCountdowns.append(id)
self._endCountdown(countdownId: id, cancel: true)
}
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)
} else {
throw AppError.timerNotFound(id: id)
}
}
fileprivate func _recordAndRemoveCountdown(countdownId: String, cancel: Bool) {
fileprivate func _endCountdown(countdownId: String, cancel: Bool) {
DispatchQueue.main.async {
self._recordActivity(countdownId: countdownId, cancelled: cancel)
if !cancel {
self._recordActivity(countdownId: countdownId)
}
self.currentCountdowns.removeValue(forKey: countdownId)
self._endLiveActivity(timerId: countdownId)
}
}
// MARK: - Stopwatch
func startStopwatch(_ stopwatchId: TimerID) {
func startStopwatch(_ stopwatch: Stopwatch) {
DispatchQueue.main.async {
let now = Date()
Conductor.maestro.currentStopwatches[stopwatch.stringId] = now
self._launchLiveActivity(stopwatch: stopwatch, start: now)
guard let stopWatch = IntentDataProvider.main.timer(id: stopwatchId) as? Stopwatch else {
return
}
let lsw: LiveStopWatch = LiveStopWatch(start: Date())
self.currentStopwatches[stopWatch.stringId] = lsw
if Preferences.playConfirmationSound {
self._playSound(Const.confirmationSound.rawValue)
}
self._createTimerIntent(stopwatch)
self._endLiveActivity(timerId: stopWatch.stringId)
self._launchLiveActivity(timer: stopWatch, date: lsw.start)
}
}
func stopStopwatch(_ stopwatch: Stopwatch) {
if let lsw = Conductor.maestro.currentStopwatches[stopwatch.stringId] {
if lsw.end == nil {
let end = Date()
lsw.end = end
Conductor.maestro.currentStopwatches[stopwatch.stringId] = lsw
func stopStopwatch(_ stopwatch: Stopwatch, end: Date? = nil) {
if let start = Conductor.maestro.currentStopwatches[stopwatch.stringId] {
Conductor.maestro.currentStopwatches.removeValue(forKey: stopwatch.stringId)
do {
try CoreDataRequests.recordActivity(timer: stopwatch, dateInterval: DateInterval(start: lsw.start, end: end), cancelled: false)
try CoreDataRequests.recordActivity(timer: stopwatch, dateInterval: DateInterval(start: start, end: end ?? Date()))
} catch {
Logger.error(error)
}
self._endLiveActivity(timerId: stopwatch.stringId)
}
}
}
func restoreSoundPlayers() {
for (countdownId, interval) in self.currentCountdowns {
if !self._delayedSoundPlayers.contains(where: { $0.key == 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)
}
}
}
}
}
// MARK: - Cleanup
func cleanup() {
self._cleanupCountdowns()
withAnimation {
self._cleanupLiveTimers()
self._buildLiveTimers()
}
if #available(iOS 16.2, *) {
self.cleanupLiveActivities()
}
}
fileprivate func _cleanupCountdowns() {
let now = Date()
for (key, value) in self.currentCountdowns {
if value.end < now || self.cancelledCountdowns.contains(key) {
self._recordAndRemoveCountdown(countdownId: key, cancel: false)
if value.end < now {
self._endCountdown(countdownId: key, cancel: false)
}
}
}
@ -371,7 +199,7 @@ class Conductor: ObservableObject {
let timer = context.object(stringId: timerId)
switch timer {
case let cd as Countdown:
coolSound = cd.someSound
coolSound = cd.coolSound
case let sw as Stopwatch:
coolSound = sw.coolSound
default:
@ -386,84 +214,36 @@ class Conductor: ObservableObject {
AudioServicesPlayAlertSound(SystemSoundID(kSystemSoundID_Vibrate))
}
fileprivate func _playConfirmationSound(timer: AbstractSoundTimer) {
let fileName: String
if let confirmationSound = timer.confirmationSounds.randomElement() {
fileName = confirmationSound.fileName
} else {
fileName = Const.confirmationSound.rawValue
}
self._playSound(fileName)
}
fileprivate func _playCancellationSound() {
self._playSound(Const.cancellationSound.rawValue)
}
func playSound(_ sound: Sound) {
self._playSound(sound.fileName)
}
func playSound(_ sound: Sound, duration: TimeInterval) {
self._playSound(sound.fileName, duration: duration)
}
fileprivate func _playSound(_ filename: String, duration: TimeInterval? = nil) {
do {
try self.soundPlayer.playOrPauseSound(filename, duration: duration)
let soundFile = try sound.soundFile()
let soundPlayer = SoundPlayer()
self.soundPlayer = soundPlayer
try soundPlayer.playSound(soundFile: soundFile, repeats: false)
} catch {
Logger.error(error)
// TODO: manage error
}
}
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))")
func stopSoundIfPossible() {
self.soundPlayer?.stop()
self.soundPlayer = nil
}
self.deactivateAudioSessionIfPossible()
}
// MARK: - Intent
func deactivateAudioSessionIfPossible() {
if self._delayedSoundPlayers.isEmpty {
// do {
// try AVAudioSession.sharedInstance().setActive(false)
// } catch {
// Logger.error(error)
// }
}
}
fileprivate func _createTimerIntent(_ timer: AbstractTimer) {
let intent = LaunchTimerIntent()
func stopMainPlayersIfPossible() {
self.soundPlayer.stop()
}
let invocationPhrase = "testooooo \(timer.defaultName)" // String(format: NSLocalizedString("Launch %@", comment: ""), timer.displayName)
intent.suggestedInvocationPhrase = String(format: invocationPhrase, timer.displayName)
intent.timer = TimerIdentifier(identifier: timer.stringId, display: timer.displayName)
func activateAudioSession() {
do {
let audioSession: AVAudioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playback, options: .duckOthers)
try audioSession.setActive(true)
} catch {
Logger.error(error)
}
let interaction = INInteraction(intent: intent, response: nil)
interaction.donate()
}
// 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)
@ -477,22 +257,23 @@ class Conductor: ObservableObject {
// MARK: - Live Activity
fileprivate func _launchLiveActivity(timer: AbstractTimer, date: Date) {
fileprivate func _launchLiveActivity(stopwatch: Stopwatch, start: Date) {
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 attributes = LaunchWidgetAttributes(id: stopwatch.stringId, name: stopwatch.displayName, date: start)
let activityContent = ActivityContent(state: contentState, staleDate: nil)
do {
let _ = try ActivityKit.Activity.request(attributes: attributes, content: activityContent)
// print("Requested a Live Activity: \(String(describing: liveActivity.id))")
} catch {
let liveActivity = try ActivityKit.Activity.request(attributes: attributes, content: activityContent)
print("Requested a Live Activity: \(String(describing: liveActivity.id)).")
} catch (let error) {
Logger.error(error)
}
}
} else {
// Fallback on earlier versions
@ -500,43 +281,41 @@ class Conductor: ObservableObject {
}
class func removeLiveActivities() {
print("Ending Live Activities")
let semaphore = DispatchSemaphore(value: 0)
Task.detached(priority: .high) {
print("Task")
for activity in ActivityKit.Activity<LaunchWidgetAttributes>.activities {
print("Ending Live Activity: \(activity.id)")
if #available(iOS 16.2, *) {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
semaphore.signal()
}
semaphore.wait()
fileprivate func _liveActivity(timerId: String) -> ActivityKit.Activity<LaunchWidgetAttributes>? {
return ActivityKit.Activity<LaunchWidgetAttributes>.activities.first(where: { $0.attributes.id == timerId } )
}
fileprivate func _liveActivity(timerId: String) -> [ActivityKit.Activity<LaunchWidgetAttributes>] {
return ActivityKit.Activity<LaunchWidgetAttributes>.activities.filter { $0.attributes.id == timerId }
}
func updateLiveActivities() {
print("update live activity...")
fileprivate func _liveActivityIds() -> [String] {
let activities = ActivityKit.Activity<LaunchWidgetAttributes>.activities
return activities.map { $0.attributes.id }
}
for (countdownId, interval) in self.currentCountdowns {
func cleanupLiveActivities() {
for id in self._liveActivityIds() {
if self.liveTimers.first(where: { $0.id == id} ) == nil {
self._endLiveActivity(timerId: id)
if interval.end < Date() {
self._endLiveActivity(timerId: countdownId)
}
// if let activity = self._liveActivity(countdownId: countdownId) {
//
// Task {
//
// if ended {
// self._endLiveActivity(countdownId: countdownId)
// }
//
//// let state = LaunchWidgetAttributes.ContentState(ended: ended)
//// let content = ActivityContent(state: state, staleDate: interval.end)
//// await activity.update(content)
//// print("Ending the Live Activity: \(activity.id)")
// }
// }
}
}
fileprivate func _endLiveActivity(timerId: String) {
if #available(iOS 16.2, *) {
print("Try to end the Live Activity: \(timerId)")
for activity in self._liveActivity(timerId: timerId) {
if let activity = self._liveActivity(timerId: timerId) {
Task {
let state = LaunchWidgetAttributes.ContentState(ended: true)
let content = ActivityContent(state: state, staleDate: Date())
@ -547,13 +326,4 @@ class Conductor: ObservableObject {
}
}
fileprivate func _timerName(_ id: TimerID) -> String {
return IntentDataProvider.main.timer(id: id)?.name ?? id
}
deinit {
self.beats?.invalidate()
self.beats = nil
}
}

@ -15,12 +15,9 @@ class 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()
@ -33,17 +30,27 @@ class CountdownScheduler {
body = String(format: timesup, name)
} else {
let timesup = NSLocalizedString("Your %@ countdown is over!", comment: "")
body = String(format: timesup, duration.hourMinuteSecond)
body = String(format: timesup, duration.minuteSecond)
}
content.body = body
self._createNotification(countdown: countdown, content: content, handler: handler)
let sound = countdown.soundName
print("Selected sound = \(sound)")
// content.sound = UNNotificationSound.criticalSoundNamed(UNNotificationSoundName(rawValue: sound), withAudioVolume: 1.0)
// content.interruptionLevel = .critical
content.sound = UNNotificationSound.criticalSoundNamed(UNNotificationSoundName(rawValue: sound), withAudioVolume: 1.0)
content.interruptionLevel = .critical
content.relevanceScore = 1.0
let notificationCount = 1 + countdown.repeatCount
// self._createNotification(countdown: countdown, content: content, handler: handler)
for i in 0..<notificationCount {
let offset = Double(i) * 10.0 // every 30 seconds
self._createNotification(countdown: countdown, offset: offset, content: content, handler: handler)
}
}
fileprivate func _createNotification(countdown: Countdown, offset: TimeInterval = 0.0, content: UNMutableNotificationContent, handler: @escaping (Result<Date?, Error>) -> Void) {
@ -61,17 +68,36 @@ class CountdownScheduler {
if let error {
handler(.failure(error))
print("Scheduling error = \(error)")
} else {
if offset == 0.0 {
if let triggerDate = trigger.nextTriggerDate() {
Conductor.maestro.startCountdown(triggerDate, countdown: countdown)
handler(.success(triggerDate))
} else {
let backupDate = Date().addingTimeInterval(duration)
Conductor.maestro.startCountdown(backupDate, countdown: countdown)
}
}
}
}
}
// DispatchQueue.main.async {
// let d = countdown.duration + offset
// let td = Date().addingTimeInterval(d)
// Conductor.maestro.startCountdown(td, countdown: countdown)
// }
}
func cancelCurrentNotifications(countdownId: String) {
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
let ids = requests.map { $0.identifier }.filter { $0.hasPrefix(countdownId) }
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids)
}
// Conductor.maestro.cancelCountdown(id: countdownId)
}
}

@ -1,34 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CLIENT_ID</key>
<string>510588659240-nmmokt44krq1de2i7q9ldtve27vd2pmb.apps.googleusercontent.com</string>
<key>REVERSED_CLIENT_ID</key>
<string>com.googleusercontent.apps.510588659240-nmmokt44krq1de2i7q9ldtve27vd2pmb</string>
<key>API_KEY</key>
<string>AIzaSyCDbEuPsEkVgfkzmR4yiueNb1woqfLayoY</string>
<key>GCM_SENDER_ID</key>
<string>510588659240</string>
<key>PLIST_VERSION</key>
<string>1</string>
<key>BUNDLE_ID</key>
<string>com.staxriver.LeCountdown</string>
<key>PROJECT_ID</key>
<string>enchant-c3492</string>
<key>STORAGE_BUCKET</key>
<string>enchant-c3492.appspot.com</string>
<key>IS_ADS_ENABLED</key>
<false></false>
<key>IS_ANALYTICS_ENABLED</key>
<false></false>
<key>IS_APPINVITE_ENABLED</key>
<true></true>
<key>IS_GCM_ENABLED</key>
<true></true>
<key>IS_SIGNIN_ENABLED</key>
<true></true>
<key>GOOGLE_APP_ID</key>
<string>1:510588659240:ios:86bec8b9f46ddcceb4f71d</string>
</dict>
</plist>

@ -6,24 +6,11 @@
<array>
<string>com.staxriver.lecountdown.refresh</string>
</array>
<key>INAlternativeAppNames</key>
<array>
<dict>
<key>INAlternativeAppName</key>
<string>Momo</string>
</dict>
<dict>
<key>INAlternativeAppName</key>
<string>Gogo</string>
</dict>
</array>
<key>ITSAppUsesNonExemptEncryption</key>
<false/>
<key>NSUserActivityTypes</key>
<array>
<string>LaunchTimerIntent</string>
<string>SelectTimerIntent</string>
<string>app.enchant.NewCountdown</string>
<string>app.kikai.NewCountdown</string>
</array>
<key>UIApplicationSceneManifest</key>
<dict>
@ -35,7 +22,7 @@
<key>UIBackgroundModes</key>
<array>
<string>audio</string>
<string>remote-notification</string>
<string>fetch</string>
</array>
</dict>
</plist>

@ -1,70 +0,0 @@
//
// LaunchTimer.swift
// LeCountdown
//
// Created by Laurent Morvillier on 07/03/2023.
//
import Foundation
import AppIntents
import SwiftUI
import CoreData
@available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *)
struct StartTimerIntent: AudioStartingIntent {
static let intentClassName = "StartTimerIntent"
static var title: LocalizedStringResource = "Launch Timer"
static var description = IntentDescription("Launch timers and stopwatches")
@Parameter(title: "Timer")
var timer: TimerIdentifierAppEntity?
static var parameterSummary: some ParameterSummary {
Summary("")
}
static var openAppWhenRun: Bool = true
func perform() async throws -> some IntentResult {
let timerIdentifier: TimerIdentifierAppEntity
if let timer {
timerIdentifier = timer
} else {
let entities = await _timerEntities()
timerIdentifier = try await $timer.requestDisambiguation(among: entities)
}
if let abstractTimer = await _timer(id: timerIdentifier.id) {
do {
_ = try await TimerRouter.performAction(timer: abstractTimer)
return .result(value: 1)
} catch {
Logger.error(error)
throw error
}
} else {
throw AppError.timerNotFound(id: timerIdentifier.id)
}
}
fileprivate func _timerEntities() async -> [TimerIdentifierAppEntity] {
await PersistenceController.shared.container.performBackgroundTask { context in
do {
let timers: [AbstractTimer] = try IntentDataProvider.main.timers(context: context)
return timers.map { TimerIdentifierAppEntity(id: $0.stringId, displayString: $0.displayName) }
} catch {
return []
}
}
}
fileprivate func _timer(id: String) async -> AbstractTimer? {
await PersistenceController.shared.container.performBackgroundTask { context in
return IntentDataProvider.main.timer(context: context, id: id)
}
}
}

@ -1,46 +0,0 @@
//
// TimerIdentifierAppEntity.swift
// LeCountdown
//
// Created by Laurent Morvillier on 07/03/2023.
//
import Foundation
import AppIntents
@available(iOS 16.0, macOS 13.0, watchOS 9.0, tvOS 16.0, *)
struct TimerIdentifierAppEntity: AppEntity {
static var typeDisplayRepresentation = TypeDisplayRepresentation(name: "Timer Identifier")
static var defaultQuery = TimerIdentifierAppEntityQuery()
var id: String // if your identifier is not a String, conform the entity to EntityIdentifierConvertible.
var displayString: String
var displayRepresentation: DisplayRepresentation {
DisplayRepresentation(title: "\(displayString)")
}
init(id: String, displayString: String) {
self.id = id
self.displayString = displayString
}
struct TimerIdentifierAppEntityQuery: EntityQuery {
func entities(for identifiers: [TimerIdentifierAppEntity.ID]) async throws -> [TimerIdentifierAppEntity] {
await PersistenceController.shared.container.performBackgroundTask { context in
let timers = identifiers.compactMap { IntentDataProvider.main.timer(context: context, id: $0) }
return timers.map { TimerIdentifierAppEntity(id: $0.stringId, displayString: $0.displayName) }
}
}
func suggestedEntities() async throws -> [TimerIdentifierAppEntity] {
try await PersistenceController.shared.container.performBackgroundTask { context in
let timers = try IntentDataProvider.main.timers(context: context)
return timers.map { TimerIdentifierAppEntity(id: $0.stringId, displayString: $0.displayName) }
}
}
}
}

@ -1,25 +0,0 @@
//
// TimerShortcuts.swift
// LeCountdown
//
// Created by Laurent Morvillier on 07/03/2023.
//
import AppIntents
struct TimerShortcuts: AppShortcutsProvider {
static var appShortcuts: [AppShortcut] {
AppShortcut(intent: StartTimerIntent(), phrases: [
"\(.applicationName) \(\.$timer)",
"\(.applicationName) my \(\.$timer)",
"\(.applicationName) the \(\.$timer)",
"Start \(\.$timer) with \(.applicationName)",
"Launch \(\.$timer) with \(.applicationName)",
"Start \(.applicationName)",
"Launch \(.applicationName)"
])
}
}

@ -9,8 +9,6 @@ import SwiftUI
import CoreData
import BackgroundTasks
import AVFoundation
import Combine
import CloudKit
@main
struct LeCountdownApp: App {
@ -18,42 +16,49 @@ struct LeCountdownApp: App {
let persistenceController = PersistenceController.shared
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) var scenePhase
@State var showStartView: Bool = false
init() {
UITabBar.appearance().backgroundColor = UIColor(white: 0.96, alpha: 1.0)
UIPageControl.appearance().currentPageIndicatorTintColor = .systemPink
UIPageControl.appearance().pageIndicatorTintColor = UIColor(white: 0.7, alpha: 1.0)
Logger.log("path = \(Bundle.main.bundlePath)")
self._registerBackgroundRefreshes()
}
@Environment(\.scenePhase) var scenePhase
var body: some Scene {
WindowGroup {
Group {
#if os(iOS)
if UIDevice.isPhoneIdiom {
CompactHomeView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
.fullScreenCover(isPresented: $showStartView) {
StartView(isPresented: $showStartView)
} else {
RegularHomeView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
}
#else
RegularHomeView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
#endif
}
// .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
// self._willEnterForegroundNotification()
// }
.onAppear {
self._onAppear()
}
.onChange(of: scenePhase) { newPhase in
switch newPhase {
case .inactive:
Conductor.maestro.stopMainPlayersIfPossible()
Conductor.maestro.memoryWarningReceived = false
Conductor.maestro.stopSoundIfPossible()
case .active:
// Logger.log("onChange(of: scenePhase) active")
// Logger.log(Conductor.maestro.currentCountdowns.count)
Conductor.maestro.restoreSoundPlayers()
Logger.log("onChange(of: scenePhase) active")
Logger.log(Conductor.maestro.currentCountdowns.count)
Conductor.maestro.cleanup()
default:
break
@ -63,20 +68,17 @@ struct LeCountdownApp: App {
}
fileprivate func _shouldShowStartView() -> Bool {
let count = persistenceController.container.viewContext.count(entityName: "AbstractTimer")
return count == 0 && Preferences.hasShownStartView == false
}
// fileprivate func _willEnterForegroundNotification() {
// Conductor.maestro.cleanup()
// }
fileprivate func _onAppear() {
Logger.log("preferredLanguages = \(String(describing: Locale.preferredLanguages))")
self.showStartView = self._shouldShowStartView()
Sound.computeSoundDurationsIfNecessary()
self._patch()
let containerAvailable = self.isICloudContainerAvailable()
Logger.log("isICloudContainerAvailable = \(containerAvailable)")
// let voices = AVSpeechSynthesisVoice.speechVoices()
// let grouped = Dictionary(grouping: voices, by: { $0.language })
@ -102,17 +104,18 @@ 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
@ -128,43 +131,4 @@ struct LeCountdownApp: App {
}
func isICloudContainerAvailable() -> Bool {
print(#function)
CKContainer.default().accountStatus { (accountStatus, error) in
if accountStatus == .available {
return
}
///
// Checking account availability
// Silently return if everything goes well, or do a second check 200ms after the first failure
//
DispatchQueue.global().asyncAfter(deadline: .now() + 0.2) {
guard error != nil, accountStatus != CKAccountStatus.available else {return}
print("iCloud account is not available! Be sure you have signed in iCloud on this device!")
}
}
if FileManager.default.ubiquityIdentityToken != nil {
//print("User logged in")
return true
}
else {
//print("User is not logged in")
return false
}
}
static func askPermissions() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { success, error in
print("requestAuthorization > success = \(success), error = \(String(describing: error))")
}
}
}

@ -43,7 +43,7 @@ class CoreDataRequests {
return activity
}
static func recordActivity(timer: AbstractTimer, dateInterval: DateInterval, cancelled: Bool) throws {
static func recordActivity(timer: AbstractTimer, dateInterval: DateInterval) throws {
guard let activity = timer.activity else {
return
@ -54,86 +54,8 @@ class CoreDataRequests {
record.start = dateInterval.start
record.end = dateInterval.end
record.activity = activity
record.cancelled = cancelled
try context.save()
}
static func years(context: NSManagedObjectContext, activity: Activity) throws -> [Int] {
let predicate: NSPredicate = NSPredicate(format: "activity = %@", activity)
let distinct = try context.distinct(entityName: "Record", attributes: ["year"], predicate: predicate)
if let distinctYears = distinct as? [[String : Int]] {
return distinctYears.compactMap {
if let year = $0["year"] {
return year
} else {
Logger.w("issue with dictionary \($0)")
return nil
}
}
} else {
Logger.w("Could not cast \(distinct) as [Int]")
return []
}
}
static func months(context: NSManagedObjectContext, activity: Activity, year: Int) throws -> [Int] {
let predicate: NSPredicate = NSPredicate(format: "activity = %@ AND year = %i", activity, year)
let distinct = try context.distinct(entityName: "Record", attributes: ["month"], predicate: predicate)
if let distinctMonths = distinct as? [[String : Int]] {
return distinctMonths.compactMap {
if let month = $0["month"] {
return month
} else {
Logger.w("issue with dictionary \($0)")
return nil
}
}
} else {
Logger.w("Could not cast \(distinct) as [Int]")
return []
}
}
static func months(context: NSManagedObjectContext, activity: Activity) throws -> [Month] {
let predicate: NSPredicate = NSPredicate(format: "activity = %@", activity)
let distinct = try context.distinct(entityName: "Record", attributes: ["year", "month"], predicate: predicate)
if let distinctMonths = distinct as? [[String : Int]] {
return distinctMonths.compactMap {
if let month = $0["month"],
let year = $0["year"] {
return Month(month: month, year: year)
} else {
Logger.w("issue with dictionary \($0)")
return nil
}
}
} else {
Logger.w("Could not cast \(distinct) as [Int]")
return []
}
}
static func oldestDate(context: NSManagedObjectContext, activity: Activity) -> Date? {
let request = Record.fetchRequest()
request.sortDescriptors = [NSSortDescriptor(key: "start", ascending: true)]
request.predicate = NSPredicate(format: "activity = %@", activity)
do {
let records: [Record] = try context.fetch(request)
return records.first?.start
} catch {
Logger.error(error)
}
return nil
}
}

@ -2,7 +2,7 @@
// AbstractSoundTimer+CoreDataProperties.swift
// LeCountdown
//
// Created by Laurent Morvillier on 17/05/2023.
// Created by Laurent Morvillier on 10/02/2023.
//
//
@ -16,8 +16,7 @@ extension AbstractSoundTimer {
return NSFetchRequest<AbstractSoundTimer>(entityName: "AbstractSoundTimer")
}
@NSManaged public var confirmationSoundList: String?
@NSManaged public var playableIds: String?
@NSManaged public var repeatCount: Int16
@NSManaged public var soundList: String?
}

@ -2,7 +2,7 @@
// AbstractTimer+CoreDataProperties.swift
// LeCountdown
//
// Created by Laurent Morvillier on 17/05/2023.
// Created by Laurent Morvillier on 01/02/2023.
//
//
@ -16,8 +16,8 @@ extension AbstractTimer {
return NSFetchRequest<AbstractTimer>(entityName: "AbstractTimer")
}
@NSManaged public var image: String?
@NSManaged public var order: Int16
@NSManaged public var image: String?
@NSManaged public var activity: Activity?
}

@ -2,7 +2,7 @@
// Record+CoreDataProperties.swift
// LeCountdown
//
// Created by Laurent Morvillier on 01/06/2023.
// Created by Laurent Morvillier on 21/02/2023.
//
//
@ -18,10 +18,9 @@ extension Record {
@NSManaged public var duration: Double
@NSManaged public var end: Date?
@NSManaged public var month: Int16
@NSManaged public var start: Date?
@NSManaged public var year: Int16
@NSManaged public var cancelled: Bool
@NSManaged public var month: Int16
@NSManaged public var activity: Activity?
}

@ -2,14 +2,15 @@
// Stopwatch+CoreDataClass.swift
// LeCountdown
//
// Created by Laurent Morvillier on 17/05/2023.
// Created by Laurent Morvillier on 01/02/2023.
//
//
import Foundation
import CoreData
import SwiftUI
@objc(Stopwatch)
public class Stopwatch: AbstractSoundTimer {
public class Stopwatch: AbstractTimer {
}

@ -2,7 +2,7 @@
// Stopwatch+CoreDataProperties.swift
// LeCountdown
//
// Created by Laurent Morvillier on 17/05/2023.
// Created by Laurent Morvillier on 17/02/2023.
//
//

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

@ -1,52 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21513" systemVersion="22A400" minimumToolsVersion="Automatic" sourceLanguage="Swift" usedWithCloudKit="YES" userDefinedModelVersionIdentifier="">
<entity name="AbstractSoundTimer" representedClassName="AbstractSoundTimer" isAbstract="YES" parentEntity="AbstractTimer" syncable="YES">
<attribute name="confirmationSoundList" optional="YES" attributeType="String"/>
<attribute name="repeatCount" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<attribute name="soundList" 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" usesScalarValueType="YES"/>
<relationship name="group" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="IntervalGroup" inverseName="countdown" inverseEntity="IntervalGroup"/>
</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="Interval" representedClassName="Interval" syncable="YES">
<attribute name="duration" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="soundList" optional="YES" attributeType="String"/>
<relationship name="group" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="IntervalGroup" inverseName="intervals" inverseEntity="IntervalGroup"/>
</entity>
<entity name="IntervalGroup" representedClassName="IntervalGroup" syncable="YES">
<attribute name="repeatCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="countdown" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Countdown" inverseName="group" inverseEntity="Countdown"/>
<relationship name="intervals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Interval" inverseName="group" inverseEntity="Interval"/>
</entity>
<entity name="Record" representedClassName="Record" syncable="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="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>

@ -1,52 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22A400" 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"/>
<attribute name="repeatCount" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
</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" usesScalarValueType="YES"/>
<relationship name="group" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="IntervalGroup" inverseName="countdown" inverseEntity="IntervalGroup"/>
</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="Interval" representedClassName="Interval" syncable="YES">
<attribute name="duration" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="soundList" optional="YES" attributeType="String"/>
<relationship name="group" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="IntervalGroup" inverseName="intervals" inverseEntity="IntervalGroup"/>
</entity>
<entity name="IntervalGroup" representedClassName="IntervalGroup" syncable="YES">
<attribute name="repeatCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="countdown" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Countdown" inverseName="group" inverseEntity="Countdown"/>
<relationship name="intervals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Interval" inverseName="group" inverseEntity="Interval"/>
</entity>
<entity name="Record" representedClassName="Record" syncable="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="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>

@ -1,53 +0,0 @@
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<model type="com.apple.IDECoreDataModeler.DataModel" documentVersion="1.0" lastSavedToolsVersion="21754" systemVersion="22A400" 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"/>
<attribute name="repeatCount" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
</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" usesScalarValueType="YES"/>
<relationship name="group" optional="YES" maxCount="1" deletionRule="Cascade" destinationEntity="IntervalGroup" inverseName="countdown" inverseEntity="IntervalGroup"/>
</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="Interval" representedClassName="Interval" syncable="YES">
<attribute name="duration" attributeType="Double" defaultValueString="0.0" usesScalarValueType="YES"/>
<attribute name="soundList" optional="YES" attributeType="String"/>
<relationship name="group" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="IntervalGroup" inverseName="intervals" inverseEntity="IntervalGroup"/>
</entity>
<entity name="IntervalGroup" representedClassName="IntervalGroup" syncable="YES">
<attribute name="repeatCount" optional="YES" attributeType="Integer 16" defaultValueString="0" usesScalarValueType="YES"/>
<relationship name="countdown" optional="YES" maxCount="1" deletionRule="Nullify" destinationEntity="Countdown" inverseName="group" inverseEntity="Countdown"/>
<relationship name="intervals" optional="YES" toMany="YES" deletionRule="Cascade" destinationEntity="Interval" inverseName="group" inverseEntity="Interval"/>
</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="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>

@ -1,38 +0,0 @@
//
// LiveStopWatch.swift
// LeCountdown
//
// Created by Laurent Morvillier on 06/04/2023.
//
import Foundation
class LiveStopWatch: Codable, Hashable, Equatable {
var start: Date
var end: Date?
init(start: Date, end: Date? = nil) {
self.start = start
self.end = end
}
static func == (lhs: LiveStopWatch, rhs: LiveStopWatch) -> Bool {
if lhs.start == rhs.start {
if let end = lhs.end, end == rhs.end {
return true
} else if lhs.end == nil && rhs.end == nil {
return true
}
}
return false
}
func hash(into hasher: inout Hasher) {
hasher.combine(start)
if let end {
hasher.combine(end)
}
}
}

@ -11,18 +11,16 @@ import CoreData
struct LiveTimer: Identifiable, Comparable {
var id: String
var date: Date
var endDate: Date?
static func < (lhs: LiveTimer, rhs: LiveTimer) -> Bool {
return lhs.date < rhs.date
}
func timer(context: NSManagedObjectContext) -> AbstractTimer? {
return context.object(stringId: self.id)
return context.object(stringId: self.id) as? AbstractTimer
}
var ended: Bool {
return self.date < Date()
}
}

@ -9,76 +9,42 @@ import Foundation
import SwiftUI
import CoreData
extension AbstractSoundTimer {
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 allSounds: Set<Sound> {
return self.playables.reduce(Set<Sound>()) { $0.union($1.soundList) }
}
extension AbstractSoundTimer {
var confirmationSounds: Set<Sound> {
if let confirmationSoundList {
return Set(confirmationSoundList.enumItems())
var sounds: Set<Sound> {
if let soundList {
return Set(soundList.enumItems())
}
return []
}
func setConfirmationSounds(_ sounds: Set<Sound>) {
self.confirmationSoundList = sounds.stringRepresentation
func setSounds(_ sounds: Set<Sound>) {
self.soundList = sounds.stringRepresentation
}
var someSound: Sound {
var sounds: Set<Sound> = self.allSounds
if !AppGuard.main.isSubscriber {
sounds = sounds.filter { !$0.isRestricted }
}
var coolSound: Sound {
var sounds = self.sounds
// remove last played sound if at least 3 sounds remains
if sounds.count > 2,
let lastSoundId = Preferences.lastSoundPlayed,
let lastSound = Sound(rawValue: lastSoundId) {
sounds.remove(lastSound)
}
// remove last played sound by timer if the playlist has at least 3 sounds
// remove last played sound if the playlist has at least 3 sounds
if sounds.count > 2,
let lastSoundId = Preferences.lastSelectedSoundByTimer[self.stringId],
let lastSoundId = Preferences.lastSelectedSound[self.stringId],
let lastSound = Sound(rawValue: lastSoundId) {
sounds.remove(lastSound)
}
if let random = sounds.randomElement() {
Preferences.lastSelectedSoundByTimer[self.stringId] = random.id
Preferences.lastSoundPlayed = random.id
Preferences.lastSelectedSound[self.stringId] = random.id
return random
}
return Sound.default
}
var soundName: String {
return self.coolSound.soundName
}
}
extension Stopwatch {
@ -158,3 +124,27 @@ extension CustomSound : Localized {
}
// MARK: - Storage convenience
fileprivate let separator = "|"
fileprivate let formatter: NumberFormatter = NumberFormatter()
extension String {
func enumItems<T : RawRepresentable<Int>>() -> [T] {
let ids: [String] = self.components(separatedBy: separator)
let intIds: [Int] = ids.compactMap { formatter.number(from: $0)?.intValue }
return intIds.compactMap { T(rawValue: $0) }
}
}
extension Sequence where Element : RawRepresentable<Int> {
var stringRepresentation: String {
let ids = self.compactMap { formatter.string(from: NSNumber(value: $0.rawValue)) }
return ids.joined(separator: separator)
}
}

@ -7,8 +7,6 @@
import Foundation
typealias TimerID = String
extension AbstractTimer {
var displayName: String {
@ -27,7 +25,7 @@ extension AbstractTimer {
if let url = URL(string: self.stringId) {
return url
} else {
return URL(filePath: "") // dummy URL to avoid the pain of dealing with optional/error
fatalError("Can't produce url with \(self.stringId)")
}
}

@ -10,20 +10,19 @@ import CoreData
extension NSManagedObjectContext {
func object<T: NSManagedObject>(stringId: String) -> T? {
func object(stringId: String) -> NSManagedObject? {
guard let url = URL(string: stringId) else { return nil }
guard let objectId: NSManagedObjectID = PersistenceController.shared.container.persistentStoreCoordinator.managedObjectID(forURIRepresentation: url) else { return nil }
return self.object(with: objectId) as? T
guard let objectId = PersistenceController.shared.container.persistentStoreCoordinator.managedObjectID(forURIRepresentation: url) else { return nil }
return self.object(with: objectId)
}
func distinct(entityName: String, attributes: [String], predicate: NSPredicate? = nil) throws -> [Any] {
func distinct(entityName: String, attributes: [String]) throws -> [Any] {
let request = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
request.entity = NSEntityDescription.entity(forEntityName: entityName, in: self)
request.returnsDistinctResults = true
request.resultType = .dictionaryResultType
request.predicate = predicate
if let entity = request.entity {
let entityProperties = entity.propertiesByName
var properties = [NSPropertyDescription]()
@ -38,27 +37,9 @@ extension NSManagedObjectContext {
return try self.fetch(request)
}
func count(entityName: String) -> Int {
func count(entityName: String) throws -> Int {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
do {
return try self.count(for: fetchRequest)
} catch {
return 0
}
}
func fetch<T>(entityName: String, predicate: NSPredicate? = nil, sortDescriptor: NSSortDescriptor? = nil) -> [T] where T : NSFetchRequestResult {
let request = NSFetchRequest<T>(entityName: entityName)
request.predicate = predicate
if let sortDescriptor = sortDescriptor {
request.sortDescriptors = [sortDescriptor]
}
do {
return try self.fetch(request)
} catch {
Logger.error(error)
return []
}
}
}
@ -73,8 +54,4 @@ extension NSManagedObject {
return self.objectID.uriRepresentation().absoluteString
}
static var entityName: String {
return self.entity().managedObjectClassName
}
}

@ -31,21 +31,14 @@ struct PersistenceController {
countdown.image = CoolPic.pic1.rawValue
}
for i in 0..<20 {
for i in 0..<14 {
let record = Record(context: viewContext)
let randomMonth = (0...10 * 31).randomElement() ?? 3
let monthTimeInterval = Double(randomMonth) * 3600 * 24
let start = Date().addingTimeInterval(-monthTimeInterval)
let duration = Double((1...10).randomElement() ?? 5) * 60.0
let end = start.addingTimeInterval(duration)
record.start = start
record.end = end
record.start = Date()
record.end = Date()
record.activity = activities.randomElement()
}
do {
try viewContext.save()
} catch {
@ -53,7 +46,6 @@ struct PersistenceController {
// Replace this implementation with code to handle the error appropriately.
// fatalError() causes the application to generate a crash log and terminate. You should not use this function in a shipping application, although it may be useful during development.
let nsError = error as NSError
FileLogger.log("app terminated by ourselves")
fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
}
return result
@ -73,8 +65,8 @@ struct PersistenceController {
let options = NSPersistentCloudKitContainerOptions(containerIdentifier: id)
storeDescription.cloudKitContainerOptions = options
// let remoteChangeKey = "NSPersistentStoreRemoteChangeNotificationOptionKey"
// storeDescription.setOption(true as NSNumber, forKey: remoteChangeKey)
let remoteChangeKey = "NSPersistentStoreRemoteChangeNotificationOptionKey"
storeDescription.setOption(true as NSNumber, forKey: remoteChangeKey)
container = NSPersistentCloudKitContainer(name: "LeCountdown")
container.persistentStoreDescriptions = [storeDescription]
@ -102,7 +94,6 @@ struct PersistenceController {
* The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was.
*/
FileLogger.log("app terminated by ourselves")
fatalError("Unresolved error \(error), \(error.userInfo)")
}
})
@ -116,7 +107,6 @@ fileprivate extension URL {
/// Returns a URL for the given app group and database pointing to the sqlite database.
static func storeURL(for appGroup: String, databaseName: String) -> URL {
guard let fileContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
FileLogger.log("app terminated by ourselves")
fatalError("Shared file container could not be created.")
}

@ -1,85 +0,0 @@
//
// DelaySoundPlayer.swift
// LeCountdown
//
// Created by Laurent Morvillier on 08/03/2023.
//
import Foundation
import AVFoundation
@objc class DelaySoundPlayer: NSObject, AVAudioPlayerDelegate {
fileprivate var _player: AVAudioPlayer
fileprivate var _timerID: TimerID
fileprivate var _timer: Timer? = nil
init(timerID: TimerID, sound: Sound) throws {
self._timerID = timerID
let soundFile = try sound.soundFile()
guard let url = soundFile.url else {
throw SoundPlayerError.missingResourceError(file: soundFile)
}
self._player = try AVAudioPlayer(contentsOf: url)
}
func restore(for playDate: Date, repeatCount: Int) throws {
let timeLeft = playDate.timeIntervalSinceNow
try self._play(in: timeLeft, repeatCount: repeatCount)
}
func start(in duration: TimeInterval, repeatCount: Int) throws {
try self._play(in: duration, repeatCount: repeatCount)
}
fileprivate func _play(in duration: TimeInterval, repeatCount: Int) throws {
Conductor.maestro.activateAudioSession()
self._player.prepareToPlay()
self._player.volume = 1.0
self._player.delegate = self
self._player.numberOfLoops = repeatCount
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)")
if !result {
throw SoundPlayerError.playReturnedFalse
}
}
func stop() {
self._player.stop()
FileLogger.log("Player stopped")
}
// MARK: - Delegate
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
FileLogger.log("audioPlayerDidFinishPlaying: successfully = \(flag), player volume = \(player.volume)")
Logger.log("audioPlayerDidFinishPlaying: successfully = \(flag)")
Conductor.maestro.cleanupLiveActivities()
self.stop()
}
fileprivate func _activateAudioSession() {
do {
let audioSession: AVAudioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playback, options: .duckOthers)
try audioSession.setActive(true)
} catch {
Logger.error(error)
}
}
}

@ -8,23 +8,6 @@
import Foundation
import AVFoundation
protocol Playable: StringRepresentable, Equatable, Hashable {
var soundList: Set<Sound> { get }
}
extension Playlist : Playable {
var stringValue: String { self.rawValue }
var soundList: Set<Sound> {
return Set(SoundCatalog.main.sounds(for: self))
}
}
extension Sound: Playable {
var stringValue: String { self.rawValue.formatted() }
var soundList: Set<Sound> {
return [self]
}
}
protocol Localized {
var localizedString: String { get }
}
@ -40,59 +23,34 @@ class SoundCatalog {
}
func sounds(for playlist: Playlist) -> [Sound] {
switch playlist {
case .shorts:
return [.FF_SH_bowl_drone_tap_hold_E, .FF_SH_bowl_drone_tapping_C, .EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab, .ESM_Ambient_Game_Menu_Soft_Wood]
default:
return self._soundsByPlaylist[playlist] ?? []
}
}
}
enum Catalog {
case ring
case confirmation
var playlists: [Playlist] {
switch self {
case .ring: return [.stephanBodzin, .nature, .relax]
case .confirmation: return [.shorts]
}
}
}
enum Playlist: String, CaseIterable, Identifiable, Localized {
enum Playlist: Int, CaseIterable, Identifiable, Localized {
var id: String { return self.rawValue }
var id: Int { return self.rawValue }
case custom
case nature
case fun
case stephanBodzin
case relax
case shorts
static var selectable: [Playlist] {
return Playlist.allCases.filter { $0 != .custom }
}
var localizedString: String {
switch self {
case .nature:
return NSLocalizedString("Nature", comment: "")
case .fun:
return NSLocalizedString("Fun", comment: "")
case .stephanBodzin:
return "Stephan Bodzin - Boavista"
return "Stephan Bodzin"
case .custom:
return NSLocalizedString("Custom", comment: "")
case .relax:
return NSLocalizedString("Relax", comment: "")
case .shorts:
return NSLocalizedString("Confirmation", comment: "")
}
}
var shortName: String {
switch self {
case .stephanBodzin:
return "Boavista"
default:
return self.localizedString
}
}
@ -103,139 +61,70 @@ enum Sound: Int, CaseIterable, Identifiable, Localized {
var id: Int { return self.rawValue }
case trainhorn = 1 // default
case forestStream
// StephanBodzin
case sbSEM_Synths_Loop4_Nothing_Like_You
case sbClave_Loop_LLL
case sbLoop_ToneSD_Boavista
case sbArpeggio_Loop_River
case sbSquareArp_Loop_River
case sbHighChords_Loop_River
case sbMatriarchFxs_Loop2_Collider
// Relax
case FF_SH_bowl_drone_tapping_C
case FF_SH_bowl_drone_tap_hold_E
case EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab
case EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm
case EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am
case EX_ATSM_Bell_Binaural_Flam_Eb
case EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am
case EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm
// Nature
case rain_soft
case stream1
case stream2
case surf1
case crickets
case tropicalForestMorning
case desertMorning
case wetland
case riparianZone
// Shorts
case ESM_Ambient_Game_Menu_Soft_Wood
case sbRose
case trancosoBowl
case natureOceanShore
static var `default`: Sound { .sbSEM_Synths_Loop4_Nothing_Like_You }
var localizedString: String {
switch self {
case .trainhorn: return NSLocalizedString("Train horn", comment: "")
case .forestStream: return NSLocalizedString("Forest stream", comment: "")
case .sbSEM_Synths_Loop4_Nothing_Like_You: return "Nothing Like You"
case .sbClave_Loop_LLL: return "LLL"
case .sbLoop_ToneSD_Boavista: return "Boavista"
case .sbArpeggio_Loop_River: return "River 1"
case .sbSquareArp_Loop_River: return "River 2"
case .sbHighChords_Loop_River: return "River 3"
case .sbMatriarchFxs_Loop2_Collider: return "Collider"
case .FF_SH_bowl_drone_tapping_C: return "Bowl 1"
case .FF_SH_bowl_drone_tap_hold_E: return "Bowl 2"
case .EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm: return "Koshi Chimes 1"
case .EX_ATSM_Bell_Binaural_Flam_Eb: return "Bell Binaural"
case .EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am: return "Sansula"
case .EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm: return "Chimey percussion"
case .EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am: return "Koshi Chimes 2"
case .EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab: return "Bowl 3"
case .rain_soft: return "Rain"
case .stream1: return "Stream 1"
case .stream2: return "Stream 2"
case .surf1: return "Surf 1"
case .crickets: return "Crickets"
case .tropicalForestMorning: return "Forest morning 1"
case .desertMorning: return "Desert morning 2"
case .wetland: return "Wetland"
case .riparianZone: return "Riparian Zone"
case .ESM_Ambient_Game_Menu_Soft_Wood: return "Wood percussion"
case .sbRose: return "Rose"
case .trancosoBowl: return "Bowl 4"
case .natureOceanShore: return "Ocean Shore"
}
}
var fileName: String {
var soundName: String {
switch self {
case .trainhorn: return "train_horn.mp3"
case .forestStream: return "forest_stream.mp3"
case .sbSEM_Synths_Loop4_Nothing_Like_You: return "SEM_Synths_Loop4_Nothing_Like_You.wav"
case .sbClave_Loop_LLL: return "Clave_Loop_LLL.wav"
case .sbLoop_ToneSD_Boavista: return "Loop_ToneSD_Boavista.wav"
case .sbArpeggio_Loop_River: return "Arpeggio_Loop_River.wav"
case .sbSquareArp_Loop_River: return "SquareArp_Loop_River.wav"
case .sbHighChords_Loop_River: return "HighChords_Loop_River.wav"
case .sbMatriarchFxs_Loop2_Collider: return "MatriarchFxs_Loop2_Collider.wav"
case .FF_SH_bowl_drone_tapping_C: return "FF_SH_bowl_drone_tapping_C.wav"
case .FF_SH_bowl_drone_tap_hold_E: return "FF_SH_bowl_drone_tap_hold_E.wav"
case .EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm: return "EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm.wav"
case .EX_ATSM_Bell_Binaural_Flam_Eb: return "EX_ATSM_Bell_Binaural_Flam_Eb.wav"
case .EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am: return "EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am.wav"
case .EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm: return "EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm.wav"
case .EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am: return "EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am.wav"
case .EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab: return "EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab.wav"
case .rain_soft: return "QP01 0011 Rain soft.wav"
case .stream1: return "QP01 0017 Stream sparkling.wav"
case .stream2: return "QP01 0018 Stream moderate.wav"
case .surf1: return "QP01 0023 Surf moderate sandy.wav"
case .crickets: return "QP01 0028 Insect crickets isolated.wav"
case .tropicalForestMorning: return "QP01 0037 Tropical forest morning.wav"
case .desertMorning: return "QP01 0130 Desert morning bird chorus.wav"
case .wetland: return "QP01 0096 Wetland lake early morning.wav"
case .riparianZone: return "QP01 0118 Riparian Zone thrush.wav"
case .ESM_Ambient_Game_Menu_Soft_Wood: return "ESM_Ambient_Game_Menu_Soft_Wood_Confirm_1_Notification_Button_Settings_UI.wav"
case .sbRose: return "rose1.mp3"
case .trancosoBowl: return "trancoso_bowl1.mp3"
case .natureOceanShore: return "QP01 0075 Ocean shore waves delicate birds.wav"
}
}
var playlist: Playlist {
switch self {
case .sbSEM_Synths_Loop4_Nothing_Like_You, .sbLoop_ToneSD_Boavista, .sbArpeggio_Loop_River, .sbSquareArp_Loop_River, .sbHighChords_Loop_River, .sbMatriarchFxs_Loop2_Collider, .sbRose:
case .trainhorn: return .fun
case .forestStream: return .nature
case .sbSEM_Synths_Loop4_Nothing_Like_You, .sbClave_Loop_LLL, .sbLoop_ToneSD_Boavista, .sbArpeggio_Loop_River, .sbSquareArp_Loop_River, .sbHighChords_Loop_River, .sbMatriarchFxs_Loop2_Collider:
return .stephanBodzin
case .FF_SH_bowl_drone_tapping_C, .FF_SH_bowl_drone_tap_hold_E, .EX_ATSM_Koshi_Chimes_Aria_Tuning_Texture_Longer_Dm, .EX_ATSM_Bell_Binaural_Flam_Eb, .EX_ATSM_160_Metal_Tonal_Percussion_Sansula_Loop_Call_Am, .EX_ATSM_125_Metal_Percussion_Wing_Loop_Chimey_Dm, .EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am, .EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab, .trancosoBowl:
return .relax
case .rain_soft, .stream1, .stream2, .surf1, .crickets, .tropicalForestMorning, .desertMorning, .wetland, .riparianZone, .natureOceanShore:
return .nature
case .ESM_Ambient_Game_Menu_Soft_Wood:
return .shorts
}
}
var isRestricted: Bool {
switch self {
case .sbSEM_Synths_Loop4_Nothing_Like_You, .sbLoop_ToneSD_Boavista, .FF_SH_bowl_drone_tapping_C, .EX_ATSM_Bell_Binaural_Flam_Eb, .tropicalForestMorning, .rain_soft, .ESM_Ambient_Game_Menu_Soft_Wood:
return false
default:
return true
}
}
var url: URL? {
let components = self.fileName.components(separatedBy: ".")
let components = self.soundName.components(separatedBy: ".")
if components.count == 2 {
return Bundle.main.url(forResource: components[0],
withExtension: components[1])
return Bundle.main.url(forResource: components[0], withExtension: components[1])
} else {
print("bad sound file name for \(self)")
return nil
}
}
func soundFile() throws -> SoundFile {
return try SoundFile(fullName: self.fileName)
return try SoundFile(fullName: self.soundName)
}
func duration() async throws -> TimeInterval {
@ -253,9 +142,9 @@ enum Sound: Int, CaseIterable, Identifiable, Localized {
static func computeSoundDurationsIfNecessary() {
Task {
for sound in Sound.allCases {
if Preferences.soundDurations[sound.fileName] == nil {
if Preferences.soundDurations[sound.rawValue] == nil {
if let duration = try? await sound.duration() {
Preferences.soundDurations[sound.fileName] = duration
Preferences.soundDurations[sound.rawValue] = duration
}
}
}
@ -263,7 +152,7 @@ enum Sound: Int, CaseIterable, Identifiable, Localized {
}
var formattedDuration: String {
if let duration = Preferences.soundDurations[self.fileName] {
if let duration = Preferences.soundDurations[self.rawValue] {
return duration.minuteSecond
} else {
return ""

@ -31,96 +31,47 @@ struct SoundFile {
enum SoundPlayerError : Error {
case missingResourceError(file: SoundFile)
case badFileName(name: String)
case playReturnedFalse
}
@objc class SoundPlayer: NSObject, AVAudioPlayerDelegate, ObservableObject {
@objc class SoundPlayer: NSObject, AVAudioPlayerDelegate {
fileprivate var _player: AVAudioPlayer?
fileprivate var _timer: Timer? = nil
@Published var currentFileName: String? = nil
func playSound(_ sound: Sound) throws {
try self._playSound(sound)
}
func playSound(_ sound: Sound, duration: TimeInterval) throws {
try self._playSound(sound, duration: duration)
}
fileprivate func _playSound(_ sound: Sound, duration: TimeInterval? = nil) throws {
try self.playOrPauseSound(sound.fileName, duration: duration)
}
func playOrPauseSound(_ file: String, duration: TimeInterval? = nil) throws {
if file == self.currentFileName {
if self._player?.isPlaying ?? false {
self._player?.stop()
self.currentFileName = nil
} else {
self._player?.play()
self.currentFileName = file
}
return
}
self.currentFileName = file
self._player?.stop()
let soundFile = try SoundFile(fullName: file)
if let duration {
try self.play(soundFile: soundFile, for: duration)
} else {
try self.playSound(soundFile: soundFile)
}
}
func play(soundFile: SoundFile, for duration: TimeInterval) throws {
try self.playSound(soundFile: soundFile)
self._timer = Timer(timeInterval: duration, repeats: false, block: { _ in
self._player?.stop()
})
}
func playSound(soundFile: SoundFile) throws {
func playSound(soundFile: SoundFile, repeats: Bool) throws {
guard let url = soundFile.url else {
throw SoundPlayerError.missingResourceError(file: soundFile)
}
let player = try AVAudioPlayer(contentsOf: url)
player.prepareToPlay()
player.volume = 1.0
player.delegate = self
// let audioSession: AVAudioSession = AVAudioSession.sharedInstance()
// try audioSession.setCategory(.playback)
// try audioSession.setActive(true)
self._player = player
_player = try AVAudioPlayer(contentsOf: url)
_player?.prepareToPlay()
// let loopCount = repeats ? Int.max : 0
// _player?.numberOfLoops = 0 //loopCount
_player?.volume = 1.0
_player?.delegate = self
// do {
// try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .defaultToSpeaker])
// } catch {
// print("audioSession error = \(error)")
// }
_player?.play()
player.play()
}
func stop() {
self._player?.stop()
self.currentFileName = nil
}
// func isSoundPlaying(_ sound: Sound) -> Bool {
// return sound.fileName == self.currentFileName && (self._player?.isPlaying ?? false)
// }
// MARK: - Delegate
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
self.currentFileName = nil
Conductor.maestro.deactivateAudioSessionIfPossible()
self.stop()
}
func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
if let error {
Logger.error(error)
}
}
}

@ -1,82 +0,0 @@
//
// StatePlayer.swift
// LeCountdown
//
// Created by Laurent Morvillier on 17/03/2023.
//
import Foundation
import AVFoundation
enum DemoError: Error {
case didNotFindDemoFile
}
class StatePlayer: NSObject, ObservableObject, AVAudioPlayerDelegate {
enum State {
case noResource
case playing
case paused
case none
var systemImage: String {
switch self {
case .paused, .none: return "play.circle"
case .playing: return "pause.circle"
case .noResource: return ""
}
}
}
@Published var audioPlayer: AVAudioPlayer? = nil
@Published var state: State = .noResource
@Published var completion: Double? = nil
var timer: Timer? = nil
override init() {
super.init()
self.timer = Timer(timeInterval: 1.0, repeats: true, block: { timer in
self.calculateCompletion()
})
}
func load(resource: String, ext: String) {
do {
guard let demoURL = Bundle.main.url(forResource: resource, withExtension: ext) else {
throw DemoError.didNotFindDemoFile
}
self.audioPlayer = try AVAudioPlayer(contentsOf: demoURL)
self.audioPlayer?.delegate = self
self.state = .none
} catch {
Logger.error(error)
}
}
func calculateCompletion() {
if let player = self.audioPlayer {
self.completion = player.currentTime / player.duration
}
self.completion = nil
}
func play() {
self.audioPlayer?.play()
self.state = .playing
}
func pause() {
self.audioPlayer?.pause()
self.state = .paused
}
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
self.state = .none
}
}

@ -36,15 +36,15 @@ extension NSManagedObjectContext {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Record")
let basePredicate = NSPredicate(format: "start != nil AND activity = %@", activity)
let basePredicate = NSPredicate(format: "start != nil")
var predicates: [NSPredicate] = []
predicates.append(basePredicate)
predicates.append(NSPredicate(format: "activity = %@", activity))
if let filter {
predicates.append(filter.predicate)
}
let finalPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
fetchRequest.predicate = finalPredicate
fetchRequest.predicate = NSCompoundPredicate.init(andPredicateWithSubpredicates: predicates)
fetchRequest.propertiesToFetch = expressions
fetchRequest.resultType = NSFetchRequestResultType.dictionaryResultType
@ -77,9 +77,10 @@ extension NSManagedObjectContext {
if let value {
let request = Record.fetchRequest()
request.predicate = finalPredicate
if let filter {
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [basePredicate, filter.predicate])
request.sortDescriptors = [NSSortDescriptor(key: "start", ascending: true)]
}
let records: [Record] = try self.fetch(request)
let points: [Point] = records.compactMap { $0.point(stat:stat) }

@ -28,7 +28,7 @@ enum Filter: Identifiable, Hashable {
switch self {
case .none: return NSLocalizedString("All", comment: "")
case .year(let year): return "\(year)"
case .month(let month): return month.localizedString.capitalized
case .month(let month): return month.localizedString
}
}
@ -55,13 +55,11 @@ fileprivate extension Int {
}
struct Month: Identifiable {
struct Month {
var month: Int
var year: Int
var id: Int { return self.year * 1000 + month }
var start: NSDate {
let components: DateComponents = DateComponents(year: self.year, month: self.month)
if let date = Calendar.current.date(from: components) {

@ -16,7 +16,7 @@ enum Stat: Int, CaseIterable {
var localizedName: String {
switch self {
case .count: return NSLocalizedString("Count", comment: "")
case .totalDuration: return NSLocalizedString("Total duration", comment: "")
case .totalDuration: return NSLocalizedString("Duration", comment: "")
case .averageDuration: return NSLocalizedString("Average duration", comment: "")
}
}
@ -56,14 +56,14 @@ enum Stat: Int, CaseIterable {
}
}
// var calendarYUnit: Calendar.Component? {
// switch self {
// case .totalDuration, .averageDuration:
// return .hour
// default:
// return nil
// }
// }
var calendarYUnit: Calendar.Component? {
switch self {
case .totalDuration, .averageDuration:
return .hour
default:
return nil
}
}
}
@ -111,7 +111,7 @@ struct StatValue: Identifiable {
let identifier: String
switch timeFrame {
// case .all: identifier = point.date.formattedYear
case .all: identifier = point.date.formattedYear
case .year: identifier = point.date.formattedMonth
case .month: identifier = point.date.formattedDay
}

@ -1,6 +1,6 @@
//
// AppGuard.swift
// LeCountdown
// Guard.swift
// Poker Analytics 6
//
// Created by Laurent Morvillier on 20/04/2022.
//
@ -14,16 +14,7 @@ public enum StoreError: Error {
enum StorePlan : String, CaseIterable {
case none
case monthly = "com.staxriver.enchant.monthly"
case yearly = "com.staxriver.enchant.yearly"
var formattedPeriod: String {
switch self {
case .none: return ""
case .monthly: return NSLocalizedString("month", comment: "")
case .yearly: return NSLocalizedString("year", comment: "")
}
}
case unlimited = "com.staxriver.lecountdown.unlimited"
}
extension Notification.Name {
@ -32,13 +23,11 @@ extension Notification.Name {
@objc class AppGuard : NSObject {
static let freeTimersCount: Int = 3
static var main: AppGuard = AppGuard()
@Published private(set) var purchasedTransactions = Set<StoreKit.Transaction>()
@Published var currentBestPlan: StoreKit.Transaction? = nil
var currentBestPlan: StoreKit.Transaction? = nil
var updateListenerTask: Task<Void, Error>? = nil
@ -125,32 +114,35 @@ extension Notification.Name {
return transaction
}
var isSubscriber: Bool {
return self.currentPlan != .none
var isAuthorized: Bool {
return self.currentPlan == .unlimited
}
var currentPlan: StorePlan {
return .yearly
// #if DEBUG
// return .yearly
// #else
// if let currentBestPlan = self.currentBestPlan,
// let plan = StorePlan(rawValue: currentBestPlan.productID) {
// return plan
// }
// return .none
// #endif
#if DEBUG
return .unlimited
#else
if let currentBestPlan = self.currentBestPlan, let plan = StorePlan(rawValue: currentBestPlan.productID) {
return plan
}
if let vf = Preferences.verifiedTransaction(),
vf.expiryDate > Date(), vf.graceDate > Date(),
let plan = StorePlan(rawValue: vf.productId) {
return plan
}
return .none
#endif
}
fileprivate func _updateBestPlan() {
if let monthly = self.purchasedTransactions.first(where: { $0.productID == StorePlan.monthly.rawValue }) {
self.currentBestPlan = monthly
} else if let yearly = self.purchasedTransactions.first(where: { $0.productID == StorePlan.yearly.rawValue }) {
self.currentBestPlan = yearly
if let unlimited = self.purchasedTransactions.first(where: { $0.productID == StorePlan.unlimited.rawValue }) {
self.currentBestPlan = unlimited
} else {
self.currentBestPlan = nil
}
}
}

@ -1,102 +0,0 @@
//
// Store.swift
// Poker Analytics 6
//
// Created by Laurent Morvillier on 20/04/2022.
//
import Foundation
import StoreKit
protocol StoreDelegate {
func productsReceived()
func errorDidOccur(error: Error)
}
class Store: ObservableObject {
@Published private(set) var products: [Product] = []
@Published private(set) var purchasedTransactions = Set<StoreKit.Transaction>()
var delegate: StoreDelegate? = nil
var updateListenerTask: Task<Void, Error>? = nil
init() {
self.updateListenerTask = listenForTransactions()
Task {
//Initialize the store by starting a product request.
await self.requestProducts()
}
}
deinit {
self.updateListenerTask?.cancel()
}
func indexOf(identifier: String) -> Int? {
return self.products.map { $0.id }.firstIndex(of: identifier)
}
@MainActor
func requestProducts() async {
do {
let identifiers: [String] = [StorePlan.monthly.rawValue, StorePlan.yearly.rawValue]
products = try await Product.products(for: identifiers)
// Logger.log("products = \(self.products.count)")
self.delegate?.productsReceived()
} catch {
self.delegate?.errorDidOccur(error: error)
Logger.error(error)
}
}
func listenForTransactions() -> Task<Void, Error> {
return Task.detached {
//Iterate through any transactions which didn't come from a direct call to `purchase()`.
for await result in Transaction.updates {
do {
let transaction = try await AppGuard.main.processTransactionResult(result)
//Always finish a transaction.
await transaction.finish()
} catch {
self.delegate?.errorDidOccur(error: error)
//StoreKit has a receipt it can read but it failed verification. Don't deliver content to the user.
print("Transaction failed verification")
}
}
}
}
func purchase(_ product: Product) async throws -> StoreKit.Transaction? {
// Begin a purchase.
let result = try await product.purchase()
switch result {
case .success(let verificationResult):
let transaction = try await AppGuard.main.processTransactionResult(verificationResult)
// Always finish a transaction.
await transaction.finish()
DispatchQueue.main.asyncAfter(deadline: DispatchTime(uptimeNanoseconds: 200000), execute: {
Conductor.maestro.playSound(Sound.EX_ATSM_140_Koshi_Chimes_Aria_Tuning_Loop_Wondering_Am)
NotificationCenter.default.post(name: Notification.Name.StoreEventHappened, object: nil)
})
return transaction
case .userCancelled, .pending:
return nil
default:
return nil
}
}
}

@ -6,216 +6,15 @@
//
import SwiftUI
import StoreKit
fileprivate enum Feature: Int, Identifiable, CaseIterable {
case unlimitedTimers
case allSounds
case longSounds
case allStats
var id: Int { self.rawValue }
var localizedString: String {
switch self {
case .unlimitedTimers: return NSLocalizedString("Unlimited timers and stopwatches", comment: "")
case .allSounds: return NSLocalizedString("Access all the sound library", comment: "")
case .longSounds: return NSLocalizedString("Access long version of sounds", comment: "")
case .allStats: return NSLocalizedString("See all your activities in detail", comment: "")
}
}
}
struct StoreView: View, StoreDelegate {
@StateObject private var store: Store = Store()
@State private var errorMessage: String? = nil
@Binding var isPresented: Bool
struct StoreView: View {
var body: some View {
Group {
if !self.store.products.isEmpty {
PlanView(isPresented: self.$isPresented)
.environmentObject(self.store)
} else {
ProgressView()
.progressViewStyle(.circular)
}
}.onAppear {
self._configure()
}
Text("Hello Store!")
}
fileprivate func _configure() {
self.store.delegate = self
}
// MARK: - StoreDelegate
func productsReceived() {
}
func errorDidOccur(error: Error) {
}
}
struct PlanView: View {
@EnvironmentObject var store: Store
@State var _loadingProduct: String? = nil
@State var _purchased: Bool = false
@Binding var isPresented: Bool
var body: some View {
VStack {
Text("Permanent enchantment")
.font(.title)
.fontWeight(.bold)
.padding()
Group {
ForEach(Feature.allCases) { feature in
HStack {
Text(feature.localizedString)
Spacer()
Image(systemName: "checkmark.circle.fill")
.foregroundColor(.accentColor)
}.fontWeight(.medium)
}
}
.padding(.horizontal, 24.0)
.padding(.vertical, 2.0)
Spacer()
PlayerWrapperView()
Text("Purchase").font(.title)
ForEach(self.store.products) { product in
Button {
self._purchase(product: product)
} label: {
HStack {
Spacer()
if product.id == self._loadingProduct {
if self._purchased {
Image(systemName: "checkmark.circle.fill")
} else {
ProgressView()
.progressViewStyle(.circular).tint(.white)
}
} else {
VStack {
if let plan = StorePlan(rawValue: product.id) {
Text("\(product.displayPrice) / \(plan.formattedPeriod)").font(.title3)
.foregroundColor(.white)
} else {
Text("Plan not found")
}
}
}
Spacer()
}.frame(height: 44.0)
}
.buttonStyle(.borderedProminent)
.fontWeight(.medium)
}
}.padding()
.foregroundColor(.black)
.background(Color(red: 1.0, green: 0.8, blue: 0.9))
}
fileprivate func _purchase(product: Product) {
Task {
self._loadingProduct = product.id
let result = try await store.purchase(product)
switch result {
case .none:
self._loadingProduct = nil
case .some:
self._purchased = true
self.isPresented = false
}
}
}
}
struct PlayerWrapperView: View {
@StateObject var player: StatePlayer = StatePlayer()
var body: some View {
let state = self.player.state
Group {
switch state {
case .none, .paused:
Button {
self._pause()
} label: {
HStack {
Image(systemName: state.systemImage)
Spacer()
ProgressView(value: self.player.completion)
}.font(.title)
}.buttonStyle(.borderedProminent)
.fontWeight(.medium)
case .playing:
Button {
self._pause()
} label: {
HStack {
Image(systemName: state.systemImage)
Spacer()
ProgressView(value: self.player.completion)
// Slider(value: self.$player.completion)
}.font(.title)
}.buttonStyle(.borderedProminent)
.fontWeight(.medium)
case .noResource:
EmptyView()
}
}
.onAppear {
self.player.load(resource: "QP01 0118 Riparian Zone thrush", ext: "wav")
}
}
fileprivate func _play() {
self.player.play()
}
fileprivate func _pause() {
self.player.pause()
}
}
struct StoreView_Previews: PreviewProvider {
static var previews: some View {
PlanView(isPresented: .constant(false)).environmentObject(Store())
StoreView()
}
}

@ -1,39 +0,0 @@
//
// SubscriptionButtonView.swift
// LeCountdown
//
// Created by Laurent Morvillier on 05/04/2023.
//
import SwiftUI
struct SubscriptionButtonView: View {
@State private var showSubscriptionSheet: Bool = false
var body: some View {
Button {
self.showSubscriptionSheet = true
} label: {
Text("Get fully enchanted")
.frame(maxWidth: .infinity)
}
.sheet(isPresented: self.$showSubscriptionSheet, content: {
StoreView(isPresented: self.$showSubscriptionSheet)
})
.frame(height: 50.0)
.background(Color.accentColor)
.cornerRadius(8.0)
.padding(.horizontal)
.buttonStyle(.bordered)
.foregroundColor(.white)
}
}
struct SubscriptionButtonView_Previews: PreviewProvider {
static var previews: some View {
SubscriptionButtonView()
}
}

@ -31,14 +31,11 @@ class TimerRouter {
}
static func performAction(timer: AbstractTimer, handler: @escaping (Result<Void, Error>) -> Void) {
BoringContext.main.reset() // should put the app back on the main screen
switch timer {
case let countdown as Countdown:
self._launchCountdown(countdown.stringId, handler: handler)
self._launchCountdown(countdown, handler: handler)
case let stopwatch as Stopwatch:
self._startStopwatch(stopwatch.stringId, handler: handler)
self._startStopwatch(stopwatch, handler: handler)
case let alarm as Alarm:
self._scheduleAlarm(alarm, handler: handler)
default:
@ -48,17 +45,24 @@ class TimerRouter {
}
fileprivate static func _launchCountdown(_ countdownId: TimerID, handler: @escaping (Result<Void, Error>) -> Void) {
static func stopTimer(timer: AbstractTimer) {
switch timer {
case let countdown as Countdown:
Conductor.maestro.cancelCountdown(id: countdown.stringId)
case let stopwatch as Stopwatch:
self._stopStopwatch(stopwatch)
default:
print("missing launcher for \(self)")
}
}
fileprivate static func _launchCountdown(_ countdown: Countdown, handler: @escaping (Result<Void, Error>) -> Void) {
UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async {
guard let countdown = IntentDataProvider.main.timer(id: countdownId) as? Countdown else {
handler(.failure(AppError.timerNotFound(id: countdownId)))
return
}
switch settings.authorizationStatus {
case .notDetermined, .denied:
handler(.failure(TimerRouterError.notificationAuthorizationMissing))
default:
CountdownScheduler.master.scheduleIfPossible(countdown: countdown) { result in
switch result {
case .success:
@ -76,12 +80,12 @@ class TimerRouter {
}
fileprivate static func _startStopwatch(_ stopwatchId: TimerID, handler: @escaping (Result<Void, Error>) -> Void) {
Conductor.maestro.startStopwatch(stopwatchId)
fileprivate static func _startStopwatch(_ stopwatch: Stopwatch, handler: @escaping (Result<Void, Error>) -> Void) {
Conductor.maestro.startStopwatch(stopwatch)
handler(.success(Void()))
}
fileprivate static func _stopStopwatch(_ stopwatch: Stopwatch) {
fileprivate static func _stopStopwatch(_ stopwatch: Stopwatch, end: Date? = nil) {
Conductor.maestro.stopStopwatch(stopwatch)
}

@ -1,39 +0,0 @@
//
// MyError.swift
// LeCountdown
//
// Created by Laurent Morvillier on 17/05/2023.
//
import Foundation
enum AppError: LocalizedError {
case defaultError(error: Error)
case timerNotFound(id: String)
case timerNotManaged(timer: AbstractTimer)
var errorDescription: String? {
switch self {
case .defaultError(let error):
return error.localizedDescription
case .timerNotFound(let id):
return "Timer not found: \(id)"
case .timerNotManaged(let timer):
return "Timer not managed: \(timer.stringId)"
}
}
var errorMessage: String? {
switch self {
case .defaultError(let error):
return error.localizedDescription
case .timerNotFound:
return "Timer not found"
case .timerNotManaged:
return "Timer not managed"
}
}
}

@ -8,18 +8,31 @@
import Foundation
import MediaPlayer
class AppleMusicPlayer {
@objc class AppleMusicPlayer : NSObject, MPMediaPickerControllerDelegate {
let mediaItemCollection: MPMediaItemCollection
init(mediaItemCollection: MPMediaItemCollection) {
self.mediaItemCollection = mediaItemCollection
}
func play() {
let musicPlayer = MPMusicPlayerApplicationController.applicationQueuePlayer
musicPlayer.setQueue(with: self.mediaItemCollection)
musicPlayer.play()
}
// func play() {
//
// let musicPlayer = MPMusicPlayerApplicationController.applicationQueuePlayer
// musicPlayer.setQueue(with: .songs())
//
// }
//
// func showMediaPicker(source: UIView) {
// let controller = MPMediaPickerController(mediaTypes: .music)
// controller.allowsPickingMultipleItems = true
// controller.popoverPresentationController?.sourceView = source
// controller.delegate = self
//// present(controller, animated: true)
// }
//
// func mediaPicker(_ mediaPicker: MPMediaPickerController, didPickMediaItems mediaItemCollection: MPMediaItemCollection) {
//
//
//// let p = MPMediaItemCollection(items: [MPMediaItem])
//
// let i = MPMediaItem()
// i.
//
// }
}

@ -1,31 +0,0 @@
//
// BoringContext.swift
// LeCountdown
//
// Created by Laurent Morvillier on 22/05/2023.
//
import Foundation
import SwiftUI
class BoringContext : ObservableObject {
static let main: BoringContext = BoringContext()
@Published var isShowingNewData = false
@Published var error: Error?
@Published var showDefaultAlert: Bool = false
@Published var showPermissionAlert: Bool = false
@Published var siriTimer: AbstractTimer? = nil
func reset() {
DispatchQueue.main.async {
self.isShowingNewData = false
self.showDefaultAlert = false
self.showPermissionAlert = false
}
}
}

@ -1,60 +0,0 @@
//
// Codable+Extensions.swift
// LeCountdown
//
// Created by Laurent Morvillier on 10/04/2023.
//
import Foundation
extension Encodable {
var jsonString: String? {
if let data = self.jsonData {
return String(data: data, encoding: .utf8)
} else {
return nil
}
}
var jsonData: Data? {
let encoder: JSONEncoder = JSONEncoder()
encoder.outputFormatting = .prettyPrinted
do {
return try encoder.encode(self)
} catch {
Logger.error(error)
return nil
}
}
}
extension String {
func decode<T : Decodable>() -> T? {
return self.data(using: .utf8)?.decode()
}
func decodeArray<T : Decodable>() throws -> [T]? {
return try self.data(using: .utf8)?.decodeArray()
}
}
extension Data {
func decode<T : Decodable>() -> T? {
do {
return try JSONDecoder().decode(T.self, from: self)
} catch {
Logger.error(error)
return nil
}
}
func decodeArray<T : Decodable>() throws -> [T] {
return try JSONDecoder().decode([T].self, from: self)
}
}

@ -7,12 +7,6 @@
import Foundation
extension Date: Identifiable {
public var id: TimeInterval { return self.timeIntervalSince1970 }
}
extension Date {
static let monthYearFormatter = {
@ -28,23 +22,10 @@ extension Date {
return df
}()
static let dateTimeFormatter = {
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .medium
return df
}()
var startOfDay: Date {
return Calendar.current.startOfDay(for: self)
}
var startOfMonth: Date {
let calendar = Calendar(identifier: .gregorian)
let components = calendar.dateComponents([.year, .month], from: self)
return calendar.date(from: components)!
}
func get(_ component: Calendar.Component, calendar: Calendar = Calendar.current) -> Int {
return calendar.component(component, from: self)
}
@ -53,8 +34,7 @@ extension Date {
var month: Int { self.get(.month) }
var formattedYear: String { return "\(self.year)" }
var formattedMonth: String { return Date.monthYearFormatter.string(from: self).capitalized }
var formattedMonth: String { return Date.monthYearFormatter.string(from: self) }
var formattedDay: String { return Date.dayFormatter.string(from: self) }
var formattedDateTime: String { return Date.dateTimeFormatter.string(from: self) }
}

@ -1,57 +0,0 @@
//
// Extensions.swift
// LeCountdown
//
// Created by Laurent Morvillier on 28/03/2023.
//
import Foundation
// MARK: - Storage convenience
let idSeparator = "|"
let numberFormatter: NumberFormatter = NumberFormatter()
extension String {
func enumItems<T : RawRepresentable<Int>>() -> [T] {
let ids: [String] = self.components(separatedBy: idSeparator)
let intIds: [Int] = ids.compactMap { numberFormatter.number(from: $0)?.intValue }
return intIds.compactMap { T(rawValue: $0) }
}
}
extension Sequence where Element : StringRepresentable {
var stringRepresentation: String {
let ids = self.compactMap { $0.stringValue }
return ids.joined(separator: idSeparator)
}
}
protocol StringRepresentable {
var stringValue: String { get }
}
extension String: StringRepresentable {
var stringValue: String { self }
}
extension Bundle {
var applicationName: String {
if let displayName: String = self.localizedInfoDictionary?["CFBundleDisplayName"] as? String {
return displayName
} else if let name: String = self.localizedInfoDictionary?["CFBundleName"] as? String {
return name
} else if let name = self.infoDictionary?["CFBundleDisplayName"] as? String {
return name
}
return "No Name Found"
}
var version: String {
return Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "no version"
}
}

@ -1,83 +0,0 @@
//
// FileLogger.swift
// LeCountdown
//
// Created by Laurent Morvillier on 10/04/2023.
//
import Foundation
struct Log: Identifiable, Codable {
var id: String = UUID().uuidString
var date: Date
var file: String
var line: Int
var function: String
var message: String
var content: String {
return "\(file).\(line).\(function): \(message)"
}
}
class FileLogger {
fileprivate let fileName = "logs.json"
static var main: FileLogger = FileLogger()
var logs: [Log]
var timer: Timer? = nil
init() {
self.logs = []
do {
let content = try FileUtils.readDocumentFile(fileName: self.fileName)
if let logs: [Log] = try content.decodeArray() {
self.logs = logs
} else {
Logger.w("Log decoding failed")
}
} catch {
Logger.error(error)
}
}
func addLog(_ log: Log) {
self.logs.append(log)
self.logs = self.logs.suffix(50)
self._scheduleWrite()
}
fileprivate func _scheduleWrite() {
self.timer?.invalidate()
self.timer = Timer.scheduledTimer(withTimeInterval: 5.0, repeats: false, block: { _ in
self._writeLogs()
})
}
fileprivate func _writeLogs() {
DispatchQueue(label: "app.enchant.write", qos: .utility).async {
if let json = self.logs.jsonString {
do {
let _ = try FileUtils.writeToDocumentDirectory(content: json, fileName: self.fileName)
} catch {
Logger.error(error)
}
}
}
}
@objc static public func log(_ message: String, file: String = #file, function: String = #function, line: Int = #line) {
let filestr = NSString(string: file)
let log = Log(date: Date(), file: filestr.lastPathComponent, line: line, function: function, message: message)
FileLogger.main.addLog(log)
}
}

@ -1,54 +0,0 @@
//
// FileUtils.swift
// LeCountdown
//
// Created by Laurent Morvillier on 10/04/2023.
//
import Foundation
enum FileError : Error {
case documentDirectoryNotFound
}
class FileUtils {
static func pathsFromDocumentsDirectory() throws -> [String] {
let documentsURL: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask)[0]
return try FileManager.default.contentsOfDirectory(atPath: documentsURL.path)
}
static func readDocumentFile(fileName: String) throws -> String {
if let dir: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let fileURL: URL = dir.appendingPathComponent(fileName)
return try String(contentsOf: fileURL, encoding: .utf8)
}
throw FileError.documentDirectoryNotFound
}
static func readFile(fileURL: URL) throws -> String {
return try String(contentsOf: fileURL, encoding: .utf8)
}
static func writeToDocumentDirectory(content: String, fileName: String) throws -> URL {
if let dir: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let fileURL: URL = dir.appendingPathComponent(fileName)
try content.write(to: fileURL, atomically: false, encoding: .utf8)
return fileURL
}
throw FileError.documentDirectoryNotFound
}
@discardableResult static func writeToDocumentDirectory(data: Data, fileName: String) throws -> URL {
if let dir: URL = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let fileURL: URL = dir.appendingPathComponent(fileName)
try data.write(to: fileURL)
Logger.log("Wrote file = \(fileURL.absoluteString)")
return fileURL
}
throw FileError.documentDirectoryNotFound
}
}

@ -6,7 +6,6 @@
//
import Foundation
import Firebase
@objc public class Logger : NSObject {
@ -29,7 +28,6 @@ import Firebase
}
}
print("ERROR: \(filestr.lastPathComponent).\(line).\(function): \(fireBaseError)")
Crashlytics.crashlytics().record(error: error)
}
@objc static public func w(_ message: Any, file: String = #file, function: String = #function, line: Int = #line) {

@ -8,40 +8,20 @@
import Foundation
enum PreferenceKey: String {
case hasShownStartView
case installDate
case countdowns
case pausedCountdowns
case stopwatches
case playConfirmationSound
case playCancellationSound
case showSilentModeAlert
case soundDurations
case lastSoundPlayedByTimer
case lastSoundPlayed
case tips
case timerSiriTips
case cloudKitSchemaInitialized
case defaultVolume
case raiseSoundOnLaunch
}
class Preferences {
@UserDefault(PreferenceKey.countdowns.rawValue, defaultValue: [:]) static var savedCountdowns: [String : DateInterval]
@UserDefault(PreferenceKey.soundDurations.rawValue, defaultValue: [:]) static var soundDurations: [String : TimeInterval]
@UserDefault(PreferenceKey.lastSoundPlayedByTimer.rawValue, defaultValue: [:]) static var lastSelectedSoundByTimer: [String : Int]
@UserDefault(PreferenceKey.lastSoundPlayed.rawValue, defaultValue: nil) static var lastSoundPlayed: Int?
@UserDefault(PreferenceKey.soundDurations.rawValue, defaultValue: [:]) static var soundDurations: [Int : TimeInterval]
@UserDefault(PreferenceKey.lastSoundPlayed.rawValue, defaultValue: [:]) static var lastSelectedSound: [String : Int]
@UserDefault(PreferenceKey.tips.rawValue, defaultValue: nil) static var lastShownTip: Int?
@UserDefault(PreferenceKey.timerSiriTips.rawValue, defaultValue: []) static var timerSiriTips: Set<String>
@UserDefault(PreferenceKey.playConfirmationSound.rawValue, defaultValue: true) static var playConfirmationSound: Bool
@UserDefault(PreferenceKey.playCancellationSound.rawValue, defaultValue: true) static var playCancellationSound: Bool
@UserDefault(PreferenceKey.cloudKitSchemaInitialized.rawValue, defaultValue: false) static var cloudKitSchemaInitialized: Bool
@UserDefault(PreferenceKey.defaultVolume.rawValue, defaultValue: 0.5) static var defaultVolume: Float
@UserDefault(PreferenceKey.installDate.rawValue, defaultValue: nil) static var installDate: Date?
@UserDefault(PreferenceKey.hasShownStartView.rawValue, defaultValue: false) static var hasShownStartView: Bool
@UserDefault(PreferenceKey.raiseSoundOnLaunch.rawValue, defaultValue: false) static var raiseSoundOnLaunch: Bool
static var hideSilentModeAlerts: Bool {
return UserDefaults.standard.bool(forKey: PreferenceKey.showSilentModeAlert.rawValue)

@ -13,7 +13,7 @@ import UniformTypeIdentifiers
enum Shortcut: String {
case newCountdown = "app.enchant.NewCountdown"
case newCountdown = "app.kikai.NewCountdown"
var userActivity: NSUserActivity {
let activity = NSUserActivity(activityType: self.rawValue)

@ -20,14 +20,13 @@ class TextToSpeechRecorder {
let utterance = AVSpeechUtterance(string: speech)
utterance.voice = AVSpeechSynthesisVoice(language: "fr-FR")
// synthesizer.speak(utterance)
// return
synthesizer.speak(utterance)
return
var output: AVAudioFile?
synthesizer.write(utterance) { buffer in
guard let pcmBuffer = buffer as? AVAudioPCMBuffer else {
FileLogger.log("app terminated by ourselves")
fatalError("unknown buffer type: \(buffer)")
}
let fileName = "\(UUID().uuidString).caf"

@ -9,18 +9,9 @@ import Foundation
extension TimeInterval {
var hourMinuteSecond: String {
let h = self.hour
if h > 0 {
return String(format:"%d:%02d:%02d", hour, minute, second)
} else {
return self.minuteSecond
}
}
var hourMinuteSecondHS: String {
let h = self.hour
if h > 0 {
if h > 1 {
return String(format:"%d:%02d:%02d", hour, minute, second)
} else {
return String(format:"%02d:%02d.%02d", minute, second, hundredth)
@ -48,11 +39,4 @@ extension TimeInterval {
Int((self*100).truncatingRemainder(dividingBy: 100))
}
var timeFormatted: String {
let dateformatter = DateFormatter()
dateformatter.dateStyle = .none
dateformatter.timeStyle = .short
return dateformatter.string(from: Date(timeIntervalSince1970: self))
}
}

@ -8,28 +8,22 @@
import Foundation
enum Tip: Int, CaseIterable, Identifiable {
// case siri
case siri
case widget
var id: Int { self.rawValue }
var localizedString: String {
switch self {
case .widget: return NSLocalizedString("Widget Tip", comment: "")
// case .siri: return NSLocalizedString("You can ask Siri to create and launch countdowns and stopwatches", comment: "")
case .widget: return NSLocalizedString("You can add widget for your timers and countdowns by modifying your home or lock screen", comment: "")
case .siri: return NSLocalizedString("You can ask Siri to create and launch countdowns and stopwatches", comment: "")
}
}
var pictoName: String {
switch self {
// case .siri: return "wave.3.right" //"dot.radiowaves.right"
case .siri: return "wave.3.right" //"dot.radiowaves.right"
case .widget: return "app.badge"
}
}
var link: URL? {
switch self {
case .widget: return URL(string: "https://support.apple.com/en-us/HT207122")
}
}
}

@ -17,15 +17,4 @@ extension UIDevice {
@objc static var isPhoneIdiom: Bool {
return UIDevice.current.userInterfaceIdiom == .phone
}
static var modelName: String {
var systemInfo = utsname()
uname(&systemInfo)
let machineMirror = Mirror(reflecting: systemInfo.machine)
return machineMirror.children.reduce("") { identifier, element in
guard let value = element.value as? Int8, value != 0 else { return identifier }
return identifier + String(UnicodeScalar(UInt8(value)))
}
}
}

@ -1,13 +0,0 @@
//
// URLs.swift
// LeCountdown
//
// Created by Laurent Morvillier on 07/10/2023.
//
import Foundation
enum URLs: String {
case mail = "hello@getenchanted.app"
case appLink = "https://apps.apple.com/app/enchant-amazing-timers/id6446093767"
}

@ -9,83 +9,135 @@ import SwiftUI
import CoreData
import Combine
struct ContentView<T : AbstractTimer>: View {
class BoringContext : ObservableObject {
@Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject var conductor: Conductor
@Environment(\.colorScheme) var colorScheme
@Published var isShowingNewData = false
@Published var error: Error?
@Published var showDefaultAlert: Bool = false
@Published var showPermissionAlert: Bool = false
@StateObject var boringContext: BoringContext = BoringContext.main
}
@State private var isEditing: Bool = false
class TimerSpot : Identifiable, Equatable {
@State private var showTip: Tip? = nil
var id: Int16 { return self.order }
@State private var siriTipShown: Bool = false
var order: Int16
var timer: AbstractTimer?
@State var showAddSheet: Bool = false
@State private var showSettingsSheet: Bool = false
@State private var showStatsSheet: Bool = false
@State private var showSubscriptionSheet: Bool = false
init(order: Int16, timer: AbstractTimer? = nil) {
self.order = order
self.timer = timer
}
func setOrder(order: Int16) {
self.order = order
self.timer?.order = order
}
static func == (lhs: TimerSpot, rhs: TimerSpot) -> Bool {
return lhs.order == rhs.order && lhs.timer?.stringId == rhs.timer?.stringId
}
}
struct ContentView<T : AbstractTimer>: View {
@StateObject var boringContext: BoringContext = BoringContext()
@EnvironmentObject var conductor: Conductor
@Environment(\.managedObjectContext) private var viewContext
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \AbstractTimer.order, ascending: true)],
sortDescriptors: [NSSortDescriptor(keyPath: \T.order, ascending: true)],
animation: .default)
private var timers: FetchedResults<AbstractTimer>
private var timers: FetchedResults<T>
@State private var isEditing: Bool = false
@State private var tipsShown: Bool = false
fileprivate let itemSpacing: CGFloat = 10.0
var body: some View {
let columns: [GridItem] = self._columns()
GeometryReader { reader in
let width: CGFloat = reader.size.width / CGFloat(columns.count) - 15.0
VStack {
if !AppGuard.main.isSubscriber {
SubscriptionButtonView()
}
ScrollView {
TimersView(isEditing: self.$isEditing,
showSubscriptionSheet: self.$showSubscriptionSheet,
showAddSheet: self.$showAddSheet,
siriHandler: { timer in
self._handleSiriTips(timer: timer)
})
.environment(\.managedObjectContext, viewContext)
.environmentObject(self.boringContext)
LazyVGrid(
columns: columns,
spacing: itemSpacing
) {
if !conductor.liveTimers.isEmpty {
ReorderableForEach(items: Array(timers)) { timer in
Spacer()
DialView(timer: timer, isEditingBinding: self.$isEditing, frameSize: width)
.environment(\.managedObjectContext, viewContext)
.environmentObject(Conductor.maestro)
.environmentObject(boringContext)
} moveAction: { from, to in
self._reorder(from: from, to: to)
}
#if !TARGET_IPHONE_SIMULATOR
SiriVolumeView(timer: self.boringContext.siriTimer, siriTipShown: self.$siriTipShown)
#endif
}
}.padding(.horizontal, itemSpacing)
if !self.tipsShown, let tip = Preferences.tipToShow {
TipView(tip: tip) {
self._hideTip(tip)
}.padding()
}
if !conductor.liveTimers.isEmpty {
LiveTimerListView()
.environment(\.managedObjectContext, viewContext)
.environmentObject(conductor)
.padding(.horizontal, 12.0)
.foregroundColor(.black)
.background(self._backgroundColor)
.foregroundColor(.white)
.background(Color(white: 0.1))
.cornerRadius(32.0, corners: [.topRight, .topLeft])
} else if let tip = self.showTip, self.timers.count > 0 {
TipView(tip: tip) {
Preferences.lastShownTip = tip.rawValue
self.showTip = nil
}.padding()
}
}
.navigationTitle(Bundle.main.applicationName)
}
.navigationTitle("Home")
.alert(boringContext.error?.localizedDescription ?? "missing error", isPresented: $boringContext.showDefaultAlert) {
Button("OK", role: .cancel) { }
}
.alert("You need to accept notifications, please check your settings", isPresented: $boringContext.showPermissionAlert) {
PermissionAlertView()
}
.sheet(isPresented: self.$boringContext.isShowingNewData, content: {
.sheet(isPresented: $boringContext.isShowingNewData, content: {
// self._newView(isPresented: $boringContext.isShowingNewData)
// .environment(\.managedObjectContext, viewContext)
})
.toolbar {
MainToolbarView(isEditing: self.$isEditing, showAddSheet: self.$showAddSheet)
// ToolbarItem(placement: .navigationBarTrailing) {
// Button {
// self.boringContext.isShowingNewData = true
// } label: {
// HStack {
// Image(systemName: "plus")
// }
// }
// }
ToolbarItem(placement: .navigationBarTrailing) {
Button {
withAnimation {
self.isEditing.toggle()
}
} label: {
Text(self.isEditing ? "Done" : "Edit")
}
}
}
.onAppear {
self.showTip = Preferences.tipToShow
self._askPermissions()
// self.showLiveTimersSheet = !conductor.liveTimers.isEmpty
}
.onOpenURL { url in
self._performActionIfPossible(url: url)
@ -93,24 +145,49 @@ struct ContentView<T : AbstractTimer>: View {
}
fileprivate var _backgroundColor: Color {
return Color(white: self.colorScheme == .dark ? 0.1 : 0.9)
}
fileprivate func _handleSiriTips(timer: AbstractTimer) {
let timerId = timer.stringId
if !Preferences.timerSiriTips.contains(timerId) {
self.boringContext.siriTimer = timer
Preferences.timerSiriTips.insert(timerId)
self.siriTipShown = true
fileprivate func _columnCount() -> Int {
#if os(iOS)
if UIDevice.isPhoneIdiom {
return 2
} else {
self.boringContext.siriTimer = nil
self.siriTipShown = false
return 3
}
#else
return 3
#endif
}
fileprivate func _columns() -> [GridItem] {
return (0..<self._columnCount()).map { _ in GridItem(spacing: 10.0) }
}
// MARK: - Business
fileprivate func _hideTip(_ tip: Tip) {
Preferences.lastShownTip = tip.rawValue
self.tipsShown = true
}
fileprivate func _reorder(from: IndexSet, to: Int) {
var timers: [AbstractTimer] = Array(self.timers)
timers.move(fromOffsets: from, toOffset: to)
for (i, countdown) in timers.enumerated() {
countdown.order = Int16(i)
}
do {
try viewContext.save()
} catch {
Logger.error(error)
self.boringContext.error = error
}
}
fileprivate func _askPermissions() {
UNUserNotificationCenter.current().requestAuthorization(options: [.alert, .sound]) { success, error in
print("requestAuthorization > success = \(success), error = \(String(describing: error))")
}
}
fileprivate func _performActionIfPossible(url: URL) {
// hide new window if launching a timer
@ -119,7 +196,7 @@ struct ContentView<T : AbstractTimer>: View {
print("_performActionIfPossible")
let urlString = url.absoluteString
if let timer = self.viewContext.object(stringId: urlString) as? AbstractTimer {
if let timer = viewContext.object(stringId: urlString) as? AbstractTimer {
if timer is T {
print("performAction")
@ -138,8 +215,11 @@ struct ContentView<T : AbstractTimer>: View {
}
}
}
}
// print("Start countdown: \(countdown.name ?? ""), \(countdown.duration)")
// self._launchCountdown(countdown)
} else {
print("timer not found with id = \(urlString)")
}
@ -148,64 +228,37 @@ struct ContentView<T : AbstractTimer>: View {
}
struct MainToolbarView: ToolbarContent {
struct MainToolbarView: View {
@Binding var isEditing: Bool
@Binding var showAddSheet: Bool
var isShowingNewData: Binding<Bool>
@State var showSettingsSheet: Bool = false
@State var showStatsSheet: Bool = false
@FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \AbstractTimer.order, ascending: true)],
animation: .default)
private var timers: FetchedResults<AbstractTimer>
@FetchRequest(sortDescriptors: [NSSortDescriptor(keyPath: \Record.start, ascending: true)])
private var records: FetchedResults<Record>
var body: some ToolbarContent {
if !self.timers.isEmpty {
ToolbarItem(placement: .navigationBarLeading) {
var body: some View {
Button {
withAnimation {
self.isEditing.toggle()
}
self.isShowingNewData.wrappedValue = true
} label: {
Text(self.isEditing ? "Done" : "Edit")
HStack {
Image(systemName: "timer")
Text("countdown")
}
}
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
if !self.timers.isEmpty {
Button {
withAnimation {
self.showSettingsSheet.toggle()
}
self.isShowingNewData.wrappedValue = true
} label: {
Image(systemName: "gearshape.fill")
HStack {
Image(systemName: "stopwatch")
Text("stopwatch")
}
.sheet(isPresented: self.$showSettingsSheet, content: {
NavigationStack {
SettingsView()
.navigationBarTitleDisplayMode(.inline)
}.presentationDetents([.height(400.0)])
})
}
Button {
withAnimation {
self.showAddSheet.toggle()
}
self.isShowingNewData.wrappedValue = true
} label: {
Image(systemName: "plus")
HStack {
Image(systemName: "alarm")
Text("alarm")
}
.sheet(isPresented: self.$showAddSheet, content: {
StartView(isPresented: self.$showAddSheet)
})
}
}
}
fileprivate extension Countdown {
@ -218,40 +271,19 @@ fileprivate extension Countdown {
return endDate != nil
}
}
class TimerSpot : Identifiable, Equatable {
var id: Int16 { return self.order }
var order: Int16
var timer: AbstractTimer?
init(order: Int16, timer: AbstractTimer? = nil) {
self.order = order
self.timer = timer
}
func setOrder(order: Int16) {
self.order = order
self.timer?.order = order
}
// var colorForStatus: Color {
// if isLive {
// return Color(red: 0.9, green: 1.0, blue: 0.95)
// } else {
// return Color(red: 0.9, green: 0.95, blue: 1.0)
// }
// }
static func == (lhs: TimerSpot, rhs: TimerSpot) -> Bool {
return lhs.order == rhs.order && lhs.timer?.stringId == rhs.timer?.stringId
}
}
struct Toolbar_Previews: PreviewProvider {
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
NavigationStack {
ContentView<AbstractTimer>()
.environmentObject(Conductor.maestro)
}
.navigationTitle("Title")
.toolbar {
MainToolbarView(isEditing: .constant(false), showAddSheet: .constant(false))
}
ContentView<Countdown>().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) .environmentObject(Conductor.maestro)
}
}

Some files were not shown because too many files have changed in this diff Show More

Loading…
Cancel
Save