Import export settings

This commit is contained in:
Arkadiusz Fal
2024-02-01 23:54:16 +01:00
parent 9826ee4d36
commit 2be6f04e37
46 changed files with 2801 additions and 169 deletions

View File

@@ -153,7 +153,7 @@ struct AccountForm: View {
return
}
let account = AccountsModel.add(instance: instance, name: name, username: username, password: password)
let account = AccountsModel.add(instance: instance, id: nil, name: name, username: username, password: password)
selectedAccount?.wrappedValue = account
presentationMode.wrappedValue.dismiss()

View File

@@ -0,0 +1,165 @@
import SwiftUI
struct ExportSettings: View {
@ObservedObject private var model = ImportExportSettingsModel.shared
@State private var presentingShareSheet = false
@StateObject private var settings = SettingsModel.shared
private var filesToShare = [ImportExportSettingsModel.exportFile]
@ObservedObject private var navigation = NavigationModel.shared
var body: some View {
Group {
#if os(macOS)
VStack {
list
importExportButtons
}
#else
list
#if os(iOS)
.listStyle(.insetGrouped)
.sheet(
isPresented: $presentingShareSheet,
onDismiss: { self.model.isExportInProgress = false }
) {
ShareSheet(activityItems: filesToShare)
.id("settings-share-\(filesToShare.count)")
}
#endif
#endif
}
.navigationTitle("Export Settings")
}
var list: some View {
List {
exportView
}
.onAppear {
model.reset()
}
}
var importExportButtons: some View {
HStack {
importButton
Spacer()
exportButton
}
}
@ViewBuilder var importButton: some View {
#if os(macOS)
Button {
navigation.presentingSettingsFileImporter = true
} label: {
Label("Import", systemImage: "square.and.arrow.down")
}
#endif
}
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(isGroupInSelectedGroups ? 1 : 0)
}
.animation(nil, value: isGroupInSelectedGroups)
.contentShape(Rectangle())
}
}
var isGroupInSelectedGroups: Bool {
model.selectedExportGroups.contains(group)
}
}
var exportView: some View {
Group {
Section(header: Text("Settings")) {
ForEach(ImportExportSettingsModel.ExportGroup.settingsGroups) { group in
ExportGroupRow(group: group)
}
}
Section(header: Text("Locations")) {
ForEach(ImportExportSettingsModel.ExportGroup.locationsGroups) { group in
ExportGroupRow(group: group)
.disabled(!model.isGroupEnabled(group))
}
}
Section(header: Text("Other"), footer: otherGroupsFooter) {
ForEach(ImportExportSettingsModel.ExportGroup.otherGroups) { group in
ExportGroupRow(group: group)
}
}
#if !os(macOS)
Section {
exportButton
}
#endif
}
.buttonStyle(.plain)
.disabled(model.isExportInProgress)
}
var exportButton: some View {
Button(action: exportSettings) {
Label(model.isExportInProgress ? "Export in progress..." : "Export...", systemImage: model.isExportInProgress ? "fireworks" : "square.and.arrow.up")
.animation(nil, value: model.isExportInProgress)
#if !os(macOS)
.foregroundColor(.accent)
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
#endif
}
.disabled(!model.isExportAvailable)
}
@ViewBuilder var otherGroupsFooter: some View {
Text("Other data include last used playback preferences and listing options")
}
func exportSettings() {
let export = {
model.isExportInProgress = true
Delay.by(0.3) {
model.exportAction()
#if !os(macOS)
self.presentingShareSheet = true
#endif
}
}
if model.isGroupSelected(.accountsUnencryptedPasswords) {
settings.presentAlert(Alert(
title: Text("Are you sure you want to export unencrypted passwords?"),
message: Text("Do not share this file with anyone or you can lose access to your accounts. If you don't select to export passwords you will be asked to provide them during import"),
primaryButton: .destructive(Text("Export"), action: export),
secondaryButton: .cancel()
))
} else {
export()
}
}
}
#Preview {
NavigationView {
ExportSettings()
}
}

View 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
)
}
}

View File

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

View 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")!))
}

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

View 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")!)
)
)
}

View File

@@ -7,7 +7,7 @@ struct SettingsView: View {
#if os(macOS)
private enum Tabs: Hashable {
case browsing, player, controls, quality, history, sponsorBlock, locations, advanced, help
case browsing, player, controls, quality, history, sponsorBlock, locations, advanced, importExport, help
}
@State private var selection: Tabs = .browsing
@@ -24,13 +24,22 @@ struct SettingsView: View {
@Default(.instances) private var instances
@State private var filesToShare = []
@ObservedObject private var navigation = NavigationModel.shared
@ObservedObject private var settingsModel = SettingsModel.shared
var body: some View {
settings
.alert(isPresented: $model.presentingAlert) { model.alert }
#if os(iOS)
.backport
.scrollDismissesKeyboardInteractively()
#if !os(tvOS)
.modifier(ImportSettingsFileImporterViewModifier(isPresented: $navigation.presentingSettingsFileImporter))
.modifier(ImportSettingsSheetViewModifier(isPresented: $settingsModel.presentingSettingsImportSheet, settingsFile: $settingsModel.settingsImportURL))
#endif
#if os(iOS)
.backport
.scrollDismissesKeyboardInteractively()
#endif
.alert(isPresented: $model.presentingAlert) { model.alert }
}
var settings: some View {
@@ -101,6 +110,14 @@ struct SettingsView: View {
}
.tag(Tabs.advanced)
Group {
ExportSettings()
}
.tabItem {
Label("Export", systemImage: "square.and.arrow.up")
}
.tag(Tabs.importExport)
Form {
Help()
}
@@ -110,7 +127,7 @@ struct SettingsView: View {
.tag(Tabs.help)
}
.padding(20)
.frame(width: 650, height: windowHeight)
.frame(width: 700, height: windowHeight)
#else
NavigationView {
settingsList
@@ -206,6 +223,8 @@ struct SettingsView: View {
.padding(.horizontal, 20)
#endif
importView
Section(footer: helpFooter) {
NavigationLink {
Help()
@@ -260,6 +279,28 @@ struct SettingsView: View {
}
#endif
var importView: some View {
Section {
Button(action: importSettings) {
Label("Import Settings...", systemImage: "square.and.arrow.down")
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
.foregroundColor(.accent)
.buttonStyle(.plain)
NavigationLink(destination: LazyView(ExportSettings())) {
Label("Export Settings", systemImage: "square.and.arrow.up")
.frame(maxWidth: .infinity, alignment: .leading)
.contentShape(Rectangle())
}
}
}
func importSettings() {
navigation.presentingSettingsFileImporter = true
}
#if os(macOS)
private var windowHeight: Double {
switch selection {
@@ -278,7 +319,9 @@ struct SettingsView: View {
case .locations:
return 600
case .advanced:
return 380
return 500
case .importExport:
return 580
case .help:
return 650
}