passepartout-apple/Passepartout/Library/Sources/AppUIMain/Views/Migration/MigrateView.swift
Davide 3737560851
Add the option to migrate old profiles (#879)
Finalize migration flow:

- Add entry to "Add" menu
- Suggest to migrate old profiles when there are no profiles
- Add informational message
- Keep included profiles on top
- Allow deletion of migratable profiles
- Fix duplicated Form in preview
- Rename views and models

Improve some Theme modifiers:

- Empty message with full screen option
- Progress modifier with custom view
- Confirmation dialog with custom message
2024-11-16 12:29:03 +01:00

239 lines
6.9 KiB
Swift

//
// MigrateView.swift
// Passepartout
//
// Created by Davide De Rosa on 11/13/24.
// Copyright (c) 2024 Davide De Rosa. All rights reserved.
//
// https://github.com/passepartoutvpn
//
// This file is part of Passepartout.
//
// Passepartout is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Passepartout is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Passepartout. If not, see <http://www.gnu.org/licenses/>.
//
import CommonLibrary
import CommonUtils
import PassepartoutKit
import SwiftUI
// FIXME: #878, show CloudKit progress
struct MigrateView: View {
enum Style {
case list
case table
}
@EnvironmentObject
private var migrationManager: MigrationManager
@Environment(\.dismiss)
private var dismiss
let style: Style
@ObservedObject
var profileManager: ProfileManager
@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 {
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
)
.navigationTitle(title)
.task {
await fetch()
}
.withErrorHandler(errorHandler)
}
}
private extension MigrateView {
var title: String {
Strings.Views.Migrate.title
}
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)
}
}
}
}
private extension MigrateView {
func onDelete(_ profiles: [MigratableProfile]) {
profilesPendingDeletion = profiles
isDeleting = true
}
func perform(at step: MigrateViewStep) async {
switch step {
case .fetched(let profiles):
await migrate(profiles)
case .migrated(let profiles):
await save(profiles)
case .imported:
dismiss()
default:
assertionFailure("No action allowed at step \(step), why is button enabled?")
}
}
func fetch() async {
guard model.step == .initial else {
return
}
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.profiles)
} catch {
pp_log(.App.migration, .error, "Unable to fetch migratable profiles: \(error)")
errorHandler.handle(error, title: title)
model.step = .initial
}
}
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 {
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(_ 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
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
}
}
}