Author SHA1 Message Date
Antoine Jaury 34cb8d24a4 refactor: remove redundant color attributes and improve color contrast handling 2026-10-19 15:53:25 +02:00
Antoine Jaury 0add1176db refactor: improve layout for speed preferences 2026-10-19 14:40:03 +02:00
Antoine Jaury 0d42413136 refactor: add scrollable support for subtitle preferences bottom sheet 2026-10-19 14:36:19 +02:00
Antoine Jaury f2e35fc8f2 refactor: update navigation label for "Records" in French localization 2026-10-19 14:34:54 +02:00
Antoine Jaury 6d31dcda93 refactor: update Gradle to 9.8.1 2026-10-19 14:18:15 +02:00
Antoine Jaury 032ba458f7 refactor: remove unverified Breton language resources 2026-10-19 14:17:32 +02:00
Antoine Jaury d51223bb19 refactor: update dependencies 2026-10-12 16:15:24 +02:00
Antoine Jaury d721b0475d refactor: update gradle 2026-10-12 16:02:29 +02:00
Antoine Jaury 9cbb4181b3 refactor: update web page links 2026-10-12 16:02:16 +02:00
Antoine Jaury 0324186130 refactor: update offline web pages for accessibility 2026-10-12 15:51:41 +02:00
Antoine Jaury 5539bf8b0d refactor: improve accessibility for audio settings 2026-10-12 15:02:41 +02:00
Antoine Jaury 82a2ef3bdb refactor: improve accessibility for subtitle size settings 2026-10-12 14:41:12 +02:00
Antoine Jaury 8339ee128b refactor: improve accessibility for previous/next buttons in player 2026-10-12 14:29:59 +02:00
Antoine Jaury 8dc1e8e71e refactor: improve accessibility title for player 2026-10-12 14:13:27 +02:00
Antoine Jaury 1a23703911 refactor: set traversal order for player 2026-10-12 14:03:21 +02:00
Antoine Jaury 242d494c86 refactor: improve player accessibility 2026-10-12 14:03:21 +02:00
Antoine Jaury 477cb445e3 refactor: improve record list accessibility 2026-10-09 15:27:19 +02:00
45 changed files with 990 additions and 315 deletions
+4
View File
@@ -11,6 +11,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
### 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
+9
View File
@@ -0,0 +1,9 @@
plugins {
id("gwenedeg.kmp.library")
}
kotlin {
androidLibrary {
namespace = "bzh.ajaury.chombev.core.accessibility"
}
}
@@ -0,0 +1,15 @@
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
}
@@ -0,0 +1,12 @@
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>() }
}
@@ -0,0 +1,7 @@
package bzh.ajaury.chombev.core.accessibility.data
import bzh.ajaury.chombev.core.accessibility.domain.AccessibilityRepository
internal class UndetectableAccessibilityRepository : AccessibilityRepository {
override fun isScreenReaderEnabled(): Boolean = false
}
@@ -0,0 +1,5 @@
package bzh.ajaury.chombev.core.accessibility.di
import org.koin.core.module.Module
expect val accessibilityModule: Module
@@ -0,0 +1,5 @@
package bzh.ajaury.chombev.core.accessibility.domain
interface AccessibilityRepository {
fun isScreenReaderEnabled(): Boolean
}
@@ -0,0 +1,8 @@
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()
}
@@ -0,0 +1,12 @@
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>() }
}
@@ -0,0 +1,12 @@
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>() }
}
@@ -0,0 +1,12 @@
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>() }
}
@@ -138,13 +138,16 @@ internal class PlaybackRepositoryImpl(
)
}
override suspend fun load(record: Record) {
override suspend fun load(
record: Record,
autoPlay: Boolean,
) {
phraseBreakJob?.cancel()
cancelRunningBreak()
pendingPhraseStart = null
pendingSeekTarget = null
playAudio(filePath = record.audioResourcePath)
loadAudio(filePath = record.audioResourcePath, autoPlay = autoPlay)
subtitle.value = loadSubtitle(record = record)
reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO)
recordTitle.value = record.title
@@ -264,12 +267,15 @@ internal class PlaybackRepositoryImpl(
audioPlayer.seekTo(position)
}
private fun playAudio(filePath: String) {
private fun loadAudio(
filePath: String,
autoPlay: Boolean,
) {
try {
audioPlayer.load(uri = resourceReader.uri(filePath))
audioPlayer.play()
if (autoPlay) audioPlayer.play()
} catch (exception: Exception) {
logger.error(message = "Failed to play audio: $filePath", throwable = exception)
logger.error(message = "Failed to load audio: $filePath", throwable = exception)
}
}
@@ -15,7 +15,10 @@ import kotlin.time.Duration
interface PlaybackRepository {
val playerState: Flow<PlayerState>
suspend fun load(record: Record)
suspend fun load(
record: Record,
autoPlay: Boolean = true,
)
suspend fun play()
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,53 +0,0 @@
<resources>
<!-- Breton (br) -->
<string name="app_name">Donemat - Breton d'Auray</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 "Donemat - Breton d'Auray"</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="onboarding_close">Serriñ</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>
@@ -2,7 +2,7 @@
<!-- Default locale: French -->
<string name="app_name">Donemat - Breton d'Auray</string>
<string name="nav_records">Enregistrements</string>
<string name="nav_records">Écouter</string>
<string name="nav_deepen">Approfondir</string>
<string name="nav_about">À propos</string>
@@ -11,20 +11,27 @@
<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$d s</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="auto_pause_title">Pause automatique</string>
<string name="auto_pause_description">Mettre en pause après chaque phrase</string>
@@ -44,9 +51,15 @@
<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>
@@ -46,7 +46,6 @@ fun OnboardingInfoPage(
Text(
text = description,
style = MaterialTheme.typography.bodyLarge,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
}
@@ -51,7 +51,6 @@ fun OnboardingLevelPage(
Text(
text = stringResource(Res.string.onboarding_level_description),
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
textAlign = TextAlign.Center,
)
LevelOption(
@@ -110,7 +109,6 @@ 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.surfaceVariant
MaterialTheme.colorScheme.onBackground
},
),
)
+1
View File
@@ -14,6 +14,7 @@ kotlin {
}
commonMain.dependencies {
// Core modules
implementation(projects.core.accessibility)
implementation(projects.core.model)
implementation(projects.core.logging)
implementation(projects.core.audioplayer)
@@ -0,0 +1,23 @@
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
}
@@ -22,7 +22,10 @@ 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
@@ -85,6 +88,8 @@ 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,
@@ -102,6 +107,12 @@ 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,
@@ -58,6 +58,7 @@ fun PlayerControl(
contentAlignment = Alignment.Center,
) {
SpeedPreferenceButton(
modifier = Modifier.traversalOrder(PlayerTraversalOrder.PLAYBACK_SETTINGS),
isActive = isPlaybackModified,
onSpeedClicked = onSpeedClicked,
)
@@ -69,17 +70,20 @@ 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,11 +30,17 @@ 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
@@ -43,7 +49,10 @@ 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
@@ -110,6 +119,9 @@ 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,
@@ -185,39 +197,50 @@ private fun PlayerTopBar(
showPreferences: () -> Unit,
) {
TopAppBar(
modifier = Modifier.shadow(elevation = 2.dp),
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 },
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.recordEmoji to uiState.recordTitle,
targetState = uiState.record,
transitionSpec = {
fadeIn(tween()) togetherWith
fadeOut(tween())
},
label = "PlayerTitle",
) { (emoji, title) ->
) { 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) }
Row(
modifier = Modifier
.traversalOrder(PlayerTraversalOrder.TITLE)
.clearAndSetSemantics {
description?.let { contentDescription = it }
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
text = emoji,
text = record?.emoji.orEmpty(),
style = MaterialTheme.typography.headlineSmall,
)
Column {
Text(
text = title.transcription,
text = record?.title?.transcription.orEmpty(),
style = MaterialTheme.typography.headlineSmall,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
title.translation?.let { translation ->
record?.title?.translation?.let { translation ->
Text(
text = translation,
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
@@ -227,15 +250,22 @@ private fun PlayerTopBar(
}
},
navigationIcon = {
IconButton(onClick = onBackClicked) {
Icon(
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.player_back),
)
}
BackButton(onBackClicked = onBackClicked)
},
actions = {
IconButton(onClick = showPreferences) {
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,
) {
Icon(
imageVector = Icons.Filled.Subtitles,
contentDescription = stringResource(Res.string.subtitle_preferences_action),
@@ -245,6 +275,27 @@ 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(
@@ -270,17 +321,25 @@ 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(
recordEmoji = "🙏",
recordTitle = Phrase(
transcription = "Demat",
translation = "Bonjour",
),
fakeRecord,
playerState = PlayerState(
playbackState = PlaybackState.PLAYING,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
@@ -301,14 +360,13 @@ private fun PlayerScreenPausedPreview() {
MaterialTheme {
PlayerScreen(
uiState = PlayerUiState(
recordEmoji = "🙏",
recordTitle = Phrase(
transcription = "Demat",
translation = "Bonjour",
),
record = fakeRecord,
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,
),
@@ -326,11 +384,7 @@ private fun PlayerScreenEndedPreview() {
MaterialTheme {
PlayerScreen(
uiState = PlayerUiState(
recordEmoji = "🙏",
recordTitle = Phrase(
transcription = "Demat",
translation = "Bonjour",
),
record = fakeRecord,
playerState = PlayerState(
playbackState = PlaybackState.ENDED,
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
@@ -0,0 +1,30 @@
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,6 +29,7 @@ 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
@@ -97,7 +98,9 @@ fun SubtitleList(
}
val density = LocalDensity.current
BoxWithConstraints(modifier = modifier) {
// 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 {}) {
// 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,8 +10,17 @@ 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(
@@ -20,27 +29,55 @@ 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(verticalArrangement = Arrangement.spacedBy(4.dp)) {
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),
) {
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
horizontalArrangement = Arrangement.spacedBy(16.dp),
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,
color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha),
modifier = Modifier.weight(1f).alpha(contentAlpha),
)
Text(
modifier = Modifier.alpha(contentAlpha),
text = valueLabel,
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha),
style = MaterialTheme.typography.labelLarge,
maxLines = 1,
softWrap = false,
)
}
Slider(
@@ -53,6 +90,17 @@ 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() {
@@ -67,6 +115,20 @@ 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,19 +8,33 @@ 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),
modifier = modifier
.size(60.dp)
.semantics {
if (canGoToNextSentence) {
onClick(label = actionLabel) {
onNextSentenceClicked()
true
}
}
},
enabled = canGoToNextSentence,
onClick = onNextSentenceClicked,
) {
@@ -38,6 +38,7 @@ 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
@@ -61,7 +62,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,6 +9,7 @@ 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
@@ -33,7 +34,9 @@ fun PlaybackProgress(
}
seekProgress = null
},
modifier = Modifier.fillMaxWidth(),
modifier = Modifier
.fillMaxWidth()
.clearAndSetSemantics { },
)
}
@@ -8,19 +8,33 @@ 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),
modifier = modifier
.size(60.dp)
.semantics {
if (canGoToPreviousSentence) {
onClick(label = actionLabel) {
onPreviousSentenceClicked()
true
}
}
},
enabled = canGoToPreviousSentence,
onClick = onPreviousSentenceClicked,
) {
@@ -9,10 +9,13 @@ 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
/**
@@ -22,10 +25,19 @@ 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),
modifier = modifier
.size(60.dp)
.semantics {
onClick(label = openSettingsLabel) {
onSpeedClicked()
true
}
},
onClick = onSpeedClicked,
) {
Icon(
@@ -1,6 +1,6 @@
package bzh.ajaury.chombev.player.ui.viewmodel
import bzh.ajaury.chombev.core.model.Phrase
import bzh.ajaury.chombev.core.model.Record
import bzh.ajaury.chombev.player.model.PlayerState
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
@@ -8,8 +8,7 @@ import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
data class PlayerUiState(
val recordEmoji: String = "",
val recordTitle: Phrase = Phrase(transcription = ""),
val record: Record? = null,
val playerState: PlayerState = PlayerState(),
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
@@ -20,7 +19,10 @@ 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,6 +29,7 @@ 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)
@@ -40,8 +41,7 @@ class PlayerViewModel(
preferencesRepository.playbackPreferences,
) { record, playerState, subtitlePreferences, playbackPreferences ->
PlayerUiState(
recordEmoji = record?.emoji ?: "",
recordTitle = record?.title ?: Phrase(transcription = ""),
record = record,
playerState = playerState,
subtitlePreferences = subtitlePreferences,
playbackSpeed = playbackPreferences.speed,
@@ -62,11 +62,14 @@ 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)
playbackRepository.load(record = alreadyLoadedRecord, autoPlay = autoPlay)
return@launch
}
@@ -76,7 +79,7 @@ class PlayerViewModel(
return@launch
}
playbackRepository.load(record = loadedRecord)
playbackRepository.load(record = loadedRecord, autoPlay = autoPlay)
record.value = loadedRecord
}
}
@@ -51,7 +51,6 @@ fun PreferenceToggle(
Text(
text = subtitle,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
@@ -5,6 +5,8 @@ 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
@@ -20,6 +22,14 @@ import androidx.compose.material3.rememberBottomSheetState
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
@@ -30,6 +40,7 @@ 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
@@ -80,6 +91,8 @@ 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),
@@ -120,10 +133,50 @@ 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),
.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
},
)
},
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
@@ -11,23 +11,39 @@ 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 { onClick() }
.padding(16.dp),
.clickable(
role = Role.Button,
onClickLabel = stringResource(Res.string.records_chapter_action_description),
) {
onClick()
}.padding(16.dp)
.clearAndSetSemantics {
contentDescription = rowDescription
},
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
@@ -44,7 +60,6 @@ fun RecordView(
Text(
text = translation,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -70,3 +85,23 @@ 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,5 +1,6 @@
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
@@ -23,6 +24,7 @@ val recordsFeatureModule: Module = module {
recordsModule,
resourcesModule,
audioPlayerModule,
accessibilityModule,
subtitleModule,
)
+7 -7
View File
@@ -11,19 +11,19 @@ composeMultiplatform = "1.12.1" # https://github.com/JetBrains/compose-multi
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.1.0" # https://github.com/touchlab/Kermit/releases
kermit = "2.2.0" # https://github.com/touchlab/Kermit/releases
koin = "4.2.2" # https://github.com/InsertKoinIO/koin/releases
kotlin = "2.4.20" # https://github.com/JetBrains/kotlin/releases
kotlinSerialization = "2.4.20" # https://github.com/JetBrains/kotlin/releases
kotlinWrappers = "2026.9.2" # https://github.com/JetBrains/kotlin-wrappers/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.0" # https://developer.android.com/jetpack/androidx/releases/media3
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.1" # https://square.github.io/okio/changelog/
sentry = "0.27.0" # https://github.com/getsentry/sentry-kotlin-multiplatform/releases
okio = "3.18.2" # https://square.github.io/okio/changelog/
sentry = "0.28.0" # https://github.com/getsentry/sentry-kotlin-multiplatform/releases
[libraries]
# Gradle plugin artifacts, used by build-logic convention plugins only
+1 -1
View File
@@ -1,7 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
+1
View File
@@ -39,6 +39,7 @@ include(":webApp")
include(":shared")
// Core modules
include(":core:accessibility")
include(":core:audioplayer")
include(":core:coroutines")
include(":core:logging")
@@ -7,11 +7,11 @@ import bzh.ajaury.chombev.web.model.WebPage
*/
object WebPages {
val DEEPEN = WebPage(
url = "https://ksk.bzh/fr/chom-bev-approfondir/",
url = "https://ksk.bzh/fr/donemat-approfondir/",
offlineAsset = "files/offline_deepen.html",
)
val ABOUT = WebPage(
url = "https://ksk.bzh/fr/chom-bev-a-propos/",
url = "https://ksk.bzh/fr/donemat-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(0xFFFFFFFF)
internal val md_light_onPrimary = Color(0xFF000000)
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(0xFFF5FAFB)
internal val md_light_surface = Color(0xFFFFFFFF)
internal val md_light_onSurface = Color(0xFF171D1E)
internal val md_light_surfaceVariant = Color(0xFF91949C)
internal val md_light_onSurfaceVariant = Color(0xFF3F484A)
internal val md_light_surfaceVariant = Color(0xFFFAFEFF)
internal val md_light_onSurfaceVariant = Color(0xFF000000)
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(0xFFBFC8CA)
internal val md_dark_onSurfaceVariant = Color(0xFFFFFFFF)
internal val md_dark_outline = Color(0xFF899294)
internal val md_dark_outlineVariant = Color(0xFF3F484A)
@@ -0,0 +1,139 @@
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')}"
}
}
}