Debouncing and form validation improvements

This commit is contained in:
Arkadiusz Fal 2021-09-26 22:12:43 +02:00
parent f9396985c9
commit a0f74a5899
10 changed files with 168 additions and 96 deletions

View File

@ -0,0 +1,11 @@
import Foundation
extension String {
var serializationSafe: String {
let serializationUnsafe = ":;"
let forbidden = CharacterSet(charactersIn: serializationUnsafe)
let result = unicodeScalars.filter { !forbidden.contains($0) }
return String(String.UnicodeScalarView(result))
}
}

View File

@ -2,7 +2,7 @@ import Foundation
import Siesta import Siesta
import SwiftUI import SwiftUI
final class InstanceAccountValidator: Service { final class AccountValidator: Service {
let url: String let url: String
let account: Instance.Account? let account: Instance.Account?
@ -14,14 +14,14 @@ final class InstanceAccountValidator: Service {
init( init(
url: String, url: String,
account: Instance.Account? = nil, account: Instance.Account? = nil,
formObjectID: Binding<String>, id: Binding<String>,
valid: Binding<Bool>, valid: Binding<Bool>,
validated: Binding<Bool>, validated: Binding<Bool>,
error: Binding<String?>? = nil error: Binding<String?>? = nil
) { ) {
self.url = url self.url = url
self.account = account self.account = account
self.formObjectID = formObjectID formObjectID = id
self.valid = valid self.valid = valid
self.validated = validated self.validated = validated
self.error = error self.error = error

View File

@ -74,9 +74,15 @@
37484C2D26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */; }; 37484C2D26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */; };
37484C2E26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */; }; 37484C2E26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */; };
37484C2F26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */; }; 37484C2F26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */; };
37484C3126FCB8F900287258 /* InstanceAccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* InstanceAccountValidator.swift */; }; 37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* AccountValidator.swift */; };
37484C3226FCB8F900287258 /* InstanceAccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* InstanceAccountValidator.swift */; }; 37484C3226FCB8F900287258 /* AccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* AccountValidator.swift */; };
37484C3326FCB8F900287258 /* InstanceAccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* InstanceAccountValidator.swift */; }; 37484C3326FCB8F900287258 /* AccountValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 37484C3026FCB8F900287258 /* AccountValidator.swift */; };
375168D62700FAFF008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; };
375168D72700FDB8008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; };
375168D82700FDB9008F96A6 /* Debounce.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D52700FAFF008F96A6 /* Debounce.swift */; };
375168DB27010806008F96A6 /* String+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D92701070E008F96A6 /* String+Format.swift */; };
375168DC27010807008F96A6 /* String+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D92701070E008F96A6 /* String+Format.swift */; };
375168DD27010808008F96A6 /* String+Format.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375168D92701070E008F96A6 /* String+Format.swift */; };
375DFB5826F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; }; 375DFB5826F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; };
375DFB5926F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; }; 375DFB5926F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; };
375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; }; 375DFB5A26F9DA010013F468 /* InstancesModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 375DFB5726F9DA010013F468 /* InstancesModel.swift */; };
@ -324,7 +330,9 @@
37484C2426FC83E000287258 /* InstanceFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceFormView.swift; sourceTree = "<group>"; }; 37484C2426FC83E000287258 /* InstanceFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceFormView.swift; sourceTree = "<group>"; };
37484C2826FC83FF00287258 /* AccountFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFormView.swift; sourceTree = "<group>"; }; 37484C2826FC83FF00287258 /* AccountFormView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountFormView.swift; sourceTree = "<group>"; };
37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceDetailsSettingsView.swift; sourceTree = "<group>"; }; 37484C2C26FC844700287258 /* InstanceDetailsSettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceDetailsSettingsView.swift; sourceTree = "<group>"; };
37484C3026FCB8F900287258 /* InstanceAccountValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstanceAccountValidator.swift; sourceTree = "<group>"; }; 37484C3026FCB8F900287258 /* AccountValidator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AccountValidator.swift; sourceTree = "<group>"; };
375168D52700FAFF008F96A6 /* Debounce.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Debounce.swift; sourceTree = "<group>"; };
375168D92701070E008F96A6 /* String+Format.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "String+Format.swift"; sourceTree = "<group>"; };
375DFB5726F9DA010013F468 /* InstancesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesModel.swift; sourceTree = "<group>"; }; 375DFB5726F9DA010013F468 /* InstancesModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InstancesModel.swift; sourceTree = "<group>"; };
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = "<group>"; }; 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = EnvironmentValues.swift; sourceTree = "<group>"; };
3761AC0E26F0F9A600AA496F /* UnsubscribeAlertModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnsubscribeAlertModifier.swift; sourceTree = "<group>"; }; 3761AC0E26F0F9A600AA496F /* UnsubscribeAlertModifier.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UnsubscribeAlertModifier.swift; sourceTree = "<group>"; };
@ -640,6 +648,7 @@
379775922689365600DD52A8 /* Array+Next.swift */, 379775922689365600DD52A8 /* Array+Next.swift */,
376578842685429C00D4EA09 /* CaseIterable+Next.swift */, 376578842685429C00D4EA09 /* CaseIterable+Next.swift */,
37BA794E26DC3E0E002A0235 /* Int+Format.swift */, 37BA794E26DC3E0E002A0235 /* Int+Format.swift */,
375168D92701070E008F96A6 /* String+Format.swift */,
377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */, 377A20A82693C9A2002842B8 /* TypedContentAccessors.swift */,
); );
path = Extensions; path = Extensions;
@ -676,6 +685,7 @@
371AAE2726CEBF4700901972 /* Videos */, 371AAE2726CEBF4700901972 /* Videos */,
371AAE2826CEC7D900901972 /* Views */, 371AAE2826CEC7D900901972 /* Views */,
3788AC2126F683AB00F6BAA9 /* Watch Now */, 3788AC2126F683AB00F6BAA9 /* Watch Now */,
375168D52700FAFF008F96A6 /* Debounce.swift */,
372915E52687E3B900F5A35B /* Defaults.swift */, 372915E52687E3B900F5A35B /* Defaults.swift */,
3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */, 3761ABFC26F0F8DE00AA496F /* EnvironmentValues.swift */,
37D4B0C22671614700C925CA /* PearvidiousApp.swift */, 37D4B0C22671614700C925CA /* PearvidiousApp.swift */,
@ -741,7 +751,7 @@
37AAF28F26740715007FC770 /* Channel.swift */, 37AAF28F26740715007FC770 /* Channel.swift */,
37141672267A8E10006CA35D /* Country.swift */, 37141672267A8E10006CA35D /* Country.swift */,
378E50FA26FE8B9F00F49626 /* Instance.swift */, 378E50FA26FE8B9F00F49626 /* Instance.swift */,
37484C3026FCB8F900287258 /* InstanceAccountValidator.swift */, 37484C3026FCB8F900287258 /* AccountValidator.swift */,
375DFB5726F9DA010013F468 /* InstancesModel.swift */, 375DFB5726F9DA010013F468 /* InstancesModel.swift */,
37977582268922F600DD52A8 /* InvidiousAPI.swift */, 37977582268922F600DD52A8 /* InvidiousAPI.swift */,
371F2F19269B43D300E4A7AB /* NavigationModel.swift */, 371F2F19269B43D300E4A7AB /* NavigationModel.swift */,
@ -1069,6 +1079,7 @@
37BA793F26DB8F97002A0235 /* ChannelVideosView.swift in Sources */, 37BA793F26DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
37754C9D26B7500000DBD602 /* VideosView.swift in Sources */, 37754C9D26B7500000DBD602 /* VideosView.swift in Sources */,
37C194C726F6A9C8005D3B96 /* RecentsModel.swift in Sources */, 37C194C726F6A9C8005D3B96 /* RecentsModel.swift in Sources */,
375168DD27010808008F96A6 /* String+Format.swift in Sources */,
37484C1926FC837400287258 /* PlaybackSettingsView.swift in Sources */, 37484C1926FC837400287258 /* PlaybackSettingsView.swift in Sources */,
3711403F26B206A6005B3555 /* SearchModel.swift in Sources */, 3711403F26B206A6005B3555 /* SearchModel.swift in Sources */,
37F64FE426FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */, 37F64FE426FE70A60081B69E /* RedrawOnViewModifier.swift in Sources */,
@ -1084,6 +1095,7 @@
377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */, 377FC7DC267A081800A6BBAF /* PopularView.swift in Sources */,
3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */, 3705B182267B4E4900704544 /* TrendingCategory.swift in Sources */,
37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */, 37EAD86F267B9ED100D9E01B /* Segment.swift in Sources */,
375168D62700FAFF008F96A6 /* Debounce.swift in Sources */,
37E64DD126D597EB00C71877 /* SubscriptionsModel.swift in Sources */, 37E64DD126D597EB00C71877 /* SubscriptionsModel.swift in Sources */,
376578892685471400D4EA09 /* Playlist.swift in Sources */, 376578892685471400D4EA09 /* Playlist.swift in Sources */,
37B81B0526D2CEDA00675966 /* PlaybackModel.swift in Sources */, 37B81B0526D2CEDA00675966 /* PlaybackModel.swift in Sources */,
@ -1103,7 +1115,7 @@
376578912685490700D4EA09 /* PlaylistsView.swift in Sources */, 376578912685490700D4EA09 /* PlaylistsView.swift in Sources */,
37484C2D26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */, 37484C2D26FC844700287258 /* InstanceDetailsSettingsView.swift in Sources */,
377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */, 377A20A92693C9A2002842B8 /* TypedContentAccessors.swift in Sources */,
37484C3126FCB8F900287258 /* InstanceAccountValidator.swift in Sources */, 37484C3126FCB8F900287258 /* AccountValidator.swift in Sources */,
376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */, 376B2E0726F920D600B1D64D /* SignInRequiredView.swift in Sources */,
37BD672426F13D65004BE0C1 /* AppSidebarPlaylists.swift in Sources */, 37BD672426F13D65004BE0C1 /* AppSidebarPlaylists.swift in Sources */,
37B17DA2268A1F8A006AEE9B /* VideoContextMenuView.swift in Sources */, 37B17DA2268A1F8A006AEE9B /* VideoContextMenuView.swift in Sources */,
@ -1161,7 +1173,7 @@
37B81B0026D2CA3700675966 /* VideoDetails.swift in Sources */, 37B81B0026D2CA3700675966 /* VideoDetails.swift in Sources */,
37484C1A26FC837400287258 /* PlaybackSettingsView.swift in Sources */, 37484C1A26FC837400287258 /* PlaybackSettingsView.swift in Sources */,
37BD07C32698AD4F003EBB87 /* ContentView.swift in Sources */, 37BD07C32698AD4F003EBB87 /* ContentView.swift in Sources */,
37484C3226FCB8F900287258 /* InstanceAccountValidator.swift in Sources */, 37484C3226FCB8F900287258 /* AccountValidator.swift in Sources */,
37F49BA426CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */, 37F49BA426CAA59B00304AC0 /* Playlist+Fixtures.swift in Sources */,
37EAD870267B9ED100D9E01B /* Segment.swift in Sources */, 37EAD870267B9ED100D9E01B /* Segment.swift in Sources */,
3788AC2826F6840700F6BAA9 /* WatchNowSection.swift in Sources */, 3788AC2826F6840700F6BAA9 /* WatchNowSection.swift in Sources */,
@ -1202,9 +1214,11 @@
37754C9E26B7500000DBD602 /* VideosView.swift in Sources */, 37754C9E26B7500000DBD602 /* VideosView.swift in Sources */,
3797758C2689345500DD52A8 /* Store.swift in Sources */, 3797758C2689345500DD52A8 /* Store.swift in Sources */,
37141674267A8E10006CA35D /* Country.swift in Sources */, 37141674267A8E10006CA35D /* Country.swift in Sources */,
375168DC27010807008F96A6 /* String+Format.swift in Sources */,
37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */, 37AAF2A126741C97007FC770 /* SubscriptionsView.swift in Sources */,
37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */, 37BA794C26DC30EC002A0235 /* AppSidebarPlaylists.swift in Sources */,
37D4B19826717E1500C925CA /* Video.swift in Sources */, 37D4B19826717E1500C925CA /* Video.swift in Sources */,
375168D72700FDB8008F96A6 /* Debounce.swift in Sources */,
37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */, 37D4B0E52671614900C925CA /* PearvidiousApp.swift in Sources */,
37BD07C12698AD3B003EBB87 /* TrendingCountry.swift in Sources */, 37BD07C12698AD3B003EBB87 /* TrendingCountry.swift in Sources */,
37BA794026DB8F97002A0235 /* ChannelVideosView.swift in Sources */, 37BA794026DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
@ -1263,6 +1277,7 @@
376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */, 376B2E0926F920D600B1D64D /* SignInRequiredView.swift in Sources */,
37141671267A8ACC006CA35D /* TrendingView.swift in Sources */, 37141671267A8ACC006CA35D /* TrendingView.swift in Sources */,
3788AC2926F6840700F6BAA9 /* WatchNowSection.swift in Sources */, 3788AC2926F6840700F6BAA9 /* WatchNowSection.swift in Sources */,
375168D82700FDB9008F96A6 /* Debounce.swift in Sources */,
37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */, 37BA794126DB8F97002A0235 /* ChannelVideosView.swift in Sources */,
37AAF29226740715007FC770 /* Channel.swift in Sources */, 37AAF29226740715007FC770 /* Channel.swift in Sources */,
37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */, 37EAD86D267B9C5600D9E01B /* SponsorBlockAPI.swift in Sources */,
@ -1280,6 +1295,7 @@
37AAF27E26737323007FC770 /* PopularView.swift in Sources */, 37AAF27E26737323007FC770 /* PopularView.swift in Sources */,
37A9966026D6F9B9006E3224 /* WatchNowView.swift in Sources */, 37A9966026D6F9B9006E3224 /* WatchNowView.swift in Sources */,
37484C1F26FC83A400287258 /* InstancesSettingsView.swift in Sources */, 37484C1F26FC83A400287258 /* InstancesSettingsView.swift in Sources */,
375168DB27010806008F96A6 /* String+Format.swift in Sources */,
37AAF29A26740A01007FC770 /* VideosListView.swift in Sources */, 37AAF29A26740A01007FC770 /* VideosListView.swift in Sources */,
37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */, 37C7A1D7267BFD9D0010EAD6 /* SponsorBlockSegment.swift in Sources */,
376578932685490700D4EA09 /* PlaylistsView.swift in Sources */, 376578932685490700D4EA09 /* PlaylistsView.swift in Sources */,
@ -1291,7 +1307,7 @@
37BA794526DBA973002A0235 /* PlaylistsModel.swift in Sources */, 37BA794526DBA973002A0235 /* PlaylistsModel.swift in Sources */,
37B17DA0268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */, 37B17DA0268A1F89006AEE9B /* VideoContextMenuView.swift in Sources */,
37BE0BD726A1D4A90092E2DB /* PlayerViewController.swift in Sources */, 37BE0BD726A1D4A90092E2DB /* PlayerViewController.swift in Sources */,
37484C3326FCB8F900287258 /* InstanceAccountValidator.swift in Sources */, 37484C3326FCB8F900287258 /* AccountValidator.swift in Sources */,
37CEE4C32677B697005A1EFE /* Stream.swift in Sources */, 37CEE4C32677B697005A1EFE /* Stream.swift in Sources */,
37484C2326FC83C400287258 /* AccountSettingsView.swift in Sources */, 37484C2326FC83C400287258 /* AccountSettingsView.swift in Sources */,
37C194C926F6A9C8005D3B96 /* RecentsModel.swift in Sources */, 37C194C926F6A9C8005D3B96 /* RecentsModel.swift in Sources */,

