diff --git a/Android/qsferaApp/build.gradle b/Android/qsferaApp/build.gradle index 9e86f239b..348f092c5 100644 --- a/Android/qsferaApp/build.gradle +++ b/Android/qsferaApp/build.gradle @@ -137,8 +137,8 @@ android { testInstrumentationRunner "eu.qsfera.android.utils.OCTestAndroidJUnitRunner" - versionCode = 35 - versionName = "1.3.7" + versionCode = 37 + versionName = "1.3.9" buildConfigField "String", gitRemote, "\"" + getGitOriginRemote() + "\"" buildConfigField "String", commitSHA1, "\"" + getLatestGitHash() + "\"" diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHomeActivity.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHomeActivity.kt index 837c5744c..c91c81f62 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHomeActivity.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHomeActivity.kt @@ -12,8 +12,10 @@ import android.content.Intent import android.net.Uri import android.os.Bundle import android.view.View +import android.view.ViewGroup import android.widget.EditText import android.widget.ImageView +import android.widget.RadioButton import android.widget.TextView import android.widget.Toast import androidx.activity.result.contract.ActivityResultContracts @@ -25,6 +27,7 @@ import androidx.core.view.updatePadding import androidx.lifecycle.lifecycleScope import com.google.android.material.bottomnavigation.BottomNavigationView import com.google.android.material.bottomsheet.BottomSheetDialog +import com.google.android.material.switchmaterial.SwitchMaterial import eu.qsfera.android.R import eu.qsfera.android.data.ClientManager import eu.qsfera.android.domain.files.FileRepository @@ -60,6 +63,7 @@ class CloudHomeActivity : FileActivity() { private lateinit var toolbarBack: View private lateinit var toolbarTitle: TextView private lateinit var toolbarSearchButton: View + private lateinit var toolbarMoreButton: View private lateinit var toolbarSearch: SearchView private lateinit var selectionToolbar: View private lateinit var selectionCount: TextView @@ -129,6 +133,7 @@ class CloudHomeActivity : FileActivity() { toolbarBack = findViewById(R.id.cloud_toolbar_back) toolbarTitle = findViewById(R.id.cloud_toolbar_title) toolbarSearchButton = findViewById(R.id.cloud_toolbar_search_button) + toolbarMoreButton = findViewById(R.id.cloud_toolbar_more_button) toolbarSearch = findViewById(R.id.cloud_toolbar_search) selectionToolbar = findViewById(R.id.cloud_selection_toolbar) selectionCount = findViewById(R.id.cloud_selection_count) @@ -146,9 +151,9 @@ class CloudHomeActivity : FileActivity() { selectionMore.setOnClickListener { showMediaActionsSheet() } } - fun showMediaSelection(count: Int) { - selectionToolbar.visibility = if (count > 0) View.VISIBLE else View.GONE - if (count > 0) { + fun showMediaSelection(count: Int, active: Boolean = count > 0) { + selectionToolbar.visibility = if (active) View.VISIBLE else View.GONE + if (active) { selectionCount.text = resources.getQuantityString(R.plurals.items_selected_count, count, count) } else { setSelectionBusy(false) @@ -278,9 +283,11 @@ class CloudHomeActivity : FileActivity() { private fun setupToolbar() { toolbarAvatar.setOnClickListener { showProfileSheet() } toolbarBack.setOnClickListener { cloudFragment()?.navigateUp() } + toolbarMoreButton.setOnClickListener { showPhotoViewSheet() } toolbarSearchButton.setOnClickListener { toolbarTitle.visibility = View.GONE toolbarSearchButton.visibility = View.GONE + toolbarMoreButton.visibility = View.GONE toolbarSearch.visibility = View.VISIBLE toolbarSearch.requestFocus() } @@ -302,6 +309,49 @@ class CloudHomeActivity : FileActivity() { } } + private fun showPhotoViewSheet() { + val fragment = cloudFragment() ?: return + if (currentSection != CloudSection.PHOTOS) return + val dialog = BottomSheetDialog(this) + val content = layoutInflater.inflate( + R.layout.sheet_cloud_photo_view, + findViewById(android.R.id.content), + false, + ) + dialog.setContentView(content) + + content.findViewById(R.id.cloud_photo_select_files).setOnClickListener { + dialog.dismiss() + fragment.startMediaSelection() + } + + val screenshotsSwitch = content.findViewById(R.id.cloud_photo_show_screenshots).apply { + isChecked = fragment.areScreenshotsShown() + setOnCheckedChangeListener { _, checked -> fragment.setScreenshotsShown(checked) } + } + content.findViewById(R.id.cloud_photo_show_screenshots_row).setOnClickListener { + screenshotsSwitch.isChecked = !screenshotsSwitch.isChecked + } + + val modeRows = linkedMapOf( + CloudPhotoGridMode.SMART to Pair(R.id.cloud_photo_view_smart, R.id.cloud_photo_view_smart_radio), + CloudPhotoGridMode.LARGE to Pair(R.id.cloud_photo_view_large, R.id.cloud_photo_view_large_radio), + CloudPhotoGridMode.STANDARD to Pair(R.id.cloud_photo_view_standard, R.id.cloud_photo_view_standard_radio), + CloudPhotoGridMode.MONTHS to Pair(R.id.cloud_photo_view_months, R.id.cloud_photo_view_months_radio), + ) + fun selectMode(mode: CloudPhotoGridMode) { + modeRows.forEach { (candidate, ids) -> + content.findViewById(ids.second).isChecked = candidate == mode + } + fragment.setPhotoGridMode(mode) + } + selectMode(fragment.photoGridMode()) + modeRows.forEach { (mode, ids) -> + content.findViewById(ids.first).setOnClickListener { selectMode(mode) } + } + dialog.show() + } + @Suppress("DEPRECATION") private fun setupBottomNavigation() { bottomNavigation.setOnNavigationItemSelectedListener { item -> @@ -336,12 +386,14 @@ class CloudHomeActivity : FileActivity() { toolbarBack.visibility = View.GONE toolbarAvatar.visibility = View.VISIBLE toolbarTitle.text = getString(section.titleResource) + toolbarMoreButton.visibility = if (section == CloudSection.PHOTOS) View.VISIBLE else View.GONE } fun showNestedTitle(title: String) { toolbarAvatar.visibility = View.GONE toolbarBack.visibility = View.VISIBLE toolbarTitle.text = title + toolbarMoreButton.visibility = View.GONE } fun restoreSectionTitle() { @@ -355,6 +407,7 @@ class CloudHomeActivity : FileActivity() { toolbarSearch.visibility = View.GONE toolbarTitle.visibility = View.VISIBLE toolbarSearchButton.visibility = View.VISIBLE + toolbarMoreButton.visibility = if (currentSection == CloudSection.PHOTOS) View.VISIBLE else View.GONE cloudFragment()?.filter("") } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt index 3908c11dc..5b5f95d42 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubAdapter.kt @@ -6,6 +6,7 @@ package eu.qsfera.android.presentation.cloud import android.accounts.Account +import android.content.Context import android.view.LayoutInflater import android.view.HapticFeedbackConstants import android.view.View @@ -18,6 +19,8 @@ import androidx.recyclerview.widget.DiffUtil import androidx.recyclerview.widget.RecyclerView import coil.dispose import coil.load +import coil.request.ImageRequest +import coil.size.Scale import eu.qsfera.android.R import eu.qsfera.android.presentation.thumbnails.ThumbnailsRequester import eu.qsfera.android.utils.MimetypeIconUtil @@ -36,10 +39,21 @@ internal class CloudHubAdapter( ) : RecyclerView.Adapter() { private var rows: List = emptyList() private var selectedMediaKeys: Set = emptySet() + private var gridSpanCount = DEFAULT_GRID_SPANS + private var mediaSpanSize = DEFAULT_MEDIA_SPANS + private var mediaPreviewSize = PREVIEW_MEDIUM + private val prefetchedUrls = mutableSetOf() + + fun updateGridGeometry(spanCount: Int, mediaSpans: Int, previewSize: Int) { + gridSpanCount = spanCount + mediaSpanSize = mediaSpans + mediaPreviewSize = previewSize + } fun updateAccount(newAccount: Account) { if (account == newAccount) return account = newAccount + prefetchedUrls.clear() notifyItemRangeChanged(0, itemCount) } @@ -65,7 +79,12 @@ internal class CloudHubAdapter( val changedKeys = (selectedMediaKeys - newSelection) + (newSelection - selectedMediaKeys) selectedMediaKeys = newSelection rows.forEachIndexed { index, row -> - if (row is CloudHubRow.Media && row.item.selectionKey in changedKeys) { + val changed = when (row) { + is CloudHubRow.Media -> row.item.selectionKey in changedKeys + is CloudHubRow.SmartMediaGroup -> row.items.any { it.selectionKey in changedKeys } + else -> false + } + if (changed) { notifyItemChanged(index) } } @@ -79,6 +98,7 @@ internal class CloudHubAdapter( CloudHubRow.Shortcuts -> { TYPE_SHORTCUTS } is CloudHubRow.PhotoStatus -> { TYPE_PHOTO_STATUS } is CloudHubRow.Media -> { TYPE_MEDIA } + is CloudHubRow.SmartMediaGroup -> { TYPE_SMART_MEDIA_GROUP } is CloudHubRow.Storage -> { TYPE_STORAGE } is CloudHubRow.Album -> { TYPE_ALBUM } is CloudHubRow.Action -> { TYPE_ACTION } @@ -86,15 +106,16 @@ internal class CloudHubAdapter( } fun spanSize(position: Int): Int = when (rows[position]) { - is CloudHubRow.Media, - is CloudHubRow.Storage -> { 2 } - is CloudHubRow.Album -> { 3 } + is CloudHubRow.Media -> mediaSpanSize + is CloudHubRow.Storage -> DEFAULT_MEDIA_SPANS + is CloudHubRow.Album -> DEFAULT_ALBUM_SPANS is CloudHubRow.Header, is CloudHubRow.FeedCard, + is CloudHubRow.SmartMediaGroup, CloudHubRow.Shortcuts, is CloudHubRow.PhotoStatus, is CloudHubRow.Action, - is CloudHubRow.Status -> { 6 } + is CloudHubRow.Status -> gridSpanCount } override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): Holder { @@ -104,6 +125,7 @@ internal class CloudHubAdapter( TYPE_SHORTCUTS -> { R.layout.item_cloud_shortcuts } TYPE_PHOTO_STATUS -> { R.layout.item_cloud_photo_status } TYPE_MEDIA -> { R.layout.item_cloud_media } + TYPE_SMART_MEDIA_GROUP -> { R.layout.item_cloud_photo_smart_group } TYPE_STORAGE -> { R.layout.item_cloud_storage } TYPE_ALBUM -> { R.layout.item_cloud_album } TYPE_ACTION -> { R.layout.item_cloud_action } @@ -120,7 +142,10 @@ internal class CloudHubAdapter( is CloudHubRow.FeedCard -> { bindFeedCard(holder.itemView, row) } CloudHubRow.Shortcuts -> { bindShortcuts(holder.itemView) } is CloudHubRow.PhotoStatus -> { bindPhotoStatus(holder.itemView, row) } - is CloudHubRow.Media -> { bindMedia(holder.itemView, row.item) } + is CloudHubRow.Media -> { + bindMedia(holder.itemView, row.item, previewSize = mediaPreviewSize) + } + is CloudHubRow.SmartMediaGroup -> { bindSmartMediaGroup(holder.itemView, row) } is CloudHubRow.Storage -> { bindStorage(holder.itemView, row.item) } is CloudHubRow.Album -> { bindAlbum(holder.itemView, row) } is CloudHubRow.Action -> { bindAction(holder.itemView, row) } @@ -200,7 +225,11 @@ internal class CloudHubAdapter( view.context.getString(R.string.cloud_photo_status, status.photos, status.videos) } - private fun bindMedia(view: View, item: CloudMediaItem) { + private fun bindMedia( + view: View, + item: CloudMediaItem, + previewSize: Int = PREVIEW_MEDIUM, + ) { val image = view.findViewById(R.id.cloud_media_image) val video = view.findViewById(R.id.cloud_media_video) val selectionScrim = view.findViewById(R.id.cloud_media_selection_scrim) @@ -210,7 +239,7 @@ internal class CloudHubAdapter( selectionScrim.visibility = if (selected) View.VISIBLE else View.GONE selectionCheck.visibility = if (selected) View.VISIBLE else View.GONE view.isActivated = selected - loadMediaImage(image, item, PREVIEW_MEDIUM) + loadMediaImage(image, item, previewSize) view.contentDescription = item.name view.setOnClickListener { onMediaClick(item) } view.setOnLongClickListener { @@ -220,8 +249,78 @@ internal class CloudHubAdapter( } } + private fun bindSmartMediaGroup(view: View, group: CloudHubRow.SmartMediaGroup) { + view.findViewById(R.id.cloud_smart_grid).apply { + mediaCount = group.items.size + mirrored = group.mirrored + } + view.findViewById(R.id.cloud_smart_date).apply { + text = group.date + visibility = if (group.date.isBlank()) View.GONE else View.VISIBLE + } + val slots = listOf( + R.id.cloud_smart_media_first, + R.id.cloud_smart_media_second, + R.id.cloud_smart_media_third, + R.id.cloud_smart_media_fourth, + R.id.cloud_smart_media_fifth, + R.id.cloud_smart_media_sixth, + ) + slots.forEachIndexed { index, slotId -> + view.findViewById(slotId).apply { + val item = group.items.getOrNull(index) + visibility = if (item == null) View.INVISIBLE else View.VISIBLE + if (item != null) { + val previewSize = if (CloudSmartPhotoLayout.isLargeSlot(group.items.size, index)) { + PREVIEW_LARGE + } else { + PREVIEW_MEDIUM + } + bindMedia(this, item, previewSize = previewSize) + } else { + findViewById(R.id.cloud_media_image).dispose() + setOnClickListener(null) + setOnLongClickListener(null) + } + } + } + } + + fun monthLabelAt(position: Int): String? = + (rows.getOrNull(position) as? CloudHubRow.Media)?.label + + fun prefetchRows(context: Context, rowsToPrefetch: List) { + val loader = ThumbnailsRequester.getContentAddressedImageLoader(account) + rowsToPrefetch.forEach { row -> + when (row) { + is CloudHubRow.Media -> prefetchMedia(context, loader, row.item, mediaPreviewSize) + is CloudHubRow.SmartMediaGroup -> row.items.forEachIndexed { index, item -> + val size = if (CloudSmartPhotoLayout.isLargeSlot(row.items.size, index)) { + PREVIEW_LARGE + } else { + PREVIEW_MEDIUM + } + prefetchMedia(context, loader, item, size) + } + else -> Unit + } + } + } + + private fun prefetchMedia(context: Context, loader: coil.ImageLoader, item: CloudMediaItem, size: Int) { + if (!item.isImage && !item.isVideo) return + val url = runCatching { previewUri(item, size) }.getOrNull() ?: return + if (!prefetchedUrls.add(url)) return + loader.enqueue( + ImageRequest.Builder(context.applicationContext) + .data(url) + .scale(Scale.FILL) + .build() + ) + } + private fun loadMediaImage(image: ImageView, item: CloudMediaItem, size: Int) { - if (item.isImage) { + if (item.isImage || item.isVideo) { image.scaleType = ImageView.ScaleType.CENTER_CROP val loader = ThumbnailsRequester.getContentAddressedImageLoader(account) val requestKey = item.selectionKey @@ -232,11 +331,19 @@ internal class CloudHubAdapter( } else { image.load(preview, loader) { placeholder(R.drawable.cloud_media_placeholder) + scale(Scale.FILL) crossfade(true) listener( onError = { _, _ -> if (image.getTag(R.id.cloud_media_image) == requestKey) { - loadOriginalMediaImage(image, item, requestKey) + if (item.isImage) { + loadOriginalMediaImage(image, item, requestKey) + } else { + image.scaleType = ImageView.ScaleType.CENTER_INSIDE + image.setImageResource( + MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name) + ) + } } } ) @@ -258,13 +365,14 @@ internal class CloudHubAdapter( image.load(originalUri, ThumbnailsRequester.getContentAddressedImageLoader(account)) { placeholder(R.drawable.cloud_media_placeholder) error(MimetypeIconUtil.getFileTypeIconId(item.mimeType, item.name)) + scale(Scale.FILL) memoryCacheKey("$originalUri#${item.etag}") diskCacheKey("$originalUri#${item.etag}") crossfade(true) listener( onStart = { if (image.getTag(R.id.cloud_media_image) != requestKey) image.dispose() - } + }, ) } } @@ -305,9 +413,17 @@ internal class CloudHubAdapter( item.etag.ifBlank { item.modifiedAt.toString() }, size, size, + ThumbnailsRequester.PreviewProcessor.FIT, ) } else { - ThumbnailsRequester.getPreviewUriForFile(item.toOCFile(account.name), account, item.etag, size, size) + ThumbnailsRequester.getPreviewUriForFile( + item.toOCFile(account.name), + account, + item.etag, + size, + size, + ThumbnailsRequester.PreviewProcessor.FIT, + ) } private fun contentUri(item: CloudMediaItem): String = @@ -343,6 +459,9 @@ internal class CloudHubAdapter( CloudHubRow.Shortcuts -> { "shortcuts" } is CloudHubRow.PhotoStatus -> { "photo-status" } is CloudHubRow.Media -> { "media:${row.item.spaceId.orEmpty()}:${row.item.remotePath}" } + is CloudHubRow.SmartMediaGroup -> { + "smart:${row.date}:${row.items.firstOrNull()?.selectionKey.orEmpty()}:${row.mirrored}" + } is CloudHubRow.Storage -> { "storage:${row.item.remotePath}" } is CloudHubRow.Album -> { "album:${row.path}" } is CloudHubRow.Action -> { "action:${row.id}" } @@ -361,6 +480,10 @@ internal class CloudHubAdapter( private const val TYPE_SHORTCUTS = 6 private const val TYPE_PHOTO_STATUS = 7 private const val TYPE_STORAGE = 8 + private const val TYPE_SMART_MEDIA_GROUP = 9 + private const val DEFAULT_GRID_SPANS = 6 + private const val DEFAULT_MEDIA_SPANS = 2 + private const val DEFAULT_ALBUM_SPANS = 3 private const val PREVIEW_SMALL = 320 private const val PREVIEW_MEDIUM = 512 private const val PREVIEW_LARGE = 1024 diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt index 7beed5c95..00d7a63a1 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudHubFragment.kt @@ -23,6 +23,7 @@ import com.google.android.material.floatingactionbutton.FloatingActionButton import eu.qsfera.android.R import eu.qsfera.android.data.ClientManager import eu.qsfera.android.data.executeRemoteOperation +import eu.qsfera.android.data.providers.SharedPreferencesProvider import eu.qsfera.android.domain.files.FileRepository import eu.qsfera.android.domain.files.model.OCFile import eu.qsfera.android.domain.spaces.SpacesRepository @@ -42,6 +43,7 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.koin.android.ext.android.inject import java.text.DateFormat +import java.text.SimpleDateFormat import java.util.Date import java.util.Locale @@ -50,11 +52,13 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { private val fileRepository: FileRepository by inject() private val spacesRepository: SpacesRepository by inject() private val transferRepository: TransferRepository by inject() + private val preferencesProvider: SharedPreferencesProvider by inject() private lateinit var section: CloudSection private lateinit var adapter: CloudHubAdapter private lateinit var refresh: SwipeRefreshLayout private lateinit var recycler: RecyclerView + private lateinit var gridLayoutManager: GridLayoutManager private lateinit var fab: FloatingActionButton private var allMedia: List = emptyList() private var storageItems: List = emptyList() @@ -71,6 +75,9 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { private var filesMode = CloudFilesMode.ALL private var parentFilesMode: CloudFilesMode? = null private var selection = CloudMediaSelection() + private var selectionMode = false + private var photoGridMode = CloudPhotoGridMode.LARGE + private var showScreenshots = true var currentFolderPath: String = ROOT_PATH private set @@ -86,6 +93,10 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) section = CloudSection.fromWireValue(arguments?.getString(ARG_SECTION)) + photoGridMode = CloudPhotoGridMode.fromWireValue( + preferencesProvider.getString(PREFERENCE_PHOTO_GRID_MODE, CloudPhotoGridMode.LARGE.wireValue) + ) + showScreenshots = preferencesProvider.getBoolean(PREFERENCE_SHOW_SCREENSHOTS, true) val restoredSelection = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { savedInstanceState?.getParcelableArrayList(STATE_SELECTION, CloudMediaItem::class.java) } else { @@ -93,11 +104,13 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { savedInstanceState?.getParcelableArrayList(STATE_SELECTION) } selection = CloudMediaSelection(restoredSelection.orEmpty()) + selectionMode = savedInstanceState?.getBoolean(STATE_SELECTION_MODE, false) == true || !selection.isEmpty requireActivity().onBackPressedDispatcher.addCallback(this, nestedBackCallback) } override fun onSaveInstanceState(outState: Bundle) { outState.putParcelableArrayList(STATE_SELECTION, ArrayList(selection.items)) + outState.putBoolean(STATE_SELECTION_MODE, selectionMode) super.onSaveInstanceState(outState) } @@ -116,13 +129,22 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { onLoadMore = { loadNextMediaPage(reset = false) }, onFeedGroupClick = ::openFeedGroup, ) - recycler = view.findViewById(R.id.cloud_list).apply { - layoutManager = GridLayoutManager(requireContext(), GRID_SPANS).also { layout -> - layout.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { + gridLayoutManager = GridLayoutManager(requireContext(), GRID_SPANS).also { layout -> + layout.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() { override fun getSpanSize(position: Int): Int = this@CloudHubFragment.adapter.spanSize(position) - } } + } + recycler = view.findViewById(R.id.cloud_list).apply { + layoutManager = gridLayoutManager adapter = this@CloudHubFragment.adapter + addItemDecoration( + CloudPhotoMonthDecoration( + labelAt = this@CloudHubFragment.adapter::monthLabelAt, + isEnabled = { + section == CloudSection.PHOTOS && photoGridMode == CloudPhotoGridMode.MONTHS + }, + ) + ) addOnScrollListener(object : RecyclerView.OnScrollListener() { override fun onScrolled(recyclerView: RecyclerView, dx: Int, dy: Int) { if (section == CloudSection.FILES || section == CloudSection.MORE) return @@ -185,6 +207,56 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { recycler.setPadding(sidePadding, 0, sidePadding, resources.getDimensionPixelSize(R.dimen.cloud_list_bottom_padding)) fab.isVisible = section == CloudSection.FILES || section == CloudSection.PHOTOS refresh.isEnabled = section != CloudSection.MORE + applyGridGeometry() + } + + private fun applyGridGeometry() { + if (!this::gridLayoutManager.isInitialized || !this::adapter.isInitialized) return + val geometry = if (section == CloudSection.PHOTOS) { + when (photoGridMode) { + CloudPhotoGridMode.SMART, + CloudPhotoGridMode.LARGE -> { + GridGeometry(GRID_SPANS, MEDIA_SPANS_LARGE, PHOTO_PREVIEW_LARGE) + } + CloudPhotoGridMode.STANDARD -> { + GridGeometry(GRID_SPANS_STANDARD, MEDIA_SPANS_STANDARD, PHOTO_PREVIEW_SMALL) + } + CloudPhotoGridMode.MONTHS -> { + GridGeometry(GRID_SPANS_MONTHS, MEDIA_SPANS_MONTHS, PHOTO_PREVIEW_SMALL) + } + } + } else { + GridGeometry(GRID_SPANS, MEDIA_SPANS_LARGE, PHOTO_PREVIEW_LARGE) + } + adapter.updateGridGeometry(geometry.spans, geometry.mediaSpans, geometry.previewSize) + gridLayoutManager.spanCount = geometry.spans + recycler.requestLayout() + } + + internal fun photoGridMode(): CloudPhotoGridMode = photoGridMode + + internal fun setPhotoGridMode(mode: CloudPhotoGridMode) { + if (photoGridMode == mode) return + photoGridMode = mode + preferencesProvider.putString(PREFERENCE_PHOTO_GRID_MODE, mode.wireValue) + applyGridGeometry() + render() + recycler.scrollToPosition(0) + } + + fun areScreenshotsShown(): Boolean = showScreenshots + + fun setScreenshotsShown(show: Boolean) { + if (showScreenshots == show) return + showScreenshots = show + preferencesProvider.putBoolean(PREFERENCE_SHOW_SCREENSHOTS, show) + render() + } + + fun startMediaSelection() { + if (section != CloudSection.PHOTOS) return + selectionMode = true + updateSelectionUi() } fun reload() { @@ -534,12 +606,21 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { CloudSection.MORE -> emptyList() } adapter.submitRows(rows) + if (section == CloudSection.PHOTOS) { + recycler.post { + if (isAdded && this::adapter.isInitialized) { + adapter.prefetchRows(requireContext(), rows) + } + } + } adapter.updateMediaSelection(selection.keys) } private fun filteredMedia(): List = allMedia.filter { media -> - query.isBlank() || media.name.contains(query, ignoreCase = true) || + val matchesQuery = query.isBlank() || media.name.contains(query, ignoreCase = true) || media.parentPath.contains(query, ignoreCase = true) + val includedByScreenshotSetting = section != CloudSection.PHOTOS || showScreenshots || !media.isScreenshot() + matchesQuery && includedByScreenshotSetting } private fun feedRows(media: List): List { @@ -609,7 +690,58 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { ) ) } else { - addAll(media.map(CloudHubRow::Media)) + addAll( + when (photoGridMode) { + CloudPhotoGridMode.SMART -> smartPhotoRows(media) + CloudPhotoGridMode.MONTHS -> monthPhotoRows(media) + CloudPhotoGridMode.LARGE, + CloudPhotoGridMode.STANDARD -> media.map(CloudHubRow::Media) + } + ) + } + } + + private fun smartPhotoRows(media: List): List { + val dayKeyFormat = SimpleDateFormat("yyyy-MM-dd", Locale.ROOT) + val dayLabelFormat = SimpleDateFormat("d MMMM", Locale.getDefault()) + var groupIndex = 0 + return buildList { + media.groupBy { dayKeyFormat.format(Date(it.modifiedAt)) } + .values + .forEach { dayItems -> + dayItems.chunked(SMART_GROUP_SIZE).forEachIndexed { index, items -> + add( + CloudHubRow.SmartMediaGroup( + date = if (index == 0) { + dayLabelFormat.format(Date(items.first().modifiedAt)) + } else { + "" + }, + items = items, + mirrored = groupIndex % 2 == 1, + ) + ) + groupIndex++ + } + } + } + } + + private fun monthPhotoRows(media: List): List { + val monthKeyFormat = SimpleDateFormat("yyyy-MM", Locale.ROOT) + val monthLabelFormat = SimpleDateFormat("LLLL", Locale.getDefault()) + var previousMonth: String? = null + return media.map { item -> + val month = monthKeyFormat.format(Date(item.modifiedAt)) + val label = if (month == previousMonth) { + null + } else { + previousMonth = month + monthLabelFormat.format(Date(item.modifiedAt)).replaceFirstChar { character -> + character.titlecase(Locale.getDefault()) + } + } + CloudHubRow.Media(item, label) } } @@ -640,7 +772,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } private fun handleMediaClick(media: CloudMediaItem) { - if (selection.isEmpty) { + if (!selectionMode && selection.isEmpty) { startActivity(CloudMediaPreviewActivity.createIntent(requireContext(), media)) } else { toggleMediaSelection(media) @@ -648,17 +780,19 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } private fun toggleMediaSelection(media: CloudMediaItem) { + selectionMode = true selection.toggle(media) updateSelectionUi() } fun clearSelection() { - if (selection.isEmpty) return + if (selection.isEmpty && !selectionMode) return selection.clear() + selectionMode = false updateSelectionUi() } - fun hasSelection(): Boolean = !selection.isEmpty + fun hasSelection(): Boolean = selectionMode || !selection.isEmpty fun selectAllVisibleMedia() { selection.selectAll(selectableMedia()) @@ -735,7 +869,7 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { private fun updateSelectionUi() { if (this::adapter.isInitialized) adapter.updateMediaSelection(selection.keys) - (activity as? CloudHomeActivity)?.showMediaSelection(selection.size) + (activity as? CloudHomeActivity)?.showMediaSelection(selection.size, selectionMode) } private fun openFeedGroup(date: String) { @@ -857,12 +991,29 @@ class CloudHubFragment : Fragment(R.layout.fragment_cloud_hub) { } companion object { + private data class GridGeometry( + val spans: Int, + val mediaSpans: Int, + val previewSize: Int, + ) + private const val ARG_SECTION = "cloud_section" private const val GRID_SPANS = 6 + private const val GRID_SPANS_STANDARD = 10 + private const val GRID_SPANS_MONTHS = 8 + private const val MEDIA_SPANS_LARGE = 2 + private const val MEDIA_SPANS_STANDARD = 2 + private const val MEDIA_SPANS_MONTHS = 1 + private const val SMART_GROUP_SIZE = 6 + private const val PHOTO_PREVIEW_SMALL = 320 + private const val PHOTO_PREVIEW_LARGE = 512 private const val MEDIA_PAGE_SIZE = 200 private const val LOAD_MORE_THRESHOLD = 18 private const val ROOT_PATH = "/" private const val STATE_SELECTION = "cloud_media_selection" + private const val STATE_SELECTION_MODE = "cloud_media_selection_mode" + private const val PREFERENCE_PHOTO_GRID_MODE = "cloud_photo_grid_mode" + private const val PREFERENCE_SHOW_SCREENSHOTS = "cloud_photo_show_screenshots" fun newInstance(section: CloudSection): CloudHubFragment = CloudHubFragment().apply { arguments = bundleOf(ARG_SECTION to section.wireValue) @@ -902,3 +1053,17 @@ private data class LoadedStorageFolder( val spaceId: String?, val usedCacheAfterRefreshFailure: Boolean, ) + +private fun CloudMediaItem.isScreenshot(): Boolean { + val searchable = "$parentPath/$name".lowercase(Locale.ROOT) + return SCREENSHOT_MARKERS.any(searchable::contains) +} + +private val SCREENSHOT_MARKERS = listOf( + "/screenshots/", + "/screenshot_", + "/screenshot-", + "/скриншоты/", + "/скриншот_", + "/снимки экрана/", +) diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt index 1dcb31bef..fe980c38a 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudMediaPreviewActivity.kt @@ -11,6 +11,7 @@ import android.content.Intent import android.os.Build import android.os.Bundle import android.view.View +import android.widget.ImageView import android.widget.ProgressBar import androidx.annotation.OptIn import androidx.appcompat.app.AppCompatActivity @@ -24,6 +25,7 @@ import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.source.ProgressiveMediaSource import androidx.media3.ui.PlayerView import coil.load +import coil.size.Scale import com.github.chrisbanes.photoview.PhotoView import eu.qsfera.android.MainApp import eu.qsfera.android.R @@ -59,32 +61,41 @@ class CloudMediaPreviewActivity : AppCompatActivity() { private fun showImage(media: CloudMediaItem) { val account = AccountUtils.getCurrentQSferaAccount(this) - val photo = findViewById(R.id.cloud_preview_photo).apply { visibility = View.VISIBLE } + val photo = findViewById(R.id.cloud_preview_photo).apply { + scaleType = ImageView.ScaleType.FIT_CENTER + visibility = View.VISIBLE + } val progress = findViewById(R.id.cloud_preview_progress) val loader = ThumbnailsRequester.getContentAddressedImageLoader(account) - photo.load( - previewUri(media, account, 2560, 2560), - loader, - ) { + photo.load(previewUri(media, account, 2560, 2560), loader) { + scale(Scale.FIT) crossfade(true) listener( onSuccess = { _, _ -> progress.visibility = View.GONE }, - onError = { _, _ -> - val originalUri = runCatching { contentUri(media, account) }.getOrNull() - if (originalUri == null) { - progress.visibility = View.GONE - } else { - photo.load(originalUri, loader) { - memoryCacheKey("$originalUri#${media.etag}") - diskCacheKey("$originalUri#${media.etag}") - crossfade(true) - listener( - onSuccess = { _, _ -> progress.visibility = View.GONE }, - onError = { _, _ -> progress.visibility = View.GONE }, - ) - } - } - }, + onError = { _, _ -> loadOriginalFallback(photo, progress, media, account) }, + ) + } + } + + private fun loadOriginalFallback( + photo: PhotoView, + progress: ProgressBar, + media: CloudMediaItem, + account: Account, + ) { + val originalUri = runCatching { contentUri(media, account) }.getOrNull() + if (originalUri == null) { + progress.visibility = View.GONE + return + } + photo.load(originalUri, ThumbnailsRequester.getContentAddressedImageLoader(account)) { + scale(Scale.FIT) + memoryCacheKey("$originalUri#${media.etag}") + diskCacheKey("$originalUri#${media.etag}") + crossfade(true) + listener( + onSuccess = { _, _ -> progress.visibility = View.GONE }, + onError = { _, _ -> progress.visibility = View.GONE }, ) } } @@ -132,6 +143,7 @@ class CloudMediaPreviewActivity : AppCompatActivity() { media.etag.ifBlank { media.modifiedAt.toString() }, width, height, + ThumbnailsRequester.PreviewProcessor.FIT, ) } else { ThumbnailsRequester.getPreviewUriForFile( @@ -140,6 +152,7 @@ class CloudMediaPreviewActivity : AppCompatActivity() { media.etag.ifBlank { media.modifiedAt.toString() }, width, height, + ThumbnailsRequester.PreviewProcessor.FIT, ) } diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt index 679ce9400..850cefe3d 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudModels.kt @@ -116,6 +116,18 @@ internal enum class CloudFilesMode { SPACES, } +internal enum class CloudPhotoGridMode(val wireValue: String) { + SMART("smart"), + LARGE("large"), + STANDARD("standard"), + MONTHS("months"); + + companion object { + fun fromWireValue(value: String?): CloudPhotoGridMode = + entries.firstOrNull { it.wireValue == value } ?: LARGE + } +} + enum class CloudSection(val wireValue: String) { FEED("feed"), FILES("files"), @@ -133,7 +145,12 @@ internal sealed interface CloudHubRow { data class FeedCard(val date: String, val items: List) : CloudHubRow data object Shortcuts : CloudHubRow data class PhotoStatus(val photos: Int, val videos: Int) : CloudHubRow - data class Media(val item: CloudMediaItem) : CloudHubRow + data class Media(val item: CloudMediaItem, val label: String? = null) : CloudHubRow + data class SmartMediaGroup( + val date: String, + val items: List, + val mirrored: Boolean, + ) : CloudHubRow data class Storage(val item: CloudStorageItem) : CloudHubRow data class Album(val path: String, val title: String, val count: Int, val cover: CloudMediaItem?) : CloudHubRow data class Action(val id: CloudAction, val title: String, val summary: String, val icon: Int) : CloudHubRow diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudPhotoMonthDecoration.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudPhotoMonthDecoration.kt new file mode 100644 index 000000000..7041d5683 --- /dev/null +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudPhotoMonthDecoration.kt @@ -0,0 +1,66 @@ +/** + * qsfera Android client application + * + * Copyright (C) 2026 QSfera. + */ +package eu.qsfera.android.presentation.cloud + +import android.graphics.Canvas +import android.graphics.Color +import android.graphics.Paint +import android.graphics.RectF +import android.graphics.Typeface +import androidx.recyclerview.widget.RecyclerView + +/** Draws month labels above the photo cells so a label is never clipped to one narrow column. */ +internal class CloudPhotoMonthDecoration( + private val labelAt: (Int) -> String?, + private val isEnabled: () -> Boolean, +) : RecyclerView.ItemDecoration() { + private val backgroundPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = 0xF2FFFFFF.toInt() + style = Paint.Style.FILL + } + private val textPaint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = Color.BLACK + typeface = Typeface.create(Typeface.DEFAULT, Typeface.BOLD) + } + private val bounds = RectF() + + override fun onDrawOver(canvas: Canvas, parent: RecyclerView, state: RecyclerView.State) { + if (!isEnabled()) return + val density = parent.resources.displayMetrics.density + val scaledDensity = parent.resources.displayMetrics.scaledDensity + val horizontalPadding = 8f * density + val verticalPadding = 5f * density + val offset = 8f * density + val radius = 7f * density + textPaint.textSize = 16f * scaledDensity + + for (index in 0 until parent.childCount) { + val child = parent.getChildAt(index) + val position = parent.getChildAdapterPosition(child) + if (position == RecyclerView.NO_POSITION) continue + val label = labelAt(position)?.takeIf(String::isNotBlank) ?: continue + + val textWidth = textPaint.measureText(label) + val fontMetrics = textPaint.fontMetrics + val textHeight = fontMetrics.descent - fontMetrics.ascent + val left = child.left + offset + val top = child.top + offset + bounds.set( + left, + top, + left + textWidth + horizontalPadding * 2, + top + textHeight + verticalPadding * 2, + ) + canvas.drawRoundRect(bounds, radius, radius, backgroundPaint) + canvas.drawText( + label, + left + horizontalPadding, + top + verticalPadding - fontMetrics.ascent, + textPaint, + ) + } + } +} diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudSmartPhotoLayout.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudSmartPhotoLayout.kt new file mode 100644 index 000000000..0060f6b11 --- /dev/null +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/cloud/CloudSmartPhotoLayout.kt @@ -0,0 +1,195 @@ +/** + * qsfera Android client application + * + * Copyright (C) 2026 QSfera. + */ +package eu.qsfera.android.presentation.cloud + +import android.content.Context +import android.util.AttributeSet +import android.view.ViewGroup +import kotlin.math.roundToInt + +/** + * Lays out up to six media tiles as a compact justified mosaic. The six-item geometry mirrors + * the day group visible in Yandex Disk: four tiles in a narrow column and two larger tiles in a + * wide column. Smaller groups use dedicated geometries instead of leaving empty holes. + */ +internal class CloudSmartPhotoLayout @JvmOverloads constructor( + context: Context, + attrs: AttributeSet? = null, +) : ViewGroup(context, attrs) { + var mediaCount: Int = 0 + set(value) { + val normalized = value.coerceIn(0, MAX_MEDIA) + if (field != normalized) { + field = normalized + requestLayout() + } + } + + var mirrored: Boolean = false + set(value) { + if (field != value) { + field = value + requestLayout() + } + } + + private var tileBounds: List = emptyList() + private val gap = resources.displayMetrics.density.roundToInt().coerceAtLeast(1) + + override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) { + val width = MeasureSpec.getSize(widthMeasureSpec) + tileBounds = geometry(width, mediaCount, mirrored, gap) + val desiredHeight = tileBounds.maxOfOrNull(TileBounds::bottom) ?: 0 + + for (index in 0 until childCount) { + val bounds = tileBounds.getOrNull(index) + if (bounds == null) { + getChildAt(index).measure( + MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(0, MeasureSpec.EXACTLY), + ) + } else { + getChildAt(index).measure( + MeasureSpec.makeMeasureSpec(bounds.width, MeasureSpec.EXACTLY), + MeasureSpec.makeMeasureSpec(bounds.height, MeasureSpec.EXACTLY), + ) + } + } + + setMeasuredDimension( + resolveSize(width, widthMeasureSpec), + resolveSize(desiredHeight, heightMeasureSpec), + ) + } + + override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) { + for (index in 0 until childCount) { + val child = getChildAt(index) + val bounds = tileBounds.getOrNull(index) + if (bounds == null) { + child.layout(0, 0, 0, 0) + } else { + child.layout(bounds.left, bounds.top, bounds.right, bounds.bottom) + } + } + } + + internal data class TileBounds( + val left: Int, + val top: Int, + val right: Int, + val bottom: Int, + ) { + val width: Int get() = (right - left).coerceAtLeast(0) + val height: Int get() = (bottom - top).coerceAtLeast(0) + } + + companion object { + private const val MAX_MEDIA = 6 + + internal fun isLargeSlot(count: Int, index: Int): Boolean = when (count.coerceIn(0, MAX_MEDIA)) { + 1 -> index == 0 + 2 -> index <= 1 + 3 -> index == 0 + 4 -> index <= 3 + 5 -> index == 1 || index == 2 + 6 -> index == 4 || index == 5 + else -> false + } + + internal fun geometry(width: Int, count: Int, mirrored: Boolean, gap: Int): List { + if (width <= 0 || count <= 0) return emptyList() + val normalizedCount = count.coerceAtMost(MAX_MEDIA) + val specs = when (normalizedCount) { + 1 -> GeometrySpec(rows = 1, heightRatio = 0.72f, cells = listOf(CellSpec(0, 0, 1, 1))) + 2 -> GeometrySpec( + columns = 2, + rows = 1, + heightRatio = 0.62f, + cells = listOf(CellSpec(0, 0, 1, 1), CellSpec(1, 0, 1, 1)), + ) + 3 -> GeometrySpec( + columns = 3, + rows = 2, + heightRatio = 0.66f, + cells = listOf( + CellSpec(0, 0, 2, 2), + CellSpec(2, 0, 1, 1), + CellSpec(2, 1, 1, 1), + ), + ) + 4 -> GeometrySpec( + columns = 2, + rows = 2, + heightRatio = 1f, + cells = listOf( + CellSpec(0, 0, 1, 1), + CellSpec(1, 0, 1, 1), + CellSpec(0, 1, 1, 1), + CellSpec(1, 1, 1, 1), + ), + ) + 5 -> GeometrySpec( + columns = 3, + rows = 4, + heightRatio = 1f, + cells = listOf( + CellSpec(0, 0, 1, 1), + CellSpec(0, 1, 1, 3), + CellSpec(1, 0, 2, 2), + CellSpec(1, 2, 1, 2), + CellSpec(2, 2, 1, 2), + ), + ) + else -> GeometrySpec( + columns = 3, + rows = 6, + heightRatio = 4f / 3f, + cells = listOf( + CellSpec(0, 0, 1, 1), + CellSpec(0, 1, 1, 2), + CellSpec(0, 3, 1, 2), + CellSpec(0, 5, 1, 1), + CellSpec(1, 0, 2, 2), + CellSpec(1, 2, 2, 4), + ), + ) + } + val height = (width * specs.heightRatio).roundToInt() + return specs.cells.map { cell -> + val rawLeft = cell.column * width / specs.columns + val rawRight = (cell.column + cell.columnSpan) * width / specs.columns + val rawTop = cell.row * height / specs.rows + val rawBottom = (cell.row + cell.rowSpan) * height / specs.rows + val adjusted = TileBounds( + left = rawLeft + if (cell.column > 0) gap else 0, + top = rawTop + if (cell.row > 0) gap else 0, + right = rawRight - if (cell.column + cell.columnSpan < specs.columns) gap else 0, + bottom = rawBottom - if (cell.row + cell.rowSpan < specs.rows) gap else 0, + ) + if (mirrored) { + adjusted.copy(left = width - adjusted.right, right = width - adjusted.left) + } else { + adjusted + } + } + } + + private data class GeometrySpec( + val columns: Int = 1, + val rows: Int, + val heightRatio: Float, + val cells: List, + ) + + private data class CellSpec( + val column: Int, + val row: Int, + val columnSpan: Int, + val rowSpan: Int, + ) + } +} diff --git a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt index dff7dd8e0..402ae08b9 100644 --- a/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt +++ b/Android/qsferaApp/src/main/java/eu/qsfera/android/presentation/thumbnails/ThumbnailsRequester.kt @@ -53,6 +53,10 @@ import timber.log.Timber import java.util.Locale object ThumbnailsRequester : KoinComponent { + enum class PreviewProcessor(val queryValue: String) { + FIT("fit"), + } + private val clientManager: ClientManager by inject() private val preferencesProvider: SharedPreferencesProvider by inject() @@ -99,8 +103,15 @@ object ThumbnailsRequester : KoinComponent { return "$baseUrl/graph/v1.0/me/photo/\$value?u=${account.name.hashCode().toString(16)}" } - fun getPreviewUriForFile(file: OCFile, account: Account, etag: String? = null, width: Int = 1024, height: Int = 1024): String = - getPreviewUri(file.remotePath, etag ?: file.remoteEtag, account, width, height) + @JvmOverloads + fun getPreviewUriForFile( + file: OCFile, + account: Account, + etag: String? = null, + width: Int = 1024, + height: Int = 1024, + processor: PreviewProcessor? = null, + ): String = getPreviewUri(file.remotePath, etag ?: file.remoteEtag, account, width, height, processor) fun getPreviewUriForFile(fileWithSyncInfo: OCFileWithSyncInfo, account: Account, width: Int = 1024, height: Int = 1024): String = getPreviewUriForFile(fileWithSyncInfo.file, account, null, width, height) @@ -111,10 +122,14 @@ object ThumbnailsRequester : KoinComponent { etag: String? = null, width: Int = 1024, height: Int = 1024, + processor: PreviewProcessor? = null, ): String { val absoluteHref = getContentUriForWebDavHref(webDavHref, account) val separator = if ('?' in absoluteHref) '&' else '?' - return "$absoluteHref${separator}x=$width&y=$height&c=${etag.orEmpty()}&preview=1" + return withProcessor( + "$absoluteHref${separator}x=$width&y=$height&c=${etag.orEmpty()}&preview=1", + processor, + ) } /** @@ -146,15 +161,28 @@ object ThumbnailsRequester : KoinComponent { fun getPreviewUriForSpaceSpecial(spaceSpecial: SpaceSpecial): String = String.format(Locale.US, SPACE_SPECIAL_PREVIEW_URI, spaceSpecial.webDavUrl, 1024, 1024, spaceSpecial.eTag) - private fun getPreviewUri(remotePath: String?, etag: String?, account: Account, width: Int, height: Int): String { + private fun getPreviewUri( + remotePath: String?, + etag: String?, + account: Account, + width: Int, + height: Int, + processor: PreviewProcessor?, + ): String { val baseUrl = getAccountBaseUrl(account) val normalizedRemotePath = remotePath.orEmpty() val path = if (normalizedRemotePath.startsWith("/")) normalizedRemotePath else "/$normalizedRemotePath" val encodedPath = Uri.encode(path, "/") - return String.format(Locale.US, FILE_PREVIEW_URI, baseUrl, encodedPath, width, height, etag.orEmpty()) + return withProcessor( + String.format(Locale.US, FILE_PREVIEW_URI, baseUrl, encodedPath, width, height, etag.orEmpty()), + processor, + ) } + private fun withProcessor(uri: String, processor: PreviewProcessor?): String = + processor?.let { "$uri&processor=${it.queryValue}" } ?: uri + private fun getAccountBaseUrl(account: Account): String = accountBaseUrls.getOrPut(account.name) { val accountManager = AccountManager.get(appContext) accountManager.getUserData(account, eu.qsfera.android.lib.common.accounts.AccountUtils.Constants.KEY_OC_BASE_URL) diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_large.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_large.xml new file mode 100644 index 000000000..6afae36ab --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_large.xml @@ -0,0 +1,10 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_months.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_months.xml new file mode 100644 index 000000000..9f4571583 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_months.xml @@ -0,0 +1,10 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_smart.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_smart.xml new file mode 100644 index 000000000..d09d93ded --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_smart.xml @@ -0,0 +1,10 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_standard.xml b/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_standard.xml new file mode 100644 index 000000000..203aa6427 --- /dev/null +++ b/Android/qsferaApp/src/main/res/drawable/ic_cloud_photo_view_standard.xml @@ -0,0 +1,10 @@ + + + + diff --git a/Android/qsferaApp/src/main/res/layout/activity_cloud_home.xml b/Android/qsferaApp/src/main/res/layout/activity_cloud_home.xml index ae747a164..95277f0a5 100644 --- a/Android/qsferaApp/src/main/res/layout/activity_cloud_home.xml +++ b/Android/qsferaApp/src/main/res/layout/activity_cloud_home.xml @@ -65,9 +65,23 @@ android:padding="11dp" android:src="@drawable/ic_cloud_search" app:layout_constraintBottom_toBottomOf="parent" - app:layout_constraintEnd_toEndOf="parent" + app:layout_constraintEnd_toStartOf="@id/cloud_toolbar_more_button" app:layout_constraintTop_toTopOf="parent" /> + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/sheet_cloud_photo_view.xml b/Android/qsferaApp/src/main/res/layout/sheet_cloud_photo_view.xml new file mode 100644 index 000000000..7da986928 --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/sheet_cloud_photo_view.xml @@ -0,0 +1,234 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/layout/view_cloud_smart_media.xml b/Android/qsferaApp/src/main/res/layout/view_cloud_smart_media.xml new file mode 100644 index 000000000..4575894cd --- /dev/null +++ b/Android/qsferaApp/src/main/res/layout/view_cloud_smart_media.xml @@ -0,0 +1,46 @@ + + + + + + + + + + + diff --git a/Android/qsferaApp/src/main/res/values/strings.xml b/Android/qsferaApp/src/main/res/values/strings.xml index 9078942d7..0ab8ed788 100644 --- a/Android/qsferaApp/src/main/res/values/strings.xml +++ b/Android/qsferaApp/src/main/res/values/strings.xml @@ -899,6 +899,16 @@ GitHub repo]]> + + Настройки отображения фото + Выбрать файлы + Показывать скриншоты + Вид + Умная плитка + Крупная плитка + Стандартная плитка + По месяцам + Link Button diff --git a/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudPhotoGridModeTest.kt b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudPhotoGridModeTest.kt new file mode 100644 index 000000000..666b2e22e --- /dev/null +++ b/Android/qsferaApp/src/test/java/eu/qsfera/android/presentation/cloud/CloudPhotoGridModeTest.kt @@ -0,0 +1,49 @@ +/** + * qsfera Android client application + * + * Copyright (C) 2026 QSfera. + */ +package eu.qsfera.android.presentation.cloud + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +class CloudPhotoGridModeTest { + + @Test + fun `all persisted grid mode values can be restored`() { + CloudPhotoGridMode.entries.forEach { mode -> + assertEquals(mode, CloudPhotoGridMode.fromWireValue(mode.wireValue)) + } + } + + @Test + fun `unknown persisted grid mode falls back to large tiles`() { + assertEquals(CloudPhotoGridMode.LARGE, CloudPhotoGridMode.fromWireValue("unknown")) + assertEquals(CloudPhotoGridMode.LARGE, CloudPhotoGridMode.fromWireValue(null)) + } + + @Test + fun `smart tile geometries fill every supported group without empty slots`() { + for (count in 1..6) { + val bounds = CloudSmartPhotoLayout.geometry(width = 1200, count = count, mirrored = false, gap = 2) + + assertEquals(count, bounds.size) + assertTrue(bounds.all { it.width > 0 && it.height > 0 }) + assertTrue(bounds.all { it.left >= 0 && it.top >= 0 && it.right <= 1200 }) + } + } + + @Test + fun `mirrored smart geometry preserves tile sizes`() { + val regular = CloudSmartPhotoLayout.geometry(width = 1200, count = 6, mirrored = false, gap = 2) + val mirrored = CloudSmartPhotoLayout.geometry(width = 1200, count = 6, mirrored = true, gap = 2) + + regular.zip(mirrored).forEach { (left, right) -> + assertEquals(left.width, right.width) + assertEquals(left.height, right.height) + assertEquals(1200 - left.right, right.left) + } + } +}