diff --git a/Fyreplace.xcodeproj/project.pbxproj b/Fyreplace.xcodeproj/project.pbxproj index 1db13fc..0fb1de0 100644 --- a/Fyreplace.xcodeproj/project.pbxproj +++ b/Fyreplace.xcodeproj/project.pbxproj @@ -23,6 +23,7 @@ 4D351AEB2CA6BD45002EEB8F /* SettingsScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D351AEA2CA6BD3A002EEB8F /* SettingsScreenTests.swift */; }; 4D351AED2CA6BE2D002EEB8F /* FakeScreenBase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D351AEC2CA6BE27002EEB8F /* FakeScreenBase.swift */; }; 4D39A4C82BF516B7003FA52E /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 4D39A4C72BF516B7003FA52E /* Localizable.xcstrings */; }; + 4D40ACB42CC3ECBC00B26FDF /* User.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D40ACB32CC3ECB300B26FDF /* User.swift */; }; 4D4AF71C2C7CE72900621FF3 /* Tokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4AF71B2C7CE72900621FF3 /* Tokens.swift */; }; 4D4D394A2C086DA2007196D2 /* PublishedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4D39492C086DA2007196D2 /* PublishedScreen.swift */; }; 4D51F2802C621ADB0018E76E /* ViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D51F27F2C621ADB0018E76E /* ViewProtocol.swift */; }; @@ -111,6 +112,7 @@ 4D351AEA2CA6BD3A002EEB8F /* SettingsScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenTests.swift; sourceTree = ""; }; 4D351AEC2CA6BE27002EEB8F /* FakeScreenBase.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeScreenBase.swift; sourceTree = ""; }; 4D39A4C72BF516B7003FA52E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; sourceTree = ""; }; + 4D40ACB32CC3ECB300B26FDF /* User.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = User.swift; sourceTree = ""; }; 4D4AF71B2C7CE72900621FF3 /* Tokens.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Tokens.swift; sourceTree = ""; }; 4D4D39492C086DA2007196D2 /* PublishedScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PublishedScreen.swift; sourceTree = ""; }; 4D51F27F2C621ADB0018E76E /* ViewProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ViewProtocol.swift; sourceTree = ""; }; @@ -425,6 +427,7 @@ 4DE785872C88F392000EC4E5 /* HTTPField.swift */, 4D060BCA2C9438E8008C32D1 /* View.swift */, 4D5251F22C109FAC00018CD2 /* Label+Destination.swift */, + 4D40ACB32CC3ECB300B26FDF /* User.swift */, ); path = Extensions; sourceTree = ""; @@ -644,6 +647,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4D40ACB42CC3ECBC00B26FDF /* User.swift in Sources */, 4DA04EE22CAEEAD800B70D73 /* CGFloat.swift in Sources */, 4D9B3B3D2C34B13E00A8F7AD /* LogoHeader.swift in Sources */, 4DA7BFBB2C5FDEC1005CC4FF /* FakeClient.swift in Sources */, diff --git a/Fyreplace/Extensions/User.swift b/Fyreplace/Extensions/User.swift new file mode 100644 index 0000000..074e19e --- /dev/null +++ b/Fyreplace/Extensions/User.swift @@ -0,0 +1,3 @@ +extension Components.Schemas.User { + static let maxBioSize = 3000 +} diff --git a/Fyreplace/Fakes/FakeClient.swift b/Fyreplace/Fakes/FakeClient.swift index c303e29..61b9094 100644 --- a/Fyreplace/Fakes/FakeClient.swift +++ b/Fyreplace/Fakes/FakeClient.swift @@ -361,10 +361,13 @@ extension FakeClient { } } - func setCurrentUserBio(_: Operations.setCurrentUserBio.Input) async throws + func setCurrentUserBio(_ input: Operations.setCurrentUserBio.Input) async throws -> Operations.setCurrentUserBio.Output { - fatalError("Not implemented") + return switch input.body { + case let .plainText(text): + .ok(.init(body: .plainText(text))) + } } func setUserBanned(_: Operations.setUserBanned.Input) async throws diff --git a/Fyreplace/Resources/Localizable.xcstrings b/Fyreplace/Resources/Localizable.xcstrings index 886f2a5..a325200 100644 --- a/Fyreplace/Resources/Localizable.xcstrings +++ b/Fyreplace/Resources/Localizable.xcstrings @@ -654,6 +654,36 @@ } } }, + "Settings.Bio.Footer:%lld,%lld" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "%1$lld/%2$lld" + } + } + } + }, + "Settings.Bio.Header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bio" + } + } + } + }, + "Settings.Bio.Update" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Save" + } + } + } + }, "Settings.DateJoined:%@" : { "localizations" : { "en" : { diff --git a/Fyreplace/Views/Screens/SettingsScreen.swift b/Fyreplace/Views/Screens/SettingsScreen.swift index 561e40f..8065618 100644 --- a/Fyreplace/Views/Screens/SettingsScreen.swift +++ b/Fyreplace/Views/Screens/SettingsScreen.swift @@ -14,6 +14,9 @@ struct SettingsScreen: View, SettingsScreenProtocol { @State var currentUser: Components.Schemas.User? + @State + var bio = "" + @State var isLoadingAvatar = false @@ -29,9 +32,12 @@ struct SettingsScreen: View, SettingsScreenProtocol { @State private var avatarItem: PhotosPickerItem? + @FocusState + private var bioFocused: Bool + var body: some View { DynamicForm { - Section { + Section("Settings.Profile.Header") { let logoutButton = Button("Settings.Logout", role: .destructive, action: logout) HStack { @@ -70,11 +76,32 @@ struct SettingsScreen: View, SettingsScreenProtocol { Spacer() } #endif - } header: { - Text("Settings.Profile.Header") } Section { + TextEditor(text: $bio) + .scrollContentBackground(.hidden) + .frame(maxHeight: 160) + + HStack { + Spacer() + Button("Settings.Bio.Update") { + Task { + await updateBio() + } + } + .disabled(!canUpdateBio) + #if !os(macOS) + Spacer() + #endif + } + } header: { + Text("Settings.Bio.Header") + } footer: { + Text("Settings.Bio.Footer:\(bio.count),\(Components.Schemas.User.maxBioSize)") + } + + Section("Settings.About.Header") { Link(destination: config.app.info.website) { Label("App.Help.Website", systemImage: "safari") } @@ -94,8 +121,6 @@ struct SettingsScreen: View, SettingsScreenProtocol { Label("App.Help.SourceCode", systemImage: "curlybraces") } .foregroundStyle(.tint) - } header: { - Text("Settings.About.Header") } } .navigationTitle(Destination.settings.titleKey) diff --git a/Fyreplace/Views/Screens/SettingsScreenProtocol.swift b/Fyreplace/Views/Screens/SettingsScreenProtocol.swift index 9c9c7d6..056e51d 100644 --- a/Fyreplace/Views/Screens/SettingsScreenProtocol.swift +++ b/Fyreplace/Views/Screens/SettingsScreenProtocol.swift @@ -7,11 +7,16 @@ protocol SettingsScreenProtocol: ViewProtocol { var token: String { get nonmutating set } var currentUser: Components.Schemas.User? { get nonmutating set } + var bio: String { get nonmutating set } var isLoadingAvatar: Bool { get nonmutating set } } @MainActor extension SettingsScreenProtocol { + var canUpdateBio: Bool { + bio != currentUser?.bio ?? "" && bio.count <= Components.Schemas.User.maxBioSize + } + func getCurrentUser() async { await call { let response = try await api.getCurrentUser() @@ -21,6 +26,7 @@ extension SettingsScreenProtocol { switch ok.body { case let .json(user): currentUser = user + bio = user.bio } return nil @@ -94,6 +100,37 @@ extension SettingsScreenProtocol { isLoadingAvatar = false } + func updateBio() async { + await call { + let response = try await api.setCurrentUserBio( + body: .plainText(bio.isEmpty ? .init() : .init(stringLiteral: bio))) + + switch response { + case let .ok(ok): + switch ok.body { + case let .plainText(text): + bio = try await .init( + collecting: text, upTo: Components.Schemas.User.maxBioSize * 4) + currentUser?.bio = bio + } + + return nil + + case .badRequest(_): + return .failure( + title: "Error.BadRequest.Title", + text: "Error.BadRequest.Message" + ) + + case .unauthorized: + return .authorizationIssue() + + case .forbidden, .default: + return .error() + } + } + } + func logout() { token = "" } diff --git a/FyreplaceTests/Screens/SettingsScreenTests.swift b/FyreplaceTests/Screens/SettingsScreenTests.swift index f1a2672..93b9bb9 100644 --- a/FyreplaceTests/Screens/SettingsScreenTests.swift +++ b/FyreplaceTests/Screens/SettingsScreenTests.swift @@ -9,6 +9,7 @@ struct SettingsScreenTests { class FakeScreen: FakeScreenBase, SettingsScreenProtocol { var token = "" var currentUser: Components.Schemas.User? + var bio = "" var isLoadingAvatar = false } @@ -25,7 +26,8 @@ struct SettingsScreenTests { let screen = FakeScreen(eventBus: eventBus, api: .fake()) await screen.getCurrentUser() await screen.updateAvatar( - with: try await .init(collecting: FakeClient.largeImageBody, upTo: 64)) + with: try await .init(collecting: FakeClient.largeImageBody, upTo: 64) + ) #expect(eventBus.storedEvents.count == 1) #expect(screen.currentUser?.avatar == "") } @@ -36,7 +38,8 @@ struct SettingsScreenTests { let screen = FakeScreen(eventBus: eventBus, api: .fake()) await screen.getCurrentUser() await screen.updateAvatar( - with: try await .init(collecting: FakeClient.notImageBody, upTo: 64)) + with: try await .init(collecting: FakeClient.notImageBody, upTo: 64) + ) #expect(eventBus.storedEvents.count == 1) #expect(screen.currentUser?.avatar == "") } @@ -47,7 +50,8 @@ struct SettingsScreenTests { let screen = FakeScreen(eventBus: eventBus, api: .fake()) await screen.getCurrentUser() await screen.updateAvatar( - with: try await .init(collecting: FakeClient.normalImageBody, upTo: 64)) + with: try await .init(collecting: FakeClient.normalImageBody, upTo: 64) + ) #expect(eventBus.storedEvents.isEmpty) #expect(screen.currentUser?.avatar == FakeClient.avatar) } @@ -58,9 +62,43 @@ struct SettingsScreenTests { let screen = FakeScreen(eventBus: eventBus, api: .fake()) await screen.getCurrentUser() await screen.updateAvatar( - with: try await .init(collecting: FakeClient.normalImageBody, upTo: 64)) + with: try await .init(collecting: FakeClient.normalImageBody, upTo: 64) + ) await screen.removeAvatar() #expect(eventBus.storedEvents.isEmpty) #expect(screen.currentUser?.avatar == "") } + + @Test("Bio must have correct length") + func bioMustHaveCorrectLength() async throws { + let screen = FakeScreen(eventBus: .init(), api: .fake()) + await screen.getCurrentUser() + screen.bio = "Hello" + #expect(screen.canUpdateBio) + screen.bio = .init(repeating: "a", count: Components.Schemas.User.maxBioSize) + #expect(screen.canUpdateBio) + screen.bio += "a" + #expect(!screen.canUpdateBio) + } + + @Test("Bio must be different") + func bioMustHaveBeDifferent() async throws { + let screen = FakeScreen(eventBus: .init(), api: .fake()) + await screen.getCurrentUser() + screen.bio = "Hello" + #expect(screen.canUpdateBio) + await screen.updateBio() + #expect(!screen.canUpdateBio) + } + + @Test("Updating bio produces no failures") + func updateBioProducesNoFailures() async throws { + let eventBus = StoringEventBus() + let screen = FakeScreen(eventBus: eventBus, api: .fake()) + await screen.getCurrentUser() + screen.bio = "Hello" + await screen.updateBio() + #expect(eventBus.storedEvents.isEmpty) + #expect(screen.currentUser?.bio == screen.bio) + } }