Compare commits

..
19 Commits
Author SHA1 Message Date
OneWay f5945dfacc Show the empty-state badge on empty subfolders in galleries
The stats fix from f557f21 covered the folders screen only. The gallery
screen loads subfolder stats with the same update-only-on-change
pattern, so empty subfolders kept statsLoaded = false and their previews
stayed without the badge.

Apply the same explicit flag when a successful load changes nothing,
also in the incremental update after captures.
2026-10-22 19:56:49 +08:00
OneWay 4623faa87f Scroll the rename field to follow the caret
Single-line text fields in this Compose version never scroll their
content when the caret moves: the caret walks out of the visible area
and the tail of a long file name stays unreachable. Both material3 and
foundation fields are affected, so the follow-the-caret step is broken
below them, in the shared text core.

Add NameField on the state-based BasicTextField that scrolls explicitly:
on every selection change the caret is brought into view with
animateScrollTo. Rename dialogs open with the caret at the end, so the
tail of a long name is visible immediately.

Verified on an emulator with a name overflowing the field by ~640px:
moving the caret between the ends shifts the text by the overflow
amount in both directions.
2026-10-22 19:56:10 +08:00
OneWay 18cc15ed07 Show the file name in the fullscreen photo viewer
The fullscreen viewer identified a photo only by its pixels. Put the
file name on the same line as the close button, ellipsized when long,
so it is always clear which file is on screen.
2026-10-22 19:55:38 +08:00
OneWay 8d36b340cf Mark empty folders as stats-loaded so previews show the badge
The stats update only applied statsLoaded = true when the loaded values
differed from the current state. Empty folders start with count 0 and
no previews, nothing ever differs, so they stayed statsLoaded = false
and their preview kept hiding the empty-state badge.

Set the flag explicitly when a successful load changes nothing.
2026-10-22 19:55:38 +08:00
OneWay 37dfde47a9 Polish empty folders hint wording
Drop the trailing period and the word "your": "Create a folder to
start organizing photos". The plural reads naturally without a
possessive.
2026-10-21 12:35:27 +08:00
OneWay a5bb20b1d2 Polish empty gallery hint wording
Drop the trailing period and the word "your"; restore the article so
the sentence stays grammatical: "Use the camera button to add the
first photo".
2026-10-21 12:34:13 +08:00
OneWay 596ac96e77 Hide sort and display-mode actions in empty folders
A folder without photos and subfolders kept the sort and list/grid
toggle icons in the top bar although they had nothing to act on. Keep
them only when the folder has content; the create-subfolder action
stays.
2026-10-21 12:34:13 +08:00
OneWay 4df4727535 Show empty-state badge on empty folder previews
Empty folder previews drew a bare folder glyph. Use the same
photo-library icon badge as the empty gallery block for consistency.
2026-10-21 12:34:12 +08:00
OneWay 3ce1b4a40f Stay on the folders screen after capturing into the root
Capturing from the root screen pushed the root folder's gallery after
saving, a screen whose back button led straight back to the folders
screen the user never left intentionally.

The root screen never sets showTargetGalleryAfterSave now, so the
capture lands back on the folders screen, where the new photo appears
in the grid.
2026-10-21 12:34:12 +08:00
OneWay 90dad3a9de Add Move action and file cards on the root screen
The root screen dropped loose files entirely: listGallery results were
reduced to subfolders, and no card there offered Move. Files saved to
the root were invisible and unreachable.

- List photos and ZIP archives of the root next to folder cards
- Wire Move for folders (excluding the moved subtree from targets) and
  for files, reusing the folder tree picker sheet
- Support the full file menu on the root: ZIP, JPG 85, rename, delete,
  share
- Treat a root with only files as non-empty instead of showing the
  empty state

Verified on device: a file moved from the root into a subfolder and a
folder moved into a sibling via the sheet.
2026-10-21 12:34:12 +08:00
OneWay e47912edf6 Fix Move sheet collapsing to half height after opening
In material3 1.5.0-beta01 the ModalBottomSheet settles on its
PartialExpand anchor (50% of the screen) once the enter animation
finishes: the content stays laid out for the full-height sheet, so the
folder list clips at the screen bottom and the Cancel / Move buttons
end up off-screen. Verified on device with slow-motion animation dumps.

- Skip the partial anchor so the sheet always settles fully expanded
- Keep the folder list on an adaptive weighted slot: short trees wrap
  the sheet to their content, long trees expand the sheet to the full
  screen height with the list scrolling and the buttons pinned at the
  bottom (verified with a 17-folder tree on device)
2026-10-21 10:28:11 +08:00
OneWay 7156582046 Bump Gradle to 9.8.2 and refresh dependency versions
- Gradle wrapper 9.8.1 -> 9.8.2 (latest stable)
- kotlinx-serialization-json 1.11.0 -> 1.12.0
- ben-manes versions plugin 0.64.0 -> 0.66.0
- Everything else in libs.versions.toml is already the latest stable as
  of 2026-10-21; newer tags exist only as pre-releases (AGP 9.5.0-alpha10,
  Kotlin 2.5.0-Beta1, lifecycle 2.12.0-alpha04, activity 1.14.0-alpha03,
  DataStore 1.3.0-rc01, baselineprofile 1.6.0-alpha01)
2026-10-21 09:05:46 +08:00
OneWay 71ad849166 Remove the brightness dip in screen transition crossfades
- Fading the outgoing and incoming screens simultaneously makes content
  present on both (the camera FAB) dip to ~75% opacity mid-transition
- Fade only one layer: hold the outgoing destination opaque under the
  fading-in incoming one, and reveal the destination instantly under the
  fading-out one on back
2026-10-15 13:50:31 +08:00
OneWay 761db7a69d Keep the camera FAB stable across folder navigation
- Skip rendering the FAB until saved preferences load, so it is never
  drawn at the default corner and never jumps to its dragged position
- Share user preferences as one hot process-wide StateFlow instead of a
  cold disk read per screen, so a newly created gallery screen receives
  the FAB position synchronously in the ViewModel constructor
2026-10-15 13:50:31 +08:00
OneWay 428749d8a8 Show the gallery folder name from the first frame
- Carry the folder name through GalleryRoute into the ViewModel's
  SavedStateHandle, so the TopBar title is correct immediately on
  navigation, before any directory listing completes
- Seed a provisional name from the SAF URI segment in loadPhotos as a
  fallback for entry points without a name (after-camera navigation)
- Drop the 'Gallery' fallback title and the unused gallery_title string
2026-10-15 13:50:31 +08:00
OneWay 460c50bfd9 Fix photo viewer flicker when opening an image
- Keep the grid composed under the viewer instead of swapping it for a
  black box, removing the momentary black flash while the pager draws
  its first frame after tapping a photo
- Restore the pre-audit fade: thumbnail stays fully opaque under the
  fading-in full image instead of crossfading both over black
2026-10-15 13:50:31 +08:00
OneWay 5832e2db4a Replace launcher icon with adaptive-only vector glyph 2026-10-15 13:50:31 +08:00
OneWay 76ea8ba9a0 Make GalleryUiState derivations lazy and drop dead sorts
copy() ran on every state update (each drag-select step included) and eagerly
recomputed five derived properties: two unused O(N log N) sorts, an O(N)
selection filter, an O(N) selectable-URI set and its containsAll check.

- Remove sortedPhotos/sortedSubfolders: never read (the screen sorts with its
  own remember() calls)
- selectedPhotos/selectablePhotoUris/allPhotosSelected become by lazy:
  computed at most once per instance, only when actually read; body delegates
  stay out of generated copy()/equals(), @Immutable semantics hold
- Memoize selectablePhotoUris across drag-select steps in the ViewModel keyed
  by photos list identity, so a drag step no longer recomputes the O(N) set
2026-10-14 16:06:12 +08:00
OneWay c2ddfdf89d Apply Compose performance audit fixes
- FolderTreePickerSheet: add contentType to lazy folder items for slot reuse
- GalleryScreen: use plain remember instead of derivedStateOf over non-state
  inputs; defer photo fade alpha to Draw phase via graphicsLayer; react to
  zoom threshold with snapshotFlow instead of per-frame effect relaunch
- GalleryViewModel: drop @Immutable from MovePhotoSheetState so Strong
  Skipping compares the lazy feed by O(1) reference equality
- SystemCameraCapture: move temp-file disk I/O off the main thread
2026-10-14 16:06:12 +08:00
26 changed files with 609 additions and 211 deletions

No files matched your search

