Add UI prototype for selecting file to storage demo app

This commit is contained in:
Torsten Grote 2024-06-20 16:30:06 -03:00
parent 2b07b8417c
commit 5012099419
No known key found for this signature in database
GPG key ID: 3E5F77D92CF891FF
18 changed files with 471 additions and 8 deletions

View file

@ -46,6 +46,7 @@ import org.calyxos.backup.storage.api.SnapshotItem
import org.calyxos.backup.storage.api.StorageBackup
import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_TIMESTAMP_START
import org.calyxos.backup.storage.restore.RestoreService.Companion.EXTRA_USER_ID
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
import java.util.LinkedList
@ -98,6 +99,8 @@ internal class RestoreViewModel(
get() = appDataRestoreManager.restoreBackupResult
override val snapshots = storageBackup.getBackupSnapshots().asLiveData(ioDispatcher)
override val fileSelectionManager: FileSelectionManager
get() = TODO("Not yet implemented")
internal fun loadRestoreSets() = viewModelScope.launch(ioDispatcher) {
val backups = restoreCoordinator.getAvailableMetadata()?.mapNotNull { (token, metadata) ->

View file

@ -28,6 +28,7 @@ import org.calyxos.backup.storage.backup.BackupJobService
import org.calyxos.backup.storage.scanner.DocumentScanner
import org.calyxos.backup.storage.scanner.MediaScanner
import org.calyxos.backup.storage.ui.backup.BackupContentViewModel
import org.calyxos.backup.storage.ui.restore.FileSelectionManager
import org.calyxos.backup.storage.ui.restore.SnapshotViewModel
private val logEmptyState = """
@ -47,6 +48,7 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
private val app: App = application as App
private val settingsManager = app.settingsManager
override val storageBackup: StorageBackup = app.storageBackup
override val fileSelectionManager = FileSelectionManager()
private val _backupLog = MutableLiveData(BackupProgress(0, 0, logEmptyState))
val backupLog: LiveData<BackupProgress> = _backupLog
@ -124,6 +126,11 @@ class MainViewModel(application: Application) : BackupContentViewModel(applicati
}
fun onSnapshotClicked(item: SnapshotItem) {
val snapshot = item.snapshot ?: error("${item.storedSnapshot} had null snapshot")
fileSelectionManager.onSnapshotChosen(snapshot)
}
fun onFilesSelected(item: SnapshotItem) {
val snapshot = item.snapshot
check(snapshot != null)

View file

@ -0,0 +1,36 @@
/*
* SPDX-FileCopyrightText: 2021 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package de.grobox.storagebackuptester.restore
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.activityViewModels
import de.grobox.storagebackuptester.MainViewModel
import org.calyxos.backup.storage.ui.restore.FileSelectionFragment
class DemoFileSelectionFragment : FileSelectionFragment() {
override val viewModel: MainViewModel by activityViewModels()
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
val v = super.onCreateView(inflater, container, savedInstanceState)
// val topStub: ViewStub = v.findViewById(R.id.topStub)
// topStub.layoutResource = R.layout.footer_snapshot
// val header = topStub.inflate()
// header.findViewById<Button>(R.id.button).setOnClickListener {
// requireActivity().onBackPressed()
// }
return v
}
}

View file

@ -39,8 +39,8 @@ class DemoSnapshotFragment : SnapshotFragment() {
override fun onSnapshotClicked(item: SnapshotItem) {
viewModel.onSnapshotClicked(item)
parentFragmentManager.beginTransaction()
.replace(R.id.container, RestoreFragment.newInstance())
.addToBackStack("RESTORE")
.replace(R.id.container, DemoFileSelectionFragment())
.addToBackStack("SELECT")
.commit()
}

View file

@ -52,17 +52,17 @@ class RestoreFragment : Fragment() {
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
viewModel.restoreLog.observe(viewLifecycleOwner, { progress ->
viewModel.restoreLog.observe(viewLifecycleOwner) { progress ->
progress.text?.let { adapter.addItem(it) }
horizontalProgressBar.max = progress.total
horizontalProgressBar.setProgress(progress.current, true)
list.postDelayed({
list.scrollToPosition(adapter.itemCount - 1)
}, 50)
})
viewModel.restoreProgressVisible.observe(viewLifecycleOwner, { visible ->
}
viewModel.restoreProgressVisible.observe(viewLifecycleOwner) { visible ->
progressBar.visibility = if (visible) VISIBLE else INVISIBLE
})
}
}
override fun onStart() {

View file

@ -0,0 +1,58 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.backup.storage.ui.restore
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.annotation.CallSuper
import androidx.fragment.app.Fragment
import androidx.lifecycle.Lifecycle.State.STARTED
import androidx.lifecycle.flowWithLifecycle
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.RecyclerView
import kotlinx.coroutines.launch
import org.calyxos.backup.storage.R
public abstract class FileSelectionFragment : Fragment() {
protected abstract val viewModel: SnapshotViewModel
private lateinit var list: RecyclerView
private lateinit var adapter: FilesAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?,
): View {
requireActivity().setTitle(R.string.select_files_title)
val v = inflater.inflate(R.layout.fragment_select_files, container, false)
list = v.findViewById(R.id.list)
return v
}
@CallSuper
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
adapter = FilesAdapter(
viewModel.fileSelectionManager::onExpandClicked,
viewModel.fileSelectionManager::onCheckedChanged,
)
list.adapter = adapter
lifecycleScope.launch {
viewModel.fileSelectionManager.files.flowWithLifecycle(lifecycle, STARTED).collect {
onFileItemsChanged(it)
}
}
}
@CallSuper
public open fun onFileItemsChanged(filesItems: List<FilesItem>) {
adapter.submitList(filesItems)
}
}

View file

@ -0,0 +1,126 @@
/*
* SPDX-FileCopyrightText: 2024 The Calyx Institute
* SPDX-License-Identifier: Apache-2.0
*/
package org.calyxos.backup.storage.ui.restore
import android.content.res.Resources
import android.text.format.DateUtils.FORMAT_ABBREV_ALL
import android.text.format.DateUtils.getRelativeTimeSpanString
import android.text.format.Formatter
import android.view.LayoutInflater
import android.view.View
import android.view.View.VISIBLE
import android.view.ViewGroup
import android.widget.CheckBox
import android.widget.ImageView
import android.widget.TextView
import androidx.core.content.ContextCompat.getDrawable
import androidx.core.view.updatePadding
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView.ViewHolder
import org.calyxos.backup.storage.R
import org.calyxos.backup.storage.backup.BackupMediaFile.MediaType.AUDIO
import org.calyxos.backup.storage.backup.BackupMediaFile.MediaType.IMAGES
import org.calyxos.backup.storage.backup.BackupMediaFile.MediaType.VIDEO
import org.calyxos.backup.storage.ui.restore.FilesAdapter.FilesViewHolder
private class FilesItemCallback : DiffUtil.ItemCallback<FilesItem>() {
override fun areItemsTheSame(oldItem: FilesItem, newItem: FilesItem): Boolean {
if (oldItem is FileItem && newItem is FileItem) return newItem.file == oldItem.file
if (oldItem is FolderItem && newItem is FolderItem) return newItem.name == oldItem.name
return false
}
override fun areContentsTheSame(oldItem: FilesItem, newItem: FilesItem): Boolean {
if (oldItem is FileItem && newItem is FileItem) return newItem.selected == oldItem.selected
if (oldItem is FolderItem && newItem is FolderItem) {
return newItem.selected == oldItem.selected && newItem.expanded == oldItem.expanded &&
newItem.partiallySelected == oldItem.partiallySelected
}
return false
}
}
internal class FilesAdapter(
private val onExpandClicked: (FolderItem) -> Unit,
private val onCheckedChanged: (FilesItem) -> Unit,
) : ListAdapter<FilesItem, FilesViewHolder>(FilesItemCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): FilesViewHolder {
val v = LayoutInflater.from(parent.context).inflate(R.layout.item_file, parent, false)
return FilesViewHolder(v)
}
override fun onBindViewHolder(holder: FilesViewHolder, position: Int) {
holder.bind(getItem(position))
}
inner class FilesViewHolder(itemView: View) : ViewHolder(itemView) {
private val context = itemView.context
private val expandView: ImageView = itemView.findViewById(R.id.expandView)
private val nameView: TextView = itemView.findViewById(R.id.nameView)
private val infoView: TextView = itemView.findViewById(R.id.infoView)
private val checkBox: CheckBox = itemView.findViewById(R.id.checkBox)
private val indentPadding = (8 * Resources.getSystem().displayMetrics.density).toInt()
private val checkBoxDrawable = checkBox.buttonDrawable
private val indeterminateDrawable =
getDrawable(context, R.drawable.ic_indeterminate_check_box)
fun bind(item: FilesItem) {
if (item is FolderItem) {
expandView.visibility = VISIBLE
val res = if (item.expanded) {
R.drawable.ic_keyboard_arrow_down
} else {
R.drawable.ic_chevron_right
}
expandView.setImageResource(res)
itemView.setOnClickListener {
onExpandClicked(item)
}
} else if (item is FileItem) {
expandView.setImageResource(getDrawableResource(item))
itemView.setOnClickListener(null)
}
itemView.updatePadding(left = indentPadding * item.level)
nameView.text = item.name
val now = System.currentTimeMillis()
var text = Formatter.formatShortFileSize(context, item.size)
item.lastModified?.let {
text += " - " + getRelativeTimeSpanString(it, now, 0L, FORMAT_ABBREV_ALL)
}
if (item is FolderItem) {
val numStr = context.getString(R.string.select_files_number_of_files, item.numFiles)
text += " - $numStr"
}
infoView.text = text
// unset and re-reset onCheckedChangeListener while updating checked state
checkBox.setOnCheckedChangeListener(null)
checkBox.isChecked = item.selected
checkBox.setOnCheckedChangeListener { _, _ ->
onCheckedChanged(item)
}
if (item is FolderItem && item.partiallySelected) {
checkBox.buttonDrawable = indeterminateDrawable
} else {
checkBox.buttonDrawable = checkBoxDrawable
}
}
}
private fun getDrawableResource(item: FileItem): Int = item.file.mediaFile?.type?.let { type ->
when (type) {
IMAGES -> R.drawable.ic_image
VIDEO -> R.drawable.ic_video_file
AUDIO -> R.drawable.ic_audio_file
else -> R.drawable.ic_insert_drive_file
}
} ?: R.drawable.ic_insert_drive_file
}

View file

@ -22,6 +22,7 @@ import org.calyxos.backup.storage.api.SnapshotResult
public interface SnapshotViewModel {
public val snapshots: LiveData<SnapshotResult>
public val fileSelectionManager: FileSelectionManager
}
internal interface SnapshotClickListener {
@ -46,7 +47,7 @@ public abstract class SnapshotFragment : Fragment(), SnapshotClickListener {
val adapter = SnapshotAdapter(this)
list.adapter = adapter
viewModel.snapshots.observe(viewLifecycleOwner, {
viewModel.snapshots.observe(viewLifecycleOwner) {
progressBar.visibility = INVISIBLE
when (it) {
is SnapshotResult.Success -> {
@ -54,6 +55,7 @@ public abstract class SnapshotFragment : Fragment(), SnapshotClickListener {
emptyStateView.visibility = VISIBLE
} else adapter.submitList(it.snapshots)
}
is SnapshotResult.Error -> {
val color = resources.getColor(R.color.design_default_color_error, null)
emptyStateView.setTextColor(color)
@ -61,7 +63,7 @@ public abstract class SnapshotFragment : Fragment(), SnapshotClickListener {
emptyStateView.visibility = VISIBLE
}
}
})
}
return v
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2H6C4.9,2 4.01,2.9 4.01,4L4,20c0,1.1 0.89,2 1.99,2H18c1.1,0 2,-0.9 2,-2V8L14,2zM16,13h-3v3.75c0,1.24 -1.01,2.25 -2.25,2.25S8.5,17.99 8.5,16.75c0,-1.24 1.01,-2.25 2.25,-2.25c0.46,0 0.89,0.14 1.25,0.38V11h4V13zM13,9V3.5L18.5,9H13z" />
</vector>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M10,6L8.59,7.41 13.17,12l-4.58,4.59L10,18l6,-6z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M21,19V5c0,-1.1 -0.9,-2 -2,-2H5c-1.1,0 -2,0.9 -2,2v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2zM8.5,13.5l2.5,3.01L14.5,12l4.5,6H5l3.5,-4.5z" />
</vector>

View file

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="32dp"
android:height="32dp"
android:tint="?colorAccent"
android:viewportWidth="48"
android:viewportHeight="48">
<group
android:name="icon_null"
android:scaleX="0.2"
android:scaleY="0.2"
android:translateX="6"
android:translateY="6">
<group
android:name="check"
android:scaleX="7.5"
android:scaleY="7.5">
<path
android:name="check_path_merged"
android:fillColor="#FF000000"
android:pathData="M19,3H5C3.9,3 3,3.9 3,5v14c0,1.1 0.9,2 2,2h14c1.1,0 2,-0.9 2,-2V5C21,3.9 20.1,3 19,3zM17,13H7v-2h10V13z" />
</group>
</group>
</vector>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M6,2c-1.1,0 -1.99,0.9 -1.99,2L4,20c0,1.1 0.89,2 1.99,2L18,22c1.1,0 2,-0.9 2,-2L20,8l-6,-6L6,2zM13,9L13,3.5L18.5,9L13,9z" />
</vector>

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M7.41,8.59L12,13.17l4.59,-4.58L18,10l-6,6 -6,-6 1.41,-1.41z" />
</vector>

View file

@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: Material Design Authors / Google LLC
SPDX-License-Identifier: Apache-2.0
-->
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:tint="?android:attr/colorControlNormal"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="@android:color/white"
android:pathData="M14,2H6.01c-1.1,0 -2,0.89 -2,2L4,20c0,1.1 0.9,2 2,2h12c1.1,0 2,-0.9 2,-2V8L14,2zM13,9V3.5L18.5,9H13zM14,14l2,-1.06v4.12L14,16v1c0,0.55 -0.45,1 -1,1H9c-0.55,0 -1,-0.45 -1,-1v-4c0,-0.55 0.45,-1 1,-1h4c0.55,0 1,0.45 1,1V14z" />
</vector>

View file

@ -0,0 +1,58 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent">
<com.google.android.material.appbar.AppBarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.appbar.CollapsingToolbarLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_scrollFlags="scroll|enterAlwaysCollapsed">
<ViewStub
android:id="@+id/topStub"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:inflatedId="@+id/topStub"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:layout="@layout/item_custom"
tools:visibility="visible" />
</com.google.android.material.appbar.CollapsingToolbarLayout>
</com.google.android.material.appbar.AppBarLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/list"
android:layout_width="match_parent"
android:layout_height="match_parent"
app:layoutManager="androidx.recyclerview.widget.LinearLayoutManager"
app:layout_constraintBottom_toTopOf="@+id/bottomStub"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/topStub"
tools:listitem="@layout/item_file" />
<com.google.android.material.floatingactionbutton.ExtendedFloatingActionButton
android:id="@+id/fab"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="bottom|end"
android:layout_margin="16dp"
android:text="@string/select_files_button_restore"
android:textColor="#ffffff"
app:backgroundTint="?colorAccent"
app:icon="@drawable/ic_cloud_restore"
app:iconTint="#ffffff"
app:layout_behavior="com.google.android.material.appbar.AppBarLayout$ScrollingViewBehavior"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" />
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View file

@ -0,0 +1,54 @@
<?xml version="1.0" encoding="utf-8"?><!--
SPDX-FileCopyrightText: 2021 The Calyx Institute
SPDX-License-Identifier: Apache-2.0
-->
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:id="@+id/layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?selectableItemBackground"
android:paddingVertical="8dp">
<ImageView
android:id="@+id/expandView"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="center"
android:src="@drawable/ic_folder"
app:layout_constraintEnd_toStartOf="@+id/nameView"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<TextView
android:id="@+id/nameView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:textAppearance="?android:attr/textAppearanceListItem"
app:layout_constraintEnd_toStartOf="@+id/checkBox"
app:layout_constraintStart_toEndOf="@+id/expandView"
app:layout_constraintTop_toTopOf="parent"
tools:text="File/folder name which might be quite long, who knows...?" />
<TextView
android:id="@+id/infoView"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="@+id/nameView"
app:layout_constraintHorizontal_chainStyle="spread_inside"
app:layout_constraintStart_toStartOf="@+id/nameView"
app:layout_constraintTop_toBottomOf="@+id/nameView"
tools:text="24h ago - 23 MB" />
<androidx.appcompat.widget.AppCompatCheckBox
android:id="@+id/checkBox"
android:layout_width="48dp"
android:layout_height="48dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toEndOf="@+id/nameView"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>

View file

@ -22,4 +22,8 @@
<string name="snapshots_title">Available storage backups</string>
<string name="snapshots_empty">No storage backups found\n\nSorry, but there is nothing that can be restored.</string>
<string name="snapshots_error">Error loading snapshots</string>
<string name="select_files_title">Files to be restored</string>
<string name="select_files_number_of_files">%1$d file(s)</string>
<string name="select_files_button_restore">Restore checked files</string>
</resources>