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:
Kyle Bolen
2026-07-28 05:05:21 +00:00
parent 43086a91f1
commit 0e6f0d9c7e
7 changed files with 567 additions and 314 deletions

View File

@@ -90,14 +90,13 @@ class AndroidConnector(
val check = runAdb(serial, "shell", "ls", dir, "2>/dev/null") val check = runAdb(serial, "shell", "ls", dir, "2>/dev/null")
if (check.contains("No such file") || check.isBlank()) continue if (check.contains("No such file") || check.isBlank()) continue
// Single ls -la call for the whole directory — one ADB round trip per folder // Derive the human-readable folder name from the path
// instead of one per file. Format varies slightly by Android version but is val folderName = dir.substringAfterLast("/")
// consistently: permissions links user group SIZE DATE TIME FILENAME
val lsOutput = runAdb(serial, "shell", "ls", "-la", dir) val lsOutput = runAdb(serial, "shell", "ls", "-la", dir)
for (line in lsOutput.lines()) { for (line in lsOutput.lines()) {
val trimmed = line.trim() val trimmed = line.trim()
// Skip total line, blank lines, directories
if (trimmed.isBlank() || trimmed.startsWith("total") || trimmed.startsWith("d")) continue if (trimmed.isBlank() || trimmed.startsWith("total") || trimmed.startsWith("d")) continue
val parts = trimmed.split(Regex("\\s+"), limit = 8) val parts = trimmed.split(Regex("\\s+"), limit = 8)
@@ -120,6 +119,7 @@ class AndroidConnector(
exifModel = device.displayName, exifModel = device.displayName,
mediaType = if (ext in setOf("mp4", "mov", "3gp", "avi")) mediaType = if (ext in setOf("mp4", "mov", "3gp", "avi"))
MediaType.VIDEO else MediaType.PHOTO, MediaType.VIDEO else MediaType.PHOTO,
sourceFolder = folderName,
) )
) )
} }

View File

@@ -20,6 +20,8 @@ data class PhonePhoto(
val exifModel: String?, val exifModel: String?,
/** Media type */ /** Media type */
val mediaType: MediaType, 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) */ /** True if this is the still component of an Apple Live Photo (paired with a .MOV) */
val isLivePhotoStill: Boolean = false, val isLivePhotoStill: Boolean = false,
/** True if this is the motion component of an Apple Live Photo (.MOV companion) */ /** True if this is the motion component of an Apple Live Photo (.MOV companion) */

View File

