diff --git a/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift b/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift index ba3239ad..847e7dbb 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/About/DonateView.swift @@ -152,3 +152,10 @@ private extension DonateView { } } } + +// MARK: - Previews + +#Preview { + DonateView() + .withMockEnvironment() +} diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/AddProfileMenu.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/AddProfileMenu.swift index 1d769c76..48b2f278 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/AddProfileMenu.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/AddProfileMenu.swift @@ -41,8 +41,7 @@ struct AddProfileMenu: View { Menu { newProfileButton importProfileButton - // FIXME: ###, migrations UI -// migrateProfilesButton + migrateProfilesButton } label: { ThemeImage(.add) } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift index e0197896..ad82264d 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/AppCoordinator.swift @@ -168,6 +168,9 @@ extension AppCoordinator { return } present(.editProviderEntity($0, pair.0, pair.1)) + }, + onMigrateProfiles: { + modalRoute = .migrateProfiles } ) ) @@ -240,7 +243,7 @@ extension AppCoordinator { var migrateViewStyle: MigrateView.Style { #if os(iOS) - .section + .list #else .table #endif diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileContainerView.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileContainerView.swift index ea5b99a8..672c7489 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileContainerView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileContainerView.swift @@ -52,7 +52,8 @@ struct ProfileContainerView: View, Routable { debugChanges() return innerView .modifier(ContainerModifier( - profileManager: profileManager + profileManager: profileManager, + flow: flow )) .modifier(ProfileImporterModifier( profileManager: profileManager, @@ -108,6 +109,8 @@ private struct ContainerModifier: ViewModifier { @ObservedObject var profileManager: ProfileManager + let flow: ProfileContainerView.Flow? + @State private var search = "" @@ -117,7 +120,16 @@ private struct ContainerModifier: ViewModifier { .themeProgress( if: !profileManager.isReady, isEmpty: !profileManager.hasProfiles, - emptyMessage: Strings.Views.Profiles.Folders.noProfiles + emptyContent: { + VStack(spacing: 16) { + Text(Strings.Views.Profiles.Folders.noProfiles) + .themeEmptyMessage(fullScreen: false) + + Button(Strings.Views.Profiles.Folders.NoProfiles.migrate) { + flow?.onMigrateProfiles() + } + } + } ) .searchable(text: $search) .onChange(of: search) { diff --git a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileFlow.swift b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileFlow.swift index 88d7dfb6..4739327d 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileFlow.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/App/ProfileFlow.swift @@ -30,4 +30,6 @@ struct ProfileFlow { let onEditProfile: (ProfileHeader) -> Void let onEditProviderEntity: (Profile) -> Void + + let onMigrateProfiles: () -> Void } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateButton.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateButton.swift new file mode 100644 index 00000000..a72851c9 --- /dev/null +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateButton.swift @@ -0,0 +1,68 @@ +// +// MigrateButton.swift +// Passepartout +// +// Created by Davide De Rosa on 11/16/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 . +// + +import SwiftUI + +struct MigrateButton: View { + let step: MigrateViewStep + + let action: () -> Void + + var body: some View { + Button(title, action: action) + .disabled(!isEnabled) + } +} + +private extension MigrateButton { + var title: String { + switch step { + case .initial, .fetching, .fetched: + return Strings.Views.Migrate.Items.migrate + + case .migrating, .migrated: + return Strings.Views.Migrate.Items.import + + case .importing, .imported: + return Strings.Global.done + } + } + + var isEnabled: Bool { + switch step { + case .initial, .fetching, .migrating, .importing: + return false + + case .fetched(let profiles): + return !profiles.isEmpty + + case .migrated(let profiles): + return !profiles.isEmpty + + case .imported: + return true + } + } +} diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Section.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateContentView+List.swift similarity index 54% rename from Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Section.swift rename to Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateContentView+List.swift index f5b501ef..3ff05a21 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Section.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateContentView+List.swift @@ -1,5 +1,5 @@ // -// MigrateView+Section.swift +// MigrateContentView+List.swift // Passepartout // // Created by Davide De Rosa on 11/13/24. @@ -26,31 +26,89 @@ import CommonLibrary import SwiftUI -extension MigrateView { - struct SectionView: View { - let step: Model.Step +extension MigrateContentView { + struct ListView: View { + let step: MigrateViewStep let profiles: [MigratableProfile] @Binding var statuses: [UUID: MigrationStatus] + @Binding + var isEditing: Bool + + let onDelete: ([MigratableProfile]) -> Void + + let performButton: () -> PerformButton + + @State + private var selection: Set = [] + var body: some View { - Section { - ForEach(profiles, id: \.id) { - ControlView( - step: step, - profile: $0, - isIncluded: isIncludedBinding(for: $0.id), - status: statusBinding(for: $0.id) - ) + List { + Section { + Text(Strings.Views.Migrate.Sections.Main.header) + } + Section { + ForEach(profiles, id: \.id) { + if isEditing { + EditableRowView(profile: $0, selection: $selection) + } else { + ControlView( + step: step, + profile: $0, + isIncluded: isIncludedBinding(for: $0.id), + status: statusBinding(for: $0.id) + ) + } + } + } header: { + editButton + } + .disabled(!step.canSelect) + } + .toolbar { + ToolbarItem(placement: .confirmationAction) { + performButton() + .disabled(isEditing) } } } } } -private extension MigrateView.SectionView { +private extension MigrateContentView.ListView { + var editButton: some View { + HStack { + if isEditing { + Button(Strings.Global.cancel) { + isEditing = false + } + } + Spacer() + Button(isEditing ? Strings.Global.delete : Strings.Global.edit, role: isEditing ? .destructive : nil) { + if isEditing { + if !selection.isEmpty { + onDelete(profiles.filter { + selection.contains($0.id) + }) + // disable isEditing after confirmation + } else { + isEditing = false + } + } else { + selection = [] + isEditing = true + } + } + .disabled(isEditing && selection.isEmpty) + } + .frame(height: 30) + } +} + +private extension MigrateContentView.ListView { func isIncludedBinding(for profileId: UUID) -> Binding { Binding { statuses[profileId] != .excluded @@ -76,9 +134,32 @@ private extension MigrateView.SectionView { } } -private extension MigrateView.SectionView { +private extension MigrateContentView.ListView { + struct EditableRowView: View { + let profile: MigratableProfile + + @Binding + var selection: Set + + var body: some View { + Button { + if selection.contains(profile.id) { + selection.remove(profile.id) + } else { + selection.insert(profile.id) + } + } label: { + HStack { + CardView(profile: profile) + Spacer() + ThemeImage(selection.contains(profile.id) ? .selectionOn : .selectionOff) + } + } + } + } + struct ControlView: View { - let step: MigrateView.Model.Step + let step: MigrateViewStep let profile: MigratableProfile @@ -121,7 +202,7 @@ private extension MigrateView.SectionView { } } -private extension MigrateView.SectionView { +private extension MigrateContentView.ListView { struct CardView: View { let profile: MigratableProfile @@ -139,7 +220,7 @@ private extension MigrateView.SectionView { } } -private extension MigrateView.SectionView { +private extension MigrateContentView.ListView { struct StatusView: View { let isIncluded: Bool diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Table.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateContentView+Table.swift similarity index 55% rename from Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Table.swift rename to Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateContentView+Table.swift index cd7a9a47..150c84f9 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Table.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateContentView+Table.swift @@ -1,5 +1,5 @@ // -// MigrateView+Table.swift +// MigrateContentView+Table.swift // Passepartout // // Created by Davide De Rosa on 11/13/24. @@ -26,43 +26,69 @@ import CommonLibrary import SwiftUI -extension MigrateView { +extension MigrateContentView { struct TableView: View { @EnvironmentObject private var theme: Theme - let step: Model.Step + let step: MigrateViewStep let profiles: [MigratableProfile] @Binding var statuses: [UUID: MigrationStatus] + let onDelete: ([MigratableProfile]) -> Void + + let performButton: () -> PerformButton + var body: some View { - Table(profiles) { - TableColumn(Strings.Global.name) { - Text($0.name) - .foregroundStyle(statuses.style(for: $0.id)) + Form { + Section { + Text(Strings.Views.Migrate.Sections.Main.header) } - TableColumn(Strings.Global.lastUpdate) { - Text($0.timestamp) - .foregroundStyle(statuses.style(for: $0.id)) - } - TableColumn("") { - ControlView( - step: step, - isIncluded: isIncludedBinding(for: $0.id), - status: statuses[$0.id] - ) - .environmentObject(theme) // TODO: #873, Table loses environment + Section { + Table(profiles) { + TableColumn(Strings.Global.name) { + Text($0.name) + .foregroundStyle(statuses.style(for: $0.id)) + } + TableColumn(Strings.Global.lastUpdate) { + Text($0.timestamp) + .foregroundStyle(statuses.style(for: $0.id)) + } + TableColumn("") { + ControlView( + step: step, + isIncluded: isIncludedBinding(for: $0.id), + status: statuses[$0.id] + ) + .environmentObject(theme) // TODO: #873, Table loses environment + } + .width(20) + TableColumn("") { profile in + Button { + onDelete([profile]) + } label: { + ThemeImage(.editableSectionRemove) + } + .environmentObject(theme) // TODO: #873, Table loses environment + } + .width(20) + } } + .disabled(!step.canSelect) + } + .themeForm() + .toolbar { + ToolbarItem(placement: .confirmationAction, content: performButton) } } } } -private extension MigrateView.TableView { +private extension MigrateContentView.TableView { func isIncludedBinding(for profileId: UUID) -> Binding { Binding { statuses[profileId] != .excluded @@ -76,9 +102,9 @@ private extension MigrateView.TableView { } } -private extension MigrateView.TableView { +private extension MigrateContentView.TableView { struct ControlView: View { - let step: MigrateView.Model.Step + let step: MigrateViewStep @Binding var isIncluded: Bool diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Content.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateContentView.swift similarity index 66% rename from Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Content.swift rename to Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateContentView.swift index 71cad4ba..e3190904 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Content.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateContentView.swift @@ -27,33 +27,43 @@ import CommonLibrary import PassepartoutKit import SwiftUI -extension MigrateView { - struct ContentView: View { - let style: Style +struct MigrateContentView: View where PerformButton: View { + let style: MigrateView.Style - let step: Model.Step + let step: MigrateViewStep - let profiles: [MigratableProfile] + let profiles: [MigratableProfile] - @Binding - var statuses: [UUID: MigrationStatus] + @Binding + var statuses: [UUID: MigrationStatus] - var body: some View { - switch style { - case .section: - MigrateView.SectionView( - step: step, - profiles: profiles, - statuses: $statuses - ) + @Binding + var isEditing: Bool - case .table: - MigrateView.TableView( - step: step, - profiles: profiles, - statuses: $statuses - ) - } + let onDelete: ([MigratableProfile]) -> Void + + let performButton: () -> PerformButton + + var body: some View { + switch style { + case .list: + ListView( + step: step, + profiles: profiles, + statuses: $statuses, + isEditing: $isEditing, + onDelete: onDelete, + performButton: performButton + ) + + case .table: + TableView( + step: step, + profiles: profiles, + statuses: $statuses, + onDelete: onDelete, + performButton: performButton + ) } } } @@ -74,7 +84,7 @@ extension Dictionary where Key == UUID, Value == MigrationStatus { #Preview("Fetched") { PrivatePreviews.MigratePreview( - step: .fetched, + step: .fetched(PrivatePreviews.profiles), profiles: PrivatePreviews.profiles, initialStatuses: [ PrivatePreviews.profiles[1].id: .excluded, @@ -99,6 +109,15 @@ extension Dictionary where Key == UUID, Value == MigrationStatus { .withMockEnvironment() } +#Preview("Empty") { + PrivatePreviews.MigratePreview( + step: .fetched([]), + profiles: [], + initialStatuses: [:] + ) + .withMockEnvironment() +} + private struct PrivatePreviews { static let oneDay: TimeInterval = 24 * 60 * 60 @@ -111,7 +130,7 @@ private struct PrivatePreviews { ] struct MigratePreview: View { - let step: MigrateView.Model.Step + let step: MigrateViewStep let profiles: [MigratableProfile] @@ -120,21 +139,27 @@ private struct PrivatePreviews { @State private var statuses: [UUID: MigrationStatus] = [:] + @State + private var isEditing = false + #if os(iOS) - private let style: MigrateView.Style = .section + private let style: MigrateView.Style = .list #else private let style: MigrateView.Style = .table #endif var body: some View { - Form { - MigrateView.ContentView( - style: style, - step: step, - profiles: profiles, - statuses: $statuses - ) - } + MigrateContentView( + style: style, + step: step, + profiles: profiles, + statuses: $statuses, + isEditing: $isEditing, + onDelete: { _ in }, + performButton: { + Button("Item") {} + } + ) .navigationTitle("Migrate") .themeNavigationStack() .task { diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Model.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Model.swift index 0e001680..62e6d05e 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Model.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView+Model.swift @@ -29,23 +29,7 @@ import PassepartoutKit extension MigrateView { struct Model: Equatable { - enum Step: Equatable { - case initial - - case fetching - - case fetched - - case migrating - - case migrated([Profile]) - - case importing - - case imported - } - - var step: Step = .initial + var step: MigrateViewStep = .initial var profiles: [MigratableProfile] = [] @@ -81,15 +65,22 @@ extension MigrateView.Model { // } // } .sorted { - $0.name.lowercased() < $1.name.lowercased() - } - } + switch step { + case .initial, .fetching, .fetched: + return $0.name.lowercased() < $1.name.lowercased() - var selection: Set { - Set(profiles - .filter { - statuses[$0.id] != .excluded + case .migrating, .migrated, .importing, .imported: + return (statuses[$0.id].rank, $0.name.lowercased()) < (statuses[$1.id].rank, $1.name.lowercased()) + } } - .map(\.id)) + } +} + +private extension Optional where Wrapped == MigrationStatus { + var rank: Int { + if self == .excluded { + return .max + } + return .min } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift index 874c6bf9..1d6aadb0 100644 --- a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift @@ -28,11 +28,11 @@ import CommonUtils import PassepartoutKit import SwiftUI -// FIXME: ###, migrations UI +// FIXME: #878, show CloudKit progress struct MigrateView: View { enum Style { - case section + case list case table } @@ -51,28 +51,43 @@ struct MigrateView: View { @State private var model = Model() + @State + private var isEditing = false + + @State + private var isDeleting = false + + @State + private var profilesPendingDeletion: [MigratableProfile]? + @StateObject private var errorHandler: ErrorHandler = .default() var body: some View { - Form { - ContentView( - style: style, - step: model.step, - profiles: model.visibleProfiles, - statuses: $model.statuses - ) - .disabled(model.step != .fetched) - } - .themeForm() - .themeProgress( - if: model.step == .fetching, - isEmpty: model.profiles.isEmpty, - emptyMessage: "Nothing to migrate" + debugChanges() + return MigrateContentView( + style: style, + step: model.step, + profiles: model.visibleProfiles, + statuses: $model.statuses, + isEditing: $isEditing, + onDelete: onDelete, + performButton: performButton + ) + .themeProgress( + if: [.initial, .fetching].contains(model.step), + isEmpty: model.profiles.isEmpty, + emptyMessage: Strings.Views.Migrate.noProfiles + ) + .themeAnimation(on: model.step, category: .profiles) + .themeConfirmation( + isPresented: $isDeleting, + title: Strings.Views.Migrate.Alerts.Delete.title, + message: messageForDeletion, + isDestructive: true, + action: confirmPendingDeletion ) - .themeAnimation(on: model, category: .profiles) .navigationTitle(title) - .toolbar(content: toolbarContent) .task { await fetch() } @@ -85,52 +100,35 @@ private extension MigrateView { Strings.Views.Migrate.title } - func toolbarContent() -> some ToolbarContent { - ToolbarItem(placement: .confirmationAction) { - Button(itemTitle(at: model.step)) { - Task { - await itemPerform(at: model.step) - } + var messageForDeletion: String? { + profilesPendingDeletion.map { + let nameList = $0 + .map(\.name) + .joined(separator: "\n") + + return Strings.Views.Migrate.Alerts.Delete.message(nameList) + } + } + + func performButton() -> some View { + MigrateButton(step: model.step) { + Task { + await perform(at: model.step) } - .disabled(!itemEnabled(at: model.step)) } } } private extension MigrateView { - func itemTitle(at step: Model.Step) -> String { - switch step { - case .initial, .fetching, .fetched: - return "Proceed" - - case .migrating, .migrated: - return "Import" - - case .importing, .imported: - return "Done" - } + func onDelete(_ profiles: [MigratableProfile]) { + profilesPendingDeletion = profiles + isDeleting = true } - func itemEnabled(at step: Model.Step) -> Bool { + func perform(at step: MigrateViewStep) async { switch step { - case .initial, .fetching, .migrating, .importing: - return false - - case .fetched: - return !model.profiles.isEmpty - - case .migrated(let profiles): - return !profiles.isEmpty - - case .imported: - return true - } - } - - func itemPerform(at step: Model.Step) async { - switch step { - case .fetched: - await migrate() + case .fetched(let profiles): + await migrate(profiles) case .migrated(let profiles): await save(profiles) @@ -139,7 +137,7 @@ private extension MigrateView { dismiss() default: - fatalError("No action allowed at step \(step)") + assertionFailure("No action allowed at step \(step), why is button enabled?") } } @@ -149,12 +147,13 @@ private extension MigrateView { } do { model.step = .fetching + pp_log(.App.migration, .notice, "Fetch migratable profiles...") let migratable = try await migrationManager.fetchMigratableProfiles() let knownIDs = Set(profileManager.headers.map(\.id)) model.profiles = migratable.filter { !knownIDs.contains($0.id) } - model.step = .fetched + model.step = .fetched(model.profiles) } catch { pp_log(.App.migration, .error, "Unable to fetch migratable profiles: \(error)") errorHandler.handle(error, title: title) @@ -162,31 +161,78 @@ private extension MigrateView { } } - func migrate() async { - guard model.step == .fetched else { - fatalError("Must call fetch() and succeed") + func migrate(_ allProfiles: [MigratableProfile]) async { + guard case .fetched = model.step else { + assertionFailure("Must call fetch() and succeed, why is button enabled?") + return } + + let profiles = allProfiles.filter { + model.statuses[$0.id] != .excluded + } + guard !profiles.isEmpty else { + assertionFailure("Nothing to migrate, why is button enabled?") + return + } + + let previousStep = model.step + model.step = .migrating do { - model.step = .migrating - let profiles = try await migrationManager.migrateProfiles(model.profiles, selection: model.selection) { + pp_log(.App.migration, .notice, "Migrate \(profiles.count) profiles...") + let profiles = try await migrationManager.migratedProfiles(profiles) { model.statuses[$0] = $1 } + model.excludeFailed() model.step = .migrated(profiles) } catch { pp_log(.App.migration, .error, "Unable to migrate profiles: \(error)") errorHandler.handle(error, title: title) + model.step = previousStep } } - func save(_ profiles: [Profile]) async { - guard case .migrated(let profiles) = model.step, !profiles.isEmpty else { - fatalError("Must call migrate() and succeed with non-empty profiles") + func save(_ allProfiles: [Profile]) async { + guard case .migrated = model.step else { + assertionFailure("Must call migrate() and succeed, why is button enabled?") + return } + + let profiles = allProfiles.filter { + model.statuses[$0.id] != .excluded + } + guard !profiles.isEmpty else { + assertionFailure("Nothing to import, why is button enabled?") + return + } + model.step = .importing - model.excludeFailed() + pp_log(.App.migration, .notice, "Import \(profiles.count) migrated profiles...") await migrationManager.importProfiles(profiles, into: profileManager) { model.statuses[$0] = $1 } model.step = .imported } + + func confirmPendingDeletion() { + guard let profilesPendingDeletion else { + isEditing = false + assertionFailure("No profiles pending deletion?") + return + } + let deletedIds = Set(profilesPendingDeletion.map(\.id)) + Task { + do { + try await migrationManager.deleteMigratableProfiles(withIds: deletedIds) + withAnimation { + model.profiles.removeAll { + deletedIds.contains($0.id) + } + model.step = .fetched(model.profiles) + } + } catch { + pp_log(.App.migration, .error, "Unable to delete migratable profiles \(deletedIds): \(error)") + } + isEditing = false + } + } } diff --git a/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateViewStep.swift b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateViewStep.swift new file mode 100644 index 00000000..c4d5d87c --- /dev/null +++ b/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateViewStep.swift @@ -0,0 +1,51 @@ +// +// MigrateViewStep.swift +// Passepartout +// +// Created by Davide De Rosa on 11/16/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 . +// + +import CommonLibrary +import Foundation +import PassepartoutKit + +enum MigrateViewStep: Equatable { + case initial + + case fetching + + case fetched([MigratableProfile]) + + case migrating + + case migrated([Profile]) + + case importing + + case imported + + var canSelect: Bool { + guard case .fetched = self else { + return false + } + return true + } +} diff --git a/Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift b/Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift index c836da2d..dd61e2da 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Business/MigrationManager.swift @@ -46,11 +46,10 @@ public final class MigrationManager: ObservableObject { private nonisolated let simulation: Simulation? - public convenience init(profileStrategy: ProfileMigrationStrategy? = nil) { - self.init(profileStrategy: profileStrategy, simulation: nil) - } - - public init(profileStrategy: ProfileMigrationStrategy? = nil, simulation: Simulation?) { + public init( + profileStrategy: ProfileMigrationStrategy? = nil, + simulation: Simulation? = nil + ) { self.profileStrategy = profileStrategy ?? DummyProfileStrategy() self.simulation = simulation } @@ -63,31 +62,30 @@ extension MigrationManager { try await profileStrategy.fetchMigratableProfiles() } - public func migrateProfile(withId profileId: UUID) async throws -> Profile? { + public func migratedProfile(withId profileId: UUID) async throws -> Profile? { try await profileStrategy.fetchProfile(withId: profileId) } - public func migrateProfiles( - _ profiles: [MigratableProfile], - selection: Set, + public func migratedProfiles( + _ migratableProfiles: [MigratableProfile], onUpdate: @escaping @MainActor (UUID, MigrationStatus) -> Void ) async throws -> [Profile] { - profiles.forEach { - onUpdate($0.id, selection.contains($0.id) ? .pending : .excluded) + migratableProfiles.forEach { + onUpdate($0.id, .pending) } return try await withThrowingTaskGroup(of: Profile?.self, returning: [Profile].self) { group in - selection.forEach { profileId in + migratableProfiles.forEach { migratable in group.addTask { do { try await self.simulateBehavior() - guard let profile = try await self.simulateMigrateProfile(withId: profileId) else { - await onUpdate(profileId, .failed) + guard let profile = try await self.simulateMigrateProfile(withId: migratable.id) else { + await onUpdate(migratable.id, .failed) return nil } - await onUpdate(profileId, .migrated) + await onUpdate(migratable.id, .migrated) return profile } catch { - await onUpdate(profileId, .failed) + await onUpdate(migratable.id, .failed) return nil } } @@ -125,6 +123,10 @@ extension MigrationManager { } } } + + public func deleteMigratableProfiles(withIds profileIds: Set) async throws { + try await simulateDeleteProfiles(withIds: profileIds) + } } // MARK: - Simulation @@ -155,6 +157,13 @@ private extension MigrationManager { } try await manager.save(profile, force: true) } + + func simulateDeleteProfiles(withIds profileIds: Set) async throws { + if simulation?.fakeProfiles ?? false { + return + } + try await profileStrategy.deleteProfiles(withIds: profileIds) + } } // MARK: - Dummy @@ -170,4 +179,7 @@ private final class DummyProfileStrategy: ProfileMigrationStrategy { func fetchProfile(withId profileId: UUID) async throws -> Profile? { nil } + + func deleteProfiles(withIds profileIds: Set) async throws { + } } diff --git a/Passepartout/Library/Sources/CommonLibrary/Strategy/ProfileMigrationStrategy.swift b/Passepartout/Library/Sources/CommonLibrary/Strategy/ProfileMigrationStrategy.swift index b3a379c7..bac7ed46 100644 --- a/Passepartout/Library/Sources/CommonLibrary/Strategy/ProfileMigrationStrategy.swift +++ b/Passepartout/Library/Sources/CommonLibrary/Strategy/ProfileMigrationStrategy.swift @@ -30,4 +30,6 @@ public protocol ProfileMigrationStrategy { func fetchMigratableProfiles() async throws -> [MigratableProfile] func fetchProfile(withId profileId: UUID) async throws -> Profile? + + func deleteProfiles(withIds profileIds: Set) async throws } diff --git a/Passepartout/Library/Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift b/Passepartout/Library/Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift index 8e6e4a83..98355896 100644 --- a/Passepartout/Library/Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift +++ b/Passepartout/Library/Sources/LegacyV2/Strategy/CDProfileRepositoryV2.swift @@ -92,6 +92,19 @@ final class CDProfileRepositoryV2: Sendable { ) return profiles } + + func deleteProfiles(withIds profileIds: Set) async throws { + try await context.perform { [weak self] in + guard let self else { + return + } + let request = CDProfile.fetchRequest() + request.predicate = NSPredicate(format: "any uuid in %@", profileIds.map(\.uuidString)) + let existing = try context.fetch(request) + existing.forEach(context.delete) + try context.save() + } + } } extension CDProfileRepositoryV2 { diff --git a/Passepartout/Library/Sources/LegacyV2/Strategy/ProfileV2MigrationStrategy.swift b/Passepartout/Library/Sources/LegacyV2/Strategy/ProfileV2MigrationStrategy.swift index ab557105..7284b647 100644 --- a/Passepartout/Library/Sources/LegacyV2/Strategy/ProfileV2MigrationStrategy.swift +++ b/Passepartout/Library/Sources/LegacyV2/Strategy/ProfileV2MigrationStrategy.swift @@ -71,6 +71,10 @@ extension ProfileV2MigrationStrategy { return nil } } + + public func deleteProfiles(withIds profileIds: Set) async throws { + try await profilesRepository.deleteProfiles(withIds: profileIds) + } } // MARK: - Internal diff --git a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift index da16033b..8d4df244 100644 --- a/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift +++ b/Passepartout/Library/Sources/UILibrary/L10n/SwiftGen+Strings.swift @@ -231,6 +231,8 @@ public enum Strings { public static let country = Strings.tr("Localizable", "global.country", fallback: "Country") /// Default public static let `default` = Strings.tr("Localizable", "global.default", fallback: "Default") + /// Delete + public static let delete = Strings.tr("Localizable", "global.delete", fallback: "Delete") /// Destination public static let destination = Strings.tr("Localizable", "global.destination", fallback: "Destination") /// Disable @@ -720,8 +722,34 @@ public enum Strings { } } public enum Migrate { + /// Nothing to migrate + public static let noProfiles = Strings.tr("Localizable", "views.migrate.no_profiles", fallback: "Nothing to migrate") /// Migrate public static let title = Strings.tr("Localizable", "views.migrate.title", fallback: "Migrate") + public enum Alerts { + public enum Delete { + /// Do you want to discard these profiles? You will not be able to recover them later. + /// + /// %@ + public static func message(_ p1: Any) -> String { + return Strings.tr("Localizable", "views.migrate.alerts.delete.message", String(describing: p1), fallback: "Do you want to discard these profiles? You will not be able to recover them later.\n\n%@") + } + /// Discard + public static let title = Strings.tr("Localizable", "views.migrate.alerts.delete.title", fallback: "Discard") + } + } + public enum Items { + /// Import + public static let `import` = Strings.tr("Localizable", "views.migrate.items.import", fallback: "Import") + /// Proceed + public static let migrate = Strings.tr("Localizable", "views.migrate.items.migrate", fallback: "Proceed") + } + public enum Sections { + public enum Main { + /// Select below the profiles from old versions of Passepartout that you want to import. Profiles will disappear from this list once imported successfully. + public static let header = Strings.tr("Localizable", "views.migrate.sections.main.header", fallback: "Select below the profiles from old versions of Passepartout that you want to import. Profiles will disappear from this list once imported successfully.") + } + } } public enum Profile { public enum ModuleList { @@ -755,6 +783,10 @@ public enum Strings { public static let `default` = Strings.tr("Localizable", "views.profiles.folders.default", fallback: "My profiles") /// No profiles public static let noProfiles = Strings.tr("Localizable", "views.profiles.folders.no_profiles", fallback: "No profiles") + public enum NoProfiles { + /// Migrate old profiles... + public static let migrate = Strings.tr("Localizable", "views.profiles.folders.no_profiles.migrate", fallback: "Migrate old profiles...") + } } public enum Rows { /// %d modules diff --git a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings index 89289fd2..7b139941 100644 --- a/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings +++ b/Passepartout/Library/Sources/UILibrary/Resources/en.lproj/Localizable.strings @@ -13,6 +13,7 @@ "global.connection" = "Connection"; "global.country" = "Country"; "global.default" = "Default"; +"global.delete" = "Delete"; "global.destination" = "Destination"; "global.disable" = "Disable"; "global.disabled" = "Disabled"; @@ -121,6 +122,7 @@ "views.profiles.folders.default" = "My profiles"; "views.profiles.folders.add_profile" = "Add profile"; "views.profiles.folders.no_profiles" = "No profiles"; +"views.profiles.folders.no_profiles.migrate" = "Migrate old profiles..."; "views.profiles.toolbar.new_profile" = "New profile"; "views.profiles.toolbar.import_profile" = "Import profile"; "views.profiles.toolbar.migrate_profiles" = "Migrate profiles"; @@ -158,6 +160,12 @@ "views.about.credits.translations" = "Translations"; "views.migrate.title" = "Migrate"; +"views.migrate.no_profiles" = "Nothing to migrate"; +"views.migrate.items.migrate" = "Proceed"; +"views.migrate.items.import" = "Import"; +"views.migrate.sections.main.header" = "Select below the profiles from old versions of Passepartout that you want to import. Profiles will disappear from this list once imported successfully."; +"views.migrate.alerts.delete.title" = "Discard"; +"views.migrate.alerts.delete.message" = "Do you want to discard these profiles? You will not be able to recover them later.\n\n%@"; "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."; diff --git a/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift b/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift index 4678cef8..e21fd17b 100644 --- a/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift +++ b/Passepartout/Library/Sources/UILibrary/Theme/Theme+ImageName.swift @@ -55,6 +55,8 @@ extension Theme { case progress case remove case search + case selectionOff + case selectionOn case settings case share case show @@ -102,6 +104,8 @@ extension Theme.ImageName { case .progress: return "clock" case .remove: return "minus" case .search: return "magnifyingglass" + case .selectionOff: return "circle" + case .selectionOn: return "checkmark.circle.fill" case .settings: return "gearshape" case .share: return "square.and.arrow.up" case .show: return "eye" diff --git a/Passepartout/Library/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift b/Passepartout/Library/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift index c37e9ef8..b8558669 100644 --- a/Passepartout/Library/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift +++ b/Passepartout/Library/Sources/UILibrary/Theme/UI/Theme+Modifiers.swift @@ -82,12 +82,14 @@ extension View { public func themeConfirmation( isPresented: Binding, title: String, + message: String? = nil, isDestructive: Bool = false, action: @escaping () -> Void ) -> some View { modifier(ThemeConfirmationModifier( isPresented: isPresented, title: title, + message: message, isDestructive: isDestructive, action: action )) @@ -165,8 +167,8 @@ extension View { .truncationMode(mode) } - public func themeEmptyMessage() -> some View { - modifier(ThemeEmptyMessageModifier()) + public func themeEmptyMessage(fullScreen: Bool = true) -> some View { + modifier(ThemeEmptyMessageModifier(fullScreen: fullScreen)) } public func themeError(_ isError: Bool) -> some View { @@ -178,11 +180,28 @@ extension View { } public func themeProgress(if isProgressing: Bool) -> some View { - modifier(ThemeProgressViewModifier(isProgressing: isProgressing)) + modifier(ThemeProgressViewModifier(isProgressing: isProgressing) { + EmptyView() + }) } - public func themeProgress(if isProgressing: Bool, isEmpty: Bool, emptyMessage: String) -> some View { - modifier(ThemeProgressViewModifier(isProgressing: isProgressing, isEmpty: isEmpty, emptyMessage: emptyMessage)) + public func themeProgress( + if isProgressing: Bool, + isEmpty: Bool, + emptyMessage: String + ) -> some View { + modifier(ThemeProgressViewModifier(isProgressing: isProgressing, isEmpty: isEmpty) { + Text(emptyMessage) + .themeEmptyMessage() + }) + } + + public func themeProgress( + if isProgressing: Bool, + isEmpty: Bool, + @ViewBuilder emptyContent: @escaping () -> EmptyContent + ) -> some View where EmptyContent: View { + modifier(ThemeProgressViewModifier(isProgressing: isProgressing, isEmpty: isEmpty, emptyContent: emptyContent)) } #if !os(tvOS) @@ -319,6 +338,8 @@ struct ThemeConfirmationModifier: ViewModifier { let title: String + let message: String? + let isDestructive: Bool let action: () -> Void @@ -329,7 +350,7 @@ struct ThemeConfirmationModifier: ViewModifier { Button(Strings.Theme.Confirmation.ok, role: isDestructive ? .destructive : nil, action: action) Text(Strings.Theme.Confirmation.cancel) } message: { - Text(Strings.Theme.Confirmation.message) + Text(message ?? Strings.Theme.Confirmation.message) } } } @@ -382,13 +403,19 @@ struct ThemeEmptyMessageModifier: ViewModifier { @EnvironmentObject private var theme: Theme + let fullScreen: Bool + func body(content: Content) -> some View { VStack { - Spacer() + if fullScreen { + Spacer() + } content .font(theme.emptyMessageFont) .foregroundStyle(theme.emptyMessageColor) - Spacer() + if fullScreen { + Spacer() + } } } } @@ -421,12 +448,12 @@ struct ThemeAnimationModifier: ViewModifier where T: Equatable { } } -struct ThemeProgressViewModifier: ViewModifier { +struct ThemeProgressViewModifier: ViewModifier where EmptyContent: View { let isProgressing: Bool var isEmpty: Bool? - var emptyMessage: String? + var emptyContent: (() -> EmptyContent)? func body(content: Content) -> some View { ZStack { @@ -435,9 +462,8 @@ struct ThemeProgressViewModifier: ViewModifier { if isProgressing { ThemeProgressView() - } else if let isEmpty, let emptyMessage, isEmpty { - Text(emptyMessage) - .themeEmptyMessage() + } else if let isEmpty, let emptyContent, isEmpty { + emptyContent() } } } diff --git a/Passepartout/Tests/MigrationManagerTests.swift b/Passepartout/Tests/MigrationManagerTests.swift index 73021a36..f5129ecb 100644 --- a/Passepartout/Tests/MigrationManagerTests.swift +++ b/Passepartout/Tests/MigrationManagerTests.swift @@ -79,7 +79,7 @@ extension MigrationManagerTests { let sut = newManager() let id = try XCTUnwrap(UUID(uuidString: "8A568345-85C4-44C1-A9C4-612E8B07ADC5")) - let migrated = try await sut.migrateProfile(withId: id) + let migrated = try await sut.migratedProfile(withId: id) let profile = try XCTUnwrap(migrated) XCTAssertEqual(profile.id, id) @@ -114,7 +114,7 @@ extension MigrationManagerTests { let sut = newManager() let id = try XCTUnwrap(UUID(uuidString: "981E7CBD-7733-4CF3-9A51-2777614ED5D4")) - let migrated = try await sut.migrateProfile(withId: id) + let migrated = try await sut.migratedProfile(withId: id) let profile = try XCTUnwrap(migrated) XCTAssertEqual(profile.id, id) @@ -138,7 +138,7 @@ extension MigrationManagerTests { let sut = newManager() let id = try XCTUnwrap(UUID(uuidString: "239AD322-7440-4198-990A-D91379916FE2")) - let migrated = try await sut.migrateProfile(withId: id) + let migrated = try await sut.migratedProfile(withId: id) let profile = try XCTUnwrap(migrated) XCTAssertEqual(profile.id, id) @@ -171,7 +171,7 @@ extension MigrationManagerTests { let sut = newManager() let id = try XCTUnwrap(UUID(uuidString: "069F76BD-1F6B-425C-AD83-62477A8B6558")) - let migrated = try await sut.migrateProfile(withId: id) + let migrated = try await sut.migratedProfile(withId: id) let profile = try XCTUnwrap(migrated) XCTAssertEqual(profile.id, id)