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

View File

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

View File

@ -10,6 +10,7 @@ struct AccountFormView: View {
@State private var valid = false
@State private var validated = false
@State private var validationDebounce = Debounce()
@FocusState private var focused: Bool
@ -19,6 +20,18 @@ struct AccountFormView: View {
var body: some View {
VStack {
header
form
footer
}
#if os(iOS)
.padding(.vertical)
#else
.frame(width: 400, height: 145)
#endif
}
var header: some View {
HStack(alignment: .center) {
Text("Add Account")
.font(.title2.bold())
@ -33,7 +46,9 @@ struct AccountFormView: View {
#endif
}
.padding(.horizontal)
}
var form: some View {
Form {
TextField("Name", text: $name, prompt: Text("Account Name (optional)"))
.focused($focused)
@ -42,20 +57,15 @@ struct AccountFormView: View {
}
.onAppear(perform: initializeForm)
.onChange(of: sid) { _ in validate() }
#if os(macOS)
.padding(.horizontal)
#endif
}
var footer: some View {
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)
validationStatus
Spacer()
Button("Save", action: submitForm)
@ -68,42 +78,50 @@ struct AccountFormView: View {
.padding(.horizontal)
}
#if os(iOS)
.padding(.vertical)
#else
.frame(width: 400, height: 145)
#endif
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)
}
func initializeForm() {
private func initializeForm() {
focused = true
}
func validate() {
private func validate() {
validationDebounce.invalidate()
guard !sid.isEmpty else {
validator.reset()
return
}
validationDebounce.debouncing(2) {
validator.validateAccount()
}
}
func submitForm() {
private func submitForm() {
guard valid else {
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
dismiss()
}
private var validator: InstanceAccountValidator {
InstanceAccountValidator(
private var validator: AccountValidator {
AccountValidator(
url: instance.url,
account: Instance.Account(url: instance.url, sid: sid),
formObjectID: $sid,
id: $sid,
valid: $valid,
validated: $validated
)

View File

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

View File

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

View File

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

View File

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