@@ -9,8 +9,13 @@ import androidx.datastore.preferences.core.floatPreferencesKey
import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore import androidx.datastore.preferences.preferencesDataStore
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
@@ -41,17 +46,26 @@ class UserPreferencesRepository @Inject constructor(
val SCREEN_TRANSITIONS_ENABLED = booleanPreferencesKey("screen_transitions_enabled") val SCREEN_TRANSITIONS_ENABLED = booleanPreferencesKey("screen_transitions_enabled")
} }
val userPreferencesFlow: Flow<UserPreferences> = context.dataStore.data.map { prefs -> // Hot, process-wide cache: the DataStore file is read once, and every screen
UserPreferences( // created later receives the current value synchronously on subscription,
rootFolderUri = prefs[Keys.ROOT_FOLDER_URI], // instead of re-reading disk per screen and flashing default positions.
cameraFabXFraction = prefs[Keys.CAMERA_FAB_X_FRACTION]?.coerceIn(0f, 1f) ?: 1f, val userPreferencesFlow: StateFlow<UserPreferences> = context.dataStore.data
cameraFabYFraction = prefs[Keys.CAMERA_FAB_Y_FRACTION]?.coerceIn(0f, 1f) ?: 1f, .map { prefs ->
gallerySortAscending = prefs[Keys.GALLERY_SORT_ASCENDING] ?: true, UserPreferences(
galleryGridMode = prefs[Keys.GALLERY_GRID_MODE] ?: true, rootFolderUri = prefs[Keys.ROOT_FOLDER_URI],
zipJpegCompression = prefs[Keys.ZIP_JPEG_COMPRESSION] ?: true, cameraFabXFraction = prefs[Keys.CAMERA_FAB_X_FRACTION]?.coerceIn(0f, 1f) ?: 1f,
screenTransitionsEnabled = prefs[Keys.SCREEN_TRANSITIONS_ENABLED] ?: true, cameraFabYFraction = prefs[Keys.CAMERA_FAB_Y_FRACTION]?.coerceIn(0f, 1f) ?: 1f,
gallerySortAscending = prefs[Keys.GALLERY_SORT_ASCENDING] ?: true,
galleryGridMode = prefs[Keys.GALLERY_GRID_MODE] ?: true,
zipJpegCompression = prefs[Keys.ZIP_JPEG_COMPRESSION] ?: true,
screenTransitionsEnabled = prefs[Keys.SCREEN_TRANSITIONS_ENABLED] ?: true,
)
}
.stateIn(
scope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
started = SharingStarted.Eagerly,
initialValue = UserPreferences(),
) )
}
suspend fun setRootFolderUri(uri: String) { suspend fun setRootFolderUri(uri: String) {
context.dataStore.edit { prefs -> context.dataStore.edit { prefs ->
@@ -32,7 +32,10 @@ import kotlinx.serialization.Serializable
object FoldersRoute object FoldersRoute
@Serializable @Serializable
data class GalleryRoute(val folderUri: String) data class GalleryRoute(
val folderUri: String,
val folderName: String = "",
)
@Serializable @Serializable
data class CameraRoute( data class CameraRoute(
@@ -64,22 +67,23 @@ fun OneCameraNavGraph(navController: NavHostController) {
navController = navController, navController = navController,
startDestination = FoldersRoute, startDestination = FoldersRoute,
enterTransition = { if (transitionsEnabled) fadeIn(tween(250)) else EnterTransition.None }, enterTransition = { if (transitionsEnabled) fadeIn(tween(250)) else EnterTransition.None },
exitTransition = { if (transitionsEnabled) fadeOut(tween(250)) else ExitTransition.None }, exitTransition = {
popEnterTransition = { if (transitionsEnabled) fadeIn(tween(250)) else EnterTransition.None }, if (transitionsEnabled) {
ExitTransition.KeepUntilTransitionsFinished
} else {
ExitTransition.None
}
},
popEnterTransition = { EnterTransition.None },
popExitTransition = { if (transitionsEnabled) fadeOut(tween(250)) else ExitTransition.None }, popExitTransition = { if (transitionsEnabled) fadeOut(tween(250)) else ExitTransition.None },
) { ) {
composable<FoldersRoute> { composable<FoldersRoute> {
FoldersScreen( FoldersScreen(
onNavigateToGallery = { folderUri -> onNavigateToGallery = { folderUri, folderName ->
navController.navigate(GalleryRoute(folderUri)) navController.navigate(GalleryRoute(folderUri, folderName))
}, },
onNavigateToCamera = { folderUri -> onNavigateToCamera = { folderUri ->
navController.navigate( navController.navigate(CameraRoute(targetFolderUri = folderUri))
CameraRoute(
targetFolderUri = folderUri,
showTargetGalleryAfterSave = true,
),
)
}, },
onNavigateToSettings = { onNavigateToSettings = {
navController.navigate(SettingsRoute) navController.navigate(SettingsRoute)
@@ -93,8 +97,8 @@ fun OneCameraNavGraph(navController: NavHostController) {
onNavigateToCamera = { folderUri -> onNavigateToCamera = { folderUri ->
navController.navigate(CameraRoute(folderUri)) navController.navigate(CameraRoute(folderUri))
}, },
onNavigateToSubfolder = { folderUri -> onNavigateToSubfolder = { folderUri, folderName ->
navController.navigate(GalleryRoute(folderUri)) navController.navigate(GalleryRoute(folderUri, folderName))
}, },
onNavigateBack = { navController.popBackStack() }, onNavigateBack = { navController.popBackStack() },
) )
@@ -37,7 +37,9 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
import com.example.onecamera.R import com.example.onecamera.R
import com.example.onecamera.ui.components.OneCameraEmptyState import com.example.onecamera.ui.components.OneCameraEmptyState
import com.example.onecamera.ui.components.OneCameraScreenPadding import com.example.onecamera.ui.components.OneCameraScreenPadding
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.io.File import java.io.File
@@ -111,23 +113,26 @@ fun SystemCameraCapture(
LaunchedEffect(hasLaunchedCamera) { LaunchedEffect(hasLaunchedCamera) {
if (!hasLaunchedCamera) { if (!hasLaunchedCamera) {
viewModel.clearLastSaveResult() viewModel.clearLastSaveResult()
val tempDir = File(context.cacheDir, "camera_captures").apply { mkdirs() } val (preparedPath, preparedUri) = withContext(Dispatchers.IO) {
val tempDir = File(context.cacheDir, "camera_captures").apply { mkdirs() }
tempDir.listFiles()?.forEach { runCatching { it.delete() } } tempDir.listFiles()?.forEach { runCatching { it.delete() } }
val tempFile = File(tempDir, "capture_${System.currentTimeMillis()}.jpg").apply { val tempFile = File(tempDir, "capture_${System.currentTimeMillis()}.jpg").apply {
createNewFile() createNewFile()
}
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
tempFile,
)
tempFile.absolutePath to uri.toString()
} }
val uri = FileProvider.getUriForFile( tempPhotoUriString = preparedUri
context, tempPhotoPath = preparedPath
"${context.packageName}.fileprovider",
tempFile,
)
tempPhotoUriString = uri.toString()
tempPhotoPath = tempFile.absolutePath
hasLaunchedCamera = true hasLaunchedCamera = true
hasReceivedCameraResult = false hasReceivedCameraResult = false
launchUptimeMillis = SystemClock.elapsedRealtime() launchUptimeMillis = SystemClock.elapsedRealtime()
takePictureLauncher.launch(uri) takePictureLauncher.launch(Uri.parse(preparedUri))
} }
} }
@@ -143,7 +148,7 @@ fun SystemCameraCapture(
tempPhotoUriString != null tempPhotoUriString != null
) { ) {
val tempLength = tempPhotoPath?.let { path -> val tempLength = tempPhotoPath?.let { path ->
runCatching { File(path).length() }.getOrNull() runCatching { withContext(Dispatchers.IO) { File(path).length() } }.getOrNull()
} ?: 0L } ?: 0L
val tempPhotoUri = tempPhotoUriString?.let(Uri::parse) val tempPhotoUri = tempPhotoUriString?.let(Uri::parse)
if (tempLength > 0L && tempPhotoUri != null) { if (tempLength > 0L && tempPhotoUri != null) {
@@ -184,10 +184,10 @@ private fun FolderPreview(photos: List<Uri>, statsLoaded: Boolean, modifier: Mod
if (photos.isEmpty()) { if (photos.isEmpty()) {
if (statsLoaded) { if (statsLoaded) {
Icon( OneCameraIconBadge(
painter = painterResource(R.drawable.ic_symbol_folder), icon = painterResource(R.drawable.ic_symbol_photo_library),
contentDescription = null, size = 64.dp,
tint = MaterialTheme.colorScheme.onSurfaceVariant, iconSize = 32.dp,
) )
} }
} else { } else {
@@ -7,6 +7,7 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
@@ -19,6 +20,7 @@ import androidx.compose.material3.ListItem
import androidx.compose.material3.ListItemDefaults import androidx.compose.material3.ListItemDefaults
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -50,8 +52,11 @@ fun FolderTreePickerSheet(
val selectedFolder = state.folders.firstOrNull { it.uri.toString() == selectedFolderUri } val selectedFolder = state.folders.firstOrNull { it.uri.toString() == selectedFolderUri }
val canMove = selectedFolder != null && !state.isMoving val canMove = selectedFolder != null && !state.isMoving
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
ModalBottomSheet( ModalBottomSheet(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
sheetState = sheetState,
containerColor = MaterialTheme.colorScheme.surface, containerColor = MaterialTheme.colorScheme.surface,
) { ) {
Column( Column(
@@ -105,7 +110,11 @@ fun FolderTreePickerSheet(
.fillMaxWidth() .fillMaxWidth()
.weight(1f, fill = false), .weight(1f, fill = false),
) { ) {
items(state.folders, key = { it.uri }) { folder -> items(
items = state.folders,
key = { it.uri },
contentType = { "folder" },
) { folder ->
val isCurrent = currentFolderUri == folder.uri.toString() val isCurrent = currentFolderUri == folder.uri.toString()
val isSelected = selectedFolderUri == folder.uri.toString() val isSelected = selectedFolderUri == folder.uri.toString()
ListItem( ListItem(
@@ -1,50 +1,113 @@
package com.example.onecamera.ui.folders package com.example.onecamera.ui.folders
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.AlertDialog import androidx.compose.material3.AlertDialog
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextLayoutResult
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.example.onecamera.R import com.example.onecamera.R
import com.example.onecamera.domain.model.FolderNode import com.example.onecamera.domain.model.FolderNode
import com.example.onecamera.domain.model.Photo import com.example.onecamera.domain.model.Photo
import kotlin.math.roundToInt
@Composable
private fun NameField(
label: String,
state: TextFieldState,
modifier: Modifier = Modifier,
) {
val scrollState = rememberScrollState()
val density = LocalDensity.current
var layoutResult by remember { mutableStateOf<TextLayoutResult?>(null) }
var fieldWidth by remember { mutableStateOf(0) }
LaunchedEffect(state.selection, layoutResult, fieldWidth) {
val layout = layoutResult ?: return@LaunchedEffect
if (fieldWidth == 0) return@LaunchedEffect
val cursor = state.selection.end.coerceIn(0, layout.layoutInput.text.length)
val cursorX = layout.getHorizontalPosition(cursor, true).coerceAtLeast(0f)
val edgeMargin = with(density) { 40.dp.toPx() }
val target = when {
cursorX < scrollState.value -> cursorX
cursorX > scrollState.value + fieldWidth - edgeMargin ->
cursorX - fieldWidth + edgeMargin
else -> return@LaunchedEffect
}
scrollState.animateScrollTo(target.roundToInt().coerceIn(0, scrollState.maxValue))
}
Column(modifier = modifier) {
Text(
text = label,
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(Modifier.height(4.dp))
BasicTextField(
state = state,
textStyle = MaterialTheme.typography.bodyLarge.copy(
color = MaterialTheme.colorScheme.onSurface,
),
onTextLayout = { textLayoutResultProvider ->
layoutResult = textLayoutResultProvider()
},
scrollState = scrollState,
modifier = Modifier
.fillMaxWidth()
.onSizeChanged { fieldWidth = it.width }
.border(
1.dp,
MaterialTheme.colorScheme.outline,
MaterialTheme.shapes.extraSmall,
)
.padding(horizontal = 14.dp, vertical = 14.dp),
)
}
}
@Composable @Composable
fun CreateFolderDialog( fun CreateFolderDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onCreate: (String) -> Unit, onCreate: (String) -> Unit,
) { ) {
var name by remember { mutableStateOf("") } val nameState = rememberTextFieldState("")
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text(stringResource(R.string.dialog_create_folder_title)) }, title = { Text(stringResource(R.string.dialog_create_folder_title)) },
text = { text = {
OutlinedTextField( NameField(
value = name, label = stringResource(R.string.dialog_folder_name),
onValueChange = { name = it }, state = nameState,
label = { Text(stringResource(R.string.dialog_folder_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
}, },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onCreate(name) }, onClick = { onCreate(nameState.text.toString()) },
enabled = name.isNotBlank(), enabled = nameState.text.isNotBlank(),
) { ) {
Text(stringResource(R.string.action_create)) Text(stringResource(R.string.action_create))
} }
@@ -63,7 +126,10 @@ fun RenameFolderDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onRename: (String) -> Unit, onRename: (String) -> Unit,
) { ) {
var name by remember { mutableStateOf(folder.name) } val nameState = rememberTextFieldState(
initialText = folder.name,
initialSelection = TextRange(folder.name.length),
)
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -72,19 +138,16 @@ fun RenameFolderDialog(
Column { Column {
Text(stringResource(R.string.dialog_current_name, folder.name)) Text(stringResource(R.string.dialog_current_name, folder.name))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedTextField( NameField(
value = name, label = stringResource(R.string.dialog_new_name),
onValueChange = { name = it }, state = nameState,
label = { Text(stringResource(R.string.dialog_new_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
} }
}, },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onRename(name) }, onClick = { onRename(nameState.text.toString()) },
enabled = name.isNotBlank() && name != folder.name, enabled = nameState.text.isNotBlank() && nameState.text.toString() != folder.name,
) { ) {
Text(stringResource(R.string.action_rename)) Text(stringResource(R.string.action_rename))
} }
@@ -103,7 +166,10 @@ fun RenameFileDialog(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onRename: (String) -> Unit, onRename: (String) -> Unit,
) { ) {
var name by remember { mutableStateOf(file.name) } val nameState = rememberTextFieldState(
initialText = file.name,
initialSelection = TextRange(file.name.length),
)
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
@@ -112,19 +178,16 @@ fun RenameFileDialog(
Column { Column {
Text(stringResource(R.string.dialog_current_name, file.name)) Text(stringResource(R.string.dialog_current_name, file.name))
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
OutlinedTextField( NameField(
value = name, label = stringResource(R.string.dialog_new_name),
onValueChange = { name = it }, state = nameState,
label = { Text(stringResource(R.string.dialog_new_name)) },
singleLine = true,
modifier = Modifier.fillMaxWidth(),
) )
} }
}, },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onRename(name) }, onClick = { onRename(nameState.text.toString()) },
enabled = name.isNotBlank() && name != file.name, enabled = nameState.text.isNotBlank() && nameState.text.toString() != file.name,
) { ) {
Text(stringResource(R.string.action_rename)) Text(stringResource(R.string.action_rename))
} }
@@ -18,6 +18,7 @@ import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.grid.rememberLazyGridState
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import androidx.compose.material3.IconButton
@@ -27,6 +28,7 @@ import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.SnackbarResult import androidx.compose.material3.SnackbarResult
import androidx.compose.material3.Text import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
@@ -49,15 +51,17 @@ import androidx.lifecycle.compose.LifecycleResumeEffect
import com.example.onecamera.R import com.example.onecamera.R
import com.example.onecamera.ui.components.DraggableCameraFab import com.example.onecamera.ui.components.DraggableCameraFab
import com.example.onecamera.ui.components.FolderCard import com.example.onecamera.ui.components.FolderCard
import com.example.onecamera.ui.components.FolderTreePickerSheet
import com.example.onecamera.ui.components.OneCameraContentMaxWidth import com.example.onecamera.ui.components.OneCameraContentMaxWidth
import com.example.onecamera.ui.components.OneCameraEmptyState import com.example.onecamera.ui.components.OneCameraEmptyState
import com.example.onecamera.ui.components.OneCameraScreenPadding import com.example.onecamera.ui.components.OneCameraScreenPadding
import com.example.onecamera.ui.components.PhotoCard
import com.example.onecamera.ui.components.oneCameraTopAppBarColors import com.example.onecamera.ui.components.oneCameraTopAppBarColors
@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class)
@Composable @Composable
fun FoldersScreen( fun FoldersScreen(
onNavigateToGallery: (String) -> Unit, onNavigateToGallery: (folderUri: String, folderName: String) -> Unit,
onNavigateToCamera: (String) -> Unit, onNavigateToCamera: (String) -> Unit,
onNavigateToSettings: () -> Unit, onNavigateToSettings: () -> Unit,
viewModel: FoldersViewModel = hiltViewModel(), viewModel: FoldersViewModel = hiltViewModel(),
@@ -192,8 +196,8 @@ fun FoldersScreen(
.widthIn(max = 560.dp), .widthIn(max = 560.dp),
) )
} }
uiState.isLoading && uiState.folders.isEmpty() -> Unit uiState.isLoading && uiState.folders.isEmpty() && uiState.files.isEmpty() -> Unit
uiState.folders.isEmpty() -> { uiState.folders.isEmpty() && uiState.files.isEmpty() -> {
OneCameraEmptyState( OneCameraEmptyState(
icon = painterResource(R.drawable.ic_symbol_create_new_folder), icon = painterResource(R.drawable.ic_symbol_create_new_folder),
title = stringResource(R.string.folders_empty_title), title = stringResource(R.string.folders_empty_title),
@@ -222,7 +226,7 @@ fun FoldersScreen(
) { ) {
items( items(
items = uiState.folders, items = uiState.folders,
key = { it.uri }, key = { "folder_${it.uri}" },
contentType = { "folder" }, contentType = { "folder" },
) { folder -> ) { folder ->
FolderCard( FolderCard(
@@ -230,16 +234,33 @@ fun FoldersScreen(
onClick = { onClick = {
viewModel.prefetchFolderForNavigation(folder.uri.toString()) viewModel.prefetchFolderForNavigation(folder.uri.toString())
onNavigateToGallery(folder.uri.toString()) onNavigateToGallery(folder.uri.toString(), folder.name)
}, },
onCompress = { viewModel.zipFolderAndShare(folder) }, onCompress = { viewModel.zipFolderAndShare(folder) },
onCompressJpeg85 = { onCompressJpeg85 = {
viewModel.zipFolderCompressedJpegsAndShare(folder) viewModel.zipFolderCompressedJpegsAndShare(folder)
}, },
onMove = { viewModel.showMoveSheetForFolder(folder) },
onRename = { viewModel.showRenameDialog(folder) }, onRename = { viewModel.showRenameDialog(folder) },
onDelete = { viewModel.showDeleteDialog(folder) }, onDelete = { viewModel.showDeleteDialog(folder) },
) )
} }
items(
items = uiState.files,
key = { "file_${it.uri}" },
contentType = { "file" },
) { file ->
PhotoCard(
photo = file,
onClick = { },
onMove = { viewModel.showMoveSheetForFile(file) },
onRename = { viewModel.showRenameFileDialog(file) },
onCompressFolder = { viewModel.zipFileAndShare(file) },
onCompressJpeg85 = { viewModel.zipFileCompressedJpegsAndShare(file) },
onShare = { viewModel.shareFile(file) },
onDelete = { viewModel.showDeleteFileDialog(file) },
)
}
} }
} }
} }
@@ -277,4 +298,43 @@ fun FoldersScreen(
onDelete = { viewModel.deleteFolder(folder) }, onDelete = { viewModel.deleteFolder(folder) },
) )
} }
uiState.renameFile?.let { file ->
RenameFileDialog(
file = file,
onDismiss = viewModel::dismissRenameFileDialog,
onRename = { viewModel.renameFile(file, it) },
)
}
uiState.deleteFile?.let { file ->
AlertDialog(
onDismissRequest = viewModel::dismissDeleteFileDialog,
title = { Text(stringResource(R.string.gallery_delete_photo_title)) },
text = {
Text(stringResource(R.string.gallery_delete_photo_message, file.name))
},
confirmButton = {
TextButton(onClick = { viewModel.deleteFile(file) }) {
Text(stringResource(R.string.action_delete))
}
},
dismissButton = {
TextButton(onClick = viewModel::dismissDeleteFileDialog) {
Text(stringResource(R.string.action_cancel))
}
},
)
}
uiState.moveSheet?.let { sheet ->
FolderTreePickerSheet(
state = sheet,
currentFolderUri = uiState.rootFolderUri,
onDismiss = viewModel::dismissMoveSheet,
onSelect = { target ->
when {
sheet.folder != null -> viewModel.moveFolderTo(sheet.folder, target)
sheet.file != null -> viewModel.moveFileTo(sheet.file, target)
}
},
)
}
} }
@@ -13,14 +13,18 @@ import com.example.onecamera.data.storage.ExternalMediaObserver
import com.example.onecamera.data.storage.saf.MissingUriPermissionException import com.example.onecamera.data.storage.saf.MissingUriPermissionException
import com.example.onecamera.domain.model.FolderNode import com.example.onecamera.domain.model.FolderNode
import com.example.onecamera.domain.model.GalleryFileKind import com.example.onecamera.domain.model.GalleryFileKind
import com.example.onecamera.domain.model.MovePhotoResult
import com.example.onecamera.domain.model.Photo import com.example.onecamera.domain.model.Photo
import com.example.onecamera.domain.model.mergeLoadedSubfolderStats import com.example.onecamera.domain.model.mergeLoadedSubfolderStats
import com.example.onecamera.domain.repository.PhotoRepository import com.example.onecamera.domain.repository.PhotoRepository
import com.example.onecamera.domain.usecase.CreateFolderUseCase import com.example.onecamera.domain.usecase.CreateFolderUseCase
import com.example.onecamera.domain.usecase.DeleteEntryUseCase import com.example.onecamera.domain.usecase.DeleteEntryUseCase
import com.example.onecamera.domain.usecase.ListFolderTreeUseCase
import com.example.onecamera.domain.usecase.ListGalleryUseCase import com.example.onecamera.domain.usecase.ListGalleryUseCase
import com.example.onecamera.domain.usecase.MovePhotoUseCase
import com.example.onecamera.domain.usecase.RenameEntryUseCase import com.example.onecamera.domain.usecase.RenameEntryUseCase
import com.example.onecamera.domain.usecase.ZipFolderUseCase import com.example.onecamera.domain.usecase.ZipFolderUseCase
import com.example.onecamera.ui.gallery.MovePhotoSheetState
import com.example.onecamera.ui.components.evictThumbnails import com.example.onecamera.ui.components.evictThumbnails
import com.example.onecamera.ui.components.thumbnailWarmupRequests import com.example.onecamera.ui.components.thumbnailWarmupRequests
import com.example.onecamera.ui.components.NAVIGATION_PREFETCH_THUMBNAIL_COUNT import com.example.onecamera.ui.components.NAVIGATION_PREFETCH_THUMBNAIL_COUNT
@@ -35,12 +39,16 @@ import javax.inject.Inject
@Immutable @Immutable
data class FoldersUiState( data class FoldersUiState(
val folders: List<FolderNode> = emptyList(), val folders: List<FolderNode> = emptyList(),
val files: List<Photo> = emptyList(),
val isLoading: Boolean = false, val isLoading: Boolean = false,
val rootFolderUri: String? = null, val rootFolderUri: String? = null,
val isRootFolderResolved: Boolean = false, val isRootFolderResolved: Boolean = false,
val showCreateDialog: Boolean = false, val showCreateDialog: Boolean = false,
val showRenameDialog: FolderNode? = null, val showRenameDialog: FolderNode? = null,
val showDeleteDialog: FolderNode? = null, val showDeleteDialog: FolderNode? = null,
val renameFile: Photo? = null,
val deleteFile: Photo? = null,
val moveSheet: MovePhotoSheetState? = null,
val cameraFabXFraction: Float = 1f, val cameraFabXFraction: Float = 1f,
val cameraFabYFraction: Float = 1f, val cameraFabYFraction: Float = 1f,
val shareFileRequest: com.example.onecamera.ui.gallery.ShareFileRequest? = null, val shareFileRequest: com.example.onecamera.ui.gallery.ShareFileRequest? = null,
@@ -54,6 +62,8 @@ class FoldersViewModel @Inject constructor(
private val preferencesRepository: UserPreferencesRepository, private val preferencesRepository: UserPreferencesRepository,
private val photoRepository: PhotoRepository, private val photoRepository: PhotoRepository,
private val listGalleryUseCase: ListGalleryUseCase, private val listGalleryUseCase: ListGalleryUseCase,
private val listFolderTreeUseCase: ListFolderTreeUseCase,
private val movePhotoUseCase: MovePhotoUseCase,
private val createFolderUseCase: CreateFolderUseCase, private val createFolderUseCase: CreateFolderUseCase,
private val renameEntryUseCase: RenameEntryUseCase, private val renameEntryUseCase: RenameEntryUseCase,
private val deleteEntryUseCase: DeleteEntryUseCase, private val deleteEntryUseCase: DeleteEntryUseCase,
@@ -123,13 +133,14 @@ class FoldersViewModel @Inject constructor(
val uri = rootUriString.toUri() val uri = rootUriString.toUri()
runCatching { runCatching {
OneCameraTrace.asyncSection(OneCameraTrace.FOLDERS_LOAD) { OneCameraTrace.asyncSection(OneCameraTrace.FOLDERS_LOAD) {
listGalleryUseCase(uri).subfolders listGalleryUseCase(uri)
} }
}.onSuccess { folders -> }.onSuccess { content ->
val merged = mergeLoadedSubfolderStats(folders, uiState.value.folders) val merged = mergeLoadedSubfolderStats(content.subfolders, uiState.value.folders)
uiState.value = uiState.value.copy( uiState.value = uiState.value.copy(
folders = merged, folders = merged,
files = content.photos,
isLoading = false, isLoading = false,
error = null, error = null,
needsRootReselection = false, needsRootReselection = false,
@@ -219,6 +230,10 @@ class FoldersViewModel @Inject constructor(
statsLoaded = true, statsLoaded = true,
) )
uiState.value = uiState.value.copy(folders = updated) uiState.value = uiState.value.copy(folders = updated)
} else if (!old.statsLoaded) {
val updated = current.toMutableList()
updated[idx] = old.copy(statsLoaded = true)
uiState.value = uiState.value.copy(folders = updated)
} }
} }
@@ -256,6 +271,9 @@ class FoldersViewModel @Inject constructor(
statsLoaded = true, statsLoaded = true,
) )
changed = true changed = true
} else if (!old.statsLoaded) {
updated[idx] = old.copy(statsLoaded = true)
changed = true
} }
} }
} }
@@ -367,6 +385,230 @@ class FoldersViewModel @Inject constructor(
} }
} }
fun zipFileAndShare(file: Photo) {
val rootUri = uiState.value.rootFolderUri?.toUri() ?: return
viewModelScope.launch {
uiState.value = uiState.value.copy(error = null)
val archiveBase = file.name.substringBeforeLast('.').ifBlank { file.name }
val outcome = zipFolderUseCase.selectedFiles(rootUri, archiveBase, listOf(file))
if (outcome.success && outcome.archive != null) {
uiState.value = uiState.value.copy(
shareFileRequest = outcome.archive.toShareFileRequest(),
)
photoEventBus.onPhotoSaved(rootUri, outcome.archive)
loadFolders()
} else {
uiState.value = uiState.value.copy(
error = outcome.errorMessage ?: "Could not create ZIP archive.",
)
}
}
}
fun zipFileCompressedJpegsAndShare(file: Photo) {
val rootUri = uiState.value.rootFolderUri?.toUri() ?: return
viewModelScope.launch {
uiState.value = uiState.value.copy(error = null)
val archiveBase = file.name.substringBeforeLast('.').ifBlank { file.name }
val outcome = zipFolderUseCase.compressedSelectedJpegs(rootUri, archiveBase, listOf(file))
if (outcome.success && outcome.archive != null) {
uiState.value = uiState.value.copy(
shareFileRequest = outcome.archive.toShareFileRequest(),
)
photoEventBus.onPhotoSaved(rootUri, outcome.archive)
loadFolders()
} else {
uiState.value = uiState.value.copy(
error = outcome.errorMessage ?: "Could not create ZIP archive.",
)
}
}
}
fun shareFile(file: Photo) {
uiState.value = uiState.value.copy(shareFileRequest = file.toShareFileRequest())
}
fun showRenameFileDialog(file: Photo) {
uiState.value = uiState.value.copy(renameFile = file)
}
fun dismissRenameFileDialog() {
uiState.value = uiState.value.copy(renameFile = null)
}
fun renameFile(file: Photo, newName: String) {
viewModelScope.launch {
val renamed = renameEntryUseCase(file.uri, newName)
dismissRenameFileDialog()
if (!renamed) {
uiState.value = uiState.value.copy(error = "Failed to rename file")
}
loadFolders()
}
}
fun showDeleteFileDialog(file: Photo) {
uiState.value = uiState.value.copy(deleteFile = file)
}
fun dismissDeleteFileDialog() {
uiState.value = uiState.value.copy(deleteFile = null)
}
fun deleteFile(file: Photo) {
viewModelScope.launch {
val deleted = deleteEntryUseCase(file.uri)
dismissDeleteFileDialog()
if (!deleted) {
uiState.value = uiState.value.copy(error = "Failed to delete file")
}
loadFolders()
}
}
fun showMoveSheetForFolder(folder: FolderNode) {
uiState.value = uiState.value.copy(
moveSheet = MovePhotoSheetState(folder = folder),
)
loadMoveDestinations(
onMissingRoot = {
MovePhotoSheetState(
folder = folder,
isLoading = false,
error = "Select a root folder in Settings first.",
)
},
onLoaded = { destinationFolders ->
val movingPath = folder.path
uiState.value.moveSheet?.copy(
folders = destinationFolders.filterNot { destination ->
destination.uri == folder.uri ||
destination.path == movingPath ||
destination.path.startsWith("$movingPath/")
},
isLoading = false,
error = null,
)
},
)
}
fun showMoveSheetForFile(file: Photo) {
uiState.value = uiState.value.copy(
moveSheet = MovePhotoSheetState(file = file),
)
loadMoveDestinations(
onMissingRoot = {
MovePhotoSheetState(
file = file,
isLoading = false,
error = "Select a root folder in Settings first.",
)
},
onLoaded = { destinationFolders ->
uiState.value.moveSheet?.copy(
folders = destinationFolders,
isLoading = false,
error = null,
)
},
)
}
fun dismissMoveSheet() {
uiState.value = uiState.value.copy(moveSheet = null)
}
private fun loadMoveDestinations(
onMissingRoot: () -> MovePhotoSheetState,
onLoaded: (List<FolderNode>) -> MovePhotoSheetState?,
) {
val rootUri = uiState.value.rootFolderUri?.toUri()
if (rootUri == null) {
uiState.value = uiState.value.copy(moveSheet = onMissingRoot())
return
}
viewModelScope.launch {
runCatching {
listFolderTreeUseCase(rootUri)
}.onSuccess { folders ->
uiState.value = uiState.value.copy(
moveSheet = onLoaded(folders) ?: uiState.value.moveSheet?.copy(
folders = folders,
isLoading = false,
error = null,
),
)
}.onFailure { error ->
uiState.value = uiState.value.copy(
moveSheet = uiState.value.moveSheet?.copy(
isLoading = false,
error = error.message ?: "Failed to load folder tree",
),
)
}
}
}
fun moveFolderTo(folder: FolderNode, targetFolder: FolderNode) {
val rootUri = uiState.value.rootFolderUri?.toUri() ?: return
viewModelScope.launch {
uiState.value = uiState.value.copy(
moveSheet = uiState.value.moveSheet?.copy(isMoving = true, error = null),
)
when (val result = movePhotoUseCase.folder(folder.uri, rootUri, targetFolder.uri, folder.name)) {
is MovePhotoResult.Success -> {
uiState.value = uiState.value.copy(moveSheet = null)
photoEventBus.onPhotoSaved(targetFolder.uri)
photoEventBus.onPhotoSaved(rootUri)
loadFolders()
}
is MovePhotoResult.PartialSuccess -> {
uiState.value = uiState.value.copy(moveSheet = null, error = result.message)
loadFolders()
}
is MovePhotoResult.Failure -> {
uiState.value = uiState.value.copy(
moveSheet = uiState.value.moveSheet?.copy(
isMoving = false,
error = result.message,
),
)
}
}
}
}
fun moveFileTo(file: Photo, targetFolder: FolderNode) {
val rootUri = uiState.value.rootFolderUri?.toUri() ?: return
viewModelScope.launch {
uiState.value = uiState.value.copy(
moveSheet = uiState.value.moveSheet?.copy(isMoving = true, error = null),
)
when (val result = movePhotoUseCase(file.uri, rootUri, targetFolder.uri, file.name)) {
is MovePhotoResult.Success -> {
uiState.value = uiState.value.copy(moveSheet = null)
photoEventBus.onPhotoSaved(targetFolder.uri)
photoEventBus.onPhotoSaved(rootUri)
loadFolders()
}
is MovePhotoResult.PartialSuccess -> {
uiState.value = uiState.value.copy(moveSheet = null, error = result.message)
loadFolders()
}
is MovePhotoResult.Failure -> {
uiState.value = uiState.value.copy(
moveSheet = uiState.value.moveSheet?.copy(
isMoving = false,
error = result.message,
),
)
}
}
}
}
fun clearShareRequest() { fun clearShareRequest() {
uiState.value = uiState.value.copy(shareFileRequest = null) uiState.value = uiState.value.copy(shareFileRequest = null)
} }
@@ -14,6 +14,7 @@ import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxHeight import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@@ -41,11 +42,11 @@ import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@@ -86,13 +87,14 @@ import com.example.onecamera.ui.folders.RenameFolderDialog
import com.github.panpf.zoomimage.CoilZoomAsyncImage import com.github.panpf.zoomimage.CoilZoomAsyncImage
import com.github.panpf.zoomimage.rememberCoilZoomState import com.github.panpf.zoomimage.rememberCoilZoomState
import com.github.panpf.zoomimage.zoom.GestureType import com.github.panpf.zoomimage.zoom.GestureType
import kotlinx.coroutines.flow.distinctUntilChanged
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3ExpressiveApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class, ExperimentalMaterial3ExpressiveApi::class)
@Composable @Composable
fun GalleryScreen( fun GalleryScreen(
folderUri: String, folderUri: String,
onNavigateToCamera: (String) -> Unit, onNavigateToCamera: (String) -> Unit,
onNavigateToSubfolder: (String) -> Unit, onNavigateToSubfolder: (folderUri: String, folderName: String) -> Unit,
onNavigateBack: () -> Unit, onNavigateBack: () -> Unit,
viewModel: GalleryViewModel = hiltViewModel(), viewModel: GalleryViewModel = hiltViewModel(),
) { ) {
@@ -198,10 +200,8 @@ fun GalleryScreen(
uiState.subfolders.sortedByDescending { it.name.lowercase() } uiState.subfolders.sortedByDescending { it.name.lowercase() }
} }
} }
val sortedGalleryPhotos by remember(sortedPhotos) { val sortedGalleryPhotos = remember(sortedPhotos) {
derivedStateOf { sortedPhotos.filter { it.kind == GalleryFileKind.Photo }
sortedPhotos.filter { it.kind == GalleryFileKind.Photo }
}
} }
when (val dialog = uiState.dialog) { when (val dialog = uiState.dialog) {
@@ -282,9 +282,7 @@ fun GalleryScreen(
title = { title = {
Column { Column {
Text( Text(
uiState.folderName.ifEmpty { uiState.folderName,
stringResource(R.string.gallery_title)
},
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
) )
@@ -334,47 +332,50 @@ fun GalleryScreen(
} }
} else { } else {
val folderHasContent = uiState.photos.isNotEmpty() || uiState.subfolders.isNotEmpty()
ButtonGroup(overflowIndicator = {}) { ButtonGroup(overflowIndicator = {}) {
customItem( if (folderHasContent) {
buttonGroupContent = { customItem(
IconButton(onClick = { viewModel.toggleSortOrder() }) { buttonGroupContent = {
Icon( IconButton(onClick = { viewModel.toggleSortOrder() }) {
painter = painterResource( Icon(
if (uiState.sortAscending) { painter = painterResource(
R.drawable.sort_ascending_24 if (uiState.sortAscending) {
R.drawable.sort_ascending_24
} else {
R.drawable.sort_descending_24
},
),
contentDescription = if (uiState.sortAscending) {
stringResource(R.string.gallery_sort_descending)
} else { } else {
R.drawable.sort_descending_24 stringResource(R.string.gallery_sort_ascending)
}, },
), )
contentDescription = if (uiState.sortAscending) { }
stringResource(R.string.gallery_sort_descending) },
} else { menuContent = {},
stringResource(R.string.gallery_sort_ascending) )
}, customItem(
) buttonGroupContent = {
} IconButton(onClick = { viewModel.toggleDisplayMode() }) {
}, Icon(
menuContent = {}, painter = if (uiState.isGridMode) {
) painterResource(R.drawable.ic_symbol_view_list)
customItem( } else {
buttonGroupContent = { painterResource(R.drawable.ic_symbol_grid_view)
IconButton(onClick = { viewModel.toggleDisplayMode() }) { },
Icon( contentDescription = if (uiState.isGridMode) {
painter = if (uiState.isGridMode) { stringResource(R.string.gallery_show_list)
painterResource(R.drawable.ic_symbol_view_list) } else {
} else { stringResource(R.string.gallery_show_grid)
painterResource(R.drawable.ic_symbol_grid_view) },
}, )
contentDescription = if (uiState.isGridMode) { }
stringResource(R.string.gallery_show_list) },
} else { menuContent = {},
stringResource(R.string.gallery_show_grid) )
}, }
)
}
},
menuContent = {},
)
customItem( customItem(
buttonGroupContent = { buttonGroupContent = {
IconButton(onClick = { viewModel.showCreateSubfolderDialog() }) { IconButton(onClick = { viewModel.showCreateSubfolderDialog() }) {
@@ -409,14 +410,6 @@ fun GalleryScreen(
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
when { when {
uiState.selectedPhoto != null -> {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black),
)
}
!uiState.viewPreferencesLoaded -> { !uiState.viewPreferencesLoaded -> {
Box(modifier = Modifier.fillMaxSize()) Box(modifier = Modifier.fillMaxSize())
} }
@@ -443,7 +436,7 @@ fun GalleryScreen(
onFolderClick = { onFolderClick = {
viewModel.prefetchFolderForNavigation(it.uri.toString()) viewModel.prefetchFolderForNavigation(it.uri.toString())
onNavigateToSubfolder(it.uri.toString()) onNavigateToSubfolder(it.uri.toString(), it.name)
}, },
onFolderCompress = { viewModel.zipFolderAndShare(it) }, onFolderCompress = { viewModel.zipFolderAndShare(it) },
onFolderCompressJpeg85 = { viewModel.zipFolderCompressedJpegsAndShare(it) }, onFolderCompressJpeg85 = { viewModel.zipFolderCompressedJpegsAndShare(it) },
@@ -485,12 +478,14 @@ fun GalleryScreen(
enter = fadeIn(tween(250)), enter = fadeIn(tween(250)),
exit = fadeOut(tween(150)), exit = fadeOut(tween(150)),
) { ) {
DraggableCameraFab( if (uiState.viewPreferencesLoaded) {
xFraction = uiState.cameraFabXFraction, DraggableCameraFab(
yFraction = uiState.cameraFabYFraction, xFraction = uiState.cameraFabXFraction,
onPositionChange = viewModel::saveCameraFabPosition, yFraction = uiState.cameraFabYFraction,
onClick = { onNavigateToCamera(uiState.currentFolderUri ?: folderUri) }, onPositionChange = viewModel::saveCameraFabPosition,
) onClick = { onNavigateToCamera(uiState.currentFolderUri ?: folderUri) },
)
}
} }
SelectionActionsBar( SelectionActionsBar(
@@ -537,10 +532,8 @@ internal fun FullscreenPhotoPagerContent(
onDismiss: () -> Unit, onDismiss: () -> Unit,
onDelete: (Photo) -> Unit, onDelete: (Photo) -> Unit,
) { ) {
val initialPage by remember(photos, initialPhoto.uri) { val initialPage = remember(photos, initialPhoto.uri) {
derivedStateOf { photos.indexOfFirst { it.uri == initialPhoto.uri }.coerceAtLeast(0)
photos.indexOfFirst { it.uri == initialPhoto.uri }.coerceAtLeast(0)
}
} }
val pagerState = rememberPagerState(initialPage = initialPage) { photos.size.coerceAtLeast(1) } val pagerState = rememberPagerState(initialPage = initialPage) { photos.size.coerceAtLeast(1) }
@@ -570,19 +563,29 @@ internal fun FullscreenPhotoPagerContent(
) )
} }
} }
IconButton( Row(
onClick = onDismiss,
modifier = Modifier modifier = Modifier
.align(Alignment.TopEnd) .fillMaxWidth()
.align(Alignment.TopCenter)
.statusBarsPadding() .statusBarsPadding()
.padding(16.dp), .padding(start = 16.dp, top = 16.dp, end = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) { ) {
Icon( Text(
painter = painterResource(R.drawable.ic_symbol_close), text = photos.getOrNull(pagerState.currentPage)?.name.orEmpty(),
contentDescription = stringResource(R.string.action_close), color = Color.White,
tint = Color.White, style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.weight(1f),
) )
IconButton(onClick = onDismiss) {
Icon(
painter = painterResource(R.drawable.ic_symbol_close),
contentDescription = stringResource(R.string.action_close),
tint = Color.White,
)
}
} }
IconButton( IconButton(
onClick = { onClick = {
@@ -646,7 +649,6 @@ private fun ZoomablePhoto(
animationSpec = tween(durationMillis = fadeMs), animationSpec = tween(durationMillis = fadeMs),
label = "fullAlpha", label = "fullAlpha",
) )
val scale by remember { derivedStateOf { zoomState.zoomable.transform.scaleX } }
LaunchedEffect(photo.uri) { LaunchedEffect(photo.uri) {
@@ -663,11 +665,13 @@ private fun ZoomablePhoto(
} }
} }
LaunchedEffect(scale) { LaunchedEffect(zoomState) {
val zoomed = scale > 1.01f snapshotFlow { zoomState.zoomable.transform.scaleX > 1.01f }
.distinctUntilChanged()
onZoomChange(zoomed) .collect { zoomed ->
zoomState.subsampling.setDisabled(!zoomed) onZoomChange(zoomed)
zoomState.subsampling.setDisabled(!zoomed)
}
} }
Box(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
@@ -4,6 +4,7 @@ import android.content.Context
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.SavedStateHandle
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import coil3.SingletonImageLoader import coil3.SingletonImageLoader
@@ -45,7 +46,6 @@ sealed interface GalleryDialog {
data class DeleteSubfolder(val folder: FolderNode) : GalleryDialog data class DeleteSubfolder(val folder: FolderNode) : GalleryDialog
} }
@Immutable
data class MovePhotoSheetState( data class MovePhotoSheetState(
val file: Photo? = null, val file: Photo? = null,
val files: List<Photo> = emptyList(), val files: List<Photo> = emptyList(),
@@ -96,26 +96,25 @@ data class GalleryUiState(
val error: String? = null, val error: String? = null,
) { ) {
val isSelectionMode: Boolean = selectedPhotoUris.isNotEmpty() val isSelectionMode: Boolean = selectedPhotoUris.isNotEmpty()
val selectedPhotos: List<Photo> = photos.filter { it.uri.toString() in selectedPhotoUris }
val sortedSubfolders: List<FolderNode> = if (sortAscending) { val selectedPhotos: List<Photo> by lazy {
subfolders.sortedBy { it.name.lowercase() } photos.filter { it.uri.toString() in selectedPhotoUris }
} else {
subfolders.sortedByDescending { it.name.lowercase() }
} }
val sortedPhotos: List<Photo> = if (sortAscending) {
photos.sortedBy { it.name.lowercase() } val selectablePhotoUris: Set<String> by lazy {
} else { photos.asSequence()
photos.sortedByDescending { it.name.lowercase() } .filter { it.kind == GalleryFileKind.Photo }
.mapTo(HashSet()) { it.uri.toString() }
}
val allPhotosSelected: Boolean by lazy {
selectablePhotoUris.isNotEmpty() && selectedPhotoUris.containsAll(selectablePhotoUris)
} }
val selectablePhotoUris: Set<String> = photos
.filter { it.kind == GalleryFileKind.Photo }
.map { it.uri.toString() }
.toSet()
val allPhotosSelected: Boolean = selectablePhotoUris.isNotEmpty() && selectedPhotoUris.containsAll(selectablePhotoUris)
} }
@HiltViewModel @HiltViewModel
class GalleryViewModel @Inject constructor( class GalleryViewModel @Inject constructor(
savedStateHandle: SavedStateHandle,
private val preferencesRepository: UserPreferencesRepository, private val preferencesRepository: UserPreferencesRepository,
private val listGalleryUseCase: ListGalleryUseCase, private val listGalleryUseCase: ListGalleryUseCase,
private val listFolderTreeUseCase: ListFolderTreeUseCase, private val listFolderTreeUseCase: ListFolderTreeUseCase,
@@ -131,7 +130,11 @@ class GalleryViewModel @Inject constructor(
) : ViewModel() { ) : ViewModel() {
val uiState: StateFlow<GalleryUiState> val uiState: StateFlow<GalleryUiState>
field: MutableStateFlow<GalleryUiState> = MutableStateFlow(GalleryUiState()) field: MutableStateFlow<GalleryUiState> = MutableStateFlow(
// Navigation puts type-safe route args into the SavedStateHandle, so the
// title is correct from the very first frame, before any listing completes.
GalleryUiState(folderName = savedStateHandle.get<String>("folderName").orEmpty()),
)
private var forceReload = false private var forceReload = false
@@ -202,6 +205,9 @@ class GalleryViewModel @Inject constructor(
loadJob = viewModelScope.launch { loadJob = viewModelScope.launch {
uiState.value = uiState.value.copy( uiState.value = uiState.value.copy(
currentFolderUri = folderUriString, currentFolderUri = folderUriString,
folderName = state.folderName.ifEmpty {
folderUriString.substringAfterLast(':').substringAfterLast('/')
},
isLoading = true, isLoading = true,
error = null, error = null,
) )
@@ -281,6 +287,10 @@ class GalleryViewModel @Inject constructor(
statsLoaded = true, statsLoaded = true,
) )
uiState.value = uiState.value.copy(subfolders = updated) uiState.value = uiState.value.copy(subfolders = updated)
} else if (!old.statsLoaded) {
val updated = current.toMutableList()
updated[idx] = old.copy(statsLoaded = true)
uiState.value = uiState.value.copy(subfolders = updated)
} }
} }
prefetchThumbnailUris(stats.previews) prefetchThumbnailUris(stats.previews)
@@ -343,18 +353,31 @@ class GalleryViewModel @Inject constructor(
fun clearSelection() { fun clearSelection() {
dragSelectionBaseline = emptySet() dragSelectionBaseline = emptySet()
dragSelectableCache = null
uiState.value = uiState.value.copy(selectedPhotoUris = emptySet()) uiState.value = uiState.value.copy(selectedPhotoUris = emptySet())
} }
private var dragSelectionBaseline: Set<String> = emptySet() private var dragSelectionBaseline: Set<String> = emptySet()
private var dragSelectableCache: Pair<List<Photo>, Set<String>>? = null
private fun currentSelectablePhotoUris(): Set<String> {
val photos = uiState.value.photos
dragSelectableCache?.let { (cachedPhotos, uris) ->
if (cachedPhotos === photos) return uris
}
val uris = uiState.value.selectablePhotoUris
dragSelectableCache = photos to uris
return uris
}
fun beginDragSelection(anchorUri: String) { fun beginDragSelection(anchorUri: String) {
dragSelectionBaseline = uiState.value.selectedPhotoUris + anchorUri dragSelectionBaseline = uiState.value.selectedPhotoUris + anchorUri
uiState.value = uiState.value.copy(selectedPhotoUris = dragSelectionBaseline) uiState.value = uiState.value.copy(selectedPhotoUris = dragSelectionBaseline)
} }
fun updateDragSelection(rangeUris: Set<String>) { fun updateDragSelection(rangeUris: Set<String>) {
val selectable = uiState.value.selectablePhotoUris val selectable = currentSelectablePhotoUris()
uiState.value = uiState.value.copy( uiState.value = uiState.value.copy(
selectedPhotoUris = dragSelectionBaseline + rangeUris.intersect(selectable), selectedPhotoUris = dragSelectionBaseline + rangeUris.intersect(selectable),
) )
@@ -362,6 +385,7 @@ class GalleryViewModel @Inject constructor(
fun endDragSelection() { fun endDragSelection() {
dragSelectionBaseline = emptySet() dragSelectionBaseline = emptySet()
dragSelectableCache = null
} }
fun zipSelectedPhotosAndShare() { fun zipSelectedPhotosAndShare() {
@@ -4,20 +4,7 @@
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path <path
android:strokeColor="#000000" android:fillColor="#C9584D"
android:strokeWidth="3.375" android:pathData="M66.90,53.85C66.90,54.93 66.82,56.08 66.67,57.08C66.53,58.07 66.30,58.95 66.00,59.80C65.71,60.66 65.34,61.46 64.91,62.20C64.48,62.94 63.97,63.63 63.41,64.25C62.86,64.87 62.23,65.42 61.56,65.90C60.89,66.38 60.16,66.79 59.39,67.11C58.61,67.44 57.82,67.69 56.92,67.85C56.02,68.02 54.97,68.10 54.00,68.10C53.03,68.10 51.98,68.02 51.08,67.85C50.18,67.69 49.39,67.44 48.61,67.11C47.84,66.79 47.11,66.38 46.44,65.90C45.77,65.42 45.14,64.87 44.59,64.25C44.03,63.63 43.52,62.94 43.09,62.20C42.66,61.46 42.29,60.66 42.00,59.80C41.70,58.95 41.47,58.07 41.33,57.08C41.18,56.08 41.10,54.93 41.10,53.85C41.10,52.77 41.18,51.62 41.33,50.62C41.47,49.63 41.70,48.75 42.00,47.90C42.29,47.04 42.66,46.24 43.09,45.50C43.52,44.76 44.03,44.07 44.59,43.45C45.14,42.83 45.77,42.28 46.44,41.80C47.11,41.32 47.84,40.91 48.61,40.59C49.39,40.26 50.18,40.01 51.08,39.85C51.98,39.68 53.03,39.60 54.00,39.60C54.97,39.60 56.02,39.68 56.92,39.85C57.82,40.01 58.61,40.26 59.39,40.59C60.16,40.91 60.89,41.32 61.56,41.80C62.23,42.28 62.86,42.83 63.41,43.45C63.97,44.07 64.48,44.76 64.91,45.50C65.34,46.24 65.71,47.04 66.00,47.90C66.30,48.75 66.53,49.63 66.67,50.62C66.82,51.62 66.90,52.77 66.90,53.85ZM61.50,54.00C61.50,53.30 61.46,52.54 61.37,51.90C61.29,51.25 61.16,50.67 60.99,50.12C60.82,49.56 60.61,49.03 60.36,48.55C60.12,48.07 59.83,47.62 59.51,47.21C59.20,46.81 58.84,46.45 58.46,46.14C58.08,45.83 57.66,45.56 57.22,45.35C56.78,45.13 56.33,44.97 55.81,44.86C55.30,44.75 54.70,44.70 54.15,44.70C53.60,44.70 53.00,44.75 52.49,44.86C51.97,44.97 51.52,45.13 51.08,45.35C50.64,45.56 50.22,45.83 49.84,46.14C49.46,46.45 49.10,46.81 48.79,47.21C48.47,47.62 48.18,48.07 47.94,48.55C47.69,49.03 47.48,49.56 47.31,50.12C47.14,50.67 47.01,51.25 46.93,51.90C46.84,52.54 46.80,53.30 46.80,54.00C46.80,54.70 46.84,55.46 46.93,56.10C47.01,56.75 47.14,57.33 47.31,57.88C47.48,58.44 47.69,58.97 47.94,59.45C48.18,59.93 48.47,60.38 48.79,60.79C49.10,61.19 49.46,61.55 49.84,61.86C50.22,62.17 50.64,62.44 51.08,62.65C51.52,62.87 51.97,63.03 52.49,63.14C53.00,63.25 53.60,63.30 54.15,63.30C54.70,63.30 55.30,63.25 55.81,63.14C56.33,63.03 56.78,62.87 57.22,62.65C57.66,62.44 58.08,62.17 58.46,61.86C58.84,61.55 59.20,61.19 59.51,60.79C59.83,60.38 60.12,59.93 60.36,59.45C60.61,58.97 60.82,58.44 60.99,57.88C61.16,57.33 61.29,56.75 61.37,56.10C61.46,55.46 61.50,54.70 61.50,54.00Z" />
android:strokeLineJoin="round"
android:pathData="M 43.453125 32.90625 L 64.546875 32.90625 a 10.546875 10.546875 0 0 1 10.546875 10.546875 L 75.09375 64.546875 a 10.546875 10.546875 0 0 1 -10.546875 10.546875 L 43.453125 75.09375 a 10.546875 10.546875 0 0 1 -10.546875 -10.546875 L 32.90625 43.453125 a 10.546875 10.546875 0 0 1 10.546875 -10.546875 Z" />
<path
android:strokeColor="#000000"
android:strokeWidth="3.375"
android:pathData="M 41.34375 54 a 12.65625 12.65625 0 1 0 25.3125 0 a 12.65625 12.65625 0 1 0 -25.3125 0 Z" />
<path
android:fillColor="#d94e47"
android:pathData="M 68.71875 38.390625 a 2.53125 2.53125 0 1 0 5.0625 0 a 2.53125 2.53125 0 1 0 -5.0625 0 Z" />
</vector> </vector>
@@ -4,20 +4,7 @@
android:height="108dp" android:height="108dp"
android:viewportWidth="108" android:viewportWidth="108"
android:viewportHeight="108"> android:viewportHeight="108">
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="3.375"
android:strokeLineJoin="round"
android:pathData="M 43.453125 32.90625 L 64.546875 32.90625 a 10.546875 10.546875 0 0 1 10.546875 10.546875 L 75.09375 64.546875 a 10.546875 10.546875 0 0 1 -10.546875 10.546875 L 43.453125 75.09375 a 10.546875 10.546875 0 0 1 -10.546875 -10.546875 L 32.90625 43.453125 a 10.546875 10.546875 0 0 1 10.546875 -10.546875 Z" />
<path
android:strokeColor="#FFFFFF"
android:strokeWidth="3.375"
android:pathData="M 41.34375 54 a 12.65625 12.65625 0 1 0 25.3125 0 a 12.65625 12.65625 0 1 0 -25.3125 0 Z" />
<path <path
android:fillColor="#FFFFFF" android:fillColor="#FFFFFF"
android:pathData="M 67.5 38.390625 a 2.109375 2.109375 0 1 0 4.21875 0 a 2.109375 2.109375 0 1 0 -4.21875 0 Z" /> android:pathData="M66.90,53.85C66.90,54.93 66.82,56.08 66.67,57.08C66.53,58.07 66.30,58.95 66.00,59.80C65.71,60.66 65.34,61.46 64.91,62.20C64.48,62.94 63.97,63.63 63.41,64.25C62.86,64.87 62.23,65.42 61.56,65.90C60.89,66.38 60.16,66.79 59.39,67.11C58.61,67.44 57.82,67.69 56.92,67.85C56.02,68.02 54.97,68.10 54.00,68.10C53.03,68.10 51.98,68.02 51.08,67.85C50.18,67.69 49.39,67.44 48.61,67.11C47.84,66.79 47.11,66.38 46.44,65.90C45.77,65.42 45.14,64.87 44.59,64.25C44.03,63.63 43.52,62.94 43.09,62.20C42.66,61.46 42.29,60.66 42.00,59.80C41.70,58.95 41.47,58.07 41.33,57.08C41.18,56.08 41.10,54.93 41.10,53.85C41.10,52.77 41.18,51.62 41.33,50.62C41.47,49.63 41.70,48.75 42.00,47.90C42.29,47.04 42.66,46.24 43.09,45.50C43.52,44.76 44.03,44.07 44.59,43.45C45.14,42.83 45.77,42.28 46.44,41.80C47.11,41.32 47.84,40.91 48.61,40.59C49.39,40.26 50.18,40.01 51.08,39.85C51.98,39.68 53.03,39.60 54.00,39.60C54.97,39.60 56.02,39.68 56.92,39.85C57.82,40.01 58.61,40.26 59.39,40.59C60.16,40.91 60.89,41.32 61.56,41.80C62.23,42.28 62.86,42.83 63.41,43.45C63.97,44.07 64.48,44.76 64.91,45.50C65.34,46.24 65.71,47.04 66.00,47.90C66.30,48.75 66.53,49.63 66.67,50.62C66.82,51.62 66.90,52.77 66.90,53.85ZM61.50,54.00C61.50,53.30 61.46,52.54 61.37,51.90C61.29,51.25 61.16,50.67 60.99,50.12C60.82,49.56 60.61,49.03 60.36,48.55C60.12,48.07 59.83,47.62 59.51,47.21C59.20,46.81 58.84,46.45 58.46,46.14C58.08,45.83 57.66,45.56 57.22,45.35C56.78,45.13 56.33,44.97 55.81,44.86C55.30,44.75 54.70,44.70 54.15,44.70C53.60,44.70 53.00,44.75 52.49,44.86C51.97,44.97 51.52,45.13 51.08,45.35C50.64,45.56 50.22,45.83 49.84,46.14C49.46,46.45 49.10,46.81 48.79,47.21C48.47,47.62 48.18,48.07 47.94,48.55C47.69,49.03 47.48,49.56 47.31,50.12C47.14,50.67 47.01,51.25 46.93,51.90C46.84,52.54 46.80,53.30 46.80,54.00C46.80,54.70 46.84,55.46 46.93,56.10C47.01,56.75 47.14,57.33 47.31,57.88C47.48,58.44 47.69,58.97 47.94,59.45C48.18,59.93 48.47,60.38 48.79,60.79C49.10,61.19 49.46,61.55 49.84,61.86C50.22,62.17 50.64,62.44 51.08,62.65C51.52,62.87 51.97,63.03 52.49,63.14C53.00,63.25 53.60,63.30 54.15,63.30C54.70,63.30 55.30,63.25 55.81,63.14C56.33,63.03 56.78,62.87 57.22,62.65C57.66,62.44 58.08,62.17 58.46,61.86C58.84,61.55 59.20,61.19 59.51,60.79C59.83,60.38 60.12,59.93 60.36,59.45C60.61,58.97 60.82,58.44 60.99,57.88C61.16,57.33 61.29,56.75 61.37,56.10C61.46,55.46 61.50,54.70 61.50,54.00Z" />
</vector> </vector>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 982 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.6 KiB

+2 -3
View File
@@ -33,7 +33,7 @@
<string name="folders_root_body">Select a root folder. OneCamera will organize photos into folders inside it.</string> <string name="folders_root_body">Select a root folder. OneCamera will organize photos into folders inside it.</string>
<string name="folders_choose_root">Choose folder</string> <string name="folders_choose_root">Choose folder</string>
<string name="folders_empty_title">No folders yet</string> <string name="folders_empty_title">No folders yet</string>
<string name="folders_empty_body">Create a folder to start organizing your photos.</string> <string name="folders_empty_body">Create a folder to start organizing photos</string>
<string name="folders_empty_action">Create first folder</string> <string name="folders_empty_action">Create first folder</string>
<string name="folders_share_chooser">Share %1$s</string> <string name="folders_share_chooser">Share %1$s</string>
<string name="folders_share_no_app">No app is available to share this archive.</string> <string name="folders_share_no_app">No app is available to share this archive.</string>
@@ -46,7 +46,6 @@
<item quantity="other">%d photos</item> <item quantity="other">%d photos</item>
</plurals> </plurals>
<string name="gallery_title">Gallery</string>
<string name="gallery_create_subfolder">Create subfolder</string> <string name="gallery_create_subfolder">Create subfolder</string>
<string name="gallery_sort_ascending">Sort ascending</string> <string name="gallery_sort_ascending">Sort ascending</string>
<string name="gallery_sort_descending">Sort descending</string> <string name="gallery_sort_descending">Sort descending</string>
@@ -56,7 +55,7 @@
<string name="gallery_delete_photo_title">Delete photo?</string> <string name="gallery_delete_photo_title">Delete photo?</string>
<string name="gallery_delete_photo_message">Delete “%1$s”? This action cannot be undone.</string> <string name="gallery_delete_photo_message">Delete “%1$s”? This action cannot be undone.</string>
<string name="gallery_empty_title">No photos yet</string> <string name="gallery_empty_title">No photos yet</string>
<string name="gallery_empty_body">Use the camera button to add your first photo.</string> <string name="gallery_empty_body">Use the camera button to add the first photo</string>
<string name="gallery_photo">Photo</string> <string name="gallery_photo">Photo</string>
<string name="gallery_zip_archive">ZIP archive</string> <string name="gallery_zip_archive">ZIP archive</string>
<string name="gallery_share_photos_chooser">Share photos</string> <string name="gallery_share_photos_chooser">Share photos</string>
+1 -1
View File
@@ -5,7 +5,7 @@ plugins {
alias(libs.plugins.ksp) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.kotlin.serialization) apply false alias(libs.plugins.kotlin.serialization) apply false
alias(libs.plugins.baselineprofile) apply false alias(libs.plugins.baselineprofile) apply false
id("com.github.ben-manes.versions") version "0.64.0" id("com.github.ben-manes.versions") version "0.66.0"
} }
tasks.register("assembleDebug") { tasks.register("assembleDebug") {
+1 -1
View File
@@ -13,7 +13,7 @@ datastorePreferences = "1.2.1"
documentfile = "1.1.0" documentfile = "1.1.0"
coil = "3.6.3" coil = "3.6.3"
zoomimage = "1.7.0" zoomimage = "1.7.0"
kotlinxSerializationJson = "1.11.0" kotlinxSerializationJson = "1.12.0"
benchmark = "1.5.0" benchmark = "1.5.0"
profileinstaller = "1.4.1" profileinstaller = "1.4.1"
tracing = "2.0.4" tracing = "2.0.4"
+1 -1
View File
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.1-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.8.2-bin.zip
networkTimeout=10000 networkTimeout=10000
retries=0 retries=0
retryBackOffMs=500 retryBackOffMs=500