mirror of
https://github.com/yattee/yattee.git
synced 2025-08-06 18:54:11 +00:00
Import export settings
This commit is contained in:
187
Shared/Settings/Import/ImportSettingsAccountRow.swift
Normal file
187
Shared/Settings/Import/ImportSettingsAccountRow.swift
Normal file
@@ -0,0 +1,187 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ImportSettingsAccountRow: View {
|
||||
var account: Account
|
||||
var fileModel: ImportSettingsFileModel
|
||||
|
||||
@State private var password = ""
|
||||
|
||||
@State private var isValid = false
|
||||
@State private var isValidated = false
|
||||
@State private var isValidating = false
|
||||
@State private var validationError: String?
|
||||
@State private var validationDebounce = Debounce()
|
||||
|
||||
@ObservedObject private var model = ImportSettingsSheetViewModel.shared
|
||||
|
||||
func afterValidation() {
|
||||
if isValid {
|
||||
model.importableAccounts.insert(account.id)
|
||||
model.selectedAccounts.insert(account.id)
|
||||
model.importableAccountsPasswords[account.id] = password
|
||||
} else {
|
||||
model.selectedAccounts.remove(account.id)
|
||||
model.importableAccounts.remove(account.id)
|
||||
model.importableAccountsPasswords.removeValue(forKey: account.id)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
Button(action: { model.toggleAccount(account, accounts: accounts) }) {
|
||||
let accountExists = AccountsModel.shared.find(account.id) != nil
|
||||
|
||||
VStack(alignment: .leading) {
|
||||
HStack {
|
||||
Text(account.username)
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accentColor)
|
||||
.opacity(isChecked ? 1 : 0)
|
||||
}
|
||||
Text(account.instance?.description ?? "")
|
||||
.font(.caption)
|
||||
.foregroundColor(.secondary)
|
||||
|
||||
Group {
|
||||
if let instanceID = account.instanceID {
|
||||
if accountExists {
|
||||
HStack {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
Text("Account already exists")
|
||||
}
|
||||
} else {
|
||||
Group {
|
||||
if InstancesModel.shared.find(instanceID) != nil {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Custom Location already exists")
|
||||
}
|
||||
} else if model.selectedInstances.contains(instanceID) {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
Text("Custom Location selected for import")
|
||||
}
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text("Custom Location not selected for import")
|
||||
}
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 20)
|
||||
|
||||
if account.password.isNil || account.password!.isEmpty {
|
||||
Group {
|
||||
if password.isEmpty {
|
||||
HStack {
|
||||
Image(systemName: "key")
|
||||
Text("Password required to import")
|
||||
}
|
||||
.foregroundColor(Color("AppRedColor"))
|
||||
} else {
|
||||
AccountValidationStatus(
|
||||
app: .constant(instance.app),
|
||||
isValid: $isValid,
|
||||
isValidated: $isValidated,
|
||||
isValidating: $isValidating,
|
||||
error: $validationError
|
||||
)
|
||||
}
|
||||
}
|
||||
.frame(minHeight: 20)
|
||||
} else {
|
||||
HStack {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundColor(.green)
|
||||
|
||||
Text("Password saved in import file")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.foregroundColor(.primary)
|
||||
.font(.caption)
|
||||
.padding(.vertical, 2)
|
||||
|
||||
if !accountExists && (account.password.isNil || account.password!.isEmpty) {
|
||||
SecureField("Password", text: $password)
|
||||
.onChange(of: password) { _ in validate() }
|
||||
#if !os(tvOS)
|
||||
.textFieldStyle(RoundedBorderTextFieldStyle())
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.contentShape(Rectangle())
|
||||
.onChange(of: isValid) { _ in afterValidation() }
|
||||
.animation(nil, value: isChecked)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
var isChecked: Bool {
|
||||
model.isSelectedForImport(account)
|
||||
}
|
||||
|
||||
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
|
||||
fileModel.locationsSettingsGroupImporter
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
fileModel.locationsSettingsGroupImporter?.accounts ?? []
|
||||
}
|
||||
|
||||
private var instance: Instance! {
|
||||
(fileModel.locationsSettingsGroupImporter?.instances ?? []).first { $0.id == account.instanceID }
|
||||
}
|
||||
|
||||
private var validator: AccountValidator {
|
||||
AccountValidator(
|
||||
app: .constant(instance.app),
|
||||
url: instance.apiURLString,
|
||||
account: Account(instanceID: instance.id, urlString: instance.apiURLString, username: account.username, password: password),
|
||||
id: .constant(account.username),
|
||||
isValid: $isValid,
|
||||
isValidated: $isValidated,
|
||||
isValidating: $isValidating,
|
||||
error: $validationError
|
||||
)
|
||||
}
|
||||
|
||||
private func validate() {
|
||||
isValid = false
|
||||
validationDebounce.invalidate()
|
||||
|
||||
guard !account.username.isEmpty, !password.isEmpty else {
|
||||
validator.reset()
|
||||
return
|
||||
}
|
||||
|
||||
isValidating = true
|
||||
|
||||
validationDebounce.debouncing(1) {
|
||||
validator.validateAccount()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
let fileModel = ImportSettingsFileModel(url: URL(string: "https://gist.githubusercontent.com/arekf/578668969c9fdef1b3828bea864c3956/raw/f794a95a20261bcb1145e656c8dda00bea339e2a/yattee-recents.yatteesettings")!)
|
||||
|
||||
return List {
|
||||
ImportSettingsAccountRow(
|
||||
account: .init(name: "arekf", urlString: "https://instance.com", username: "arekf"),
|
||||
fileModel: fileModel
|
||||
)
|
||||
ImportSettingsAccountRow(
|
||||
account: .init(name: "arekf", urlString: "https://instance.com", username: "arekf", password: "a"),
|
||||
fileModel: fileModel
|
||||
)
|
||||
}
|
||||
}
|
@@ -0,0 +1,30 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
struct ImportSettingsFileImporterViewModifier: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.fileImporter(isPresented: $isPresented, allowedContentTypes: [.json]) { result in
|
||||
do {
|
||||
let selectedFile = try result.get()
|
||||
var urlToOpen: URL?
|
||||
|
||||
if let bookmarkURL = URLBookmarkModel.shared.loadBookmark(selectedFile) {
|
||||
urlToOpen = bookmarkURL
|
||||
}
|
||||
|
||||
if selectedFile.startAccessingSecurityScopedResource() {
|
||||
URLBookmarkModel.shared.saveBookmark(selectedFile)
|
||||
urlToOpen = selectedFile
|
||||
}
|
||||
|
||||
guard let urlToOpen else { return }
|
||||
NavigationModel.shared.presentSettingsImportSheet(urlToOpen, forceSettings: true)
|
||||
} catch {
|
||||
NavigationModel.shared.presentAlert(title: "Could not open Files")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
260
Shared/Settings/Import/ImportSettingsSheetView.swift
Normal file
260
Shared/Settings/Import/ImportSettingsSheetView.swift
Normal file
@@ -0,0 +1,260 @@
|
||||
import SwiftUI
|
||||
|
||||
struct ImportSettingsSheetView: View {
|
||||
@Binding var settingsFile: URL?
|
||||
@StateObject private var model = ImportSettingsSheetViewModel.shared
|
||||
@StateObject private var importExportModel = ImportExportSettingsModel.shared
|
||||
|
||||
@Environment(\.presentationMode) private var presentationMode
|
||||
|
||||
@State private var presentingCompletedAlert = false
|
||||
|
||||
private let accountsModel = AccountsModel.shared
|
||||
|
||||
var body: some View {
|
||||
Group {
|
||||
#if os(macOS)
|
||||
list
|
||||
.frame(width: 700, height: 800)
|
||||
#else
|
||||
NavigationView {
|
||||
list
|
||||
}
|
||||
#endif
|
||||
}
|
||||
.onAppear {
|
||||
guard let fileModel else { return }
|
||||
model.reset(fileModel.locationsSettingsGroupImporter)
|
||||
importExportModel.reset(fileModel)
|
||||
}
|
||||
.onChange(of: settingsFile) { _ in
|
||||
importExportModel.reset(fileModel)
|
||||
}
|
||||
}
|
||||
|
||||
var list: some View {
|
||||
List {
|
||||
importGroupView
|
||||
|
||||
importOptions
|
||||
|
||||
metadata
|
||||
}
|
||||
.alert(isPresented: $presentingCompletedAlert) {
|
||||
completedAlert
|
||||
}
|
||||
#if os(iOS)
|
||||
.backport
|
||||
.scrollDismissesKeyboardInteractively()
|
||||
#endif
|
||||
.navigationTitle("Import Settings")
|
||||
.toolbar {
|
||||
ToolbarItem(placement: .cancellationAction) {
|
||||
Button(action: { presentationMode.wrappedValue.dismiss() }) {
|
||||
Text("Cancel")
|
||||
}
|
||||
}
|
||||
ToolbarItem(placement: .confirmationAction) {
|
||||
Button(action: {
|
||||
fileModel?.performImport()
|
||||
presentingCompletedAlert = true
|
||||
ImportExportSettingsModel.shared.reset()
|
||||
}) {
|
||||
Text("Import")
|
||||
}
|
||||
.disabled(!canImport)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var completedAlert: Alert {
|
||||
Alert(
|
||||
title: Text("Import Completed"),
|
||||
dismissButton: .default(Text("Close")) {
|
||||
if accountsModel.isEmpty,
|
||||
let account = InstancesModel.shared.all.first?.anonymousAccount
|
||||
{
|
||||
accountsModel.setCurrent(account)
|
||||
}
|
||||
presentationMode.wrappedValue.dismiss()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
var canImport: Bool {
|
||||
return !model.selectedAccounts.isEmpty || !model.selectedInstances.isEmpty || !importExportModel.selectedExportGroups.isEmpty
|
||||
}
|
||||
|
||||
var fileModel: ImportSettingsFileModel? {
|
||||
guard let settingsFile else { return nil }
|
||||
|
||||
return ImportSettingsFileModel(url: settingsFile)
|
||||
}
|
||||
|
||||
var locationsSettingsGroupImporter: LocationsSettingsGroupImporter? {
|
||||
guard let fileModel else { return nil }
|
||||
|
||||
return fileModel.locationsSettingsGroupImporter
|
||||
}
|
||||
|
||||
struct ExportGroupRow: View {
|
||||
let group: ImportExportSettingsModel.ExportGroup
|
||||
|
||||
@ObservedObject private var model = ImportExportSettingsModel.shared
|
||||
|
||||
var body: some View {
|
||||
Button(action: { model.toggleExportGroupSelection(group) }) {
|
||||
HStack {
|
||||
Text(group.label)
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundColor(.accent)
|
||||
.opacity(isChecked ? 1 : 0)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.foregroundColor(.primary)
|
||||
.animation(nil, value: isChecked)
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
var isChecked: Bool {
|
||||
model.selectedExportGroups.contains(group)
|
||||
}
|
||||
}
|
||||
|
||||
var importGroupView: some View {
|
||||
Group {
|
||||
Section(header: Text("Settings")) {
|
||||
ForEach(ImportExportSettingsModel.ExportGroup.settingsGroups) { group in
|
||||
ExportGroupRow(group: group)
|
||||
.disabled(!fileModel!.isGroupIncludedInFile(group))
|
||||
}
|
||||
}
|
||||
|
||||
Section(header: Text("Other")) {
|
||||
ForEach(ImportExportSettingsModel.ExportGroup.otherGroups) { group in
|
||||
ExportGroupRow(group: group)
|
||||
.disabled(!fileModel!.isGroupIncludedInFile(group))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var metadata: some View {
|
||||
if let fileModel {
|
||||
Section(header: Text("File information")) {
|
||||
MetadataRow(name: Text("Name"), value: Text(fileModel.filename))
|
||||
|
||||
if let date = fileModel.metadataDate {
|
||||
MetadataRow(name: Text("Date"), value: Text(date))
|
||||
}
|
||||
|
||||
if let build = fileModel.metadataBuild {
|
||||
MetadataRow(name: Text("Build"), value: Text(build))
|
||||
}
|
||||
|
||||
if let platform = fileModel.metadataPlatform {
|
||||
MetadataRow(name: Text("Platform"), value: Text(platform))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct MetadataRow: View {
|
||||
let name: Text
|
||||
let value: Text
|
||||
|
||||
var body: some View {
|
||||
HStack {
|
||||
name
|
||||
.layoutPriority(2)
|
||||
|
||||
Spacer()
|
||||
|
||||
value
|
||||
.layoutPriority(1)
|
||||
.lineLimit(2)
|
||||
.foregroundColor(.secondary)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
var instances: [Instance] {
|
||||
locationsSettingsGroupImporter?.instances ?? []
|
||||
}
|
||||
|
||||
var accounts: [Account] {
|
||||
locationsSettingsGroupImporter?.accounts ?? []
|
||||
}
|
||||
|
||||
struct ImportInstanceRow: View {
|
||||
var instance: Instance
|
||||
var accounts: [Account]
|
||||
|
||||
@ObservedObject private var model = ImportSettingsSheetViewModel.shared
|
||||
|
||||
var body: some View {
|
||||
Button(action: { model.toggleInstance(instance, accounts: accounts) }) {
|
||||
VStack {
|
||||
Group {
|
||||
HStack {
|
||||
Text(instance.description)
|
||||
Spacer()
|
||||
Image(systemName: "checkmark")
|
||||
.opacity(isChecked ? 1 : 0)
|
||||
.foregroundColor(.accentColor)
|
||||
}
|
||||
|
||||
if model.isInstanceAlreadyAdded(instance) {
|
||||
HStack {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.foregroundColor(.red)
|
||||
Text("Custom Location already exists")
|
||||
}
|
||||
.font(.caption)
|
||||
.padding(.vertical, 2)
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
.contentShape(Rectangle())
|
||||
.foregroundColor(.primary)
|
||||
.transaction { t in t.animation = nil }
|
||||
}
|
||||
.buttonStyle(.plain)
|
||||
}
|
||||
|
||||
var isChecked: Bool {
|
||||
model.isImportable(instance) && model.selectedInstances.contains(instance.id)
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder var importOptions: some View {
|
||||
if let fileModel {
|
||||
if fileModel.isPublicInstancesSettingsGroupInFile || !instances.isEmpty {
|
||||
Section(header: Text("Locations")) {
|
||||
if fileModel.isPublicInstancesSettingsGroupInFile {
|
||||
ExportGroupRow(group: .locationsSettings)
|
||||
}
|
||||
|
||||
ForEach(instances) { instance in
|
||||
ImportInstanceRow(instance: instance, accounts: accounts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if !accounts.isEmpty {
|
||||
Section(header: Text("Accounts")) {
|
||||
ForEach(accounts) { account in
|
||||
ImportSettingsAccountRow(account: account, fileModel: fileModel)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
ImportSettingsSheetView(settingsFile: .constant(URL(string: "https://gist.githubusercontent.com/arekf/578668969c9fdef1b3828bea864c3956/raw/f794a95a20261bcb1145e656c8dda00bea339e2a/yattee-recents.yatteesettings")!))
|
||||
}
|
77
Shared/Settings/Import/ImportSettingsSheetViewModel.swift
Normal file
77
Shared/Settings/Import/ImportSettingsSheetViewModel.swift
Normal file
@@ -0,0 +1,77 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
|
||||
class ImportSettingsSheetViewModel: ObservableObject {
|
||||
static let shared = ImportSettingsSheetViewModel()
|
||||
|
||||
@Published var selectedInstances = Set<Instance.ID>()
|
||||
@Published var selectedAccounts = Set<Account.ID>()
|
||||
|
||||
@Published var importableAccounts = Set<Account.ID>()
|
||||
@Published var importableAccountsPasswords = [Account.ID: String]()
|
||||
|
||||
func toggleInstance(_ instance: Instance, accounts: [Account]) {
|
||||
if selectedInstances.contains(instance.id) {
|
||||
selectedInstances.remove(instance.id)
|
||||
} else {
|
||||
guard isImportable(instance) else { return }
|
||||
selectedInstances.insert(instance.id)
|
||||
}
|
||||
|
||||
removeNonImportableFromSelectedAccounts(accounts: accounts)
|
||||
}
|
||||
|
||||
func toggleAccount(_ account: Account, accounts: [Account]) {
|
||||
if selectedAccounts.contains(account.id) {
|
||||
selectedAccounts.remove(account.id)
|
||||
} else {
|
||||
guard isImportable(account.id, accounts: accounts) else { return }
|
||||
selectedAccounts.insert(account.id)
|
||||
}
|
||||
}
|
||||
|
||||
func isSelectedForImport(_ account: Account) -> Bool {
|
||||
importableAccounts.contains(account.id) && selectedAccounts.contains(account.id)
|
||||
}
|
||||
|
||||
func isImportable(_ accountID: Account.ID, accounts: [Account]) -> Bool {
|
||||
guard let account = accounts.first(where: { $0.id == accountID }),
|
||||
let instanceID = account.instanceID,
|
||||
AccountsModel.shared.find(accountID) == nil
|
||||
else { return false }
|
||||
|
||||
return ((account.password != nil && !account.password!.isEmpty) ||
|
||||
importableAccounts.contains(account.id)) && (
|
||||
(InstancesModel.shared.find(instanceID) != nil) ||
|
||||
selectedInstances.contains(instanceID)
|
||||
)
|
||||
}
|
||||
|
||||
func isImportable(_ instance: Instance) -> Bool {
|
||||
!isInstanceAlreadyAdded(instance)
|
||||
}
|
||||
|
||||
func isInstanceAlreadyAdded(_ instance: Instance) -> Bool {
|
||||
InstancesModel.shared.find(instance.id) != nil || InstancesModel.shared.findByURLString(instance.apiURLString) != nil
|
||||
}
|
||||
|
||||
func removeNonImportableFromSelectedAccounts(accounts: [Account]) {
|
||||
selectedAccounts = Set(selectedAccounts.filter { isImportable($0, accounts: accounts) })
|
||||
}
|
||||
|
||||
func reset() {
|
||||
selectedAccounts = []
|
||||
selectedInstances = []
|
||||
importableAccounts = []
|
||||
}
|
||||
|
||||
func reset(_ importer: LocationsSettingsGroupImporter? = nil) {
|
||||
reset()
|
||||
|
||||
guard let importer else { return }
|
||||
|
||||
selectedInstances = Set(importer.instances.filter { isImportable($0) }.map(\.id))
|
||||
importableAccounts = Set(importer.accounts.filter { isImportable($0.id, accounts: importer.accounts) }.map(\.id))
|
||||
selectedAccounts = importableAccounts
|
||||
}
|
||||
}
|
25
Shared/Settings/Import/ImportSettingsSheetViewModifier.swift
Normal file
25
Shared/Settings/Import/ImportSettingsSheetViewModifier.swift
Normal file
@@ -0,0 +1,25 @@
|
||||
import Foundation
|
||||
import SwiftUI
|
||||
import SwiftyJSON
|
||||
|
||||
struct ImportSettingsSheetViewModifier: ViewModifier {
|
||||
@Binding var isPresented: Bool
|
||||
@Binding var settingsFile: URL?
|
||||
|
||||
func body(content: Content) -> some View {
|
||||
content
|
||||
.sheet(isPresented: $isPresented) {
|
||||
ImportSettingsSheetView(settingsFile: $settingsFile)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#Preview {
|
||||
Text("")
|
||||
.modifier(
|
||||
ImportSettingsSheetViewModifier(
|
||||
isPresented: .constant(true),
|
||||
settingsFile: .constant(URL(string: "https://gist.githubusercontent.com/arekf/87b4d6702755b01139431dcb809f9fdc/raw/7bb5cdba3ffc0c479f5260430ddc43c4a79a7a72/yattee-177-iPhone.yatteesettings")!)
|
||||
)
|
||||
)
|
||||
}
|
Reference in New Issue
Block a user