Compare commits

...

161 Commits

Author SHA1 Message Date
Laurent db026a6811 adds claude.md 2 months ago
Laurent 042077514d fix build issue 2 months ago
Laurent b351b7f394 Limits the logs to 50 2 years ago
Laurent b9e06ea55c Bumps to build 2 2 years ago
Laurent aa6aebad91 Minor improvements 2 years ago
Laurent 0174531e88 Bumps to 1.0.20 2 years ago
Laurent 891cd77084 Fixing live activity color and dynamic island alignment 2 years ago
Laurent b5b829fa16 Bumps to 1.0.19 2 years ago
Laurent 4902513bd9 Multiple bug fixes + new settings for volume 2 years ago
Laurent 927fbef4e6 Fix timer being too small 2 years ago
Laurent 631211329c build 2 2 years ago
Laurent 4a37aac048 adds share app + write review links + translations 2 years ago
Laurent ea6293799c Avoid systematic crashes when things go wrong + better email management 2 years ago
Laurent 6b4fcaac4c Bumps to 1.0.17 2 years ago
Laurent ba24781ccf Improve the done button visibility 2 years ago
Laurent ce92eb58ec Change mail to hello@getenchanted.app 2 years ago
Laurent f0900bca79 Improves iPad layout 2 years ago
Laurent 9b35f69fb2 Bumps to 1.0.16 2 years ago
Laurent 2962e914f4 Removes beta disclaimer 2 years ago
Laurent 445e77a6e5 Removes rename confirmation popup 2 years ago
Laurent 7d39da6ee9 UI improvement 2 years ago
Laurent 434b442726 version 1.0.14 2 years ago
Laurent 0fddc1783c Fix dynamic island restarting the timer on tap 2 years ago
Laurent 0659a607fc Graphical updates and stats are hidden 2 years ago
Laurent 4ab4d3df70 Fixes 2 years ago
Laurent 6f99988130 Improve StartView UI 2 years ago
Laurent 69bc2abcb0 Records errors on crashlytics + 1.0.8 2 years ago
Laurent b33787541a Fix missing localizations 2 years ago
Laurent 6a20b46370 Split line to identify bug 2 years ago
Laurent a3917a88f6 Sound fix 2 years ago
Laurent 9b4bc17140 Bumps to 1.0.7 2 years ago
Laurent affb3b4959 Replace the current + system with the StartView 2 years ago
Laurent 2b062b04a4 Multiples fixes and improvements 2 years ago
Laurent c453446c89 Improve UI 2 years ago
Laurent 1289616c01 Bumps to 1.0.5 2 years ago
Laurent b5ae7f0065 Fix crash when creating custom timer in the start view 2 years ago
Laurent 01103b5f18 Adds script to upload dsyms to firebase 2 years ago
Laurent 6eb1bfaa34 Fix link opening 3 years ago
Laurent cdb829af1c Bumps to 1.0.4 3 years ago
Laurent 1dffd82236 Small improvements 3 years ago
Laurent 29d310bb41 Fixes tips 3 years ago
Laurent f0850bea64 Adds tip view 3 years ago
Laurent 9f760ea874 Fix stopwatch dismissing 3 years ago
Laurent 4eb74fe4d9 StartView integration 3 years ago
Laurent e76429b7cb Work on onboarding 3 years ago
Laurent afbcd4b5d5 Stability improvements 3 years ago
Laurent 6a66e40489 cleanup 3 years ago
Laurent c398b31944 Fixes Siri flow 3 years ago
Laurent 3feb133a79 Fix picker loading 3 years ago
Laurent b8e67a5ffc Fixes 3 years ago
Laurent f4975cb876 Adds button to pause timers 3 years ago
Laurent cd34b42882 minor improvements 3 years ago
Laurent 1cba6e5c88 Fix default view for single widget 3 years ago
Laurent e56cea2234 Bumps version to 1.0.3 3 years ago
Laurent c3e4d40116 Changes duration selection with a picker 3 years ago
Laurent e4758a3fea Fixes issues with stopwatch editing 3 years ago
Laurent 528c3d0af9 Adds ability to stop sounds in SoundSelectionView 3 years ago
Laurent f521f35cba Small improvements and better dark mode support 3 years ago
Laurent 3c77bcafca Fades in and out nature sounds, changes the surf sound 3 years ago
Laurent 6b7416fb66 Couple of fixes 3 years ago
Laurent 1739393345 Improvements 3 years ago
Laurent 09cd5815f2 Fixes undismissable live timer 3 years ago
Laurent 51b617f333 Adds code for picking media from Apple Music 3 years ago
Laurent 70abbf9eff Fix issue 3 years ago
Laurent b873be1e29 Throws exception if player returns false when playing at time 3 years ago
Laurent b709b6da24 Added logs and activate audio session each time the app enters the foreground 3 years ago
Laurent bb9641e8e8 Remove memory warning when leaving app 3 years ago
Laurent 47be480d37 Shows when app receives memory warning 3 years ago
Laurent 845b9a7eac Improve stats look 3 years ago
Laurent 0522ca17cf End previous live activity when launching a new timer 3 years ago
Laurent de847a739e Sound improvements 3 years ago
Laurent 19858a6a3c Adds more log and stops cancelling audioplayer when audio finishes 3 years ago
Laurent 19fca143bc Refactor stats 3 years ago
Laurent b8293b3674 Calendar view for activity 3 years ago
Laurent 74691184dc cleanup 3 years ago
Laurent ded4d7b32d suite 3 years ago
Laurent 9bb6e824cb Adds cancel sheet for countdowns 3 years ago
Laurent ef6a75724c small improvements 3 years ago
Laurent 78b43f5f63 Adding more logs 3 years ago
Laurent 2cca84e670 Adds log view 3 years ago
Laurent a2f8146a77 cleanup + build 4 3 years ago
Laurent 501fc0df82 Fixes mac crash 3 years ago
Laurent 48ec6f9c84 cleanup 3 years ago
Laurent 04796d7181 better live activity cleanup 3 years ago
Laurent 68ad65ff62 Fix live activities being dismissed 3 years ago
Laurent 82f2bbeb5b Fix look 3 years ago
Laurent 39e7e39e7a Fixes iPad 3 years ago
Laurent 604d3219d4 cleanup 3 years ago
Laurent c1bdac59ff Live activities and various improvements 3 years ago
Laurent c0731189ad Fade out sounds 3 years ago
Laurent cf6246e8bf Adds install date 3 years ago
Laurent 4c4607ecc8 Fixes stopwatches 3 years ago
Laurent 5322f59f43 cleanup 3 years ago
Laurent 3352cc6e4e Various fixes and improvements 3 years ago
Laurent 5e22e43b72 details 3 years ago
Laurent bed245f64d Various fixes 3 years ago
Laurent d05483d117 Navigation changes 3 years ago
Laurent b4ef494280 Change navigation 3 years ago
Laurent 57e3895a99 Add siri phrases 3 years ago
Laurent f321de3b10 Sound update 3 years ago
Laurent 9b03cea1bd Fixes and sound added 3 years ago
Laurent cf3836444b Prevents non-subscriber from launching additional timers 3 years ago
Laurent badad9cefb Filter sounds if user is not a subscriber 3 years ago
Laurent 74b1092680 Fixes in the subscription process 3 years ago
Laurent 51057f3fcb Remove log 3 years ago
Laurent e87d380a9a Adds subscription button 3 years ago
Laurent 2ce1b66eff fade out sound 3 years ago
Laurent 51b0430b47 Improvements 3 years ago
Laurent d81d951f43 Default volume setting 3 years ago
Laurent 1bfc8350a9 Fix migration 3 years ago
Laurent af90ac71b7 Fixes build 3 years ago
Laurent ef1bfb7352 Store playlists as well as sounds 3 years ago
Laurent 7a9ae3f7ff Fixes 3 years ago
Laurent 3d91c28e09 Change cancellation sound 3 years ago
Laurent 406732e4b9 Fixes sound duration issue 3 years ago
Laurent 47d17734c1 Define playlists for presets 3 years ago
Laurent c7780497a6 Fix playing multiple sounds at once 3 years ago
Laurent f36323fea8 Adds yearly plan 3 years ago
Laurent ea459d7a2a Adds confirmation sounds 3 years ago
Laurent 79f817bf27 cancellation sound 3 years ago
Laurent f4afba87bb Fix submission issue : the app must work without notifications 3 years ago
Laurent a7bb2cf906 Improvement on player UI 3 years ago
Laurent 1fac7ff50b crashlytics integration 3 years ago
Laurent 685c35ae8f Improves siri tips for ipad 3 years ago
Laurent 4cf0d866e9 Launching settings 3 years ago
Laurent 11942f1172 Bumps to 1.0 and remove mac app 3 years ago
Laurent 29023aa107 Adds nature playlist 3 years ago
Laurent a152777799 Traductions 3 years ago
Laurent 205efa3704 Fixes crashes on mac app 3 years ago
Laurent 5f70c4da00 Fix build 3 years ago
Laurent 30d4d80321 Fixes siri launch 3 years ago
Laurent 37141bb579 Fixes 3 years ago
Laurent 6fe943f15f cleanup audio session activation 3 years ago
Laurent 762d7b62b3 Improve lockscreen widget 3 years ago
Laurent 863ee350d9 Improve widget look 3 years ago
Laurent f78c2de4a4 icon + improvements 3 years ago
Laurent b4d9bca76b Adds Relax playlist 3 years ago
Laurent 97b3e5aca9 Fixes 3 years ago
Laurent 1df168f4a8 Finalize stuff for 1.0 3 years ago
Laurent 5d6bdb8c96 Stuff 3 years ago
Laurent 2a09dc6593 Restore sound play after quitting 3 years ago
Laurent 604e167faa Fixes issue when running multiple countdowns at the same time 3 years ago
Laurent 0e388c6a21 look and feel 3 years ago
Laurent 9d8db42edf Adds settings view 3 years ago
Laurent 197ad6c400 fix 3 years ago
Laurent 04cc931ce8 UI improvements 3 years ago
Laurent d0b31d2c3e Visual changes 3 years ago
Laurent 1d9d2e5ce1 Fixes cloudkit issues 3 years ago
Laurent b42e494ac5 Cleanup 3 years ago
Laurent 32bd299739 update look and feel 3 years ago
Laurent 2a89996d8f Stat fixes 3 years ago
Laurent 5bee77c3b0 Fixes graph data 3 years ago
Laurent e7e447f799 Update confirmation sound 3 years ago
Laurent 0ab3149980 work on subscriptions 3 years ago
Laurent e40f9d672f Adds confirmation sound 3 years ago
Laurent 6d49965b18 Show volume view when starting a countdown 3 years ago
Laurent b99fb2a59a Changing notification to player to trigger audio 3 years ago
Laurent 99d55b3c3c Fixes navigation issue 3 years ago
Laurent aa71352e92 cleanup 3 years ago
Laurent 26c94d7471 Working App Intents! 3 years ago
Laurent 89d0adb448 Fixes 3 years ago
  1. 86
      CLAUDE.md
  2. 58
      LaunchIntents/IntentHandler.swift
  3. 4
      LaunchWidget/Base.lproj/LaunchWidget.intentdefinition
  4. 27
      LaunchWidget/DefaultView.swift
  5. 8
      LaunchWidget/LaunchWidget.swift
  6. 68
      LaunchWidget/LaunchWidgetLiveActivity.swift
  7. 82
      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. 428
      LeCountdown/Conductor.swift
  18. 44
      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. 106
      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. 35
      LeCountdown/Model/NSManagedContext+Extensions.swift
  40. 22
      LeCountdown/Model/Persistence.swift
  41. 85
      LeCountdown/Sound/DelaySoundPlayer.swift
  42. 175
      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. 15
      LeCountdown/Stats/Context+Calculations.swift
  77. 6
      LeCountdown/Stats/Filter.swift
  78. 20
      LeCountdown/Stats/Stat.swift
  79. 60
      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. 320
      LeCountdown/Views/ContentView.swift
  101. Some files were not shown because too many files have changed in this diff Show More

