Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions LESSONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -184,6 +184,12 @@ State that captures the user's entire current configuration (e.g. "X axis = Cate

### Code Quality / Review

**Text-based unused-import scrubbing wrongly flags Compose's `getValue`/`setValue` delegate imports**
When trimming imports without a compiler (e.g. after deleting half a file in a build-less environment), a "does the identifier appear in the body?" check is right for almost every import but wrong for `androidx.compose.runtime.getValue`/`setValue`: property delegation (`var x by remember { mutableStateOf(...) }`, `val s by flow.collectAsState()`) uses those operators without their names ever appearing in the source. Removing them fails only at compile time. Whitelist the delegate-operator imports in any mechanical scrub, and treat `by` in a file as proof they are needed.

**Stage a deletion: supersede-and-annotate in one phase, delete against the manifest in the next**
When a new surface replaces old code, cutting the last references and deleting the code in the same change makes the diff unreviewable and the parity argument untestable. The pattern that worked here: the phase that ships the replacement leaves the old code compiled but unreferenced, annotated `@Suppress("unused")` with a comment naming its replacement (a removal manifest in the code itself). The deletion phase then verifies each annotation still holds, deletes, and proves the removal with greps for every deleted symbol and route string. The annotations make the dead set explicit to both reviewers and later sessions, and anything that regrew a reference in between fails the grep instead of silently surviving.

**Branch protection blocks force push — use merge, not rebase, for conflict resolution**
When a branch is protected against force push and upstream has moved on, `git rebase origin/main` rewrites local history that can no longer be pushed. The only forward path is `git merge origin/main`, which creates a merge commit but preserves the existing remote history. If both branches claimed the same version string, resolve by bumping the lower-priority branch's version upward in the same merge commit — don't leave the version collision for the reviewer to spot.

Expand Down
70 changes: 15 additions & 55 deletions app/src/main/java/com/mapgie/goflo/MainActivity.kt
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,6 @@ import com.mapgie.goflo.ui.screens.history.PeriodDetailScreen
import com.mapgie.goflo.ui.screens.history.PeriodDetailViewModel
import com.mapgie.goflo.ui.screens.home.HomeScreen
import com.mapgie.goflo.ui.screens.home.HomeViewModel
import com.mapgie.goflo.ui.screens.log.LogCategoryScreen
import com.mapgie.goflo.ui.screens.log.LogCategoryViewModel
import com.mapgie.goflo.ui.screens.dashboard.DashboardScreen
import com.mapgie.goflo.ui.screens.dashboard.DashboardViewModel
import com.mapgie.goflo.ui.screens.settings.PrivacyPolicyScreen
Expand Down Expand Up @@ -206,11 +204,12 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa
val appPrefs by app.preferencesStore.preferences.collectAsState(initial = AppPreferences())
val dashboardEnabled = appPrefs.dashboardEnabled

// Deep-link from the Quick Log widget: navigate to the category log screen for today.
// Deep-link from the Quick Log widget: open today's unified day screen
// with that category's input focused.
LaunchedEffect(pendingCategoryId) {
if (pendingCategoryId != -1L) {
navController.navigate(
Screen.LogCategory.newEntry(pendingCategoryId, java.time.LocalDate.now())
Screen.LogDay.forCategory(java.time.LocalDate.now(), pendingCategoryId)
)
}
}
Expand Down Expand Up @@ -377,23 +376,6 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa
)
}

composable(
route = Screen.LogPeriod.route,
arguments = listOf(
navArgument("periodId") { type = NavType.LongType; defaultValue = -1L },
navArgument("startDate") { type = NavType.StringType; nullable = true; defaultValue = null }
)
) { backStack ->
val periodId = backStack.arguments?.getLong("periodId") ?: -1L
val startDateStr = backStack.arguments?.getString("startDate")
val prefilledDate = startDateStr?.let { runCatching { java.time.LocalDate.parse(it) }.getOrNull() }
val vm: com.mapgie.goflo.ui.screens.log.LogPeriodViewModel = viewModel(
key = "log_${periodId}_${startDateStr}",
factory = com.mapgie.goflo.ui.screens.log.LogPeriodViewModel.Factory(app.repository, periodId, prefilledDate, app.trackingRepository, app, app.preferencesStore)
)
com.mapgie.goflo.ui.screens.log.LogPeriodScreen(viewModel = vm, onBack = { navController.popBackStack() })
}

