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")
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,
)
)
}

View File

@@ -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) */

View File

@@ -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

View File

@@ -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,
)

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
}
/**
* 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.