Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
da9619b8fe | ||
|
|
8f9e1ab87d | ||
|
|
e12b7f1bfd | ||
|
|
59116a8248 | ||
|
|
77f76a22ea | ||
|
|
a143ac1a68 | ||
|
|
1700e2ea72 | ||
|
|
021f1e773e | ||
|
|
a979389fb5 | ||
|
|
894cca26a5 | ||
|
|
6a1acb7505 | ||
|
|
b7eb12cd82 | ||
|
|
b37d83c9a1 | ||
|
|
6897118e1c | ||
|
|
4e77185b3c |
@@ -7,6 +7,32 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
### Changed
|
||||
|
||||
### Fixed
|
||||
|
||||
## [1.0.0-beta04] - 2026-10-02
|
||||
|
||||
### Fixed
|
||||
|
||||
- correct the way pause between phrases was handled
|
||||
|
||||
## [1.0.0-beta03] - 2026-09-30
|
||||
|
||||
### Fixed
|
||||
|
||||
- correct transcriptions and translations for all records
|
||||
|
||||
## [1.0.0-beta02] - 2026-09-17
|
||||
|
||||
### Changed
|
||||
|
||||
- rename app to "Donemat - Breton d'Auray"
|
||||
|
||||
## [1.0.0-beta01] - 2026-09-07
|
||||
|
||||
### Changed
|
||||
|
||||
- show on boarding as a modal on large screens
|
||||
@@ -45,13 +71,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [1.0.0-alpha01] - 2026-07-08
|
||||
|
||||
First public release of **Chom Bev e Brezhoneg**.
|
||||
First public release of **Donemat - Breton d'Auray**.
|
||||
|
||||
### Added
|
||||
|
||||
#### Listening
|
||||
|
||||
- Record list of the *Chom Bev* audio course, with an emoji, the Breton title and
|
||||
- Record list of the *Donemat - Breton d'Auray* audio course, with an emoji, the Breton title and
|
||||
its French translation for each record.
|
||||
- Audio player with play/pause/replay, a seekable progress bar, and
|
||||
previous/next-sentence navigation.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
# Chom Bev e Brezhoneg - Survive in Breton - Survive en Breton
|
||||
# "Donemat - Breton d'Auray" - Survive in Breton
|
||||
|
||||
This is a Compose Multiplatform project to listen to Breton from Vannes country. It uses an audio
|
||||
player adapted for each platform and synchronised subtitles.
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||
import java.util.Properties
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.androidApplication)
|
||||
@@ -41,7 +40,7 @@ android {
|
||||
.toInt()
|
||||
|
||||
defaultConfig {
|
||||
applicationId = "bzh.ajaury.chombev"
|
||||
applicationId = "bzh.ksk.donemat"
|
||||
minSdk =
|
||||
libs.versions.androidMinSdk
|
||||
.get()
|
||||
@@ -50,8 +49,8 @@ android {
|
||||
libs.versions.androidTargetSdk
|
||||
.get()
|
||||
.toInt()
|
||||
versionCode = 2
|
||||
versionName = "1.0.0-beta01"
|
||||
versionCode = 5
|
||||
versionName = "1.0.0-beta04"
|
||||
}
|
||||
packaging {
|
||||
resources {
|
||||
|
||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 1010 B |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 1.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 5.8 KiB |
@@ -1,3 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">Chom Bev</string>
|
||||
<string name="app_name">Donemat - Breton d\'Auray</string>
|
||||
</resources>
|
||||
@@ -3,7 +3,7 @@ package bzh.ajaury.chombev.core.logging.data
|
||||
import bzh.ajaury.chombev.core.logging.domain.Logger
|
||||
|
||||
internal class KermitLogger : Logger {
|
||||
private val tag: String = "Chom Bev"
|
||||
private val tag: String = "Donemat"
|
||||
|
||||
override fun debug(
|
||||
message: String,
|
||||
|
||||
@@ -21,5 +21,8 @@ kotlin {
|
||||
implementation(projects.data.resources)
|
||||
implementation(projects.data.subtitle)
|
||||
}
|
||||
commonTest.dependencies {
|
||||
implementation(libs.kotlinx.coroutinesTest)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,6 +10,7 @@ import bzh.ajaury.chombev.player.model.PlaybackState
|
||||
import bzh.ajaury.chombev.player.model.PlaybackTiming
|
||||
import bzh.ajaury.chombev.player.model.PlayerState
|
||||
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
|
||||
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
||||
import bzh.ajaury.chombev.resources.domain.ResourceReader
|
||||
import bzh.ajaury.chombev.subtitle.domain.GetCurrentSubtitleIndexUseCase
|
||||
import bzh.ajaury.chombev.subtitle.domain.SubtitleRepository
|
||||
@@ -18,23 +19,17 @@ import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelAndJoin
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.firstOrNull
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration
|
||||
@@ -53,13 +48,18 @@ internal class PlaybackRepositoryImpl(
|
||||
) : PlaybackRepository {
|
||||
// Audio operations must run on the main thread (e.g. ExoPlayer), so keep the scope on it.
|
||||
private val scope = CoroutineScope(SupervisorJob() + dispatcherProvider.main)
|
||||
private var speedObservationJob: Job? = null
|
||||
private var preferencesObservationJob: Job? = null
|
||||
private var phraseBreakJob: Job? = null
|
||||
private var playbackPreferences = PlaybackPreferences()
|
||||
|
||||
// Pause-then-resume of an in-progress phrase break; cancelled when the user takes over.
|
||||
private var breakJob: Job? = null
|
||||
private val isBreaking = MutableStateFlow(false)
|
||||
|
||||
private var reachedPhraseIndex = -1
|
||||
private var pendingPhraseStart: Duration? = null
|
||||
private var pendingSeekTarget: Duration? = null
|
||||
|
||||
private val recordTitle = MutableStateFlow(Phrase(transcription = ""))
|
||||
private val subtitle = MutableStateFlow(Subtitle(emptyList()))
|
||||
|
||||
@@ -113,45 +113,16 @@ internal class PlaybackRepositoryImpl(
|
||||
subtitle,
|
||||
currentPosition,
|
||||
) { subtitle, position ->
|
||||
val currentSubtitleIndex = getCurrentSubtitleIndex(
|
||||
subtitle = subtitle,
|
||||
position = position,
|
||||
)
|
||||
|
||||
val isGoingToPlayNextPhrase = isGoingToPlayNextPhrase(
|
||||
currentSubtitleIndex = currentSubtitleIndex,
|
||||
subtitle = subtitle,
|
||||
position = position,
|
||||
)
|
||||
|
||||
SubtitleProgress(
|
||||
subtitle = subtitle,
|
||||
currentPosition = position,
|
||||
currentSubtitleIndex = currentSubtitleIndex,
|
||||
isGoingToPlayNextPhrase = isGoingToPlayNextPhrase,
|
||||
currentSubtitleIndex = getCurrentSubtitleIndex(
|
||||
subtitle = subtitle,
|
||||
position = position,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private fun isGoingToPlayNextPhrase(
|
||||
currentSubtitleIndex: Int?,
|
||||
subtitle: Subtitle,
|
||||
position: Duration,
|
||||
): Boolean {
|
||||
if (currentSubtitleIndex == null) {
|
||||
return false
|
||||
}
|
||||
|
||||
val nextSubtitleIndex = currentSubtitleIndex + 1
|
||||
if (nextSubtitleIndex <= 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
val nextSubtitle = subtitle.lines.getOrNull(nextSubtitleIndex)
|
||||
?: return false
|
||||
|
||||
return nextSubtitle.startTime < position + 2 * POSITION_POLL_INTERVAL
|
||||
}
|
||||
|
||||
override val playerState: Flow<PlayerState> = combine(
|
||||
playbackState,
|
||||
playbackTiming,
|
||||
@@ -168,19 +139,27 @@ internal class PlaybackRepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun load(record: Record) {
|
||||
phraseBreakJob?.cancel()
|
||||
cancelRunningBreak()
|
||||
pendingPhraseStart = null
|
||||
pendingSeekTarget = null
|
||||
|
||||
playAudio(filePath = record.audioResourcePath)
|
||||
subtitle.value = loadSubtitle(record = record)
|
||||
reachedPhraseIndex = upcomingPhraseIndex(position = Duration.ZERO)
|
||||
recordTitle.value = record.title
|
||||
|
||||
observePlaybackSpeed()
|
||||
observePlaybackPreferences()
|
||||
observePhraseBreaks()
|
||||
}
|
||||
|
||||
private fun observePlaybackSpeed() {
|
||||
speedObservationJob?.cancel()
|
||||
speedObservationJob = scope.launch {
|
||||
preferencesRepository.playbackPreferences.collect { playbackPreferences ->
|
||||
setSpeed(speed = playbackPreferences.speed)
|
||||
private fun observePlaybackPreferences() {
|
||||
preferencesObservationJob?.cancel()
|
||||
preferencesObservationJob = scope.launch {
|
||||
preferencesRepository.playbackPreferences.collect { preferences ->
|
||||
// Cached so that reaching a phrase reacts immediately, without a suspending read.
|
||||
playbackPreferences = preferences
|
||||
setSpeed(speed = preferences.speed)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,51 +172,98 @@ internal class PlaybackRepositoryImpl(
|
||||
*
|
||||
* Auto-pause takes precedence: while it is on, the break between phrases is ignored.
|
||||
*
|
||||
* This only happens on automatic advancement — manual seeking and previous/next navigation are
|
||||
* ignored because they cancel the running break job and never re-enter [PlaybackState.PLAYING]
|
||||
* through this path.
|
||||
* A phrase is considered reached as soon as a polled position is past its start minus
|
||||
* [PHRASE_LOOKAHEAD], even if the poll came late and the phrase already started: in that case
|
||||
* playback is rewound to the phrase start when resuming. Each phrase is only handled once.
|
||||
*
|
||||
* Manual seeking and previous/next navigation never trigger a break: [seekAudio] marks the
|
||||
* phrase at the target position as already reached.
|
||||
*/
|
||||
private suspend fun observePhraseBreaks() {
|
||||
private fun observePhraseBreaks() {
|
||||
phraseBreakJob?.cancel()
|
||||
cancelRunningBreakJob()
|
||||
phraseBreakJob = subtitleProgress
|
||||
.map { it.isGoingToPlayNextPhrase }
|
||||
.distinctUntilChanged()
|
||||
.filter { it }
|
||||
.onEach {
|
||||
if (playbackState.firstOrNull() != PlaybackState.PLAYING) return@onEach
|
||||
|
||||
val playbackPreferences = preferencesRepository.playbackPreferences.first()
|
||||
if (playbackPreferences.autoPause) {
|
||||
audioPlayer.pause()
|
||||
return@onEach
|
||||
phraseBreakJob = scope.launch {
|
||||
playbackState.collectLatest { state ->
|
||||
if (state != PlaybackState.PLAYING) return@collectLatest
|
||||
while (true) {
|
||||
checkPhraseBoundary()
|
||||
delay(POSITION_POLL_INTERVAL)
|
||||
}
|
||||
|
||||
val breakDuration = playbackPreferences.breakBetweenPhrases
|
||||
if (breakDuration <= Duration.ZERO) return@onEach
|
||||
|
||||
insertBreakBeforePhrase(breakDuration = breakDuration)
|
||||
}.launchIn(scope)
|
||||
}
|
||||
|
||||
private suspend fun insertBreakBeforePhrase(breakDuration: Duration) {
|
||||
breakJob?.cancelAndJoin()
|
||||
breakJob = scope.launch {
|
||||
try {
|
||||
// Pause and rewind the polling overshoot so the next phrase resumes from its very start.
|
||||
isBreaking.value = true
|
||||
audioPlayer.pause()
|
||||
delay(breakDuration)
|
||||
} finally {
|
||||
// Only resume if still paused by this break; the user may have acted during it.
|
||||
if (playbackState.firstOrNull() == PlaybackState.BREAKING) {
|
||||
audioPlayer.play()
|
||||
}
|
||||
isBreaking.value = false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkPhraseBoundary() {
|
||||
if (isBreaking.value || audioPlayer.playbackState.value != PlaybackState.PLAYING) return
|
||||
|
||||
val position = audioPlayer.currentPosition
|
||||
pendingSeekTarget?.let { target ->
|
||||
// Some players (e.g. AVPlayer) seek asynchronously and still report the pre-seek
|
||||
// position for a while: ignore it, or it could be taken for a phrase change.
|
||||
if ((position - target).absoluteValue > SEEK_TOLERANCE) return
|
||||
pendingSeekTarget = null
|
||||
}
|
||||
|
||||
val upcomingIndex = upcomingPhraseIndex(position = position)
|
||||
if (upcomingIndex <= reachedPhraseIndex) return
|
||||
reachedPhraseIndex = upcomingIndex
|
||||
|
||||
// There is no break before the very first phrase.
|
||||
if (upcomingIndex == 0) return
|
||||
val phraseStart = subtitle.value.lines[upcomingIndex].startTime
|
||||
|
||||
val preferences = playbackPreferences
|
||||
when {
|
||||
preferences.autoPause -> {
|
||||
pendingPhraseStart = phraseStart
|
||||
audioPlayer.pause()
|
||||
}
|
||||
|
||||
preferences.breakBetweenPhrases > Duration.ZERO -> {
|
||||
insertBreakBeforePhrase(
|
||||
breakDuration = preferences.breakBetweenPhrases,
|
||||
phraseStart = phraseStart,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun insertBreakBeforePhrase(
|
||||
breakDuration: Duration,
|
||||
phraseStart: Duration,
|
||||
) {
|
||||
breakJob?.cancel()
|
||||
pendingPhraseStart = phraseStart
|
||||
isBreaking.value = true
|
||||
audioPlayer.pause()
|
||||
breakJob = scope.launch {
|
||||
delay(breakDuration)
|
||||
isBreaking.value = false
|
||||
resumeAtPhraseStart(phraseStart = phraseStart)
|
||||
}
|
||||
}
|
||||
|
||||
private fun resumeAtPhraseStart(phraseStart: Duration) {
|
||||
pendingPhraseStart = null
|
||||
// The phrase may have been detected a little late: rewind so it is heard from its start.
|
||||
if (audioPlayer.currentPosition > phraseStart) {
|
||||
seekAudio(position = phraseStart)
|
||||
}
|
||||
audioPlayer.play()
|
||||
}
|
||||
|
||||
private fun upcomingPhraseIndex(position: Duration): Int =
|
||||
getCurrentSubtitleIndex(
|
||||
subtitle = subtitle.value,
|
||||
position = position + PHRASE_LOOKAHEAD,
|
||||
) ?: -1
|
||||
|
||||
private fun seekAudio(position: Duration) {
|
||||
pendingPhraseStart = null
|
||||
reachedPhraseIndex = upcomingPhraseIndex(position = position)
|
||||
pendingSeekTarget = position
|
||||
audioPlayer.seekTo(position)
|
||||
}
|
||||
|
||||
private fun playAudio(filePath: String) {
|
||||
try {
|
||||
audioPlayer.load(uri = resourceReader.uri(filePath))
|
||||
@@ -265,24 +291,30 @@ internal class PlaybackRepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun play() {
|
||||
cancelRunningBreakJob()
|
||||
audioPlayer.play()
|
||||
cancelRunningBreak()
|
||||
val phraseStart = pendingPhraseStart
|
||||
if (phraseStart != null) {
|
||||
resumeAtPhraseStart(phraseStart = phraseStart)
|
||||
} else {
|
||||
audioPlayer.play()
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun pause() {
|
||||
cancelRunningBreakJob()
|
||||
cancelRunningBreak()
|
||||
audioPlayer.pause()
|
||||
}
|
||||
|
||||
override suspend fun replay() {
|
||||
cancelRunningBreakJob()
|
||||
audioPlayer.seekTo(Duration.ZERO)
|
||||
cancelRunningBreak()
|
||||
seekAudio(position = Duration.ZERO)
|
||||
audioPlayer.play()
|
||||
}
|
||||
|
||||
override suspend fun seekTo(position: Duration) {
|
||||
cancelRunningBreakJob()
|
||||
audioPlayer.seekTo(position)
|
||||
val wasBreaking = cancelRunningBreak()
|
||||
seekAudio(position = position)
|
||||
if (wasBreaking) audioPlayer.play()
|
||||
}
|
||||
|
||||
private fun setSpeed(speed: Float) {
|
||||
@@ -310,9 +342,8 @@ internal class PlaybackRepositoryImpl(
|
||||
}
|
||||
|
||||
override suspend fun seekToSentence(sentenceIndex: Int) {
|
||||
cancelRunningBreakJob()
|
||||
val matchingSubtitleLine = subtitle.value.lines.getOrNull(sentenceIndex) ?: return
|
||||
audioPlayer.seekTo(matchingSubtitleLine.startTime)
|
||||
seekTo(position = matchingSubtitleLine.startTime)
|
||||
}
|
||||
|
||||
private fun currentSentenceIndex(): Int? =
|
||||
@@ -322,32 +353,33 @@ internal class PlaybackRepositoryImpl(
|
||||
)
|
||||
|
||||
override suspend fun stop() {
|
||||
breakJob?.cancel()
|
||||
isBreaking.value = false
|
||||
phraseBreakJob?.cancel()
|
||||
cancelRunningBreak()
|
||||
pendingPhraseStart = null
|
||||
subtitle.value = Subtitle(emptyList())
|
||||
audioPlayer.stop()
|
||||
}
|
||||
|
||||
override fun release() {
|
||||
breakJob?.cancel()
|
||||
isBreaking.value = false
|
||||
phraseBreakJob?.cancel()
|
||||
cancelRunningBreak()
|
||||
pendingPhraseStart = null
|
||||
audioPlayer.release()
|
||||
subtitle.value = Subtitle(emptyList())
|
||||
}
|
||||
|
||||
private suspend fun cancelRunningBreakJob() {
|
||||
breakJob?.cancelAndJoin()
|
||||
|
||||
// Unlock if paused by a break.
|
||||
if (playbackState.value == PlaybackState.BREAKING) {
|
||||
audioPlayer.play()
|
||||
}
|
||||
|
||||
private fun cancelRunningBreak(): Boolean {
|
||||
breakJob?.cancel()
|
||||
breakJob = null
|
||||
val wasBreaking = isBreaking.value
|
||||
isBreaking.value = false
|
||||
return wasBreaking
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val POSITION_POLL_INTERVAL = 50.milliseconds
|
||||
private val PHRASE_LOOKAHEAD = 2 * POSITION_POLL_INTERVAL
|
||||
private val SEEK_TOLERANCE = 500.milliseconds
|
||||
private val PREVIOUS_SENTENCE_THRESHOLD = 4.seconds
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,5 +7,4 @@ data class SubtitleProgress(
|
||||
val subtitle: Subtitle,
|
||||
val currentPosition: Duration,
|
||||
val currentSubtitleIndex: Int?,
|
||||
val isGoingToPlayNextPhrase: Boolean,
|
||||
)
|
||||
|
||||
@@ -0,0 +1,249 @@
|
||||
package bzh.ajaury.chombev.player.data
|
||||
|
||||
import bzh.ajaury.chombev.core.coroutines.domain.DispatcherProvider
|
||||
import bzh.ajaury.chombev.core.logging.domain.Logger
|
||||
import bzh.ajaury.chombev.core.model.Phrase
|
||||
import bzh.ajaury.chombev.core.model.Record
|
||||
import bzh.ajaury.chombev.player.domain.AudioPlayer
|
||||
import bzh.ajaury.chombev.player.model.PlaybackState
|
||||
import bzh.ajaury.chombev.preferences.domain.PreferencesRepository
|
||||
import bzh.ajaury.chombev.preferences.model.PlaybackPreferences
|
||||
import bzh.ajaury.chombev.preferences.model.SubtitlePreferences
|
||||
import bzh.ajaury.chombev.resources.domain.ResourceReader
|
||||
import bzh.ajaury.chombev.subtitle.domain.GetCurrentSubtitleIndexUseCase
|
||||
import bzh.ajaury.chombev.subtitle.domain.SubtitleRepository
|
||||
import bzh.ajaury.chombev.subtitle.model.Subtitle
|
||||
import bzh.ajaury.chombev.subtitle.model.SubtitleLine
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.time.Duration
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class PlaybackRepositoryImplTest {
|
||||
private val dispatcher = StandardTestDispatcher()
|
||||
private val audioPlayer = FakeAudioPlayer()
|
||||
private val preferences = MutableStateFlow(PlaybackPreferences(breakBetweenPhrases = 2.seconds))
|
||||
|
||||
private val repository = PlaybackRepositoryImpl(
|
||||
audioPlayer = audioPlayer,
|
||||
resourceReader = FakeResourceReader,
|
||||
subtitleRepository = FakeSubtitleRepository,
|
||||
preferencesRepository = FakePreferencesRepository(preferences),
|
||||
getCurrentSubtitleIndex = GetCurrentSubtitleIndexUseCase(),
|
||||
logger = FakeLogger,
|
||||
dispatcherProvider = FakeDispatcherProvider(dispatcher),
|
||||
)
|
||||
|
||||
@Test
|
||||
fun breaks_before_the_next_phrase_when_the_poll_lands_just_before_it() = runTest(dispatcher) {
|
||||
loadAndPlay()
|
||||
|
||||
playTo(SECOND_PHRASE_START - 60.milliseconds)
|
||||
|
||||
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||
advanceTimeBy(2.seconds + 1.milliseconds)
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
assertEquals(SECOND_PHRASE_START - 60.milliseconds, audioPlayer.currentPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun breaks_and_rewinds_when_the_poll_lands_after_the_next_phrase_started() = runTest(dispatcher) {
|
||||
loadAndPlay()
|
||||
|
||||
// A late poll (e.g. busy main thread) skips the window right before the phrase start.
|
||||
playTo(SECOND_PHRASE_START + 80.milliseconds)
|
||||
|
||||
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||
advanceTimeBy(2.seconds + 1.milliseconds)
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
assertEquals(SECOND_PHRASE_START, audioPlayer.currentPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun breaks_only_once_per_phrase() = runTest(dispatcher) {
|
||||
loadAndPlay()
|
||||
playTo(SECOND_PHRASE_START - 60.milliseconds)
|
||||
advanceTimeBy(2.seconds + 1.milliseconds)
|
||||
|
||||
playTo(SECOND_PHRASE_START + 500.milliseconds)
|
||||
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun auto_pause_stops_and_resumes_from_the_phrase_start() = runTest(dispatcher) {
|
||||
preferences.value = PlaybackPreferences(autoPause = true)
|
||||
loadAndPlay()
|
||||
|
||||
playTo(SECOND_PHRASE_START + 80.milliseconds)
|
||||
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||
|
||||
advanceTimeBy(10.seconds)
|
||||
assertEquals(PlaybackState.PAUSED, audioPlayer.playbackState.value)
|
||||
|
||||
repository.play()
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
assertEquals(SECOND_PHRASE_START, audioPlayer.currentPosition)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun seeking_to_the_next_sentence_does_not_break() = runTest(dispatcher) {
|
||||
loadAndPlay()
|
||||
playTo(1.seconds)
|
||||
|
||||
repository.goToNextSentence()
|
||||
runCurrent()
|
||||
advanceTimeBy(200.milliseconds)
|
||||
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun a_stale_position_after_a_backward_seek_does_not_break() = runTest(dispatcher) {
|
||||
loadAndPlay()
|
||||
playTo(SECOND_PHRASE_START + 1.seconds)
|
||||
advanceTimeBy(3.seconds)
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
|
||||
// Like AVPlayer, the player still reports the pre-seek position for a while.
|
||||
audioPlayer.asynchronousSeek = true
|
||||
repository.seekToSentence(0)
|
||||
advanceTimeBy(200.milliseconds)
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
|
||||
audioPlayer.completeSeek()
|
||||
advanceTimeBy(200.milliseconds)
|
||||
assertEquals(PlaybackState.PLAYING, audioPlayer.playbackState.value)
|
||||
}
|
||||
|
||||
private suspend fun TestScope.loadAndPlay() {
|
||||
repository.load(record)
|
||||
runCurrent()
|
||||
}
|
||||
|
||||
private fun TestScope.playTo(position: Duration) {
|
||||
audioPlayer.position = position
|
||||
advanceTimeBy(60.milliseconds)
|
||||
}
|
||||
|
||||
private companion object {
|
||||
val SECOND_PHRASE_START = 4310.milliseconds
|
||||
|
||||
val record = Record(
|
||||
id = 1,
|
||||
index = 0,
|
||||
emoji = "",
|
||||
title = Phrase(transcription = ""),
|
||||
audioResourcePath = "audio",
|
||||
subtitleResourcePath = "subtitle",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private class FakeAudioPlayer : AudioPlayer {
|
||||
override val playbackState = MutableStateFlow(PlaybackState.IDLE)
|
||||
var position: Duration = Duration.ZERO
|
||||
var asynchronousSeek = false
|
||||
private var pendingSeek: Duration? = null
|
||||
|
||||
override val currentPosition: Duration get() = position
|
||||
override val duration: Duration = 10.seconds
|
||||
|
||||
override fun seekTo(position: Duration) {
|
||||
if (asynchronousSeek) pendingSeek = position else this.position = position
|
||||
}
|
||||
|
||||
fun completeSeek() {
|
||||
pendingSeek?.let { position = it }
|
||||
pendingSeek = null
|
||||
}
|
||||
|
||||
override fun play() {
|
||||
playbackState.value = PlaybackState.PLAYING
|
||||
}
|
||||
|
||||
override fun pause() {
|
||||
playbackState.value = PlaybackState.PAUSED
|
||||
}
|
||||
|
||||
override fun stop() {
|
||||
playbackState.value = PlaybackState.IDLE
|
||||
}
|
||||
|
||||
override fun release() = stop()
|
||||
|
||||
override fun load(uri: String) = Unit
|
||||
|
||||
override fun setSpeed(speed: Float) = Unit
|
||||
}
|
||||
|
||||
private object FakeResourceReader : ResourceReader {
|
||||
override suspend fun read(resourcePath: String): String = ""
|
||||
|
||||
override fun uri(resourcePath: String): String = resourcePath
|
||||
}
|
||||
|
||||
private object FakeSubtitleRepository : SubtitleRepository {
|
||||
override suspend fun getSubtitle(
|
||||
transcriptionContent: String,
|
||||
translationContent: String?,
|
||||
): Subtitle = Subtitle(
|
||||
lines = listOf(
|
||||
SubtitleLine(startTime = 0.milliseconds, phrase = Phrase(transcription = "Demat deoc'h !")),
|
||||
SubtitleLine(startTime = 4310.milliseconds, phrase = Phrase(transcription = "Kenavo !")),
|
||||
SubtitleLine(startTime = 7350.milliseconds, phrase = Phrase(transcription = "Kenavo emberr !")),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private class FakePreferencesRepository(
|
||||
override val playbackPreferences: StateFlow<PlaybackPreferences>,
|
||||
) : PreferencesRepository {
|
||||
override val subtitlePreferences: Flow<SubtitlePreferences> = flowOf(SubtitlePreferences())
|
||||
override val onboardingCompleted: Flow<Boolean> = flowOf(true)
|
||||
|
||||
override suspend fun setShowTranscription(enabled: Boolean) = Unit
|
||||
|
||||
override suspend fun setShowTranslation(enabled: Boolean) = Unit
|
||||
|
||||
override suspend fun setSubtitleTextScale(scale: Float) = Unit
|
||||
|
||||
override suspend fun setShowPhoneticMarkers(enabled: Boolean) = Unit
|
||||
|
||||
override suspend fun setPlaybackSpeed(speed: Float) = Unit
|
||||
|
||||
override suspend fun setBreakBetweenPhrases(duration: Duration) = Unit
|
||||
|
||||
override suspend fun setAutoPause(enabled: Boolean) = Unit
|
||||
|
||||
override suspend fun setOnboardingCompleted() = Unit
|
||||
}
|
||||
|
||||
private object FakeLogger : Logger {
|
||||
override fun debug(message: String, throwable: Throwable?) = Unit
|
||||
|
||||
override fun info(message: String, throwable: Throwable?) = Unit
|
||||
|
||||
override fun warning(message: String, throwable: Throwable?) = Unit
|
||||
|
||||
override fun error(message: String, throwable: Throwable?) = Unit
|
||||
}
|
||||
|
||||
private class FakeDispatcherProvider(dispatcher: CoroutineDispatcher) : DispatcherProvider {
|
||||
override val main = dispatcher
|
||||
override val default = dispatcher
|
||||
override val io = dispatcher
|
||||
}
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
|
||||
<link rel="profile" href="https://gmpg.org/xfn/11">
|
||||
|
||||
<title>App Chom Bev – À propos – Kerlenn Sten Kidna</title>
|
||||
<title>App Donemat - Breton d'Auray – À propos – Kerlenn Sten Kidna</title>
|
||||
<meta name='robots' content='max-image-preview:large' />
|
||||
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna » Flux" href="https://ksk.bzh/fr/feed/" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna » Flux des commentaires" href="https://ksk.bzh/fr/comments/feed/" />
|
||||
@@ -164,7 +164,7 @@
|
||||
|
||||
|
||||
|
||||
<pre class="wp-block-preformatted">"<strong>Chom Bev e Brezhoneg</strong>" est une application qui vous est proposée gratuitement par l'association <strong>Kerlenn Sten Kidna</strong> dans le but de vous aider à vous initier au Breton, et particulièrement aux sonorités et tournures typiques du <strong>Breton Vannetais</strong>.</pre>
|
||||
<pre class="wp-block-preformatted">"<strong>Donemat - Breton d'Auray</strong>" est une application qui vous est proposée gratuitement par l'association <strong>Kerlenn Sten Kidna</strong> dans le but de vous aider à vous initier au Breton, et particulièrement aux sonorités et tournures typiques du <strong>Breton Vannetais</strong>.</pre>
|
||||
|
||||
|
||||
|
||||
@@ -176,7 +176,7 @@
|
||||
|
||||
|
||||
|
||||
<p class="wp-block-paragraph">Cette application vous est proposée par l’association <a href="https://ksk.bzh" target="_blank" rel="noreferrer noopener">Kerlenn Sten Kidna (KSK)</a>. « Chom Bev e Brezhoneg » est une oeuvre créée par KSK, initialement sous la forme d’un livret imprimé et accompagné d’un CD audio. Il est adapté ici au format numérique. L’association KSK a notamment pour mission de faire vivre le patrimoine culturel breton en pays d’Auray. Elle propose un ensemble de ressources (collectages audio, vidéo, écrits, …) mais aussi des rencontres (stages, soirées jeux, cafés pain-beurre, club lecteur …) et des cours hebdomadaires de breton (en journée & en soirée, en présentiel & à distance). </p>
|
||||
<p class="wp-block-paragraph">Cette application vous est proposée par l’association <a href="https://ksk.bzh" target="_blank" rel="noreferrer noopener">Kerlenn Sten Kidna (KSK)</a>. «Donemat - Breton d'Auray» est une oeuvre créée par KSK, initialement sous la forme d’un livret imprimé et accompagné d’un CD audio ("Chom Bev e Brezhoneg"). Il est adapté ici au format numérique. L’association KSK a notamment pour mission de faire vivre le patrimoine culturel breton en pays d’Auray. Elle propose un ensemble de ressources (collectages audio, vidéo, écrits, …) mais aussi des rencontres (stages, soirées jeux, cafés pain-beurre, club lecteur …) et des cours hebdomadaires de breton (en journée & en soirée, en présentiel & à distance). </p>
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=5, viewport-fit=cover">
|
||||
<link rel="profile" href="https://gmpg.org/xfn/11">
|
||||
|
||||
<title>App Chom Bev - Approfondir – Kerlenn Sten Kidna</title>
|
||||
<title>App Donemat - Breton d'Auray - Approfondir – Kerlenn Sten Kidna</title>
|
||||
<meta name='robots' content='max-image-preview:large' />
|
||||
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna » Flux" href="https://ksk.bzh/fr/feed/" />
|
||||
<link rel="alternate" type="application/rss+xml" title="Kerlenn Sten Kidna » Flux des commentaires" href="https://ksk.bzh/fr/comments/feed/" />
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<resources>
|
||||
<!-- Breton (br) -->
|
||||
<string name="app_name">Chom bev</string>
|
||||
<string name="app_name">Donemat - Breton d'Auray</string>
|
||||
|
||||
<string name="nav_records">Dastumoù</string>
|
||||
<string name="nav_deepen">Donaat</string>
|
||||
@@ -28,7 +28,7 @@
|
||||
<string name="auto_pause_title">Ehan emgefreek</string>
|
||||
<string name="auto_pause_description">Ehanañ goude pep frazenn</string>
|
||||
|
||||
<string name="onboarding_goal_title">Degemer mat e Chom bev</string>
|
||||
<string name="onboarding_goal_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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<resources>
|
||||
<!-- Default locale: French -->
|
||||
<string name="app_name">Chom bev</string>
|
||||
<string name="app_name">Donemat - Breton d'Auray</string>
|
||||
|
||||
<string name="nav_records">Enregistrements</string>
|
||||
<string name="nav_deepen">Approfondir</string>
|
||||
@@ -28,7 +28,7 @@
|
||||
<string name="auto_pause_title">Pause automatique</string>
|
||||
<string name="auto_pause_description">Mettre en pause après chaque phrase</string>
|
||||
|
||||
<string name="onboarding_goal_title">Bienvenue dans\n Chom bev e Brezhoneg</string>
|
||||
<string name="onboarding_goal_title">Bienvenue dans\n Donemat - Breton d'Auray</string>
|
||||
<string name="onboarding_goal_description">Apprenez le breton en écoutant des enregistrements, phrase par phrase, pour améliorer votre compréhension et votre prononciation du Breton Vannetais.</string>
|
||||
<string name="onboarding_subtitles_title">Des sous-titres à votre rythme</string>
|
||||
<string name="onboarding_subtitles_description">Depuis le lecteur, affichez ou masquez la transcription bretonne et la traduction française, ajustez la taille du texte et les marques de prononciation.</string>
|
||||
|
||||
@@ -7,7 +7,7 @@ fun main() {
|
||||
application {
|
||||
Window(
|
||||
onCloseRequest = ::exitApplication,
|
||||
title = "Chom Bev",
|
||||
title = "Donemat - Breton d'Auray",
|
||||
) {
|
||||
App()
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Crash reporting — Sentry SDK on GlitchTip
|
||||
|
||||
Chom Bev reports crashes and handled errors with the
|
||||
"Donemat - Breton d'Auray" reports crashes and handled errors with the
|
||||
[Sentry Kotlin Multiplatform SDK](https://docs.sentry.io/platforms/kotlin/guides/kotlin-multiplatform/),
|
||||
but sends them to [GlitchTip](https://glitchtip.com) instead of Sentry. GlitchTip is an
|
||||
open-source, Sentry-API-compatible backend, so the Sentry SDKs and `glitchtip-cli` work against
|
||||
@@ -42,7 +42,7 @@ Add the DSN to `local.properties` at the repository root (this file is git-ignor
|
||||
glitchtip.dsn=https://<key>@eu.glitchtip.com/<project-id>
|
||||
```
|
||||
|
||||
Find the value in GlitchTip under **Settings → Projects → _Chom Bev e Brezhoneg_ → Client Keys (DSN)**.
|
||||
Find the value in GlitchTip under **Settings → Projects → _Donemat_ → Client Keys (DSN)**.
|
||||
|
||||
On CI, set the `GLITCHTIP_DSN` environment variable instead — it takes precedence over
|
||||
`local.properties`.
|
||||
|
||||
@@ -58,7 +58,7 @@ private fun OnboardingInfoPagePreview() {
|
||||
MaterialTheme {
|
||||
OnboardingInfoPage(
|
||||
icon = Icons.Filled.Hearing,
|
||||
title = "Bienvenue dans Chom bev",
|
||||
title = "Bienvenue dans Donemat - Breton d'Auray",
|
||||
description = "Apprenez le breton en écoutant des enregistrements, phrase par phrase.",
|
||||
)
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
@@ -40,7 +41,7 @@ fun PlaybackSpeedBottomSheet(
|
||||
onBreakChange: (Float) -> Unit = {},
|
||||
onAutoPauseChange: (Boolean) -> Unit = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
|
||||
@@ -14,8 +14,9 @@ import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.SheetState
|
||||
import androidx.compose.material3.SheetValue
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.rememberModalBottomSheetState
|
||||
import androidx.compose.material3.rememberBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@@ -50,7 +51,7 @@ fun SubtitlePreferencesBottomSheet(
|
||||
onTextSizeDecrease: () -> Unit = {},
|
||||
onTextSizeIncrease: () -> Unit = {},
|
||||
onDismiss: () -> Unit = {},
|
||||
sheetState: SheetState = rememberModalBottomSheetState(),
|
||||
sheetState: SheetState = rememberBottomSheetState(initialValue = SheetValue.Expanded),
|
||||
) {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
[versions]
|
||||
agp = "9.3.1"
|
||||
androidCompileSdk = "37"
|
||||
androidMinSdk = "24"
|
||||
androidTargetSdk = "37"
|
||||
androidxActivity = "1.13.0"
|
||||
androidxLifecycle = "2.11.0"
|
||||
androidxWebkit = "1.16.0"
|
||||
composeIconsExtended = "1.7.3"
|
||||
composeMultiplatform = "1.11.1"
|
||||
datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore
|
||||
googleServices = "4.5.0"
|
||||
jaad = "0.9.4"
|
||||
kermit = "2.1.0"
|
||||
koin = "4.2.2"
|
||||
kotlin = "2.4.10"
|
||||
kotlinSerialization = "2.4.10"
|
||||
kotlinWrappers = "2026.8.0"
|
||||
kotlinxBrowser = "0.5.0"
|
||||
kotlinxCoroutines = "1.11.0"
|
||||
kotlinxSerializationJson = "1.11.0"
|
||||
material3 = "1.11.0-alpha07"
|
||||
media3 = "1.11.0"
|
||||
navigation3 = "1.1.1"
|
||||
okio = "3.18.1"
|
||||
sentry = "0.27.0"
|
||||
agp = "9.4.1" # https://developer.android.com/build/releases/gradle-plugin
|
||||
androidCompileSdk = "37" # https://developer.android.com/tools/releases/platforms
|
||||
androidMinSdk = "24" # https://developer.android.com/tools/releases/platforms
|
||||
androidTargetSdk = "37" # https://developer.android.com/tools/releases/platforms
|
||||
androidxActivity = "1.13.0" # https://developer.android.com/jetpack/androidx/releases/activity
|
||||
androidxLifecycle = "2.11.0" # https://developer.android.com/jetpack/androidx/releases/lifecycle
|
||||
androidxWebkit = "1.17.1" # https://developer.android.com/jetpack/androidx/releases/webkit
|
||||
composeIconsExtended = "1.7.3" # https://github.com/JetBrains/compose-multiplatform/releases
|
||||
composeMultiplatform = "1.12.1" # https://github.com/JetBrains/compose-multiplatform/releases
|
||||
datastore = "1.2.1" # https://developer.android.com/jetpack/androidx/releases/datastore
|
||||
googleServices = "4.5.0" # https://developers.google.com/android/guides/releases
|
||||
jaad = "0.9.4" # https://github.com/Tianscar/jaad/releases
|
||||
kermit = "2.1.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
|
||||
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
|
||||
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
|
||||
|
||||
[libraries]
|
||||
# Gradle plugin artifacts, used by build-logic convention plugins only
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionPath=wrapper/dists
|
||||
distributionSha256Sum=9c0f7faeeb306cb14e4279a3e084ca6b596894089a0638e68a07c945a32c9e14
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.7.1-bin.zip
|
||||
networkTimeout=10000
|
||||
validateDistributionUrl=true
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
TEAM_ID=
|
||||
|
||||
PRODUCT_NAME=Chom Bev
|
||||
PRODUCT_BUNDLE_IDENTIFIER=bzh.ajaury.chombev.Gwenedeg$(TEAM_ID)
|
||||
PRODUCT_NAME=Donemat - Breton d'Auray
|
||||
PRODUCT_BUNDLE_IDENTIFIER=bzh.ksk.donemat$(TEAM_ID)
|
||||
|
||||
// Keep in sync with androidApp versionName/versionCode and CHANGELOG.md.
|
||||
// Apple only accepts numeric versions, so pre-release suffixes (e.g. -alpha01) are dropped here.
|
||||
CURRENT_PROJECT_VERSION=1
|
||||
MARKETING_VERSION=1.0.0
|
||||
CURRENT_PROJECT_VERSION=5
|
||||
MARKETING_VERSION=1.0.0.4
|
||||
@@ -11,7 +11,7 @@
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
9DA43AABF87DF46F5E794E72 /* Chom Bev.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Chom Bev.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
9DA43AABF87DF46F5E794E72 /* Donemat.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = "Chom Bev.app"; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */
|
||||
@@ -55,7 +55,7 @@
|
||||
47B0C8770C82BEADFE13EF77 /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
9DA43AABF87DF46F5E794E72 /* Chom Bev.app */,
|
||||
9DA43AABF87DF46F5E794E72 /* Donemat.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
@@ -93,7 +93,7 @@
|
||||
F36B7318302CAEC900B3E490 /* Sentry */,
|
||||
);
|
||||
productName = iosApp;
|
||||
productReference = 9DA43AABF87DF46F5E794E72 /* Chom Bev.app */;
|
||||
productReference = 9DA43AABF87DF46F5E794E72 /* Donemat.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Chom Bev</title>
|
||||
<title>Donemat - Breton d'Auray</title>
|
||||
<link type="text/css" rel="stylesheet" href="styles.css">
|
||||
</head>
|
||||
<body style="text-align: center; align-content: center">
|
||||
|
||||