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")
|
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,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) */
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
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
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user