Match Yandex photo grid and preserve photo framing
Android / test-and-build (push) Failing after 56s
Android / test-and-build (push) Failing after 56s
This commit is contained in:
@@ -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() + "\""
|
||||
|
||||
+56
-3
@@ -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<ViewGroup>(android.R.id.content),
|
||||
false,
|
||||
)
|
||||
dialog.setContentView(content)
|
||||
|
||||
content.findViewById<View>(R.id.cloud_photo_select_files).setOnClickListener {
|
||||
dialog.dismiss()
|
||||
fragment.startMediaSelection()
|
||||
}
|
||||
|
||||
val screenshotsSwitch = content.findViewById<SwitchMaterial>(R.id.cloud_photo_show_screenshots).apply {
|
||||
isChecked = fragment.areScreenshotsShown()
|
||||
setOnCheckedChangeListener { _, checked -> fragment.setScreenshotsShown(checked) }
|
||||
}
|
||||
content.findViewById<View>(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<RadioButton>(ids.second).isChecked = candidate == mode
|
||||
}
|
||||
fragment.setPhotoGridMode(mode)
|
||||
}
|
||||
selectMode(fragment.photoGridMode())
|
||||
modeRows.forEach { (mode, ids) ->
|
||||
content.findViewById<View>(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("")
|
||||
}
|
||||
|
||||
|
||||
+135
-12
@@ -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<CloudHubAdapter.Holder>() {
|
||||
private var rows: List<CloudHubRow> = emptyList()
|
||||
private var selectedMediaKeys: Set<String> = emptySet()
|
||||
private var gridSpanCount = DEFAULT_GRID_SPANS
|
||||
private var mediaSpanSize = DEFAULT_MEDIA_SPANS
|
||||
private var mediaPreviewSize = PREVIEW_MEDIUM
|
||||
private val prefetchedUrls = mutableSetOf<String>()
|
||||
|
||||
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<ImageView>(R.id.cloud_media_image)
|
||||
val video = view.findViewById<ImageView>(R.id.cloud_media_video)
|
||||
val selectionScrim = view.findViewById<View>(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<CloudSmartPhotoLayout>(R.id.cloud_smart_grid).apply {
|
||||
mediaCount = group.items.size
|
||||
mirrored = group.mirrored
|
||||
}
|
||||
view.findViewById<TextView>(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<View>(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<ImageView>(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<CloudHubRow>) {
|
||||
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
|
||||
|
||||
+175
-10
@@ -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<CloudMediaItem> = emptyList()
|
||||
private var storageItems: List<CloudStorageItem> = 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<RecyclerView>(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<RecyclerView>(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<CloudMediaItem> = 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<CloudMediaItem>): List<CloudHubRow> {
|
||||
@@ -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<CloudMediaItem>): List<CloudHubRow> {
|
||||
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<CloudMediaItem>): List<CloudHubRow> {
|
||||
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-",
|
||||
"/скриншоты/",
|
||||
"/скриншот_",
|
||||
"/снимки экрана/",
|
||||
)
|
||||
|
||||
+34
-21
@@ -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<PhotoView>(R.id.cloud_preview_photo).apply { visibility = View.VISIBLE }
|
||||
val photo = findViewById<PhotoView>(R.id.cloud_preview_photo).apply {
|
||||
scaleType = ImageView.ScaleType.FIT_CENTER
|
||||
visibility = View.VISIBLE
|
||||
}
|
||||
val progress = findViewById<ProgressBar>(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,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+18
-1
@@ -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<CloudMediaItem>) : 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<CloudMediaItem>,
|
||||
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
|
||||
|
||||
+66
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+195
@@ -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<TileBounds> = 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<TileBounds> {
|
||||
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<CellSpec>,
|
||||
)
|
||||
|
||||
private data class CellSpec(
|
||||
val column: Int,
|
||||
val row: Int,
|
||||
val columnSpan: Int,
|
||||
val rowSpan: Int,
|
||||
)
|
||||
}
|
||||
}
|
||||
+33
-5
@@ -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)
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/qsfera_text_primary"
|
||||
android:pathData="M3,3h8v8H3zM13,3h8v8h-8zM3,13h8v8H3zM13,13h8v8h-8z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/qsfera_text_primary"
|
||||
android:pathData="M4,3h16c0.55,0 1,0.45 1,1v16c0,0.55 -0.45,1 -1,1H4c-0.55,0 -1,-0.45 -1,-1V4c0,-0.55 0.45,-1 1,-1zM5,8v5h6V8zM13,8v5h6V8zM5,15v4h6v-4zM13,15v4h6v-4z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/qsfera_text_primary"
|
||||
android:pathData="M3,3h11v18H3zM16,3h5v8h-5zM16,13h5v8h-5z" />
|
||||
</vector>
|
||||
@@ -0,0 +1,10 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="24"
|
||||
android:viewportHeight="24">
|
||||
<path
|
||||
android:fillColor="@color/qsfera_text_primary"
|
||||
android:pathData="M3,3h5v5H3zM9.5,3h5v5h-5zM16,3h5v5h-5zM3,9.5h5v5H3zM9.5,9.5h5v5h-5zM16,9.5h5v5h-5zM3,16h5v5H3zM9.5,16h5v5h-5zM16,16h5v5h-5z" />
|
||||
</vector>
|
||||
@@ -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" />
|
||||
|
||||
<androidx.appcompat.widget.AppCompatImageButton
|
||||
android:id="@+id/cloud_toolbar_more_button"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:background="?attr/selectableItemBackgroundBorderless"
|
||||
android:contentDescription="@string/cloud_photo_view_options"
|
||||
android:padding="11dp"
|
||||
android:src="@drawable/ic_cloud_more_vert"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"
|
||||
app:tint="@color/qsfera_text_primary" />
|
||||
|
||||
<androidx.appcompat.widget.SearchView
|
||||
android:id="@+id/cloud_toolbar_search"
|
||||
android:layout_width="0dp"
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="0dp"
|
||||
android:contentDescription="@null"
|
||||
android:scaleType="fitCenter"
|
||||
android:visibility="gone"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_margin="1dp"
|
||||
android:background="@drawable/cloud_media_placeholder"
|
||||
android:clipToOutline="true"
|
||||
android:foreground="?attr/selectableItemBackgroundBorderless">
|
||||
|
||||
<ImageView
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<eu.qsfera.android.presentation.cloud.CloudSmartPhotoLayout
|
||||
android:id="@+id/cloud_smart_grid"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
|
||||
<include
|
||||
android:id="@+id/cloud_smart_media_first"
|
||||
layout="@layout/view_cloud_smart_media" />
|
||||
|
||||
<include
|
||||
android:id="@+id/cloud_smart_media_second"
|
||||
layout="@layout/view_cloud_smart_media" />
|
||||
|
||||
<include
|
||||
android:id="@+id/cloud_smart_media_third"
|
||||
layout="@layout/view_cloud_smart_media" />
|
||||
|
||||
<include
|
||||
android:id="@+id/cloud_smart_media_fourth"
|
||||
layout="@layout/view_cloud_smart_media" />
|
||||
|
||||
<include
|
||||
android:id="@+id/cloud_smart_media_fifth"
|
||||
layout="@layout/view_cloud_smart_media" />
|
||||
|
||||
<include
|
||||
android:id="@+id/cloud_smart_media_sixth"
|
||||
layout="@layout/view_cloud_smart_media" />
|
||||
</eu.qsfera.android.presentation.cloud.CloudSmartPhotoLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cloud_smart_date"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="top|start"
|
||||
android:layout_margin="12dp"
|
||||
android:ellipsize="end"
|
||||
android:maxLines="1"
|
||||
android:paddingHorizontal="4dp"
|
||||
android:paddingVertical="2dp"
|
||||
android:shadowColor="#99000000"
|
||||
android:shadowDx="0"
|
||||
android:shadowDy="1"
|
||||
android:shadowRadius="3"
|
||||
android:textColor="@android:color/white"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold" />
|
||||
</FrameLayout>
|
||||
@@ -0,0 +1,234 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:background="@drawable/cloud_sheet_background"
|
||||
android:orientation="vertical"
|
||||
android:paddingStart="18dp"
|
||||
android:paddingTop="12dp"
|
||||
android:paddingEnd="18dp"
|
||||
android:paddingBottom="24dp">
|
||||
|
||||
<View
|
||||
android:layout_width="40dp"
|
||||
android:layout_height="4dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:background="@drawable/cloud_sheet_handle" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/cloud_photo_select_files"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:text="@string/cloud_photo_select_files"
|
||||
android:textColor="@color/qsfera_text_primary"
|
||||
android:textSize="17sp"
|
||||
app:drawableStartCompat="@drawable/ic_select_all"
|
||||
android:drawablePadding="18dp" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cloud_photo_show_screenshots_row"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="4dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_select_inverse" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/cloud_photo_show_screenshots"
|
||||
android:textColor="@color/qsfera_text_primary"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<com.google.android.material.switchmaterial.SwitchMaterial
|
||||
android:id="@+id/cloud_photo_show_screenshots"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:contentDescription="@string/cloud_photo_show_screenshots" />
|
||||
</LinearLayout>
|
||||
|
||||
<View
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="1dp"
|
||||
android:layout_marginTop="8dp"
|
||||
android:layout_marginBottom="14dp"
|
||||
android:background="@color/qsfera_divider" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="12dp"
|
||||
android:paddingBottom="8dp"
|
||||
android:text="@string/cloud_photo_view_title"
|
||||
android:textColor="@color/qsfera_text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cloud_photo_view_smart"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_cloud_photo_view_smart" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/cloud_photo_view_smart"
|
||||
android:textColor="@color/qsfera_text_primary"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/cloud_photo_view_smart_radio"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:buttonTint="@color/qsfera_blue"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
android:gravity="center" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cloud_photo_view_large"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_cloud_photo_view_large" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/cloud_photo_view_large"
|
||||
android:textColor="@color/qsfera_text_primary"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/cloud_photo_view_large_radio"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:buttonTint="@color/qsfera_blue"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
android:gravity="center" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cloud_photo_view_standard"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_cloud_photo_view_standard" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/cloud_photo_view_standard"
|
||||
android:textColor="@color/qsfera_text_primary"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/cloud_photo_view_standard_radio"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:buttonTint="@color/qsfera_blue"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
android:gravity="center" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:id="@+id/cloud_photo_view_months"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="56dp"
|
||||
android:background="?attr/selectableItemBackground"
|
||||
android:clickable="true"
|
||||
android:focusable="true"
|
||||
android:gravity="center_vertical"
|
||||
android:orientation="horizontal"
|
||||
android:paddingStart="12dp"
|
||||
android:paddingEnd="12dp">
|
||||
|
||||
<ImageView
|
||||
android:layout_width="24dp"
|
||||
android:layout_height="24dp"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:src="@drawable/ic_cloud_photo_view_months" />
|
||||
|
||||
<TextView
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="18dp"
|
||||
android:layout_weight="1"
|
||||
android:text="@string/cloud_photo_view_months"
|
||||
android:textColor="@color/qsfera_text_primary"
|
||||
android:textSize="17sp" />
|
||||
|
||||
<RadioButton
|
||||
android:id="@+id/cloud_photo_view_months_radio"
|
||||
android:layout_width="48dp"
|
||||
android:layout_height="48dp"
|
||||
android:buttonTint="@color/qsfera_blue"
|
||||
android:clickable="false"
|
||||
android:focusable="false"
|
||||
android:gravity="center" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/cloud_media_placeholder"
|
||||
android:foreground="?attr/selectableItemBackgroundBorderless">
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cloud_media_image"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:contentDescription="@null"
|
||||
android:importantForAccessibility="no"
|
||||
android:scaleType="centerCrop" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cloud_media_video"
|
||||
android:layout_width="28dp"
|
||||
android:layout_height="28dp"
|
||||
android:layout_gravity="bottom|end"
|
||||
android:layout_margin="8dp"
|
||||
android:background="@drawable/cloud_card_background"
|
||||
android:importantForAccessibility="no"
|
||||
android:padding="5dp"
|
||||
android:src="@drawable/ic_play_arrow"
|
||||
android:visibility="gone" />
|
||||
|
||||
<View
|
||||
android:id="@+id/cloud_media_selection_scrim"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@color/cloud_media_selection_scrim"
|
||||
android:visibility="gone" />
|
||||
|
||||
<ImageView
|
||||
android:id="@+id/cloud_media_selection_check"
|
||||
android:layout_width="30dp"
|
||||
android:layout_height="30dp"
|
||||
android:layout_gravity="top|end"
|
||||
android:layout_margin="8dp"
|
||||
android:background="@drawable/cloud_media_selection_badge"
|
||||
android:contentDescription="@string/cloud_selection_selected"
|
||||
android:padding="5dp"
|
||||
android:src="@drawable/ic_cloud_check"
|
||||
android:visibility="gone" />
|
||||
</FrameLayout>
|
||||
@@ -899,6 +899,16 @@
|
||||
|
||||
<string name="feedback_dialog_get_in_contact_description"><![CDATA[ Discuss in our <a href=\"%1$s\"><b>GitHub repo</b></a>]]></string>
|
||||
|
||||
<!-- Cloud photo view options -->
|
||||
<string name="cloud_photo_view_options">Настройки отображения фото</string>
|
||||
<string name="cloud_photo_select_files">Выбрать файлы</string>
|
||||
<string name="cloud_photo_show_screenshots">Показывать скриншоты</string>
|
||||
<string name="cloud_photo_view_title">Вид</string>
|
||||
<string name="cloud_photo_view_smart">Умная плитка</string>
|
||||
<string name="cloud_photo_view_large">Крупная плитка</string>
|
||||
<string name="cloud_photo_view_standard">Стандартная плитка</string>
|
||||
<string name="cloud_photo_view_months">По месяцам</string>
|
||||
|
||||
<string name="link_role_accessibility">Link</string>
|
||||
<string name="button_role_accessibility">Button</string>
|
||||
|
||||
|
||||
+49
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user