mirror of
https://github.com/outbackdingo/firezone.git
synced 2026-01-27 10:18:54 +00:00
feat(client/kotlin): add Favorite Resources menu (#6107)
It's missing a couple pieces, see the tasklist  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:
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
10
kotlin/android/app/src/main/res/drawable/all_resources.xml
Normal file
10
kotlin/android/app/src/main/res/drawable/all_resources.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user