From 739e6ac1759ad043dc4c98de5da8d42000c1714b Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Jul 2026 18:28:22 +0900 Subject: [PATCH 01/13] Add libs:daily-log api and impl modules and define Log data model and LogRepository interface --- libs/daily-log/api/build.gradle.kts | 54 +++++++++++++++++++ .../dev/yaseyo/dailylog/api/DailyLog.kt | 11 ++++ .../yaseyo/dailylog/api/DailyLogRepository.kt | 5 ++ settings.gradle.kts | 2 + 4 files changed, 72 insertions(+) create mode 100644 libs/daily-log/api/build.gradle.kts create mode 100644 libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/DailyLog.kt create mode 100644 libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/DailyLogRepository.kt 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..5fc689c --- /dev/null +++ b/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/DailyLog.kt @@ -0,0 +1,11 @@ +package dev.yaseyo.dailylog.api + +import dev.yaseyo.domain.models.Macros +import kotlinx.datetime.LocalDate + +data class DailyLog( + val date: LocalDate, + val totals: Macros, + val targets: Macros, + val activity: Long, +) 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..eebdaf7 --- /dev/null +++ b/libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/DailyLogRepository.kt @@ -0,0 +1,5 @@ +package dev.yaseyo.dailylog.api + +interface DailyLogRepository { + suspend fun getLogForToday(): Result +} diff --git a/settings.gradle.kts b/settings.gradle.kts index bd4595b..0ba7232 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,5 +46,7 @@ include(":libs:design") include(":libs:network") include(":libs:di") include(":libs:datastore") +include(":libs:daily-log:api") +include(":libs:daily-log:impl") include(":feature:onboarding") include(":feature:home") From 69cdcdcf76dc0a2f71fe6b2c7c94d3b39eaf3bab Mon Sep 17 00:00:00 2001 From: Taki Date: Tue, 21 Jul 2026 19:27:03 +0900 Subject: [PATCH 02/13] Implement daily-log implementation module and update domain models - Add `libs:daily-log:impl` module with `DailyLogRepository` and Ktor-based `DailyLogApi` implementations. - Introduce `MacroTarget` domain model and add `@Serializable` to `Macros`. - Update `DailyLog` API model to include `MacroTarget` and use `activityKcal` (Double) instead of activity (Long). - Register `dailyLogImplModule` in the main dependency injection configuration. - Add `kotlinx-serialization` dependency to `libs:domain-models`. --- .../dev/yaseyo/dailylog/api/DailyLog.kt | 5 +- libs/daily-log/impl/build.gradle.kts | 58 +++++++++++++++++++ .../dailylog/impl/DailyLogImplModule.kt | 14 +++++ .../dailylog/impl/network/DailyLogApi.kt | 7 +++ .../dailylog/impl/network/DailyLogApiImpl.kt | 19 ++++++ .../dailylog/impl/network/DailyLogNet.kt | 23 ++++++++ .../impl/repository/DailyLogRepositoryImpl.kt | 24 ++++++++ libs/di/build.gradle.kts | 1 + .../kotlin/dev/yaseyo/di/AppModules.kt | 2 + libs/domain-models/build.gradle.kts | 3 +- .../dev/yaseyo/domain/models/MacroTarget.kt | 12 ++++ .../kotlin/dev/yaseyo/domain/models/Macros.kt | 3 + .../dev/yaseyo/network/NetworkModule.kt | 2 +- 13 files changed, 169 insertions(+), 4 deletions(-) create mode 100644 libs/daily-log/impl/build.gradle.kts create mode 100644 libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/DailyLogImplModule.kt create mode 100644 libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogApi.kt create mode 100644 libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogApiImpl.kt create mode 100644 libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogNet.kt create mode 100644 libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/repository/DailyLogRepositoryImpl.kt create mode 100644 libs/domain-models/src/commonMain/kotlin/dev/yaseyo/domain/models/MacroTarget.kt 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 index 5fc689c..d0157ee 100644 --- 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 @@ -1,11 +1,12 @@ 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: Macros, - val activity: Long, + val targets: MacroTarget, + val activityKcal: Double, ) diff --git a/libs/daily-log/impl/build.gradle.kts b/libs/daily-log/impl/build.gradle.kts new file mode 100644 index 0000000..5bcaf32 --- /dev/null +++ b/libs/daily-log/impl/build.gradle.kts @@ -0,0 +1,58 @@ +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) + 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..d5e35f8 --- /dev/null +++ b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogApi.kt @@ -0,0 +1,7 @@ +package dev.yaseyo.dailylog.impl.network + +import kotlinx.datetime.LocalDate + +internal interface DailyLogApi { + suspend fun getLog(date: LocalDate): DailyLogNet +} 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..b80feb6 --- /dev/null +++ b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogApiImpl.kt @@ -0,0 +1,19 @@ +package dev.yaseyo.dailylog.impl.network + +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.request.get +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() +} 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..6abab23 --- /dev/null +++ b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/DailyLogNet.kt @@ -0,0 +1,23 @@ +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 +data class DailyLogNet( + val date: String, + val totals: Macros, + val activityKcal: Double, + val targets: MacroTarget, +) { + fun toDailyLog(): DailyLog = + DailyLog( + date = LocalDate.parse(date), + totals = totals, + activityKcal = activityKcal, + targets = targets, + ) +} 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..0be3939 --- /dev/null +++ b/libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/repository/DailyLogRepositoryImpl.kt @@ -0,0 +1,24 @@ +package dev.yaseyo.dailylog.impl.repository + +import dev.yaseyo.dailylog.api.DailyLog +import dev.yaseyo.dailylog.api.DailyLogRepository +import dev.yaseyo.dailylog.impl.network.DailyLogApi +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime +import kotlin.time.Clock + +internal class DailyLogRepositoryImpl( + private val logApi: DailyLogApi, +) : DailyLogRepository { + override suspend fun getLogForToday(): Result = + runCatching { + logApi + .getLog( + date = Clock + .System + .now() + .toLocalDateTime(timeZone = TimeZone.currentSystemDefault()) + .date, + ).toDailyLog() + } +} diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index 5f75d5d..2f6d875 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) 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..930d239 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -2,6 +2,7 @@ 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.navigation.navigationModule import dev.yaseyo.network.networkModule @@ -18,4 +19,5 @@ internal val appModules = listOf( networkModule, onboardingModule, dataStoreModule, + dailyLogImplModule, ) 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() From bca4c3dbc2cc3d50aeb0c1512cb2809b17c181d1 Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 23 Jul 2026 15:30:52 +0900 Subject: [PATCH 03/13] Add home feature module and configure its dependency injection and navigation --- .../dev/yaseyo/home/HomeFeatureAndroidModule.kt | 8 ++++++++ .../kotlin/dev/yaseyo/home/HomeFeatureNavigation.kt | 13 +++++++++++++ libs/di/build.gradle.kts | 1 + .../kotlin/dev/yaseyo/di/KoinInitializer.kt | 2 ++ 4 files changed, 24 insertions(+) create mode 100644 feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureAndroidModule.kt create mode 100644 feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureNavigation.kt 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..6a68a87 --- /dev/null +++ b/feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureNavigation.kt @@ -0,0 +1,13 @@ +package dev.yaseyo.home + +import androidx.navigation3.runtime.EntryProviderScope +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 { + } + } +} diff --git a/libs/di/build.gradle.kts b/libs/di/build.gradle.kts index 2f6d875..b153e3c 100644 --- a/libs/di/build.gradle.kts +++ b/libs/di/build.gradle.kts @@ -47,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) } From 9b6ec04571756dae3ca95a21840c3f14f4a0d0ea Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 23 Jul 2026 15:39:39 +0900 Subject: [PATCH 04/13] Add libs:daily-log module and create skeleton for HomeViewModel and HomeScreen in feature:home --- feature/home/build.gradle.kts | 1 + .../dev/yaseyo/home/ui/HomeEventHandler.kt | 3 +++ .../kotlin/dev/yaseyo/home/ui/HomeScreen.kt | 8 ++++++++ .../dev/yaseyo/home/ui/HomeViewModel.kt | 9 +++++++++ libs/daily-log/build.gradle.kts | 20 +++++++++++++++++++ 5 files changed, 41 insertions(+) create mode 100644 feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeEventHandler.kt create mode 100644 feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt create mode 100644 feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt create mode 100644 libs/daily-log/build.gradle.kts diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 0e9c093..246417a 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { implementation(projects.libs.coroutines) api(projects.libs.navigation) implementation(projects.libs.design) + implementation(projects.libs.dailyLog) implementation(libs.androidx.lifecycle.viewmodel) implementation(libs.compose.runtime) 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..9bdc274 --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt @@ -0,0 +1,8 @@ +package dev.yaseyo.home.ui + +import androidx.compose.runtime.Composable +import org.koin.compose.viewmodel.koinViewModel + +@Composable +internal fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) { +} 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..f03b197 --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt @@ -0,0 +1,9 @@ +package dev.yaseyo.home.ui + +import androidx.lifecycle.ViewModel +import dev.yaseyo.dailylog.api.DailyLogRepository + +internal class HomeViewModel( + private val dailyLogRepository: DailyLogRepository, +) : ViewModel(), + HomeEventHandler 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) + } + } + } +} From 28c1bc4f6d18b886692dc17b121fe7d5e20a0d83 Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 23 Jul 2026 15:48:32 +0900 Subject: [PATCH 05/13] Update dependencies and versions in libs.versions.toml --- gradle/libs.versions.toml | 36 ++++++++++++++++++------------------ 1 file changed, 18 insertions(+), 18 deletions(-) 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" } From ebeae299b09afb56d10acde311980b9836615da5 Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 23 Jul 2026 16:24:41 +0900 Subject: [PATCH 06/13] Implement `logFood` in `DailyLogRepository` and update log fetching to use `Flow` --- .../yaseyo/dailylog/api/DailyLogRepository.kt | 10 +++- .../dev/yaseyo/dailylog/api/MealType.kt | 8 +++ libs/daily-log/impl/build.gradle.kts | 1 + .../dailylog/impl/network/DailyLogApi.kt | 5 ++ .../dailylog/impl/network/DailyLogApiImpl.kt | 14 +++++ .../impl/network/LogEntryRequestNet.kt | 10 ++++ .../impl/repository/DailyLogRepositoryImpl.kt | 56 ++++++++++++++++--- 7 files changed, 94 insertions(+), 10 deletions(-) create mode 100644 libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/MealType.kt create mode 100644 libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/LogEntryRequestNet.kt 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 index eebdaf7..0687209 100644 --- 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 @@ -1,5 +1,13 @@ package dev.yaseyo.dailylog.api +import kotlinx.coroutines.flow.Flow + interface DailyLogRepository { - suspend fun getLogForToday(): Result + 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/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/impl/build.gradle.kts b/libs/daily-log/impl/build.gradle.kts index 5bcaf32..15b45c6 100644 --- a/libs/daily-log/impl/build.gradle.kts +++ b/libs/daily-log/impl/build.gradle.kts @@ -27,6 +27,7 @@ kotlin { implementation(libs.kotlinx.serialization.json) implementation(projects.libs.dailyLog.api) + implementation(projects.libs.coroutines) api(projects.libs.domainModels) } } 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 index d5e35f8..fb24348 100644 --- 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 @@ -4,4 +4,9 @@ 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 index b80feb6..8f208f0 100644 --- 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 @@ -3,6 +3,10 @@ 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( @@ -16,4 +20,14 @@ internal class DailyLogApiImpl( 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/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 index 0be3939..d650f03 100644 --- 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 @@ -1,24 +1,62 @@ 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 { - override suspend fun getLogForToday(): Result = + 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 { - logApi - .getLog( - date = Clock - .System - .now() - .toLocalDateTime(timeZone = TimeZone.currentSystemDefault()) - .date, - ).toDailyLog() + 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() + } + }.onSuccess { logForToday.value = it } + } + + private fun today() = + Clock.System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date } From e03ded752d4d14351c27bbdf389e27146100f91e Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 23 Jul 2026 16:42:50 +0900 Subject: [PATCH 07/13] Implement HomeViewModel UI state and integrate HomeScreen with DailyLogRepository --- .../dev/yaseyo/home/HomeFeatureNavigation.kt | 2 ++ .../kotlin/dev/yaseyo/home/di/HomeModule.kt | 9 +++++++ .../kotlin/dev/yaseyo/home/ui/HomeScreen.kt | 9 +++++++ .../kotlin/dev/yaseyo/home/ui/HomeUiState.kt | 20 ++++++++++++++ .../dev/yaseyo/home/ui/HomeViewModel.kt | 26 +++++++++++++++++-- .../yaseyo/dailylog/api/DailyLogRepository.kt | 2 +- .../impl/repository/DailyLogRepositoryImpl.kt | 13 +++++++--- .../kotlin/dev/yaseyo/di/AppModules.kt | 2 ++ 8 files changed, 77 insertions(+), 6 deletions(-) create mode 100644 feature/home/src/commonMain/kotlin/dev/yaseyo/home/di/HomeModule.kt create mode 100644 feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeUiState.kt diff --git a/feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureNavigation.kt b/feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureNavigation.kt index 6a68a87..92f26f8 100644 --- a/feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureNavigation.kt +++ b/feature/home/src/androidMain/kotlin/dev/yaseyo/home/HomeFeatureNavigation.kt @@ -1,6 +1,7 @@ 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 @@ -8,6 +9,7 @@ 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/HomeScreen.kt b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt index 9bdc274..9ff843e 100644 --- a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt @@ -1,8 +1,17 @@ package dev.yaseyo.home.ui +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.lifecycle.compose.collectAsStateWithLifecycle import org.koin.compose.viewmodel.koinViewModel @Composable internal fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) { + val state by viewModel.uiState.collectAsStateWithLifecycle() + + Column { + Text(text = state.toString()) + } } 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..a3c3426 --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeUiState.kt @@ -0,0 +1,20 @@ +package dev.yaseyo.home.ui + +import dev.yaseyo.dailylog.api.DailyLog + +internal sealed interface HomeUiState { + val loading: Boolean + + data object Initializing : HomeUiState { + override val loading: Boolean = true + } + + data class Content( + val dailyLog: DailyLog, + override val loading: Boolean = false, + ) : HomeUiState + + data object Error : HomeUiState { + override val loading: Boolean = false + } +} 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 index f03b197..3d45849 100644 --- a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt @@ -1,9 +1,31 @@ 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( - private val dailyLogRepository: DailyLogRepository, + dailyLogRepository: DailyLogRepository, ) : ViewModel(), - HomeEventHandler + HomeEventHandler { + val uiState: StateFlow = dailyLogRepository + .getLogForToday() + .map { dailyLogResult -> + dailyLogResult?.fold( + onSuccess = { + HomeUiState.Content(dailyLog = it) + }, + onFailure = { + HomeUiState.Error + }, + ) ?: HomeUiState.Initializing + }.stateIn( + scope = viewModelScope, + started = SharingStarted.WhileSubscribed(5000), + initialValue = HomeUiState.Initializing, + ) +} 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 index 0687209..1572e4f 100644 --- 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 @@ -3,7 +3,7 @@ package dev.yaseyo.dailylog.api import kotlinx.coroutines.flow.Flow interface DailyLogRepository { - fun getLogForToday(): Flow + fun getLogForToday(): Flow?> suspend fun logFood( foodItemId: 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 index d650f03..d820d1a 100644 --- 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 @@ -18,9 +18,9 @@ internal class DailyLogRepositoryImpl( private val logApi: DailyLogApi, private val dispatcherProvider: DispatcherProvider, ) : DailyLogRepository { - val logForToday: MutableStateFlow = MutableStateFlow(null) + val logForToday: MutableStateFlow?> = MutableStateFlow(null) - override fun getLogForToday(): Flow = + override fun getLogForToday(): Flow?> = logForToday.onStart { if (logForToday.value == null) { fetchAndUpdateLog() @@ -51,7 +51,14 @@ internal class DailyLogRepositoryImpl( withContext(dispatcherProvider.io) { logApi.getLog(today()).toDailyLog() } - }.onSuccess { logForToday.value = it } + }.fold( + onSuccess = { + logForToday.value = Result.success(it) + }, + onFailure = { + logForToday.value = Result.failure(it) + }, + ) } private fun today() = 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 930d239..9657faf 100644 --- a/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt +++ b/libs/di/src/commonMain/kotlin/dev/yaseyo/di/AppModules.kt @@ -4,6 +4,7 @@ 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 @@ -20,4 +21,5 @@ internal val appModules = listOf( onboardingModule, dataStoreModule, dailyLogImplModule, + homeModule, ) From 3836ad2c8d8afc4356472767440bb05f9bef4e89 Mon Sep 17 00:00:00 2001 From: Taki Date: Thu, 23 Jul 2026 23:24:39 +0900 Subject: [PATCH 08/13] Implement KcalProgressHero widget and update Home UI state and screen - Add `KcalProgressHero` circular progress widget for calorie tracking - Update `HomeUiState` to include calorie progress, current, activity, and target values - Refactor `HomeScreen` to use `LazyColumn` with a sticky date header and `AnimatedContent` for state transitions - Update `HomeViewModel` to map daily log results to the new UI state values - Improve `YaseyoPreview` to support system dark theme and center content by default - Add `kotlinx-datetime` dependency to the home feature module --- feature/home/build.gradle.kts | 1 + .../kotlin/dev/yaseyo/home/ui/HomeScreen.kt | 127 +++++++++++++++++- .../kotlin/dev/yaseyo/home/ui/HomeUiState.kt | 18 +-- .../dev/yaseyo/home/ui/HomeViewModel.kt | 7 +- .../yaseyo/home/ui/widget/KcalProgressHero.kt | 117 ++++++++++++++++ .../kotlin/dev/yaseyo/design/YaseyoPreview.kt | 11 +- 6 files changed, 263 insertions(+), 18 deletions(-) create mode 100644 feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/KcalProgressHero.kt diff --git a/feature/home/build.gradle.kts b/feature/home/build.gradle.kts index 246417a..cf8e0bc 100644 --- a/feature/home/build.gradle.kts +++ b/feature/home/build.gradle.kts @@ -32,6 +32,7 @@ kotlin { 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/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt index 9ff843e..84a2b8c 100644 --- a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt @@ -1,17 +1,138 @@ package dev.yaseyo.home.ui -import androidx.compose.foundation.layout.Column +import androidx.compose.animation.AnimatedContent +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +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.design.YaseyoPreview +import dev.yaseyo.design.YaseyoTheme +import dev.yaseyo.home.ui.widget.KcalProgressHero +import kotlinx.datetime.TimeZone +import kotlinx.datetime.toLocalDateTime import org.koin.compose.viewmodel.koinViewModel +import kotlin.time.Clock @Composable internal fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) { val state by viewModel.uiState.collectAsStateWithLifecycle() - Column { - Text(text = state.toString()) + HomeScreen(state = state) +} + +@Composable +private fun HomeScreen(state: HomeUiState) { + LazyColumn( + modifier = Modifier.fillMaxSize(), + ) { + stickyHeader { + Text( + modifier = Modifier.padding(16.dp), + text = Clock + .System + .now() + .toLocalDateTime(TimeZone.currentSystemDefault()) + .date + .toString(), + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = YaseyoTheme.colors.contentPrimary, + ) + + HorizontalDivider() + } + + item { + AnimatedContent( + modifier = Modifier.fillMaxSize(), + targetState = state, + contentKey = { + it::class.simpleName + }, + ) { state -> + when (state) { + is HomeUiState.Content -> { + KcalProgressHero( + modifier = Modifier.padding(16.dp), + progress = { + state.progress + }, + currentKcal = state.currentKcal, + baseTargetKcal = state.baseTargetKcal, + activityKcal = state.activityKcal, + ) + } + + 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(), + ), + ) + } + +@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 index a3c3426..85cb7eb 100644 --- a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeUiState.kt +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeUiState.kt @@ -1,20 +1,14 @@ package dev.yaseyo.home.ui -import dev.yaseyo.dailylog.api.DailyLog - internal sealed interface HomeUiState { - val loading: Boolean - - data object Initializing : HomeUiState { - override val loading: Boolean = true - } + data object Initializing : HomeUiState data class Content( - val dailyLog: DailyLog, - override val loading: Boolean = false, + val progress: Float, + val currentKcal: Long, + val activityKcal: Long, + val baseTargetKcal: Long, ) : HomeUiState - data object Error : HomeUiState { - override val loading: Boolean = false - } + 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 index 3d45849..499cd59 100644 --- a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt @@ -17,7 +17,12 @@ internal class HomeViewModel( .map { dailyLogResult -> dailyLogResult?.fold( onSuccess = { - HomeUiState.Content(dailyLog = it) + 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(), + ) }, onFailure = { HomeUiState.Error 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..da87efa --- /dev/null +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/KcalProgressHero.kt @@ -0,0 +1,117 @@ +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 + .fillMaxSize() + .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/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt index a901803..577da36 100644 --- a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt @@ -1,15 +1,17 @@ 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.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) { @@ -17,7 +19,12 @@ fun YaseyoPreview( Scaffold( containerColor = colors.backgroundBase, ) { - Box(modifier = Modifier.padding(it).fillMaxSize()) { + Box( + modifier = Modifier + .padding(it) + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { composable() } } From a8617a721f9eab00aba3acbc9fb6ab553ff7db9f Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 24 Jul 2026 17:00:51 +0900 Subject: [PATCH 09/13] Implement user session management using DataStore in UserRepository --- .../ResolveStartDestinationUseCase.kt | 6 ++- .../onboarding/fake/FakeUserRepository.kt | 5 ++ .../dev/yaseyo/user/api/UserRepository.kt | 4 ++ libs/user/impl/build.gradle.kts | 2 + .../dev/yaseyo/user/impl/UserImplModule.kt | 6 +-- .../yaseyo/user/impl/UserRepositoryImpl.kt | 28 +++++++++++ .../user/impl/FakePreferencesDataStore.kt | 21 +++++++++ .../user/impl/UserRepositoryImplTest.kt | 46 ++++++++++++++++++- 8 files changed, 113 insertions(+), 5 deletions(-) create mode 100644 libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/FakePreferencesDataStore.kt 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/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..661c093 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) } } 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/FakePreferencesDataStore.kt b/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/FakePreferencesDataStore.kt new file mode 100644 index 0000000..e07573b --- /dev/null +++ b/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/FakePreferencesDataStore.kt @@ -0,0 +1,21 @@ +package dev.yaseyo.user.impl + +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 + +internal 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/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..26641cc 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 @@ -11,7 +11,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 +103,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) + } } From d41947663103dc618d81f5861e84691dcab17d38 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 24 Jul 2026 17:08:52 +0900 Subject: [PATCH 10/13] Add libs:datastore:testing module and migrate FakePreferencesDataStore --- libs/datastore/testing/build.gradle.kts | 19 +++++++++++++++++++ .../testing}/FakePreferencesDataStore.kt | 4 ++-- libs/user/impl/build.gradle.kts | 1 + .../user/impl/UserRepositoryImplTest.kt | 1 + settings.gradle.kts | 1 + 5 files changed, 24 insertions(+), 2 deletions(-) create mode 100644 libs/datastore/testing/build.gradle.kts rename libs/{user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl => datastore/testing/src/commonMain/kotlin/dev/yaseyo/datastore/testing}/FakePreferencesDataStore.kt (89%) 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/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/FakePreferencesDataStore.kt b/libs/datastore/testing/src/commonMain/kotlin/dev/yaseyo/datastore/testing/FakePreferencesDataStore.kt similarity index 89% rename from libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/FakePreferencesDataStore.kt rename to libs/datastore/testing/src/commonMain/kotlin/dev/yaseyo/datastore/testing/FakePreferencesDataStore.kt index e07573b..732f1ee 100644 --- a/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/FakePreferencesDataStore.kt +++ b/libs/datastore/testing/src/commonMain/kotlin/dev/yaseyo/datastore/testing/FakePreferencesDataStore.kt @@ -1,4 +1,4 @@ -package dev.yaseyo.user.impl +package dev.yaseyo.datastore.testing import androidx.datastore.core.DataStore import androidx.datastore.preferences.core.Preferences @@ -6,7 +6,7 @@ import androidx.datastore.preferences.core.emptyPreferences import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -internal class FakePreferencesDataStore( +class FakePreferencesDataStore( initial: Preferences = emptyPreferences(), ) : DataStore { private val state = MutableStateFlow(initial) diff --git a/libs/user/impl/build.gradle.kts b/libs/user/impl/build.gradle.kts index 661c093..cbcf96f 100644 --- a/libs/user/impl/build.gradle.kts +++ b/libs/user/impl/build.gradle.kts @@ -38,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/commonTest/kotlin/dev/yaseyo/user/impl/UserRepositoryImplTest.kt b/libs/user/impl/src/commonTest/kotlin/dev/yaseyo/user/impl/UserRepositoryImplTest.kt index 26641cc..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 diff --git a/settings.gradle.kts b/settings.gradle.kts index 0ba7232..205473a 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -46,6 +46,7 @@ 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") From 2ceec29ca21b7f7df6a9b35b179b242d571a778d Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 24 Jul 2026 18:40:37 +0900 Subject: [PATCH 11/13] Implement MacroProgressRows widget and update HomeScreen to display macro status --- .../kotlin/dev/yaseyo/home/ui/HomeScreen.kt | 183 ++++++++++++------ .../kotlin/dev/yaseyo/home/ui/HomeUiState.kt | 15 +- .../dev/yaseyo/home/ui/HomeViewModel.kt | 11 ++ .../yaseyo/home/ui/widget/KcalProgressHero.kt | 4 +- .../home/ui/widget/MacroProgressRows.kt | 130 +++++++++++++ .../dev/yaseyo/home/util/DateTimeUtil.kt | 18 ++ .../kotlin/dev/yaseyo/design/YaseyoPreview.kt | 21 +- 7 files changed, 307 insertions(+), 75 deletions(-) create mode 100644 feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/MacroProgressRows.kt create mode 100644 feature/home/src/commonMain/kotlin/dev/yaseyo/home/util/DateTimeUtil.kt 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 index 84a2b8c..c9ab711 100644 --- a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt @@ -2,7 +2,11 @@ package dev.yaseyo.home.ui import androidx.compose.animation.AnimatedContent 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.material3.HorizontalDivider @@ -19,10 +23,11 @@ import androidx.lifecycle.compose.collectAsStateWithLifecycle import dev.yaseyo.design.YaseyoPreview import dev.yaseyo.design.YaseyoTheme import dev.yaseyo.home.ui.widget.KcalProgressHero -import kotlinx.datetime.TimeZone -import kotlinx.datetime.toLocalDateTime +import dev.yaseyo.home.ui.widget.MacroProgressRowData +import dev.yaseyo.home.ui.widget.MacroProgressRows +import dev.yaseyo.home.util.currentDayOfWeek +import dev.yaseyo.home.util.now import org.koin.compose.viewmodel.koinViewModel -import kotlin.time.Clock @Composable internal fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) { @@ -33,77 +38,120 @@ internal fun HomeScreen(viewModel: HomeViewModel = koinViewModel()) { @Composable private fun HomeScreen(state: HomeUiState) { - LazyColumn( + Column( modifier = Modifier.fillMaxSize(), ) { - stickyHeader { - Text( - modifier = Modifier.padding(16.dp), - text = Clock - .System - .now() - .toLocalDateTime(TimeZone.currentSystemDefault()) - .date - .toString(), - fontSize = 24.sp, - fontWeight = FontWeight.Medium, - color = YaseyoTheme.colors.contentPrimary, - ) - - HorizontalDivider() - } + Text( + modifier = Modifier.padding(16.dp), + text = "${currentDayOfWeek()} – ${now().date}", + fontSize = 24.sp, + fontWeight = FontWeight.Medium, + color = YaseyoTheme.colors.contentPrimary, + ) - item { - AnimatedContent( - modifier = Modifier.fillMaxSize(), - targetState = state, - contentKey = { - it::class.simpleName - }, - ) { state -> - when (state) { - is HomeUiState.Content -> { - KcalProgressHero( - modifier = Modifier.padding(16.dp), - progress = { - state.progress - }, - currentKcal = state.currentKcal, - baseTargetKcal = state.baseTargetKcal, - activityKcal = state.activityKcal, - ) - } + HorizontalDivider() - HomeUiState.Error -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { + 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), + ) { + item { Text( - text = "Something went wrong. Please try again.", - color = YaseyoTheme.colors.contentSecondary, - fontSize = 16.sp, + text = "Today's Progress", + color = YaseyoTheme.colors.contentPrimary, + fontSize = 24.sp, ) } - } - - HomeUiState.Initializing -> { - Box( - modifier = Modifier - .fillMaxSize() - .padding(16.dp), - contentAlignment = Alignment.Center, - ) { + item { + KcalProgressHero( + modifier = Modifier.padding(16.dp), + progress = { + state.progress + }, + currentKcal = state.currentKcal, + baseTargetKcal = state.baseTargetKcal, + activityKcal = state.activityKcal, + ) + } + item { + Spacer(modifier = Modifier.height(16.dp)) + } + item { Text( - text = "Loading...", - color = YaseyoTheme.colors.contentSecondary, - fontSize = 16.sp, + text = "Macro Progress", + color = YaseyoTheme.colors.contentPrimary, + fontSize = 24.sp, + ) + } + item { + Spacer(modifier = Modifier.height(16.dp)) + } + + 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, + ), + ), ) } } } + + 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, + ) + } + } } } } @@ -119,6 +167,17 @@ private fun HomeScreenContentPreview() = 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(), + ), ), ) } 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 index 85cb7eb..c70eeef 100644 --- a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeUiState.kt +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeUiState.kt @@ -8,7 +8,20 @@ internal sealed interface HomeUiState { val currentKcal: Long, val activityKcal: Long, val baseTargetKcal: Long, - ) : HomeUiState + val macroStatus: MacroStatus, + ) : 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 index 499cd59..4d46418 100644 --- a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt @@ -22,6 +22,17 @@ internal class HomeViewModel( 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(), + ), ) }, onFailure = { 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 index da87efa..4561de3 100644 --- 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 @@ -29,9 +29,7 @@ internal fun KcalProgressHero( Box(modifier = modifier, contentAlignment = Alignment.Center) { CircularProgressIndicator( - modifier = Modifier - .fillMaxSize() - .aspectRatio(1f), + modifier = Modifier.aspectRatio(1f), progress = progress, color = YaseyoTheme.colors.accentDefault, strokeWidth = 32.dp, 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/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/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt index 577da36..28f8b82 100644 --- a/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt +++ b/libs/design/src/commonMain/kotlin/dev/yaseyo/design/YaseyoPreview.kt @@ -4,6 +4,7 @@ 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 @@ -16,16 +17,18 @@ fun YaseyoPreview( ) { YaseyoTheme(darkTheme = darkTheme) { val colors = YaseyoTheme.colors - Scaffold( - containerColor = colors.backgroundBase, - ) { - Box( - modifier = Modifier - .padding(it) - .fillMaxSize(), - contentAlignment = Alignment.Center, + MaterialTheme { + Scaffold( + containerColor = colors.backgroundBase, ) { - composable() + Box( + modifier = Modifier + .padding(it) + .fillMaxSize(), + contentAlignment = Alignment.Center, + ) { + composable() + } } } } From ed81cdb9f5b8e068f1bb89e65088a8d78fb78735 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 24 Jul 2026 18:55:34 +0900 Subject: [PATCH 12/13] Add MealEntry and update DailyLog to include meal entries --- .../dev/yaseyo/dailylog/api/DailyLog.kt | 1 + .../dev/yaseyo/dailylog/api/MealEntry.kt | 21 +++++++++ .../dailylog/impl/network/DailyLogNet.kt | 4 +- .../dailylog/impl/network/FoodEntryNet.kt | 46 +++++++++++++++++++ 4 files changed, 71 insertions(+), 1 deletion(-) create mode 100644 libs/daily-log/api/src/commonMain/kotlin/dev/yaseyo/dailylog/api/MealEntry.kt create mode 100644 libs/daily-log/impl/src/commonMain/kotlin/dev/yaseyo/dailylog/impl/network/FoodEntryNet.kt 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 index d0157ee..afbdfb7 100644 --- 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 @@ -9,4 +9,5 @@ data class DailyLog( 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/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/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 index 6abab23..60eaef1 100644 --- 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 @@ -7,11 +7,12 @@ import kotlinx.datetime.LocalDate import kotlinx.serialization.Serializable @Serializable -data class DailyLogNet( +internal data class DailyLogNet( val date: String, val totals: Macros, val activityKcal: Double, val targets: MacroTarget, + val entries: List, ) { fun toDailyLog(): DailyLog = DailyLog( @@ -19,5 +20,6 @@ data class DailyLogNet( 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() }) From 0a956d1f839b140653abd4f1a13066896468bb10 Mon Sep 17 00:00:00 2001 From: Taki Date: Fri, 24 Jul 2026 19:07:46 +0900 Subject: [PATCH 13/13] Add MealEntryCard widget and display meal entries on HomeScreen --- .../kotlin/dev/yaseyo/home/ui/HomeScreen.kt | 92 ++++++++- .../kotlin/dev/yaseyo/home/ui/HomeUiState.kt | 3 + .../dev/yaseyo/home/ui/HomeViewModel.kt | 1 + .../yaseyo/home/ui/widget/MealEntryCard.kt | 192 ++++++++++++++++++ 4 files changed, 281 insertions(+), 7 deletions(-) create mode 100644 feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/widget/MealEntryCard.kt 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 index c9ab711..bfea775 100644 --- a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeScreen.kt @@ -1,6 +1,7 @@ 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 @@ -9,6 +10,7 @@ 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 @@ -20,14 +22,18 @@ 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()) { @@ -63,6 +69,7 @@ private fun HomeScreen(state: HomeUiState) { LazyColumn( horizontalAlignment = Alignment.CenterHorizontally, contentPadding = PaddingValues(16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), ) { item { Text( @@ -82,9 +89,6 @@ private fun HomeScreen(state: HomeUiState) { activityKcal = state.activityKcal, ) } - item { - Spacer(modifier = Modifier.height(16.dp)) - } item { Text( text = "Macro Progress", @@ -92,10 +96,6 @@ private fun HomeScreen(state: HomeUiState) { fontSize = 24.sp, ) } - item { - Spacer(modifier = Modifier.height(16.dp)) - } - item { MacroProgressRows( rows = listOf( @@ -120,6 +120,25 @@ private fun HomeScreen(state: HomeUiState) { ), ) } + + 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) + } } } @@ -178,10 +197,69 @@ private fun HomeScreenContentPreview() = 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() = 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 index c70eeef..e2ab1f1 100644 --- a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeUiState.kt +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeUiState.kt @@ -1,5 +1,7 @@ package dev.yaseyo.home.ui +import dev.yaseyo.dailylog.api.MealEntry + internal sealed interface HomeUiState { data object Initializing : HomeUiState @@ -9,6 +11,7 @@ internal sealed interface HomeUiState { val activityKcal: Long, val baseTargetKcal: Long, val macroStatus: MacroStatus, + val mealEntries: List, ) : HomeUiState { data class MacroStatus( val currentProtein: Double, 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 index 4d46418..45eaa1e 100644 --- a/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt +++ b/feature/home/src/commonMain/kotlin/dev/yaseyo/home/ui/HomeViewModel.kt @@ -33,6 +33,7 @@ internal class HomeViewModel( targetFat = it.targets.fat, fatProgress = (it.totals.fat / it.targets.fat).toFloat(), ), + mealEntries = it.mealEntries, ) }, onFailure = { 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"), + ), + ) + } + }