@@ -5,7 +5,6 @@ import com.bolenpad.photophetch.device.DeviceConnector
import com.bolenpad.photophetch.device.DeviceException import com.bolenpad.photophetch.device.DeviceException
import com.bolenpad.photophetch.device.IosConnector import com.bolenpad.photophetch.device.IosConnector
import com.bolenpad.photophetch.model.AppConfig import com.bolenpad.photophetch.model.AppConfig
import com.bolenpad.photophetch.model.CopyResult
import com.bolenpad.photophetch.model.DeviceInfo import com.bolenpad.photophetch.model.DeviceInfo
import com.bolenpad.photophetch.model.DeviceType import com.bolenpad.photophetch.model.DeviceType
import com.bolenpad.photophetch.model.ImportJob import com.bolenpad.photophetch.model.ImportJob
@@ -31,15 +30,9 @@ import java.time.LocalDate
/** Top-level navigation destinations */ /** Top-level navigation destinations */
enum class Screen { enum class Screen {
CONNECT, CONNECT, BROWSE, IMPORT_CONFIG, IMPORTING, VERIFY, SETTINGS,
BROWSE,
IMPORT_CONFIG,
IMPORTING,
VERIFY,
SETTINGS,
} }
/** Sealed state for the device scan */
sealed class DeviceScanState { sealed class DeviceScanState {
object Idle : DeviceScanState() object Idle : DeviceScanState()
object Scanning : DeviceScanState() object Scanning : DeviceScanState()
@@ -47,36 +40,40 @@ sealed class DeviceScanState {
data class Error(val message: String) : DeviceScanState() data class Error(val message: String) : DeviceScanState()
} }
/** Sealed state for the photo listing */
sealed class PhotoListState { sealed class PhotoListState {
object Idle : PhotoListState() object Idle : PhotoListState()
object Loading : PhotoListState() object Loading : PhotoListState()
data class Loaded( data class Loaded(
/** All photos from the device, unfiltered */
val all: List<PhonePhoto>, val all: List<PhonePhoto>,
val filtered: List<PhonePhoto>, /** Folders derived from all photos, sorted: camera-roll folders first then alphabetical */
val shootingInHeic: Boolean, val folders: List<FolderInfo>,
) : PhotoListState() ) : PhotoListState()
data class Error(val message: String) : 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( data class ImportProgress(
val completed: Int, val completed: Int,
val total: Int, val total: Int,
val currentFile: String, val currentFile: String,
) )
/** class AppState(private val configStore: ConfigStore) {
* Application state container — the single source of truth for all UI state.
*/
class AppState(
private val configStore: ConfigStore,
) {
private val log = LoggerFactory.getLogger(AppState::class.java) private val log = LoggerFactory.getLogger(AppState::class.java)
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Main)
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Persisted config // Config
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private val _config = MutableStateFlow(configStore.load()) private val _config = MutableStateFlow(configStore.load())
val config: StateFlow<AppConfig> = _config.asStateFlow() val config: StateFlow<AppConfig> = _config.asStateFlow()
@@ -86,7 +83,6 @@ class AppState(
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
private val _currentScreen = MutableStateFlow(Screen.CONNECT) private val _currentScreen = MutableStateFlow(Screen.CONNECT)
val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow() val currentScreen: StateFlow<Screen> = _currentScreen.asStateFlow()
fun navigateTo(screen: Screen) { _currentScreen.value = screen } 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) private val _photoList = MutableStateFlow<PhotoListState>(PhotoListState.Idle)
val photoList: StateFlow<PhotoListState> = _photoList.asStateFlow() 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()) private val _selectedPhotos = MutableStateFlow<Set<PhonePhoto>>(emptySet())
val selectedPhotos: StateFlow<Set<PhonePhoto>> = _selectedPhotos.asStateFlow() val selectedPhotos: StateFlow<Set<PhonePhoto>> = _selectedPhotos.asStateFlow()
private val _dateFilter = MutableStateFlow<Pair<LocalDate?, LocalDate?>>(null to null) /** Whether HEIC warning should be shown */
val dateFilter: StateFlow<Pair<LocalDate?, LocalDate?>> = _dateFilter.asStateFlow() val shootingInHeic: Boolean
get() = (_photoList.value as? PhotoListState.Loaded)
/** When true, bypass ExifFilter and show all photos from all DCIM folders */ ?.all?.let { ExifFilter.isShootingInHeic(it) } ?: false
private val _showAll = MutableStateFlow(false)
val showAll: StateFlow<Boolean> = _showAll.asStateFlow()
fun loadPhotos(device: DeviceInfo) { fun loadPhotos(device: DeviceInfo) {
scope.launch { scope.launch {
_photoList.value = PhotoListState.Loading _photoList.value = PhotoListState.Loading
_selectedFolder.value = null
_selectedPhotos.value = emptySet()
try { try {
val connector = connectorFor(device) val connector = connectorFor(device)
val raw = withContext(Dispatchers.IO) { connector.listPhotos(device) } val raw = withContext(Dispatchers.IO) { connector.listPhotos(device) }
val (from, to) = _dateFilter.value
val filtered = if (_showAll.value) { // Build folder summaries
applyDateFilter(raw, from, to) val folders = buildFolderList(raw)
} else {
applyDateFilter( _photoList.value = PhotoListState.Loaded(all = raw, folders = folders)
ExifFilter.filter(raw, _config.value.defaultStripLivePhotoMotion),
from, to, // Default: select the first camera-roll folder
) val defaultFolder = folders.firstOrNull { it.isCameraRoll }?.name
} selectFolder(defaultFolder)
val shootingInHeic = ExifFilter.isShootingInHeic(filtered)
_photoList.value = PhotoListState.Loaded(
all = raw,
filtered = filtered,
shootingInHeic = shootingInHeic,
)
} catch (e: Exception) { } catch (e: Exception) {
log.error("Photo listing failed", e) log.error("Photo listing failed", e)
_photoList.value = PhotoListState.Error(e.message ?: "Unknown error") _photoList.value = PhotoListState.Error(e.message ?: "Unknown error")
@@ -165,54 +164,62 @@ class AppState(
} }
} }
fun toggleShowAll() { fun selectFolder(folderName: String?) {
_showAll.value = !_showAll.value _selectedFolder.value = folderName
val current = _photoList.value as? PhotoListState.Loaded ?: return _selectedPhotos.value = emptySet()
val (from, to) = _dateFilter.value refreshVisiblePhotos()
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 setDateFilter(from: LocalDate?, to: LocalDate?) { private fun refreshVisiblePhotos() {
_dateFilter.value = from to to val loaded = _photoList.value as? PhotoListState.Loaded ?: return
val current = _photoList.value as? PhotoListState.Loaded ?: return val folder = _selectedFolder.value
val base = if (_showAll.value) current.all _visiblePhotos.value = if (folder == null) {
else ExifFilter.filter(current.all, _config.value.defaultStripLivePhotoMotion) // All camera-roll folders merged
_photoList.value = current.copy(filtered = applyDateFilter(base, from, to)) 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) { fun togglePhotoSelection(photo: PhonePhoto) {
_selectedPhotos.update { current -> _selectedPhotos.update { if (photo in it) it - photo else it + photo }
if (photo in current) current - photo else current + photo
}
} }
fun selectAll() { fun selectAllVisible() {
val photos = (_photoList.value as? PhotoListState.Loaded)?.filtered ?: return _selectedPhotos.value = _visiblePhotos.value.toSet()
_selectedPhotos.value = photos.toSet()
} }
fun selectNone() { fun selectNone() {
_selectedPhotos.value = emptySet() _selectedPhotos.value = emptySet()
} }
private fun applyDateFilter( fun selectFolder_All() {
photos: List<PhonePhoto>, val loaded = _photoList.value as? PhotoListState.Loaded ?: return
from: LocalDate?, val folder = _selectedFolder.value ?: return
to: LocalDate?, val folderPhotos = loaded.all.filter { it.sourceFolder == folder }.toSet()
): List<PhonePhoto> { _selectedPhotos.update { it + folderPhotos }
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))
}
} }
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
@@ -249,17 +256,15 @@ class AppState(
.replace("{date}", today.toString()) .replace("{date}", today.toString())
.replace("{device}", StagingPath.deviceLabel(device)) .replace("{device}", StagingPath.deviceLabel(device))
// Auto-increment if the folder already exists
val folderName = if (cfg.stagingRoot.isNotBlank()) { val folderName = if (cfg.stagingRoot.isNotBlank()) {
StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), baseName) StagingPath.uniqueFolderName(Paths.get(cfg.stagingRoot), baseName)
} else { } else baseName
baseName
}
// Multi-folder selection → preserve structure by default
val multipleSourceFolders = _selectedPhotos.value.map { it.sourceFolder }.distinct().size > 1
_importConfig.update { it.copy( _importConfig.update { it.copy(
folderName = folderName, folderName = folderName,
// When showing all files, default to preserving folder structure preserveFolderStructure = multipleSourceFolders,
preserveFolderStructure = _showAll.value,
) } ) }
navigateTo(Screen.IMPORT_CONFIG) navigateTo(Screen.IMPORT_CONFIG)
} }
@@ -311,16 +316,11 @@ class AppState(
fun deleteVerifiedFromDevice() { fun deleteVerifiedFromDevice() {
val device = _selectedDevice.value ?: return val device = _selectedDevice.value ?: return
val result = _importResult.value ?: return val result = _importResult.value ?: return
val toDelete = result.results.filter { it.verified }.map { it.photo }
scope.launch(Dispatchers.IO) { scope.launch(Dispatchers.IO) {
val connector = connectorFor(device) val connector = connectorFor(device)
toDelete.forEach { photo -> result.results.filter { it.verified }.forEach { r ->
try { try { connector.deletePhoto(device, r.photo) }
connector.deletePhoto(device, photo) catch (e: DeviceException) { log.error("Delete failed for ${r.photo.filename}: ${e.message}") }
} catch (e: DeviceException) {
log.error("Delete failed for ${photo.filename}: ${e.message}")
}
} }
} }
} }
@@ -338,9 +338,7 @@ class AppState(
saveConfig() saveConfig()
} }
fun saveConfig() { fun saveConfig() { configStore.save(_config.value) }
configStore.save(_config.value)
}
// ------------------------------------------------------------------------- // -------------------------------------------------------------------------
// Helpers // Helpers

View File

@@ -1,61 +1,45 @@
package com.bolenpad.photophetch.ui 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.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.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.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.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.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 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.LocalDate
import java.time.ZoneId
import java.time.format.DateTimeFormatter
@Composable @Composable
fun BrowseScreen(state: AppState) { fun BrowseScreen(state: AppState) {
val photoList by state.photoList.collectAsState() 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 device by state.selectedDevice.collectAsState()
val config by state.config.collectAsState() val config by state.config.collectAsState()
val dateFilter by state.dateFilter.collectAsState()
val showAll by state.showAll.collectAsState()
Column(modifier = Modifier.fillMaxSize()) { Column(modifier = Modifier.fillMaxSize()) {
// Top bar
// ── Top bar ──────────────────────────────────────────────────────────
BrowseTopBar( BrowseTopBar(
device = device, deviceName = device?.displayName ?: "Browse Photos",
selected = selected, selectedCount = selectedPhotos.size,
showAll = showAll,
onBack = { state.navigateTo(Screen.CONNECT) }, onBack = { state.navigateTo(Screen.CONNECT) },
onSelectAll = { state.selectAll() }, onSelectAll = { state.selectAllVisible() },
onSelectNone = { state.selectNone() }, onSelectNone = { state.selectNone() },
onToggleShowAll = { state.toggleShowAll() },
onImport = { state.prepareImport() }, onImport = { state.prepareImport() },
) )
// ── HEIC warning ─────────────────────────────────────────────────────
if (state.shootingInHeic && !config.heicWarningDismissed) {
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) {
@@ -67,53 +51,42 @@ fun BrowseScreen(state: AppState) {
} }
} }
is PhotoListState.Loaded -> { is PhotoListState.Loaded -> {
// HEIC warning banner Row(modifier = Modifier.fillMaxSize()) {
if (pl.shootingInHeic && !config.heicWarningDismissed) { // Left: folder panel — fixed 200dp width
HeicWarningBanner( FolderPanel(
convertByDefault = config.defaultConvertHeicToJpeg, folders = pl.folders,
onDismiss = { state.dismissHeicWarning() }, selectedFolder = selectedFolder,
selectedPhotos = selectedPhotos,
onSelectFolder = { state.selectFolder(it) },
modifier = Modifier.width(200.dp).fillMaxHeight(),
) )
}
// Date filter row VerticalDivider()
DateFilterRow(
from = dateFilter.first,
to = dateFilter.second,
onFilterChange = { from, to -> state.setDateFilter(from, to) },
)
// Photo count summary // Right: day-grouped photo grid
Row( Column(modifier = Modifier.weight(1f).fillMaxHeight()) {
modifier = Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 4.dp), // Folder subtitle bar
horizontalArrangement = Arrangement.SpaceBetween, FolderSubtitleBar(
) { folderName = selectedFolder ?: "All Camera Photos",
Text( photoCount = visiblePhotos.size,
"${pl.filtered.size} photos • ${selected.size} selected", selectedCount = selectedPhotos.size,
style = MaterialTheme.typography.bodySmall, )
color = MaterialTheme.colorScheme.onSurfaceVariant, DayGroupedGrid(
) photos = visiblePhotos,
} selectedPhotos = selectedPhotos,
device = device,
if (pl.filtered.isEmpty()) { adbBin = config.adbPath ?: "adb",
Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { onTogglePhoto = { state.togglePhotoSelection(it) },
Text("No photos match the current filter", color = MaterialTheme.colorScheme.onSurfaceVariant) onSelectDay = { _, dayPhotos ->
} // Toggle: if all selected → deselect, else select all
} else { val allSelected = dayPhotos.all { it in selectedPhotos }
LazyVerticalGrid( dayPhotos.forEach { photo ->
columns = GridCells.Adaptive(minSize = 110.dp), val inSet = photo in selectedPhotos
modifier = Modifier.fillMaxSize().padding(4.dp), if (allSelected && inSet) state.togglePhotoSelection(photo)
verticalArrangement = Arrangement.spacedBy(4.dp), else if (!allSelected && !inSet) state.togglePhotoSelection(photo)
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) },
)
}
} }
} }
} }
@@ -134,17 +107,15 @@ fun BrowseScreen(state: AppState) {
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun BrowseTopBar( private fun BrowseTopBar(
device: DeviceInfo?, deviceName: String,
selected: Set<PhonePhoto>, selectedCount: Int,
showAll: Boolean,
onBack: () -> Unit, onBack: () -> Unit,
onSelectAll: () -> Unit, onSelectAll: () -> Unit,
onSelectNone: () -> Unit, onSelectNone: () -> Unit,
onToggleShowAll: () -> Unit,
onImport: () -> Unit, onImport: () -> Unit,
) { ) {
TopAppBar( TopAppBar(
title = { Text(device?.displayName ?: "Browse Photos") }, title = { Text(deviceName) },
navigationIcon = { navigationIcon = {
IconButton(onClick = onBack) { IconButton(onClick = onBack) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back") Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back")
@@ -153,158 +124,46 @@ private fun BrowseTopBar(
actions = { actions = {
TextButton(onClick = onSelectAll) { Text("All") } TextButton(onClick = onSelectAll) { Text("All") }
TextButton(onClick = onSelectNone) { Text("None") } TextButton(onClick = onSelectNone) { Text("None") }
FilterChip(
selected = showAll,
onClick = onToggleShowAll,
label = { Text("Show all") },
modifier = Modifier.padding(horizontal = 4.dp),
)
Button( Button(
onClick = onImport, onClick = onImport,
enabled = selected.isNotEmpty(), enabled = selectedCount > 0,
modifier = Modifier.padding(end = 8.dp), modifier = Modifier.padding(end = 8.dp),
) { ) {
Text("Import ${if (selected.isNotEmpty()) "(${selected.size})" else ""}") Text(if (selectedCount > 0) "Import ($selectedCount)" else "Import")
} }
}, },
) )
} }
@Composable @Composable
private fun PhotoCell( private fun FolderSubtitleBar(
photo: PhonePhoto, folderName: String,
device: DeviceInfo?, photoCount: Int,
adbBin: String, selectedCount: Int,
isSelected: Boolean,
onToggle: () -> Unit,
) { ) {
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( Row(
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp, vertical = 4.dp), modifier = Modifier
horizontalArrangement = Arrangement.spacedBy(8.dp), .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 6.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon(Icons.Default.DateRange, contentDescription = null, modifier = Modifier.size(16.dp)) Text(
Text("Filter:", style = MaterialTheme.typography.labelMedium) folderName,
FilterChip( style = MaterialTheme.typography.titleSmall,
selected = from == null && to == null, fontWeight = FontWeight.SemiBold,
onClick = { onFilterChange(null, null) },
label = { Text("All") },
) )
FilterChip( Text(
selected = from == today.minusDays(7) && to == null, if (selectedCount > 0) "$selectedCount / $photoCount selected"
onClick = { onFilterChange(today.minusDays(7), null) }, else "$photoCount photos",
label = { Text("Last 7 days") }, style = MaterialTheme.typography.bodySmall,
) color = MaterialTheme.colorScheme.onSurfaceVariant,
FilterChip(
selected = from == today.minusDays(30) && to == null,
onClick = { onFilterChange(today.minusDays(30), null) },
label = { Text("Last 30 days") },
) )
} }
} }
@Composable @Composable
private fun HeicWarningBanner(convertByDefault: Boolean, onDismiss: () -> Unit) { private fun HeicWarningBanner(onDismiss: () -> Unit) {
Card( Card(
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),
@@ -316,12 +175,10 @@ private fun HeicWarningBanner(convertByDefault: Boolean, onDismiss: () -> Unit)
) { ) {
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, style = MaterialTheme.typography.bodySmall) Text("Phone is shooting in HEIC", fontWeight = FontWeight.SemiBold,
style = MaterialTheme.typography.bodySmall)
Text( Text(
if (convertByDefault) "Files will be converted to JPEG during import. To avoid this: Settings → Camera → Formats → Most Compatible.",
"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.",
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onTertiaryContainer, color = MaterialTheme.colorScheme.onTertiaryContainer,
) )

View 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))
)
}
}
}

View 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)
}
}
}
}

View File

@@ -90,6 +90,16 @@ object ExifFilter {
return true 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). * Returns photos that are HEIC files (and not Live Photo motion clips).
* Used to determine whether to show the HEIC conversion warning. * Used to determine whether to show the HEIC conversion warning.