feat(client/kotlin): add Favorite Resources menu (#6107)

It's missing a couple pieces, see the tasklist


![image](https://github.com/user-attachments/assets/370e13fc-c0cd-4444-9539-0c7d90f3ba05)

Refs #5123

```[tasklist]
- [x] Add `Add to Favorites` and `Remove from Favorites` buttons
- [x] Update Changelog
- [x] Load and save Favorites from `SharedPreferences`
- [x] Wire up `onClick` events
- [x] Hide and show Resources in the menu based on whether they're favorited
- [x] Hide tabs if nothing is favorited
- [x] Tab icons
- [ ] Make the "Reset Settings" button also reset Favorites
- [ ] Change the "Add to Favorites" and "Remove from Favorites" to a checkbox or star or something cool
```
This commit is contained in:
Reactor Scram
2024-08-06 17:17:09 -05:00
committed by GitHub
parent 22c7414cd1
commit 861ca1861d
11 changed files with 209 additions and 6 deletions

View File

@@ -61,6 +61,10 @@ internal class Repository
fun getDeviceIdSync(): String? = sharedPreferences.getString(DEVICE_ID_KEY, null)
fun getFavoritesSync(): HashSet<String> = HashSet(sharedPreferences.getStringSet(FAVORITE_RESOURCES_KEY, null).orEmpty())
fun saveFavoritesSync(value: HashSet<String>) = sharedPreferences.edit().putStringSet(FAVORITE_RESOURCES_KEY, value).apply()
fun getToken(): Flow<String?> =
flow {
emit(sharedPreferences.getString(TOKEN_KEY, null))
@@ -161,6 +165,7 @@ internal class Repository
private const val AUTH_BASE_URL_KEY = "authBaseUrl"
private const val ACTOR_NAME_KEY = "actorName"
private const val API_URL_KEY = "apiUrl"
private const val FAVORITE_RESOURCES_KEY = "favoriteResources"
private const val LOG_FILTER_KEY = "logFilter"
private const val TOKEN_KEY = "token"
private const val NONCE_KEY = "nonce"

View File

@@ -17,12 +17,18 @@ import android.widget.LinearLayout
import android.widget.TextView
import android.widget.Toast
import androidx.browser.customtabs.CustomTabsIntent
import androidx.fragment.app.activityViewModels
import com.google.android.material.bottomsheet.BottomSheetDialogFragment
import com.google.android.material.button.MaterialButton
import dev.firezone.android.R
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.model.StatusEnum
class ResourceDetailsBottomSheet(private val resource: Resource) : BottomSheetDialogFragment() {
private lateinit var view: View
private val viewModel: SessionViewModel by activityViewModels()
private var isFavorite: Boolean = false
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
@@ -35,8 +41,11 @@ class ResourceDetailsBottomSheet(private val resource: Resource) : BottomSheetDi
view: View,
savedInstanceState: Bundle?,
) {
this.view = view
super.onViewCreated(view, savedInstanceState)
val addToFavoritesBtn: MaterialButton = view.findViewById(R.id.addToFavoritesBtn)
val removeFromFavoritesBtn: MaterialButton = view.findViewById(R.id.removeFromFavoritesBtn)
val resourceNameTextView: TextView = view.findViewById(R.id.tvResourceName)
val resourceAddressTextView: TextView = view.findViewById(R.id.tvResourceAddress)
val resourceAddressDescriptionTextView: TextView = view.findViewById(R.id.tvResourceAddressDescription)
@@ -47,6 +56,16 @@ class ResourceDetailsBottomSheet(private val resource: Resource) : BottomSheetDi
val siteNameLayout: LinearLayout = view.findViewById(R.id.siteNameLayout)
val siteStatusLayout: LinearLayout = view.findViewById(R.id.siteStatusLayout)
addToFavoritesBtn.setOnClickListener {
viewModel.addFavoriteResource(resource.id)
refreshButtons()
}
removeFromFavoritesBtn.setOnClickListener {
viewModel.removeFavoriteResource(resource.id)
refreshButtons()
}
refreshButtons()
resourceNameTextView.text = resource.name
val displayAddress = resource.addressDescription ?: resource.address
resourceAddressTextView.text = displayAddress
@@ -111,6 +130,14 @@ class ResourceDetailsBottomSheet(private val resource: Resource) : BottomSheetDi
}
}
private fun refreshButtons() {
val addToFavoritesBtn: MaterialButton = view.findViewById(R.id.addToFavoritesBtn)
val removeFromFavoritesBtn: MaterialButton = view.findViewById(R.id.removeFromFavoritesBtn)
val isFavorite = viewModel.favoriteResourcesLiveData.value!!.contains(resource.id)
addToFavoritesBtn.visibility = if (isFavorite) View.GONE else View.VISIBLE
removeFromFavoritesBtn.visibility = if (isFavorite) View.VISIBLE else View.GONE
}
private fun copyToClipboard(text: String) {
val clipboard = requireContext().getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clip = ClipData.newPlainText("Copied Text", text)

View File

@@ -10,7 +10,9 @@ import androidx.recyclerview.widget.RecyclerView
import dev.firezone.android.databinding.ListItemResourceBinding
import dev.firezone.android.tunnel.model.Resource
internal class ResourcesAdapter : ListAdapter<Resource, ResourcesAdapter.ViewHolder>(ResourceDiffCallback()) {
internal class ResourcesAdapter() : ListAdapter<Resource, ResourcesAdapter.ViewHolder>(ResourceDiffCallback()) {
private var favoriteResources: HashSet<String> = HashSet()
override fun onCreateViewHolder(
parent: ViewGroup,
viewType: Int,
@@ -27,6 +29,7 @@ internal class ResourcesAdapter : ListAdapter<Resource, ResourcesAdapter.ViewHol
holder.bind(resource)
holder.itemView.setOnClickListener {
// Show bottom sheet
val isFavorite = favoriteResources.contains(resource.id)
val fragmentManager = (holder.itemView.context as AppCompatActivity).supportFragmentManager
val bottomSheet = ResourceDetailsBottomSheet(resource)
bottomSheet.show(fragmentManager, "ResourceDetailsBottomSheet")

View File

@@ -7,10 +7,12 @@ import android.content.Intent
import android.content.ServiceConnection
import android.os.Bundle
import android.os.IBinder
import android.view.View
import androidx.activity.viewModels
import androidx.appcompat.app.AppCompatActivity
import androidx.recyclerview.widget.DividerItemDecoration
import androidx.recyclerview.widget.LinearLayoutManager
import com.google.android.material.tabs.TabLayout
import dagger.hilt.android.AndroidEntryPoint
import dev.firezone.android.databinding.ActivitySessionBinding
import dev.firezone.android.features.settings.ui.SettingsActivity
@@ -88,6 +90,19 @@ internal class SessionActivity : AppCompatActivity() {
binding.rvResourcesList.addItemDecoration(dividerItemDecoration)
binding.rvResourcesList.adapter = resourcesAdapter
binding.rvResourcesList.layoutManager = layoutManager
binding.tabLayout.addOnTabSelectedListener(
object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
viewModel.tabSelected(tab.position)
refreshList()
}
override fun onTabUnselected(tab: TabLayout.Tab?) {}
override fun onTabReselected(tab: TabLayout.Tab) {}
},
)
}
private fun setupObservers() {
@@ -98,9 +113,28 @@ internal class SessionActivity : AppCompatActivity() {
}
}
viewModel.resourcesLiveData.observe(this) { resources ->
resourcesAdapter.submitList(resources)
viewModel.resourcesLiveData.observe(this) { value ->
refreshList()
}
viewModel.favoriteResourcesLiveData.observe(this) {
refreshList()
}
viewModel.tabSelected(binding.tabLayout.selectedTabPosition)
viewModel.favoriteResourcesLiveData.value = viewModel.repo.getFavoritesSync()
}
private fun refreshList() {
if (viewModel.forceAllResourcesTab()) {
binding.tabLayout.selectTab(binding.tabLayout.getTabAt(SessionViewModel.RESOURCES_TAB_ALL), true)
}
binding.tabLayout.visibility =
if (viewModel.showFavoritesTab()) {
View.VISIBLE
} else {
View.GONE
}
resourcesAdapter.submitList(viewModel.resourcesList())
}
companion object {

View File

@@ -15,17 +15,84 @@ internal class SessionViewModel
constructor() : ViewModel() {
@Inject
internal lateinit var repo: Repository
private val _favoriteResourcesLiveData = MutableLiveData<HashSet<String>>(HashSet())
private val _serviceStatusLiveData = MutableLiveData<State>()
private val _resourcesLiveData = MutableLiveData<List<Resource>>(emptyList())
private var showOnlyFavorites: Boolean = false
val favoriteResourcesLiveData: MutableLiveData<HashSet<String>>
get() = _favoriteResourcesLiveData
val serviceStatusLiveData: MutableLiveData<State>
get() = _serviceStatusLiveData
val resourcesLiveData: MutableLiveData<List<Resource>>
get() = _resourcesLiveData
fun clearToken() = repo.clearToken()
private val favoriteResources: HashSet<String>
get() = favoriteResourcesLiveData.value!!
// Actor name
fun clearActorName() = repo.clearActorName()
fun getActorName() = repo.getActorNameSync()
fun addFavoriteResource(id: String) {
val value = favoriteResources
value.add(id)
repo.saveFavoritesSync(value)
// Update LiveData
_favoriteResourcesLiveData.value = value
}
fun removeFavoriteResource(id: String) {
val value = favoriteResources
value.remove(id)
repo.saveFavoritesSync(value)
if (forceAllResourcesTab()) {
showOnlyFavorites = false
}
// Update LiveData
_favoriteResourcesLiveData.value = value
}
fun clearToken() = repo.clearToken()
// The subset of Resources to actually render
fun resourcesList(): List<Resource> {
val resources = resourcesLiveData.value!!
return if (favoriteResources.isEmpty()) {
resources
} else if (showOnlyFavorites) {
resources.filter { favoriteResources.contains(it.id) }
} else {
resources
}
}
fun forceAllResourcesTab(): Boolean {
return favoriteResources.isEmpty()
}
fun showFavoritesTab(): Boolean {
return favoriteResources.isNotEmpty()
}
fun tabSelected(position: Int) {
showOnlyFavorites =
when (position) {
RESOURCES_TAB_FAVORITES -> {
true
}
RESOURCES_TAB_ALL -> {
false
}
else -> throw IllegalArgumentException("Invalid tab position: $position")
}
}
companion object {
const val RESOURCES_TAB_FAVORITES = 0
const val RESOURCES_TAB_ALL = 1
}
}

View File

@@ -0,0 +1,10 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="6.35"
android:viewportHeight="6.35"
android:tint="#000000">
<path
android:pathData="M1.5875,0.7937C1.1478,0.7937 0.7937,1.1478 0.7937,1.5875L0.7937,2.9104L2.9104,2.9104L2.9104,0.7937L1.5875,0.7937zM3.4396,0.7937L3.4396,2.9104L5.5563,2.9104L5.5563,1.5875C5.5563,1.1478 5.2022,0.7937 4.7625,0.7937L3.4396,0.7937zM0.7937,3.4396L0.7937,4.7625C0.7937,5.2022 1.1478,5.5563 1.5875,5.5563L2.9104,5.5563L2.9104,3.4396L0.7937,3.4396zM3.4396,3.4396L3.4396,5.5563L4.7625,5.5563C5.2022,5.5563 5.5563,5.2022 5.5563,4.7625L5.5563,3.4396L3.4396,3.4396z"
android:fillColor="@android:color/white"/>
</vector>

View File

@@ -0,0 +1,5 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:height="24dp" android:tint="#000000" android:viewportHeight="24" android:viewportWidth="24" android:width="24dp">
<path android:fillColor="@android:color/white" android:pathData="M12,17.27L18.18,21l-1.64,-7.03L22,9.24l-7.19,-0.61L12,2 9.19,8.63 2,9.24l5.46,4.73L5.82,21z"/>
</vector>

View File

@@ -51,6 +51,30 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/llContainer" />
<com.google.android.material.tabs.TabLayout
android:id="@+id/tabLayout"
android:layout_width="match_parent"
android:layout_height="0dp"
app:layout_constraintTop_toBottomOf="@+id/tvResourcesList"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintHorizontal_bias="1.0"
app:layout_constraintStart_toStartOf="parent"
app:tabInlineLabel="true">
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:icon="@drawable/baseline_star_24"
android:text="@string/resources_favorites" />
<com.google.android.material.tabs.TabItem
android:layout_width="wrap_content"
android:layout_height="match_parent"
android:icon="@drawable/all_resources"
android:text="@string/resources_all" />
</com.google.android.material.tabs.TabLayout>
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/rvResourcesList"
android:layout_width="match_parent"
@@ -58,7 +82,7 @@
android:layout_marginTop="@dimen/spacing_2x"
android:layout_marginBottom="@dimen/spacing_2x"
app:layout_constraintBottom_toTopOf="@id/btSignOut"
app:layout_constraintTop_toBottomOf="@id/tvResourcesList"
app:layout_constraintTop_toBottomOf="@id/tabLayout"
tools:layout_editor_absoluteX="16dp" />
<com.google.android.material.button.MaterialButton

View File

@@ -68,6 +68,20 @@
android:layout_weight="1" />
</LinearLayout>
<com.google.android.material.button.MaterialButton
android:id="@+id/addToFavoritesBtn"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Add to Favorites"/>
<com.google.android.material.button.MaterialButton
android:id="@+id/removeFromFavoritesBtn"
style="?attr/materialButtonOutlinedStyle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="Remove from Favorites"/>
<!-- Site Section -->
<TextView
android:id="@+id/labelSite"

View File

@@ -1,6 +1,10 @@
<resources>
<string name="app_short_name">Firezone</string>
<!-- Resources -->
<string name="resources_favorites">Favorites</string>
<string name="resources_all">All</string>
<!-- Settings -->
<string name="settings_title">Settings</string>
<string name="save">Save</string>

View File

@@ -1,6 +1,7 @@
import Link from "next/link";
import ChangeItem from "./ChangeItem";
import Entries from "./Entries";
import Entry from "./Entry";
import Link from "next/link";
export default function Android() {
return (
@@ -8,6 +9,15 @@ export default function Android() {
href="https://play.google.com/store/apps/details?id=dev.firezone.android"
title="Android"
>
{/*
<Entry version="1.1.5" date={new Date("2024-07-31")}>
<ul className="list-disc space-y-2 pl-4 mb-4">
<ChangeItem pull="6107">
Adds the ability to mark Resources as favorites.
</ChangeItem>
</ul>
</Entry>
*/}
<Entry version="1.1.4" date={new Date("2024-08-02")}>
<ul className="list-disc space-y-2 pl-4 mb-4">
<li className="pl-2">