Add TV settings tab (#921)

Includes:

- Credits
- Donations
- Diagnostics
- Version

Had to:

- Wrap tab view into a NavigationStack for full-screen navigation
- Take out navigation titles of about subviews
- Customize donations view layout with modifier
- Fix credits and debug log to support scrolling

Closes #914
This commit is contained in:
Davide 2024-11-23 23:01:11 +01:00 committed by GitHub
parent f13a292b4b
commit bad9e8b58e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
27 changed files with 487 additions and 108 deletions

View File

@ -23,9 +23,6 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
#if !os(tvOS)
import AppUIMain
#endif
import CommonLibrary
import PassepartoutKit
import SwiftUI

View File

@ -85,15 +85,19 @@ extension AboutCoordinator {
switch item {
case .credits:
CreditsView()
.navigationTitle(Strings.Views.About.Credits.title)
case .diagnostics:
DiagnosticsView(profileManager: profileManager, tunnel: tunnel)
.navigationTitle(Strings.Views.Diagnostics.title)
case .donate:
DonateView()
DonateView(modifier: DonateViewModifier())
.navigationTitle(Strings.Views.Donate.title)
case .links:
LinksView()
.navigationTitle(Strings.Views.About.Links.title)
default:
Text(Strings.Global.Nouns.noSelection)

View File

@ -0,0 +1,39 @@
//
// DonateViewModifier+iOS.swift
// Passepartout
//
// Created by Davide De Rosa on 11/23/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
#if os(iOS)
import SwiftUI
struct DonateViewModifier: ViewModifier {
func body(content: Content) -> some View {
List {
content
.themeSection(footer: Strings.Views.Donate.Sections.Main.footer)
}
}
}
#endif

View File

@ -0,0 +1,42 @@
//
// DonateViewModifier+macOS.swift
// Passepartout
//
// Created by Davide De Rosa on 11/23/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
#if os(macOS)
import SwiftUI
struct DonateViewModifier: ViewModifier {
func body(content: Content) -> some View {
Form {
Section {
Text(Strings.Views.Donate.Sections.Main.footer)
}
content
}
.themeForm()
}
}
#endif

View File

@ -88,7 +88,6 @@ struct DiagnosticsView: View {
tunnelLogs = await availableTunnelLogs()
}
.themeForm()
.navigationTitle(Strings.Views.Diagnostics.title)
.alert(Strings.Views.Diagnostics.ReportIssue.title, isPresented: $isPresentingUnableToEmail) {
Button(Strings.Global.Nouns.ok, role: .cancel) {
isPresentingUnableToEmail = false
@ -105,11 +104,7 @@ private extension DiagnosticsView {
navLink(Strings.Views.Diagnostics.Rows.app, to: .app(title: Strings.Views.Diagnostics.Rows.app))
navLink(Strings.Views.Diagnostics.Rows.tunnel, to: .tunnel(title: Strings.Views.Diagnostics.Rows.tunnel, url: nil))
Toggle(Strings.Views.Diagnostics.Rows.includePrivateData, isOn: $logsPrivateData)
.onChange(of: logsPrivateData) {
PassepartoutConfiguration.shared.logsAddresses = $0
PassepartoutConfiguration.shared.logsModules = $0
}
LogsPrivateDataToggle()
}
.themeSection(header: Strings.Views.Diagnostics.Sections.live)
}

View File

@ -33,7 +33,6 @@ struct DebugLogContentView: View {
var body: some View {
TextEditor(text: .constant(lines.joined(separator: "\n")))
.font(.caption)
.monospaced()
}
}

View File

@ -34,7 +34,6 @@ struct DebugLogContentView: View {
List {
ForEach(Array(lines.enumerated()), id: \.offset) {
Text($0.element)
.monospaced()
}
}
}

View File

@ -42,21 +42,24 @@ public struct AppCoordinator: View, AppCoordinatorConforming {
public var body: some View {
debugChanges()
return TabView {
profileView
.tabItem {
Text(Strings.Global.Nouns.profile)
}
return NavigationStack {
TabView {
profileView
.tabItem {
Text(Strings.Global.Nouns.profile)
}
// searchView
// .tabItem {
// ThemeImage(.search)
// }
// searchView
// .tabItem {
// ThemeImage(.search)
// }
settingsView
.tabItem {
ThemeImage(.settings)
}
settingsView
.tabItem {
ThemeImage(.settings)
}
}
.navigationDestination(for: AppCoordinatorRoute.self, destination: pushDestination)
}
}
}
@ -73,10 +76,41 @@ private extension AppCoordinator {
// }
var settingsView: some View {
SettingsView()
SettingsView(tunnel: tunnel)
}
}
private extension AppCoordinator {
@ViewBuilder
func pushDestination(_ item: AppCoordinatorRoute?) -> some View {
switch item {
case .appLog:
DebugLogView(withAppParameters: Constants.shared.log) {
DebugLogContentView(lines: $0)
}
case .credits:
CreditsView()
.resized(width: 0.5)
.themeList()
case .donate:
DonateView(modifier: DonateViewModifier())
case .tunnelLog:
DebugLogView(withTunnel: tunnel, parameters: Constants.shared.log) {
DebugLogContentView(lines: $0)
}
default:
EmptyView()
}
}
}
// MARK: -
#Preview {
AppCoordinator(
profileManager: .mock,

View File

@ -0,0 +1,36 @@
//
// AppCoordinatorRoute.swift
// Passepartout
//
// Created by Davide De Rosa on 11/23/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import Foundation
enum AppCoordinatorRoute: Hashable {
case appLog
case credits
case donate
case tunnelLog
}

View File

@ -100,24 +100,24 @@ private extension ActiveProfileView {
func detailView(for profile: Profile) -> some View {
VStack(spacing: 10) {
if let connectionType = profile.localizedDescription(optionalStyle: .connectionType) {
DetailRowView(title: Strings.Global.Nouns.protocol) {
ListRowView(title: Strings.Global.Nouns.protocol) {
Text(connectionType)
}
}
if let pair = profile.selectedProvider {
if let metadata = providerManager.provider(withId: pair.selection.id) {
DetailRowView(title: Strings.Global.Nouns.provider) {
ListRowView(title: Strings.Global.Nouns.provider) {
Text(metadata.description)
}
}
if let entity = pair.selection.entity {
DetailRowView(title: Strings.Global.Nouns.country) {
ListRowView(title: Strings.Global.Nouns.country) {
ThemeCountryText(entity.header.countryCode)
}
}
}
if let otherList = profile.localizedDescription(optionalStyle: .nonConnectionTypes) {
DetailRowView(title: otherList) {
ListRowView(title: otherList) {
EmptyView()
}
}
@ -171,29 +171,11 @@ private extension ActiveProfileView {
private extension ActiveProfileView {
func onProviderEntityRequired(_ profile: Profile) {
// FIXME: #788, TV missing provider entity
// FIXME: #913, TV missing provider entity
}
func onPurchaseRequired(_ features: Set<AppFeature>) {
// FIXME: #788, TV purchase required
}
}
// MARK: - Subviews
private struct DetailRowView<Content>: View where Content: View {
let title: String
@ViewBuilder
let content: Content
var body: some View {
HStack {
Text(title)
.fontWeight(.light)
Spacer()
content
}
// FIXME: #913, TV purchase required
}
}

View File

@ -52,8 +52,7 @@ struct ProfileListView: View {
List {
ForEach(previews, id: \.id, content: toggleButton(for:))
}
.listStyle(.grouped)
.scrollClipDisabled()
.themeList()
.themeProgress(if: false, isEmpty: !profileManager.hasProfiles) {
Text(Strings.Views.App.Folders.noProfiles)
.themeEmptyMessage()
@ -105,11 +104,11 @@ private extension ProfileListView {
private extension ProfileListView {
func onProviderEntityRequired(_ profile: Profile) {
// FIXME: #788, TV missing provider entity
// FIXME: #913, TV missing provider entity
}
func onPurchaseRequired(_ features: Set<AppFeature>) {
// FIXME: #788, TV purchase required
// FIXME: #913, TV purchase required
}
}

View File

@ -0,0 +1,45 @@
//
// DebugLogContentView.swift
// Passepartout
//
// Created by Davide De Rosa on 11/23/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import SwiftUI
struct DebugLogContentView: View {
let lines: [String]
var body: some View {
ScrollView {
LazyVStack {
ForEach(Array(lines.enumerated()), id: \.offset) {
Button($0.element) {
//
}
.buttonStyle(.plain)
.frame(maxWidth: .infinity, alignment: .leading)
.font(.caption)
}
}
}
}
}

View File

@ -0,0 +1,49 @@
//
// DonateViewModifier.swift
// Passepartout
//
// Created by Davide De Rosa on 11/23/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import SwiftUI
struct DonateViewModifier: ViewModifier {
func body(content: Content) -> some View {
VStack {
Text(Strings.Views.Donate.Sections.Main.footer)
.frame(maxWidth: .infinity, alignment: .leading)
.padding(.bottom)
ScrollView {
LazyVGrid(columns: columns) {
content
}
}
}
.padding(.top, 150)
}
}
private extension DonateViewModifier {
var columns: [GridItem] {
[GridItem(.adaptive(minimum: 500))]
}
}

View File

@ -23,18 +23,61 @@
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI
// FIXME: #788, UI for TV
import UILibrary
struct SettingsView: View {
let tunnel: ExtendedTunnel
var body: some View {
VStack {
Text("Settings")
}
listView
.resized(width: 0.5)
}
}
#Preview {
SettingsView()
private extension SettingsView {
var listView: some View {
List {
creditsSection
diagnosticsSection
aboutSection
}
.themeList()
}
var creditsSection: some View {
Group {
NavigationLink(Strings.Views.About.Credits.title, value: AppCoordinatorRoute.credits)
NavigationLink(Strings.Views.Donate.title, value: AppCoordinatorRoute.donate)
}
.themeSection(header: Strings.Unlocalized.appName)
}
var diagnosticsSection: some View {
Group {
NavigationLink(Strings.Views.Diagnostics.Rows.app, value: AppCoordinatorRoute.appLog)
NavigationLink(Strings.Views.Diagnostics.Rows.tunnel, value: AppCoordinatorRoute.tunnelLog)
LogsPrivateDataToggle()
}
.themeSection(header: Strings.Views.Diagnostics.title)
}
var aboutSection: some View {
Group {
Text(Strings.Global.Nouns.version)
.themeTrailingValue(BundleConfiguration.mainVersionString)
}
.themeSection(header: Strings.Views.About.title)
}
}
// MARK: -
#Preview {
SettingsView(tunnel: .mock)
.themeNavigationStack()
.withMockEnvironment()
}

View File

@ -0,0 +1,42 @@
//
// ListRowView.swift
// Passepartout
//
// Created by Davide De Rosa on 11/23/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import SwiftUI
struct ListRowView<Content>: View where Content: View {
let title: String
@ViewBuilder
let content: Content
var body: some View {
HStack {
Text(title)
.fontWeight(.light)
Spacer()
content
}
}
}

View File

@ -178,18 +178,15 @@ private extension GenericCreditsView {
var translationsSection: some View {
Section {
ForEach(sortedLanguages, id: \.self) { code in
HStack {
Text(code.localizedAsLanguageCode ?? code)
Spacer()
credits.translations[code].map { authors in
VStack(spacing: 4) {
ForEach(authors, id: \.self) {
Text($0)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
#if os(tvOS)
Button {
//
} label: {
translationLabel(code)
}
#else
translationLabel(code)
#endif
}
} header: {
translationsHeader.map(Text.init)
@ -204,6 +201,21 @@ private extension GenericCreditsView {
}
.navigationTitle(content.name)
}
func translationLabel(_ code: String) -> some View {
HStack {
Text(code.localizedAsLanguageCode ?? code)
Spacer()
credits.translations[code].map { authors in
VStack(spacing: 4) {
ForEach(authors, id: \.self) {
Text($0)
.frame(maxWidth: .infinity, alignment: .trailing)
}
}
}
}
}
}
// MARK: -

View File

@ -50,6 +50,20 @@ extension View {
}
}
public func resized(width: CGFloat? = nil, height: CGFloat? = nil) -> some View {
GeometryReader { geo in
self
.frame(
width: width.map {
$0 * geo.size.width
},
height: height.map {
$0 * geo.size.height
}
)
}
}
public func setLater<T>(_ value: T?, millis: Int = 50, block: @escaping (T?) -> Void) {
Task {
block(nil)

View File

@ -662,10 +662,8 @@ public enum Strings {
}
public enum Sections {
public enum Main {
/// If you want to display gratitude for my work, here are a couple amounts you can donate instantly.
///
/// You will only be charged once per donation, and you can donate multiple times.
public static let footer = Strings.tr("Localizable", "views.donate.sections.main.footer", fallback: "If you want to display gratitude for my work, here are a couple amounts you can donate instantly.\n\nYou will only be charged once per donation, and you can donate multiple times.")
/// If you want to display gratitude for my work, here are a couple of amounts you can donate instantly. You will only be charged once per donation, and you can donate multiple times.
public static let footer = Strings.tr("Localizable", "views.donate.sections.main.footer", fallback: "If you want to display gratitude for my work, here are a couple of amounts you can donate instantly. You will only be charged once per donation, and you can donate multiple times.")
}
}
}

View File

@ -46,7 +46,7 @@
"views.diagnostics.alerts.report_issue.email" = "The device is not configured to send e-mails.";
"views.donate.title" = "Make a donation";
"views.donate.sections.main.footer" = "If you want to display gratitude for my work, here are a couple amounts you can donate instantly.\n\nYou will only be charged once per donation, and you can donate multiple times.";
"views.donate.sections.main.footer" = "If you want to display gratitude for my work, here are a couple of amounts you can donate instantly. You will only be charged once per donation, and you can donate multiple times.";
"views.donate.alerts.thank_you.message" = "This means a lot to me and I really hope you keep using and promoting this app.";
"views.migration.title" = "Migrate";

View File

@ -115,6 +115,15 @@ extension View {
}
}
public func themeList() -> some View {
#if os(tvOS)
listStyle(.grouped)
.scrollClipDisabled()
#else
self
#endif
}
public func themeForm() -> some View {
formStyle(.grouped)
}

View File

@ -42,7 +42,6 @@ public struct CreditsView: View {
.localizedDescription
}
)
.navigationTitle(Strings.Views.About.Credits.title)
.themeForm()
}
}

View File

@ -27,7 +27,7 @@ import CommonLibrary
import CommonUtils
import SwiftUI
public struct DonateView: View {
public struct DonateView<Modifier>: View where Modifier: ViewModifier {
@EnvironmentObject
private var iapManager: IAPManager
@ -35,6 +35,8 @@ public struct DonateView: View {
@Environment(\.dismiss)
private var dismiss
private let modifier: Modifier
@State
private var availableProducts: [InAppProduct] = []
@ -50,13 +52,15 @@ public struct DonateView: View {
@StateObject
private var errorHandler: ErrorHandler = .default()
public init() {
public init(modifier: Modifier) {
self.modifier = modifier
}
public var body: some View {
donationsView
productsRows
.modifier(modifier)
.themeProgress(if: isFetchingProducts)
.navigationTitle(title)
.disabled(purchasingIdentifier != nil)
.alert(
title,
isPresented: $isThankYouPresented,
@ -75,27 +79,17 @@ private extension DonateView {
Strings.Views.Donate.title
}
var donationsView: some View {
Form {
#if os(macOS)
Section {
Text(Strings.Views.Donate.Sections.Main.footer)
}
#endif
ForEach(availableProducts, id: \.productIdentifier) {
PaywallProductView(
iapManager: iapManager,
style: .donation,
product: $0,
purchasingIdentifier: $purchasingIdentifier,
onComplete: onComplete,
onError: onError
)
}
.themeSection(footer: Strings.Views.Donate.Sections.Main.footer)
var productsRows: some View {
ForEach(availableProducts, id: \.productIdentifier) {
PaywallProductView(
iapManager: iapManager,
style: .donation,
product: $0,
purchasingIdentifier: $purchasingIdentifier,
onComplete: onComplete,
onError: onError
)
}
.themeForm()
.disabled(purchasingIdentifier != nil)
}
func thankYouActions() -> some View {
@ -159,6 +153,6 @@ private extension DonateView {
// MARK: - Previews
#Preview {
DonateView()
DonateView(modifier: EmptyModifier())
.withMockEnvironment()
}

View File

@ -36,7 +36,6 @@ public struct LinksView: View {
supportSection
webSection
}
.navigationTitle(Strings.Views.About.Links.title)
.themeForm()
}
}

View File

@ -38,6 +38,7 @@ public struct DebugLogView<Content>: View where Content: View {
public var body: some View {
content(currentLines)
.monospaced()
.themeEmpty(if: currentLines.isEmpty, message: Strings.Global.Nouns.noContent)
.toolbar(content: toolbarContent)
.task {
@ -50,7 +51,9 @@ private extension DebugLogView {
@ViewBuilder
func toolbarContent() -> some View {
#if !os(tvOS)
copyButton
#endif
// if !currentLines.isEmpty {
// shareButton
// }

View File

@ -60,7 +60,7 @@ public struct PaywallProductView: View {
}
public var body: some View {
if #available(iOS 17, macOS 14, *) {
if #available(iOS 17, macOS 14, tvOS 17, *) {
StoreKitProductView(
style: style,
product: product,

View File

@ -63,7 +63,10 @@ private extension ProductView {
@ViewBuilder
func withPaywallStyle(_ paywallStyle: PaywallProductViewStyle) -> some View {
#if !os(tvOS)
#if os(tvOS)
productViewStyle(.compact)
.padding()
#else
switch paywallStyle {
case .recurring:
productViewStyle(.compact)
@ -71,8 +74,6 @@ private extension ProductView {
case .oneTime, .donation:
productViewStyle(.compact)
}
#else
productViewStyle(.compact)
#endif
}
}

View File

@ -0,0 +1,45 @@
//
// LogsPrivateDataToggle.swift
// Passepartout
//
// Created by Davide De Rosa on 11/23/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import PassepartoutKit
import SwiftUI
public struct LogsPrivateDataToggle: View {
@AppStorage(AppPreference.logsPrivateData.key, store: .appGroup)
private var logsPrivateData = false
public init() {
}
public var body: some View {
Toggle(Strings.Views.Diagnostics.Rows.includePrivateData, isOn: $logsPrivateData)
.onChange(of: logsPrivateData) {
PassepartoutConfiguration.shared.logsAddresses = $0
PassepartoutConfiguration.shared.logsModules = $0
}
}
}