diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 0e9c093..cf8e0bc 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -31,6 +31,8 @@ kotlin { implementation(projects.libs.coroutines) api(projects.libs.navigation) implementation(projects.libs.design) + implementation(projects.libs.dailyLog) + implementation(libs.kotlinx.datetime) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.compose.runtime) diff --git a/feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureAndroidModule.kt b/feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureAndroidModule.kt new file mode 100644 index 0000000..22b9e74 --- /dev/null +++ b/feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureAndroidModule.kt @@ -0,0 +1,8 @@ +package dev.yaseyo.home + +import dev.yaseyo.navigation.FeatureNavigation +import org.koin.dsl.module + +val homeFeatureAndroidModule = module { + factory { HomeFeatureNavigation() } +} diff --git a/feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureNavigation.kt b/feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureNavigation.kt new file mode 100644 index 0000000..92f26f8 --- /dev/null +++ b/feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureNavigation.kt @@ -0,0 +1,15 @@ +package dev.yaseyo.home + +import androidx.navigation3.runtime.EntryProviderScope +import dev.yaseyo.home.ui.HomeScreen +import dev.yaseyo.navigation.AppRoute +import dev.yaseyo.navigation.FeatureNavigation +import dev.yaseyo.navigation.Home + +internal class HomeFeatureNavigation : FeatureNavigation { + override val navEntryProvider: EntryProviderScope.() -> Unit = { + entry { + HomeScreen() + } + } +} diff --git a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/di/HomeModule.kt b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/di/HomeModule.kt new file mode 100644 index 0000000..7e6f451 --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/di/HomeModule.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.home.di + +import dev.yaseyo.home.ui.HomeViewModel +import org.koin.core.module.dsl.viewModelOf +import org.koin.dsl.module + +val homeModule = module { + viewModelOf(::HomeViewModel) +} diff --git a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeEventHandler.kt b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeEventHandler.kt new file mode 100644 index 0000000..8f8f1a4 --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeEventHandler.kt @@ -0,0 +1,3 @@ +package dev.yaseyo.home.ui + +internal interface HomeEventHandler diff --git a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt new file mode 100644 index 0000000..bfea775 --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt @@ -0,0 +1,275 @@ +package dev.yaseyo.home.ui + +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.HorizontalDivider +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.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import dev.yaseyo.dailylog.api.MealEntry +import dev.yaseyo.dailylog.api.MealType +import dev.yaseyo.design.YaseyoPreview +import dev.yaseyo.design.YaseyoTheme +import dev.yaseyo.home.ui.widget.KcalProgressHero +import dev.yaseyo.home.ui.widget.MacroProgressRowData +import dev.yaseyo.home.ui.widget.MacroProgressRows +import dev.yaseyo.home.ui.widget.MealEntryCard +import dev.yaseyo.home.util.currentDayOfWeek +import dev.yaseyo.home.util.now +import org.koin.compose.viewmodel.koinViewModel +import kotlin.time.Instant + +@Composable +internal fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + HomeScreen(state = state) +} + +@Composable +private fun HomeScreen(state: HomeUiState) { + Column( + modifier = Modifier.fillMaxSize(), + ) { + Text( + modifier = Modifier.padding(16.dp), + text = "${currentDayOfWeek()} – ${now().date}", + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = YaseyoTheme.colors.contentPrimary, + ) + + HorizontalDivider() + + AnimatedContent( + modifier = Modifier.weight(1f), + targetState = state, + contentKey = { + it::class.simpleName + }, + ) { state -> + when (state) { + is HomeUiState.Content -> { + LazyColumn( + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + item { + Text( + text = "Today's Progress", + color = YaseyoTheme.colors.contentPrimary, + fontSize = 24.sp, + ) + } + item { + KcalProgressHero( + modifier = Modifier.padding(16.dp), + progress = { + state.progress + }, + currentKcal = state.currentKcal, + baseTargetKcal = state.baseTargetKcal, + activityKcal = state.activityKcal, + ) + } + item { + Text( + text = "Macro Progress", + color = YaseyoTheme.colors.contentPrimary, + fontSize = 24.sp, + ) + } + item { + MacroProgressRows( + rows = listOf( + MacroProgressRowData( + label = "Protein", + progress = { state.macroStatus.proteinProgress }, + currentGrams = state.macroStatus.currentProtein, + targetGrams = state.macroStatus.targetProtein, + ), + MacroProgressRowData( + label = "Carbs", + progress = { state.macroStatus.carbsProgress }, + currentGrams = state.macroStatus.currentCarbs, + targetGrams = state.macroStatus.targetCarbs, + ), + MacroProgressRowData( + label = "Fat", + progress = { state.macroStatus.fatProgress }, + currentGrams = state.macroStatus.currentFat, + targetGrams = state.macroStatus.targetFat, + ), + ), + ) + } + + item { + Spacer(modifier = Modifier.height(16.dp)) + } + + item { + Text( + text = "Meal Entries", + color = YaseyoTheme.colors.contentPrimary, + fontSize = 24.sp, + ) + } + + items( + items = state.mealEntries, + key = { it.id }, + ) { mealEntry -> + MealEntryCard(mealEntry = mealEntry) + } + } + } + + HomeUiState.Error -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Something went wrong. Please try again.", + color = YaseyoTheme.colors.contentSecondary, + fontSize = 16.sp, + ) + } + } + + HomeUiState.Initializing -> { + Box( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + contentAlignment = Alignment.Center, + ) { + Text( + text = "Loading...", + color = YaseyoTheme.colors.contentSecondary, + fontSize = 16.sp, + ) + } + } + } + } + } +} + +@PreviewLightDark +@Composable +private fun HomeScreenContentPreview() = + YaseyoPreview { + HomeScreen( + state = HomeUiState.Content( + currentKcal = 1200, + activityKcal = 300, + baseTargetKcal = 2200, + progress = (1200.0 / 2500.0).toFloat(), + macroStatus = HomeUiState.Content.MacroStatus( + currentProtein = 90.0, + targetProtein = 180.0, + proteinProgress = (90.0 / 180.0).toFloat(), + currentCarbs = 110.0, + targetCarbs = 220.0, + carbsProgress = (110.0 / 220.0).toFloat(), + currentFat = 40.0, + targetFat = 70.0, + fatProgress = (40.0 / 70.0).toFloat(), + ), + mealEntries = listOf( + previewMealEntry( + id = "meal_1", + mealType = MealType.Breakfast, + notes = "Oatmeal with banana and honey", + kcal = 420.0, + protein = 18.0, + carbs = 65.0, + fat = 9.0, + loggedAt = "2026-07-24T08:30:00Z", + ), + previewMealEntry( + id = "meal_2", + mealType = MealType.Lunch, + notes = null, + kcal = 680.0, + protein = 42.0, + carbs = 70.0, + fat = 22.0, + loggedAt = "2026-07-24T12:15:00Z", + ), + previewMealEntry( + id = "meal_3", + mealType = MealType.Snack, + notes = null, + kcal = 180.0, + protein = 4.0, + carbs = 22.0, + fat = 8.0, + loggedAt = "2026-07-24T15:45:00Z", + ), + ), + ), + ) + } + +private fun previewMealEntry( + id: String, + mealType: MealType, + notes: String?, + kcal: Double, + protein: Double, + carbs: Double, + fat: Double, + loggedAt: String, +) = MealEntry( + id = id, + dailyLogId = "log_1", + mealType = mealType, + quantity = 1.0, + notes = notes, + foodItemId = "food_1", + recipeId = null, + kcal = kcal, + protein = protein, + carbs = carbs, + fat = fat, + fiber = 4.0, + loggedAt = Instant.parse(loggedAt), + createdAt = Instant.parse(loggedAt), + updatedAt = Instant.parse(loggedAt), +) + +@PreviewLightDark +@Composable +private fun HomeScreenErrorPreview() = + YaseyoPreview { + HomeScreen(state = HomeUiState.Error) + } + +@PreviewLightDark +@Composable +private fun HomeScreenLoadingPreview() = + YaseyoPreview { + HomeScreen(state = HomeUiState.Initializing) + } diff --git a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeUiState.kt b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeUiState.kt new file mode 100644 index 0000000..e2ab1f1 --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeUiState.kt @@ -0,0 +1,30 @@ +package dev.yaseyo.home.ui + +import dev.yaseyo.dailylog.api.MealEntry + +internal sealed interface HomeUiState { + data object Initializing : HomeUiState + + data class Content( + val progress: Float, + val currentKcal: Long, + val activityKcal: Long, + val baseTargetKcal: Long, + val macroStatus: MacroStatus, + val mealEntries: List, + ) : HomeUiState { + data class MacroStatus( + val currentProtein: Double, + val targetProtein: Double, + val proteinProgress: Float, + val currentCarbs: Double, + val targetCarbs: Double, + val carbsProgress: Float, + val currentFat: Double, + val targetFat: Double, + val fatProgress: Float, + ) + } + + data object Error : HomeUiState +} diff --git a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt new file mode 100644 index 0000000..45eaa1e --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt @@ -0,0 +1,48 @@ +package dev.yaseyo.home.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import dev.yaseyo.dailylog.api.DailyLogRepository +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn + +internal class HomeViewModel( + dailyLogRepository: DailyLogRepository, +) : ViewModel(), + HomeEventHandler { + val uiState: StateFlow = dailyLogRepository + .getLogForToday() + .map { dailyLogResult -> + dailyLogResult?.fold( + onSuccess = { + HomeUiState.Content( + currentKcal = it.totals.kcal.toLong(), + activityKcal = (it.targets.effectiveKcal - it.targets.kcal).toLong(), + baseTargetKcal = it.targets.kcal.toLong(), + progress = (it.totals.kcal / it.targets.effectiveKcal).toFloat(), + macroStatus = HomeUiState.Content.MacroStatus( + currentProtein = it.totals.protein, + targetProtein = it.targets.protein, + proteinProgress = (it.totals.protein / it.targets.protein).toFloat(), + currentCarbs = it.totals.carbs, + targetCarbs = it.targets.carbs, + carbsProgress = (it.totals.carbs / it.targets.carbs).toFloat(), + currentFat = it.totals.fat, + targetFat = it.targets.fat, + fatProgress = (it.totals.fat / it.targets.fat).toFloat(), + ), + mealEntries = it.mealEntries, + ) + }, + onFailure = { + HomeUiState.Error + }, + ) ?: HomeUiState.Initializing + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = HomeUiState.Initializing, + ) +} diff --git a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/KcalProgressHero.kt b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/KcalProgressHero.kt new file mode 100644 index 0000000..4561de3 --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/KcalProgressHero.kt @@ -0,0 +1,115 @@ +package dev.yaseyo.home.ui.widget + +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.yaseyo.design.YaseyoPreview +import dev.yaseyo.design.YaseyoTheme + +@Composable +internal fun KcalProgressHero( + progress: () -> Float, + currentKcal: Long, + activityKcal: Long, + baseTargetKcal: Long, + modifier: Modifier = Modifier, +) { + val effectiveKcal = baseTargetKcal + activityKcal + + Box(modifier = modifier, contentAlignment = Alignment.Center) { + CircularProgressIndicator( + modifier = Modifier.aspectRatio(1f), + progress = progress, + color = YaseyoTheme.colors.accentDefault, + strokeWidth = 32.dp, + trackColor = YaseyoTheme.colors.accentMuted, + strokeCap = StrokeCap.Butt, + ) + + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = "$currentKcal/$effectiveKcal", + color = YaseyoTheme.colors.contentPrimary, + fontSize = 32.sp, + ) + if (activityKcal != 0L) { + Text( + text = "$baseTargetKcal base + $activityKcal from activity", + color = YaseyoTheme.colors.contentSecondary, + fontSize = 13.sp, + ) + } + } + } +} + +@PreviewLightDark +@Composable +private fun KcalProgressHeroEmptyPreview() = + YaseyoPreview { + KcalProgressHero( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + progress = { 0f }, + currentKcal = 0, + activityKcal = 0, + baseTargetKcal = 2000, + ) + } + +@PreviewLightDark +@Composable +private fun KcalProgressHeroPartialPreview() = + YaseyoPreview { + KcalProgressHero( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + progress = { 0.45f }, + currentKcal = 900, + activityKcal = 200, + baseTargetKcal = 2000, + ) + } + +@PreviewLightDark +@Composable +private fun KcalProgressHeroCompletePreview() = + YaseyoPreview { + KcalProgressHero( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + progress = { 1f }, + currentKcal = 2000, + activityKcal = 0, + baseTargetKcal = 2000, + ) + } + +@PreviewLightDark +@Composable +private fun KcalProgressHeroOverPreview() = + YaseyoPreview { + KcalProgressHero( + modifier = Modifier + .fillMaxSize() + .padding(16.dp), + progress = { 1.2f }, + currentKcal = 2200, + activityKcal = 0, + baseTargetKcal = 2000, + ) + } diff --git a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/MacroProgressRows.kt b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/MacroProgressRows.kt new file mode 100644 index 0000000..ee156ab --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/MacroProgressRows.kt @@ -0,0 +1,130 @@ +package dev.yaseyo.home.ui.widget + +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.layout.Layout +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.Constraints +import androidx.compose.ui.unit.dp +import dev.yaseyo.design.YaseyoPreview +import dev.yaseyo.design.YaseyoTheme + +internal data class MacroProgressRowData( + val label: String, + val progress: () -> Float, + val currentGrams: Double, + val targetGrams: Double, +) + +@Composable +internal fun MacroProgressRows( + rows: List, + modifier: Modifier = Modifier, +) { + val density = LocalDensity.current + val horizontalSpacingPx = with(density) { 16.dp.roundToPx() } + val verticalSpacingPx = with(density) { 12.dp.roundToPx() } + val indicatorHeightPx = with(density) { 16.dp.roundToPx() } + + Layout( + modifier = modifier.fillMaxWidth(), + content = { + rows.forEach { row -> + Text( + text = row.label, + color = YaseyoTheme.colors.contentPrimary, + maxLines = 1, + ) + LinearProgressIndicator( + progress = row.progress, + strokeCap = StrokeCap.Butt, + color = YaseyoTheme.colors.accentDefault, + trackColor = YaseyoTheme.colors.accentMuted, + gapSize = 0.dp, + ) + Text( + text = "${row.currentGrams.toInt()} / ${row.targetGrams.toInt()}g", + color = YaseyoTheme.colors.contentPrimary, + maxLines = 1, + ) + } + }, + ) { measurables, constraints -> + val labelMeasurables = rows.indices.map { measurables[it * 3] } + val indicatorMeasurables = rows.indices.map { measurables[it * 3 + 1] } + val fractionMeasurables = rows.indices.map { measurables[it * 3 + 2] } + + val labelColumnWidth = labelMeasurables.maxOf { it.maxIntrinsicWidth(Int.MAX_VALUE) } + val fractionColumnWidth = fractionMeasurables.maxOf { it.maxIntrinsicWidth(Int.MAX_VALUE) } + val indicatorWidth = (constraints.maxWidth - labelColumnWidth - fractionColumnWidth - horizontalSpacingPx * 2) + .coerceAtLeast(0) + + val labelPlaceables = labelMeasurables.map { it.measure(Constraints.fixedWidth(labelColumnWidth)) } + val indicatorPlaceables = indicatorMeasurables.map { + it.measure(Constraints.fixed(indicatorWidth, indicatorHeightPx)) + } + val fractionPlaceables = fractionMeasurables.map { it.measure(Constraints.fixedWidth(fractionColumnWidth)) } + + val rowHeights = rows.indices.map { i -> + maxOf(labelPlaceables[i].height, indicatorPlaceables[i].height, fractionPlaceables[i].height) + } + val totalHeight = rowHeights.sum() + verticalSpacingPx * (rows.size - 1).coerceAtLeast(0) + + layout(width = constraints.maxWidth, height = totalHeight) { + var y = 0 + rows.indices.forEach { i -> + val rowHeight = rowHeights[i] + val label = labelPlaceables[i] + val indicator = indicatorPlaceables[i] + val fraction = fractionPlaceables[i] + + label.place(x = 0, y = y + (rowHeight - label.height) / 2) + indicator.place( + x = labelColumnWidth + horizontalSpacingPx, + y = y + (rowHeight - indicator.height) / 2, + ) + fraction.place( + x = labelColumnWidth + horizontalSpacingPx + indicatorWidth + horizontalSpacingPx, + y = y + (rowHeight - fraction.height) / 2, + ) + + y += rowHeight + verticalSpacingPx + } + } + } +} + +@PreviewLightDark +@Composable +private fun MacroProgressRowsPreview() = + YaseyoPreview { + MacroProgressRows( + modifier = Modifier.padding(16.dp), + rows = listOf( + MacroProgressRowData( + label = "Protein", + progress = { 0.5f }, + currentGrams = 90.0, + targetGrams = 180.0, + ), + MacroProgressRowData( + label = "Carbs", + progress = { 0.5f }, + currentGrams = 1100.0, + targetGrams = 2200.0, + ), + MacroProgressRowData( + label = "Fat", + progress = { 0.57f }, + currentGrams = 40.0, + targetGrams = 70.0, + ), + ), + ) + } diff --git a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/MealEntryCard.kt b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/MealEntryCard.kt new file mode 100644 index 0000000..f09957e --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/MealEntryCard.kt @@ -0,0 +1,192 @@ +package dev.yaseyo.home.ui.widget + +import androidx.compose.foundation.background +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.shape.RoundedCornerShape +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.tooling.preview.PreviewLightDark +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import dev.yaseyo.dailylog.api.MealEntry +import dev.yaseyo.dailylog.api.MealType +import dev.yaseyo.design.YaseyoPreview +import dev.yaseyo.design.YaseyoTheme +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Instant + +@Composable +internal fun MealEntryCard( + mealEntry: MealEntry, + modifier: Modifier = Modifier, +) { + val colors = YaseyoTheme.colors + + Column( + modifier = modifier + .fillMaxWidth() + .clip(RoundedCornerShape(16.dp)) + .background(colors.backgroundSubtle) + .padding(horizontal = 16.dp, vertical = 14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + modifier = Modifier + .clip(RoundedCornerShape(8.dp)) + .background(colors.accentSubtle) + .padding(horizontal = 8.dp, vertical = 4.dp), + text = mealEntry.mealType.name, + fontSize = 12.sp, + fontWeight = FontWeight.SemiBold, + color = colors.contentPrimary, + ) + Text( + text = mealEntry.loggedAt.formatTime(), + fontSize = 12.sp, + color = colors.contentTertiary, + ) + } + Text( + text = "${mealEntry.kcal.toInt()} kcal", + fontSize = 16.sp, + fontWeight = FontWeight.SemiBold, + color = colors.contentPrimary, + ) + } + + val notes = mealEntry.notes + if (notes != null) { + Text( + text = notes, + fontSize = 14.sp, + color = colors.contentSecondary, + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.spacedBy(16.dp), + ) { + MacroStat(label = "Protein", grams = mealEntry.protein) + MacroStat(label = "Carbs", grams = mealEntry.carbs) + MacroStat(label = "Fat", grams = mealEntry.fat) + } + } +} + +@Composable +private fun MacroStat( + label: String, + grams: Double, + modifier: Modifier = Modifier, +) { + val colors = YaseyoTheme.colors + Row( + modifier = modifier, + horizontalArrangement = Arrangement.spacedBy(4.dp), + ) { + Text(text = label, fontSize = 13.sp, color = colors.contentTertiary) + Text( + text = "${grams.toInt()}g", + fontSize = 13.sp, + fontWeight = FontWeight.Medium, + color = colors.contentSecondary, + ) + } +} + +private fun Instant.formatTime(): String { + val time = toLocalDateTime(TimeZone.currentSystemDefault()).time + val hour = time.hour.toString().padStart(2, '0') + val minute = time.minute.toString().padStart(2, '0') + return "$hour:$minute" +} + +private val previewMealEntry = MealEntry( + id = "meal_1", + dailyLogId = "log_1", + mealType = MealType.Breakfast, + quantity = 1.0, + notes = "Oatmeal with banana and honey", + foodItemId = "food_1", + recipeId = null, + kcal = 420.0, + protein = 18.0, + carbs = 65.0, + fat = 9.0, + fiber = 6.0, + loggedAt = Instant.parse("2026-07-24T08:30:00Z"), + createdAt = Instant.parse("2026-07-24T08:30:00Z"), + updatedAt = Instant.parse("2026-07-24T08:30:00Z"), +) + +@PreviewLightDark +@Composable +private fun MealEntryCardPreview() = + YaseyoPreview { + MealEntryCard( + modifier = Modifier.padding(16.dp), + mealEntry = previewMealEntry, + ) + } + +@PreviewLightDark +@Composable +private fun MealEntryCardNoNotesPreview() = + YaseyoPreview { + MealEntryCard( + modifier = Modifier.padding(16.dp), + mealEntry = previewMealEntry.copy( + mealType = MealType.Lunch, + notes = null, + kcal = 680.0, + protein = 42.0, + carbs = 70.0, + fat = 22.0, + ), + ) + } + +@PreviewLightDark +@Composable +private fun MealEntryCardListPreview() = + YaseyoPreview { + Column( + modifier = Modifier.padding(16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + MealEntryCard(mealEntry = previewMealEntry) + Spacer(modifier = Modifier.height(0.dp)) + MealEntryCard( + mealEntry = previewMealEntry.copy( + mealType = MealType.Snack, + notes = null, + kcal = 180.0, + protein = 4.0, + carbs = 22.0, + fat = 8.0, + loggedAt = Instant.parse("2026-07-24T15:45:00Z"), + ), + ) + } + } diff --git a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/util/DateTimeUtil.kt b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/util/DateTimeUtil.kt new file mode 100644 index 0000000..34b8d9a --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/util/DateTimeUtil.kt @@ -0,0 +1,18 @@ +package dev.yaseyo.home.util + +import kotlinx.datetime.LocalDateTime +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock + +internal fun now(): LocalDateTime = + Clock + .System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + +internal fun currentDayOfWeek(): String = + now() + .dayOfWeek.name + .lowercase() + .replaceFirstChar { it.uppercase() } diff --git a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt index 143c2ad..65a046e 100644 --- a/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt +++ b/feature/onboarding/src/commonMain/kotlin/dev/yaseyo/onboarding/ResolveStartDestinationUseCase.kt @@ -16,7 +16,11 @@ class ResolveStartDestinationUseCase( val appState = when (authRepository.authState.value) { is AuthState.SignedOut -> AppState.RequiresLogin - is AuthState.SignedIn -> { + is AuthState.SignedIn if userRepository.isUserSessionActive().isSuccess -> { + AppState.FullySetup + } + + else -> { val user = userRepository.getCurrentUser() if (user.isSuccess) { AppState.FullySetup diff --git a/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeUserRepository.kt b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeUserRepository.kt index e53e913..ffda3b9 100644 --- a/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeUserRepository.kt +++ b/feature/onboarding/src/commonTest/kotlin/dev/yaseyo/onboarding/fake/FakeUserRepository.kt @@ -9,7 +9,12 @@ import kotlin.time.Instant internal class FakeUserRepository( private val createUserResult: Result = Result.success(DEFAULT_USER), + private val isUserSessionActiveResult: Result = Result.success(Unit), ) : UserRepository { + override suspend fun isUserSessionActive(): Result = isUserSessionActiveResult + + override suspend fun clearUserSession(): Result = Result.success(Unit) + override suspend fun getCurrentUser(): Result = Result.success(DEFAULT_USER) override suspend fun createUser( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0c98074..1047e0d 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,39 +1,39 @@ [versions] agp = "9.1.1" datastore = "1.2.1" -firebase-gitlive = "2.4.0" -firebase-android-bom = "34.12.0" -googleServices = "4.4.4" -android-compileSdk = "36" +firebase-gitlive = "2.5.0" +firebase-android-bom = "34.16.0" +googleServices = "4.5.0" +android-compileSdk = "37" android-minSdk = "26" android-targetSdk = "36" androidx-activity = "1.13.0" androidx-appcompat = "1.7.1" -androidx-core = "1.18.0" +androidx-core = "1.19.0" androidx-espresso = "3.7.0" -androidx-lifecycle = "2.10.0" +androidx-lifecycle = "2.11.0" androidx-testExt = "1.3.0" -composeMultiplatform = "1.11.0" +composeMultiplatform = "1.11.1" junit = "4.13.2" -kotlin = "2.3.20" +kotlin = "2.4.0" material3 = "1.10.0-alpha05" -kotlinStdlib = "2.3.20" -koin = "4.2.1" +kotlinStdlib = "2.4.0" +koin = "4.2.2" androidx-startup = "1.2.0" -androidx-splashscreen = "1.0.1" +androidx-splashscreen = "1.2.0" runner = "1.7.0" core = "1.7.0" -skie = "0.10.11" +skie = "0.10.13" kotlinx-coroutines = "1.11.0" -spotless = "8.4.0" +spotless = "8.8.0" ktlint = "1.5.0" -ktor = "3.4.3" +ktor = "3.5.1" kotlinx-serialization = "1.11.0" kotlinx-datetime = "0.8.0" -nav3Core = "1.1.1" -lifecycleViewmodelNav3 = "2.11.0-beta01" -ui-tooling = "1.11.1" -composeNavigationEvent = "1.0.1" +nav3Core = "1.1.4" +lifecycleViewmodelNav3 = "2.11.0" +ui-tooling = "1.11.4" +composeNavigationEvent = "1.1.0" [libraries] androidx-datastore = { module = "androidx.datastore:datastore", version.ref = "datastore" } diff --git a/libs/daily-log/api/build.gradle.kts b/libs/daily-log/api/build.gradle.kts new file mode 100644 index 0000000..d4b3f6b --- /dev/null +++ b/libs/daily-log/api/build.gradle.kts @@ -0,0 +1,54 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + android { + namespace = "dev.yaseyo.dailylog.api" + + withHostTestBuilder { + sourceSetTreeName = "test" + } + + withDeviceTestBuilder { + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + api(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + + api(projects.libs.domainModels) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + androidMain { + dependencies { + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.runner) + implementation(libs.androidx.core) + implementation(libs.androidx.testExt.junit) + } + } + + iosMain { + dependencies { + } + } + } +} diff --git a/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/DailyLog.kt b/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/DailyLog.kt new file mode 100644 index 0000000..afbdfb7 --- /dev/null +++ b/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/DailyLog.kt @@ -0,0 +1,13 @@ +package dev.yaseyo.dailylog.api + +import dev.yaseyo.domain.models.MacroTarget +import dev.yaseyo.domain.models.Macros +import kotlinx.datetime.LocalDate + +data class DailyLog( + val date: LocalDate, + val totals: Macros, + val targets: MacroTarget, + val activityKcal: Double, + val mealEntries: List, +) diff --git a/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/DailyLogRepository.kt b/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/DailyLogRepository.kt new file mode 100644 index 0000000..1572e4f --- /dev/null +++ b/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/DailyLogRepository.kt @@ -0,0 +1,13 @@ +package dev.yaseyo.dailylog.api + +import kotlinx.coroutines.flow.Flow + +interface DailyLogRepository { + fun getLogForToday(): Flow?> + + suspend fun logFood( + foodItemId: String, + quantity: Double, + mealType: MealType, + ): Result +} diff --git a/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/MealEntry.kt b/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/MealEntry.kt new file mode 100644 index 0000000..488a99e --- /dev/null +++ b/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/MealEntry.kt @@ -0,0 +1,21 @@ +package dev.yaseyo.dailylog.api + +import kotlin.time.Instant + +data class MealEntry( + val id: String, + val dailyLogId: String, + val mealType: MealType, + val quantity: Double, + val notes: String?, + val foodItemId: String?, + val recipeId: String?, + val kcal: Double, + val protein: Double, + val carbs: Double, + val fat: Double, + val fiber: Double, + val loggedAt: Instant, + val createdAt: Instant, + val updatedAt: Instant, +) diff --git a/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/MealType.kt b/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/MealType.kt new file mode 100644 index 0000000..263caf1 --- /dev/null +++ b/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/MealType.kt @@ -0,0 +1,8 @@ +package dev.yaseyo.dailylog.api + +enum class MealType { + Breakfast, + Lunch, + Dinner, + Snack, +} diff --git a/libs/daily-log/build.gradle.kts b/libs/daily-log/build.gradle.kts new file mode 100644 index 0000000..2345f57 --- /dev/null +++ b/libs/daily-log/build.gradle.kts @@ -0,0 +1,20 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.dailyLog" + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + api(libs.kotlinx.coroutines.core) + api(projects.libs.dailyLog.api) + implementation(libs.koin.core) + } + } + } +} diff --git a/libs/daily-log/impl/build.gradle.kts b/libs/daily-log/impl/build.gradle.kts new file mode 100644 index 0000000..15b45c6 --- /dev/null +++ b/libs/daily-log/impl/build.gradle.kts @@ -0,0 +1,59 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + android { + namespace = "dev.yaseyo.dailylog.impl" + + withHostTestBuilder { + sourceSetTreeName = "test" + } + + withDeviceTestBuilder { + }.configure { + instrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" + } + } + + sourceSets { + commonMain { + dependencies { + implementation(libs.kotlin.stdlib) + api(libs.kotlinx.coroutines.core) + implementation(libs.kotlinx.datetime) + implementation(libs.koin.core) + implementation(libs.ktor.client.core) + implementation(libs.kotlinx.serialization.json) + + implementation(projects.libs.dailyLog.api) + implementation(projects.libs.coroutines) + api(projects.libs.domainModels) + } + } + + commonTest { + dependencies { + implementation(libs.kotlin.test) + } + } + + androidMain { + dependencies { + } + } + + getByName("androidDeviceTest") { + dependencies { + implementation(libs.androidx.runner) + implementation(libs.androidx.core) + implementation(libs.androidx.testExt.junit) + } + } + + iosMain { + dependencies { + } + } + } +} diff --git a/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/DailyLogImplModule.kt b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/DailyLogImplModule.kt new file mode 100644 index 0000000..966c2bf --- /dev/null +++ b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/DailyLogImplModule.kt @@ -0,0 +1,14 @@ +package dev.yaseyo.dailylog.impl + +import dev.yaseyo.dailylog.api.DailyLogRepository +import dev.yaseyo.dailylog.impl.network.DailyLogApi +import dev.yaseyo.dailylog.impl.network.DailyLogApiImpl +import dev.yaseyo.dailylog.impl.repository.DailyLogRepositoryImpl +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind +import org.koin.dsl.module + +val dailyLogImplModule = module { + singleOf(::DailyLogRepositoryImpl) bind DailyLogRepository::class + singleOf(::DailyLogApiImpl) bind DailyLogApi::class +} diff --git a/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogApi.kt b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogApi.kt new file mode 100644 index 0000000..fb24348 --- /dev/null +++ b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogApi.kt @@ -0,0 +1,12 @@ +package dev.yaseyo.dailylog.impl.network + +import kotlinx.datetime.LocalDate + +internal interface DailyLogApi { + suspend fun getLog(date: LocalDate): DailyLogNet + + suspend fun logEntry( + date: LocalDate, + request: LogEntryRequestNet, + ) +} diff --git a/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogApiImpl.kt b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogApiImpl.kt new file mode 100644 index 0000000..8f208f0 --- /dev/null +++ b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogApiImpl.kt @@ -0,0 +1,33 @@ +package dev.yaseyo.dailylog.impl.network + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.datetime.LocalDate + +internal class DailyLogApiImpl( + private val client: HttpClient, +) : DailyLogApi { + companion object { + private const val LOG_PATH = "/logs/" + } + + override suspend fun getLog(date: LocalDate): DailyLogNet = + client + .get("$LOG_PATH$date/summary") + .body() + + override suspend fun logEntry( + date: LocalDate, + request: LogEntryRequestNet, + ) { + client.post("$LOG_PATH$date/entries") { + contentType(ContentType.Application.Json) + setBody(request) + } + } +} diff --git a/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogNet.kt b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogNet.kt new file mode 100644 index 0000000..60eaef1 --- /dev/null +++ b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogNet.kt @@ -0,0 +1,25 @@ +package dev.yaseyo.dailylog.impl.network + +import dev.yaseyo.dailylog.api.DailyLog +import dev.yaseyo.domain.models.MacroTarget +import dev.yaseyo.domain.models.Macros +import kotlinx.datetime.LocalDate +import kotlinx.serialization.Serializable + +@Serializable +internal data class DailyLogNet( + val date: String, + val totals: Macros, + val activityKcal: Double, + val targets: MacroTarget, + val entries: List, +) { + fun toDailyLog(): DailyLog = + DailyLog( + date = LocalDate.parse(date), + totals = totals, + activityKcal = activityKcal, + targets = targets, + mealEntries = entries.map { it.toDomain() }, + ) +} diff --git a/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/FoodEntryNet.kt b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/FoodEntryNet.kt new file mode 100644 index 0000000..60aaf45 --- /dev/null +++ b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/FoodEntryNet.kt @@ -0,0 +1,46 @@ +package dev.yaseyo.dailylog.impl.network + +import dev.yaseyo.dailylog.api.MealEntry +import dev.yaseyo.dailylog.api.MealType +import kotlinx.serialization.Serializable +import kotlin.time.Instant + +@Serializable +internal data class FoodEntryNet( + val id: String, + val dailyLogId: String, + val mealType: String, + val quantity: Double, + val notes: String? = null, + val foodItemId: String? = null, + val recipeId: String? = null, + val kcal: Double, + val protein: Double, + val carbs: Double, + val fat: Double, + val fiber: Double, + val loggedAt: String, + val createdAt: String, + val updatedAt: String, +) + +internal fun FoodEntryNet.toDomain() = + MealEntry( + id = id, + dailyLogId = dailyLogId, + mealType = mealType.toMealType(), + quantity = quantity, + notes = notes, + foodItemId = foodItemId, + recipeId = recipeId, + kcal = kcal, + protein = protein, + carbs = carbs, + fat = fat, + fiber = fiber, + loggedAt = Instant.parse(loggedAt), + createdAt = Instant.parse(createdAt), + updatedAt = Instant.parse(updatedAt), + ) + +private fun String.toMealType() = MealType.valueOf(lowercase().replaceFirstChar { it.uppercase() }) diff --git a/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/LogEntryRequestNet.kt b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/LogEntryRequestNet.kt new file mode 100644 index 0000000..a3cbd23 --- /dev/null +++ b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/LogEntryRequestNet.kt @@ -0,0 +1,10 @@ +package dev.yaseyo.dailylog.impl.network + +import kotlinx.serialization.Serializable + +@Serializable +internal data class LogEntryRequestNet( + val foodItemId: String, + val quantity: Double, + val mealType: String, +) diff --git a/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/repository/DailyLogRepositoryImpl.kt b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/repository/DailyLogRepositoryImpl.kt new file mode 100644 index 0000000..d820d1a --- /dev/null +++ b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/repository/DailyLogRepositoryImpl.kt @@ -0,0 +1,69 @@ +package dev.yaseyo.dailylog.impl.repository + +import dev.yaseyo.coroutines.DispatcherProvider +import dev.yaseyo.dailylog.api.DailyLog +import dev.yaseyo.dailylog.api.DailyLogRepository +import dev.yaseyo.dailylog.api.MealType +import dev.yaseyo.dailylog.impl.network.DailyLogApi +import dev.yaseyo.dailylog.impl.network.LogEntryRequestNet +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.withContext +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock + +internal class DailyLogRepositoryImpl( + private val logApi: DailyLogApi, + private val dispatcherProvider: DispatcherProvider, +) : DailyLogRepository { + val logForToday: MutableStateFlow?> = MutableStateFlow(null) + + override fun getLogForToday(): Flow?> = + logForToday.onStart { + if (logForToday.value == null) { + fetchAndUpdateLog() + } + } + + override suspend fun logFood( + foodItemId: String, + quantity: Double, + mealType: MealType, + ): Result = + runCatching { + withContext(dispatcherProvider.io) { + logApi.logEntry( + date = today(), + request = LogEntryRequestNet( + foodItemId = foodItemId, + quantity = quantity, + mealType = mealType.name.uppercase(), + ), + ) + fetchAndUpdateLog() + } + } + + private suspend fun fetchAndUpdateLog() { + runCatching { + withContext(dispatcherProvider.io) { + logApi.getLog(today()).toDailyLog() + } + }.fold( + onSuccess = { + logForToday.value = Result.success(it) + }, + onFailure = { + logForToday.value = Result.failure(it) + }, + ) + } + + private fun today() = + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date +} diff --git a/libs/datastore/testing/build.gradle.kts b/libs/datastore/testing/build.gradle.kts new file mode 100644 index 0000000..3de4a6a --- /dev/null +++ b/libs/datastore/testing/build.gradle.kts @@ -0,0 +1,19 @@ +plugins { + id("yaseyo.kmp.library") +} + +kotlin { + androidLibrary { + namespace = "dev.yaseyo.datastore.testing" + } + + sourceSets { + commonMain { + dependencies { + api(libs.androidx.datastore) + api(libs.androidx.datastore.preferences) + implementation(libs.kotlinx.coroutines.core) + } + } + } +} diff --git a/libs/datastore/testing/src/commonMain/kotlin/dev/yaseyo/datastore/testing/FakePreferencesDataStore.kt b/libs/datastore/testing/src/commonMain/kotlin/dev/yaseyo/datastore/testing/FakePreferencesDataStore.kt new file mode 100644 index 0000000..732f1ee --- /dev/null +++ b/libs/datastore/testing/src/commonMain/kotlin/dev/yaseyo/datastore/testing/FakePreferencesDataStore.kt @@ -0,0 +1,21 @@ +package dev.yaseyo.datastore.testing + +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.emptyPreferences +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow + +class FakePreferencesDataStore( + initial: Preferences = emptyPreferences(), +) : DataStore { + private val state = MutableStateFlow(initial) + + override val data: Flow = state + + override suspend fun updateData(transform: suspend (Preferences) -> Preferences): Preferences { + val updated = transform(state.value) + state.value = updated + return updated + } +} diff --git a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt index a901803..28f8b82 100644 --- a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt @@ -1,24 +1,34 @@ package dev.yaseyo.design +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Scaffold import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier @Composable fun YaseyoPreview( - darkTheme: Boolean = false, + darkTheme: Boolean = isSystemInDarkTheme(), composable: @Composable () -> Unit, ) { YaseyoTheme(darkTheme = darkTheme) { val colors = YaseyoTheme.colors - Scaffold( - containerColor = colors.backgroundBase, - ) { - Box(modifier = Modifier.padding(it).fillMaxSize()) { - composable() + MaterialTheme { + Scaffold( + containerColor = colors.backgroundBase, + ) { + Box( + modifier = Modifier + .padding(it) + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + composable() + } } } } diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index 5f75d5d..b153e3c 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -37,6 +37,7 @@ kotlin { implementation(projects.libs.auth.impl) implementation(projects.libs.network) implementation(projects.libs.datastore) + implementation(projects.libs.dailyLog.impl) // Exported Libs api(projects.libs.auth.api) @@ -46,6 +47,7 @@ kotlin { // Features api(projects.feature.onboarding) + api(projects.feature.home) } } diff --git a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt index 43d9911..4c46e57 100644 --- a/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt +++ b/libs/di/src/androidMain/kotlin/dev/yaseyo/di/KoinInitializer.kt @@ -2,6 +2,7 @@ package dev.yaseyo.di import android.content.Context import androidx.startup.Initializer +import dev.yaseyo.home.homeFeatureAndroidModule import dev.yaseyo.navigation.navigationAndroidModule import dev.yaseyo.onboarding.onboardingFeatureAndroidModule import org.koin.android.ext.koin.androidContext @@ -16,6 +17,7 @@ class KoinInitializer : Initializer { val modules = appModules + listOf( navigationAndroidModule, onboardingFeatureAndroidModule, + homeFeatureAndroidModule, ) modules(modules) } diff --git a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt index a55351d..9657faf 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -2,7 +2,9 @@ package dev.yaseyo.di import dev.yaseyo.auth.impl.authImplModule import dev.yaseyo.coroutines.coroutinesModule +import dev.yaseyo.dailylog.impl.dailyLogImplModule import dev.yaseyo.datastore.dataStoreModule +import dev.yaseyo.home.di.homeModule import dev.yaseyo.navigation.navigationModule import dev.yaseyo.network.networkModule import dev.yaseyo.onboarding.di.onboardingModule @@ -18,4 +20,6 @@ internal val appModules = listOf( networkModule, onboardingModule, dataStoreModule, + dailyLogImplModule, + homeModule, ) diff --git a/libs/domain-models/build.gradle.kts b/libs/domain-models/build.gradle.kts index 51f0766..fd99a0f 100644 --- a/libs/domain-models/build.gradle.kts +++ b/libs/domain-models/build.gradle.kts @@ -3,7 +3,7 @@ plugins { } kotlin { - androidLibrary { + android { namespace = "dev.yaseyo.domain.models" withHostTestBuilder { @@ -20,6 +20,7 @@ kotlin { commonMain { dependencies { implementation(libs.kotlin.stdlib) + implementation(libs.kotlinx.serialization.json) } } diff --git a/libs/domain-models/src/commonMain/kotlin/dev/yaseyo/domain/models/MacroTarget.kt b/libs/domain-models/src/commonMain/kotlin/dev/yaseyo/domain/models/MacroTarget.kt new file mode 100644 index 0000000..952e4ba --- /dev/null +++ b/libs/domain-models/src/commonMain/kotlin/dev/yaseyo/domain/models/MacroTarget.kt @@ -0,0 +1,12 @@ +package dev.yaseyo.domain.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MacroTarget( + val kcal: Double, + val effectiveKcal: Double, + val protein: Double, + val carbs: Double, + val fat: Double, +) diff --git a/libs/domain-models/src/commonMain/kotlin/dev/yaseyo/domain/models/Macros.kt b/libs/domain-models/src/commonMain/kotlin/dev/yaseyo/domain/models/Macros.kt index a6778e8..26f92d9 100644 --- a/libs/domain-models/src/commonMain/kotlin/dev/yaseyo/domain/models/Macros.kt +++ b/libs/domain-models/src/commonMain/kotlin/dev/yaseyo/domain/models/Macros.kt @@ -1,5 +1,8 @@ package dev.yaseyo.domain.models +import kotlinx.serialization.Serializable + +@Serializable data class Macros( val kcal: Double, val protein: Double, diff --git a/libs/network/src/commonMain/kotlin/dev/yaseyo/network/NetworkModule.kt b/libs/network/src/commonMain/kotlin/dev/yaseyo/network/NetworkModule.kt index b66568a..7bcad92 100644 --- a/libs/network/src/commonMain/kotlin/dev/yaseyo/network/NetworkModule.kt +++ b/libs/network/src/commonMain/kotlin/dev/yaseyo/network/NetworkModule.kt @@ -20,7 +20,7 @@ import org.koin.dsl.module val networkModule = module { - single { + single { val config = get() val authRepository = get() diff --git a/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/UserRepository.kt b/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/UserRepository.kt index 3a30e97..b2a6ad9 100644 --- a/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/UserRepository.kt +++ b/libs/user/api/src/commonMain/kotlin/dev/yaseyo/user/api/UserRepository.kt @@ -3,6 +3,10 @@ package dev.yaseyo.user.api import kotlinx.datetime.LocalDate interface UserRepository { + suspend fun isUserSessionActive(): Result + + suspend fun clearUserSession(): Result + suspend fun getCurrentUser(): Result suspend fun createUser( diff --git a/libs/user/impl/build.gradle.kts b/libs/user/impl/build.gradle.kts index e822d57..cbcf96f 100644 --- a/libs/user/impl/build.gradle.kts +++ b/libs/user/impl/build.gradle.kts @@ -27,6 +27,8 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(libs.kotlinx.datetime) implementation(libs.koin.core) + + implementation(projects.libs.datastore) } } @@ -36,6 +38,7 @@ kotlin { implementation(libs.ktor.testing) implementation(libs.ktor.client.content.negotiation) implementation(libs.ktor.serialization.kotlinx.json) + implementation(projects.libs.datastore.testing) } } diff --git a/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/UserImplModule.kt b/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/UserImplModule.kt index 0851fdb..c78d654 100644 --- a/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/UserImplModule.kt +++ b/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/UserImplModule.kt @@ -3,13 +3,13 @@ package dev.yaseyo.user.impl import dev.yaseyo.user.api.UserRepository import dev.yaseyo.user.impl.network.UserApi import dev.yaseyo.user.impl.network.UserApiImpl +import org.koin.core.module.dsl.singleOf +import org.koin.dsl.bind import org.koin.dsl.module val userImplModule = module { single { UserApiImpl(client = get()) } - single { - UserRepositoryImpl(api = get()) - } + singleOf(::UserRepositoryImpl) bind UserRepository::class } diff --git a/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/UserRepositoryImpl.kt b/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/UserRepositoryImpl.kt index d4c890e..699f47a 100644 --- a/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/UserRepositoryImpl.kt +++ b/libs/user/impl/src/commonMain/kotlin/dev/yaseyo/user/impl/UserRepositoryImpl.kt @@ -1,19 +1,47 @@ package dev.yaseyo.user.impl +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.edit import dev.yaseyo.user.api.ActivityLevel import dev.yaseyo.user.api.Sex import dev.yaseyo.user.api.User import dev.yaseyo.user.api.UserRepository import dev.yaseyo.user.impl.network.UserApi import dev.yaseyo.user.impl.network.toDomain +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map import kotlinx.datetime.LocalDate internal class UserRepositoryImpl( private val api: UserApi, + private val dataStore: DataStore, ) : UserRepository { + override suspend fun isUserSessionActive(): Result = + runCatching { + val userId = dataStore.data.map { it[Keys.USER_ID_KEY] }.first() + check(userId == true) { "No cached user session" } + } + + override suspend fun clearUserSession(): Result = + runCatching { + dataStore.edit { prefs -> + prefs[Keys.USER_ID_KEY] = false + } + }.map {} + + private object Keys { + val USER_ID_KEY = booleanPreferencesKey("user_id") + } + override suspend fun getCurrentUser(): Result = runCatching { api.getUser().toDomain() + }.onSuccess { + dataStore.edit { prefs -> + prefs[Keys.USER_ID_KEY] = true + } } override suspend fun createUser( diff --git a/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/UserRepositoryImplTest.kt b/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/UserRepositoryImplTest.kt index ed1b62d..823183e 100644 --- a/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/UserRepositoryImplTest.kt +++ b/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/UserRepositoryImplTest.kt @@ -1,5 +1,6 @@ package dev.yaseyo.user.impl +import dev.yaseyo.datastore.testing.FakePreferencesDataStore import dev.yaseyo.user.api.ActivityLevel import dev.yaseyo.user.api.Sex import dev.yaseyo.user.impl.network.FakeUserApi @@ -11,7 +12,10 @@ import kotlin.test.assertIs import kotlin.test.assertTrue class UserRepositoryImplTest { - private fun repo(fakeApi: FakeUserApi = FakeUserApi()) = UserRepositoryImpl(fakeApi) + private fun repo( + fakeApi: FakeUserApi = FakeUserApi(), + dataStore: FakePreferencesDataStore = FakePreferencesDataStore(), + ) = UserRepositoryImpl(fakeApi, dataStore) // --- getCurrentUser --- @@ -100,4 +104,45 @@ class UserRepositoryImplTest { assertTrue(result.isFailure) assertEquals(error, result.exceptionOrNull()) } + + // --- isUserSessionActive --- + + @Test + fun `isUserSessionActive returns failure when nothing cached`() = + runBlocking { + val result = repo().isUserSessionActive() + assertTrue(result.isFailure) + } + + @Test + fun `isUserSessionActive returns success after getCurrentUser caches session`() = + runBlocking { + val dataStore = FakePreferencesDataStore() + val repo = repo(dataStore = dataStore) + repo.getCurrentUser() + + val result = repo.isUserSessionActive() + assertTrue(result.isSuccess) + } + + @Test + fun `isUserSessionActive returns failure after clearUserSession`() = + runBlocking { + val dataStore = FakePreferencesDataStore() + val repo = repo(dataStore = dataStore) + repo.getCurrentUser() + repo.clearUserSession() + + val result = repo.isUserSessionActive() + assertTrue(result.isFailure) + } + + // --- clearUserSession --- + + @Test + fun `clearUserSession returns success`() = + runBlocking { + val result = repo().clearUserSession() + assertTrue(result.isSuccess) + } } diff --git a/settings.gradle.kts b/settings.gradle.kts index bd4595b..205473a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,5 +46,8 @@ include(":libs:design") include(":libs:network") include(":libs:di") include(":libs:datastore") +include(":libs:datastore:testing") +include(":libs:daily-log:api") +include(":libs:daily-log:impl") include(":feature:onboarding") include(":feature:home")