15
Shared/Debounce.swift Normal file
View File

@ -0,0 +1,15 @@
import Foundation
struct Debounce {
private var timer: Timer?
mutating func debouncing(_ interval: TimeInterval, action: @escaping () -> Void) {
timer = Timer.scheduledTimer(withTimeInterval: interval, repeats: false) { _ in
action()
}
}
func invalidate() {
timer?.invalidate()
}
}

View File

@ -8,12 +8,12 @@ struct AccountsMenuView: View {
var body: some View { var body: some View {
Menu { Menu {
ForEach(instances, id: \.self) { instance in ForEach(instances) { instance in
Button(accountButtonTitle(instance: instance, account: instance.anonymousAccount)) { Button(accountButtonTitle(instance: instance, account: instance.anonymousAccount)) {
api.setAccount(instance.anonymousAccount) api.setAccount(instance.anonymousAccount)
} }
ForEach(instance.accounts, id: \.self) { account in ForEach(instance.accounts) { account in
Button(accountButtonTitle(instance: instance, account: account)) { Button(accountButtonTitle(instance: instance, account: account)) {
api.setAccount(account) api.setAccount(account)
} }

View File

@ -10,6 +10,7 @@ struct AccountFormView: View {
@State private var valid = false @State private var valid = false
@State private var validated = false @State private var validated = false
@State private var validationDebounce = Debounce()
@FocusState private var focused: Bool @FocusState private var focused: Bool
@ -19,55 +20,10 @@ struct AccountFormView: View {
var body: some View { var body: some View {
VStack { VStack {
HStack(alignment: .center) { header
Text("Add Account") form
.font(.title2.bold()) footer
Spacer()
Button("Cancel") {
dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
}
.padding(.horizontal)
Form {
TextField("Name", text: $name, prompt: Text("Account Name (optional)"))
.focused($focused)
TextField("SID", text: $sid, prompt: Text("Invidious SID Cookie"))
}
.onAppear(perform: initializeForm)
.onChange(of: sid) { _ in validate() }
#if os(macOS)
.padding(.horizontal)
#endif
HStack {
HStack(spacing: 4) {
Image(systemName: valid ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundColor(valid ? .green : .red)
VStack(alignment: .leading) {
Text(valid ? "Account found" : "Invalid account details")
}
}
.opacity(validated ? 1 : 0)
Spacer()
Button("Save", action: submitForm)
.disabled(!valid)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
}
.frame(minHeight: 35)
.padding(.horizontal)
} }
#if os(iOS) #if os(iOS)
.padding(.vertical) .padding(.vertical)
#else #else
@ -75,35 +31,97 @@ struct AccountFormView: View {
#endif #endif
} }
func initializeForm() { var header: some View {
HStack(alignment: .center) {
Text("Add Account")
.font(.title2.bold())
Spacer()
Button("Cancel") {
dismiss()
}
#if !os(tvOS)
.keyboardShortcut(.cancelAction)
#endif
}
.padding(.horizontal)
}
var form: some View {
Form {
TextField("Name", text: $name, prompt: Text("Account Name (optional)"))
.focused($focused)
TextField("SID", text: $sid, prompt: Text("Invidious SID Cookie"))
}
.onAppear(perform: initializeForm)
.onChange(of: sid) { _ in validate() }
#if os(macOS)
.padding(.horizontal)
#endif
}
var footer: some View {
HStack {
validationStatus
Spacer()
Button("Save", action: submitForm)
.disabled(!valid)
#if !os(tvOS)
.keyboardShortcut(.defaultAction)
#endif
}
.frame(minHeight: 35)
.padding(.horizontal)
}
var validationStatus: some View {
HStack(spacing: 4) {
Image(systemName: valid ? "checkmark.circle.fill" : "xmark.circle.fill")
.foregroundColor(valid ? .green : .red)
VStack(alignment: .leading) {
Text(valid ? "Account found" : "Invalid account details")
}
}
.opacity(validated ? 1 : 0)
}
private func initializeForm() {
focused = true focused = true
} }
func validate() { private func validate() {
validationDebounce.invalidate()
guard !sid.isEmpty else { guard !sid.isEmpty else {
validator.reset() validator.reset()
return return
} }
validator.validateAccount() validationDebounce.debouncing(2) {
validator.validateAccount()
}
} }
func submitForm() { private func submitForm() {
guard valid else { guard valid else {
return return
} }
let account = instances.addAccount(instance: instance, name: name, sid: sid) let account = instances.addAccount(instance: instance, name: name.serializationSafe, sid: sid)
selectedAccount?.wrappedValue = account selectedAccount?.wrappedValue = account
dismiss() dismiss()
} }
private var validator: InstanceAccountValidator { private var validator: AccountValidator {
InstanceAccountValidator( AccountValidator(
url: instance.url, url: instance.url,
account: Instance.Account(url: instance.url, sid: sid), account: Instance.Account(url: instance.url, sid: sid),
formObjectID: $sid, id: $sid,
valid: $valid, valid: $valid,
validated: $validated validated: $validated
) )

View File

@ -15,7 +15,7 @@ struct InstanceDetailsSettingsView: View {
var body: some View { var body: some View {
List { List {
Section(header: Text("Accounts")) { Section(header: Text("Accounts")) {
ForEach(instance.accounts, id: \.self) { account in ForEach(instance.accounts) { account in
Text(account.description) Text(account.description)
#if !os(tvOS) #if !os(tvOS)
.swipeActions(edge: .trailing, allowsFullSwipe: false) { .swipeActions(edge: .trailing, allowsFullSwipe: false) {

View File

@ -9,6 +9,7 @@ struct InstanceFormView: View {
@State private var valid = false @State private var valid = false
@State private var validated = false @State private var validated = false
@State private var validationError: String? @State private var validationError: String?
@State private var validationDebounce = Debounce()
@FocusState private var nameFieldFocused: Bool @FocusState private var nameFieldFocused: Bool
@ -78,14 +79,13 @@ struct InstanceFormView: View {
.padding(.vertical) .padding(.vertical)
#else #else
.frame(width: 400, height: 150) .frame(width: 400, height: 150)
#endif #endif
} }
var validator: InstanceAccountValidator { var validator: AccountValidator {
InstanceAccountValidator( AccountValidator(
url: url, url: url,
formObjectID: $url, id: $url,
valid: $valid, valid: $valid,
validated: $validated, validated: $validated,
error: $validationError error: $validationError
@ -93,15 +93,16 @@ struct InstanceFormView: View {
} }
func validate() { func validate() {
valid = false validationDebounce.invalidate()
validated = false
validationError = nil
guard !url.isEmpty else { guard !url.isEmpty else {
validator.reset()
return return
} }
validator.validateInstance() validationDebounce.debouncing(2) {
validator.validateInstance()
}
} }
func initializeForm() { func initializeForm() {

View File

@ -27,7 +27,7 @@ struct InstancesSettingsView: View {
Group { Group {
#if os(iOS) #if os(iOS)
Section(header: instancesHeader) { Section(header: instancesHeader) {
ForEach(instances, id: \.self) { instance in ForEach(instances) { instance in
Button(action: { Button(action: {
self.selectedInstanceID = instance.id self.selectedInstanceID = instance.id
self.presentingInstanceDetails = true self.presentingInstanceDetails = true
@ -62,7 +62,7 @@ struct InstancesSettingsView: View {
if !instances.isEmpty { if !instances.isEmpty {
Picker("Instance", selection: $selectedInstanceID) { Picker("Instance", selection: $selectedInstanceID) {
ForEach(instances, id: \.url) { instance in ForEach(instances) { instance in
Text(instance.description).tag(Optional(instance.id)) Text(instance.description).tag(Optional(instance.id))
} }
} }
@ -81,7 +81,7 @@ struct InstancesSettingsView: View {
} else { } else {
Text("Accounts") Text("Accounts")
List(selection: $selectedAccount) { List(selection: $selectedAccount) {
ForEach(instance.accounts, id: \.self) { account in ForEach(instance.accounts) { account in
AccountSettingsView(instance: instance, account: account, AccountSettingsView(instance: instance, account: account,
selectedAccount: $selectedAccount) selectedAccount: $selectedAccount)
} }

View File

@ -12,21 +12,21 @@ struct SearchView: View {
@State private var presentingClearConfirmation = false @State private var presentingClearConfirmation = false
@State private var recentsChanged = false @State private var recentsChanged = false
@State private var searchDebounce = Debounce()
@State private var recentsDebounce = Debounce()
@Environment(\.navigationStyle) private var navigationStyle @Environment(\.navigationStyle) private var navigationStyle
@EnvironmentObject<RecentsModel> private var recents @EnvironmentObject<RecentsModel> private var recents
@EnvironmentObject<SearchModel> private var state @EnvironmentObject<SearchModel> private var state
@State private var searchDebounceTimer: Timer?
@State private var recentSearchDebounceTimer: Timer?
init(_ query: SearchQuery? = nil) { init(_ query: SearchQuery? = nil) {
self.query = query self.query = query
} }
var body: some View { var body: some View {
VStack { VStack {
if navigationStyle == .tab && state.queryText.isEmpty { if showRecentQueries {
recentQueries recentQueries
} else { } else {
#if os(tvOS) #if os(tvOS)
@ -40,15 +40,11 @@ struct SearchView: View {
VideosView(videos: state.store.collection) VideosView(videos: state.store.collection)
#endif #endif
if state.store.collection.isEmpty && !state.isLoading && !state.query.isEmpty { if noResults {
Text("No results") Text("No results")
if searchFiltersActive { if searchFiltersActive {
Button("Reset search filters") { Button("Reset search filters", action: resetFilters)
self.searchSortOrder = .relevance
self.searchDate = .any
self.searchDuration = .any
}
} }
Spacer() Spacer()
@ -101,14 +97,14 @@ struct SearchView: View {
state.loadSuggestions(newQuery) state.loadSuggestions(newQuery)
#if os(tvOS) #if os(tvOS)
searchDebounceTimer?.invalidate() searchDebounce.invalidate()
recentSearchDebounceTimer?.invalidate() recentsDebounce.invalidate()
searchDebounceTimer = Timer.scheduledTimer(withTimeInterval: 2, repeats: false) { _ in searchDebounce.debouncing(2) {
state.changeQuery { query in query.query = newQuery } state.changeQuery { query in query.query = newQuery }
} }
recentSearchDebounceTimer = Timer.scheduledTimer(withTimeInterval: 10, repeats: false) { _ in recentsDebounce.debouncing(10) {
recents.addQuery(newQuery) recents.addQuery(newQuery)
} }
#endif #endif
@ -147,10 +143,24 @@ struct SearchView: View {
#endif #endif
} }
var filtersActive: Bool { fileprivate var showRecentQueries: Bool {
navigationStyle == .tab && state.queryText.isEmpty
}
fileprivate var filtersActive: Bool {
searchDuration != .any || searchDate != .any searchDuration != .any || searchDate != .any
} }
fileprivate func resetFilters() {
searchSortOrder = .relevance
searchDate = .any
searchDuration = .any
}
fileprivate var noResults: Bool {
state.store.collection.isEmpty && !state.isLoading && !state.query.isEmpty
}
var recentQueries: some View { var recentQueries: some View {
VStack { VStack {
List { List {
@ -282,6 +292,7 @@ struct SearchView: View {
searchSortOrderButton searchSortOrderButton
} }
.frame(maxWidth: .infinity, alignment: .trailing) .frame(maxWidth: .infinity, alignment: .trailing)
HStack(spacing: 30) { HStack(spacing: 30) {
Text("Duration") Text("Duration")
.foregroundColor(.secondary) .foregroundColor(.secondary)