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 import Intents
class IntentHandler: INExtension, SelectTimerIntentHandling { class IntentHandler: INExtension, SelectTimerIntentHandling, LaunchTimerIntentHandling {
//
// // MARK: - SelectTimerIntentHandling // MARK: - SelectTimerIntentHandling
//
// func resolveTimer(for intent: LaunchTimerIntent) async -> TimerIdentifierResolutionResult { func resolveTimer(for intent: LaunchTimerIntent) async -> TimerIdentifierResolutionResult {
// if let timer = intent.timer { if let timer = intent.timer {
// print("resolveTimer(for intent: LaunchTimerIntent) success !") print("resolveTimer(for intent: LaunchTimerIntent) success !")
// return .success(with: timer) return .success(with: timer)
// } }
// print("resolveTimer(for intent: LaunchTimerIntent) needsValue") print("resolveTimer(for intent: LaunchTimerIntent) needsValue")
// return .needsValue() return .needsValue()
// } }
//
// func handle(intent: LaunchTimerIntent) async -> LaunchTimerIntentResponse { func handle(intent: LaunchTimerIntent) async -> LaunchTimerIntentResponse {
// if let timerIdentifier = intent.timer, if let timerIdentifier = intent.timer,
// let timerId = timerIdentifier.identifier, let timerId = timerIdentifier.identifier,
// let timer = IntentDataProvider.main.timer(id: timerId) { let timer = IntentDataProvider.main.timer(id: timerId) {
// do { do {
// let _ = try await TimerRouter.performAction(timer: timer) let _ = try await TimerRouter.performAction(timer: timer)
// print("handle(intent: LaunchTimerIntent) success !") print("handle(intent: LaunchTimerIntent) success !")
// return .success(timer: timerIdentifier) return .success(timer: timerIdentifier)
// } catch { } catch {
// return LaunchTimerIntentResponse(code: LaunchTimerIntentResponseCode(rawValue: 1)!, userActivity: nil) return LaunchTimerIntentResponse(code: LaunchTimerIntentResponseCode(rawValue: 1)!, userActivity: nil)
// } }
// } }
// print("handle(intent: LaunchTimerIntent) no timer") print("handle(intent: LaunchTimerIntent) no timer")
// return LaunchTimerIntentResponse(code: LaunchTimerIntentResponseCode(rawValue: 1)!, userActivity: nil) return LaunchTimerIntentResponse(code: LaunchTimerIntentResponseCode(rawValue: 1)!, userActivity: nil)
// } }
// MARK: - SelectTimerIntentHandling // MARK: - SelectTimerIntentHandling
@ -58,7 +58,7 @@ class IntentHandler: INExtension, SelectTimerIntentHandling {
let displayName: String let displayName: String
switch timer { switch timer {
case let countdown as Countdown: case let countdown as Countdown:
let formattedDuration = countdown.duration.hourMinuteSecond let formattedDuration = countdown.duration.minuteSecond
if let name = timer.activity?.name, !name.isEmpty { if let name = timer.activity?.name, !name.isEmpty {
displayName = "\(name) (\(formattedDuration))" displayName = "\(name) (\(formattedDuration))"
} else { } else {

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

@ -14,7 +14,6 @@ struct DefaultView: View {
var body: some View { var body: some View {
Group {
switch family { switch family {
case .accessoryCorner, .accessoryCircular, .accessoryRectangular, .accessoryInline: case .accessoryCorner, .accessoryCircular, .accessoryRectangular, .accessoryInline:
VStack { VStack {
@ -26,13 +25,7 @@ struct DefaultView: View {
Text("Tea".uppercased()).monospaced() Text("Tea".uppercased()).monospaced()
Text("4:00").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 var entry: Provider.Entry
let backgroundOpacity = 0.3
@ViewBuilder @ViewBuilder
var body: some View { var body: some View {
switch family { switch family {
case .systemSmall, .accessoryInline: case .systemSmall, .accessoryInline:
if let timer = entry.timers.first { if let timer = entry.timers.first {
CountdownSimpleWidgetView(timer: timer) CountdownSimpleWidgetView(timer: timer)
.background(Image(timer.imageName))
} else { } else {
DefaultView() DefaultView()
} }
@ -101,8 +100,7 @@ struct LaunchWidgetEntryView : View {
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white.opacity(backgroundOpacity)) .background(Color.white.opacity(0.1))
.cornerRadius(16.0)
case .accessoryRectangular: case .accessoryRectangular:
Group { Group {
if let timer = entry.timers.first { if let timer = entry.timers.first {
@ -112,7 +110,7 @@ struct LaunchWidgetEntryView : View {
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white.opacity(backgroundOpacity)) .background(Color.white.opacity(0.1))
.cornerRadius(16.0) .cornerRadius(16.0)
default: default:
MultiCountdownView(timers: entry.timers) MultiCountdownView(timers: entry.timers)

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

@ -9,43 +9,6 @@ import SwiftUI
import WidgetKit import WidgetKit
import CoreData 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 { struct SingleTimerView: View {
@Environment(\.widgetFamily) var family: WidgetFamily @Environment(\.widgetFamily) var family: WidgetFamily
@ -58,7 +21,7 @@ struct SingleTimerView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Text(timer.displayName.uppercased()) Text(timer.displayName.uppercased())
if let countdown = timer as? Countdown { if let countdown = timer as? Countdown {
Text(countdown.duration.hourMinuteSecond) Text(countdown.duration.minuteSecond)
} }
} }
Spacer() Spacer()
@ -67,9 +30,7 @@ struct SingleTimerView: View {
} }
.padding() .padding()
.monospaced() .monospaced()
.foregroundColor(.white) .foregroundColor(Color.white)
.background(GradientView())
// .background(.white.opacity(0.5))
.font(self.font) .font(self.font)
.widgetURL(timer.url) .widgetURL(timer.url)
} }
@ -95,18 +56,12 @@ struct LockScreenCountdownView: View {
var body: some View { var body: some View {
VStack { VStack {
let title = self.timer.displayName.uppercased() Text(activityName.uppercased())
switch self.family {
case .accessoryCircular:
Text(title)
default:
Text(title)
if let countdown = self.timer as? Countdown { if let countdown = self.timer as? Countdown {
Text(countdown.duration.hourMinuteSecond) Text(countdown.duration.minuteSecond)
.monospaced() .monospaced()
} }
} }
}
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
.foregroundColor(Color.white) .foregroundColor(Color.white)
.font(self.font) .font(self.font)
@ -129,9 +84,7 @@ struct LockScreenCountdownView: View {
} }
private var font: Font { private var font: Font {
return .system(size: 14.0, weight: .medium) return .body
// return .body
// switch self.family { // switch self.family {
// case .systemSmall, .systemMedium, .systemLarge, .systemExtraLarge: // case .systemSmall, .systemMedium, .systemLarge, .systemExtraLarge:
// return .body // return .body
@ -173,31 +126,24 @@ struct MultiCountdownView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Spacer() Spacer()
Text(timer.displayName.uppercased()).lineLimit(1) Text(timer.displayName.uppercased())
if let countdown = timer as? Countdown { if let countdown = timer as? Countdown {
Text(countdown.duration.hourMinuteSecond) Text(countdown.duration.minuteSecond)
} }
Spacer() Spacer()
} }
Spacer() Spacer()
} }
.padding(.horizontal, 12.0) .padding(.horizontal)
.padding(.vertical, 4.0) .font(.callout)
.font(.footnote) .background(Image(timer.imageName))
.background(Color.white.opacity(0.1))
.foregroundColor(.white) .foregroundColor(.white)
.monospaced() .monospaced()
.cornerRadius(16.0) .cornerRadius(16.0)
} }
} }
} }.padding()
.padding(.horizontal, 12.0)
.padding(.vertical, 16.0)
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(GradientView())
// .background(.white.opacity(0.5))
} }
} }
@ -217,9 +163,9 @@ struct CountdownView_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
SingleTimerView(timer: Countdown.fake(context: PersistenceController.preview.container.viewContext)).previewContext(WidgetPreviewContext(family: .systemSmall)).background(.black) 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: .accessoryRectangular))
LockScreenCountdownView(timer: Countdown.fake(context: PersistenceController.preview.container.viewContext)).previewContext(WidgetPreviewContext(family: .accessoryCircular)) 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] { 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"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1500" LastUpgradeVersion = "1420"
wasCreatedForAppExtension = "YES" wasCreatedForAppExtension = "YES"
version = "2.0"> version = "2.0">
<BuildAction <BuildAction

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

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<Scheme <Scheme
LastUpgradeVersion = "1500" LastUpgradeVersion = "1420"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@ -50,20 +50,6 @@
ReferencedContainer = "container:LeCountdown.xcodeproj"> ReferencedContainer = "container:LeCountdown.xcodeproj">
</BuildableReference> </BuildableReference>
</BuildableProductRunnable> </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> </LaunchAction>
<ProfileAction <ProfileAction
buildConfiguration = "Release" buildConfiguration = "Release"

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

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 Intents
import AudioToolbox import AudioToolbox
import ActivityKit import ActivityKit
import AVFoundation
enum BGTaskIdentifier : String { enum BGTaskIdentifier : String {
case refresh = "com.staxriver.lecountdown.refresh" 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 { class Conductor: ObservableObject {
static let maestro: Conductor = Conductor() static let maestro: Conductor = Conductor()
@ObservedObject var soundPlayer: SoundPlayer = SoundPlayer() @Published var soundPlayer: SoundPlayer? = nil
fileprivate var _delayedSoundPlayers: [TimerID : DelaySoundPlayer] = [:]
fileprivate var beats: Timer? = nil
@UserDefault(PreferenceKey.countdowns.rawValue, defaultValue: [:]) static var savedCountdowns: [String : DateInterval] @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 : Date]
@UserDefault(PreferenceKey.stopwatches.rawValue, defaultValue: [:]) static var savedStopwatches: [String : LiveStopWatch]
@Published private (set) var liveTimers: [LiveTimer] = [] @Published private (set) var liveTimers: [LiveTimer] = []
@Published var memoryWarningReceived: Bool = false
init() { init() {
self.currentCountdowns = Conductor.savedCountdowns self.currentCountdowns = Conductor.savedCountdowns
self.currentStopwatches = Conductor.savedStopwatches 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] = [] @Published var cancelledCountdowns: [String] = []
@ -62,23 +37,14 @@ class Conductor: ObservableObject {
@Published var currentCountdowns: [String : DateInterval] = [:] { @Published var currentCountdowns: [String : DateInterval] = [:] {
didSet { didSet {
Conductor.savedCountdowns = currentCountdowns Conductor.savedCountdowns = currentCountdowns
// Logger.log("**** currentCountdowns didSet, count = \(currentCountdowns.count)") Logger.log("**** currentCountdowns didSet, count = \(currentCountdowns.count)")
withAnimation { withAnimation {
self._buildLiveTimers() self._buildLiveTimers()
} }
} }
} }
@Published var pausedCountdowns: [String : TimeInterval] = [:] { @Published var currentStopwatches: [String : Date] = [:] {
didSet {
Conductor.savedPausedCountdowns = pausedCountdowns
withAnimation {
self._buildLiveTimers()
}
}
}
@Published var currentStopwatches: [String : LiveStopWatch] = [:] {
didSet { didSet {
Conductor.savedStopwatches = currentStopwatches Conductor.savedStopwatches = currentStopwatches
withAnimation { withAnimation {
@ -87,24 +53,15 @@ class Conductor: ObservableObject {
} }
} }
func removeLiveTimer(id: TimerID) { func removeLiveTimer(id: String) {
// Logger.log("removeLiveTimer")
self.liveTimers.removeAll(where: { $0.id == id }) self.liveTimers.removeAll(where: { $0.id == id })
self.cancelledCountdowns.removeAll(where: { $0 == 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() { fileprivate func _buildLiveTimers() {
Logger.log("_buildLiveTimers")
let liveCountdowns = self.currentCountdowns.map { let liveCountdowns = self.currentCountdowns.map {
return LiveTimer(id: $0, date: $1.end) return LiveTimer(id: $0, date: $1.end)
} }
@ -119,8 +76,8 @@ class Conductor: ObservableObject {
} }
} }
let liveStopwatches: [LiveTimer] = self.currentStopwatches.map { let liveStopwatches = self.currentStopwatches.map {
return LiveTimer(id: $0, date: $1.start, endDate: $1.end) return LiveTimer(id: $0, date: $1)
} }
for liveStopwatch in liveStopwatches { for liveStopwatch in liveStopwatches {
if let index = self.liveTimers.firstIndex(where: { $0.id == liveStopwatch.id }) { if let index = self.liveTimers.firstIndex(where: { $0.id == liveStopwatch.id }) {
@ -133,16 +90,17 @@ class Conductor: ObservableObject {
} }
func isCountdownCancelled(_ countdown: Countdown) -> Bool { func notifyUser(countdownId: String) {
return self.cancelledCountdowns.contains(where: { $0 == countdown.stringId }) 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 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] { let dateInterval = self.currentCountdowns[countdownId] {
do { do {
try CoreDataRequests.recordActivity(timer: countdown, dateInterval: dateInterval, cancelled: cancelled) try CoreDataRequests.recordActivity(timer: countdown, dateInterval: dateInterval)
} catch { } catch {
Logger.error(error) Logger.error(error)
// TODO: show error to user // TODO: show error to user
@ -152,211 +110,81 @@ class Conductor: ObservableObject {
// MARK: - Countdown // MARK: - Countdown
func startCountdown(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) { func startCountdown(_ date: Date, countdown: Countdown) {
// DispatchQueue.main.async {
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))
let dateInterval = DateInterval(start: start, end: end) Logger.log("Starts countdown: \(countdown.displayName)")
self.currentCountdowns[countdownId] = dateInterval
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) { let dateInterval = DateInterval(start: Date(), end: date)
self.removeLiveTimer(id: timerId) self.currentCountdowns[countdown.stringId] = dateInterval
if let player = self._delayedSoundPlayers[timerId] {
player.stop() // release resources
}
self._endLiveActivity(timerId: timerId)
}
func cancelCountdown(id: TimerID) { // self._launchLiveActivity(countdown: countdown, endDate: date)
FileLogger.log("Cancel \(self._timerName(id))")
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id)
self.currentCountdowns.removeValue(forKey: id)
self.removeLiveTimer(id: id) self._createTimerIntent(countdown)
self.cancelSoundPlayer(id: id)
self._recordAndRemoveCountdown(countdownId: id, cancel: true) Logger.log("countdowns count = \(self.currentCountdowns.count)")
self.pausedCountdowns.removeValue(forKey: id)
if Preferences.playCancellationSound {
self._playCancellationSound()
}
self._endLiveActivity(timerId: id)
}
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()) func cancelCountdown(id: String) {
self.pausedCountdowns[id] = remainingTime
// cancel stuff
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id) CountdownScheduler.master.cancelCurrentNotifications(countdownId: id)
self.cancelSoundPlayer(id: id) self.stopSoundIfPossible()
self._endLiveActivity(timerId: id) self.cancelledCountdowns.append(id)
self._endCountdown(countdownId: id, cancel: true)
} }
func resumeCountdown(id: TimerID) throws { fileprivate func _endCountdown(countdownId: String, cancel: Bool) {
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) {
DispatchQueue.main.async { DispatchQueue.main.async {
self._recordActivity(countdownId: countdownId, cancelled: cancel) if !cancel {
self._recordActivity(countdownId: countdownId)
}
self.currentCountdowns.removeValue(forKey: countdownId) self.currentCountdowns.removeValue(forKey: countdownId)
self._endLiveActivity(timerId: countdownId)
} }
} }
// MARK: - Stopwatch // MARK: - Stopwatch
func startStopwatch(_ stopwatchId: TimerID) { func startStopwatch(_ stopwatch: Stopwatch) {
DispatchQueue.main.async { 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 { self._createTimerIntent(stopwatch)
return
}
let lsw: LiveStopWatch = LiveStopWatch(start: Date())
self.currentStopwatches[stopWatch.stringId] = lsw
if Preferences.playConfirmationSound {
self._playSound(Const.confirmationSound.rawValue)
}
self._endLiveActivity(timerId: stopWatch.stringId)
self._launchLiveActivity(timer: stopWatch, date: lsw.start)
} }
} }
func stopStopwatch(_ stopwatch: Stopwatch) { func stopStopwatch(_ stopwatch: Stopwatch, end: Date? = nil) {
if let lsw = Conductor.maestro.currentStopwatches[stopwatch.stringId] { if let start = Conductor.maestro.currentStopwatches[stopwatch.stringId] {
Conductor.maestro.currentStopwatches.removeValue(forKey: stopwatch.stringId)
if lsw.end == nil {
let end = Date()
lsw.end = end
Conductor.maestro.currentStopwatches[stopwatch.stringId] = lsw
do { 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 { } catch {
Logger.error(error) Logger.error(error)
} }
self._endLiveActivity(timerId: stopwatch.stringId) 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 // MARK: - Cleanup
func cleanup() { func cleanup() {
self._cleanupCountdowns() self._cleanupCountdowns()
withAnimation {
self._cleanupLiveTimers()
self._buildLiveTimers() self._buildLiveTimers()
} }
if #available(iOS 16.2, *) {
self.cleanupLiveActivities()
}
}
fileprivate func _cleanupCountdowns() { fileprivate func _cleanupCountdowns() {
let now = Date() let now = Date()
for (key, value) in self.currentCountdowns { for (key, value) in self.currentCountdowns {
if value.end < now || self.cancelledCountdowns.contains(key) { if value.end < now {
self._recordAndRemoveCountdown(countdownId: key, cancel: false) self._endCountdown(countdownId: key, cancel: false)
} }
} }
} }
@ -371,7 +199,7 @@ class Conductor: ObservableObject {
let timer = context.object(stringId: timerId) let timer = context.object(stringId: timerId)
switch timer { switch timer {
case let cd as Countdown: case let cd as Countdown:
coolSound = cd.someSound coolSound = cd.coolSound
case let sw as Stopwatch: case let sw as Stopwatch:
coolSound = sw.coolSound coolSound = sw.coolSound
default: default:
@ -386,84 +214,36 @@ class Conductor: ObservableObject {
AudioServicesPlayAlertSound(SystemSoundID(kSystemSoundID_Vibrate)) 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) { 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 { 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 { } catch {
Logger.error(error) Logger.error(error)
// TODO: manage error // TODO: manage error
} }
} }
func cancelSoundPlayer(id: TimerID) { func stopSoundIfPossible() {
if let soundPlayer = self._delayedSoundPlayers[id] { self.soundPlayer?.stop()
soundPlayer.stop() self.soundPlayer = nil
self._delayedSoundPlayers.removeValue(forKey: id)
FileLogger.log("cancelled sound player for \(self._timerName(id))")
} }
self.deactivateAudioSessionIfPossible() // MARK: - Intent
}
func deactivateAudioSessionIfPossible() { fileprivate func _createTimerIntent(_ timer: AbstractTimer) {
if self._delayedSoundPlayers.isEmpty { let intent = LaunchTimerIntent()
// do {
// try AVAudioSession.sharedInstance().setActive(false)
// } catch {
// Logger.error(error)
// }
}
}
func stopMainPlayersIfPossible() { let invocationPhrase = "testooooo \(timer.defaultName)" // String(format: NSLocalizedString("Launch %@", comment: ""), timer.displayName)
self.soundPlayer.stop() intent.suggestedInvocationPhrase = String(format: invocationPhrase, timer.displayName)
} intent.timer = TimerIdentifier(identifier: timer.stringId, display: timer.displayName)
func activateAudioSession() { let interaction = INInteraction(intent: intent, response: nil)
do { interaction.donate()
let audioSession: AVAudioSession = AVAudioSession.sharedInstance()
try audioSession.setCategory(.playback, options: .duckOthers)
try audioSession.setActive(true)
} catch {
Logger.error(error)
}
} }
// 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) { fileprivate func _scheduleAppRefresh(countdown: Countdown) {
let request = BGAppRefreshTaskRequest(identifier: BGTaskIdentifier.refresh.rawValue) let request = BGAppRefreshTaskRequest(identifier: BGTaskIdentifier.refresh.rawValue)
request.earliestBeginDate = Date(timeIntervalSinceNow: countdown.duration) request.earliestBeginDate = Date(timeIntervalSinceNow: countdown.duration)
@ -477,22 +257,23 @@ class Conductor: ObservableObject {
// MARK: - Live Activity // MARK: - Live Activity
fileprivate func _launchLiveActivity(timer: AbstractTimer, date: Date) { fileprivate func _launchLiveActivity(stopwatch: Stopwatch, start: Date) {
if #available(iOS 16.2, *) { if #available(iOS 16.2, *) {
if ActivityAuthorizationInfo().areActivitiesEnabled { if ActivityAuthorizationInfo().areActivitiesEnabled {
let contentState = LaunchWidgetAttributes.ContentState(ended: false) 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) let activityContent = ActivityContent(state: contentState, staleDate: nil)
do { do {
let _ = try ActivityKit.Activity.request(attributes: attributes, content: activityContent) let liveActivity = try ActivityKit.Activity.request(attributes: attributes, content: activityContent)
// print("Requested a Live Activity: \(String(describing: liveActivity.id))") print("Requested a Live Activity: \(String(describing: liveActivity.id)).")
} catch { } catch (let error) {
Logger.error(error) Logger.error(error)
} }
} }
} else { } else {
// Fallback on earlier versions // Fallback on earlier versions
@ -500,43 +281,41 @@ class Conductor: ObservableObject {
} }
class func removeLiveActivities() { fileprivate func _liveActivity(timerId: String) -> ActivityKit.Activity<LaunchWidgetAttributes>? {
print("Ending Live Activities") return ActivityKit.Activity<LaunchWidgetAttributes>.activities.first(where: { $0.attributes.id == timerId } )
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>] { func updateLiveActivities() {
return ActivityKit.Activity<LaunchWidgetAttributes>.activities.filter { $0.attributes.id == timerId } print("update live activity...")
}
fileprivate func _liveActivityIds() -> [String] { for (countdownId, interval) in self.currentCountdowns {
let activities = ActivityKit.Activity<LaunchWidgetAttributes>.activities
return activities.map { $0.attributes.id }
}
func cleanupLiveActivities() { if interval.end < Date() {
for id in self._liveActivityIds() { self._endLiveActivity(timerId: countdownId)
if self.liveTimers.first(where: { $0.id == id} ) == nil {
self._endLiveActivity(timerId: id)
} }
// 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) { fileprivate func _endLiveActivity(timerId: String) {
if #available(iOS 16.2, *) { if #available(iOS 16.2, *) {
print("Try to end the Live Activity: \(timerId)") print("Try to end the Live Activity: \(timerId)")
for activity in self._liveActivity(timerId: timerId) { if let activity = self._liveActivity(timerId: timerId) {
Task { Task {
let state = LaunchWidgetAttributes.ContentState(ended: true) let state = LaunchWidgetAttributes.ContentState(ended: true)
let content = ActivityContent(state: state, staleDate: Date()) 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 = "||" static let notificationIdSeparator: String = "||"
func scheduleIfPossible(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) { func scheduleIfPossible(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) {
DispatchQueue.main.async {
self.cancelCurrentNotifications(countdownId: countdown.stringId) self.cancelCurrentNotifications(countdownId: countdown.stringId)
Conductor.maestro.startCountdown(countdown: countdown, handler: handler)
self._scheduleCountdownNotification(countdown: countdown, handler: handler) self._scheduleCountdownNotification(countdown: countdown, handler: handler)
} }
}
fileprivate func _scheduleCountdownNotification(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) { fileprivate func _scheduleCountdownNotification(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) {
let content = UNMutableNotificationContent() let content = UNMutableNotificationContent()
@ -33,17 +30,27 @@ class CountdownScheduler {
body = String(format: timesup, name) body = String(format: timesup, name)
} else { } else {
let timesup = NSLocalizedString("Your %@ countdown is over!", comment: "") let timesup = NSLocalizedString("Your %@ countdown is over!", comment: "")
body = String(format: timesup, duration.hourMinuteSecond) body = String(format: timesup, duration.minuteSecond)
} }
content.body = body 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.sound = UNNotificationSound.criticalSoundNamed(UNNotificationSoundName(rawValue: sound), withAudioVolume: 1.0)
// content.interruptionLevel = .critical content.interruptionLevel = .critical
content.relevanceScore = 1.0 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) { 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 { if let error {
handler(.failure(error)) handler(.failure(error))
print("Scheduling error = \(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) { func cancelCurrentNotifications(countdownId: String) {
UNUserNotificationCenter.current().getPendingNotificationRequests { requests in UNUserNotificationCenter.current().getPendingNotificationRequests { requests in
let ids = requests.map { $0.identifier }.filter { $0.hasPrefix(countdownId) } let ids = requests.map { $0.identifier }.filter { $0.hasPrefix(countdownId) }
UNUserNotificationCenter.current().removePendingNotificationRequests(withIdentifiers: ids) 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> <array>
<string>com.staxriver.lecountdown.refresh</string> <string>com.staxriver.lecountdown.refresh</string>
</array> </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> <key>NSUserActivityTypes</key>
<array> <array>
<string>LaunchTimerIntent</string> <string>LaunchTimerIntent</string>
<string>SelectTimerIntent</string> <string>SelectTimerIntent</string>
<string>app.enchant.NewCountdown</string> <string>app.kikai.NewCountdown</string>
</array> </array>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
@ -35,7 +22,7 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>audio</string> <string>audio</string>
<string>remote-notification</string> <string>fetch</string>
</array> </array>
</dict> </dict>
</plist> </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 CoreData
import BackgroundTasks import BackgroundTasks
import AVFoundation import AVFoundation
import Combine
import CloudKit
@main @main
struct LeCountdownApp: App { struct LeCountdownApp: App {
@ -18,42 +16,49 @@ struct LeCountdownApp: App {
let persistenceController = PersistenceController.shared let persistenceController = PersistenceController.shared
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate @UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) var scenePhase
@State var showStartView: Bool = false
init() { init() {
UITabBar.appearance().backgroundColor = UIColor(white: 0.96, alpha: 1.0)
UIPageControl.appearance().currentPageIndicatorTintColor = .systemPink UIPageControl.appearance().currentPageIndicatorTintColor = .systemPink
UIPageControl.appearance().pageIndicatorTintColor = UIColor(white: 0.7, alpha: 1.0) UIPageControl.appearance().pageIndicatorTintColor = UIColor(white: 0.7, alpha: 1.0)
Logger.log("path = \(Bundle.main.bundlePath)")
self._registerBackgroundRefreshes() self._registerBackgroundRefreshes()
} }
@Environment(\.scenePhase) var scenePhase
var body: some Scene { var body: some Scene {
WindowGroup { WindowGroup {
Group {
#if os(iOS)
if UIDevice.isPhoneIdiom {
CompactHomeView() CompactHomeView()
.environment(\.managedObjectContext, persistenceController.container.viewContext) .environment(\.managedObjectContext, persistenceController.container.viewContext)
.fullScreenCover(isPresented: $showStartView) { } else {
StartView(isPresented: $showStartView) RegularHomeView()
.environment(\.managedObjectContext, persistenceController.container.viewContext) .environment(\.managedObjectContext, persistenceController.container.viewContext)
} }
#else
RegularHomeView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
#endif
}
// .onReceive(NotificationCenter.default.publisher(for: UIApplication.willEnterForegroundNotification)) { _ in
// self._willEnterForegroundNotification()
// }
.onAppear { .onAppear {
self._onAppear() self._onAppear()
} }
.onChange(of: scenePhase) { newPhase in .onChange(of: scenePhase) { newPhase in
switch newPhase { switch newPhase {
case .inactive: case .inactive:
Conductor.maestro.stopMainPlayersIfPossible() Conductor.maestro.stopSoundIfPossible()
Conductor.maestro.memoryWarningReceived = false
case .active: case .active:
// Logger.log("onChange(of: scenePhase) active") Logger.log("onChange(of: scenePhase) active")
// Logger.log(Conductor.maestro.currentCountdowns.count) Logger.log(Conductor.maestro.currentCountdowns.count)
Conductor.maestro.restoreSoundPlayers()
Conductor.maestro.cleanup() Conductor.maestro.cleanup()
default: default:
break break
@ -63,20 +68,17 @@ struct LeCountdownApp: App {
} }
fileprivate func _shouldShowStartView() -> Bool { // fileprivate func _willEnterForegroundNotification() {
let count = persistenceController.container.viewContext.count(entityName: "AbstractTimer") // Conductor.maestro.cleanup()
return count == 0 && Preferences.hasShownStartView == false // }
}
fileprivate func _onAppear() { fileprivate func _onAppear() {
Logger.log("preferredLanguages = \(String(describing: Locale.preferredLanguages))") Logger.log("preferredLanguages = \(String(describing: Locale.preferredLanguages))")
self.showStartView = self._shouldShowStartView() Sound.computeSoundDurationsIfNecessary()
self._patch() self._patch()
let containerAvailable = self.isICloudContainerAvailable()
Logger.log("isICloudContainerAvailable = \(containerAvailable)")
// let voices = AVSpeechSynthesisVoice.speechVoices() // let voices = AVSpeechSynthesisVoice.speechVoices()
// let grouped = Dictionary(grouping: voices, by: { $0.language }) // let grouped = Dictionary(grouping: voices, by: { $0.language })
@ -102,17 +104,18 @@ struct LeCountdownApp: App {
print("_handleAppRefresh = \(task.description)") print("_handleAppRefresh = \(task.description)")
// task.expirationHandler = { task.expirationHandler = {
// print("expired") print("expired")
// } }
//
// DispatchQueue.main.async { DispatchQueue.main.async {
// Conductor.maestro.updateLiveActivities() Conductor.maestro.updateLiveActivities()
// task.setTaskCompleted(success: true) task.setTaskCompleted(success: true)
// } }
} }
fileprivate func _patch() { fileprivate func _patch() {
let context = PersistenceController.shared.container.viewContext 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 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 { guard let activity = timer.activity else {
return return
@ -54,86 +54,8 @@ class CoreDataRequests {
record.start = dateInterval.start record.start = dateInterval.start
record.end = dateInterval.end record.end = dateInterval.end
record.activity = activity record.activity = activity
record.cancelled = cancelled
try context.save() 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 // AbstractSoundTimer+CoreDataProperties.swift
// LeCountdown // 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") return NSFetchRequest<AbstractSoundTimer>(entityName: "AbstractSoundTimer")
} }
@NSManaged public var confirmationSoundList: String?
@NSManaged public var playableIds: String?
@NSManaged public var repeatCount: Int16 @NSManaged public var repeatCount: Int16
@NSManaged public var soundList: String?
} }

@ -2,7 +2,7 @@
// AbstractTimer+CoreDataProperties.swift // AbstractTimer+CoreDataProperties.swift
// LeCountdown // 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") return NSFetchRequest<AbstractTimer>(entityName: "AbstractTimer")
} }
@NSManaged public var image: String?
@NSManaged public var order: Int16 @NSManaged public var order: Int16
@NSManaged public var image: String?
@NSManaged public var activity: Activity? @NSManaged public var activity: Activity?
} }

@ -2,7 +2,7 @@
// Record+CoreDataProperties.swift // Record+CoreDataProperties.swift
// LeCountdown // 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 duration: Double
@NSManaged public var end: Date? @NSManaged public var end: Date?
@NSManaged public var month: Int16
@NSManaged public var start: Date? @NSManaged public var start: Date?
@NSManaged public var year: Int16 @NSManaged public var year: Int16
@NSManaged public var cancelled: Bool @NSManaged public var month: Int16
@NSManaged public var activity: Activity? @NSManaged public var activity: Activity?
} }

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

@ -2,7 +2,7 @@
// Stopwatch+CoreDataProperties.swift // Stopwatch+CoreDataProperties.swift
// LeCountdown // 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"> <plist version="1.0">
<dict> <dict>
<key>_XCCurrentVersionName</key> <key>_XCCurrentVersionName</key>
<string>LeCountdown.0.6.5.xcdatamodel</string> <string>LeCountdown.0.6.2.xcdatamodel</string>
</dict> </dict>
</plist> </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 { struct LiveTimer: Identifiable, Comparable {
var id: String var id: String
var date: Date var date: Date
var endDate: Date?
static func < (lhs: LiveTimer, rhs: LiveTimer) -> Bool { static func < (lhs: LiveTimer, rhs: LiveTimer) -> Bool {
return lhs.date < rhs.date return lhs.date < rhs.date
} }
func timer(context: NSManagedObjectContext) -> AbstractTimer? { func timer(context: NSManagedObjectContext) -> AbstractTimer? {
return context.object(stringId: self.id) return context.object(stringId: self.id) as? AbstractTimer
} }
var ended: Bool { var ended: Bool {
return self.date < Date() return self.date < Date()
} }
} }

@ -9,76 +9,42 @@ import Foundation
import SwiftUI import SwiftUI
import CoreData 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> { extension AbstractSoundTimer {
return self.playables.reduce(Set<Sound>()) { $0.union($1.soundList) }
}
var confirmationSounds: Set<Sound> { var sounds: Set<Sound> {
if let confirmationSoundList { if let soundList {
return Set(confirmationSoundList.enumItems()) return Set(soundList.enumItems())
} }
return [] return []
} }
func setConfirmationSounds(_ sounds: Set<Sound>) { func setSounds(_ sounds: Set<Sound>) {
self.confirmationSoundList = sounds.stringRepresentation self.soundList = sounds.stringRepresentation
} }
var someSound: Sound { var coolSound: Sound {
var sounds: Set<Sound> = self.allSounds var sounds = self.sounds
if !AppGuard.main.isSubscriber {
sounds = sounds.filter { !$0.isRestricted }
}
// remove last played sound if at least 3 sounds remains // remove last played sound if the playlist has at least 3 sounds
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
if sounds.count > 2, if sounds.count > 2,
let lastSoundId = Preferences.lastSelectedSoundByTimer[self.stringId], let lastSoundId = Preferences.lastSelectedSound[self.stringId],
let lastSound = Sound(rawValue: lastSoundId) { let lastSound = Sound(rawValue: lastSoundId) {
sounds.remove(lastSound) sounds.remove(lastSound)
} }
if let random = sounds.randomElement() { if let random = sounds.randomElement() {
Preferences.lastSelectedSoundByTimer[self.stringId] = random.id Preferences.lastSelectedSound[self.stringId] = random.id
Preferences.lastSoundPlayed = random.id
return random return random
} }
return Sound.default return Sound.default
} }
var soundName: String {
return self.coolSound.soundName
}
} }
extension Stopwatch { 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 import Foundation
typealias TimerID = String
extension AbstractTimer { extension AbstractTimer {
var displayName: String { var displayName: String {
@ -27,7 +25,7 @@ extension AbstractTimer {
if let url = URL(string: self.stringId) { if let url = URL(string: self.stringId) {
return url return url
} else { } 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 { 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 url = URL(string: stringId) else { return nil }
guard let objectId: NSManagedObjectID = PersistenceController.shared.container.persistentStoreCoordinator.managedObjectID(forURIRepresentation: url) else { return nil } guard let objectId = PersistenceController.shared.container.persistentStoreCoordinator.managedObjectID(forURIRepresentation: url) else { return nil }
return self.object(with: objectId) as? T 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) let request = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
request.entity = NSEntityDescription.entity(forEntityName: entityName, in: self) request.entity = NSEntityDescription.entity(forEntityName: entityName, in: self)
request.returnsDistinctResults = true request.returnsDistinctResults = true
request.resultType = .dictionaryResultType request.resultType = .dictionaryResultType
request.predicate = predicate
if let entity = request.entity { if let entity = request.entity {
let entityProperties = entity.propertiesByName let entityProperties = entity.propertiesByName
var properties = [NSPropertyDescription]() var properties = [NSPropertyDescription]()
@ -38,27 +37,9 @@ extension NSManagedObjectContext {
return try self.fetch(request) return try self.fetch(request)
} }
func count(entityName: String) -> Int { func count(entityName: String) throws -> Int {
let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName) let fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: entityName)
do {
return try self.count(for: fetchRequest) 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 return self.objectID.uriRepresentation().absoluteString
} }
static var entityName: String {
return self.entity().managedObjectClassName
}
} }

@ -31,21 +31,14 @@ struct PersistenceController {
countdown.image = CoolPic.pic1.rawValue countdown.image = CoolPic.pic1.rawValue
} }
for i in 0..<20 { for i in 0..<14 {
let record = Record(context: viewContext) let record = Record(context: viewContext)
record.start = Date()
let randomMonth = (0...10 * 31).randomElement() ?? 3 record.end = Date()
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.activity = activities.randomElement() record.activity = activities.randomElement()
} }
do { do {
try viewContext.save() try viewContext.save()
} catch { } catch {
@ -53,7 +46,6 @@ struct PersistenceController {
// Replace this implementation with code to handle the error appropriately. // 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. // 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 let nsError = error as NSError
FileLogger.log("app terminated by ourselves")
fatalError("Unresolved error \(nsError), \(nsError.userInfo)") fatalError("Unresolved error \(nsError), \(nsError.userInfo)")
} }
return result return result
@ -73,8 +65,8 @@ struct PersistenceController {
let options = NSPersistentCloudKitContainerOptions(containerIdentifier: id) let options = NSPersistentCloudKitContainerOptions(containerIdentifier: id)
storeDescription.cloudKitContainerOptions = options storeDescription.cloudKitContainerOptions = options
// let remoteChangeKey = "NSPersistentStoreRemoteChangeNotificationOptionKey" let remoteChangeKey = "NSPersistentStoreRemoteChangeNotificationOptionKey"
// storeDescription.setOption(true as NSNumber, forKey: remoteChangeKey) storeDescription.setOption(true as NSNumber, forKey: remoteChangeKey)
container = NSPersistentCloudKitContainer(name: "LeCountdown") container = NSPersistentCloudKitContainer(name: "LeCountdown")
container.persistentStoreDescriptions = [storeDescription] container.persistentStoreDescriptions = [storeDescription]
@ -102,7 +94,6 @@ struct PersistenceController {
* The store could not be migrated to the current model version. * The store could not be migrated to the current model version.
Check the error message to determine what the actual problem was. Check the error message to determine what the actual problem was.
*/ */
FileLogger.log("app terminated by ourselves")
fatalError("Unresolved error \(error), \(error.userInfo)") 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. /// Returns a URL for the given app group and database pointing to the sqlite database.
static func storeURL(for appGroup: String, databaseName: String) -> URL { static func storeURL(for appGroup: String, databaseName: String) -> URL {
guard let fileContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else { guard let fileContainer = FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup) else {
FileLogger.log("app terminated by ourselves")
fatalError("Shared file container could not be created.") 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 Foundation
import AVFoundation 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 { protocol Localized {
var localizedString: String { get } var localizedString: String { get }
} }
@ -40,59 +23,34 @@ class SoundCatalog {
} }
func sounds(for playlist: Playlist) -> [Sound] { 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] ?? [] 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 custom
case nature case nature
case fun
case stephanBodzin case stephanBodzin
case relax
case shorts static var selectable: [Playlist] {
return Playlist.allCases.filter { $0 != .custom }
}
var localizedString: String { var localizedString: String {
switch self { switch self {
case .nature: case .nature:
return NSLocalizedString("Nature", comment: "") return NSLocalizedString("Nature", comment: "")
case .fun:
return NSLocalizedString("Fun", comment: "")
case .stephanBodzin: case .stephanBodzin:
return "Stephan Bodzin - Boavista" return "Stephan Bodzin"
case .custom: case .custom:
return NSLocalizedString("Custom", comment: "") 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 } var id: Int { return self.rawValue }
case trainhorn = 1 // default
case forestStream
// StephanBodzin // StephanBodzin
case sbSEM_Synths_Loop4_Nothing_Like_You case sbSEM_Synths_Loop4_Nothing_Like_You
case sbClave_Loop_LLL
case sbLoop_ToneSD_Boavista case sbLoop_ToneSD_Boavista
case sbArpeggio_Loop_River case sbArpeggio_Loop_River
case sbSquareArp_Loop_River case sbSquareArp_Loop_River
case sbHighChords_Loop_River case sbHighChords_Loop_River
case sbMatriarchFxs_Loop2_Collider 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 } static var `default`: Sound { .sbSEM_Synths_Loop4_Nothing_Like_You }
var localizedString: String { var localizedString: String {
switch self { 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 .sbSEM_Synths_Loop4_Nothing_Like_You: return "Nothing Like You"
case .sbClave_Loop_LLL: return "LLL"
case .sbLoop_ToneSD_Boavista: return "Boavista" case .sbLoop_ToneSD_Boavista: return "Boavista"
case .sbArpeggio_Loop_River: return "River 1" case .sbArpeggio_Loop_River: return "River 1"
case .sbSquareArp_Loop_River: return "River 2" case .sbSquareArp_Loop_River: return "River 2"
case .sbHighChords_Loop_River: return "River 3" case .sbHighChords_Loop_River: return "River 3"
case .sbMatriarchFxs_Loop2_Collider: return "Collider" 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 { 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 .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 .sbLoop_ToneSD_Boavista: return "Loop_ToneSD_Boavista.wav"
case .sbArpeggio_Loop_River: return "Arpeggio_Loop_River.wav" case .sbArpeggio_Loop_River: return "Arpeggio_Loop_River.wav"
case .sbSquareArp_Loop_River: return "SquareArp_Loop_River.wav" case .sbSquareArp_Loop_River: return "SquareArp_Loop_River.wav"
case .sbHighChords_Loop_River: return "HighChords_Loop_River.wav" case .sbHighChords_Loop_River: return "HighChords_Loop_River.wav"
case .sbMatriarchFxs_Loop2_Collider: return "MatriarchFxs_Loop2_Collider.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 { var playlist: Playlist {
switch self { 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 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? { var url: URL? {
let components = self.fileName.components(separatedBy: ".")
let components = self.soundName.components(separatedBy: ".")
if components.count == 2 { if components.count == 2 {
return Bundle.main.url(forResource: components[0], return Bundle.main.url(forResource: components[0], withExtension: components[1])
withExtension: components[1])
} else { } else {
print("bad sound file name for \(self)") print("bad sound file name for \(self)")
return nil return nil
} }
} }
func soundFile() throws -> SoundFile { func soundFile() throws -> SoundFile {
return try SoundFile(fullName: self.fileName) return try SoundFile(fullName: self.soundName)
} }
func duration() async throws -> TimeInterval { func duration() async throws -> TimeInterval {
@ -253,9 +142,9 @@ enum Sound: Int, CaseIterable, Identifiable, Localized {
static func computeSoundDurationsIfNecessary() { static func computeSoundDurationsIfNecessary() {
Task { Task {
for sound in Sound.allCases { for sound in Sound.allCases {
if Preferences.soundDurations[sound.fileName] == nil { if Preferences.soundDurations[sound.rawValue] == nil {
if let duration = try? await sound.duration() { 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 { var formattedDuration: String {
if let duration = Preferences.soundDurations[self.fileName] { if let duration = Preferences.soundDurations[self.rawValue] {
return duration.minuteSecond return duration.minuteSecond
} else { } else {
return "" return ""

@ -31,96 +31,47 @@ struct SoundFile {
enum SoundPlayerError : Error { enum SoundPlayerError : Error {
case missingResourceError(file: SoundFile) case missingResourceError(file: SoundFile)
case badFileName(name: String) case badFileName(name: String)
case playReturnedFalse
} }
@objc class SoundPlayer: NSObject, AVAudioPlayerDelegate, ObservableObject { @objc class SoundPlayer: NSObject, AVAudioPlayerDelegate {
fileprivate var _player: AVAudioPlayer? fileprivate var _player: AVAudioPlayer?
fileprivate var _timer: Timer? = nil func playSound(soundFile: SoundFile, repeats: Bool) throws {
@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 {
guard let url = soundFile.url else { guard let url = soundFile.url else {
throw SoundPlayerError.missingResourceError(file: soundFile) throw SoundPlayerError.missingResourceError(file: soundFile)
} }
let player = try AVAudioPlayer(contentsOf: url) // let audioSession: AVAudioSession = AVAudioSession.sharedInstance()
player.prepareToPlay() // try audioSession.setCategory(.playback)
player.volume = 1.0 // try audioSession.setActive(true)
player.delegate = self
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() { func stop() {
self._player?.stop() self._player?.stop()
self.currentFileName = nil
} }
// func isSoundPlaying(_ sound: Sound) -> Bool {
// return sound.fileName == self.currentFileName && (self._player?.isPlaying ?? false)
// }
// MARK: - Delegate // MARK: - Delegate
func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 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 fetchRequest = NSFetchRequest<NSFetchRequestResult>(entityName: "Record")
let basePredicate = NSPredicate(format: "start != nil AND activity = %@", activity) let basePredicate = NSPredicate(format: "start != nil")
var predicates: [NSPredicate] = [] var predicates: [NSPredicate] = []
predicates.append(basePredicate) predicates.append(basePredicate)
predicates.append(NSPredicate(format: "activity = %@", activity))
if let filter { if let filter {
predicates.append(filter.predicate) predicates.append(filter.predicate)
} }
let finalPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates) fetchRequest.predicate = NSCompoundPredicate.init(andPredicateWithSubpredicates: predicates)
fetchRequest.predicate = finalPredicate
fetchRequest.propertiesToFetch = expressions fetchRequest.propertiesToFetch = expressions
fetchRequest.resultType = NSFetchRequestResultType.dictionaryResultType fetchRequest.resultType = NSFetchRequestResultType.dictionaryResultType
@ -77,9 +77,10 @@ extension NSManagedObjectContext {
if let value { if let value {
let request = Record.fetchRequest() let request = Record.fetchRequest()
request.predicate = finalPredicate if let filter {
request.predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [basePredicate, filter.predicate])
request.sortDescriptors = [NSSortDescriptor(key: "start", ascending: true)] request.sortDescriptors = [NSSortDescriptor(key: "start", ascending: true)]
}
let records: [Record] = try self.fetch(request) let records: [Record] = try self.fetch(request)
let points: [Point] = records.compactMap { $0.point(stat:stat) } let points: [Point] = records.compactMap { $0.point(stat:stat) }

@ -28,7 +28,7 @@ enum Filter: Identifiable, Hashable {
switch self { switch self {
case .none: return NSLocalizedString("All", comment: "") case .none: return NSLocalizedString("All", comment: "")
case .year(let year): return "\(year)" 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 month: Int
var year: Int var year: Int
var id: Int { return self.year * 1000 + month }
var start: NSDate { var start: NSDate {
let components: DateComponents = DateComponents(year: self.year, month: self.month) let components: DateComponents = DateComponents(year: self.year, month: self.month)
if let date = Calendar.current.date(from: components) { if let date = Calendar.current.date(from: components) {

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

@ -1,6 +1,6 @@
// //
// AppGuard.swift // Guard.swift
// LeCountdown // Poker Analytics 6
// //
// Created by Laurent Morvillier on 20/04/2022. // Created by Laurent Morvillier on 20/04/2022.
// //
@ -14,16 +14,7 @@ public enum StoreError: Error {
enum StorePlan : String, CaseIterable { enum StorePlan : String, CaseIterable {
case none case none
case monthly = "com.staxriver.enchant.monthly" case unlimited = "com.staxriver.lecountdown.unlimited"
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: "")
}
}
} }
extension Notification.Name { extension Notification.Name {
@ -32,13 +23,11 @@ extension Notification.Name {
@objc class AppGuard : NSObject { @objc class AppGuard : NSObject {
static let freeTimersCount: Int = 3
static var main: AppGuard = AppGuard() static var main: AppGuard = AppGuard()
@Published private(set) var purchasedTransactions = Set<StoreKit.Transaction>() @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 var updateListenerTask: Task<Void, Error>? = nil
@ -125,32 +114,35 @@ extension Notification.Name {
return transaction return transaction
} }
var isSubscriber: Bool { var isAuthorized: Bool {
return self.currentPlan != .none return self.currentPlan == .unlimited
} }
var currentPlan: StorePlan { var currentPlan: StorePlan {
return .yearly
// #if DEBUG #if DEBUG
// return .yearly return .unlimited
// #else #else
// if let currentBestPlan = self.currentBestPlan, if let currentBestPlan = self.currentBestPlan, let plan = StorePlan(rawValue: currentBestPlan.productID) {
// let plan = StorePlan(rawValue: currentBestPlan.productID) { return plan
// return plan }
// } if let vf = Preferences.verifiedTransaction(),
// return .none vf.expiryDate > Date(), vf.graceDate > Date(),
// #endif let plan = StorePlan(rawValue: vf.productId) {
return plan
}
return .none
#endif
} }
fileprivate func _updateBestPlan() { fileprivate func _updateBestPlan() {
if let monthly = self.purchasedTransactions.first(where: { $0.productID == StorePlan.monthly.rawValue }) {
self.currentBestPlan = monthly if let unlimited = self.purchasedTransactions.first(where: { $0.productID == StorePlan.unlimited.rawValue }) {
} else if let yearly = self.purchasedTransactions.first(where: { $0.productID == StorePlan.yearly.rawValue }) { self.currentBestPlan = unlimited
self.currentBestPlan = yearly
} else { } else {
self.currentBestPlan = nil 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 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 { var body: some View {
Text("Hello Store!")
Group {
if !self.store.products.isEmpty {
PlanView(isPresented: self.$isPresented)
.environmentObject(self.store)
} else {
ProgressView()
.progressViewStyle(.circular)
}
}.onAppear {
self._configure()
}
} }
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 { struct StoreView_Previews: PreviewProvider {
static var previews: some View { 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) { 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 { switch timer {
case let countdown as Countdown: case let countdown as Countdown:
self._launchCountdown(countdown.stringId, handler: handler) self._launchCountdown(countdown, handler: handler)
case let stopwatch as Stopwatch: case let stopwatch as Stopwatch:
self._startStopwatch(stopwatch.stringId, handler: handler) self._startStopwatch(stopwatch, handler: handler)
case let alarm as Alarm: case let alarm as Alarm:
self._scheduleAlarm(alarm, handler: handler) self._scheduleAlarm(alarm, handler: handler)
default: 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 UNUserNotificationCenter.current().getNotificationSettings { settings in
DispatchQueue.main.async { switch settings.authorizationStatus {
case .notDetermined, .denied:
guard let countdown = IntentDataProvider.main.timer(id: countdownId) as? Countdown else { handler(.failure(TimerRouterError.notificationAuthorizationMissing))
handler(.failure(AppError.timerNotFound(id: countdownId))) default:
return
}
CountdownScheduler.master.scheduleIfPossible(countdown: countdown) { result in CountdownScheduler.master.scheduleIfPossible(countdown: countdown) { result in
switch result { switch result {
case .success: case .success:
@ -76,12 +80,12 @@ class TimerRouter {
} }
fileprivate static func _startStopwatch(_ stopwatchId: TimerID, handler: @escaping (Result<Void, Error>) -> Void) { fileprivate static func _startStopwatch(_ stopwatch: Stopwatch, handler: @escaping (Result<Void, Error>) -> Void) {
Conductor.maestro.startStopwatch(stopwatchId) Conductor.maestro.startStopwatch(stopwatch)
handler(.success(Void())) handler(.success(Void()))
} }
fileprivate static func _stopStopwatch(_ stopwatch: Stopwatch) { fileprivate static func _stopStopwatch(_ stopwatch: Stopwatch, end: Date? = nil) {
Conductor.maestro.stopStopwatch(stopwatch) 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 Foundation
import MediaPlayer import MediaPlayer
class AppleMusicPlayer { @objc class AppleMusicPlayer : NSObject, MPMediaPickerControllerDelegate {
let mediaItemCollection: MPMediaItemCollection // func play() {
//
init(mediaItemCollection: MPMediaItemCollection) { // let musicPlayer = MPMusicPlayerApplicationController.applicationQueuePlayer
self.mediaItemCollection = mediaItemCollection // musicPlayer.setQueue(with: .songs())
} //
// }
func play() { //
let musicPlayer = MPMusicPlayerApplicationController.applicationQueuePlayer // func showMediaPicker(source: UIView) {
musicPlayer.setQueue(with: self.mediaItemCollection) // let controller = MPMediaPickerController(mediaTypes: .music)
musicPlayer.play() // 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 import Foundation
extension Date: Identifiable {
public var id: TimeInterval { return self.timeIntervalSince1970 }
}
extension Date { extension Date {
static let monthYearFormatter = { static let monthYearFormatter = {
@ -28,23 +22,10 @@ extension Date {
return df return df
}() }()
static let dateTimeFormatter = {
let df = DateFormatter()
df.dateStyle = .short
df.timeStyle = .medium
return df
}()
var startOfDay: Date { var startOfDay: Date {
return Calendar.current.startOfDay(for: self) 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 { func get(_ component: Calendar.Component, calendar: Calendar = Calendar.current) -> Int {
return calendar.component(component, from: self) return calendar.component(component, from: self)
} }
@ -53,8 +34,7 @@ extension Date {
var month: Int { self.get(.month) } var month: Int { self.get(.month) }
var formattedYear: String { return "\(self.year)" } 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 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 Foundation
import Firebase
@objc public class Logger : NSObject { @objc public class Logger : NSObject {
@ -29,7 +28,6 @@ import Firebase
} }
} }
print("ERROR: \(filestr.lastPathComponent).\(line).\(function): \(fireBaseError)") 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) { @objc static public func w(_ message: Any, file: String = #file, function: String = #function, line: Int = #line) {

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

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

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

@ -9,18 +9,9 @@ import Foundation
extension TimeInterval { 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 { var hourMinuteSecondHS: String {
let h = self.hour let h = self.hour
if h > 0 { if h > 1 {
return String(format:"%d:%02d:%02d", hour, minute, second) return String(format:"%d:%02d:%02d", hour, minute, second)
} else { } else {
return String(format:"%02d:%02d.%02d", minute, second, hundredth) return String(format:"%02d:%02d.%02d", minute, second, hundredth)
@ -48,11 +39,4 @@ extension TimeInterval {
Int((self*100).truncatingRemainder(dividingBy: 100)) 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 import Foundation
enum Tip: Int, CaseIterable, Identifiable { enum Tip: Int, CaseIterable, Identifiable {
// case siri case siri
case widget case widget
var id: Int { self.rawValue } var id: Int { self.rawValue }
var localizedString: String { var localizedString: String {
switch self { switch self {
case .widget: return NSLocalizedString("Widget Tip", 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: "") case .siri: return NSLocalizedString("You can ask Siri to create and launch countdowns and stopwatches", comment: "")
} }
} }
var pictoName: String { var pictoName: String {
switch self { 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" 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 { @objc static var isPhoneIdiom: Bool {
return UIDevice.current.userInterfaceIdiom == .phone 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 CoreData
import Combine import Combine
struct ContentView<T : AbstractTimer>: View { class BoringContext : ObservableObject {
@Environment(\.managedObjectContext) private var viewContext @Published var isShowingNewData = false
@EnvironmentObject var conductor: Conductor @Published var error: Error?
@Environment(\.colorScheme) var colorScheme @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 init(order: Int16, timer: AbstractTimer? = nil) {
@State private var showSettingsSheet: Bool = false self.order = order
@State private var showStatsSheet: Bool = false self.timer = timer
@State private var showSubscriptionSheet: Bool = false }
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( @FetchRequest(
sortDescriptors: [NSSortDescriptor(keyPath: \AbstractTimer.order, ascending: true)], sortDescriptors: [NSSortDescriptor(keyPath: \T.order, ascending: true)],
animation: .default) 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 { var body: some View {
let columns: [GridItem] = self._columns()
GeometryReader { reader in
let width: CGFloat = reader.size.width / CGFloat(columns.count) - 15.0
VStack { VStack {
if !AppGuard.main.isSubscriber { ScrollView {
SubscriptionButtonView()
}
TimersView(isEditing: self.$isEditing, LazyVGrid(
showSubscriptionSheet: self.$showSubscriptionSheet, columns: columns,
showAddSheet: self.$showAddSheet, spacing: itemSpacing
siriHandler: { timer in ) {
self._handleSiriTips(timer: timer)
})
.environment(\.managedObjectContext, viewContext)
.environmentObject(self.boringContext)
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) }.padding(.horizontal, itemSpacing)
#endif
if !self.tipsShown, let tip = Preferences.tipToShow {
TipView(tip: tip) {
self._hideTip(tip)
}.padding()
}
if !conductor.liveTimers.isEmpty {
LiveTimerListView() LiveTimerListView()
.environment(\.managedObjectContext, viewContext) .environment(\.managedObjectContext, viewContext)
.environmentObject(conductor) .environmentObject(conductor)
.padding(.horizontal, 12.0) .foregroundColor(.white)
.foregroundColor(.black) .background(Color(white: 0.1))
.background(self._backgroundColor)
.cornerRadius(32.0, corners: [.topRight, .topLeft]) .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) { .alert(boringContext.error?.localizedDescription ?? "missing error", isPresented: $boringContext.showDefaultAlert) {
Button("OK", role: .cancel) { } Button("OK", role: .cancel) { }
} }
.alert("You need to accept notifications, please check your settings", isPresented: $boringContext.showPermissionAlert) { .alert("You need to accept notifications, please check your settings", isPresented: $boringContext.showPermissionAlert) {
PermissionAlertView() PermissionAlertView()
} }
.sheet(isPresented: self.$boringContext.isShowingNewData, content: { .sheet(isPresented: $boringContext.isShowingNewData, content: {
// self._newView(isPresented: $boringContext.isShowingNewData)
// .environment(\.managedObjectContext, viewContext)
}) })
.toolbar { .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 { .onAppear {
self.showTip = Preferences.tipToShow self._askPermissions()
// self.showLiveTimersSheet = !conductor.liveTimers.isEmpty
} }
.onOpenURL { url in .onOpenURL { url in
self._performActionIfPossible(url: url) self._performActionIfPossible(url: url)
@ -93,24 +145,49 @@ struct ContentView<T : AbstractTimer>: View {
} }
fileprivate var _backgroundColor: Color { fileprivate func _columnCount() -> Int {
return Color(white: self.colorScheme == .dark ? 0.1 : 0.9) #if os(iOS)
} if UIDevice.isPhoneIdiom {
return 2
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
} else { } else {
self.boringContext.siriTimer = nil return 3
self.siriTipShown = false }
#else
return 3
#endif
} }
fileprivate func _columns() -> [GridItem] {
return (0..<self._columnCount()).map { _ in GridItem(spacing: 10.0) }
} }
// MARK: - Business // 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) { fileprivate func _performActionIfPossible(url: URL) {
// hide new window if launching a timer // hide new window if launching a timer
@ -119,7 +196,7 @@ struct ContentView<T : AbstractTimer>: View {
print("_performActionIfPossible") print("_performActionIfPossible")
let urlString = url.absoluteString 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 { if timer is T {
print("performAction") print("performAction")
@ -138,8 +215,11 @@ struct ContentView<T : AbstractTimer>: View {
} }
} }
} }
} }
// print("Start countdown: \(countdown.name ?? ""), \(countdown.duration)")
// self._launchCountdown(countdown)
} else { } else {
print("timer not found with id = \(urlString)") 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 var isShowingNewData: Binding<Bool>
@Binding var showAddSheet: Bool
@State var showSettingsSheet: Bool = false var body: some View {
@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) {
Button { Button {
withAnimation { self.isShowingNewData.wrappedValue = true
self.isEditing.toggle()
}
} label: { } label: {
Text(self.isEditing ? "Done" : "Edit") HStack {
Image(systemName: "timer")
Text("countdown")
} }
} }
}
ToolbarItemGroup(placement: .navigationBarTrailing) {
if !self.timers.isEmpty {
Button { Button {
withAnimation { self.isShowingNewData.wrappedValue = true
self.showSettingsSheet.toggle()
}
} label: { } 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 { Button {
withAnimation { self.isShowingNewData.wrappedValue = true
self.showAddSheet.toggle()
}
} label: { } label: {
Image(systemName: "plus") HStack {
Image(systemName: "alarm")
Text("alarm")
} }
.sheet(isPresented: self.$showAddSheet, content: {
StartView(isPresented: self.$showAddSheet)
})
} }
} }
} }
fileprivate extension Countdown { fileprivate extension Countdown {
@ -218,40 +271,19 @@ fileprivate extension Countdown {
return endDate != nil return endDate != nil
} }
} // var colorForStatus: Color {
// if isLive {
class TimerSpot : Identifiable, Equatable { // return Color(red: 0.9, green: 1.0, blue: 0.95)
// } else {
var id: Int16 { return self.order } // return Color(red: 0.9, green: 0.95, blue: 1.0)
// }
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
}
static func == (lhs: TimerSpot, rhs: TimerSpot) -> Bool {
return lhs.order == rhs.order && lhs.timer?.stringId == rhs.timer?.stringId
} }
} struct ContentView_Previews: PreviewProvider {
struct Toolbar_Previews: PreviewProvider {
static var previews: some View { static var previews: some View {
NavigationStack { ContentView<Countdown>().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) .environmentObject(Conductor.maestro)
ContentView<AbstractTimer>()
.environmentObject(Conductor.maestro)
}
.navigationTitle("Title")
.toolbar {
MainToolbarView(isEditing: .constant(false), showAddSheet: .constant(false))
}
} }
} }

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

Loading…
Cancel
Save