From bd6340ce77846abd765ec8829fb2757e88882125 Mon Sep 17 00:00:00 2001 From: Davide De Rosa Date: Mon, 3 Jul 2023 17:37:16 +0200 Subject: [PATCH] Split reusable views into extensions (#322) Like in #321 --- Passepartout.xcodeproj/project.pbxproj | 4 - .../App/Reusable/AddingTextField.swift | 8 +- .../App/Reusable/CopySavingButton.swift | 14 +- .../App/Reusable/DestructiveButton.swift | 36 ----- .../App/Reusable/EditableTextList.swift | 62 ++++---- .../App/Reusable/GenericCreditsView.swift | 140 ++++++++++-------- Passepartout/App/Reusable/LockableView.swift | 42 +++--- .../App/Reusable/MailComposerView.swift | 26 ++-- Passepartout/App/Reusable/StyledPicker.swift | 6 +- .../App/Views/OrganizerView+Profiles.swift | 2 +- .../App/Views/ProfileView+MainMenu.swift | 2 +- 11 files changed, 171 insertions(+), 171 deletions(-) delete mode 100644 Passepartout/App/Reusable/DestructiveButton.swift diff --git a/Passepartout.xcodeproj/project.pbxproj b/Passepartout.xcodeproj/project.pbxproj index 6e68e64c9..18a881c90 100644 --- a/Passepartout.xcodeproj/project.pbxproj +++ b/Passepartout.xcodeproj/project.pbxproj @@ -104,7 +104,6 @@ 0E71ACF927C12E4800F85C4B /* CreditsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E71ACF827C12E4800F85C4B /* CreditsView.swift */; }; 0E71ACFB27C12E5300F85C4B /* VersionView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E71ACFA27C12E5300F85C4B /* VersionView.swift */; }; 0E71ACFD27C1321A00F85C4B /* ActivityView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E71ACFC27C1321A00F85C4B /* ActivityView.swift */; }; - 0E7577D72816A3B200081CBE /* DestructiveButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7577D62816A3B200081CBE /* DestructiveButton.swift */; }; 0E7577DF2817E22C00081CBE /* VPNToggle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7577DE2817E22C00081CBE /* VPNToggle.swift */; }; 0E7A8C0A2A1D410500780F4B /* PersistenceManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0E7A8C092A1D410400780F4B /* PersistenceManager.swift */; }; 0E7A8C0C2A1D4A6100780F4B /* PassepartoutLibrary in Frameworks */ = {isa = PBXBuildFile; productRef = 0E7A8C0B2A1D4A6100780F4B /* PassepartoutLibrary */; }; @@ -398,7 +397,6 @@ 0E71ACF827C12E4800F85C4B /* CreditsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CreditsView.swift; sourceTree = ""; }; 0E71ACFA27C12E5300F85C4B /* VersionView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VersionView.swift; sourceTree = ""; }; 0E71ACFC27C1321A00F85C4B /* ActivityView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ActivityView.swift; sourceTree = ""; }; - 0E7577D62816A3B200081CBE /* DestructiveButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DestructiveButton.swift; sourceTree = ""; }; 0E7577DE2817E22C00081CBE /* VPNToggle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VPNToggle.swift; sourceTree = ""; }; 0E7A8C072A1D40BA00780F4B /* Picker+OpenVPN.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Picker+OpenVPN.swift"; sourceTree = ""; }; 0E7A8C082A1D40BA00780F4B /* Picker+Network.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Picker+Network.swift"; sourceTree = ""; }; @@ -613,7 +611,6 @@ 0EB4042D27CA136200378B1A /* AddingTextField.swift */, 0EB3412F27C7761A00483410 /* Binding+Extensions.swift */, 0E9ED48027FD9BAE003B2316 /* CopySavingButton.swift */, - 0E7577D62816A3B200081CBE /* DestructiveButton.swift */, 0EDE02C127F61C79000FBE3C /* EditableTextList.swift */, 0E3A593B2A50975700B3FE40 /* ErrorHandler.swift */, 0E2C171A27CB5A3A007E8488 /* GenericCreditsView.swift */, @@ -1458,7 +1455,6 @@ 0E3A593C2A50975700B3FE40 /* ErrorHandler.swift in Sources */, 0E34AC8227F892C40042F2AB /* OnDemandView+SSID.swift in Sources */, 0E3B7FCD27E47B3700C66F13 /* AddHostView+Name.swift in Sources */, - 0E7577D72816A3B200081CBE /* DestructiveButton.swift in Sources */, 0EF2212D27E66EB5001D0BD7 /* AddProviderView.swift in Sources */, 0EB90CC129C25BBD00E64628 /* InteractiveConnectionView.swift in Sources */, 0E35C09A280E95BB0071FA35 /* ProviderProfileAvailability.swift in Sources */, diff --git a/Passepartout/App/Reusable/AddingTextField.swift b/Passepartout/App/Reusable/AddingTextField.swift index 96990837a..ea7f921f7 100644 --- a/Passepartout/App/Reusable/AddingTextField.swift +++ b/Passepartout/App/Reusable/AddingTextField.swift @@ -51,15 +51,19 @@ struct AddingTextField: View { } } } +} + +// MARK: - - private func doAdd() { +private extension AddingTextField { + func doAdd() { withAnimation { onAdd?() isAdding = true } } - private func doCommit() { + func doCommit() { withAnimation { onCommit?() isAdding = false diff --git a/Passepartout/App/Reusable/CopySavingButton.swift b/Passepartout/App/Reusable/CopySavingButton.swift index 4dd74919f..128a8da5e 100644 --- a/Passepartout/App/Reusable/CopySavingButton.swift +++ b/Passepartout/App/Reusable/CopySavingButton.swift @@ -51,12 +51,20 @@ struct CopySavingButton: View { } } } +} + +// MARK: - - private var canSave: Bool { +private extension CopySavingButton { + var canSave: Bool { isLoaded && (saveAnyway || copy != original) } +} + +// MARK: - - private func loadFromOriginal(once: Bool) { +private extension CopySavingButton { + func loadFromOriginal(once: Bool) { guard !once || !isLoaded else { return } @@ -64,7 +72,7 @@ struct CopySavingButton: View { isLoaded = true } - private func saveToOriginal() { + func saveToOriginal() { if copy != original { original = copy } diff --git a/Passepartout/App/Reusable/DestructiveButton.swift b/Passepartout/App/Reusable/DestructiveButton.swift deleted file mode 100644 index f19e8a122..000000000 --- a/Passepartout/App/Reusable/DestructiveButton.swift +++ /dev/null @@ -1,36 +0,0 @@ -// -// DestructiveButton.swift -// Passepartout -// -// Created by Davide De Rosa on 4/25/22. -// Copyright (c) 2023 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 . -// - -import SwiftUI - -struct DestructiveButton: View { - let action: () -> Void - - let label: () -> Label - - var body: some View { - Button(role: .destructive, action: action, label: label) - } -} diff --git a/Passepartout/App/Reusable/EditableTextList.swift b/Passepartout/App/Reusable/EditableTextList.swift index 2de20f090..7612e5689 100644 --- a/Passepartout/App/Reusable/EditableTextList.swift +++ b/Passepartout/App/Reusable/EditableTextList.swift @@ -64,14 +64,6 @@ struct EditableTextList: View { private let addedUUID = UUID() - private var addedText: Binding { - .init { - editedTextStrings[addedUUID] ?? "" - } set: { - editedTextStrings[addedUUID] = $0 - } - } - var body: some View { debugChanges() return Group { @@ -90,8 +82,20 @@ struct EditableTextList: View { } }.onChange(of: elements, perform: remapElements) } +} + +// MARK: - + +private extension EditableTextList { + var addedText: Binding { + .init { + editedTextStrings[addedUUID] ?? "" + } set: { + editedTextStrings[addedUUID] = $0 + } + } - private func existingRow(_ element: IdentifiableString) -> some View { + func existingRow(_ element: IdentifiableString) -> some View { let editedText = binding(toEditedElement: element) return textField(.init(isNewElement: false, text: editedText, onEditingChanged: { @@ -104,7 +108,7 @@ struct EditableTextList: View { })) } - private var newRow: some View { + var newRow: some View { AddingTextField( onAdd: { addedText.wrappedValue = "" @@ -120,10 +124,8 @@ struct EditableTextList: View { } } -// MARK: View model - -extension EditableTextList { - private func remapElements(_ newElements: [String]) { +private extension EditableTextList { + func remapElements(_ newElements: [String]) { var oldIdentifiableElements = identifiableElements var newIdentifiableElements: [IdentifiableString] = [] @@ -148,7 +150,20 @@ extension EditableTextList { } } - private func addElement() { + func binding(toEditedElement element: IdentifiableString) -> Binding { +// print(">>> <-> \(element)") + .init { + editedTextStrings[element.id] ?? element.string + } set: { + editedTextStrings[element.id] = $0 + } + } +} + +// MARK: - + +private extension EditableTextList { + func addElement() { guard allowsDuplicates || !identifiableElements.contains(where: { $0.string == addedText.wrappedValue }) else { @@ -159,16 +174,7 @@ extension EditableTextList { commit() } - private func binding(toEditedElement element: IdentifiableString) -> Binding { -// print(">>> <-> \(element)") - .init { - editedTextStrings[element.id] ?? element.string - } set: { - editedTextStrings[element.id] = $0 - } - } - - private func replaceElement(at id: UUID, with editedText: Binding) { + func replaceElement(at id: UUID, with editedText: Binding) { // print(">>> \(identifiableElements[id].string) -> \(editedText.wrappedValue)") guard let i = identifiableElements.firstIndex(where: { $0.id == id @@ -188,21 +194,21 @@ extension EditableTextList { commit() } - private func onDelete(offsets: IndexSet) { + func onDelete(offsets: IndexSet) { var mapped = mapping(identifiableElements) mapped.remove(atOffsets: offsets) identifiableElements = mapped commit() } - private func onMove(indexSet: IndexSet, to offset: Int) { + func onMove(indexSet: IndexSet, to offset: Int) { var mapped = mapping(identifiableElements) mapped.move(fromOffsets: indexSet, toOffset: offset) identifiableElements = mapped commit() } - private func commit() { + func commit() { // print(">>> identifiableElements = \(identifiableElements.map { "\($0.string) (\($0.id))" })") elements = identifiableElements.map(\.string) } diff --git a/Passepartout/App/Reusable/GenericCreditsView.swift b/Passepartout/App/Reusable/GenericCreditsView.swift index 4d35aad06..5f07ba300 100644 --- a/Passepartout/App/Reusable/GenericCreditsView.swift +++ b/Passepartout/App/Reusable/GenericCreditsView.swift @@ -26,6 +26,36 @@ import SwiftUI struct GenericCreditsView: View { + var licensesHeader: String? = "Licenses" + + var noticesHeader: String? = "Notices" + + var translationsHeader: String? = "Translations" + + let licenses: [License] + + let notices: [Notice] + + let translations: [String: String] + + @State private var contentForLicense: [String: String] = [:] + + var body: some View { + List { + if !licenses.isEmpty { + licensesSection + } + if !notices.isEmpty { + noticesSection + } + if !translations.isEmpty { + translationsSection + } + } + } +} + +extension GenericCreditsView { struct License { let name: String @@ -50,54 +80,53 @@ struct GenericCreditsView: View { self.noticeString = noticeString } } +} - var licensesHeader: String? = "Licenses" - - var noticesHeader: String? = "Notices" - - var translationsHeader: String? = "Translations" - - let licenses: [License] - - let notices: [Notice] - - let translations: [String: String] +private extension GenericCreditsView { + struct LicenseView: View { + let url: URL - @State private var contentForLicense: [String: String] = [:] + @Binding var content: String? - var body: some View { - List { - if !licenses.isEmpty { - licensesSection - } - if !notices.isEmpty { - noticesSection - } - if !translations.isEmpty { - translationsSection - } + var body: some View { + ZStack { + content.map { unwrapped in + ScrollView { + Text(unwrapped) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding() + } + } + if content == nil { + ProgressView() + } + }.onAppear(perform: loadURL) } } +} + +// MARK: - - private var sortedLicenses: [License] { +private extension GenericCreditsView { + var sortedLicenses: [License] { licenses.sorted { $0.name.lowercased() < $1.name.lowercased() } } - private var sortedNotices: [Notice] { + var sortedNotices: [Notice] { notices.sorted { $0.name.lowercased() < $1.name.lowercased() } } - private var sortedLanguages: [String] { + var sortedLanguages: [String] { translations.keys.sorted { $0.localizedAsCountryCode < $1.localizedAsCountryCode } } - private var licensesSection: some View { + var licensesSection: some View { Section( header: licensesHeader.map(Text.init) ) { @@ -118,7 +147,7 @@ struct GenericCreditsView: View { } } - private var noticesSection: some View { + var noticesSection: some View { Section( header: noticesHeader.map(Text.init) ) { @@ -128,7 +157,7 @@ struct GenericCreditsView: View { } } - private var translationsSection: some View { + var translationsSection: some View { Section( header: translationsHeader.map(Text.init) ) { @@ -145,7 +174,7 @@ struct GenericCreditsView: View { } } - private func noticeView(_ content: Notice) -> some View { + func noticeView(_ content: Notice) -> some View { VStack { Text(content.noticeString) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) @@ -155,46 +184,27 @@ struct GenericCreditsView: View { } } -extension GenericCreditsView { - struct LicenseView: View { - let url: URL +private extension String { + var localizedAsCountryCode: String { + Locale.current.localizedString(forLanguageCode: self)?.capitalized ?? self + } +} - @Binding var content: String? +// MARK: - - var body: some View { - ZStack { - content.map { unwrapped in - ScrollView { - Text(unwrapped) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding() - } - } - if content == nil { - ProgressView() - } - }.onAppear(perform: loadURL) +private extension GenericCreditsView.LicenseView { + func loadURL() { + guard content == nil else { + return } - - private func loadURL() { - guard content == nil else { - return - } - Task { @MainActor in - withAnimation { - do { - content = try String(contentsOf: url) - } catch { - content = AppError(error).localizedDescription - } + Task { @MainActor in + withAnimation { + do { + content = try String(contentsOf: url) + } catch { + content = AppError(error).localizedDescription } } } } } - -private extension String { - var localizedAsCountryCode: String { - Locale.current.localizedString(forLanguageCode: self)?.capitalized ?? self - } -} diff --git a/Passepartout/App/Reusable/LockableView.swift b/Passepartout/App/Reusable/LockableView.swift index 99a6843e1..9f26433b2 100644 --- a/Passepartout/App/Reusable/LockableView.swift +++ b/Passepartout/App/Reusable/LockableView.swift @@ -66,8 +66,31 @@ struct LockableView: View { } }.onChange(of: scenePhase, perform: onScenePhase) } +} + +// MARK: - + +private final class Lock: ObservableObject { + enum State { + case none + + case covered + + case locked + } + + static let shared = Lock() + + @Published var state: State = .locked + + private init() { + } +} - private func onScenePhase(_ scenePhase: ScenePhase) { +// MARK: - + +private extension LockableView { + func onScenePhase(_ scenePhase: ScenePhase) { switch scenePhase { case .active: unlockIfNeeded() @@ -114,20 +137,3 @@ struct LockableView: View { } } } - -private final class Lock: ObservableObject { - enum State { - case none - - case covered - - case locked - } - - static let shared = Lock() - - @Published var state: State = .locked - - private init() { - } -} diff --git a/Passepartout/App/Reusable/MailComposerView.swift b/Passepartout/App/Reusable/MailComposerView.swift index 218c48a91..df330b3ca 100644 --- a/Passepartout/App/Reusable/MailComposerView.swift +++ b/Passepartout/App/Reusable/MailComposerView.swift @@ -27,18 +27,6 @@ import MessageUI import SwiftUI struct MailComposerView: UIViewControllerRepresentable { - final class Coordinator: NSObject, MFMailComposeViewControllerDelegate { - @Binding private var isPresented: Bool - - init(_ view: MailComposerView) { - _isPresented = view._isPresented - } - - public func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { - isPresented = false - } - } - struct Attachment { let data: Data @@ -80,3 +68,17 @@ struct MailComposerView: UIViewControllerRepresentable { Coordinator(self) } } + +extension MailComposerView { + final class Coordinator: NSObject, MFMailComposeViewControllerDelegate { + @Binding private var isPresented: Bool + + init(_ view: MailComposerView) { + _isPresented = view._isPresented + } + + public func mailComposeController(_ controller: MFMailComposeViewController, didFinishWith result: MFMailComposeResult, error: Error?) { + isPresented = false + } + } +} diff --git a/Passepartout/App/Reusable/StyledPicker.swift b/Passepartout/App/Reusable/StyledPicker.swift index 45cf81472..e0368544b 100644 --- a/Passepartout/App/Reusable/StyledPicker.swift +++ b/Passepartout/App/Reusable/StyledPicker.swift @@ -49,8 +49,12 @@ struct StyledPicker: View { } } } +} + +// MARK: - - private func pickerView() -> some View { +private extension StyledPicker { + func pickerView() -> some View { List { Section { ForEach(values, id: \.self) { value in diff --git a/Passepartout/App/Views/OrganizerView+Profiles.swift b/Passepartout/App/Views/OrganizerView+Profiles.swift index ffaa5728e..f66e84b07 100644 --- a/Passepartout/App/Views/OrganizerView+Profiles.swift +++ b/Passepartout/App/Views/OrganizerView+Profiles.swift @@ -157,7 +157,7 @@ private extension OrganizerView.ProfileContextMenu { } var deleteButton: some View { - DestructiveButton { + Button(role: .destructive) { withAnimation { profileManager.removeProfiles(withIds: [header.id]) } diff --git a/Passepartout/App/Views/ProfileView+MainMenu.swift b/Passepartout/App/Views/ProfileView+MainMenu.swift index 50e6c0665..622d8e7de 100644 --- a/Passepartout/App/Views/ProfileView+MainMenu.swift +++ b/Passepartout/App/Views/ProfileView+MainMenu.swift @@ -229,7 +229,7 @@ private extension ProfileView.MainMenu { } var deleteProfileButton: some View { - DestructiveButton { + Button(role: .destructive) { alertType = .deleteProfile isAlertPresented = true } label: {