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
This commit is contained in:
parent
9ca103e949
commit
3737560851
|
@ -152,3 +152,10 @@ private extension DonateView {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Previews
|
||||
|
||||
#Preview {
|
||||
DonateView()
|
||||
.withMockEnvironment()
|
||||
}
|
||||
|
|
|
@ -41,8 +41,7 @@ struct AddProfileMenu: View {
|
|||
Menu {
|
||||
newProfileButton
|
||||
importProfileButton
|
||||
// FIXME: ###, migrations UI
|
||||
// migrateProfilesButton
|
||||
migrateProfilesButton
|
||||
} label: {
|
||||
ThemeImage(.add)
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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) {
|
||||
|
|
|
@ -30,4 +30,6 @@ struct ProfileFlow {
|
|||
let onEditProfile: (ProfileHeader) -> Void
|
||||
|
||||
let onEditProviderEntity: (Profile) -> Void
|
||||
|
||||
let onMigrateProfiles: () -> Void
|
||||
}
|
||||
|
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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<UUID> = []
|
||||
|
||||
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<Bool> {
|
||||
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<UUID>
|
||||
|
||||
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
|
||||
|
|
@ -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<Bool> {
|
||||
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
|
|
@ -27,33 +27,43 @@ import CommonLibrary
|
|||
import PassepartoutKit
|
||||
import SwiftUI
|
||||
|
||||
extension MigrateView {
|
||||
struct ContentView: View {
|
||||
let style: Style
|
||||
struct MigrateContentView<PerformButton>: 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 {
|
|
@ -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<UUID> {
|
||||
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
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||
//
|
||||
|
||||
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
|
||||
}
|
||||
}
|
|
@ -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<UUID>,
|
||||
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<UUID>) 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<UUID>) 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<UUID>) async throws {
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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<UUID>) async throws
|
||||
}
|
||||
|
|
|
@ -92,6 +92,19 @@ final class CDProfileRepositoryV2: Sendable {
|
|||
)
|
||||
return profiles
|
||||
}
|
||||
|
||||
func deleteProfiles(withIds profileIds: Set<UUID>) 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 {
|
||||
|
|
|
@ -71,6 +71,10 @@ extension ProfileV2MigrationStrategy {
|
|||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
public func deleteProfiles(withIds profileIds: Set<UUID>) async throws {
|
||||
try await profilesRepository.deleteProfiles(withIds: profileIds)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Internal
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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.";
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -82,12 +82,14 @@ extension View {
|
|||
public func themeConfirmation(
|
||||
isPresented: Binding<Bool>,
|
||||
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<EmptyContent>(
|
||||
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<T>: ViewModifier where T: Equatable {
|
|||
}
|
||||
}
|
||||
|
||||
struct ThemeProgressViewModifier: ViewModifier {
|
||||
struct ThemeProgressViewModifier<EmptyContent>: 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
Loading…
Reference in New Issue