composable(
route = Screen.PinSetup.route,
arguments = listOf(navArgument("changing") {
Expand Down Expand Up @@ -632,56 +614,34 @@ private fun MainNavHost(app: GoFloApplication, currentTheme: AppTheme, pendingCa
)
}

// ── Per-day category logging ─────────────────────────────────────────

composable(
route = Screen.LogCategory.route,
arguments = listOf(
navArgument("categoryId") { type = NavType.LongType },
navArgument("date") { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument("logId") { type = NavType.LongType; defaultValue = -1L }
)
) { backStack ->
val categoryId = backStack.arguments?.getLong("categoryId") ?: return@composable
val dateStr = backStack.arguments?.getString("date")
val logId = backStack.arguments?.getLong("logId")?.takeIf { it != -1L }
val prefilledDate = dateStr?.let { runCatching { java.time.LocalDate.parse(it) }.getOrNull() }
val vm: LogCategoryViewModel = viewModel(
key = "log_cat_${categoryId}_${dateStr}_${logId}",
factory = LogCategoryViewModel.Factory(
categoryId = categoryId,
prefilledDate = prefilledDate,
existingLogId = logId,
repository = app.trackingRepository
)
)
LogCategoryScreen(
viewModel = vm,
onNavigateBack = { navController.popBackStack() }
)
}

// ── Unified day logging (logging redesign Phase 5) ───────────────────
// Additive route: the LogPeriod and LogCategory destinations above
// stay registered and reachable until parity sign-off (Phase 8).
// ── Unified day logging ──────────────────────────────────────────────
// The one logging destination: a running period is a state of the
// day. Optional deep links focus one category's input or load one
// specific log for in-place editing.

composable(
route = Screen.LogDay.route,
arguments = listOf(
navArgument("date") { type = NavType.StringType; nullable = true; defaultValue = null }
navArgument("date") { type = NavType.StringType; nullable = true; defaultValue = null },
navArgument("categoryId") { type = NavType.LongType; defaultValue = -1L },
navArgument("logId") { type = NavType.LongType; defaultValue = -1L },
)
) { backStack ->
val dateStr = backStack.arguments?.getString("date")
val date = dateStr?.let { runCatching { java.time.LocalDate.parse(it) }.getOrNull() }
?: java.time.LocalDate.now()
val focusCategoryId = backStack.arguments?.getLong("categoryId")?.takeIf { it != -1L }
val editLogId = backStack.arguments?.getLong("logId")?.takeIf { it != -1L }
val vm: com.mapgie.goflo.ui.screens.log.LogViewModel = viewModel(
key = "log_day_$dateStr",
key = "log_day_${dateStr}_${focusCategoryId}_$editLogId",
factory = com.mapgie.goflo.ui.screens.log.LogViewModel.Factory(
repository = app.repository,
trackingRepository = app.trackingRepository,
date = date,
application = app,
preferencesStore = app.preferencesStore,
focusCategoryId = focusCategoryId,
editLogId = editLogId,
)
)
com.mapgie.goflo.ui.screens.log.LogScreen(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ data class AppPreferences(
/**
* The tracking category ID to open when the user taps the FAB (Quick Log).
* -1L means "Log Period" (the default). Any other value is a TrackingCategory.id
* and opens LogCategoryScreen for that category.
* and opens the unified day screen with that category focused.
*/
val quickLogCategoryId: Long = -1L,
/** Whether to show predicted future period days on the calendar. */
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package com.mapgie.goflo.ui.components

import androidx.compose.material3.DatePicker
import androidx.compose.material3.DatePickerDialog
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.rememberDatePickerState
import androidx.compose.runtime.Composable
import java.time.Instant
import java.time.LocalDate
import java.time.ZoneId

/**
* The one shared Material 3 date-picker dialog (logging redesign Phase 8
* consolidated the per-screen private copies into this component).
*
* Dates are converted through UTC in both directions so the picked calendar
* day never shifts with the device time zone. [minDate], when set, makes OK a
* no-op for earlier picks (used for period end dates, which cannot precede the
* start).
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun DatePickerDialogWrapper(
initial: LocalDate,
minDate: LocalDate? = null,
onConfirm: (LocalDate) -> Unit,
onDismiss: () -> Unit,
) {
val initialMillis = initial.atStartOfDay(ZoneId.of("UTC")).toInstant().toEpochMilli()
val pickerState = rememberDatePickerState(initialSelectedDateMillis = initialMillis)

DatePickerDialog(
onDismissRequest = onDismiss,
confirmButton = {
TextButton(onClick = {
val millis = pickerState.selectedDateMillis ?: return@TextButton
val picked = Instant.ofEpochMilli(millis).atZone(ZoneId.of("UTC")).toLocalDate()
if (minDate == null || !picked.isBefore(minDate)) {
onConfirm(picked)
}
}) { Text("OK") }
},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } },
) {
DatePicker(state = pickerState)
}
}
28 changes: 12 additions & 16 deletions app/src/main/java/com/mapgie/goflo/ui/components/DayLogSheet.kt
Original file line number Diff line number Diff line change
Expand Up @@ -67,12 +67,10 @@ fun DayLogSheet(
onDismiss: () -> Unit,
onEditPeriod: (Long) -> Unit,
onEditTrackingLog: (categoryId: Long, logId: Long) -> Unit,
/** Opens the full log menu so one category can be picked directly. */
onLogMore: () -> Unit,
/**
* Optional entry to the unified day screen (logging redesign Phase 5).
* Null hides the row; the classic per-screen actions above are unaffected.
*/
onOpenDayLog: (() -> Unit)? = null,
/** Opens the unified day screen for this day, the standard logging surface. */
onOpenDayLog: () -> Unit,
) {
val sheetState = rememberModalBottomSheetState()

Expand Down Expand Up @@ -142,7 +140,7 @@ fun DayLogSheet(
HorizontalDivider()

// Split tracked categories into those logged with the period (the
// ones that appear on the Log Period screen) and everything else.
// ones pinned into the day screen's flow context) and everything else.
val periodLinkedCats = if (period != null) {
categoryOrder.filter { catId ->
logsByCategory[catId]?.firstOrNull()?.category?.showInLogPeriod == true
Expand Down Expand Up @@ -217,22 +215,20 @@ fun DayLogSheet(
HorizontalDivider()
}

// ── Log more ──────────────────────────────────────────────────────
// ── Open the day / log more ───────────────────────────────────────

OutlinedButton(
onClick = { onDismiss(); onLogMore() },
onClick = { onDismiss(); onOpenDayLog() },
modifier = Modifier.fillMaxWidth()
) {
Text("Log more for this day")
Text("Open day log")
}

if (onOpenDayLog != null) {
TextButton(
onClick = { onDismiss(); onOpenDayLog() },
modifier = Modifier.fillMaxWidth()
) {
Text("Try the new day log (preview)")
}
TextButton(
onClick = { onDismiss(); onLogMore() },
modifier = Modifier.fillMaxWidth()
) {
Text("Log more for this day…")
}

Spacer(Modifier.height(8.dp))
Expand Down
44 changes: 12 additions & 32 deletions app/src/main/java/com/mapgie/goflo/ui/navigation/Screen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,6 @@ sealed class Screen(val route: String) {
data object Stats : Screen("stats")
data object StatsGrid : Screen("stats_grid")
data object Settings : Screen("settings")
data object LogPeriod : Screen("log_period?periodId={periodId}&startDate={startDate}") {
fun withId(periodId: Long, targetDate: LocalDate? = null) =
if (targetDate != null) "log_period?periodId=$periodId&startDate=$targetDate" else "log_period?periodId=$periodId"
val newEntry = "log_period?periodId=-1"
fun newEntryForDate(date: LocalDate) = "log_period?periodId=-1&startDate=$date"
}
data object PinSetup : Screen("pin_setup?changing={changing}") {
val newPin = "pin_setup?changing=false"
val changePin = "pin_setup?changing=true"
Expand Down Expand Up @@ -61,46 +55,32 @@ sealed class Screen(val route: String) {
fun newForCategory(categoryId: Long) = "edit_alarm?alarmId=-1&categoryId=$categoryId"
}

// ── Per-day category logging ────────────────────────────────────────────────

/**
* Route for logging or editing a tracking category entry.
* - [categoryId] — the TrackingCategory.id to log
* - [date] — ISO 8601 date string; omit to default to today
* - [logId] — the existing TrackingLog.id when editing; omit for a new entry
*/
data object LogCategory : Screen(
"log_category/{categoryId}?date={date}&logId={logId}"
) {
fun newEntry(categoryId: Long, date: LocalDate) =
"log_category/$categoryId?date=$date"

fun editEntry(categoryId: Long, logId: Long) =
"log_category/$categoryId?logId=$logId"
}

// ── Period detail (History drill-in) ───────────────────────────────────────

/**
* Read-only view of one period episode expanded into its individual days.
* Opened from a History card; day rows continue to [LogDay] and the Edit
* action continues to [LogPeriod].
* Opened from a History card; day rows and the top-bar action both
* continue to [LogDay].
*/
data object PeriodDetail : Screen("period_detail/{periodId}") {
fun forPeriod(periodId: Long) = "period_detail/$periodId"
}

// ── Unified day logging (logging redesign Phase 5) ─────────────────────────
// ── Unified day logging ────────────────────────────────────────────────────

/**
* Route for the unified day screen, where a running period is a state of
* the day rather than a separate destination.
*
* Additive: [LogPeriod] and [LogCategory] stay registered and reachable
* until the parity sign-off (removal is Phase 8 of the logging redesign).
* the day rather than a separate destination. Since Phase 8 of the logging
* redesign this is the only logging destination.
* - [date] — ISO 8601 date string; omit to default to today
* - [categoryId] — optional category to focus (expand and scroll context to)
* - [logId] — optional specific TrackingLog.id to load for in-place editing;
* the way to edit one particular log of an allow-multiple category
*/
data object LogDay : Screen("log_day?date={date}") {
data object LogDay : Screen("log_day?date={date}&categoryId={categoryId}&logId={logId}") {
fun forDate(date: LocalDate) = "log_day?date=$date"
fun forCategory(date: LocalDate, categoryId: Long) =
"log_day?date=$date&categoryId=$categoryId"
fun forLog(date: LocalDate, logId: Long) = "log_day?date=$date&logId=$logId"
}
}
Loading
Loading