diff --git a/.github/workflows/android_build.yml b/.github/workflows/android_build.yml
deleted file mode 100644
index afdc61c..0000000
--- a/.github/workflows/android_build.yml
+++ /dev/null
@@ -1,22 +0,0 @@
-name: Android Build
-
-on: [pull_request, push]
-
-jobs:
- build:
- runs-on: ubuntu-latest
-
- steps:
- - name: Checkout the code
- uses: actions/checkout@v2
-
- - name: Set Up JDK
- uses: actions/setup-java@v1
- with:
- java-version: 17
-
- - name: Run Tests
- run: ./gradlew test
-
- - name: Build the app
- run: ./gradlew build
\ No newline at end of file
diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
new file mode 100644
index 0000000..fa56880
--- /dev/null
+++ b/.github/workflows/build.yml
@@ -0,0 +1,45 @@
+name: Build & Test
+
+on:
+ push:
+ branches: [main, master, develop]
+ pull_request:
+ branches: [main, master, develop]
+
+jobs:
+ build:
+ name: Build & Test
+ runs-on: ubuntu-latest
+
+ steps:
+ - name: Checkout
+ uses: actions/checkout@v4
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v4
+ with:
+ java-version: '17'
+ distribution: 'temurin'
+
+ - name: Setup Gradle
+ uses: gradle/actions/setup-gradle@v4
+ with:
+ cache-read-only: ${{ github.ref != 'refs/heads/main' }}
+
+ # Runs commonTest + androidHostTest for core module
+ - name: Run Core Module Tests
+ run: ./gradlew :core:testAndroidHostTest
+
+ # Runs commonTest + Android unit tests for app module
+ - name: Run App Module Tests
+ run: ./gradlew :app:testDebugUnitTest
+
+ - name: Build Debug APK
+ run: ./gradlew :app:assembleDebug
+
+ - name: Upload APK
+ uses: actions/upload-artifact@v4
+ with:
+ name: debug-apk
+ path: app/build/outputs/apk/debug/*.apk
+ retention-days: 7
\ No newline at end of file
diff --git a/.idea/ChatHistory_schema_v4.xml b/.idea/ChatHistory_schema_v4.xml
new file mode 100644
index 0000000..7880fd1
--- /dev/null
+++ b/.idea/ChatHistory_schema_v4.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/artifacts/core.xml b/.idea/artifacts/core.xml
new file mode 100644
index 0000000..88ce0c8
--- /dev/null
+++ b/.idea/artifacts/core.xml
@@ -0,0 +1,8 @@
+
+
+ $PROJECT_DIR$/core/build/libs
+
+
+
+
+
\ No newline at end of file
diff --git a/.idea/firebender/chat_history.db b/.idea/firebender/chat_history.db
new file mode 100644
index 0000000..09968d7
Binary files /dev/null and b/.idea/firebender/chat_history.db differ
diff --git a/.idea/firebender/chat_history.db-shm b/.idea/firebender/chat_history.db-shm
new file mode 100644
index 0000000..b5612b9
Binary files /dev/null and b/.idea/firebender/chat_history.db-shm differ
diff --git a/.idea/firebender/chat_history.db-wal b/.idea/firebender/chat_history.db-wal
new file mode 100644
index 0000000..e7a8bfd
Binary files /dev/null and b/.idea/firebender/chat_history.db-wal differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-commonMain-WmoUwA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-commonMain-WmoUwA.klib
new file mode 100644
index 0000000..a580fff
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-commonMain-WmoUwA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-nonJvmMain-WmoUwA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-nonJvmMain-WmoUwA.klib
new file mode 100644
index 0000000..9a6b924
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.annotation-annotation-1.9.1-nonJvmMain-WmoUwA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-commonMain-j7f1lg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-commonMain-j7f1lg.klib
new file mode 100644
index 0000000..b4bbb52
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-commonMain-j7f1lg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-darwinMain-1oCDtg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-darwinMain-1oCDtg.klib
new file mode 100644
index 0000000..9d7c42d
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-darwinMain-1oCDtg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nativeMain-j7f1lg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nativeMain-j7f1lg.klib
new file mode 100644
index 0000000..feac87a
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nativeMain-j7f1lg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nonJvmMain-j7f1lg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nonJvmMain-j7f1lg.klib
new file mode 100644
index 0000000..7cfdd44
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-nonJvmMain-j7f1lg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-unixMain-j7f1lg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-unixMain-j7f1lg.klib
new file mode 100644
index 0000000..0fbe7dc
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.collection-collection-1.5.0-unixMain-j7f1lg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-commonMain-jL1lig.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-commonMain-jL1lig.klib
new file mode 100644
index 0000000..8a3f255
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-commonMain-jL1lig.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-darwinMain-LBH16w.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-darwinMain-LBH16w.klib
new file mode 100644
index 0000000..22c67e1
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-darwinMain-LBH16w.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nativeMain-jL1lig.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nativeMain-jL1lig.klib
new file mode 100644
index 0000000..d91958b
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nativeMain-jL1lig.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonAndroidMain-jL1lig.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonAndroidMain-jL1lig.klib
new file mode 100644
index 0000000..7d2f402
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonAndroidMain-jL1lig.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonJvmMain-jL1lig.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonJvmMain-jL1lig.klib
new file mode 100644
index 0000000..5bafcc4
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-nonJvmMain-jL1lig.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-unixMain-jL1lig.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-unixMain-jL1lig.klib
new file mode 100644
index 0000000..cf168af
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-1.10.0-unixMain-jL1lig.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-annotation-1.10.0-commonMain-stVLwg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-annotation-1.10.0-commonMain-stVLwg.klib
new file mode 100644
index 0000000..f24c9db
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-annotation-1.10.0-commonMain-stVLwg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-commonMain-gAgqGg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-commonMain-gAgqGg.klib
new file mode 100644
index 0000000..9203d6f
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-commonMain-gAgqGg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-nonJvmMain-gAgqGg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-nonJvmMain-gAgqGg.klib
new file mode 100644
index 0000000..423be95
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-retain-1.10.0-nonJvmMain-gAgqGg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-saveable-1.10.0-commonMain-2vxcdQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-saveable-1.10.0-commonMain-2vxcdQ.klib
new file mode 100644
index 0000000..97fe739
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.compose.runtime-runtime-saveable-1.10.0-commonMain-2vxcdQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-commonMain-ENdyRA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-commonMain-ENdyRA.klib
new file mode 100644
index 0000000..4caba4a
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-commonMain-ENdyRA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-nonJvmMain-ENdyRA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-nonJvmMain-ENdyRA.klib
new file mode 100644
index 0000000..401ac46
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-common-2.10.0-nonJvmMain-ENdyRA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-commonMain-MSpfvg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-commonMain-MSpfvg.klib
new file mode 100644
index 0000000..92a5aac
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-commonMain-MSpfvg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nativeMain-MSpfvg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nativeMain-MSpfvg.klib
new file mode 100644
index 0000000..3eac374
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nativeMain-MSpfvg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nonJvmMain-MSpfvg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nonJvmMain-MSpfvg.klib
new file mode 100644
index 0000000..e96d244
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-2.10.0-nonJvmMain-MSpfvg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-commonMain-UUsvAg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-commonMain-UUsvAg.klib
new file mode 100644
index 0000000..51265b6
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-commonMain-UUsvAg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-nonAndroidMain-UUsvAg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-nonAndroidMain-UUsvAg.klib
new file mode 100644
index 0000000..916ced1
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-runtime-compose-2.10.0-nonAndroidMain-UUsvAg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-commonMain-1eFh5A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-commonMain-1eFh5A.klib
new file mode 100644
index 0000000..03c0b6d
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-commonMain-1eFh5A.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-darwinMain-DPL3kg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-darwinMain-DPL3kg.klib
new file mode 100644
index 0000000..2b1e298
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-darwinMain-DPL3kg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nativeMain-1eFh5A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nativeMain-1eFh5A.klib
new file mode 100644
index 0000000..e30060b
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nativeMain-1eFh5A.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nonJvmMain-1eFh5A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nonJvmMain-1eFh5A.klib
new file mode 100644
index 0000000..e3493e8
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-nonJvmMain-1eFh5A.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-unixMain-1eFh5A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-unixMain-1eFh5A.klib
new file mode 100644
index 0000000..9e0c384
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-2.10.0-unixMain-1eFh5A.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-commonMain-bNK2hQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-commonMain-bNK2hQ.klib
new file mode 100644
index 0000000..b90d172
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-commonMain-bNK2hQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nativeMain-bNK2hQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nativeMain-bNK2hQ.klib
new file mode 100644
index 0000000..4c60494
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nativeMain-bNK2hQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nonAndroidMain-bNK2hQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nonAndroidMain-bNK2hQ.klib
new file mode 100644
index 0000000..28aa0c4
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-nonAndroidMain-bNK2hQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigation3-navigation3-runtime-1.1.0-alpha01-commonMain-Ia37Wg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigation3-navigation3-runtime-1.1.0-alpha01-commonMain-Ia37Wg.klib
new file mode 100644
index 0000000..4a32b5a
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigation3-navigation3-runtime-1.1.0-alpha01-commonMain-Ia37Wg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-commonMain-CVCQsg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-commonMain-CVCQsg.klib
new file mode 100644
index 0000000..efd5910
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-commonMain-CVCQsg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-darwinMain-v-PyAQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-darwinMain-v-PyAQ.klib
new file mode 100644
index 0000000..621464c
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-darwinMain-v-PyAQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-nativeMain-CVCQsg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-nativeMain-CVCQsg.klib
new file mode 100644
index 0000000..f80260c
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-nativeMain-CVCQsg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-unixMain-CVCQsg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-unixMain-CVCQsg.klib
new file mode 100644
index 0000000..86cfa5e
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.navigationevent-navigationevent-1.0.1-unixMain-CVCQsg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-commonMain-FV9dPA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-commonMain-FV9dPA.klib
new file mode 100644
index 0000000..d1fa591
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-commonMain-FV9dPA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-nativeMain-FV9dPA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-nativeMain-FV9dPA.klib
new file mode 100644
index 0000000..622bac5
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-common-2.8.4-nativeMain-FV9dPA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-commonMain-oKhOvw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-commonMain-oKhOvw.klib
new file mode 100644
index 0000000..b97b17b
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-commonMain-oKhOvw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-jvmNativeMain-oKhOvw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-jvmNativeMain-oKhOvw.klib
new file mode 100644
index 0000000..0be6098
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-jvmNativeMain-oKhOvw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-nativeMain-oKhOvw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-nativeMain-oKhOvw.klib
new file mode 100644
index 0000000..32c6269
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.room-room-runtime-2.8.4-nativeMain-oKhOvw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-commonMain-JkDvJw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-commonMain-JkDvJw.klib
new file mode 100644
index 0000000..88b04f5
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-commonMain-JkDvJw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-darwinMain-PeDSBw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-darwinMain-PeDSBw.klib
new file mode 100644
index 0000000..42e7ec2
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-darwinMain-PeDSBw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nativeMain-JkDvJw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nativeMain-JkDvJw.klib
new file mode 100644
index 0000000..6daa445
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nativeMain-JkDvJw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nonAndroidMain-JkDvJw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nonAndroidMain-JkDvJw.klib
new file mode 100644
index 0000000..d1a18db
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-nonAndroidMain-JkDvJw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-unixMain-JkDvJw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-unixMain-JkDvJw.klib
new file mode 100644
index 0000000..e197e3b
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-1.4.0-unixMain-JkDvJw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-commonMain-YfHBdg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-commonMain-YfHBdg.klib
new file mode 100644
index 0000000..f7218c1
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-commonMain-YfHBdg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-nonAndroidMain-YfHBdg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-nonAndroidMain-YfHBdg.klib
new file mode 100644
index 0000000..33ae7b6
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.savedstate-savedstate-compose-1.4.0-nonAndroidMain-YfHBdg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-commonMain-Y3bhRQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-commonMain-Y3bhRQ.klib
new file mode 100644
index 0000000..964098d
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-commonMain-Y3bhRQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-nativeMain-Y3bhRQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-nativeMain-Y3bhRQ.klib
new file mode 100644
index 0000000..3260e06
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-2.6.2-nativeMain-Y3bhRQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-commonMain-dCYVRw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-commonMain-dCYVRw.klib
new file mode 100644
index 0000000..1f49c9a
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-commonMain-dCYVRw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-nativeMain-dCYVRw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-nativeMain-dCYVRw.klib
new file mode 100644
index 0000000..26ee413
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-bundled-2.6.2-nativeMain-dCYVRw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-framework-2.6.2-nativeMain-5Ubkcg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-framework-2.6.2-nativeMain-5Ubkcg.klib
new file mode 100644
index 0000000..a02f4cd
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/androidx.sqlite-sqlite-framework-2.6.2-nativeMain-5Ubkcg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/app.cash.turbine-turbine-1.2.1-commonMain-3FY4Jw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/app.cash.turbine-turbine-1.2.1-commonMain-3FY4Jw.klib
new file mode 100644
index 0000000..af8183b
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/app.cash.turbine-turbine-1.2.1-commonMain-3FY4Jw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-1.7.0-commonMain-E3zqSA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-1.7.0-commonMain-E3zqSA.klib
new file mode 100644
index 0000000..7c803ec
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-1.7.0-commonMain-E3zqSA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-core-1.7.0-commonMain-jbtC-Q.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-core-1.7.0-commonMain-jbtC-Q.klib
new file mode 100644
index 0000000..5797a4d
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/cafe.adriel.lyricist-lyricist-core-1.7.0-commonMain-jbtC-Q.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-commonMain-rUoOlA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-commonMain-rUoOlA.klib
new file mode 100644
index 0000000..0a07300
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-commonMain-rUoOlA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-nativeMain-rUoOlA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-nativeMain-rUoOlA.klib
new file mode 100644
index 0000000..b2199d3
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-2.0.8-nativeMain-rUoOlA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-appleMain-Mv5SxQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-appleMain-Mv5SxQ.klib
new file mode 100644
index 0000000..04946d4
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-appleMain-Mv5SxQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-commonMain-3AHZ4A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-commonMain-3AHZ4A.klib
new file mode 100644
index 0000000..dc43258
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-commonMain-3AHZ4A.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-nativeMain-3AHZ4A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-nativeMain-3AHZ4A.klib
new file mode 100644
index 0000000..0d7aae1
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-core-2.0.8-nativeMain-3AHZ4A.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-crashlytics-2.0.8-commonMain-zvpb6Q.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-crashlytics-2.0.8-commonMain-zvpb6Q.klib
new file mode 100644
index 0000000..df227a5
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-kermit-crashlytics-2.0.8-commonMain-zvpb6Q.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-appleMain-oa7GPg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-appleMain-oa7GPg.klib
new file mode 100644
index 0000000..fc7216e
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-appleMain-oa7GPg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-commonMain-t1ZQYw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-commonMain-t1ZQYw.klib
new file mode 100644
index 0000000..fab96b5
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-commonMain-t1ZQYw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-nativeMain-t1ZQYw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-nativeMain-t1ZQYw.klib
new file mode 100644
index 0000000..eb3f933
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrency-2.1.0-nativeMain-t1ZQYw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrent-collections-2.1.0-commonMain-jSCQOA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrent-collections-2.1.0-commonMain-jSCQOA.klib
new file mode 100644
index 0000000..3a38149
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-concurrent-collections-2.1.0-commonMain-jSCQOA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-commonMain-dKdBGA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-commonMain-dKdBGA.klib
new file mode 100644
index 0000000..01c5195
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-commonMain-dKdBGA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-nativeMain-dKdBGA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-nativeMain-dKdBGA.klib
new file mode 100644
index 0000000..42afcd0
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab-stately-strict-2.1.0-nativeMain-dKdBGA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-commonMain-KN4_Hg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-commonMain-KN4_Hg.klib
new file mode 100644
index 0000000..4af1a12
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-commonMain-KN4_Hg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-darwinMain-FcyXVA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-darwinMain-FcyXVA.klib
new file mode 100644
index 0000000..264896e
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-core-0.8.5-darwinMain-FcyXVA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-commonMain-0LMh8A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-commonMain-0LMh8A.klib
new file mode 100644
index 0000000..7e095ad
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-commonMain-0LMh8A.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-darwinMain-Pbfr_A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-darwinMain-Pbfr_A.klib
new file mode 100644
index 0000000..0f4a971
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/co.touchlab.crashkios-crashlytics-0.8.5-darwinMain-Pbfr_A.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.rickclephas.kmp-nsexception-kt-core-0.1.10-commonMain-Z_lGpw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.rickclephas.kmp-nsexception-kt-core-0.1.10-commonMain-Z_lGpw.klib
new file mode 100644
index 0000000..44acfe6
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.rickclephas.kmp-nsexception-kt-core-0.1.10-commonMain-Z_lGpw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-apple64Main-YvI2jQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-apple64Main-YvI2jQ.klib
new file mode 100644
index 0000000..276e9fc
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-apple64Main-YvI2jQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-appleMain-YvI2jQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-appleMain-YvI2jQ.klib
new file mode 100644
index 0000000..8939240
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-appleMain-YvI2jQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-commonMain-oQmQdg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-commonMain-oQmQdg.klib
new file mode 100644
index 0000000..2f56da0
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-1.3.0-commonMain-oQmQdg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-appleMain-ckY_5A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-appleMain-ckY_5A.klib
new file mode 100644
index 0000000..1326a79
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-appleMain-ckY_5A.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-commonMain-QEsy7A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-commonMain-QEsy7A.klib
new file mode 100644
index 0000000..81a635b
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.russhwolf-multiplatform-settings-no-arg-1.3.0-commonMain-QEsy7A.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-appleMain-oxtp1g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-appleMain-oxtp1g.klib
new file mode 100644
index 0000000..f661264
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-appleMain-oxtp1g.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-commonMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-commonMain-6b-baw.klib
new file mode 100644
index 0000000..797e70d
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-commonMain-6b-baw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-hashFunctions-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-hashFunctions-6b-baw.klib
new file mode 100644
index 0000000..083bc76
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-hashFunctions-6b-baw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nativeMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nativeMain-6b-baw.klib
new file mode 100644
index 0000000..cc7f6b9
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nativeMain-6b-baw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJsMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJsMain-6b-baw.klib
new file mode 100644
index 0000000..95c09c1
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJsMain-6b-baw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJvmMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJvmMain-6b-baw.klib
new file mode 100644
index 0000000..d958153
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-nonJvmMain-6b-baw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-systemFileSystemMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-systemFileSystemMain-6b-baw.klib
new file mode 100644
index 0000000..292cdc0
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-systemFileSystemMain-6b-baw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-unixMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-unixMain-6b-baw.klib
new file mode 100644
index 0000000..493225c
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-unixMain-6b-baw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-zlibMain-6b-baw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-zlibMain-6b-baw.klib
new file mode 100644
index 0000000..d0a6c95
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/com.squareup.okio-okio-3.15.0-zlibMain-6b-baw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-commonMain-lMMlqw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-commonMain-lMMlqw.klib
new file mode 100644
index 0000000..b1f7158
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-commonMain-lMMlqw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-jvmNativeMain-lMMlqw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-jvmNativeMain-lMMlqw.klib
new file mode 100644
index 0000000..1972880
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-jvmNativeMain-lMMlqw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-skikoMain-lMMlqw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-skikoMain-lMMlqw.klib
new file mode 100644
index 0000000..00b9ff2
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-core-2.0.2-skikoMain-lMMlqw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-lite-2.0.2-commonMain-f_1qIA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-lite-2.0.2-commonMain-f_1qIA.klib
new file mode 100644
index 0000000..881578c
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-compottie-lite-2.0.2-commonMain-f_1qIA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-keight-core-0.0.04-commonMain-aCo-QQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-keight-core-0.0.04-commonMain-aCo-QQ.klib
new file mode 100644
index 0000000..646e95e
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.github.alexzhirkevich-keight-core-0.0.04-commonMain-aCo-QQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-commonMain-0NT22Q.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-commonMain-0NT22Q.klib
new file mode 100644
index 0000000..24e8cd9
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-commonMain-0NT22Q.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-nativeMain-Txoobw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-nativeMain-Txoobw.klib
new file mode 100644
index 0000000..5e757d4
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-4.1.1-nativeMain-Txoobw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-viewmodel-4.1.1-commonMain-0POyBw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-viewmodel-4.1.1-commonMain-0POyBw.klib
new file mode 100644
index 0000000..d0d9631
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-compose-viewmodel-4.1.1-commonMain-0POyBw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-commonMain-hXs0VA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-commonMain-hXs0VA.klib
new file mode 100644
index 0000000..3eccdb3
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-commonMain-hXs0VA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-nativeMain-hXs0VA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-nativeMain-hXs0VA.klib
new file mode 100644
index 0000000..60cd907
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-4.1.1-nativeMain-hXs0VA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-viewmodel-4.1.1-commonMain-EFzrFQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-viewmodel-4.1.1-commonMain-EFzrFQ.klib
new file mode 100644
index 0000000..e59c384
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.insert-koin-koin-core-viewmodel-4.1.1-commonMain-EFzrFQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-commonMain-Gm0JHA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-commonMain-Gm0JHA.klib
new file mode 100644
index 0000000..3a16003
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-commonMain-Gm0JHA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-nonjvmMain-Gm0JHA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-nonjvmMain-Gm0JHA.klib
new file mode 100644
index 0000000..dabec71
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-core-6.0.7-nonjvmMain-Gm0JHA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-commonMain-Jfng_w.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-commonMain-Jfng_w.klib
new file mode 100644
index 0000000..ef70344
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-commonMain-Jfng_w.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-nonjvmMain-Jfng_w.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-nonjvmMain-Jfng_w.klib
new file mode 100644
index 0000000..8363099
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-assertions-shared-6.0.7-nonjvmMain-Jfng_w.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-commonMain-BC0QFg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-commonMain-BC0QFg.klib
new file mode 100644
index 0000000..00351b5
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-commonMain-BC0QFg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nativeMain-BC0QFg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nativeMain-BC0QFg.klib
new file mode 100644
index 0000000..45af768
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nativeMain-BC0QFg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nonjvmMain-BC0QFg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nonjvmMain-BC0QFg.klib
new file mode 100644
index 0000000..7e046da
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/io.kotest-kotest-common-6.0.7-nonjvmMain-BC0QFg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-common-2.10.0-alpha07-commonMain-F7wFOg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-common-2.10.0-alpha07-commonMain-F7wFOg.klib
new file mode 100644
index 0000000..03f48a6
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-common-2.10.0-alpha07-commonMain-F7wFOg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-2.10.0-alpha06-commonMain-sW-D8A.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-2.10.0-alpha06-commonMain-sW-D8A.klib
new file mode 100644
index 0000000..7acebb5
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-2.10.0-alpha06-commonMain-sW-D8A.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-compose-2.10.0-alpha06-commonMain-JomyRw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-compose-2.10.0-alpha06-commonMain-JomyRw.klib
new file mode 100644
index 0000000..71555b3
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-runtime-compose-2.10.0-alpha06-commonMain-JomyRw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-2.10.0-alpha07-commonMain-3MD3hQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-2.10.0-alpha07-commonMain-3MD3hQ.klib
new file mode 100644
index 0000000..7b4dbe5
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-2.10.0-alpha07-commonMain-3MD3hQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-commonMain-1J6zNw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-commonMain-1J6zNw.klib
new file mode 100644
index 0000000..0d25488
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-commonMain-1J6zNw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nativeMain-P2UNFA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nativeMain-P2UNFA.klib
new file mode 100644
index 0000000..7a9db05
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nativeMain-P2UNFA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nonAndroidMain-1J6zNw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nonAndroidMain-1J6zNw.klib
new file mode 100644
index 0000000..def6201
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-compose-2.10.0-alpha07-nonAndroidMain-1J6zNw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-commonMain-lOMNYw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-commonMain-lOMNYw.klib
new file mode 100644
index 0000000..68a2438
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-commonMain-lOMNYw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-nonAndroidMain-lOMNYw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-nonAndroidMain-lOMNYw.klib
new file mode 100644
index 0000000..096f8f1
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-navigation3-2.10.0-alpha07-nonAndroidMain-lOMNYw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-alpha07-commonMain-aoKHeg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-alpha07-commonMain-aoKHeg.klib
new file mode 100644
index 0000000..440a11b
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.lifecycle-lifecycle-viewmodel-savedstate-2.10.0-alpha07-commonMain-aoKHeg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-commonMain-LvU4Gg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-commonMain-LvU4Gg.klib
new file mode 100644
index 0000000..263b9ab
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-commonMain-LvU4Gg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-uikitMain-ZuYz1Q.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-uikitMain-ZuYz1Q.klib
new file mode 100644
index 0000000..67d1236
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigation3-navigation3-ui-1.0.0-alpha06-uikitMain-ZuYz1Q.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-commonMain-8XaMeA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-commonMain-8XaMeA.klib
new file mode 100644
index 0000000..00bdec8
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-commonMain-8XaMeA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-nonAndroidMain-8XaMeA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-nonAndroidMain-8XaMeA.klib
new file mode 100644
index 0000000..e756dd8
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.navigationevent-navigationevent-compose-1.0.0-rc01-nonAndroidMain-8XaMeA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-1.3.6-commonMain-Gx5ULw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-1.3.6-commonMain-Gx5ULw.klib
new file mode 100644
index 0000000..4bdf70f
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-1.3.6-commonMain-Gx5ULw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-compose-1.3.6-commonMain-Fw7d1w.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-compose-1.3.6-commonMain-Fw7d1w.klib
new file mode 100644
index 0000000..7dd18e3
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.androidx.savedstate-savedstate-compose-1.3.6-commonMain-Fw7d1w.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-commonMain-XCRBBA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-commonMain-XCRBBA.klib
new file mode 100644
index 0000000..368a0f6
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-commonMain-XCRBBA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonAndroidMain-XCRBBA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonAndroidMain-XCRBBA.klib
new file mode 100644
index 0000000..3526df8
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonAndroidMain-XCRBBA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonJvmMain-XCRBBA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonJvmMain-XCRBBA.klib
new file mode 100644
index 0000000..7d48cf6
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-1.10.0-rc02-nonJvmMain-XCRBBA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-commonMain-Y18Qzg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-commonMain-Y18Qzg.klib
new file mode 100644
index 0000000..2d6a00f
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-commonMain-Y18Qzg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-nonJvmMain-Y18Qzg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-nonJvmMain-Y18Qzg.klib
new file mode 100644
index 0000000..4699c8d
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.animation-animation-core-1.10.0-rc02-nonJvmMain-Y18Qzg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.annotation-internal-annotation-1.10.0-rc02-commonMain-jG1XTg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.annotation-internal-annotation-1.10.0-rc02-commonMain-jG1XTg.klib
new file mode 100644
index 0000000..280db72
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.annotation-internal-annotation-1.10.0-rc02-commonMain-jG1XTg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.collection-internal-collection-1.10.0-rc02-commonMain-PdmhIQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.collection-internal-collection-1.10.0-rc02-commonMain-PdmhIQ.klib
new file mode 100644
index 0000000..42e411a
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.collection-internal-collection-1.10.0-rc02-commonMain-PdmhIQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-blockingMain-OSNkTw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-blockingMain-OSNkTw.klib
new file mode 100644
index 0000000..f254840
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-blockingMain-OSNkTw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-commonMain-OSNkTw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-commonMain-OSNkTw.klib
new file mode 100644
index 0000000..424d4ae
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-commonMain-OSNkTw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-iosMain-_aNPIA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-iosMain-_aNPIA.klib
new file mode 100644
index 0000000..751b9a3
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-iosMain-_aNPIA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-nativeMain-_aNPIA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-nativeMain-_aNPIA.klib
new file mode 100644
index 0000000..fd1d139
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-nativeMain-_aNPIA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-skikoMain-OSNkTw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-skikoMain-OSNkTw.klib
new file mode 100644
index 0000000..266e902
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.components-components-resources-1.10.0-rc02-skikoMain-OSNkTw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-commonMain-buy1Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-commonMain-buy1Tw.klib
new file mode 100644
index 0000000..5c44f5c
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-commonMain-buy1Tw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-darwinMain-BSwZaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-darwinMain-BSwZaQ.klib
new file mode 100644
index 0000000..b486799
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-darwinMain-BSwZaQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nativeMain-BSwZaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nativeMain-BSwZaQ.klib
new file mode 100644
index 0000000..61dd533
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nativeMain-BSwZaQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nonJvmMain-buy1Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nonJvmMain-buy1Tw.klib
new file mode 100644
index 0000000..c3b0dad
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-nonJvmMain-buy1Tw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-skikoMain-buy1Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-skikoMain-buy1Tw.klib
new file mode 100644
index 0000000..10fbf76
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-skikoMain-buy1Tw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-uikitMain-BSwZaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-uikitMain-BSwZaQ.klib
new file mode 100644
index 0000000..e872a72
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-1.10.0-rc02-uikitMain-BSwZaQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-commonMain-pSMAXg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-commonMain-pSMAXg.klib
new file mode 100644
index 0000000..2a1e4ad
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-commonMain-pSMAXg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-nonJvmMain-pSMAXg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-nonJvmMain-pSMAXg.klib
new file mode 100644
index 0000000..ac48842
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-nonJvmMain-pSMAXg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-skikoMain-pSMAXg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-skikoMain-pSMAXg.klib
new file mode 100644
index 0000000..9c02de3
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.foundation-foundation-layout-1.10.0-rc02-skikoMain-pSMAXg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-commonMain-tnX3iQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-commonMain-tnX3iQ.klib
new file mode 100644
index 0000000..ada7df9
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-commonMain-tnX3iQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-nonAndroidMain-tnX3iQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-nonAndroidMain-tnX3iQ.klib
new file mode 100644
index 0000000..9918195
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material-material-ripple-1.9.1-nonAndroidMain-tnX3iQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-commonMain-PrP0Cw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-commonMain-PrP0Cw.klib
new file mode 100644
index 0000000..9ba43d2
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-commonMain-PrP0Cw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-darwinMain-ZkaPNQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-darwinMain-ZkaPNQ.klib
new file mode 100644
index 0000000..e1db80f
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-darwinMain-ZkaPNQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nativeMain-ZkaPNQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nativeMain-ZkaPNQ.klib
new file mode 100644
index 0000000..7c4a4bf
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nativeMain-ZkaPNQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nonJvmMain-PrP0Cw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nonJvmMain-PrP0Cw.klib
new file mode 100644
index 0000000..07f52ef
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-nonJvmMain-PrP0Cw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-skikoMain-PrP0Cw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-skikoMain-PrP0Cw.klib
new file mode 100644
index 0000000..cae7d47
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.material3-material3-1.9.0-skikoMain-PrP0Cw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-1.10.0-rc02-commonMain-y8wUOA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-1.10.0-rc02-commonMain-y8wUOA.klib
new file mode 100644
index 0000000..a593f06
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-1.10.0-rc02-commonMain-y8wUOA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-saveable-1.10.0-rc02-commonMain-uS-nIg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-saveable-1.10.0-rc02-commonMain-uS-nIg.klib
new file mode 100644
index 0000000..7be9555
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.runtime-runtime-saveable-1.10.0-rc02-commonMain-uS-nIg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-commonMain-Ikk6iA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-commonMain-Ikk6iA.klib
new file mode 100644
index 0000000..f6ffa69
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-commonMain-Ikk6iA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nativeMain-NIMPcw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nativeMain-NIMPcw.klib
new file mode 100644
index 0000000..ac4d84f
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nativeMain-NIMPcw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nonJvmMain-Ikk6iA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nonJvmMain-Ikk6iA.klib
new file mode 100644
index 0000000..cc6dd80
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-nonJvmMain-Ikk6iA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-skikoMain-Ikk6iA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-skikoMain-Ikk6iA.klib
new file mode 100644
index 0000000..511f703
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-skikoMain-Ikk6iA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-uikitMain-NIMPcw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-uikitMain-NIMPcw.klib
new file mode 100644
index 0000000..f4e4008
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-1.10.0-rc02-uikitMain-NIMPcw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-commonMain-CRGTfA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-commonMain-CRGTfA.klib
new file mode 100644
index 0000000..0e97b7c
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-commonMain-CRGTfA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-jbMain-CRGTfA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-jbMain-CRGTfA.klib
new file mode 100644
index 0000000..39e6459
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-backhandler-1.10.0-rc02-jbMain-CRGTfA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-geometry-1.10.0-rc02-commonMain-qqJ-Hg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-geometry-1.10.0-rc02-commonMain-qqJ-Hg.klib
new file mode 100644
index 0000000..572f423
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-geometry-1.10.0-rc02-commonMain-qqJ-Hg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-commonMain-DCwFSQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-commonMain-DCwFSQ.klib
new file mode 100644
index 0000000..57ffbba
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-commonMain-DCwFSQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nativeMain-SQidgQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nativeMain-SQidgQ.klib
new file mode 100644
index 0000000..b94deed
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nativeMain-SQidgQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nonJvmMain-DCwFSQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nonJvmMain-DCwFSQ.klib
new file mode 100644
index 0000000..ea1c08f
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-nonJvmMain-DCwFSQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoExcludingWebMain-DCwFSQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoExcludingWebMain-DCwFSQ.klib
new file mode 100644
index 0000000..8b630bd
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoExcludingWebMain-DCwFSQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoMain-DCwFSQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoMain-DCwFSQ.klib
new file mode 100644
index 0000000..4e34ac0
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-graphics-1.10.0-rc02-skikoMain-DCwFSQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-commonMain-kU2cPg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-commonMain-kU2cPg.klib
new file mode 100644
index 0000000..05e93f5
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-commonMain-kU2cPg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-darwinMain-PRSAaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-darwinMain-PRSAaQ.klib
new file mode 100644
index 0000000..5674e9c
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-darwinMain-PRSAaQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nativeMain-PRSAaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nativeMain-PRSAaQ.klib
new file mode 100644
index 0000000..dab9950
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nativeMain-PRSAaQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nonJvmMain-kU2cPg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nonJvmMain-kU2cPg.klib
new file mode 100644
index 0000000..c791e1e
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-nonJvmMain-kU2cPg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-skikoMain-kU2cPg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-skikoMain-kU2cPg.klib
new file mode 100644
index 0000000..49daffa
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-skikoMain-kU2cPg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-uikitMain-PRSAaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-uikitMain-PRSAaQ.klib
new file mode 100644
index 0000000..bee395d
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-text-1.10.0-rc02-uikitMain-PRSAaQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-commonMain-8Expeg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-commonMain-8Expeg.klib
new file mode 100644
index 0000000..03ae69f
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-commonMain-8Expeg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-nonJvmMain-8Expeg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-nonJvmMain-8Expeg.klib
new file mode 100644
index 0000000..2b29a20
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-tooling-preview-1.10.0-rc02-nonJvmMain-8Expeg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-uikit-1.10.0-rc02-uikitMain-Q1SrDw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-uikit-1.10.0-rc02-uikitMain-Q1SrDw.klib
new file mode 100644
index 0000000..e41f926
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-uikit-1.10.0-rc02-uikitMain-Q1SrDw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-commonMain-JGR4Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-commonMain-JGR4Tw.klib
new file mode 100644
index 0000000..6106981
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-commonMain-JGR4Tw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonAndroidMain-JGR4Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonAndroidMain-JGR4Tw.klib
new file mode 100644
index 0000000..787a5e7
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonAndroidMain-JGR4Tw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonJvmMain-JGR4Tw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonJvmMain-JGR4Tw.klib
new file mode 100644
index 0000000..bb881b6
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-unit-1.10.0-rc02-nonJvmMain-JGR4Tw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-commonMain-WTwl9g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-commonMain-WTwl9g.klib
new file mode 100644
index 0000000..218c7a3
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-commonMain-WTwl9g.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-nonJvmMain-WTwl9g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-nonJvmMain-WTwl9g.klib
new file mode 100644
index 0000000..4ea2627
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-nonJvmMain-WTwl9g.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-uikitMain-sNYMHg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-uikitMain-sNYMHg.klib
new file mode 100644
index 0000000..822e00e
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.compose.ui-ui-util-1.10.0-rc02-uikitMain-sNYMHg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.3.0-commonMain-1ooqQQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.3.0-commonMain-1ooqQQ.klib
new file mode 100644
index 0000000..c0cccdf
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-stdlib-2.3.0-commonMain-1ooqQQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-annotationsCommonMain-4o4N4g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-annotationsCommonMain-4o4N4g.klib
new file mode 100644
index 0000000..e38964f
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-annotationsCommonMain-4o4N4g.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-assertionsCommonMain-4o4N4g.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-assertionsCommonMain-4o4N4g.klib
new file mode 100644
index 0000000..82b8e68
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlin-kotlin-test-2.2.0-assertionsCommonMain-4o4N4g.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-commonMain-wFq7cg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-commonMain-wFq7cg.klib
new file mode 100644
index 0000000..2cc3cd8
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-commonMain-wFq7cg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-wFq7cg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-wFq7cg.klib
new file mode 100644
index 0000000..bed0f0d
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.23.1-nativeMain-wFq7cg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-commonMain-7K06eQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-commonMain-7K06eQ.klib
new file mode 100644
index 0000000..7940a04
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-commonMain-7K06eQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeMain-7K06eQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeMain-7K06eQ.klib
new file mode 100644
index 0000000..ca1adc5
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeMain-7K06eQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeUnixLikeMain-7K06eQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeUnixLikeMain-7K06eQ.klib
new file mode 100644
index 0000000..0657f2b
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-atomicfu-0.27.0-nativeUnixLikeMain-7K06eQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-commonMain-wJOvIw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-commonMain-wJOvIw.klib
new file mode 100644
index 0000000..c50afea
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-commonMain-wJOvIw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-concurrentMain-wJOvIw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-concurrentMain-wJOvIw.klib
new file mode 100644
index 0000000..f3bdf1c
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-concurrentMain-wJOvIw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeDarwinMain--jzUgA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeDarwinMain--jzUgA.klib
new file mode 100644
index 0000000..295f171
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeDarwinMain--jzUgA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeMain-wJOvIw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeMain-wJOvIw.klib
new file mode 100644
index 0000000..b2db0ee
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-core-1.10.2-nativeMain-wJOvIw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-commonMain-vXmODA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-commonMain-vXmODA.klib
new file mode 100644
index 0000000..308bdb0
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-commonMain-vXmODA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-nativeMain-vXmODA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-nativeMain-vXmODA.klib
new file mode 100644
index 0000000..69fa1e6
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-coroutines-test-1.10.2-nativeMain-vXmODA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonKotlinMain-uTxUqg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonKotlinMain-uTxUqg.klib
new file mode 100644
index 0000000..9f01ebc
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonKotlinMain-uTxUqg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonMain-uTxUqg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonMain-uTxUqg.klib
new file mode 100644
index 0000000..bc9c565
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-commonMain-uTxUqg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-darwinMain-HeEIcg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-darwinMain-HeEIcg.klib
new file mode 100644
index 0000000..82ded14
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-darwinMain-HeEIcg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzdbOnFilesystemMain-uTxUqg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzdbOnFilesystemMain-uTxUqg.klib
new file mode 100644
index 0000000..f4db381
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzdbOnFilesystemMain-uTxUqg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzfileMain-uTxUqg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzfileMain-uTxUqg.klib
new file mode 100644
index 0000000..b517675
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-datetime-0.7.1-tzfileMain-uTxUqg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-commonMain-0z2eOA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-commonMain-0z2eOA.klib
new file mode 100644
index 0000000..c4a8446
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-commonMain-0z2eOA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-nativeMain-0z2eOA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-nativeMain-0z2eOA.klib
new file mode 100644
index 0000000..68b2ea6
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.6.2-nativeMain-0z2eOA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-commonMain-DVGtaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-commonMain-DVGtaQ.klib
new file mode 100644
index 0000000..951a26c
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-commonMain-DVGtaQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-nativeMain-DVGtaQ.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-nativeMain-DVGtaQ.klib
new file mode 100644
index 0000000..c77ed23
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-core-1.9.0-nativeMain-DVGtaQ.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-commonMain-_ulzXg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-commonMain-_ulzXg.klib
new file mode 100644
index 0000000..17e7b9f
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-commonMain-_ulzXg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-nativeMain-_ulzXg.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-nativeMain-_ulzXg.klib
new file mode 100644
index 0000000..ffbb93d
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.kotlinx-kotlinx-serialization-json-1.9.0-nativeMain-_ulzXg.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-commonMain-aAAseA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-commonMain-aAAseA.klib
new file mode 100644
index 0000000..ab89de3
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-commonMain-aAAseA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-darwinMain-TtyIcw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-darwinMain-TtyIcw.klib
new file mode 100644
index 0000000..9a555d5
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-darwinMain-TtyIcw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-iosMain-TtyIcw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-iosMain-TtyIcw.klib
new file mode 100644
index 0000000..8620cfa
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-iosMain-TtyIcw.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeJsMain-aAAseA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeJsMain-aAAseA.klib
new file mode 100644
index 0000000..4497fcd
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeJsMain-aAAseA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeMain-aAAseA.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeMain-aAAseA.klib
new file mode 100644
index 0000000..115ac39
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-nativeMain-aAAseA.klib differ
diff --git a/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-uikitMain-TtyIcw.klib b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-uikitMain-TtyIcw.klib
new file mode 100644
index 0000000..ec4ca1e
Binary files /dev/null and b/.kotlin/metadata/kotlinTransformedMetadataLibraries/org.jetbrains.skiko-skiko-0.9.37.3-uikitMain-TtyIcw.klib differ
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index e94453f..625450a 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -1,4 +1,7 @@
+@file:OptIn(ExperimentalSwiftExportDsl::class)
+
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
+import org.jetbrains.kotlin.gradle.swiftexport.ExperimentalSwiftExportDsl
plugins {
alias(libs.plugins.android.gradle)
@@ -174,6 +177,7 @@ android {
testOptions {
unitTests.isReturnDefaultValues = true
+ unitTests.isIncludeAndroidResources = true
}
}
@@ -182,6 +186,8 @@ dependencies {
testImplementation(libs.junit)
testImplementation(libs.coroutines.test)
+ testImplementation(libs.robolectric)
+ testImplementation(libs.androidx.core)
androidTestImplementation(libs.androidx.test.ext.junit)
androidTestImplementation(libs.androidx.test.espresso.core)
testImplementation(libs.mockk)
diff --git a/app/src/androidMain/AndroidManifest.xml b/app/src/androidMain/AndroidManifest.xml
index 6110523..02183f5 100644
--- a/app/src/androidMain/AndroidManifest.xml
+++ b/app/src/androidMain/AndroidManifest.xml
@@ -6,6 +6,7 @@
+
@@ -50,6 +51,10 @@
+
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt
index ef21413..cec22b1 100644
--- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/AlarmReceiver.kt
@@ -8,10 +8,8 @@ import android.content.Context
import android.content.Intent
import android.os.PowerManager
import co.touchlab.kermit.Logger
+import com.timilehinaregbesola.mathalarm.coroutines.AppCoroutineScope
import com.timilehinaregbesola.mathalarm.framework.Usecases
-import kotlinx.coroutines.DelicateCoroutinesApi
-import kotlinx.coroutines.GlobalScope
-import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
@@ -19,9 +17,9 @@ import org.koin.core.component.inject
* [BroadcastReceiver] to be notified by the [android.app.AlarmManager].
*/
class AlarmReceiver : BroadcastReceiver(), KoinComponent {
- val usecases: Usecases by inject()
+ private val usecases: Usecases by inject()
+ private val appScope: AppCoroutineScope by inject()
- @OptIn(DelicateCoroutinesApi::class)
override fun onReceive(context: Context, intent: Intent) {
Logger.d("onReceive() - intent ${intent.action}")
@@ -33,21 +31,27 @@ class AlarmReceiver : BroadcastReceiver(), KoinComponent {
)
wakelock.acquire(3000)
}
- GlobalScope.launch {
+
+ appScope.launch {
handleIntent(intent)
}
}
- private suspend fun handleIntent(intent: Intent?): Unit? {
- return when (intent?.action) {
+ private suspend fun handleIntent(intent: Intent?) {
+ when (intent?.action) {
ALARM_ACTION -> getAlarmId(intent)?.let { usecases.showAlarm(it) }
COMPLETE_ACTION -> getAlarmId(intent)?.let { usecases.completeAlarm(it) }
SNOOZE_ACTION -> getAlarmId(intent)?.let { usecases.snoozeAlarm(it) }
+ DISMISS_ACTION -> {
+ // User swiped away the notification - immediately re-show it
+ Logger.d("Notification dismissed by user, re-showing alarm")
+ getAlarmId(intent)?.let { usecases.showAlarm(it) }
+ }
Intent.ACTION_BOOT_COMPLETED,
"android.intent.action.QUICKBOOT_POWERON",
"android.intent.action.MY_PACKAGE_REPLACED",
AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED -> {
- Logger.d("Reboot Reboot!!")
+ Logger.d("Rescheduling alarms after system event")
usecases.rescheduleFutureAlarms()
}
else -> {
@@ -68,5 +72,7 @@ class AlarmReceiver : BroadcastReceiver(), KoinComponent {
const val COMPLETE_ACTION = "com.timilehinaregbesola.mathalarm.SET_COMPLETE"
const val SNOOZE_ACTION = "com.timilehinaregbesola.mathalarm.SNOOZE"
+
+ const val DISMISS_ACTION = "com.timilehinaregbesola.mathalarm.NOTIFICATION_DISMISSED"
}
}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt
index be55f45..bd489ba 100644
--- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/di/AppModule.kt
@@ -16,6 +16,10 @@ import com.timilehinaregbesola.mathalarm.framework.app.permission.AlarmPermissio
import com.timilehinaregbesola.mathalarm.framework.app.permission.AlarmPermissionImpl
import com.timilehinaregbesola.mathalarm.framework.app.permission.AndroidVersion
import com.timilehinaregbesola.mathalarm.framework.app.permission.AndroidVersionImpl
+import com.timilehinaregbesola.mathalarm.framework.app.permission.PermissionChecker
+import com.timilehinaregbesola.mathalarm.framework.app.permission.PermissionCheckerImpl
+import com.timilehinaregbesola.mathalarm.framework.app.permission.ScreenNavigator
+import com.timilehinaregbesola.mathalarm.framework.app.permission.ScreenNavigatorImpl
import com.timilehinaregbesola.mathalarm.framework.database.AlarmDatabase
import com.timilehinaregbesola.mathalarm.framework.database.MIGRATION_2_3
import com.timilehinaregbesola.mathalarm.framework.database.MIGRATION_3_4
@@ -28,6 +32,7 @@ import com.timilehinaregbesola.mathalarm.interactors.PlayerWrapper
import com.timilehinaregbesola.mathalarm.notification.AlarmNotificationScheduler
import com.timilehinaregbesola.mathalarm.notification.MathAlarmNotification
import com.timilehinaregbesola.mathalarm.notification.MathAlarmNotificationChannel
+import com.timilehinaregbesola.mathalarm.notification.PendingIntentIdGenerator
import com.timilehinaregbesola.mathalarm.utils.getAlarmManager
import kotlinx.coroutines.InternalCoroutinesApi
import org.koin.android.ext.koin.androidApplication
@@ -59,6 +64,10 @@ val androidModule = module {
single { get().alarmDatabaseDao }
+ single { PendingIntentIdGenerator() }
+
+ single { AlarmNotificationScheduler(androidContext(), getWith("AlarmNotificationScheduler"), get()) }
+
// Android Alarm Interactor
single { AlarmInteractorImpl(get(), getWith("AlarmInteractorImpl")) }
@@ -75,8 +84,6 @@ val androidModule = module {
single { MathAlarmNotificationChannel(androidContext()) }
- single { AlarmNotificationScheduler(androidContext(), getWith("AlarmNotificationScheduler")) }
-
@OptIn(
ExperimentalAnimationApi::class,
InternalCoroutinesApi::class,
@@ -86,7 +93,7 @@ val androidModule = module {
)
single {
NotificationInteractorImpl(
- get(),
+ androidContext(),
getWith("NotificationInteractorImpl")
)
}
@@ -94,11 +101,17 @@ val androidModule = module {
// Android Audio Player
single { PlayerWrapper(androidContext(), getWith("PlayerWrapper")) }
- // Android Version and Permission
+ // Permission abstractions
single { AndroidVersionImpl() }
+ single { ScreenNavigatorImpl(androidContext()) }
+ single { PermissionCheckerImpl(androidContext().getAlarmManager()) }
single {
- AlarmPermissionImpl(androidContext().getAlarmManager(), get())
+ AlarmPermissionImpl(
+ screenNavigator = get(),
+ permissionChecker = get(),
+ androidVersion = get()
+ )
}
// Platform Logger - Android-specific with Crashlytics integration
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt
index 0a967cc..82d65cf 100644
--- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt
@@ -1,11 +1,13 @@
package com.timilehinaregbesola.mathalarm.framework.app.permission
-import android.annotation.SuppressLint
-import android.app.AlarmManager
import android.os.Build
+/**
+ * Android implementation of [AlarmPermission] using abstracted dependencies.
+ */
class AlarmPermissionImpl(
- private val alarmManager: AlarmManager?,
+ private val screenNavigator: ScreenNavigator,
+ private val permissionChecker: PermissionChecker,
private val androidVersion: AndroidVersion
) : AlarmPermission {
@@ -14,14 +16,19 @@ class AlarmPermissionImpl(
*
* @return `true` if the permission is granted, `false` otherwise
*/
- @SuppressLint("NewApi")
override fun hasExactAlarmPermission(): Boolean {
- if (alarmManager == null) return false
-
return if (androidVersion.currentVersion >= Build.VERSION_CODES.S) {
- alarmManager.canScheduleExactAlarms()
+ permissionChecker.canScheduleExactAlarms()
} else {
true
}
}
+
+ override fun openExactAlarmPermissionScreen() {
+ screenNavigator.openExactAlarmPermissionScreen()
+ }
+
+ override fun openAppSettings() {
+ screenNavigator.openAppSettings()
+ }
}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionChecker.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionChecker.kt
new file mode 100644
index 0000000..c65c1c2
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionChecker.kt
@@ -0,0 +1,13 @@
+package com.timilehinaregbesola.mathalarm.framework.app.permission
+
+/**
+ * Abstraction for checking system permissions.
+ * Extracted for better testability.
+ */
+interface PermissionChecker {
+ /**
+ * Checks if the app can schedule exact alarms.
+ * @return true if exact alarms can be scheduled
+ */
+ fun canScheduleExactAlarms(): Boolean
+}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionCheckerImpl.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionCheckerImpl.kt
new file mode 100644
index 0000000..5fc5d58
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/PermissionCheckerImpl.kt
@@ -0,0 +1,24 @@
+package com.timilehinaregbesola.mathalarm.framework.app.permission
+
+import android.annotation.SuppressLint
+import android.app.AlarmManager
+import android.os.Build
+
+/**
+ * Android implementation of [PermissionChecker].
+ */
+class PermissionCheckerImpl(
+ private val alarmManager: AlarmManager?
+) : PermissionChecker {
+
+ @SuppressLint("NewApi")
+ override fun canScheduleExactAlarms(): Boolean {
+ if (alarmManager == null) return false
+
+ return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ alarmManager.canScheduleExactAlarms()
+ } else {
+ true
+ }
+ }
+}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigator.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigator.kt
new file mode 100644
index 0000000..a272209
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigator.kt
@@ -0,0 +1,17 @@
+package com.timilehinaregbesola.mathalarm.framework.app.permission
+
+/**
+ * Abstraction for navigating to system screens.
+ * Extracted for better testability.
+ */
+interface ScreenNavigator {
+ /**
+ * Opens the exact alarm permission screen (Android S+).
+ */
+ fun openExactAlarmPermissionScreen()
+
+ /**
+ * Opens the app settings screen.
+ */
+ fun openAppSettings()
+}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigatorImpl.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigatorImpl.kt
new file mode 100644
index 0000000..945b362
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/ScreenNavigatorImpl.kt
@@ -0,0 +1,41 @@
+package com.timilehinaregbesola.mathalarm.framework.app.permission
+
+import android.content.Context
+import android.content.Intent
+import android.net.Uri
+import android.os.Build
+import android.provider.Settings
+import androidx.core.net.toUri
+
+/**
+ * Android implementation of [ScreenNavigator].
+ */
+class ScreenNavigatorImpl(
+ private val context: Context
+) : ScreenNavigator {
+
+ override fun openExactAlarmPermissionScreen() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ val intent = Intent(
+ Settings.ACTION_REQUEST_SCHEDULE_EXACT_ALARM,
+ "package:${context.packageName}".toUri()
+ ).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ } else {
+ // On older versions, open app settings instead
+ openAppSettings()
+ }
+ }
+
+ override fun openAppSettings() {
+ val intent = Intent(
+ Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ "package:${context.packageName}".toUri()
+ ).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt
index 98b91dc..a8e6be0 100644
--- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt
@@ -3,26 +3,24 @@ package com.timilehinaregbesola.mathalarm.interactors
import co.touchlab.kermit.Logger
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import com.timilehinaregbesola.mathalarm.notification.AlarmNotificationScheduler
-import org.koin.core.component.KoinComponent
-import org.koin.core.component.inject
-import org.koin.core.parameter.parametersOf
class AlarmInteractorImpl(
private val alarmManager: AlarmNotificationScheduler,
private val logger: Logger
-) :
- AlarmInteractor, KoinComponent {
+) : AlarmInteractor {
- override fun schedule(alarm: Alarm, reschedule: Boolean): Boolean {
- logger.d("AlarmInteractorImpl.schedule called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, repeat=${alarm.repeat}, repeatDays=${alarm.repeatDays}, reschedule=$reschedule")
- val result = alarmManager.scheduleAlarm(alarm, reschedule)
- logger.d("AlarmInteractorImpl.schedule result for alarmId=${alarm.alarmId}: $result")
- return result
+ override fun schedule(alarm: Alarm, timeInMillis: Long) {
+ logger.d("AlarmInteractorImpl.schedule: alarmId=${alarm.alarmId}, timeInMillis=$timeInMillis")
+ alarmManager.scheduleAlarm(alarm, timeInMillis)
}
override fun cancel(alarm: Alarm) {
- logger.d("AlarmInteractorImpl.cancel called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, repeat=${alarm.repeat}, repeatDays=${alarm.repeatDays}")
+ logger.d("AlarmInteractorImpl.cancel: alarmId=${alarm.alarmId}")
alarmManager.cancelAlarm(alarm)
- logger.d("AlarmInteractorImpl.cancel completed for alarmId=${alarm.alarmId}")
+ }
+
+ override fun update(alarm: Alarm) {
+ logger.d("AlarmInteractorImpl.update: alarmId=${alarm.alarmId}")
+ alarmManager.updateAlarm(alarm)
}
}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt
index 1eabd94..aa2309f 100644
--- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt
@@ -10,6 +10,7 @@ actual interface AudioPlayer {
actual val currentPosition: Int
actual val duration: Int
+ actual val isPlaying: Boolean
actual fun init()
actual fun startAlarmAudio()
@@ -36,6 +37,8 @@ class PlayerWrapper(
get() = player?.currentPosition?: 0
override val duration: Int
get() = player?.duration?: 0
+ override val isPlaying: Boolean
+ get() = player?.isPlaying == true
override fun init() {
player = MediaPlayer().apply {
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/NotificationInteractorImpl.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/NotificationInteractorImpl.kt
index 530d8de..a1a891a 100644
--- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/NotificationInteractorImpl.kt
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/NotificationInteractorImpl.kt
@@ -1,12 +1,13 @@
package com.timilehinaregbesola.mathalarm.interactors
+import android.content.Context
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.ui.ExperimentalComposeUiApi
import co.touchlab.kermit.Logger
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
-import com.timilehinaregbesola.mathalarm.notification.MathAlarmNotification
+import com.timilehinaregbesola.mathalarm.notification.AlarmService
import kotlinx.coroutines.InternalCoroutinesApi
@ExperimentalFoundationApi
@@ -15,21 +16,17 @@ import kotlinx.coroutines.InternalCoroutinesApi
@InternalCoroutinesApi
@ExperimentalAnimationApi
internal class NotificationInteractorImpl(
- private val alarmNotification: MathAlarmNotification,
+ private val context: Context,
private val logger: Logger
) : NotificationInteractor {
override fun show(alarm: Alarm) {
- logger.d("show - alarmId = ${alarm.alarmId}")
- if (alarm.repeat) {
- alarmNotification.showRepeating(alarm)
- } else {
- alarmNotification.show(alarm)
- }
+ logger.d("show - alarmId = ${alarm.alarmId}, starting AlarmService")
+ AlarmService.startAlarm(context, alarm)
}
override fun dismiss(notificationId: Long) {
- logger.d("dismiss - alarmId = $notificationId")
- alarmNotification.dismiss(notificationId)
+ logger.d("dismiss - alarmId = $notificationId, stopping AlarmService")
+ AlarmService.stopAlarm(context)
}
}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/ActiveAlarmManager.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/ActiveAlarmManager.kt
new file mode 100644
index 0000000..e0a0328
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/ActiveAlarmManager.kt
@@ -0,0 +1,35 @@
+package com.timilehinaregbesola.mathalarm.notification
+
+/**
+ * Singleton to track the currently active alarm being displayed on the math screen.
+ * Used to re-show the alarm notification if the user force-closes the app without
+ * solving the math problem.
+ */
+object ActiveAlarmManager {
+
+ /**
+ * The ID of the currently active alarm, or null if no alarm is active.
+ */
+ @Volatile
+ var activeAlarmId: Long? = null
+ private set
+
+ /**
+ * Sets the active alarm when the math screen is displayed.
+ */
+ fun setActiveAlarm(alarmId: Long) {
+ activeAlarmId = alarmId
+ }
+
+ /**
+ * Clears the active alarm when it's properly dismissed (solved or snoozed).
+ */
+ fun clearActiveAlarm() {
+ activeAlarmId = null
+ }
+
+ /**
+ * Checks if there's an active alarm that wasn't properly dismissed.
+ */
+ fun hasActiveAlarm(): Boolean = activeAlarmId != null
+}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt
index a120199..a143865 100644
--- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationScheduler.kt
@@ -1,258 +1,100 @@
package com.timilehinaregbesola.mathalarm.notification
-import android.annotation.SuppressLint
import android.app.PendingIntent
-import android.app.PendingIntent.FLAG_MUTABLE
-import android.app.PendingIntent.FLAG_UPDATE_CURRENT
import android.content.Context
import android.content.Intent
-import android.os.Build
import co.touchlab.kermit.Logger
import com.timilehinaregbesola.mathalarm.AlarmReceiver
import com.timilehinaregbesola.mathalarm.AlarmReceiver.Companion.ALARM_ACTION
import com.timilehinaregbesola.mathalarm.AlarmReceiver.Companion.EXTRA_TASK
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
-import com.timilehinaregbesola.mathalarm.utils.SAT
-import com.timilehinaregbesola.mathalarm.utils.SUN
import com.timilehinaregbesola.mathalarm.utils.cancelAlarm
import com.timilehinaregbesola.mathalarm.utils.fullDays
-import com.timilehinaregbesola.mathalarm.utils.initLocalDateTimeInSystemZone
import com.timilehinaregbesola.mathalarm.utils.setExactAlarm
-import com.timilehinaregbesola.mathalarm.utils.toIndex
-import kotlinx.datetime.DatePeriod
-import kotlinx.datetime.LocalDate
-import kotlinx.datetime.LocalDateTime
-import kotlinx.datetime.LocalTime
-import kotlinx.datetime.TimeZone
-import kotlinx.datetime.plus
-import kotlinx.datetime.toInstant
-import kotlinx.datetime.toLocalDateTime
-import kotlin.time.Clock
-import kotlin.time.ExperimentalTime
-import kotlin.time.Instant
/**
* Alarm manager to schedule an event based on the time from a Alarm.
*/
-class AlarmNotificationScheduler(private val context: Context, private val logger: Logger) {
+class AlarmNotificationScheduler(
+ private val context: Context,
+ private val logger: Logger,
+ private val idGenerator: PendingIntentIdGenerator = PendingIntentIdGenerator()
+) {
/**
- * Schedules all the alarm of the object at once including repeating ones
+ * Schedules a single alarm notification at the specified time.
*
- * @param passedAlarm alarm to be scheduled
- * @param reschedule whether alarm is repeating
+ * @param alarm the alarm to schedule
+ * @param timeInMillis the exact time to trigger the alarm
*/
- @OptIn(ExperimentalTime::class)
- @SuppressLint("UnspecifiedImmutableFlag")
- fun scheduleAlarm(passedAlarm: Alarm, reschedule: Boolean): Boolean {
- logger.d("Schedule alarm for id=${passedAlarm.alarmId}, time=${passedAlarm.hour}:${passedAlarm.minute}, repeat=${passedAlarm.repeat}, repeatDays=${passedAlarm.repeatDays}, reschedule=$reschedule")
- val alarmIntent = Intent(context, AlarmReceiver::class.java).apply {
- action = ALARM_ACTION
- putExtra(EXTRA_TASK, passedAlarm.alarmId)
- }
- val alarmIntentList: MutableList = ArrayList()
- val timeInstants: MutableList = ArrayList()
- val tz = TimeZone.currentSystemDefault()
- var hasExistingAlarms = false
-
- // If there is no day set, set the alarm on the closest possible date
- if (passedAlarm.repeatDays == "FFFFFFF") {
- logger.d("No repeat days set, determining closest possible date")
- val dateTime = passedAlarm.initLocalDateTimeInSystemZone()
- val instant = dateTime.toInstant(tz)
- val nowInstant = Clock.System.now()
- logger.d("Alarm datetime: $dateTime, instant: $instant, now: $nowInstant")
-
- var dayOfTheWeek = dateTime.date.dayOfWeek.toIndex()
- logger.d("Current day of week: $dayOfTheWeek")
-
- if (instant > nowInstant) { // set it today
- val sb = StringBuilder("FFFFFFF")
- sb.setCharAt(dayOfTheWeek, 'T')
- passedAlarm.repeatDays = sb.toString()
- logger.d("Alarm time is in the future, setting for today. New repeatDays: ${passedAlarm.repeatDays}")
- } else { // alarm time already passed for the day so set it tomorrow
- val sb = StringBuilder("FFFFFFF")
- if (dayOfTheWeek == SAT) { // if it is saturday
- dayOfTheWeek = SUN
- } else {
- dayOfTheWeek++
- }
- sb.setCharAt(dayOfTheWeek, 'T')
- passedAlarm.repeatDays = sb.toString()
- logger.d("Alarm time already passed, setting for tomorrow (day $dayOfTheWeek). New repeatDays: ${passedAlarm.repeatDays}")
- }
- }
-
- for (i in SUN..SAT) {
- if (passedAlarm.repeatDays[i] == 'T') {
- logger.d("Processing day $i (${fullDays[i]}) which is set to true")
- val nowInstant = Clock.System.now()
- val localNow = nowInstant.toLocalDateTime(tz)
- val todayDate = localNow.date
-
- val currentDay = todayDate.dayOfWeek.toIndex()
-
- logger.d("Current day: $currentDay (${fullDays[currentDay]})")
-
- val daysUntilAlarm: Int
- val targetDate: LocalDate
-
- val alarmTimeToday = passedAlarm.initLocalDateTimeInSystemZone()
- val alarmInstantToday = alarmTimeToday.toInstant(tz)
- logger.d("Alarm time today would be: $alarmTimeToday (${alarmInstantToday})")
- logger.d("Current time is: $localNow (${nowInstant})")
-
- val isPastToday = alarmInstantToday < nowInstant
- logger.d("Is alarm time past for today? $isPastToday")
-
- if (currentDay > i || (currentDay == i && isPastToday)) {
- // days left till end of week(sat) + the day of the week of the alarm
- // EX: alarm = i = tues = 2; current = wed = 3; end of week = sat = 6
- // end - current = 6 - 3 = 3 -> 3 days till saturday/end of week
- // end of week + 1 (to sunday) + day of week alarm is on = 3 + 1 + 2 = 6
- daysUntilAlarm = SAT - currentDay + 1 + i
- targetDate = todayDate.plus(DatePeriod(days = daysUntilAlarm))
- logger.d("Current day ($currentDay) > alarm day ($i) or same day but time passed, scheduling for next week")
- logger.d("Days until alarm: $daysUntilAlarm, target date: $targetDate")
- } else {
- daysUntilAlarm = i - currentDay
- targetDate = todayDate.plus(DatePeriod(days = daysUntilAlarm))
- logger.d("Current day ($currentDay) <= alarm day ($i) and time not passed, scheduling for this week")
- logger.d("Days until alarm: $daysUntilAlarm, target date: $targetDate")
- }
-
- val targetDateTime = LocalDateTime(
- date = targetDate,
- time = LocalTime(passedAlarm.hour, passedAlarm.minute, 0)
- )
- val targetInstant = targetDateTime.toInstant(tz)
-
- val stringId: StringBuilder = StringBuilder().append(passedAlarm.alarmId).append(i)
- .append(passedAlarm.hour).append(passedAlarm.minute)
- val id = stringId.toString().split("-").joinToString("")
- val intentId = id.toInt()
- logger.d("Generated intent ID: $intentId for alarm ID: ${passedAlarm.alarmId}, day: $i, time: ${passedAlarm.hour}:${passedAlarm.minute}")
-
- // Check if a previous alarm has been set
- logger.d("Checking if a previous alarm with this ID already exists")
- val isSet = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- PendingIntent.getBroadcast(
- context,
- intentId,
- alarmIntent,
- PendingIntent.FLAG_NO_CREATE or FLAG_MUTABLE,
- )
- } else {
- PendingIntent.getBroadcast(context, intentId, alarmIntent, PendingIntent.FLAG_NO_CREATE)
- }
-
- if (isSet != null) {
- logger.d("An alarm with ID $intentId already exists")
- hasExistingAlarms = true
- if (!reschedule) {
- logger.d("Not rescheduling because reschedule flag is false")
- // context.showToast(R.string.alarm_duplicate_toast_text)
- } else {
- // If reschedule is true, cancel the existing alarm and create a new one
- logger.d("Canceling existing alarm because reschedule flag is true")
- context.cancelAlarm(isSet)
- isSet.cancel()
- }
- }
-
- // If reschedule is true or no existing alarm was found, create a new one
- if (isSet == null || reschedule) {
- logger.d("Proceeding to create new alarm")
-
- val pendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- PendingIntent.getBroadcast(
- context,
- intentId,
- alarmIntent,
- PendingIntent.FLAG_CANCEL_CURRENT or FLAG_MUTABLE,
- )
- } else {
- PendingIntent.getBroadcast(
- context,
- intentId,
- alarmIntent,
- PendingIntent.FLAG_CANCEL_CURRENT,
- )
- }
-
- alarmIntentList.add(pendingIntent)
- timeInstants.add(targetInstant)
- }
- }
- }
-
- // Return true if we scheduled new alarms OR if there were existing alarms
- if (alarmIntentList.isEmpty() && !hasExistingAlarms) {
- logger.w("No alarms were scheduled and no existing alarms found")
- return false
- }
-
- logger.d("Scheduling ${alarmIntentList.size} alarms")
- for (i in alarmIntentList.indices) {
- val pendingIntent = alarmIntentList[i]
- val instant = timeInstants[i]
- logger.d("Scheduling alarm #${i+1}/${alarmIntentList.size} for time: ${instant}")
- context.setExactAlarm(instant.toEpochMilliseconds(), pendingIntent)
- logger.d("Alarm #${i+1} scheduled successfully")
- }
+ fun scheduleAlarm(alarm: Alarm, timeInMillis: Long) {
+ logger.d("Scheduling alarm: id=${alarm.alarmId}, time=$timeInMillis")
+
+ val alarmIntent = createAlarmIntent(alarm)
+ val intentId = idGenerator.generateSimpleId(alarm.alarmId)
+
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ intentId,
+ alarmIntent,
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+
+ context.setExactAlarm(timeInMillis, pendingIntent)
+ logger.d("Alarm scheduled successfully: id=${alarm.alarmId} at $timeInMillis")
+ }
- logger.d("All ${alarmIntentList.size} alarms scheduled successfully, returning true")
- return true
+ /**
+ * Updates an existing alarm notification.
+ *
+ * @param alarm the alarm to update
+ */
+ fun updateAlarm(alarm: Alarm) {
+ logger.d("Update alarm called for id=${alarm.alarmId} - no action needed on Android")
+ // On Android, the notification will trigger a BroadcastReceiver which will always get the
+ // most recent Alarm data from the database, so no action needed here.
}
/**
- * Cancels an alarm - Called when an alarm is turned off, deleted, and rescheduled
+ * Cancels all scheduled notifications for an alarm.
*
* @param alarm alarm to be canceled
*/
fun cancelAlarm(alarm: Alarm) {
- logger.d("AlarmNotificationScheduler.cancelAlarm called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, repeat=${alarm.repeat}, repeatDays=${alarm.repeatDays}")
+ logger.d("Canceling alarm: id=${alarm.alarmId}")
- val receiverIntent = Intent(context, AlarmReceiver::class.java)
- receiverIntent.action = ALARM_ACTION
- receiverIntent.putExtra(EXTRA_TASK, alarm.alarmId)
+ val receiverIntent = createAlarmIntent(alarm)
- var canceledCount = 0
- for (i in 0..6) { // For each day of the week
- if (alarm.repeatDays.getOrNull(i) == 'T') {
- logger.d("Canceling alarm for day $i (${fullDays[i]})")
+ cancelAlarmWithId(receiverIntent, idGenerator.generateSimpleId(alarm.alarmId))
- val stringId: StringBuilder = StringBuilder().append(alarm.alarmId).append(i)
- .append(alarm.hour).append(alarm.minute)
- val id = stringId.toString().split("-").joinToString("")
- val intentId = id.toInt()
- logger.d("Generated intent ID: $intentId for alarm ID: ${alarm.alarmId}, day: $i, time: ${alarm.hour}:${alarm.minute}")
+ // Also cancel any day-specific alarms for repeating alarms
+ for (i in 0..6) {
+ if (alarm.repeatDays.getOrNull(i) == 'T') {
+ val intentId = idGenerator.generateId(alarm, i)
+ cancelAlarmWithId(receiverIntent, intentId)
+ logger.d("Canceled alarm for day $i (${fullDays[i]})")
+ }
+ }
- val cancelPendingIntent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
- PendingIntent.getBroadcast(
- context,
- intentId,
- receiverIntent,
- FLAG_UPDATE_CURRENT or FLAG_MUTABLE,
- )
- } else {
- PendingIntent.getBroadcast(
- context,
- intentId,
- receiverIntent,
- FLAG_UPDATE_CURRENT,
- )
- }
+ logger.d("Alarm canceled: id=${alarm.alarmId}")
+ }
- logger.d("Calling context.cancelAlarm for intent ID: $intentId")
- context.cancelAlarm(cancelPendingIntent)
- cancelPendingIntent.cancel()
- logger.d("Alarm canceled for day $i (${fullDays[i]})")
- canceledCount++
- }
+ private fun createAlarmIntent(alarm: Alarm): Intent {
+ return Intent(context, AlarmReceiver::class.java).apply {
+ action = ALARM_ACTION
+ putExtra(EXTRA_TASK, alarm.alarmId)
}
+ }
+
+ private fun cancelAlarmWithId(intent: Intent, intentId: Int) {
+ val pendingIntent = PendingIntent.getBroadcast(
+ context,
+ intentId,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
- logger.d("AlarmNotificationScheduler.cancelAlarm completed for alarmId=${alarm.alarmId}, canceled $canceledCount alarms")
+ context.cancelAlarm(pendingIntent)
+ pendingIntent.cancel()
}
}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmService.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmService.kt
new file mode 100644
index 0000000..aeec317
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmService.kt
@@ -0,0 +1,411 @@
+package com.timilehinaregbesola.mathalarm.notification
+
+import android.app.PendingIntent
+import android.app.Service
+import android.app.TaskStackBuilder
+import android.content.Context
+import android.content.Intent
+import android.content.pm.ServiceInfo
+import android.graphics.BitmapFactory
+import android.media.AudioAttributes
+import android.media.MediaPlayer
+import android.media.RingtoneManager
+import android.os.Build
+import android.os.Handler
+import android.os.IBinder
+import android.os.Looper
+import android.os.PowerManager
+import androidx.compose.animation.ExperimentalAnimationApi
+import androidx.compose.foundation.ExperimentalFoundationApi
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.core.app.NotificationCompat
+import androidx.core.app.ServiceCompat
+import androidx.core.net.toUri
+import co.touchlab.kermit.Logger
+import com.timilehinaregbesola.mathalarm.AlarmReceiver
+import com.timilehinaregbesola.mathalarm.R
+import com.timilehinaregbesola.mathalarm.domain.model.Alarm
+import com.timilehinaregbesola.mathalarm.framework.database.AlarmEntity
+import com.timilehinaregbesola.mathalarm.framework.database.AlarmMapper
+import com.timilehinaregbesola.mathalarm.presentation.MainActivity
+import kotlinx.coroutines.InternalCoroutinesApi
+import kotlinx.serialization.json.Json
+import org.koin.android.ext.android.inject
+import java.io.InputStream
+import java.net.URLEncoder
+
+/**
+ * Foreground service that handles alarm playback independently of the app lifecycle.
+ * This ensures the alarm keeps ringing even if the user force-closes the app.
+ */
+@ExperimentalAnimationApi
+@InternalCoroutinesApi
+@ExperimentalComposeUiApi
+@ExperimentalMaterial3Api
+@ExperimentalFoundationApi
+class AlarmService : Service() {
+
+ private val channel: MathAlarmNotificationChannel by inject()
+ private val logger = Logger.withTag("AlarmService")
+
+ private var mediaPlayer: MediaPlayer? = null
+ private var wakeLock: PowerManager.WakeLock? = null
+ private var currentAlarm: Alarm? = null
+ private val timeoutHandler = Handler(Looper.getMainLooper())
+
+ // Timing controller for ring/pause/restart cycle
+ private var timingController: AlarmTimingController? = null
+
+ // Handler-based scheduler for the timing controller
+ private val handlerScheduler = object : AlarmTimingController.TimingScheduler {
+ override fun scheduleDelayed(task: Runnable, delayMillis: Long) {
+ timeoutHandler.postDelayed(task, delayMillis)
+ }
+
+ override fun cancel(task: Runnable) {
+ timeoutHandler.removeCallbacks(task)
+ }
+ }
+
+ override fun onBind(intent: Intent?): IBinder? = null
+
+ override fun onCreate() {
+ super.onCreate()
+ logger.d("AlarmService created")
+
+ // Acquire wake lock to keep CPU running
+ val powerManager = getSystemService(Context.POWER_SERVICE) as PowerManager
+ wakeLock = powerManager.newWakeLock(
+ PowerManager.PARTIAL_WAKE_LOCK,
+ "mathalarm:alarmservice"
+ ).apply {
+ // Acquire for max 1 hour - service will be stopped when user dismisses
+ acquire(MAX_WAKE_LOCK_MILLIS)
+ }
+ }
+
+ override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ logger.d("onStartCommand: action=${intent?.action}")
+
+ when (intent?.action) {
+ ACTION_START_ALARM -> {
+ val alarmJson = intent.getStringExtra(EXTRA_ALARM_JSON)
+ if (alarmJson != null) {
+ try {
+ val alarmEntity = Json.decodeFromString(alarmJson)
+ val alarm = AlarmMapper().mapToDomainModel(alarmEntity)
+ startAlarm(alarm)
+ } catch (e: Exception) {
+ logger.e("Failed to parse alarm JSON", e)
+ stopSelf()
+ }
+ } else {
+ logger.e("No alarm JSON provided")
+ stopSelf()
+ }
+ }
+ ACTION_STOP_ALARM -> {
+ logger.d("Stopping alarm service")
+ stopSelf()
+ }
+ ACTION_REFRESH_NOTIFICATION -> {
+ // Refresh notification to trigger full-screen intent again
+ currentAlarm?.let { alarm ->
+ logger.d("Refreshing notification for alarm: ${alarm.title}")
+ refreshNotificationForAlarm(alarm)
+ }
+ }
+ else -> {
+ logger.w("Unknown action: ${intent?.action}")
+ }
+ }
+
+ return START_NOT_STICKY
+ }
+
+ private fun startAlarm(alarm: Alarm) {
+ logger.d("Starting alarm: ${alarm.title}")
+ currentAlarm = alarm
+
+ ActiveAlarmManager.setActiveAlarm(alarm.alarmId)
+
+ // Initialize timing controller with callbacks
+ timingController = AlarmTimingController(
+ ringDurationMillis = RING_DURATION_MILLIS,
+ silencePeriodMillis = SILENCE_PERIOD_MILLIS,
+ onStartRinging = { onStartRinging(alarm) },
+ onPauseRinging = { onPauseRinging(alarm) },
+ scheduler = handlerScheduler
+ )
+
+ // Show initial notification
+ showForegroundNotification(alarm, isPaused = false)
+
+ // Start the timing controller (will call onStartRinging)
+ timingController?.start()
+ }
+
+ private fun onStartRinging(alarm: Alarm) {
+ logger.d("Starting/Restarting alarm audio")
+ showForegroundNotification(alarm, isPaused = false)
+ startAudioPlayback(alarm)
+ }
+
+ private fun onPauseRinging(alarm: Alarm) {
+ logger.d("Pausing alarm audio, will restart in ${SILENCE_PERIOD_MILLIS / 1000}s")
+ stopAudioPlayback()
+ showForegroundNotification(alarm, isPaused = true)
+ }
+
+ private fun showForegroundNotification(alarm: Alarm, isPaused: Boolean) {
+ val notification = buildNotification(alarm, isPaused = isPaused)
+
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) {
+ ServiceCompat.startForeground(
+ this,
+ alarm.alarmId.toInt(),
+ notification.build(),
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
+ )
+ } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
+ startForeground(
+ alarm.alarmId.toInt(),
+ notification.build(),
+ ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK
+ )
+ } else {
+ startForeground(alarm.alarmId.toInt(), notification.build())
+ }
+ }
+
+ private fun refreshNotificationForAlarm(alarm: Alarm) {
+ val isPaused = timingController?.currentState == AlarmTimingController.State.PAUSED
+ showForegroundNotification(alarm, isPaused = isPaused)
+ }
+
+ private fun startAudioPlayback(alarm: Alarm) {
+ try {
+ stopAudioPlayback()
+
+ val toneUri = alarm.alarmTone.toUri()
+ var uriExists = false
+
+ try {
+ val inputStream: InputStream? = contentResolver.openInputStream(toneUri)
+ inputStream?.close()
+ uriExists = true
+ } catch (_: Exception) {
+ logger.w("Alarm tone URI does not exist: $toneUri")
+ }
+
+ val toneString = if (uriExists) {
+ alarm.alarmTone
+ } else {
+ RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM).toString()
+ }
+
+ mediaPlayer = MediaPlayer().apply {
+ setAudioAttributes(
+ AudioAttributes.Builder()
+ .setContentType(AudioAttributes.CONTENT_TYPE_MUSIC)
+ .setUsage(AudioAttributes.USAGE_ALARM)
+ .build()
+ )
+ setDataSource(this@AlarmService, toneString.toUri())
+ isLooping = true
+ setOnErrorListener { mp, _, _ ->
+ logger.e("MediaPlayer error")
+ mp.release()
+ mediaPlayer = null
+ true
+ }
+ prepare()
+ start()
+ }
+
+ logger.d("Audio playback started")
+ } catch (e: Exception) {
+ logger.e("Failed to start audio playback", e)
+ }
+ }
+
+ private fun stopAudioPlayback() {
+ try {
+ mediaPlayer?.run {
+ if (isPlaying) stop()
+ release()
+ }
+ } catch (e: Exception) {
+ logger.w("Error stopping media player", e)
+ } finally {
+ mediaPlayer = null
+ }
+ }
+
+ private fun buildNotification(alarm: Alarm, isPaused: Boolean = false): NotificationCompat.Builder {
+ val alarmImage = BitmapFactory.decodeResource(resources, R.drawable.icon)
+ val vibratePattern = if (isPaused) null else longArrayOf(0, 100, 200, 300)
+ val bigPicStyle = NotificationCompat.BigPictureStyle()
+ .bigPicture(alarmImage)
+
+ val contentText = if (isPaused) {
+ "Paused - will ring again soon. Tap to dismiss."
+ } else {
+ alarm.title
+ }
+
+ return NotificationCompat.Builder(this, channel.getAlarmChannelId()).apply {
+ setContentIntent(buildPendingIntent(alarm))
+ setSmallIcon(R.drawable.icon)
+ setContentTitle(getString(R.string.notification_title))
+ setContentText(contentText)
+ setStyle(bigPicStyle)
+ setSound(null) // We handle audio separately
+ setLargeIcon(alarmImage)
+ setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
+ setCategory(NotificationCompat.CATEGORY_ALARM)
+ if (vibratePattern != null) {
+ setVibrate(vibratePattern)
+ }
+ setPriority(NotificationCompat.PRIORITY_HIGH)
+ setOngoing(true) // Cannot be dismissed by swiping
+ setAutoCancel(false)
+ addAction(getSnoozeAction(alarm))
+ // Only set full-screen intent when actively ringing
+ if (!isPaused) {
+ setFullScreenIntent(buildPendingIntent(alarm), true)
+ }
+ // Re-show notification immediately if user somehow manages to dismiss it
+ setDeleteIntent(getDismissIntent(alarm))
+ }
+ }
+
+ private fun getDismissIntent(alarm: Alarm): PendingIntent {
+ return getActionIntent(alarm, AlarmReceiver.DISMISS_ACTION, REQUEST_CODE_ACTION_DISMISS)
+ }
+
+ private fun buildPendingIntent(alarm: Alarm): PendingIntent {
+ val alarmEntity = AlarmMapper().mapFromDomainModel(alarm)
+ val json = Json.encodeToString(alarmEntity)
+ val alarmJson = URLEncoder.encode(json, "utf-8")
+ val notificationIntent = Intent(
+ Intent.ACTION_VIEW,
+ "https://timilehinaregbesola.com/alarmId=$alarmJson".toUri(),
+ this,
+ MainActivity::class.java,
+ )
+ return TaskStackBuilder.create(this).run {
+ addNextIntentWithParentStack(notificationIntent)
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
+ getPendingIntent(REQUEST_CODE_OPEN_TASK, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE)
+ } else {
+ getPendingIntent(REQUEST_CODE_OPEN_TASK, PendingIntent.FLAG_UPDATE_CURRENT)
+ }
+ }!!
+ }
+
+ private fun getSnoozeAction(alarm: Alarm): NotificationCompat.Action {
+ val actionTitle = getString(R.string.notification_action_snooze)
+ val intent = getActionIntent(alarm, AlarmReceiver.SNOOZE_ACTION, REQUEST_CODE_ACTION_SNOOZE)
+ return NotificationCompat.Action(0, actionTitle, intent)
+ }
+
+ private fun getActionIntent(
+ alarm: Alarm,
+ intentAction: String,
+ requestCode: Int,
+ ): PendingIntent {
+ val receiverIntent = Intent(this, AlarmReceiver::class.java).apply {
+ action = intentAction
+ putExtra(AlarmReceiver.EXTRA_TASK, alarm.alarmId)
+ }
+
+ return PendingIntent.getBroadcast(
+ this,
+ requestCode,
+ receiverIntent,
+ PendingIntent.FLAG_CANCEL_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
+ }
+
+ override fun onDestroy() {
+ logger.d("AlarmService destroyed")
+
+ ActiveAlarmManager.clearActiveAlarm()
+
+ // Stop timing controller (cancels all scheduled callbacks)
+ timingController?.stop()
+ timingController = null
+
+ stopAudioPlayback()
+
+ try {
+ wakeLock?.let {
+ if (it.isHeld) it.release()
+ }
+ } catch (e: Exception) {
+ logger.w("Error releasing wake lock", e)
+ }
+ wakeLock = null
+
+ super.onDestroy()
+ }
+
+ companion object {
+ const val ACTION_START_ALARM = "com.timilehinaregbesola.mathalarm.START_ALARM"
+ const val ACTION_STOP_ALARM = "com.timilehinaregbesola.mathalarm.STOP_ALARM"
+ const val ACTION_REFRESH_NOTIFICATION = "com.timilehinaregbesola.mathalarm.REFRESH_NOTIFICATION"
+ const val EXTRA_ALARM_JSON = "extra_alarm_json"
+
+ private const val REQUEST_CODE_OPEN_TASK = 1_121_111
+ private const val REQUEST_CODE_ACTION_SNOOZE = 4_321
+ private const val REQUEST_CODE_ACTION_DISMISS = 5_678
+
+ // Ring for 10 minutes before auto-pausing
+ private const val RING_DURATION_MILLIS = 10 * 60 * 1000L
+
+ // Silence period before ringing again (1 minute)
+ private const val SILENCE_PERIOD_MILLIS = 1 * 60 * 1000L
+
+ // Maximum wake lock duration (1 hour) - service stops when user dismisses
+ private const val MAX_WAKE_LOCK_MILLIS = 60 * 60 * 1000L
+
+ /**
+ * Start the alarm service with the given alarm.
+ */
+ fun startAlarm(context: Context, alarm: Alarm) {
+ val alarmEntity = AlarmMapper().mapFromDomainModel(alarm)
+ val alarmJson = Json.encodeToString(alarmEntity)
+
+ val intent = Intent(context, AlarmService::class.java).apply {
+ action = ACTION_START_ALARM
+ putExtra(EXTRA_ALARM_JSON, alarmJson)
+ }
+
+ context.startForegroundService(intent)
+ }
+
+ /**
+ * Stop the alarm service.
+ */
+ fun stopAlarm(context: Context) {
+ val intent = Intent(context, AlarmService::class.java).apply {
+ action = ACTION_STOP_ALARM
+ }
+ context.stopService(intent)
+ }
+
+ /**
+ * Refresh the notification to trigger full-screen intent again.
+ * Call this when the user closes the app while alarm is active.
+ */
+ fun refreshNotification(context: Context) {
+ val intent = Intent(context, AlarmService::class.java).apply {
+ action = ACTION_REFRESH_NOTIFICATION
+ }
+
+ context.startForegroundService(intent)
+ }
+ }
+}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmTimingController.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmTimingController.kt
new file mode 100644
index 0000000..252d367
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/AlarmTimingController.kt
@@ -0,0 +1,104 @@
+package com.timilehinaregbesola.mathalarm.notification
+
+/**
+ * Controls the timing state machine for alarm ringing behavior.
+ * Extracted from AlarmService for testability.
+ *
+ * State flow:
+ * IDLE -> RINGING (start) -> PAUSED (after ringDuration) -> RINGING (after silencePeriod) -> ...
+ */
+class AlarmTimingController(
+ private val ringDurationMillis: Long = DEFAULT_RING_DURATION_MILLIS,
+ private val silencePeriodMillis: Long = DEFAULT_SILENCE_PERIOD_MILLIS,
+ private val onStartRinging: () -> Unit,
+ private val onPauseRinging: () -> Unit,
+ private val scheduler: TimingScheduler
+) {
+
+ enum class State {
+ IDLE,
+ RINGING,
+ PAUSED
+ }
+
+ var currentState: State = State.IDLE
+ private set
+
+ private var ringTimeoutTask: Runnable? = null
+ private var restartTask: Runnable? = null
+
+ /**
+ * Start the alarm - begins ringing and schedules auto-pause.
+ */
+ fun start() {
+ if (currentState != State.IDLE) {
+ // Already running, just restart the ringing
+ restartRinging()
+ return
+ }
+
+ currentState = State.RINGING
+ onStartRinging()
+ scheduleAutoPause()
+ }
+
+ /**
+ * Stop the alarm completely - cancels all scheduled tasks.
+ */
+ fun stop() {
+ cancelAllTasks()
+ currentState = State.IDLE
+ }
+
+ /**
+ * Called when ring duration expires - pauses audio and schedules restart.
+ */
+ private fun pauseRinging() {
+ if (currentState != State.RINGING) return
+
+ currentState = State.PAUSED
+ onPauseRinging()
+ scheduleRestart()
+ }
+
+ /**
+ * Called after silence period - restarts audio and schedules next pause.
+ */
+ private fun restartRinging() {
+ currentState = State.RINGING
+ onStartRinging()
+ scheduleAutoPause()
+ }
+
+ private fun scheduleAutoPause() {
+ ringTimeoutTask?.let { scheduler.cancel(it) }
+ ringTimeoutTask = Runnable { pauseRinging() }
+ scheduler.scheduleDelayed(ringTimeoutTask!!, ringDurationMillis)
+ }
+
+ private fun scheduleRestart() {
+ restartTask?.let { scheduler.cancel(it) }
+ restartTask = Runnable { restartRinging() }
+ scheduler.scheduleDelayed(restartTask!!, silencePeriodMillis)
+ }
+
+ private fun cancelAllTasks() {
+ ringTimeoutTask?.let { scheduler.cancel(it) }
+ restartTask?.let { scheduler.cancel(it) }
+ ringTimeoutTask = null
+ restartTask = null
+ }
+
+ /**
+ * Interface for scheduling - allows injection of test scheduler.
+ */
+ interface TimingScheduler {
+ fun scheduleDelayed(task: Runnable, delayMillis: Long)
+ fun cancel(task: Runnable)
+ }
+
+ companion object {
+ const val DEFAULT_RING_DURATION_MILLIS = 10 * 60 * 1000L // 10 minutes
+ const val DEFAULT_SILENCE_PERIOD_MILLIS = 1 * 60 * 1000L // 1 minute
+ }
+}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotification.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotification.kt
index 0ecc908..2cfea33 100644
--- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotification.kt
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotification.kt
@@ -49,26 +49,30 @@ class MathAlarmNotification(
logger.d("Showing notification for '${alarm.title}'")
val builder = buildNotification(alarm)
// builder.addAction(getCompleteAction(alarm))
- var uriExists: Boolean
- val toneUri = alarm.alarmTone.toUri()
- player.apply {
- init()
- reset()
- try {
- val inputStream: InputStream? = context.contentResolver.openInputStream(toneUri)
- inputStream?.close()
- uriExists = true
- } catch (e: Exception) {
- uriExists = false
- logger.w("File corresponding to the uri does not exist $toneUri")
+
+ // Only start audio if not already playing (to avoid duplicate sounds on notification re-show)
+ if (!player.isPlaying) {
+ var uriExists: Boolean
+ val toneUri = alarm.alarmTone.toUri()
+ player.apply {
+ init()
+ reset()
+ try {
+ val inputStream: InputStream? = context.contentResolver.openInputStream(toneUri)
+ inputStream?.close()
+ uriExists = true
+ } catch (e: Exception) {
+ uriExists = false
+ logger.w("File corresponding to the uri does not exist $toneUri")
+ }
+ val toneString = if (uriExists) {
+ alarm.alarmTone
+ } else {
+ RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM).toString()
+ }
+ setDataSourceFromString(toneString)
+ startAlarmAudio()
}
- val toneString = if (uriExists) {
- alarm.alarmTone
- } else {
- RingtoneManager.getDefaultUri(RingtoneManager.TYPE_ALARM).toString()
- }
- setDataSourceFromString(toneString)
- startAlarmAudio()
}
context.getNotificationManager()?.notify(alarm.alarmId.toInt(), builder.build())
}
@@ -116,6 +120,8 @@ class MathAlarmNotification(
setAutoCancel(true)
addAction(getSnoozeAction(alarm))
setFullScreenIntent(buildPendingIntent(alarm), true)
+ // Re-show notification immediately if user tries to dismiss it
+ setDeleteIntent(getDismissIntent(alarm))
}
private fun buildPendingIntent(alarm: Alarm): PendingIntent {
@@ -150,6 +156,10 @@ class MathAlarmNotification(
return NotificationCompat.Action(ACTION_NO_ICON, actionTitle, intent)
}
+ private fun getDismissIntent(alarm: Alarm): PendingIntent {
+ return getIntent(alarm, AlarmReceiver.DISMISS_ACTION, REQUEST_CODE_ACTION_DISMISS)
+ }
+
private fun getIntent(
alarm: Alarm,
intentAction: String,
@@ -177,6 +187,8 @@ class MathAlarmNotification(
private const val REQUEST_CODE_ACTION_SNOOZE = 4_321
+ private const val REQUEST_CODE_ACTION_DISMISS = 5_678
+
private const val ACTION_NO_ICON = 0
}
}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotificationChannel.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotificationChannel.kt
index 91583c5..9938924 100644
--- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotificationChannel.kt
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/MathAlarmNotificationChannel.kt
@@ -18,6 +18,8 @@ class MathAlarmNotificationChannel(context: Context) {
val alarmDescription = context.getString(R.string.channel_alarm_description)
NotificationChannel(ALARM_CHANNEL_ID, alarmName, NotificationManager.IMPORTANCE_HIGH).apply {
description = alarmDescription
+ setBypassDnd(true)
+ lockscreenVisibility = android.app.Notification.VISIBILITY_PUBLIC
context.getNotificationManager()?.createNotificationChannel(this)
}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/PendingIntentIdGenerator.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/PendingIntentIdGenerator.kt
new file mode 100644
index 0000000..bec8978
--- /dev/null
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/notification/PendingIntentIdGenerator.kt
@@ -0,0 +1,37 @@
+package com.timilehinaregbesola.mathalarm.notification
+
+import com.timilehinaregbesola.mathalarm.domain.model.Alarm
+
+/**
+ * Generates unique PendingIntent IDs for alarms.
+ */
+class PendingIntentIdGenerator {
+
+ /**
+ * Generates a unique PendingIntent ID for a specific alarm and day combination.
+ *
+ * The ID is generated from: alarmId + dayIndex + hour + minute
+ * This ensures each alarm/day combination has a unique ID.
+ *
+ * @param alarm the alarm
+ * @param dayIndex the day index (0-6, Sunday to Saturday)
+ * @return unique integer ID for the PendingIntent
+ */
+ fun generateId(alarm: Alarm, dayIndex: Int): Int {
+ val stringId = StringBuilder()
+ .append(alarm.alarmId)
+ .append(dayIndex)
+ .append(alarm.hour)
+ .append(alarm.minute)
+ return stringId.toString().replace("-", "").toInt()
+ }
+
+ /**
+ * Generates a simple PendingIntent ID using just the alarm ID.
+ * Useful for one-time alarms or snooze operations.
+ *
+ * @param alarmId the alarm ID
+ * @return integer ID for the PendingIntent
+ */
+ fun generateSimpleId(alarmId: Long): Int = alarmId.toInt()
+}
diff --git a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/MainActivity.kt b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/MainActivity.kt
index 118ce43..3918470 100644
--- a/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/MainActivity.kt
+++ b/app/src/androidMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/MainActivity.kt
@@ -1,10 +1,17 @@
package com.timilehinaregbesola.mathalarm.presentation
+import android.app.KeyguardManager
+import android.content.Context
import android.content.Intent
import android.graphics.Color
+import android.os.Build
import android.os.Bundle
+import android.view.WindowManager
import androidx.activity.compose.setContent
import androidx.appcompat.app.AppCompatActivity
+import co.touchlab.kermit.Logger
+import com.timilehinaregbesola.mathalarm.notification.ActiveAlarmManager
+import com.timilehinaregbesola.mathalarm.notification.AlarmService
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -37,11 +44,14 @@ import java.nio.charset.StandardCharsets
class MainActivity : AppCompatActivity() {
val preferences: AlarmPreferencesImpl by inject()
private lateinit var lyricist: Lyricist
+ private val logger = Logger.withTag("MainActivity")
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
super.onCreate(savedInstanceState)
+ setupLockScreenFlags()
+
deeplinkInfo = intent.extractAlarmJson()
setContent {
@@ -56,6 +66,22 @@ class MainActivity : AppCompatActivity() {
}
}
+ private fun setupLockScreenFlags() {
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O_MR1) {
+ setShowWhenLocked(true)
+ setTurnScreenOn(true)
+ val keyguardManager = getSystemService(Context.KEYGUARD_SERVICE) as KeyguardManager
+ keyguardManager.requestDismissKeyguard(this, null)
+ } else {
+ @Suppress("DEPRECATION")
+ window.addFlags(
+ WindowManager.LayoutParams.FLAG_SHOW_WHEN_LOCKED or
+ WindowManager.LayoutParams.FLAG_TURN_SCREEN_ON or
+ WindowManager.LayoutParams.FLAG_DISMISS_KEYGUARD
+ )
+ }
+ }
+
private fun updateTheme(darkTheme: Boolean) {
window.apply {
statusBarColor = if (darkTheme) darkPrimary.toArgb() else Color.WHITE
@@ -65,6 +91,16 @@ class MainActivity : AppCompatActivity() {
}
}
+ override fun onStop() {
+ super.onStop()
+ // If user closes the app while alarm is active, refresh the notification
+ // This will trigger the full-screen intent to pop up again
+ if (ActiveAlarmManager.hasActiveAlarm()) {
+ logger.d("App stopped with active alarm - refreshing notification")
+ AlarmService.refreshNotification(this)
+ }
+ }
+
private fun Intent.extractAlarmJson(): String? {
return data?.lastPathSegment
?.takeIf { it.startsWith("$PARAM=") }
diff --git a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/di/CommonModule.kt b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/di/CommonModule.kt
index 93f3d59..db622e5 100644
--- a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/di/CommonModule.kt
+++ b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/di/CommonModule.kt
@@ -1,6 +1,7 @@
package com.timilehinaregbesola.mathalarm.di
import com.russhwolf.settings.Settings
+import com.timilehinaregbesola.mathalarm.coroutines.AppCoroutineScope
import com.timilehinaregbesola.mathalarm.data.AlarmDataSource
import com.timilehinaregbesola.mathalarm.data.AlarmRepository
import com.timilehinaregbesola.mathalarm.framework.RoomAlarmDataSource
@@ -11,6 +12,8 @@ import com.timilehinaregbesola.mathalarm.presentation.alarmmath.AlarmMathViewMod
import com.timilehinaregbesola.mathalarm.presentation.alarmsettings.AlarmSettingsViewModel
import com.timilehinaregbesola.mathalarm.presentation.appsettings.AlarmPreferencesImpl
import com.timilehinaregbesola.mathalarm.presentation.appsettings.AppThemeOptionsMapper
+import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator
+import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculatorImpl
import com.timilehinaregbesola.mathalarm.provider.DateTimeProvider
import com.timilehinaregbesola.mathalarm.provider.DateTimeProviderImpl
import com.timilehinaregbesola.mathalarm.usecases.AddAlarm
@@ -36,11 +39,17 @@ val commonModule = module {
single { AlarmMapper() }
single { AppThemeOptionsMapper() }
+ // Coroutine Scope - replaces GlobalScope usage
+ single { AppCoroutineScope() }
+
// DateTime Provider
single { DateTimeProviderImpl() }
- // Schedule Next Alarm
- single { ScheduleNextAlarm(get()) }
+ // Alarm Time Calculator - moves time calculation to domain layer
+ single { AlarmTimeCalculatorImpl() }
+
+ // Schedule Next Alarm (now depends on AlarmTimeCalculator)
+ single { ScheduleNextAlarm(get(), get()) }
// Data Source and Repository
single { RoomAlarmDataSource(get(), get()) }
@@ -65,9 +74,9 @@ val commonModule = module {
findAlarm = FindAlarm(get()),
getSavedAlarms = GetSavedAlarms(get()),
updateAlarm = UpdateAlarm(get()),
- scheduleAlarm = ScheduleAlarm(get(), get()),
+ scheduleAlarm = ScheduleAlarm(get(), get(), get()),
completeAlarm = CompleteAlarm(get(), get(), get()),
- rescheduleFutureAlarms = RescheduleFutureAlarms(get(), get()),
+ rescheduleFutureAlarms = RescheduleFutureAlarms(get(), get(), get(), get()),
scheduleNextAlarm = get(),
showAlarm = ShowAlarm(get(), get(), get()),
snoozeAlarm = SnoozeAlarm(get(), get(), get(), get()),
diff --git a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt
index 7ef7133..de6d763 100644
--- a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt
+++ b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermission.kt
@@ -1,7 +1,7 @@
package com.timilehinaregbesola.mathalarm.framework.app.permission
/**
- * Platform-abstracted interface for checking alarm permissions.
+ * Platform-abstracted interface for checking and managing alarm permissions.
*/
interface AlarmPermission {
/**
@@ -10,4 +10,16 @@ interface AlarmPermission {
* @return `true` if the permission is granted, `false` otherwise
*/
fun hasExactAlarmPermission(): Boolean
+
+ /**
+ * Opens the system settings screen for exact alarm permission.
+ * On Android S+, this opens the SCHEDULE_EXACT_ALARM permission screen.
+ */
+ fun openExactAlarmPermissionScreen()
+
+ /**
+ * Opens the app settings screen.
+ * Useful for directing users to manually enable permissions.
+ */
+ fun openAppSettings()
}
diff --git a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt
index 185e8ad..a69baf7 100644
--- a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt
+++ b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt
@@ -5,6 +5,7 @@ package com.timilehinaregbesola.mathalarm.interactors
expect interface AudioPlayer {
val currentPosition: Int
val duration: Int
+ val isPlaying: Boolean
fun init()
fun startAlarmAudio()
diff --git a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/LabelTextField.kt b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/LabelTextField.kt
index 3dc1751..e65d932 100644
--- a/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/LabelTextField.kt
+++ b/app/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/components/LabelTextField.kt
@@ -2,6 +2,7 @@ package com.timilehinaregbesola.mathalarm.presentation.alarmsettings.components
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@@ -10,6 +11,7 @@ import androidx.compose.material3.TextFieldDefaults.colors
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color.Companion.Transparent
+import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.tooling.preview.Preview
import com.timilehinaregbesola.mathalarm.presentation.ui.icon.Label
@@ -33,6 +35,9 @@ fun LabelTextField(
label = label,
placeholder = placeholder,
singleLine = true,
+ keyboardOptions = KeyboardOptions(
+ imeAction = ImeAction.Done
+ ),
colors = colors(
unfocusedContainerColor = Transparent,
focusedContainerColor = Transparent,
diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt
index d470666..1851e5e 100644
--- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt
+++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt
@@ -2,26 +2,44 @@ package com.timilehinaregbesola.mathalarm.fake
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor
-import com.timilehinaregbesola.mathalarm.utils.initLocalDateTimeInSystemZone
import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Instant
class AlarmInteractorFake : AlarmInteractor {
private val alarmMap: MutableMap = mutableMapOf()
- override fun schedule(alarm: Alarm, reschedule: Boolean): Boolean {
- alarmMap[alarm.alarmId] = FakeData(reschedule, alarm.initLocalDateTimeInSystemZone())
- return true
+
+ override fun schedule(alarm: Alarm, timeInMillis: Long) {
+ val instant = Instant.fromEpochMilliseconds(timeInMillis)
+ val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
+ alarmMap[alarm.alarmId] = FakeData(timeInMillis, dateTime)
}
override fun cancel(alarm: Alarm) {
alarmMap.remove(alarm.alarmId)
}
+ override fun update(alarm: Alarm) {
+ val existing = alarmMap[alarm.alarmId]
+ if (existing != null) {
+ alarmMap[alarm.alarmId] = existing.copy(updated = true)
+ }
+ }
+
fun isAlarmScheduled(alarm: Alarm): Boolean = alarmMap.contains(alarm.alarmId)
fun clear() = alarmMap.clear()
- fun getAlarmTime(alarmId: Long): LocalDateTime? =
- alarmMap[alarmId]?.time
+ fun getAlarmTimeMillis(alarmId: Long): Long? = alarmMap[alarmId]?.timeInMillis
+
+ fun getAlarmTime(alarmId: Long): LocalDateTime? = alarmMap[alarmId]?.dateTime
+
+ fun getScheduledAlarms(): Map = alarmMap.toMap()
}
-data class FakeData(val reschedule: Boolean, val time: LocalDateTime)
+data class FakeData(
+ val timeInMillis: Long,
+ val dateTime: LocalDateTime,
+ val updated: Boolean = false
+)
diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt
index f211591..d31fa97 100644
--- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt
+++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmPermissionFake.kt
@@ -5,9 +5,27 @@ import com.timilehinaregbesola.mathalarm.framework.app.permission.AlarmPermissio
class AlarmPermissionFake(
private var hasPermission: Boolean = true
) : AlarmPermission {
+ var exactAlarmPermissionScreenOpened = false
+ private set
+ var appSettingsOpened = false
+ private set
+
override fun hasExactAlarmPermission(): Boolean = hasPermission
+ override fun openExactAlarmPermissionScreen() {
+ exactAlarmPermissionScreenOpened = true
+ }
+
+ override fun openAppSettings() {
+ appSettingsOpened = true
+ }
+
fun setPermission(granted: Boolean) {
hasPermission = granted
}
+
+ fun reset() {
+ exactAlarmPermissionScreenOpened = false
+ appSettingsOpened = false
+ }
}
diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt
new file mode 100644
index 0000000..da73594
--- /dev/null
+++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt
@@ -0,0 +1,35 @@
+package com.timilehinaregbesola.mathalarm.fake
+
+import com.timilehinaregbesola.mathalarm.domain.model.Alarm
+import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+
+/**
+ * Fake implementation of AlarmTimeCalculator for testing.
+ */
+@OptIn(ExperimentalTime::class)
+class AlarmTimeCalculatorFake : AlarmTimeCalculator {
+
+ private var currentTimeMillis: Long = Clock.System.now().toEpochMilliseconds()
+
+ /**
+ * Set a custom current time for testing.
+ */
+ fun setCurrentTime(timeMillis: Long) {
+ currentTimeMillis = timeMillis
+ }
+
+ override fun calculateAlarmTimes(alarm: Alarm): List {
+ // Return a simple future time for testing (1 hour from "now")
+ return listOf(currentTimeMillis + 3600_000L)
+ }
+
+ override fun calculateNextAlarmTime(alarm: Alarm): Long? {
+ return currentTimeMillis + 3600_000L
+ }
+
+ override fun isInFuture(timeInMillis: Long): Boolean {
+ return timeInMillis > currentTimeMillis
+ }
+}
diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AudioPlayerFake.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AudioPlayerFake.kt
index 78f5585..433c600 100644
--- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AudioPlayerFake.kt
+++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AudioPlayerFake.kt
@@ -6,7 +6,8 @@ class AudioPlayerFake : AudioPlayer {
var isInitialized = false
var isReset = false
var dataSource: String? = null
- var isPlaying = false
+ private var _isPlaying = false
+ override val isPlaying: Boolean get() = _isPlaying
var isStopped = false
var volume: Float = 1.0f
@@ -27,13 +28,13 @@ class AudioPlayerFake : AudioPlayer {
}
override fun startAlarmAudio() {
- isPlaying = true
+ _isPlaying = true
isStopped = false
}
override fun stop() {
isStopped = true
- isPlaying = false
+ _isPlaying = false
}
override fun setPerceivedVolume(perceived: Float) {
diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModelTest.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModelTest.kt
index 4051947..f2d6103 100644
--- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModelTest.kt
+++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmlist/AlarmListViewModelTest.kt
@@ -42,21 +42,22 @@ class AlarmListViewModelTest {
dateTimeProvider = DateTimeProviderFake()
permission = AlarmPermissionFake()
- val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor)
+ val alarmTimeCalculator = AlarmTimeCalculatorFake()
+ val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator)
usecases = Usecases(
addAlarm = AddAlarm(repository),
findAlarm = FindAlarm(repository),
deleteAlarm = DeleteAlarm(repository, alarmInteractor),
getSavedAlarms = GetSavedAlarms(repository),
- scheduleAlarm = ScheduleAlarm(repository, alarmInteractor),
+ scheduleAlarm = ScheduleAlarm(repository, alarmInteractor, alarmTimeCalculator),
showAlarm = ShowAlarm(repository, notificationInteractor, scheduleNextAlarm),
completeAlarm = CompleteAlarm(repository, alarmInteractor, notificationInteractor),
updateAlarm = UpdateAlarm(repository),
cancelAlarm = CancelAlarm(alarmInteractor),
clearAlarms = ClearAlarms(repository, alarmInteractor),
scheduleNextAlarm = scheduleNextAlarm,
- rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor),
+ rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor, alarmTimeCalculator, scheduleNextAlarm),
snoozeAlarm = SnoozeAlarm(dateTimeProvider, notificationInteractor, alarmInteractor, repository)
)
diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModelTest.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModelTest.kt
index e0b4e9e..278a594 100644
--- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModelTest.kt
+++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmmath/AlarmMathViewModelTest.kt
@@ -6,6 +6,7 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake
import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake
+import com.timilehinaregbesola.mathalarm.fake.AlarmTimeCalculatorFake
import com.timilehinaregbesola.mathalarm.fake.AudioPlayerFake
import com.timilehinaregbesola.mathalarm.fake.DateTimeProviderFake
import com.timilehinaregbesola.mathalarm.fake.NotificationInteractorFake
@@ -60,21 +61,22 @@ class AlarmMathViewModelTest {
notificationInteractor = NotificationInteractorFake()
dateTimeProvider = DateTimeProviderFake()
- val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor)
+ val alarmTimeCalculator = AlarmTimeCalculatorFake()
+ val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator)
usecases = Usecases(
addAlarm = AddAlarm(repository),
findAlarm = FindAlarm(repository),
deleteAlarm = DeleteAlarm(repository, alarmInteractor),
getSavedAlarms = GetSavedAlarms(repository),
- scheduleAlarm = ScheduleAlarm(repository, alarmInteractor),
+ scheduleAlarm = ScheduleAlarm(repository, alarmInteractor, alarmTimeCalculator),
showAlarm = ShowAlarm(repository, notificationInteractor, scheduleNextAlarm),
completeAlarm = CompleteAlarm(repository, alarmInteractor, notificationInteractor),
updateAlarm = UpdateAlarm(repository),
cancelAlarm = CancelAlarm(alarmInteractor),
clearAlarms = ClearAlarms(repository, alarmInteractor),
scheduleNextAlarm = scheduleNextAlarm,
- rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor),
+ rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor, alarmTimeCalculator, scheduleNextAlarm),
snoozeAlarm = SnoozeAlarm(dateTimeProvider, notificationInteractor, alarmInteractor, repository)
)
diff --git a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModelTest.kt b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModelTest.kt
index 4b97e11..21fe85b 100644
--- a/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModelTest.kt
+++ b/app/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/presentation/alarmsettings/AlarmSettingsViewModelTest.kt
@@ -38,21 +38,22 @@ class AlarmSettingsViewModelTest {
notificationInteractor = NotificationInteractorFake()
dateTimeProvider = DateTimeProviderFake()
- val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor)
+ val alarmTimeCalculator = AlarmTimeCalculatorFake()
+ val scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator)
usecases = Usecases(
addAlarm = AddAlarm(repository),
findAlarm = FindAlarm(repository),
deleteAlarm = DeleteAlarm(repository, alarmInteractor),
getSavedAlarms = GetSavedAlarms(repository),
- scheduleAlarm = ScheduleAlarm(repository, alarmInteractor),
+ scheduleAlarm = ScheduleAlarm(repository, alarmInteractor, alarmTimeCalculator),
showAlarm = ShowAlarm(repository, notificationInteractor, scheduleNextAlarm),
completeAlarm = CompleteAlarm(repository, alarmInteractor, notificationInteractor),
updateAlarm = UpdateAlarm(repository),
cancelAlarm = CancelAlarm(alarmInteractor),
clearAlarms = ClearAlarms(repository, alarmInteractor),
scheduleNextAlarm = scheduleNextAlarm,
- rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor),
+ rescheduleFutureAlarms = RescheduleFutureAlarms(repository, alarmInteractor, alarmTimeCalculator, scheduleNextAlarm),
snoozeAlarm = SnoozeAlarm(dateTimeProvider, notificationInteractor, alarmInteractor, repository)
)
diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/MainViewController.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/MainViewController.kt
index 2ac245e..42206f3 100644
--- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/MainViewController.kt
+++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/MainViewController.kt
@@ -10,19 +10,67 @@ import androidx.compose.ui.window.ComposeUIViewController
import cafe.adriel.lyricist.ProvideStrings
import cafe.adriel.lyricist.rememberStrings
import com.timilehinaregbesola.mathalarm.di.initKoin
+import com.timilehinaregbesola.mathalarm.di.prewarmDatabase
import com.timilehinaregbesola.mathalarm.navigation.NavGraph
-import com.timilehinaregbesola.mathalarm.notification.IosNotificationSetup
import com.timilehinaregbesola.mathalarm.notification.NotificationDeeplinkHolder
import com.timilehinaregbesola.mathalarm.presentation.appsettings.AlarmPreferencesImpl
import com.timilehinaregbesola.mathalarm.presentation.appsettings.shouldUseDarkColors
import com.timilehinaregbesola.mathalarm.presentation.ui.MathAlarmTheme
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.InternalCoroutinesApi
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.launch
import org.koin.core.component.KoinComponent
import platform.UIKit.UIViewController
+/**
+ * Initialize Koin early - called from Swift App init() before UI loads.
+ */
+fun doInitKoin() {
+ initKoin()
+}
+
+/**
+ * Prewarm the database in background - called after Koin init
+ * This initializes Room in background so it's ready when UI needs it
+ */
+fun prewarmDatabaseInBackground() {
+ prewarmDatabase()
+}
+
+/**
+ * Request notification permissions in a non-blocking way.
+ * Call this after the UI is shown to avoid blocking startup.
+ *
+ * The permission request is deferred to not block app launch,
+ * following best practices for permission timing.
+ */
+fun requestNotificationPermissionsDeferred() {
+ CoroutineScope(Dispatchers.Main).launch {
+ // Small delay to ensure UI is fully rendered first
+ delay(500)
+
+ try {
+ val koinComponent = object : KoinComponent {}
+ val scheduler: com.timilehinaregbesola.mathalarm.notification.IosAlarmScheduler =
+ koinComponent.getKoin().get()
+
+ scheduler.requestPermissions { granted ->
+ println("requestNotificationPermissionsDeferred: granted = $granted")
+ }
+ } catch (e: Exception) {
+ println("requestNotificationPermissionsDeferred: error = ${e.message}")
+ }
+ }
+}
+
/**
* iOS Main View Controller - Entry point for the Compose Multiplatform UI
- * Note: Notification delegate is set up in Swift AppDelegate for proper timing
+ *
+ * Note: Koin is initialized earlier via doInitKoin() from Swift's App init().
+ * Notification categories are registered via IosAlarmScheduler on first access.
+ * Notification delegate is set up in Swift AppDelegate for proper timing.
*/
@OptIn(
ExperimentalAnimationApi::class,
@@ -32,18 +80,7 @@ import platform.UIKit.UIViewController
InternalCoroutinesApi::class
)
fun MainViewController(): UIViewController {
- println("MainViewController: Initializing...")
-
- // Initialize Koin DI
- initKoin()
-
- // Setup notification categories for alarm actions
- IosNotificationSetup.setupNotificationCategories()
-
- // Request notification permissions
- IosNotificationSetup.requestPermissions { granted ->
- println("MainViewController: Notification permissions granted = $granted")
- }
+ println("MainViewController: Creating Compose UI...")
return ComposeUIViewController(configure = { enforceStrictPlistSanityCheck = false }) {
val preferences = rememberKoinInject()
diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/di/IosModule.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/di/IosModule.kt
index cff6fbc..b5c1675 100644
--- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/di/IosModule.kt
+++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/di/IosModule.kt
@@ -16,8 +16,16 @@ import com.timilehinaregbesola.mathalarm.interactors.AudioPlayer
import com.timilehinaregbesola.mathalarm.interactors.IosAudioPlayer
import com.timilehinaregbesola.mathalarm.interactors.NotificationInteractor
import com.timilehinaregbesola.mathalarm.interactors.NotificationInteractorImpl
+import com.timilehinaregbesola.mathalarm.notification.IosAlarmNotification
import com.timilehinaregbesola.mathalarm.notification.IosAlarmScheduler
+import com.timilehinaregbesola.mathalarm.notification.NotificationActionDelegate
import kotlinx.cinterop.ExperimentalForeignApi
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.IO
+import kotlinx.coroutines.launch
+import org.koin.core.component.KoinComponent
+import org.koin.core.component.inject
import org.koin.core.context.startKoin
import org.koin.dsl.module
import platform.Foundation.NSDocumentDirectory
@@ -28,8 +36,10 @@ import platform.Foundation.NSUserDomainMask
* iOS-specific Koin module
*/
val iosModule = module {
- // Room Database for iOS
+ // Room Database for iOS - uses lazy initialization
+ // The database will only be built when first injected
single {
+ println("IosModule: Building Room database (lazy init)")
val dbFile = documentDirectory() + "/alarm_history_database.db"
Room.databaseBuilder(
name = dbFile
@@ -41,9 +51,21 @@ val iosModule = module {
single { get().alarmDatabaseDao }
- // iOS Alarm Scheduler
+ // iOS Alarm Scheduler - schedules notifications
single { IosAlarmScheduler(getWith("IosAlarmScheduler")) }
+ // iOS Alarm Notification - handles showing/dismissing delivered notifications
+ single { IosAlarmNotification(getWith("IosAlarmNotification")) }
+
+ // Notification Action Delegate - handles snooze/dismiss actions from notifications
+ single {
+ NotificationActionDelegate(
+ appCoroutineScope = get(),
+ usecases = get(),
+ logger = getWith("NotificationActionDelegate")
+ )
+ }
+
// iOS Audio Player
single { IosAudioPlayer(getWith("IosAudioPlayer")) }
@@ -70,14 +92,39 @@ val iosModule = module {
}
/**
- * Initialize Koin for iOS
+ * Initialize Koin for iOS.
+ * Called once from Swift's App init() before UI loads.
*/
fun initKoin() {
+ println("IosModule: Initializing Koin")
startKoin {
modules(commonModule, iosModule)
}
}
+/**
+ * Prewarm the database in background.
+ * Call this after Koin init to initialize Room on a background thread,
+ * so it's ready when the UI needs it.
+ *
+ */
+fun prewarmDatabase() {
+ CoroutineScope(Dispatchers.IO).launch {
+ try {
+ println("IosModule: Prewarming database in background")
+ val helper = object : KoinComponent {
+ val database: AlarmDatabase by inject()
+ }
+ // Trigger lazy initialization by accessing the database
+ // This runs the Room builder and migrations off the main thread
+ helper.database.alarmDatabaseDao
+ println("IosModule: Database prewarmed successfully")
+ } catch (e: Exception) {
+ println("IosModule: Database prewarm failed: ${e.message}")
+ }
+ }
+}
+
/**
* Get iOS document directory path
*/
diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt
index 19d7e38..261f7c9 100644
--- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt
+++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionImpl.kt
@@ -1,5 +1,9 @@
package com.timilehinaregbesola.mathalarm.framework.app.permission
+import platform.Foundation.NSURL
+import platform.UIKit.UIApplication
+import platform.UIKit.UIApplicationOpenSettingsURLString
+
/**
* iOS implementation of AlarmPermission.
* iOS doesn't require explicit exact alarm permissions - local notifications
@@ -13,4 +17,22 @@ class AlarmPermissionImpl : AlarmPermission {
* @return Always returns true on iOS
*/
override fun hasExactAlarmPermission(): Boolean = true
+
+ /**
+ * On iOS, opens the app settings screen.
+ * iOS doesn't have a separate exact alarm permission screen.
+ */
+ override fun openExactAlarmPermissionScreen() {
+ openAppSettings()
+ }
+
+ /**
+ * Opens the iOS Settings app to this app's settings page.
+ */
+ override fun openAppSettings() {
+ val url = NSURL.URLWithString(UIApplicationOpenSettingsURLString)
+ if (url != null) {
+ UIApplication.sharedApplication.openURL(url)
+ }
+ }
}
diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt
index fcc7ffd..edde3c3 100644
--- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt
+++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractorImpl.kt
@@ -13,13 +13,22 @@ class AlarmInteractorImpl(
private val scheduler = IosAlarmScheduler(logger)
- override fun schedule(alarm: Alarm, reschedule: Boolean): Boolean {
- logger.d { "AlarmInteractorImpl.schedule called: alarmId=${alarm.alarmId}, time=${alarm.hour}:${alarm.minute}, reschedule=$reschedule" }
- return scheduler.scheduleAlarm(alarm, reschedule)
+ override fun schedule(alarm: Alarm, timeInMillis: Long) {
+ logger.d { "AlarmInteractorImpl.schedule: alarmId=${alarm.alarmId}, timeInMillis=$timeInMillis" }
+ // iOS scheduler handles time internally via UNCalendarNotificationTrigger
+ // We pass the alarm and it schedules based on alarm's hour/minute
+ scheduler.scheduleAlarm(alarm, reschedule = false)
}
override fun cancel(alarm: Alarm) {
- logger.d { "AlarmInteractorImpl.cancel called: alarmId=${alarm.alarmId}" }
+ logger.d { "AlarmInteractorImpl.cancel: alarmId=${alarm.alarmId}" }
scheduler.cancelAlarm(alarm)
}
+
+ override fun update(alarm: Alarm) {
+ logger.d { "AlarmInteractorImpl.update: alarmId=${alarm.alarmId}" }
+ // On iOS, we need to cancel and reschedule to update
+ scheduler.cancelAlarm(alarm)
+ scheduler.scheduleAlarm(alarm, reschedule = true)
+ }
}
diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt
index 990dfe8..4b45d3b 100644
--- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt
+++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AudioPlayer.kt
@@ -12,6 +12,7 @@ import platform.Foundation.NSURL
actual interface AudioPlayer {
actual val currentPosition: Int
actual val duration: Int
+ actual val isPlaying: Boolean
actual fun init()
actual fun startAlarmAudio()
@@ -38,6 +39,9 @@ class IosAudioPlayer(
override val duration: Int
get() = (audioPlayer?.duration?.times(1000))?.toInt() ?: 0
+ override val isPlaying: Boolean
+ get() = audioPlayer?.isPlaying() == true
+
override fun init() {
try {
// Configure audio session for playback
diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmNotification.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmNotification.kt
new file mode 100644
index 0000000..7a0e2da
--- /dev/null
+++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmNotification.kt
@@ -0,0 +1,78 @@
+package com.timilehinaregbesola.mathalarm.notification
+
+import co.touchlab.kermit.Logger
+import platform.UserNotifications.UNUserNotificationCenter
+
+/**
+ * iOS implementation for showing and dismissing alarm notifications.
+ *
+ * Following Alkaa's pattern of separating notification display concerns
+ * from scheduling concerns for better code organization.
+ *
+ * On iOS, the notification scheduler handles showing notifications via triggers,
+ * so the show methods are no-ops. This class primarily handles dismissing
+ * delivered notifications.
+ */
+class IosAlarmNotification(
+ private val logger: Logger
+) {
+ private val notificationCenter = UNUserNotificationCenter.currentNotificationCenter()
+
+ /**
+ * Show an alarm notification.
+ * On iOS, notifications are shown by the system when the trigger fires,
+ * so this is a no-op.
+ */
+ fun show(alarmId: Long) {
+ logger.d { "IosAlarmNotification.show - no-op on iOS (handled by trigger)" }
+ // On iOS, the notification scheduler is responsible for showing notifications
+ // via UNCalendarNotificationTrigger. This method exists for API consistency.
+ }
+
+ /**
+ * Show a repeating alarm notification.
+ * On iOS, repeating notifications are handled by the trigger configuration,
+ * so this is a no-op.
+ */
+ fun showRepeating(alarmId: Long) {
+ logger.d { "IosAlarmNotification.showRepeating - no-op on iOS (handled by trigger)" }
+ // Repeating notifications are configured in the trigger, not shown manually
+ }
+
+ /**
+ * Dismiss a delivered notification for the given alarm.
+ *
+ * @param alarmId the alarm ID to dismiss notifications for
+ */
+ fun dismiss(alarmId: Long) {
+ logger.d { "IosAlarmNotification.dismiss - alarmId: $alarmId" }
+
+ val identifiers = buildNotificationIdentifiers(alarmId)
+ notificationCenter.removeDeliveredNotificationsWithIdentifiers(identifiers)
+
+ logger.d { "IosAlarmNotification.dismiss - removed ${identifiers.size} delivered notifications" }
+ }
+
+ /**
+ * Dismiss all delivered notifications.
+ */
+ fun dismissAll() {
+ logger.d { "IosAlarmNotification.dismissAll" }
+ notificationCenter.removeAllDeliveredNotifications()
+ }
+
+ /**
+ * Build list of notification identifiers for an alarm.
+ * Includes main alarm identifier and day-specific identifiers for repeating alarms.
+ */
+ private fun buildNotificationIdentifiers(alarmId: Long): List {
+ val identifiers = mutableListOf("alarm_$alarmId")
+
+ // Include day-specific identifiers for repeating alarms
+ for (i in 0..6) {
+ identifiers.add("alarm_${alarmId}_day_$i")
+ }
+
+ return identifiers
+ }
+}
diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmScheduler.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmScheduler.kt
index c1856e8..049108b 100644
--- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmScheduler.kt
+++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosAlarmScheduler.kt
@@ -5,43 +5,88 @@ import com.timilehinaregbesola.mathalarm.alarm.AlarmScheduleRequest
import com.timilehinaregbesola.mathalarm.alarm.AlarmSchedulerBridge
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import kotlinx.cinterop.ExperimentalForeignApi
-import kotlinx.datetime.Clock
-import kotlinx.datetime.DateTimeUnit
-import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.DatePeriod
import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.LocalTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.plus
+import kotlinx.datetime.toInstant
import kotlinx.datetime.toLocalDateTime
import platform.Foundation.NSCalendar
import platform.Foundation.NSCalendarUnitDay
import platform.Foundation.NSCalendarUnitHour
import platform.Foundation.NSCalendarUnitMinute
import platform.Foundation.NSCalendarUnitMonth
-import platform.Foundation.NSCalendarUnitSecond
-import platform.Foundation.NSCalendarUnitWeekday
import platform.Foundation.NSCalendarUnitYear
+import platform.Foundation.NSDate
import platform.Foundation.NSDateComponents
+import platform.Foundation.dateWithTimeIntervalSince1970
import platform.UserNotifications.UNAuthorizationOptionAlert
import platform.UserNotifications.UNAuthorizationOptionBadge
import platform.UserNotifications.UNAuthorizationOptionSound
import platform.UserNotifications.UNCalendarNotificationTrigger
import platform.UserNotifications.UNMutableNotificationContent
+import platform.UserNotifications.UNNotificationAction
+import platform.UserNotifications.UNNotificationActionOptionDestructive
+import platform.UserNotifications.UNNotificationActionOptionNone
+import platform.UserNotifications.UNNotificationCategory
+import platform.UserNotifications.UNNotificationCategoryOptionNone
import platform.UserNotifications.UNNotificationRequest
import platform.UserNotifications.UNNotificationSound
import platform.UserNotifications.UNUserNotificationCenter
import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
/**
* iOS Alarm Scheduler - Hybrid Implementation
*
* Uses AlarmKit on iOS 26+ for native alarm experience,
* falls back to UNUserNotificationCenter on iOS 15-25.
+ *
+ * Following Alkaa's patterns:
+ * - Registers notification categories with action buttons on init
+ * - Uses NSDate for cleaner time conversion
+ * - Separates notification display concerns to IosAlarmNotification
*/
class IosAlarmScheduler(
private val logger: Logger
) {
private val notificationCenter = UNUserNotificationCenter.currentNotificationCenter()
+ init {
+ registerNotificationCategories()
+ }
+
+ /**
+ * Register notification categories with Snooze and Dismiss action buttons.
+ * This enables interactive buttons on the alarm notification.
+ */
+ private fun registerNotificationCategories() {
+ logger.d { "Registering notification categories with actions" }
+
+ val snoozeAction = UNNotificationAction.actionWithIdentifier(
+ identifier = IosNotificationConstants.ACTION_IDENTIFIER_SNOOZE,
+ title = "Snooze",
+ options = UNNotificationActionOptionNone
+ )
+
+ val dismissAction = UNNotificationAction.actionWithIdentifier(
+ identifier = IosNotificationConstants.ACTION_IDENTIFIER_DISMISS,
+ title = "Dismiss",
+ options = UNNotificationActionOptionDestructive
+ )
+
+ val alarmCategory = UNNotificationCategory.categoryWithIdentifier(
+ identifier = IosNotificationConstants.CATEGORY_IDENTIFIER_ALARM,
+ actions = listOf(snoozeAction, dismissAction),
+ intentIdentifiers = emptyList(),
+ options = UNNotificationCategoryOptionNone
+ )
+
+ notificationCenter.setNotificationCategories(setOf(alarmCategory))
+ logger.d { "Notification categories registered successfully" }
+ }
+
/**
* Check if AlarmKit is available (iOS 26+)
*/
@@ -146,18 +191,18 @@ class IosAlarmScheduler(
}
/**
- * Schedule a one-time alarm that fires at the next occurrence of the specified time
+ * Schedule a one-time alarm that fires at the next occurrence of the specified time.
+ * Uses NSDate for cleaner time conversion following Alkaa's pattern.
*/
@OptIn(ExperimentalForeignApi::class, ExperimentalTime::class)
private fun scheduleOneTimeAlarm(alarm: Alarm): Boolean {
val content = createNotificationContent(alarm)
- // Create date components for the alarm time
- val dateComponents = NSDateComponents().apply {
- hour = alarm.hour.toLong()
- minute = alarm.minute.toLong()
- second = 0
- }
+ // Calculate the next occurrence time in milliseconds
+ val timeInMillis = calculateNextAlarmTimeMillis(alarm.hour, alarm.minute)
+
+ // Convert to NSDate and extract components (cleaner than manual NSDateComponents)
+ val dateComponents = dateComponentsFromMillis(timeInMillis)
val trigger = UNCalendarNotificationTrigger.triggerWithDateMatchingComponents(
dateComponents = dateComponents,
@@ -170,6 +215,7 @@ class IosAlarmScheduler(
trigger = trigger
)
+ logger.d { "Scheduling one-time alarm: id=${alarm.alarmId} at $timeInMillis" }
notificationCenter.addNotificationRequest(request) { error ->
if (error != null) {
logger.e { "Failed to schedule one-time alarm: ${error.localizedDescription}" }
@@ -181,6 +227,50 @@ class IosAlarmScheduler(
return true
}
+ /**
+ * Convert milliseconds timestamp to NSDateComponents.
+ * Following Alkaa's cleaner approach using NSDate.
+ */
+ @OptIn(ExperimentalForeignApi::class)
+ private fun dateComponentsFromMillis(timeInMillis: Long): NSDateComponents {
+ val nsDate = NSDate.dateWithTimeIntervalSince1970(timeInMillis / 1000.0)
+ return NSCalendar.currentCalendar.components(
+ NSCalendarUnitYear or NSCalendarUnitMonth or NSCalendarUnitDay
+ or NSCalendarUnitHour or NSCalendarUnitMinute,
+ fromDate = nsDate
+ )
+ }
+
+ /**
+ * Calculate the next occurrence of the given hour:minute in milliseconds.
+ */
+ @OptIn(ExperimentalTime::class)
+ private fun calculateNextAlarmTimeMillis(hour: Int, minute: Int): Long {
+ val now = Instant.fromEpochMilliseconds(kotlin.time.Clock.System.now().toEpochMilliseconds())
+ val tz = TimeZone.currentSystemDefault()
+ val localNow = now.toLocalDateTime(tz)
+ val today = localNow.date
+
+ // Create alarm time for today
+ val alarmTimeToday = LocalDateTime(
+ date = today,
+ time = LocalTime(hour, minute, 0)
+ )
+ val alarmInstantToday = alarmTimeToday.toInstant(tz)
+
+ // If alarm time has passed today, schedule for tomorrow
+ return if (alarmInstantToday > now) {
+ alarmInstantToday.toEpochMilliseconds()
+ } else {
+ val tomorrow = today.plus(DatePeriod(days = 1))
+ val alarmTimeTomorrow = LocalDateTime(
+ date = tomorrow,
+ time = LocalTime(hour, minute, 0)
+ )
+ alarmTimeTomorrow.toInstant(tz).toEpochMilliseconds()
+ }
+ }
+
/**
* Schedule a repeating alarm for specific days of the week
*/
@@ -237,50 +327,57 @@ class IosAlarmScheduler(
}
/**
- * Create notification content for an alarm
+ * Create notification content for an alarm.
+ * Uses constants from IosNotificationConstants for consistency.
+ *
+ * Note: Critical alerts require special Apple entitlement (not available to most apps).
+ * Instead, we use:
+ * - TimeSensitive interruption level (breaks through Focus modes)
+ * - Default sound for notification
+ * - AlarmAudioController plays full alarm sound when notification arrives/is tapped
*/
private fun createNotificationContent(alarm: Alarm): UNMutableNotificationContent {
return UNMutableNotificationContent().apply {
setTitle("Math Alarm")
setBody(alarm.title.ifEmpty { "Time to wake up! Solve the math problem to dismiss." })
- // Use critical sound with max volume for alarm
- // This bypasses Do Not Disturb and silent mode
- // Note: Critical alerts require special entitlement from Apple for App Store
- // For development, we use the default critical sound
+ // Use default sound or custom sound if bundled
+ // The actual alarm sound is played by AlarmAudioController in Swift
+ // when the notification arrives (foreground) or is tapped (background)
val alarmSound = if (alarm.alarmTone.isNotEmpty()) {
- // Try to use custom sound file if specified
- UNNotificationSound.criticalSoundNamed(alarm.alarmTone, withAudioVolume = 1.0f)
+ // Try to use custom sound from bundle (must be .caf, .wav, .aiff, max 30 seconds)
+ UNNotificationSound.soundNamed(alarm.alarmTone)
} else {
- // Use default critical sound at max volume
- UNNotificationSound.defaultCriticalSoundWithAudioVolume(1.0f)
+ // Use default notification sound - AlarmAudioController handles full alarm
+ UNNotificationSound.defaultSound
}
setSound(alarmSound)
// Set relevance score to maximum for alarm notifications
setRelevanceScore(1.0)
- // Mark as interruptive (iOS 15+)
- setInterruptionLevel(platform.UserNotifications.UNNotificationInterruptionLevel.UNNotificationInterruptionLevelCritical)
+ // Use TimeSensitive interruption level (iOS 15+)
+ // This breaks through Focus modes without requiring critical alerts entitlement
+ setInterruptionLevel(platform.UserNotifications.UNNotificationInterruptionLevel.UNNotificationInterruptionLevelTimeSensitive)
- // Add ALL alarm data to userInfo for handling when notification is tapped
- // This data is used to navigate to the MathScreen with the alarm info
+ // Add alarm data to userInfo using constants for key names
+ // This data is used by NotificationActionDelegate and for navigating to MathScreen
setUserInfo(mapOf(
- "alarmId" to alarm.alarmId,
- "hour" to alarm.hour,
- "minute" to alarm.minute,
- "repeat" to alarm.repeat,
- "repeatDays" to alarm.repeatDays,
- "difficulty" to alarm.difficulty,
- "snooze" to alarm.snooze,
- "vibrate" to alarm.vibrate,
- "alarmTone" to alarm.alarmTone,
- "title" to alarm.title,
- "isOn" to alarm.isOn
+ IosNotificationConstants.USER_INFO_ALARM_ID to alarm.alarmId,
+ IosNotificationConstants.USER_INFO_HOUR to alarm.hour,
+ IosNotificationConstants.USER_INFO_MINUTE to alarm.minute,
+ IosNotificationConstants.USER_INFO_REPEAT to alarm.repeat,
+ IosNotificationConstants.USER_INFO_REPEAT_DAYS to alarm.repeatDays,
+ IosNotificationConstants.USER_INFO_DIFFICULTY to alarm.difficulty,
+ IosNotificationConstants.USER_INFO_SNOOZE to alarm.snooze,
+ IosNotificationConstants.USER_INFO_VIBRATE to alarm.vibrate,
+ IosNotificationConstants.USER_INFO_ALARM_TONE to alarm.alarmTone,
+ IosNotificationConstants.USER_INFO_TITLE to alarm.title,
+ IosNotificationConstants.USER_INFO_IS_ON to alarm.isOn
))
- // Set category for action buttons
- setCategoryIdentifier("ALARM_CATEGORY")
+ // Set category for action buttons (registered in init)
+ setCategoryIdentifier(IosNotificationConstants.CATEGORY_IDENTIFIER_ALARM)
}
}
diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationConstants.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationConstants.kt
new file mode 100644
index 0000000..8daf700
--- /dev/null
+++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationConstants.kt
@@ -0,0 +1,79 @@
+package com.timilehinaregbesola.mathalarm.notification
+
+/**
+ * Constants for iOS notification configuration.
+ * Centralized to avoid magic strings and ensure consistency.
+ */
+object IosNotificationConstants {
+
+ /**
+ * Category identifier for alarm notifications.
+ * Used to associate actions with the notification.
+ */
+ const val CATEGORY_IDENTIFIER_ALARM = "ALARM_CATEGORY"
+
+ /**
+ * Action identifier for snooze button.
+ */
+ const val ACTION_IDENTIFIER_SNOOZE = "SNOOZE_ACTION"
+
+ /**
+ * Action identifier for dismiss button.
+ */
+ const val ACTION_IDENTIFIER_DISMISS = "DISMISS_ACTION"
+
+ /**
+ * Key for storing alarm ID in notification userInfo.
+ */
+ const val USER_INFO_ALARM_ID = "alarmId"
+
+ /**
+ * Key for storing alarm hour in notification userInfo.
+ */
+ const val USER_INFO_HOUR = "hour"
+
+ /**
+ * Key for storing alarm minute in notification userInfo.
+ */
+ const val USER_INFO_MINUTE = "minute"
+
+ /**
+ * Key for storing repeat flag in notification userInfo.
+ */
+ const val USER_INFO_REPEAT = "repeat"
+
+ /**
+ * Key for storing repeat days in notification userInfo.
+ */
+ const val USER_INFO_REPEAT_DAYS = "repeatDays"
+
+ /**
+ * Key for storing difficulty in notification userInfo.
+ */
+ const val USER_INFO_DIFFICULTY = "difficulty"
+
+ /**
+ * Key for storing snooze duration in notification userInfo.
+ */
+ const val USER_INFO_SNOOZE = "snooze"
+
+ /**
+ * Key for storing vibrate flag in notification userInfo.
+ */
+ const val USER_INFO_VIBRATE = "vibrate"
+
+ /**
+ * Key for storing alarm tone in notification userInfo.
+ */
+ const val USER_INFO_ALARM_TONE = "alarmTone"
+
+ /**
+ * Key for storing alarm title in notification userInfo.
+ */
+ const val USER_INFO_TITLE = "title"
+
+ /**
+ * Key for storing isOn flag in notification userInfo.
+ */
+ const val USER_INFO_IS_ON = "isOn"
+}
diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationSetup.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationSetup.kt
index 0335f07..bede9da 100644
--- a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationSetup.kt
+++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/IosNotificationSetup.kt
@@ -2,7 +2,6 @@ package com.timilehinaregbesola.mathalarm.notification
import platform.UserNotifications.UNAuthorizationOptionAlert
import platform.UserNotifications.UNAuthorizationOptionBadge
-import platform.UserNotifications.UNAuthorizationOptionCriticalAlert
import platform.UserNotifications.UNAuthorizationOptionSound
import platform.UserNotifications.UNNotificationAction
import platform.UserNotifications.UNNotificationActionOptionDestructive
@@ -61,16 +60,20 @@ object IosNotificationSetup {
/**
* Request notification permissions
* Call this when appropriate in the app flow
+ *
+ * Note: Critical alerts require special Apple entitlement (not available to most apps).
+ * We use standard notification permissions - actual alarm sound is played by
+ * AlarmAudioController when the notification arrives.
*/
fun requestPermissions(onResult: (Boolean) -> Unit) {
val notificationCenter = UNUserNotificationCenter.currentNotificationCenter()
- // Request authorization including critical alerts for alarm sounds
+ // Request standard notification authorization
+ // Critical alerts require Apple approval - we handle alarm sounds via AlarmAudioController
notificationCenter.requestAuthorizationWithOptions(
options = UNAuthorizationOptionAlert or
UNAuthorizationOptionSound or
- UNAuthorizationOptionBadge or
- UNAuthorizationOptionCriticalAlert
+ UNAuthorizationOptionBadge
) { granted, error ->
onResult(granted)
}
diff --git a/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/NotificationActionDelegate.kt b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/NotificationActionDelegate.kt
new file mode 100644
index 0000000..0fee5c5
--- /dev/null
+++ b/app/src/iosMain/kotlin/com/timilehinaregbesola/mathalarm/notification/NotificationActionDelegate.kt
@@ -0,0 +1,84 @@
+package com.timilehinaregbesola.mathalarm.notification
+
+import co.touchlab.kermit.Logger
+import com.timilehinaregbesola.mathalarm.coroutines.AppCoroutineScope
+import com.timilehinaregbesola.mathalarm.framework.Usecases
+import platform.UserNotifications.UNNotificationResponse
+
+/**
+ * Delegate class to handle iOS notification actions.
+ *
+ * Following Alkaa's pattern of separating notification action handling
+ * from the scheduler for cleaner code organization.
+ */
+class NotificationActionDelegate(
+ private val appCoroutineScope: AppCoroutineScope,
+ private val usecases: Usecases,
+ private val logger: Logger
+) {
+
+ /**
+ * Handles the notification response when user interacts with notification actions.
+ *
+ * @param response the notification response from iOS
+ * @param onCompletion callback to execute after handling is complete
+ */
+ fun handleNotificationResponse(response: UNNotificationResponse, onCompletion: () -> Unit) {
+ logger.d { "NotificationActionDelegate - handling response" }
+
+ val content = response.notification.request.content
+ val userInfo = content.userInfo
+
+ val alarmId = (userInfo[IosNotificationConstants.USER_INFO_ALARM_ID] as? Long) ?: run {
+ logger.e { "NotificationActionDelegate - alarmId not found in userInfo" }
+ onCompletion()
+ return
+ }
+
+ logger.d { "NotificationActionDelegate - alarmId: $alarmId, action: ${response.actionIdentifier}" }
+
+ when (response.actionIdentifier) {
+ IosNotificationConstants.ACTION_IDENTIFIER_SNOOZE -> {
+ snoozeAlarm(alarmId, onCompletion)
+ }
+ IosNotificationConstants.ACTION_IDENTIFIER_DISMISS -> {
+ dismissAlarm(alarmId, onCompletion)
+ }
+ // Default action when user taps the notification itself
+ "com.apple.UNNotificationDefaultActionIdentifier" -> {
+ logger.d { "NotificationActionDelegate - default action (notification tapped)" }
+ onCompletion()
+ }
+ else -> {
+ logger.w { "NotificationActionDelegate - Unknown action: ${response.actionIdentifier}" }
+ onCompletion()
+ }
+ }
+ }
+
+ private fun snoozeAlarm(alarmId: Long, onCompletion: () -> Unit) {
+ logger.d { "NotificationActionDelegate - snoozing alarm $alarmId" }
+ appCoroutineScope.launch {
+ try {
+ usecases.snoozeAlarm(alarmId)
+ logger.d { "NotificationActionDelegate - alarm $alarmId snoozed successfully" }
+ } catch (e: Exception) {
+ logger.e { "NotificationActionDelegate - failed to snooze alarm: ${e.message}" }
+ }
+ onCompletion()
+ }
+ }
+
+ private fun dismissAlarm(alarmId: Long, onCompletion: () -> Unit) {
+ logger.d { "NotificationActionDelegate - dismissing alarm $alarmId" }
+ appCoroutineScope.launch {
+ try {
+ usecases.completeAlarm(alarmId)
+ logger.d { "NotificationActionDelegate - alarm $alarmId dismissed successfully" }
+ } catch (e: Exception) {
+ logger.e { "NotificationActionDelegate - failed to dismiss alarm: ${e.message}" }
+ }
+ onCompletion()
+ }
+ }
+}
diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/AlarmReceiverTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/AlarmReceiverTest.kt
new file mode 100644
index 0000000..2e9fb29
--- /dev/null
+++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/AlarmReceiverTest.kt
@@ -0,0 +1,120 @@
+package com.timilehinaregbesola.mathalarm
+
+import android.app.AlarmManager
+import android.app.Application
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.R], application = TestApplication::class)
+class AlarmReceiverTest {
+
+ private lateinit var context: Context
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ }
+
+ @Test
+ fun `ALARM_ACTION intent should have correct extras`() {
+ val alarmId = 123L
+ val intent = createAlarmIntent(AlarmReceiver.ALARM_ACTION, alarmId)
+
+ assertEquals("Action should be ALARM_ACTION", AlarmReceiver.ALARM_ACTION, intent.action)
+ assertEquals("Should have correct alarm ID", alarmId, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, 0))
+ }
+
+ @Test
+ fun `COMPLETE_ACTION intent should have correct extras`() {
+ val alarmId = 456L
+ val intent = createAlarmIntent(AlarmReceiver.COMPLETE_ACTION, alarmId)
+
+ assertEquals("Action should be COMPLETE_ACTION", AlarmReceiver.COMPLETE_ACTION, intent.action)
+ assertEquals("Should have correct alarm ID", alarmId, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, 0))
+ }
+
+ @Test
+ fun `SNOOZE_ACTION intent should have correct extras`() {
+ val alarmId = 789L
+ val intent = createAlarmIntent(AlarmReceiver.SNOOZE_ACTION, alarmId)
+
+ assertEquals("Action should be SNOOZE_ACTION", AlarmReceiver.SNOOZE_ACTION, intent.action)
+ assertEquals("Should have correct alarm ID", alarmId, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, 0))
+ }
+
+ @Test
+ fun `DISMISS_ACTION intent should have correct extras`() {
+ val alarmId = 111L
+ val intent = createAlarmIntent(AlarmReceiver.DISMISS_ACTION, alarmId)
+
+ assertEquals("Action should be DISMISS_ACTION", AlarmReceiver.DISMISS_ACTION, intent.action)
+ assertEquals("Should have correct alarm ID", alarmId, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, 0))
+ }
+
+ // ==================== Boot Event Intent Tests ====================
+
+ @Test
+ fun `BOOT_COMPLETED intent should have correct action`() {
+ val intent = Intent(Intent.ACTION_BOOT_COMPLETED)
+
+ assertEquals("Action should be BOOT_COMPLETED", Intent.ACTION_BOOT_COMPLETED, intent.action)
+ }
+
+ @Test
+ fun `QUICKBOOT_POWERON intent should have correct action`() {
+ val intent = Intent("android.intent.action.QUICKBOOT_POWERON")
+
+ assertEquals("Action should be QUICKBOOT_POWERON",
+ "android.intent.action.QUICKBOOT_POWERON",
+ intent.action)
+ }
+
+ @Test
+ fun `MY_PACKAGE_REPLACED intent should have correct action`() {
+ val intent = Intent("android.intent.action.MY_PACKAGE_REPLACED")
+
+ assertEquals("Action should be MY_PACKAGE_REPLACED",
+ "android.intent.action.MY_PACKAGE_REPLACED",
+ intent.action)
+ }
+
+ @Test
+ fun `SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED intent should have correct action`() {
+ val intent = Intent(AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED)
+
+ assertEquals("Action should be SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED",
+ AlarmManager.ACTION_SCHEDULE_EXACT_ALARM_PERMISSION_STATE_CHANGED,
+ intent.action)
+ }
+
+ @Test
+ fun `alarm intent with zero ID should work`() {
+ val intent = createAlarmIntent(AlarmReceiver.ALARM_ACTION, 0L)
+
+ assertEquals("Should have zero alarm ID", 0L, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, -1))
+ }
+
+ @Test
+ fun `alarm intent with large ID should work`() {
+ val largeId = Long.MAX_VALUE
+ val intent = createAlarmIntent(AlarmReceiver.ALARM_ACTION, largeId)
+
+ assertEquals("Should have large alarm ID", largeId, intent.getLongExtra(AlarmReceiver.EXTRA_TASK, 0))
+ }
+
+ private fun createAlarmIntent(action: String, alarmId: Long): Intent {
+ return Intent(context, AlarmReceiver::class.java).apply {
+ this.action = action
+ putExtra(AlarmReceiver.EXTRA_TASK, alarmId)
+ }
+ }
+}
diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/TestApplication.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/TestApplication.kt
new file mode 100644
index 0000000..11d287c
--- /dev/null
+++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/TestApplication.kt
@@ -0,0 +1,14 @@
+package com.timilehinaregbesola.mathalarm
+
+import android.app.Application
+
+/**
+ * Test Application class that does NOT initialize Koin.
+ * This is used by Robolectric tests to avoid Koin initialization conflicts.
+ */
+class TestApplication : Application() {
+ override fun onCreate() {
+ super.onCreate()
+ // Do NOT start Koin - tests will manage their own state
+ }
+}
diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/PermissionCheckerFake.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/PermissionCheckerFake.kt
new file mode 100644
index 0000000..0fe2f33
--- /dev/null
+++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/PermissionCheckerFake.kt
@@ -0,0 +1,12 @@
+package com.timilehinaregbesola.mathalarm.fake
+
+import com.timilehinaregbesola.mathalarm.framework.app.permission.PermissionChecker
+
+/**
+ * Fake implementation of PermissionChecker for testing.
+ */
+class PermissionCheckerFake(
+ var canScheduleExactAlarms: Boolean = true
+) : PermissionChecker {
+ override fun canScheduleExactAlarms(): Boolean = canScheduleExactAlarms
+}
diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/ScreenNavigatorFake.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/ScreenNavigatorFake.kt
new file mode 100644
index 0000000..22f4708
--- /dev/null
+++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/fake/ScreenNavigatorFake.kt
@@ -0,0 +1,26 @@
+package com.timilehinaregbesola.mathalarm.fake
+
+import com.timilehinaregbesola.mathalarm.framework.app.permission.ScreenNavigator
+
+/**
+ * Fake implementation of ScreenNavigator for testing.
+ */
+class ScreenNavigatorFake : ScreenNavigator {
+ var exactAlarmPermissionScreenOpened = false
+ private set
+ var appSettingsOpened = false
+ private set
+
+ override fun openExactAlarmPermissionScreen() {
+ exactAlarmPermissionScreenOpened = true
+ }
+
+ override fun openAppSettings() {
+ appSettingsOpened = true
+ }
+
+ fun reset() {
+ exactAlarmPermissionScreenOpened = false
+ appSettingsOpened = false
+ }
+}
diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt
index de9b91f..1fbfad9 100644
--- a/app/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt
+++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/framework/app/permission/AlarmPermissionTest.kt
@@ -1,23 +1,29 @@
package com.timilehinaregbesola.mathalarm.framework.app.permission
-import android.app.AlarmManager
import android.os.Build
import com.timilehinaregbesola.mathalarm.fake.AndroidVersionFake
-import io.mockk.every
-import io.mockk.mockk
+import com.timilehinaregbesola.mathalarm.fake.PermissionCheckerFake
+import com.timilehinaregbesola.mathalarm.fake.ScreenNavigatorFake
import org.junit.Assert
+import org.junit.Before
import org.junit.Test
class AlarmPermissionTest {
- private val alarmManager = mockk(relaxed = true)
-
+ private val screenNavigator = ScreenNavigatorFake()
+ private val permissionChecker = PermissionCheckerFake()
private val androidVersion = AndroidVersionFake()
- private val alarmPermission = AlarmPermissionImpl(alarmManager, androidVersion)
+ private lateinit var alarmPermission: AlarmPermissionImpl
+
+ @Before
+ fun setup() {
+ alarmPermission = AlarmPermissionImpl(screenNavigator, permissionChecker, androidVersion)
+ screenNavigator.reset()
+ }
@Test
fun `test if when permission is granted returns true`() {
- every { alarmManager.canScheduleExactAlarms() } returns true
+ permissionChecker.canScheduleExactAlarms = true
androidVersion.version = Build.VERSION_CODES.S
val result = alarmPermission.hasExactAlarmPermission()
@@ -27,7 +33,7 @@ class AlarmPermissionTest {
@Test
fun `test if when permission is not granted returns false`() {
- every { alarmManager.canScheduleExactAlarms() } returns false
+ permissionChecker.canScheduleExactAlarms = false
androidVersion.version = Build.VERSION_CODES.S
val result = alarmPermission.hasExactAlarmPermission()
@@ -38,9 +44,24 @@ class AlarmPermissionTest {
@Test
fun `test if Android below S returns true`() {
androidVersion.version = Build.VERSION_CODES.M
+ permissionChecker.canScheduleExactAlarms = false // Should be ignored
val result = alarmPermission.hasExactAlarmPermission()
Assert.assertTrue(result)
}
+
+ @Test
+ fun `test openExactAlarmPermissionScreen delegates to navigator`() {
+ alarmPermission.openExactAlarmPermissionScreen()
+
+ Assert.assertTrue(screenNavigator.exactAlarmPermissionScreenOpened)
+ }
+
+ @Test
+ fun `test openAppSettings delegates to navigator`() {
+ alarmPermission.openAppSettings()
+
+ Assert.assertTrue(screenNavigator.appSettingsOpened)
+ }
}
diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt
index ce3a5da..b7b242b 100644
--- a/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt
+++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmNotificationSchedulerTest.kt
@@ -1,44 +1,253 @@
package com.timilehinaregbesola.mathalarm.notification
+import android.app.AlarmManager
+import android.app.Application
import android.content.Context
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
import co.touchlab.kermit.Logger
+import com.timilehinaregbesola.mathalarm.AlarmReceiver
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
-import io.mockk.every
-import io.mockk.mockk
-import io.mockk.mockkStatic
-import kotlinx.datetime.LocalDateTime
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertNotNull
+import org.junit.Assert.assertTrue
import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowAlarmManager
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.R], application = com.timilehinaregbesola.mathalarm.TestApplication::class)
class AlarmNotificationSchedulerTest {
- private val mockContext = mockk(relaxed = true)
- private val mockLogger = Logger.withTag("AlarmNotificationSchedulerTest")
-
- private val mockAlarm = mockk()
-
- private val scheduler = AlarmNotificationScheduler(mockContext, mockLogger)
+ private lateinit var context: Context
+ private lateinit var alarmManager: AlarmManager
+ private lateinit var shadowAlarmManager: ShadowAlarmManager
+ private lateinit var scheduler: AlarmNotificationScheduler
+ private lateinit var logger: Logger
@Before
fun setUp() {
- val now = LocalDateTime(2023, 1, 1, 10, 30) // Example LocalDateTime
- mockkStatic("com.timilehinaregbesola.mathalarm.utils.extension-context")
- every { mockAlarm.repeatDays } returns "FFFTFFF"
- every { mockAlarm.alarmId } returns 22L
- every { mockAlarm.repeat } returns false
- every { mockAlarm.newDateTime } returns now
- every { mockAlarm.hour } returns now.hour
- every { mockAlarm.minute } returns now.minute
- }
-
-// @Test
-// fun `check if alarm scheduled is valid`() {
-// scheduler.scheduleAlarm(mockAlarm, false)
-// verify { mockContext.setExactAlarm(mockAlarm.newDateTime.time.toNanosecondOfDay(), any()) }
-// }
-//
-// @Test
-// fun `check if alarm canceled is valid`() {
-// scheduler.cancelAlarm(mockAlarm)
-// verify { mockContext.cancelAlarm(any()) }
-// }
+ context = ApplicationProvider.getApplicationContext()
+ alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ shadowAlarmManager = shadowOf(alarmManager)
+ logger = Logger.withTag("AlarmNotificationSchedulerTest")
+ scheduler = AlarmNotificationScheduler(context, logger)
+ }
+
+ @Test
+ fun `scheduleAlarm should schedule alarm with correct time`() {
+ val alarm = createAlarm(id = 1L)
+ val triggerTime = System.currentTimeMillis() + 60_000L // 1 minute from now
+
+ scheduler.scheduleAlarm(alarm, triggerTime)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should have one scheduled alarm", 1, scheduledAlarms.size)
+ assertEquals("Trigger time should match", triggerTime, scheduledAlarms[0].triggerAtTime)
+ }
+
+ @Test
+ fun `scheduleAlarm should create correct PendingIntent targeting AlarmReceiver`() {
+ val alarmId = 123L
+ val alarm = createAlarm(id = alarmId)
+ val triggerTime = System.currentTimeMillis() + 60_000L
+
+ scheduler.scheduleAlarm(alarm, triggerTime)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertNotNull("Should have scheduled alarm", scheduledAlarms.firstOrNull())
+ assertEquals("Should use RTC_WAKEUP type", AlarmManager.RTC_WAKEUP, scheduledAlarms[0].type)
+
+ val pendingIntent = scheduledAlarms[0].operation
+ val shadowPendingIntent = shadowOf(pendingIntent)
+ val savedIntent = shadowPendingIntent.savedIntent
+
+ assertEquals("Intent should have alarm ID", alarmId, savedIntent.getLongExtra(AlarmReceiver.EXTRA_TASK, -1))
+ assertEquals("Intent action should be ALARM_ACTION", AlarmReceiver.ALARM_ACTION, savedIntent.action)
+ assertEquals("Intent should target AlarmReceiver", AlarmReceiver::class.java.name, savedIntent.component?.className)
+ assertTrue("Should be a broadcast PendingIntent", shadowPendingIntent.isBroadcastIntent)
+ }
+
+ @Test
+ fun `scheduleAlarm with different alarms should schedule multiple alarms`() {
+ val alarm1 = createAlarm(id = 1L)
+ val alarm2 = createAlarm(id = 2L)
+ val alarm3 = createAlarm(id = 3L)
+ val baseTime = System.currentTimeMillis()
+
+ scheduler.scheduleAlarm(alarm1, baseTime + 60_000L)
+ scheduler.scheduleAlarm(alarm2, baseTime + 120_000L)
+ scheduler.scheduleAlarm(alarm3, baseTime + 180_000L)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should have three scheduled alarms", 3, scheduledAlarms.size)
+ }
+
+ @Test
+ fun `scheduleAlarm with same alarm ID should replace previous alarm`() {
+ val alarm = createAlarm(id = 1L)
+ val firstTime = System.currentTimeMillis() + 60_000L
+ val secondTime = System.currentTimeMillis() + 120_000L
+
+ scheduler.scheduleAlarm(alarm, firstTime)
+ scheduler.scheduleAlarm(alarm, secondTime)
+
+ // Due to FLAG_CANCEL_CURRENT, the second schedule should replace the first
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ // Note: Robolectric may keep both or only the latest depending on version
+ // The important thing is that when the alarm fires, it uses the latest time
+ assertTrue("Should have at least one scheduled alarm", scheduledAlarms.isNotEmpty())
+ }
+
+
+ @Test
+ fun `cancelAlarm should remove scheduled alarm`() {
+ val alarm = createAlarm(id = 1L)
+ val triggerTime = System.currentTimeMillis() + 60_000L
+
+ scheduler.scheduleAlarm(alarm, triggerTime)
+ assertEquals("Should have one scheduled alarm before cancel", 1, shadowAlarmManager.scheduledAlarms.size)
+
+ scheduler.cancelAlarm(alarm)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertTrue("Scheduled alarms should be empty after cancel", scheduledAlarms.isEmpty())
+ }
+
+ @Test
+ fun `cancelAlarm for repeating alarm should cancel all day-specific alarms`() {
+ // Repeating alarm on Mon, Wed, Fri (indices 1, 3, 5)
+ val alarm = createAlarm(id = 1L, repeatDays = "FTFTFTF")
+
+ // Schedule the base alarm
+ scheduler.scheduleAlarm(alarm, System.currentTimeMillis() + 60_000L)
+
+ // Cancel should attempt to cancel the base alarm and all day-specific ones
+ scheduler.cancelAlarm(alarm)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertTrue("All alarms should be canceled", scheduledAlarms.isEmpty())
+ }
+
+ @Test
+ fun `cancelAlarm should not affect other alarms`() {
+ val alarm1 = createAlarm(id = 1L)
+ val alarm2 = createAlarm(id = 2L)
+ val triggerTime = System.currentTimeMillis() + 60_000L
+
+ scheduler.scheduleAlarm(alarm1, triggerTime)
+ scheduler.scheduleAlarm(alarm2, triggerTime + 60_000L)
+ assertEquals("Should have two scheduled alarms", 2, shadowAlarmManager.scheduledAlarms.size)
+
+ scheduler.cancelAlarm(alarm1)
+
+ val remainingAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should have one remaining alarm", 1, remainingAlarms.size)
+
+ // Verify the remaining alarm is for alarm2
+ val pendingIntent = remainingAlarms[0].operation
+ val shadowPendingIntent = shadowOf(pendingIntent)
+ val savedIntent = shadowPendingIntent.savedIntent
+ assertEquals("Remaining alarm should be alarm2", 2L, savedIntent.getLongExtra(AlarmReceiver.EXTRA_TASK, -1))
+ }
+
+ @Test
+ fun `different alarms should have different PendingIntent request codes`() {
+ val alarm1 = createAlarm(id = 1L)
+ val alarm2 = createAlarm(id = 2L)
+ val triggerTime = System.currentTimeMillis() + 60_000L
+
+ scheduler.scheduleAlarm(alarm1, triggerTime)
+ scheduler.scheduleAlarm(alarm2, triggerTime)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should have two scheduled alarms", 2, scheduledAlarms.size)
+
+ val requestCode1 = shadowOf(scheduledAlarms[0].operation).requestCode
+ val requestCode2 = shadowOf(scheduledAlarms[1].operation).requestCode
+
+ assertTrue("Request codes should be different", requestCode1 != requestCode2)
+ }
+
+ // ==================== Edge Case Tests ====================
+
+ @Test
+ fun `scheduleAlarm with zero ID should still work`() {
+ val alarm = createAlarm(id = 0L)
+ val triggerTime = System.currentTimeMillis() + 60_000L
+
+ scheduler.scheduleAlarm(alarm, triggerTime)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should have one scheduled alarm", 1, scheduledAlarms.size)
+ }
+
+ @Test
+ fun `scheduleAlarm with very large ID should work`() {
+ val alarm = createAlarm(id = Long.MAX_VALUE)
+ val triggerTime = System.currentTimeMillis() + 60_000L
+
+ scheduler.scheduleAlarm(alarm, triggerTime)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should have one scheduled alarm", 1, scheduledAlarms.size)
+ }
+
+ @Test
+ fun `updateAlarm should not throw exception`() {
+ val alarm = createAlarm(id = 1L)
+
+ // updateAlarm is a no-op on Android but should not throw
+ scheduler.updateAlarm(alarm)
+
+ // No assertions needed - just verify no exception is thrown
+ }
+
+ @Test
+ fun `cancelAlarm with all days enabled should attempt to cancel 7 day-specific alarms`() {
+ val alarm = createAlarm(id = 1L, repeatDays = "TTTTTTT")
+
+ // Schedule the alarm
+ scheduler.scheduleAlarm(alarm, System.currentTimeMillis() + 60_000L)
+
+ // Cancel should handle all 7 days
+ scheduler.cancelAlarm(alarm)
+
+ assertTrue("All alarms should be canceled", shadowAlarmManager.scheduledAlarms.isEmpty())
+ }
+
+ @Test
+ fun `cancelAlarm with no repeat days should only cancel base alarm`() {
+ val alarm = createAlarm(id = 1L, repeatDays = "FFFFFFF")
+
+ scheduler.scheduleAlarm(alarm, System.currentTimeMillis() + 60_000L)
+ scheduler.cancelAlarm(alarm)
+
+ assertTrue("Alarm should be canceled", shadowAlarmManager.scheduledAlarms.isEmpty())
+ }
+
+
+ private fun createAlarm(
+ id: Long,
+ hour: Int = 7,
+ minute: Int = 0,
+ repeatDays: String = "FFFFFFF",
+ repeat: Boolean = repeatDays.contains('T'),
+ title: String = "Test Alarm"
+ ): Alarm {
+ return Alarm(
+ alarmId = id,
+ hour = hour,
+ minute = minute,
+ repeatDays = repeatDays,
+ repeat = repeat,
+ title = title,
+ isOn = true,
+ isSaved = true
+ )
+ }
}
diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmTimingControllerTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmTimingControllerTest.kt
new file mode 100644
index 0000000..b5ea068
--- /dev/null
+++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/notification/AlarmTimingControllerTest.kt
@@ -0,0 +1,193 @@
+package com.timilehinaregbesola.mathalarm.notification
+
+import io.mockk.mockk
+import io.mockk.verify
+import io.mockk.verifyOrder
+import io.mockk.slot
+import org.junit.Assert.assertEquals
+import org.junit.Before
+import org.junit.Test
+
+class AlarmTimingControllerTest {
+
+ private lateinit var onStartRinging: () -> Unit
+ private lateinit var onPauseRinging: () -> Unit
+ private lateinit var testScheduler: TestTimingScheduler
+ private lateinit var controller: AlarmTimingController
+
+ // Test scheduler that allows manual time advancement
+ class TestTimingScheduler : AlarmTimingController.TimingScheduler {
+ private val scheduledTasks = mutableMapOf()
+ private var currentTime = 0L
+
+ override fun scheduleDelayed(task: Runnable, delayMillis: Long) {
+ scheduledTasks[task] = currentTime + delayMillis
+ }
+
+ override fun cancel(task: Runnable) {
+ scheduledTasks.remove(task)
+ }
+
+ fun advanceTimeBy(millis: Long) {
+ currentTime += millis
+ // Execute tasks that should have fired
+ val toExecute = scheduledTasks.filter { it.value <= currentTime }.keys.toList()
+ toExecute.forEach { task ->
+ scheduledTasks.remove(task)
+ task.run()
+ }
+ }
+
+ fun hasScheduledTasks(): Boolean = scheduledTasks.isNotEmpty()
+
+ fun getScheduledTaskCount(): Int = scheduledTasks.size
+ }
+
+ @Before
+ fun setup() {
+ onStartRinging = mockk(relaxed = true)
+ onPauseRinging = mockk(relaxed = true)
+ testScheduler = TestTimingScheduler()
+
+ controller = AlarmTimingController(
+ ringDurationMillis = 10_000L, // 10 seconds for testing
+ silencePeriodMillis = 2_000L, // 2 seconds for testing
+ onStartRinging = onStartRinging,
+ onPauseRinging = onPauseRinging,
+ scheduler = testScheduler
+ )
+ }
+
+ @Test
+ fun `start should trigger onStartRinging and set state to RINGING`() {
+ controller.start()
+
+ verify(exactly = 1) { onStartRinging() }
+ assertEquals(AlarmTimingController.State.RINGING, controller.currentState)
+ }
+
+ @Test
+ fun `start should schedule auto-pause task`() {
+ controller.start()
+
+ assert(testScheduler.hasScheduledTasks())
+ }
+
+ @Test
+ fun `after ring duration, should pause and call onPauseRinging`() {
+ controller.start()
+
+ // Advance time past ring duration
+ testScheduler.advanceTimeBy(10_000L)
+
+ verify(exactly = 1) { onPauseRinging() }
+ assertEquals(AlarmTimingController.State.PAUSED, controller.currentState)
+ }
+
+ @Test
+ fun `after silence period, should restart ringing`() {
+ controller.start()
+
+ // Advance to pause
+ testScheduler.advanceTimeBy(10_000L)
+ verify(exactly = 1) { onPauseRinging() }
+
+ // Advance past silence period
+ testScheduler.advanceTimeBy(2_000L)
+
+ // Should have called onStartRinging twice (initial + restart)
+ verify(exactly = 2) { onStartRinging() }
+ assertEquals(AlarmTimingController.State.RINGING, controller.currentState)
+ }
+
+ @Test
+ fun `full cycle should repeat - ring, pause, ring, pause`() {
+ controller.start()
+
+ // First ring cycle
+ verify(exactly = 1) { onStartRinging() }
+
+ // After 10s - pause
+ testScheduler.advanceTimeBy(10_000L)
+ verify(exactly = 1) { onPauseRinging() }
+
+ // After 2s silence - restart
+ testScheduler.advanceTimeBy(2_000L)
+ verify(exactly = 2) { onStartRinging() }
+
+ // After another 10s - pause again
+ testScheduler.advanceTimeBy(10_000L)
+ verify(exactly = 2) { onPauseRinging() }
+
+ // After 2s silence - restart again
+ testScheduler.advanceTimeBy(2_000L)
+ verify(exactly = 3) { onStartRinging() }
+ }
+
+ @Test
+ fun `stop should cancel all tasks and set state to IDLE`() {
+ controller.start()
+
+ controller.stop()
+
+ assertEquals(AlarmTimingController.State.IDLE, controller.currentState)
+ // Advancing time should not trigger any callbacks
+ testScheduler.advanceTimeBy(100_000L)
+ verify(exactly = 1) { onStartRinging() } // Only the initial call
+ verify(exactly = 0) { onPauseRinging() }
+ }
+
+ @Test
+ fun `stop during paused state should cancel restart`() {
+ controller.start()
+
+ // Advance to pause
+ testScheduler.advanceTimeBy(10_000L)
+ assertEquals(AlarmTimingController.State.PAUSED, controller.currentState)
+
+ // Stop during pause
+ controller.stop()
+
+ // Advancing time should not restart
+ testScheduler.advanceTimeBy(10_000L)
+ verify(exactly = 1) { onStartRinging() } // Only initial, no restart
+ }
+
+ @Test
+ fun `calling start when already ringing should restart cycle`() {
+ controller.start()
+ verify(exactly = 1) { onStartRinging() }
+
+ // Advance partway through ring duration
+ testScheduler.advanceTimeBy(5_000L)
+
+ // Call start again
+ controller.start()
+
+ // Should restart ringing
+ verify(exactly = 2) { onStartRinging() }
+ assertEquals(AlarmTimingController.State.RINGING, controller.currentState)
+ }
+
+ @Test
+ fun `order of callbacks should be correct`() {
+ controller.start()
+
+ // Full cycle
+ testScheduler.advanceTimeBy(10_000L) // Pause
+ testScheduler.advanceTimeBy(2_000L) // Restart
+ testScheduler.advanceTimeBy(10_000L) // Pause again
+
+ verifyOrder {
+ onStartRinging() // Initial
+ onPauseRinging() // After first ring
+ onStartRinging() // Restart
+ onPauseRinging() // After second ring
+ }
+ }
+
+ @Test
+ fun `initial state should be IDLE`() {
+ assertEquals(AlarmTimingController.State.IDLE, controller.currentState)
+ }
+}
diff --git a/app/src/test/java/com/timilehinaregbesola/mathalarm/utils/AlarmManagerExtensionsTest.kt b/app/src/test/java/com/timilehinaregbesola/mathalarm/utils/AlarmManagerExtensionsTest.kt
new file mode 100644
index 0000000..9234c5d
--- /dev/null
+++ b/app/src/test/java/com/timilehinaregbesola/mathalarm/utils/AlarmManagerExtensionsTest.kt
@@ -0,0 +1,177 @@
+package com.timilehinaregbesola.mathalarm.utils
+
+import android.app.AlarmManager
+import android.app.Application
+import android.app.PendingIntent
+import android.content.Context
+import android.content.Intent
+import android.os.Build
+import androidx.test.core.app.ApplicationProvider
+import com.timilehinaregbesola.mathalarm.AlarmReceiver
+import org.junit.Assert.assertEquals
+import org.junit.Assert.assertTrue
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.Shadows.shadowOf
+import org.robolectric.annotation.Config
+import org.robolectric.shadows.ShadowAlarmManager
+
+@RunWith(RobolectricTestRunner::class)
+@Config(sdk = [Build.VERSION_CODES.R], application = com.timilehinaregbesola.mathalarm.TestApplication::class)
+class AlarmManagerExtensionsTest {
+
+ private lateinit var context: Context
+ private lateinit var alarmManager: AlarmManager
+ private lateinit var shadowAlarmManager: ShadowAlarmManager
+
+ @Before
+ fun setUp() {
+ context = ApplicationProvider.getApplicationContext()
+ alarmManager = context.getSystemService(Context.ALARM_SERVICE) as AlarmManager
+ shadowAlarmManager = shadowOf(alarmManager)
+ }
+
+ @Test
+ fun `setExactAlarm with future time should schedule alarm`() {
+ val futureTime = System.currentTimeMillis() + 60_000L // 1 minute from now
+ val pendingIntent = createTestPendingIntent(1)
+
+ context.setExactAlarm(futureTime, pendingIntent)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should have one scheduled alarm", 1, scheduledAlarms.size)
+ assertEquals("Trigger time should match", futureTime, scheduledAlarms[0].triggerAtTime)
+ }
+
+ @Test
+ fun `setExactAlarm should use RTC_WAKEUP as default type`() {
+ val futureTime = System.currentTimeMillis() + 60_000L
+ val pendingIntent = createTestPendingIntent(1)
+
+ context.setExactAlarm(futureTime, pendingIntent)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should use RTC_WAKEUP type", AlarmManager.RTC_WAKEUP, scheduledAlarms[0].type)
+ }
+
+ @Test
+ fun `setExactAlarm with custom type should use that type`() {
+ val futureTime = System.currentTimeMillis() + 60_000L
+ val pendingIntent = createTestPendingIntent(1)
+
+ context.setExactAlarm(futureTime, pendingIntent, AlarmManager.ELAPSED_REALTIME_WAKEUP)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should use ELAPSED_REALTIME_WAKEUP type",
+ AlarmManager.ELAPSED_REALTIME_WAKEUP,
+ scheduledAlarms[0].type)
+ }
+
+ @Test
+ fun `setExactAlarm with past time should add one week to trigger time`() {
+ val pastTime = System.currentTimeMillis() - 60_000L // 1 minute in the past
+ val pendingIntent = createTestPendingIntent(1)
+ val oneWeekInMillis = 7 * 24 * 60 * 60 * 1000L
+
+ context.setExactAlarm(pastTime, pendingIntent)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should have one scheduled alarm", 1, scheduledAlarms.size)
+
+ // The alarm should be scheduled for pastTime + 1 week
+ val expectedTime = pastTime + oneWeekInMillis
+ assertEquals("Trigger time should be past time + 1 week", expectedTime, scheduledAlarms[0].triggerAtTime)
+ }
+
+ @Test
+ fun `setExactAlarm with null PendingIntent should not schedule alarm`() {
+ val futureTime = System.currentTimeMillis() + 60_000L
+
+ context.setExactAlarm(futureTime, null)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertTrue("Should have no scheduled alarms", scheduledAlarms.isEmpty())
+ }
+
+ @Test
+ fun `multiple setExactAlarm calls should schedule multiple alarms`() {
+ val baseTime = System.currentTimeMillis()
+
+ context.setExactAlarm(baseTime + 60_000L, createTestPendingIntent(1))
+ context.setExactAlarm(baseTime + 120_000L, createTestPendingIntent(2))
+ context.setExactAlarm(baseTime + 180_000L, createTestPendingIntent(3))
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should have three scheduled alarms", 3, scheduledAlarms.size)
+ }
+
+ // ==================== cancelAlarm Tests ====================
+
+ @Test
+ fun `cancelAlarm should remove scheduled alarm`() {
+ val futureTime = System.currentTimeMillis() + 60_000L
+ val pendingIntent = createTestPendingIntent(1)
+
+ context.setExactAlarm(futureTime, pendingIntent)
+ assertEquals("Should have one alarm before cancel", 1, shadowAlarmManager.scheduledAlarms.size)
+
+ context.cancelAlarm(pendingIntent)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertTrue("Should have no alarms after cancel", scheduledAlarms.isEmpty())
+ }
+
+ @Test
+ fun `cancelAlarm with null PendingIntent should not throw`() {
+ // This should not throw any exception
+ context.cancelAlarm(null)
+
+ // No assertion needed - just verify no exception is thrown
+ }
+
+ @Test
+ fun `cancelAlarm should only cancel matching alarm`() {
+ val futureTime = System.currentTimeMillis() + 60_000L
+ val pendingIntent1 = createTestPendingIntent(1)
+ val pendingIntent2 = createTestPendingIntent(2)
+
+ context.setExactAlarm(futureTime, pendingIntent1)
+ context.setExactAlarm(futureTime + 60_000L, pendingIntent2)
+ assertEquals("Should have two alarms before cancel", 2, shadowAlarmManager.scheduledAlarms.size)
+
+ context.cancelAlarm(pendingIntent1)
+
+ val scheduledAlarms = shadowAlarmManager.scheduledAlarms
+ assertEquals("Should have one alarm after cancel", 1, scheduledAlarms.size)
+ }
+
+ @Test
+ fun `cancelAlarm for non-existent alarm should not throw`() {
+ val pendingIntent = createTestPendingIntent(999)
+
+ // This should not throw any exception even though the alarm was never scheduled
+ context.cancelAlarm(pendingIntent)
+ }
+
+ @Test
+ fun `getAlarmManager should return AlarmManager instance`() {
+ val alarmManager = context.getAlarmManager()
+
+ assertTrue("Should return AlarmManager", alarmManager is AlarmManager)
+ }
+
+ private fun createTestPendingIntent(requestCode: Int): PendingIntent {
+ val intent = Intent(context, AlarmReceiver::class.java).apply {
+ action = AlarmReceiver.ALARM_ACTION
+ putExtra(AlarmReceiver.EXTRA_TASK, requestCode.toLong())
+ }
+ return PendingIntent.getBroadcast(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
+ )
+ }
+}
diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/coroutines/AppCoroutineScope.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/coroutines/AppCoroutineScope.kt
new file mode 100644
index 0000000..d7c262f
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/coroutines/AppCoroutineScope.kt
@@ -0,0 +1,38 @@
+package com.timilehinaregbesola.mathalarm.coroutines
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
+import kotlinx.coroutines.launch
+import kotlin.coroutines.CoroutineContext
+
+/**
+ * Application-scoped coroutine scope for background operations.
+ *
+ * This replaces GlobalScope usage with a properly managed scope that:
+ * - Uses SupervisorJob so child failures don't cancel siblings
+ * - Uses Default dispatcher for CPU-intensive work
+ * - Can be properly cancelled if needed during app shutdown
+ *
+ */
+class AppCoroutineScope : CoroutineScope {
+
+ private val job = SupervisorJob()
+
+ override val coroutineContext: CoroutineContext
+ get() = Dispatchers.Default + job
+
+ /**
+ * Launch a coroutine in the application scope.
+ */
+ fun launch(block: suspend CoroutineScope.() -> Unit) =
+ (this as CoroutineScope).launch(block = block)
+
+ /**
+ * Cancel all coroutines in this scope.
+ * Should be called during application shutdown if needed.
+ */
+ fun cancel() {
+ job.cancel()
+ }
+}
diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractor.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractor.kt
index fc90c20..c1c85ef 100644
--- a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractor.kt
+++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/interactors/AlarmInteractor.kt
@@ -11,9 +11,9 @@ interface AlarmInteractor {
* Schedules a new alarm.
*
* @param alarm the alarm
- * @param reschedule whether repeating
+ * @param timeInMillis the time to schedule the alarm in milliseconds
*/
- fun schedule(alarm: Alarm, reschedule: Boolean): Boolean
+ fun schedule(alarm: Alarm, timeInMillis: Long)
/**
* Cancels an alarm.
@@ -21,4 +21,13 @@ interface AlarmInteractor {
* @param alarm the alarm
*/
fun cancel(alarm: Alarm)
+
+ /**
+ * Updates an existing alarm.
+ * On Android, the notification will trigger a BroadcastReceiver which will always get the
+ * most recent Alarm data from the database, so this may be a no-op on some platforms.
+ *
+ * @param alarm the alarm to be updated
+ */
+ fun update(alarm: Alarm)
}
diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculator.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculator.kt
new file mode 100644
index 0000000..794b14f
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculator.kt
@@ -0,0 +1,37 @@
+package com.timilehinaregbesola.mathalarm.provider
+
+import com.timilehinaregbesola.mathalarm.domain.model.Alarm
+
+/**
+ * Provides alarm time calculations, respecting Inversion of Control.
+ */
+interface AlarmTimeCalculator {
+
+ /**
+ * Calculates all trigger times for an alarm based on its repeat configuration.
+ *
+ * For non-repeating alarms, returns a single time.
+ * For repeating alarms, returns times for each enabled day.
+ *
+ * @param alarm the alarm to calculate times for
+ * @return list of times in milliseconds when the alarm should trigger
+ */
+ fun calculateAlarmTimes(alarm: Alarm): List
+
+ /**
+ * Calculates the next single trigger time for an alarm.
+ * Useful for snooze and one-time scheduling.
+ *
+ * @param alarm the alarm
+ * @return the next trigger time in milliseconds, or null if no valid time
+ */
+ fun calculateNextAlarmTime(alarm: Alarm): Long?
+
+ /**
+ * Checks if the given time is in the future.
+ *
+ * @param timeInMillis the time to check
+ * @return true if the time is in the future
+ */
+ fun isInFuture(timeInMillis: Long): Boolean
+}
diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImpl.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImpl.kt
new file mode 100644
index 0000000..198691b
--- /dev/null
+++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImpl.kt
@@ -0,0 +1,127 @@
+package com.timilehinaregbesola.mathalarm.provider
+
+import com.timilehinaregbesola.mathalarm.domain.model.Alarm
+import kotlinx.datetime.DatePeriod
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.LocalTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.plus
+import kotlinx.datetime.toInstant
+import kotlin.time.ExperimentalTime
+
+@OptIn(ExperimentalTime::class)
+class AlarmTimeCalculatorImpl(
+ private val dateTimeProvider: DateTimeProvider = DateTimeProviderImpl()
+) : AlarmTimeCalculator {
+
+ companion object {
+ private const val SUN = 0
+ private const val SAT = 6
+ }
+
+ override fun calculateAlarmTimes(alarm: Alarm): List {
+ val tz = TimeZone.currentSystemDefault()
+ val localNow = dateTimeProvider.getCurrentDateTime()
+ val nowInstant = localNow.toInstant(tz)
+ val todayDate = localNow.date
+ val currentDayIndex = todayDate.dayOfWeek.toIndex()
+
+ val times = mutableListOf()
+
+ // Determine which days to schedule
+ val repeatDays = if (alarm.repeatDays == "FFFFFFF") {
+ // No repeat days set - determine the closest possible date
+ val alarmTimeToday = LocalDateTime(
+ date = todayDate,
+ time = LocalTime(alarm.hour, alarm.minute, 0)
+ )
+ val alarmInstantToday = alarmTimeToday.toInstant(tz)
+
+ val dayIndex = if (alarmInstantToday > nowInstant) {
+ currentDayIndex // Set for today
+ } else {
+ // Set for tomorrow
+ if (currentDayIndex == SAT) SUN else currentDayIndex + 1
+ }
+
+ "FFFFFFF".toCharArray().apply {
+ this[dayIndex] = 'T'
+ }.concatToString()
+ } else {
+ alarm.repeatDays
+ }
+
+ for (i in SUN..SAT) {
+ if (repeatDays.getOrNull(i) == 'T') {
+ val targetDate = calculateTargetDate(
+ currentDayIndex = currentDayIndex,
+ targetDayIndex = i,
+ todayDate = todayDate,
+ alarmHour = alarm.hour,
+ alarmMinute = alarm.minute,
+ nowInstant = nowInstant,
+ tz = tz
+ )
+
+ val targetDateTime = LocalDateTime(
+ date = targetDate,
+ time = LocalTime(alarm.hour, alarm.minute, 0)
+ )
+
+ times.add(targetDateTime.toInstant(tz).toEpochMilliseconds())
+ }
+ }
+
+ return times
+ }
+
+ override fun calculateNextAlarmTime(alarm: Alarm): Long? {
+ val times = calculateAlarmTimes(alarm)
+ return times.minOrNull()
+ }
+
+ @OptIn(ExperimentalTime::class)
+ override fun isInFuture(timeInMillis: Long): Boolean {
+ val tz = TimeZone.currentSystemDefault()
+ val currentTime = dateTimeProvider.getCurrentDateTime().toInstant(tz).toEpochMilliseconds()
+ return timeInMillis > currentTime
+ }
+
+ private fun calculateTargetDate(
+ currentDayIndex: Int,
+ targetDayIndex: Int,
+ todayDate: kotlinx.datetime.LocalDate,
+ alarmHour: Int,
+ alarmMinute: Int,
+ nowInstant: kotlin.time.Instant,
+ tz: TimeZone
+ ): kotlinx.datetime.LocalDate {
+ val alarmTimeToday = LocalDateTime(
+ date = todayDate,
+ time = LocalTime(alarmHour, alarmMinute, 0)
+ )
+ val alarmInstantToday = alarmTimeToday.toInstant(tz)
+ val isPastToday = alarmInstantToday < nowInstant
+
+ val daysUntilAlarm = if (currentDayIndex > targetDayIndex || (currentDayIndex == targetDayIndex && isPastToday)) {
+ // Schedule for next week
+ SAT - currentDayIndex + 1 + targetDayIndex
+ } else {
+ // Schedule for this week
+ targetDayIndex - currentDayIndex
+ }
+
+ return todayDate.plus(DatePeriod(days = daysUntilAlarm))
+ }
+
+ private fun DayOfWeek.toIndex(): Int = when (this) {
+ DayOfWeek.SUNDAY -> 0
+ DayOfWeek.MONDAY -> 1
+ DayOfWeek.TUESDAY -> 2
+ DayOfWeek.WEDNESDAY -> 3
+ DayOfWeek.THURSDAY -> 4
+ DayOfWeek.FRIDAY -> 5
+ DayOfWeek.SATURDAY -> 6
+ }
+}
diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarms.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarms.kt
index 521ff55..ee256c3 100644
--- a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarms.kt
+++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarms.kt
@@ -3,27 +3,67 @@ package com.timilehinaregbesola.mathalarm.usecases
import com.timilehinaregbesola.mathalarm.data.AlarmRepository
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor
+import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator
+import kotlinx.coroutines.flow.first
/**
* Use case to reschedule tasks scheduled in the future or missing repeating.
*/
class RescheduleFutureAlarms(
private val alarmRepository: AlarmRepository,
- private val alarmInteractor: AlarmInteractor
+ private val alarmInteractor: AlarmInteractor,
+ private val alarmTimeCalculator: AlarmTimeCalculator,
+ private val scheduleNextAlarm: ScheduleNextAlarm
) {
/**
- * Reschedule scheduled and misses repeating tasks.
+ * Reschedule scheduled and missed repeating alarms.
+ *
+ * This separates handling into two categories:
+ * 1. Future alarms - alarms with next trigger time in the future
+ * 2. Missed repeating alarms - repeating alarms whose time has passed
*/
suspend operator fun invoke() {
- alarmRepository.getSavedAlarms().collect { list ->
- val futureAlarms = list.filter { it.isOn }
- futureAlarms.forEach { rescheduleFutureAlarm(it) }
- }
+ val activeAlarms = alarmRepository.getSavedAlarms().first().filter { it.isOn }
+
+ val futureAlarms = activeAlarms.filter { isFutureAlarm(it) }
+ val missedRepeating = activeAlarms.filter { isMissedRepeating(it) }
+
+ futureAlarms.forEach { rescheduleFutureAlarm(it) }
+ missedRepeating.forEach { rescheduleRepeatingAlarm(it) }
}
+ /**
+ * Checks if the alarm's next trigger time is in the future.
+ */
+ private fun isFutureAlarm(alarm: Alarm): Boolean {
+ val nextTime = alarmTimeCalculator.calculateNextAlarmTime(alarm) ?: return false
+ return alarmTimeCalculator.isInFuture(nextTime)
+ }
+
+ /**
+ * Checks if this is a repeating alarm whose time has already passed.
+ */
+ private fun isMissedRepeating(alarm: Alarm): Boolean {
+ if (!alarm.repeat) return false
+ val nextTime = alarmTimeCalculator.calculateNextAlarmTime(alarm) ?: return false
+ return !alarmTimeCalculator.isInFuture(nextTime)
+ }
+
+ /**
+ * Reschedule a future alarm by calculating its times and scheduling each.
+ */
private fun rescheduleFutureAlarm(alarm: Alarm) {
- alarmInteractor.schedule(alarm, alarm.repeat)
-// logger.debug("Task '${alarm.title} rescheduled to '${alarm.dueDate}")
+ val alarmTimes = alarmTimeCalculator.calculateAlarmTimes(alarm)
+ alarmTimes.forEach { timeInMillis ->
+ alarmInteractor.schedule(alarm, timeInMillis)
+ }
+ }
+
+ /**
+ * Reschedule a missed repeating alarm by advancing to its next occurrence.
+ */
+ private fun rescheduleRepeatingAlarm(alarm: Alarm) {
+ scheduleNextAlarm(alarm)
}
}
diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarm.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarm.kt
index 8cbdb55..b6ee0b8 100644
--- a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarm.kt
+++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarm.kt
@@ -3,28 +3,48 @@ package com.timilehinaregbesola.mathalarm.usecases
import com.timilehinaregbesola.mathalarm.data.AlarmRepository
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor
+import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator
class ScheduleAlarm(
private val alarmRepository: AlarmRepository,
- private val alarmInteractor: AlarmInteractor
+ private val alarmInteractor: AlarmInteractor,
+ private val alarmTimeCalculator: AlarmTimeCalculator
) {
/**
* Schedules a new alarm.
*
* @param alarm the alarm
- * @param reschedule whether alarm should reschedule
+ * @param reschedule whether alarm should reschedule (cancels existing before scheduling)
*/
suspend operator fun invoke(alarm: Alarm, reschedule: Boolean) {
val foundAlarm = if (alarm.alarmId == 0L) {
alarmRepository.getLatestAlarm()
} else {
- val found = alarmRepository.findAlarm(alarm.alarmId) ?: return
- found
+ alarmRepository.findAlarm(alarm.alarmId) ?: return
+ } ?: return
+
+ // Cancel existing alarm if rescheduling
+ if (reschedule) {
+ alarmInteractor.cancel(foundAlarm)
+ }
+
+ // Calculate all alarm times and schedule each one
+ val alarmTimes = alarmTimeCalculator.calculateAlarmTimes(foundAlarm)
+
+ if (alarmTimes.isEmpty()) {
+ // No valid times to schedule
+ val updatedAlarm = foundAlarm.copy(isOn = false)
+ alarmRepository.updateAlarm(updatedAlarm)
+ return
}
-// val foundAlarm = alarmRepository.findAlarm(alarm.alarmId) ?: return
- val isOn = alarmInteractor.schedule(foundAlarm!!, reschedule)
- val updatedAlarm = foundAlarm.copy(isOn = isOn)
+
+ // Schedule each alarm time
+ alarmTimes.forEach { timeInMillis ->
+ alarmInteractor.schedule(foundAlarm, timeInMillis)
+ }
+
+ val updatedAlarm = foundAlarm.copy(isOn = true)
alarmRepository.updateAlarm(updatedAlarm)
alarmRepository.getAlarms()
}
diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarm.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarm.kt
index b0e163a..8edf452 100644
--- a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarm.kt
+++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarm.kt
@@ -2,12 +2,14 @@ package com.timilehinaregbesola.mathalarm.usecases
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor
+import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator
/**
* Schedules the next alarm entry or the missing ones in a repeating alarm.
*/
class ScheduleNextAlarm(
private val alarmInteractor: AlarmInteractor,
+ private val alarmTimeCalculator: AlarmTimeCalculator,
) {
/**
@@ -19,7 +21,9 @@ class ScheduleNextAlarm(
require(alarm.repeat) { "Alarm is not repeating" }
require(alarm.isOn) { "Alarm is not on" }
- alarmInteractor.schedule(alarm, alarm.repeat)
-// logger.debug("ScheduleNextAlarm = Task = '${alarm.title}' at ${alarm.dueDate.time} ")
+ val alarmTimes = alarmTimeCalculator.calculateAlarmTimes(alarm)
+ alarmTimes.forEach { timeInMillis ->
+ alarmInteractor.schedule(alarm, timeInMillis)
+ }
}
}
diff --git a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt
index cb427ed..5e810d0 100644
--- a/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt
+++ b/core/src/commonMain/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarm.kt
@@ -35,11 +35,15 @@ class SnoozeAlarm(
val alarm = alarmRepository.findAlarm(alarmId) ?: return
val snoozedTime = getSnoozedDateTime(dateTimeProvider.getCurrentDateTime(), minutes)
- alarm.apply {
- hour = snoozedTime.hour
+ val updatedAlarm = alarm.copy(
+ hour = snoozedTime.hour,
minute = snoozedTime.minute
- }
- alarmInteractor.schedule(alarm, alarm.repeat)
+ )
+
+ // Calculate snooze time in millis and schedule
+ val tz = TimeZone.currentSystemDefault()
+ val timeInMillis = snoozedTime.toInstant(tz).toEpochMilliseconds()
+ alarmInteractor.schedule(updatedAlarm, timeInMillis)
notificationInteractor.dismiss(alarmId)
}
diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt
index 50b3fd0..36a8132 100644
--- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt
+++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmInteractorFake.kt
@@ -5,38 +5,40 @@ import com.timilehinaregbesola.mathalarm.interactors.AlarmInteractor
import kotlinx.datetime.LocalDateTime
import kotlinx.datetime.TimeZone
import kotlinx.datetime.toLocalDateTime
-import kotlin.time.Clock
-import kotlin.time.ExperimentalTime
+import kotlin.time.Instant
class AlarmInteractorFake : AlarmInteractor {
private val alarmMap: MutableMap = mutableMapOf()
- override fun schedule(alarm: Alarm, reschedule: Boolean): Boolean {
- alarmMap[alarm.alarmId] = FakeData(reschedule, getAlarmDateTime(alarm))
- return true
+ override fun schedule(alarm: Alarm, timeInMillis: Long) {
+ val instant = Instant.fromEpochMilliseconds(timeInMillis)
+ val dateTime = instant.toLocalDateTime(TimeZone.currentSystemDefault())
+ alarmMap[alarm.alarmId] = FakeData(timeInMillis, dateTime)
}
override fun cancel(alarm: Alarm) {
alarmMap.remove(alarm.alarmId)
}
+ override fun update(alarm: Alarm) {
+ // For testing, update just marks the alarm as updated
+ val existing = alarmMap[alarm.alarmId]
+ if (existing != null) {
+ alarmMap[alarm.alarmId] = existing.copy(updated = true)
+ }
+ }
+
fun isAlarmScheduled(alarm: Alarm): Boolean = alarmMap.contains(alarm.alarmId)
fun clear() = alarmMap.clear()
- fun getAlarmTime(alarmId: Long): LocalDateTime? =
- alarmMap[alarmId]?.time
-
- @OptIn(ExperimentalTime::class)
- private fun getAlarmDateTime(alarm: Alarm): LocalDateTime {
- val nowInstant = Clock.System.now()
- val tz = TimeZone.currentSystemDefault()
- val today = nowInstant.toLocalDateTime(tz).date
- return LocalDateTime(
- date = today,
- time = kotlinx.datetime.LocalTime(alarm.hour, alarm.minute, 0)
- )
- }
+ fun getAlarmTimeMillis(alarmId: Long): Long? = alarmMap[alarmId]?.timeInMillis
+
+ fun getScheduledAlarms(): Map = alarmMap.toMap()
}
-data class FakeData(val reschedule: Boolean, val time: LocalDateTime)
+data class FakeData(
+ val timeInMillis: Long,
+ val dateTime: LocalDateTime,
+ val updated: Boolean = false
+)
diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt
new file mode 100644
index 0000000..da73594
--- /dev/null
+++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/AlarmTimeCalculatorFake.kt
@@ -0,0 +1,35 @@
+package com.timilehinaregbesola.mathalarm.fake
+
+import com.timilehinaregbesola.mathalarm.domain.model.Alarm
+import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculator
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+
+/**
+ * Fake implementation of AlarmTimeCalculator for testing.
+ */
+@OptIn(ExperimentalTime::class)
+class AlarmTimeCalculatorFake : AlarmTimeCalculator {
+
+ private var currentTimeMillis: Long = Clock.System.now().toEpochMilliseconds()
+
+ /**
+ * Set a custom current time for testing.
+ */
+ fun setCurrentTime(timeMillis: Long) {
+ currentTimeMillis = timeMillis
+ }
+
+ override fun calculateAlarmTimes(alarm: Alarm): List {
+ // Return a simple future time for testing (1 hour from "now")
+ return listOf(currentTimeMillis + 3600_000L)
+ }
+
+ override fun calculateNextAlarmTime(alarm: Alarm): Long? {
+ return currentTimeMillis + 3600_000L
+ }
+
+ override fun isInFuture(timeInMillis: Long): Boolean {
+ return timeInMillis > currentTimeMillis
+ }
+}
diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/DateTimeProviderFake.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/DateTimeProviderFake.kt
new file mode 100644
index 0000000..8c72e3a
--- /dev/null
+++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/fake/DateTimeProviderFake.kt
@@ -0,0 +1,45 @@
+package com.timilehinaregbesola.mathalarm.fake
+
+import com.timilehinaregbesola.mathalarm.provider.DateTimeProvider
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.time.Clock
+import kotlin.time.ExperimentalTime
+
+class DateTimeProviderFake : DateTimeProvider {
+ private var fixedDateTime: LocalDateTime? = null
+
+ @OptIn(ExperimentalTime::class)
+ override fun getCurrentDateTime(): LocalDateTime {
+ return fixedDateTime ?: Clock.System.now().toLocalDateTime(TimeZone.currentSystemDefault())
+ }
+
+ /**
+ * Set a fixed date/time for deterministic testing.
+ */
+ fun setFixedDateTime(dateTime: LocalDateTime) {
+ fixedDateTime = dateTime
+ }
+
+ /**
+ * Set a fixed date/time using individual components.
+ */
+ fun setFixedDateTime(
+ year: Int,
+ month: Int,
+ dayOfMonth: Int,
+ hour: Int,
+ minute: Int,
+ second: Int = 0
+ ) {
+ fixedDateTime = LocalDateTime(year, month, dayOfMonth, hour, minute, second)
+ }
+
+ /**
+ * Clear the fixed time and return to using the real clock.
+ */
+ fun clearFixedDateTime() {
+ fixedDateTime = null
+ }
+}
diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImplTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImplTest.kt
new file mode 100644
index 0000000..f5e5b86
--- /dev/null
+++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/provider/AlarmTimeCalculatorImplTest.kt
@@ -0,0 +1,287 @@
+package com.timilehinaregbesola.mathalarm.provider
+
+import com.timilehinaregbesola.mathalarm.domain.model.Alarm
+import com.timilehinaregbesola.mathalarm.fake.DateTimeProviderFake
+import io.kotest.matchers.collections.shouldHaveSize
+import io.kotest.matchers.shouldBe
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.Instant
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toInstant
+import kotlinx.datetime.toLocalDateTime
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+
+class AlarmTimeCalculatorImplTest {
+
+ private lateinit var dateTimeProvider: DateTimeProviderFake
+ private lateinit var calculator: AlarmTimeCalculatorImpl
+
+ @BeforeTest
+ fun setup() {
+ dateTimeProvider = DateTimeProviderFake()
+ calculator = AlarmTimeCalculatorImpl(dateTimeProvider)
+ }
+
+ @Test
+ fun `non-repeating alarm in the future today should be scheduled for today`() {
+ // Wednesday, January 8, 2025 at 9:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 9, 0)
+
+ // Alarm set for 10:00 AM (1 hour in future)
+ val alarm = Alarm(hour = 10, minute = 0, repeatDays = "FFFFFFF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ times shouldHaveSize 1
+ val alarmDateTime = instantToLocalDateTime(times.first())
+ alarmDateTime.dayOfMonth shouldBe 8
+ alarmDateTime.hour shouldBe 10
+ alarmDateTime.minute shouldBe 0
+ }
+
+ @Test
+ fun `non-repeating alarm in the past today should be scheduled for tomorrow`() {
+ // Wednesday, January 8, 2025 at 11:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 11, 0)
+
+ // Alarm set for 10:00 AM (already passed)
+ val alarm = Alarm(hour = 10, minute = 0, repeatDays = "FFFFFFF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ times shouldHaveSize 1
+ val alarmDateTime = instantToLocalDateTime(times.first())
+ alarmDateTime.dayOfMonth shouldBe 9 // Tomorrow
+ alarmDateTime.hour shouldBe 10
+ alarmDateTime.minute shouldBe 0
+ }
+
+ @Test
+ fun `non-repeating alarm at midnight should handle day rollover`() {
+ // Saturday at 11:30 PM
+ dateTimeProvider.setFixedDateTime(2025, 1, 11, 23, 30)
+
+ // Alarm set for midnight (00:00) - should be tomorrow
+ val alarm = Alarm(hour = 0, minute = 0, repeatDays = "FFFFFFF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ times shouldHaveSize 1
+ val alarmDateTime = instantToLocalDateTime(times.first())
+ alarmDateTime.dayOfMonth shouldBe 12 // Sunday
+ alarmDateTime.hour shouldBe 0
+ alarmDateTime.minute shouldBe 0
+ }
+
+ @Test
+ fun `repeating alarm for weekdays only should return 5 times`() {
+ // Sunday, January 5, 2025 at 8:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 5, 8, 0)
+
+ // Alarm for weekdays: Mon-Fri (indices 1-5)
+ // repeatDays format: "FTTTTFF" (Sun=F, Mon=T, Tue=T, Wed=T, Thu=T, Fri=F, Sat=F)
+ // Wait, let me check the format - indices are SUN=0, MON=1, etc.
+ // So weekdays Mon-Fri = indices 1,2,3,4,5 = "FTTTTTF"
+ val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FTTTTTF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ times shouldHaveSize 5
+ }
+
+ @Test
+ fun `repeating alarm for weekends only should return 2 times`() {
+ // Monday, January 6, 2025 at 8:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 6, 8, 0)
+
+ // Alarm for weekends: Sat and Sun (indices 0 and 6)
+ // repeatDays = "TFFFFFT"
+ val alarm = Alarm(hour = 9, minute = 0, repeat = true, repeatDays = "TFFFFFT")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ times shouldHaveSize 2
+ }
+
+ @Test
+ fun `repeating alarm for all days should return 7 times`() {
+ // Monday, January 6, 2025 at 8:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 6, 8, 0)
+
+ val alarm = Alarm(hour = 6, minute = 30, repeat = true, repeatDays = "TTTTTTT")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ times shouldHaveSize 7
+ }
+
+ @Test
+ fun `repeating alarm for single day in future this week`() {
+ // Monday, January 6, 2025 at 8:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 6, 8, 0)
+
+ // Alarm only on Wednesday (index 3)
+ val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FFFTFFF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ times shouldHaveSize 1
+ val alarmDateTime = instantToLocalDateTime(times.first())
+ alarmDateTime.dayOfWeek shouldBe DayOfWeek.WEDNESDAY
+ alarmDateTime.dayOfMonth shouldBe 8 // Wednesday Jan 8
+ }
+
+ @Test
+ fun `repeating alarm for single day already passed this week should schedule next week`() {
+ // Wednesday, January 8, 2025 at 8:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 8, 0)
+
+ // Alarm only on Monday (index 1) - already passed this week
+ val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FTFFFFF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ times shouldHaveSize 1
+ val alarmDateTime = instantToLocalDateTime(times.first())
+ alarmDateTime.dayOfWeek shouldBe DayOfWeek.MONDAY
+ alarmDateTime.dayOfMonth shouldBe 13 // Next Monday
+ }
+
+ @Test
+ fun `repeating alarm for today but time already passed should schedule next week`() {
+ // Wednesday, January 8, 2025 at 10:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 10, 0)
+
+ // Alarm only on Wednesday (index 3) at 7:00 AM - already passed
+ val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FFFTFFF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ times shouldHaveSize 1
+ val alarmDateTime = instantToLocalDateTime(times.first())
+ alarmDateTime.dayOfWeek shouldBe DayOfWeek.WEDNESDAY
+ alarmDateTime.dayOfMonth shouldBe 15 // Next Wednesday
+ }
+
+ @Test
+ fun `repeating alarm for today in the future should schedule today`() {
+ // Wednesday, January 8, 2025 at 6:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 6, 0)
+
+ // Alarm only on Wednesday (index 3) at 7:00 AM - in the future
+ val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FFFTFFF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ times shouldHaveSize 1
+ val alarmDateTime = instantToLocalDateTime(times.first())
+ alarmDateTime.dayOfWeek shouldBe DayOfWeek.WEDNESDAY
+ alarmDateTime.day shouldBe 8 // Today
+ }
+
+ @Test
+ fun `saturday to sunday rollover for non-repeating alarm`() {
+ // Saturday at 11:00 PM
+ dateTimeProvider.setFixedDateTime(2025, 1, 11, 23, 0)
+
+ // Alarm set for 8:00 AM (already passed today, so tomorrow)
+ val alarm = Alarm(hour = 8, minute = 0, repeatDays = "FFFFFFF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ times shouldHaveSize 1
+ val alarmDateTime = instantToLocalDateTime(times.first())
+ alarmDateTime.dayOfWeek shouldBe DayOfWeek.SUNDAY
+ alarmDateTime.day shouldBe 12
+ }
+
+ @Test
+ fun `calculateNextAlarmTime returns soonest time`() {
+ // Monday, January 6, 2025 at 8:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 6, 8, 0)
+
+ // Alarm for Tuesday and Thursday
+ val alarm = Alarm(hour = 7, minute = 0, repeat = true, repeatDays = "FFTFTFF")
+
+ val nextTime = calculator.calculateNextAlarmTime(alarm)
+
+ // Should return Tuesday (soonest)
+ val nextDateTime = instantToLocalDateTime(nextTime!!)
+ nextDateTime.dayOfWeek shouldBe DayOfWeek.TUESDAY
+ }
+
+ @Test
+ fun `isInFuture returns true for future time`() {
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 10, 0)
+
+ val futureTime = LocalDateTime(2025, 1, 8, 11, 0)
+ .toInstant(TimeZone.currentSystemDefault())
+ .toEpochMilliseconds()
+
+ calculator.isInFuture(futureTime) shouldBe true
+ }
+
+ @Test
+ fun `isInFuture returns false for past time`() {
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 10, 0)
+
+ val pastTime = LocalDateTime(2025, 1, 8, 9, 0)
+ .toInstant(TimeZone.currentSystemDefault())
+ .toEpochMilliseconds()
+
+ calculator.isInFuture(pastTime) shouldBe false
+ }
+
+ @Test
+ fun `alarm at same minute should be treated as passed`() {
+ // Wednesday at 7:00:30 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 7, 0, 30)
+
+ // Alarm at 7:00 AM - same minute but seconds past
+ val alarm = Alarm(hour = 7, minute = 0, repeatDays = "FFFFFFF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ // Should schedule for tomorrow since the time has effectively passed
+ val alarmDateTime = instantToLocalDateTime(times.first())
+ alarmDateTime.day shouldBe 9
+ }
+
+ @Test
+ fun `alarm crossing month boundary`() {
+ // January 31st at 10:00 PM
+ dateTimeProvider.setFixedDateTime(2025, 1, 31, 22, 0)
+
+ // Alarm at 8:00 AM (already passed, should be February 1st)
+ val alarm = Alarm(hour = 8, minute = 0, repeatDays = "FFFFFFF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ val alarmDateTime = instantToLocalDateTime(times.first())
+ alarmDateTime.monthNumber shouldBe 2
+ alarmDateTime.dayOfMonth shouldBe 1
+ }
+
+ @Test
+ fun `alarm crossing year boundary`() {
+ // December 31st at 10:00 PM
+ dateTimeProvider.setFixedDateTime(2024, 12, 31, 22, 0)
+
+ // Alarm at 8:00 AM (already passed, should be January 1st next year)
+ val alarm = Alarm(hour = 8, minute = 0, repeatDays = "FFFFFFF")
+
+ val times = calculator.calculateAlarmTimes(alarm)
+
+ val alarmDateTime = instantToLocalDateTime(times.first())
+ alarmDateTime.year shouldBe 2025
+ alarmDateTime.monthNumber shouldBe 1
+ alarmDateTime.dayOfMonth shouldBe 1
+ }
+
+ private fun instantToLocalDateTime(epochMillis: Long): LocalDateTime {
+ return Instant.fromEpochMilliseconds(epochMillis)
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ }
+}
diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/BootPersistenceTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/BootPersistenceTest.kt
new file mode 100644
index 0000000..d192609
--- /dev/null
+++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/BootPersistenceTest.kt
@@ -0,0 +1,292 @@
+package com.timilehinaregbesola.mathalarm.usecases
+
+import com.timilehinaregbesola.mathalarm.data.AlarmRepository
+import com.timilehinaregbesola.mathalarm.domain.model.Alarm
+import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake
+import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake
+import com.timilehinaregbesola.mathalarm.fake.DateTimeProviderFake
+import com.timilehinaregbesola.mathalarm.provider.AlarmTimeCalculatorImpl
+import io.kotest.matchers.shouldBe
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.runTest
+import kotlinx.datetime.DayOfWeek
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
+import kotlin.test.BeforeTest
+import kotlin.test.Test
+import kotlin.time.Instant
+
+/**
+ * Integration-style tests that verify alarms are correctly rescheduled after
+ * boot/reboot scenarios. Uses real AlarmTimeCalculatorImpl with a fake time provider
+ * to simulate realistic scenarios.
+ */
+@ExperimentalCoroutinesApi
+class BootPersistenceTest {
+ private lateinit var dataSource: AlarmRepositoryFake
+ private lateinit var alarmRepository: AlarmRepository
+ private lateinit var alarmInteractor: AlarmInteractorFake
+ private lateinit var dateTimeProvider: DateTimeProviderFake
+ private lateinit var alarmTimeCalculator: AlarmTimeCalculatorImpl
+ private lateinit var scheduleNextAlarm: ScheduleNextAlarm
+ private lateinit var rescheduleFutureAlarms: RescheduleFutureAlarms
+ private lateinit var addAlarm: AddAlarm
+
+ @BeforeTest
+ fun setup() = runTest {
+ dataSource = AlarmRepositoryFake()
+ alarmRepository = AlarmRepository(dataSource)
+ alarmInteractor = AlarmInteractorFake()
+ dateTimeProvider = DateTimeProviderFake()
+ alarmTimeCalculator = AlarmTimeCalculatorImpl(dateTimeProvider)
+ scheduleNextAlarm = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator)
+ rescheduleFutureAlarms = RescheduleFutureAlarms(
+ alarmRepository,
+ alarmInteractor,
+ alarmTimeCalculator,
+ scheduleNextAlarm
+ )
+ addAlarm = AddAlarm(alarmRepository)
+
+ alarmRepository.clear()
+ alarmInteractor.clear()
+ }
+
+ @Test
+ fun `after reboot - future one-time alarms are rescheduled`() = runTest {
+ // Scenario: User has a one-time alarm set for 7:00 AM tomorrow
+ // Device reboots at 10:00 PM today
+ // After reboot, the alarm should be rescheduled
+
+ // Set current time to Wednesday 10:00 PM
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 22, 0)
+
+ // Alarm for 7:00 AM (tomorrow since time is 10 PM)
+ val alarm = Alarm(
+ alarmId = 1,
+ hour = 7,
+ minute = 0,
+ repeatDays = "FFFFFFF", // Non-repeating
+ isOn = true,
+ isSaved = true,
+ title = "Wake up"
+ )
+ addAlarm(alarm)
+
+ // Simulate boot - reschedule alarms
+ rescheduleFutureAlarms()
+
+ // Verify alarm is scheduled
+ alarmInteractor.isAlarmScheduled(alarm) shouldBe true
+
+ // Verify it's scheduled for tomorrow (Thursday)
+ val scheduledTime = alarmInteractor.getAlarmTimeMillis(alarm.alarmId)
+ val scheduledDateTime = instantToLocalDateTime(scheduledTime!!)
+ scheduledDateTime.day shouldBe 9 // Thursday
+ scheduledDateTime.hour shouldBe 7
+ scheduledDateTime.minute shouldBe 0
+ }
+
+ @Test
+ fun `after reboot - repeating weekday alarms are rescheduled`() = runTest {
+ // Set current time to Sunday 8:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 5, 8, 0)
+
+ // Weekday alarm at 6:30 AM (Mon-Fri)
+ val alarm = Alarm(
+ alarmId = 2,
+ hour = 6,
+ minute = 30,
+ repeat = true,
+ repeatDays = "FTTTTTF", // Mon-Fri (indices 1-5)
+ isOn = true,
+ isSaved = true,
+ title = "Workday alarm"
+ )
+ addAlarm(alarm)
+
+ // Simulate boot
+ rescheduleFutureAlarms()
+
+ // Alarm should be scheduled (the actual scheduling creates entries for all enabled days,
+ // but the fake only stores the last scheduled time. The important thing is that
+ // the alarm IS scheduled)
+ alarmInteractor.isAlarmScheduled(alarm) shouldBe true
+
+ // Verify scheduled time is at the correct hour/minute
+ val scheduledTime = alarmInteractor.getAlarmTimeMillis(alarm.alarmId)
+ val scheduledDateTime = instantToLocalDateTime(scheduledTime!!)
+ scheduledDateTime.hour shouldBe 6
+ scheduledDateTime.minute shouldBe 30
+ }
+
+ @Test
+ fun `after reboot - disabled alarms are NOT rescheduled`() = runTest {
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 8, 0)
+
+ val enabledAlarm = Alarm(
+ alarmId = 1,
+ hour = 7,
+ minute = 0,
+ isOn = true,
+ isSaved = true
+ )
+ val disabledAlarm = Alarm(
+ alarmId = 2,
+ hour = 8,
+ minute = 0,
+ isOn = false, // Disabled
+ isSaved = true
+ )
+ addAlarm(enabledAlarm)
+ addAlarm(disabledAlarm)
+
+ rescheduleFutureAlarms()
+
+ alarmInteractor.isAlarmScheduled(enabledAlarm) shouldBe true
+ alarmInteractor.isAlarmScheduled(disabledAlarm) shouldBe false
+ }
+
+ @Test
+ fun `after reboot - daily repeating alarm is rescheduled`() = runTest {
+ // Scenario: User had a daily 7:00 AM alarm
+ // Device reboots at 9:00 AM (after the alarm time passed)
+ // The alarm should be rescheduled for future days
+
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 9, 0) // Wednesday 9 AM
+
+ val repeatingAlarm = Alarm(
+ alarmId = 3,
+ hour = 7,
+ minute = 0,
+ repeat = true,
+ repeatDays = "TTTTTTT", // Daily
+ isOn = true,
+ isSaved = true
+ )
+ addAlarm(repeatingAlarm)
+
+ rescheduleFutureAlarms()
+
+ // Should be scheduled
+ alarmInteractor.isAlarmScheduled(repeatingAlarm) shouldBe true
+
+ // Verify time is correct (7:00 AM)
+ val scheduledTime = alarmInteractor.getAlarmTimeMillis(repeatingAlarm.alarmId)
+ val scheduledDateTime = instantToLocalDateTime(scheduledTime!!)
+ scheduledDateTime.hour shouldBe 7
+ scheduledDateTime.minute shouldBe 0
+ }
+
+ @Test
+ fun `after reboot with multiple alarms - all active alarms rescheduled correctly`() = runTest {
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 12, 0) // Wednesday noon
+
+ val morningAlarm = Alarm(alarmId = 1, hour = 6, minute = 0, isOn = true, isSaved = true)
+ val eveningAlarm = Alarm(alarmId = 2, hour = 18, minute = 0, isOn = true, isSaved = true)
+ val disabledAlarm = Alarm(alarmId = 3, hour = 8, minute = 0, isOn = false, isSaved = true)
+ val weekendAlarm = Alarm(
+ alarmId = 4,
+ hour = 10,
+ minute = 0,
+ repeat = true,
+ repeatDays = "TFFFFFT", // Sat & Sun
+ isOn = true,
+ isSaved = true
+ )
+
+ addAlarm(morningAlarm)
+ addAlarm(eveningAlarm)
+ addAlarm(disabledAlarm)
+ addAlarm(weekendAlarm)
+
+ rescheduleFutureAlarms()
+
+ // Morning alarm: 6 AM already passed today, should be tomorrow
+ alarmInteractor.isAlarmScheduled(morningAlarm) shouldBe true
+ val morningTime = alarmInteractor.getAlarmTimeMillis(morningAlarm.alarmId)
+ instantToLocalDateTime(morningTime!!).dayOfMonth shouldBe 9
+
+ // Evening alarm: 6 PM is in the future, should be today
+ alarmInteractor.isAlarmScheduled(eveningAlarm) shouldBe true
+ val eveningTime = alarmInteractor.getAlarmTimeMillis(eveningAlarm.alarmId)
+ instantToLocalDateTime(eveningTime!!).dayOfMonth shouldBe 8
+ instantToLocalDateTime(eveningTime).hour shouldBe 18
+
+ // Disabled alarm should not be scheduled
+ alarmInteractor.isAlarmScheduled(disabledAlarm) shouldBe false
+
+ // Weekend alarm should be scheduled for Saturday (Jan 11)
+ alarmInteractor.isAlarmScheduled(weekendAlarm) shouldBe true
+ val weekendTime = alarmInteractor.getAlarmTimeMillis(weekendAlarm.alarmId)
+ instantToLocalDateTime(weekendTime!!).dayOfWeek shouldBe DayOfWeek.SATURDAY
+ }
+
+ @Test
+ fun `package replaced event - alarms are rescheduled same as boot`() = runTest {
+ // This tests the same flow as ACTION_MY_PACKAGE_REPLACED
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 10, 0)
+
+ val alarm = Alarm(
+ alarmId = 1,
+ hour = 15,
+ minute = 30,
+ isOn = true,
+ isSaved = true
+ )
+ addAlarm(alarm)
+
+ // Simulate package replacement (app updated)
+ rescheduleFutureAlarms()
+
+ alarmInteractor.isAlarmScheduled(alarm) shouldBe true
+ val scheduledTime = alarmInteractor.getAlarmTimeMillis(alarm.alarmId)
+ instantToLocalDateTime(scheduledTime!!).hour shouldBe 15
+ instantToLocalDateTime(scheduledTime).minute shouldBe 30
+ }
+
+
+ @Test
+ fun `reboot at midnight - correctly handles day transition`() = runTest {
+ // Reboot exactly at midnight
+ dateTimeProvider.setFixedDateTime(2025, 1, 9, 0, 0)
+
+ // Alarm at 00:30 (30 minutes after midnight)
+ val alarm = Alarm(
+ alarmId = 1,
+ hour = 0,
+ minute = 30,
+ isOn = true,
+ isSaved = true
+ )
+ addAlarm(alarm)
+
+ rescheduleFutureAlarms()
+
+ alarmInteractor.isAlarmScheduled(alarm) shouldBe true
+ val scheduledTime = alarmInteractor.getAlarmTimeMillis(alarm.alarmId)
+ val scheduledDateTime = instantToLocalDateTime(scheduledTime!!)
+ scheduledDateTime.day shouldBe 9 // Same day (today)
+ scheduledDateTime.hour shouldBe 0
+ scheduledDateTime.minute shouldBe 30
+ }
+
+ @Test
+ fun `no saved alarms - reschedule completes without error`() = runTest {
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 10, 0)
+
+ // No alarms added
+
+ rescheduleFutureAlarms()
+
+ // Should complete without error, no alarms scheduled
+ alarmInteractor.getScheduledAlarms().size shouldBe 0
+ }
+
+
+ private fun instantToLocalDateTime(epochMillis: Long): LocalDateTime {
+ return Instant.fromEpochMilliseconds(epochMillis)
+ .toLocalDateTime(TimeZone.currentSystemDefault())
+ }
+}
diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarmsTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarmsTest.kt
index c32b5c2..8951808 100644
--- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarmsTest.kt
+++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/RescheduleFutureAlarmsTest.kt
@@ -4,6 +4,7 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake
import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake
+import com.timilehinaregbesola.mathalarm.fake.AlarmTimeCalculatorFake
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
@@ -19,10 +20,19 @@ class RescheduleFutureAlarmsTest {
private val alarmRepository = AlarmRepository(dataSource)
private val alarmInteractor = AlarmInteractorFake()
+
+ private val alarmTimeCalculator = AlarmTimeCalculatorFake()
private val addAlarmUseCase = AddAlarm(alarmRepository)
+
+ private val scheduleNextAlarmUseCase = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator)
- private val rescheduleFutureAlarmUseCase = RescheduleFutureAlarms(alarmRepository, alarmInteractor)
+ private val rescheduleFutureAlarmUseCase = RescheduleFutureAlarms(
+ alarmRepository,
+ alarmInteractor,
+ alarmTimeCalculator,
+ scheduleNextAlarmUseCase
+ )
@BeforeTest
fun setup() = runTest {
diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarmTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarmTest.kt
index ecf5305..705d33d 100644
--- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarmTest.kt
+++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleAlarmTest.kt
@@ -4,6 +4,7 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake
import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake
+import com.timilehinaregbesola.mathalarm.fake.AlarmTimeCalculatorFake
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
@@ -17,12 +18,14 @@ class ScheduleAlarmTest {
private val alarmRepository = AlarmRepository(dataSource)
private val alarmInteractor = AlarmInteractorFake()
+
+ private val alarmTimeCalculator = AlarmTimeCalculatorFake()
private val addAlarmUseCase = AddAlarm(alarmRepository)
private val findAlarmUseCase = FindAlarm(alarmRepository)
- private val scheduleAlarmUseCase = ScheduleAlarm(alarmRepository, alarmInteractor)
+ private val scheduleAlarmUseCase = ScheduleAlarm(alarmRepository, alarmInteractor, alarmTimeCalculator)
@BeforeTest
fun setup() = runTest {
diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarmTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarmTest.kt
index 6216cc6..2b76acf 100644
--- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarmTest.kt
+++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ScheduleNextAlarmTest.kt
@@ -4,6 +4,7 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake
import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake
+import com.timilehinaregbesola.mathalarm.fake.AlarmTimeCalculatorFake
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import kotlin.test.BeforeTest
@@ -19,10 +20,12 @@ class ScheduleNextAlarmTest {
private val alarmRepository = AlarmRepository(dataSource)
private val alarmInteractor = AlarmInteractorFake()
+
+ private val alarmTimeCalculator = AlarmTimeCalculatorFake()
private val addAlarmUseCase = AddAlarm(alarmRepository)
- private val scheduleNextAlarmUseCase = ScheduleNextAlarm(alarmInteractor)
+ private val scheduleNextAlarmUseCase = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator)
private val baseAlarm = Alarm(alarmId = 2, title = "new alarm", isOn = true)
diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ShowAlarmTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ShowAlarmTest.kt
index ff310be..aea7ffb 100644
--- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ShowAlarmTest.kt
+++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/ShowAlarmTest.kt
@@ -4,6 +4,7 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake
import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake
+import com.timilehinaregbesola.mathalarm.fake.AlarmTimeCalculatorFake
import com.timilehinaregbesola.mathalarm.fake.NotificationInteractorFake
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
@@ -19,12 +20,14 @@ class ShowAlarmTest {
private val alarmRepository = AlarmRepository(dataSource)
private val alarmInteractor = AlarmInteractorFake()
+
+ private val alarmTimeCalculator = AlarmTimeCalculatorFake()
private val notificationInteractor = NotificationInteractorFake()
private val addAlarmUseCase = AddAlarm(alarmRepository)
- private val scheduleNextAlarmUseCase = ScheduleNextAlarm(alarmInteractor)
+ private val scheduleNextAlarmUseCase = ScheduleNextAlarm(alarmInteractor, alarmTimeCalculator)
private val showAlarmUseCase = ShowAlarm(alarmRepository, notificationInteractor, scheduleNextAlarmUseCase)
diff --git a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt
index fa52e52..6796fc6 100644
--- a/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt
+++ b/core/src/commonTest/kotlin/com/timilehinaregbesola/mathalarm/usecases/SnoozeAlarmTest.kt
@@ -4,56 +4,115 @@ import com.timilehinaregbesola.mathalarm.data.AlarmRepository
import com.timilehinaregbesola.mathalarm.domain.model.Alarm
import com.timilehinaregbesola.mathalarm.fake.AlarmInteractorFake
import com.timilehinaregbesola.mathalarm.fake.AlarmRepositoryFake
+import com.timilehinaregbesola.mathalarm.fake.DateTimeProviderFake
import com.timilehinaregbesola.mathalarm.fake.NotificationInteractorFake
-import com.timilehinaregbesola.mathalarm.provider.DateTimeProviderImpl
+import io.kotest.matchers.shouldBe
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
+import kotlinx.datetime.LocalDateTime
+import kotlinx.datetime.TimeZone
+import kotlinx.datetime.toLocalDateTime
import kotlin.test.BeforeTest
import kotlin.test.Test
import kotlin.test.assertFailsWith
+import kotlin.test.assertFalse
+import kotlin.time.Instant
@ExperimentalCoroutinesApi
class SnoozeAlarmTest {
- private val dataSource = AlarmRepositoryFake()
-
- private val alarmRepository = AlarmRepository(dataSource)
-
- private val alarmInteractor = AlarmInteractorFake()
-
- private val notificationInteractor = NotificationInteractorFake()
-
- private val calendarProvider = DateTimeProviderImpl()
-
- private val addAlarmUseCase = AddAlarm(alarmRepository)
-
- private val snoozeAlarmUseCase = SnoozeAlarm(
- calendarProvider, notificationInteractor, alarmInteractor, alarmRepository
- )
+ private lateinit var dataSource: AlarmRepositoryFake
+ private lateinit var alarmRepository: AlarmRepository
+ private lateinit var alarmInteractor: AlarmInteractorFake
+ private lateinit var notificationInteractor: NotificationInteractorFake
+ private lateinit var dateTimeProvider: DateTimeProviderFake
+ private lateinit var addAlarmUseCase: AddAlarm
+ private lateinit var snoozeAlarmUseCase: SnoozeAlarm
private val baseAlarm = Alarm(alarmId = 216L, title = "snooze me", isOn = true)
+
@BeforeTest
fun setup() = runTest {
+ dataSource = AlarmRepositoryFake()
+ alarmRepository = AlarmRepository(dataSource)
+ alarmInteractor = AlarmInteractorFake()
+ notificationInteractor = NotificationInteractorFake()
+ dateTimeProvider = DateTimeProviderFake()
+ addAlarmUseCase = AddAlarm(alarmRepository)
+ snoozeAlarmUseCase = SnoozeAlarm(
+ dateTimeProvider, notificationInteractor, alarmInteractor, alarmRepository
+ )
+
alarmRepository.clear()
alarmInteractor.clear()
notificationInteractor.clear()
}
- // Test fails sometimes
- /* @Test
- fun `test if alarm is snoozed`() = runTest {
+ @Test
+ fun `test if alarm is snoozed for correct time`() = runTest {
+ // Set fixed time: 7:00 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 7, 0)
addAlarmUseCase(baseAlarm)
val snoozeMinutes = 5
snoozeAlarmUseCase(baseAlarm.alarmId, snoozeMinutes)
- val calendarAssert = Calendar.getInstance().apply {
- time = calendarProvider.getCurrentCalendar().time
- add(Calendar.MINUTE, snoozeMinutes)
- }
+ // Should be scheduled for 7:05 AM
+ val scheduledTime = alarmInteractor.getAlarmTimeMillis(baseAlarm.alarmId)
+ val scheduledDateTime = instantToLocalDateTime(scheduledTime!!)
+
+ scheduledDateTime.hour shouldBe 7
+ scheduledDateTime.minute shouldBe 5
+ }
+
+ @Test
+ fun `test if snooze correctly handles hour rollover`() = runTest {
+ // Set fixed time: 7:58 AM
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 7, 58)
+ addAlarmUseCase(baseAlarm)
+ val snoozeMinutes = 5
+
+ snoozeAlarmUseCase(baseAlarm.alarmId, snoozeMinutes)
+
+ // Should be scheduled for 8:03 AM
+ val scheduledTime = alarmInteractor.getAlarmTimeMillis(baseAlarm.alarmId)
+ val scheduledDateTime = instantToLocalDateTime(scheduledTime!!)
+
+ scheduledDateTime.hour shouldBe 8
+ scheduledDateTime.minute shouldBe 3
+ }
+
+ @Test
+ fun `test if snooze correctly handles day rollover`() = runTest {
+ // Set fixed time: 11:58 PM
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 23, 58)
+ addAlarmUseCase(baseAlarm)
+ val snoozeMinutes = 5
+
+ snoozeAlarmUseCase(baseAlarm.alarmId, snoozeMinutes)
+
+ // Should be scheduled for 12:03 AM next day
+ val scheduledTime = alarmInteractor.getAlarmTimeMillis(baseAlarm.alarmId)
+ val scheduledDateTime = instantToLocalDateTime(scheduledTime!!)
+
+ scheduledDateTime.day shouldBe 9
+ scheduledDateTime.hour shouldBe 0
+ scheduledDateTime.minute shouldBe 3
+ }
+
+ @Test
+ fun `test if snooze with custom duration works`() = runTest {
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 9, 0)
+ addAlarmUseCase(baseAlarm)
+ val snoozeMinutes = 15
+
+ snoozeAlarmUseCase(baseAlarm.alarmId, snoozeMinutes)
- val result = alarmInteractor.getAlarmTime(baseAlarm.alarmId)
- Assert.assertEquals(calendarAssert.time.time, result?.time?.time)
- }*/
+ val scheduledTime = alarmInteractor.getAlarmTimeMillis(baseAlarm.alarmId)
+ val scheduledDateTime = instantToLocalDateTime(scheduledTime!!)
+
+ scheduledDateTime.hour shouldBe 9
+ scheduledDateTime.minute shouldBe 15
+ }
@Test
fun `test if error is shown when snoozing with negative number`() = runTest {
@@ -63,11 +122,36 @@ class SnoozeAlarmTest {
}
@Test
- fun `test if notification is dismissed`() = runTest {
+ fun `test if error is shown when snoozing with zero minutes`() = runTest {
+ assertFailsWith {
+ snoozeAlarmUseCase(baseAlarm.alarmId, 0)
+ }
+ }
+
+ @Test
+ fun `test if notification is dismissed after snooze`() = runTest {
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 7, 0)
+ addAlarmUseCase(baseAlarm)
notificationInteractor.show(baseAlarm)
snoozeAlarmUseCase(baseAlarm.alarmId, 5)
- notificationInteractor.isNotificationShown(baseAlarm.alarmId)
+ assertFalse(notificationInteractor.isNotificationShown(baseAlarm.alarmId))
+ }
+
+ @Test
+ fun `test snooze with non-existent alarm does nothing`() = runTest {
+ dateTimeProvider.setFixedDateTime(2025, 1, 8, 7, 0)
+ // Don't add alarm to repository
+
+ snoozeAlarmUseCase(999L, 5)
+
+ // Should not throw, but alarm should not be scheduled
+ alarmInteractor.getAlarmTimeMillis(999L) shouldBe null
+ }
+
+ private fun instantToLocalDateTime(epochMillis: Long): LocalDateTime {
+ return Instant.fromEpochMilliseconds(epochMillis)
+ .toLocalDateTime(TimeZone.currentSystemDefault())
}
}
diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml
index 146e580..af4e586 100644
--- a/gradle/libs.versions.toml
+++ b/gradle/libs.versions.toml
@@ -37,6 +37,7 @@ test_espresso_core = "3.7.0"
test_junit = "4.13.2"
test_junit_ext = "1.3.0"
test_mockk = "1.14.7"
+test_robolectric = "4.16"
test_turbine = "1.2.1"
test_kotest = "6.0.7"
timber = "4.7.1"
@@ -89,6 +90,7 @@ lyricist = { module = "cafe.adriel.lyricist:lyricist", version.ref = "lyricist"
lyricist-processor = { module = "cafe.adriel.lyricist:lyricist-processor", version.ref = "lyricist" }
material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "material3" }
mockk = { module = "io.mockk:mockk", version.ref = "test_mockk" }
+robolectric = { module = "org.robolectric:robolectric", version.ref = "test_robolectric" }
turbine = { module = "app.cash.turbine:turbine", version.ref = "test_turbine" }
kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "test_kotest" }
multiplatform-settings-no-arg = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "multiplatformSettingsNoArg" }
diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj
index 8865cee..3448f42 100644
--- a/iosApp/iosApp.xcodeproj/project.pbxproj
+++ b/iosApp/iosApp.xcodeproj/project.pbxproj
@@ -11,6 +11,7 @@
058557D9273AAEEB004C7B11 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 058557D8273AAEEB004C7B11 /* ContentView.swift */; };
2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2152FB032600AC8F00CF470E /* iOSApp.swift */; };
ALARMKIT01273AAA24004C7B11 /* AlarmKitWrapper.swift in Sources */ = {isa = PBXBuildFile; fileRef = ALARMKIT02273AAA24004C7B11 /* AlarmKitWrapper.swift */; };
+ ALARMAUDIO01273AAA24004C7B /* AlarmAudioController.swift in Sources */ = {isa = PBXBuildFile; fileRef = ALARMAUDIO02273AAA24004C7B /* AlarmAudioController.swift */; };
/* End PBXBuildFile section */
/* Begin PBXFileReference section */
@@ -20,6 +21,7 @@
7555FF7B242A565900829871 /* MathAlarm.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = MathAlarm.app; sourceTree = BUILT_PRODUCTS_DIR; };
7555FF8C242A565B00829871 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; };
ALARMKIT02273AAA24004C7B11 /* AlarmKitWrapper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmKitWrapper.swift; sourceTree = ""; };
+ ALARMAUDIO02273AAA24004C7B /* AlarmAudioController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlarmAudioController.swift; sourceTree = ""; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -36,6 +38,7 @@
058557D7273AAEEB004C7B11 /* iosApp */ = {
isa = PBXGroup;
children = (
+ ALARMAUDIO02273AAA24004C7B /* AlarmAudioController.swift */,
ALARMKIT02273AAA24004C7B11 /* AlarmKitWrapper.swift */,
058557BA273AAA24004C7B11 /* Assets.xcassets */,
058557D8273AAEEB004C7B11 /* ContentView.swift */,
@@ -160,6 +163,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ ALARMAUDIO01273AAA24004C7B /* AlarmAudioController.swift in Sources */,
ALARMKIT01273AAA24004C7B11 /* AlarmKitWrapper.swift in Sources */,
2152FB042600AC8F00CF470E /* iOSApp.swift in Sources */,
058557D9273AAEEB004C7B11 /* ContentView.swift in Sources */,
diff --git a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/timilehin.xcuserdatad/UserInterfaceState.xcuserstate b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/timilehin.xcuserdatad/UserInterfaceState.xcuserstate
index 1d33eae..7e5c8b0 100644
Binary files a/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/timilehin.xcuserdatad/UserInterfaceState.xcuserstate and b/iosApp/iosApp.xcodeproj/project.xcworkspace/xcuserdata/timilehin.xcuserdatad/UserInterfaceState.xcuserstate differ
diff --git a/iosApp/iosApp.xcodeproj/xcuserdata/timilehin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist b/iosApp/iosApp.xcodeproj/xcuserdata/timilehin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
index b813454..8a11e74 100644
--- a/iosApp/iosApp.xcodeproj/xcuserdata/timilehin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
+++ b/iosApp/iosApp.xcodeproj/xcuserdata/timilehin.xcuserdatad/xcdebugger/Breakpoints_v2.xcbkptlist
@@ -8,7 +8,7 @@
BreakpointExtensionID = "Xcode.Breakpoint.FileBreakpoint">
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/iosApp/iosApp/AlarmAudioController.swift b/iosApp/iosApp/AlarmAudioController.swift
new file mode 100644
index 0000000..2d9a637
--- /dev/null
+++ b/iosApp/iosApp/AlarmAudioController.swift
@@ -0,0 +1,205 @@
+import Foundation
+import AVFoundation
+import AudioToolbox
+
+/// Controller for playing alarm sounds and haptic feedback
+/// Used when notifications arrive (both foreground and when user taps notification)
+class AlarmAudioController: NSObject, AVAudioPlayerDelegate {
+
+ static let shared = AlarmAudioController()
+
+ private var audioPlayer: AVAudioPlayer?
+ private var vibrationTimer: Timer?
+ private var soundTimer: Timer?
+ private var isPlaying = false
+
+ /// Default alarm sound name (bundled with app)
+ static let defaultSoundName = "alarm_default"
+
+ // System sound IDs that are known to work
+ // See: https://github.com/TUNER88/iOSSystemSoundsLibrary
+ private let alarmSoundID: SystemSoundID = 1005 // SMS Alert - loud and noticeable
+ private let alternateSoundID: SystemSoundID = 1007 // SMS Tri-tone
+
+ private override init() {
+ super.init()
+ print("AlarmAudioController: Initialized")
+ }
+
+ // MARK: - Audio Session Configuration
+
+ private func configureAudioSession() {
+ do {
+ let session = AVAudioSession.sharedInstance()
+ // Use playback category to play even when device is silent/locked
+ // Don't mix with others - alarm should be prominent
+ try session.setCategory(.playback, mode: .default, options: [])
+ try session.setActive(true, options: .notifyOthersOnDeactivation)
+ print("AlarmAudioController: Audio session configured for playback")
+ } catch {
+ print("AlarmAudioController: Failed to configure audio session: \(error)")
+ }
+ }
+
+ // MARK: - Public Interface
+
+ /// Start playing alarm sound
+ /// - Parameters:
+ /// - soundName: Name of the sound file (without extension), empty for default
+ /// - vibrate: Whether to enable vibration
+ func startAlarm(soundName: String, vibrate: Bool) {
+ // Allow restarting if called again
+ if isPlaying {
+ print("AlarmAudioController: Already playing, restarting...")
+ stopAlarm()
+ }
+
+ print("AlarmAudioController: 🔔 Starting alarm - sound='\(soundName)', vibrate=\(vibrate)")
+ isPlaying = true
+
+ // Configure audio session for playback
+ configureAudioSession()
+
+ // Try to play custom/bundled sound first
+ if !playBundledSound(named: soundName) {
+ // Fall back to system sounds
+ print("AlarmAudioController: No bundled sound, using system alert sounds")
+ startSystemSoundLoop()
+ }
+
+ // Start vibration if enabled
+ if vibrate {
+ startVibration()
+ }
+ }
+
+ /// Stop the alarm sound and vibration
+ func stopAlarm() {
+ print("AlarmAudioController: 🔕 Stopping alarm")
+ isPlaying = false
+
+ audioPlayer?.stop()
+ audioPlayer = nil
+
+ soundTimer?.invalidate()
+ soundTimer = nil
+
+ stopVibration()
+
+ // Deactivate audio session
+ do {
+ try AVAudioSession.sharedInstance().setActive(false, options: .notifyOthersOnDeactivation)
+ } catch {
+ print("AlarmAudioController: Failed to deactivate audio session: \(error)")
+ }
+ }
+
+ /// Snooze the alarm - stops current sound, will be rescheduled
+ func snoozeAlarm(minutes: Int32) {
+ print("AlarmAudioController: Snoozing alarm for \(minutes) minutes")
+ stopAlarm()
+ }
+
+ // MARK: - Bundled Sound Playback
+
+ private func playBundledSound(named soundName: String) -> Bool {
+ let effectiveName = soundName.isEmpty ? AlarmAudioController.defaultSoundName : soundName
+
+ // Try to find the sound file in bundle with various extensions
+ let extensions = ["caf", "aiff", "wav", "mp3", "m4a", "aac"]
+ var soundURL: URL? = nil
+
+ for ext in extensions {
+ if let url = Bundle.main.url(forResource: effectiveName, withExtension: ext) {
+ soundURL = url
+ print("AlarmAudioController: Found bundled sound: \(effectiveName).\(ext)")
+ break
+ }
+ }
+
+ // Also try without extension (file might have extension in name)
+ if soundURL == nil, let url = Bundle.main.url(forResource: effectiveName, withExtension: nil) {
+ soundURL = url
+ print("AlarmAudioController: Found bundled sound (no ext): \(effectiveName)")
+ }
+
+ guard let url = soundURL else {
+ print("AlarmAudioController: No bundled sound found for '\(effectiveName)'")
+ return false
+ }
+
+ do {
+ audioPlayer = try AVAudioPlayer(contentsOf: url)
+ audioPlayer?.delegate = self
+ audioPlayer?.numberOfLoops = -1 // Loop indefinitely
+ audioPlayer?.volume = 1.0
+ audioPlayer?.prepareToPlay()
+
+ if audioPlayer?.play() == true {
+ print("AlarmAudioController: ▶️ Playing bundled sound: \(url.lastPathComponent)")
+ return true
+ } else {
+ print("AlarmAudioController: Failed to start playback")
+ return false
+ }
+ } catch {
+ print("AlarmAudioController: Failed to play bundled sound: \(error)")
+ return false
+ }
+ }
+
+ // MARK: - System Sound Playback (Fallback)
+
+ private func startSystemSoundLoop() {
+ print("AlarmAudioController: Starting system sound loop")
+ playSystemSound()
+
+ // Repeat every 1.5 seconds for alarm effect
+ soundTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { [weak self] _ in
+ guard let self = self, self.isPlaying else { return }
+ self.playSystemSound()
+ }
+ }
+
+ private func playSystemSound() {
+ guard isPlaying else { return }
+
+ // Play alert sound (respects ringer volume, plays through speaker)
+ AudioServicesPlayAlertSound(alarmSoundID)
+ print("AlarmAudioController: 🔊 Playing system sound \(alarmSoundID)")
+ }
+
+ // MARK: - Vibration
+
+ private func startVibration() {
+ print("AlarmAudioController: Starting vibration")
+
+ // Vibrate immediately
+ AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
+
+ // Set up repeating vibration
+ vibrationTimer = Timer.scheduledTimer(withTimeInterval: 1.0, repeats: true) { [weak self] _ in
+ guard self?.isPlaying == true else { return }
+ AudioServicesPlaySystemSound(kSystemSoundID_Vibrate)
+ }
+ }
+
+ private func stopVibration() {
+ vibrationTimer?.invalidate()
+ vibrationTimer = nil
+ }
+
+ // MARK: - AVAudioPlayerDelegate
+
+ func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
+ print("AlarmAudioController: Audio player finished (success: \(flag))")
+ }
+
+ func audioPlayerDecodeErrorDidOccur(_ player: AVAudioPlayer, error: Error?) {
+ print("AlarmAudioController: Audio decode error: \(error?.localizedDescription ?? "unknown")")
+ // Fall back to system sounds
+ if isPlaying {
+ startSystemSoundLoop()
+ }
+ }
+}
diff --git a/iosApp/iosApp/AlarmKitWrapper.swift b/iosApp/iosApp/AlarmKitWrapper.swift
index 2b762ef..1e1ec71 100644
--- a/iosApp/iosApp/AlarmKitWrapper.swift
+++ b/iosApp/iosApp/AlarmKitWrapper.swift
@@ -557,16 +557,63 @@ class AlarmKitWrapperImpl: NSObject {
let stopIntent = StopAlarmIntent(alarmUUID: alarmUUID)
let snoozeIntent: SnoozeAlarmIntent? = snoozeMinutes > 0 ? SnoozeAlarmIntent(alarmUUID: alarmUUID) : nil
- // Create alarm configuration with intents
- // Note: .default may not play sounds in some cases, .named("") plays default sound
- let configuration = MathAlarmConfiguration(
- countdownDuration: countdownDuration,
- schedule: schedule,
- attributes: attributes,
- stopIntent: stopIntent,
- secondaryIntent: snoozeIntent,
- sound: .named("")
- )
+ // Create alarm configuration
+ // Note on AlarmKit sounds (from https://levelup.gitconnected.com/swiftui-alarm-app-copycat-with-alarmkit-wwdc-2025-part-2-5c3cb2194c54):
+ // - .default does NOT play any sound (AlarmKit bug/quirk)
+ // - .named("") plays the default system alarm sound
+ // - .named("customSound") plays a custom sound from bundle
+ let hasCustomSound = !soundName.isEmpty && Bundle.main.url(forResource: soundName, withExtension: "caf") != nil
+
+ let configuration: MathAlarmConfiguration
+ if countdownDuration == nil {
+ // Traditional alarm without countdown - use .alarm() factory
+ print("AlarmKitWrapper: Creating traditional alarm configuration")
+ if hasCustomSound {
+ print("AlarmKitWrapper: Using custom sound: \(soundName)")
+ configuration = MathAlarmConfiguration.alarm(
+ schedule: schedule,
+ attributes: attributes,
+ stopIntent: stopIntent,
+ secondaryIntent: snoozeIntent,
+ sound: .named(soundName)
+ )
+ } else {
+ // Using .named("") to get default system alarm sound
+ print("AlarmKitWrapper: Using default system alarm sound via .named(\"\")")
+ configuration = MathAlarmConfiguration.alarm(
+ schedule: schedule,
+ attributes: attributes,
+ stopIntent: stopIntent,
+ secondaryIntent: snoozeIntent,
+ sound: .named("")
+ )
+ }
+ } else {
+ // Alarm with countdown/snooze - use generic initializer
+ print("AlarmKitWrapper: Creating alarm with countdown configuration")
+ if hasCustomSound {
+ print("AlarmKitWrapper: Using custom sound: \(soundName)")
+ configuration = MathAlarmConfiguration(
+ countdownDuration: countdownDuration,
+ schedule: schedule,
+ attributes: attributes,
+ stopIntent: stopIntent,
+ secondaryIntent: snoozeIntent,
+ sound: .named(soundName)
+ )
+ } else {
+ // Using .named("") to get default system alarm sound
+ print("AlarmKitWrapper: Using default system alarm sound via .named(\"\")")
+ configuration = MathAlarmConfiguration(
+ countdownDuration: countdownDuration,
+ schedule: schedule,
+ attributes: attributes,
+ stopIntent: stopIntent,
+ secondaryIntent: snoozeIntent,
+ sound: .named("")
+ )
+ }
+ }
print("AlarmKitWrapper: Created configuration with intents, about to schedule...")
// Schedule the alarm with id and configuration
diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift
index db5c5ec..c3c0eef 100644
--- a/iosApp/iosApp/iOSApp.swift
+++ b/iosApp/iosApp/iOSApp.swift
@@ -9,9 +9,23 @@ struct iOSApp: App {
@UIApplicationDelegateAdaptor(AppDelegate.self) var appDelegate
@Environment(\.scenePhase) private var scenePhase
+ init() {
+ print("iOSApp.init: Initializing Koin early (before UI)")
+ MainViewControllerKt.doInitKoin()
+
+ // Prewarm database in background so it's ready when UI needs it
+ MainViewControllerKt.prewarmDatabaseInBackground()
+ }
+
var body: some Scene {
WindowGroup {
- ContentView().ignoresSafeArea()
+ ContentView()
+ .ignoresSafeArea()
+ .onAppear {
+ // Request notification permissions after UI is shown
+ // This is deferred to not block startup
+ MainViewControllerKt.requestNotificationPermissionsDeferred()
+ }
}
.onChange(of: scenePhase) { newPhase in
if newPhase == .active {
@@ -186,6 +200,9 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
// Handle alarm notification - start audio and set deeplink
private func handleAlarmNotification(userInfo: [AnyHashable: Any]) {
+ print("iOSApp: 🔔 handleAlarmNotification called")
+ print("iOSApp: userInfo keys = \(userInfo.keys)")
+
// Extract alarm data
let alarmId = (userInfo["alarmId"] as? NSNumber)?.int64Value ?? 0
let hour = (userInfo["hour"] as? NSNumber)?.intValue ?? 0
@@ -196,9 +213,14 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
let title = (userInfo["title"] as? String) ?? ""
let alarmTone = (userInfo["alarmTone"] as? String) ?? ""
+ print("iOSApp: Extracted - alarmId=\(alarmId), tone='\(alarmTone)', vibrate=\(vibrate)")
+
// Start alarm audio immediately when notification is tapped
- print("iOSApp: Starting alarm audio - tone=\(alarmTone), vibrate=\(vibrate)")
- AlarmAudioController.shared.startAlarm(soundName: alarmTone, vibrate: vibrate)
+ // Run on main thread to ensure audio session works correctly
+ print("iOSApp: 🔊 Starting AlarmAudioController...")
+ DispatchQueue.main.async {
+ AlarmAudioController.shared.startAlarm(soundName: alarmTone, vibrate: vibrate)
+ }
// Create JSON string for the alarm - matching AlarmEntity format
let vibrateStr = vibrate ? "true" : "false"
@@ -216,18 +238,26 @@ class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCenterDele
withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
let userInfo = notification.request.content.userInfo
- print("iOSApp: Notification arrived in foreground")
+ print("iOSApp: 🔔 Notification arrived in foreground!")
+ print("iOSApp: userInfo = \(userInfo)")
// Start alarm audio immediately when notification arrives in foreground
let alarmTone = (userInfo["alarmTone"] as? String) ?? ""
let vibrate = (userInfo["vibrate"] as? NSNumber)?.boolValue ?? false
- AlarmAudioController.shared.startAlarm(soundName: alarmTone, vibrate: vibrate)
+ print("iOSApp: Starting AlarmAudioController - tone='\(alarmTone)', vibrate=\(vibrate)")
+
+ // Run on main thread to ensure audio session works
+ DispatchQueue.main.async {
+ AlarmAudioController.shared.startAlarm(soundName: alarmTone, vibrate: vibrate)
+ }
// Also set the deeplink so the UI navigates to MathScreen
handleAlarmNotification(userInfo: userInfo)
- // Show banner and badge (sound is handled by our audio player)
- completionHandler([.banner, .badge])
+ // Show banner, badge, AND sound (sound as backup in case AlarmAudioController fails)
+ // Our AlarmAudioController provides the full looping alarm, but notification sound
+ // gives us at least something if that fails
+ completionHandler([.banner, .badge, .sound])
}
}