Skip to content

Commit e49eae1

Browse files
authored
Merge pull request getodk#5971 from seadowg/saved-form-select
Retain selection when rotating delete saved forms
2 parents 6c6c19d + 2d3a939 commit e49eae1

149 files changed

Lines changed: 2253 additions & 1888 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

androidshared/build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ dependencies {
5959
implementation(Dependencies.play_services_location)
6060

6161
testImplementation(project(":test-shared"))
62+
testImplementation(project(":androidtest"))
6263
testImplementation(Dependencies.junit)
6364
testImplementation(Dependencies.androidx_test_ext_junit)
6465
testImplementation(Dependencies.androidx_test_espresso_core)

androidshared/src/main/java/org/odk/collect/androidshared/data/AppState.kt

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ import androidx.fragment.app.Fragment
88
import androidx.lifecycle.LiveData
99
import androidx.lifecycle.MutableLiveData
1010
import androidx.lifecycle.ViewModel
11+
import kotlinx.coroutines.flow.Flow
12+
import kotlinx.coroutines.flow.MutableStateFlow
1113

1214
/**
1315
* [AppState] can be used as a shared store of state that lives at an "app"/"in-memory" level
@@ -50,6 +52,10 @@ class AppState {
5052
return get(key, MutableLiveData(default))
5153
}
5254

55+
fun <T> getFlow(key: String, default: T): Flow<T> {
56+
return get(key, MutableStateFlow(default))
57+
}
58+
5359
fun set(key: String, value: Any?) {
5460
map[key] = value
5561
}
@@ -58,6 +64,16 @@ class AppState {
5864
get(key, MutableLiveData<T>()).postValue(value)
5965
}
6066

67+
fun <T> setFlow(key: String, value: T) {
68+
get<MutableStateFlow<T>>(key).let {
69+
if (it != null) {
70+
it.value = value
71+
} else {
72+
map[key] = MutableStateFlow(value)
73+
}
74+
}
75+
}
76+
6177
fun clear() {
6278
map.clear()
6379
}

androidshared/src/main/java/org/odk/collect/androidshared/livedata/LiveDataUtils.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import java.util.function.Consumer;
1515
import java.util.function.Function;
1616

17+
import kotlin.Pair;
1718
import kotlin.Triple;
1819

1920
public class LiveDataUtils {
@@ -54,6 +55,13 @@ public static <T> LiveData<T> liveDataOf(T value) {
5455
return new MutableLiveData<>(value);
5556
}
5657

58+
public static <T, U> LiveData<Pair<T, U>> zip(LiveData<T> one, LiveData<U> two) {
59+
return new ZippedLiveData<>(
60+
new LiveData[]{one, two},
61+
values -> new Pair<>((T) values[0], (U) values[1])
62+
);
63+
}
64+
5765
public static <T, U, V> LiveData<Triple<T, U, V>> zip3(LiveData<T> one, LiveData<U> two, LiveData<V> three) {
5866
return new ZippedLiveData<>(
5967
new LiveData[]{one, two, three},

collect_app/src/main/java/org/odk/collect/android/views/EmptyListView.kt renamed to androidshared/src/main/java/org/odk/collect/androidshared/ui/EmptyListView.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
1-
package org.odk.collect.android.views
1+
package org.odk.collect.androidshared.ui
22

33
import android.content.Context
44
import android.util.AttributeSet
55
import android.view.LayoutInflater
66
import android.widget.FrameLayout
77
import androidx.annotation.DrawableRes
8-
import org.odk.collect.android.R
9-
import org.odk.collect.android.databinding.EmptyListViewBinding
8+
import org.odk.collect.androidshared.R
9+
import org.odk.collect.androidshared.databinding.EmptyListViewBinding
1010

1111
class EmptyListView(context: Context, attrs: AttributeSet?) : FrameLayout(context, attrs) {
1212
constructor(context: Context) : this(context, null)
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
package org.odk.collect.androidshared.ui
2+
3+
import androidx.fragment.app.Fragment
4+
import androidx.fragment.app.FragmentActivity
5+
import androidx.viewpager2.adapter.FragmentStateAdapter
6+
7+
class ListFragmentStateAdapter(
8+
activity: FragmentActivity,
9+
private val fragments: List<Class<out Fragment>>
10+
) : FragmentStateAdapter(activity) {
11+
12+
private val fragmentFactory = activity.supportFragmentManager.fragmentFactory
13+
14+
override fun createFragment(position: Int): Fragment {
15+
return fragmentFactory.instantiate(
16+
Thread.currentThread().contextClassLoader,
17+
fragments[position].name
18+
)
19+
}
20+
21+
override fun getItemCount(): Int {
22+
return fragments.size
23+
}
24+
}

androidshared/src/main/java/org/odk/collect/androidshared/ui/MultiSelectViewModel.kt

Lines changed: 0 additions & 47 deletions
This file was deleted.
Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,17 @@
1+
package org.odk.collect.androidshared.ui
2+
3+
import android.content.Context
4+
import androidx.core.content.ContextCompat
5+
import androidx.recyclerview.widget.DividerItemDecoration
6+
import org.odk.collect.androidshared.R
7+
8+
object RecyclerViewUtils {
9+
10+
fun verticalLineDivider(context: Context): DividerItemDecoration {
11+
val itemDecoration = DividerItemDecoration(context, DividerItemDecoration.VERTICAL)
12+
val drawable = ContextCompat.getDrawable(context, R.drawable.list_item_divider)!!
13+
itemDecoration.setDrawable(drawable)
14+
15+
return itemDecoration
16+
}
17+
}

androidshared/src/main/java/org/odk/collect/androidshared/ui/SnackbarUtils.kt

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -133,12 +133,12 @@ object SnackbarUtils {
133133
data class Action(val text: String, val listener: () -> Unit)
134134

135135
abstract class SnackbarPresenterObserver<T : Any?>(private val parentView: View) :
136-
Observer<Consumable<T>> {
136+
Observer<Consumable<T>?> {
137137

138138
abstract fun getSnackbarDetails(value: T): SnackbarDetails
139139

140-
override fun onChanged(consumable: Consumable<T>) {
141-
if (!consumable.isConsumed()) {
140+
override fun onChanged(consumable: Consumable<T>?) {
141+
if (consumable != null && !consumable.isConsumed()) {
142142
showLongSnackbar(parentView, getSnackbarDetails(consumable.value))
143143
consumable.consume()
144144
}
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
package org.odk.collect.androidshared.ui.multiselect
2+
3+
import android.view.View
4+
import android.view.ViewGroup
5+
import android.widget.CheckBox
6+
import androidx.recyclerview.widget.RecyclerView
7+
8+
/**
9+
* An adapter for creating multi select lists with `MultiSelectViewModel` and `RecyclerView`.
10+
*/
11+
class MultiSelectAdapter<T, VH : MultiSelectAdapter.ViewHolder<T>>(
12+
private val multiSelectViewModel: MultiSelectViewModel<*>,
13+
private val viewHolderFactory: (ViewGroup) -> VH
14+
) : RecyclerView.Adapter<VH>() {
15+
16+
var selected: Set<Long> = emptySet()
17+
set(value) {
18+
field = value
19+
notifyDataSetChanged()
20+
}
21+
22+
var data = emptyList<MultiSelectItem<T>>()
23+
set(value) {
24+
field = value.toList()
25+
notifyDataSetChanged()
26+
}
27+
28+
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): VH {
29+
return viewHolderFactory(parent)
30+
}
31+
32+
override fun onBindViewHolder(holder: VH, position: Int) {
33+
val item = data[position]
34+
holder.setItem(item.item)
35+
36+
val checkbox = holder.getCheckbox().also {
37+
it.isChecked = selected.contains(item.id)
38+
it.setOnClickListener {
39+
multiSelectViewModel.toggle(item.id)
40+
}
41+
}
42+
43+
holder.getSelectArea().setOnClickListener {
44+
checkbox.performClick()
45+
}
46+
}
47+
48+
override fun getItemCount(): Int {
49+
return data.size
50+
}
51+
52+
abstract class ViewHolder<T>(view: View) : RecyclerView.ViewHolder(view) {
53+
abstract fun setItem(item: T)
54+
abstract fun getCheckbox(): CheckBox
55+
open fun getSelectArea(): View {
56+
return itemView
57+
}
58+
}
59+
}
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package org.odk.collect.androidshared.ui.multiselect
2+
3+
import android.content.Context
4+
import android.os.Bundle
5+
import android.view.LayoutInflater
6+
import android.view.View
7+
import android.view.ViewGroup
8+
import android.widget.FrameLayout
9+
import androidx.fragment.app.Fragment
10+
import org.odk.collect.androidshared.databinding.MultiSelectControlsLayoutBinding
11+
12+
/**
13+
* A control UI for performing "select all" and "clear all" on multi select lists using
14+
* `MultiSelectViewModel`. Also supports an action that's text can be defined (via the
15+
* constructor) and can be reacted to by responding to the `"action"` Fragment result.
16+
*/
17+
class MultiSelectControlsFragment(
18+
private val actionText: String,
19+
private val multiSelectViewModel: MultiSelectViewModel<*>
20+
) : Fragment() {
21+
22+
override fun onCreateView(
23+
inflater: LayoutInflater,
24+
container: ViewGroup?,
25+
savedInstanceState: Bundle?
26+
): View {
27+
return MultiSelectControlsView(requireContext())
28+
}
29+
30+
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
31+
val controls = view as MultiSelectControlsView
32+
33+
controls.actionText = actionText
34+
35+
multiSelectViewModel.getSelected().observe(viewLifecycleOwner) {
36+
controls.selected = it
37+
}
38+
39+
multiSelectViewModel.isAllSelected().observe(viewLifecycleOwner) {
40+
controls.isAllSelected = it
41+
}
42+
43+
controls.listener = object : MultiSelectControlsView.Listener {
44+
override fun onSelectAll() {
45+
multiSelectViewModel.selectAll()
46+
}
47+
48+
override fun onClearAll() {
49+
multiSelectViewModel.unselectAll()
50+
}
51+
52+
override fun onAction(selected: Set<Long>) {
53+
parentFragmentManager.setFragmentResult(
54+
REQUEST_ACTION,
55+
Bundle().apply {
56+
putLongArray(
57+
RESULT_SELECTED,
58+
selected.toLongArray()
59+
)
60+
}
61+
)
62+
}
63+
}
64+
}
65+
66+
companion object {
67+
const val REQUEST_ACTION = "action"
68+
const val RESULT_SELECTED = "selected"
69+
}
70+
}
71+
72+
private class MultiSelectControlsView(context: Context) :
73+
FrameLayout(context) {
74+
75+
var selected = emptySet<Long>()
76+
set(value) {
77+
field = value
78+
render()
79+
}
80+
81+
var isAllSelected = false
82+
set(value) {
83+
field = value
84+
render()
85+
}
86+
87+
var listener: Listener? = null
88+
var actionText: String = ""
89+
set(value) {
90+
field = value
91+
render()
92+
}
93+
94+
private val binding =
95+
MultiSelectControlsLayoutBinding.inflate(LayoutInflater.from(context), this, true)
96+
97+
init {
98+
render()
99+
}
100+
101+
private fun render() {
102+
if (isAllSelected) {
103+
binding.selectAll.setText(org.odk.collect.strings.R.string.clear_all)
104+
binding.selectAll.setOnClickListener {
105+
listener?.onClearAll()
106+
}
107+
} else {
108+
binding.selectAll.setText(org.odk.collect.strings.R.string.select_all)
109+
binding.selectAll.setOnClickListener {
110+
listener?.onSelectAll()
111+
}
112+
}
113+
114+
binding.action.text = actionText
115+
binding.action.isEnabled = selected.isNotEmpty()
116+
binding.action.setOnClickListener {
117+
listener?.onAction(selected)
118+
}
119+
}
120+
121+
interface Listener {
122+
fun onSelectAll()
123+
fun onClearAll()
124+
fun onAction(selected: Set<Long>)
125+
}
126+
}

0 commit comments

Comments
 (0)