@ -0,0 +1,86 @@
# 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, LaunchTimerIntentHandling { class IntentHandler: INExtension, SelectTimerIntentHandling {
//
// 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, LaunchTimerIntentHa
let displayName: String let displayName: String
switch timer { switch timer {
case let countdown as Countdown: case let countdown as Countdown:
let formattedDuration = countdown.duration.minuteSecond let formattedDuration = countdown.duration.hourMinuteSecond
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>14C18</string> <string>14E222b</string>
<key>INIntentDefinitionToolsVersion</key> <key>INIntentDefinitionToolsVersion</key>
<string>14.2</string> <string>14.3</string>
<key>INIntents</key> <key>INIntents</key>
<array> <array>
<dict> <dict>

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

@ -81,13 +81,14 @@ 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()
} }
@ -100,7 +101,8 @@ struct LaunchWidgetEntryView : View {
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white.opacity(0.1)) .background(Color.white.opacity(backgroundOpacity))
.cornerRadius(16.0)
case .accessoryRectangular: case .accessoryRectangular:
Group { Group {
if let timer = entry.timers.first { if let timer = entry.timers.first {
@ -110,7 +112,7 @@ struct LaunchWidgetEntryView : View {
} }
} }
.frame(maxWidth: .infinity, maxHeight: .infinity) .frame(maxWidth: .infinity, maxHeight: .infinity)
.background(Color.white.opacity(0.1)) .background(Color.white.opacity(backgroundOpacity))
.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(name) Text(self.name)
Spacer() Spacer()
Text(endDate, style: .timer) Text(self.endDate, style: .timer)
.monospaced() .monospaced()
}.padding() }.padding()
.font(.title) .font(.title)
@ -32,33 +32,27 @@ struct LaunchWidgetLiveActivity: Widget {
var body: some WidgetConfiguration { var body: some WidgetConfiguration {
ActivityConfiguration(for: LaunchWidgetAttributes.self) { context in ActivityConfiguration(for: LaunchWidgetAttributes.self) { context in
// let range = Date()...context.attributes.date
// Lock screen/banner UI goes here // Lock screen/banner UI goes here
HStack { VStack(alignment: .leading) {
if context.attributes.isTimer {
let range = Date()...context.attributes.date
Text(timerInterval: range,
pauseTime: range.lowerBound)
.font(.title)
} else {
Text(context.attributes.date, style: .timer)
.font(.title)
}
Text(context.attributes.name.uppercased()) Text(context.attributes.name.uppercased())
Spacer() .font(.callout)
Text(context.attributes.date, style: .timer)
.font(.title) }
.padding()
// if Date() < context.attributes.date {
// Text(context.attributes.date, style: .timer)
// } else {
// GreenCheckmarkView()
// }
// if context.attributes.endDate > self.model.now {
// Text(context.attributes.endDate, style: .timer)
// .monospaced()
// } else {
// Text("It's time!")
// }
}.padding()
.monospaced() .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
@ -79,13 +73,29 @@ struct LaunchWidgetLiveActivity: Widget {
} }
} }
} compactLeading: { } compactLeading: {
Text("L") Text(context.attributes.name.uppercased())
} compactTrailing: { } compactTrailing: {
Text("T") Group {
if context.attributes.isTimer {
let range = Date()...context.attributes.date
Text(timerInterval: range,
pauseTime: range.lowerBound)
} else {
Text(context.attributes.date, style: .timer)
}
}.multilineTextAlignment(.trailing)
} minimal: { } minimal: {
Text("Min") if context.attributes.isTimer {
let range = Date()...context.attributes.date
Text(timerInterval: range,
pauseTime: range.lowerBound)
.font(Font.system(size: 11.0))
} else {
Text(context.attributes.date, style: .timer)
.font(Font.system(size: 11.0))
}
} }
.widgetURL(URL(string: context.attributes.id)) // .widgetURL(URL(string: context.attributes.id))
.keylineTint(Color.red) .keylineTint(Color.red)
} }
} }
@ -100,7 +110,7 @@ struct LaunchWidgetLiveActivity_Previews: PreviewProvider {
static let attributes = LaunchWidgetAttributes( static let attributes = LaunchWidgetAttributes(
id: "", id: "",
name: "Tea", name: "Tea",
date: Date().addingTimeInterval(3600.0)) date: Date().addingTimeInterval(3600.0), isTimer: true)
static let contentState = LaunchWidgetAttributes.ContentState(ended: false) static let contentState = LaunchWidgetAttributes.ContentState(ended: false)

@ -9,6 +9,43 @@ 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
@ -21,7 +58,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.minuteSecond) Text(countdown.duration.hourMinuteSecond)
} }
} }
Spacer() Spacer()
@ -30,7 +67,9 @@ struct SingleTimerView: View {
} }
.padding() .padding()
.monospaced() .monospaced()
.foregroundColor(Color.white) .foregroundColor(.white)
.background(GradientView())
// .background(.white.opacity(0.5))
.font(self.font) .font(self.font)
.widgetURL(timer.url) .widgetURL(timer.url)
} }
@ -56,10 +95,16 @@ struct LockScreenCountdownView: View {
var body: some View { var body: some View {
VStack { VStack {
Text(activityName.uppercased()) let title = self.timer.displayName.uppercased()
if let countdown = self.timer as? Countdown { switch self.family {
Text(countdown.duration.minuteSecond) case .accessoryCircular:
.monospaced() Text(title)
default:
Text(title)
if let countdown = self.timer as? Countdown {
Text(countdown.duration.hourMinuteSecond)
.monospaced()
}
} }
} }
.multilineTextAlignment(.center) .multilineTextAlignment(.center)
@ -84,7 +129,9 @@ struct LockScreenCountdownView: View {
} }
private var font: Font { private var font: Font {
return .body return .system(size: 14.0, weight: .medium)
// return .body
// switch self.family { // switch self.family {
// case .systemSmall, .systemMedium, .systemLarge, .systemExtraLarge: // case .systemSmall, .systemMedium, .systemLarge, .systemExtraLarge:
// return .body // return .body
@ -126,24 +173,31 @@ struct MultiCountdownView: View {
VStack(alignment: .leading) { VStack(alignment: .leading) {
Spacer() Spacer()
Text(timer.displayName.uppercased()) Text(timer.displayName.uppercased()).lineLimit(1)
if let countdown = timer as? Countdown { if let countdown = timer as? Countdown {
Text(countdown.duration.minuteSecond) Text(countdown.duration.hourMinuteSecond)
} }
Spacer() Spacer()
} }
Spacer() Spacer()
} }
.padding(.horizontal) .padding(.horizontal, 12.0)
.font(.callout) .padding(.vertical, 4.0)
.background(Image(timer.imageName)) .font(.footnote)
.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))
} }
} }
@ -163,9 +217,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] {

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

File diff suppressed because it is too large Load Diff

@ -0,0 +1,113 @@
{
"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 = "1420" LastUpgradeVersion = "1500"
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 = "1420" LastUpgradeVersion = "1500"
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 = "1420" LastUpgradeVersion = "1500"
version = "1.3"> version = "1.3">
<BuildAction <BuildAction
parallelizeBuildables = "YES" parallelizeBuildables = "YES"
@ -50,6 +50,20 @@
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,26 +7,84 @@
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 application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool { func applicationWillEnterForeground(_ application: UIApplication) {
// self._activateAudioSession()
}
if userActivity.interaction == nil { func applicationWillTerminate(_ application: UIApplication) {
Logger.log("applicationWillTerminate")
FileLogger.log("applicationWillTerminate")
Conductor.removeLiveActivities()
// Conductor.maestro.removeLiveActivities()
}
func applicationDidReceiveMemoryWarning(_ application: UIApplication) {
Conductor.maestro.memoryWarningReceived = true
Logger.log("applicationDidReceiveMemoryWarning")
FileLogger.log("applicationDidReceiveMemoryWarning")
}
fileprivate func _initSchemaIfNeeded() {
if !Preferences.cloudKitSchemaInitialized {
do {
try PersistenceController.shared.container.initializeCloudKitSchema()
Preferences.cloudKitSchemaInitialized = true
} catch {
print("initializeCloudKitSchema error: \(error)")
}
}
}
func application(_ application: UIApplication, continue userActivity: NSUserActivity, restorationHandler: @escaping ([UIUserActivityRestoring]?) -> Void) -> Bool {
if userActivity.interaction == nil {
Logger.log("restorationHandler called! interaction is nil") 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 {
@ -34,27 +92,28 @@ 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")
let timerId = self._timerId(notificationId: response.notification.request.identifier) FileLogger.log("userNotificationCenter didReceive > cancelling sound player")
Conductor.maestro.cancelCountdown(id: timerId) if let timerId = self._timerId(notificationId: response.notification.request.identifier) {
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) -> String { fileprivate func _timerId(notificationId: String) -> TimerID? {
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 {
fatalError("bad notification format : \(notificationId)") FileLogger.log("Couldn't parse notification Id: \(notificationId)")
return nil
} }
} }
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 834 KiB

@ -1,214 +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>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,25 +11,50 @@ 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()
@Published var soundPlayer: SoundPlayer? = nil @ObservedObject var soundPlayer: SoundPlayer = SoundPlayer()
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.stopwatches.rawValue, defaultValue: [:]) static var savedStopwatches: [String : Date] @UserDefault(PreferenceKey.pausedCountdowns.rawValue, defaultValue: [:]) static var savedPausedCountdowns: [String : TimeInterval]
@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] = []
@ -37,14 +62,23 @@ 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 currentStopwatches: [String : Date] = [:] { @Published var pausedCountdowns: [String : TimeInterval] = [:] {
didSet {
Conductor.savedPausedCountdowns = pausedCountdowns
withAnimation {
self._buildLiveTimers()
}
}
}
@Published var currentStopwatches: [String : LiveStopWatch] = [:] {
didSet { didSet {
Conductor.savedStopwatches = currentStopwatches Conductor.savedStopwatches = currentStopwatches
withAnimation { withAnimation {
@ -53,14 +87,23 @@ class Conductor: ObservableObject {
} }
} }
func removeLiveTimer(id: String) { func removeLiveTimer(id: TimerID) {
// 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 _buildLiveTimers() { fileprivate func _cleanupLiveTimers() {
self.liveTimers.removeAll()
}
Logger.log("_buildLiveTimers") fileprivate func _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)
@ -76,8 +119,8 @@ class Conductor: ObservableObject {
} }
} }
let liveStopwatches = self.currentStopwatches.map { let liveStopwatches: [LiveTimer] = self.currentStopwatches.map {
return LiveTimer(id: $0, date: $1) return LiveTimer(id: $0, date: $1.start, endDate: $1.end)
} }
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 }) {
@ -90,17 +133,16 @@ class Conductor: ObservableObject {
} }
func notifyUser(countdownId: String) { func isCountdownCancelled(_ countdown: Countdown) -> Bool {
self._playSound(timerId: countdownId) return self.cancelledCountdowns.contains(where: { $0 == countdown.stringId })
self._endCountdown(countdownId: countdownId, cancel: false)
} }
fileprivate func _recordActivity(countdownId: String) { fileprivate func _recordActivity(countdownId: String, cancelled: Bool) {
let context = PersistenceController.shared.container.viewContext let context = PersistenceController.shared.container.viewContext
if let countdown = context.object(stringId: countdownId) as? Countdown, if let countdown: Countdown = context.object(stringId: countdownId),
let dateInterval = self.currentCountdowns[countdownId] { let dateInterval = self.currentCountdowns[countdownId] {
do { do {
try CoreDataRequests.recordActivity(timer: countdown, dateInterval: dateInterval) try CoreDataRequests.recordActivity(timer: countdown, dateInterval: dateInterval, cancelled: cancelled)
} catch { } catch {
Logger.error(error) Logger.error(error)
// TODO: show error to user // TODO: show error to user
@ -110,81 +152,211 @@ class Conductor: ObservableObject {
// MARK: - Countdown // MARK: - Countdown
func startCountdown(_ date: Date, countdown: Countdown) { func startCountdown(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) {
// 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)
Logger.log("Starts countdown: \(countdown.displayName)") let countdownId = countdown.stringId
// cleanup existing countdowns FileLogger.log("schedule countdown \(self._timerName(countdownId)) at \(end)")
self.removeLiveTimer(id: countdown.stringId)
// self._cleanupTimers.removeValue(forKey: countdown.stringId) let sound = countdown.someSound
let soundPlayer = try DelaySoundPlayer(timerID: countdownId, sound: sound)
self._delayedSoundPlayers[countdownId] = soundPlayer
let dateInterval = DateInterval(start: Date(), end: date) try soundPlayer.start(in: interval,
self.currentCountdowns[countdown.stringId] = dateInterval repeatCount: Int(countdown.repeatCount))
// self._launchLiveActivity(countdown: countdown, endDate: date) let dateInterval = DateInterval(start: start, end: end)
self.currentCountdowns[countdownId] = dateInterval
self._createTimerIntent(countdown) self._launchLiveActivity(timer: countdown, date: end)
Logger.log("countdowns count = \(self.currentCountdowns.count)") return end
}
// } fileprivate func _cleanupPreviousTimerIfNecessary(_ timerId: TimerID) {
self.removeLiveTimer(id: timerId)
if let player = self._delayedSoundPlayers[timerId] {
player.stop() // release resources
}
self._endLiveActivity(timerId: timerId)
} }
func cancelCountdown(id: String) { func cancelCountdown(id: TimerID) {
FileLogger.log("Cancel \(self._timerName(id))")
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id) CountdownScheduler.master.cancelCurrentNotifications(countdownId: id)
self.stopSoundIfPossible() self.currentCountdowns.removeValue(forKey: id)
self.cancelledCountdowns.append(id)
self._endCountdown(countdownId: id, cancel: true) self.removeLiveTimer(id: id)
self.cancelSoundPlayer(id: id)
self._recordAndRemoveCountdown(countdownId: id, cancel: true)
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]
} }
fileprivate func _endCountdown(countdownId: String, cancel: Bool) { func pauseCountdown(id: TimerID) {
guard let interval = self.currentCountdowns[id] else {
return
}
let remainingTime = interval.end.timeIntervalSince(Date())
self.pausedCountdowns[id] = remainingTime
// cancel stuff
CountdownScheduler.master.cancelCurrentNotifications(countdownId: id)
self.cancelSoundPlayer(id: id)
self._endLiveActivity(timerId: id)
}
func resumeCountdown(id: TimerID) throws {
let context = PersistenceController.shared.container.viewContext
if let countdown: Countdown = context.object(stringId: id),
let remainingTime = self.pausedCountdowns[id] {
_ = try self._scheduleSoundPlayer(countdown: countdown, in: remainingTime)
self.pausedCountdowns.removeValue(forKey: id)
} else {
throw AppError.timerNotFound(id: id)
}
}
fileprivate func _recordAndRemoveCountdown(countdownId: String, cancel: Bool) {
DispatchQueue.main.async { DispatchQueue.main.async {
if !cancel { self._recordActivity(countdownId: countdownId, cancelled: 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(_ stopwatch: Stopwatch) { func startStopwatch(_ stopwatchId: TimerID) {
DispatchQueue.main.async { DispatchQueue.main.async {
let now = Date()
Conductor.maestro.currentStopwatches[stopwatch.stringId] = now
self._launchLiveActivity(stopwatch: stopwatch, start: now)
self._createTimerIntent(stopwatch) guard let stopWatch = IntentDataProvider.main.timer(id: stopwatchId) as? Stopwatch else {
return
}
let lsw: LiveStopWatch = LiveStopWatch(start: Date())
self.currentStopwatches[stopWatch.stringId] = lsw
if Preferences.playConfirmationSound {
self._playSound(Const.confirmationSound.rawValue)
}
self._endLiveActivity(timerId: stopWatch.stringId)
self._launchLiveActivity(timer: stopWatch, date: lsw.start)
} }
} }
func stopStopwatch(_ stopwatch: Stopwatch, end: Date? = nil) { func stopStopwatch(_ stopwatch: Stopwatch) {
if let start = Conductor.maestro.currentStopwatches[stopwatch.stringId] { if let lsw = Conductor.maestro.currentStopwatches[stopwatch.stringId] {
Conductor.maestro.currentStopwatches.removeValue(forKey: stopwatch.stringId)
do { if lsw.end == nil {
try CoreDataRequests.recordActivity(timer: stopwatch, dateInterval: DateInterval(start: start, end: end ?? Date())) let end = Date()
} catch { lsw.end = end
Logger.error(error)
Conductor.maestro.currentStopwatches[stopwatch.stringId] = lsw
do {
try CoreDataRequests.recordActivity(timer: stopwatch, dateInterval: DateInterval(start: lsw.start, end: end), cancelled: false)
} catch {
Logger.error(error)
}
self._endLiveActivity(timerId: stopwatch.stringId)
}
}
}
func restoreSoundPlayers() {
for (countdownId, interval) in self.currentCountdowns {
if !self._delayedSoundPlayers.contains(where: { $0.key == countdownId }) {
let context = PersistenceController.shared.container.viewContext
if let countdown: Countdown = context.object(stringId: countdownId) {
do {
let sound: Sound = countdown.someSound
let soundPlayer = try DelaySoundPlayer(timerID: countdownId, sound: sound)
self._delayedSoundPlayers[countdownId] = soundPlayer
try soundPlayer.restore(for: interval.end, repeatCount: Int(countdown.repeatCount))
FileLogger.log("Restored sound player for \(self._timerName(countdownId))")
} catch {
Logger.error(error)
}
}
} }
self._endLiveActivity(timerId: stopwatch.stringId)
} }
} }
// MARK: - Cleanup // MARK: - Cleanup
func cleanup() { func cleanup() {
self._cleanupCountdowns() self._cleanupCountdowns()
self._buildLiveTimers() withAnimation {
self._cleanupLiveTimers()
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 { if value.end < now || self.cancelledCountdowns.contains(key) {
self._endCountdown(countdownId: key, cancel: false) self._recordAndRemoveCountdown(countdownId: key, cancel: false)
} }
} }
} }
@ -199,7 +371,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.coolSound coolSound = cd.someSound
case let sw as Stopwatch: case let sw as Stopwatch:
coolSound = sw.coolSound coolSound = sw.coolSound
default: default:
@ -214,36 +386,84 @@ 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 {
let soundFile = try sound.soundFile() try self.soundPlayer.playOrPauseSound(filename, duration: duration)
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 stopSoundIfPossible() { func cancelSoundPlayer(id: TimerID) {
self.soundPlayer?.stop() if let soundPlayer = self._delayedSoundPlayers[id] {
self.soundPlayer = nil soundPlayer.stop()
} self._delayedSoundPlayers.removeValue(forKey: id)
FileLogger.log("cancelled sound player for \(self._timerName(id))")
}
// MARK: - Intent self.deactivateAudioSessionIfPossible()
}
fileprivate func _createTimerIntent(_ timer: AbstractTimer) { func deactivateAudioSessionIfPossible() {
let intent = LaunchTimerIntent() if self._delayedSoundPlayers.isEmpty {
// do {
// try AVAudioSession.sharedInstance().setActive(false)
// } catch {
// Logger.error(error)
// }
}
}
let invocationPhrase = "testooooo \(timer.defaultName)" // String(format: NSLocalizedString("Launch %@", comment: ""), timer.displayName) func stopMainPlayersIfPossible() {
intent.suggestedInvocationPhrase = String(format: invocationPhrase, timer.displayName) self.soundPlayer.stop()
intent.timer = TimerIdentifier(identifier: timer.stringId, display: timer.displayName) }
let interaction = INInteraction(intent: intent, response: nil) func activateAudioSession() {
interaction.donate() do {
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)
@ -257,23 +477,22 @@ class Conductor: ObservableObject {
// MARK: - Live Activity // MARK: - Live Activity
fileprivate func _launchLiveActivity(stopwatch: Stopwatch, start: Date) { fileprivate func _launchLiveActivity(timer: AbstractTimer, date: 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: stopwatch.stringId, name: stopwatch.displayName, date: start) let attributes = LaunchWidgetAttributes(id: timer.stringId, name: timer.displayName, date: date, isTimer: timer is Countdown)
let activityContent = ActivityContent(state: contentState, staleDate: nil) let activityContent = ActivityContent(state: contentState, staleDate: nil)
do { do {
let liveActivity = try ActivityKit.Activity.request(attributes: attributes, content: activityContent) let _ = 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 (let error) { } catch {
Logger.error(error) Logger.error(error)
} }
} }
} else { } else {
// Fallback on earlier versions // Fallback on earlier versions
@ -281,41 +500,43 @@ class Conductor: ObservableObject {
} }
fileprivate func _liveActivity(timerId: String) -> ActivityKit.Activity<LaunchWidgetAttributes>? { class func removeLiveActivities() {
return ActivityKit.Activity<LaunchWidgetAttributes>.activities.first(where: { $0.attributes.id == timerId } ) print("Ending Live Activities")
let semaphore = DispatchSemaphore(value: 0)
Task.detached(priority: .high) {
print("Task")
for activity in ActivityKit.Activity<LaunchWidgetAttributes>.activities {
print("Ending Live Activity: \(activity.id)")
if #available(iOS 16.2, *) {
await activity.end(nil, dismissalPolicy: .immediate)
}
}
semaphore.signal()
}
semaphore.wait()
} }
func updateLiveActivities() { fileprivate func _liveActivity(timerId: String) -> [ActivityKit.Activity<LaunchWidgetAttributes>] {
print("update live activity...") return ActivityKit.Activity<LaunchWidgetAttributes>.activities.filter { $0.attributes.id == timerId }
}
for (countdownId, interval) in self.currentCountdowns { fileprivate func _liveActivityIds() -> [String] {
let activities = ActivityKit.Activity<LaunchWidgetAttributes>.activities
return activities.map { $0.attributes.id }
}
if interval.end < Date() { func cleanupLiveActivities() {
self._endLiveActivity(timerId: countdownId) for id in self._liveActivityIds() {
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)")
if let activity = self._liveActivity(timerId: timerId) { for activity in 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())
@ -326,4 +547,13 @@ 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,8 +15,11 @@ 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) {
self.cancelCurrentNotifications(countdownId: countdown.stringId) DispatchQueue.main.async {
self._scheduleCountdownNotification(countdown: countdown, handler: handler) self.cancelCurrentNotifications(countdownId: countdown.stringId)
Conductor.maestro.startCountdown(countdown: countdown, handler: handler)
self._scheduleCountdownNotification(countdown: countdown, handler: handler)
}
} }
fileprivate func _scheduleCountdownNotification(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) { fileprivate func _scheduleCountdownNotification(countdown: Countdown, handler: @escaping (Result<Date?, Error>) -> Void) {
@ -30,27 +33,17 @@ 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.minuteSecond) body = String(format: timesup, duration.hourMinuteSecond)
} }
content.body = body content.body = body
let sound = countdown.soundName self._createNotification(countdown: countdown, content: content, handler: handler)
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) {
@ -68,36 +61,17 @@ 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)
} }
} }

@ -0,0 +1,34 @@
<?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,11 +6,24 @@
<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.kikai.NewCountdown</string> <string>app.enchant.NewCountdown</string>
</array> </array>
<key>UIApplicationSceneManifest</key> <key>UIApplicationSceneManifest</key>
<dict> <dict>
@ -22,7 +35,7 @@
<key>UIBackgroundModes</key> <key>UIBackgroundModes</key>
<array> <array>
<string>audio</string> <string>audio</string>
<string>fetch</string> <string>remote-notification</string>
</array> </array>
</dict> </dict>
</plist> </plist>

@ -0,0 +1,70 @@
//
// 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)
}
}
}

@ -0,0 +1,46 @@
//
// 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) }
}
}
}
}

@ -0,0 +1,25 @@
//
// 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,6 +9,8 @@ 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 {
@ -16,49 +18,42 @@ 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 { CompactHomeView()
#if os(iOS) .environment(\.managedObjectContext, persistenceController.container.viewContext)
.fullScreenCover(isPresented: $showStartView) {
if UIDevice.isPhoneIdiom { StartView(isPresented: $showStartView)
CompactHomeView()
.environment(\.managedObjectContext, persistenceController.container.viewContext)
} else {
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.stopSoundIfPossible() Conductor.maestro.stopMainPlayersIfPossible()
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
@ -68,17 +63,20 @@ struct LeCountdownApp: App {
} }
// fileprivate func _willEnterForegroundNotification() { fileprivate func _shouldShowStartView() -> Bool {
// Conductor.maestro.cleanup() let count = persistenceController.container.viewContext.count(entityName: "AbstractTimer")
// } 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))")
Sound.computeSoundDurationsIfNecessary() self.showStartView = self._shouldShowStartView()
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 })
@ -104,18 +102,17 @@ 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
@ -131,4 +128,43 @@ 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) throws { static func recordActivity(timer: AbstractTimer, dateInterval: DateInterval, cancelled: Bool) throws {
guard let activity = timer.activity else { guard let activity = timer.activity else {
return return
@ -54,8 +54,86 @@ 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 10/02/2023. // Created by Laurent Morvillier on 17/05/2023.
// //
// //
@ -16,7 +16,8 @@ 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 01/02/2023. // Created by Laurent Morvillier on 17/05/2023.
// //
// //
@ -16,8 +16,8 @@ extension AbstractTimer {
return NSFetchRequest<AbstractTimer>(entityName: "AbstractTimer") return NSFetchRequest<AbstractTimer>(entityName: "AbstractTimer")
} }
@NSManaged public var order: Int16
@NSManaged public var image: String? @NSManaged public var image: String?
@NSManaged public var order: Int16
@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 21/02/2023. // Created by Laurent Morvillier on 01/06/2023.
// //
// //
@ -18,9 +18,10 @@ 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 month: Int16 @NSManaged public var cancelled: Bool
@NSManaged public var activity: Activity? @NSManaged public var activity: Activity?
} }

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

@ -2,7 +2,7 @@
// Stopwatch+CoreDataProperties.swift // Stopwatch+CoreDataProperties.swift
// LeCountdown // LeCountdown
// //
// Created by Laurent Morvillier on 17/02/2023. // Created by Laurent Morvillier on 17/05/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.2.xcdatamodel</string> <string>LeCountdown.0.6.5.xcdatamodel</string>
</dict> </dict>
</plist> </plist>

@ -0,0 +1,52 @@
<?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>

@ -0,0 +1,52 @@
<?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>

@ -0,0 +1,53 @@
<?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>

@ -0,0 +1,38 @@
//
// 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,16 +11,18 @@ 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) as? AbstractTimer return context.object(stringId: self.id)
} }
var ended: Bool { var ended: Bool {
return self.date < Date() return self.date < Date()
} }
} }

