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)