From 861ca1861d03281265c03d5c2ef891cee35146fb Mon Sep 17 00:00:00 2001 From: Reactor Scram Date: Tue, 6 Aug 2024 17:17:09 -0500 Subject: [PATCH] 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 ``` --- .../firezone/android/core/data/Repository.kt | 5 ++ .../session/ui/ResourceDetailsBottomSheet.kt | 27 ++++++++ .../features/session/ui/ResourcesAdapter.kt | 5 +- .../features/session/ui/SessionActivity.kt | 38 +++++++++- .../features/session/ui/SessionViewModel.kt | 69 ++++++++++++++++++- .../src/main/res/drawable/all_resources.xml | 10 +++ .../main/res/drawable/baseline_star_24.xml | 5 ++ .../src/main/res/layout/activity_session.xml | 26 ++++++- .../res/layout/fragment_resource_details.xml | 14 ++++ .../app/src/main/res/values/strings.xml | 4 ++ website/src/components/Changelog/Android.tsx | 12 +++- 11 files changed, 209 insertions(+), 6 deletions(-) create mode 100644 kotlin/android/app/src/main/res/drawable/all_resources.xml create mode 100644 kotlin/android/app/src/main/res/drawable/baseline_star_24.xml diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt index c32768929..07e8cf02d 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/core/data/Repository.kt @@ -61,6 +61,10 @@ internal class Repository fun getDeviceIdSync(): String? = sharedPreferences.getString(DEVICE_ID_KEY, null) + fun getFavoritesSync(): HashSet = HashSet(sharedPreferences.getStringSet(FAVORITE_RESOURCES_KEY, null).orEmpty()) + + fun saveFavoritesSync(value: HashSet) = sharedPreferences.edit().putStringSet(FAVORITE_RESOURCES_KEY, value).apply() + fun getToken(): Flow = 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" diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt index bb8d43e45..d1620d42a 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourceDetailsBottomSheet.kt @@ -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) diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt index a6906cd9f..025b4e23e 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/ResourcesAdapter.kt @@ -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(ResourceDiffCallback()) { +internal class ResourcesAdapter() : ListAdapter(ResourceDiffCallback()) { + private var favoriteResources: HashSet = HashSet() + override fun onCreateViewHolder( parent: ViewGroup, viewType: Int, @@ -27,6 +29,7 @@ internal class ResourcesAdapter : ListAdapter - 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 { diff --git a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt index aa55faf36..85f146f9f 100644 --- a/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt +++ b/kotlin/android/app/src/main/java/dev/firezone/android/features/session/ui/SessionViewModel.kt @@ -15,17 +15,84 @@ internal class SessionViewModel constructor() : ViewModel() { @Inject internal lateinit var repo: Repository + private val _favoriteResourcesLiveData = MutableLiveData>(HashSet()) private val _serviceStatusLiveData = MutableLiveData() private val _resourcesLiveData = MutableLiveData>(emptyList()) + private var showOnlyFavorites: Boolean = false + val favoriteResourcesLiveData: MutableLiveData> + get() = _favoriteResourcesLiveData val serviceStatusLiveData: MutableLiveData get() = _serviceStatusLiveData val resourcesLiveData: MutableLiveData> get() = _resourcesLiveData - fun clearToken() = repo.clearToken() + private val favoriteResources: HashSet + 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 { + 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 + } } diff --git a/kotlin/android/app/src/main/res/drawable/all_resources.xml b/kotlin/android/app/src/main/res/drawable/all_resources.xml new file mode 100644 index 000000000..ec0e5a9bc --- /dev/null +++ b/kotlin/android/app/src/main/res/drawable/all_resources.xml @@ -0,0 +1,10 @@ + + + diff --git a/kotlin/android/app/src/main/res/drawable/baseline_star_24.xml b/kotlin/android/app/src/main/res/drawable/baseline_star_24.xml new file mode 100644 index 000000000..333cefebd --- /dev/null +++ b/kotlin/android/app/src/main/res/drawable/baseline_star_24.xml @@ -0,0 +1,5 @@ + + + + + diff --git a/kotlin/android/app/src/main/res/layout/activity_session.xml b/kotlin/android/app/src/main/res/layout/activity_session.xml index a5b3500c5..35c9d4c0e 100644 --- a/kotlin/android/app/src/main/res/layout/activity_session.xml +++ b/kotlin/android/app/src/main/res/layout/activity_session.xml @@ -51,6 +51,30 @@ app:layout_constraintStart_toStartOf="parent" app:layout_constraintTop_toBottomOf="@+id/llContainer" /> + + + + + + + + + + + + Firezone + + Favorites + All + Settings Save diff --git a/website/src/components/Changelog/Android.tsx b/website/src/components/Changelog/Android.tsx index e9b021106..69f555cbc 100644 --- a/website/src/components/Changelog/Android.tsx +++ b/website/src/components/Changelog/Android.tsx @@ -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" > + {/* + +
    + + Adds the ability to mark Resources as favorites. + +
+
+ */}