diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.kt index 8098195..21eade4 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingScreen.kt @@ -232,22 +232,9 @@ private fun FastHeadingContent( uiState: IFastingViewModel.FastingUiState, modifier: Modifier = Modifier ) { - val scope = rememberCoroutineScope() - val tooltipState = rememberTooltipState() val spacing = fastingSpacing() val typography = fastingTypography() - @StringRes - var phaseTooltipResId by remember { mutableStateOf(null) } - - LaunchedEffect(tooltipState.isVisible) { - if (tooltipState.isVisible) { - delay(4.seconds) - tooltipState.dismiss() - phaseTooltipResId = null - } - } - Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally @@ -262,29 +249,9 @@ private fun FastHeadingContent( modifier = Modifier.padding(bottom = spacing.small) ) - TooltipBox( - positionProvider = TooltipDefaults - .rememberTooltipPositionProvider(TooltipAnchorPosition.Below), - tooltip = { - phaseTooltipResId?.let { stringRes -> - PlainTooltip { Text(stringResource(stringRes)) } - } - }, - state = tooltipState, - ) { - TimeLine( - elapsedHours = uiState.elapsedHours, - modifier = Modifier - .fillMaxWidth() - .padding(vertical = spacing.medium), - onPhaseClick = { phase -> - phaseTooltipResId = phase.title - scope.launch { - tooltipState.show() - } - } - ) - } + TimeLineWithTooltip( + elapsedHours = uiState.elapsedHours + ) // Energy Mode Text( @@ -297,24 +264,97 @@ private fun FastHeadingContent( Spacer(modifier = Modifier.size(height = spacing.large, width = 1.dp)) - // Timer + TimerDisplay( + timerText = uiState.timerText, + milliseconds = uiState.milliseconds, + daysAndHoursText = uiState.daysAndHoursText + ) + } +} + +@Composable +private fun TimeLineWithTooltip( + elapsedHours: Double +) { + val scope = rememberCoroutineScope() + val tooltipState = rememberTooltipState() + val spacing = fastingSpacing() + + @StringRes + var phaseTooltipResId by remember { mutableStateOf(null) } + + LaunchedEffect(tooltipState.isVisible) { + if (tooltipState.isVisible) { + delay(4.seconds) + tooltipState.dismiss() + phaseTooltipResId = null + } + } + + TooltipBox( + positionProvider = TooltipDefaults + .rememberTooltipPositionProvider(TooltipAnchorPosition.Below), + tooltip = { + phaseTooltipResId?.let { stringRes -> + PlainTooltip { Text(stringResource(stringRes)) } + } + }, + state = tooltipState, + ) { + TimeLine( + elapsedHours = elapsedHours, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = spacing.medium), + onPhaseClick = { phase -> + phaseTooltipResId = phase.title + scope.launch { + tooltipState.show() + } + } + ) + } +} + +@Composable +private fun TimerDisplay( + timerText: String, + milliseconds: String, + daysAndHoursText: String? +) { + val spacing = fastingSpacing() + val typography = fastingTypography() + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(bottom = spacing.large) + ) { Row( - verticalAlignment = Alignment.Bottom, - modifier = Modifier.padding(bottom = spacing.large) + verticalAlignment = Alignment.Bottom ) { Text( - text = uiState.timerText, + text = timerText, style = typography.timerText(), color = MaterialTheme.colorScheme.onBackground, fontWeight = FontWeight.Bold, ) Text( - text = uiState.milliseconds, + text = milliseconds, style = typography.timerMilliseconds(), color = MaterialTheme.colorScheme.onBackground, modifier = Modifier.padding(start = spacing.small, bottom = spacing.small) ) } + + // Days and hours text (shown when >= 24 hours) + daysAndHoursText?.let { daysText -> + Text( + text = daysText, + style = typography.energyMode(), + color = MaterialTheme.colorScheme.onBackground.copy(alpha = 0.7f), + modifier = Modifier.padding(top = spacing.small) + ) + } } } @@ -327,9 +367,6 @@ private fun FastDetailsContent( onShowStartFastSelector: () -> Unit, modifier: Modifier = Modifier ) { - val spacing = fastingSpacing() - val typography = fastingTypography() - Column( modifier = modifier, horizontalAlignment = Alignment.CenterHorizontally, @@ -337,102 +374,154 @@ private fun FastDetailsContent( Spacer(modifier = Modifier.weight(1f)) // Phase Information - Column( + PhaseInformationSection( + uiState = uiState, + onShowInfoDialog = onShowInfoDialog + ) + + StageDescriptionAndActions( + uiState = uiState, + onShowEndFastConfirmation = onShowEndFastConfirmation, + onShowStartFastSelector = onShowStartFastSelector, + viewModel = viewModel, modifier = Modifier .fillMaxWidth() - .padding(bottom = spacing.medium) - ) { - // Fat Burn Phase - StageInfo( - onShowInfoDialog = onShowInfoDialog, - titleRes = R.string.info_dialog_fat_burn_title, - contentRes = R.string.info_dialog_fat_burn_content, - labelRes = R.string.fast_fat_burn_label, - timeText = uiState.fatBurnTime, - stageState = uiState.fatBurnStageState - ) + .weight(2f) + ) + } +} - // Ketosis Phase - StageInfo( - onShowInfoDialog = onShowInfoDialog, - titleRes = R.string.info_dialog_ketosis_title, - contentRes = R.string.info_dialog_ketosis_content, - labelRes = R.string.fast_ketosis_label, - timeText = uiState.ketosisTime, - stageState = uiState.ketosisStageState - ) +@Composable +private fun StageDescriptionAndActions( + uiState: IFastingViewModel.FastingUiState, + onShowEndFastConfirmation: () -> Unit, + onShowStartFastSelector: () -> Unit, + viewModel: IFastingViewModel, + modifier: Modifier = Modifier +) { + val spacing = fastingSpacing() + val typography = fastingTypography() - // Autophagy Phase - StageInfo( - onShowInfoDialog = onShowInfoDialog, - titleRes = R.string.info_dialog_autophagy_title, - contentRes = R.string.info_dialog_autophagy_content, - labelRes = R.string.fast_autophagy_label, - timeText = uiState.autophagyTime, - stageState = uiState.autophagyStageState + Row(modifier = modifier) { + // Stage Description + Box( + modifier = Modifier + .fillMaxWidth() + .weight(1f) + .verticalScroll(rememberScrollState()) + ) { + Text( + text = uiState.stageDescription, + style = typography.stageDescription(), + color = MaterialTheme.colorScheme.onBackground, + modifier = Modifier.padding(top = spacing.medium, end = spacing.medium) ) } - Row(modifier = Modifier + // Bottom Controls Row + FastActionButtons( + isFasting = uiState.isFasting, + onShowEndFastConfirmation = onShowEndFastConfirmation, + onShowStartFastSelector = onShowStartFastSelector, + viewModel = viewModel, + modifier = Modifier + .align(Alignment.Bottom) + .wrapContentHeight() + .padding(top = spacing.medium) + ) + } +} + +@Composable +private fun PhaseInformationSection( + uiState: IFastingViewModel.FastingUiState, + onShowInfoDialog: (Int, Int) -> Unit +) { + val spacing = fastingSpacing() + + Column( + modifier = Modifier .fillMaxWidth() - .weight(2f)) { - // Stage Description - Box( - modifier = Modifier - .fillMaxWidth() - .weight(1f) - .verticalScroll(rememberScrollState()) + .padding(bottom = spacing.medium) + ) { + // Fat Burn Phase + StageInfo( + onShowInfoDialog = onShowInfoDialog, + titleRes = R.string.info_dialog_fat_burn_title, + contentRes = R.string.info_dialog_fat_burn_content, + labelRes = R.string.fast_fat_burn_label, + timeText = uiState.fatBurnTime, + stageState = uiState.fatBurnStageState + ) + + // Ketosis Phase + StageInfo( + onShowInfoDialog = onShowInfoDialog, + titleRes = R.string.info_dialog_ketosis_title, + contentRes = R.string.info_dialog_ketosis_content, + labelRes = R.string.fast_ketosis_label, + timeText = uiState.ketosisTime, + stageState = uiState.ketosisStageState + ) + + // Autophagy Phase + StageInfo( + onShowInfoDialog = onShowInfoDialog, + titleRes = R.string.info_dialog_autophagy_title, + contentRes = R.string.info_dialog_autophagy_content, + labelRes = R.string.fast_autophagy_label, + timeText = uiState.autophagyTime, + stageState = uiState.autophagyStageState + ) + } +} + +@Composable +private fun FastActionButtons( + isFasting: Boolean, + onShowEndFastConfirmation: () -> Unit, + onShowStartFastSelector: () -> Unit, + viewModel: IFastingViewModel, + modifier: Modifier = Modifier +) { + val spacing = fastingSpacing() + + Row( + modifier = modifier, + horizontalArrangement = Arrangement.End, + verticalAlignment = Alignment.CenterVertically + ) { + // Debug Button (only in debug builds) + if (BuildConfig.DEBUG) { + FloatingActionButton( + onClick = { viewModel.debugIncreaseFastingTimeByOneHour() }, + modifier = Modifier.padding(end = spacing.medium) ) { - Text( - text = uiState.stageDescription, - style = typography.stageDescription(), - color = MaterialTheme.colorScheme.onBackground, - modifier = Modifier.padding(top = spacing.medium, end = spacing.medium) + Icon( + imageVector = Icons.Default.Add, + contentDescription = stringResource(id = R.string.debug_add_hour_button) ) } + } - // Bottom Controls Row - Row( - modifier = Modifier - .align(Alignment.Bottom) - .wrapContentHeight() - .padding(top = spacing.medium), - horizontalArrangement = Arrangement.End, - verticalAlignment = Alignment.CenterVertically + // Start/Stop Button + if (isFasting) { + FloatingActionButton( + onClick = onShowEndFastConfirmation, ) { - // Debug Button (only in debug builds) - if (BuildConfig.DEBUG) { - FloatingActionButton( - onClick = { viewModel.debugIncreaseFastingTimeByOneHour() }, - modifier = Modifier.padding(end = spacing.medium) - ) { - Icon( - imageVector = Icons.Default.Add, - contentDescription = stringResource(id = R.string.debug_add_hour_button) - ) - } - } - - // Start/Stop Button - if (uiState.isFasting) { - FloatingActionButton( - onClick = onShowEndFastConfirmation, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_fast_stop), - contentDescription = stringResource(id = R.string.stop_fast_button_description) - ) - } - } else { - FloatingActionButton( - onClick = onShowStartFastSelector, - ) { - Icon( - painter = painterResource(id = R.drawable.ic_start_fast), - contentDescription = stringResource(id = R.string.start_fast_button_description) - ) - } - } + Icon( + painter = painterResource(id = R.drawable.ic_fast_stop), + contentDescription = stringResource(id = R.string.stop_fast_button_description) + ) + } + } else { + FloatingActionButton( + onClick = onShowStartFastSelector, + ) { + Icon( + painter = painterResource(id = R.drawable.ic_start_fast), + contentDescription = stringResource(id = R.string.start_fast_button_description) + ) } } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingViewModel.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingViewModel.kt index be928ca..596ce10 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingViewModel.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/FastingViewModel.kt @@ -137,10 +137,24 @@ class FastingViewModel( val timerText = "$hours:$minutesStr:$secondsStr" val millisecondsText = "%02d".format(nanoseconds / 10000000) + // Calculate days and hours text for durations >= 24 hours + val daysAndHoursText = if (hours >= 24) { + val days = hours / 24 + val remainingHours = hours % 24 + if (remainingHours == 0L) { + if (days == 1L) "1 day" else "$days days" + } else { + val dayText = if (days == 1L) "1 day" else "$days days" + val hourText = if (remainingHours == 1L) "1 hour" else "$remainingHours hours" + "$dayText, $hourText" + } + } else null + _uiState.update { it.copy( timerText = timerText, - milliseconds = millisecondsText + milliseconds = millisecondsText, + daysAndHoursText = daysAndHoursText ) } } diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/IFastingViewModel.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/IFastingViewModel.kt index a47691b..37547e8 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/IFastingViewModel.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/IFastingViewModel.kt @@ -25,6 +25,7 @@ interface IFastingViewModel { val elapsedHours: Double = 0.0, val milliseconds: String = "00", val timerText: String = "00:00:00", + val daysAndHoursText: String? = null, val showGradientBackground: Boolean = true, ) diff --git a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/TimeLine.kt b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/TimeLine.kt index 56e0a39..f74a93f 100644 --- a/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/TimeLine.kt +++ b/app/src/main/java/com/darkrockstudios/apps/fasttrack/screens/fasting/TimeLine.kt @@ -1,15 +1,25 @@ package com.darkrockstudios.apps.fasttrack.screens.fasting +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.rememberInfiniteTransition +import androidx.compose.animation.core.tween import androidx.compose.foundation.Canvas import androidx.compose.foundation.gestures.detectTapGestures import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.height import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Path import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.unit.dp import com.darkrockstudios.apps.fasttrack.data.Phase @@ -36,30 +46,49 @@ fun TimeLine( onPhaseClick: (Phase) -> Unit = {} ) { val padding = 16.dp - val spacing = 18.dp + val spacing = 4.dp val barSize = 16.dp val needleSize = 3.dp val needleRadius = 4.dp + val slantOffset = 8.dp val outlineColor = MaterialTheme.colorScheme.onBackground + + val curPhase = Stages.getCurrentPhase(elapsedHours.hours) + + // Continuous blink animation for current phase + val infiniteTransition = rememberInfiniteTransition(label = "phase_blink") + val blinkProgress by infiniteTransition.animateFloat( + initialValue = 0f, + targetValue = 1f, + animationSpec = infiniteRepeatable( + animation = tween(durationMillis = 500), + repeatMode = RepeatMode.Reverse + ), + label = "blink_progress" + ) Canvas( modifier = modifier .fillMaxWidth() .height(padding + barSize) - .pointerInput(Stages.phases) { + .pointerInput(curPhase) { detectTapGestures { offset -> val paddingPx = padding.toPx() val spacingPx = spacing.toPx() val barSizePx = barSize.toPx() - val availableWidth = size.width - paddingPx - val phaseWidth = (availableWidth / Stages.phases.size) - spacingPx + val slantOffsetPx = slantOffset.toPx() + + val phaseWidth = (size.width - (2 * paddingPx) - (Stages.phases.size - 1) * spacingPx) / Stages.phases.size + val totalWidth = (Stages.phases.size * phaseWidth) + ((Stages.phases.size - 1) * spacingPx) + slantOffsetPx + val startOffset = (size.width - totalWidth) / 2f + val startY = paddingPx val yOk = abs(offset.y - startY) <= (barSizePx / 2f) if (yOk) { Stages.phases.forEachIndexed { index, phase -> - val startX = (index * phaseWidth) + (index * spacingPx) + paddingPx - val endX = startX + phaseWidth + val startX = startOffset + (index * phaseWidth) + (index * spacingPx) + val endX = startX + phaseWidth + slantOffsetPx if (offset.x in startX..endX) { onPhaseClick(phase) return@detectTapGestures @@ -72,54 +101,54 @@ fun TimeLine( val lastPhase = Stages.phases.last() val lastPhaseHoursWeighted = lastPhase.hours * 1.5f - val availableWidth = size.width - padding.toPx() - val phaseWidth = (availableWidth / Stages.phases.size) - spacing.toPx() + val slantOffsetPx = slantOffset.toPx() + val barSizePx = barSize.toPx() + + val phaseWidth = (size.width - (2 * padding.toPx()) - (Stages.phases.size - 1) * spacing.toPx()) / Stages.phases.size + val totalWidth = (Stages.phases.size * phaseWidth) + ((Stages.phases.size - 1) * spacing.toPx()) + slantOffsetPx + + val startOffset = (size.width - totalWidth) / 2f - val curPhase = Stages.getCurrentPhase(elapsedHours.hours) - - // Draw the bubbles (phases) Stages.phases.forEachIndexed { index, phase -> - val startX = (index * phaseWidth) + (index * spacing.toPx()) + padding.toPx() + val startX = startOffset + (index * phaseWidth) + (index * spacing.toPx()) val startY = padding.toPx() - // Current phase, thicket orange outline - if (curPhase == phase) { - // Outline - drawLine( - color = Color(0xFFE67E22), - start = Offset(startX, startY), - end = Offset(startX + phaseWidth, startY), - strokeWidth = barSize.toPx(), - cap = StrokeCap.Round - ) + val rhombusPath = Path().apply { + moveTo(startX + slantOffsetPx, startY - barSizePx / 2) + lineTo(startX + phaseWidth + slantOffsetPx, startY - barSizePx / 2) + lineTo(startX + phaseWidth, startY + barSizePx / 2) + lineTo(startX, startY + barSizePx / 2) + close() + } - // Current phase - filled - drawLine( + if (curPhase == phase) { + drawPath( + path = rhombusPath, color = gaugeColors[index], - start = Offset(startX, startY), - end = Offset(startX + phaseWidth, startY), - strokeWidth = barSize.toPx() * 0.7f, - cap = StrokeCap.Round + style = Fill ) - } else { - // Other phases - thinner "onBackground" outline - - // Thinner outline - drawLine( - color = outlineColor, - start = Offset(startX, startY), - end = Offset(startX + phaseWidth, startY), - strokeWidth = barSize.toPx(), - cap = StrokeCap.Round + + // Continuously animate between orange and yellow + val baseOutlineColor = Color(0xFFE67E22) // Orange + val blinkColor = Color.Yellow + val currentOutlineColor = lerp(baseOutlineColor, blinkColor, blinkProgress) + + drawPath( + path = rhombusPath, + color = currentOutlineColor, + style = Stroke(width = 3.dp.toPx()) ) - - // Phase color - drawLine( + } else { + drawPath( + path = rhombusPath, color = gaugeColors[index], - start = Offset(startX, startY), - end = Offset(startX + phaseWidth, startY), - strokeWidth = barSize.toPx() * 0.8f, // Slightly thinner - cap = StrokeCap.Round + style = Fill + ) + + drawPath( + path = rhombusPath, + color = outlineColor, + style = Stroke(width = 2.dp.toPx()) ) } } @@ -140,10 +169,9 @@ fun TimeLine( val halfPadding = padding.toPx() / 2f - val startX = (curPhaseIndex * phaseWidth) + (curPhaseIndex * spacing.toPx()) + padding.toPx() + val startX = startOffset + (curPhaseIndex * phaseWidth) + (curPhaseIndex * spacing.toPx()) val x = (startX + (phaseWidth * percent)).toFloat() - // Draw needle line drawLine( color = Color.DarkGray, start = Offset(x, halfPadding), @@ -152,7 +180,6 @@ fun TimeLine( cap = StrokeCap.Square ) - // Draw needle circle drawCircle( color = Color.DarkGray, radius = needleRadius.toPx(), @@ -160,4 +187,4 @@ fun TimeLine( ) } } -} +} \ No newline at end of file