Rewrite BrowseScreen: unified scroll with folder sections + jump list
- Single LazyColumn with folder divider headers embedded — no page switching - Left panel is a jump list: click scrolls to that folder, highlights active folder as you scroll (derivedStateOf scroll spy) - Each folder section header has Select All / Deselect All buttons - Each day sub-header has Select Day / Deselect Day toggle - Removed 'All Photos' merged view — folders are always separate sections - PhotoThumb moved inline, cleaned up imports
This commit is contained in:
@@ -1,45 +1,58 @@
|
|||||||
package com.bolenpad.photophetch.ui
|
package com.bolenpad.photophetch.ui
|
||||||
|
|
||||||
|
import androidx.compose.foundation.Image
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
|
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.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.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.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
|
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 java.time.LocalDate
|
import com.bolenpad.photophetch.util.ThumbnailCache
|
||||||
|
import kotlinx.coroutines.Dispatchers
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import kotlinx.coroutines.withContext
|
||||||
|
import org.jetbrains.skia.Image as SkiaImage
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun BrowseScreen(state: AppState) {
|
fun BrowseScreen(state: AppState) {
|
||||||
val photoList by state.photoList.collectAsState()
|
val photoList by state.photoList.collectAsState()
|
||||||
val selectedFolder by state.selectedFolder.collectAsState()
|
|
||||||
val visiblePhotos by state.visiblePhotos.collectAsState()
|
|
||||||
val selectedPhotos by state.selectedPhotos.collectAsState()
|
val selectedPhotos by state.selectedPhotos.collectAsState()
|
||||||
val device by state.selectedDevice.collectAsState()
|
val device by state.selectedDevice.collectAsState()
|
||||||
val config by state.config.collectAsState()
|
val config by state.config.collectAsState()
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
|
|
||||||
// ── Top bar ──────────────────────────────────────────────────────────
|
|
||||||
BrowseTopBar(
|
BrowseTopBar(
|
||||||
deviceName = device?.displayName ?: "Browse Photos",
|
deviceName = device?.displayName ?: "Browse Photos",
|
||||||
selectedCount = selectedPhotos.size,
|
selectedCount = selectedPhotos.size,
|
||||||
onBack = { state.navigateTo(Screen.CONNECT) },
|
onBack = { state.navigateTo(Screen.CONNECT) },
|
||||||
onSelectAll = { state.selectAllVisible() },
|
|
||||||
onSelectNone = { state.selectNone() },
|
onSelectNone = { state.selectNone() },
|
||||||
onImport = { state.prepareImport() },
|
onImport = { state.prepareImport() },
|
||||||
)
|
)
|
||||||
|
|
||||||
// ── HEIC warning ─────────────────────────────────────────────────────
|
|
||||||
if (state.shootingInHeic && !config.heicWarningDismissed) {
|
if (state.shootingInHeic && !config.heicWarningDismissed) {
|
||||||
HeicWarningBanner(onDismiss = { state.dismissHeicWarning() })
|
HeicWarningBanner(onDismiss = { state.dismissHeicWarning() })
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Body ─────────────────────────────────────────────────────────────
|
|
||||||
when (val pl = photoList) {
|
when (val pl = photoList) {
|
||||||
is PhotoListState.Idle, is PhotoListState.Loading -> {
|
is PhotoListState.Idle, is PhotoListState.Loading -> {
|
||||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||||
@@ -51,43 +64,79 @@ 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 listState = rememberLazyListState()
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
|
// Map folder name → first item index in the lazy list
|
||||||
|
val folderScrollIndex = remember(folderSections) {
|
||||||
|
var idx = 0
|
||||||
|
folderSections.associate { (folder, byDay) ->
|
||||||
|
val startIdx = idx
|
||||||
|
idx += 1 // folder header
|
||||||
|
byDay.forEach { (_, dayPhotos) ->
|
||||||
|
idx += 1 // day header
|
||||||
|
idx += ((dayPhotos.size + 3) / 4) // photo rows
|
||||||
|
}
|
||||||
|
folder.name to startIdx
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Scroll spy: which folder is currently at the top of the visible area
|
||||||
|
val activeFolder by remember {
|
||||||
|
derivedStateOf {
|
||||||
|
val firstVisible = listState.firstVisibleItemIndex
|
||||||
|
folderScrollIndex.entries
|
||||||
|
.filter { it.value <= firstVisible }
|
||||||
|
.maxByOrNull { it.value }?.key
|
||||||
|
?: folderSections.firstOrNull()?.folder?.name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Row(modifier = Modifier.fillMaxSize()) {
|
Row(modifier = Modifier.fillMaxSize()) {
|
||||||
// Left: folder panel — fixed 200dp width
|
// Left: folder jump list
|
||||||
FolderPanel(
|
FolderJumpList(
|
||||||
folders = pl.folders,
|
folders = pl.folders,
|
||||||
selectedFolder = selectedFolder,
|
activeFolder = activeFolder,
|
||||||
selectedPhotos = selectedPhotos,
|
selectedPhotos = selectedPhotos,
|
||||||
onSelectFolder = { state.selectFolder(it) },
|
onJump = { folderName ->
|
||||||
modifier = Modifier.width(200.dp).fillMaxHeight(),
|
val idx = folderScrollIndex[folderName] ?: return@FolderJumpList
|
||||||
|
scope.launch { listState.animateScrollToItem(idx) }
|
||||||
|
},
|
||||||
|
modifier = Modifier.width(190.dp).fillMaxHeight(),
|
||||||
)
|
)
|
||||||
|
|
||||||
VerticalDivider()
|
VerticalDivider()
|
||||||
|
|
||||||
// Right: day-grouped photo grid
|
// Right: unified continuous scroll
|
||||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
UnifiedPhotoScroll(
|
||||||
// Folder subtitle bar
|
folderSections = folderSections,
|
||||||
FolderSubtitleBar(
|
|
||||||
folderName = selectedFolder ?: "All Camera Photos",
|
|
||||||
photoCount = visiblePhotos.size,
|
|
||||||
selectedCount = selectedPhotos.size,
|
|
||||||
)
|
|
||||||
DayGroupedGrid(
|
|
||||||
photos = visiblePhotos,
|
|
||||||
selectedPhotos = selectedPhotos,
|
selectedPhotos = selectedPhotos,
|
||||||
device = device,
|
device = device,
|
||||||
adbBin = config.adbPath ?: "adb",
|
adbBin = config.adbPath ?: "adb",
|
||||||
|
listState = listState,
|
||||||
onTogglePhoto = { state.togglePhotoSelection(it) },
|
onTogglePhoto = { state.togglePhotoSelection(it) },
|
||||||
onSelectDay = { _, dayPhotos ->
|
onSelectAllFolder = { folder ->
|
||||||
// Toggle: if all selected → deselect, else select all
|
folder.forEach { p ->
|
||||||
val allSelected = dayPhotos.all { it in selectedPhotos }
|
if (p !in selectedPhotos) state.togglePhotoSelection(p)
|
||||||
dayPhotos.forEach { photo ->
|
|
||||||
val inSet = photo in selectedPhotos
|
|
||||||
if (allSelected && inSet) state.togglePhotoSelection(photo)
|
|
||||||
else if (!allSelected && !inSet) state.togglePhotoSelection(photo)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
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),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
is PhotoListState.Error -> {
|
is PhotoListState.Error -> {
|
||||||
@@ -104,13 +153,294 @@ fun BrowseScreen(state: AppState) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Data helpers ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
private data class FolderSection(
|
||||||
|
val folder: FolderInfo,
|
||||||
|
val byDay: List<Pair<java.time.LocalDate, List<PhonePhoto>>>,
|
||||||
|
val allPhotos: List<PhonePhoto>,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun buildFolderSections(pl: PhotoListState.Loaded): List<FolderSection> {
|
||||||
|
val zoneId = java.time.ZoneId.systemDefault()
|
||||||
|
return pl.folders.map { folder ->
|
||||||
|
val photos = pl.all
|
||||||
|
.filter { it.sourceFolder == folder.name }
|
||||||
|
.sortedByDescending { it.dateTaken }
|
||||||
|
val byDay = photos
|
||||||
|
.groupBy { it.dateTaken.atZone(zoneId).toLocalDate() }
|
||||||
|
.entries
|
||||||
|
.sortedByDescending { it.key }
|
||||||
|
.map { it.key to it.value.sortedByDescending { p -> p.dateTaken } }
|
||||||
|
FolderSection(folder, byDay, photos)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Left jump list ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FolderJumpList(
|
||||||
|
folders: List<FolderInfo>,
|
||||||
|
activeFolder: String?,
|
||||||
|
selectedPhotos: Set<PhonePhoto>,
|
||||||
|
onJump: (String) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
Surface(tonalElevation = 1.dp, modifier = modifier) {
|
||||||
|
androidx.compose.foundation.lazy.LazyColumn(
|
||||||
|
modifier = Modifier.fillMaxSize(),
|
||||||
|
contentPadding = PaddingValues(vertical = 8.dp),
|
||||||
|
) {
|
||||||
|
val (cameraRoll, other) = folders.partition { it.isCameraRoll }
|
||||||
|
|
||||||
|
if (cameraRoll.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"CAMERA",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 12.dp, top = 8.dp, bottom = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(cameraRoll.size) { i ->
|
||||||
|
JumpRow(
|
||||||
|
folder = cameraRoll[i],
|
||||||
|
isActive = cameraRoll[i].name == activeFolder,
|
||||||
|
selectedCount = selectedPhotos.count { it.sourceFolder == cameraRoll[i].name },
|
||||||
|
onClick = { onJump(cameraRoll[i].name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (other.isNotEmpty()) {
|
||||||
|
item {
|
||||||
|
Text(
|
||||||
|
"OTHER",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.padding(start = 12.dp, top = 12.dp, bottom = 2.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
items(other.size) { i ->
|
||||||
|
JumpRow(
|
||||||
|
folder = other[i],
|
||||||
|
isActive = other[i].name == activeFolder,
|
||||||
|
selectedCount = selectedPhotos.count { it.sourceFolder == other[i].name },
|
||||||
|
onClick = { onJump(other[i].name) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun JumpRow(
|
||||||
|
folder: FolderInfo,
|
||||||
|
isActive: Boolean,
|
||||||
|
selectedCount: Int,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.clickable(onClick = onClick)
|
||||||
|
.background(
|
||||||
|
if (isActive) MaterialTheme.colorScheme.secondaryContainer
|
||||||
|
else MaterialTheme.colorScheme.surface,
|
||||||
|
)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
folder.name,
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
fontWeight = if (isActive) FontWeight.SemiBold else FontWeight.Normal,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"${folder.photoCount}",
|
||||||
|
style = MaterialTheme.typography.labelSmall,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
if (selectedCount > 0) {
|
||||||
|
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
||||||
|
Text("$selectedCount", style = MaterialTheme.typography.labelSmall)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Right unified scroll ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun UnifiedPhotoScroll(
|
||||||
|
folderSections: List<FolderSection>,
|
||||||
|
selectedPhotos: Set<PhonePhoto>,
|
||||||
|
device: com.bolenpad.photophetch.model.DeviceInfo?,
|
||||||
|
adbBin: String,
|
||||||
|
listState: LazyListState,
|
||||||
|
onTogglePhoto: (PhonePhoto) -> Unit,
|
||||||
|
onSelectAllFolder: (List<PhonePhoto>) -> Unit,
|
||||||
|
onDeselectAllFolder: (List<PhonePhoto>) -> Unit,
|
||||||
|
onSelectDay: (List<PhonePhoto>) -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
) {
|
||||||
|
|
||||||
|
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 ─────────────────────────────────────────────────
|
||||||
|
section.byDay.forEach { (day, dayPhotos) ->
|
||||||
|
val daySelected = dayPhotos.count { it in selectedPhotos }
|
||||||
|
|
||||||
|
item(key = "day-${section.folder.name}-$day") {
|
||||||
|
DaySubHeader(
|
||||||
|
day = day,
|
||||||
|
total = dayPhotos.size,
|
||||||
|
selected = daySelected,
|
||||||
|
onSelectDay = { onSelectDay(dayPhotos) },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
val rows = dayPhotos.chunked(4)
|
||||||
|
items(
|
||||||
|
count = rows.size,
|
||||||
|
key = { idx -> "row-${section.folder.name}-$day-$idx" },
|
||||||
|
) { rowIdx ->
|
||||||
|
val rowPhotos = rows[rowIdx]
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.padding(horizontal = 8.dp),
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(3.dp),
|
||||||
|
) {
|
||||||
|
rowPhotos.forEach { photo ->
|
||||||
|
PhotoThumb(
|
||||||
|
photo = photo,
|
||||||
|
isSelected = photo in selectedPhotos,
|
||||||
|
device = device,
|
||||||
|
adbBin = adbBin,
|
||||||
|
onToggle = { onTogglePhoto(photo) },
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
repeat(4 - rowPhotos.size) { Spacer(Modifier.weight(1f)) }
|
||||||
|
}
|
||||||
|
Spacer(Modifier.height(3.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun FolderDividerHeader(
|
||||||
|
name: String,
|
||||||
|
total: Int,
|
||||||
|
selected: Int,
|
||||||
|
allSelected: Boolean,
|
||||||
|
onSelectAll: () -> Unit,
|
||||||
|
onDeselectAll: () -> Unit,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
HorizontalDivider(thickness = 2.dp, modifier = Modifier.padding(top = 8.dp))
|
||||||
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth()
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceVariant)
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
) {
|
||||||
|
Column {
|
||||||
|
Text(name, style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold)
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
Row(horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onSelectAll,
|
||||||
|
enabled = !allSelected,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) { Text("Select all", style = MaterialTheme.typography.labelMedium) }
|
||||||
|
if (selected > 0) {
|
||||||
|
TextButton(
|
||||||
|
onClick = onDeselectAll,
|
||||||
|
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
|
||||||
|
) { 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 ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
private fun BrowseTopBar(
|
private fun BrowseTopBar(
|
||||||
deviceName: String,
|
deviceName: String,
|
||||||
selectedCount: Int,
|
selectedCount: Int,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onSelectAll: () -> Unit,
|
|
||||||
onSelectNone: () -> Unit,
|
onSelectNone: () -> Unit,
|
||||||
onImport: () -> Unit,
|
onImport: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -122,8 +452,9 @@ private fun BrowseTopBar(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
actions = {
|
actions = {
|
||||||
TextButton(onClick = onSelectAll) { Text("All") }
|
if (selectedCount > 0) {
|
||||||
TextButton(onClick = onSelectNone) { Text("None") }
|
TextButton(onClick = onSelectNone) { Text("Clear") }
|
||||||
|
}
|
||||||
Button(
|
Button(
|
||||||
onClick = onImport,
|
onClick = onImport,
|
||||||
enabled = selectedCount > 0,
|
enabled = selectedCount > 0,
|
||||||
@@ -135,33 +466,6 @@ private fun BrowseTopBar(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FolderSubtitleBar(
|
|
||||||
folderName: String,
|
|
||||||
photoCount: Int,
|
|
||||||
selectedCount: Int,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
folderName,
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
if (selectedCount > 0) "$selectedCount / $photoCount selected"
|
|
||||||
else "$photoCount photos",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeicWarningBanner(onDismiss: () -> Unit) {
|
private fun HeicWarningBanner(onDismiss: () -> Unit) {
|
||||||
Card(
|
Card(
|
||||||
@@ -178,7 +482,7 @@ private fun HeicWarningBanner(onDismiss: () -> Unit) {
|
|||||||
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 during import. To avoid this: 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,
|
||||||
)
|
)
|
||||||
@@ -187,3 +491,86 @@ private fun HeicWarningBanner(onDismiss: () -> Unit) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Photo thumbnail cell ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun PhotoThumb(
|
||||||
|
photo: PhonePhoto,
|
||||||
|
isSelected: Boolean,
|
||||||
|
device: DeviceInfo?,
|
||||||
|
adbBin: String,
|
||||||
|
onToggle: () -> Unit,
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,244 +0,0 @@
|
|||||||
package com.bolenpad.photophetch.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.CheckCircle
|
|
||||||
import androidx.compose.material.icons.filled.Image
|
|
||||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.*
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
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.unit.dp
|
|
||||||
import com.bolenpad.photophetch.model.DeviceInfo
|
|
||||||
import com.bolenpad.photophetch.model.MediaType
|
|
||||||
import com.bolenpad.photophetch.model.PhonePhoto
|
|
||||||
import com.bolenpad.photophetch.util.ThumbnailCache
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.withContext
|
|
||||||
import org.jetbrains.skia.Image
|
|
||||||
import java.time.LocalDate
|
|
||||||
import java.time.ZoneId
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
import java.time.format.TextStyle
|
|
||||||
import java.util.Locale
|
|
||||||
|
|
||||||
private val dayHeaderFmt = DateTimeFormatter.ofPattern("EEEE, MMMM d, yyyy")
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DayGroupedGrid(
|
|
||||||
photos: List<PhonePhoto>,
|
|
||||||
selectedPhotos: Set<PhonePhoto>,
|
|
||||||
device: DeviceInfo?,
|
|
||||||
adbBin: String,
|
|
||||||
onTogglePhoto: (PhonePhoto) -> Unit,
|
|
||||||
onSelectDay: (LocalDate, List<PhonePhoto>) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
if (photos.isEmpty()) {
|
|
||||||
Box(modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
|
||||||
Text("No photos in this folder", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Group by date descending
|
|
||||||
val zoneId = ZoneId.systemDefault()
|
|
||||||
val byDay: List<Pair<LocalDate, List<PhonePhoto>>> = photos
|
|
||||||
.groupBy { it.dateTaken.atZone(zoneId).toLocalDate() }
|
|
||||||
.entries
|
|
||||||
.sortedByDescending { it.key }
|
|
||||||
.map { it.key to it.value.sortedByDescending { p -> p.dateTaken } }
|
|
||||||
|
|
||||||
LazyColumn(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(bottom = 16.dp),
|
|
||||||
) {
|
|
||||||
byDay.forEach { (day, dayPhotos) ->
|
|
||||||
val daySelected = dayPhotos.count { it in selectedPhotos }
|
|
||||||
|
|
||||||
item(key = "header-$day") {
|
|
||||||
DayHeader(
|
|
||||||
day = day,
|
|
||||||
total = dayPhotos.size,
|
|
||||||
selected = daySelected,
|
|
||||||
onSelectAll = { onSelectDay(day, dayPhotos) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Rows of 4 cells
|
|
||||||
val rows = dayPhotos.chunked(4)
|
|
||||||
items(rows, key = { "row-$day-${it.first().devicePath}" }) { rowPhotos ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
rowPhotos.forEach { photo ->
|
|
||||||
PhotoThumb(
|
|
||||||
photo = photo,
|
|
||||||
isSelected = photo in selectedPhotos,
|
|
||||||
device = device,
|
|
||||||
adbBin = adbBin,
|
|
||||||
onToggle = { onTogglePhoto(photo) },
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
// Fill remaining slots in last row
|
|
||||||
repeat(4 - rowPhotos.size) {
|
|
||||||
Spacer(Modifier.weight(1f))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun DayHeader(
|
|
||||||
day: LocalDate,
|
|
||||||
total: Int,
|
|
||||||
selected: Int,
|
|
||||||
onSelectAll: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 12.dp, end = 8.dp, top = 16.dp, bottom = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
Text(
|
|
||||||
dayHeaderFmt.format(day),
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
if (selected > 0) {
|
|
||||||
Text(
|
|
||||||
"$selected / $total selected",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Text(
|
|
||||||
"$total photos",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
TextButton(
|
|
||||||
onClick = onSelectAll,
|
|
||||||
contentPadding = PaddingValues(horizontal = 8.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
if (selected == total) "Deselect day" else "Select day",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun PhotoThumb(
|
|
||||||
photo: PhonePhoto,
|
|
||||||
isSelected: Boolean,
|
|
||||||
device: DeviceInfo?,
|
|
||||||
adbBin: String,
|
|
||||||
onToggle: () -> Unit,
|
|
||||||
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 = Image.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) {
|
|
||||||
androidx.compose.foundation.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Selection indicator top-right
|
|
||||||
Icon(
|
|
||||||
if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (isSelected) MaterialTheme.colorScheme.primary
|
|
||||||
else androidx.compose.ui.graphics.Color.White.copy(alpha = 0.8f),
|
|
||||||
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(22.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
// Dark scrim at bottom when selected
|
|
||||||
if (isSelected) {
|
|
||||||
Box(
|
|
||||||
Modifier.fillMaxWidth().height(24.dp).align(Alignment.BottomCenter)
|
|
||||||
.background(MaterialTheme.colorScheme.primary.copy(alpha = 0.3f))
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,142 +0,0 @@
|
|||||||
package com.bolenpad.photophetch.ui
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.*
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.items
|
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.filled.Folder
|
|
||||||
import androidx.compose.material.icons.filled.FolderSpecial
|
|
||||||
import androidx.compose.material3.*
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import java.time.format.DateTimeFormatter
|
|
||||||
|
|
||||||
private val monthFmt = DateTimeFormatter.ofPattern("MMM d")
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun FolderPanel(
|
|
||||||
folders: List<FolderInfo>,
|
|
||||||
selectedFolder: String?,
|
|
||||||
selectedPhotos: Set<com.bolenpad.photophetch.model.PhonePhoto>,
|
|
||||||
onSelectFolder: (String?) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
tonalElevation = 1.dp,
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
contentPadding = PaddingValues(vertical = 8.dp),
|
|
||||||
) {
|
|
||||||
item {
|
|
||||||
FolderRow(
|
|
||||||
name = "All Photos",
|
|
||||||
subtitle = "${folders.sumOf { it.photoCount }} total",
|
|
||||||
isCameraRoll = true,
|
|
||||||
isSelected = selectedFolder == null,
|
|
||||||
selectedCount = if (selectedFolder == null) selectedPhotos.size else 0,
|
|
||||||
onClick = { onSelectFolder(null) },
|
|
||||||
)
|
|
||||||
HorizontalDivider(modifier = Modifier.padding(horizontal = 8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
val (cameraRoll, other) = folders.partition { it.isCameraRoll }
|
|
||||||
|
|
||||||
if (cameraRoll.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"CAMERA",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
items(cameraRoll, key = { it.name }) { folder ->
|
|
||||||
FolderRow(
|
|
||||||
name = folder.name,
|
|
||||||
subtitle = "${folder.photoCount} · ${monthFmt.format(folder.earliestDate)}–${monthFmt.format(folder.latestDate)}",
|
|
||||||
isCameraRoll = true,
|
|
||||||
isSelected = selectedFolder == folder.name,
|
|
||||||
selectedCount = if (selectedFolder == folder.name) selectedPhotos.size else 0,
|
|
||||||
onClick = { onSelectFolder(folder.name) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (other.isNotEmpty()) {
|
|
||||||
item {
|
|
||||||
Text(
|
|
||||||
"OTHER",
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 4.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
items(other, key = { it.name }) { folder ->
|
|
||||||
FolderRow(
|
|
||||||
name = folder.name,
|
|
||||||
subtitle = "${folder.photoCount} · ${monthFmt.format(folder.earliestDate)}–${monthFmt.format(folder.latestDate)}",
|
|
||||||
isCameraRoll = false,
|
|
||||||
isSelected = selectedFolder == folder.name,
|
|
||||||
selectedCount = if (selectedFolder == folder.name) selectedPhotos.size else 0,
|
|
||||||
onClick = { onSelectFolder(folder.name) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun FolderRow(
|
|
||||||
name: String,
|
|
||||||
subtitle: String,
|
|
||||||
isCameraRoll: Boolean,
|
|
||||||
isSelected: Boolean,
|
|
||||||
selectedCount: Int,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable(onClick = onClick)
|
|
||||||
.background(
|
|
||||||
if (isSelected) MaterialTheme.colorScheme.secondaryContainer
|
|
||||||
else MaterialTheme.colorScheme.surface
|
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
if (isCameraRoll) Icons.Default.FolderSpecial else Icons.Default.Folder,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = if (isSelected) MaterialTheme.colorScheme.secondary
|
|
||||||
else MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
)
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
name,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
fontWeight = if (isSelected) FontWeight.SemiBold else FontWeight.Normal,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
subtitle,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
if (selectedCount > 0) {
|
|
||||||
Badge(containerColor = MaterialTheme.colorScheme.primary) {
|
|
||||||
Text("$selectedCount", style = MaterialTheme.typography.labelSmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user