@ -9,42 +9,76 @@ import Foundation
import SwiftUI import SwiftUI
import CoreData import CoreData
extension AbstractSoundTimer { extension AbstractSoundTimer {
var sounds: Set<Sound> { var playables: [any Playable] {
if let soundList { return playables(idList: self.playableIds)
return Set(soundList.enumItems()) }
var confirmationPlayables: [any Playable] {
return playables(idList: self.confirmationSoundList)
}
func playables(idList: String?) -> [any Playable] {
if let idList {
var playables: [any Playable] = []
let ids: [String] = idList.components(separatedBy: idSeparator)
for id in ids {
if let intId = numberFormatter.number(from: id)?.intValue,
let sound = Sound(rawValue: intId) {
playables.append(sound)
} else if let playlist = Playlist(rawValue: id) {
playables.append(playlist)
}
}
return playables
}
return []
}
var allSounds: Set<Sound> {
return self.playables.reduce(Set<Sound>()) { $0.union($1.soundList) }
}
var confirmationSounds: Set<Sound> {
if let confirmationSoundList {
return Set(confirmationSoundList.enumItems())
} }
return [] return []
} }
func setSounds(_ sounds: Set<Sound>) { func setConfirmationSounds(_ sounds: Set<Sound>) {
self.soundList = sounds.stringRepresentation self.confirmationSoundList = sounds.stringRepresentation
} }
var coolSound: Sound { var someSound: Sound {
var sounds = self.sounds var sounds: Set<Sound> = self.allSounds
if !AppGuard.main.isSubscriber {
sounds = sounds.filter { !$0.isRestricted }
}
// remove last played sound if the playlist has at least 3 sounds // remove last played sound if at least 3 sounds remains
if sounds.count > 2, if sounds.count > 2,
let lastSoundId = Preferences.lastSelectedSound[self.stringId], 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,
let lastSoundId = Preferences.lastSelectedSoundByTimer[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.lastSelectedSound[self.stringId] = random.id Preferences.lastSelectedSoundByTimer[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 {
@ -124,27 +158,3 @@ 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,6 +7,8 @@
import Foundation import Foundation
typealias TimerID = String
extension AbstractTimer { extension AbstractTimer {
var displayName: String { var displayName: String {
@ -25,7 +27,7 @@ extension AbstractTimer {
if let url = URL(string: self.stringId) { if let url = URL(string: self.stringId) {
return url return url
} else { } else {
fatalError("Can't produce url with \(self.stringId)") return URL(filePath: "") // dummy URL to avoid the pain of dealing with optional/error
} }
} }

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

@ -31,14 +31,21 @@ struct PersistenceController {
countdown.image = CoolPic.pic1.rawValue countdown.image = CoolPic.pic1.rawValue
} }
for i in 0..<14 { for i in 0..<20 {
let record = Record(context: viewContext) let record = Record(context: viewContext)
record.start = Date()
record.end = Date() let randomMonth = (0...10 * 31).randomElement() ?? 3
let monthTimeInterval = Double(randomMonth) * 3600 * 24
let start = Date().addingTimeInterval(-monthTimeInterval)
let duration = Double((1...10).randomElement() ?? 5) * 60.0
let end = start.addingTimeInterval(duration)
record.start = start
record.end = end
record.activity = activities.randomElement() record.activity = activities.randomElement()
} }
do { do {
try viewContext.save() try viewContext.save()
} catch { } catch {
@ -46,6 +53,7 @@ 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
@ -65,8 +73,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]
@ -94,6 +102,7 @@ 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)")
} }
}) })
@ -107,6 +116,7 @@ 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.")
} }

@ -0,0 +1,85 @@
//
// 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,6 +8,23 @@
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 }
} }
@ -23,34 +40,59 @@ class SoundCatalog {
} }
func sounds(for playlist: Playlist) -> [Sound] { func sounds(for playlist: Playlist) -> [Sound] {
return self._soundsByPlaylist[playlist] ?? [] switch playlist {
case .shorts:
return [.FF_SH_bowl_drone_tap_hold_E, .FF_SH_bowl_drone_tapping_C, .EX_ATSM_20_Inch_Highwall_Bowl_Hit_Ring_Ab, .ESM_Ambient_Game_Menu_Soft_Wood]
default:
return self._soundsByPlaylist[playlist] ?? []
}
} }
} }
enum Playlist: Int, CaseIterable, Identifiable, Localized { enum Catalog {
case ring
case confirmation
var id: Int { return self.rawValue } var playlists: [Playlist] {
switch self {
case .ring: return [.stephanBodzin, .nature, .relax]
case .confirmation: return [.shorts]
}
}
}
enum Playlist: String, CaseIterable, Identifiable, Localized {
var id: String { return self.rawValue }
case custom case custom
case nature case nature
case fun
case stephanBodzin case stephanBodzin
case relax
static var selectable: [Playlist] { case shorts
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" return "Stephan Bodzin - Boavista"
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
} }
} }
@ -61,70 +103,139 @@ 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 soundName: String { var fileName: 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 .trainhorn: return .fun case .sbSEM_Synths_Loop4_Nothing_Like_You, .sbLoop_ToneSD_Boavista, .sbArpeggio_Loop_River, .sbSquareArp_Loop_River, .sbHighChords_Loop_River, .sbMatriarchFxs_Loop2_Collider, .sbRose:
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 url: URL? { 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
}
}
let components = self.soundName.components(separatedBy: ".") var url: URL? {
let components = self.fileName.components(separatedBy: ".")
if components.count == 2 { if components.count == 2 {
return Bundle.main.url(forResource: components[0], withExtension: components[1]) return Bundle.main.url(forResource: components[0],
withExtension: components[1])
} else { } 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.soundName) return try SoundFile(fullName: self.fileName)
} }
func duration() async throws -> TimeInterval { func duration() async throws -> TimeInterval {
@ -142,9 +253,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.rawValue] == nil { if Preferences.soundDurations[sound.fileName] == nil {
if let duration = try? await sound.duration() { if let duration = try? await sound.duration() {
Preferences.soundDurations[sound.rawValue] = duration Preferences.soundDurations[sound.fileName] = duration
} }
} }
} }
@ -152,7 +263,7 @@ enum Sound: Int, CaseIterable, Identifiable, Localized {
} }
var formattedDuration: String { var formattedDuration: String {
if let duration = Preferences.soundDurations[self.rawValue] { if let duration = Preferences.soundDurations[self.fileName] {
return duration.minuteSecond return duration.minuteSecond
} else { } else {
return "" return ""

@ -31,47 +31,96 @@ 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 { @objc class SoundPlayer: NSObject, AVAudioPlayerDelegate, ObservableObject {
fileprivate var _player: AVAudioPlayer? fileprivate var _player: AVAudioPlayer?
func playSound(soundFile: SoundFile, repeats: Bool) throws { fileprivate var _timer: Timer? = nil
guard let url = soundFile.url else {
throw SoundPlayerError.missingResourceError(file: soundFile) @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)
} }
}
// let audioSession: AVAudioSession = AVAudioSession.sharedInstance() func play(soundFile: SoundFile, for duration: TimeInterval) throws {
// try audioSession.setCategory(.playback) try self.playSound(soundFile: soundFile)
// try audioSession.setActive(true) self._timer = Timer(timeInterval: duration, repeats: false, block: { _ in
self._player?.stop()
})
}
_player = try AVAudioPlayer(contentsOf: url) func playSound(soundFile: SoundFile) throws {
_player?.prepareToPlay() guard let url = soundFile.url else {
// let loopCount = repeats ? Int.max : 0 throw SoundPlayerError.missingResourceError(file: soundFile)
// _player?.numberOfLoops = 0 //loopCount }
_player?.volume = 1.0
_player?.delegate = self
// do { let player = try AVAudioPlayer(contentsOf: url)
// try AVAudioSession.sharedInstance().setCategory(.playAndRecord, mode: .default, options: [.allowBluetooth, .defaultToSpeaker]) player.prepareToPlay()
// } catch { player.volume = 1.0
// print("audioSession error = \(error)") player.delegate = self
// }
_player?.play() self._player = player
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)
}
} }
} }

