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
2 changes: 1 addition & 1 deletion app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ android {
minSdk = 31 // Android 12
targetSdk = 36
versionCode = 3_005_002
versionName = "3.5.2"
versionName = "3.5.3"

testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,16 @@
package com.markel.flowstate.core.designsystem.ui

import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.runtime.*
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.State
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.snapshotFlow
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlin.math.abs

Expand All @@ -16,21 +24,60 @@ fun rememberFabVisibilityState(
lazyListState: LazyListState,
forceVisible: Boolean = false,
scrollThreshold: Int = 60
): State<Boolean> = rememberFabVisibilityState(
scrollPosition = remember(lazyListState) {
snapshotFlow {
lazyListState.firstVisibleItemIndex to lazyListState.firstVisibleItemScrollOffset
}
},
forceVisible = forceVisible,
scrollThreshold = scrollThreshold,
)

/**
* Overload for the staggered grid used by the Flow screen.
*
* `LazyStaggeredGridState` exposes the exact same two fields the logic needs
* (`firstVisibleItemIndex` and `firstVisibleItemScrollOffset`), so both
* overloads share a single implementation.
*/
@Composable
fun rememberFabVisibilityState(
gridState: LazyStaggeredGridState,
forceVisible: Boolean = false,
scrollThreshold: Int = 60
): State<Boolean> = rememberFabVisibilityState(
scrollPosition = remember(gridState) {
snapshotFlow {
gridState.firstVisibleItemIndex to gridState.firstVisibleItemScrollOffset
}
},
forceVisible = forceVisible,
scrollThreshold = scrollThreshold,
)

/**
* Shared implementation, expressed only in terms of the scroll position so that
* `LazyListState` and `LazyStaggeredGridState` can both reuse it.
*/
@Composable
private fun rememberFabVisibilityState(
scrollPosition: Flow<Pair<Int, Int>>,
forceVisible: Boolean,
scrollThreshold: Int,
): State<Boolean> {
val fabVisible = rememberSaveable { mutableStateOf(true) }

if (forceVisible) {
fabVisible.value = true
}

LaunchedEffect(lazyListState) {
LaunchedEffect(scrollPosition) {
var previousIndex = 0
var previousOffset = 0
var accumulatedScroll = 0

snapshotFlow {
lazyListState.firstVisibleItemIndex to lazyListState.firstVisibleItemScrollOffset
}
scrollPosition
// Avoid processing if the state hasn't changed at all
.distinctUntilChanged()
.collect { (currentIndex, currentOffset) ->
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package com.markel.flowstate.feature.flow

import com.markel.flowstate.core.domain.CheckList
import com.markel.flowstate.core.domain.Idea
import com.markel.flowstate.core.domain.Task

/**
* Flat item model for the Flow screen.
*
* Every row the grid renders — including the section headers — is one of these.
* That makes the index reported by the `reorderable` library (`from.index` /
* `to.index`) map 1:1 onto `items[index]`, which removes the hardcoded
* `headerOffset` arithmetic that `SectionedFlowView` needs while headers are
* emitted as independent `item { }` blocks.
*
* Reordering is scoped to a single [Section]: a move is only valid when the
* dragged item and the target item share the same non-null [section]. Items whose
* [section] is `null` (headers) are fixed chrome and never move. Enforcing that
* is *not* just a data rule — the grid has to keep a drag from ever targeting a
* foreign row, see [isReorderTargetFor].
*
* Adding a future item type (drawings, recordings, …) means adding a variant
* here plus its card composable — the grid, the reorder state and the span
* logic stay untouched. That is what keeps a future single combined list cheap.
*/
sealed interface FlowItem {

/** The reorderable scope an item belongs to. */
enum class Section { TASKS, CHECKLISTS, IDEAS }

/** Section this item may be reordered within, or `null` for fixed chrome. */
val section: Section? get() = null

/**
* `true` when the item must span **every** column of the grid
* (`StaggeredGridItemSpan.FullLine`) instead of occupying a single one.
*
* Note this is not "fill the available width" — every item does that. It is
* "take all N columns instead of 1". Only the two card types opt out.
*/
val spanAllColumns: Boolean get() = true

// ── Tasks ─────────────────────────────────────────────────────────────────

data object TasksHeader : FlowItem

data class TaskRow(val task: Task) : FlowItem {
override val section: Section? get() = Section.TASKS
}

// ── CheckLists ────────────────────────────────────────────────────────────

data object CheckListsHeader : FlowItem

data class CheckListCard(val checkList: CheckList) : FlowItem {
override val section: Section? get() = Section.CHECKLISTS
override val spanAllColumns: Boolean get() = false
}

// ── Ideas ─────────────────────────────────────────────────────────────────

data object IdeasHeader : FlowItem

data class IdeaCard(val idea: Idea) : FlowItem {
override val section: Section? get() = Section.IDEAS
override val spanAllColumns: Boolean get() = false
}

// ── Chrome ────────────────────────────────────────────────────────────────

/**
* Empty room after the last section so the FAB never covers content.
*/
data object BottomSpacer : FlowItem
}

/**
* Whether a row of [this] section may be used as the swap target of the row
* currently being dragged ([draggedSection]).
*
* `reorderable` has **no way to veto a move**: `onMove` returns `Unit` and the
* library immediately assumes the list changed. Right after invoking it it
* *predicts* the dragged row's new slot (`predictedDraggingItemOffset`) and
* compensates the row's visual offset with that prediction while it waits for the
* next layout pass (up to `MoveItemsLayoutInfoUpdateMaxWaitDuration`, 1 s). If the
* move is then rejected by the ViewModel no layout pass ever disagrees with the
* prediction, so the dragged row is left translated by a delta that does not
* exist — the visible jump.
*
* The rejection therefore has to happen *before* the library chooses a target,
* which is exactly what `ReorderableItem(enabled = …)` does: disabled keys are
* removed from the library's `reorderableKeys` set and are never picked as a
* target.
*
* A `null` [section] (chrome rows) is never a target while a drag is in flight;
* chrome is not a `ReorderableItem` anyway. With no drag in flight
* ([draggedSection] is `null`) every row is a valid target again, so the grid can
* never be left locked by a drag that ended without its stop callback.
*/
internal fun FlowItem.Section?.isReorderTargetFor(draggedSection: FlowItem.Section?): Boolean =
draggedSection == null || this == draggedSection

/**
* Stable key for an item, used by the grid's `itemsIndexed`.
*
* Must be unique across **all** [FlowItem] variants: a collision between, say, a
* task and an idea would make Compose reuse the wrong composable and the wrong
* `SwipeToDismissBoxState`.
*
* [taskDeleteVersions] is folded into the task key on purpose. Bumping it after
* an undo gives the item a brand-new key, so Compose creates a fresh composable
* with a fresh `SwipeToDismissBoxState` instead of restoring the stale
* `EndToStart` saved state that would immediately re-trigger the deletion.
*/
internal fun FlowItem.stableKey(taskDeleteVersions: Map<Int, Int> = emptyMap()): Any =
when (this) {
FlowItem.TasksHeader -> "tasks_header"
is FlowItem.TaskRow -> Pair(task.id, taskDeleteVersions[task.id] ?: 0)
FlowItem.CheckListsHeader -> "checklists_header"
is FlowItem.CheckListCard -> "cl_${checkList.id}"
FlowItem.IdeasHeader -> "ideas_header"
is FlowItem.IdeaCard -> "idea_${idea.id}"
FlowItem.BottomSpacer -> "bottom_spacer"
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ package com.markel.flowstate.feature.flow

import android.content.res.Configuration
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.lazy.staggeredgrid.LazyStaggeredGridState
import androidx.compose.foundation.lazy.staggeredgrid.rememberLazyStaggeredGridState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.*
import androidx.compose.runtime.*
Expand Down Expand Up @@ -73,12 +73,12 @@ fun FlowScreen(
val currentTabId = selectedCategoryId ?: Category.GENERAL_ID

// Registry of each tab's LazyListState so the FAB can read the active list state from a single variable
val tabListStates = remember { mutableStateMapOf<Int, LazyListState>() }
val tabGridStates = remember { mutableStateMapOf<Int, LazyStaggeredGridState>() }

// ── FAB ──────────
val activeListState = tabListStates[currentTabId] ?: rememberLazyListState()
val activeGridState = tabGridStates[currentTabId] ?: rememberLazyStaggeredGridState()
val fabVisible by rememberFabVisibilityState(
lazyListState = activeListState,
gridState = activeGridState,
forceVisible = allEmpty
)

Expand Down Expand Up @@ -107,27 +107,25 @@ fun FlowScreen(
}

saveableStateHolder.SaveableStateProvider(currentTabId) {
val tabListState = rememberLazyListState()
val tabGridState = rememberLazyStaggeredGridState()

// Publish this tab's LazyListState to the shared registry
// so the FAB (outside SaveableStateProvider) can read it.
SideEffect { tabListStates[currentTabId] = tabListState }
// Publish this tab's grid state to the shared registry so the
// FAB (outside SaveableStateProvider) can read it.
SideEffect { tabGridStates[currentTabId] = tabGridState }

SectionedFlowView(
uiState = flowUiState,
onScrolled = { isHeaderMinimized = true },
onItemReorder = { from, to -> flowViewModel.onItemReorder(from, to) },
onReorderCommit = flowViewModel::commitReorder,
onTaskClick = { onNavigateToTaskEditor(it.id) },
onTaskDelete = { task -> flowViewModel.onTaskSwiped(task) },
onTaskToggle = { taskViewModel.toggleTaskDone(it) },
onTaskReorder = { from, to -> flowViewModel.onTaskReorder(from, to) },
onIdeaClick = { onNavigateToIdeaEditor(it.id) },
onIdeaReorder = { from, to -> flowViewModel.onIdeaReorder(from, to) },
onCheckListClick = { onNavigateToCheckListEditor(it.id, it.categoryId) },
onCheckListReorder = { from, to -> flowViewModel.onCheckListReorder(from, to) },
showPermissionBanner = showPermissionBanner,
taskDeleteVersions = taskDeleteVersions,
categoriesEnabled = categoriesEnabled,
outerListState = tabListState,
gridState = tabGridState,
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,5 +15,46 @@ sealed interface FlowUiState {
val selectedCategoryId: Int? = Category.GENERAL_ID,
val categoriesEnabled: Boolean = false,
val pendingTaskCounts: Map<Int?, Int> = emptyMap()
) : FlowUiState
}
) : FlowUiState {

/**
* Flat, render-ready list consumed by `SectionedFlowView`.
*/
val items: List<FlowItem> = buildList {
if (tasks.isNotEmpty()) {
add(FlowItem.TasksHeader)
tasks.forEach { add(FlowItem.TaskRow(it)) }
}
if (ideas.isNotEmpty()) {
add(FlowItem.IdeasHeader)
ideas.forEach { add(FlowItem.IdeaCard(it)) }
}
if (checkLists.isNotEmpty()) {
add(FlowItem.CheckListsHeader)
checkLists.forEach { add(FlowItem.CheckListCard(it)) }
}
// Only when there is something to scroll: an empty screen shows
// EmptyStateView and must keep `items.isEmpty()` true.
if (isNotEmpty()) add(FlowItem.BottomSpacer)
}
}
}

/**
* Rebuilds a [FlowUiState.Success] from an already-reordered [items] list.
*
* Used by the reorder callback: a drag hands back a shuffled `List<FlowItem>`,
* and this projects it back onto the three typed lists. `filterIsInstance`
* preserves the relative order of each section and the assignment order
* (tasks → ideas → checklists) matches how [FlowUiState.Success.items] is built,
* so the resulting `items` mirrors the list that was passed in.
*
* Chrome items (headers) are dropped — they are re-emitted by `items` in their
* canonical position. As a side effect a cross-section move (which the
* caller rejects anyway) degrades safely instead of corrupting a typed list.
*/
internal fun FlowUiState.Success.withItems(items: List<FlowItem>): FlowUiState.Success = copy(
tasks = items.filterIsInstance<FlowItem.TaskRow>().map { it.task },
checkLists = items.filterIsInstance<FlowItem.CheckListCard>().map { it.checkList },
ideas = items.filterIsInstance<FlowItem.IdeaCard>().map { it.idea },
)
Loading
Loading