Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5d44e9ee3 | ||
|
|
5c6892efb7 | ||
|
|
e0edc52c6b | ||
|
|
6f0787b10d | ||
|
|
d843aabe19 |
@@ -32,6 +32,3 @@ data/resources/src/commonMain/composeResources/files/records.json
|
||||
keystore.properties
|
||||
*.jks
|
||||
*.keystore
|
||||
|
||||
# Secrets — see docs/sentry-glitchtip.md (the .example template stays versioned)
|
||||
.sentryclirc
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
; 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=<your-glitchtip-auth-token>
|
||||
|
||||
[defaults]
|
||||
url=https://eu.glitchtip.com/
|
||||
org=kerlenn-sten-kidna
|
||||
project=chom-bev-e-brezhoneg
|
||||
@@ -7,50 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
- improve accessibility globally in the app
|
||||
- add scrollable support for subtitle preferences bottom sheet
|
||||
- improve color contrast handling
|
||||
|
||||
### Fixed
|
||||
|
||||
## [1.0.0-beta04] - 2026-10-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- correct the way pause between phrases was handled
|
||||
|
||||
## [1.0.0-beta03] - 2026-09-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- correct transcriptions and translations for all records
|
||||
|
||||
## [1.0.0-beta02] - 2026-09-17
|
||||
|
||||
### Changed
|
||||
|
||||
- rename app to "Donemat - Breton d'Auray"
|
||||
|
||||
## [1.0.0-beta01] - 2026-09-07
|
||||
|
||||
### Changed
|
||||
|
||||
- show on boarding as a modal on large screens
|
||||
- limit player controller size
|
||||
- browse the web pages inside the app on web, instead of only linking to them
|
||||
- embed web pages inside the app on web
|
||||
|
||||
### 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.
|
||||
- add emoji fallback for web
|
||||
- correct seeking to a sentence on web
|
||||
|
||||
## [1.0.0-alpha02] - 2026-08-04
|
||||
|
||||
### Added
|
||||
@@ -75,13 +31,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [1.0.0-alpha01] - 2026-07-08
|
||||
|
||||
First public release of **Donemat - Breton d'Auray**.
|
||||
First public release of **Chom Bev e Brezhoneg**.
|
||||
|
||||
### Added
|
||||
|
||||
#### Listening
|
||||
|
||||
- Record list of the *Donemat - Breton d'Auray* audio course, with an emoji, the Breton title and
|
||||
- Record list of the *Chom Bev* audio course, with an emoji, the Breton title and
|
||||
its French translation for each record.
|
||||
- Audio player with play/pause/replay, a seekable progress bar, and
|
||||
previous/next-sentence navigation.
|
||||
@@ -108,7 +64,7 @@ First public release of **Donemat - Breton d'Auray**.
|
||||
- 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 `javax.sound.sampled` (Desktop).
|
||||
audio APIs (Web) and VorbisSPI (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.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# "Donemat - Breton d'Auray" - Survive in Breton
|
||||
# Chom Bev e Brezhoneg - Survive in Breton - Survive en Breton
|
||||
|
||||
This is a Compose Multiplatform project to listen to Breton from Vannes country. It uses an audio
|
||||
player adapted for each platform and synchronised subtitles.
|
||||
@@ -93,24 +93,8 @@ 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
|
||||
|
||||
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.
|
||||
|
||||
```
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# 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)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
@@ -11,11 +12,7 @@ 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 {
|
||||
@@ -30,6 +27,9 @@ dependencies {
|
||||
implementation(libs.androidx.activity.compose)
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
debugImplementation(libs.compose.uiTooling)
|
||||
|
||||
// Crash logging
|
||||
implementation(libs.glitchtips)
|
||||
}
|
||||
|
||||
android {
|
||||
@@ -40,7 +40,7 @@ android {
|
||||
.toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "bzh.ksk.donemat"
|
||||
applicationId = "bzh.ajaury.chombev"
|
||||
minSdk =
|
||||
libs.versions.androidMinSdk
|
||||
.get()
|
||||
@@ -49,8 +49,12 @@ android {
|
||||
libs.versions.androidTargetSdk
|
||||
.get()
|
||||
.toInt()
|
||||
versionCode = 5
|
||||
versionName = "1.0.0-beta04"
|
||||
versionCode = 1
|
||||
versionName = "1.0.0-alpha01"
|
||||
|
||||
// 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 {
|
||||
@@ -68,3 +72,12 @@ 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)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,11 @@
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Injected from local.properties (glitchtip.dsn); an empty DSN disables the SDK. -->
|
||||
<meta-data android:name="io.sentry.dsn" android:value="${glitchtipDsn}" />
|
||||
<meta-data android:name="io.sentry.traces.sample-rate" android:value="0.01" />
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -10,11 +10,10 @@ 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
|
||||
|
||||
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 1.9 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 1010 B After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 2.7 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 4.2 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 5.8 KiB After Width: | Height: | Size: 16 KiB |
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Donemat - Breton d\'Auray</string>
|
||||
<string name="app_name">Chom Bev</string>
|
||||
</resources>
|
||||
@@ -1,84 +0,0 @@
|
||||
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<Properties> =
|
||||
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<String> =
|
||||
providers
|
||||
.environmentVariable(environmentVariable)
|
||||
.orElse(localProperties.map { properties -> properties.getProperty(localPropertyKey).orEmpty() })
|
||||
.orElse("")
|
||||
|
||||
// GlitchTip DSN, consumed by initializeSentry() in the shared module.
|
||||
val glitchtipDsn: Provider<String> = 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)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
plugins {
|
||||
id("gwenedeg.kmp.library")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
androidLibrary {
|
||||
namespace = "bzh.ajaury.chombev.core.accessibility"
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
package bzh.ajaury.chombev.core.accessibility.data
|
||||
|
||||
import android.content.Context
|
||||
import android.view.accessibility.AccessibilityManager
|
||||
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
|
||||
|
||||
internal class AndroidAccessibilityRepository(
|
||||
context: Context,
|
||||
) : AccessibilityRepository {
|
||||
private val accessibilityManager = context.getSystemService(AccessibilityManager::class.java)
|
||||
|
||||
// Screen readers such as TalkBack turn on touch exploration. Other accessibility services
|
||||
// (password managers, automation tools) do not, so `isEnabled` alone would misreport them.
|
||||
override fun isScreenReaderEnabled(): Boolean = accessibilityManager?.isTouchExplorationEnabled == true
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package bzh.ajaury.chombev.core.accessibility.di
|
||||
|
||||
import bzh.ajaury.chombev.core.accessibility.data.AndroidAccessibilityRepository
|
||||
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
actual val accessibilityModule: Module = module {
|
||||
singleOf(::AndroidAccessibilityRepository) { bind<AccessibilityRepository>() }
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
package bzh.ajaury.chombev.core.accessibility.data
|
||||
|
||||
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
|
||||
|
||||
internal class UndetectableAccessibilityRepository : AccessibilityRepository {
|
||||
override fun isScreenReaderEnabled(): Boolean = false
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
package bzh.ajaury.chombev.core.accessibility.di
|
||||
|
||||
import org.koin.core.module.Module
|
||||
|
||||
expect val accessibilityModule: Module
|
||||
@@ -1,5 +0,0 @@
|
||||
package bzh.ajaury.chombev.core.accessibility.domain
|
||||
|
||||
interface AccessibilityRepository {
|
||||
fun isScreenReaderEnabled(): Boolean
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
package bzh.ajaury.chombev.core.accessibility.data
|
||||
|
||||
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
|
||||
import platform.UIKit.UIAccessibilityIsVoiceOverRunning
|
||||
|
||||
internal class IosAccessibilityRepository : AccessibilityRepository {
|
||||
override fun isScreenReaderEnabled(): Boolean = UIAccessibilityIsVoiceOverRunning()
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package bzh.ajaury.chombev.core.accessibility.di
|
||||
|
||||
import bzh.ajaury.chombev.core.accessibility.data.IosAccessibilityRepository
|
||||
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
actual val accessibilityModule: Module = module {
|
||||
singleOf(::IosAccessibilityRepository) { bind<AccessibilityRepository>() }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package bzh.ajaury.chombev.core.accessibility.di
|
||||
|
||||
import bzh.ajaury.chombev.core.accessibility.data.UndetectableAccessibilityRepository
|
||||
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
actual val accessibilityModule: Module = module {
|
||||
singleOf(::UndetectableAccessibilityRepository) { bind<AccessibilityRepository>() }
|
||||
}
|
||||
@@ -1,12 +0,0 @@
|
||||
package bzh.ajaury.chombev.core.accessibility.di
|
||||
|
||||
import bzh.ajaury.chombev.core.accessibility.data.UndetectableAccessibilityRepository
|
||||
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
|
||||
import org.koin.core.module.Module
|
||||
import org.koin.core.module.dsl.bind
|
||||
import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
actual val accessibilityModule: Module = module {
|
||||
singleOf(::UndetectableAccessibilityRepository) { bind<AccessibilityRepository>() }
|
||||
}
|
||||
@@ -14,8 +14,8 @@ kotlin {
|
||||
implementation(libs.media3.common)
|
||||
}
|
||||
jvmMain.dependencies {
|
||||
// Desktop audio: AAC/MP4 SPI so javax.sound.sampled can decode .m4a
|
||||
implementation(libs.jaad)
|
||||
// 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 {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
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
|
||||
@@ -10,6 +11,7 @@ import org.koin.core.module.dsl.singleOf
|
||||
import org.koin.dsl.module
|
||||
|
||||
actual val audioPlayerModule: Module = module {
|
||||
includes(loggingModule)
|
||||
singleOf(::IosAudioPlayer) { bind<AudioPlayer>() }
|
||||
singleOf(::IosAudioSessionManager) { bind<AudioSessionManager>() }
|
||||
}
|
||||
|
||||
@@ -18,9 +18,9 @@ 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.
|
||||
* 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
|
||||
|
||||
@@ -24,8 +24,7 @@ internal class WebAudioPlayer : AudioPlayer {
|
||||
get() = (audio?.duration?.takeIf { it.isFinite() } ?: 0.0).seconds
|
||||
|
||||
override fun seekTo(position: Duration) {
|
||||
audio?.currentTime = position.inWholeMilliseconds.toDouble() / 1000
|
||||
audio?.play()
|
||||
audio?.currentTime = position.inWholeMicroseconds.toDouble()
|
||||
}
|
||||
|
||||
override fun load(uri: String) {
|
||||
|
||||
@@ -3,7 +3,7 @@ package bzh.ajaury.chombev.core.logging.data
|
||||
import bzh.ajaury.chombev.core.logging.domain.Logger
|
||||
|
||||
internal class KermitLogger : Logger {
|
||||
private val tag: String = "Donemat"
|
||||
private val tag: String = "Chom Bev"
|
||||
|
||||
override fun debug(
|
||||
message: String,
|
||||
|
||||
@@ -21,8 +21,5 @@ kotlin {
|
||||
implementation(projects.data.resources)
|
||||
implementation(projects.data.subtitle)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlinx.coroutinesTest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ import bzh.ajaury.chombev.player.model.PlaybackState
|
||||
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
||||
import bzh.ajaury.chombev.player.model.PlayerState
|
||||
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
|
||||
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
||||
import bzh.ajaury.chombev.resources.domain.ResourceReader
|
||||
import bzh.ajaury.chombev.subtitle.domain.GetCurrentSubtitleIndexUseCase
|
||||
import bzh.ajaury.chombev.subtitle.domain.SubtitleRepository
|
||||
@@ -19,17 +18,23 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration
|
||||
@@ -48,18 +53,13 @@ internal class PlaybackRepositoryImpl(
|
||||
) : PlaybackRepository {
|
||||
// Audio operations must run on the main thread (e.g. ExoPlayer), so keep the scope on it.
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.main)
|
||||
private var preferencesObservationJob: Job? = null
|
||||
private var speedObservationJob: Job? = null
|
||||
private var phraseBreakJob: Job? = null
|
||||
private var playbackPreferences = PlaybackPreferences()
|
||||
|
||||
// Pause-then-resume of an in-progress phrase break; cancelled when the user takes over.
|
||||
private var breakJob: Job? = null
|
||||
private val isBreaking = MutableStateFlow(false)
|
||||
|
||||
private var reachedPhraseIndex = -1
|
||||
private var pendingPhraseStart: Duration? = null
|
||||
private var pendingSeekTarget: Duration? = null
|
||||
|
||||
private val recordTitle = MutableStateFlow(Phrase(transcription = ""))
|
||||
private val subtitle = MutableStateFlow(Subtitle(emptyList()))
|
||||
|
||||
@@ -113,16 +113,45 @@ internal class PlaybackRepositoryImpl(
|
||||
subtitle,
|
||||
currentPosition,
|
||||
) { subtitle, position ->
|
||||
val currentSubtitleIndex = getCurrentSubtitleIndex(
|
||||
subtitle = subtitle,
|
||||
position = position,
|
||||
)
|
||||
|
||||
val isGoingToPlayNextPhrase = isGoingToPlayNextPhrase(
|
||||
currentSubtitleIndex = currentSubtitleIndex,
|
||||
subtitle = subtitle,
|
||||
position = position,
|
||||
)
|
||||
|
||||
SubtitleProgress(
|
||||
subtitle = subtitle,
|
||||
currentPosition = position,
|
||||
currentSubtitleIndex = getCurrentSubtitleIndex(
|
||||
subtitle = subtitle,
|
||||
position = position,
|
||||
),
|
||||
currentSubtitleIndex = currentSubtitleIndex,
|
||||
isGoingToPlayNextPhrase = isGoingToPlayNextPhrase,
|
||||
)
|
||||
}
|
||||
|
||||
private fun isGoingToPlayNextPhrase(
|
||||
currentSubtitleIndex: Int?,
|
||||
subtitle: Subtitle,
|
||||
position: Duration,
|
||||
): Boolean {
|
||||
if (currentSubtitleIndex == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
val nextSubtitleIndex = currentSubtitleIndex + 1
|
||||
if (nextSubtitleIndex <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
val nextSubtitle = subtitle.lines.getOrNull(nextSubtitleIndex)
|
||||
?: return false
|
||||
|
||||
return nextSubtitle.startTime < position + 2 * POSITION_POLL_INTERVAL
|
||||
}
|
||||
|
||||
override val playerState: Flow<PlayerState> = combine(
|
||||
playbackState,
|
||||
playbackTiming,
|
||||
@@ -138,31 +167,20 @@ internal class PlaybackRepositoryImpl(
|
||||
)
|
||||
}
|
||||
|
||||
override suspend fun load(
|
||||
record: Record,
|
||||
autoPlay: Boolean,
|
||||
) {
|
||||
phraseBreakJob?.cancel()
|
||||
cancelRunningBreak()
|
||||
pendingPhraseStart = null
|
||||
pendingSeekTarget = null
|
||||
|
||||
loadAudio(filePath = record.audioResourcePath, autoPlay = autoPlay)
|
||||
override suspend fun load(record: Record) {
|
||||
playAudio(filePath = record.audioResourcePath)
|
||||
subtitle.value = loadSubtitle(record = record)
|
||||
reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO)
|
||||
recordTitle.value = record.title
|
||||
|
||||
observePlaybackPreferences()
|
||||
observePlaybackSpeed()
|
||||
observePhraseBreaks()
|
||||
}
|
||||
|
||||
private fun observePlaybackPreferences() {
|
||||
preferencesObservationJob?.cancel()
|
||||
preferencesObservationJob = scope.launch {
|
||||
preferencesRepository.playbackPreferences.collect { preferences ->
|
||||
// Cached so that reaching a phrase reacts immediately, without a suspending read.
|
||||
playbackPreferences = preferences
|
||||
setSpeed(speed = preferences.speed)
|
||||
private fun observePlaybackSpeed() {
|
||||
speedObservationJob?.cancel()
|
||||
speedObservationJob = scope.launch {
|
||||
preferencesRepository.playbackPreferences.collect { playbackPreferences ->
|
||||
setSpeed(speed = playbackPreferences.speed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -175,107 +193,57 @@ internal class PlaybackRepositoryImpl(
|
||||
*
|
||||
* Auto-pause takes precedence: while it is on, the break between phrases is ignored.
|
||||
*
|
||||
* A phrase is considered reached as soon as a polled position is past its start minus
|
||||
* [PHRASE_LOOKAHEAD], even if the poll came late and the phrase already started: in that case
|
||||
* playback is rewound to the phrase start when resuming. Each phrase is only handled once.
|
||||
*
|
||||
* Manual seeking and previous/next navigation never trigger a break: [seekAudio] marks the
|
||||
* phrase at the target position as already reached.
|
||||
* This only happens on automatic advancement — manual seeking and previous/next navigation are
|
||||
* ignored because they cancel the running break job and never re-enter [PlaybackState.PLAYING]
|
||||
* through this path.
|
||||
*/
|
||||
private fun observePhraseBreaks() {
|
||||
private suspend fun observePhraseBreaks() {
|
||||
phraseBreakJob?.cancel()
|
||||
phraseBreakJob = scope.launch {
|
||||
playbackState.collectLatest { state ->
|
||||
if (state != PlaybackState.PLAYING) return@collectLatest
|
||||
while (true) {
|
||||
checkPhraseBoundary()
|
||||
delay(POSITION_POLL_INTERVAL)
|
||||
cancelRunningBreakJob()
|
||||
phraseBreakJob = subtitleProgress
|
||||
.map { it.isGoingToPlayNextPhrase }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.onEach {
|
||||
if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach
|
||||
|
||||
val playbackPreferences = preferencesRepository.playbackPreferences.first()
|
||||
if (playbackPreferences.autoPause) {
|
||||
audioPlayer.pause()
|
||||
return@onEach
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val breakDuration = playbackPreferences.breakBetweenPhrases
|
||||
if (breakDuration <= Duration.ZERO) return@onEach
|
||||
|
||||
insertBreakBeforePhrase(breakDuration = breakDuration)
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
private fun checkPhraseBoundary() {
|
||||
if (isBreaking.value || audioPlayer.playbackState.value != PlaybackState.PLAYING) return
|
||||
|
||||
val position = audioPlayer.currentPosition
|
||||
pendingSeekTarget?.let { target ->
|
||||
// Some players (e.g. AVPlayer) seek asynchronously and still report the pre-seek
|
||||
// position for a while: ignore it, or it could be taken for a phrase change.
|
||||
if ((position - target).absoluteValue > SEEK_TOLERANCE) return
|
||||
pendingSeekTarget = null
|
||||
}
|
||||
|
||||
val upcomingIndex = upcomingPhraseIndex(position = position)
|
||||
if (upcomingIndex <= reachedPhraseIndex) return
|
||||
reachedPhraseIndex = upcomingIndex
|
||||
|
||||
// There is no break before the very first phrase.
|
||||
if (upcomingIndex == 0) return
|
||||
val phraseStart = subtitle.value.lines[upcomingIndex].startTime
|
||||
|
||||
val preferences = playbackPreferences
|
||||
when {
|
||||
preferences.autoPause -> {
|
||||
pendingPhraseStart = phraseStart
|
||||
audioPlayer.pause()
|
||||
}
|
||||
|
||||
preferences.breakBetweenPhrases > Duration.ZERO -> {
|
||||
insertBreakBeforePhrase(
|
||||
breakDuration = preferences.breakBetweenPhrases,
|
||||
phraseStart = phraseStart,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertBreakBeforePhrase(
|
||||
breakDuration: Duration,
|
||||
phraseStart: Duration,
|
||||
) {
|
||||
breakJob?.cancel()
|
||||
pendingPhraseStart = phraseStart
|
||||
isBreaking.value = true
|
||||
audioPlayer.pause()
|
||||
private suspend fun insertBreakBeforePhrase(breakDuration: Duration) {
|
||||
breakJob?.cancelAndJoin()
|
||||
breakJob = scope.launch {
|
||||
delay(breakDuration)
|
||||
isBreaking.value = false
|
||||
resumeAtPhraseStart(phraseStart = phraseStart)
|
||||
try {
|
||||
// Pause and rewind the polling overshoot so the next phrase resumes from its very start.
|
||||
isBreaking.value = true
|
||||
audioPlayer.pause()
|
||||
delay(breakDuration)
|
||||
} finally {
|
||||
// Only resume if still paused by this break; the user may have acted during it.
|
||||
if (playbackState.firstOrNull() == PlaybackState.BREAKING) {
|
||||
audioPlayer.play()
|
||||
}
|
||||
isBreaking.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun resumeAtPhraseStart(phraseStart: Duration) {
|
||||
pendingPhraseStart = null
|
||||
// The phrase may have been detected a little late: rewind so it is heard from its start.
|
||||
if (audioPlayer.currentPosition > phraseStart) {
|
||||
seekAudio(position = phraseStart)
|
||||
}
|
||||
audioPlayer.play()
|
||||
}
|
||||
|
||||
private fun upcomingPhraseIndex(position: Duration): Int =
|
||||
getCurrentSubtitleIndex(
|
||||
subtitle = subtitle.value,
|
||||
position = position + PHRASE_LOOKAHEAD,
|
||||
) ?: -1
|
||||
|
||||
private fun seekAudio(position: Duration) {
|
||||
pendingPhraseStart = null
|
||||
reachedPhraseIndex = upcomingPhraseIndex(position = position)
|
||||
pendingSeekTarget = position
|
||||
audioPlayer.seekTo(position)
|
||||
}
|
||||
|
||||
private fun loadAudio(
|
||||
filePath: String,
|
||||
autoPlay: Boolean,
|
||||
) {
|
||||
private fun playAudio(filePath: String) {
|
||||
try {
|
||||
audioPlayer.load(uri = resourceReader.uri(filePath))
|
||||
if (autoPlay) audioPlayer.play()
|
||||
audioPlayer.play()
|
||||
} catch (exception: Exception) {
|
||||
logger.error(message = "Failed to load audio: $filePath", throwable = exception)
|
||||
logger.error(message = "Failed to play audio: $filePath", throwable = exception)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -297,30 +265,24 @@ internal class PlaybackRepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun play() {
|
||||
cancelRunningBreak()
|
||||
val phraseStart = pendingPhraseStart
|
||||
if (phraseStart != null) {
|
||||
resumeAtPhraseStart(phraseStart = phraseStart)
|
||||
} else {
|
||||
audioPlayer.play()
|
||||
}
|
||||
cancelRunningBreakJob()
|
||||
audioPlayer.play()
|
||||
}
|
||||
|
||||
override suspend fun pause() {
|
||||
cancelRunningBreak()
|
||||
cancelRunningBreakJob()
|
||||
audioPlayer.pause()
|
||||
}
|
||||
|
||||
override suspend fun replay() {
|
||||
cancelRunningBreak()
|
||||
seekAudio(position = Duration.ZERO)
|
||||
cancelRunningBreakJob()
|
||||
audioPlayer.seekTo(Duration.ZERO)
|
||||
audioPlayer.play()
|
||||
}
|
||||
|
||||
override suspend fun seekTo(position: Duration) {
|
||||
val wasBreaking = cancelRunningBreak()
|
||||
seekAudio(position = position)
|
||||
if (wasBreaking) audioPlayer.play()
|
||||
cancelRunningBreakJob()
|
||||
audioPlayer.seekTo(position)
|
||||
}
|
||||
|
||||
private fun setSpeed(speed: Float) {
|
||||
@@ -348,8 +310,9 @@ internal class PlaybackRepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun seekToSentence(sentenceIndex: Int) {
|
||||
cancelRunningBreakJob()
|
||||
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
|
||||
seekTo(position = matchingSubtitleLine.startTime)
|
||||
audioPlayer.seekTo(matchingSubtitleLine.startTime)
|
||||
}
|
||||
|
||||
private fun currentSentenceIndex(): Int? =
|
||||
@@ -359,33 +322,32 @@ internal class PlaybackRepositoryImpl(
|
||||
)
|
||||
|
||||
override suspend fun stop() {
|
||||
phraseBreakJob?.cancel()
|
||||
cancelRunningBreak()
|
||||
pendingPhraseStart = null
|
||||
breakJob?.cancel()
|
||||
isBreaking.value = false
|
||||
subtitle.value = Subtitle(emptyList())
|
||||
audioPlayer.stop()
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
phraseBreakJob?.cancel()
|
||||
cancelRunningBreak()
|
||||
pendingPhraseStart = null
|
||||
breakJob?.cancel()
|
||||
isBreaking.value = false
|
||||
audioPlayer.release()
|
||||
subtitle.value = Subtitle(emptyList())
|
||||
}
|
||||
|
||||
private fun cancelRunningBreak(): Boolean {
|
||||
breakJob?.cancel()
|
||||
breakJob = null
|
||||
val wasBreaking = isBreaking.value
|
||||
private suspend fun cancelRunningBreakJob() {
|
||||
breakJob?.cancelAndJoin()
|
||||
|
||||
// Unlock if paused by a break.
|
||||
if (playbackState.value == PlaybackState.BREAKING) {
|
||||
audioPlayer.play()
|
||||
}
|
||||
|
||||
isBreaking.value = false
|
||||
return wasBreaking
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val POSITION_POLL_INTERVAL = 50.milliseconds
|
||||
private val PHRASE_LOOKAHEAD = 2 * POSITION_POLL_INTERVAL
|
||||
private val SEEK_TOLERANCE = 500.milliseconds
|
||||
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,4 +7,5 @@ data class SubtitleProgress(
|
||||
val subtitle: Subtitle,
|
||||
val currentPosition: Duration,
|
||||
val currentSubtitleIndex: Int?,
|
||||
val isGoingToPlayNextPhrase: Boolean,
|
||||
)
|
||||
|
||||
@@ -15,10 +15,7 @@ import kotlin.time.Duration
|
||||
interface PlaybackRepository {
|
||||
val playerState: Flow<PlayerState>
|
||||
|
||||
suspend fun load(
|
||||
record: Record,
|
||||
autoPlay: Boolean = true,
|
||||
)
|
||||
suspend fun load(record: Record)
|
||||
|
||||
suspend fun play()
|
||||
|
||||
|
||||
@@ -1,249 +0,0 @@
|
||||
package bzh.ajaury.chombev.player.data
|
||||
|
||||
import bzh.ajaury.chombev.core.coroutines.domain.DispatcherProvider
|
||||
import bzh.ajaury.chombev.core.logging.domain.Logger
|
||||
import bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.core.model.Record
|
||||
import bzh.ajaury.chombev.player.domain.AudioPlayer
|
||||
import bzh.ajaury.chombev.player.model.PlaybackState
|
||||
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
|
||||
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
||||
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
||||
import bzh.ajaury.chombev.resources.domain.ResourceReader
|
||||
import bzh.ajaury.chombev.subtitle.domain.GetCurrentSubtitleIndexUseCase
|
||||
import bzh.ajaury.chombev.subtitle.domain.SubtitleRepository
|
||||
import bzh.ajaury.chombev.subtitle.model.Subtitle
|
||||
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PlaybackRepositoryImplTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val audioPlayer = FakeAudioPlayer()
|
||||
private val preferences = MutableStateFlow(PlaybackPreferences(breakBetweenPhrases = 2.seconds))
|
||||
|
||||
private val repository = PlaybackRepositoryImpl(
|
||||
audioPlayer = audioPlayer,
|
||||
resourceReader = FakeResourceReader,
|
||||
subtitleRepository = FakeSubtitleRepository,
|
||||
preferencesRepository = FakePreferencesRepository(preferences),
|
||||
getCurrentSubtitleIndex = GetCurrentSubtitleIndexUseCase(),
|
||||
logger = FakeLogger,
|
||||
dispatcherProvider = FakeDispatcherProvider(dispatcher),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun breaks_before_the_next_phrase_when_the_poll_lands_just_before_it() = runTest(dispatcher) {
|
||||
loadAndPlay()
|
||||
|
||||
playTo(SECOND_PHRASE_START - 60.milliseconds)
|
||||
|
||||
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||
advanceTimeBy(2.seconds + 1.milliseconds)
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
assertEquals(SECOND_PHRASE_START - 60.milliseconds, audioPlayer.currentPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun breaks_and_rewinds_when_the_poll_lands_after_the_next_phrase_started() = runTest(dispatcher) {
|
||||
loadAndPlay()
|
||||
|
||||
// A late poll (e.g. busy main thread) skips the window right before the phrase start.
|
||||
playTo(SECOND_PHRASE_START + 80.milliseconds)
|
||||
|
||||
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||
advanceTimeBy(2.seconds + 1.milliseconds)
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
assertEquals(SECOND_PHRASE_START, audioPlayer.currentPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun breaks_only_once_per_phrase() = runTest(dispatcher) {
|
||||
loadAndPlay()
|
||||
playTo(SECOND_PHRASE_START - 60.milliseconds)
|
||||
advanceTimeBy(2.seconds + 1.milliseconds)
|
||||
|
||||
playTo(SECOND_PHRASE_START + 500.milliseconds)
|
||||
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auto_pause_stops_and_resumes_from_the_phrase_start() = runTest(dispatcher) {
|
||||
preferences.value = PlaybackPreferences(autoPause = true)
|
||||
loadAndPlay()
|
||||
|
||||
playTo(SECOND_PHRASE_START + 80.milliseconds)
|
||||
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||
|
||||
advanceTimeBy(10.seconds)
|
||||
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||
|
||||
repository.play()
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
assertEquals(SECOND_PHRASE_START, audioPlayer.currentPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seeking_to_the_next_sentence_does_not_break() = runTest(dispatcher) {
|
||||
loadAndPlay()
|
||||
playTo(1.seconds)
|
||||
|
||||
repository.goToNextSentence()
|
||||
runCurrent()
|
||||
advanceTimeBy(200.milliseconds)
|
||||
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_stale_position_after_a_backward_seek_does_not_break() = runTest(dispatcher) {
|
||||
loadAndPlay()
|
||||
playTo(SECOND_PHRASE_START + 1.seconds)
|
||||
advanceTimeBy(3.seconds)
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
|
||||
// Like AVPlayer, the player still reports the pre-seek position for a while.
|
||||
audioPlayer.asynchronousSeek = true
|
||||
repository.seekToSentence(0)
|
||||
advanceTimeBy(200.milliseconds)
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
|
||||
audioPlayer.completeSeek()
|
||||
advanceTimeBy(200.milliseconds)
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
}
|
||||
|
||||
private suspend fun TestScope.loadAndPlay() {
|
||||
repository.load(record)
|
||||
runCurrent()
|
||||
}
|
||||
|
||||
private fun TestScope.playTo(position: Duration) {
|
||||
audioPlayer.position = position
|
||||
advanceTimeBy(60.milliseconds)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SECOND_PHRASE_START = 4310.milliseconds
|
||||
|
||||
val record = Record(
|
||||
id = 1,
|
||||
index = 0,
|
||||
emoji = "",
|
||||
title = Phrase(transcription = ""),
|
||||
audioResourcePath = "audio",
|
||||
subtitleResourcePath = "subtitle",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeAudioPlayer : AudioPlayer {
|
||||
override val playbackState = MutableStateFlow(PlaybackState.IDLE)
|
||||
var position: Duration = Duration.ZERO
|
||||
var asynchronousSeek = false
|
||||
private var pendingSeek: Duration? = null
|
||||
|
||||
override val currentPosition: Duration get() = position
|
||||
override val duration: Duration = 10.seconds
|
||||
|
||||
override fun seekTo(position: Duration) {
|
||||
if (asynchronousSeek) pendingSeek = position else this.position = position
|
||||
}
|
||||
|
||||
fun completeSeek() {
|
||||
pendingSeek?.let { position = it }
|
||||
pendingSeek = null
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
playbackState.value = PlaybackState.PLAYING
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
playbackState.value = PlaybackState.PAUSED
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
playbackState.value = PlaybackState.IDLE
|
||||
}
|
||||
|
||||
override fun release() = stop()
|
||||
|
||||
override fun load(uri: String) = Unit
|
||||
|
||||
override fun setSpeed(speed: Float) = Unit
|
||||
}
|
||||
|
||||
private object FakeResourceReader : ResourceReader {
|
||||
override suspend fun read(resourcePath: String): String = ""
|
||||
|
||||
override fun uri(resourcePath: String): String = resourcePath
|
||||
}
|
||||
|
||||
private object FakeSubtitleRepository : SubtitleRepository {
|
||||
override suspend fun getSubtitle(
|
||||
transcriptionContent: String,
|
||||
translationContent: String?,
|
||||
): Subtitle = Subtitle(
|
||||
lines = listOf(
|
||||
SubtitleLine(startTime = 0.milliseconds, phrase = Phrase(transcription = "Demat deoc'h !")),
|
||||
SubtitleLine(startTime = 4310.milliseconds, phrase = Phrase(transcription = "Kenavo !")),
|
||||
SubtitleLine(startTime = 7350.milliseconds, phrase = Phrase(transcription = "Kenavo emberr !")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private class FakePreferencesRepository(
|
||||
override val playbackPreferences: StateFlow<PlaybackPreferences>,
|
||||
) : PreferencesRepository {
|
||||
override val subtitlePreferences: Flow<SubtitlePreferences> = flowOf(SubtitlePreferences())
|
||||
override val onboardingCompleted: Flow<Boolean> = flowOf(true)
|
||||
|
||||
override suspend fun setShowTranscription(enabled: Boolean) = Unit
|
||||
|
||||
override suspend fun setShowTranslation(enabled: Boolean) = Unit
|
||||
|
||||
override suspend fun setSubtitleTextScale(scale: Float) = Unit
|
||||
|
||||
override suspend fun setShowPhoneticMarkers(enabled: Boolean) = Unit
|
||||
|
||||
override suspend fun setPlaybackSpeed(speed: Float) = Unit
|
||||
|
||||
override suspend fun setBreakBetweenPhrases(duration: Duration) = Unit
|
||||
|
||||
override suspend fun setAutoPause(enabled: Boolean) = Unit
|
||||
|
||||
override suspend fun setOnboardingCompleted() = Unit
|
||||
}
|
||||
|
||||
private object FakeLogger : Logger {
|
||||
override fun debug(message: String, throwable: Throwable?) = Unit
|
||||
|
||||
override fun info(message: String, throwable: Throwable?) = Unit
|
||||
|
||||
override fun warning(message: String, throwable: Throwable?) = Unit
|
||||
|
||||
override fun error(message: String, throwable: Throwable?) = Unit
|
||||
}
|
||||
|
||||
private class FakeDispatcherProvider(dispatcher: CoroutineDispatcher) : DispatcherProvider {
|
||||
override val main = dispatcher
|
||||
override val default = dispatcher
|
||||
override val io = dispatcher
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
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.
|
||||
@@ -0,0 +1,44 @@
|
||||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:aapt="http://schemas.android.com/aapt"
|
||||
android:width="450dp"
|
||||
android:height="450dp"
|
||||
android:viewportWidth="64"
|
||||
android:viewportHeight="64">
|
||||
<path
|
||||
android:pathData="M56.25,18V46L32,60 7.75,46V18L32,4Z"
|
||||
android:fillColor="#6075f2"/>
|
||||
<path
|
||||
android:pathData="m41.5,26.5v11L32,43V60L56.25,46V18Z"
|
||||
android:fillColor="#6b57ff"/>
|
||||
<path
|
||||
android:pathData="m32,43 l-9.5,-5.5v-11L7.75,18V46L32,60Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:centerX="23.131"
|
||||
android:centerY="18.441"
|
||||
android:gradientRadius="42.132"
|
||||
android:type="radial">
|
||||
<item android:offset="0" android:color="#FF5383EC"/>
|
||||
<item android:offset="0.867" android:color="#FF7F52FF"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="M22.5,26.5 L32,21 41.5,26.5 56.25,18 32,4 7.75,18Z">
|
||||
<aapt:attr name="android:fillColor">
|
||||
<gradient
|
||||
android:startX="44.172"
|
||||
android:startY="4.377"
|
||||
android:endX="17.973"
|
||||
android:endY="34.035"
|
||||
android:type="linear">
|
||||
<item android:offset="0" android:color="#FF33C3FF"/>
|
||||
<item android:offset="0.878" android:color="#FF5383EC"/>
|
||||
</gradient>
|
||||
</aapt:attr>
|
||||
</path>
|
||||
<path
|
||||
android:pathData="m32,21 l9.526,5.5v11L32,43 22.474,37.5v-11z"
|
||||
android:fillColor="#000000"/>
|
||||
</vector>
|
||||
@@ -0,0 +1,52 @@
|
||||
<resources>
|
||||
<!-- Breton (br) -->
|
||||
<string name="app_name">Chom bev</string>
|
||||
|
||||
<string name="nav_records">Dastumoù</string>
|
||||
<string name="nav_deepen">Donaat</string>
|
||||
<string name="nav_about">Diwar-benn</string>
|
||||
|
||||
<string name="web_unavailable_message">N\'haller ket diskouez ar bajenn-mañ amañ.</string>
|
||||
<string name="web_open_in_browser">Digeriñ er merdeer</string>
|
||||
|
||||
<string name="subtitle_preferences_title">Istitloù</string>
|
||||
<string name="subtitle_preferences_action">Gwellvezioù istitloù</string>
|
||||
<string name="subtitle_transcription_label">Treuzskrivañ (BZH)</string>
|
||||
<string name="subtitle_transcription_description">Diskouez an treuzskrivadur brezhonek</string>
|
||||
<string name="subtitle_translation_label">Troidigezh (FR)</string>
|
||||
<string name="subtitle_translation_description">Diskouez an droidigezh c\'hallek</string>
|
||||
<string name="subtitle_text_size_label">Ment an destenn</string>
|
||||
<string name="subtitle_text_size_decrease">Bihanaat ment an destenn</string>
|
||||
<string name="subtitle_text_size_increase">Brasaat ment an destenn</string>
|
||||
<string name="subtitle_marked_letters_label">Merkoù distagañ</string>
|
||||
<string name="subtitle_marked_letters_description">Diskouez ar c\'hensonennoù mut ha al liammoù</string>
|
||||
|
||||
<string name="playback_speed_title">Tizh al lenn</string>
|
||||
<string name="playback_settings_action">Arventennoù al lenn</string>
|
||||
<string name="break_between_phrases_title">Ehan etre ar frazennoù</string>
|
||||
<string name="break_between_phrases_value">%1$d s</string>
|
||||
<string name="auto_pause_title">Ehan emgefreek</string>
|
||||
<string name="auto_pause_description">Ehanañ goude pep frazenn</string>
|
||||
|
||||
<string name="onboarding_goal_title">Degemer mat e Chom bev</string>
|
||||
<string name="onboarding_goal_description">Deskit brezhoneg en ur selaou enrolladennoù, frazenn ha frazenn, evit gwellaat ho komprenidigezh hag ho tistagadur.</string>
|
||||
<string name="onboarding_subtitles_title">Istitloù diouzh ho tro</string>
|
||||
<string name="onboarding_subtitles_description">Adalek al lenner, diskouezit pe kuzhit an treuzskrivadur brezhonek hag an droidigezh c\'hallek, cheñchit ment an destenn hag ar merkoù distagañ.</string>
|
||||
<string name="onboarding_playback_title">Un tizh diouzh ho live</string>
|
||||
<string name="onboarding_playback_description">Gorrekait tizh al lenn hag ouzhpennit un ehan etre ar frazennoù evit kaout amzer da adlavaret.</string>
|
||||
<string name="onboarding_level_title">Dibabit ho live</string>
|
||||
<string name="onboarding_level_description">Gallout a reot cheñch an arventennoù-se pa garot adalek al lenner.</string>
|
||||
<string name="onboarding_level_beginner_title">Deraouad</string>
|
||||
<string name="onboarding_level_beginner_description">Lenn gorrekaet (x0.9) gant un ehan 3 s etre ar frazennoù</string>
|
||||
<string name="onboarding_level_advanced_title">Boas / araokaet</string>
|
||||
<string name="onboarding_level_advanced_description">Tizh boas, hep ehan etre ar frazennoù</string>
|
||||
<string name="onboarding_next">War-lerc\'h</string>
|
||||
<string name="onboarding_start">Kregiñ</string>
|
||||
|
||||
<string name="player_back">Distreiñ</string>
|
||||
<string name="player_previous_sentence">Frazenn a-raok</string>
|
||||
<string name="player_next_sentence">Frazenn war-lerc\'h</string>
|
||||
<string name="player_play">Lenn</string>
|
||||
<string name="player_pause">Ehan</string>
|
||||
<string name="player_replay">Adlenn</string>
|
||||
</resources>
|
||||
@@ -1,41 +1,34 @@
|
||||
<resources>
|
||||
<!-- Default locale: French -->
|
||||
<string name="app_name">Donemat - Breton d'Auray</string>
|
||||
<string name="app_name">Chom bev</string>
|
||||
|
||||
<string name="nav_records">Écouter</string>
|
||||
<string name="nav_records">Enregistrements</string>
|
||||
<string name="nav_deepen">Approfondir</string>
|
||||
<string name="nav_about">À propos</string>
|
||||
|
||||
<string name="web_unavailable_message">Impossible d'afficher cette page ici.</string>
|
||||
<string name="web_unavailable_message">Impossible d\'afficher cette page ici.</string>
|
||||
<string name="web_open_in_browser">Ouvrir dans le navigateur</string>
|
||||
|
||||
<string name="subtitle_preferences_title">Sous-titres</string>
|
||||
<string name="subtitle_preferences_action">Préférences de sous-titrage</string>
|
||||
<string name="settings_open_action_description">accéder aux paramètres</string>
|
||||
<string name="subtitle_transcription_label">Transcription (BZH)</string>
|
||||
<string name="subtitle_transcription_description">Afficher la transcription bretonne</string>
|
||||
<string name="subtitle_translation_label">Traduction (FR)</string>
|
||||
<string name="subtitle_translation_description">Afficher la traduction française</string>
|
||||
<string name="subtitle_text_size_label">Taille du texte</string>
|
||||
<string name="subtitle_text_size_value">%1$d %</string>
|
||||
<string name="subtitle_text_size_decrease">Réduire la taille du texte</string>
|
||||
<string name="subtitle_text_size_increase">Augmenter la taille du texte</string>
|
||||
<string name="subtitle_marked_letters_label">Marques de prononciation</string>
|
||||
<string name="subtitle_marked_letters_description">Afficher les consonnes muettes et les liaisons</string>
|
||||
|
||||
<string name="playback_speed_title">Vitesse de lecture</string>
|
||||
<string name="playback_speed_value">%1$d %</string>
|
||||
<string name="playback_settings_action">Réglages de lecture</string>
|
||||
<string name="break_between_phrases_title">Pause entre les phrases</string>
|
||||
<string name="break_between_phrases_value">%1$ds</string>
|
||||
<plurals name="break_between_phrases_value_description">
|
||||
<item quantity="one">%1$d seconde</item>
|
||||
<item quantity="other">%1$d secondes</item>
|
||||
</plurals>
|
||||
<string name="break_between_phrases_value">%1$d s</string>
|
||||
<string name="auto_pause_title">Pause automatique</string>
|
||||
<string name="auto_pause_description">Mettre en pause après chaque phrase</string>
|
||||
|
||||
<string name="onboarding_goal_title">Bienvenue dans\n Donemat - Breton d'Auray</string>
|
||||
<string name="onboarding_goal_title">Bienvenue dans\n Chom bev e Brezhoneg</string>
|
||||
<string name="onboarding_goal_description">Apprenez le breton en écoutant des enregistrements, phrase par phrase, pour améliorer votre compréhension et votre prononciation du Breton Vannetais.</string>
|
||||
<string name="onboarding_subtitles_title">Des sous-titres à votre rythme</string>
|
||||
<string name="onboarding_subtitles_description">Depuis le lecteur, affichez ou masquez la transcription bretonne et la traduction française, ajustez la taille du texte et les marques de prononciation.</string>
|
||||
@@ -49,17 +42,10 @@
|
||||
<string name="onboarding_level_advanced_description">Vitesse normale, sans pause</string>
|
||||
<string name="onboarding_next">Suivant</string>
|
||||
<string name="onboarding_start">Commencer</string>
|
||||
<string name="onboarding_close">Fermer</string>
|
||||
|
||||
<string name="records_chapter_action_description">écouter</string>
|
||||
<string name="records_chapter_number">Chapitre %1$d</string>
|
||||
|
||||
<string name="player_back">Retour</string>
|
||||
<string name="player_back_action_label">revenir à la liste des contenus</string>
|
||||
<string name="player_previous_sentence">Phrase précédente</string>
|
||||
<string name="player_next_sentence">Phrase suivante</string>
|
||||
<string name="player_previous_sentence_action_label">passer à la phrase précédente</string>
|
||||
<string name="player_next_sentence_action_label">passer à la phrase suivante</string>
|
||||
<string name="player_play">Lire</string>
|
||||
<string name="player_pause">Pause</string>
|
||||
<string name="player_replay">Rejouer</string>
|
||||
|
||||
@@ -7,7 +7,7 @@ fun main() {
|
||||
application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Donemat - Breton d'Auray",
|
||||
title = "Chom Bev",
|
||||
) {
|
||||
App()
|
||||
}
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
# Crash reporting — Sentry SDK on GlitchTip
|
||||
|
||||
"Donemat - Breton d'Auray" 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: <https://eu.glitchtip.com>.
|
||||
|
||||
| | |
|
||||
|-----------------------|------------------------------------------------------------------------------------------------------|
|
||||
| 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://<key>@eu.glitchtip.com/<project-id>
|
||||
```
|
||||
|
||||
Find the value in GlitchTip under **Settings → Projects → _Donemat_ → 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=<your-glitchtip-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)
|
||||
@@ -1,27 +1,20 @@
|
||||
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.sizeIn
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
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
|
||||
@@ -30,8 +23,6 @@ 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
|
||||
@@ -40,7 +31,6 @@ 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
|
||||
@@ -56,156 +46,93 @@ 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(
|
||||
showAsDialog: Boolean = false,
|
||||
viewModel: OnboardingViewModel = koinViewModel(),
|
||||
) {
|
||||
fun OnboardingScreen(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
|
||||
|
||||
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),
|
||||
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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
)
|
||||
OnboardingPageIndicator(
|
||||
pageCount = PAGE_COUNT,
|
||||
currentPage = pagerState.currentPage,
|
||||
)
|
||||
|
||||
1 -> OnboardingInfoPage(
|
||||
icon = Icons.Filled.Subtitles,
|
||||
title = stringResource(Res.string.onboarding_subtitles_title),
|
||||
description = stringResource(Res.string.onboarding_subtitles_description),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(24.dp))
|
||||
|
||||
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,
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -221,23 +148,3 @@ 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 = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ fun OnboardingInfoPage(
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
@@ -57,7 +58,7 @@ private fun OnboardingInfoPagePreview() {
|
||||
MaterialTheme {
|
||||
OnboardingInfoPage(
|
||||
icon = Icons.Filled.Hearing,
|
||||
title = "Bienvenue dans Donemat - Breton d'Auray",
|
||||
title = "Bienvenue dans Chom bev",
|
||||
description = "Apprenez le breton en écoutant des enregistrements, phrase par phrase.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ fun OnboardingLevelPage(
|
||||
Text(
|
||||
text = stringResource(Res.string.onboarding_level_description),
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
LevelOption(
|
||||
@@ -109,6 +110,7 @@ private fun LevelOption(
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ fun OnboardingPageIndicator(
|
||||
if (index == currentPage) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -36,12 +36,6 @@ class OnboardingViewModel(
|
||||
selectedLevel.value = level
|
||||
}
|
||||
|
||||
fun skipOnboarding() {
|
||||
viewModelScope.launch {
|
||||
preferencesRepository.setOnboardingCompleted()
|
||||
}
|
||||
}
|
||||
|
||||
fun completeOnboarding() {
|
||||
val level = selectedLevel.value
|
||||
viewModelScope.launch {
|
||||
|
||||
@@ -14,7 +14,6 @@ kotlin {
|
||||
}
|
||||
commonMain.dependencies {
|
||||
// Core modules
|
||||
implementation(projects.core.accessibility)
|
||||
implementation(projects.core.model)
|
||||
implementation(projects.core.logging)
|
||||
implementation(projects.core.audioplayer)
|
||||
|
||||
@@ -1,23 +0,0 @@
|
||||
package bzh.ajaury.chombev
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.core.model.Record
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.records_chapter_number
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
internal fun rowDescription(record: Record): String = rowDescription(index = record.index, title = record.title)
|
||||
|
||||
@Composable
|
||||
internal fun rowDescription(
|
||||
index: Int,
|
||||
title: Phrase,
|
||||
): String {
|
||||
val chapterNumber = stringResource(Res.string.records_chapter_number, index)
|
||||
val rowDescription =
|
||||
listOfNotNull(chapterNumber, title.translation)
|
||||
.joinToString(separator = ", ")
|
||||
return rowDescription
|
||||
}
|
||||
@@ -8,8 +8,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -22,10 +21,7 @@ import bzh.ajaury.chombev.resources.generated.resources.auto_pause_description
|
||||
import bzh.ajaury.chombev.resources.generated.resources.auto_pause_title
|
||||
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_title
|
||||
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value
|
||||
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_value_description
|
||||
import bzh.ajaury.chombev.resources.generated.resources.playback_speed_title
|
||||
import bzh.ajaury.chombev.resources.generated.resources.playback_speed_value
|
||||
import org.jetbrains.compose.resources.pluralStringResource
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@@ -44,7 +40,7 @@ fun PlaybackSpeedBottomSheet(
|
||||
onBreakChange: (Float) -> Unit = {},
|
||||
onAutoPauseChange: (Boolean) -> Unit = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -88,8 +84,6 @@ private fun PlaybackSpeedContent(
|
||||
LabeledSlider(
|
||||
title = stringResource(Res.string.playback_speed_title),
|
||||
valueLabel = formatPlaybackSpeed(speed),
|
||||
// "x0.7" reads poorly aloud: screen readers get "70 %" instead.
|
||||
valueDescription = stringResource(Res.string.playback_speed_value, (speed * 100).roundToInt()),
|
||||
value = speed,
|
||||
valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED,
|
||||
steps = speedSteps,
|
||||
@@ -107,12 +101,6 @@ private fun PlaybackSpeedContent(
|
||||
Res.string.break_between_phrases_value,
|
||||
breakSeconds.roundToInt().toString(),
|
||||
),
|
||||
// "2 s" may be spelled out letter by letter: screen readers get "2 secondes" instead.
|
||||
valueDescription = pluralStringResource(
|
||||
Res.plurals.break_between_phrases_value_description,
|
||||
breakSeconds.roundToInt(),
|
||||
breakSeconds.roundToInt(),
|
||||
),
|
||||
value = breakSeconds,
|
||||
valueRange = 0f..maxBreakSeconds.toFloat(),
|
||||
steps = breakSteps,
|
||||
|
||||
@@ -6,7 +6,6 @@ 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
|
||||
@@ -49,7 +48,7 @@ fun PlayerControl(
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = Modifier.widthIn(max = 350.dp).fillMaxWidth(),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// Left region: the speed button is centered in the space left of the transport group.
|
||||
@@ -58,7 +57,6 @@ fun PlayerControl(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
SpeedPreferenceButton(
|
||||
modifier = Modifier.traversalOrder(PlayerTraversalOrder.PLAYBACK_SETTINGS),
|
||||
isActive = isPlaybackModified,
|
||||
onSpeedClicked = onSpeedClicked,
|
||||
)
|
||||
@@ -70,20 +68,17 @@ fun PlayerControl(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
PreviousButton(
|
||||
modifier = Modifier.traversalOrder(PlayerTraversalOrder.PREVIOUS),
|
||||
canGoToPreviousSentence = canGoToPreviousSentence,
|
||||
onPreviousSentenceClicked = onPreviousSentenceClicked,
|
||||
)
|
||||
|
||||
PlayButton(
|
||||
modifier = Modifier.traversalOrder(PlayerTraversalOrder.PLAY),
|
||||
playbackState = playbackState,
|
||||
breakDuration = breakBetweenPhrases,
|
||||
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
|
||||
)
|
||||
|
||||
NextButton(
|
||||
modifier = Modifier.traversalOrder(PlayerTraversalOrder.NEXT),
|
||||
canGoToNextSentence = canGoToNextSentence,
|
||||
onNextSentenceClicked = onNextSentenceClicked,
|
||||
)
|
||||
|
||||
@@ -30,17 +30,11 @@ import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.shadow
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
import bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.core.model.Record
|
||||
import bzh.ajaury.chombev.player.model.PlaybackState
|
||||
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
||||
import bzh.ajaury.chombev.player.model.PlayerState
|
||||
@@ -49,10 +43,7 @@ import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
|
||||
import bzh.ajaury.chombev.preferences.ui.SubtitlePreferencesBottomSheet
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_back
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_back_action_label
|
||||
import bzh.ajaury.chombev.resources.generated.resources.settings_open_action_description
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_preferences_action
|
||||
import bzh.ajaury.chombev.rowDescription
|
||||
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
import org.koin.compose.viewmodel.koinViewModel
|
||||
@@ -119,9 +110,6 @@ fun PlayerScreen(
|
||||
var showSpeed by remember { mutableStateOf(false) }
|
||||
|
||||
Scaffold(
|
||||
// Root of the screen reader order defined by PlayerTraversalOrder, which interleaves the
|
||||
// top bar and the controls.
|
||||
modifier = Modifier.semantics { isTraversalGroup = true },
|
||||
topBar = {
|
||||
PlayerTopBar(
|
||||
uiState = uiState,
|
||||
@@ -197,50 +185,39 @@ private fun PlayerTopBar(
|
||||
showPreferences: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
modifier = Modifier
|
||||
.shadow(elevation = 2.dp)
|
||||
// TopAppBar makes itself a traversal group, which would keep its subtitle settings
|
||||
// button next to the title. Flattened so PlayerTraversalOrder can move it last.
|
||||
.semantics { isTraversalGroup = false },
|
||||
modifier = Modifier.shadow(elevation = 2.dp),
|
||||
title = {
|
||||
// Animate the title in as it loads, and between records: the record data isn't there on
|
||||
// the first frame, so fade + rise it into place once uiState carries it.
|
||||
AnimatedContent(
|
||||
targetState = uiState.record,
|
||||
targetState = uiState.recordEmoji to uiState.recordTitle,
|
||||
transitionSpec = {
|
||||
fadeIn(tween()) togetherWith
|
||||
fadeOut(tween())
|
||||
},
|
||||
label = "PlayerTitle",
|
||||
) { record ->
|
||||
// Same description as the record's row in the list; none until the record is loaded.
|
||||
val description =
|
||||
record?.index?.let { rowDescription(index = it, title = record.title) }
|
||||
) { (emoji, title) ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.traversalOrder(PlayerTraversalOrder.TITLE)
|
||||
.clearAndSetSemantics {
|
||||
description?.let { contentDescription = it }
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Text(
|
||||
text = record?.emoji.orEmpty(),
|
||||
text = emoji,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
)
|
||||
Column {
|
||||
Text(
|
||||
text = record?.title?.transcription.orEmpty(),
|
||||
text = title.transcription,
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
|
||||
record?.title?.translation?.let { translation ->
|
||||
title.translation?.let { translation ->
|
||||
Text(
|
||||
text = translation,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
@@ -250,22 +227,15 @@ private fun PlayerTopBar(
|
||||
}
|
||||
},
|
||||
navigationIcon = {
|
||||
BackButton(onBackClicked = onBackClicked)
|
||||
IconButton(onClick = onBackClicked) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.player_back),
|
||||
)
|
||||
}
|
||||
},
|
||||
actions = {
|
||||
val openSettingsLabel = stringResource(Res.string.settings_open_action_description)
|
||||
IconButton(
|
||||
// Names the action: "double-tap to open settings" rather than the generic "to activate".
|
||||
modifier = Modifier
|
||||
.traversalOrder(PlayerTraversalOrder.SUBTITLE_SETTINGS)
|
||||
.semantics {
|
||||
onClick(label = openSettingsLabel) {
|
||||
showPreferences()
|
||||
true
|
||||
}
|
||||
},
|
||||
onClick = showPreferences,
|
||||
) {
|
||||
IconButton(onClick = showPreferences) {
|
||||
Icon(
|
||||
imageVector = Icons.Filled.Subtitles,
|
||||
contentDescription = stringResource(Res.string.subtitle_preferences_action),
|
||||
@@ -275,27 +245,6 @@ private fun PlayerTopBar(
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun BackButton(onBackClicked: () -> Unit) {
|
||||
val backActionLabel = stringResource(Res.string.player_back_action_label)
|
||||
IconButton(
|
||||
onClick = onBackClicked,
|
||||
modifier = Modifier
|
||||
.traversalOrder(PlayerTraversalOrder.BACK)
|
||||
.semantics {
|
||||
onClick(label = backActionLabel) {
|
||||
onBackClicked()
|
||||
true
|
||||
}
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
||||
contentDescription = stringResource(Res.string.player_back),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private val previewSubtitleLines =
|
||||
listOf(
|
||||
SubtitleLine(
|
||||
@@ -321,25 +270,17 @@ private val previewSubtitleLines =
|
||||
),
|
||||
)
|
||||
|
||||
private val fakeRecord = Record(
|
||||
id = 1,
|
||||
index = 1,
|
||||
emoji = "🙏",
|
||||
title = Phrase(
|
||||
transcription = "Demat",
|
||||
translation = "Bonjour",
|
||||
),
|
||||
audioResourcePath = "",
|
||||
subtitleResourcePath = "",
|
||||
)
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun PlayerScreenPlayingPreview() {
|
||||
MaterialTheme {
|
||||
PlayerScreen(
|
||||
uiState = PlayerUiState(
|
||||
fakeRecord,
|
||||
recordEmoji = "🙏",
|
||||
recordTitle = Phrase(
|
||||
transcription = "Demat",
|
||||
translation = "Bonjour",
|
||||
),
|
||||
playerState = PlayerState(
|
||||
playbackState = PlaybackState.PLAYING,
|
||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
||||
@@ -360,13 +301,14 @@ private fun PlayerScreenPausedPreview() {
|
||||
MaterialTheme {
|
||||
PlayerScreen(
|
||||
uiState = PlayerUiState(
|
||||
record = fakeRecord,
|
||||
recordEmoji = "🙏",
|
||||
recordTitle = Phrase(
|
||||
transcription = "Demat",
|
||||
translation = "Bonjour",
|
||||
),
|
||||
playerState = PlayerState(
|
||||
playbackState = PlaybackState.PAUSED,
|
||||
playbackTiming = PlaybackTiming(
|
||||
position = 1.2.seconds,
|
||||
duration = 5.seconds,
|
||||
),
|
||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
||||
subtitleLines = previewSubtitleLines,
|
||||
currentSubtitleIndex = 1,
|
||||
),
|
||||
@@ -384,7 +326,11 @@ private fun PlayerScreenEndedPreview() {
|
||||
MaterialTheme {
|
||||
PlayerScreen(
|
||||
uiState = PlayerUiState(
|
||||
record = fakeRecord,
|
||||
recordEmoji = "🙏",
|
||||
recordTitle = Phrase(
|
||||
transcription = "Demat",
|
||||
translation = "Bonjour",
|
||||
),
|
||||
playerState = PlayerState(
|
||||
playbackState = PlaybackState.ENDED,
|
||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
package bzh.ajaury.chombev.player.ui
|
||||
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.isTraversalGroup
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.semantics.traversalIndex
|
||||
|
||||
/**
|
||||
* Screen reader order on the player, which departs from the visual layout: the title and back
|
||||
* button first, then the transport controls, the settings last.
|
||||
*/
|
||||
internal enum class PlayerTraversalOrder {
|
||||
TITLE,
|
||||
BACK,
|
||||
PLAY,
|
||||
PREVIOUS,
|
||||
NEXT,
|
||||
PLAYBACK_SETTINGS,
|
||||
SUBTITLE_SETTINGS,
|
||||
}
|
||||
|
||||
/**
|
||||
* Places this element at [order] in the player's screen reader order. Grouped so that an element
|
||||
* made of several nodes (e.g. the play button and its break ring) moves as a whole.
|
||||
*/
|
||||
internal fun Modifier.traversalOrder(order: PlayerTraversalOrder): Modifier =
|
||||
semantics {
|
||||
isTraversalGroup = true
|
||||
traversalIndex = order.ordinal.toFloat()
|
||||
}
|
||||
@@ -29,7 +29,6 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.text.font.FontStyle
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -98,9 +97,7 @@ fun SubtitleList(
|
||||
}
|
||||
|
||||
val density = LocalDensity.current
|
||||
// Subtitles transcribe the audio being played: hide them from screen readers, which would
|
||||
// otherwise read the same phrases a second time.
|
||||
BoxWithConstraints(modifier = modifier.clearAndSetSemantics {}) {
|
||||
BoxWithConstraints(modifier = modifier) {
|
||||
// Half-height padding so the first and last lines can reach the vertical center. Captured here
|
||||
// because `maxHeight` (BoxWithConstraintsScope) is not in scope inside the provider lambda.
|
||||
val verticalPadding = maxHeight / 2
|
||||
|
||||
@@ -10,17 +10,8 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.alpha
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.disabled
|
||||
import androidx.compose.ui.semantics.progressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.setProgress
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
@Composable
|
||||
fun LabeledSlider(
|
||||
@@ -29,55 +20,27 @@ fun LabeledSlider(
|
||||
value: Float,
|
||||
valueRange: ClosedFloatingPointRange<Float>,
|
||||
steps: Int,
|
||||
valueDescription: String = valueLabel,
|
||||
onValueChange: (Float) -> Unit = {},
|
||||
enabled: Boolean = true,
|
||||
) {
|
||||
// Dim the labels to match the Slider's disabled appearance.
|
||||
val contentAlpha = if (enabled) 1f else 0.38f
|
||||
|
||||
Column(
|
||||
// The whole block (title, value and slider) is a single adjustable element for screen
|
||||
// readers, named by the title: swiping up or down moves one step and reads the new value.
|
||||
modifier = Modifier.clearAndSetSemantics {
|
||||
contentDescription = title
|
||||
stateDescription = valueDescription
|
||||
progressBarRangeInfo = ProgressBarRangeInfo(
|
||||
current = value,
|
||||
range = valueRange,
|
||||
steps = steps,
|
||||
)
|
||||
if (enabled) {
|
||||
setProgress { target ->
|
||||
val newValue = target.snapToSteps(valueRange, steps)
|
||||
if (newValue == value) return@setProgress false
|
||||
onValueChange(newValue)
|
||||
true
|
||||
}
|
||||
} else {
|
||||
disabled()
|
||||
}
|
||||
},
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
// The title takes the remaining width and wraps if needed, so the value always keeps
|
||||
// its full width on a single line.
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
modifier = Modifier.weight(1f).alpha(contentAlpha),
|
||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha),
|
||||
)
|
||||
Text(
|
||||
modifier = Modifier.alpha(contentAlpha),
|
||||
text = valueLabel,
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
maxLines = 1,
|
||||
softWrap = false,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha),
|
||||
)
|
||||
}
|
||||
Slider(
|
||||
@@ -90,17 +53,6 @@ fun LabeledSlider(
|
||||
}
|
||||
}
|
||||
|
||||
private fun Float.snapToSteps(
|
||||
range: ClosedFloatingPointRange<Float>,
|
||||
steps: Int,
|
||||
): Float {
|
||||
val clamped = coerceIn(range)
|
||||
if (steps <= 0) return clamped
|
||||
val stepSize = (range.endInclusive - range.start) / (steps + 1)
|
||||
val stepIndex = ((clamped - range.start) / stepSize).roundToInt()
|
||||
return (range.start + stepIndex * stepSize).coerceIn(range)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun LabeledSliderPreview() {
|
||||
@@ -115,20 +67,6 @@ private fun LabeledSliderPreview() {
|
||||
}
|
||||
}
|
||||
|
||||
@Preview(widthDp = 200)
|
||||
@Composable
|
||||
private fun LabeledSliderLongTitlePreview() {
|
||||
MaterialTheme {
|
||||
LabeledSlider(
|
||||
title = "Break between phrases",
|
||||
valueLabel = "3 s",
|
||||
value = 3f,
|
||||
valueRange = 0f..5f,
|
||||
steps = 4,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun LabeledSliderDisabledPreview() {
|
||||
|
||||
@@ -8,33 +8,19 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_next_sentence
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_next_sentence_action_label
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun NextButton(
|
||||
canGoToNextSentence: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onNextSentenceClicked: () -> Unit = {},
|
||||
) {
|
||||
val actionLabel = stringResource(Res.string.player_next_sentence_action_label)
|
||||
IconButton(
|
||||
modifier = modifier
|
||||
.size(60.dp)
|
||||
.semantics {
|
||||
if (canGoToNextSentence) {
|
||||
onClick(label = actionLabel) {
|
||||
onNextSentenceClicked()
|
||||
true
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(60.dp),
|
||||
enabled = canGoToNextSentence,
|
||||
onClick = onNextSentenceClicked,
|
||||
) {
|
||||
|
||||
@@ -38,7 +38,6 @@ private val BreakRingSize = 68.dp
|
||||
fun PlayButton(
|
||||
playbackState: PlaybackState,
|
||||
breakDuration: Duration = Duration.ZERO,
|
||||
modifier: Modifier = Modifier,
|
||||
onMainPlayActionButtonClicked: () -> Unit = {},
|
||||
) {
|
||||
val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO
|
||||
@@ -62,7 +61,7 @@ fun PlayButton(
|
||||
// The ring reserves its footprint at all times so the transport row does not jump when a break
|
||||
// starts; the indicator itself is only drawn while breaking.
|
||||
Box(
|
||||
modifier = modifier.size(BreakRingSize),
|
||||
modifier = Modifier.size(BreakRingSize),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
if (isBreaking) {
|
||||
|
||||
@@ -9,7 +9,6 @@ import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
||||
import kotlin.time.Duration
|
||||
@@ -34,9 +33,7 @@ fun PlaybackProgress(
|
||||
}
|
||||
seekProgress = null
|
||||
},
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clearAndSetSemantics { },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,33 +8,19 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_previous_sentence
|
||||
import bzh.ajaury.chombev.resources.generated.resources.player_previous_sentence_action_label
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun PreviousButton(
|
||||
canGoToPreviousSentence: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onPreviousSentenceClicked: () -> Unit = {},
|
||||
) {
|
||||
val actionLabel = stringResource(Res.string.player_previous_sentence_action_label)
|
||||
IconButton(
|
||||
modifier = modifier
|
||||
.size(60.dp)
|
||||
.semantics {
|
||||
if (canGoToPreviousSentence) {
|
||||
onClick(label = actionLabel) {
|
||||
onPreviousSentenceClicked()
|
||||
true
|
||||
}
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(60.dp),
|
||||
enabled = canGoToPreviousSentence,
|
||||
onClick = onPreviousSentenceClicked,
|
||||
) {
|
||||
|
||||
@@ -9,13 +9,10 @@ import androidx.compose.material3.LocalContentColor
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.onClick
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.playback_settings_action
|
||||
import bzh.ajaury.chombev.resources.generated.resources.settings_open_action_description
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
/**
|
||||
@@ -25,19 +22,10 @@ import org.jetbrains.compose.resources.stringResource
|
||||
@Composable
|
||||
fun SpeedPreferenceButton(
|
||||
isActive: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onSpeedClicked: () -> Unit = {},
|
||||
) {
|
||||
val openSettingsLabel = stringResource(Res.string.settings_open_action_description)
|
||||
IconButton(
|
||||
modifier = modifier
|
||||
.size(60.dp)
|
||||
.semantics {
|
||||
onClick(label = openSettingsLabel) {
|
||||
onSpeedClicked()
|
||||
true
|
||||
}
|
||||
},
|
||||
modifier = Modifier.size(60.dp),
|
||||
onClick = onSpeedClicked,
|
||||
) {
|
||||
Icon(
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
package bzh.ajaury.chombev.player.ui.viewmodel
|
||||
|
||||
import bzh.ajaury.chombev.core.model.Record
|
||||
import bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.player.model.PlayerState
|
||||
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
||||
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
||||
@@ -8,7 +8,8 @@ import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
data class PlayerUiState(
|
||||
val record: Record? = null,
|
||||
val recordEmoji: String = "",
|
||||
val recordTitle: Phrase = Phrase(transcription = ""),
|
||||
val playerState: PlayerState = PlayerState(),
|
||||
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
|
||||
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
|
||||
@@ -19,10 +20,7 @@ data class PlayerUiState(
|
||||
get() = playerState.playbackTiming.position > 2.seconds
|
||||
|
||||
val canGoToNextSentence: Boolean
|
||||
get() = (
|
||||
playerState.currentSubtitleIndex
|
||||
?: Int.MAX_VALUE
|
||||
) < playerState.subtitleLines.lastIndex
|
||||
get() = (playerState.currentSubtitleIndex ?: Int.MAX_VALUE) < playerState.subtitleLines.lastIndex
|
||||
|
||||
/** True when any playback setting differs from its default, used to highlight the button. */
|
||||
val isPlaybackModified: Boolean
|
||||
|
||||
@@ -2,8 +2,8 @@ package bzh.ajaury.chombev.player.ui.viewmodel
|
||||
|
||||
import androidx.lifecycle.ViewModel
|
||||
import androidx.lifecycle.viewModelScope
|
||||
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
|
||||
import bzh.ajaury.chombev.core.logging.domain.Logger
|
||||
import bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.core.model.Record
|
||||
import bzh.ajaury.chombev.player.domain.AudioSessionManager
|
||||
import bzh.ajaury.chombev.player.domain.PlaybackRepository
|
||||
@@ -29,7 +29,6 @@ class PlayerViewModel(
|
||||
private val playbackRepository: PlaybackRepository,
|
||||
private val preferencesRepository: PreferencesRepository,
|
||||
private val audioSessionManager: AudioSessionManager,
|
||||
private val accessibilityRepository: AccessibilityRepository,
|
||||
private val logger: Logger,
|
||||
) : ViewModel() {
|
||||
private val record = MutableStateFlow<Record?>(null)
|
||||
@@ -41,7 +40,8 @@ class PlayerViewModel(
|
||||
preferencesRepository.playbackPreferences,
|
||||
) { record, playerState, subtitlePreferences, playbackPreferences ->
|
||||
PlayerUiState(
|
||||
record = record,
|
||||
recordEmoji = record?.emoji ?: "",
|
||||
recordTitle = record?.title ?: Phrase(transcription = ""),
|
||||
playerState = playerState,
|
||||
subtitlePreferences = subtitlePreferences,
|
||||
playbackSpeed = playbackPreferences.speed,
|
||||
@@ -62,14 +62,11 @@ class PlayerViewModel(
|
||||
viewModelScope.launch {
|
||||
playbackRepository.stop()
|
||||
|
||||
// A screen reader speaks over the audio: let its user start playback once oriented.
|
||||
val autoPlay = !accessibilityRepository.isScreenReaderEnabled()
|
||||
|
||||
logger.debug("Loading record $recordId")
|
||||
val alreadyLoadedRecord = record.value
|
||||
if (alreadyLoadedRecord != null) {
|
||||
logger.debug("Record $recordId already loaded")
|
||||
playbackRepository.load(record = alreadyLoadedRecord, autoPlay = autoPlay)
|
||||
playbackRepository.load(record = alreadyLoadedRecord)
|
||||
return@launch
|
||||
}
|
||||
|
||||
@@ -79,7 +76,7 @@ class PlayerViewModel(
|
||||
return@launch
|
||||
}
|
||||
|
||||
playbackRepository.load(record = loadedRecord, autoPlay = autoPlay)
|
||||
playbackRepository.load(record = loadedRecord)
|
||||
record.value = loadedRecord
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ fun PreferenceToggle(
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -5,8 +5,6 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.Remove
|
||||
@@ -16,20 +14,11 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.ProgressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.progressBarRangeInfo
|
||||
import androidx.compose.ui.semantics.setProgress
|
||||
import androidx.compose.ui.semantics.stateDescription
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
||||
@@ -40,7 +29,6 @@ import bzh.ajaury.chombev.resources.generated.resources.subtitle_preferences_tit
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_decrease
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_increase
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_label
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_value
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_description
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_label
|
||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_translation_description
|
||||
@@ -62,7 +50,7 @@ fun SubtitlePreferencesBottomSheet(
|
||||
onTextSizeDecrease: () -> Unit = {},
|
||||
onTextSizeIncrease: () -> Unit = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
@@ -91,8 +79,6 @@ private fun SubtitlePreferencesContent(
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
// Scrolls when a large system font size makes the content taller than the sheet.
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(bottom = 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
@@ -133,50 +119,10 @@ private fun SubtitleTextSizeControl(
|
||||
onDecrease: () -> Unit = {},
|
||||
onIncrease: () -> Unit = {},
|
||||
) {
|
||||
val label = stringResource(Res.string.subtitle_text_size_label)
|
||||
val value = stringResource(Res.string.subtitle_text_size_value, (textScale * 100).roundToInt())
|
||||
val decreaseLabel = stringResource(Res.string.subtitle_text_size_decrease)
|
||||
val increaseLabel = stringResource(Res.string.subtitle_text_size_increase)
|
||||
// The steps are uneven (0.1 then 0.2 apart), so screen readers adjust a step index instead.
|
||||
val steps = SubtitlePreferences.TEXT_SCALE_STEPS
|
||||
val stepIndex = steps.indexOfFirst { it >= textScale - 0.001f }.coerceAtLeast(0)
|
||||
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp)
|
||||
// A single adjustable element for screen readers, like a slider: swiping up or down
|
||||
// changes the size and announces the new value. The buttons only serve touch use.
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = label
|
||||
stateDescription = value
|
||||
progressBarRangeInfo = ProgressBarRangeInfo(
|
||||
current = stepIndex.toFloat(),
|
||||
range = 0f..steps.lastIndex.toFloat(),
|
||||
steps = steps.size - 2,
|
||||
)
|
||||
setProgress { target ->
|
||||
val targetIndex = target.roundToInt()
|
||||
when {
|
||||
targetIndex > stepIndex -> onIncrease()
|
||||
targetIndex < stepIndex -> onDecrease()
|
||||
else -> return@setProgress false
|
||||
}
|
||||
true
|
||||
}
|
||||
// Same adjustments from the screen reader's actions menu, for users unaware of the
|
||||
// adjust gesture.
|
||||
customActions = listOf(
|
||||
CustomAccessibilityAction(label = increaseLabel) {
|
||||
onIncrease()
|
||||
true
|
||||
},
|
||||
CustomAccessibilityAction(label = decreaseLabel) {
|
||||
onDecrease()
|
||||
true
|
||||
},
|
||||
)
|
||||
},
|
||||
.padding(vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
|
||||
@@ -11,39 +11,23 @@ import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.semantics.Role
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.text.font.FontFamily
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.dp
|
||||
import bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.core.model.Record
|
||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
||||
import bzh.ajaury.chombev.resources.generated.resources.records_chapter_action_description
|
||||
import bzh.ajaury.chombev.rowDescription
|
||||
import org.jetbrains.compose.resources.stringResource
|
||||
|
||||
@Composable
|
||||
fun RecordView(
|
||||
record: Record,
|
||||
onClick: () -> Unit = {},
|
||||
) {
|
||||
val rowDescription = rowDescription(record)
|
||||
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
role = Role.Button,
|
||||
onClickLabel = stringResource(Res.string.records_chapter_action_description),
|
||||
) {
|
||||
onClick()
|
||||
}.padding(16.dp)
|
||||
.clearAndSetSemantics {
|
||||
contentDescription = rowDescription
|
||||
},
|
||||
.clickable { onClick() }
|
||||
.padding(16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
@@ -60,6 +44,7 @@ fun RecordView(
|
||||
Text(
|
||||
text = translation,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -85,23 +70,3 @@ private fun RecordViewPreview() {
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun RecordViewWithoutTranslationPreview() {
|
||||
RecordView(
|
||||
record =
|
||||
Record(
|
||||
id = 1,
|
||||
index = 1,
|
||||
emoji = "🙏",
|
||||
title = Phrase(
|
||||
transcription = "Sample Record",
|
||||
translation = null,
|
||||
),
|
||||
audioResourcePath = "files/Chom_bev_01.ogg",
|
||||
subtitleResourcePath = "files/Chom_bev_01_st_bzh.lrc",
|
||||
),
|
||||
onClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package bzh.ajaury.chombev.records.ui.di
|
||||
|
||||
import bzh.ajaury.chombev.core.accessibility.di.accessibilityModule
|
||||
import bzh.ajaury.chombev.player.di.audioPlayerModule
|
||||
import bzh.ajaury.chombev.player.di.playbackModule
|
||||
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
|
||||
@@ -24,7 +23,6 @@ val recordsFeatureModule: Module = module {
|
||||
recordsModule,
|
||||
resourcesModule,
|
||||
audioPlayerModule,
|
||||
accessibilityModule,
|
||||
subtitleModule,
|
||||
)
|
||||
|
||||
|
||||
@@ -14,15 +14,6 @@ kotlin {
|
||||
implementation(libs.compose.uiToolingPreview)
|
||||
implementation(libs.androidx.webkit)
|
||||
}
|
||||
// webMain is the js + wasmJs intermediate source set from the default hierarchy template.
|
||||
webMain.dependencies {
|
||||
// DOM access (document, navigator) to embed the page in an <iframe>
|
||||
implementation(libs.kotlinx.browser)
|
||||
}
|
||||
wasmJsMain {
|
||||
// HTMLIFrameElement interop on Wasm requires this opt-in.
|
||||
languageSettings.optIn("kotlin.js.ExperimentalWasmJsInterop")
|
||||
}
|
||||
commonMain.dependencies {
|
||||
// Core modules
|
||||
implementation(projects.core.logging)
|
||||
|
||||
@@ -4,12 +4,11 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
/**
|
||||
* Embeds a live web page. Android and iOS use their native web view, the web uses an `<iframe>`
|
||||
* overlaying the Compose canvas, and all three honour [darkTheme] so the page matches the app's
|
||||
* light/dark appearance — as far as the page itself allows. Desktop, which has no web view to
|
||||
* embed, falls back to [WebViewUnavailable] and offers to open the page in the system browser.
|
||||
* Embeds a live web page. Platforms with a native web view (Android, iOS) render the page inline,
|
||||
* honouring [darkTheme] so the page matches the app's light/dark appearance; the others fall back to
|
||||
* [WebViewUnavailable], which offers to open the page in the system browser.
|
||||
*
|
||||
* When [url] cannot be reached, the embedded views swap in [offlineHtml] — a bundled offline
|
||||
* When [url] cannot be reached, the native web views swap in [offlineHtml] — a bundled offline
|
||||
* snapshot — so the page still shows content. When it is `null` no fallback is loaded.
|
||||
*
|
||||
* [onLoadingStateChange] reports whether a page is currently loading, so the caller can show a
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
package bzh.ajaury.chombev.web.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
actual fun PlatformWebView(
|
||||
url: String,
|
||||
offlineHtml: String?,
|
||||
darkTheme: Boolean,
|
||||
onLoadingStateChange: (Boolean) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
// The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser.
|
||||
LaunchedEffect(Unit) { onLoadingStateChange(false) }
|
||||
WebViewUnavailable(url = url, modifier = modifier)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
package bzh.ajaury.chombev.web.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
|
||||
@Composable
|
||||
actual fun PlatformWebView(
|
||||
url: String,
|
||||
offlineHtml: String?,
|
||||
darkTheme: Boolean,
|
||||
onLoadingStateChange: (Boolean) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
// The Compose canvas can't host an iframe cleanly; nothing loads, so report done and open in browser.
|
||||
LaunchedEffect(Unit) { onLoadingStateChange(false) }
|
||||
WebViewUnavailable(url = url, modifier = modifier)
|
||||
}
|
||||
@@ -1,65 +0,0 @@
|
||||
package bzh.ajaury.chombev.web.ui
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberUpdatedState
|
||||
import androidx.compose.ui.ExperimentalComposeUiApi
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.viewinterop.HtmlElementView
|
||||
import kotlinx.browser.document
|
||||
import kotlinx.browser.window
|
||||
import org.w3c.dom.HTMLIFrameElement
|
||||
|
||||
/**
|
||||
* Scripts, forms and target=_blank links keep the embedded site usable; top-level navigation is
|
||||
* withheld, so a framed page cannot replace the app with itself.
|
||||
*/
|
||||
private const val IFRAME_SANDBOX =
|
||||
"allow-scripts allow-same-origin allow-forms allow-popups allow-popups-to-escape-sandbox"
|
||||
|
||||
/**
|
||||
* Embeds the page in an `<iframe>`. [HtmlElementView] overlays the element on the Compose canvas and
|
||||
* keeps it aligned with this composable's bounds, so the page is browsed inside the app rather than
|
||||
* in a separate tab.
|
||||
*/
|
||||
@OptIn(ExperimentalComposeUiApi::class)
|
||||
@Composable
|
||||
actual fun PlatformWebView(
|
||||
url: String,
|
||||
offlineHtml: String?,
|
||||
darkTheme: Boolean,
|
||||
onLoadingStateChange: (Boolean) -> Unit,
|
||||
modifier: Modifier,
|
||||
) {
|
||||
// The element outlives recompositions, so its load listener has to read the latest callback.
|
||||
val currentOnLoadingStateChange by rememberUpdatedState(onLoadingStateChange)
|
||||
|
||||
HtmlElementView(
|
||||
modifier = modifier,
|
||||
factory = {
|
||||
(document.createElement("iframe") as HTMLIFrameElement).apply {
|
||||
setAttribute("sandbox", IFRAME_SANDBOX)
|
||||
setAttribute("referrerpolicy", "no-referrer")
|
||||
style.border = "none"
|
||||
style.width = "100%"
|
||||
style.height = "100%"
|
||||
addEventListener("load") { currentOnLoadingStateChange(false) }
|
||||
}
|
||||
},
|
||||
// Re-applied on recomposition, but only the theme changes without reloading the page: `src`
|
||||
// is left alone unless it actually differs.
|
||||
update = { iframe ->
|
||||
// Propagating a colour scheme is all a cross-origin frame allows; a page with
|
||||
// `prefers-color-scheme` styles then matches the app.
|
||||
iframe.style.setProperty("color-scheme", if (darkTheme) "dark" else "light")
|
||||
|
||||
if (offlineHtml != null && !window.navigator.onLine) {
|
||||
// The snapshots use absolute URLs, so they need no base URL to render correctly.
|
||||
if (iframe.srcdoc != offlineHtml) iframe.srcdoc = offlineHtml
|
||||
} else if (iframe.src != url) {
|
||||
currentOnLoadingStateChange(true)
|
||||
iframe.src = url
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
@@ -1,8 +1,5 @@
|
||||
#Kotlin
|
||||
kotlin.code.style=official
|
||||
# Install the JS/Wasm build dependencies with npm instead of Yarn Classic, which is unmaintained and
|
||||
# warns about a deprecated Node API (DEP0169) on recent Node versions.
|
||||
kotlin.js.yarn=false
|
||||
kotlin.daemon.jvmargs=-Xmx6G
|
||||
|
||||
#Gradle
|
||||
|
||||
@@ -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=AZUL
|
||||
toolchainVendor=AMAZON
|
||||
toolchainVersion=21
|
||||
@@ -1,29 +1,29 @@
|
||||
[versions]
|
||||
agp = "9.4.1" # https://developer.android.com/build/releases/gradle-plugin
|
||||
androidCompileSdk = "37" # https://developer.android.com/tools/releases/platforms
|
||||
androidMinSdk = "24" # https://developer.android.com/tools/releases/platforms
|
||||
androidTargetSdk = "37" # https://developer.android.com/tools/releases/platforms
|
||||
androidxActivity = "1.13.0" # https://developer.android.com/jetpack/androidx/releases/activity
|
||||
androidxLifecycle = "2.11.0" # https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||
androidxWebkit = "1.17.1" # https://developer.android.com/jetpack/androidx/releases/webkit
|
||||
composeIconsExtended = "1.7.3" # https://github.com/JetBrains/compose-multiplatform/releases
|
||||
composeMultiplatform = "1.12.1" # https://github.com/JetBrains/compose-multiplatform/releases
|
||||
datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore
|
||||
googleServices = "4.5.0" # https://developers.google.com/android/guides/releases
|
||||
jaad = "0.9.4" # https://github.com/Tianscar/jaad/releases
|
||||
kermit = "2.2.0" # https://github.com/touchlab/Kermit/releases
|
||||
koin = "4.2.2" # https://github.com/InsertKoinIO/koin/releases
|
||||
kotlin = "2.4.21" # https://github.com/JetBrains/kotlin/releases
|
||||
kotlinSerialization = "2.4.21" # https://github.com/JetBrains/kotlin/releases
|
||||
kotlinWrappers = "2026.10.0" # https://github.com/JetBrains/kotlin-wrappers/releases
|
||||
kotlinxBrowser = "0.5.0" # https://github.com/Kotlin/kotlinx-browser/releases
|
||||
kotlinxCoroutines = "1.11.0" # https://github.com/Kotlin/kotlinx.coroutines/releases
|
||||
kotlinxSerializationJson = "1.11.0" # https://github.com/Kotlin/kotlinx.serialization/releases
|
||||
material3 = "1.12.0-alpha03" # https://developer.android.com/jetpack/androidx/releases/compose-material3
|
||||
media3 = "1.11.1" # https://developer.android.com/jetpack/androidx/releases/media3
|
||||
navigation3 = "1.1.2" # https://developer.android.com/jetpack/androidx/releases/navigation3
|
||||
okio = "3.18.2" # https://square.github.io/okio/changelog/
|
||||
sentry = "0.28.0" # https://github.com/getsentry/sentry-kotlin-multiplatform/releases
|
||||
agp = "9.2.1"
|
||||
androidCompileSdk = "37"
|
||||
androidMinSdk = "24"
|
||||
androidTargetSdk = "37"
|
||||
androidxActivity = "1.13.0"
|
||||
androidxLifecycle = "2.11.0-rc01"
|
||||
androidxWebkit = "1.16.0"
|
||||
media3 = "1.10.1"
|
||||
navigation3 = "1.1.1"
|
||||
composeMultiplatform = "1.11.1"
|
||||
composeIconsExtended = "1.7.3"
|
||||
glitchtips = "8.47.0"
|
||||
googleServices = "4.5.0"
|
||||
kotlin = "2.4.0"
|
||||
kotlinWrappers = "2026.7.0"
|
||||
kotlinxBrowser = "0.5.0"
|
||||
kotlinxCoroutines = "1.11.0"
|
||||
kotlinSerialization = "2.4.0"
|
||||
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"
|
||||
|
||||
[libraries]
|
||||
# Gradle plugin artifacts, used by build-logic convention plugins only
|
||||
@@ -44,7 +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" }
|
||||
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" }
|
||||
@@ -64,6 +64,7 @@ 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]
|
||||
@@ -74,5 +75,4 @@ 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"}
|
||||
sentry = { id = "io.sentry.kotlin.multiplatform.gradle", version.ref = "sentry" }
|
||||
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlinSerialization"}
|
||||
@@ -1,7 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
TEAM_ID=
|
||||
|
||||
PRODUCT_NAME=Donemat - Breton d'Auray
|
||||
PRODUCT_BUNDLE_IDENTIFIER=bzh.ksk.donemat$(TEAM_ID)
|
||||
PRODUCT_NAME=Chom Bev
|
||||
PRODUCT_BUNDLE_IDENTIFIER=bzh.ajaury.chombev.Gwenedeg$(TEAM_ID)
|
||||
|
||||
// Keep in sync with androidApp versionName/versionCode and CHANGELOG.md.
|
||||
// Apple only accepts numeric versions, so pre-release suffixes (e.g. -alpha01) are dropped here.
|
||||
CURRENT_PROJECT_VERSION=5
|
||||
MARKETING_VERSION=1.0.0.4
|
||||
CURRENT_PROJECT_VERSION=1
|
||||
MARKETING_VERSION=1.0.0
|
||||
@@ -6,12 +6,8 @@
|
||||
objectVersion = 77;
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
F36B7319302CAEC900B3E490 /* Sentry in Frameworks */ = {isa = PBXBuildFile; productRef = F36B7318302CAEC900B3E490 /* Sentry */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
9DA43AABF87DF46F5E794E72 /* Donemat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Chom Bev.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9DA43AABF87DF46F5E794E72 /* Gwenedeg.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Gwenedeg.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -45,7 +41,6 @@
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
F36B7319302CAEC900B3E490 /* Sentry in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -55,7 +50,7 @@
|
||||
47B0C8770C82BEADFE13EF77 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9DA43AABF87DF46F5E794E72 /* Donemat.app */,
|
||||
9DA43AABF87DF46F5E794E72 /* Gwenedeg.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -90,10 +85,9 @@
|
||||
);
|
||||
name = iosApp;
|
||||
packageProductDependencies = (
|
||||
F36B7318302CAEC900B3E490 /* Sentry */,
|
||||
);
|
||||
productName = iosApp;
|
||||
productReference = 9DA43AABF87DF46F5E794E72 /* Donemat.app */;
|
||||
productReference = 9DA43AABF87DF46F5E794E72 /* Gwenedeg.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
@@ -120,9 +114,6 @@
|
||||
);
|
||||
mainGroup = A562F15E4A0F12F420BB5B12;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
packageReferences = (
|
||||
F36B7317302CAEC900B3E490 /* XCRemoteSwiftPackageReference "sentry-cocoa" */,
|
||||
);
|
||||
preferredProjectObjectVersion = 77;
|
||||
productRefGroup = 47B0C8770C82BEADFE13EF77 /* Products */;
|
||||
projectDirPath = "";
|
||||
@@ -185,7 +176,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 492RZGQ5SU;
|
||||
DEVELOPMENT_TEAM = "$(TEAM_ID)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = iosApp/Info.plist;
|
||||
@@ -278,7 +269,7 @@
|
||||
CODE_SIGN_IDENTITY = "Apple Development";
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
DEVELOPMENT_ASSET_PATHS = "\"iosApp/Preview Content\"";
|
||||
DEVELOPMENT_TEAM = 492RZGQ5SU;
|
||||
DEVELOPMENT_TEAM = "$(TEAM_ID)";
|
||||
ENABLE_PREVIEWS = YES;
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
INFOPLIST_FILE = iosApp/Info.plist;
|
||||
@@ -377,25 +368,6 @@
|
||||
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 */;
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
//
|
||||
// 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
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,6 @@ import SwiftUI
|
||||
|
||||
@main
|
||||
struct iOSApp: App {
|
||||
|
||||
@UIApplicationDelegateAdaptor(AppDelegate.self)
|
||||
private var appDelegate
|
||||
|
||||
var body: some Scene {
|
||||
WindowGroup {
|
||||
ContentView()
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY.
|
||||
# yarn lockfile v1
|
||||
|
||||
|
||||
"@js-joda/core@3.2.0":
|
||||
version "3.2.0"
|
||||
resolved "https://registry.yarnpkg.com/@js-joda/core/-/core-3.2.0.tgz#3e61e21b7b2b8a6be746df1335cf91d70db2a273"
|
||||
integrity sha512-PMqgJ0sw5B7FKb2d5bWYIoxjri+QlW/Pys7+Rw82jSH0QN3rB05jZ/VrrsUdh1w4+i2kw9JOejXGq/KhDOX7Kg==
|
||||
@@ -39,7 +39,6 @@ include(":webApp")
|
||||
include(":shared")
|
||||
|
||||
// Core modules
|
||||
include(":core:accessibility")
|
||||
include(":core:audioplayer")
|
||||
include(":core:coroutines")
|
||||
include(":core:logging")
|
||||
|
||||
@@ -2,8 +2,6 @@ plugins {
|
||||
id("gwenedeg.kmp.library")
|
||||
id("gwenedeg.compose")
|
||||
alias(libs.plugins.kotlinSerialization)
|
||||
id("gwenedeg.secrets")
|
||||
alias(libs.plugins.sentry)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
@@ -65,6 +63,7 @@ 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)
|
||||
|
||||
@@ -1,7 +0,0 @@
|
||||
package bzh.ajaury.chombev.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/** No-op: Android supplies the system emoji font. */
|
||||
@Composable
|
||||
internal actual fun RegisterEmojiFallbackFont() = Unit
|
||||
@@ -10,10 +10,7 @@ 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
|
||||
@@ -30,7 +27,6 @@ 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
|
||||
@@ -38,9 +34,6 @@ 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,
|
||||
@@ -60,37 +53,24 @@ fun App() {
|
||||
val darkTheme = isSystemInDarkTheme()
|
||||
|
||||
ChomBevTheme(darkTheme = darkTheme) {
|
||||
RegisterEmojiFallbackFont()
|
||||
|
||||
val onboardingViewModel: OnboardingViewModel = koinViewModel()
|
||||
val onboardingUiState by onboardingViewModel.uiState.collectAsStateWithLifecycle()
|
||||
|
||||
val showOnboardingAsDialog = isWideWindow()
|
||||
when (onboardingUiState.isCompleted) {
|
||||
// The persisted flag is still being read: render nothing rather than flashing
|
||||
// either screen.
|
||||
null -> Unit
|
||||
|
||||
val isOnboardingVisible = onboardingUiState.isCompleted == false
|
||||
val isMainContentVisible = onboardingUiState.isCompleted == true ||
|
||||
(isOnboardingVisible && showOnboardingAsDialog)
|
||||
// First launch: onboarding replaces the whole UI. Completing it flips the persisted
|
||||
// flag, which switches to the main content automatically.
|
||||
false -> OnboardingScreen()
|
||||
|
||||
if (isMainContentVisible) {
|
||||
MainNavigation(darkTheme = darkTheme)
|
||||
}
|
||||
|
||||
if (isOnboardingVisible) {
|
||||
OnboardingScreen(showAsDialog = showOnboardingAsDialog)
|
||||
true -> MainNavigation(darkTheme = darkTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@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<Route>()
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,11 +7,11 @@ import bzh.ajaury.chombev.web.model.WebPage
|
||||
*/
|
||||
object WebPages {
|
||||
val DEEPEN = WebPage(
|
||||
url = "https://ksk.bzh/fr/donemat-approfondir/",
|
||||
url = "https://ksk.bzh/fr/chom-bev-approfondir/",
|
||||
offlineAsset = "files/offline_deepen.html",
|
||||
)
|
||||
val ABOUT = WebPage(
|
||||
url = "https://ksk.bzh/fr/donemat-a-propos/",
|
||||
url = "https://ksk.bzh/fr/chom-bev-a-propos/",
|
||||
offlineAsset = "files/offline_about.html",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ import androidx.compose.ui.graphics.Color
|
||||
|
||||
// region Light
|
||||
internal val md_light_primary = Color(0xFFFF9201)
|
||||
internal val md_light_onPrimary = Color(0xFF000000)
|
||||
internal val md_light_onPrimary = Color(0xFFFFFFFF)
|
||||
internal val md_light_primaryContainer = Color(0xFFFFEBD1)
|
||||
internal val md_light_onPrimaryContainer = Color(0xFF1C1613)
|
||||
|
||||
@@ -25,10 +25,10 @@ internal val md_light_onErrorContainer = Color(0xFF410002)
|
||||
|
||||
internal val md_light_background = Color(0xFFF5FAFB)
|
||||
internal val md_light_onBackground = Color(0xFF171D1E)
|
||||
internal val md_light_surface = Color(0xFFFFFFFF)
|
||||
internal val md_light_surface = Color(0xFFF5FAFB)
|
||||
internal val md_light_onSurface = Color(0xFF171D1E)
|
||||
internal val md_light_surfaceVariant = Color(0xFFFAFEFF)
|
||||
internal val md_light_onSurfaceVariant = Color(0xFF000000)
|
||||
internal val md_light_surfaceVariant = Color(0xFFDBE4E6)
|
||||
internal val md_light_onSurfaceVariant = Color(0xFF3F484A)
|
||||
|
||||
internal val md_light_outline = Color(0xFF6F797B)
|
||||
internal val md_light_outlineVariant = Color(0xFFBFC8CA)
|
||||
@@ -64,7 +64,7 @@ internal val md_dark_onBackground = Color(0xFFDEE3E4)
|
||||
internal val md_dark_surface = Color(0xFF0E1415)
|
||||
internal val md_dark_onSurface = Color(0xFFDEE3E4)
|
||||
internal val md_dark_surfaceVariant = Color(0xFF3F484A)
|
||||
internal val md_dark_onSurfaceVariant = Color(0xFFFFFFFF)
|
||||
internal val md_dark_onSurfaceVariant = Color(0xFFBFC8CA)
|
||||
|
||||
internal val md_dark_outline = Color(0xFF899294)
|
||||
internal val md_dark_outlineVariant = Color(0xFF3F484A)
|
||||
|
||||
@@ -1,15 +0,0 @@
|
||||
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()
|
||||
@@ -1,7 +0,0 @@
|
||||
package bzh.ajaury.chombev.theme
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
|
||||
/** No-op: iOS supplies Apple Color Emoji. */
|
||||
@Composable
|
||||
internal actual fun RegisterEmojiFallbackFont() = Unit
|
||||
@@ -1,7 +0,0 @@
|
||||
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
|
||||
@@ -1,139 +0,0 @@
|
||||
package bzh.ajaury.chombev.theme
|
||||
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import kotlin.math.round
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.fail
|
||||
|
||||
/**
|
||||
* Checks the color pairs of both themes against the WCAG 2.2 AA contrast ratios: 4.5:1 for text
|
||||
* (1.4.3) and 3:1 for meaningful non-text elements such as borders (1.4.11).
|
||||
*
|
||||
* Each failure lists every offending pair at once, with its ratio, so a palette change can be fixed
|
||||
* in one go. The palette lives in commonMain, so checking it here covers every platform.
|
||||
*/
|
||||
class ColorContrastTest {
|
||||
|
||||
@Test
|
||||
fun lightColorSchemeMeetsWcagAa() {
|
||||
assertMeetsWcagAa(ChomBevLightColorScheme, themeName = "light")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun darkColorSchemeMeetsWcagAa() {
|
||||
assertMeetsWcagAa(ChomBevDarkColorScheme, themeName = "dark")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun contrastRatioMatchesWcagReferenceValues() {
|
||||
assertEquals(21.0, contrastRatio(Color.Black, Color.White), absoluteTolerance = 0.01)
|
||||
assertEquals(1.0, contrastRatio(Color.White, Color.White), absoluteTolerance = 0.01)
|
||||
// Reference value from the WebAIM contrast checker: #767676 is the lightest gray passing
|
||||
// 4.5:1 on white.
|
||||
assertEquals(4.54, contrastRatio(Color(0xFF767676), Color.White), absoluteTolerance = 0.01)
|
||||
// The ratio does not depend on which color is the foreground.
|
||||
assertEquals(contrastRatio(Color.Red, Color.White), contrastRatio(Color.White, Color.Red))
|
||||
}
|
||||
|
||||
private fun assertMeetsWcagAa(scheme: ColorScheme, themeName: String) {
|
||||
val failures = contrastPairs(scheme).mapNotNull { pair ->
|
||||
val ratio = contrastRatio(pair.foreground, pair.background)
|
||||
if (ratio >= pair.minRatio) {
|
||||
null
|
||||
} else {
|
||||
"${pair.name}: ${ratio.format()}:1 (minimum ${pair.minRatio}:1)"
|
||||
}
|
||||
}
|
||||
if (failures.isNotEmpty()) {
|
||||
fail(
|
||||
"The $themeName color scheme fails WCAG AA contrast for:\n" +
|
||||
failures.joinToString(separator = "\n") { " - $it" },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private fun contrastPairs(scheme: ColorScheme): List<ContrastPair> = with(scheme) {
|
||||
val surfaces = listOf(
|
||||
"surface" to surface,
|
||||
"surfaceDim" to surfaceDim,
|
||||
"surfaceBright" to surfaceBright,
|
||||
"surfaceContainerLowest" to surfaceContainerLowest,
|
||||
"surfaceContainerLow" to surfaceContainerLow,
|
||||
"surfaceContainer" to surfaceContainer,
|
||||
"surfaceContainerHigh" to surfaceContainerHigh,
|
||||
"surfaceContainerHighest" to surfaceContainerHighest,
|
||||
)
|
||||
buildList {
|
||||
// Content colors on the color they are paired with.
|
||||
text("onPrimary", onPrimary, "primary", primary)
|
||||
text("onPrimaryContainer", onPrimaryContainer, "primaryContainer", primaryContainer)
|
||||
text("onSecondary", onSecondary, "secondary", secondary)
|
||||
text("onSecondaryContainer", onSecondaryContainer, "secondaryContainer", secondaryContainer)
|
||||
text("onTertiary", onTertiary, "tertiary", tertiary)
|
||||
text("onTertiaryContainer", onTertiaryContainer, "tertiaryContainer", tertiaryContainer)
|
||||
text("onError", onError, "error", error)
|
||||
text("onErrorContainer", onErrorContainer, "errorContainer", errorContainer)
|
||||
text("onBackground", onBackground, "background", background)
|
||||
text("onSurfaceVariant", onSurfaceVariant, "surfaceVariant", surfaceVariant)
|
||||
text("inverseOnSurface", inverseOnSurface, "inverseSurface", inverseSurface)
|
||||
// Snackbar action.
|
||||
text("inversePrimary", inversePrimary, "inverseSurface", inverseSurface)
|
||||
|
||||
surfaces.forEach { (surfaceName, surfaceColor) ->
|
||||
text("onSurface", onSurface, surfaceName, surfaceColor)
|
||||
text("onSurfaceVariant", onSurfaceVariant, surfaceName, surfaceColor)
|
||||
// Colored text: text buttons, slider values, error messages.
|
||||
text("primary", primary, surfaceName, surfaceColor)
|
||||
text("error", error, surfaceName, surfaceColor)
|
||||
// Borders of text fields and outlined components.
|
||||
nonText("outline", outline, surfaceName, surfaceColor)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun MutableList<ContrastPair>.text(
|
||||
foregroundName: String,
|
||||
foreground: Color,
|
||||
backgroundName: String,
|
||||
background: Color,
|
||||
) {
|
||||
add(ContrastPair("$foregroundName on $backgroundName", foreground, background, TEXT_MIN_RATIO))
|
||||
}
|
||||
|
||||
private fun MutableList<ContrastPair>.nonText(
|
||||
foregroundName: String,
|
||||
foreground: Color,
|
||||
backgroundName: String,
|
||||
background: Color,
|
||||
) {
|
||||
add(ContrastPair("$foregroundName on $backgroundName", foreground, background, NON_TEXT_MIN_RATIO))
|
||||
}
|
||||
|
||||
private data class ContrastPair(
|
||||
val name: String,
|
||||
val foreground: Color,
|
||||
val background: Color,
|
||||
val minRatio: Double,
|
||||
)
|
||||
|
||||
private companion object {
|
||||
const val TEXT_MIN_RATIO = 4.5
|
||||
const val NON_TEXT_MIN_RATIO = 3.0
|
||||
|
||||
/** WCAG contrast ratio, from 1 (identical) to 21 (black on white). */
|
||||
fun contrastRatio(first: Color, second: Color): Double {
|
||||
// Color.luminance() is the WCAG relative luminance of the sRGB color.
|
||||
val lighter = maxOf(first.luminance(), second.luminance())
|
||||
val darker = minOf(first.luminance(), second.luminance())
|
||||
return (lighter + 0.05) / (darker + 0.05)
|
||||
}
|
||||
|
||||
fun Double.format(): String {
|
||||
val hundredths = round(this * 100).toInt()
|
||||
return "${hundredths / 100}.${(hundredths % 100).toString().padStart(2, '0')}"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
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)),
|
||||
)
|
||||
}
|
||||
}
|
||||