Remove grouped view mode — dense grid is the only view
Deleted: ViewMode enum, UnifiedPhotoScroll, DaySubHeader, PhotoThumb (grouped-mode cell), view toggle button, groupedListState, groupedScrollIndex. BrowseScreen is now ~100 lines simpler. DensePhotoGrid is the single grid implementation. FolderJumpList and FolderDividerHeader remain.
This commit is contained in:
@@ -1,44 +1,22 @@
|
|||||||
package com.bolenpad.photophetch.ui
|
package com.bolenpad.photophetch.ui
|
||||||
|
|
||||||
import androidx.compose.foundation.Image
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.*
|
import androidx.compose.foundation.layout.*
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material.icons.filled.Image
|
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
|
||||||
import androidx.compose.material.icons.filled.Warning
|
import androidx.compose.material.icons.filled.Warning
|
||||||
import androidx.compose.material3.*
|
import androidx.compose.material3.*
|
||||||
import androidx.compose.runtime.*
|
import androidx.compose.runtime.*
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.drawWithContent
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.ImageBitmap
|
|
||||||
import androidx.compose.ui.graphics.toComposeImageBitmap
|
|
||||||
import androidx.compose.ui.layout.ContentScale
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import com.bolenpad.photophetch.model.DeviceInfo
|
|
||||||
import com.bolenpad.photophetch.model.MediaType
|
|
||||||
import com.bolenpad.photophetch.model.PhonePhoto
|
import com.bolenpad.photophetch.model.PhonePhoto
|
||||||
import com.bolenpad.photophetch.util.ThumbnailCache
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import java.time.ZoneId
|
||||||
import org.jetbrains.skia.Image as SkiaImage
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
enum class ViewMode { GROUPED, DENSE }
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BrowseScreen(state: AppState) {
|
fun BrowseScreen(state: AppState) {
|
||||||
@@ -49,18 +27,15 @@ fun BrowseScreen(state: AppState) {
|
|||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
var columnCount by remember { mutableStateOf(4) }
|
var columnCount by remember { mutableStateOf(4) }
|
||||||
var viewMode by remember { mutableStateOf(ViewMode.GROUPED) }
|
|
||||||
|
|
||||||
BrowseTopBar(
|
BrowseTopBar(
|
||||||
deviceName = device?.displayName ?: "Browse Photos",
|
deviceName = device?.displayName ?: "Browse Photos",
|
||||||
selectedCount = selectedPhotos.size,
|
selectedCount = selectedPhotos.size,
|
||||||
columnCount = columnCount,
|
columnCount = columnCount,
|
||||||
viewMode = viewMode,
|
|
||||||
onBack = { state.navigateTo(Screen.CONNECT) },
|
onBack = { state.navigateTo(Screen.CONNECT) },
|
||||||
onSelectNone = { state.selectNone() },
|
onSelectNone = { state.selectNone() },
|
||||||
onImport = { state.prepareImport() },
|
onImport = { state.prepareImport() },
|
||||||
onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) },
|
onColumnChange = { columnCount = (columnCount + it).coerceIn(2, 8) },
|
||||||
onToggleView = { viewMode = if (viewMode == ViewMode.GROUPED) ViewMode.DENSE else ViewMode.GROUPED },
|
|
||||||
)
|
)
|
||||||
|
|
||||||
if (state.shootingInHeic && !config.heicWarningDismissed) {
|
if (state.shootingInHeic && !config.heicWarningDismissed) {
|
||||||
@@ -78,44 +53,22 @@ fun BrowseScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
is PhotoListState.Loaded -> {
|
is PhotoListState.Loaded -> {
|
||||||
// Build ordered list of (folder, photos-by-day) for the unified scroll
|
|
||||||
val folderSections = buildFolderSections(pl)
|
val folderSections = buildFolderSections(pl)
|
||||||
val groupedListState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val denseListState = rememberLazyListState()
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
val listState = if (viewMode == ViewMode.GROUPED) groupedListState else denseListState
|
// Folder scroll index for jump list
|
||||||
|
val folderScrollIndex = remember(folderSections, columnCount) {
|
||||||
// Grouped mode: folder scroll index (accounts for day headers)
|
|
||||||
val groupedScrollIndex = remember(folderSections, columnCount) {
|
|
||||||
var idx = 0
|
var idx = 0
|
||||||
folderSections.associate { (folder, byDay) ->
|
folderSections.associate { section ->
|
||||||
val startIdx = idx
|
val startIdx = idx
|
||||||
idx += 1 // folder header
|
idx += 1 // folder header
|
||||||
byDay.forEach { (_, dayPhotos) ->
|
idx += ((section.allPhotos.size + columnCount - 1) / columnCount)
|
||||||
if (dayPhotos.size >= 3) idx += 1
|
section.folder.name to startIdx
|
||||||
idx += ((dayPhotos.size + columnCount - 1) / columnCount)
|
|
||||||
}
|
|
||||||
folder.name to startIdx
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dense mode: folder scroll index (folder header + photo rows only)
|
val activeFolder by remember(folderScrollIndex) {
|
||||||
val denseScrollIndex = remember(folderSections, columnCount) {
|
|
||||||
var idx = 0
|
|
||||||
folderSections.associate { folderSection ->
|
|
||||||
val startIdx = idx
|
|
||||||
idx += 1 // folder header
|
|
||||||
idx += ((folderSection.allPhotos.size + columnCount - 1) / columnCount)
|
|
||||||
folderSection.folder.name to startIdx
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val folderScrollIndex = if (viewMode == ViewMode.GROUPED) groupedScrollIndex else denseScrollIndex
|
|
||||||
|
|
||||||
// Scroll spy — keyed on viewMode so it recomposes when switching
|
|
||||||
val activeFolder by key(viewMode) {
|
|
||||||
remember {
|
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
val firstVisible = listState.firstVisibleItemIndex
|
val firstVisible = listState.firstVisibleItemIndex
|
||||||
folderScrollIndex.entries
|
folderScrollIndex.entries
|
||||||
@@ -124,16 +77,14 @@ fun BrowseScreen(state: AppState) {
|
|||||||
?: folderSections.firstOrNull()?.folder?.name
|
?: folderSections.firstOrNull()?.folder?.name
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
// Left: folder jump list — jumps in whichever list is active
|
|
||||||
FolderJumpList(
|
FolderJumpList(
|
||||||
folders = pl.folders,
|
folders = pl.folders,
|
||||||
activeFolder = activeFolder,
|
activeFolder = activeFolder,
|
||||||
selectedPhotos = selectedPhotos,
|
selectedPhotos = selectedPhotos,
|
||||||
onJump = { folderName ->
|
onJump = { name ->
|
||||||
val idx = folderScrollIndex[folderName] ?: return@FolderJumpList
|
val idx = folderScrollIndex[name] ?: return@FolderJumpList
|
||||||
scope.launch { listState.animateScrollToItem(idx) }
|
scope.launch { listState.animateScrollToItem(idx) }
|
||||||
},
|
},
|
||||||
modifier = Modifier.width(190.dp).fillMaxHeight(),
|
modifier = Modifier.width(190.dp).fillMaxHeight(),
|
||||||
@@ -141,59 +92,24 @@ fun BrowseScreen(state: AppState) {
|
|||||||
|
|
||||||
VerticalDivider()
|
VerticalDivider()
|
||||||
|
|
||||||
// Right: swap between grouped and dense view
|
DensePhotoGrid(
|
||||||
when (viewMode) {
|
|
||||||
ViewMode.GROUPED -> UnifiedPhotoScroll(
|
|
||||||
folderSections = folderSections,
|
|
||||||
selectedPhotos = selectedPhotos,
|
|
||||||
device = device,
|
|
||||||
adbBin = config.adbPath ?: "adb",
|
|
||||||
listState = groupedListState,
|
|
||||||
columnCount = columnCount,
|
|
||||||
onTogglePhoto = { state.togglePhotoSelection(it) },
|
|
||||||
onSelectAllFolder = { folder ->
|
|
||||||
folder.forEach { p ->
|
|
||||||
if (p !in selectedPhotos) state.togglePhotoSelection(p)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDeselectAllFolder = { folder ->
|
|
||||||
folder.forEach { p ->
|
|
||||||
if (p in selectedPhotos) state.togglePhotoSelection(p)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onSelectDay = { dayPhotos ->
|
|
||||||
val allSelected = dayPhotos.all { it in selectedPhotos }
|
|
||||||
dayPhotos.forEach { p ->
|
|
||||||
val inSet = p in selectedPhotos
|
|
||||||
if (allSelected && inSet) state.togglePhotoSelection(p)
|
|
||||||
else if (!allSelected && !inSet) state.togglePhotoSelection(p)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
ViewMode.DENSE -> DensePhotoGrid(
|
|
||||||
folderSections = folderSections,
|
folderSections = folderSections,
|
||||||
selectedPhotos = selectedPhotos,
|
selectedPhotos = selectedPhotos,
|
||||||
device = device,
|
device = device,
|
||||||
adbBin = config.adbPath ?: "adb",
|
adbBin = config.adbPath ?: "adb",
|
||||||
columnCount = columnCount,
|
columnCount = columnCount,
|
||||||
listState = denseListState,
|
listState = listState,
|
||||||
onTogglePhoto = { state.togglePhotoSelection(it) },
|
onTogglePhoto = { state.togglePhotoSelection(it) },
|
||||||
onSelectAllFolder = { folder ->
|
onSelectAllFolder = { folder ->
|
||||||
folder.forEach { p ->
|
folder.forEach { p -> if (p !in selectedPhotos) state.togglePhotoSelection(p) }
|
||||||
if (p !in selectedPhotos) state.togglePhotoSelection(p)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
onDeselectAllFolder = { folder ->
|
onDeselectAllFolder = { folder ->
|
||||||
folder.forEach { p ->
|
folder.forEach { p -> if (p in selectedPhotos) state.togglePhotoSelection(p) }
|
||||||
if (p in selectedPhotos) state.togglePhotoSelection(p)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
is PhotoListState.Error -> {
|
is PhotoListState.Error -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
Column(horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
@@ -208,10 +124,10 @@ fun BrowseScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Data helpers ──────────────────────────────────────────────────────────────
|
// ─── Data helpers ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> {
|
private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> {
|
||||||
val zoneId = java.time.ZoneId.systemDefault()
|
val zoneId = ZoneId.systemDefault()
|
||||||
return pl.folders.map { folder ->
|
return pl.folders.map { folder ->
|
||||||
val photos = pl.all
|
val photos = pl.all
|
||||||
.filter { it.sourceFolder == folder.name }
|
.filter { it.sourceFolder == folder.name }
|
||||||
@@ -225,7 +141,7 @@ private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Left jump list ───────────────────────────────────────────────────────────
|
// ─── Folder jump list ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FolderJumpList(
|
private fun FolderJumpList(
|
||||||
@@ -244,38 +160,24 @@ private fun FolderJumpList(
|
|||||||
|
|
||||||
if (cameraRoll.isNotEmpty()) {
|
if (cameraRoll.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text("CAMERA", style = MaterialTheme.typography.labelSmall,
|
||||||
"CAMERA",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 2.dp),
|
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 2.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
items(cameraRoll.size) { i ->
|
items(cameraRoll.size) { i ->
|
||||||
JumpRow(
|
JumpRow(cameraRoll[i], cameraRoll[i].name == activeFolder,
|
||||||
folder = cameraRoll[i],
|
selectedPhotos.count { it.sourceFolder == cameraRoll[i].name }) { onJump(cameraRoll[i].name) }
|
||||||
isActive = cameraRoll[i].name == activeFolder,
|
|
||||||
selectedCount = selectedPhotos.count { it.sourceFolder == cameraRoll[i].name },
|
|
||||||
onClick = { onJump(cameraRoll[i].name) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (other.isNotEmpty()) {
|
if (other.isNotEmpty()) {
|
||||||
item {
|
item {
|
||||||
Text(
|
Text("OTHER", style = MaterialTheme.typography.labelSmall,
|
||||||
"OTHER",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 2.dp),
|
modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 2.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
items(other.size) { i ->
|
items(other.size) { i ->
|
||||||
JumpRow(
|
JumpRow(other[i], other[i].name == activeFolder,
|
||||||
folder = other[i],
|
selectedPhotos.count { it.sourceFolder == other[i].name }) { onJump(other[i].name) }
|
||||||
isActive = other[i].name == activeFolder,
|
|
||||||
selectedCount = selectedPhotos.count { it.sourceFolder == other[i].name },
|
|
||||||
onClick = { onJump(other[i].name) },
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -283,37 +185,20 @@ private fun FolderJumpList(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun JumpRow(
|
private fun JumpRow(folder: FolderInfo, isActive: Boolean, selectedCount: Int, onClick: () -> Unit) {
|
||||||
folder: FolderInfo,
|
|
||||||
isActive: Boolean,
|
|
||||||
selectedCount: Int,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier.fillMaxWidth().clickable(onClick = onClick)
|
||||||
.fillMaxWidth()
|
.background(if (isActive) MaterialTheme.colorScheme.secondaryContainer else MaterialTheme.colorScheme.surface)
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.background(
|
|
||||||
if (isActive) MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surface,
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
) {
|
) {
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(folder.name, style = MaterialTheme.typography.bodySmall,
|
||||||
folder.name,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
|
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
maxLines = 1,
|
maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||||
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
Text("${folder.photoCount}", style = MaterialTheme.typography.labelSmall,
|
||||||
)
|
color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||||
Text(
|
|
||||||
"${folder.photoCount}",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (selectedCount > 0) {
|
if (selectedCount > 0) {
|
||||||
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
||||||
@@ -323,94 +208,7 @@ private fun JumpRow(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Right unified scroll ─────────────────────────────────────────────────────
|
// ─── Folder divider header ────────────────────────────────────────────────────
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun UnifiedPhotoScroll(
|
|
||||||
folderSections: List<FolderSection>,
|
|
||||||
selectedPhotos: Set<PhonePhoto>,
|
|
||||||
device: com.bolenpad.photophetch.model.DeviceInfo?,
|
|
||||||
adbBin: String,
|
|
||||||
listState: LazyListState,
|
|
||||||
columnCount: Int,
|
|
||||||
onTogglePhoto: (PhonePhoto) -> Unit,
|
|
||||||
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
|
|
||||||
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
|
|
||||||
onSelectDay: (List<PhonePhoto>) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
|
|
||||||
// Column count — adjustable via +/- buttons in the top bar
|
|
||||||
LazyColumn(
|
|
||||||
state = listState,
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(bottom = 32.dp),
|
|
||||||
) {
|
|
||||||
folderSections.forEach { section ->
|
|
||||||
val folderSelected = section.allPhotos.count { it in selectedPhotos }
|
|
||||||
val allFolderSelected = folderSelected == section.allPhotos.size
|
|
||||||
|
|
||||||
// ── Folder divider header ──────────────────────────────────────
|
|
||||||
item(key = "folder-${section.folder.name}") {
|
|
||||||
FolderDividerHeader(
|
|
||||||
name = section.folder.name,
|
|
||||||
total = section.allPhotos.size,
|
|
||||||
selected = folderSelected,
|
|
||||||
allSelected = allFolderSelected,
|
|
||||||
onSelectAll = { onSelectAllFolder(section.allPhotos) },
|
|
||||||
onDeselectAll = { onDeselectAllFolder(section.allPhotos) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Day groups — adaptive headers ──────────────────────────────
|
|
||||||
// Dense days (≥3 photos): full header with count + Select day button
|
|
||||||
// Sparse days (<3 photos): small inline date chip on first photo of the day
|
|
||||||
section.byDay.forEach { (day, dayPhotos) ->
|
|
||||||
val daySelected = dayPhotos.count { it in selectedPhotos }
|
|
||||||
val isDense = dayPhotos.size >= 3
|
|
||||||
|
|
||||||
if (isDense) {
|
|
||||||
item(key = "day-${section.folder.name}-$day") {
|
|
||||||
DaySubHeader(
|
|
||||||
day = day,
|
|
||||||
total = dayPhotos.size,
|
|
||||||
selected = daySelected,
|
|
||||||
onSelectDay = { onSelectDay(dayPhotos) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val rows = dayPhotos.chunked(columnCount)
|
|
||||||
items(
|
|
||||||
count = rows.size,
|
|
||||||
key = { idx -> "row-${section.folder.name}-$day-$idx" },
|
|
||||||
) { rowIdx ->
|
|
||||||
val rowPhotos = rows[rowIdx]
|
|
||||||
val isFirstRow = rowIdx == 0
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
|
||||||
) {
|
|
||||||
rowPhotos.forEachIndexed { colIdx, photo ->
|
|
||||||
val showDateChip = !isDense && isFirstRow && colIdx == 0
|
|
||||||
PhotoThumb(
|
|
||||||
photo = photo,
|
|
||||||
isSelected = photo in selectedPhotos,
|
|
||||||
device = device,
|
|
||||||
adbBin = adbBin,
|
|
||||||
onToggle = { onTogglePhoto(photo) },
|
|
||||||
dateChip = if (showDateChip) day else null,
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
repeat(columnCount - rowPhotos.size) { Spacer(Modifier.weight(1f)) }
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(3.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
internal fun FolderDividerHeader(
|
internal fun FolderDividerHeader(
|
||||||
@@ -440,55 +238,19 @@ internal fun FolderDividerHeader(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
TextButton(
|
TextButton(onClick = onSelectAll, enabled = !allSelected,
|
||||||
onClick = onSelectAll,
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
enabled = !allSelected,
|
Text("Select all", style = MaterialTheme.typography.labelMedium)
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
}
|
||||||
) { Text("Select all", style = MaterialTheme.typography.labelMedium) }
|
|
||||||
if (selected > 0) {
|
if (selected > 0) {
|
||||||
TextButton(
|
TextButton(onClick = onDeselectAll,
|
||||||
onClick = onDeselectAll,
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp)) {
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
Text("Deselect all", style = MaterialTheme.typography.labelMedium)
|
||||||
) { Text("Deselect all", style = MaterialTheme.typography.labelMedium) }
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DaySubHeader(
|
|
||||||
day: java.time.LocalDate,
|
|
||||||
total: Int,
|
|
||||||
selected: Int,
|
|
||||||
onSelectDay: () -> Unit,
|
|
||||||
) {
|
|
||||||
val fmt = remember { java.time.format.DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy") }
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth()
|
|
||||||
.padding(start = 12.dp, end = 8.dp, top = 10.dp, bottom = 4.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text(fmt.format(day), style = MaterialTheme.typography.bodyMedium, fontWeight = FontWeight.SemiBold)
|
|
||||||
Text(
|
|
||||||
if (selected > 0) "$selected / $total selected" else "$total photos",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = if (selected > 0) MaterialTheme.colorScheme.primary
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
onClick = onSelectDay,
|
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
if (selected == total) "Deselect day" else "Select day",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Top bar ──────────────────────────────────────────────────────────────────
|
// ─── Top bar ──────────────────────────────────────────────────────────────────
|
||||||
@@ -499,12 +261,10 @@ private fun BrowseTopBar(
|
|||||||
deviceName: String,
|
deviceName: String,
|
||||||
selectedCount: Int,
|
selectedCount: Int,
|
||||||
columnCount: Int,
|
columnCount: Int,
|
||||||
viewMode: ViewMode,
|
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSelectNone: () -> Unit,
|
onSelectNone: () -> Unit,
|
||||||
onImport: () -> Unit,
|
onImport: () -> Unit,
|
||||||
onColumnChange: (Int) -> Unit,
|
onColumnChange: (Int) -> Unit,
|
||||||
onToggleView: () -> Unit,
|
|
||||||
) {
|
) {
|
||||||
TopAppBar(
|
TopAppBar(
|
||||||
title = { Text(deviceName) },
|
title = { Text(deviceName) },
|
||||||
@@ -514,15 +274,6 @@ private fun BrowseTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
// View mode toggle
|
|
||||||
FilterChip(
|
|
||||||
selected = viewMode == ViewMode.DENSE,
|
|
||||||
onClick = onToggleView,
|
|
||||||
label = { Text(if (viewMode == ViewMode.DENSE) "Dense" else "Grouped",
|
|
||||||
style = MaterialTheme.typography.labelMedium) },
|
|
||||||
modifier = Modifier.padding(end = 4.dp),
|
|
||||||
)
|
|
||||||
// Grid size controls
|
|
||||||
IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) {
|
IconButton(onClick = { onColumnChange(-1) }, enabled = columnCount > 2) {
|
||||||
Text("−", style = MaterialTheme.typography.titleMedium)
|
Text("−", style = MaterialTheme.typography.titleMedium)
|
||||||
}
|
}
|
||||||
@@ -551,121 +302,17 @@ private fun HeicWarningBanner(onDismiss: () -> Unit) {
|
|||||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
|
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||||
) {
|
) {
|
||||||
Row(
|
Row(modifier = Modifier.padding(12.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||||
modifier = Modifier.padding(12.dp),
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp))
|
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold,
|
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold,
|
||||||
style = MaterialTheme.typography.bodySmall)
|
style = MaterialTheme.typography.bodySmall)
|
||||||
Text(
|
Text("Files will be converted to JPEG. To avoid: Settings → Camera → Formats → Most Compatible.",
|
||||||
"Files will be converted to JPEG. To avoid: Settings → Camera → Formats → Most Compatible.",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
color = MaterialTheme.colorScheme.onTertiaryContainer)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
TextButton(onClick = onDismiss) { Text("Dismiss") }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Photo thumbnail cell ─────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PhotoThumb(
|
|
||||||
photo: PhonePhoto,
|
|
||||||
isSelected: Boolean,
|
|
||||||
device: DeviceInfo?,
|
|
||||||
adbBin: String,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
dateChip: LocalDate? = null,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
|
|
||||||
var thumbFailed by remember(photo.devicePath) { mutableStateOf(false) }
|
|
||||||
|
|
||||||
LaunchedEffect(photo.devicePath) {
|
|
||||||
if (device?.adbSerial != null && !thumbFailed && thumbnail == null) {
|
|
||||||
val bytes = withContext(Dispatchers.IO) {
|
|
||||||
ThumbnailCache.get(adbBin, device.adbSerial, photo.devicePath, photo.filename)
|
|
||||||
}
|
|
||||||
if (bytes != null) {
|
|
||||||
runCatching {
|
|
||||||
thumbnail = SkiaImage.makeFromEncoded(bytes).toComposeImageBitmap()
|
|
||||||
}.onFailure { thumbFailed = true }
|
|
||||||
} else {
|
|
||||||
thumbFailed = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.aspectRatio(1f)
|
|
||||||
.clickable(onClick = onToggle)
|
|
||||||
.then(if (isSelected) Modifier.border(3.dp, MaterialTheme.colorScheme.primary) else Modifier),
|
|
||||||
) {
|
|
||||||
if (thumbnail != null) {
|
|
||||||
Image(
|
|
||||||
bitmap = thumbnail!!,
|
|
||||||
contentDescription = photo.filename,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Box(
|
|
||||||
Modifier.fillMaxSize().background(MaterialTheme.colorScheme.surfaceVariant),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
Icons.Default.Image,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = MaterialTheme.colorScheme.outline,
|
|
||||||
modifier = Modifier.size(24.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Badges top-left
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.align(Alignment.TopStart).padding(3.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(2.dp),
|
|
||||||
) {
|
|
||||||
if (photo.isHeic) {
|
|
||||||
Badge(containerColor = MaterialTheme.colorScheme.tertiaryContainer) {
|
|
||||||
Text("HEIC", style = MaterialTheme.typography.labelSmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (photo.mediaType == MediaType.VIDEO) {
|
|
||||||
Badge(containerColor = MaterialTheme.colorScheme.tertiary) {
|
|
||||||
Text("▶", style = MaterialTheme.typography.labelSmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sparse-day date chip — bottom left, small pill
|
|
||||||
if (dateChip != null) {
|
|
||||||
val fmt = remember { DateTimeFormatter.ofPattern("MMM d") }
|
|
||||||
Text(
|
|
||||||
fmt.format(dateChip),
|
|
||||||
fontSize = 10.sp,
|
|
||||||
color = Color.White,
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.BottomStart)
|
|
||||||
.padding(4.dp)
|
|
||||||
.background(Color.Black.copy(alpha = 0.5f), shape = MaterialTheme.shapes.extraSmall)
|
|
||||||
.padding(horizontal = 4.dp, vertical = 1.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selection indicator top-right
|
|
||||||
Icon(
|
|
||||||
if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (isSelected) MaterialTheme.colorScheme.primary else Color.White.copy(alpha = 0.8f),
|
|
||||||
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(22.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -3,20 +3,12 @@ package com.bolenpad.photophetch.ui
|
|||||||
import com.bolenpad.photophetch.model.PhonePhoto
|
import com.bolenpad.photophetch.model.PhonePhoto
|
||||||
import java.time.LocalDate
|
import java.time.LocalDate
|
||||||
|
|
||||||
/** A folder's photos grouped by day, used by both Grouped and Dense grid views */
|
/** A folder's photos grouped by day */
|
||||||
data class FolderSection(
|
data class FolderSection(
|
||||||
val folder: FolderInfo,
|
val folder: FolderInfo,
|
||||||
val byDay: List<Pair<LocalDate, List<PhonePhoto>>>,
|
val byDay: List<Pair<LocalDate, List<PhonePhoto>>>,
|
||||||
val allPhotos: List<PhonePhoto>,
|
val allPhotos: List<PhonePhoto>,
|
||||||
)
|
)
|
||||||
|
|
||||||
/** Item types used in the DensePhotoGrid flat list */
|
/** Item types used in DensePhotoGrid flat list */
|
||||||
enum class GridItemType { FOLDER_HEADER, DAY_BOUNDARY, PHOTO_ROW }
|
enum class GridItemType { FOLDER_HEADER, PHOTO_ROW }
|
||||||
|
|
||||||
/** A single item in the DensePhotoGrid flat list */
|
|
||||||
data class GridItem(
|
|
||||||
val type: GridItemType,
|
|
||||||
val folderSection: FolderSection? = null,
|
|
||||||
val rowPhotos: List<PhonePhoto>? = null,
|
|
||||||
val dayDate: LocalDate? = null,
|
|
||||||
)
|
|
||||||
|
|||||||
Reference in New Issue
Block a user