diff --git a/app/app/src/main/java/mx/paputec/rachita/data/HabitRepositoryImpl.kt b/app/app/src/main/java/mx/paputec/rachita/data/HabitRepositoryImpl.kt new file mode 100644 index 0000000..b411511 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/data/HabitRepositoryImpl.kt @@ -0,0 +1,56 @@ +package mx.paputec.rachita.data + +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.map +import mx.paputec.rachita.data.local.HabitDao +import mx.paputec.rachita.data.local.HabitEntity +import mx.paputec.rachita.data.local.HabitLogEntity +import mx.paputec.rachita.domain.model.Habit +import mx.paputec.rachita.domain.model.HabitIcon +import mx.paputec.rachita.domain.model.HabitLog +import mx.paputec.rachita.domain.model.HabitPalette +import mx.paputec.rachita.domain.repository.HabitRepository + +class HabitRepositoryImpl( + private val dao: HabitDao, +) : HabitRepository { + + override val activeHabits: Flow> = + dao.observeActiveHabits().map { entities -> entities.map { it.toDomain() } } + + override val doneLogs: Flow> = + dao.observeDoneLogs().map { logs -> + logs.map { HabitLog(habitId = it.habitId, date = LocalDate.parse(it.date)) } + } + + override suspend fun toggleHabit(habitId: String, date: LocalDate) { + val iso = date.toString() + val current = dao.getLog(habitId, iso) + dao.upsertLog( + HabitLogEntity(habitId = habitId, date = iso, done = !(current?.done ?: false)), + ) + } + + override suspend fun seedDefaultHabitsIfEmpty() { + if (dao.countHabits() > 0) return + // PRD DA-02 example habits — copy exact from prototype. + val now = System.currentTimeMillis() + dao.insertHabits( + listOf( + HabitEntity("banarse", "Bañarse", "Antes de dormir", HabitIcon.Drop.name, HabitPalette.Mint.name, 0, true, now), + HabitEntity("dormir", "Dormir temprano", "A las 9:00 pm", HabitIcon.Moon.name, HabitPalette.Lilac.name, 1, true, now), + HabitEntity("escuela", "Ir a la escuela", "Sin faltar nunca", HabitIcon.School.name, HabitPalette.Coral.name, 2, true, now), + ), + ) + } +} + +private fun HabitEntity.toDomain() = Habit( + id = id, + name = name, + subtitle = subtitle, + icon = HabitIcon.entries.firstOrNull { it.name == icon } ?: HabitIcon.Drop, + palette = HabitPalette.entries.firstOrNull { it.name == palette } ?: HabitPalette.Mint, + sortOrder = sortOrder, +) diff --git a/app/app/src/main/java/mx/paputec/rachita/data/local/HabitDao.kt b/app/app/src/main/java/mx/paputec/rachita/data/local/HabitDao.kt new file mode 100644 index 0000000..75e5b3e --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/data/local/HabitDao.kt @@ -0,0 +1,30 @@ +package mx.paputec.rachita.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query +import androidx.room.Upsert +import kotlinx.coroutines.flow.Flow + +@Dao +interface HabitDao { + + @Query("SELECT * FROM habits WHERE active = 1 ORDER BY sortOrder") + fun observeActiveHabits(): Flow> + + @Query("SELECT * FROM habit_logs WHERE done = 1") + fun observeDoneLogs(): Flow> + + @Query("SELECT * FROM habit_logs WHERE habitId = :habitId AND date = :date") + suspend fun getLog(habitId: String, date: String): HabitLogEntity? + + @Upsert + suspend fun upsertLog(log: HabitLogEntity) + + @Query("SELECT COUNT(*) FROM habits") + suspend fun countHabits(): Int + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertHabits(habits: List) +} diff --git a/app/app/src/main/java/mx/paputec/rachita/data/local/HabitEntities.kt b/app/app/src/main/java/mx/paputec/rachita/data/local/HabitEntities.kt new file mode 100644 index 0000000..01e2aaf --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/data/local/HabitEntities.kt @@ -0,0 +1,25 @@ +package mx.paputec.rachita.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "habits") +data class HabitEntity( + @PrimaryKey val id: String, + val name: String, + val subtitle: String, + val icon: String, + val palette: String, + val sortOrder: Int, + val active: Boolean, + val createdAtEpochMs: Long, +) + +// One row per habit per day (CLAUDE.md §7). Date stored as ISO-8601 (yyyy-MM-dd) +// so range queries stay lexicographically correct without converters. +@Entity(tableName = "habit_logs", primaryKeys = ["habitId", "date"]) +data class HabitLogEntity( + val habitId: String, + val date: String, + val done: Boolean, +) diff --git a/app/app/src/main/java/mx/paputec/rachita/data/local/RachitaDatabase.kt b/app/app/src/main/java/mx/paputec/rachita/data/local/RachitaDatabase.kt new file mode 100644 index 0000000..3b18038 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/data/local/RachitaDatabase.kt @@ -0,0 +1,13 @@ +package mx.paputec.rachita.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [HabitEntity::class, HabitLogEntity::class], + version = 1, + exportSchema = false, +) +abstract class RachitaDatabase : RoomDatabase() { + abstract fun habitDao(): HabitDao +} diff --git a/app/app/src/main/java/mx/paputec/rachita/di/DataModule.kt b/app/app/src/main/java/mx/paputec/rachita/di/DataModule.kt index 0a900de..4452764 100644 --- a/app/app/src/main/java/mx/paputec/rachita/di/DataModule.kt +++ b/app/app/src/main/java/mx/paputec/rachita/di/DataModule.kt @@ -1,14 +1,20 @@ package mx.paputec.rachita.di import android.content.Context +import androidx.room.Room import dagger.Module import dagger.Provides import dagger.hilt.InstallIn import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.components.SingletonComponent import javax.inject.Singleton +import mx.paputec.rachita.data.HabitRepositoryImpl +import mx.paputec.rachita.data.local.HabitDao +import mx.paputec.rachita.data.local.RachitaDatabase import mx.paputec.rachita.data.preferences.ProfileRepositoryImpl +import mx.paputec.rachita.domain.repository.HabitRepository import mx.paputec.rachita.domain.repository.ProfileRepository +import mx.paputec.rachita.platform.battery.BatteryStatusMonitor @Module @InstallIn(SingletonComponent::class) @@ -18,4 +24,21 @@ object DataModule { @Singleton fun provideProfileRepository(@ApplicationContext context: Context): ProfileRepository = ProfileRepositoryImpl(context) + + @Provides + @Singleton + fun provideDatabase(@ApplicationContext context: Context): RachitaDatabase = + Room.databaseBuilder(context, RachitaDatabase::class.java, "rachita.db").build() + + @Provides + fun provideHabitDao(db: RachitaDatabase): HabitDao = db.habitDao() + + @Provides + @Singleton + fun provideHabitRepository(dao: HabitDao): HabitRepository = HabitRepositoryImpl(dao) + + @Provides + @Singleton + fun provideBatteryStatusMonitor(@ApplicationContext context: Context): BatteryStatusMonitor = + BatteryStatusMonitor(context) } diff --git a/app/app/src/main/java/mx/paputec/rachita/domain/StreakCalculator.kt b/app/app/src/main/java/mx/paputec/rachita/domain/StreakCalculator.kt new file mode 100644 index 0000000..20f0c1f --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/domain/StreakCalculator.kt @@ -0,0 +1,35 @@ +package mx.paputec.rachita.domain + +import java.time.LocalDate +import mx.paputec.rachita.domain.model.HabitLog + +/** + * Streak rules from PRD DA-11 (v1, no freezer): + * - A date is "secured" when every active habit has a done log on it. + * - The streak is the consecutive run of secured dates ending today — or ending + * yesterday while today is still in progress. Missing a full day resets to 0. + * + * Everything is computed from logs, never stored, so toggling a habit off + * (DA-03 reversibility) naturally rolls the streak back with no drift. + */ +object StreakCalculator { + + fun securedDates(doneLogs: List, activeHabitIds: Set): Set { + if (activeHabitIds.isEmpty()) return emptySet() + return doneLogs + .filter { it.habitId in activeHabitIds } + .groupBy({ it.date }, { it.habitId }) + .filterValues { it.toSet().containsAll(activeHabitIds) } + .keys + } + + fun currentStreak(secured: Set, today: LocalDate): Int { + var day = if (today in secured) today else today.minusDays(1) + var count = 0 + while (day in secured) { + count++ + day = day.minusDays(1) + } + return count + } +} diff --git a/app/app/src/main/java/mx/paputec/rachita/domain/model/Habit.kt b/app/app/src/main/java/mx/paputec/rachita/domain/model/Habit.kt new file mode 100644 index 0000000..70abc39 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/domain/model/Habit.kt @@ -0,0 +1,28 @@ +package mx.paputec.rachita.domain.model + +import java.time.LocalDate + +// Icon and palette are semantic keys; the UI layer maps them to vectors/colors +// so the domain stays Android-free (CLAUDE.md §6.2 spirit applied to the client). +enum class HabitIcon { Drop, Moon, School } + +enum class HabitPalette { Mint, Lilac, Coral } + +data class Habit( + val id: String, + val name: String, + val subtitle: String, + val icon: HabitIcon, + val palette: HabitPalette, + val sortOrder: Int, +) + +data class HabitLog( + val habitId: String, + val date: LocalDate, +) + +data class BatteryStatus( + val percent: Int, + val isCharging: Boolean, +) diff --git a/app/app/src/main/java/mx/paputec/rachita/domain/repository/HabitRepository.kt b/app/app/src/main/java/mx/paputec/rachita/domain/repository/HabitRepository.kt new file mode 100644 index 0000000..3316950 --- /dev/null +++ b/app/app/src/main/java/mx/paputec/rachita/domain/repository/HabitRepository.kt @@ -0,0 +1,20 @@ +package mx.paputec.rachita.domain.repository + +import java.time.LocalDate +import kotlinx.coroutines.flow.Flow +import mx.paputec.rachita.domain.model.Habit +import mx.paputec.rachita.domain.model.HabitLog + +interface HabitRepository { + /** Active habits ordered for display. */ + val activeHabits: Flow> + + /** Every log marked done, across all dates (small volume: habits × days). */ + val doneLogs: Flow> + + /** Flips today's done state for the habit (DA-03: toggle is reversible). */ + suspend fun toggleHabit(habitId: String, date: LocalDate) + + /** Inserts the three PRD §5.2 example habits on first run (DA-02). */ + suspend fun seedDefaultHabitsIfEmpty() +} diff --git a/app/app/src/test/java/mx/paputec/rachita/domain/StreakCalculatorTest.kt b/app/app/src/test/java/mx/paputec/rachita/domain/StreakCalculatorTest.kt new file mode 100644 index 0000000..f0e1224 --- /dev/null +++ b/app/app/src/test/java/mx/paputec/rachita/domain/StreakCalculatorTest.kt @@ -0,0 +1,55 @@ +package mx.paputec.rachita.domain + +import java.time.LocalDate +import mx.paputec.rachita.domain.model.HabitLog +import org.junit.Assert.assertEquals +import org.junit.Test + +class StreakCalculatorTest { + + private val habits = setOf("banarse", "dormir", "escuela") + private val today: LocalDate = LocalDate.of(2026, 7, 3) + + private fun securedDay(date: LocalDate): List = habits.map { HabitLog(it, date) } + + @Test + fun `day counts as secured only when every active habit is done`() { + val logs = securedDay(today) + listOf(HabitLog("banarse", today.minusDays(1))) + val secured = StreakCalculator.securedDates(logs, habits) + assertEquals(setOf(today), secured) + } + + @Test + fun `streak counts consecutive secured days including today`() { + val logs = securedDay(today) + securedDay(today.minusDays(1)) + securedDay(today.minusDays(2)) + val secured = StreakCalculator.securedDates(logs, habits) + assertEquals(3, StreakCalculator.currentStreak(secured, today)) + } + + @Test + fun `streak survives while today is still in progress`() { + val logs = securedDay(today.minusDays(1)) + securedDay(today.minusDays(2)) + val secured = StreakCalculator.securedDates(logs, habits) + assertEquals(2, StreakCalculator.currentStreak(secured, today)) + } + + @Test + fun `a missed full day resets the streak to zero`() { + val logs = securedDay(today.minusDays(2)) + securedDay(today.minusDays(3)) + val secured = StreakCalculator.securedDates(logs, habits) + assertEquals(0, StreakCalculator.currentStreak(secured, today)) + } + + @Test + fun `empty history yields zero streak`() { + assertEquals(0, StreakCalculator.currentStreak(emptySet(), today)) + } + + @Test + fun `unchecking a habit today rolls the streak back`() { + val fullYesterday = securedDay(today.minusDays(1)) + val partialToday = listOf(HabitLog("banarse", today), HabitLog("dormir", today)) + val secured = StreakCalculator.securedDates(fullYesterday + partialToday, habits) + assertEquals(1, StreakCalculator.currentStreak(secured, today)) + } +}