@ -0,0 +1,82 @@
//
// 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") let basePredicate = NSPredicate(format: "start != nil AND activity = %@", activity)
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)
} }
fetchRequest.predicate = NSCompoundPredicate.init(andPredicateWithSubpredicates: predicates) let finalPredicate = NSCompoundPredicate(andPredicateWithSubpredicates: predicates)
fetchRequest.predicate = finalPredicate
fetchRequest.propertiesToFetch = expressions fetchRequest.propertiesToFetch = expressions
fetchRequest.resultType = NSFetchRequestResultType.dictionaryResultType fetchRequest.resultType = NSFetchRequestResultType.dictionaryResultType
@ -77,10 +77,9 @@ extension NSManagedObjectContext {
if let value { if let value {
let request = Record.fetchRequest() let request = Record.fetchRequest()
if let filter { request.predicate = finalPredicate
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 case .month(let month): return month.localizedString.capitalized
} }
} }
@ -55,11 +55,13 @@ fileprivate extension Int {
} }
struct Month { struct Month: Identifiable {
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("Duration", comment: "") case .totalDuration: return NSLocalizedString("Total 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 @@
// //
// Guard.swift // AppGuard.swift
// Poker Analytics 6 // LeCountdown
// //
// Created by Laurent Morvillier on 20/04/2022. // Created by Laurent Morvillier on 20/04/2022.
// //
@ -12,22 +12,33 @@ public enum StoreError: Error {
case failedVerification case failedVerification
} }
enum StorePlan : String, CaseIterable { enum StorePlan: String, CaseIterable {
case none case none
case unlimited = "com.staxriver.lecountdown.unlimited" case monthly = "com.staxriver.enchant.monthly"
case yearly = "com.staxriver.enchant.yearly"
var formattedPeriod: String {
switch self {
case .none: return ""
case .monthly: return NSLocalizedString("month", comment: "")
case .yearly: return NSLocalizedString("year", comment: "")
}
}
} }
extension Notification.Name { extension Notification.Name {
static let StoreEventHappened = Notification.Name("storeEventHappened") static let StoreEventHappened = Notification.Name("storeEventHappened")
} }
@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>()
var currentBestPlan: StoreKit.Transaction? = nil @Published var currentBestPlan: StoreKit.Transaction? = nil
var updateListenerTask: Task<Void, Error>? = nil var updateListenerTask: Task<Void, Error>? = nil
@ -114,35 +125,32 @@ extension Notification.Name {
return transaction return transaction
} }
var isAuthorized: Bool { var isSubscriber: Bool {
return self.currentPlan == .unlimited return self.currentPlan != .none
} }
var currentPlan: StorePlan { var currentPlan: StorePlan {
return .yearly
#if DEBUG
return .unlimited // #if DEBUG
#else // return .yearly
if let currentBestPlan = self.currentBestPlan, let plan = StorePlan(rawValue: currentBestPlan.productID) { // #else
return plan // if let currentBestPlan = self.currentBestPlan,
} // let plan = StorePlan(rawValue: currentBestPlan.productID) {
if let vf = Preferences.verifiedTransaction(), // return plan
vf.expiryDate > Date(), vf.graceDate > Date(), // }
let plan = StorePlan(rawValue: vf.productId) { // return .none
return plan // #endif
}
return .none
#endif
} }
fileprivate func _updateBestPlan() { fileprivate func _updateBestPlan() {
if let monthly = self.purchasedTransactions.first(where: { $0.productID == StorePlan.monthly.rawValue }) {
if let unlimited = self.purchasedTransactions.first(where: { $0.productID == StorePlan.unlimited.rawValue }) { self.currentBestPlan = monthly
self.currentBestPlan = unlimited } else if let yearly = self.purchasedTransactions.first(where: { $0.productID == StorePlan.yearly.rawValue }) {
self.currentBestPlan = yearly
} else { } else {
self.currentBestPlan = nil self.currentBestPlan = nil
} }
} }
} }

