From 85cc6df5270698380d3c81d5ea9ae2b2104c4d59 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 11 Aug 2026 10:41:40 +0200 Subject: [PATCH 01/31] chore: bump version to 1.0.0-alpha02 --- androidApp/build.gradle.kts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index a4550b8..c751ee4 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -12,7 +12,11 @@ plugins { // Apply Google Services only when the Firebase config is present: google-services.json is not // tracked in git (see google-services.example.json), so builds must work without it. if (file("google-services.json").exists()) { - apply(plugin = libs.plugins.googleServices.get().pluginId) + apply( + plugin = libs.plugins.googleServices + .get() + .pluginId, + ) } kotlin { @@ -50,7 +54,7 @@ android { .get() .toInt() versionCode = 1 - versionName = "1.0.0-alpha01" + versionName = "1.0.0-alpha02" // Crash-reporting DSN, kept out of the public repo: read from local.properties // (glitchtip.dsn=...). Empty when absent, which disables the Sentry SDK. From a06081e2f6236d0072c18e211cfa5b16e3c04b66 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 11 Aug 2026 10:42:13 +0200 Subject: [PATCH 02/31] chore: update iOS app name --- iosApp/iosApp.xcodeproj/project.pbxproj | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index ce49e3f..99b29ef 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXFileReference section */ - 9DA43AABF87DF46F5E794E72 /* Gwenedeg.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gwenedeg.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 9DA43AABF87DF46F5E794E72 /* Chom Bev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Chom Bev.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ /* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ @@ -50,7 +50,7 @@ 47B0C8770C82BEADFE13EF77 /* Products */ = { isa = PBXGroup; children = ( - 9DA43AABF87DF46F5E794E72 /* Gwenedeg.app */, + 9DA43AABF87DF46F5E794E72 /* Chom Bev.app */, ); name = Products; sourceTree = ""; @@ -87,7 +87,7 @@ packageProductDependencies = ( ); productName = iosApp; - productReference = 9DA43AABF87DF46F5E794E72 /* Gwenedeg.app */; + productReference = 9DA43AABF87DF46F5E794E72 /* Chom Bev.app */; productType = "com.apple.product-type.application"; }; /* End PBXNativeTarget section */ From 34cf1e0bdbd57a46313dd2bcd7bffdf6c27ca3d1 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 11 Aug 2026 10:42:36 +0200 Subject: [PATCH 03/31] chore: update AGP to 9.3.1 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 30523d7..86bcc14 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,5 @@ [versions] -agp = "9.2.1" +agp = "9.3.1" androidCompileSdk = "37" androidMinSdk = "24" androidTargetSdk = "37" From 06714cd0c7f57cd06b42002de17a147f5536086b Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 11 Aug 2026 10:42:51 +0200 Subject: [PATCH 04/31] chore: remove redundant Koin core dependency --- shared/build.gradle.kts | 1 - 1 file changed, 1 deletion(-) diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index ad48720..7d6919f 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -63,7 +63,6 @@ kotlin { // DI implementation(project.dependencies.platform(libs.koin.bom)) - implementation(libs.koin.core) implementation(libs.koin.core.viewmodel) implementation(libs.koin.compose) implementation(libs.koin.compose.viewmodel) From 9abb1b9e4b2524f1cacda63fc5f63d9ce5bdd0c6 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 11 Aug 2026 10:43:00 +0200 Subject: [PATCH 05/31] chore: update Gradle toolchain vendor to AZUL --- gradle/gradle-daemon-jvm.properties | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/gradle/gradle-daemon-jvm.properties b/gradle/gradle-daemon-jvm.properties index 050d142..75b3d71 100644 --- a/gradle/gradle-daemon-jvm.properties +++ b/gradle/gradle-daemon-jvm.properties @@ -8,5 +8,5 @@ toolchainUrl.MAC_OS.X86_64=https\://api.foojay.io/disco/v3.0/ids/109553caae279a6 toolchainUrl.UNIX.AARCH64=https\://api.foojay.io/disco/v3.0/ids/b62178ff26b34365c61e54dea2180e32/redirect toolchainUrl.UNIX.X86_64=https\://api.foojay.io/disco/v3.0/ids/f2dede3f3c566068b401dc14a9646d39/redirect toolchainUrl.WINDOWS.X86_64=https\://api.foojay.io/disco/v3.0/ids/9eb5d45802b65696ed3ce0f14bb1e4ff/redirect -toolchainVendor=AMAZON +toolchainVendor=AZUL toolchainVersion=21 \ No newline at end of file From 1f19bd7e7d196723be2159a40cf1e04edc9a1f21 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 11 Aug 2026 10:48:31 +0200 Subject: [PATCH 06/31] chore: update library versions in libs.versions.toml --- gradle/libs.versions.toml | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 86bcc14..a06a169 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,26 +4,26 @@ androidCompileSdk = "37" androidMinSdk = "24" androidTargetSdk = "37" androidxActivity = "1.13.0" -androidxLifecycle = "2.11.0-rc01" +androidxLifecycle = "2.11.0" androidxWebkit = "1.16.0" -media3 = "1.10.1" +media3 = "1.11.0" navigation3 = "1.1.1" composeMultiplatform = "1.11.1" composeIconsExtended = "1.7.3" -glitchtips = "8.47.0" +glitchtips = "8.52.0" googleServices = "4.5.0" -kotlin = "2.4.0" -kotlinWrappers = "2026.7.0" +kotlin = "2.4.10" +kotlinWrappers = "2026.8.0" kotlinxBrowser = "0.5.0" kotlinxCoroutines = "1.11.0" -kotlinSerialization = "2.4.0" +kotlinSerialization = "2.4.10" kotlinxSerializationJson = "1.11.0" material3 = "1.11.0-alpha07" koin = "4.2.2" kermit = "2.1.0" vorbisspi = "1.0.3.3" datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore -okio = "3.17.0" +okio = "3.18.1" [libraries] # Gradle plugin artifacts, used by build-logic convention plugins only From 87d4f3ce5d8a4c5a16046a85368f415bd7466750 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Tue, 11 Aug 2026 13:08:38 +0200 Subject: [PATCH 07/31] chore: remove unused compose-multiplatform drawable file --- .../drawable/compose-multiplatform.xml | 44 ------------------- 1 file changed, 44 deletions(-) delete mode 100644 data/resources/src/commonMain/composeResources/drawable/compose-multiplatform.xml diff --git a/data/resources/src/commonMain/composeResources/drawable/compose-multiplatform.xml b/data/resources/src/commonMain/composeResources/drawable/compose-multiplatform.xml deleted file mode 100644 index 1ffc948..0000000 --- a/data/resources/src/commonMain/composeResources/drawable/compose-multiplatform.xml +++ /dev/null @@ -1,44 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file From 48a78e574338f3bbbfb74ee709441aed57b67db3 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Wed, 12 Aug 2026 15:47:17 +0200 Subject: [PATCH 08/31] feature: integrate Sentry SDK for crash reporting on Android and iOS --- .gitignore | 3 + .sentryclirc.example | 17 ++ README.md | 12 ++ androidApp/README.md | 5 - androidApp/build.gradle.kts | 16 -- androidApp/src/main/AndroidManifest.xml | 5 - .../kotlin/bzh/ajaury/chombev/ChomBevApp.kt | 5 +- docs/sentry-glitchtip.md | 164 ++++++++++++++++++ gradle/libs.versions.toml | 22 +-- iosApp/iosApp.xcodeproj/project.pbxproj | 32 +++- iosApp/iosApp/AppDelegate.swift | 18 ++ iosApp/iosApp/iOSApp.swift | 4 + shared/build.gradle.kts | 2 + .../kotlin/bzh/ajaury/chombev/SentryHelper.kt | 20 +++ 14 files changed, 284 insertions(+), 41 deletions(-) create mode 100644 .sentryclirc.example create mode 100644 docs/sentry-glitchtip.md create mode 100644 iosApp/iosApp/AppDelegate.swift create mode 100644 shared/src/commonMain/kotlin/bzh/ajaury/chombev/SentryHelper.kt diff --git a/.gitignore b/.gitignore index 2f80f28..fbfb549 100644 --- a/.gitignore +++ b/.gitignore @@ -32,3 +32,6 @@ data/resources/src/commonMain/composeResources/files/records.json keystore.properties *.jks *.keystore + +# Secrets — see docs/sentry-glitchtip.md (the .example template stays versioned) +.sentryclirc diff --git a/.sentryclirc.example b/.sentryclirc.example new file mode 100644 index 0000000..93e32b9 --- /dev/null +++ b/.sentryclirc.example @@ -0,0 +1,17 @@ +; Template for the local `.sentryclirc` used by sentry-cli to upload debug symbols +; to GlitchTip. Copy it to `.sentryclirc` at the repository root and fill in the token: +; +; cp .sentryclirc.example .sentryclirc +; chmod 600 .sentryclirc +; +; `.sentryclirc` is git-ignored and must never be committed — it holds a credential. +; See docs/sentry-glitchtip.md for the full procedure. + +[auth] +; Created in GlitchTip under your user menu > Profile > Auth Tokens. +token= + +[defaults] +url=https://eu.glitchtip.com/ +org=kerlenn-sten-kidna +project=chom-bev-e-brezhoneg diff --git a/README.md b/README.md index 6a007f8..bb79e1f 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,18 @@ See [Resource's README.md](data/resources/README.md) to handle the resource file audio files, and subtitles). +## Documentation + +- [Crash reporting — Sentry SDK on GlitchTip](./docs/sentry-glitchtip.md): DSN configuration, + `.sentryclirc` setup, and the iOS Archive procedure for uploading dSYM files. + +## Crash reporting + +Crashes are reported with the Sentry Kotlin Multiplatform SDK to a [GlitchTip](https://glitchtip.com) +instance. The DSN is read from `local.properties` and never committed, so a fresh clone builds and +runs with crash reporting disabled until you configure it. See +[docs/sentry-glitchtip.md](./docs/sentry-glitchtip.md).R + ## License This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. diff --git a/androidApp/README.md b/androidApp/README.md index fe30b45..3e6dcaf 100644 --- a/androidApp/README.md +++ b/androidApp/README.md @@ -1,10 +1,5 @@ # Android app -## Crash logging - -GlitchTip is configured in the project. It uses the open source solution Senstry to log crashes -generated by the app and send them to GlitchTip on a European server. - ## StrictMode (debug builds) The app enables Android [StrictMode](https://developer.android.com/reference/android/os/StrictMode) diff --git a/androidApp/build.gradle.kts b/androidApp/build.gradle.kts index c751ee4..21ccc0e 100644 --- a/androidApp/build.gradle.kts +++ b/androidApp/build.gradle.kts @@ -31,9 +31,6 @@ dependencies { implementation(libs.androidx.activity.compose) implementation(libs.compose.uiToolingPreview) debugImplementation(libs.compose.uiTooling) - - // Crash logging - implementation(libs.glitchtips) } android { @@ -55,10 +52,6 @@ android { .toInt() versionCode = 1 versionName = "1.0.0-alpha02" - - // Crash-reporting DSN, kept out of the public repo: read from local.properties - // (glitchtip.dsn=...). Empty when absent, which disables the Sentry SDK. - manifestPlaceholders["glitchtipDsn"] = localProperty("glitchtip.dsn") ?: "" } packaging { resources { @@ -76,12 +69,3 @@ android { targetCompatibility = JavaVersion.VERSION_11 } } - -/** Reads a key from the root local.properties, or null when the file or key is absent. */ -fun localProperty(key: String): String? { - val file = rootProject.file("local.properties") - if (!file.exists()) return null - return Properties() - .apply { file.inputStream().use { load(it) } } - .getProperty(key) -} diff --git a/androidApp/src/main/AndroidManifest.xml b/androidApp/src/main/AndroidManifest.xml index 5d94f36..4ef8eef 100644 --- a/androidApp/src/main/AndroidManifest.xml +++ b/androidApp/src/main/AndroidManifest.xml @@ -20,11 +20,6 @@ - - - - - \ No newline at end of file diff --git a/androidApp/src/main/kotlin/bzh/ajaury/chombev/ChomBevApp.kt b/androidApp/src/main/kotlin/bzh/ajaury/chombev/ChomBevApp.kt index 8dca7e3..f334338 100644 --- a/androidApp/src/main/kotlin/bzh/ajaury/chombev/ChomBevApp.kt +++ b/androidApp/src/main/kotlin/bzh/ajaury/chombev/ChomBevApp.kt @@ -10,10 +10,11 @@ class ChomBevApp : Application() { if (isDebuggable()) { enableStrictMode() } + + SentryHelper.initializeSentry() } - private fun isDebuggable(): Boolean = - applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 + private fun isDebuggable(): Boolean = applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE != 0 /** * Enables StrictMode for debug builds only. All violations are logged diff --git a/docs/sentry-glitchtip.md b/docs/sentry-glitchtip.md new file mode 100644 index 0000000..6268359 --- /dev/null +++ b/docs/sentry-glitchtip.md @@ -0,0 +1,164 @@ +# Crash reporting — Sentry SDK on GlitchTip + +Chom Bev reports crashes and handled errors with the +[Sentry Kotlin Multiplatform SDK](https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform/), +but sends them to [GlitchTip](https://glitchtip.com) instead of Sentry. GlitchTip is an +open-source, Sentry-API-compatible backend, so the Sentry SDKs and `glitchtip-cli` work against +it unchanged — only the DSN (and the CLI's `url`) point somewhere else. + +Our instance is the hosted EU one: . + +| | | +|-----------------------|------------------------------------------------------------------------------------------------------| +| SDK | `io.sentry.kotlin.multiplatform` (see `sentry` in `gradle/libs.versions.toml`) | +| Gradle plugin | `io.sentry.kotlin.multiplatform.gradle`, applied in `shared/build.gradle.kts` | +| iOS native dependency | `sentry-cocoa` via Swift Package Manager, declared in the Xcode project | +| Initialisation | `initializeSentry()` in `shared/src/commonMain/kotlin/bzh/ajaury/chombev/SentryHelper.kt` | +| Called from | `ChomBevApp.onCreate()` (Android), `AppDelegate.application(_:didFinishLaunchingWithOptions:)` (iOS) | + +There are two distinct pieces of configuration, and they are easy to confuse: + +1. **The DSN** — where the *app* sends events at runtime. Injected at build time from + `local.properties`. See [Runtime configuration](#1-runtime-configuration--the-dsn). +2. **The auth token** — how *your machine or CI* uploads debug symbols to GlitchTip. + Stored in `.sentryclirc`. See [CLI configuration](#2-cli-configuration--sentryclirc). + +Neither is committed. + +--- + +## 1. Runtime configuration — the DSN + +The DSN is a write-only endpoint URL identifying the GlitchTip project. It is kept out of +version control by the `gwenedeg.secrets` convention plugin +(`build-logic/src/main/kotlin/gwenedeg.secrets.gradle.kts`), which reads it at build +time and generates an internal `BuildSecrets` object into `commonMain`. + +### Setup + +Add the DSN to `local.properties` at the repository root (this file is git-ignored): + +```properties +glitchtip.dsn=https://@eu.glitchtip.com/ +``` + +Find the value in GlitchTip under **Settings → Projects → _Chom Bev e Brezhoneg_ → Client Keys (DSN)**. + +On CI, set the `GLITCHTIP_DSN` environment variable instead — it takes precedence over +`local.properties`. + +### Behaviour when it is missing + +`initializeSentry()` returns early when the DSN is blank, so a fresh clone builds and runs +with crash reporting simply disabled. No placeholder or dummy DSN is needed. + +### Adding other secrets + +Extend the `secret(environmentVariable, localPropertyKey)` helper in the convention plugin +rather than hardcoding values in Kotlin sources. + +--- + +## 2. CLI configuration — `.sentryclirc` + +`sentry-cli` needs an auth token to upload debug symbols. It reads its settings from a +`.sentryclirc` INI file in the current working directory (and from `~/.sentryclirc`), +or from environment variables. + +> **`.sentryclirc` contains a credential and must never be committed.** +> It is listed in `.gitignore`. Only `.sentryclirc.example` is versioned. + +### Setup + +```bash +cp .sentryclirc.example .sentryclirc +chmod 600 .sentryclirc +``` + +Then edit `.sentryclirc` and replace the token placeholder: + +```ini +[auth] +token= + +[defaults] +url=https://eu.glitchtip.com/ +org=kerlenn-sten-kidna +project=chom-bev-e-brezhoneg +``` + +- `url` is mandatory: without it `glitchtip-cli` talks to sentry.io, not GlitchTip. +- `org` and `project` are the *slugs* shown in the GlitchTip URLs, not the display names. + +### Creating the token + +In GlitchTip: **user menu → Profile → Auth Tokens → Create New Token**. Grant it at least +`project:read`, `project:write` and `project:releases`. Copy the token immediately — it is +shown only once. + +### On CI + +Do not write the file. Export the equivalent environment variables, sourced from your CI +secret store: + +```bash +export SENTRY_URL=https://eu.glitchtip.com/ +export SENTRY_ORG=kerlenn-sten-kidna +export SENTRY_PROJECT=chom-bev-e-brezhoneg +export SENTRY_AUTH_TOKEN=*** +``` + +### Installing glitchtip-cli + +```bash +curl -fsSL https://glitchtip.com/install.sh | sh +``` + +Check that the configuration resolves: + +```bash +glitchtip-cli info +``` + +It should print the GlitchTip URL and report the token as valid. + +--- + +## 3. iOS Archive — uploading dSYM files + +### Why this is needed + +A release iOS binary ships without symbol names. Without the matching dSYM, GlitchTip shows +crash frames as raw memory addresses. Uploading the dSYM lets it resolve them back to +function names and line numbers. + +The shared Kotlin framework is linked **statically** (`isStatic = true` in +`shared/build.gradle.kts`), so Kotlin/Native frames end up in the app's own dSYM — there is +no separate framework dSYM to upload. + +### Prerequisites + +You need `glitchtip-cli` installed and `.sentryclirc` filled in ([section 2](#2-cli-configuration--sentryclirc)). + +### Upload after an archive + +1. **Product → Archive** in Xcode. +2. In the Organizer, right-click the archive → **Show in Finder**. +3. Right-click the `.xcarchive` → **Show Package Contents**. +4. Upload the `dSYMs` folder: + +```bash +cd /path/to/Gwenedeg +glitchtip-cli debug-files upload /path/to/Gwenedeg.xcarchive/dSYMs +``` + +Add `--include-sources` if you also want source context attached to native frames. + +## 4. References + +- [Sentry — Kotlin Multiplatform SDK](https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform/) +- [Sentry — Uploading debug symbols for Apple platforms](https://docs.sentry.io/platforms/apple/guides/ios/dsym/) +- [Sentry — data collected by the SDK](https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform/data-management/data-collected/) +- [GlitchTip documentation](https://glitchtip.com/documentation) +- [GlitchTip CLI documentation](https://glitchtip.com/documentation/cli) +- [Kotlin — symbolicating iOS crash reports](https://kotlinlang.org/docs/native-ios-symbolication.html) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a06a169..a2d471f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -6,24 +6,24 @@ androidTargetSdk = "37" androidxActivity = "1.13.0" androidxLifecycle = "2.11.0" androidxWebkit = "1.16.0" -media3 = "1.11.0" -navigation3 = "1.1.1" -composeMultiplatform = "1.11.1" composeIconsExtended = "1.7.3" -glitchtips = "8.52.0" +composeMultiplatform = "1.11.1" +datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore googleServices = "4.5.0" +kermit = "2.1.0" +koin = "4.2.2" kotlin = "2.4.10" +kotlinSerialization = "2.4.10" kotlinWrappers = "2026.8.0" kotlinxBrowser = "0.5.0" kotlinxCoroutines = "1.11.0" -kotlinSerialization = "2.4.10" kotlinxSerializationJson = "1.11.0" material3 = "1.11.0-alpha07" -koin = "4.2.2" -kermit = "2.1.0" -vorbisspi = "1.0.3.3" -datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore +media3 = "1.11.0" +navigation3 = "1.1.1" okio = "3.18.1" +sentry = "0.27.0" +vorbisspi = "1.0.3.3" [libraries] # Gradle plugin artifacts, used by build-logic convention plugins only @@ -44,7 +44,6 @@ compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.re compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } -glitchtips = { module = "io.sentry:sentry-android", version.ref = "glitchtips" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } @@ -75,4 +74,5 @@ composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "k googleServices = { id = "com.google.gms.google-services", version.ref = "googleServices" } kotlinJvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } -kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"} \ No newline at end of file +kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"} +sentry = { id = "io.sentry.kotlin.multiplatform.gradle", version.ref = "sentry" } \ No newline at end of file diff --git a/iosApp/iosApp.xcodeproj/project.pbxproj b/iosApp/iosApp.xcodeproj/project.pbxproj index 99b29ef..4afb7b6 100644 --- a/iosApp/iosApp.xcodeproj/project.pbxproj +++ b/iosApp/iosApp.xcodeproj/project.pbxproj @@ -6,6 +6,10 @@ objectVersion = 77; objects = { +/* Begin PBXBuildFile section */ + F36B7319302CAEC900B3E490 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = F36B7318302CAEC900B3E490 /* Sentry */; }; +/* End PBXBuildFile section */ + /* Begin PBXFileReference section */ 9DA43AABF87DF46F5E794E72 /* Chom Bev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Chom Bev.app"; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -41,6 +45,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + F36B7319302CAEC900B3E490 /* Sentry in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -85,6 +90,7 @@ ); name = iosApp; packageProductDependencies = ( + F36B7318302CAEC900B3E490 /* Sentry */, ); productName = iosApp; productReference = 9DA43AABF87DF46F5E794E72 /* Chom Bev.app */; @@ -114,6 +120,9 @@ ); mainGroup = A562F15E4A0F12F420BB5B12; minimizedProjectReferenceProxies = 1; + packageReferences = ( + F36B7317302CAEC900B3E490 /* XCRemoteSwiftPackageReference "sentry-cocoa" */, + ); preferredProjectObjectVersion = 77; productRefGroup = 47B0C8770C82BEADFE13EF77 /* Products */; projectDirPath = ""; @@ -176,7 +185,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "$(TEAM_ID)"; + DEVELOPMENT_TEAM = 492RZGQ5SU; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -269,7 +278,7 @@ CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\""; - DEVELOPMENT_TEAM = "$(TEAM_ID)"; + DEVELOPMENT_TEAM = 492RZGQ5SU; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; INFOPLIST_FILE = iosApp/Info.plist; @@ -368,6 +377,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + F36B7317302CAEC900B3E490 /* XCRemoteSwiftPackageReference "sentry-cocoa" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/getsentry/sentry-cocoa"; + requirement = { + kind = exactVersion; + version = 8.58.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + F36B7318302CAEC900B3E490 /* Sentry */ = { + isa = XCSwiftPackageProductDependency; + package = F36B7317302CAEC900B3E490 /* XCRemoteSwiftPackageReference "sentry-cocoa" */; + productName = Sentry; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 50584B9465876AB65BD227D3 /* Project object */; } diff --git a/iosApp/iosApp/AppDelegate.swift b/iosApp/iosApp/AppDelegate.swift new file mode 100644 index 0000000..818b0ab --- /dev/null +++ b/iosApp/iosApp/AppDelegate.swift @@ -0,0 +1,18 @@ +// +// AppDelegate.swift +// iosApp +// +// Created by Antoine Jaury on 12/08/2026. +// +import UIKit +import Shared + +final class AppDelegate: NSObject, UIApplicationDelegate { + + + func application(_ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool { + SentryHelper().initializeSentry() + return true + } +} diff --git a/iosApp/iosApp/iOSApp.swift b/iosApp/iosApp/iOSApp.swift index d83dca6..ce67523 100644 --- a/iosApp/iosApp/iOSApp.swift +++ b/iosApp/iosApp/iOSApp.swift @@ -2,6 +2,10 @@ import SwiftUI @main struct iOSApp: App { + + @UIApplicationDelegateAdaptor(AppDelegate.self) + private var appDelegate + var body: some Scene { WindowGroup { ContentView() diff --git a/shared/build.gradle.kts b/shared/build.gradle.kts index 7d6919f..9462145 100644 --- a/shared/build.gradle.kts +++ b/shared/build.gradle.kts @@ -2,6 +2,8 @@ plugins { id("gwenedeg.kmp.library") id("gwenedeg.compose") alias(libs.plugins.kotlinSerialization) + id("gwenedeg.secrets") + alias(libs.plugins.sentry) } kotlin { diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/SentryHelper.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/SentryHelper.kt new file mode 100644 index 0000000..7e2ee30 --- /dev/null +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/SentryHelper.kt @@ -0,0 +1,20 @@ +package bzh.ajaury.chombev + +import io.sentry.kotlin.multiplatform.Sentry + +object SentryHelper { + fun initializeSentry() { + // The DSN is injected at build time from local.properties (or the GLITCHTIP_DSN + // environment variable) so it never lands in version control. Without it, crash + // reporting is simply disabled. + val dsn = BuildSecrets.GLITCHTIP_DSN + if (dsn.isBlank()) return + + Sentry.init { options -> + options.dsn = dsn + options.debug = true + options.sendDefaultPii = false + options.enableAutoSessionTracking = false + } + } +} From 705e9d32f75129e40474ed116c6f6b27df351c86 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Wed, 12 Aug 2026 15:47:24 +0200 Subject: [PATCH 09/31] chore: remove unused loggingModule dependency from iOS AudioPlayerModule --- .../bzh/ajaury/chombev/player/di/AudioPlayerModule.ios.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/di/AudioPlayerModule.ios.kt b/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/di/AudioPlayerModule.ios.kt index d42a63b..683e42b 100644 --- a/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/di/AudioPlayerModule.ios.kt +++ b/core/audioplayer/src/iosMain/kotlin/bzh/ajaury/chombev/player/di/AudioPlayerModule.ios.kt @@ -1,6 +1,5 @@ package bzh.ajaury.chombev.player.di -import bzh.ajaury.chombev.core.logging.di.loggingModule import bzh.ajaury.chombev.player.data.IosAudioPlayer import bzh.ajaury.chombev.player.data.IosAudioSessionManager import bzh.ajaury.chombev.player.domain.AudioPlayer @@ -11,7 +10,6 @@ import org.koin.core.module.dsl.singleOf import org.koin.dsl.module actual val audioPlayerModule: Module = module { - includes(loggingModule) singleOf(::IosAudioPlayer) { bind() } singleOf(::IosAudioSessionManager) { bind() } } From eb94d55d6943b53498ea04dccb513829b35593e9 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 4 Sep 2026 09:40:10 +0200 Subject: [PATCH 10/31] chore: remove VorbisSPI dependency --- CHANGELOG.md | 2 +- core/audioplayer/build.gradle.kts | 4 ---- .../kotlin/bzh/ajaury/chombev/player/data/JvmAudioPlayer.kt | 4 ---- gradle/libs.versions.toml | 2 -- 4 files changed, 1 insertion(+), 11 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index c6e8730..a507449 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -64,7 +64,7 @@ First public release of **Chom Bev e Brezhoneg**. - Targets Android, iOS, Desktop (JVM) and Web (JS + Wasm), all sharing the same Compose Multiplatform UI. Only Android has been deeply tested for this version. - Audio playback backed by ExoPlayer (Android), AVFoundation (iOS), the browser - audio APIs (Web) and VorbisSPI (Desktop). + audio APIs (Web). - Light and dark themes; interface localised in French and Breton. - Modular architecture: `core`, `data` and `feature` modules glued by a thin `shared` module, with Koin dependency injection validated at build time. diff --git a/core/audioplayer/build.gradle.kts b/core/audioplayer/build.gradle.kts index 11e4918..c19c823 100644 --- a/core/audioplayer/build.gradle.kts +++ b/core/audioplayer/build.gradle.kts @@ -13,10 +13,6 @@ kotlin { implementation(libs.media3.exoplayer) implementation(libs.media3.common) } - jvmMain.dependencies { - // Desktop audio: Ogg Vorbis SPI so javax.sound.sampled can decode .ogg - implementation(libs.vorbisspi) - } // webMain is the js + wasmJs intermediate source set from the default hierarchy template. webMain.dependencies { // Browser audio (HTMLAudioElement, document) for js + wasmJs diff --git a/core/audioplayer/src/jvmMain/kotlin/bzh/ajaury/chombev/player/data/JvmAudioPlayer.kt b/core/audioplayer/src/jvmMain/kotlin/bzh/ajaury/chombev/player/data/JvmAudioPlayer.kt index 5100852..be644c4 100644 --- a/core/audioplayer/src/jvmMain/kotlin/bzh/ajaury/chombev/player/data/JvmAudioPlayer.kt +++ b/core/audioplayer/src/jvmMain/kotlin/bzh/ajaury/chombev/player/data/JvmAudioPlayer.kt @@ -17,10 +17,6 @@ import kotlin.time.Duration.Companion.microseconds /** * Desktop [AudioPlayer] backed by [javax.sound.sampled]. - * - * The Ogg Vorbis recordings are decoded to PCM by the VorbisSPI providers on the classpath, which - * the JDK [AudioSystem] discovers automatically, so loading works the same as any natively - * supported format. */ internal class JvmAudioPlayer : AudioPlayer { private var clip: Clip? = null diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a2d471f..b624049 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,7 +23,6 @@ media3 = "1.11.0" navigation3 = "1.1.1" okio = "3.18.1" sentry = "0.27.0" -vorbisspi = "1.0.3.3" [libraries] # Gradle plugin artifacts, used by build-logic convention plugins only @@ -63,7 +62,6 @@ lifecycle-viewmodelNavigation3 = { module = "org.jetbrains.androidx.lifecycle:li media3-exoplayer = { module = "androidx.media3:media3-exoplayer", version.ref = "media3" } media3-common = { module = "androidx.media3:media3-common", version.ref = "media3" } navigation3 = { module = "org.jetbrains.androidx.navigation3:navigation3-ui", version.ref = "navigation3" } -vorbisspi = { module = "com.googlecode.soundlibs:vorbisspi", version.ref = "vorbisspi" } wrappers-browser = { module = "org.jetbrains.kotlin-wrappers:kotlin-browser", version.ref = "kotlinWrappers" } [plugins] From 8de3ee0a1faae4f5d73b45cb823d0666b5c29c49 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 4 Sep 2026 09:40:58 +0200 Subject: [PATCH 11/31] chore: add Gradle convention plugin for injecting build-time secrets --- .../main/kotlin/gwenedeg.secrets.gradle.kts | 84 +++++++++++++++++++ 1 file changed, 84 insertions(+) create mode 100644 build-logic/src/main/kotlin/gwenedeg.secrets.gradle.kts diff --git a/build-logic/src/main/kotlin/gwenedeg.secrets.gradle.kts b/build-logic/src/main/kotlin/gwenedeg.secrets.gradle.kts new file mode 100644 index 0000000..5454b8a --- /dev/null +++ b/build-logic/src/main/kotlin/gwenedeg.secrets.gradle.kts @@ -0,0 +1,84 @@ +import java.util.Properties + +/** + * Injects build-time secrets into `commonMain` so they stay out of version control. + * + * Each secret is read from the environment first (CI), then from `local.properties` + * (local development), and defaults to an empty string when neither provides it — + * consumers are expected to degrade gracefully rather than fail. + * + * The values are emitted as a generated `BuildSecrets` object, available to every + * Kotlin target of the module. + */ + +plugins { + id("org.jetbrains.kotlin.multiplatform") +} + +val secretsPackage = "bzh.ajaury.chombev" + +private val localProperties: Provider = + providers + .fileContents(layout.settingsDirectory.file("local.properties")) + .asText + .map { text -> Properties().apply { load(text.reader()) } } + +/** Resolves a secret from [environmentVariable], falling back to [localPropertyKey], then to an empty string. */ +fun secret( + environmentVariable: String, + localPropertyKey: String, +): Provider = + providers + .environmentVariable(environmentVariable) + .orElse(localProperties.map { properties -> properties.getProperty(localPropertyKey).orEmpty() }) + .orElse("") + +// GlitchTip DSN, consumed by initializeSentry() in the shared module. +val glitchtipDsn: Provider = secret(environmentVariable = "GLITCHTIP_DSN", localPropertyKey = "glitchtip.dsn") + +val generateBuildSecrets = + tasks.register("generateBuildSecrets") { + description = "Generates the BuildSecrets object from local.properties and environment variables." + + // Escaped here so the value can be inlined as-is in a Kotlin string literal. + val dsn = + glitchtipDsn.map { value -> + value + .replace("\\", "\\\\") + .replace("\"", "\\\"") + .replace("$", "\${'$'}") + } + val outputDir = layout.buildDirectory.dir("generated/secrets/commonMain/kotlin") + // Kept in locals so the execution-time action never captures the script instance. + val packageName = secretsPackage + val packagePath = packageName.replace('.', '/') + + inputs.property("glitchtipDsn", dsn) + outputs.dir(outputDir) + + doLast { + val file = + outputDir + .get() + .asFile + .resolve("$packagePath/BuildSecrets.kt") + file.parentFile.mkdirs() + file.writeText( + """ + package $packageName + + // Generated by the `gwenedeg.secrets` convention plugin. Do not edit. + internal object BuildSecrets { + const val GLITCHTIP_DSN: String = "${dsn.get()}" + } + + """.trimIndent(), + ) + } + } + +kotlin { + sourceSets.commonMain { + kotlin.srcDir(generateBuildSecrets) + } +} From ac07be56342bd9685caa7e525dc3e417beba6039 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Fri, 4 Sep 2026 18:48:44 +0200 Subject: [PATCH 12/31] chore: fix typography in string resource --- .../src/commonMain/composeResources/values/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index f8a3fb6..f9c967d 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -6,7 +6,7 @@ Approfondir À propos - Impossible d\'afficher cette page ici. + Impossible d'afficher cette page ici. Ouvrir dans le navigateur Sous-titres From 3acba87728033b44904d33b7965a7a619589bb7e Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Sat, 5 Sep 2026 14:21:29 +0200 Subject: [PATCH 13/31] chore: add jaad dependency to fix `.m4a` playback on Desktop --- CHANGELOG.md | 7 ++++++- core/audioplayer/build.gradle.kts | 4 ++++ .../bzh/ajaury/chombev/player/data/JvmAudioPlayer.kt | 4 ++++ gradle/libs.versions.toml | 2 ++ 4 files changed, 16 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a507449..8c807a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,11 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Desktop: audio playback failed with *Failed to play audio* on every record, + because the JDK cannot decode the `.m4a` (AAC) recordings on its own. + ## [1.0.0-alpha02] - 2026-08-04 ### Added @@ -64,7 +69,7 @@ First public release of **Chom Bev e Brezhoneg**. - Targets Android, iOS, Desktop (JVM) and Web (JS + Wasm), all sharing the same Compose Multiplatform UI. Only Android has been deeply tested for this version. - Audio playback backed by ExoPlayer (Android), AVFoundation (iOS), the browser - audio APIs (Web). + audio APIs (Web) and `javax.sound.sampled` (Desktop). - Light and dark themes; interface localised in French and Breton. - Modular architecture: `core`, `data` and `feature` modules glued by a thin `shared` module, with Koin dependency injection validated at build time. diff --git a/core/audioplayer/build.gradle.kts b/core/audioplayer/build.gradle.kts index c19c823..44d0c5e 100644 --- a/core/audioplayer/build.gradle.kts +++ b/core/audioplayer/build.gradle.kts @@ -13,6 +13,10 @@ kotlin { implementation(libs.media3.exoplayer) implementation(libs.media3.common) } + jvmMain.dependencies { + // Desktop audio: AAC/MP4 SPI so javax.sound.sampled can decode .m4a + implementation(libs.jaad) + } // webMain is the js + wasmJs intermediate source set from the default hierarchy template. webMain.dependencies { // Browser audio (HTMLAudioElement, document) for js + wasmJs diff --git a/core/audioplayer/src/jvmMain/kotlin/bzh/ajaury/chombev/player/data/JvmAudioPlayer.kt b/core/audioplayer/src/jvmMain/kotlin/bzh/ajaury/chombev/player/data/JvmAudioPlayer.kt index be644c4..f1af5d4 100644 --- a/core/audioplayer/src/jvmMain/kotlin/bzh/ajaury/chombev/player/data/JvmAudioPlayer.kt +++ b/core/audioplayer/src/jvmMain/kotlin/bzh/ajaury/chombev/player/data/JvmAudioPlayer.kt @@ -17,6 +17,10 @@ import kotlin.time.Duration.Companion.microseconds /** * Desktop [AudioPlayer] backed by [javax.sound.sampled]. + * + * The JDK decodes neither AAC nor its MP4 container, so the `.m4a` recordings are decoded to PCM by + * the jaad AAC provider on the classpath, which [AudioSystem] discovers automatically. Loading then + * works the same as for any natively supported format. */ internal class JvmAudioPlayer : AudioPlayer { private var clip: Clip? = null diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b624049..f70ac34 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -10,6 +10,7 @@ composeIconsExtended = "1.7.3" composeMultiplatform = "1.11.1" datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore googleServices = "4.5.0" +jaad = "0.9.4" kermit = "2.1.0" koin = "4.2.2" kotlin = "2.4.10" @@ -43,6 +44,7 @@ compose-runtime = { module = "org.jetbrains.compose.runtime:runtime", version.re compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "composeMultiplatform" } compose-uiTooling = { module = "org.jetbrains.compose.ui:ui-tooling", version.ref = "composeMultiplatform" } compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "composeMultiplatform" } +jaad = { module = "com.tianscar.javasound:jaad", version.ref = "jaad" } koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin" } koin-compose = { module = "io.insert-koin:koin-compose" } koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" } From 46dfe644933db2735146e60e2d36d942efb5bdd9 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Sat, 5 Sep 2026 14:49:39 +0200 Subject: [PATCH 14/31] chore: add emoji fallback font support for web --- CHANGELOG.md | 1 + README.md | 4 + .../resources/licenses/NotoColorEmoji-OFL.txt | 92 ++++++++++++++++++ .../font/noto_color_emoji.ttf | Bin 0 -> 76000 bytes .../theme/EmojiFallbackFont.android.kt | 7 ++ .../kotlin/bzh/ajaury/chombev/App.kt | 3 + .../ajaury/chombev/theme/EmojiFallbackFont.kt | 15 +++ .../chombev/theme/EmojiFallbackFont.ios.kt | 7 ++ .../chombev/theme/EmojiFallbackFont.jvm.kt | 7 ++ .../chombev/theme/EmojiFallbackFont.web.kt | 34 +++++++ 10 files changed, 170 insertions(+) create mode 100644 data/resources/licenses/NotoColorEmoji-OFL.txt create mode 100644 data/resources/src/webMain/composeResources/font/noto_color_emoji.ttf create mode 100644 shared/src/androidMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.android.kt create mode 100644 shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.kt create mode 100644 shared/src/iosMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.ios.kt create mode 100644 shared/src/jvmMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.jvm.kt create mode 100644 shared/src/webMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.web.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c807a7..8c2007b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Desktop: audio playback failed with *Failed to play audio* on every record, because the JDK cannot decode the `.m4a` (AAC) recordings on its own. +- add emoji fallback for web ## [1.0.0-alpha02] - 2026-08-04 diff --git a/README.md b/README.md index bb79e1f..0d6eab4 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,10 @@ runs with crash reporting disabled until you configure it. See ## License +The web build bundles a subset of [Noto Color Emoji](https://github.com/googlefonts/noto-emoji), +licensed under the SIL Open Font License 1.1 +([data/resources/licenses/NotoColorEmoji-OFL.txt](./data/resources/licenses/NotoColorEmoji-OFL.txt)). + This project is licensed under the Apache License 2.0. See the [LICENSE](LICENSE) file for details. ``` diff --git a/data/resources/licenses/NotoColorEmoji-OFL.txt b/data/resources/licenses/NotoColorEmoji-OFL.txt new file mode 100644 index 0000000..d952d62 --- /dev/null +++ b/data/resources/licenses/NotoColorEmoji-OFL.txt @@ -0,0 +1,92 @@ +This Font Software is licensed under the SIL Open Font License, +Version 1.1. + +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font +creation efforts of academic and linguistic communities, and to +provide a free and open framework in which fonts may be shared and +improved in partnership with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply to +any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software +components as distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, +deleting, or substituting -- in part or in whole -- any of the +components of the Original Version, by changing formats or by porting +the Font Software to a new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, +modify, redistribute, and sell modified and unmodified copies of the +Font Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, in +Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the +corresponding Copyright Holder. This restriction only applies to the +primary font name as presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created using +the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/data/resources/src/webMain/composeResources/font/noto_color_emoji.ttf b/data/resources/src/webMain/composeResources/font/noto_color_emoji.ttf new file mode 100644 index 0000000000000000000000000000000000000000..374200bc8b72362ba91fffecae1ae6c48701b74f GIT binary patch literal 76000 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 z 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Ð)#9)>$Ip7aAn_4;5(;Mn*nHES*)hBbr zR4`CTc4Vo@m_RCf)R3gCPi3_OD!amkVR5y_0tYA2@KYql0;u-C^^&breX>C{mHq>R$Io| zs>Zmk(YaD0Dq7(;GiI0-%B}%=JbV3fTdGSky#CMYx=T_TYS8|vu0o@owA|lyyiT>d zs!(S~RI+M-pkT^XjrCdS0R_tQa%2yF=4UCXZf{~8XB}s!V6C)-6dCa}DRPUB}}~OYpM}&sQ%+-*;uv3JUKmC)n?#a&ms+;6YZ2Qc|(u z=eW>SV;2JRtBlp1kFoJGmQ*D=XqibZB7`?meeV) zs3*ypjGk=`M&DiJb}`y#SnXx4&YMzdm{en>lm%7!;_B!U^=$M2*~J)&DW=Fw+TE@+ zUxGK;Y_um5dt@-K?~Jx_uHs11;&u})vGb&#NOijslO8iZCft0oios6U1$txjHe2#( zW$4_9tYN%e4;#+@jr7Ed4E6vx-VvFcOvn0qFfErQ9KIYB-BX_veJCDdgytp2%+=~1 zZ%#0nl2TY0a5Gz#l3XF(u5=f&);-aUdSUb~;hy%OO2kc!Ip)nzb>xwAd}k9^v=}jN zB>M2FstaA=KiTx8|G?e9yDGG)-um+7}CNrx>?$z5fr+;yBvTQ`c zOe#iV{TX@b=r9+T=k)frGRN#SRnx~b8dpnS58E(Z!-l4$qU3}`JwvvmbHcF?^X%f?RACY3MV(=LboRq|iwyf_T}e%X+h1Lp zQ<+46*VDjx4`>AjQ+(c3w@*s0zFdD{noOSUi?*RWMONZN!e fGp&6e!E#%orz- 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 X1%OG^AIs*#^Oe!7gLi9Kxvl>LC{7P4 literal 0 HcmV?d00001 diff --git a/shared/src/androidMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.android.kt b/shared/src/androidMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.android.kt new file mode 100644 index 0000000..08f1046 --- /dev/null +++ b/shared/src/androidMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.android.kt @@ -0,0 +1,7 @@ +package bzh.ajaury.chombev.theme + +import androidx.compose.runtime.Composable + +/** No-op: Android supplies the system emoji font. */ +@Composable +internal actual fun RegisterEmojiFallbackFont() = Unit diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt index e112594..76028dc 100644 --- a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt @@ -27,6 +27,7 @@ import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel import bzh.ajaury.chombev.player.ui.PlayerScreen import bzh.ajaury.chombev.records.ui.RecordsScreen import bzh.ajaury.chombev.theme.ChomBevTheme +import bzh.ajaury.chombev.theme.RegisterEmojiFallbackFont import bzh.ajaury.chombev.web.model.WebPage import bzh.ajaury.chombev.web.ui.WebViewScreen import org.koin.compose.KoinApplication @@ -53,6 +54,8 @@ fun App() { val darkTheme = isSystemInDarkTheme() ChomBevTheme(darkTheme = darkTheme) { + RegisterEmojiFallbackFont() + val onboardingViewModel: OnboardingViewModel = koinViewModel() val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle() diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.kt new file mode 100644 index 0000000..309c6fe --- /dev/null +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.kt @@ -0,0 +1,15 @@ +package bzh.ajaury.chombev.theme + +import androidx.compose.runtime.Composable + +/** + * Makes colour emoji renderable on platforms whose font manager has no emoji font of its own. + * + * Android, iOS and Desktop resolve emoji through the system fonts, so their implementations do + * nothing. Only the web needs a bundled font: Skia runs in the browser without any system font + * manager, and the default font baked into skiko has no emoji glyphs — they come out as tofu boxes. + * + * Emits nothing; call it once, high in the composition. + */ +@Composable +internal expect fun RegisterEmojiFallbackFont() diff --git a/shared/src/iosMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.ios.kt b/shared/src/iosMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.ios.kt new file mode 100644 index 0000000..c9e8524 --- /dev/null +++ b/shared/src/iosMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.ios.kt @@ -0,0 +1,7 @@ +package bzh.ajaury.chombev.theme + +import androidx.compose.runtime.Composable + +/** No-op: iOS supplies Apple Color Emoji. */ +@Composable +internal actual fun RegisterEmojiFallbackFont() = Unit diff --git a/shared/src/jvmMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.jvm.kt b/shared/src/jvmMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.jvm.kt new file mode 100644 index 0000000..443e576 --- /dev/null +++ b/shared/src/jvmMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.jvm.kt @@ -0,0 +1,7 @@ +package bzh.ajaury.chombev.theme + +import androidx.compose.runtime.Composable + +/** No-op: The desktop JVM resolves emoji through the system font manager. */ +@Composable +internal actual fun RegisterEmojiFallbackFont() = Unit diff --git a/shared/src/webMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.web.kt b/shared/src/webMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.web.kt new file mode 100644 index 0000000..78d3df2 --- /dev/null +++ b/shared/src/webMain/kotlin/bzh/ajaury/chombev/theme/EmojiFallbackFont.web.kt @@ -0,0 +1,34 @@ +package bzh.ajaury.chombev.theme + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.ui.platform.LocalFontFamilyResolver +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.platform.Font +import bzh.ajaury.chombev.resources.generated.resources.Res +import bzh.ajaury.chombev.resources.generated.resources.noto_color_emoji +import org.jetbrains.compose.resources.getFontResourceBytes +import org.jetbrains.compose.resources.rememberResourceEnvironment + +private const val EMOJI_FONT_IDENTITY = "NotoColorEmoji" + +/** + * Loads the bundled Noto Color Emoji subset and hands it to the font resolver. + * + * Resolving a font is what registers its typeface with Skia's fallback provider — Compose builds + * its [org.jetbrains.skia.paragraph.FontCollection] on a `FontMgrWithFallback` fed by every font + * the app has loaded — so a single [androidx.compose.ui.text.font.FontFamily.Resolver.preload] is + * enough for emoji to appear in any `Text`, without touching the screens that display them. + */ +@Composable +internal actual fun RegisterEmojiFallbackFont() { + val resourceEnvironment = rememberResourceEnvironment() + val fontFamilyResolver = LocalFontFamilyResolver.current + + LaunchedEffect(resourceEnvironment, fontFamilyResolver) { + val fontBytes = getFontResourceBytes(resourceEnvironment, Res.font.noto_color_emoji) + fontFamilyResolver.preload( + FontFamily(Font(identity = EMOJI_FONT_IDENTITY, data = fontBytes)), + ) + } +} From 4ec151dda5fe8a893c625816d1bee70fba65ad03 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Sat, 5 Sep 2026 15:51:32 +0200 Subject: [PATCH 15/31] feature: show onboarding as a modal on large screens, add close functionality --- CHANGELOG.md | 4 + .../composeResources/values-br/strings.xml | 1 + .../composeResources/values/strings.xml | 1 + .../chombev/onboarding/ui/OnboardingScreen.kt | 211 +++++++++++++----- .../ui/viewmodel/OnboardingViewModel.kt | 6 + .../kotlin/bzh/ajaury/chombev/App.kt | 33 ++- 6 files changed, 189 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8c2007b..e1edda2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- show on boarding as a modal on large screens + ### Fixed - Desktop: audio playback failed with *Failed to play audio* on every record, diff --git a/data/resources/src/commonMain/composeResources/values-br/strings.xml b/data/resources/src/commonMain/composeResources/values-br/strings.xml index 2a7a3d3..8498575 100644 --- a/data/resources/src/commonMain/composeResources/values-br/strings.xml +++ b/data/resources/src/commonMain/composeResources/values-br/strings.xml @@ -42,6 +42,7 @@ Tizh boas, hep ehan etre ar frazennoù War-lerc\'h Kregiñ + Serriñ Distreiñ Frazenn a-raok diff --git a/data/resources/src/commonMain/composeResources/values/strings.xml b/data/resources/src/commonMain/composeResources/values/strings.xml index f9c967d..4b415a8 100644 --- a/data/resources/src/commonMain/composeResources/values/strings.xml +++ b/data/resources/src/commonMain/composeResources/values/strings.xml @@ -42,6 +42,7 @@ Vitesse normale, sans pause Suivant Commencer + Fermer Retour Phrase précédente diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt index 836fe15..c88af1f 100644 --- a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/OnboardingScreen.kt @@ -1,20 +1,27 @@ package bzh.ajaury.chombev.onboarding.ui +import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.sizeIn import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Close import androidx.compose.material.icons.filled.Hearing import androidx.compose.material.icons.filled.Speed import androidx.compose.material.icons.filled.Subtitles import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -23,6 +30,8 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import androidx.compose.ui.window.DialogProperties import androidx.lifecycle.compose.collectAsStateWithLifecycle import bzh.ajaury.chombev.onboarding.model.OnboardingLevel import bzh.ajaury.chombev.onboarding.ui.components.OnboardingInfoPage @@ -31,6 +40,7 @@ import bzh.ajaury.chombev.onboarding.ui.components.OnboardingPageIndicator import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingUiState import bzh.ajaury.chombev.onboarding.ui.viewmodel.OnboardingViewModel import bzh.ajaury.chombev.resources.generated.resources.Res +import bzh.ajaury.chombev.resources.generated.resources.onboarding_close import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_description import bzh.ajaury.chombev.resources.generated.resources.onboarding_goal_title import bzh.ajaury.chombev.resources.generated.resources.onboarding_next @@ -46,93 +56,156 @@ import org.koin.compose.viewmodel.koinViewModel private const val PAGE_COUNT = 4 private const val LEVEL_PAGE_INDEX = PAGE_COUNT - 1 +private val DIALOG_MAX_WIDTH = 560.dp +private val DIALOG_MAX_HEIGHT = 680.dp +private const val DIALOG_WINDOW_FRACTION = 0.9f + @Composable -fun OnboardingScreen(viewModel: OnboardingViewModel = koinViewModel()) { +fun OnboardingScreen( + showAsDialog: Boolean = false, + viewModel: OnboardingViewModel = koinViewModel(), +) { val uiState by viewModel.uiState.collectAsStateWithLifecycle() OnboardingScreen( uiState = uiState, + showAsDialog = showAsDialog, onLevelSelected = viewModel::selectLevel, onFinish = viewModel::completeOnboarding, + onDismiss = viewModel::skipOnboarding, ) } @Composable fun OnboardingScreen( uiState: OnboardingUiState, + showAsDialog: Boolean = false, onLevelSelected: (OnboardingLevel) -> Unit = {}, onFinish: () -> Unit = {}, + onDismiss: () -> Unit = {}, +) { + if (showAsDialog) { + Dialog( + onDismissRequest = onDismiss, + // The content sizes itself against the window rather than the platform default width. + properties = DialogProperties(usePlatformDefaultWidth = false), + ) { + Surface( + modifier = Modifier + .sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT) + .fillMaxSize(fraction = DIALOG_WINDOW_FRACTION), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + OnboardingContent( + uiState = uiState, + onLevelSelected = onLevelSelected, + onFinish = onFinish, + onClose = onDismiss, + ) + } + } + } else { + Scaffold { innerPadding -> + OnboardingContent( + uiState = uiState, + modifier = Modifier.padding(innerPadding), + onLevelSelected = onLevelSelected, + onFinish = onFinish, + ) + } + } +} + +@Composable +private fun OnboardingContent( + uiState: OnboardingUiState, + modifier: Modifier = Modifier, + onLevelSelected: (OnboardingLevel) -> Unit = {}, + onFinish: () -> Unit = {}, + onClose: (() -> Unit)? = null, ) { val pagerState = rememberPagerState(pageCount = { PAGE_COUNT }) val scope = rememberCoroutineScope() val isLastPage = pagerState.currentPage == LEVEL_PAGE_INDEX - Scaffold { innerPadding -> - Column( - modifier = Modifier - .padding(innerPadding) - .fillMaxSize() - .padding(24.dp), - horizontalAlignment = Alignment.CenterHorizontally, - ) { - HorizontalPager( - state = pagerState, - modifier = Modifier - .weight(1f) - .fillMaxWidth(), - ) { page -> - when (page) { - 0 -> OnboardingInfoPage( - icon = Icons.Filled.Hearing, - title = stringResource(Res.string.onboarding_goal_title), - description = stringResource(Res.string.onboarding_goal_description), - ) - - 1 -> OnboardingInfoPage( - icon = Icons.Filled.Subtitles, - title = stringResource(Res.string.onboarding_subtitles_title), - description = stringResource(Res.string.onboarding_subtitles_description), - ) - - 2 -> OnboardingInfoPage( - icon = Icons.Filled.Speed, - title = stringResource(Res.string.onboarding_playback_title), - description = stringResource(Res.string.onboarding_playback_description), - ) - - else -> OnboardingLevelPage( - selectedLevel = uiState.selectedLevel, - onLevelSelected = onLevelSelected, + Column( + modifier = modifier + .fillMaxSize() + .padding(24.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + if (onClose != null) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + IconButton(onClick = onClose) { + Icon( + imageVector = Icons.Filled.Close, + contentDescription = stringResource(Res.string.onboarding_close), ) } } + } - OnboardingPageIndicator( - pageCount = PAGE_COUNT, - currentPage = pagerState.currentPage, - ) + HorizontalPager( + state = pagerState, + modifier = Modifier + .weight(1f) + .fillMaxWidth(), + ) { page -> + when (page) { + 0 -> OnboardingInfoPage( + icon = Icons.Filled.Hearing, + title = stringResource(Res.string.onboarding_goal_title), + description = stringResource(Res.string.onboarding_goal_description), + ) - Spacer(modifier = Modifier.height(24.dp)) + 1 -> OnboardingInfoPage( + icon = Icons.Filled.Subtitles, + title = stringResource(Res.string.onboarding_subtitles_title), + description = stringResource(Res.string.onboarding_subtitles_description), + ) - Button( - modifier = Modifier.fillMaxWidth(), - onClick = { - if (isLastPage) { - onFinish() - } else { - scope.launch { - pagerState.animateScrollToPage(pagerState.currentPage + 1) - } - } - }, - ) { - Text( - text = stringResource( - if (isLastPage) Res.string.onboarding_start else Res.string.onboarding_next, - ), + 2 -> OnboardingInfoPage( + icon = Icons.Filled.Speed, + title = stringResource(Res.string.onboarding_playback_title), + description = stringResource(Res.string.onboarding_playback_description), + ) + + else -> OnboardingLevelPage( + selectedLevel = uiState.selectedLevel, + onLevelSelected = onLevelSelected, ) } } + + OnboardingPageIndicator( + pageCount = PAGE_COUNT, + currentPage = pagerState.currentPage, + ) + + Spacer(modifier = Modifier.height(24.dp)) + + Button( + modifier = Modifier.fillMaxWidth(), + onClick = { + if (isLastPage) { + onFinish() + } else { + scope.launch { + pagerState.animateScrollToPage(pagerState.currentPage + 1) + } + } + }, + ) { + Text( + text = stringResource( + if (isLastPage) Res.string.onboarding_start else Res.string.onboarding_next, + ), + ) + } } } @@ -148,3 +221,23 @@ private fun OnboardingScreenPreview() { ) } } + +@Preview +@Composable +private fun OnboardingContentAsDialogPreview() { + MaterialTheme { + Surface( + modifier = Modifier.sizeIn(maxWidth = DIALOG_MAX_WIDTH, maxHeight = DIALOG_MAX_HEIGHT), + shape = MaterialTheme.shapes.extraLarge, + color = MaterialTheme.colorScheme.surfaceContainerHigh, + ) { + OnboardingContent( + uiState = OnboardingUiState( + isCompleted = false, + selectedLevel = OnboardingLevel.BEGINNER, + ), + onClose = {}, + ) + } + } +} diff --git a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt index eaee4f0..1875dd6 100644 --- a/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt +++ b/feature/onboarding/src/commonMain/kotlin/bzh/ajaury/chombev/onboarding/ui/viewmodel/OnboardingViewModel.kt @@ -36,6 +36,12 @@ class OnboardingViewModel( selectedLevel.value = level } + fun skipOnboarding() { + viewModelScope.launch { + preferencesRepository.setOnboardingCompleted() + } + } + fun completeOnboarding() { val level = selectedLevel.value viewModelScope.launch { diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt index 76028dc..701abe1 100644 --- a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/App.kt @@ -10,7 +10,10 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.saveable.rememberSerializable +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalWindowInfo import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.navigation3.runtime.NavEntry import androidx.navigation3.runtime.metadata @@ -35,6 +38,9 @@ import org.koin.compose.viewmodel.koinViewModel import org.koin.dsl.koinConfiguration import org.koin.dsl.module +private val WIDE_WINDOW_MIN_WIDTH = 840.dp +private val WIDE_WINDOW_MIN_HEIGHT = 600.dp + val appModule = module { includes( loggingModule, @@ -59,21 +65,32 @@ fun App() { val onboardingViewModel: OnboardingViewModel = koinViewModel() val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle() - when (onboardingUiState.isCompleted) { - // The persisted flag is still being read: render nothing rather than flashing - // either screen. - null -> Unit + val showOnboardingAsDialog = isWideWindow() - // First launch: onboarding replaces the whole UI. Completing it flips the persisted - // flag, which switches to the main content automatically. - false -> OnboardingScreen() + val isOnboardingVisible = onboardingUiState.isCompleted == false + val isMainContentVisible = onboardingUiState.isCompleted == true || + (isOnboardingVisible && showOnboardingAsDialog) - true -> MainNavigation(darkTheme = darkTheme) + if (isMainContentVisible) { + MainNavigation(darkTheme = darkTheme) + } + + if (isOnboardingVisible) { + OnboardingScreen(showAsDialog = showOnboardingAsDialog) } } } } +@Composable +private fun isWideWindow(): Boolean { + val containerSize = LocalWindowInfo.current.containerSize + return with(LocalDensity.current) { + containerSize.width.toDp() >= WIDE_WINDOW_MIN_WIDTH && + containerSize.height.toDp() >= WIDE_WINDOW_MIN_HEIGHT + } +} + @Composable private fun MainNavigation(darkTheme: Boolean) { val serializer = SnapshotStateListSerializer() From 46da355819c8ff8fe0d5a2ac30c39835ad41aa83 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Sat, 5 Sep 2026 15:57:52 +0200 Subject: [PATCH 16/31] refactor: improve contrast between surfaces --- shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/Color.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/Color.kt b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/Color.kt index 8c3a9d1..50cbbfb 100644 --- a/shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/Color.kt +++ b/shared/src/commonMain/kotlin/bzh/ajaury/chombev/theme/Color.kt @@ -27,7 +27,7 @@ internal val md_light_background = Color(0xFFF5FAFB) internal val md_light_onBackground = Color(0xFF171D1E) internal val md_light_surface = Color(0xFFF5FAFB) internal val md_light_onSurface = Color(0xFF171D1E) -internal val md_light_surfaceVariant = Color(0xFFDBE4E6) +internal val md_light_surfaceVariant = Color(0xFF91949C) internal val md_light_onSurfaceVariant = Color(0xFF3F484A) internal val md_light_outline = Color(0xFF6F797B) From fe0ed2391b88b385673ff5eb844de8c0867920df Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Sat, 5 Sep 2026 16:16:11 +0200 Subject: [PATCH 17/31] chore: limit player controller width to improve UI consistency --- CHANGELOG.md | 1 + .../kotlin/bzh/ajaury/chombev/player/ui/PlayerControl.kt | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e1edda2..2fd6031 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed - show on boarding as a modal on large screens +- limit player controller size ### Fixed diff --git a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerControl.kt b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerControl.kt index eb41a74..e2742fc 100644 --- a/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerControl.kt +++ b/feature/records/src/commonMain/kotlin/bzh/ajaury/chombev/player/ui/PlayerControl.kt @@ -6,6 +6,7 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.widthIn import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment @@ -48,7 +49,7 @@ fun PlayerControl( ) Row( - modifier = Modifier.fillMaxWidth(), + modifier = Modifier.widthIn(max = 350.dp).fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, ) { // Left region: the speed button is centered in the space left of the transport group. From 880aba06a25646e50f67d86ec9a7a417f72183b9 Mon Sep 17 00:00:00 2001 From: Antoine Jaury Date: Sat, 5 Sep 2026 16:23:58 +0200 Subject: [PATCH 18/31] feature: embed web pages inside the app using `