From 127c7e5bd0abeeb28924eb74557056a9d321b97c Mon Sep 17 00:00:00 2001 From: hozaifa1 <20hozaifa02@gmail.com> Date: Tue, 19 May 2026 21:43:17 +0600 Subject: [PATCH 1/2] feat: add HIIT countdown timer with auto set/rest cycling Adds support for HIIT-style autonomous timer flow: - Press play -> set countdown begins - Countdown reaches 0 -> rest timer auto-starts - Rest reaches 0 -> next set countdown auto-starts - Repeats until all sets are complete Implementation: - New Exercise fields: targetDuration (sec), autoAdvanceSets (bool) - New ExerciseDC field: cautions (common-mistakes text shown in info screen) - Room migration 3 -> 4 for the three new columns - WorkoutService gains countdown coroutine + StateFlows (countdownTime, isCountdownActive, countdownTotal, countdownFinished) - New service actions: START_COUNTDOWN, CANCEL_COUNTDOWN - WorkoutScreenViewModel: HiitPhase sealed class (Idle / SetCountdown / RestBetweenSets / ExerciseDone) plus observers that mark sets complete and auto-advance on rest expiry - New HiitCountdownCard composable: circular arc + phase label + play / cancel / skip-rest controls (orange=set, blue=rest, red=last 3s, green=done) - WorkoutScreen renders the card for DURATION exercises with targetDuration > 0 and autoAdvanceSets enabled - InfoExerciseScreen shows a "Common Mistakes" warning card when the exercise has non-empty cautions --- .../main/java/org/librefit/db/AppDatabase.kt | 27 +- .../java/org/librefit/db/entity/Exercise.kt | 6 +- .../java/org/librefit/db/entity/ExerciseDC.kt | 4 +- .../java/org/librefit/di/DatabaseModule.kt | 2 +- .../librefit/enums/WorkoutServiceActions.kt | 6 +- .../org/librefit/services/WorkoutService.kt | 77 ++++++ .../services/WorkoutServiceManager.kt | 22 ++ .../ui/components/HiitCountdownCard.kt | 250 ++++++++++++++++++ .../java/org/librefit/ui/models/UiExercise.kt | 4 +- .../org/librefit/ui/models/UiExerciseDC.kt | 3 +- .../ui/models/mappers/UiExerciseDCMapper.kt | 6 +- .../ui/models/mappers/UiExerciseMapper.kt | 8 +- .../infoExercise/InfoExerciseScreen.kt | 43 ++- .../ui/screens/workout/WorkoutScreen.kt | 77 +++++- .../screens/workout/WorkoutScreenViewModel.kt | 124 +++++++++ 15 files changed, 645 insertions(+), 14 deletions(-) create mode 100644 app/src/main/java/org/librefit/ui/components/HiitCountdownCard.kt diff --git a/app/src/main/java/org/librefit/db/AppDatabase.kt b/app/src/main/java/org/librefit/db/AppDatabase.kt index d6797a2b5..487638844 100644 --- a/app/src/main/java/org/librefit/db/AppDatabase.kt +++ b/app/src/main/java/org/librefit/db/AppDatabase.kt @@ -27,7 +27,7 @@ import org.librefit.db.entity.Workout @Database( entities = [Workout::class, Exercise::class, Set::class, Measurement::class, ExerciseDC::class], - version = 3, + version = 4, exportSchema = true, autoMigrations = [ AutoMigration(from = 1, to = 2) @@ -38,6 +38,31 @@ abstract class AppDatabase : RoomDatabase() { companion object { const val NAME = "librefit_database" + val MIGRATION_3_4 = object : Migration(3, 4) { + override fun migrate(db: SupportSQLiteDatabase) { + // Add HIIT countdown fields to exercises table + db.execSQL( + """ + ALTER TABLE exercises + ADD COLUMN targetDuration INTEGER NOT NULL DEFAULT 0 + """.trimIndent() + ) + db.execSQL( + """ + ALTER TABLE exercises + ADD COLUMN autoAdvanceSets INTEGER NOT NULL DEFAULT 0 + """.trimIndent() + ) + // Add cautions field to dataset (ExerciseDC) table + db.execSQL( + """ + ALTER TABLE dataset + ADD COLUMN cautions TEXT NOT NULL DEFAULT '' + """.trimIndent() + ) + } + } + val MIGRATION_2_3 = object : Migration(2, 3) { override fun migrate(db: SupportSQLiteDatabase) { db.execSQL( diff --git a/app/src/main/java/org/librefit/db/entity/Exercise.kt b/app/src/main/java/org/librefit/db/entity/Exercise.kt index e5edcd68b..a915a0896 100644 --- a/app/src/main/java/org/librefit/db/entity/Exercise.kt +++ b/app/src/main/java/org/librefit/db/entity/Exercise.kt @@ -67,5 +67,9 @@ data class Exercise( val setMode: SetMode = SetMode.LOAD, val restTime: Int = 0, val position: Int = 0, - val workoutId: Long = 0// Foreign key reference to Workout + val workoutId: Long = 0,// Foreign key reference to Workout + /** Target duration in seconds for DURATION sets used in countdown mode (HIIT). 0 = use stopwatch instead. */ + val targetDuration: Int = 0, + /** When true, sets auto-advance: countdown → rest → next set countdown, without user input. */ + val autoAdvanceSets: Boolean = false ) diff --git a/app/src/main/java/org/librefit/db/entity/ExerciseDC.kt b/app/src/main/java/org/librefit/db/entity/ExerciseDC.kt index bdc60031a..b544e9407 100644 --- a/app/src/main/java/org/librefit/db/entity/ExerciseDC.kt +++ b/app/src/main/java/org/librefit/db/entity/ExerciseDC.kt @@ -199,5 +199,7 @@ data class ExerciseDC( val instructions: List = listOf(), val category: Category = Category.POWERLIFTING, val images: List = listOf(), - val isCustomExercise: Boolean = false + val isCustomExercise: Boolean = false, + /** Common mistakes and cautions for the exercise (user-facing text). */ + val cautions: String = "" ) \ No newline at end of file diff --git a/app/src/main/java/org/librefit/di/DatabaseModule.kt b/app/src/main/java/org/librefit/di/DatabaseModule.kt index bdcbec36b..5fba56f00 100644 --- a/app/src/main/java/org/librefit/di/DatabaseModule.kt +++ b/app/src/main/java/org/librefit/di/DatabaseModule.kt @@ -35,7 +35,7 @@ object DatabaseModule { AppDatabase::class.java, AppDatabase.NAME ) - .addMigrations(AppDatabase.MIGRATION_2_3) + .addMigrations(AppDatabase.MIGRATION_2_3, AppDatabase.MIGRATION_3_4) .build() } diff --git a/app/src/main/java/org/librefit/enums/WorkoutServiceActions.kt b/app/src/main/java/org/librefit/enums/WorkoutServiceActions.kt index ffda015cf..28fc13a66 100644 --- a/app/src/main/java/org/librefit/enums/WorkoutServiceActions.kt +++ b/app/src/main/java/org/librefit/enums/WorkoutServiceActions.kt @@ -15,5 +15,9 @@ enum class WorkoutServiceActions(val string: String) { MODIFY_REST_TIMER("PAUSE_REST_TIMER"), WORKOUT_FOCUS("WORKOUT_FOCUS"), STOP_SERVICE("STOP_SERVICE"), - SET_ELAPSED_TIME("SET_ELAPSED_TIME") + SET_ELAPSED_TIME("SET_ELAPSED_TIME"), + /** Start a HIIT countdown timer (counts down from targetDuration to 0). */ + START_COUNTDOWN("START_COUNTDOWN"), + /** Cancel an active countdown without advancing to rest. */ + CANCEL_COUNTDOWN("CANCEL_COUNTDOWN") } \ No newline at end of file diff --git a/app/src/main/java/org/librefit/services/WorkoutService.kt b/app/src/main/java/org/librefit/services/WorkoutService.kt index 4ced37ecf..b1105251f 100644 --- a/app/src/main/java/org/librefit/services/WorkoutService.kt +++ b/app/src/main/java/org/librefit/services/WorkoutService.kt @@ -86,14 +86,39 @@ class WorkoutService : Service() { private val _restTime = MutableStateFlow(0) val restTime: StateFlow = _restTime + /** Countdown timer for HIIT sets (counts down to 0). */ + private val _countdownTime = MutableStateFlow(0) + val countdownTime: StateFlow = _countdownTime + + /** Whether a HIIT countdown is actively running. */ + private val _isCountdownActive = MutableStateFlow(false) + val isCountdownActive: StateFlow = _isCountdownActive + + /** Total duration for the current countdown (used for progress calculation). */ + private val _countdownTotal = MutableStateFlow(0) + val countdownTotal: StateFlow = _countdownTotal + + /** Emits true once when a countdown finishes — consumed by the ViewModel to auto-start rest. */ + private val _countdownFinished = MutableStateFlow(false) + val countdownFinished: StateFlow = _countdownFinished + const val EXTRA_INITIAL_REST_TIME = "EXTRA_INITIAL_REST_TIME" const val EXTRA_ADD_TEN_SECONDS = "EXTRA_ADD_TEN_SECONDS" const val EXTRA_IS_FOCUSED = "EXTRA_IS_FOCUSED" const val EXTRA_SET_ELAPSED_TIME = "EXTRA_SET_ELAPSED_TIME" + const val EXTRA_COUNTDOWN_DURATION = "EXTRA_COUNTDOWN_DURATION" + /** Rest duration to auto-start after countdown finishes (0 = skip rest). */ + const val EXTRA_COUNTDOWN_REST_DURATION = "EXTRA_COUNTDOWN_REST_DURATION" + + /** Acknowledge the [countdownFinished] signal (call from ViewModel after reading it). */ + fun clearCountdownFinished() { + _countdownFinished.update { false } + } } private var initialRestTime = 0 private var isFocused = true + private var countdownRestDuration = 0 @Inject lateinit var notificationHelper: NotificationHelper @@ -145,6 +170,24 @@ class WorkoutService : Service() { it + (intent?.getIntExtra(EXTRA_SET_ELAPSED_TIME, 0) ?: 0) } } + + WorkoutServiceActions.START_COUNTDOWN -> { + val duration = intent?.getIntExtra(EXTRA_COUNTDOWN_DURATION, 0) ?: 0 + countdownRestDuration = + intent?.getIntExtra(EXTRA_COUNTDOWN_REST_DURATION, 0) ?: 0 + countdownJob?.cancel() + _countdownTotal.update { duration } + _countdownTime.update { duration } + _isCountdownActive.update { true } + _countdownFinished.update { false } + startCountdown() + } + + WorkoutServiceActions.CANCEL_COUNTDOWN -> { + countdownJob?.cancel() + _isCountdownActive.update { false } + _countdownTime.update { 0 } + } } return START_STICKY @@ -153,9 +196,15 @@ class WorkoutService : Service() { fun stopService() { stopwatchJob?.cancel() restTimerJob?.cancel() + countdownJob?.cancel() _timeElapsed.update { 0 } _restTime.update { 0 } + _countdownTime.update { 0 } + _isCountdownActive.update { false } + _countdownFinished.update { false } + _countdownTotal.update { 0 } initialRestTime = 0 + countdownRestDuration = 0 stopForeground(STOP_FOREGROUND_REMOVE) stopSelf() } @@ -200,6 +249,34 @@ class WorkoutService : Service() { } + private var countdownJob: Job? = null + + /** + * HIIT countdown: counts from [countdownTime] down to 0, then: + * 1. Signals [countdownFinished] for the ViewModel + * 2. Auto-starts the rest timer if [countdownRestDuration] > 0 + */ + private fun startCountdown() { + countdownJob = serviceScope.launch { + while (countdownTime.value > 0) { + delay(1000) + _countdownTime.update { (it - 1).coerceAtLeast(0) } + } + _isCountdownActive.update { false } + _countdownFinished.update { true } + + // Auto-start rest if configured + if (countdownRestDuration > 0) { + restTimerJob?.cancel() + initialRestTime = countdownRestDuration + _restTime.update { countdownRestDuration } + startRestTimer() + } + } + } + + + private var restTimerJob: Job? = null private fun startRestTimer() { diff --git a/app/src/main/java/org/librefit/services/WorkoutServiceManager.kt b/app/src/main/java/org/librefit/services/WorkoutServiceManager.kt index cc49e5792..4bf83f37d 100644 --- a/app/src/main/java/org/librefit/services/WorkoutServiceManager.kt +++ b/app/src/main/java/org/librefit/services/WorkoutServiceManager.kt @@ -13,6 +13,8 @@ import android.content.Intent import dagger.hilt.android.qualifiers.ApplicationContext import org.librefit.enums.WorkoutServiceActions import org.librefit.services.WorkoutService.Companion.EXTRA_ADD_TEN_SECONDS +import org.librefit.services.WorkoutService.Companion.EXTRA_COUNTDOWN_DURATION +import org.librefit.services.WorkoutService.Companion.EXTRA_COUNTDOWN_REST_DURATION import org.librefit.services.WorkoutService.Companion.EXTRA_INITIAL_REST_TIME import org.librefit.services.WorkoutService.Companion.EXTRA_IS_FOCUSED import org.librefit.services.WorkoutService.Companion.EXTRA_SET_ELAPSED_TIME @@ -73,6 +75,26 @@ class WorkoutServiceManager @Inject constructor( context.startForegroundService(serviceIntent) } + /** + * Start a HIIT countdown timer that counts down from [durationSeconds] to 0, + * then auto-starts rest timer for [restDurationSeconds] if > 0. + */ + fun startCountdown(durationSeconds: Int, restDurationSeconds: Int) { + val serviceIntent = workoutServiceIntent.apply { + action = WorkoutServiceActions.START_COUNTDOWN.string + putExtra(EXTRA_COUNTDOWN_DURATION, durationSeconds) + putExtra(EXTRA_COUNTDOWN_REST_DURATION, restDurationSeconds) + } + context.startForegroundService(serviceIntent) + } + + fun cancelCountdown() { + val serviceIntent = workoutServiceIntent.apply { + action = WorkoutServiceActions.CANCEL_COUNTDOWN.string + } + context.startForegroundService(serviceIntent) + } + fun stopService() { val serviceIntent = workoutServiceIntent.apply { action = WorkoutServiceActions.STOP_SERVICE.string diff --git a/app/src/main/java/org/librefit/ui/components/HiitCountdownCard.kt b/app/src/main/java/org/librefit/ui/components/HiitCountdownCard.kt new file mode 100644 index 000000000..802b93e31 --- /dev/null +++ b/app/src/main/java/org/librefit/ui/components/HiitCountdownCard.kt @@ -0,0 +1,250 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2025-2026. The LibreFit Contributors + * + * LibreFit is subject to additional terms covering author attribution and trademark usage; + * see the ADDITIONAL_TERMS.md and TRADEMARK_POLICY.md files in the project root. + */ + +package org.librefit.ui.components + +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.Info +import androidx.compose.material.icons.filled.Pause +import androidx.compose.material.icons.filled.PlayArrow +import androidx.compose.material.icons.filled.SkipNext +import androidx.compose.material3.ElevatedCard +import androidx.compose.material3.FilledTonalButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import org.librefit.ui.screens.workout.WorkoutScreenViewModel.HiitPhase + +/** + * A card that displays a large circular countdown timer for HIIT-style exercises. + * + * Shows the current set number, exercise name, a big countdown arc, and play/skip controls. + * The arc is orange during a set countdown, blue during rest, and green when done. + */ +@Composable +fun HiitCountdownCard( + exerciseName: String, + currentSetIndex: Int, + totalSets: Int, + countdownSeconds: Int, + countdownTotal: Int, + restSeconds: Int, + restTotal: Int, + hiitPhase: HiitPhase, + onPlayPressed: () -> Unit, + onCancelPressed: () -> Unit, + onSkipRest: () -> Unit, + onInfoPressed: () -> Unit, + modifier: Modifier = Modifier +) { + val isSetPhase = hiitPhase is HiitPhase.SetCountdown + val isRestPhase = hiitPhase is HiitPhase.RestBetweenSets + val isIdle = hiitPhase is HiitPhase.Idle + val isDone = hiitPhase is HiitPhase.ExerciseDone + + // Arc progress + val displaySeconds = when { + isSetPhase -> countdownSeconds + isRestPhase -> restSeconds + else -> 0 + } + val displayTotal = when { + isSetPhase -> countdownTotal + isRestPhase -> restTotal + else -> 1 + } + val progress by animateFloatAsState( + targetValue = if (displayTotal > 0) displaySeconds.toFloat() / displayTotal else 0f, + animationSpec = tween(durationMillis = 300), + label = "countdown_progress" + ) + + // Colors + val arcColor by animateColorAsState( + targetValue = when { + isRestPhase -> Color(0xFF42A5F5) // Blue for rest + isDone -> Color(0xFF66BB6A) // Green for done + countdownSeconds <= 3 && isSetPhase -> Color(0xFFEF5350) // Red for last 3 seconds + else -> Color(0xFFFF7043) // Orange for set + }, + animationSpec = tween(300), + label = "arc_color" + ) + + val phaseLabel = when { + isSetPhase -> "SET" + isRestPhase -> "REST" + isDone -> "DONE" + else -> "READY" + } + + ElevatedCard( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(20.dp), + horizontalAlignment = Alignment.CenterHorizontally + ) { + // Header row: exercise name + set counter + info + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically + ) { + Column { + Text( + text = exerciseName, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold + ) + Text( + text = "Set ${currentSetIndex + 1} / $totalSets", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + IconButton(onClick = onInfoPressed) { + Icon(Icons.Default.Info, contentDescription = "Exercise details") + } + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Phase label + Text( + text = phaseLabel, + style = MaterialTheme.typography.labelLarge, + color = arcColor, + fontWeight = FontWeight.Bold + ) + + Spacer(modifier = Modifier.height(8.dp)) + + // Countdown arc + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(200.dp) + ) { + Canvas(modifier = Modifier.size(200.dp)) { + val strokeWidth = 12.dp.toPx() + val diameter = size.minDimension - strokeWidth + val topLeft = Offset(strokeWidth / 2, strokeWidth / 2) + + // Background track + drawArc( + color = Color.Gray.copy(alpha = 0.2f), + startAngle = -90f, + sweepAngle = 360f, + useCenter = false, + topLeft = topLeft, + size = Size(diameter, diameter), + style = Stroke(width = strokeWidth, cap = StrokeCap.Round) + ) + + // Progress arc + drawArc( + color = arcColor, + startAngle = -90f, + sweepAngle = 360f * progress, + useCenter = false, + topLeft = topLeft, + size = Size(diameter, diameter), + style = Stroke(width = strokeWidth, cap = StrokeCap.Round) + ) + } + + // Time text + val minutes = displaySeconds / 60 + val seconds = displaySeconds % 60 + Text( + text = if (isDone) "Done!" else "%d:%02d".format(minutes, seconds), + fontSize = 48.sp, + fontWeight = FontWeight.Bold, + color = if (isDone) Color(0xFF66BB6A) else MaterialTheme.colorScheme.onSurface, + textAlign = TextAlign.Center + ) + } + + Spacer(modifier = Modifier.height(16.dp)) + + // Controls + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxWidth() + ) { + when { + isIdle -> { + FilledTonalButton(onClick = onPlayPressed) { + Icon(Icons.Default.PlayArrow, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Start Set") + } + } + + isSetPhase -> { + FilledTonalButton(onClick = onCancelPressed) { + Icon(Icons.Default.Pause, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Cancel") + } + } + + isRestPhase -> { + FilledTonalButton(onClick = onSkipRest) { + Icon(Icons.Default.SkipNext, contentDescription = null) + Spacer(Modifier.width(8.dp)) + Text("Skip Rest") + } + } + + isDone -> { + Text( + text = "All sets complete! Move to next exercise.", + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = TextAlign.Center + ) + } + } + } + } + } +} diff --git a/app/src/main/java/org/librefit/ui/models/UiExercise.kt b/app/src/main/java/org/librefit/ui/models/UiExercise.kt index 7ace3079f..dab39970a 100644 --- a/app/src/main/java/org/librefit/ui/models/UiExercise.kt +++ b/app/src/main/java/org/librefit/ui/models/UiExercise.kt @@ -28,5 +28,7 @@ data class UiExercise( val setMode: SetMode = SetMode.LOAD, val restTime: Int = 0, val position: Int = 0, - val workoutId: Long = 0 + val workoutId: Long = 0, + val targetDuration: Int = 0, + val autoAdvanceSets: Boolean = false ) diff --git a/app/src/main/java/org/librefit/ui/models/UiExerciseDC.kt b/app/src/main/java/org/librefit/ui/models/UiExerciseDC.kt index f52cb12c3..198330a53 100644 --- a/app/src/main/java/org/librefit/ui/models/UiExerciseDC.kt +++ b/app/src/main/java/org/librefit/ui/models/UiExerciseDC.kt @@ -38,5 +38,6 @@ data class UiExerciseDC( val instructions: ImmutableList = persistentListOf(), val category: Category = Category.POWERLIFTING, val images: ImmutableList = persistentListOf(), - val isCustomExercise: Boolean = false + val isCustomExercise: Boolean = false, + val cautions: String = "" ) \ No newline at end of file diff --git a/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseDCMapper.kt b/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseDCMapper.kt index 149376391..e63fa7f1e 100644 --- a/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseDCMapper.kt +++ b/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseDCMapper.kt @@ -25,7 +25,8 @@ fun ExerciseDC.toUi(): UiExerciseDC { instructions = this.instructions.toImmutableList(), category = this.category, images = this.images.toImmutableList(), - isCustomExercise = this.isCustomExercise + isCustomExercise = this.isCustomExercise, + cautions = this.cautions ) } @@ -42,6 +43,7 @@ fun UiExerciseDC.toEntity(): ExerciseDC { instructions = this.instructions, category = this.category, images = this.images, - isCustomExercise = this.isCustomExercise + isCustomExercise = this.isCustomExercise, + cautions = this.cautions ) } \ No newline at end of file diff --git a/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseMapper.kt b/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseMapper.kt index ede9429f9..5783e7efb 100644 --- a/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseMapper.kt +++ b/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseMapper.kt @@ -19,7 +19,9 @@ fun Exercise.toUi(): UiExercise { setMode = this.setMode, restTime = this.restTime, position = this.position, - workoutId = this.workoutId + workoutId = this.workoutId, + targetDuration = this.targetDuration, + autoAdvanceSets = this.autoAdvanceSets ) } @@ -31,6 +33,8 @@ fun UiExercise.toEntity(): Exercise { setMode = this.setMode, restTime = this.restTime, position = this.position, - workoutId = this.workoutId + workoutId = this.workoutId, + targetDuration = this.targetDuration, + autoAdvanceSets = this.autoAdvanceSets ) } diff --git a/app/src/main/java/org/librefit/ui/screens/infoExercise/InfoExerciseScreen.kt b/app/src/main/java/org/librefit/ui/screens/infoExercise/InfoExerciseScreen.kt index 350a97146..58bb7908d 100644 --- a/app/src/main/java/org/librefit/ui/screens/infoExercise/InfoExerciseScreen.kt +++ b/app/src/main/java/org/librefit/ui/screens/infoExercise/InfoExerciseScreen.kt @@ -37,6 +37,7 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api @@ -302,7 +303,8 @@ private fun SharedTransitionScope.InfoExerciseScreenContent( InfoExercisePages.INSTRUCTIONS -> InstructionsPage( maxHeight, - exerciseDC.instructions + exerciseDC.instructions, + exerciseDC.cautions ) null -> error("Invalid page index: $pageIndex. Expected: ${0..InfoExercisePages.entries.size}") @@ -475,6 +477,7 @@ private fun DetailsPage( private fun InstructionsPage( maxHeight: Dp, instructions: List, + cautions: String = "" ) { LazyColumn( modifier = Modifier.height(maxHeight), @@ -494,6 +497,44 @@ private fun InstructionsPage( } ) } + if (cautions.isNotBlank()) { + item { + Spacer(Modifier.height(8.dp)) + HorizontalDivider() + Spacer(Modifier.height(8.dp)) + ElevatedCard( + shape = MaterialTheme.shapes.large, + colors = CardDefaults.elevatedCardColors( + containerColor = MaterialTheme.colorScheme.errorContainer + ) + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_warning), + contentDescription = null, + tint = MaterialTheme.colorScheme.onErrorContainer + ) + Text( + text = "Common Mistakes", + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Bold, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + Spacer(Modifier.height(8.dp)) + Text( + text = cautions, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.onErrorContainer + ) + } + } + } + } } } diff --git a/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreen.kt b/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreen.kt index 5fc1a3cca..6c40b2bc8 100644 --- a/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreen.kt +++ b/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreen.kt @@ -83,6 +83,7 @@ import org.librefit.enums.exercise.Equipment import org.librefit.enums.userPreferences.ThemeMode import org.librefit.nav.Route import org.librefit.ui.components.ExerciseCard +import org.librefit.ui.components.HiitCountdownCard import org.librefit.ui.components.LibreFitLazyColumn import org.librefit.ui.components.LibreFitScaffold import org.librefit.ui.components.animations.DumbbellLottie @@ -138,6 +139,11 @@ fun SharedTransitionScope.WorkoutScreen( val dismissScrollWheelInputAutomatically by viewModel.dismissScrollWheelInputAutomatically.collectAsStateWithLifecycle() + // HIIT countdown state + val hiitPhase by viewModel.hiitPhase.collectAsStateWithLifecycle() + val countdownTime by viewModel.countdownTime.collectAsStateWithLifecycle() + val countdownTotal by viewModel.countdownTotal.collectAsStateWithLifecycle() + //It keeps the screen turned on if (keepWorkoutScreenOn) { @@ -221,6 +227,10 @@ fun SharedTransitionScope.WorkoutScreen( isHeaderSticky = isHeaderSticky, useScrollWheelForInput = useScrollWheelForInput, dismissScrollWheelInputAutomatically = dismissScrollWheelInputAutomatically, + hiitPhase = hiitPhase, + countdownSeconds = countdownTime, + countdownTotal = countdownTotal, + restSeconds = restTime, toggleStopwatch = viewModel::toggleStopwatch, updateIdSetWithRunningStopwatch = viewModel::updateIdSetWithRunningStopwatch, onSelectedExerciseIdChange = { id, idExerciseDC -> @@ -245,7 +255,10 @@ fun SharedTransitionScope.WorkoutScreen( }, moveExercise = viewModel::moveExercise, showInfo = { infoMode.value = it }, - applyPreviousSetPerformance = viewModel::applyPreviousSetPerformance + applyPreviousSetPerformance = viewModel::applyPreviousSetPerformance, + onStartHiitCountdown = viewModel::startHiitCountdown, + onCancelHiitCountdown = viewModel::cancelHiitCountdown, + onResetHiitPhase = viewModel::resetHiitPhase ) } } @@ -283,6 +296,10 @@ private fun SharedTransitionScope.WorkoutScreenContent( isHeaderSticky: Boolean, useScrollWheelForInput: Boolean, dismissScrollWheelInputAutomatically: Boolean, + hiitPhase: WorkoutScreenViewModel.HiitPhase, + countdownSeconds: Int, + countdownTotal: Int, + restSeconds: Int, toggleStopwatch: () -> Unit, updateIdSetWithRunningStopwatch: (Long?) -> Unit, addSetToExercise: (Long) -> Unit, @@ -298,7 +315,10 @@ private fun SharedTransitionScope.WorkoutScreenContent( moveExercise: (Int, Int) -> Unit, onSelectedExerciseIdChange: (Long, String) -> Unit, showInfo: (InfoMode) -> Unit, - applyPreviousSetPerformance: (Long) -> Unit + applyPreviousSetPerformance: (Long) -> Unit, + onStartHiitCountdown: (Long, Int) -> Unit, + onCancelHiitCountdown: () -> Unit, + onResetHiitPhase: () -> Unit ) { val lazyListState = rememberLazyListState() val hapticFeedback = LocalHapticFeedback.current @@ -399,6 +419,52 @@ private fun SharedTransitionScope.WorkoutScreenContent( items = exercisesWithSets, key = { _, exercise -> exercise.exercise.id } ) { i, exerciseWithSets -> + // Show HIIT countdown card for DURATION exercises with auto-advance + val exercise = exerciseWithSets.exercise + val isHiitExercise = exercise.setMode == SetMode.DURATION + && exercise.targetDuration > 0 + && exercise.autoAdvanceSets + val isActiveHiit = isHiitExercise && when (hiitPhase) { + is WorkoutScreenViewModel.HiitPhase.SetCountdown -> + (hiitPhase as WorkoutScreenViewModel.HiitPhase.SetCountdown).exerciseId == exercise.id + is WorkoutScreenViewModel.HiitPhase.RestBetweenSets -> + (hiitPhase as WorkoutScreenViewModel.HiitPhase.RestBetweenSets).exerciseId == exercise.id + is WorkoutScreenViewModel.HiitPhase.Idle -> true + is WorkoutScreenViewModel.HiitPhase.ExerciseDone -> true + } + + if (isHiitExercise && isActiveHiit) { + val currentSetIndex = when (hiitPhase) { + is WorkoutScreenViewModel.HiitPhase.SetCountdown -> + (hiitPhase as WorkoutScreenViewModel.HiitPhase.SetCountdown).setIndex + is WorkoutScreenViewModel.HiitPhase.RestBetweenSets -> + (hiitPhase as WorkoutScreenViewModel.HiitPhase.RestBetweenSets).nextSetIndex - 1 + else -> 0 + } + HiitCountdownCard( + exerciseName = exerciseWithSets.exerciseDC.name, + currentSetIndex = currentSetIndex, + totalSets = exerciseWithSets.sets.size, + countdownSeconds = countdownSeconds, + countdownTotal = countdownTotal, + restSeconds = restSeconds, + restTotal = exercise.restTime, + hiitPhase = hiitPhase, + onPlayPressed = { onStartHiitCountdown(exercise.id, currentSetIndex) }, + onCancelPressed = onCancelHiitCountdown, + onSkipRest = { + // Skip rest by starting next set immediately + val phase = hiitPhase + if (phase is WorkoutScreenViewModel.HiitPhase.RestBetweenSets) { + onStartHiitCountdown(phase.exerciseId, phase.nextSetIndex) + } + }, + onInfoPressed = { + onSelectedExerciseIdChange(exercise.id, exerciseWithSets.exerciseDC.id) + } + ) + } + ReorderableItem(reorderableLazyListState, key = exerciseWithSets.exercise.id) { isDragging -> ExerciseCard( modifier = Modifier.animateItem(), @@ -622,6 +688,13 @@ private fun WorkoutScreenPreview() { WorkoutScreenContent( animatedVisibilityScope = this@AnimatedVisibility, exercisesWithSets = e, + hiitPhase = WorkoutScreenViewModel.HiitPhase.Idle, + countdownSeconds = 0, + countdownTotal = 0, + restSeconds = 0, + onStartHiitCountdown = { _, _ -> }, + onCancelHiitCountdown = {}, + onResetHiitPhase = {}, previousPerformances = listOf( listOf( PreviousPerformanceSet(time = 612) diff --git a/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreenViewModel.kt b/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreenViewModel.kt index eaf939883..7ef2723fc 100644 --- a/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreenViewModel.kt +++ b/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreenViewModel.kt @@ -455,6 +455,29 @@ class WorkoutScreenViewModel @Inject constructor( val isStopwatchPaused = WorkoutService.isStopwatchPaused val restTime = WorkoutService.restTime + // ── HIIT countdown state from service ── + val countdownTime = WorkoutService.countdownTime + val isCountdownActive = WorkoutService.isCountdownActive + val countdownTotal = WorkoutService.countdownTotal + + /** + * HIIT phase tracks the autonomous set→rest→set cycle for DURATION exercises + * that have [UiExercise.autoAdvanceSets] enabled. + */ + sealed class HiitPhase { + /** No HIIT countdown running – manual mode or waiting for user to press play. */ + data object Idle : HiitPhase() + /** Countdown is active for the given exercise/set. */ + data class SetCountdown(val exerciseId: Long, val setIndex: Int) : HiitPhase() + /** Rest countdown between HIIT sets. */ + data class RestBetweenSets(val exerciseId: Long, val nextSetIndex: Int) : HiitPhase() + /** All sets for the current HIIT exercise are complete. */ + data object ExerciseDone : HiitPhase() + } + + private val _hiitPhase = MutableStateFlow(HiitPhase.Idle) + val hiitPhase = _hiitPhase.asStateFlow() + private var initialRestTime = 1 private var isFocused = true @@ -463,6 +486,8 @@ class WorkoutScreenViewModel @Inject constructor( init { workoutServiceManager.startStopwatch() observeChanges() + observeHiitCountdownFinished() + observeHiitRestFinished() } @@ -480,6 +505,105 @@ class WorkoutScreenViewModel @Inject constructor( } } + // ── HIIT auto-advance logic ── + + /** + * Start a HIIT countdown for a specific exercise and set. + * The service will count down [UiExercise.targetDuration] seconds, then auto-start rest. + */ + fun startHiitCountdown(exerciseId: Long, setIndex: Int) { + val eWs = exercises.value.find { it.exercise.id == exerciseId } ?: return + val exercise = eWs.exercise + if (exercise.setMode != SetMode.DURATION || exercise.targetDuration <= 0) return + + _hiitPhase.update { HiitPhase.SetCountdown(exerciseId, setIndex) } + workoutServiceManager.startCountdown( + durationSeconds = exercise.targetDuration, + restDurationSeconds = exercise.restTime + ) + } + + /** Cancel an active HIIT countdown and return to idle. */ + fun cancelHiitCountdown() { + workoutServiceManager.cancelCountdown() + _hiitPhase.update { HiitPhase.Idle } + } + + /** + * Observes [WorkoutService.countdownFinished]. + * When a countdown completes, marks the set done and transitions to rest phase. + */ + private fun observeHiitCountdownFinished() { + viewModelScope.launch(mainDispatcher) { + WorkoutService.countdownFinished.collect { finished -> + if (!finished) return@collect + val phase = _hiitPhase.value + if (phase is HiitPhase.SetCountdown) { + // Mark the current set as completed + val eWs = exercises.value.find { it.exercise.id == phase.exerciseId } + if (eWs != null) { + val set = eWs.sets.getOrNull(phase.setIndex) + if (set != null) { + // Update elapsed time and completion + updateSetTime(eWs.exercise.targetDuration, set.id) + _exercises.update { currentExercises -> + currentExercises.map { exercise -> + if (exercise.sets.any { it.id == set.id }) { + exercise.copy( + sets = exercise.sets.map { + if (it.id == set.id) it.copy(completed = true) else it + }.toImmutableList() + ) + } else exercise + } + } + syncToRepository() + } + + val nextSetIndex = phase.setIndex + 1 + if (nextSetIndex < eWs.sets.size && eWs.exercise.autoAdvanceSets) { + // Rest timer was already auto-started by the service + _hiitPhase.update { + HiitPhase.RestBetweenSets(phase.exerciseId, nextSetIndex) + } + } else { + _hiitPhase.update { HiitPhase.ExerciseDone } + } + } + } + // Acknowledge signal + WorkoutService.clearCountdownFinished() + } + } + } + + /** + * Observes rest timer. When rest finishes during a HIIT [HiitPhase.RestBetweenSets], + * auto-starts the next set's countdown. + */ + private fun observeHiitRestFinished() { + viewModelScope.launch(mainDispatcher) { + var previousRestTime = 0 + WorkoutService.restTime.collect { currentRestTime -> + // Detect transition from >0 to 0 + if (previousRestTime > 0 && currentRestTime == 0) { + val phase = _hiitPhase.value + if (phase is HiitPhase.RestBetweenSets) { + // Auto-start next set countdown + startHiitCountdown(phase.exerciseId, phase.nextSetIndex) + } + } + previousRestTime = currentRestTime + } + } + } + + /** Reset HIIT phase back to idle (e.g. when user moves to next exercise). */ + fun resetHiitPhase() { + workoutServiceManager.cancelCountdown() + _hiitPhase.update { HiitPhase.Idle } + } + fun toggleStopwatch() { if (isStopwatchPaused.value) { workoutServiceManager.startStopwatch() From c68c67dc8f821bbe88a2bb3b2c5ddec581e4850e Mon Sep 17 00:00:00 2001 From: hozaifa1 <20hozaifa02@gmail.com> Date: Sat, 23 May 2026 15:05:26 +0000 Subject: [PATCH 2/2] fix(hiit): make HIIT mode user-accessible, fix CI, add tests and previews Address review feedback on commit 127c7e5: - Add HiitSettingsCard exposed in the workout screen for DURATION exercises so users can actually enable HIIT auto-advance and pick a target duration. Without this the feature was unreachable. - Extract HiitPhase to a top-level sealed class with a pure nextPhaseAfterCountdown transition helper; unit-test the helper. - Rewrite HiitCountdownCard to use existing drawable resources and MaterialTheme.colorScheme instead of dragging in the unavailable androidx.compose.material:material-icons-extended library and hard-coding hex colours. - Move every user-facing HIIT string to strings.xml. - Add @Preview functions for all visual HIIT components (idle, set, rest, done, settings on/off). - Drop the ExerciseDC.cautions field and its 3->4 migration column. The dataset schema is defined by schemas/exercise-schema.json and must not be extended here; the field also had no UI to populate it. - Add a SessionStart hook so subsequent web sessions can run lintDebug, testDebugUnitTest, and assembleDebug locally. - Regenerate app/lint-baseline.xml: Room-generated code shifts line numbers when columns are added to the exercises entity. Stripped environment-only entries (AGP/Gradle/dependency-version notices). Verified locally with: ./gradlew lintDebug ./gradlew testDebugUnitTest ./gradlew assembleDebug --- .claude/hooks/session-start.sh | 49 + .claude/settings.json | 14 + app/lint-baseline.xml | 3266 ++++++++++------- .../org.librefit.db.AppDatabase/4.json | 394 ++ .../main/java/org/librefit/db/AppDatabase.kt | 8 - .../java/org/librefit/db/entity/ExerciseDC.kt | 4 +- .../ui/components/HiitCountdownCard.kt | 238 +- .../ui/components/HiitSettingsCard.kt | 126 + .../org/librefit/ui/models/UiExerciseDC.kt | 3 +- .../ui/models/mappers/UiExerciseDCMapper.kt | 6 +- .../infoExercise/InfoExerciseScreen.kt | 46 +- .../librefit/ui/screens/workout/HiitPhase.kt | 57 + .../ui/screens/workout/WorkoutScreen.kt | 100 +- .../screens/workout/WorkoutScreenViewModel.kt | 79 +- app/src/main/res/values/strings.xml | 12 + .../ui/screens/workout/HiitPhaseTest.kt | 56 + 16 files changed, 2997 insertions(+), 1461 deletions(-) create mode 100755 .claude/hooks/session-start.sh create mode 100644 .claude/settings.json create mode 100644 app/schemas/org.librefit.db.AppDatabase/4.json create mode 100644 app/src/main/java/org/librefit/ui/components/HiitSettingsCard.kt create mode 100644 app/src/main/java/org/librefit/ui/screens/workout/HiitPhase.kt create mode 100644 app/src/test/java/org/librefit/ui/screens/workout/HiitPhaseTest.kt diff --git a/.claude/hooks/session-start.sh b/.claude/hooks/session-start.sh new file mode 100755 index 000000000..408d99d3e --- /dev/null +++ b/.claude/hooks/session-start.sh @@ -0,0 +1,49 @@ +#!/bin/bash +# Installs the Android SDK + cmdline-tools needed to run `./gradlew lintDebug` +# and `./gradlew testDebugUnitTest` in the remote Claude Code session. +# +# Only runs in the remote environment. +set -euo pipefail + +if [ "${CLAUDE_CODE_REMOTE:-}" != "true" ]; then + exit 0 +fi + +ANDROID_SDK_ROOT="${ANDROID_SDK_ROOT:-$HOME/android-sdk}" +CMDLINE_TOOLS_VERSION="11076708" # commandlinetools-linux-11076708_latest.zip +PLATFORM_API="37.0" +BUILD_TOOLS="37.0.0" + +mkdir -p "$ANDROID_SDK_ROOT" + +if [ ! -x "$ANDROID_SDK_ROOT/cmdline-tools/latest/bin/sdkmanager" ]; then + echo "Installing Android cmdline-tools…" + TMP_ZIP="$(mktemp -d)/cmdline-tools.zip" + curl -fsSL -o "$TMP_ZIP" \ + "https://dl.google.com/android/repository/commandlinetools-linux-${CMDLINE_TOOLS_VERSION}_latest.zip" + rm -rf "$ANDROID_SDK_ROOT/cmdline-tools" + mkdir -p "$ANDROID_SDK_ROOT/cmdline-tools" + unzip -q "$TMP_ZIP" -d "$ANDROID_SDK_ROOT/cmdline-tools" + mv "$ANDROID_SDK_ROOT/cmdline-tools/cmdline-tools" "$ANDROID_SDK_ROOT/cmdline-tools/latest" + rm -f "$TMP_ZIP" +fi + +export ANDROID_SDK_ROOT +export ANDROID_HOME="$ANDROID_SDK_ROOT" +export PATH="$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools:$PATH" + +yes 2>/dev/null | sdkmanager --licenses >/dev/null || true + +sdkmanager --install \ + "platforms;android-${PLATFORM_API}" \ + "build-tools;${BUILD_TOOLS}" \ + "platform-tools" >/dev/null + +# Persist for the rest of the session. +{ + echo "export ANDROID_SDK_ROOT=\"$ANDROID_SDK_ROOT\"" + echo "export ANDROID_HOME=\"$ANDROID_SDK_ROOT\"" + echo "export PATH=\"$ANDROID_SDK_ROOT/cmdline-tools/latest/bin:$ANDROID_SDK_ROOT/platform-tools:\$PATH\"" +} >> "$CLAUDE_ENV_FILE" + +echo "Android SDK ready at $ANDROID_SDK_ROOT" diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 000000000..e06b0338e --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,14 @@ +{ + "hooks": { + "SessionStart": [ + { + "hooks": [ + { + "type": "command", + "command": "$CLAUDE_PROJECT_DIR/.claude/hooks/session-start.sh" + } + ] + } + ] + } +} diff --git a/app/lint-baseline.xml b/app/lint-baseline.xml index 40de01f55..38349deeb 100644 --- a/app/lint-baseline.xml +++ b/app/lint-baseline.xml @@ -9,17 +9,6 @@ - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + @@ -1633,7 +1743,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -1644,7 +1754,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -1655,7 +1765,7 @@ errorLine2=" ~~~~~~~~~"> @@ -1666,7 +1776,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -1677,7 +1787,7 @@ errorLine2=" ~~~~~~"> @@ -1688,7 +1798,7 @@ errorLine2=" ~~~~~~"> @@ -1699,7 +1809,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -1710,7 +1820,7 @@ errorLine2=" ~~~~~~~~~"> @@ -1721,7 +1831,7 @@ errorLine2=" ~~~~~~~~~"> @@ -1732,7 +1842,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -1743,7 +1853,7 @@ errorLine2=" ~~~~~~"> @@ -1754,7 +1864,7 @@ errorLine2=" ~~~~~~"> @@ -1765,7 +1875,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -1776,7 +1886,7 @@ errorLine2=" ~~~~~"> @@ -1787,7 +1897,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1798,7 +1908,7 @@ errorLine2=" ~~~~~~"> @@ -1809,7 +1919,7 @@ errorLine2=" ~~~~~~"> @@ -1820,7 +1930,7 @@ errorLine2=" ~~~~~"> @@ -1831,7 +1941,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1842,7 +1952,7 @@ errorLine2=" ~~~~~~"> @@ -1853,7 +1963,7 @@ errorLine2=" ~~~~~~"> @@ -1864,7 +1974,7 @@ errorLine2=" ~~~~~"> @@ -1875,7 +1985,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -1886,7 +1996,7 @@ errorLine2=" ~~~~~~"> @@ -1897,7 +2007,7 @@ errorLine2=" ~~~~~~"> @@ -1908,7 +2018,7 @@ errorLine2=" ~~~~~~~~~"> @@ -1919,7 +2029,7 @@ errorLine2=" ~~~~~~~~~"> @@ -1930,7 +2040,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -1941,7 +2051,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -1952,7 +2062,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -1963,7 +2073,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -1974,7 +2084,7 @@ errorLine2=" ~~~~"> @@ -1985,7 +2095,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -1996,7 +2106,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -2007,7 +2117,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2018,7 +2128,7 @@ errorLine2=" ~~~~~~"> @@ -2029,7 +2139,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -2040,7 +2150,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -2051,7 +2161,7 @@ errorLine2=" ^"> @@ -2062,7 +2172,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -2073,7 +2183,7 @@ errorLine2=" ~~~~~~"> @@ -2084,7 +2194,7 @@ errorLine2=" ~~~~"> @@ -2095,7 +2205,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2106,7 +2216,7 @@ errorLine2=" ~~~~"> @@ -2117,7 +2227,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2128,7 +2238,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2139,7 +2249,7 @@ errorLine2=" ~~~~~~"> @@ -2150,7 +2260,7 @@ errorLine2=" ~~~~~~"> @@ -2161,7 +2271,7 @@ errorLine2=" ~~~~~~"> @@ -2172,7 +2282,7 @@ errorLine2=" ~~~~"> @@ -2183,7 +2293,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2194,7 +2304,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2205,7 +2315,7 @@ errorLine2=" ~~~~~~"> @@ -2216,7 +2326,7 @@ errorLine2=" ~~~~~~"> @@ -2227,7 +2337,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2238,7 +2348,7 @@ errorLine2=" ~~~~"> @@ -2249,7 +2359,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2260,7 +2370,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2271,7 +2381,7 @@ errorLine2=" ~~~~~~"> @@ -2282,7 +2392,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -2293,7 +2403,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2304,7 +2414,7 @@ errorLine2=" ~~~~"> @@ -2315,7 +2425,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2326,7 +2436,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2337,7 +2447,7 @@ errorLine2=" ~~~~~~"> @@ -2348,7 +2458,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -2359,7 +2469,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2370,7 +2480,7 @@ errorLine2=" ~~~~"> @@ -2381,7 +2491,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2392,7 +2502,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2403,7 +2513,7 @@ errorLine2=" ~~~~~~"> @@ -2414,7 +2524,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -2425,7 +2535,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2436,7 +2546,7 @@ errorLine2=" ~~~~"> @@ -2447,7 +2557,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2458,7 +2568,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2469,7 +2579,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -2480,7 +2590,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -2491,7 +2601,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -2502,7 +2612,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2513,7 +2623,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -2524,7 +2634,7 @@ errorLine2=" ~~~~~~"> @@ -2535,7 +2645,7 @@ errorLine2=" ~~~~~~"> @@ -2546,7 +2656,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -2557,7 +2667,7 @@ errorLine2=" ~~~~~"> @@ -2568,7 +2678,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -2579,7 +2689,7 @@ errorLine2=" ~~~~~~"> @@ -2590,7 +2700,7 @@ errorLine2=" ~~~~~~"> @@ -2601,7 +2711,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2612,7 +2722,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2623,7 +2733,7 @@ errorLine2=" ~~~~~~"> @@ -2634,7 +2744,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -2645,7 +2755,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -2656,7 +2766,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -2667,7 +2777,7 @@ errorLine2=" ~~~~"> @@ -2678,7 +2788,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -2689,7 +2799,7 @@ errorLine2=" ~~~~~~"> @@ -2700,7 +2810,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2711,7 +2821,7 @@ errorLine2=" ~~~~~~"> @@ -2722,7 +2832,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -2733,7 +2843,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -2744,7 +2854,7 @@ errorLine2=" ^"> @@ -2755,7 +2865,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -2766,7 +2876,7 @@ errorLine2=" ~~~~~~"> @@ -2777,7 +2887,7 @@ errorLine2=" ~~~~"> @@ -2788,7 +2898,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2799,7 +2909,7 @@ errorLine2=" ~~~~"> @@ -2810,7 +2920,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2821,7 +2931,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2832,7 +2942,7 @@ errorLine2=" ~~~~~~"> @@ -2843,7 +2953,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -2854,7 +2964,7 @@ errorLine2=" ~~~~~~"> @@ -2865,7 +2975,7 @@ errorLine2=" ~~~~"> @@ -2876,7 +2986,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2887,7 +2997,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2898,7 +3008,7 @@ errorLine2=" ~~~~~~"> @@ -2909,7 +3019,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2920,7 +3030,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2931,7 +3041,7 @@ errorLine2=" ~~~~"> @@ -2942,7 +3052,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -2953,7 +3063,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2964,7 +3074,7 @@ errorLine2=" ~~~~~~"> @@ -2975,7 +3085,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -2986,7 +3096,7 @@ errorLine2=" ~~~~~~~~~"> @@ -2997,7 +3107,7 @@ errorLine2=" ~~~~"> @@ -3008,7 +3118,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3019,7 +3129,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3030,7 +3140,7 @@ errorLine2=" ~~~~~~"> @@ -3041,7 +3151,7 @@ errorLine2=" ~~~~~~"> @@ -3052,7 +3162,7 @@ errorLine2=" ~~~~~~"> @@ -3063,7 +3173,7 @@ errorLine2=" ~~~~"> @@ -3074,7 +3184,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3085,7 +3195,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3096,7 +3206,7 @@ errorLine2=" ~~~~~~"> @@ -3107,7 +3217,7 @@ errorLine2=" ~~~~~~~"> @@ -3118,7 +3228,7 @@ errorLine2=" ~~~~~~"> @@ -3129,7 +3239,7 @@ errorLine2=" ~~~~"> @@ -3140,7 +3250,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3151,7 +3261,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3162,7 +3272,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3173,7 +3283,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -3184,7 +3294,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3195,7 +3305,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3206,7 +3316,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -3217,7 +3327,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3228,7 +3338,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -3239,7 +3349,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -3250,7 +3360,7 @@ errorLine2=" ~~~~"> @@ -3261,7 +3371,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -3272,7 +3382,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -3283,7 +3393,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3294,7 +3404,7 @@ errorLine2=" ~~~~~~"> @@ -3305,7 +3415,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -3316,7 +3426,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -3327,7 +3437,7 @@ errorLine2=" ^"> @@ -3338,7 +3448,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -3349,7 +3459,7 @@ errorLine2=" ~~~~~~"> @@ -3360,7 +3470,7 @@ errorLine2=" ~~~~"> @@ -3371,7 +3481,7 @@ errorLine2=" ~~~~~~"> @@ -3382,7 +3492,7 @@ errorLine2=" ~~~~"> @@ -3393,7 +3503,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3404,7 +3514,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3415,7 +3525,7 @@ errorLine2=" ~~~~~~"> @@ -3426,7 +3536,7 @@ errorLine2=" ~~~~~~"> @@ -3437,7 +3547,7 @@ errorLine2=" ~~~~~~"> @@ -3448,7 +3558,7 @@ errorLine2=" ~~~~"> @@ -3459,7 +3569,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3470,7 +3580,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3481,7 +3591,7 @@ errorLine2=" ~~~~~~"> @@ -3492,7 +3602,7 @@ errorLine2=" ~~~~~~~"> @@ -3503,7 +3613,7 @@ errorLine2=" ~~~~~~"> @@ -3514,7 +3624,7 @@ errorLine2=" ~~~~"> @@ -3525,7 +3635,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3536,7 +3646,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3547,7 +3657,7 @@ errorLine2=" ~~~~~~"> @@ -3558,7 +3668,7 @@ errorLine2=" ~~~~~~~"> @@ -3569,7 +3679,7 @@ errorLine2=" ~~~~~~"> @@ -3580,7 +3690,7 @@ errorLine2=" ~~~~"> @@ -3591,7 +3701,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3602,7 +3712,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3613,7 +3723,7 @@ errorLine2=" ~~~~~~"> @@ -3624,7 +3734,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -3635,7 +3745,7 @@ errorLine2=" ~~~~~~"> @@ -3646,7 +3756,7 @@ errorLine2=" ~~~~"> @@ -3657,7 +3767,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3668,7 +3778,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3679,7 +3789,7 @@ errorLine2=" ~~~~~~"> @@ -3690,7 +3800,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -3701,7 +3811,7 @@ errorLine2=" ~~~~~~"> @@ -3712,7 +3822,7 @@ errorLine2=" ~~~~"> @@ -3723,7 +3833,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3734,7 +3844,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3745,7 +3855,7 @@ errorLine2=" ~~~~~~"> @@ -3756,7 +3866,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -3767,7 +3877,7 @@ errorLine2=" ~~~~~~"> @@ -3778,7 +3888,7 @@ errorLine2=" ~~~~"> @@ -3789,7 +3899,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3800,7 +3910,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3811,7 +3921,7 @@ errorLine2=" ~~~~~~"> @@ -3822,7 +3932,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -3833,7 +3943,7 @@ errorLine2=" ~~~~~~"> @@ -3844,7 +3954,7 @@ errorLine2=" ~~~~"> @@ -3855,7 +3965,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3866,7 +3976,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3877,7 +3987,7 @@ errorLine2=" ~~~~~~"> @@ -3888,7 +3998,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -3899,7 +4009,7 @@ errorLine2=" ~~~~~~"> @@ -3910,7 +4020,7 @@ errorLine2=" ~~~~"> @@ -3921,7 +4031,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3932,7 +4042,7 @@ errorLine2=" ~~~~~~~~~"> @@ -3943,7 +4053,7 @@ errorLine2=" ~~~~~~"> @@ -3954,7 +4064,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -3965,7 +4075,7 @@ errorLine2=" ~~~~~~"> @@ -3976,7 +4086,7 @@ errorLine2=" ~~~~"> @@ -3987,7 +4097,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -3998,7 +4108,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4009,7 +4119,7 @@ errorLine2=" ~~~~~~"> @@ -4020,7 +4130,7 @@ errorLine2=" ~~~~~~~~"> @@ -4031,7 +4141,7 @@ errorLine2=" ~~~~~~"> @@ -4042,7 +4152,7 @@ errorLine2=" ~~~~"> @@ -4053,7 +4163,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -4064,7 +4174,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4075,7 +4185,7 @@ errorLine2=" ~~~~~~"> @@ -4086,7 +4196,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -4097,7 +4207,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4108,7 +4218,7 @@ errorLine2=" ~~~~"> @@ -4119,7 +4229,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -4130,7 +4240,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4141,7 +4251,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -4152,7 +4262,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -4163,7 +4273,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4174,7 +4284,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4185,7 +4295,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4196,7 +4306,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -4207,7 +4317,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -4218,7 +4328,7 @@ errorLine2=" ~~~~~~~~~~~~~~~"> @@ -4229,7 +4339,7 @@ errorLine2=" ~~~~"> @@ -4240,7 +4350,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -4251,7 +4361,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4262,7 +4372,7 @@ errorLine2=" ~~~~~~~~~"> @@ -4273,7 +4383,7 @@ errorLine2=" ~~~~~~"> @@ -4284,7 +4394,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -4295,7 +4405,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -4306,7 +4416,7 @@ errorLine2=" ^"> @@ -4317,7 +4427,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~"> @@ -4328,7 +4438,7 @@ errorLine2=" ~~~~"> @@ -4339,7 +4449,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -4350,7 +4460,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -4361,7 +4471,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4372,7 +4482,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -4383,7 +4493,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> @@ -5886,7 +5996,7 @@ @@ -5923,7 +6033,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~"> @@ -5934,7 +6044,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -5945,7 +6055,7 @@ errorLine2=" ~~~~"> @@ -5956,7 +6066,7 @@ errorLine2=" ^"> @@ -5967,7 +6077,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -5978,7 +6088,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -5989,7 +6099,7 @@ errorLine2=" ~~~~"> @@ -6000,7 +6110,7 @@ errorLine2=" ^"> @@ -6011,7 +6121,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6022,7 +6132,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6033,7 +6143,7 @@ errorLine2=" ~~~~"> @@ -6044,7 +6154,7 @@ errorLine2=" ^"> @@ -6055,7 +6165,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6066,7 +6176,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6077,7 +6187,7 @@ errorLine2=" ~~~~"> @@ -6088,7 +6198,7 @@ errorLine2=" ^"> @@ -6099,7 +6209,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6110,7 +6220,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6121,7 +6231,7 @@ errorLine2=" ~~~~"> @@ -6132,7 +6242,7 @@ errorLine2=" ^"> @@ -6143,18 +6253,18 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6165,7 +6275,7 @@ errorLine2=" ~~~~"> @@ -6176,7 +6286,7 @@ errorLine2=" ^"> @@ -6187,7 +6297,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6198,7 +6308,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6209,7 +6319,7 @@ errorLine2=" ~~~~"> @@ -6220,7 +6330,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6231,7 +6341,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6242,7 +6352,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6253,7 +6363,7 @@ errorLine2=" ~~~~~~~"> @@ -6264,7 +6374,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6275,7 +6385,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6286,7 +6396,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6297,7 +6407,7 @@ errorLine2=" ~~~~~~~~"> @@ -6308,7 +6418,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6319,7 +6429,7 @@ errorLine2=" ~~~~~~"> @@ -6330,7 +6440,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6341,7 +6451,7 @@ errorLine2=" ~~~"> @@ -6352,7 +6462,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6363,7 +6473,7 @@ errorLine2=" ~~~~~~"> @@ -6374,7 +6484,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6385,7 +6495,7 @@ errorLine2=" ~~~~~~~"> @@ -6396,7 +6506,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6407,7 +6517,7 @@ errorLine2=" ~~~~~~"> @@ -6418,7 +6528,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6429,7 +6539,7 @@ errorLine2=" ~~~~~~~~"> @@ -6440,7 +6550,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6451,7 +6561,7 @@ errorLine2=" ~~~~~~"> @@ -6462,7 +6572,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6473,7 +6583,7 @@ errorLine2=" ~~~"> @@ -6484,7 +6594,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6495,7 +6605,7 @@ errorLine2=" ~~~~~~"> @@ -6506,7 +6616,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6517,7 +6627,7 @@ errorLine2=" ~~~~~~~"> @@ -6528,7 +6638,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6539,7 +6649,7 @@ errorLine2=" ~~~~~~"> @@ -6550,7 +6660,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6561,7 +6671,7 @@ errorLine2=" ~~~~~~~~"> @@ -6572,7 +6682,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6583,7 +6693,7 @@ errorLine2=" ~~~~~~"> @@ -6594,7 +6704,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -6605,7 +6715,7 @@ errorLine2=" ~~~"> @@ -6616,7 +6726,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -6627,7 +6737,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -6638,7 +6748,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6649,7 +6759,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6660,7 +6770,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6671,7 +6781,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6682,7 +6792,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6693,7 +6803,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6704,7 +6814,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6715,7 +6825,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6726,7 +6836,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -6737,7 +6847,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6748,7 +6858,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6759,7 +6869,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6770,7 +6880,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6781,7 +6891,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6792,7 +6902,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6803,7 +6913,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6814,7 +6924,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6825,7 +6935,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -6836,7 +6946,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6847,7 +6957,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6858,7 +6968,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6869,7 +6979,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6880,7 +6990,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6891,7 +7001,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6902,7 +7012,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6913,7 +7023,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6924,7 +7034,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -6935,7 +7045,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6946,7 +7056,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6957,7 +7067,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6968,7 +7078,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6979,7 +7089,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -6990,7 +7100,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7001,7 +7111,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7012,7 +7122,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7023,7 +7133,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -7034,7 +7144,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7045,7 +7155,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7056,7 +7166,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7067,7 +7177,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7078,7 +7188,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7089,7 +7199,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7100,7 +7210,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7111,7 +7221,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7122,7 +7232,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -7133,7 +7243,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7144,7 +7254,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7155,7 +7265,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7166,7 +7276,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7177,7 +7287,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7188,7 +7298,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7199,7 +7309,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7210,7 +7320,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7221,7 +7331,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -7232,7 +7342,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7243,7 +7353,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7254,7 +7364,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7265,7 +7375,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7276,7 +7386,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7287,7 +7397,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7298,7 +7408,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7309,7 +7419,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7320,7 +7430,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -7331,7 +7441,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7342,7 +7452,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7353,7 +7463,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7364,7 +7474,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7375,7 +7485,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7386,7 +7496,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7397,10 +7507,32 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + + + + + + + + @@ -7419,7 +7551,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7430,7 +7562,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7441,7 +7573,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7452,7 +7584,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7463,7 +7595,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7474,7 +7606,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7485,7 +7617,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~"> @@ -7496,7 +7628,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7507,7 +7639,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7518,7 +7650,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7529,7 +7661,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7540,7 +7672,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7551,7 +7683,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7562,7 +7694,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7573,7 +7705,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7584,7 +7716,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -7595,7 +7727,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7606,7 +7738,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7617,7 +7749,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7628,7 +7760,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7639,7 +7771,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7650,7 +7782,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7661,7 +7793,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7672,7 +7804,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> @@ -7683,7 +7815,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -7694,7 +7826,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -7705,7 +7837,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -7716,7 +7848,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -7727,7 +7859,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> @@ -7738,7 +7870,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -7749,7 +7881,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -7760,7 +7892,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -7771,7 +7903,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -7782,7 +7914,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -7793,7 +7925,7 @@ errorLine2=" ~~~~~~~~~~~~~~"> @@ -7804,13 +7936,13 @@ errorLine2=" ~~~~~~~~~~~~~~"> + message=""profile" is not translated in "in" (Indonesian)" + errorLine1=" <string name="profile">Profile</string>" + errorLine2=" ~~~~~~~~~~~~~~"> + message=""settings" is not translated in "in" (Indonesian)" + errorLine1=" <string name="settings">Settings</string>" + errorLine2=" ~~~~~~~~~~~~~~~"> + message=""about" is not translated in "in" (Indonesian)" + errorLine1=" <string name="about">About</string>" + errorLine2=" ~~~~~~~~~~~~"> + message=""add_exercise" is not translated in "in" (Indonesian) or "ar" (Arabic)" + errorLine1=" <string name="add_exercise">Add exercise</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + message=""more_options" is not translated in "in" (Indonesian)" + errorLine1=" <string name="more_options">More options</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + message=""navigate_back" is not translated in "in" (Indonesian) or "ar" (Arabic)" + errorLine1=" <string name="navigate_back">Back to the previous screen</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + message=""library" is not translated in "in" (Indonesian)" + errorLine1=" <string name="library">Library</string>" + errorLine2=" ~~~~~~~~~~~~~~"> + message=""open" is not translated in "in" (Indonesian) or "zh" (Chinese)" + errorLine1=" <string name="open">Open</string>" + errorLine2=" ~~~~~~~~~~~"> + message=""copy" is not translated in "in" (Indonesian)" + errorLine1=" <string name="copy">Copy</string>" + errorLine2=" ~~~~~~~~~~~"> + message=""support_project" is not translated in "in" (Indonesian) or "ar" (Arabic)" + errorLine1=" <string name="support_project">Support the project</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + message=""info" is not translated in "in" (Indonesian)" + errorLine1=" <string name="info">Info</string>" + errorLine2=" ~~~~~~~~~~~"> + message=""privacy_policy" is not translated in "in" (Indonesian)" + errorLine1=" <string name="privacy_policy">Privacy policy</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + message=""privacy_policy_desc" is not translated in "in" (Indonesian)" + errorLine1=" <string name="privacy_policy_desc">LibreFit cannot access the Internet. This means user data never leaves the device.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""website" is not translated in "in" (Indonesian)" + errorLine1=" <string name="website">Website</string>" + errorLine2=" ~~~~~~~~~~~~~~"> + message=""source_code" is not translated in "in" (Indonesian)" + errorLine1=" <string name="source_code">Source code</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~"> + message=""license" is not translated in "in" (Indonesian)" + errorLine1=" <string name="license">License</string>" + errorLine2=" ~~~~~~~~~~~~~~"> + message=""license_desc" is not translated in "in" (Indonesian)" + errorLine1=" <string name="license_desc">LibreFit is licensed under the GNU General Public License v3.0 (GPL-3).</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + message=""contributors" is not translated in "in" (Indonesian)" + errorLine1=" <string name="contributors">Contributors</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + message=""founder" is not translated in "in" (Indonesian)" + errorLine1=" <string name="founder">Founder</string>" + errorLine2=" ~~~~~~~~~~~~~~"> + message=""translators" is not translated in "in" (Indonesian)" + errorLine1=" <string name="translators">Translators</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~"> + message=""contributed_to" is not translated in "in" (Indonesian) or "zh" (Chinese)" + errorLine1=" <string name="contributed_to">Contributed to: </string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + message=""view_online_version" is not translated in "in" (Indonesian) or "zh" (Chinese)" + errorLine1=" <string name="view_online_version">View online version</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""version" is not translated in "in" (Indonesian)" + errorLine1=" <string name="version">Version</string>" + errorLine2=" ~~~~~~~~~~~~~~"> + message=""dependencies" is not translated in "in" (Indonesian)" + errorLine1=" <string name="dependencies">Dependencies</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~"> + message=""lets_build_it_together" is not translated in "in" (Indonesian) or "zh" (Chinese)" + errorLine1=" <string name="lets_build_it_together">Let\'s build it together</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""tutorial_desc" is not translated in "in" (Indonesian) or "zh" (Chinese)" + errorLine1=" <string name="tutorial_desc">Learn how to create routines and track your workouts.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + message=""donators" is not translated in "de" (German), "in" (Indonesian), "zh" (Chinese), "nl" (Dutch)" + errorLine1=" <string name="donators">Donators</string>" + errorLine2=" ~~~~~~~~~~~~~~~"> + message=""url_source_code_codeberg" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="url_source_code_codeberg">https://codeberg.org/LibreFitOrg/LibreFit</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""unlink_routine_question" is not translated in "in" (Indonesian), "zh" (Chinese), "ar" (Arabic)" + errorLine1=" <string name="unlink_routine_question">Unlink routine?</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""unlink_routine_text" is not translated in "hi" (Hindi), "in" (Indonesian), "zh" (Chinese), "ar" (Arabic)" + errorLine1=" <string name="unlink_routine_text">Linking routines allows to measure progress of workouts over time. If you unlink this workout from a routine, the latter will not be deleted and any workout linked to that routine will remain unchanged. The unlink will affect only the current workout.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""overview" is not translated in "in" (Indonesian), "zh" (Chinese), "ar" (Arabic)" + errorLine1=" <string name="overview">Overview</string>" + errorLine2=" ~~~~~~~~~~~~~~~"> + message=""statistics" is not translated in "in" (Indonesian), "zh" (Chinese), "ar" (Arabic)" + errorLine1=" <string name="statistics">Statistics</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~"> + message=""volume" is not translated in "in" (Indonesian), "zh" (Chinese), "ar" (Arabic)" + errorLine1=" <string name="volume">Volume</string>" + errorLine2=" ~~~~~~~~~~~~~"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + message=""url_dialog" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="url_dialog">URL</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~"> + message=""unlink_dialog" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="unlink_dialog">Unlink</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~"> + message=""discard_changes_question" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="discard_changes_question">Discard changes?</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""discard_changes_text" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="discard_changes_text">Are you sure you want to quit? Your changes won\'t be saved</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""delete" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="delete">Delete</string>" + errorLine2=" ~~~~~~~~~~~~~"> + message=""seconds" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="seconds">Seconds</string>" + errorLine2=" ~~~~~~~~~~~~~~"> + message=""done" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="done">Done</string>" + errorLine2=" ~~~~~~~~~~~"> + message=""menu" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="menu">Menu</string>" + errorLine2=" ~~~~~~~~~~~"> + message=""add" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="add">Add</string>" + errorLine2=" ~~~~~~~~~~"> + message=""save" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="save">Save</string>" + errorLine2=" ~~~~~~~~~~~"> + message=""others" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="others">Others</string>" + errorLine2=" ~~~~~~~~~~~~~"> + message=""help" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="help">Help</string>" + errorLine2=" ~~~~~~~~~~~"> + message=""paste" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="paste">Paste</string>" + errorLine2=" ~~~~~~~~~~~~"> + message=""name" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="name">Name</string>" + errorLine2=" ~~~~~~~~~~~"> + message=""edit" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="edit">Edit</string>" + errorLine2=" ~~~~~~~~~~~"> + message=""do_not_show_again" is not translated in "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan)" + errorLine1=" <string name="do_not_show_again">Do not show again</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""undo" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="undo">Undo</string>" + errorLine2=" ~~~~~~~~~~~"> + message=""clear" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "in" (Indonesian), "es" (Spanish), "zh" (Chinese), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="clear">Clear</string>" + errorLine2=" ~~~~~~~~~~~~"> + message=""hiit_mode" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="hiit_mode">HIIT mode</string>" + errorLine2=" ~~~~~~~~~~~~~~~~"> + message=""hiit_mode_desc" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="hiit_mode_desc">Auto-advance: countdown → rest → next set</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + message=""hiit_target_duration" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="hiit_target_duration">Target duration (seconds)</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""hiit_phase_ready" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="hiit_phase_ready">READY</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> + message=""hiit_phase_set" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="hiit_phase_set">SET</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + message=""hiit_phase_rest" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="hiit_phase_rest">REST</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + message=""hiit_phase_done" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="hiit_phase_done">DONE</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~"> + message=""hiit_start_set" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="hiit_start_set">Start set</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + message=""hiit_skip_rest" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="hiit_skip_rest">Skip rest</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~"> + message=""hiit_all_sets_complete" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="hiit_all_sets_complete">All sets complete! Move to next exercise.</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""hiit_set_progress" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="hiit_set_progress">Set %1$d / %2$d</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> + message=""exercise_details" is not translated in "de" (German), "hi" (Hindi), "pt" (Portuguese), "gl" (Galician), "in" (Indonesian), "it" (Italian), "fr" (French), "es" (Spanish), "zh" (Chinese), "cs" (Czech), "ar" (Arabic), "uk" (Ukrainian), "ca" (Catalan), "nl" (Dutch)" + errorLine1=" <string name="exercise_details">Exercise details</string>" + errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - @@ -12662,7 +13432,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -12673,7 +13443,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -12684,7 +13454,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~"> @@ -12695,7 +13465,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -12706,7 +13476,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~"> @@ -12717,7 +13487,7 @@ errorLine2=" ~~~~~~~~~~~"> @@ -12728,7 +13498,7 @@ errorLine2=" ~~~~~~~~~~"> @@ -12739,7 +13509,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -12750,7 +13520,7 @@ errorLine2=" ~~~~~~~~~~~~"> @@ -12761,7 +13531,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -12772,7 +13542,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -12783,7 +13553,7 @@ errorLine2=" ~~~~~~~~~~~~~"> @@ -12794,7 +13564,7 @@ errorLine2=" ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~"> @@ -12805,7 +13575,7 @@ errorLine2=" ~~~~~~~~~~~~~"> diff --git a/app/schemas/org.librefit.db.AppDatabase/4.json b/app/schemas/org.librefit.db.AppDatabase/4.json new file mode 100644 index 000000000..5b68a79ec --- /dev/null +++ b/app/schemas/org.librefit.db.AppDatabase/4.json @@ -0,0 +1,394 @@ +{ + "formatVersion": 1, + "database": { + "version": 4, + "identityHash": "8c52442f7620d14e4deb09d53fe8ee4d", + "entities": [ + { + "tableName": "workouts", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `routineId` INTEGER NOT NULL, `notes` TEXT NOT NULL, `title` TEXT NOT NULL, `state` TEXT NOT NULL, `timeElapsed` INTEGER NOT NULL, `created` TEXT NOT NULL, `completed` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "routineId", + "columnName": "routineId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "state", + "columnName": "state", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timeElapsed", + "columnName": "timeElapsed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "created", + "columnName": "created", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "completed", + "columnName": "completed", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "exercises", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `idExerciseDC` TEXT NOT NULL, `notes` TEXT NOT NULL, `setMode` TEXT NOT NULL, `restTime` INTEGER NOT NULL, `position` INTEGER NOT NULL, `workoutId` INTEGER NOT NULL, `targetDuration` INTEGER NOT NULL, `autoAdvanceSets` INTEGER NOT NULL, FOREIGN KEY(`workoutId`) REFERENCES `workouts`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`idExerciseDC`) REFERENCES `dataset`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "idExerciseDC", + "columnName": "idExerciseDC", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "setMode", + "columnName": "setMode", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "restTime", + "columnName": "restTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "workoutId", + "columnName": "workoutId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "targetDuration", + "columnName": "targetDuration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "autoAdvanceSets", + "columnName": "autoAdvanceSets", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_exercises_workoutId", + "unique": false, + "columnNames": [ + "workoutId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercises_workoutId` ON `${TABLE_NAME}` (`workoutId`)" + }, + { + "name": "index_exercises_workoutId_position", + "unique": false, + "columnNames": [ + "workoutId", + "position" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercises_workoutId_position` ON `${TABLE_NAME}` (`workoutId`, `position`)" + }, + { + "name": "index_exercises_idExerciseDC", + "unique": false, + "columnNames": [ + "idExerciseDC" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_exercises_idExerciseDC` ON `${TABLE_NAME}` (`idExerciseDC`)" + } + ], + "foreignKeys": [ + { + "table": "workouts", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "workoutId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "dataset", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "idExerciseDC" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "sets", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `load` REAL NOT NULL, `reps` INTEGER NOT NULL, `elapsedTime` INTEGER NOT NULL, `completed` INTEGER NOT NULL, `exerciseId` INTEGER NOT NULL, FOREIGN KEY(`exerciseId`) REFERENCES `exercises`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "load", + "columnName": "load", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "reps", + "columnName": "reps", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "elapsedTime", + "columnName": "elapsedTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "completed", + "columnName": "completed", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "exerciseId", + "columnName": "exerciseId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_sets_exerciseId", + "unique": false, + "columnNames": [ + "exerciseId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_sets_exerciseId` ON `${TABLE_NAME}` (`exerciseId`)" + } + ], + "foreignKeys": [ + { + "table": "exercises", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "exerciseId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "measurements", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `bodyWeight` REAL NOT NULL, `bodyFatPercentage` INTEGER NOT NULL, `muscleMassPercentage` INTEGER NOT NULL, `date` TEXT NOT NULL, `notes` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bodyWeight", + "columnName": "bodyWeight", + "affinity": "REAL", + "notNull": true + }, + { + "fieldPath": "bodyFatPercentage", + "columnName": "bodyFatPercentage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "muscleMassPercentage", + "columnName": "muscleMassPercentage", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "date", + "columnName": "date", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "notes", + "columnName": "notes", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + } + }, + { + "tableName": "dataset", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `force` TEXT, `level` TEXT NOT NULL, `mechanic` TEXT, `equipment` TEXT, `primaryMuscles` TEXT NOT NULL, `secondaryMuscles` TEXT NOT NULL, `instructions` TEXT NOT NULL, `category` TEXT NOT NULL, `images` TEXT NOT NULL, `isCustomExercise` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "force", + "columnName": "force", + "affinity": "TEXT" + }, + { + "fieldPath": "level", + "columnName": "level", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "mechanic", + "columnName": "mechanic", + "affinity": "TEXT" + }, + { + "fieldPath": "equipment", + "columnName": "equipment", + "affinity": "TEXT" + }, + { + "fieldPath": "primaryMuscles", + "columnName": "primaryMuscles", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "secondaryMuscles", + "columnName": "secondaryMuscles", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "instructions", + "columnName": "instructions", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "category", + "columnName": "category", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "images", + "columnName": "images", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "isCustomExercise", + "columnName": "isCustomExercise", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + } + } + ], + "setupQueries": [ + "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)", + "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '8c52442f7620d14e4deb09d53fe8ee4d')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/org/librefit/db/AppDatabase.kt b/app/src/main/java/org/librefit/db/AppDatabase.kt index 487638844..66d5df3e7 100644 --- a/app/src/main/java/org/librefit/db/AppDatabase.kt +++ b/app/src/main/java/org/librefit/db/AppDatabase.kt @@ -40,7 +40,6 @@ abstract class AppDatabase : RoomDatabase() { val MIGRATION_3_4 = object : Migration(3, 4) { override fun migrate(db: SupportSQLiteDatabase) { - // Add HIIT countdown fields to exercises table db.execSQL( """ ALTER TABLE exercises @@ -53,13 +52,6 @@ abstract class AppDatabase : RoomDatabase() { ADD COLUMN autoAdvanceSets INTEGER NOT NULL DEFAULT 0 """.trimIndent() ) - // Add cautions field to dataset (ExerciseDC) table - db.execSQL( - """ - ALTER TABLE dataset - ADD COLUMN cautions TEXT NOT NULL DEFAULT '' - """.trimIndent() - ) } } diff --git a/app/src/main/java/org/librefit/db/entity/ExerciseDC.kt b/app/src/main/java/org/librefit/db/entity/ExerciseDC.kt index b544e9407..bdc60031a 100644 --- a/app/src/main/java/org/librefit/db/entity/ExerciseDC.kt +++ b/app/src/main/java/org/librefit/db/entity/ExerciseDC.kt @@ -199,7 +199,5 @@ data class ExerciseDC( val instructions: List = listOf(), val category: Category = Category.POWERLIFTING, val images: List = listOf(), - val isCustomExercise: Boolean = false, - /** Common mistakes and cautions for the exercise (user-facing text). */ - val cautions: String = "" + val isCustomExercise: Boolean = false ) \ No newline at end of file diff --git a/app/src/main/java/org/librefit/ui/components/HiitCountdownCard.kt b/app/src/main/java/org/librefit/ui/components/HiitCountdownCard.kt index 802b93e31..2d9fa7423 100644 --- a/app/src/main/java/org/librefit/ui/components/HiitCountdownCard.kt +++ b/app/src/main/java/org/librefit/ui/components/HiitCountdownCard.kt @@ -22,11 +22,6 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.material.icons.Icons -import androidx.compose.material.icons.filled.Info -import androidx.compose.material.icons.filled.Pause -import androidx.compose.material.icons.filled.PlayArrow -import androidx.compose.material.icons.filled.SkipNext import androidx.compose.material3.ElevatedCard import androidx.compose.material3.FilledTonalButton import androidx.compose.material3.Icon @@ -39,20 +34,41 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Size -import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import org.librefit.ui.screens.workout.WorkoutScreenViewModel.HiitPhase +import org.librefit.R +import org.librefit.enums.userPreferences.ThemeMode +import org.librefit.ui.screens.workout.HiitPhase +import org.librefit.ui.theme.LibreFitTheme +import org.librefit.util.Formatter /** - * A card that displays a large circular countdown timer for HIIT-style exercises. + * Card showing a circular countdown timer for an HIIT-style exercise. * - * Shows the current set number, exercise name, a big countdown arc, and play/skip controls. - * The arc is orange during a set countdown, blue during rest, and green when done. + * The card has four visual states driven by [phase]: + * - [HiitPhase.Idle]: the user can press play to start. + * - [HiitPhase.SetCountdown]: counts down the set duration (primary color). + * - [HiitPhase.RestBetweenSets]: counts down the rest period (tertiary color). + * - [HiitPhase.ExerciseDone]: all sets complete (secondary color). + * + * The composable is purely visual — all transitions are owned by the caller. This makes it + * trivial to preview and to unit-test the state machine separately. + * + * @param exerciseName Name displayed in the header. + * @param currentSetIndex 0-based index of the set the countdown applies to. + * @param totalSets Total number of sets for the exercise. + * @param countdownSeconds Remaining seconds in the active set countdown. + * @param countdownTotal Total seconds for the active set countdown (used for progress). + * @param restSeconds Remaining seconds in the rest period. + * @param restTotal Total seconds for the rest period (used for progress). + * @param phase Current [HiitPhase]. */ @Composable fun HiitCountdownCard( @@ -63,53 +79,47 @@ fun HiitCountdownCard( countdownTotal: Int, restSeconds: Int, restTotal: Int, - hiitPhase: HiitPhase, + phase: HiitPhase, onPlayPressed: () -> Unit, onCancelPressed: () -> Unit, onSkipRest: () -> Unit, onInfoPressed: () -> Unit, modifier: Modifier = Modifier ) { - val isSetPhase = hiitPhase is HiitPhase.SetCountdown - val isRestPhase = hiitPhase is HiitPhase.RestBetweenSets - val isIdle = hiitPhase is HiitPhase.Idle - val isDone = hiitPhase is HiitPhase.ExerciseDone - - // Arc progress - val displaySeconds = when { - isSetPhase -> countdownSeconds - isRestPhase -> restSeconds + val displaySeconds = when (phase) { + is HiitPhase.SetCountdown -> countdownSeconds + is HiitPhase.RestBetweenSets -> restSeconds else -> 0 } - val displayTotal = when { - isSetPhase -> countdownTotal - isRestPhase -> restTotal + val displayTotal = when (phase) { + is HiitPhase.SetCountdown -> countdownTotal.coerceAtLeast(1) + is HiitPhase.RestBetweenSets -> restTotal.coerceAtLeast(1) else -> 1 } val progress by animateFloatAsState( - targetValue = if (displayTotal > 0) displaySeconds.toFloat() / displayTotal else 0f, + targetValue = displaySeconds.toFloat() / displayTotal, animationSpec = tween(durationMillis = 300), - label = "countdown_progress" + label = "hiit_countdown_progress" ) - // Colors val arcColor by animateColorAsState( - targetValue = when { - isRestPhase -> Color(0xFF42A5F5) // Blue for rest - isDone -> Color(0xFF66BB6A) // Green for done - countdownSeconds <= 3 && isSetPhase -> Color(0xFFEF5350) // Red for last 3 seconds - else -> Color(0xFFFF7043) // Orange for set + targetValue = when (phase) { + is HiitPhase.RestBetweenSets -> MaterialTheme.colorScheme.tertiary + is HiitPhase.ExerciseDone -> MaterialTheme.colorScheme.secondary + else -> MaterialTheme.colorScheme.primary }, animationSpec = tween(300), - label = "arc_color" + label = "hiit_arc_color" ) - val phaseLabel = when { - isSetPhase -> "SET" - isRestPhase -> "REST" - isDone -> "DONE" - else -> "READY" - } + val phaseLabel = stringResource( + when (phase) { + is HiitPhase.SetCountdown -> R.string.hiit_phase_set + is HiitPhase.RestBetweenSets -> R.string.hiit_phase_rest + is HiitPhase.ExerciseDone -> R.string.hiit_phase_done + HiitPhase.Idle -> R.string.hiit_phase_ready + } + ) ElevatedCard( modifier = modifier @@ -122,7 +132,6 @@ fun HiitCountdownCard( .padding(20.dp), horizontalAlignment = Alignment.CenterHorizontally ) { - // Header row: exercise name + set counter + info Row( modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween, @@ -135,19 +144,23 @@ fun HiitCountdownCard( fontWeight = FontWeight.Bold ) Text( - text = "Set ${currentSetIndex + 1} / $totalSets", + text = stringResource( + R.string.hiit_set_progress, currentSetIndex + 1, totalSets + ), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant ) } IconButton(onClick = onInfoPressed) { - Icon(Icons.Default.Info, contentDescription = "Exercise details") + Icon( + painter = painterResource(R.drawable.ic_info), + contentDescription = stringResource(R.string.exercise_details) + ) } } Spacer(modifier = Modifier.height(16.dp)) - // Phase label Text( text = phaseLabel, style = MaterialTheme.typography.labelLarge, @@ -157,7 +170,7 @@ fun HiitCountdownCard( Spacer(modifier = Modifier.height(8.dp)) - // Countdown arc + val trackColor = MaterialTheme.colorScheme.surfaceVariant Box( contentAlignment = Alignment.Center, modifier = Modifier.size(200.dp) @@ -167,9 +180,8 @@ fun HiitCountdownCard( val diameter = size.minDimension - strokeWidth val topLeft = Offset(strokeWidth / 2, strokeWidth / 2) - // Background track drawArc( - color = Color.Gray.copy(alpha = 0.2f), + color = trackColor, startAngle = -90f, sweepAngle = 360f, useCenter = false, @@ -177,8 +189,6 @@ fun HiitCountdownCard( size = Size(diameter, diameter), style = Stroke(width = strokeWidth, cap = StrokeCap.Round) ) - - // Progress arc drawArc( color = arcColor, startAngle = -90f, @@ -190,54 +200,68 @@ fun HiitCountdownCard( ) } - // Time text - val minutes = displaySeconds / 60 - val seconds = displaySeconds % 60 + // Show MM:SS during countdowns; show "Done" label when complete. Text( - text = if (isDone) "Done!" else "%d:%02d".format(minutes, seconds), + text = if (phase is HiitPhase.ExerciseDone) { + stringResource(R.string.hiit_phase_done) + } else { + Formatter.formatTime(displaySeconds).substring(3) + }, fontSize = 48.sp, fontWeight = FontWeight.Bold, - color = if (isDone) Color(0xFF66BB6A) else MaterialTheme.colorScheme.onSurface, + color = if (phase is HiitPhase.ExerciseDone) { + MaterialTheme.colorScheme.secondary + } else { + MaterialTheme.colorScheme.onSurface + }, textAlign = TextAlign.Center ) } Spacer(modifier = Modifier.height(16.dp)) - // Controls Row( horizontalArrangement = Arrangement.Center, verticalAlignment = Alignment.CenterVertically, modifier = Modifier.fillMaxWidth() ) { - when { - isIdle -> { + when (phase) { + HiitPhase.Idle -> { FilledTonalButton(onClick = onPlayPressed) { - Icon(Icons.Default.PlayArrow, contentDescription = null) + Icon( + painter = painterResource(R.drawable.ic_play_arrow), + contentDescription = null + ) Spacer(Modifier.width(8.dp)) - Text("Start Set") + Text(stringResource(R.string.hiit_start_set)) } } - isSetPhase -> { + is HiitPhase.SetCountdown -> { FilledTonalButton(onClick = onCancelPressed) { - Icon(Icons.Default.Pause, contentDescription = null) + Icon( + painter = painterResource(R.drawable.ic_pause), + contentDescription = null + ) Spacer(Modifier.width(8.dp)) - Text("Cancel") + Text(stringResource(R.string.cancel_dialog)) } } - isRestPhase -> { + is HiitPhase.RestBetweenSets -> { FilledTonalButton(onClick = onSkipRest) { - Icon(Icons.Default.SkipNext, contentDescription = null) + Icon( + painter = painterResource(R.drawable.ic_arrow_forward), + contentDescription = null + ) Spacer(Modifier.width(8.dp)) - Text("Skip Rest") + Text(stringResource(R.string.hiit_skip_rest)) } } - isDone -> { + is HiitPhase.ExerciseDone -> { Text( - text = "All sets complete! Move to next exercise.", + text = stringResource(R.string.hiit_all_sets_complete), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, textAlign = TextAlign.Center @@ -248,3 +272,87 @@ fun HiitCountdownCard( } } } + +@Preview +@Composable +private fun HiitCountdownCardSetCountdownPreview() { + LibreFitTheme(dynamicColor = false, themeMode = ThemeMode.DARK) { + HiitCountdownCard( + exerciseName = "Mountain Climbers", + currentSetIndex = 1, + totalSets = 4, + countdownSeconds = 18, + countdownTotal = 30, + restSeconds = 0, + restTotal = 15, + phase = HiitPhase.SetCountdown(exerciseId = 1L, setIndex = 1), + onPlayPressed = {}, + onCancelPressed = {}, + onSkipRest = {}, + onInfoPressed = {} + ) + } +} + +@Preview +@Composable +private fun HiitCountdownCardRestPreview() { + LibreFitTheme(dynamicColor = false, themeMode = ThemeMode.DARK) { + HiitCountdownCard( + exerciseName = "Burpees", + currentSetIndex = 2, + totalSets = 4, + countdownSeconds = 0, + countdownTotal = 30, + restSeconds = 8, + restTotal = 15, + phase = HiitPhase.RestBetweenSets(exerciseId = 1L, nextSetIndex = 2), + onPlayPressed = {}, + onCancelPressed = {}, + onSkipRest = {}, + onInfoPressed = {} + ) + } +} + +@Preview +@Composable +private fun HiitCountdownCardIdlePreview() { + LibreFitTheme(dynamicColor = false, themeMode = ThemeMode.DARK) { + HiitCountdownCard( + exerciseName = "Jumping Jacks", + currentSetIndex = 0, + totalSets = 3, + countdownSeconds = 0, + countdownTotal = 30, + restSeconds = 0, + restTotal = 15, + phase = HiitPhase.Idle, + onPlayPressed = {}, + onCancelPressed = {}, + onSkipRest = {}, + onInfoPressed = {} + ) + } +} + +@Preview +@Composable +private fun HiitCountdownCardDonePreview() { + LibreFitTheme(dynamicColor = false, themeMode = ThemeMode.DARK) { + HiitCountdownCard( + exerciseName = "Plank", + currentSetIndex = 3, + totalSets = 4, + countdownSeconds = 0, + countdownTotal = 30, + restSeconds = 0, + restTotal = 15, + phase = HiitPhase.ExerciseDone, + onPlayPressed = {}, + onCancelPressed = {}, + onSkipRest = {}, + onInfoPressed = {} + ) + } +} diff --git a/app/src/main/java/org/librefit/ui/components/HiitSettingsCard.kt b/app/src/main/java/org/librefit/ui/components/HiitSettingsCard.kt new file mode 100644 index 000000000..d7bc87871 --- /dev/null +++ b/app/src/main/java/org/librefit/ui/components/HiitSettingsCard.kt @@ -0,0 +1,126 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2025-2026. The LibreFit Contributors + * + * LibreFit is subject to additional terms covering author attribution and trademark usage; + * see the ADDITIONAL_TERMS.md and TRADEMARK_POLICY.md files in the project root. + */ + +package org.librefit.ui.components + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedCard +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.KeyboardType +import androidx.compose.ui.tooling.preview.Preview +import androidx.compose.ui.unit.dp +import org.librefit.R +import org.librefit.enums.userPreferences.ThemeMode +import org.librefit.ui.theme.LibreFitTheme + +/** + * Toggle + duration input that lets the user enable HIIT auto-advance for a single + * DURATION exercise. Visually subordinate to the exercise card it sits next to. + * + * When [autoAdvanceSets] is on, the parent screen renders [HiitCountdownCard] for this + * exercise. When off, the exercise behaves like a normal duration exercise. + */ +@Composable +fun HiitSettingsCard( + autoAdvanceSets: Boolean, + targetDurationSeconds: Int, + onAutoAdvanceChange: (Boolean) -> Unit, + onTargetDurationChange: (Int) -> Unit, + modifier: Modifier = Modifier +) { + OutlinedCard( + modifier = modifier + .fillMaxWidth() + .padding(horizontal = 16.dp, vertical = 4.dp), + shape = MaterialTheme.shapes.large + ) { + Column(modifier = Modifier.padding(16.dp)) { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween + ) { + Column(modifier = Modifier.weight(1f)) { + Text( + text = stringResource(R.string.hiit_mode), + style = MaterialTheme.typography.titleSmall, + fontWeight = FontWeight.SemiBold + ) + Text( + text = stringResource(R.string.hiit_mode_desc), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant + ) + } + Switch( + checked = autoAdvanceSets, + onCheckedChange = onAutoAdvanceChange + ) + } + AnimatedVisibility(visible = autoAdvanceSets) { + Column { + Spacer(Modifier.height(8.dp)) + OutlinedTextField( + value = if (targetDurationSeconds == 0) "" else targetDurationSeconds.toString(), + onValueChange = { input -> + // Only accept digits; treat empty as 0. Cap at 9999 to avoid silly values. + val sanitized = input.filter { it.isDigit() }.take(4) + onTargetDurationChange(sanitized.toIntOrNull() ?: 0) + }, + label = { Text(stringResource(R.string.hiit_target_duration)) }, + singleLine = true, + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Number), + modifier = Modifier.fillMaxWidth() + ) + } + } + } + } +} + +@Preview +@Composable +private fun HiitSettingsCardOnPreview() { + LibreFitTheme(dynamicColor = false, themeMode = ThemeMode.DARK) { + HiitSettingsCard( + autoAdvanceSets = true, + targetDurationSeconds = 30, + onAutoAdvanceChange = {}, + onTargetDurationChange = {} + ) + } +} + +@Preview +@Composable +private fun HiitSettingsCardOffPreview() { + LibreFitTheme(dynamicColor = false, themeMode = ThemeMode.DARK) { + HiitSettingsCard( + autoAdvanceSets = false, + targetDurationSeconds = 0, + onAutoAdvanceChange = {}, + onTargetDurationChange = {} + ) + } +} diff --git a/app/src/main/java/org/librefit/ui/models/UiExerciseDC.kt b/app/src/main/java/org/librefit/ui/models/UiExerciseDC.kt index 198330a53..f52cb12c3 100644 --- a/app/src/main/java/org/librefit/ui/models/UiExerciseDC.kt +++ b/app/src/main/java/org/librefit/ui/models/UiExerciseDC.kt @@ -38,6 +38,5 @@ data class UiExerciseDC( val instructions: ImmutableList = persistentListOf(), val category: Category = Category.POWERLIFTING, val images: ImmutableList = persistentListOf(), - val isCustomExercise: Boolean = false, - val cautions: String = "" + val isCustomExercise: Boolean = false ) \ No newline at end of file diff --git a/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseDCMapper.kt b/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseDCMapper.kt index e63fa7f1e..149376391 100644 --- a/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseDCMapper.kt +++ b/app/src/main/java/org/librefit/ui/models/mappers/UiExerciseDCMapper.kt @@ -25,8 +25,7 @@ fun ExerciseDC.toUi(): UiExerciseDC { instructions = this.instructions.toImmutableList(), category = this.category, images = this.images.toImmutableList(), - isCustomExercise = this.isCustomExercise, - cautions = this.cautions + isCustomExercise = this.isCustomExercise ) } @@ -43,7 +42,6 @@ fun UiExerciseDC.toEntity(): ExerciseDC { instructions = this.instructions, category = this.category, images = this.images, - isCustomExercise = this.isCustomExercise, - cautions = this.cautions + isCustomExercise = this.isCustomExercise ) } \ No newline at end of file diff --git a/app/src/main/java/org/librefit/ui/screens/infoExercise/InfoExerciseScreen.kt b/app/src/main/java/org/librefit/ui/screens/infoExercise/InfoExerciseScreen.kt index 58bb7908d..c26649e7a 100644 --- a/app/src/main/java/org/librefit/ui/screens/infoExercise/InfoExerciseScreen.kt +++ b/app/src/main/java/org/librefit/ui/screens/infoExercise/InfoExerciseScreen.kt @@ -37,7 +37,6 @@ import androidx.compose.foundation.pager.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.CornerSize import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.CardDefaults import androidx.compose.material3.Checkbox import androidx.compose.material3.ElevatedCard import androidx.compose.material3.ExperimentalMaterial3Api @@ -303,8 +302,7 @@ private fun SharedTransitionScope.InfoExerciseScreenContent( InfoExercisePages.INSTRUCTIONS -> InstructionsPage( maxHeight, - exerciseDC.instructions, - exerciseDC.cautions + exerciseDC.instructions ) null -> error("Invalid page index: $pageIndex. Expected: ${0..InfoExercisePages.entries.size}") @@ -476,8 +474,7 @@ private fun DetailsPage( @Composable private fun InstructionsPage( maxHeight: Dp, - instructions: List, - cautions: String = "" + instructions: List ) { LazyColumn( modifier = Modifier.height(maxHeight), @@ -488,7 +485,6 @@ private fun InstructionsPage( Text( text = buildString { instructions.forEachIndexed { index, instruction -> - // For all items except the first, add the separator BEFORE the item. if (index > 0) { append("\n\n") } @@ -497,44 +493,6 @@ private fun InstructionsPage( } ) } - if (cautions.isNotBlank()) { - item { - Spacer(Modifier.height(8.dp)) - HorizontalDivider() - Spacer(Modifier.height(8.dp)) - ElevatedCard( - shape = MaterialTheme.shapes.large, - colors = CardDefaults.elevatedCardColors( - containerColor = MaterialTheme.colorScheme.errorContainer - ) - ) { - Column(modifier = Modifier.padding(16.dp)) { - Row( - verticalAlignment = Alignment.CenterVertically, - horizontalArrangement = Arrangement.spacedBy(8.dp) - ) { - Icon( - painter = painterResource(R.drawable.ic_warning), - contentDescription = null, - tint = MaterialTheme.colorScheme.onErrorContainer - ) - Text( - text = "Common Mistakes", - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Bold, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - Spacer(Modifier.height(8.dp)) - Text( - text = cautions, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.onErrorContainer - ) - } - } - } - } } } diff --git a/app/src/main/java/org/librefit/ui/screens/workout/HiitPhase.kt b/app/src/main/java/org/librefit/ui/screens/workout/HiitPhase.kt new file mode 100644 index 000000000..da9c18624 --- /dev/null +++ b/app/src/main/java/org/librefit/ui/screens/workout/HiitPhase.kt @@ -0,0 +1,57 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2025-2026. The LibreFit Contributors + * + * LibreFit is subject to additional terms covering author attribution and trademark usage; + * see the ADDITIONAL_TERMS.md and TRADEMARK_POLICY.md files in the project root. + */ + +package org.librefit.ui.screens.workout + +import androidx.compose.runtime.Immutable + +/** + * State of the HIIT countdown for a single exercise. + * + * The phase is owned by [WorkoutScreenViewModel] and consumed by + * [org.librefit.ui.components.HiitCountdownCard]. The state machine is: + * + * ``` + * Idle ──onPlay──▶ SetCountdown ──finish──▶ RestBetweenSets ──finish──▶ SetCountdown ──▶ … ──▶ ExerciseDone + * │ │ + * └──cancel──▶ Idle ◀────────┘ (skip rest re-enters SetCountdown for the next set) + * ``` + */ +@Immutable +sealed class HiitPhase { + /** No HIIT countdown running. The user can press play to begin. */ + data object Idle : HiitPhase() + + /** Countdown is active for the given exercise's [setIndex]. */ + data class SetCountdown(val exerciseId: Long, val setIndex: Int) : HiitPhase() + + /** Rest countdown that precedes set [nextSetIndex]. */ + data class RestBetweenSets(val exerciseId: Long, val nextSetIndex: Int) : HiitPhase() + + /** All sets for the current exercise are complete. */ + data object ExerciseDone : HiitPhase() +} + +/** + * Pure state-transition helper used when a set countdown finishes. Returns the next phase + * given the current set index, the total number of sets, and whether auto-advance is on. + * + * Extracted to make the transition logic trivially unit-testable without spinning up + * a ViewModel. + */ +fun HiitPhase.SetCountdown.nextPhaseAfterCountdown( + totalSets: Int, + autoAdvance: Boolean +): HiitPhase { + val nextIndex = setIndex + 1 + return if (autoAdvance && nextIndex < totalSets) { + HiitPhase.RestBetweenSets(exerciseId = exerciseId, nextSetIndex = nextIndex) + } else { + HiitPhase.ExerciseDone + } +} diff --git a/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreen.kt b/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreen.kt index 6c40b2bc8..36357c731 100644 --- a/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreen.kt +++ b/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreen.kt @@ -84,6 +84,7 @@ import org.librefit.enums.userPreferences.ThemeMode import org.librefit.nav.Route import org.librefit.ui.components.ExerciseCard import org.librefit.ui.components.HiitCountdownCard +import org.librefit.ui.components.HiitSettingsCard import org.librefit.ui.components.LibreFitLazyColumn import org.librefit.ui.components.LibreFitScaffold import org.librefit.ui.components.animations.DumbbellLottie @@ -258,7 +259,8 @@ fun SharedTransitionScope.WorkoutScreen( applyPreviousSetPerformance = viewModel::applyPreviousSetPerformance, onStartHiitCountdown = viewModel::startHiitCountdown, onCancelHiitCountdown = viewModel::cancelHiitCountdown, - onResetHiitPhase = viewModel::resetHiitPhase + updateExerciseTargetDuration = viewModel::updateExerciseTargetDuration, + updateExerciseAutoAdvanceSets = viewModel::updateExerciseAutoAdvanceSets ) } } @@ -296,7 +298,7 @@ private fun SharedTransitionScope.WorkoutScreenContent( isHeaderSticky: Boolean, useScrollWheelForInput: Boolean, dismissScrollWheelInputAutomatically: Boolean, - hiitPhase: WorkoutScreenViewModel.HiitPhase, + hiitPhase: HiitPhase, countdownSeconds: Int, countdownTotal: Int, restSeconds: Int, @@ -318,7 +320,8 @@ private fun SharedTransitionScope.WorkoutScreenContent( applyPreviousSetPerformance: (Long) -> Unit, onStartHiitCountdown: (Long, Int) -> Unit, onCancelHiitCountdown: () -> Unit, - onResetHiitPhase: () -> Unit + updateExerciseTargetDuration: (Int, Long) -> Unit, + updateExerciseAutoAdvanceSets: (Boolean, Long) -> Unit ) { val lazyListState = rememberLazyListState() val hapticFeedback = LocalHapticFeedback.current @@ -419,50 +422,54 @@ private fun SharedTransitionScope.WorkoutScreenContent( items = exercisesWithSets, key = { _, exercise -> exercise.exercise.id } ) { i, exerciseWithSets -> - // Show HIIT countdown card for DURATION exercises with auto-advance val exercise = exerciseWithSets.exercise - val isHiitExercise = exercise.setMode == SetMode.DURATION - && exercise.targetDuration > 0 - && exercise.autoAdvanceSets - val isActiveHiit = isHiitExercise && when (hiitPhase) { - is WorkoutScreenViewModel.HiitPhase.SetCountdown -> - (hiitPhase as WorkoutScreenViewModel.HiitPhase.SetCountdown).exerciseId == exercise.id - is WorkoutScreenViewModel.HiitPhase.RestBetweenSets -> - (hiitPhase as WorkoutScreenViewModel.HiitPhase.RestBetweenSets).exerciseId == exercise.id - is WorkoutScreenViewModel.HiitPhase.Idle -> true - is WorkoutScreenViewModel.HiitPhase.ExerciseDone -> true - } + if (exercise.setMode == SetMode.DURATION) { + HiitSettingsCard( + autoAdvanceSets = exercise.autoAdvanceSets, + targetDurationSeconds = exercise.targetDuration, + onAutoAdvanceChange = { updateExerciseAutoAdvanceSets(it, exercise.id) }, + onTargetDurationChange = { updateExerciseTargetDuration(it, exercise.id) } + ) - if (isHiitExercise && isActiveHiit) { - val currentSetIndex = when (hiitPhase) { - is WorkoutScreenViewModel.HiitPhase.SetCountdown -> - (hiitPhase as WorkoutScreenViewModel.HiitPhase.SetCountdown).setIndex - is WorkoutScreenViewModel.HiitPhase.RestBetweenSets -> - (hiitPhase as WorkoutScreenViewModel.HiitPhase.RestBetweenSets).nextSetIndex - 1 - else -> 0 - } - HiitCountdownCard( - exerciseName = exerciseWithSets.exerciseDC.name, - currentSetIndex = currentSetIndex, - totalSets = exerciseWithSets.sets.size, - countdownSeconds = countdownSeconds, - countdownTotal = countdownTotal, - restSeconds = restSeconds, - restTotal = exercise.restTime, - hiitPhase = hiitPhase, - onPlayPressed = { onStartHiitCountdown(exercise.id, currentSetIndex) }, - onCancelPressed = onCancelHiitCountdown, - onSkipRest = { - // Skip rest by starting next set immediately - val phase = hiitPhase - if (phase is WorkoutScreenViewModel.HiitPhase.RestBetweenSets) { - onStartHiitCountdown(phase.exerciseId, phase.nextSetIndex) + val isHiitForThisExercise = exercise.autoAdvanceSets + && exercise.targetDuration > 0 + && when (val p = hiitPhase) { + is HiitPhase.SetCountdown -> p.exerciseId == exercise.id + is HiitPhase.RestBetweenSets -> p.exerciseId == exercise.id + HiitPhase.Idle, HiitPhase.ExerciseDone -> true } - }, - onInfoPressed = { - onSelectedExerciseIdChange(exercise.id, exerciseWithSets.exerciseDC.id) - } - ) + + if (isHiitForThisExercise) { + val currentSetIndex = when (val p = hiitPhase) { + is HiitPhase.SetCountdown -> p.setIndex + is HiitPhase.RestBetweenSets -> p.nextSetIndex - 1 + HiitPhase.Idle, HiitPhase.ExerciseDone -> 0 + }.coerceAtLeast(0) + HiitCountdownCard( + exerciseName = exerciseWithSets.exerciseDC.name, + currentSetIndex = currentSetIndex, + totalSets = exerciseWithSets.sets.size, + countdownSeconds = countdownSeconds, + countdownTotal = countdownTotal, + restSeconds = restSeconds, + restTotal = exercise.restTime, + phase = hiitPhase, + onPlayPressed = { onStartHiitCountdown(exercise.id, currentSetIndex) }, + onCancelPressed = onCancelHiitCountdown, + onSkipRest = { + val p = hiitPhase + if (p is HiitPhase.RestBetweenSets) { + onStartHiitCountdown(p.exerciseId, p.nextSetIndex) + } + }, + onInfoPressed = { + onSelectedExerciseIdChange( + exercise.id, + exerciseWithSets.exerciseDC.id + ) + } + ) + } } ReorderableItem(reorderableLazyListState, key = exerciseWithSets.exercise.id) { isDragging -> @@ -688,13 +695,14 @@ private fun WorkoutScreenPreview() { WorkoutScreenContent( animatedVisibilityScope = this@AnimatedVisibility, exercisesWithSets = e, - hiitPhase = WorkoutScreenViewModel.HiitPhase.Idle, + hiitPhase = HiitPhase.Idle, countdownSeconds = 0, countdownTotal = 0, restSeconds = 0, onStartHiitCountdown = { _, _ -> }, onCancelHiitCountdown = {}, - onResetHiitPhase = {}, + updateExerciseTargetDuration = { _, _ -> }, + updateExerciseAutoAdvanceSets = { _, _ -> }, previousPerformances = listOf( listOf( PreviousPerformanceSet(time = 612) diff --git a/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreenViewModel.kt b/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreenViewModel.kt index 7ef2723fc..ba4b36ff6 100644 --- a/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreenViewModel.kt +++ b/app/src/main/java/org/librefit/ui/screens/workout/WorkoutScreenViewModel.kt @@ -406,6 +406,29 @@ class WorkoutScreenViewModel @Inject constructor( syncToRepository() } + fun updateExerciseTargetDuration(targetDuration: Int, id: Long) { + _exercises.update { currentExercises -> + currentExercises.map { eWs -> + if (eWs.exercise.id == id) { + eWs.copy(exercise = eWs.exercise.copy(targetDuration = targetDuration)) + } else eWs + } + } + syncToRepository() + } + + fun updateExerciseAutoAdvanceSets(autoAdvance: Boolean, id: Long) { + _exercises.update { currentExercises -> + currentExercises.map { eWs -> + if (eWs.exercise.id == id) { + eWs.copy(exercise = eWs.exercise.copy(autoAdvanceSets = autoAdvance)) + } else eWs + } + } + if (!autoAdvance) cancelHiitCountdown() + syncToRepository() + } + fun updateExerciseSetMode(setMode: SetMode, id: Long) { _exercises.update { currentExercises -> currentExercises.map { eWs -> @@ -460,21 +483,6 @@ class WorkoutScreenViewModel @Inject constructor( val isCountdownActive = WorkoutService.isCountdownActive val countdownTotal = WorkoutService.countdownTotal - /** - * HIIT phase tracks the autonomous set→rest→set cycle for DURATION exercises - * that have [UiExercise.autoAdvanceSets] enabled. - */ - sealed class HiitPhase { - /** No HIIT countdown running – manual mode or waiting for user to press play. */ - data object Idle : HiitPhase() - /** Countdown is active for the given exercise/set. */ - data class SetCountdown(val exerciseId: Long, val setIndex: Int) : HiitPhase() - /** Rest countdown between HIIT sets. */ - data class RestBetweenSets(val exerciseId: Long, val nextSetIndex: Int) : HiitPhase() - /** All sets for the current HIIT exercise are complete. */ - data object ExerciseDone : HiitPhase() - } - private val _hiitPhase = MutableStateFlow(HiitPhase.Idle) val hiitPhase = _hiitPhase.asStateFlow() @@ -539,39 +547,34 @@ class WorkoutScreenViewModel @Inject constructor( if (!finished) return@collect val phase = _hiitPhase.value if (phase is HiitPhase.SetCountdown) { - // Mark the current set as completed val eWs = exercises.value.find { it.exercise.id == phase.exerciseId } if (eWs != null) { - val set = eWs.sets.getOrNull(phase.setIndex) - if (set != null) { - // Update elapsed time and completion + eWs.sets.getOrNull(phase.setIndex)?.let { set -> + // Mark the set complete and persist its elapsed time. We skip + // [updateSetCompleted] because the service already auto-starts the + // rest timer after the countdown finishes. updateSetTime(eWs.exercise.targetDuration, set.id) _exercises.update { currentExercises -> - currentExercises.map { exercise -> - if (exercise.sets.any { it.id == set.id }) { - exercise.copy( - sets = exercise.sets.map { - if (it.id == set.id) it.copy(completed = true) else it + currentExercises.map { e -> + if (e.sets.any { it.id == set.id }) { + e.copy( + sets = e.sets.map { s -> + if (s.id == set.id) s.copy(completed = true) else s }.toImmutableList() ) - } else exercise + } else e } } syncToRepository() } - - val nextSetIndex = phase.setIndex + 1 - if (nextSetIndex < eWs.sets.size && eWs.exercise.autoAdvanceSets) { - // Rest timer was already auto-started by the service - _hiitPhase.update { - HiitPhase.RestBetweenSets(phase.exerciseId, nextSetIndex) - } - } else { - _hiitPhase.update { HiitPhase.ExerciseDone } + _hiitPhase.update { + phase.nextPhaseAfterCountdown( + totalSets = eWs.sets.size, + autoAdvance = eWs.exercise.autoAdvanceSets + ) } } } - // Acknowledge signal WorkoutService.clearCountdownFinished() } } @@ -598,12 +601,6 @@ class WorkoutScreenViewModel @Inject constructor( } } - /** Reset HIIT phase back to idle (e.g. when user moves to next exercise). */ - fun resetHiitPhase() { - workoutServiceManager.cancelCountdown() - _hiitPhase.update { HiitPhase.Idle } - } - fun toggleStopwatch() { if (isStopwatchPaused.value) { workoutServiceManager.startStopwatch() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4ad23d7fa..eaee532cf 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -490,5 +490,17 @@ Do not show again Undo Clear + HIIT mode + Auto-advance: countdown → rest → next set + Target duration (seconds) + READY + SET + REST + DONE + Start set + Skip rest + All sets complete! Move to next exercise. + Set %1$d / %2$d + Exercise details diff --git a/app/src/test/java/org/librefit/ui/screens/workout/HiitPhaseTest.kt b/app/src/test/java/org/librefit/ui/screens/workout/HiitPhaseTest.kt new file mode 100644 index 000000000..e001d420b --- /dev/null +++ b/app/src/test/java/org/librefit/ui/screens/workout/HiitPhaseTest.kt @@ -0,0 +1,56 @@ +/* + * SPDX-License-Identifier: GPL-3.0-or-later + * Copyright (c) 2025-2026. The LibreFit Contributors + * + * LibreFit is subject to additional terms covering author attribution and trademark usage; + * see the ADDITIONAL_TERMS.md and TRADEMARK_POLICY.md files in the project root. + */ + +package org.librefit.ui.screens.workout + +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +class HiitPhaseTest { + + private val exerciseId = 42L + + @Test + fun `nextPhaseAfterCountdown transitions to rest when there are more sets and auto-advance is on`() { + val current = HiitPhase.SetCountdown(exerciseId = exerciseId, setIndex = 0) + + val next = current.nextPhaseAfterCountdown(totalSets = 3, autoAdvance = true) + + assertThat(next).isEqualTo( + HiitPhase.RestBetweenSets(exerciseId = exerciseId, nextSetIndex = 1) + ) + } + + @Test + fun `nextPhaseAfterCountdown finishes exercise when the last set has just completed`() { + val current = HiitPhase.SetCountdown(exerciseId = exerciseId, setIndex = 2) + + val next = current.nextPhaseAfterCountdown(totalSets = 3, autoAdvance = true) + + assertThat(next).isEqualTo(HiitPhase.ExerciseDone) + } + + @Test + fun `nextPhaseAfterCountdown finishes exercise when auto-advance is off`() { + val current = HiitPhase.SetCountdown(exerciseId = exerciseId, setIndex = 0) + + val next = current.nextPhaseAfterCountdown(totalSets = 3, autoAdvance = false) + + assertThat(next).isEqualTo(HiitPhase.ExerciseDone) + } + + @Test + fun `nextPhaseAfterCountdown preserves the exercise id across transitions`() { + val current = HiitPhase.SetCountdown(exerciseId = 9999L, setIndex = 0) + + val next = current.nextPhaseAfterCountdown(totalSets = 2, autoAdvance = true) + + assertThat(next).isInstanceOf(HiitPhase.RestBetweenSets::class.java) + assertThat((next as HiitPhase.RestBetweenSets).exerciseId).isEqualTo(9999L) + } +}