@ -0,0 +1,102 @@
//
// 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,15 +6,216 @@
// //
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 {
StoreView() PlanView(isPresented: .constant(false)).environmentObject(Store())
} }
} }

@ -0,0 +1,39 @@
//
// 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,11 +31,14 @@ 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, handler: handler) self._launchCountdown(countdown.stringId, handler: handler)
case let stopwatch as Stopwatch: case let stopwatch as Stopwatch:
self._startStopwatch(stopwatch, handler: handler) self._startStopwatch(stopwatch.stringId, handler: handler)
case let alarm as Alarm: case let alarm as Alarm:
self._scheduleAlarm(alarm, handler: handler) self._scheduleAlarm(alarm, handler: handler)
default: default:
@ -45,24 +48,17 @@ class TimerRouter {
} }
static func stopTimer(timer: AbstractTimer) { fileprivate static func _launchCountdown(_ countdownId: TimerID, handler: @escaping (Result<Void, Error>) -> Void) {
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
switch settings.authorizationStatus { DispatchQueue.main.async {
case .notDetermined, .denied:
handler(.failure(TimerRouterError.notificationAuthorizationMissing)) guard let countdown = IntentDataProvider.main.timer(id: countdownId) as? Countdown else {
default: handler(.failure(AppError.timerNotFound(id: countdownId)))
return
}
CountdownScheduler.master.scheduleIfPossible(countdown: countdown) { result in CountdownScheduler.master.scheduleIfPossible(countdown: countdown) { result in
switch result { switch result {
case .success: case .success:
@ -80,12 +76,12 @@ class TimerRouter {
} }
fileprivate static func _startStopwatch(_ stopwatch: Stopwatch, handler: @escaping (Result<Void, Error>) -> Void) { fileprivate static func _startStopwatch(_ stopwatchId: TimerID, handler: @escaping (Result<Void, Error>) -> Void) {
Conductor.maestro.startStopwatch(stopwatch) Conductor.maestro.startStopwatch(stopwatchId)
handler(.success(Void())) handler(.success(Void()))
} }
fileprivate static func _stopStopwatch(_ stopwatch: Stopwatch, end: Date? = nil) { fileprivate static func _stopStopwatch(_ stopwatch: Stopwatch) {
Conductor.maestro.stopStopwatch(stopwatch) Conductor.maestro.stopStopwatch(stopwatch)
} }

@ -0,0 +1,39 @@
//
// 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,31 +8,18 @@
import Foundation import Foundation
import MediaPlayer import MediaPlayer
@objc class AppleMusicPlayer : NSObject, MPMediaPickerControllerDelegate { class AppleMusicPlayer {
// func play() { let mediaItemCollection: MPMediaItemCollection
//
// let musicPlayer = MPMusicPlayerApplicationController.applicationQueuePlayer init(mediaItemCollection: MPMediaItemCollection) {
// musicPlayer.setQueue(with: .songs()) self.mediaItemCollection = mediaItemCollection
// }
// }
// func play() {
// func showMediaPicker(source: UIView) { let musicPlayer = MPMusicPlayerApplicationController.applicationQueuePlayer
// let controller = MPMediaPickerController(mediaTypes: .music) musicPlayer.setQueue(with: self.mediaItemCollection)
// controller.allowsPickingMultipleItems = true musicPlayer.play()
// 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.
//
// }
} }

@ -0,0 +1,31 @@
//
// 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
}
}
}

@ -0,0 +1,60 @@
//
// 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,6 +7,12 @@
import Foundation import Foundation
extension Date: Identifiable {
public var id: TimeInterval { return self.timeIntervalSince1970 }
}
extension Date { extension Date {
static let monthYearFormatter = { static let monthYearFormatter = {
@ -22,10 +28,23 @@ 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)
} }
@ -34,7 +53,8 @@ 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) } var formattedMonth: String { return Date.monthYearFormatter.string(from: self).capitalized }
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) }
} }

