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:
Davide 2024-11-16 12:29:03 +01:00 committed by GitHub
parent 9ca103e949
commit 3737560851
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
21 changed files with 612 additions and 200 deletions

View File

@ -152,3 +152,10 @@ private extension DonateView {
}
}
}
// MARK: - Previews
#Preview {
DonateView()
.withMockEnvironment()
}

View File

@ -41,8 +41,7 @@ struct AddProfileMenu: View {
Menu {
newProfileButton
importProfileButton
// FIXME: ###, migrations UI
// migrateProfilesButton
migrateProfilesButton
} label: {
ThemeImage(.add)
}

View File

@ -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

View File

@ -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) {

View File

@ -30,4 +30,6 @@ struct ProfileFlow {
let onEditProfile: (ProfileHeader) -> Void
let onEditProviderEntity: (Profile) -> Void
let onMigrateProfiles: () -> Void
}

View File

@ -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
}
}
}

View File

@ -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

View File

@ -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

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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
}
}
}

View File

@ -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
}
}

View File

@ -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 {
}
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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

View File

@ -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

View File

@ -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.";

View File

@ -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"

View File

@ -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()
}
}
}

View File

@ -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)