passepartout-apple/Passepartout/Library/Sources/CommonUtils/Views/GenericCreditsView.swift
Davide bad9e8b58e
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
2024-11-23 23:01:11 +01:00

245 lines
6.5 KiB
Swift

//
// GenericCreditsView.swift
// Passepartout
//
// Created by Davide De Rosa on 2/27/22.
// 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
public struct Credits: Decodable {
public struct License: Decodable {
public let name: String
public let licenseName: String
public let licenseURL: URL
}
public struct Notice: Decodable {
public let name: String
public let message: String
}
public let author: String
public let licenses: [License]
public let notices: [Notice]
public let translations: [String: [String]]
}
public struct GenericCreditsView: View {
private let credits: Credits
private var licensesHeader: String?
private var noticesHeader: String?
private var translationsHeader: String?
private let errorDescription: (Error) -> String
@State
private var contentForLicense: [String: String] = [:]
public init(
credits: Credits,
licensesHeader: String? = nil,
noticesHeader: String? = nil,
translationsHeader: String? = nil,
errorDescription: @escaping (Error) -> String
) {
self.credits = credits
self.licensesHeader = licensesHeader
self.noticesHeader = noticesHeader
self.translationsHeader = translationsHeader
self.errorDescription = errorDescription
}
public var body: some View {
Form {
if !credits.licenses.isEmpty {
licensesSection
}
if !credits.notices.isEmpty {
noticesSection
}
if !credits.translations.isEmpty {
translationsSection
}
}
}
}
private extension GenericCreditsView {
struct LicenseView: View {
let url: URL
let errorDescription: (Error) -> String
@Binding
var content: String?
var body: some View {
ZStack {
content.map { unwrapped in
ScrollView {
Text(unwrapped)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.monospaced()
.padding()
}
}
if content == nil {
ProgressView()
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
}
.onAppear(perform: loadURL)
}
}
}
// MARK: -
private extension GenericCreditsView {
var sortedLicenses: [Credits.License] {
credits.licenses.sorted {
$0.name.lowercased() < $1.name.lowercased()
}
}
var sortedNotices: [Credits.Notice] {
credits.notices.sorted {
$0.name.lowercased() < $1.name.lowercased()
}
}
var sortedLanguages: [String] {
credits.translations.keys.sorted {
($0.localizedAsLanguageCode ?? $0) < ($1.localizedAsLanguageCode ?? $1)
}
}
var licensesSection: some View {
Section {
ForEach(sortedLicenses, id: \.name) { license in
NavigationLink {
LicenseView(
url: license.licenseURL,
errorDescription: errorDescription,
content: $contentForLicense[license.name]
)
.navigationTitle(license.name)
} label: {
HStack {
Text(license.name)
Spacer()
Text(license.licenseName)
}
}
}
} header: {
licensesHeader.map(Text.init)
}
}
var noticesSection: some View {
Section {
ForEach(sortedNotices, id: \.name) { notice in
NavigationLink(notice.name, destination: noticeView(notice))
}
} header: {
noticesHeader.map(Text.init)
}
}
var translationsSection: some View {
Section {
ForEach(sortedLanguages, id: \.self) { code in
#if os(tvOS)
Button {
//
} label: {
translationLabel(code)
}
#else
translationLabel(code)
#endif
}
} header: {
translationsHeader.map(Text.init)
}
}
func noticeView(_ content: Credits.Notice) -> some View {
VStack {
Text(content.message)
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
.padding()
}
.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: -
@MainActor
private extension GenericCreditsView.LicenseView {
func loadURL() {
guard content == nil else {
return
}
Task {
do {
let session = URLSession(configuration: .ephemeral)
let response = try await session.data(from: url)
let string = String(data: response.0, encoding: .utf8)
withAnimation {
content = string
}
} catch {
withAnimation {
content = errorDescription(error)
}
}
}
}
}