@ -0,0 +1,57 @@
//
// 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"
}
}

@ -0,0 +1,83 @@
//
// 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)
}
}

@ -0,0 +1,54 @@
//
// 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,6 +6,7 @@
// //
import Foundation import Foundation
import Firebase
@objc public class Logger : NSObject { @objc public class Logger : NSObject {
@ -28,6 +29,7 @@ import Foundation
} }
} }
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,20 +8,40 @@
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: [Int : TimeInterval] @UserDefault(PreferenceKey.soundDurations.rawValue, defaultValue: [:]) static var soundDurations: [String : TimeInterval]
@UserDefault(PreferenceKey.lastSoundPlayed.rawValue, defaultValue: [:]) static var lastSelectedSound: [String : Int] @UserDefault(PreferenceKey.lastSoundPlayedByTimer.rawValue, defaultValue: [:]) static var lastSelectedSoundByTimer: [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.kikai.NewCountdown" case newCountdown = "app.enchant.NewCountdown"
var userActivity: NSUserActivity { var userActivity: NSUserActivity {
let activity = NSUserActivity(activityType: self.rawValue) let activity = NSUserActivity(activityType: self.rawValue)

@ -20,13 +20,14 @@ 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,9 +9,18 @@ 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 > 1 { if h > 0 {
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)
@ -39,4 +48,11 @@ 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,22 +8,28 @@
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("You can add widget for your timers and countdowns by modifying your home or lock screen", comment: "") case .widget: return NSLocalizedString("Widget Tip", 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,4 +17,15 @@ 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)))
}
}
} }

