From 229bc8afd4babbd7024960b77a735640c67afe7d Mon Sep 17 00:00:00 2001 From: Zakir Sheikh Date: Sat, 27 Jul 2024 18:22:10 +0530 Subject: [PATCH] [FEAT] Added destination Trash - Implemented MediaProvider.restore functionality. - Introduced TrashViewModel. - Added trash screen for managing deleted items. [REFACTOR] Extracted common functionality to MainViewModel - Moved FileActions and SelectionTracker from TimelineViewModel to a new superclass called MainViewModel. - This refactor was necessary to centralize shared functionalities among various view models. [FEAT] Added rudimentary implementation of Viewer Destination - Basic implementation to support viewing files in the new destination. --- .idea/other.xml | 263 +++++++++++++ .../com/zs/api/store/MediaProviderImpl.kt | 27 +- api/src/main/java/com/zs/api/store/Trashed.kt | 3 +- app/build.gradle.kts | 4 +- app/src/main/java/com/zs/gallery/Home.kt | 10 + .../main/java/com/zs/gallery/MainActivity.kt | 26 +- app/src/main/java/com/zs/gallery/bin/Trash.kt | 304 +++++++++++++++ .../main/java/com/zs/gallery/bin/TrashItem.kt | 132 +++++++ .../java/com/zs/gallery/bin/TrashViewState.kt | 16 +- .../java/com/zs/gallery/common/FileActions.kt | 58 ++- .../com/zs/gallery/files/FilesActionMenu.kt | 2 +- .../java/com/zs/gallery/files/Timeline.kt | 6 +- .../java/com/zs/gallery/folders/Folders.kt | 6 + .../com/zs/gallery/impl/AlbumViewModel.kt | 45 +-- .../com/zs/gallery/impl/FolderViewModel.kt | 15 +- .../java/com/zs/gallery/impl/Initializer.kt | 1 + .../java/com/zs/gallery/impl/MainViewModel.kt | 364 ++++++++++++++++++ .../com/zs/gallery/impl/TimelineViewModel.kt | 280 ++------------ .../com/zs/gallery/impl/TrashViewModel.kt | 98 +++++ .../com/zs/gallery/impl/ViewerViewModel.kt | 56 ++- .../java/com/zs/gallery/preview/Viewer.kt | 214 +++++++++- .../com/zs/gallery/preview/ViewerViewState.kt | 22 +- app/src/main/res/values/strings.xml | 6 + 23 files changed, 1627 insertions(+), 331 deletions(-) create mode 100644 .idea/other.xml create mode 100644 app/src/main/java/com/zs/gallery/bin/TrashItem.kt create mode 100644 app/src/main/java/com/zs/gallery/impl/MainViewModel.kt create mode 100644 app/src/main/java/com/zs/gallery/impl/TrashViewModel.kt diff --git a/.idea/other.xml b/.idea/other.xml new file mode 100644 index 0000000..0d3a1fb --- /dev/null +++ b/.idea/other.xml @@ -0,0 +1,263 @@ + + + + + + \ No newline at end of file diff --git a/api/src/main/java/com/zs/api/store/MediaProviderImpl.kt b/api/src/main/java/com/zs/api/store/MediaProviderImpl.kt index ff0fbb6..67d480d 100644 --- a/api/src/main/java/com/zs/api/store/MediaProviderImpl.kt +++ b/api/src/main/java/com/zs/api/store/MediaProviderImpl.kt @@ -39,6 +39,7 @@ import com.zs.api.store.MediaProvider.Companion.COLUMN_DATE_ADDED import com.zs.api.store.MediaProvider.Companion.COLUMN_DATE_EXPIRES import com.zs.api.store.MediaProvider.Companion.COLUMN_DATE_MODIFIED import com.zs.api.store.MediaProvider.Companion.COLUMN_DATE_TAKEN +import com.zs.api.store.MediaProvider.Companion.COLUMN_DURATION import com.zs.api.store.MediaProvider.Companion.COLUMN_HEIGHT import com.zs.api.store.MediaProvider.Companion.COLUMN_ID import com.zs.api.store.MediaProvider.Companion.COLUMN_IS_TRASHED @@ -89,7 +90,8 @@ private val Cursor.toTrashedFile: Trashed expires = getLong(2) * 1000, path = getString(3), size = getLong(4), - mimeType = getString(5) + mimeType = getString(5), + duration = getInt(6) ) private val MEDIA_PROJECTION = @@ -117,6 +119,7 @@ private val TRASHED_PROJECTION = COLUMN_PATH, // 3 COLUMN_SIZE, // 4 COLUMN_MIME_TYPE, // 5 + COLUMN_DURATION // 6 ) /** @@ -284,8 +287,24 @@ internal class MediaProviderImpl( } } - override suspend fun fetchTrashedFiles(offset: Int, limit: Int): List { - TODO("Not yet implemented") + override suspend fun fetchTrashedFiles( + offset: Int, limit: Int + ): List { + return resolver.query2( + EXTERNAL_CONTENT_URI, + TRASHED_PROJECTION, + selection = "$COLUMN_IS_TRASHED = 1", + offset = offset, + limit = limit, + order = MediaProvider.COLUMN_DATE_EXPIRES, + ascending = false, + transform = { c -> + List(c.count) { index -> + c.moveToPosition(index) + c.toTrashedFile + } + } + ) } override suspend fun fetchFilesFromDirectory( @@ -450,7 +469,7 @@ internal class MediaProviderImpl( // Count items after deletion (including trashed) val after = count(true) // Return the number of deleted items - if (Activity.RESULT_OK == result.resultCode) return before - after + if (Activity.RESULT_OK == result.resultCode) return after - before // Log for debugging if result is unexpected Log.d(TAG, "delete: before: $before, after: $after") // Deletion failed for an unknown reason diff --git a/api/src/main/java/com/zs/api/store/Trashed.kt b/api/src/main/java/com/zs/api/store/Trashed.kt index 747631d..40d56eb 100644 --- a/api/src/main/java/com/zs/api/store/Trashed.kt +++ b/api/src/main/java/com/zs/api/store/Trashed.kt @@ -22,7 +22,7 @@ package com.zs.api.store * Represents a trashed file. * * @property id The unique identifier of the trashed file. - * @propertyname The name of the trashed file. + * @property name The name of the trashed file. * @property expires The timestamp (in milliseconds) when the trashed file will be permanently deleted. * @property path The absolute path to the trashed file. * @property size The size of the trashed file in bytes. @@ -35,6 +35,7 @@ data class Trashed( val path: String, val size: Long, val mimeType: String, + val duration: Int, ) val Trashed.isImage get() = mimeType.startsWith("image/") diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e6aa1b2..47b68db 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.googol.android.apps.photos" minSdk = 21 targetSdk = 34 - versionCode = 4 - versionName = "0.1.0-dev04" + versionCode = 5 + versionName = "0.1.0-dev05" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables { diff --git a/app/src/main/java/com/zs/gallery/Home.kt b/app/src/main/java/com/zs/gallery/Home.kt index da33b49..b794fc8 100644 --- a/app/src/main/java/com/zs/gallery/Home.kt +++ b/app/src/main/java/com/zs/gallery/Home.kt @@ -68,6 +68,7 @@ import androidx.navigation.NavController import androidx.navigation.NavGraph.Companion.findStartDestination import androidx.navigation.NavGraphBuilder import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import com.google.accompanist.permissions.ExperimentalPermissionsApi @@ -85,6 +86,8 @@ import com.zs.compose_ktx.navigation.NavRailItem import com.zs.compose_ktx.navigation.NavigationItemDefaults import com.zs.compose_ktx.navigation.NavigationSuiteScaffold import com.zs.compose_ktx.toast.ToastHostState +import com.zs.gallery.bin.RouteTrash +import com.zs.gallery.bin.Trash import com.zs.gallery.common.LocalNavController import com.zs.gallery.common.LocalSystemFacade import com.zs.gallery.common.NightMode @@ -106,6 +109,7 @@ import com.zs.gallery.impl.FolderViewModel import com.zs.gallery.impl.FoldersViewModel import com.zs.gallery.impl.SettingsViewModel import com.zs.gallery.impl.TimelineViewModel +import com.zs.gallery.impl.TrashViewModel import com.zs.gallery.impl.ViewerViewModel import com.zs.gallery.preview.RouteViewer import com.zs.gallery.preview.Viewer @@ -391,6 +395,12 @@ private val NavGraphBuilder: NavGraphBuilder.() -> Unit = { val state = koinViewModel() Album(viewState = state) } + + // Trash + composable(RouteTrash){ + val state = koinViewModel() + Trash(viewState = state) + } } // Default Enter/Exit Transitions. diff --git a/app/src/main/java/com/zs/gallery/MainActivity.kt b/app/src/main/java/com/zs/gallery/MainActivity.kt index 45b925e..7bbdd83 100644 --- a/app/src/main/java/com/zs/gallery/MainActivity.kt +++ b/app/src/main/java/com/zs/gallery/MainActivity.kt @@ -19,6 +19,7 @@ package com.zs.gallery import android.os.Bundle +import android.util.Log import android.view.WindowManager import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -45,6 +46,8 @@ import com.zs.gallery.settings.Settings import kotlinx.coroutines.launch import org.koin.android.ext.android.inject +private const val TAG = "MainActivity" + /** * Manages SplashScreen */ @@ -90,27 +93,16 @@ class MainActivity : ComponentActivity(), SystemFacade { override fun observeAsState(key: Key.Key2) = preferences.observeAsState(key = key) - override fun onPause() { - val secureMode = preferences.value(Settings.KEY_SECURE_MODE) - if (secureMode) - window.setFlags( - WindowManager.LayoutParams.FLAG_SECURE, - WindowManager.LayoutParams.FLAG_SECURE - ) - super.onPause() - } - - override fun onResume() { - val isSecure = preferences.value(Settings.KEY_SECURE_MODE) - if (isSecure) - window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) - super.onResume() - } - override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) // The app has started from scratch if savedInstanceState is null. val isColdStart = savedInstanceState == null //why? + if (preferences.value(Settings.KEY_SECURE_MODE)) { + window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + } // Manage SplashScreen configureSplashScreen(isColdStart) enableEdgeToEdge() diff --git a/app/src/main/java/com/zs/gallery/bin/Trash.kt b/app/src/main/java/com/zs/gallery/bin/Trash.kt index 1f566de..4b654e8 100644 --- a/app/src/main/java/com/zs/gallery/bin/Trash.kt +++ b/app/src/main/java/com/zs/gallery/bin/Trash.kt @@ -16,12 +16,316 @@ * limitations under the License. */ +@file:SuppressLint("NewApi") +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.zs.gallery.bin +import android.annotation.SuppressLint +import androidx.activity.compose.BackHandler +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.animateContentSize +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyGridItemScope +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material.FabPosition +import androidx.compose.material.Icon +import androidx.compose.material.ListItem +import androidx.compose.material.Scaffold +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.filled.ArrowBack +import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material.icons.filled.DeleteSweep +import androidx.compose.material.icons.filled.Restore +import androidx.compose.material.icons.outlined.FolderCopy +import androidx.compose.material.icons.outlined.Info import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.primex.core.findActivity +import com.primex.core.textResource +import com.primex.material2.IconButton +import com.primex.material2.Label +import com.primex.material2.ListTile +import com.primex.material2.Text +import com.primex.material2.TextButton +import com.primex.material2.appbar.LargeTopAppBar +import com.primex.material2.appbar.TopAppBarDefaults +import com.primex.material2.appbar.TopAppBarScrollBehavior +import com.zs.api.store.MediaFile +import com.zs.api.store.Trashed +import com.zs.compose_ktx.AppTheme +import com.zs.compose_ktx.LocalWindowSize +import com.zs.compose_ktx.None +import com.zs.compose_ktx.VerticalDivider +import com.zs.compose_ktx.sharedBounds +import com.zs.gallery.R +import com.zs.gallery.common.FabActionMenu +import com.zs.gallery.common.LocalNavController +import com.zs.gallery.common.Placeholder +import com.zs.gallery.common.SelectionTracker +import com.zs.gallery.common.fullLineSpan +import com.zs.gallery.common.preference +import com.zs.gallery.files.DataProvider +import com.zs.gallery.files.FilesActionMenu +import com.zs.gallery.files.FolderViewState +import com.zs.gallery.files.GroupHeader +import com.zs.gallery.files.MediaFile +import com.zs.gallery.files.buildViewerRoute +import com.zs.gallery.preview.RouteViewer +import com.zs.gallery.settings.Settings + + +@Composable +private fun TopAppBar( + viewState: TrashViewState, + modifier: Modifier = Modifier, + behavior: TopAppBarScrollBehavior? = null, + insets: WindowInsets = WindowInsets.None +) { + AnimatedVisibility( + visible = !viewState.isInSelectionMode, + enter = slideInVertically() + fadeIn(), + exit = slideOutVertically() + fadeOut(), + content = { + LargeTopAppBar( + navigationIcon = { + val navController = LocalNavController.current + IconButton( + imageVector = Icons.AutoMirrored.Filled.ArrowBack, + onClick = navController::navigateUp + ) + }, + title = { Label(text = textResource(id = R.string.trash)) }, + scrollBehavior = behavior, + windowInsets = insets, + modifier = modifier, + style = TopAppBarDefaults.largeAppBarStyle( + containerColor = AppTheme.colors.background, + scrolledContainerColor = AppTheme.colors.background(elevation = 1.dp), + scrolledContentColor = AppTheme.colors.onBackground, + contentColor = AppTheme.colors.onBackground + ), + actions = { + val context = LocalContext.current + TextButton( + label = "Restore", + onClick = { viewState.restoreAll(context.findActivity()) }) + TextButton( + label = "Empty", + onClick = { viewState.empty(context.findActivity()) }) + } + ) + } + ) +} + + +@Composable +fun Actions( + viewState: TrashViewState, + modifier: Modifier = Modifier +) { + FabActionMenu(modifier = modifier) { + // Label + Label( + text = "${viewState.selected.size}", + modifier = Modifier.padding( + start = AppTheme.padding.normal, + end = AppTheme.padding.medium + ), + style = AppTheme.typography.titleLarge + ) + + // Divider + VerticalDivider(modifier = Modifier.height(AppTheme.padding.large)) + + val context = LocalContext.current + IconButton( + imageVector = Icons.Default.Restore, + onClick = { viewState.restore(context.findActivity()) }) + IconButton( + imageVector = Icons.Default.DeleteSweep, + onClick = { viewState.delete(context.findActivity()) }) + } +} + +/** + * The min size of the single cell in grid. + */ +private val MIN_TILE_SIZE = 100.dp +private val GridItemsArrangement = Arrangement.spacedBy(2.dp) + +@Composable +fun LazyDataGrid( + viewState: TrashViewState, + modifier: Modifier = Modifier, + paddingValues: PaddingValues = PaddingValues(vertical = AppTheme.padding.normal), + itemContent: @Composable LazyGridItemScope.(value: Trashed) -> Unit +) { + val values = viewState.data + val multiplier by preference(key = Settings.KEY_GRID_ITEM_SIZE_MULTIPLIER) + LazyVerticalGrid( + columns = GridCells.Adaptive(MIN_TILE_SIZE * multiplier), + horizontalArrangement = GridItemsArrangement, + verticalArrangement = GridItemsArrangement, + modifier = modifier, + contentPadding = paddingValues + ) { + // null means loading + val data = values + ?: return@LazyVerticalGrid item(span = fullLineSpan, key = "key_loading_placeholder") { + Placeholder( + title = stringResource(R.string.loading), + iconResId = R.raw.lt_loading_bubbles, + modifier = Modifier + .fillMaxSize() + .animateItem() + ) + } + // empty means empty + if (data.isEmpty()) + return@LazyVerticalGrid item( + span = fullLineSpan, + key = "key_empty_placeholder...", + content = { + Placeholder( + title = stringResource(R.string.oops_empty), + iconResId = R.raw.lt_empty_box, + modifier = Modifier + .fillMaxSize() + .animateItem() + ) + } + ) + + item( + span = fullLineSpan, + key = "key_recycler_info", + content = { + ListTile( + leading = { + Icon( + imageVector = Icons.Outlined.Info, + contentDescription = null, + ) + }, + headline = { + Text(text = "Photos and Videos You delete will be removed after 30 days.") + }, + modifier = Modifier + .padding(horizontal = AppTheme.padding.normal) + .fillMaxWidth(), + shape = AppTheme.shapes.compact, + centerAlign = false + ) + } + ) + + // place the actual items on the screen. + data.forEach { (header, list) -> + item( + span = fullLineSpan, + key = "key_header_$header", + content = { + val state by remember(header) { + viewState.isGroupSelected(header) + } + GroupHeader( + label = header, state = state, { + viewState.select(header) + } + ) + } + ) + items(list, key = { it.id }) { item -> + itemContent(item) + } + } + } +} +@OptIn(ExperimentalFoundationApi::class) @Composable fun Trash(viewState: TrashViewState) { + BackHandler(viewState.selected.isNotEmpty(), viewState::clear) + val clazz = LocalWindowSize.current + val behaviour = TopAppBarDefaults.enterAlwaysScrollBehavior() + val selected = viewState.selected + val navController = LocalNavController.current + Scaffold( + topBar = { + TopAppBar( + viewState = viewState, + behavior = behaviour, + insets = WindowInsets.statusBars, + ) + }, + modifier = Modifier + .nestedScroll(behaviour.nestedScrollConnection), + contentWindowInsets = WindowInsets.None, + content = { + LazyDataGrid( + viewState = viewState, + modifier = Modifier + .padding(it) + .animateContentSize(), + itemContent = { item -> + TrashItem( + value = item, + checked = when { + selected.isEmpty() -> -1 + selected.contains(item.id) -> 1 + else -> 0 + }, + modifier = Modifier.sharedBounds( + key = RouteViewer.buildSharedFrameKey(item.id), + ) then Modifier.combinedClickable( + // onClick of item + onClick = { + if (selected.isNotEmpty()) + viewState.select(item.id) + }, + // onLong Click + onLongClick = { viewState.select(item.id) } + ) + ) + } + ) + }, + floatingActionButton = { + AnimatedVisibility( + visible = viewState.isInSelectionMode, + enter = fadeIn() + slideInVertically(), + exit = fadeOut() + slideOutVertically(), + content = { + Actions(viewState = viewState) + } + ) + }, + floatingActionButtonPosition = FabPosition.Center + ) } \ No newline at end of file diff --git a/app/src/main/java/com/zs/gallery/bin/TrashItem.kt b/app/src/main/java/com/zs/gallery/bin/TrashItem.kt new file mode 100644 index 0000000..9e6654e --- /dev/null +++ b/app/src/main/java/com/zs/gallery/bin/TrashItem.kt @@ -0,0 +1,132 @@ +/* + * Copyright 2024 Zakir Sheikh + * + * Created by Zakir Sheikh on 27-07-2024. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.zs.gallery.bin + +import android.text.format.DateUtils +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ContentAlpha +import androidx.compose.material.Icon +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.CheckCircle +import androidx.compose.material.icons.filled.PlayCircleFilled +import androidx.compose.material.icons.outlined.Circle +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.scale +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.vectorResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import coil.compose.AsyncImage +import coil.request.ImageRequest +import com.primex.material2.Label +import com.zs.api.store.Trashed +import com.zs.api.store.isImage +import com.zs.api.store.mediaUri +import com.zs.compose_ktx.AppTheme +import com.zs.compose_ktx.ContentPadding +import com.zs.compose_ktx.Divider +import com.zs.gallery.R + + +@Composable +fun TrashItem( + value: Trashed, + checked: Int, // -1 for show selected circle; pass 0 for unchecked, 1 for checked. + modifier: Modifier = Modifier +) { + val elevation = if (kotlin.random.Random.nextBoolean()) 0.5.dp else 1.dp + Box(modifier = Modifier.background(AppTheme.colors.background(elevation = elevation)) then modifier) { + val selected = checked == 1 + val progress by animateFloatAsState(targetValue = if (selected) 1f else 0f) + val ctx = LocalContext.current + AsyncImage( + model = remember(key1 = value.id) { + ImageRequest.Builder(ctx).apply { + data(value.mediaUri) + crossfade(true) + }.build() + }, + contentDescription = value.name, + error = com.primex.core.rememberVectorPainter( + image = ImageVector.vectorResource(id = R.drawable.ic_error_image_placeholder), + tintColor = AppTheme.colors.onBackground.copy(alpha = ContentAlpha.Divider) + ), + contentScale = ContentScale.Crop, + modifier = Modifier + .graphicsLayer { + val scale = lerp(start = 1f, 0.8f, progress) + scaleX = scale + scaleY = scale + shape = RoundedCornerShape(lerp(0, 16, progress)) + clip = true + } + .aspectRatio(1.0f), + ) + if (!value.isImage) + Row( + modifier = Modifier + .scale(0.9f) + .align(Alignment.TopEnd), + verticalAlignment = Alignment.CenterVertically, + content = { + Label( + text = DateUtils.formatElapsedTime(value.duration / 1000L), + fontWeight = FontWeight.Bold, + style = AppTheme.typography.caption + ) + Icon( + imageVector = Icons.Filled.PlayCircleFilled, + contentDescription = null, + modifier = Modifier + .padding(ContentPadding.medium) + ) + } + ) + if (checked == -1) return@Box + + Icon( + imageVector = if (selected) Icons.Default.CheckCircle else Icons.Outlined.Circle, + contentDescription = null, + modifier = Modifier + .align(Alignment.TopStart) + .padding(4.dp) + .background( + if (selected) Color.White else Color.Transparent, + CircleShape + ), + tint = if (selected) AppTheme.colors.accent else Color.Gray + ) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/gallery/bin/TrashViewState.kt b/app/src/main/java/com/zs/gallery/bin/TrashViewState.kt index 00c274c..4223ec6 100644 --- a/app/src/main/java/com/zs/gallery/bin/TrashViewState.kt +++ b/app/src/main/java/com/zs/gallery/bin/TrashViewState.kt @@ -18,6 +18,8 @@ package com.zs.gallery.bin +import android.annotation.SuppressLint +import android.app.Activity import com.zs.api.store.Trashed import com.zs.gallery.common.FileActions import com.zs.gallery.common.Route @@ -26,10 +28,18 @@ import com.zs.gallery.common.SelectionTracker object RouteTrash : Route interface TrashViewState : FileActions, SelectionTracker { - val data: List? - + val data: Map>? /** * Removes all items from bin. */ - fun empty() + fun empty(activity: Activity) + + /** + * Restores all items from the bin + */ + @SuppressLint("NewApi") + fun restoreAll(activity: Activity){ + selectAll() + restore(activity) + } } \ No newline at end of file diff --git a/app/src/main/java/com/zs/gallery/common/FileActions.kt b/app/src/main/java/com/zs/gallery/common/FileActions.kt index dfacc1d..fcdba72 100644 --- a/app/src/main/java/com/zs/gallery/common/FileActions.kt +++ b/app/src/main/java/com/zs/gallery/common/FileActions.kt @@ -19,48 +19,78 @@ package com.zs.gallery.common import android.app.Activity +import android.os.Build +import androidx.annotation.RequiresApi import com.zs.api.store.MediaFile /** - * An interface that together works with [SelectionTracker] to do operations on [MediaFile] of MediaProvider. + * An interface for performing actions on [MediaFile] items in conjunction with a [SelectionTracker]. */ interface FileActions { /** - * Deletes/Trashes the currently [selected] files. - * @param activity the current activity, used for context. + * permanently `deletes` the currently `selected` files. + * @param activity The current activity, used for context. */ - fun delete(activity: Activity,) + fun delete(activity: Activity) /** - * Moves the currently [selected] files to the specified folder. - * @param dest the destination folder path. + * Deletes or trashes the currently `selected` files, prioritizing trashing if available. + * + * This method behaves similarly to [delete] but prioritizes moving files to the trash/recycle + * bin if it's enabled and supported on the current Android version (R+). + * + * @param activity The current activity, used for context. + */ + fun remove(activity: Activity) + + /** + * Moves the currently `selected` files to the trash/recycle bin. + * + * ***Note - This method is only available on Android versions R and above.*** + * + * @param activity The current activity, used for context. + */ + @RequiresApi(Build.VERSION_CODES.R) + fun trash(activity: Activity) + + /** + * Moves the currently `selected` files to the specified folder. + * + * @param dest The destination folder path. */ fun move(dest: String) /** - * Copies the given [file]s to the specified folder. - * @param file the media files to copy. - * @param dest the destination folder path. + * Copies the currently `selected` files to the specified folder. + * + * @param dest The destination folder path. */ fun copy(dest: String) /** - * Renames the current select file to the new [name]. - * @param name the new name for the file. - * @throws [IllegalStateException] if selected files are more than 1. + * Renames the currently selected file to the new [name]. + * + * @param name The new name for the file. + * @throws IllegalStateException if more than one file is selected. */ fun rename(name: String) /** * Shares the currently selected files. - * @param activity the current activity, used for context. + * + * @param activity The current activity, used for context. */ fun share(activity: Activity) /** - * Restores the currently selected files from trash. + * Restores the currently selected files from the trash/recycle bin. + * + * ***Note - This method is only available on Android versions R and above.*** + * + * @param activity The current activity, used for context. */ + @RequiresApi(Build.VERSION_CODES.R) fun restore(activity: Activity) } \ No newline at end of file diff --git a/app/src/main/java/com/zs/gallery/files/FilesActionMenu.kt b/app/src/main/java/com/zs/gallery/files/FilesActionMenu.kt index 40ad1db..75e176f 100644 --- a/app/src/main/java/com/zs/gallery/files/FilesActionMenu.kt +++ b/app/src/main/java/com/zs/gallery/files/FilesActionMenu.kt @@ -88,7 +88,7 @@ fun FilesActionMenu( // Delete IconButton( imageVector = Icons.Outlined.DeleteOutline, - onClick = { state.delete(context.findActivity()) } + onClick = { state.remove(context.findActivity()) } ) // Share diff --git a/app/src/main/java/com/zs/gallery/files/Timeline.kt b/app/src/main/java/com/zs/gallery/files/Timeline.kt index 7ac9182..27916bc 100644 --- a/app/src/main/java/com/zs/gallery/files/Timeline.kt +++ b/app/src/main/java/com/zs/gallery/files/Timeline.kt @@ -20,6 +20,7 @@ package com.zs.gallery.files +import android.os.Build import androidx.activity.compose.BackHandler import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.ExperimentalSharedTransitionApi @@ -67,6 +68,7 @@ import com.zs.compose_ktx.LocalWindowSize import com.zs.compose_ktx.None import com.zs.compose_ktx.sharedBounds import com.zs.gallery.R +import com.zs.gallery.bin.RouteTrash import com.zs.gallery.common.LocalNavController import com.zs.gallery.common.SelectionTracker import com.zs.gallery.preview.RouteViewer @@ -126,7 +128,9 @@ private fun TopAppBar( DropDownMenuItem( title = textResource(id = R.string.recycle_bin), icon = rememberVectorPainter(image = Icons.Outlined.Recycling), - onClick = { /*TODO*/ } + onClick = { navController.navigate(RouteTrash()) }, + // Only enable if R and above + enabled = Build.VERSION.SDK_INT >= Build.VERSION_CODES.R ) // Favourite diff --git a/app/src/main/java/com/zs/gallery/folders/Folders.kt b/app/src/main/java/com/zs/gallery/folders/Folders.kt index ee80e5e..87af829 100644 --- a/app/src/main/java/com/zs/gallery/folders/Folders.kt +++ b/app/src/main/java/com/zs/gallery/folders/Folders.kt @@ -16,9 +16,12 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.zs.gallery.folders import androidx.annotation.StringRes +import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.core.animateFloatAsState import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement @@ -67,12 +70,14 @@ import com.primex.material2.neumorphic.NeumorphicTopAppBar import com.zs.compose_ktx.AppTheme import com.zs.compose_ktx.ContentPadding import com.zs.compose_ktx.None +import com.zs.compose_ktx.sharedElement import com.zs.gallery.R import com.zs.gallery.common.LocalNavController import com.zs.gallery.common.Placeholder import com.zs.gallery.common.fullLineSpan import com.zs.gallery.common.preference import com.zs.gallery.files.RouteFolder +import com.zs.gallery.preview.RouteViewer import com.zs.gallery.settings.Settings private const val TAG = "Folders" @@ -232,6 +237,7 @@ private fun FolderGrid( value = it, modifier = Modifier .clickable() { navController.navigate(RouteFolder(it.path)) } + .sharedElement(RouteViewer.buildSharedFrameKey(it.artworkID)) .animateItem() ) } diff --git a/app/src/main/java/com/zs/gallery/impl/AlbumViewModel.kt b/app/src/main/java/com/zs/gallery/impl/AlbumViewModel.kt index 51e21ef..ec316c5 100644 --- a/app/src/main/java/com/zs/gallery/impl/AlbumViewModel.kt +++ b/app/src/main/java/com/zs/gallery/impl/AlbumViewModel.kt @@ -19,50 +19,50 @@ package com.zs.gallery.impl import android.text.format.DateUtils -import android.util.Log import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.compose.ui.text.ParagraphStyle import androidx.compose.ui.text.buildAnnotatedString -import androidx.compose.ui.text.style.LineHeightStyle -import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewModelScope import com.primex.core.withSpanStyle -import com.zs.api.store.MediaFile import com.zs.api.store.MediaProvider import com.zs.gallery.R import com.zs.gallery.files.AlbumViewState -import com.zs.gallery.settings.Settings import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.launchIn -import kotlinx.coroutines.flow.onEach private const val TAG = "AlbumViewModel" -class AlbumViewModel(provider: MediaProvider) : TimelineViewModel(provider), AlbumViewState { - override var title: CharSequence by mutableStateOf(getText(R.string.favourites)) +class AlbumViewModel( + provider: MediaProvider +) : TimelineViewModel(provider), AlbumViewState { + override var title: CharSequence by mutableStateOf(getText(R.string.favourites)) - override suspend fun update() { + override suspend fun refresh() { + // Introduce a slight delay (for potential visual feedback) delay(50) - val ids = favourites.value + + val ids = favorites + // If no favorites, skip the refresh if (ids.isEmpty()) return + // Update the title with the number of favorite files title = buildAnnotatedString { appendLine(getText(R.string.favourites)) withSpanStyle(fontSize = 10.sp) { - append("${favourites.value.size} Files") + append("${favorites.size} Files") } } - data = provider.fetchFiles( + // Fetch favorite files from the provider, ordered by modification date + values = provider.fetchFiles( *ids.toLongArray(), order = MediaProvider.COLUMN_DATE_MODIFIED, ascending = false - ).groupBy { + ) + + // Group the files by their relative time span (e.g., "Today", "Yesterday") + data = values.groupBy { DateUtils.getRelativeTimeSpanString( it.dateModified, System.currentTimeMillis(), @@ -71,14 +71,11 @@ class AlbumViewModel(provider: MediaProvider) : TimelineViewModel(provider), Alb } } + + // Since all items in this album are favorites, the selected items are also favorites. + // Calling toggleLike will therefore remove them from the favorites list. override fun remove() { - val selected = consume() - // Get the current list of favorite items - val oldList = favourites.value - // Filter the old list to exclude selected items, creating a new list - val newList = oldList.filterNot { it in selected } - // Update the preferences with the new list of favorite items - preferences[Settings.KEY_FAVOURITE_FILES] = newList// Invalidate the current state to trigger recomposition + toggleLike() invalidate() } } \ No newline at end of file diff --git a/app/src/main/java/com/zs/gallery/impl/FolderViewModel.kt b/app/src/main/java/com/zs/gallery/impl/FolderViewModel.kt index 24cdcf3..9099b61 100644 --- a/app/src/main/java/com/zs/gallery/impl/FolderViewModel.kt +++ b/app/src/main/java/com/zs/gallery/impl/FolderViewModel.kt @@ -43,8 +43,9 @@ class FolderViewModel( ) : TimelineViewModel(provider), FolderViewState { val path = RouteFolder[handle] + override var title: CharSequence by mutableStateOf(PathUtils.name(path)) - override suspend fun update() { + override suspend fun refresh() { // Workaround: Parameter is unexpectedly null on the first call. // Root cause unknown delay(50) @@ -52,17 +53,17 @@ class FolderViewModel( Log.d(TAG, "fetch: Path - $path") // Fetch the files from the directory, ordered by last modified date in descending order - val list = provider.fetchFilesFromDirectory( + values = provider.fetchFilesFromDirectory( order = MediaProvider.COLUMN_DATE_MODIFIED, path = path, ascending = false ) // Calculate the total size of all files and format it to a human readable string - val size = formatFileSize(list.sumOf { it.size }) + val size = formatFileSize(values.sumOf { it.size }) // Get the number of files in the directory - val count = list.size + val count = values.size // Extract the name of the current directory from the path val name = PathUtils.name(path) @@ -76,7 +77,7 @@ class FolderViewModel( } // Group the files by the relative time span string (e.g. "1 day ago", "2 hours ago") - data = list.groupBy { + data = values.groupBy { DateUtils.getRelativeTimeSpanString( it.dateModified, System.currentTimeMillis(), @@ -84,8 +85,4 @@ class FolderViewModel( ).toString() } } - - override var title: CharSequence by mutableStateOf( - PathUtils.name(path) - ) } \ No newline at end of file diff --git a/app/src/main/java/com/zs/gallery/impl/Initializer.kt b/app/src/main/java/com/zs/gallery/impl/Initializer.kt index ff9143e..d847198 100644 --- a/app/src/main/java/com/zs/gallery/impl/Initializer.kt +++ b/app/src/main/java/com/zs/gallery/impl/Initializer.kt @@ -51,6 +51,7 @@ private val appModules = module { viewModel { (handle: SavedStateHandle) -> ViewerViewModel(handle, get()) } viewModel { (handle: SavedStateHandle) -> FolderViewModel(handle, get()) } viewModel { AlbumViewModel(get()) } + viewModel { TrashViewModel(get()) } } class KoinInitializer : Initializer { diff --git a/app/src/main/java/com/zs/gallery/impl/MainViewModel.kt b/app/src/main/java/com/zs/gallery/impl/MainViewModel.kt new file mode 100644 index 0000000..755f046 --- /dev/null +++ b/app/src/main/java/com/zs/gallery/impl/MainViewModel.kt @@ -0,0 +1,364 @@ +/* + * Copyright 2024 Zakir Sheikh + * + * Created by Zakir Sheikh on 27-07-2024. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:SuppressLint("NewApi") + +package com.zs.gallery.impl + +import android.annotation.SuppressLint +import android.app.Activity +import android.content.ContentUris +import android.content.Intent +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.NearbyError +import androidx.compose.runtime.State +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.ui.graphics.Color +import androidx.lifecycle.viewModelScope +import com.primex.core.Rose +import com.primex.preferences.value +import com.zs.api.store.MediaProvider +import com.zs.compose_ktx.toast.Toast +import com.zs.gallery.R +import com.zs.gallery.common.FileActions +import com.zs.gallery.common.GroupSelectionLevel +import com.zs.gallery.common.SelectionTracker +import com.zs.gallery.settings.Settings +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.onEach +import kotlinx.coroutines.launch +import kotlin.also as then + +private const val TAG = "MainViewModel" + +/** + * A top-level ViewModel that provides common functionality across different ViewModels, + * such as file operations and selection tracking. + ** This ViewModel continuously observes the [MediaProvider.EXTERNAL_CONTENT_URI] and triggers + * a [refresh] whenever changes occur. Child ViewModels should update their state, including + * any relevant values, during this refresh cycle. + * + * You can manually trigger a data invalidation using the [invalidate] method. To retrieve + * the currently selected items, call the [consume] method, which returns an array of + * selected IDs. + * + * This ViewModel also observes the [favorites] list and updates it based on changes to the + * [Settings.KEY_FAVOURITE_FILES] preference flow. + * + * @param provider The MediaProvider to interact with. + * @property values The list of values managed by this ViewModel. + * @property id The unique ID associated with the values. + * @property favorites An observable list of favorite items, updated from the [Settings.KEY_FAVOURITE_FILES] preference flow. + */ +abstract class MainViewModel( + val provider: MediaProvider +) : KoinViewModel(), FileActions, SelectionTracker { + + // Data related to the ViewModel + abstract val values: List + abstract val T.id: Long + + + /** + * Triggers a refresh of the data store, causing it to re-emit its current value. + */ + fun invalidate() { + viewModelScope.launch { refresh() } + } + + + // Favourite Tracker. + val favorites = + mutableStateListOf().then { favourites -> + // Observe changes to the favorite files preference + preferences[Settings.KEY_FAVOURITE_FILES] + .onEach { value -> + // Calculate the items to add and remove to efficiently update the favorites list + val toAdd = value.filterNot { it in favourites } + val toRemove = favourites.filterNot { it in value } + + // Update the favorites list with the calculated changes + favourites.addAll(toAdd) + favourites.removeAll(toRemove) + } + .launchIn(viewModelScope) + } + + /** + * Observes the list of selected items and returns an integer indicating the favorite status of all items. + * + * @return the state of favourites in selection + * - `0` if none of the items are favorite. + * - `-1` if some of the items are favorite. + * - `1` if all of the items are favorite. + */ + val allFavourite: Int by derivedStateOf { + val list = selected // Get the list of selected items. + val favourites = favorites // Get the list of favorite items. + + // Handle empty lists: If either list is empty, return 0 (none favorite). + if (list.isEmpty() || favourites.isEmpty()) return@derivedStateOf 0 + + // Check if all selected items are favorites. + val all = list.all { favourites.contains(it) } + + // Return the appropriate state: + // 1 if all selected items are favorites, -1 if some are, 0 otherwise. + if (all) return@derivedStateOf 1 else -1 + } + + // SelectionTracker Properties + final override val selected = mutableStateListOf() + override val isInSelectionMode: Boolean by derivedStateOf(selected::isNotEmpty) + override fun clear() = selected.clear() + + + override val allSelected: Boolean by derivedStateOf { + // FixMe: For now this seems reasonable. + // However take the example of the case when size is same but ids are different + selected.size == values.size + } + + /** + * Consumes the currently selected items and returns them as an array. + * + * This function creates a new array containing the selected items, clears the `selected` list, and returns the array. + * + * @return An array containing the previously selected items. + */ + fun consume(): LongArray { + // Efficiently convert the list to an array. + val data = selected.toLongArray() + // Clear the selected items list. + selected.clear() + return data + } + + override fun selectAll() { + val data = values + // Iterate through all items in the data and select them if they are not already selected. + values.forEach { item -> + val id = item.id + if (!selected.contains(id)) { + selected.add(id) + } + } + } + + override fun select(id: Long) { + val contains = selected.contains(id) + if (contains) selected.remove(id) else selected.add(id) + } + + /** + * Toggles the favorite status of the selected items. + * + * This function checks the current favorite status of the selected items and performs the following actions: + * - If `all` selected items are already favorite, it `removes` them from favorites. + * - If `some` selected items are favorite, it `adds the remaining` unfavored items to favorites. + * - If `none` of the selected items are favorite, it `adds all` of them favorites. + */ + fun toggleLike() { + viewModelScope.launch { + // Get the selected items and clear the selection. + val selected = consume().toList() + // Get a mutable list of favorite items. + val favourites = favorites.toMutableList() + + // Determine the action and message based on whether all selected items are already favorites. + val result = when { + favourites.containsAll(selected) -> { + favourites.removeAll(selected) // Remove all selected items from favorites. + "Removed ${selected.size} files from favorites" + } + + selected.any { it in favourites } -> { + // Add the un-favorite selected items to favorites. + val filtered = selected.filterNot { it in favourites } + favourites.addAll(filtered) + "Added ${filtered.size} out of ${selected.size} files to favorites" + } + + else -> { + // Add the un-favorite selected items to favorites. + favourites.addAll(selected) + "Added ${selected.size} files to favorites" + } + } + + // Update the favorite items in preferences. + preferences[Settings.KEY_FAVOURITE_FILES] = favourites + // Display a message to the user. + showToast(result) + } + } + + abstract fun evaluateGroupSelectionLevel(key: String): GroupSelectionLevel + override fun isGroupSelected(key: String): State = + derivedStateOf { evaluateGroupSelectionLevel(key) } + + + @SuppressLint("NewApi") + override fun trash(activity: Activity) { + viewModelScope.launch { + val selected = consume() + // Ensure this is called on Android 10 or higher (API level 29). + val result = com.primex.core.runCatching(TAG) { + provider.trash(activity, *selected) + } + val msg = when (result) { + null, 0, -1 -> getText(R.string.msg_files_trash_unknown_error) // General error + -2 -> getText(R.string.msg_confirm_trashing) // Pending user confirmation (likely for trashing) + -3 -> getText(R.string.msg_files_trashing_cancelled) // User canceled the operation + else -> getText( + R.string.msg_files_trashing_success_out_total, + result, + selected.size + ) // Success with count + } + showToast(msg) + } + } + + override fun remove(activity: Activity) { + val isTrashEnabled = preferences.value(Settings.KEY_TRASH_CAN_ENABLED) + if (isTrashEnabled && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + trash(activity) + else + delete(activity) + } + + override fun delete(activity: Activity) { + viewModelScope.launch { + val selected = consume() + val result = com.primex.core.runCatching(TAG) { + // Less than R trash not matters. + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) + provider.delete(activity, *selected) + else + // Delete directly if trash is disabled. + // TODO - Test this version of delete on android version < 11 + // Add Confirmation snack for this. + provider.delete(*selected) + } + // Display a message based on the result of the deletion operation. + val msg = when (result) { + null, 0, -1 -> getText(R.string.msg_files_delete_unknown_error) // General error + -2 -> getText(R.string.msg_confirm_deletion) // Pending user confirmation (likely for trashing) + -3 -> getText(R.string.msg_files_deletion_cancelled) // User canceled the operation + else -> getText( + R.string.msg_files_deletion_success_out_total, + result, + selected.size + ) // Success with count + } + showToast(msg) + + } + } + + override fun share(activity: Activity) { + viewModelScope.launch { + // Get the list of selected items to share. + val selected = consume() + // Create an intent to share the selected items + val intent = Intent().apply { + // Map selected IDs to content URIs. + // TODO - Construct custom content uri. + val uri = selected.map { + ContentUris.withAppendedId(MediaProvider.EXTERNAL_CONTENT_URI, it) + } + // Set the action to send multiple items. + action = Intent.ACTION_SEND_MULTIPLE + // Add the URIs as extras. + putParcelableArrayListExtra( + Intent.EXTRA_STREAM, + uri.toMutableList() as ArrayList + ) + // Grant read permission to the receiving app. + addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) + // Set the MIME type to allow sharing of various file types. + type = "*/*" + // Specify supported MIME types. + putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) + } + // Start the sharing activity with a chooser. + try { + activity.startActivity(Intent.createChooser(intent, "Share Photos & Video")) + } catch (e: Exception) { + // Handle exceptions and display an error message. + showToast(R.string.msg_error_sharing_files) + Log.d(TAG, "share: ${e.message}") + } + } + } + + abstract suspend fun refresh() + override fun move(dest: String): Unit = TODO("Not yet implemented") + override fun copy(dest: String): Unit = TODO("Not yet implemented") + override fun rename(name: String): Unit = TODO("Not yet implemented") + + override fun restore(activity: Activity) { + viewModelScope.launch { + val selected = consume() + val result = com.primex.core.runCatching(TAG) { + provider.restore(activity, *selected) + } + // Display a message based on the result of the deletion operation. + val msg = when (result) { + null, 0, -1 -> getText(R.string.msg_files_restore_unknown_error) // General error + -2 -> getText(R.string.msg_confirm_restoring) // Pending user confirmation (likely for trashing) + -3 -> getText(R.string.msg_files_restoring_cancelled) // User canceled the operation + else -> getText( + R.string.msg_files_restoring_success_out_total, + result, + selected.size + ) // Success with count + } + Log.d(TAG, "restore: $result") + showToast(msg) + } + } + + init { + Log.d(TAG, "${this::class.simpleName}: created.") + provider.observer(MediaProvider.EXTERNAL_CONTENT_URI) + .onEach { refresh() } + .catch { exception -> + Log.e(TAG, "provider: ${exception.message}") + // Handle any exceptions that occur during the flow. + // This might involve logging the exception using Firebase Crashlytics. + // Display a toast message to the user, indicating something went wrong and suggesting they report the issue. + val action = showToast( + exception.message ?: "", + getText(R.string.report), + Icons.Outlined.NearbyError, + Color.Rose, + Toast.DURATION_INDEFINITE + ) + } + .launchIn(viewModelScope) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/gallery/impl/TimelineViewModel.kt b/app/src/main/java/com/zs/gallery/impl/TimelineViewModel.kt index d2887d1..0743f1e 100644 --- a/app/src/main/java/com/zs/gallery/impl/TimelineViewModel.kt +++ b/app/src/main/java/com/zs/gallery/impl/TimelineViewModel.kt @@ -56,48 +56,30 @@ import kotlinx.coroutines.launch private const val TAG = "TimelineViewModel" open class TimelineViewModel( - val provider: MediaProvider -) : KoinViewModel(), TimelineViewState { - // Trigger for refreshing the list - private val _trigger = MutableStateFlow(false) - val favourites = - preferences[Settings.KEY_FAVOURITE_FILES] - .stateIn(viewModelScope, SharingStarted.Eagerly, emptyList()) - - /** - * Forces the data store to refresh. - */ - fun invalidate() { - _trigger.value = (!_trigger.value) - } - + provider: MediaProvider +) : MainViewModel(provider), TimelineViewState { + // This property holds the current list of MediaFile items. + // It should only be updated when the actual underlying data changes. + // Since this is not observed anywhere so using observable state for this is bit too much + override var values: List = emptyList() + + // Point to the existing id property + @Suppress("EXTENSION_SHADOWED_BY_MEMBER") + override val MediaFile.id: Long get() = id override var data: Map>? by mutableStateOf(null) - final override val selected = mutableStateListOf() - override val isInSelectionMode: Boolean by derivedStateOf(selected::isNotEmpty) - override fun clear() = selected.clear() - /** - * Consumes the currently selected items and returns them as an array. - * - * This function creates a new array containing the selected items, clears the `selected` list, and returns the array. - * - * @return An array containing the previously selected items. - */ - fun consume(): LongArray { - // Efficiently convert the list to an array. - val data = selected.toLongArray() - // Clear the selected items list. - selected.clear() - return data - } - - override val allSelected: Boolean by derivedStateOf { - // FixMe: For now this seems reasonable. - // However take the example of the case when size is same but ids are different - selected.size == data?.values?.flatten()?.size + override fun evaluateGroupSelectionLevel(key: String): GroupSelectionLevel { + // Return NONE if data is not available. + val data = data?.get(key) ?: return GroupSelectionLevel.NONE + // Count selected + val count = data.count { it.id in selected } + return when (count) { + data.size -> GroupSelectionLevel.FULL // All items in the group are selected. + in 1..data.size -> GroupSelectionLevel.PARTIAL // Some items in the group are selected. + else -> GroupSelectionLevel.NONE // No items in the group are selected. + } } - override fun select(key: String) { // Return if data is not available. val data = data ?: return @@ -113,219 +95,17 @@ open class TimelineViewModel( } } - private fun evaluateGroupSelectionLevel(key: String): GroupSelectionLevel { - val data = data ?: return GroupSelectionLevel.NONE //Return NONE if data is not available. - - val all = data[key]?.map { it.id } ?: emptyList() // Get IDs of all items in the group. - val count = - all.count { it in selected } // Count how many items from the group are currently selected.// Determine the selection level based on the count. - return when (count) { - all.size -> GroupSelectionLevel.FULL // All items in the group are selected. - in 1..all.size -> GroupSelectionLevel.PARTIAL // Some items in the group are selected. - else -> GroupSelectionLevel.NONE // No items in the group are selected. - } - } - - override fun isGroupSelected(key: String): State = - derivedStateOf { evaluateGroupSelectionLevel(key) } - - override fun selectAll() { - val data = data ?: return // Return if data is not available. - - // Iterate through all items in the data and select them if they are not already selected. - data.values.flatten().forEach { item -> - val id = item.id - if (!selected.contains(id)) { - selected.add(id) - } - } - } - - override val allFavourite: Int by derivedStateOf { - val list = selected // Get the list of selected items. - val favourites = favourites.value // Get the list of favorite items. - - // Handle empty lists: If either list is empty, return 0 (none favorited). - if (list.isEmpty() || favourites.isEmpty()) return@derivedStateOf 0 - - // Check if all selected items are favorites. - val all = list.all { favourites.contains(it) } - - // Return the appropriate state: - // 1 if all selected items are favorites, -1 if some are, 0 otherwise. - if (all) return@derivedStateOf 1 else -1 - } - - override fun move(dest: String): Unit = TODO("Not yet implemented") - override fun copy(dest: String): Unit = TODO("Not yet implemented") - override fun rename(name: String): Unit = TODO("Not yet implemented") - override fun restore(activity: Activity) = TODO("Not yet implemented") - - @SuppressLint("NewApi") - suspend fun trash(ids: LongArray, activity: Activity) { - // Ensure this is called on Android 10 or higher (API level 29). - val result = com.primex.core.runCatching(TAG) { - provider.trash(activity, *ids) - } - val msg = when (result) { - null, 0, -1 -> getText(R.string.msg_files_trash_unknown_error) // General error - -2 -> getText(R.string.msg_confirm_trashing) // Pending user confirmation (likely for trashing) - -3 -> getText(R.string.msg_files_trashing_cancelled) // User canceled the operation - else -> getText( - R.string.msg_files_trashing_success_out_total, - result, - ids.size - ) // Success with count - } - showToast(msg) - } - - override fun delete(activity: Activity) { - viewModelScope.launch { - val selected = consume() - val trash = preferences.value(Settings.KEY_TRASH_CAN_ENABLED) - val result = com.primex.core.runCatching(TAG) { - // Less than R trash not matters. - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) - // TODO - Test this version of delete on android version < 11 - // Add Confirmation snack for this. - return@runCatching provider.delete(*selected) - // Handle deletion based on Android version and trash setting. - if (trash) - return@launch trash(selected, activity) - // Delete directly if trash is disabled. - provider.delete(activity, *selected) - } - // Display a message based on the result of the deletion operation. - val msg = when (result) { - null, 0, -1 -> getText(R.string.msg_files_delete_unknown_error) // General error - -2 -> getText(R.string.msg_confirm_deletion) // Pending user confirmation (likely for trashing) - -3 -> getText(R.string.msg_files_deletion_cancelled) // User canceled the operation - else -> getText( - R.string.msg_files_deletion_success_out_total, - result, - selected.size - ) // Success with count - } - showToast(msg) - } - } - - override fun share(activity: Activity) { - viewModelScope.launch { - // Get the list of selected items to share. - val selected = consume() - // Create an intent to share the selected items - val intent = Intent().apply { - // Map selected IDs to content URIs. - val uri = selected.map { - ContentUris.withAppendedId( - MediaProvider.EXTERNAL_CONTENT_URI, - it - ) - } - // Set the action to send multiple items. - action = Intent.ACTION_SEND_MULTIPLE - // Add the URIs as extras. - putParcelableArrayListExtra( - Intent.EXTRA_STREAM, - uri.toMutableList() as ArrayList - ) - // Grant read permission to the receiving app. - addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) - // Set the MIME type to allow sharing of various file types. - type = "*/*" - // Specify supported MIME types. - putExtra(Intent.EXTRA_MIME_TYPES, arrayOf("image/*", "video/*")) - } - // Start the sharing activity with a chooser. - try { - activity.startActivity(Intent.createChooser(intent, "Share Photos & Video")) - } catch (e: Exception) { - // Handle exceptions and display an error message. - showToast(R.string.msg_error_sharing_files) - Log.d(TAG, "share: ${e.message}") - } - } - } - - override fun select(id: Long) { - val contains = selected.contains(id) - if (contains) selected.remove(id) else selected.add(id) - } - - override fun toggleLike() { - viewModelScope.launch { - // Get the selected items and clear the selection. - val selected = consume().toList() - // Get a mutable list of favorite items. - val favourites = favourites.value.toMutableList() - - // Determine the action and message based on whether all selected items are already favorites. - val result = when { - favourites.containsAll(selected) -> { - favourites.removeAll(selected) // Remove all selected items from favorites. - "Removed ${selected.size} files from favorites" - } - - selected.any { it in favourites } -> { - // Add the un-favorite selected items to favorites. - val filtered = selected.filterNot { it in favourites } - favourites.addAll(filtered) - "Added ${filtered.size} out of ${selected.size} files to favorites" - } - - else -> { - // Add the un-favorite selected items to favorites. - favourites.addAll(selected) - "Added ${selected.size} files to favorites" - } - } - - // Update the favorite items in preferences. - preferences[Settings.KEY_FAVOURITE_FILES] = favourites - // Display a message to the user. - showToast(result) - } - } - - /** - * Updates the state of the ViewModel by fetching files from the provider, grouping them by date, - * and updating the 'data' property with the result. - */ - open suspend fun update() { + override suspend fun refresh() { // Fetch files from the provider, ordered by modification date in descending order. - val value = - provider.fetchFiles(order = MediaProvider.COLUMN_DATE_MODIFIED, ascending = false) - // Group the files by their relative time span (e.g., "Today", "Yesterday"). - .groupBy { - DateUtils.getRelativeTimeSpanString( - it.dateModified, - System.currentTimeMillis(), - DateUtils.DAY_IN_MILLIS - ).toString() - } - // Update the 'data' property with the grouped files. - data = value - } - - init { - Log.d(TAG, "${this::class.simpleName}: created.") - provider.observer(MediaProvider.EXTERNAL_CONTENT_URI) - .combine(_trigger) { _, _ -> update() } - .catch { exception -> - Log.e(TAG, "provider: ${exception.message}") - // Handle any exceptions that occur during the flow. - // This might involve logging the exception using Firebase Crashlytics. - // Display a toast message to the user, indicating something went wrong and suggesting they report the issue. - val action = showToast( - exception.message ?: "", - getText(R.string.report), - Icons.Outlined.NearbyError, - Color.Rose, - Toast.DURATION_INDEFINITE - ) + values = provider.fetchFiles(order = MediaProvider.COLUMN_DATE_MODIFIED, ascending = false) + data = values + // Group the files by their relative time span (e.g., "Today", "Yesterday"). + .groupBy { + DateUtils.getRelativeTimeSpanString( + it.dateModified, + System.currentTimeMillis(), + DateUtils.DAY_IN_MILLIS + ).toString() } - .launchIn(viewModelScope) } } \ No newline at end of file diff --git a/app/src/main/java/com/zs/gallery/impl/TrashViewModel.kt b/app/src/main/java/com/zs/gallery/impl/TrashViewModel.kt new file mode 100644 index 0000000..2710d3f --- /dev/null +++ b/app/src/main/java/com/zs/gallery/impl/TrashViewModel.kt @@ -0,0 +1,98 @@ +/* + * Copyright 2024 Zakir Sheikh + * + * Created by Zakir Sheikh on 27-07-2024. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.zs.gallery.impl + +import android.annotation.SuppressLint +import android.app.Activity +import android.icu.util.TimeUnit +import android.text.format.DateUtils +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import com.zs.api.store.MediaProvider +import com.zs.api.store.Trashed +import com.zs.gallery.bin.TrashViewState +import com.zs.gallery.common.GroupSelectionLevel +import kotlin.time.Duration.Companion.days +import kotlin.time.Duration.Companion.milliseconds + + +class TrashViewModel( + provider: MediaProvider +) : MainViewModel(provider), TrashViewState { + override var values: List = emptyList() + + @Suppress("EXTENSION_SHADOWED_BY_MEMBER") + override val Trashed.id: Long get() = id + override var data: Map>? by mutableStateOf(null) + + override fun evaluateGroupSelectionLevel(key: String): GroupSelectionLevel { + // Return NONE if data is not available. + val data = data?.get(key) ?: return GroupSelectionLevel.NONE + // Count selected + val count = data.count { it.id in selected } + return when (count) { + data.size -> GroupSelectionLevel.FULL // All items in the group are selected. + in 1..data.size -> GroupSelectionLevel.PARTIAL // Some items in the group are selected. + else -> GroupSelectionLevel.NONE // No items in the group are selected. + } + } + + override fun select(key: String) { + // Return if data is not available. + val data = data ?: return + // Get the current selection level of the group. + val level = evaluateGroupSelectionLevel(key) + // Get the IDs of all items in the group. + val all = data[key]?.map { it.id } ?: emptyList() + // Update the selected items based on the group selection level. + when (level) { + GroupSelectionLevel.NONE -> selected.addAll(all) // Select all items in the group. + GroupSelectionLevel.PARTIAL -> selected.addAll(all.filterNot { it in selected }) // Select only unselected items. + GroupSelectionLevel.FULL -> selected.removeAll(all.filter { it in selected }) // Deselect all selected items. + } + } + + @SuppressLint("NewApi") + override suspend fun refresh() { + // Fetch the list of trashed files from the provider + values = provider.fetchTrashedFiles() + + // Group the trashed files by their remaining days until expiration + data = values + // Calculate days left for each file + .groupBy() { + (it.expires - System.currentTimeMillis()).milliseconds.inWholeDays + } + // Transform the keys to user-friendly labels + .mapKeys { (daysLeft, _) -> + when { + daysLeft <= 0 -> "Today" // Handle expired items + daysLeft == 1L -> "1 day left" + else -> "$daysLeft days left" // Format remaining days + } + } + } + + + override fun empty(activity: Activity) { + selectAll() + trash(activity) + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zs/gallery/impl/ViewerViewModel.kt b/app/src/main/java/com/zs/gallery/impl/ViewerViewModel.kt index 6d4f250..f171ad0 100644 --- a/app/src/main/java/com/zs/gallery/impl/ViewerViewModel.kt +++ b/app/src/main/java/com/zs/gallery/impl/ViewerViewModel.kt @@ -18,9 +18,63 @@ package com.zs.gallery.impl +import android.app.Activity +import android.net.Uri +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.lifecycle.SavedStateHandle import com.zs.api.store.MediaProvider +import com.zs.gallery.preview.RouteViewer import com.zs.gallery.preview.ViewerViewState -class ViewerViewModel(handle: SavedStateHandle, provider: MediaProvider): KoinViewModel(), ViewerViewState { +class ViewerViewModel( + handle: SavedStateHandle, + private val provider: MediaProvider +): KoinViewModel(), ViewerViewState { + + val args = RouteViewer[handle] + + override var index by mutableIntStateOf(args.index) + + override fun fetchUriForIndex(): Uri = MediaProvider.contentUri(args.ids[index]) + + override val size: Int get() = args.ids.size + + override fun fetchIdForIndex(): Long { + return args.ids[index] + } + + override fun delete(activity: Activity) { + TODO("Not yet implemented") + } + + override fun remove(activity: Activity) { + TODO("Not yet implemented") + } + + override fun trash(activity: Activity) { + TODO("Not yet implemented") + } + + override fun move(dest: String) { + TODO("Not yet implemented") + } + + override fun copy(dest: String) { + TODO("Not yet implemented") + } + + override fun rename(name: String) { + TODO("Not yet implemented") + } + + override fun share(activity: Activity) { + TODO("Not yet implemented") + } + + override fun restore(activity: Activity) { + TODO("Not yet implemented") + } } \ No newline at end of file diff --git a/app/src/main/java/com/zs/gallery/preview/Viewer.kt b/app/src/main/java/com/zs/gallery/preview/Viewer.kt index 0d8c33a..c9e354b 100644 --- a/app/src/main/java/com/zs/gallery/preview/Viewer.kt +++ b/app/src/main/java/com/zs/gallery/preview/Viewer.kt @@ -1,7 +1,7 @@ /* * Copyright 2024 Zakir Sheikh * - * Created by Zakir Sheikh on 23-07-2024. + * Created by Zakir Sheikh on 18-07-2024. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -16,14 +16,222 @@ * limitations under the License. */ +@file:OptIn(ExperimentalSharedTransitionApi::class) + package com.zs.gallery.preview +import android.net.Uri +import android.util.Log +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.detectTransformGestures +import androidx.compose.foundation.gestures.rememberTransformableState +import androidx.compose.foundation.gestures.transformable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.statusBars +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material.Icon +import androidx.compose.material.TopAppBar +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material.icons.automirrored.outlined.OpenInNew +import androidx.compose.material.icons.filled.Share +import androidx.compose.material.icons.outlined.Edit +import androidx.compose.material.icons.outlined.Info +import androidx.compose.material.icons.outlined.MoreVert +import androidx.compose.material.icons.outlined.StarOutline +import androidx.compose.material.icons.twotone.Delete import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import com.zs.gallery.impl.ViewerViewModel +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.times +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.vector.rememberVectorPainter +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.times +import androidx.compose.ui.unit.toIntSize +import androidx.compose.ui.unit.toSize +import coil.compose.AsyncImage +import com.primex.material2.DropDownMenuItem +import com.primex.material2.IconButton +import com.primex.material2.menu.DropDownMenu2 +import com.zs.api.store.MediaProvider +import com.zs.compose_ktx.sharedElement +import com.zs.gallery.common.LocalNavController +import kotlin.math.cos +private const val TAG = "Viewer" @Composable -fun Viewer(viewState: ViewerViewState) { +@NonRestartableComposable +private fun Page( + uri: Uri, + modifier: Modifier = Modifier +) { + + AsyncImage( + model = uri, + contentDescription = null, + modifier = modifier + ) +} + +context(RowScope) +@Composable +private inline fun Actions( +) { + IconButton(imageVector = Icons.TwoTone.Delete, onClick = {}) + IconButton(imageVector = Icons.Outlined.StarOutline, onClick = { /*TODO*/ }) + IconButton(imageVector = Icons.Outlined.Info, onClick = { /*TODO*/ }) + + // DropDownMenu for more options + var expanded by remember { mutableStateOf(false) } + androidx.compose.material.IconButton(onClick = { expanded = true }) { + Icon(imageVector = Icons.Outlined.MoreVert, contentDescription = null) + DropDownMenu2( + expanded = expanded, + onDismissRequest = { expanded = false }, + modifier = Modifier.widthIn(min = 180.dp) + ) { + DropDownMenuItem( + title = "Edit in", + onClick = { /*TODO*/ }, + icon = rememberVectorPainter( + image = Icons.Outlined.Edit + ), + + ) + DropDownMenuItem( + title = "Use as", onClick = { /*TODO*/ }, icon = rememberVectorPainter( + image = Icons.AutoMirrored.Outlined.OpenInNew + ) + ) + + DropDownMenuItem( + title = "Share", onClick = { /*TODO*/ }, icon = rememberVectorPainter( + image = Icons.Filled.Share + ) + ) + } + } +} + +@Composable +fun Pager( + viewState: ViewerViewState, + modifier: Modifier = Modifier +) { + + val state = rememberPagerState(viewState.index, pageCount = { viewState.size }) + + // set up all transformation states + var zoom by remember { mutableFloatStateOf(1f) } + val nestedScrollConnection = remember { + object : NestedScrollConnection { + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + Log.d(TAG, "onPreScroll: Available - $available") + return if (zoom > 1f) Offset.Zero else available + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + Log.d(TAG, "onPostScroll: ") + return super.onPostScroll(consumed, available, source) + } + } + } + + var offset by remember { mutableStateOf(Offset.Zero) } + var sizeI by remember { mutableStateOf(IntSize.Zero) } + var sizeP by remember { mutableStateOf(IntSize.Zero) } + val transformState = rememberTransformableState { gestureZoom, pan, gestureRotate -> + zoom = (zoom * gestureZoom).coerceIn(1f, 3f) + // Calculate the new offset with bounds + // Calculate new offset with bounds + val scaledW = sizeI.width * zoom + val scaledH = sizeI.height * zoom + val maxX = ((scaledW - sizeP.width) / 2f).coerceAtLeast(0f) + val maxY = ((scaledH - sizeP.height) / 2f).coerceAtLeast(0f) + val newOffset = offset + pan * zoom + offset = Offset( + x = (newOffset.x).coerceIn(-maxX, maxX), + y = (newOffset.y).coerceIn(-maxY, maxY) + ) + Log.d(TAG, "size - $sizeI, sizeP - $sizeP, maxX - $maxX, maxY - $maxY") + } + + HorizontalPager( + state = state, + key = { it }, + modifier = modifier + // .nestedScroll(connection = nestedScrollConnection) + .background(Color.Black) + .onSizeChanged { sizeP = it }, + userScrollEnabled = !transformState.isTransformInProgress + // pageNestedScrollConnection = nestedScrollConnection, + ) { index -> + viewState.index = index + val uri = viewState.fetchUriForIndex() + val id = viewState.fetchIdForIndex() + Box(modifier = Modifier) { + AsyncImage( + model = uri, + onSuccess = {sizeI = it.painter.intrinsicSize.toIntSize()}, + contentDescription = null, + modifier = Modifier + .nestedScroll(nestedScrollConnection) + .sharedElement(RouteViewer.buildSharedFrameKey(id)) + ) + } + } +} + +@Composable +fun Viewer(viewState: ViewerViewState) { + Box(modifier = Modifier + .fillMaxSize() + .background(Color.Black)) { + Pager(viewState, modifier = Modifier.fillMaxSize()) + TopAppBar( + title = {}, + navigationIcon = { + val navController = LocalNavController.current + IconButton( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + onClick = navController::navigateUp + ) + }, + actions = { Actions()}, + contentColor = Color.White, + backgroundColor = Color.Transparent, + elevation = 0.dp, + modifier = Modifier.align(Alignment.TopCenter), + windowInsets = WindowInsets.statusBars + ) + } } \ No newline at end of file diff --git a/app/src/main/java/com/zs/gallery/preview/ViewerViewState.kt b/app/src/main/java/com/zs/gallery/preview/ViewerViewState.kt index 664dd03..9d60209 100644 --- a/app/src/main/java/com/zs/gallery/preview/ViewerViewState.kt +++ b/app/src/main/java/com/zs/gallery/preview/ViewerViewState.kt @@ -18,7 +18,9 @@ package com.zs.gallery.preview +import android.net.Uri import androidx.lifecycle.SavedStateHandle +import com.zs.gallery.common.FileActions import com.zs.gallery.common.SafeArgs private const val PARAM_INDEX = "param_index" @@ -52,4 +54,22 @@ object RouteViewer : SafeArgs { } } -interface ViewerViewState \ No newline at end of file +interface ViewerViewState: FileActions { + + /** + * The current index user is viewing + */ + var index: Int + + /** + * @return the number of items in the list + */ + val size: Int + + /** + * @return the uri for the current index. + */ + fun fetchUriForIndex(): Uri + + fun fetchIdForIndex(): Long +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2d4de08..95ada74 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -80,5 +80,11 @@ Confirm file trashing. File trashing process was canceled by the user. Successfully trashed %1$d out of %2$d files. + Trash + Oops, something went wrong while restoring the files. Please try again. + Confirm file restoring. + File restoring process was canceled by the user. + Successfully restored %1$d out of %2$d files. + \ No newline at end of file