feat: Internet Resource UI (#6434)

Fixes #6047

On mobile platforms the internet resource is rendered with all
non-favorite resources, since it was weird to see within the favorite
tab, for the system tray platforms it's rendered as part of favorites if
there is any favorite so that it's always visible to the user.

For mobile platforms the resource is non-clickeable, since the menu
shouldn't be of interest(maybe I should add it only for the sites?).

For non-mobile there is a sub menu where you can find the sites and the
enable/disable.

The current label for the resource is a place holder for the
screenshots, and can be set by the portal, if the portal doesn't set any
name it will just show "Internet Resource".

### Android screenshot


![image](https://github.com/user-attachments/assets/63deb25f-1cd1-4b49-be80-77570e612aa5)


### Linux Screenshot


![image](https://github.com/user-attachments/assets/7b67033d-71ee-4bac-98c8-4c5810bf43a3)


![image](https://github.com/user-attachments/assets/5bdbced5-bacd-4a09-a59c-aa853bb3baa0)

### Windows Screenshot


![image](https://github.com/user-attachments/assets/a3bbebb3-9a18-4b75-9e18-f58b1b61a7a3)

### MacOS screenshot

<img width="417" alt="image"
src="https://github.com/user-attachments/assets/5488d6e4-1cd2-42be-bcd7-3c51ec295590">

### iOS screenshot


![17044](https://github.com/user-attachments/assets/5321c363-5b43-4b1e-ac37-4fd7bdc68e28)
This commit is contained in:
Gabi
2024-08-27 01:08:19 -03:00
committed by GitHub
parent c1bcce1898
commit 63c73e5bb6
15 changed files with 218 additions and 165 deletions

View File

@@ -83,7 +83,7 @@ class ResourceDetailsBottomSheet(private val resource: ViewResource) : BottomShe
}
} else {
resourceAddressTextView.setOnClickListener {
copyToClipboard(displayAddress)
copyToClipboard(displayAddress!!)
Toast.makeText(requireContext(), "Address copied to clipboard", Toast.LENGTH_SHORT).show()
}
}

View File

@@ -2,6 +2,7 @@
package dev.firezone.android.features.session.ui
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.appcompat.app.AppCompatActivity
import androidx.core.view.isVisible
@@ -29,12 +30,15 @@ internal class ResourcesAdapter(private val activity: SessionActivity) : ListAda
) {
val resource = getItem(position)
holder.bind(resource) { newResource -> onSwitchToggled(newResource) }
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")
if (!resource.isInternetResource()) {
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")
}
}
}
@@ -48,7 +52,11 @@ internal class ResourcesAdapter(private val activity: SessionActivity) : ListAda
onSwitchToggled: (ViewResource) -> Unit,
) {
binding.resourceNameText.text = resource.name
binding.addressText.text = resource.address
if (resource.isInternetResource()) {
binding.addressText.visibility = View.GONE
} else {
binding.addressText.text = resource.address
}
// Without this the item gets reset when out of view, isn't android wonderful?
binding.enableSwitch.setOnCheckedChangeListener(null)
binding.enableSwitch.isChecked = resource.enabled

View File

@@ -101,7 +101,11 @@ internal class SessionActivity : AppCompatActivity() {
object : TabLayout.OnTabSelectedListener {
override fun onTabSelected(tab: TabLayout.Tab) {
viewModel.tabSelected(tab.position)
refreshList()
refreshList {
// TODO: we might want to remember the old position?
binding.rvResourcesList.scrollToPosition(0)
}
}
override fun onTabUnselected(tab: TabLayout.Tab?) {}
@@ -130,7 +134,7 @@ internal class SessionActivity : AppCompatActivity() {
viewModel.favoriteResourcesLiveData.value = viewModel.repo.getFavoritesSync()
}
private fun refreshList() {
private fun refreshList(afterLoad: () -> Unit = {}) {
if (viewModel.forceAllResourcesTab()) {
binding.tabLayout.selectTab(binding.tabLayout.getTabAt(SessionViewModel.RESOURCES_TAB_ALL), true)
}
@@ -141,7 +145,9 @@ internal class SessionActivity : AppCompatActivity() {
View.GONE
}
resourcesAdapter.submitList(viewModel.resourcesList())
resourcesAdapter.submitList(viewModel.resourcesList()) {
afterLoad()
}
}
companion object {

View File

@@ -2,12 +2,14 @@
package dev.firezone.android.features.session.ui
import dev.firezone.android.tunnel.model.Resource
import dev.firezone.android.tunnel.model.ResourceType
import dev.firezone.android.tunnel.model.Site
import dev.firezone.android.tunnel.model.StatusEnum
data class ViewResource(
val id: String,
val address: String,
val type: ResourceType,
val address: String?,
val addressDescription: String?,
val sites: List<Site>?,
val name: String,
@@ -19,6 +21,7 @@ data class ViewResource(
fun Resource.toViewResource(enabled: Boolean): ViewResource {
return ViewResource(
id = this.id,
type = this.type,
address = this.address,
addressDescription = this.addressDescription,
sites = this.sites,
@@ -28,3 +31,7 @@ fun Resource.toViewResource(enabled: Boolean): ViewResource {
canBeDisabled = this.canBeDisabled,
)
}
fun ViewResource.isInternetResource(): Boolean {
return this.type == ResourceType.Internet
}

View File

@@ -9,9 +9,9 @@ import kotlinx.parcelize.Parcelize
@JsonClass(generateAdapter = true)
@Parcelize
data class Resource(
val type: TypeEnum,
val type: ResourceType,
val id: String,
val address: String,
val address: String?,
@Json(name = "address_description") val addressDescription: String?,
val sites: List<Site>?,
val name: String,
@@ -20,7 +20,7 @@ data class Resource(
@Json(name = "can_be_disabled") val canBeDisabled: Boolean,
) : Parcelable
enum class TypeEnum {
enum class ResourceType {
@Json(name = "dns")
DNS,

View File

@@ -12,7 +12,7 @@
style="@style/AppTheme.Base.Body1"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:layout_constraintEnd_toStartOf="@+id/enable_switch"
app:layout_constraintEnd_toStartOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent"
tools:text="Resource Name" />
@@ -24,24 +24,17 @@
android:layout_height="wrap_content"
android:textColor="@color/neutral_600"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toStartOf="@+id/enable_switch"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/resourceNameText"
app:layout_constraintVertical_bias="0.0"
tools:text="Resource Address" />
<androidx.appcompat.widget.SwitchCompat
android:id="@+id/enable_switch"
android:layout_width="101dp"
android:layout_height="39dp"
android:layout_height="wrap_content"
android:layout_width="wrap_content"
android:checked="true"
android:minHeight="48dp"
android:scaleX="1"
android:scaleY="1"
android:text="Enabled"
android:textAlignment="viewStart"
android:textSize="14sp"
app:layout_constraintBottom_toBottomOf="parent"
android:gravity="center_vertical"
app:layout_constraintBottom_toBottomOf="@+id/resourceNameText"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />

View File

@@ -79,7 +79,7 @@ impl ResourceDescription {
}
}
fn is_internet_resource(&self) -> bool {
pub fn is_internet_resource(&self) -> bool {
matches!(self, ResourceDescription::Internet(_))
}
}
@@ -127,9 +127,6 @@ pub struct ResourceDescriptionInternet {
/// Name for display always set to "Internet Resource"
pub name: String,
/// Address for display always set to "All internet addresses"
pub address: String,
pub id: ResourceId,
pub sites: Vec<Site>,
@@ -186,7 +183,6 @@ mod tests {
fn internet_resource(uuid: &str) -> ResourceDescription {
ResourceDescription::Internet(ResourceDescriptionInternet {
name: "Internet Resource".to_string(),
address: "All internet addresses".to_string(),
id: ResourceId::from_str(uuid).unwrap(),
sites: vec![Site {
name: "test".to_string(),

View File

@@ -73,9 +73,18 @@ impl ResourceDescriptionCidr {
}
}
fn internet_resource_name() -> String {
"<-> Internet Resource".to_string()
}
/// Description of an internet resource.
#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct ResourceDescriptionInternet {
/// Name of the resource.
///
/// Used only for display.
#[serde(default = "internet_resource_name")]
pub name: String,
/// Resource's id.
pub id: ResourceId,
/// Sites for the internet resource
@@ -88,8 +97,7 @@ pub struct ResourceDescriptionInternet {
impl ResourceDescriptionInternet {
pub fn with_status(self, status: Status) -> crate::callbacks::ResourceDescriptionInternet {
crate::callbacks::ResourceDescriptionInternet {
name: "Internet Resource".to_string(),
address: "All internet addresses".to_string(),
name: self.name,
id: self.id,
sites: self.sites,
can_be_disabled: self.can_be_disabled.unwrap_or_default(),
@@ -269,6 +277,7 @@ mod tests {
{
"id": "1106047c-cd5d-4151-b679-96b93da7383b",
"type": "internet",
"name": "Internet Resource",
"gateway_groups": [{"name": "test", "id": "eb94482a-94f4-47cb-8127-14fb3afa5516"}],
"not": "relevant",
"some_other": [

View File

@@ -80,6 +80,7 @@ pub fn internet_resource(
) -> impl Strategy<Value = ResourceDescriptionInternet> {
(resource_id(), sites, any::<bool>()).prop_map(move |(id, sites, can_be_disabled)| {
ResourceDescriptionInternet {
name: "Internet Resource".to_string(),
id,
sites,
can_be_disabled: Some(can_be_disabled),

View File

@@ -12,11 +12,10 @@ use connlib_shared::{
};
use std::collections::HashSet;
use tauri::{SystemTray, SystemTrayHandle};
use url::Url;
mod builder;
pub(crate) use builder::{copyable, item, Event, Item, Menu, Window};
pub(crate) use builder::{item, Event, Menu, Window};
// Figma is the source of truth for the tray icons
// <https://www.figma.com/design/THvQQ1QxKlsk47H9DZ2bhN/Core-Library?node-id=1250-772&t=OGFabKWPx7PRUZmq-0>
@@ -40,6 +39,8 @@ const DISCONNECT_AND_QUIT: &str = "Disconnect and quit Firezone";
const DISABLE: &str = "Disable this resource";
const ENABLE: &str = "Enable this resource";
pub(crate) const INTERNET_RESOURCE_DESCRIPTION: &str = "All network traffic";
pub(crate) fn loading() -> SystemTray {
SystemTray::new()
.with_icon(tauri::Icon::Raw(BUSY_ICON.into()))
@@ -68,27 +69,25 @@ pub(crate) struct SignedIn<'a> {
}
impl<'a> SignedIn<'a> {
fn is_favorite(&self, res: &ResourceDescription) -> bool {
self.favorite_resources.contains(&res.id())
fn is_favorite(&self, resource: &ResourceId) -> bool {
self.favorite_resources.contains(resource)
}
fn add_favorite_toggle(&self, submenu: &mut Menu, resource: ResourceId) {
if self.is_favorite(&resource) {
submenu.add_item(item(Event::RemoveFavorite(resource), REMOVE_FAVORITE).selected());
} else {
submenu.add_item(item(Event::AddFavorite(resource), ADD_FAVORITE));
}
}
/// Builds the submenu that has the resource address, name, desc,
/// sites online, etc.
fn resource_submenu(&self, res: &ResourceDescription) -> Menu {
let mut submenu = Menu::default();
let mut submenu = Menu::default().resource_description(res);
submenu.add_item(resource_header(res));
let mut submenu = submenu
.separator()
.disabled("Resource")
.copyable(res.name())
.copyable(res.pastable().as_ref());
if self.is_favorite(res) {
submenu.add_item(item(Event::RemoveFavorite(res.id()), REMOVE_FAVORITE).selected());
} else {
submenu.add_item(item(Event::AddFavorite(res.id()), ADD_FAVORITE));
if !res.is_internet_resource() {
self.add_favorite_toggle(&mut submenu, res.id());
}
if res.can_be_disabled() {
@@ -123,22 +122,6 @@ impl<'a> SignedIn<'a> {
}
}
fn resource_header(res: &ResourceDescription) -> Item {
let Some(address_description) = res.address_description() else {
return copyable(&res.pastable());
};
if address_description.is_empty() {
return copyable(&res.pastable());
}
let Ok(url) = Url::parse(address_description) else {
return copyable(address_description);
};
item(Event::Url(url), format!("<{address_description}>"))
}
#[derive(PartialEq)]
pub(crate) enum Icon {
/// Must be equivalent to the default app icon, since we assume this is set when we start
@@ -247,7 +230,7 @@ fn signed_in(signed_in: &SignedIn) -> Menu {
// Always show Resources in the original order
for res in resources
.iter()
.filter(|res| favorite_resources.contains(&res.id()))
.filter(|res| favorite_resources.contains(&res.id()) || res.is_internet_resource())
{
menu = menu.add_submenu(res.name(), signed_in.resource_submenu(res));
}
@@ -266,7 +249,7 @@ fn signed_in(signed_in: &SignedIn) -> Menu {
// Always show Resources in the original order
for res in resources
.iter()
.filter(|res| !favorite_resources.contains(&res.id()))
.filter(|res| !favorite_resources.contains(&res.id()) && !res.is_internet_resource())
{
submenu = submenu.add_submenu(res.name(), signed_in.resource_submenu(res));
}
@@ -449,17 +432,7 @@ mod tests {
.add_submenu(
"Internet Resource",
Menu::default()
.copyable("")
.separator()
.disabled("Resource")
.copyable("Internet Resource")
.copyable("")
.item(
Event::AddFavorite(
ResourceId::from_str("1106047c-cd5d-4151-b679-96b93da7383b").unwrap(),
),
ADD_FAVORITE,
)
.disabled(INTERNET_RESOURCE_DESCRIPTION)
.separator()
.disabled("Site")
.copyable("test")
@@ -510,48 +483,37 @@ mod tests {
.copyable("test")
.copyable(GATEWAY_CONNECTED),
)
.add_submenu(
"Internet Resource",
Menu::default()
.disabled(INTERNET_RESOURCE_DESCRIPTION)
.separator()
.disabled("Site")
.copyable("test")
.copyable(ALL_GATEWAYS_OFFLINE),
)
.separator()
.add_submenu(
OTHER_RESOURCES,
Menu::default()
.add_submenu(
"172.172.0.0/16",
Menu::default()
.copyable("cidr resource")
.separator()
.disabled("Resource")
.copyable("172.172.0.0/16")
.copyable("172.172.0.0/16")
.item(
Event::AddFavorite(ResourceId::from_str(
"73037362-715d-4a83-a749-f18eadd970e6",
)?),
ADD_FAVORITE,
)
.separator()
.disabled("Site")
.copyable("test")
.copyable(NO_ACTIVITY),
)
.add_submenu(
"Internet Resource",
Menu::default()
.copyable("")
.separator()
.disabled("Resource")
.copyable("Internet Resource")
.copyable("")
.item(
Event::AddFavorite(ResourceId::from_str(
"1106047c-cd5d-4151-b679-96b93da7383b",
)?),
ADD_FAVORITE,
)
.separator()
.disabled("Site")
.copyable("test")
.copyable(ALL_GATEWAYS_OFFLINE),
),
Menu::default().add_submenu(
"172.172.0.0/16",
Menu::default()
.copyable("cidr resource")
.separator()
.disabled("Resource")
.copyable("172.172.0.0/16")
.copyable("172.172.0.0/16")
.item(
Event::AddFavorite(ResourceId::from_str(
"73037362-715d-4a83-a749-f18eadd970e6",
)?),
ADD_FAVORITE,
)
.separator()
.disabled("Site")
.copyable("test")
.copyable(NO_ACTIVITY),
),
)
.add_bottom_section(DISCONNECT_AND_QUIT); // Skip testing the bottom section, it's simple
@@ -623,17 +585,7 @@ mod tests {
.add_submenu(
"Internet Resource",
Menu::default()
.copyable("")
.separator()
.disabled("Resource")
.copyable("Internet Resource")
.copyable("")
.item(
Event::AddFavorite(ResourceId::from_str(
"1106047c-cd5d-4151-b679-96b93da7383b",
)?),
ADD_FAVORITE,
)
.disabled(INTERNET_RESOURCE_DESCRIPTION)
.separator()
.disabled("Site")
.copyable("test")

View File

@@ -1,9 +1,11 @@
//! An abstraction over Tauri's system tray menu structs, that implements `PartialEq` for unit testing
use connlib_shared::messages::ResourceId;
use connlib_shared::{callbacks::ResourceDescription, messages::ResourceId};
use serde::{Deserialize, Serialize};
use url::Url;
use super::INTERNET_RESOURCE_DESCRIPTION;
/// A menu that can either be assigned to the system tray directly or used as a submenu in another menu.
///
/// Equivalent to `tauri::SystemTrayMenu`
@@ -75,6 +77,22 @@ pub(crate) enum Window {
Settings,
}
fn resource_header(res: &ResourceDescription) -> Item {
let Some(address_description) = res.address_description() else {
return copyable(&res.pastable());
};
if address_description.is_empty() {
return copyable(&res.pastable());
}
let Ok(url) = Url::parse(address_description) else {
return copyable(address_description);
};
item(Event::Url(url), format!("<{address_description}>"))
}
impl Menu {
/// Appends things that always show, like About, Settings, Help, Quit, etc.
pub(crate) fn add_bottom_section(self, quit_text: &str) -> Self {
@@ -154,6 +172,26 @@ impl Menu {
self.add_separator();
self
}
fn internet_resource(self) -> Self {
self.disabled(INTERNET_RESOURCE_DESCRIPTION)
}
fn resource_body(self, resource: &ResourceDescription) -> Self {
self.separator()
.disabled("Resource")
.copyable(resource.name())
.copyable(resource.pastable().as_ref())
}
pub(crate) fn resource_description(mut self, resource: &ResourceDescription) -> Self {
if resource.is_internet_resource() {
self.internet_resource()
} else {
self.add_item(resource_header(resource));
self.resource_body(resource)
}
}
}
impl Item {

View File

@@ -25,14 +25,14 @@ public enum ResourceList {
public struct Resource: Decodable, Identifiable, Equatable {
public let id: String
public var name: String
public var address: String
public var address: String?
public var addressDescription: String?
public var status: ResourceStatus
public var sites: [Site]
public var type: ResourceType
public var canBeDisabled: Bool
public init(id: String, name: String, address: String, addressDescription: String?, status: ResourceStatus, sites: [Site], type: ResourceType, canBeDisabled: Bool) {
public init(id: String, name: String, address: String?, addressDescription: String?, status: ResourceStatus, sites: [Site], type: ResourceType, canBeDisabled: Bool) {
self.id = id
self.name = name
self.address = address
@@ -42,6 +42,10 @@ public struct Resource: Decodable, Identifiable, Equatable {
self.type = type
self.canBeDisabled = canBeDisabled
}
public func isInternetResource() -> Bool {
self.type == ResourceType.internet
}
}
public enum ResourceStatus: String, Decodable {

View File

@@ -418,12 +418,12 @@ public final class MenuBar: NSObject, ObservableObject {
// If we have no favorites, then everything is a favorite
let hasAnyFavorites = newResources.contains { model.favorites.contains($0.id) }
let newFavorites = if (hasAnyFavorites) {
newResources.filter { model.favorites.contains($0.id) }
newResources.filter { model.favorites.contains($0.id) || $0.isInternetResource() }
} else {
newResources
}
let newOthers: [Resource] = if hasAnyFavorites {
newResources.filter { !model.favorites.contains($0.id) }
newResources.filter { !model.favorites.contains($0.id) && !$0.isInternetResource() }
} else {
[]
}
@@ -511,20 +511,11 @@ public final class MenuBar: NSObject, ObservableObject {
model.isResourceEnabled(id) ? "Disable this resource" : "Enable this resource"
}
private func createSubMenu(resource: Resource) -> NSMenu {
private func nonInternetResourceHeader(resource: Resource) -> NSMenu {
let subMenu = NSMenu()
let resourceAddressDescriptionItem = NSMenuItem()
let resourceSectionItem = NSMenuItem()
let resourceNameItem = NSMenuItem()
let resourceAddressItem = NSMenuItem()
let siteSectionItem = NSMenuItem()
let siteNameItem = NSMenuItem()
let siteStatusItem = NSMenuItem()
let toggleFavoriteItem = NSMenuItem()
let enableToggle = NSMenuItem()
// AddressDescription first -- will be most common action
let resourceAddressDescriptionItem = NSMenuItem()
if let addressDescription = resource.addressDescription {
resourceAddressDescriptionItem.title = addressDescription
@@ -544,7 +535,7 @@ public final class MenuBar: NSObject, ObservableObject {
}
} else {
// Show Address first if addressDescription is missing
resourceAddressDescriptionItem.title = resource.address
resourceAddressDescriptionItem.title = resource.address! // Address is none only for non-internet resource
resourceAddressDescriptionItem.action = #selector(resourceValueTapped(_:))
}
resourceAddressDescriptionItem.isEnabled = true
@@ -553,11 +544,13 @@ public final class MenuBar: NSObject, ObservableObject {
subMenu.addItem(NSMenuItem.separator())
let resourceSectionItem = NSMenuItem()
resourceSectionItem.title = "Resource"
resourceSectionItem.isEnabled = false
subMenu.addItem(resourceSectionItem)
// Resource name
let resourceNameItem = NSMenuItem()
resourceNameItem.action = #selector(resourceValueTapped(_:))
resourceNameItem.title = resource.name
resourceNameItem.toolTip = "Resource name (click to copy)"
@@ -566,13 +559,16 @@ public final class MenuBar: NSObject, ObservableObject {
subMenu.addItem(resourceNameItem)
// Resource address
let resourceAddressItem = NSMenuItem()
resourceAddressItem.action = #selector(resourceValueTapped(_:))
resourceAddressItem.title = resource.address
resourceAddressItem.title = resource.address!
resourceAddressItem.toolTip = "Resource address (click to copy)"
resourceAddressItem.isEnabled = true
resourceAddressItem.target = self
subMenu.addItem(resourceAddressItem)
let toggleFavoriteItem = NSMenuItem()
if model.favorites.contains(resource.id) {
toggleFavoriteItem.action = #selector(removeFavoriteTapped(_:))
toggleFavoriteItem.title = "Remove from favorites"
@@ -587,6 +583,36 @@ public final class MenuBar: NSObject, ObservableObject {
toggleFavoriteItem.target = self
subMenu.addItem(toggleFavoriteItem)
return subMenu
}
private func internetResourceHeader(resource: Resource) -> NSMenu {
let subMenu = NSMenu()
let description = NSMenuItem()
description.title = "All network traffic"
description.isEnabled = false
subMenu.addItem(description)
return subMenu
}
private func resourceHeader(resource: Resource) -> NSMenu {
if resource.isInternetResource() {
internetResourceHeader(resource: resource)
} else {
nonInternetResourceHeader(resource: resource)
}
}
private func createSubMenu(resource: Resource) -> NSMenu {
let siteSectionItem = NSMenuItem()
let siteNameItem = NSMenuItem()
let siteStatusItem = NSMenuItem()
let enableToggle = NSMenuItem()
let subMenu = resourceHeader(resource: resource)
// Resource enable / disable toggle
if resource.canBeDisabled {

View File

@@ -39,18 +39,18 @@ struct ResourceView: View {
.font(.system(size: 14))
.foregroundColor(.secondary)
.frame(width: 80, alignment: .leading)
if let url = URL(string: resource.addressDescription ?? resource.address),
if let url = URL(string: resource.addressDescription ?? resource.address!),
let _ = url.host {
Button(action: {
openURL(url)
}) {
Text(resource.addressDescription ?? resource.address)
Text(resource.addressDescription ?? resource.address!)
.foregroundColor(.blue)
.underline()
.font(.system(size: 16))
.contextMenu {
Button(action: {
copyToClipboard(resource.addressDescription ?? resource.address)
copyToClipboard(resource.addressDescription ?? resource.address!)
}) {
Text("Copy address")
Image(systemName: "doc.on.doc")
@@ -58,10 +58,10 @@ struct ResourceView: View {
}
}
} else {
Text(resource.addressDescription ?? resource.address)
Text(resource.addressDescription ?? resource.address!)
.contextMenu {
Button(action: {
copyToClipboard(resource.addressDescription ?? resource.address)
copyToClipboard(resource.addressDescription ?? resource.address!)
}) {
Text("Copy address")
Image(systemName: "doc.on.doc")

View File

@@ -130,22 +130,35 @@ struct ResourceSection: View {
var body: some View {
ForEach(resources) { resource in
HStack {
NavigationLink { ResourceView(model: model, resource: resource) }
label: {
HStack {
Text(resource.name)
if resource.canBeDisabled {
Spacer()
Toggle("Enabled", isOn: Binding<Bool>(
get: { model.isResourceEnabled(resource.id) },
set: { newValue in
model.store.toggleResourceDisabled(resource: resource.id, enabled: newValue)
}
)).labelsHidden()
if !resource.isInternetResource() {
NavigationLink { ResourceView(model: model, resource: resource) }
label: {
ResourceLabel(resource: resource, model: model )
}
} else {
ResourceLabel(resource: resource, model: model)
}
}
.navigationTitle("All Resources")
}
}
}
struct ResourceLabel: View {
let resource: Resource
@ObservedObject var model: SessionViewModel
var body: some View {
HStack {
Text(resource.name)
if resource.canBeDisabled {
Spacer()
Toggle("Enabled", isOn: Binding<Bool>(
get: { model.isResourceEnabled(resource.id) },
set: { newValue in
model.store.toggleResourceDisabled(resource: resource.id, enabled: newValue)
}
)).labelsHidden()
}
}
}