@ -0,0 +1,13 @@
//
// 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,135 +9,83 @@ import SwiftUI
import CoreData import CoreData
import Combine import Combine
class BoringContext : ObservableObject {
@Published var isShowingNewData = false
@Published var error: Error?
@Published var showDefaultAlert: Bool = false
@Published var showPermissionAlert: Bool = false
}
class TimerSpot : Identifiable, Equatable {
var id: Int16 { return self.order }
var order: Int16
var timer: AbstractTimer?
init(order: Int16, timer: AbstractTimer? = nil) {
self.order = order
self.timer = timer
}
func setOrder(order: Int16) {
self.order = order
self.timer?.order = order
}
static func == (lhs: TimerSpot, rhs: TimerSpot) -> Bool {
return lhs.order == rhs.order && lhs.timer?.stringId == rhs.timer?.stringId
}
}
struct ContentView<T : AbstractTimer>: View { struct ContentView<T : AbstractTimer>: View {
@StateObject var boringContext: BoringContext = BoringContext()
@EnvironmentObject var conductor: Conductor
@Environment(\.managedObjectContext) private var viewContext @Environment(\.managedObjectContext) private var viewContext
@EnvironmentObject var conductor: Conductor
@Environment(\.colorScheme) var colorScheme
@FetchRequest( @StateObject var boringContext: BoringContext = BoringContext.main
sortDescriptors: [NSSortDescriptor(keyPath: \T.order, ascending: true)],
animation: .default)
private var timers: FetchedResults<T>
@State private var isEditing: Bool = false @State private var isEditing: Bool = false
@State private var tipsShown: Bool = false @State private var showTip: Tip? = nil
fileprivate let itemSpacing: CGFloat = 10.0
var body: some View {
let columns: [GridItem] = self._columns()
GeometryReader { reader in
let width: CGFloat = reader.size.width / CGFloat(columns.count) - 15.0
VStack { @State private var siriTipShown: Bool = false
ScrollView { @State var showAddSheet: Bool = false
@State private var showSettingsSheet: Bool = false
@State private var showStatsSheet: Bool = false
@State private var showSubscriptionSheet: Bool = false
LazyVGrid( @FetchRequest(
columns: columns, sortDescriptors: [NSSortDescriptor(keyPath: \AbstractTimer.order, ascending: true)],
spacing: itemSpacing animation: .default)
) { private var timers: FetchedResults<AbstractTimer>
ReorderableForEach(items: Array(timers)) { timer in
DialView(timer: timer, isEditingBinding: self.$isEditing, frameSize: width) var body: some View {
.environment(\.managedObjectContext, viewContext)
.environmentObject(Conductor.maestro)
.environmentObject(boringContext)
} moveAction: { from, to in
self._reorder(from: from, to: to)
}
} VStack {
}.padding(.horizontal, itemSpacing)
if !self.tipsShown, let tip = Preferences.tipToShow { if !AppGuard.main.isSubscriber {
TipView(tip: tip) { SubscriptionButtonView()
self._hideTip(tip) }
}.padding()
}
if !conductor.liveTimers.isEmpty { TimersView(isEditing: self.$isEditing,
LiveTimerListView() showSubscriptionSheet: self.$showSubscriptionSheet,
.environment(\.managedObjectContext, viewContext) showAddSheet: self.$showAddSheet,
.environmentObject(conductor) siriHandler: { timer in
.foregroundColor(.white) self._handleSiriTips(timer: timer)
.background(Color(white: 0.1)) })
.cornerRadius(32.0, corners: [.topRight, .topLeft]) .environment(\.managedObjectContext, viewContext)
} .environmentObject(self.boringContext)
if !conductor.liveTimers.isEmpty {
Spacer()
#if !TARGET_IPHONE_SIMULATOR
SiriVolumeView(timer: self.boringContext.siriTimer, siriTipShown: self.$siriTipShown)
#endif
LiveTimerListView()
.environment(\.managedObjectContext, viewContext)
.environmentObject(conductor)
.padding(.horizontal, 12.0)
.foregroundColor(.black)
.background(self._backgroundColor)
.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("Home") .navigationTitle(Bundle.main.applicationName)
.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: $boringContext.isShowingNewData, content: { .sheet(isPresented: self.$boringContext.isShowingNewData, content: {
// self._newView(isPresented: $boringContext.isShowingNewData)
// .environment(\.managedObjectContext, viewContext)
}) })
.toolbar { .toolbar {
// ToolbarItem(placement: .navigationBarTrailing) { MainToolbarView(isEditing: self.$isEditing, showAddSheet: self.$showAddSheet)
// 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._askPermissions() self.showTip = Preferences.tipToShow
// self.showLiveTimersSheet = !conductor.liveTimers.isEmpty
} }
.onOpenURL { url in .onOpenURL { url in
self._performActionIfPossible(url: url) self._performActionIfPossible(url: url)
@ -145,48 +93,23 @@ struct ContentView<T : AbstractTimer>: View {
} }
fileprivate func _columnCount() -> Int { fileprivate var _backgroundColor: Color {
#if os(iOS) return Color(white: self.colorScheme == .dark ? 0.1 : 0.9)
if UIDevice.isPhoneIdiom {
return 2
} else {
return 3
}
#else
return 3
#endif
}
fileprivate func _columns() -> [GridItem] {
return (0..<self._columnCount()).map { _ in GridItem(spacing: 10.0) }
} }
// MARK: - Business fileprivate func _handleSiriTips(timer: AbstractTimer) {
let timerId = timer.stringId
fileprivate func _hideTip(_ tip: Tip) { if !Preferences.timerSiriTips.contains(timerId) {
Preferences.lastShownTip = tip.rawValue self.boringContext.siriTimer = timer
self.tipsShown = true Preferences.timerSiriTips.insert(timerId)
} self.siriTipShown = true
} else {
fileprivate func _reorder(from: IndexSet, to: Int) { self.boringContext.siriTimer = nil
var timers: [AbstractTimer] = Array(self.timers) self.siriTipShown = false
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() { // MARK: - Business
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) {
@ -196,7 +119,7 @@ struct ContentView<T : AbstractTimer>: View {
print("_performActionIfPossible") print("_performActionIfPossible")
let urlString = url.absoluteString let urlString = url.absoluteString
if let timer = viewContext.object(stringId: urlString) as? AbstractTimer { if let timer = self.viewContext.object(stringId: urlString) as? AbstractTimer {
if timer is T { if timer is T {
print("performAction") print("performAction")
@ -215,11 +138,8 @@ 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)")
} }
@ -228,37 +148,64 @@ struct ContentView<T : AbstractTimer>: View {
} }
struct MainToolbarView: View { struct MainToolbarView: ToolbarContent {
var isShowingNewData: Binding<Bool> @Binding var isEditing: Bool
@Binding var showAddSheet: Bool
var body: some View { @State var showSettingsSheet: Bool = false
Button { @State var showStatsSheet: Bool = false
self.isShowingNewData.wrappedValue = true
} label: { @FetchRequest(
HStack { sortDescriptors: [NSSortDescriptor(keyPath: \AbstractTimer.order, ascending: true)],
Image(systemName: "timer") animation: .default)
Text("countdown") 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 {
withAnimation {
self.isEditing.toggle()
}
} label: {
Text(self.isEditing ? "Done" : "Edit")
}
} }
} }
Button { ToolbarItemGroup(placement: .navigationBarTrailing) {
self.isShowingNewData.wrappedValue = true if !self.timers.isEmpty {
} label: { Button {
HStack { withAnimation {
Image(systemName: "stopwatch") self.showSettingsSheet.toggle()
Text("stopwatch") }
} label: {
Image(systemName: "gearshape.fill")
}
.sheet(isPresented: self.$showSettingsSheet, content: {
NavigationStack {
SettingsView()
.navigationBarTitleDisplayMode(.inline)
}.presentationDetents([.height(400.0)])
})
} }
} Button {
Button { withAnimation {
self.isShowingNewData.wrappedValue = true self.showAddSheet.toggle()
} label: { }
HStack { } label: {
Image(systemName: "alarm") Image(systemName: "plus")
Text("alarm")
} }
.sheet(isPresented: self.$showAddSheet, content: {
StartView(isPresented: self.$showAddSheet)
})
} }
} }
} }
fileprivate extension Countdown { fileprivate extension Countdown {
@ -271,19 +218,40 @@ fileprivate extension Countdown {
return endDate != nil return endDate != nil
} }
// var colorForStatus: Color { }
// if isLive {
// return Color(red: 0.9, green: 1.0, blue: 0.95) class TimerSpot : Identifiable, Equatable {
// } else {
// return Color(red: 0.9, green: 0.95, blue: 1.0) var id: Int16 { return self.order }
// }
// } var order: Int16
var timer: AbstractTimer?
init(order: Int16, timer: AbstractTimer? = nil) {
self.order = order
self.timer = timer
}
func setOrder(order: Int16) {
self.order = order
self.timer?.order = order
}
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 {
ContentView<Countdown>().environment(\.managedObjectContext, PersistenceController.preview.container.viewContext) .environmentObject(Conductor.maestro) NavigationStack {
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