Compare commits
161 Commits
@ -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. |
||||
@ -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 |
||||
} |
||||
|
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> |
||||
@ -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> |
||||
@ -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)" |
||||
]) |
||||
} |
||||
|
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -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) |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -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 |
||||
} |
||||
|
||||
} |
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@ -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 |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -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() |
||||
} |
||||
} |
||||
@ -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" |
||||
} |
||||
} |
||||
|
||||
} |
||||
@ -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) |
||||
} |
||||
|
||||
} |
||||
@ -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 |
||||
} |
||||
|
||||
} |
||||
@ -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" |
||||
} |
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in new issue