Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
34cb8d24a4 | ||
|
|
0add1176db | ||
|
|
0d42413136 | ||
|
|
f2e35fc8f2 | ||
|
|
6d31dcda93 | ||
|
|
032ba458f7 | ||
|
|
d51223bb19 | ||
|
|
d721b0475d | ||
|
|
9cbb4181b3 | ||
|
|
0324186130 | ||
|
|
5539bf8b0d | ||
|
|
82a2ef3bdb | ||
|
|
8339ee128b | ||
|
|
8dc1e8e71e | ||
|
|
1a23703911 | ||
|
|
242d494c86 | ||
|
|
477cb445e3 |
@@ -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
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
plugins {
|
||||
id("gwenedeg.kmp.library")
|
||||
}
|
||||
|
||||
kotlin {
|
||||
androidLibrary {
|
||||
namespace = "bzh.ajaury.chombev.core.accessibility"
|
||||
}
|
||||
}
|
||||
+15
@@ -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
|
||||
}
|
||||
+12
@@ -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>() }
|
||||
}
|
||||
+7
@@ -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
|
||||
}
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package bzh.ajaury.chombev.core.accessibility.di
|
||||
|
||||
import org.koin.core.module.Module
|
||||
|
||||
expect val accessibilityModule: Module
|
||||
+5
@@ -0,0 +1,5 @@
|
||||
package bzh.ajaury.chombev.core.accessibility.domain
|
||||
|
||||
interface AccessibilityRepository {
|
||||
fun isScreenReaderEnabled(): Boolean
|
||||
}
|
||||
+8
@@ -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()
|
||||
}
|
||||
+12
@@ -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>() }
|
||||
}
|
||||
+12
@@ -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>() }
|
||||
}
|
||||
+12
@@ -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>() }
|
||||
}
|
||||
+11
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+4
-1
@@ -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>
|
||||
|
||||
-1
@@ -46,7 +46,6 @@ fun OnboardingInfoPage(
|
||||
Text(
|
||||
text = description,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
|
||||
-2
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -32,7 +32,7 @@ fun OnboardingPageIndicator(
|
||||
if (index == currentPage) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.surfaceVariant
|
||||
MaterialTheme.colorScheme.onBackground
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+11
@@ -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,
|
||||
)
|
||||
|
||||
+84
-30
@@ -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),
|
||||
|
||||
+30
@@ -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
|
||||
|
||||
+67
-5
@@ -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() {
|
||||
|
||||
+15
-1
@@ -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,
|
||||
) {
|
||||
|
||||
+2
-1
@@ -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) {
|
||||
|
||||
+4
-1
@@ -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 { },
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+15
-1
@@ -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,
|
||||
) {
|
||||
|
||||
+13
-1
@@ -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(
|
||||
|
||||
+6
-4
@@ -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
|
||||
|
||||
+8
-5
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
-1
@@ -51,7 +51,6 @@ fun PreferenceToggle(
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+54
-1
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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')}"
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user