Rewrite BrowseScreen: folder panel + day-grouped photo grid
New two-panel layout: - Left (200dp): FolderPanel lists all DCIM subfolders with photo count and date range. Camera-roll folders highlighted separately from other folders (Screenshots, Video Editor, etc.). Selection badge per folder. - Right: DayGroupedGrid groups photos by date with day headers. Each day header shows count and a 'Select day' / 'Deselect day' toggle. Photos displayed in rows of 4 with thumbnails. PhonePhoto now carries sourceFolder name. AndroidConnector tags each photo with its DCIM subfolder. AppState manages selectedFolder and visiblePhotos StateFlows. ExifFilter.isCameraRollFolder() classifies folders as camera-roll vs other. Preserves all existing functionality: thumbnails, import flow, HEIC warning, selection.
This commit is contained in:
@@ -90,14 +90,13 @@ class AndroidConnector(
|
||||
val check = runAdb(serial, "shell", "ls", dir, "2>/dev/null")
|
||||
if (check.contains("No such file") || check.isBlank()) continue
|
||||
|
||||
// Single ls -la call for the whole directory — one ADB round trip per folder
|
||||
// instead of one per file. Format varies slightly by Android version but is
|
||||
// consistently: permissions links user group SIZE DATE TIME FILENAME
|
||||
// Derive the human-readable folder name from the path
|
||||
val folderName = dir.substringAfterLast("/")
|
||||
|
||||
val lsOutput = runAdb(serial, "shell", "ls", "-la", dir)
|
||||
|
||||
for (line in lsOutput.lines()) {
|
||||
val trimmed = line.trim()
|
||||
// Skip total line, blank lines, directories
|
||||
if (trimmed.isBlank() || trimmed.startsWith("total") || trimmed.startsWith("d")) continue
|
||||
|
||||
val parts = trimmed.split(Regex("\\s+"), limit = 8)
|
||||
@@ -120,6 +119,7 @@ class AndroidConnector(
|
||||
exifModel = device.displayName,
|
||||
mediaType = if (ext in setOf("mp4", "mov", "3gp", "avi"))
|
||||
MediaType.VIDEO else MediaType.PHOTO,
|
||||
sourceFolder = folderName,
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
@@ -20,6 +20,8 @@ data class PhonePhoto(
|
||||
val exifModel: String?,
|
||||
/** Media type */
|
||||
val mediaType: MediaType,
|
||||
/** Source folder name on the device (e.g. "Camera", "OpenCamera", "PeakLens") */
|
||||
val sourceFolder: String = "Camera",
|
||||
/** True if this is the still component of an Apple Live Photo (paired with a .MOV) */
|
||||
val isLivePhotoStill: Boolean = false,
|
||||
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */
|
||||
|
||||
@@ -5,7 +5,6 @@ import com.bolenpad.photophetch.device.DeviceConnector
|
||||
import com.bolenpad.photophetch.device.DeviceException
|
||||
import com.bolenpad.photophetch.device.IosConnector
|
||||
import com.bolenpad.photophetch.model.AppConfig
|
||||
import com.bolenpad.photophetch.model.CopyResult
|
||||
import com.bolenpad.photophetch.model.DeviceInfo
|
||||
import com.bolenpad.photophetch.model.DeviceType
|
||||
import com.bolenpad.photophetch.model.ImportJob
|
||||
@@ -31,15 +30,9 @@ import java.time.LocalDate
|
||||
|
||||
/** Top-level navigation destinations */
|
||||
enum class Screen {
|
||||
CONNECT,
|
||||
BROWSE,
|
||||
IMPORT_CONFIG,
|
||||
IMPORTING,
|
||||
VERIFY,
|
||||
SETTINGS,
|
||||
CONNECT, BROWSE, IMPORT_CONFIG, IMPORTING, VERIFY, SETTINGS,
|
||||
}
|
||||
|
||||
/** Sealed state for the device scan */
|
||||
sealed class DeviceScanState {
|
||||
object Idle : DeviceScanState()
|
||||
object Scanning : DeviceScanState()
|
||||
@@ -47,36 +40,40 @@ sealed class DeviceScanState {
|
||||
data class Error(val message: String) : DeviceScanState()
|
||||
}
|
||||
|
||||
/** Sealed state for the photo listing */
|
||||
sealed class PhotoListState {
|
||||
object Idle : PhotoListState()
|
||||
object Loading : PhotoListState()
|
||||
data class Loaded(
|
||||
/** All photos from the device, unfiltered */
|
||||
val all: List<PhonePhoto>,
|
||||
val filtered: List<PhonePhoto>,
|
||||
val shootingInHeic: Boolean,
|
||||
/** Folders derived from all photos, sorted: camera-roll folders first then alphabetical */
|
||||
val folders: List<FolderInfo>,
|
||||
) : PhotoListState()
|
||||
data class Error(val message: String) : PhotoListState()
|
||||
}
|
||||
|
||||
/** Progress during active import */
|
||||
/** Summary of a DCIM subfolder */
|
||||
data class FolderInfo(
|
||||
val name: String,
|
||||
val photoCount: Int,
|
||||
val earliestDate: LocalDate,
|
||||
val latestDate: LocalDate,
|
||||
/** True if this folder passes the camera-roll heuristic (not screenshots etc.) */
|
||||
val isCameraRoll: Boolean,
|
||||
)
|
||||
|
||||
data class ImportProgress(
|
||||
val completed: Int,
|
||||
val total: Int,
|
||||
val currentFile: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* Application state container — the single source of truth for all UI state.
|
||||
*/
|
||||
class AppState(
|
||||
private val configStore: ConfigStore,
|
||||
) {
|
||||
class AppState(private val configStore: ConfigStore) {
|
||||
private val log = LoggerFactory.getLogger(AppState::class.java)
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Persisted config
|
||||
// Config
|
||||
// -------------------------------------------------------------------------
|
||||
private val _config = MutableStateFlow(configStore.load())
|
||||
val config: StateFlow<AppConfig> = _config.asStateFlow()
|
||||
@@ -86,7 +83,6 @@ class AppState(
|
||||
// -------------------------------------------------------------------------
|
||||
private val _currentScreen = MutableStateFlow(Screen.CONNECT)
|
||||
val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow()
|
||||
|
||||
fun navigateTo(screen: Screen) { _currentScreen.value = screen }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -122,42 +118,45 @@ class AppState(
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Photo listing
|
||||
// Photo listing + folder state
|
||||
// -------------------------------------------------------------------------
|
||||
private val _photoList = MutableStateFlow<PhotoListState>(PhotoListState.Idle)
|
||||
val photoList: StateFlow<PhotoListState> = _photoList.asStateFlow()
|
||||
|
||||
/** Currently active folder name — null means "all camera-roll folders" */
|
||||
private val _selectedFolder = MutableStateFlow<String?>(null)
|
||||
val selectedFolder: StateFlow<String?> = _selectedFolder.asStateFlow()
|
||||
|
||||
/** Photos currently visible in the grid (filtered by folder) */
|
||||
private val _visiblePhotos = MutableStateFlow<List<PhonePhoto>>(emptyList())
|
||||
val visiblePhotos: StateFlow<List<PhonePhoto>> = _visiblePhotos.asStateFlow()
|
||||
|
||||
/** Selected photo set */
|
||||
private val _selectedPhotos = MutableStateFlow<Set<PhonePhoto>>(emptySet())
|
||||
val selectedPhotos: StateFlow<Set<PhonePhoto>> = _selectedPhotos.asStateFlow()
|
||||
|
||||
private val _dateFilter = MutableStateFlow<Pair<LocalDate?, LocalDate?>>(null to null)
|
||||
val dateFilter: StateFlow<Pair<LocalDate?, LocalDate?>> = _dateFilter.asStateFlow()
|
||||
|
||||
/** When true, bypass ExifFilter and show all photos from all DCIM folders */
|
||||
private val _showAll = MutableStateFlow(false)
|
||||
val showAll: StateFlow<Boolean> = _showAll.asStateFlow()
|
||||
/** Whether HEIC warning should be shown */
|
||||
val shootingInHeic: Boolean
|
||||
get() = (_photoList.value as? PhotoListState.Loaded)
|
||||
?.all?.let { ExifFilter.isShootingInHeic(it) } ?: false
|
||||
|
||||
fun loadPhotos(device: DeviceInfo) {
|
||||
scope.launch {
|
||||
_photoList.value = PhotoListState.Loading
|
||||
_selectedFolder.value = null
|
||||
_selectedPhotos.value = emptySet()
|
||||
try {
|
||||
val connector = connectorFor(device)
|
||||
val raw = withContext(Dispatchers.IO) { connector.listPhotos(device) }
|
||||
val (from, to) = _dateFilter.value
|
||||
val filtered = if (_showAll.value) {
|
||||
applyDateFilter(raw, from, to)
|
||||
} else {
|
||||
applyDateFilter(
|
||||
ExifFilter.filter(raw, _config.value.defaultStripLivePhotoMotion),
|
||||
from, to,
|
||||
)
|
||||
}
|
||||
val shootingInHeic = ExifFilter.isShootingInHeic(filtered)
|
||||
_photoList.value = PhotoListState.Loaded(
|
||||
all = raw,
|
||||
filtered = filtered,
|
||||
shootingInHeic = shootingInHeic,
|
||||
)
|
||||
|
||||
// Build folder summaries
|
||||
val folders = buildFolderList(raw)
|
||||
|
||||
_photoList.value = PhotoListState.Loaded(all = raw, folders = folders)
|
||||
|
||||
// Default: select the first camera-roll folder
|
||||
val defaultFolder = folders.firstOrNull { it.isCameraRoll }?.name
|
||||
selectFolder(defaultFolder)
|
||||
} catch (e: Exception) {
|
||||
log.error("Photo listing failed", e)
|
||||
_photoList.value = PhotoListState.Error(e.message ?: "Unknown error")
|
||||
@@ -165,54 +164,62 @@ class AppState(
|
||||
}
|
||||
}
|
||||
|
||||
fun toggleShowAll() {
|
||||
_showAll.value = !_showAll.value
|
||||
val current = _photoList.value as? PhotoListState.Loaded ?: return
|
||||
val (from, to) = _dateFilter.value
|
||||
val filtered = if (_showAll.value) {
|
||||
applyDateFilter(current.all, from, to)
|
||||
} else {
|
||||
applyDateFilter(
|
||||
ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion),
|
||||
from, to,
|
||||
)
|
||||
}
|
||||
_photoList.value = current.copy(filtered = filtered)
|
||||
fun selectFolder(folderName: String?) {
|
||||
_selectedFolder.value = folderName
|
||||
_selectedPhotos.value = emptySet()
|
||||
refreshVisiblePhotos()
|
||||
}
|
||||
|
||||
fun setDateFilter(from: LocalDate?, to: LocalDate?) {
|
||||
_dateFilter.value = from to to
|
||||
val current = _photoList.value as? PhotoListState.Loaded ?: return
|
||||
val base = if (_showAll.value) current.all
|
||||
else ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion)
|
||||
_photoList.value = current.copy(filtered = applyDateFilter(base, from, to))
|
||||
private fun refreshVisiblePhotos() {
|
||||
val loaded = _photoList.value as? PhotoListState.Loaded ?: return
|
||||
val folder = _selectedFolder.value
|
||||
_visiblePhotos.value = if (folder == null) {
|
||||
// All camera-roll folders merged
|
||||
ExifFilter.filter(loaded.all, _config.value.defaultStripLivePhotoMotion)
|
||||
.sortedByDescending { it.dateTaken }
|
||||
} else {
|
||||
loaded.all.filter { it.sourceFolder == folder }
|
||||
.sortedByDescending { it.dateTaken }
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildFolderList(photos: List<PhonePhoto>): List<FolderInfo> {
|
||||
val zoneId = java.time.ZoneId.systemDefault()
|
||||
return photos
|
||||
.groupBy { it.sourceFolder }
|
||||
.map { (name, folderPhotos) ->
|
||||
val dates = folderPhotos.map {
|
||||
it.dateTaken.atZone(zoneId).toLocalDate()
|
||||
}
|
||||
FolderInfo(
|
||||
name = name,
|
||||
photoCount = folderPhotos.size,
|
||||
earliestDate = dates.min(),
|
||||
latestDate = dates.max(),
|
||||
isCameraRoll = ExifFilter.isCameraRollFolder(name),
|
||||
)
|
||||
}
|
||||
// Camera-roll folders first, then alphabetical
|
||||
.sortedWith(compareByDescending<FolderInfo> { it.isCameraRoll }.thenBy { it.name })
|
||||
}
|
||||
|
||||
fun togglePhotoSelection(photo: PhonePhoto) {
|
||||
_selectedPhotos.update { current ->
|
||||
if (photo in current) current - photo else current + photo
|
||||
}
|
||||
_selectedPhotos.update { if (photo in it) it - photo else it + photo }
|
||||
}
|
||||
|
||||
fun selectAll() {
|
||||
val photos = (_photoList.value as? PhotoListState.Loaded)?.filtered ?: return
|
||||
_selectedPhotos.value = photos.toSet()
|
||||
fun selectAllVisible() {
|
||||
_selectedPhotos.value = _visiblePhotos.value.toSet()
|
||||
}
|
||||
|
||||
fun selectNone() {
|
||||
_selectedPhotos.value = emptySet()
|
||||
}
|
||||
|
||||
private fun applyDateFilter(
|
||||
photos: List<PhonePhoto>,
|
||||
from: LocalDate?,
|
||||
to: LocalDate?,
|
||||
): List<PhonePhoto> {
|
||||
if (from == null && to == null) return photos
|
||||
return photos.filter { photo ->
|
||||
val date = photo.dateTaken.atZone(java.time.ZoneId.systemDefault()).toLocalDate()
|
||||
(from == null || !date.isBefore(from)) && (to == null || !date.isAfter(to))
|
||||
}
|
||||
fun selectFolder_All() {
|
||||
val loaded = _photoList.value as? PhotoListState.Loaded ?: return
|
||||
val folder = _selectedFolder.value ?: return
|
||||
val folderPhotos = loaded.all.filter { it.sourceFolder == folder }.toSet()
|
||||
_selectedPhotos.update { it + folderPhotos }
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -249,17 +256,15 @@ class AppState(
|
||||
.replace("{date}", today.toString())
|
||||
.replace("{device}", StagingPath.deviceLabel(device))
|
||||
|
||||
// Auto-increment if the folder already exists
|
||||
val folderName = if (cfg.stagingRoot.isNotBlank()) {
|
||||
StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), baseName)
|
||||
} else {
|
||||
baseName
|
||||
}
|
||||
} else baseName
|
||||
|
||||
// Multi-folder selection → preserve structure by default
|
||||
val multipleSourceFolders = _selectedPhotos.value.map { it.sourceFolder }.distinct().size > 1
|
||||
_importConfig.update { it.copy(
|
||||
folderName = folderName,
|
||||
// When showing all files, default to preserving folder structure
|
||||
preserveFolderStructure = _showAll.value,
|
||||
preserveFolderStructure = multipleSourceFolders,
|
||||
) }
|
||||
navigateTo(Screen.IMPORT_CONFIG)
|
||||
}
|
||||
@@ -311,16 +316,11 @@ class AppState(
|
||||
fun deleteVerifiedFromDevice() {
|
||||
val device = _selectedDevice.value ?: return
|
||||
val result = _importResult.value ?: return
|
||||
val toDelete = result.results.filter { it.verified }.map { it.photo }
|
||||
|
||||
scope.launch(Dispatchers.IO) {
|
||||
val connector = connectorFor(device)
|
||||
toDelete.forEach { photo ->
|
||||
try {
|
||||
connector.deletePhoto(device, photo)
|
||||
} catch (e: DeviceException) {
|
||||
log.error("Delete failed for ${photo.filename}: ${e.message}")
|
||||
}
|
||||
result.results.filter { it.verified }.forEach { r ->
|
||||
try { connector.deletePhoto(device, r.photo) }
|
||||
catch (e: DeviceException) { log.error("Delete failed for ${r.photo.filename}: ${e.message}") }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -338,9 +338,7 @@ class AppState(
|
||||
saveConfig()
|
||||
}
|
||||
|
||||
fun saveConfig() {
|
||||
configStore.save(_config.value)
|
||||
}
|
||||
fun saveConfig() { configStore.save(_config.value) }
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Helpers
|
||||
|
||||
@@ -1,61 +1,45 @@
|
||||
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.grid.GridCells
|
||||
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
|
||||
import androidx.compose.foundation.lazy.grid.items
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.CheckCircle
|
||||
import androidx.compose.material.icons.filled.DateRange
|
||||
import androidx.compose.material.icons.filled.Image
|
||||
import androidx.compose.material.icons.filled.RadioButtonUnchecked
|
||||
import androidx.compose.material.icons.filled.Warning
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun BrowseScreen(state: AppState) {
|
||||
val photoList by state.photoList.collectAsState()
|
||||
val selected by state.selectedPhotos.collectAsState()
|
||||
val selectedFolder by state.selectedFolder.collectAsState()
|
||||
val visiblePhotos by state.visiblePhotos.collectAsState()
|
||||
val selectedPhotos by state.selectedPhotos.collectAsState()
|
||||
val device by state.selectedDevice.collectAsState()
|
||||
val config by state.config.collectAsState()
|
||||
val dateFilter by state.dateFilter.collectAsState()
|
||||
val showAll by state.showAll.collectAsState()
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
// Top bar
|
||||
|
||||
// ── Top bar ──────────────────────────────────────────────────────────
|
||||
BrowseTopBar(
|
||||
device = device,
|
||||
selected = selected,
|
||||
showAll = showAll,
|
||||
deviceName = device?.displayName ?: "Browse Photos",
|
||||
selectedCount = selectedPhotos.size,
|
||||
onBack = { state.navigateTo(Screen.CONNECT) },
|
||||
onSelectAll = { state.selectAll() },
|
||||
onSelectAll = { state.selectAllVisible() },
|
||||
onSelectNone = { state.selectNone() },
|
||||
onToggleShowAll = { state.toggleShowAll() },
|
||||
onImport = { state.prepareImport() },
|
||||
)
|
||||
|
||||
// ── HEIC warning ─────────────────────────────────────────────────────
|
||||
if (state.shootingInHeic && !config.heicWarningDismissed) {
|
||||
HeicWarningBanner(onDismiss = { state.dismissHeicWarning() })
|
||||
}
|
||||
|
||||
// ── Body ─────────────────────────────────────────────────────────────
|
||||
when (val pl = photoList) {
|
||||
is PhotoListState.Idle, is PhotoListState.Loading -> {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
@@ -67,53 +51,42 @@ fun BrowseScreen(state: AppState) {
|
||||
}
|
||||
}
|
||||
is PhotoListState.Loaded -> {
|
||||
// HEIC warning banner
|
||||
if (pl.shootingInHeic && !config.heicWarningDismissed) {
|
||||
HeicWarningBanner(
|
||||
convertByDefault = config.defaultConvertHeicToJpeg,
|
||||
onDismiss = { state.dismissHeicWarning() },
|
||||
Row(modifier = Modifier.fillMaxSize()) {
|
||||
// Left: folder panel — fixed 200dp width
|
||||
FolderPanel(
|
||||
folders = pl.folders,
|
||||
selectedFolder = selectedFolder,
|
||||
selectedPhotos = selectedPhotos,
|
||||
onSelectFolder = { state.selectFolder(it) },
|
||||
modifier = Modifier.width(200.dp).fillMaxHeight(),
|
||||
)
|
||||
}
|
||||
|
||||
// Date filter row
|
||||
DateFilterRow(
|
||||
from = dateFilter.first,
|
||||
to = dateFilter.second,
|
||||
onFilterChange = { from, to -> state.setDateFilter(from, to) },
|
||||
)
|
||||
VerticalDivider()
|
||||
|
||||
// Photo count summary
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(
|
||||
"${pl.filtered.size} photos • ${selected.size} selected",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
|
||||
if (pl.filtered.isEmpty()) {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Text("No photos match the current filter", color = MaterialTheme.colorScheme.onSurfaceVariant)
|
||||
}
|
||||
} else {
|
||||
LazyVerticalGrid(
|
||||
columns = GridCells.Adaptive(minSize = 110.dp),
|
||||
modifier = Modifier.fillMaxSize().padding(4.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items(pl.filtered, key = { it.devicePath }) { photo ->
|
||||
PhotoCell(
|
||||
photo = photo,
|
||||
device = device,
|
||||
adbBin = state.config.collectAsState().value.adbPath ?: "adb",
|
||||
isSelected = photo in selected,
|
||||
onToggle = { state.togglePhotoSelection(photo) },
|
||||
)
|
||||
}
|
||||
// Right: day-grouped photo grid
|
||||
Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
|
||||
// Folder subtitle bar
|
||||
FolderSubtitleBar(
|
||||
folderName = selectedFolder ?: "All Camera Photos",
|
||||
photoCount = visiblePhotos.size,
|
||||
selectedCount = selectedPhotos.size,
|
||||
)
|
||||
DayGroupedGrid(
|
||||
photos = visiblePhotos,
|
||||
selectedPhotos = selectedPhotos,
|
||||
device = device,
|
||||
adbBin = config.adbPath ?: "adb",
|
||||
onTogglePhoto = { state.togglePhotoSelection(it) },
|
||||
onSelectDay = { _, dayPhotos ->
|
||||
// Toggle: if all selected → deselect, else select all
|
||||
val allSelected = dayPhotos.all { it in selectedPhotos }
|
||||
dayPhotos.forEach { photo ->
|
||||
val inSet = photo in selectedPhotos
|
||||
if (allSelected && inSet) state.togglePhotoSelection(photo)
|
||||
else if (!allSelected && !inSet) state.togglePhotoSelection(photo)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -134,17 +107,15 @@ fun BrowseScreen(state: AppState) {
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun BrowseTopBar(
|
||||
device: DeviceInfo?,
|
||||
selected: Set<PhonePhoto>,
|
||||
showAll: Boolean,
|
||||
deviceName: String,
|
||||
selectedCount: Int,
|
||||
onBack: () -> Unit,
|
||||
onSelectAll: () -> Unit,
|
||||
onSelectNone: () -> Unit,
|
||||
onToggleShowAll: () -> Unit,
|
||||
onImport: () -> Unit,
|
||||
) {
|
||||
TopAppBar(
|
||||
title = { Text(device?.displayName ?: "Browse Photos") },
|
||||
title = { Text(deviceName) },
|
||||
navigationIcon = {
|
||||
IconButton(onClick = onBack) {
|
||||
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
|
||||
@@ -153,158 +124,46 @@ private fun BrowseTopBar(
|
||||
actions = {
|
||||
TextButton(onClick = onSelectAll) { Text("All") }
|
||||
TextButton(onClick = onSelectNone) { Text("None") }
|
||||
FilterChip(
|
||||
selected = showAll,
|
||||
onClick = onToggleShowAll,
|
||||
label = { Text("Show all") },
|
||||
modifier = Modifier.padding(horizontal = 4.dp),
|
||||
)
|
||||
Button(
|
||||
onClick = onImport,
|
||||
enabled = selected.isNotEmpty(),
|
||||
enabled = selectedCount > 0,
|
||||
modifier = Modifier.padding(end = 8.dp),
|
||||
) {
|
||||
Text("Import ${if (selected.isNotEmpty()) "(${selected.size})" else ""}")
|
||||
Text(if (selectedCount > 0) "Import ($selectedCount)" else "Import")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun PhotoCell(
|
||||
photo: PhonePhoto,
|
||||
device: DeviceInfo?,
|
||||
adbBin: String,
|
||||
isSelected: Boolean,
|
||||
onToggle: () -> Unit,
|
||||
private fun FolderSubtitleBar(
|
||||
folderName: String,
|
||||
photoCount: Int,
|
||||
selectedCount: Int,
|
||||
) {
|
||||
val date = photo.dateTaken.atZone(ZoneId.systemDefault()).toLocalDate()
|
||||
val dateStr = date.format(DateTimeFormatter.ofPattern("MMM d"))
|
||||
|
||||
// Load thumbnail asynchronously — limited concurrency to avoid overwhelming ADB
|
||||
var thumbnail by remember(photo.devicePath) { mutableStateOf<ImageBitmap?>(null) }
|
||||
var failed by remember(photo.devicePath) { mutableStateOf(false) }
|
||||
LaunchedEffect(photo.devicePath) {
|
||||
if (device?.adbSerial != null && !failed) {
|
||||
val bytes = withContext(Dispatchers.IO) {
|
||||
ThumbnailCache.get(adbBin, device.adbSerial, photo.devicePath, photo.filename)
|
||||
}
|
||||
if (bytes != null) {
|
||||
runCatching {
|
||||
thumbnail = Image.makeFromEncoded(bytes).toComposeImageBitmap()
|
||||
}.onFailure { failed = true }
|
||||
} else {
|
||||
failed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.aspectRatio(1f)
|
||||
.clickable(onClick = onToggle)
|
||||
.background(
|
||||
if (isSelected) MaterialTheme.colorScheme.primaryContainer
|
||||
else MaterialTheme.colorScheme.surfaceVariant
|
||||
)
|
||||
.then(
|
||||
if (isSelected) Modifier.border(2.dp, MaterialTheme.colorScheme.primary)
|
||||
else Modifier
|
||||
),
|
||||
) {
|
||||
// Thumbnail image or placeholder
|
||||
if (thumbnail != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
bitmap = thumbnail!!,
|
||||
contentDescription = photo.filename,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
)
|
||||
} else {
|
||||
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) {
|
||||
Icon(
|
||||
Icons.Default.Image,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.outline,
|
||||
modifier = Modifier.size(32.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Overlay: badges top-left, date bottom-left, checkmark top-right
|
||||
Column(
|
||||
modifier = Modifier.fillMaxSize().padding(4.dp),
|
||||
verticalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
if (photo.isHeic) Badge { Text("HEIC", style = MaterialTheme.typography.labelSmall) }
|
||||
if (photo.mediaType == MediaType.VIDEO) {
|
||||
Badge(containerColor = MaterialTheme.colorScheme.tertiary) {
|
||||
Text("VID", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
if (photo.isLivePhotoStill) {
|
||||
Badge(containerColor = MaterialTheme.colorScheme.secondary) {
|
||||
Text("LIVE", style = MaterialTheme.typography.labelSmall)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
dateStr,
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = androidx.compose.ui.graphics.Color.White,
|
||||
modifier = Modifier.background(
|
||||
androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.4f),
|
||||
shape = MaterialTheme.shapes.extraSmall,
|
||||
).padding(horizontal = 3.dp, vertical = 1.dp),
|
||||
)
|
||||
}
|
||||
|
||||
Icon(
|
||||
if (isSelected) Icons.Default.CheckCircle else Icons.Default.RadioButtonUnchecked,
|
||||
contentDescription = if (isSelected) "Selected" else "Not selected",
|
||||
tint = if (isSelected) MaterialTheme.colorScheme.primary
|
||||
else androidx.compose.ui.graphics.Color.White,
|
||||
modifier = Modifier.align(Alignment.TopEnd).padding(4.dp).size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DateFilterRow(
|
||||
from: LocalDate?,
|
||||
to: LocalDate?,
|
||||
onFilterChange: (from: LocalDate?, to: LocalDate?) -> Unit,
|
||||
) {
|
||||
// Simple quick-filter buttons: Last 7 days, Last 30 days, All
|
||||
val today = LocalDate.now()
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 6.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Icon(Icons.Default.DateRange, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text("Filter:", style = MaterialTheme.typography.labelMedium)
|
||||
FilterChip(
|
||||
selected = from == null && to == null,
|
||||
onClick = { onFilterChange(null, null) },
|
||||
label = { Text("All") },
|
||||
Text(
|
||||
folderName,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
FilterChip(
|
||||
selected = from == today.minusDays(7) && to == null,
|
||||
onClick = { onFilterChange(today.minusDays(7), null) },
|
||||
label = { Text("Last 7 days") },
|
||||
)
|
||||
FilterChip(
|
||||
selected = from == today.minusDays(30) && to == null,
|
||||
onClick = { onFilterChange(today.minusDays(30), null) },
|
||||
label = { Text("Last 30 days") },
|
||||
Text(
|
||||
if (selectedCount > 0) "$selectedCount / $photoCount selected"
|
||||
else "$photoCount photos",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun HeicWarningBanner(convertByDefault: Boolean, onDismiss: () -> Unit) {
|
||||
private fun HeicWarningBanner(onDismiss: () -> Unit) {
|
||||
Card(
|
||||
colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.tertiaryContainer),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp),
|
||||
@@ -316,12 +175,10 @@ private fun HeicWarningBanner(convertByDefault: Boolean, onDismiss: () -> Unit)
|
||||
) {
|
||||
Icon(Icons.Default.Warning, contentDescription = null, modifier = Modifier.size(20.dp))
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold, style = MaterialTheme.typography.bodySmall)
|
||||
Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold,
|
||||
style = MaterialTheme.typography.bodySmall)
|
||||
Text(
|
||||
if (convertByDefault)
|
||||
"Files will be converted to JPEG during import. To avoid this, go to Settings → Camera → Formats → Most Compatible on your iPhone."
|
||||
else
|
||||
"HEIC files will be kept as-is. PhotoPhile may not handle them on Linux. Consider Settings → Camera → Formats → Most Compatible.",
|
||||
"Files will be converted to JPEG during import. To avoid this: Settings → Camera → Formats → Most Compatible.",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onTertiaryContainer,
|
||||
)
|
||||
|
||||
244
src/main/kotlin/com/bolenpad/photophetch/ui/DayGroupedGrid.kt
Normal file
244
src/main/kotlin/com/bolenpad/photophetch/ui/DayGroupedGrid.kt
Normal file
@@ -0,0 +1,244 @@
|
||||
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))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
142
src/main/kotlin/com/bolenpad/photophetch/ui/FolderPanel.kt
Normal file
142
src/main/kotlin/com/bolenpad/photophetch/ui/FolderPanel.kt
Normal file
@@ -0,0 +1,142 @@
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -90,6 +90,16 @@ object ExifFilter {
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a folder name is likely a camera-roll source
|
||||
* (i.e. not screenshots, received images, etc.)
|
||||
*/
|
||||
fun isCameraRollFolder(folderName: String): Boolean {
|
||||
val lower = folderName.lowercase()
|
||||
return excludedPathFragments.none { lower.contains(it) } &&
|
||||
screenshotFilePrefixes.none { lower.startsWith(it) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns photos that are HEIC files (and not Live Photo motion clips).
|
||||
* Used to determine whether to show the HEIC conversion warning.
|
||||
|
||||
Reference in New Issue
Block a user