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
|
### Changed
|
||||||
|
|
||||||
|
- improve accessibility globally in the app
|
||||||
|
- add scrollable support for subtitle preferences bottom sheet
|
||||||
|
- improve color contrast handling
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
## [1.0.0-beta04] - 2026-10-02
|
## [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()
|
phraseBreakJob?.cancel()
|
||||||
cancelRunningBreak()
|
cancelRunningBreak()
|
||||||
pendingPhraseStart = null
|
pendingPhraseStart = null
|
||||||
pendingSeekTarget = null
|
pendingSeekTarget = null
|
||||||
|
|
||||||
playAudio(filePath = record.audioResourcePath)
|
loadAudio(filePath = record.audioResourcePath, autoPlay = autoPlay)
|
||||||
subtitle.value = loadSubtitle(record = record)
|
subtitle.value = loadSubtitle(record = record)
|
||||||
reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO)
|
reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO)
|
||||||
recordTitle.value = record.title
|
recordTitle.value = record.title
|
||||||
@@ -264,12 +267,15 @@ internal class PlaybackRepositoryImpl(
|
|||||||
audioPlayer.seekTo(position)
|
audioPlayer.seekTo(position)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun playAudio(filePath: String) {
|
private fun loadAudio(
|
||||||
|
filePath: String,
|
||||||
|
autoPlay: Boolean,
|
||||||
|
) {
|
||||||
try {
|
try {
|
||||||
audioPlayer.load(uri = resourceReader.uri(filePath))
|
audioPlayer.load(uri = resourceReader.uri(filePath))
|
||||||
audioPlayer.play()
|
if (autoPlay) audioPlayer.play()
|
||||||
} catch (exception: Exception) {
|
} 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 {
|
interface PlaybackRepository {
|
||||||
val playerState: Flow<PlayerState>
|
val playerState: Flow<PlayerState>
|
||||||
|
|
||||||
suspend fun load(record: Record)
|
suspend fun load(
|
||||||
|
record: Record,
|
||||||
|
autoPlay: Boolean = true,
|
||||||
|
)
|
||||||
|
|
||||||
suspend fun play()
|
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 -->
|
<!-- Default locale: French -->
|
||||||
<string name="app_name">Donemat - Breton d'Auray</string>
|
<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_deepen">Approfondir</string>
|
||||||
<string name="nav_about">À propos</string>
|
<string name="nav_about">À propos</string>
|
||||||
|
|
||||||
@@ -11,20 +11,27 @@
|
|||||||
|
|
||||||
<string name="subtitle_preferences_title">Sous-titres</string>
|
<string name="subtitle_preferences_title">Sous-titres</string>
|
||||||
<string name="subtitle_preferences_action">Préférences de sous-titrage</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_label">Transcription (BZH)</string>
|
||||||
<string name="subtitle_transcription_description">Afficher la transcription bretonne</string>
|
<string name="subtitle_transcription_description">Afficher la transcription bretonne</string>
|
||||||
<string name="subtitle_translation_label">Traduction (FR)</string>
|
<string name="subtitle_translation_label">Traduction (FR)</string>
|
||||||
<string name="subtitle_translation_description">Afficher la traduction française</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_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_decrease">Réduire la taille du texte</string>
|
||||||
<string name="subtitle_text_size_increase">Augmenter 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_label">Marques de prononciation</string>
|
||||||
<string name="subtitle_marked_letters_description">Afficher les consonnes muettes et les liaisons</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_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="playback_settings_action">Réglages de lecture</string>
|
||||||
<string name="break_between_phrases_title">Pause entre les phrases</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_title">Pause automatique</string>
|
||||||
<string name="auto_pause_description">Mettre en pause après chaque phrase</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_start">Commencer</string>
|
||||||
<string name="onboarding_close">Fermer</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">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_previous_sentence">Phrase précédente</string>
|
||||||
<string name="player_next_sentence">Phrase suivante</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_play">Lire</string>
|
||||||
<string name="player_pause">Pause</string>
|
<string name="player_pause">Pause</string>
|
||||||
<string name="player_replay">Rejouer</string>
|
<string name="player_replay">Rejouer</string>
|
||||||
|
|||||||
-1
@@ -46,7 +46,6 @@ fun OnboardingInfoPage(
|
|||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
-2
@@ -51,7 +51,6 @@ fun OnboardingLevelPage(
|
|||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.onboarding_level_description),
|
text = stringResource(Res.string.onboarding_level_description),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
LevelOption(
|
LevelOption(
|
||||||
@@ -110,7 +109,6 @@ private fun LevelOption(
|
|||||||
Text(
|
Text(
|
||||||
text = description,
|
text = description,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -32,7 +32,7 @@ fun OnboardingPageIndicator(
|
|||||||
if (index == currentPage) {
|
if (index == currentPage) {
|
||||||
MaterialTheme.colorScheme.primary
|
MaterialTheme.colorScheme.primary
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.surfaceVariant
|
MaterialTheme.colorScheme.onBackground
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ kotlin {
|
|||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
// Core modules
|
// Core modules
|
||||||
|
implementation(projects.core.accessibility)
|
||||||
implementation(projects.core.model)
|
implementation(projects.core.model)
|
||||||
implementation(projects.core.logging)
|
implementation(projects.core.logging)
|
||||||
implementation(projects.core.audioplayer)
|
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.auto_pause_title
|
||||||
import bzh.ajaury.chombev.resources.generated.resources.break_between_phrases_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
|
||||||
|
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_title
|
||||||
|
import bzh.ajaury.chombev.resources.generated.resources.playback_speed_value
|
||||||
|
import org.jetbrains.compose.resources.pluralStringResource
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import kotlin.math.roundToInt
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@@ -85,6 +88,8 @@ private fun PlaybackSpeedContent(
|
|||||||
LabeledSlider(
|
LabeledSlider(
|
||||||
title = stringResource(Res.string.playback_speed_title),
|
title = stringResource(Res.string.playback_speed_title),
|
||||||
valueLabel = formatPlaybackSpeed(speed),
|
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,
|
value = speed,
|
||||||
valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED,
|
valueRange = PlaybackPreferences.MIN_SPEED..PlaybackPreferences.MAX_SPEED,
|
||||||
steps = speedSteps,
|
steps = speedSteps,
|
||||||
@@ -102,6 +107,12 @@ private fun PlaybackSpeedContent(
|
|||||||
Res.string.break_between_phrases_value,
|
Res.string.break_between_phrases_value,
|
||||||
breakSeconds.roundToInt().toString(),
|
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,
|
value = breakSeconds,
|
||||||
valueRange = 0f..maxBreakSeconds.toFloat(),
|
valueRange = 0f..maxBreakSeconds.toFloat(),
|
||||||
steps = breakSteps,
|
steps = breakSteps,
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ fun PlayerControl(
|
|||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
SpeedPreferenceButton(
|
SpeedPreferenceButton(
|
||||||
|
modifier = Modifier.traversalOrder(PlayerTraversalOrder.PLAYBACK_SETTINGS),
|
||||||
isActive = isPlaybackModified,
|
isActive = isPlaybackModified,
|
||||||
onSpeedClicked = onSpeedClicked,
|
onSpeedClicked = onSpeedClicked,
|
||||||
)
|
)
|
||||||
@@ -69,17 +70,20 @@ fun PlayerControl(
|
|||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
PreviousButton(
|
PreviousButton(
|
||||||
|
modifier = Modifier.traversalOrder(PlayerTraversalOrder.PREVIOUS),
|
||||||
canGoToPreviousSentence = canGoToPreviousSentence,
|
canGoToPreviousSentence = canGoToPreviousSentence,
|
||||||
onPreviousSentenceClicked = onPreviousSentenceClicked,
|
onPreviousSentenceClicked = onPreviousSentenceClicked,
|
||||||
)
|
)
|
||||||
|
|
||||||
PlayButton(
|
PlayButton(
|
||||||
|
modifier = Modifier.traversalOrder(PlayerTraversalOrder.PLAY),
|
||||||
playbackState = playbackState,
|
playbackState = playbackState,
|
||||||
breakDuration = breakBetweenPhrases,
|
breakDuration = breakBetweenPhrases,
|
||||||
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
|
onMainPlayActionButtonClicked = onMainPlayActionButtonClicked,
|
||||||
)
|
)
|
||||||
|
|
||||||
NextButton(
|
NextButton(
|
||||||
|
modifier = Modifier.traversalOrder(PlayerTraversalOrder.NEXT),
|
||||||
canGoToNextSentence = canGoToNextSentence,
|
canGoToNextSentence = canGoToNextSentence,
|
||||||
onNextSentenceClicked = onNextSentenceClicked,
|
onNextSentenceClicked = onNextSentenceClicked,
|
||||||
)
|
)
|
||||||
|
|||||||
+84
-30
@@ -30,11 +30,17 @@ import androidx.compose.runtime.setValue
|
|||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.shadow
|
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.text.style.TextOverflow
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import bzh.ajaury.chombev.core.model.Phrase
|
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.PlaybackState
|
||||||
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
||||||
import bzh.ajaury.chombev.player.model.PlayerState
|
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.preferences.ui.SubtitlePreferencesBottomSheet
|
||||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
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
|
||||||
|
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.resources.generated.resources.subtitle_preferences_action
|
||||||
|
import bzh.ajaury.chombev.rowDescription
|
||||||
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
|
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
@@ -110,6 +119,9 @@ fun PlayerScreen(
|
|||||||
var showSpeed by remember { mutableStateOf(false) }
|
var showSpeed by remember { mutableStateOf(false) }
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
|
// Root of the screen reader order defined by PlayerTraversalOrder, which interleaves the
|
||||||
|
// top bar and the controls.
|
||||||
|
modifier = Modifier.semantics { isTraversalGroup = true },
|
||||||
topBar = {
|
topBar = {
|
||||||
PlayerTopBar(
|
PlayerTopBar(
|
||||||
uiState = uiState,
|
uiState = uiState,
|
||||||
@@ -185,39 +197,50 @@ private fun PlayerTopBar(
|
|||||||
showPreferences: () -> Unit,
|
showPreferences: () -> Unit,
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
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 = {
|
title = {
|
||||||
// Animate the title in as it loads, and between records: the record data isn't there on
|
// 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.
|
// the first frame, so fade + rise it into place once uiState carries it.
|
||||||
AnimatedContent(
|
AnimatedContent(
|
||||||
targetState = uiState.recordEmoji to uiState.recordTitle,
|
targetState = uiState.record,
|
||||||
transitionSpec = {
|
transitionSpec = {
|
||||||
fadeIn(tween()) togetherWith
|
fadeIn(tween()) togetherWith
|
||||||
fadeOut(tween())
|
fadeOut(tween())
|
||||||
},
|
},
|
||||||
label = "PlayerTitle",
|
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(
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.traversalOrder(PlayerTraversalOrder.TITLE)
|
||||||
|
.clearAndSetSemantics {
|
||||||
|
description?.let { contentDescription = it }
|
||||||
|
},
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = emoji,
|
text = record?.emoji.orEmpty(),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
)
|
)
|
||||||
Column {
|
Column {
|
||||||
Text(
|
Text(
|
||||||
text = title.transcription,
|
text = record?.title?.transcription.orEmpty(),
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
|
|
||||||
title.translation?.let { translation ->
|
record?.title?.translation?.let { translation ->
|
||||||
Text(
|
Text(
|
||||||
text = translation,
|
text = translation,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
)
|
)
|
||||||
@@ -227,15 +250,22 @@ private fun PlayerTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
navigationIcon = {
|
navigationIcon = {
|
||||||
IconButton(onClick = onBackClicked) {
|
BackButton(onBackClicked = onBackClicked)
|
||||||
Icon(
|
|
||||||
imageVector = Icons.AutoMirrored.Filled.ArrowBack,
|
|
||||||
contentDescription = stringResource(Res.string.player_back),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
actions = {
|
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(
|
Icon(
|
||||||
imageVector = Icons.Filled.Subtitles,
|
imageVector = Icons.Filled.Subtitles,
|
||||||
contentDescription = stringResource(Res.string.subtitle_preferences_action),
|
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 =
|
private val previewSubtitleLines =
|
||||||
listOf(
|
listOf(
|
||||||
SubtitleLine(
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlayerScreenPlayingPreview() {
|
private fun PlayerScreenPlayingPreview() {
|
||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
uiState = PlayerUiState(
|
uiState = PlayerUiState(
|
||||||
recordEmoji = "🙏",
|
fakeRecord,
|
||||||
recordTitle = Phrase(
|
|
||||||
transcription = "Demat",
|
|
||||||
translation = "Bonjour",
|
|
||||||
),
|
|
||||||
playerState = PlayerState(
|
playerState = PlayerState(
|
||||||
playbackState = PlaybackState.PLAYING,
|
playbackState = PlaybackState.PLAYING,
|
||||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
||||||
@@ -301,14 +360,13 @@ private fun PlayerScreenPausedPreview() {
|
|||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
uiState = PlayerUiState(
|
uiState = PlayerUiState(
|
||||||
recordEmoji = "🙏",
|
record = fakeRecord,
|
||||||
recordTitle = Phrase(
|
|
||||||
transcription = "Demat",
|
|
||||||
translation = "Bonjour",
|
|
||||||
),
|
|
||||||
playerState = PlayerState(
|
playerState = PlayerState(
|
||||||
playbackState = PlaybackState.PAUSED,
|
playbackState = PlaybackState.PAUSED,
|
||||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
playbackTiming = PlaybackTiming(
|
||||||
|
position = 1.2.seconds,
|
||||||
|
duration = 5.seconds,
|
||||||
|
),
|
||||||
subtitleLines = previewSubtitleLines,
|
subtitleLines = previewSubtitleLines,
|
||||||
currentSubtitleIndex = 1,
|
currentSubtitleIndex = 1,
|
||||||
),
|
),
|
||||||
@@ -326,11 +384,7 @@ private fun PlayerScreenEndedPreview() {
|
|||||||
MaterialTheme {
|
MaterialTheme {
|
||||||
PlayerScreen(
|
PlayerScreen(
|
||||||
uiState = PlayerUiState(
|
uiState = PlayerUiState(
|
||||||
recordEmoji = "🙏",
|
record = fakeRecord,
|
||||||
recordTitle = Phrase(
|
|
||||||
transcription = "Demat",
|
|
||||||
translation = "Bonjour",
|
|
||||||
),
|
|
||||||
playerState = PlayerState(
|
playerState = PlayerState(
|
||||||
playbackState = PlaybackState.ENDED,
|
playbackState = PlaybackState.ENDED,
|
||||||
playbackTiming = PlaybackTiming(position = 1.2.seconds, duration = 5.seconds),
|
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.draw.clip
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.text.font.FontStyle
|
import androidx.compose.ui.text.font.FontStyle
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
@@ -97,7 +98,9 @@ fun SubtitleList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
val density = LocalDensity.current
|
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
|
// 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.
|
// because `maxHeight` (BoxWithConstraintsScope) is not in scope inside the provider lambda.
|
||||||
val verticalPadding = maxHeight / 2
|
val verticalPadding = maxHeight / 2
|
||||||
|
|||||||
+67
-5
@@ -10,8 +10,17 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlin.math.roundToInt
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LabeledSlider(
|
fun LabeledSlider(
|
||||||
@@ -20,27 +29,55 @@ fun LabeledSlider(
|
|||||||
value: Float,
|
value: Float,
|
||||||
valueRange: ClosedFloatingPointRange<Float>,
|
valueRange: ClosedFloatingPointRange<Float>,
|
||||||
steps: Int,
|
steps: Int,
|
||||||
|
valueDescription: String = valueLabel,
|
||||||
onValueChange: (Float) -> Unit = {},
|
onValueChange: (Float) -> Unit = {},
|
||||||
enabled: Boolean = true,
|
enabled: Boolean = true,
|
||||||
) {
|
) {
|
||||||
// Dim the labels to match the Slider's disabled appearance.
|
// Dim the labels to match the Slider's disabled appearance.
|
||||||
val contentAlpha = if (enabled) 1f else 0.38f
|
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(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
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(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.titleMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = contentAlpha),
|
modifier = Modifier.weight(1f).alpha(contentAlpha),
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
modifier = Modifier.alpha(contentAlpha),
|
||||||
text = valueLabel,
|
text = valueLabel,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = MaterialTheme.typography.labelLarge,
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = contentAlpha),
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Slider(
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun LabeledSliderPreview() {
|
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
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun LabeledSliderDisabledPreview() {
|
private fun LabeledSliderDisabledPreview() {
|
||||||
|
|||||||
+15
-1
@@ -8,19 +8,33 @@ import androidx.compose.material3.IconButton
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
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
|
||||||
|
import bzh.ajaury.chombev.resources.generated.resources.player_next_sentence_action_label
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun NextButton(
|
fun NextButton(
|
||||||
canGoToNextSentence: Boolean,
|
canGoToNextSentence: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onNextSentenceClicked: () -> Unit = {},
|
onNextSentenceClicked: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val actionLabel = stringResource(Res.string.player_next_sentence_action_label)
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.size(60.dp),
|
modifier = modifier
|
||||||
|
.size(60.dp)
|
||||||
|
.semantics {
|
||||||
|
if (canGoToNextSentence) {
|
||||||
|
onClick(label = actionLabel) {
|
||||||
|
onNextSentenceClicked()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
enabled = canGoToNextSentence,
|
enabled = canGoToNextSentence,
|
||||||
onClick = onNextSentenceClicked,
|
onClick = onNextSentenceClicked,
|
||||||
) {
|
) {
|
||||||
|
|||||||
+2
-1
@@ -38,6 +38,7 @@ private val BreakRingSize = 68.dp
|
|||||||
fun PlayButton(
|
fun PlayButton(
|
||||||
playbackState: PlaybackState,
|
playbackState: PlaybackState,
|
||||||
breakDuration: Duration = Duration.ZERO,
|
breakDuration: Duration = Duration.ZERO,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onMainPlayActionButtonClicked: () -> Unit = {},
|
onMainPlayActionButtonClicked: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
val isBreaking = playbackState == PlaybackState.BREAKING && breakDuration > Duration.ZERO
|
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
|
// 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.
|
// starts; the indicator itself is only drawn while breaking.
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.size(BreakRingSize),
|
modifier = modifier.size(BreakRingSize),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center,
|
||||||
) {
|
) {
|
||||||
if (isBreaking) {
|
if (isBreaking) {
|
||||||
|
|||||||
+4
-1
@@ -9,6 +9,7 @@ import androidx.compose.runtime.mutableStateOf
|
|||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
@@ -33,7 +34,9 @@ fun PlaybackProgress(
|
|||||||
}
|
}
|
||||||
seekProgress = null
|
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.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
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
|
||||||
|
import bzh.ajaury.chombev.resources.generated.resources.player_previous_sentence_action_label
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun PreviousButton(
|
fun PreviousButton(
|
||||||
canGoToPreviousSentence: Boolean,
|
canGoToPreviousSentence: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onPreviousSentenceClicked: () -> Unit = {},
|
onPreviousSentenceClicked: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val actionLabel = stringResource(Res.string.player_previous_sentence_action_label)
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.size(60.dp),
|
modifier = modifier
|
||||||
|
.size(60.dp)
|
||||||
|
.semantics {
|
||||||
|
if (canGoToPreviousSentence) {
|
||||||
|
onClick(label = actionLabel) {
|
||||||
|
onPreviousSentenceClicked()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
enabled = canGoToPreviousSentence,
|
enabled = canGoToPreviousSentence,
|
||||||
onClick = onPreviousSentenceClicked,
|
onClick = onPreviousSentenceClicked,
|
||||||
) {
|
) {
|
||||||
|
|||||||
+13
-1
@@ -9,10 +9,13 @@ import androidx.compose.material3.LocalContentColor
|
|||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import bzh.ajaury.chombev.resources.generated.resources.Res
|
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.playback_settings_action
|
||||||
|
import bzh.ajaury.chombev.resources.generated.resources.settings_open_action_description
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -22,10 +25,19 @@ import org.jetbrains.compose.resources.stringResource
|
|||||||
@Composable
|
@Composable
|
||||||
fun SpeedPreferenceButton(
|
fun SpeedPreferenceButton(
|
||||||
isActive: Boolean,
|
isActive: Boolean,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
onSpeedClicked: () -> Unit = {},
|
onSpeedClicked: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val openSettingsLabel = stringResource(Res.string.settings_open_action_description)
|
||||||
IconButton(
|
IconButton(
|
||||||
modifier = Modifier.size(60.dp),
|
modifier = modifier
|
||||||
|
.size(60.dp)
|
||||||
|
.semantics {
|
||||||
|
onClick(label = openSettingsLabel) {
|
||||||
|
onSpeedClicked()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
},
|
||||||
onClick = onSpeedClicked,
|
onClick = onSpeedClicked,
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
|
|||||||
+6
-4
@@ -1,6 +1,6 @@
|
|||||||
package bzh.ajaury.chombev.player.ui.viewmodel
|
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.player.model.PlayerState
|
||||||
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
||||||
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
||||||
@@ -8,8 +8,7 @@ import kotlin.time.Duration
|
|||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
data class PlayerUiState(
|
data class PlayerUiState(
|
||||||
val recordEmoji: String = "",
|
val record: Record? = null,
|
||||||
val recordTitle: Phrase = Phrase(transcription = ""),
|
|
||||||
val playerState: PlayerState = PlayerState(),
|
val playerState: PlayerState = PlayerState(),
|
||||||
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
|
val subtitlePreferences: SubtitlePreferences = SubtitlePreferences(),
|
||||||
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
|
val playbackSpeed: Float = PlaybackPreferences.DEFAULT_SPEED,
|
||||||
@@ -20,7 +19,10 @@ data class PlayerUiState(
|
|||||||
get() = playerState.playbackTiming.position > 2.seconds
|
get() = playerState.playbackTiming.position > 2.seconds
|
||||||
|
|
||||||
val canGoToNextSentence: Boolean
|
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. */
|
/** True when any playback setting differs from its default, used to highlight the button. */
|
||||||
val isPlaybackModified: Boolean
|
val isPlaybackModified: Boolean
|
||||||
|
|||||||
+8
-5
@@ -2,8 +2,8 @@ package bzh.ajaury.chombev.player.ui.viewmodel
|
|||||||
|
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
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.logging.domain.Logger
|
||||||
import bzh.ajaury.chombev.core.model.Phrase
|
|
||||||
import bzh.ajaury.chombev.core.model.Record
|
import bzh.ajaury.chombev.core.model.Record
|
||||||
import bzh.ajaury.chombev.player.domain.AudioSessionManager
|
import bzh.ajaury.chombev.player.domain.AudioSessionManager
|
||||||
import bzh.ajaury.chombev.player.domain.PlaybackRepository
|
import bzh.ajaury.chombev.player.domain.PlaybackRepository
|
||||||
@@ -29,6 +29,7 @@ class PlayerViewModel(
|
|||||||
private val playbackRepository: PlaybackRepository,
|
private val playbackRepository: PlaybackRepository,
|
||||||
private val preferencesRepository: PreferencesRepository,
|
private val preferencesRepository: PreferencesRepository,
|
||||||
private val audioSessionManager: AudioSessionManager,
|
private val audioSessionManager: AudioSessionManager,
|
||||||
|
private val accessibilityRepository: AccessibilityRepository,
|
||||||
private val logger: Logger,
|
private val logger: Logger,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val record = MutableStateFlow<Record?>(null)
|
private val record = MutableStateFlow<Record?>(null)
|
||||||
@@ -40,8 +41,7 @@ class PlayerViewModel(
|
|||||||
preferencesRepository.playbackPreferences,
|
preferencesRepository.playbackPreferences,
|
||||||
) { record, playerState, subtitlePreferences, playbackPreferences ->
|
) { record, playerState, subtitlePreferences, playbackPreferences ->
|
||||||
PlayerUiState(
|
PlayerUiState(
|
||||||
recordEmoji = record?.emoji ?: "",
|
record = record,
|
||||||
recordTitle = record?.title ?: Phrase(transcription = ""),
|
|
||||||
playerState = playerState,
|
playerState = playerState,
|
||||||
subtitlePreferences = subtitlePreferences,
|
subtitlePreferences = subtitlePreferences,
|
||||||
playbackSpeed = playbackPreferences.speed,
|
playbackSpeed = playbackPreferences.speed,
|
||||||
@@ -62,11 +62,14 @@ class PlayerViewModel(
|
|||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
playbackRepository.stop()
|
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")
|
logger.debug("Loading record $recordId")
|
||||||
val alreadyLoadedRecord = record.value
|
val alreadyLoadedRecord = record.value
|
||||||
if (alreadyLoadedRecord != null) {
|
if (alreadyLoadedRecord != null) {
|
||||||
logger.debug("Record $recordId already loaded")
|
logger.debug("Record $recordId already loaded")
|
||||||
playbackRepository.load(record = alreadyLoadedRecord)
|
playbackRepository.load(record = alreadyLoadedRecord, autoPlay = autoPlay)
|
||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -76,7 +79,7 @@ class PlayerViewModel(
|
|||||||
return@launch
|
return@launch
|
||||||
}
|
}
|
||||||
|
|
||||||
playbackRepository.load(record = loadedRecord)
|
playbackRepository.load(record = loadedRecord, autoPlay = autoPlay)
|
||||||
record.value = loadedRecord
|
record.value = loadedRecord
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
-1
@@ -51,7 +51,6 @@ fun PreferenceToggle(
|
|||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
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.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
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.Icons
|
||||||
import androidx.compose.material.icons.filled.Add
|
import androidx.compose.material.icons.filled.Add
|
||||||
import androidx.compose.material.icons.filled.Remove
|
import androidx.compose.material.icons.filled.Remove
|
||||||
@@ -20,6 +22,14 @@ import androidx.compose.material3.rememberBottomSheetState
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
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_decrease
|
||||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_text_size_increase
|
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_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_description
|
||||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_label
|
import bzh.ajaury.chombev.resources.generated.resources.subtitle_transcription_label
|
||||||
import bzh.ajaury.chombev.resources.generated.resources.subtitle_translation_description
|
import bzh.ajaury.chombev.resources.generated.resources.subtitle_translation_description
|
||||||
@@ -80,6 +91,8 @@ private fun SubtitlePreferencesContent(
|
|||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
|
// Scrolls when a large system font size makes the content taller than the sheet.
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(bottom = 24.dp),
|
.padding(bottom = 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
@@ -120,10 +133,50 @@ private fun SubtitleTextSizeControl(
|
|||||||
onDecrease: () -> Unit = {},
|
onDecrease: () -> Unit = {},
|
||||||
onIncrease: () -> 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(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.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),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -11,23 +11,39 @@ import androidx.compose.material3.Text
|
|||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
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.text.font.FontFamily
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import bzh.ajaury.chombev.core.model.Phrase
|
import bzh.ajaury.chombev.core.model.Phrase
|
||||||
import bzh.ajaury.chombev.core.model.Record
|
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
|
@Composable
|
||||||
fun RecordView(
|
fun RecordView(
|
||||||
record: Record,
|
record: Record,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
|
val rowDescription = rowDescription(record)
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier =
|
modifier =
|
||||||
Modifier
|
Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable { onClick() }
|
.clickable(
|
||||||
.padding(16.dp),
|
role = Role.Button,
|
||||||
|
onClickLabel = stringResource(Res.string.records_chapter_action_description),
|
||||||
|
) {
|
||||||
|
onClick()
|
||||||
|
}.padding(16.dp)
|
||||||
|
.clearAndSetSemantics {
|
||||||
|
contentDescription = rowDescription
|
||||||
|
},
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
) {
|
) {
|
||||||
@@ -44,7 +60,6 @@ fun RecordView(
|
|||||||
Text(
|
Text(
|
||||||
text = translation,
|
text = translation,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,3 +85,23 @@ private fun RecordViewPreview() {
|
|||||||
onClick = {},
|
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
|
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.audioPlayerModule
|
||||||
import bzh.ajaury.chombev.player.di.playbackModule
|
import bzh.ajaury.chombev.player.di.playbackModule
|
||||||
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
|
import bzh.ajaury.chombev.player.ui.viewmodel.PlayerViewModel
|
||||||
@@ -23,6 +24,7 @@ val recordsFeatureModule: Module = module {
|
|||||||
recordsModule,
|
recordsModule,
|
||||||
resourcesModule,
|
resourcesModule,
|
||||||
audioPlayerModule,
|
audioPlayerModule,
|
||||||
|
accessibilityModule,
|
||||||
subtitleModule,
|
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
|
datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore
|
||||||
googleServices = "4.5.0" # https://developers.google.com/android/guides/releases
|
googleServices = "4.5.0" # https://developers.google.com/android/guides/releases
|
||||||
jaad = "0.9.4" # https://github.com/Tianscar/jaad/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
|
koin = "4.2.2" # https://github.com/InsertKoinIO/koin/releases
|
||||||
kotlin = "2.4.20" # https://github.com/JetBrains/kotlin/releases
|
kotlin = "2.4.21" # https://github.com/JetBrains/kotlin/releases
|
||||||
kotlinSerialization = "2.4.20" # https://github.com/JetBrains/kotlin/releases
|
kotlinSerialization = "2.4.21" # https://github.com/JetBrains/kotlin/releases
|
||||||
kotlinWrappers = "2026.9.2" # https://github.com/JetBrains/kotlin-wrappers/releases
|
kotlinWrappers = "2026.10.0" # https://github.com/JetBrains/kotlin-wrappers/releases
|
||||||
kotlinxBrowser = "0.5.0" # https://github.com/Kotlin/kotlinx-browser/releases
|
kotlinxBrowser = "0.5.0" # https://github.com/Kotlin/kotlinx-browser/releases
|
||||||
kotlinxCoroutines = "1.11.0" # https://github.com/Kotlin/kotlinx.coroutines/releases
|
kotlinxCoroutines = "1.11.0" # https://github.com/Kotlin/kotlinx.coroutines/releases
|
||||||
kotlinxSerializationJson = "1.11.0" # https://github.com/Kotlin/kotlinx.serialization/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
|
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
|
navigation3 = "1.1.2" # https://developer.android.com/jetpack/androidx/releases/navigation3
|
||||||
okio = "3.18.1" # https://square.github.io/okio/changelog/
|
okio = "3.18.2" # https://square.github.io/okio/changelog/
|
||||||
sentry = "0.27.0" # https://github.com/getsentry/sentry-kotlin-multiplatform/releases
|
sentry = "0.28.0" # https://github.com/getsentry/sentry-kotlin-multiplatform/releases
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
# Gradle plugin artifacts, used by build-logic convention plugins only
|
# Gradle plugin artifacts, used by build-logic convention plugins only
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
|
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
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ include(":webApp")
|
|||||||
include(":shared")
|
include(":shared")
|
||||||
|
|
||||||
// Core modules
|
// Core modules
|
||||||
|
include(":core:accessibility")
|
||||||
include(":core:audioplayer")
|
include(":core:audioplayer")
|
||||||
include(":core:coroutines")
|
include(":core:coroutines")
|
||||||
include(":core:logging")
|
include(":core:logging")
|
||||||
|
|||||||
@@ -7,11 +7,11 @@ import bzh.ajaury.chombev.web.model.WebPage
|
|||||||
*/
|
*/
|
||||||
object WebPages {
|
object WebPages {
|
||||||
val DEEPEN = WebPage(
|
val DEEPEN = WebPage(
|
||||||
url = "https://ksk.bzh/fr/chom-bev-approfondir/",
|
url = "https://ksk.bzh/fr/donemat-approfondir/",
|
||||||
offlineAsset = "files/offline_deepen.html",
|
offlineAsset = "files/offline_deepen.html",
|
||||||
)
|
)
|
||||||
val ABOUT = WebPage(
|
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",
|
offlineAsset = "files/offline_about.html",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
|
|
||||||
// region Light
|
// region Light
|
||||||
internal val md_light_primary = Color(0xFFFF9201)
|
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_primaryContainer = Color(0xFFFFEBD1)
|
||||||
internal val md_light_onPrimaryContainer = Color(0xFF1C1613)
|
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_background = Color(0xFFF5FAFB)
|
||||||
internal val md_light_onBackground = Color(0xFF171D1E)
|
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_onSurface = Color(0xFF171D1E)
|
||||||
internal val md_light_surfaceVariant = Color(0xFF91949C)
|
internal val md_light_surfaceVariant = Color(0xFFFAFEFF)
|
||||||
internal val md_light_onSurfaceVariant = Color(0xFF3F484A)
|
internal val md_light_onSurfaceVariant = Color(0xFF000000)
|
||||||
|
|
||||||
internal val md_light_outline = Color(0xFF6F797B)
|
internal val md_light_outline = Color(0xFF6F797B)
|
||||||
internal val md_light_outlineVariant = Color(0xFFBFC8CA)
|
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_surface = Color(0xFF0E1415)
|
||||||
internal val md_dark_onSurface = Color(0xFFDEE3E4)
|
internal val md_dark_onSurface = Color(0xFFDEE3E4)
|
||||||
internal val md_dark_surfaceVariant = Color(0xFF3F484A)
|
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_outline = Color(0xFF899294)
|
||||||
internal val md_dark_outlineVariant = Color(0xFF3F484A)
|
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