From f250378de08e6d202ef23ab9f911e2ceb73a1ba2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Laurent=20Tr=C3=A9guier?= Date: Tue, 12 Nov 2024 17:36:43 +0100 Subject: [PATCH] List emails --- Fyreplace.xcodeproj/project.pbxproj | 16 +++++++ Fyreplace/Extensions/OpenAPI.swift | 3 ++ Fyreplace/Fakes/FakeClient.swift | 13 ++++-- Fyreplace/Fakes/Placeholders.swift | 14 +++++- Fyreplace/Resources/Localizable.xcstrings | 40 +++++++++++++++++ Fyreplace/Views/Forms/DynamicList.swift | 20 +++++++++ Fyreplace/Views/Navigation/Destination.swift | 7 ++- Fyreplace/Views/Screens/EmailsScreen.swift | 41 ++++++++++++++++++ .../Views/Screens/EmailsScreenProtocol.swift | 43 +++++++++++++++++++ Fyreplace/Views/Screens/Screen.swift | 2 + Fyreplace/Views/Screens/SettingsScreen.swift | 6 +++ .../Screens/EmailsScreenTests.swift | 20 +++++++++ .../Screens/SettingsScreenTests.swift | 1 - 13 files changed, 219 insertions(+), 7 deletions(-) create mode 100644 Fyreplace/Views/Forms/DynamicList.swift create mode 100644 Fyreplace/Views/Screens/EmailsScreen.swift create mode 100644 Fyreplace/Views/Screens/EmailsScreenProtocol.swift create mode 100644 FyreplaceTests/Screens/EmailsScreenTests.swift diff --git a/Fyreplace.xcodeproj/project.pbxproj b/Fyreplace.xcodeproj/project.pbxproj index e5f5687..aadeab3 100644 --- a/Fyreplace.xcodeproj/project.pbxproj +++ b/Fyreplace.xcodeproj/project.pbxproj @@ -17,6 +17,8 @@ 4D13AF752C492F4500845FDB /* Config.sh in Resources */ = {isa = PBXBuildFile; fileRef = 4D13AF742C492F4500845FDB /* Config.sh */; }; 4D13AF7B2C4E8F4200845FDB /* EnvironmentPicker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D13AF7A2C4E8F4200845FDB /* EnvironmentPicker.swift */; }; 4D13AF812C4E907200845FDB /* Environment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D13AF802C4E907200845FDB /* Environment.swift */; }; + 4D30D7232CCFAF4E0071B03F /* EmailsScreenProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D30D7222CCFAF440071B03F /* EmailsScreenProtocol.swift */; }; + 4D30D7262CCFB01B0071B03F /* EmailsScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D30D7252CCFB0170071B03F /* EmailsScreen.swift */; }; 4D30DA5F2C986B6C00499450 /* Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D30DA5E2C986B6C00499450 /* Avatar.swift */; }; 4D30DA612C98706C00499450 /* Placeholders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D30DA602C98706C00499450 /* Placeholders.swift */; }; 4D351AEB2CA6BD45002EEB8F /* SettingsScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D351AEA2CA6BD3A002EEB8F /* SettingsScreenTests.swift */; }; @@ -55,6 +57,7 @@ 4DB10B502C4FEBFC00634BF6 /* HelpCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB10B4F2C4FEBFC00634BF6 /* HelpCommands.swift */; }; 4DB2E36D2C416611007F958D /* SubmitButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB2E36C2C416611007F958D /* SubmitButton.swift */; }; 4DB2E36F2C418F5C007F958D /* DynamicForm.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB2E36E2C418F5C007F958D /* DynamicForm.swift */; }; + 4DB367BC2CE364C300853CFE /* EmailsScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB367BB2CE364BC00853CFE /* EmailsScreenTests.swift */; }; 4DC5B1CA2C6FA23000B75A07 /* LoginScreenProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC5B1C92C6FA23000B75A07 /* LoginScreenProtocol.swift */; }; 4DC5B1CD2C6FA28E00B75A07 /* RegisterScreenProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC5B1CC2C6FA28E00B75A07 /* RegisterScreenProtocol.swift */; }; 4DC5B1CF2C6FA2BE00B75A07 /* MainViewProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DC5B1CE2C6FA2BE00B75A07 /* MainViewProtocol.swift */; }; @@ -71,6 +74,7 @@ 4DE785952C8B17AE000EC4E5 /* SubmitOrCancel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE785942C8B17AE000EC4E5 /* SubmitOrCancel.swift */; }; 4DFB90702C5908DE00D4DABF /* LoginScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFB906F2C5908DE00D4DABF /* LoginScreenTests.swift */; }; 4DFB90762C59173C00D4DABF /* RegisterScreenTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFB90752C59173C00D4DABF /* RegisterScreenTests.swift */; }; + 4DFDBC102CD1080000CCDA4A /* DynamicList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DFDBC0E2CD107FC00CCDA4A /* DynamicList.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -102,6 +106,8 @@ 4D13AF742C492F4500845FDB /* Config.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Config.sh; sourceTree = ""; }; 4D13AF7A2C4E8F4200845FDB /* EnvironmentPicker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EnvironmentPicker.swift; sourceTree = ""; }; 4D13AF802C4E907200845FDB /* Environment.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Environment.swift; sourceTree = ""; }; + 4D30D7222CCFAF440071B03F /* EmailsScreenProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailsScreenProtocol.swift; sourceTree = ""; }; + 4D30D7252CCFB0170071B03F /* EmailsScreen.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailsScreen.swift; sourceTree = ""; }; 4D30DA5E2C986B6C00499450 /* Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Avatar.swift; sourceTree = ""; }; 4D30DA602C98706C00499450 /* Placeholders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Placeholders.swift; sourceTree = ""; }; 4D351AEA2CA6BD3A002EEB8F /* SettingsScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsScreenTests.swift; sourceTree = ""; }; @@ -153,6 +159,7 @@ 4DB10B4F2C4FEBFC00634BF6 /* HelpCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpCommands.swift; sourceTree = ""; }; 4DB2E36C2C416611007F958D /* SubmitButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SubmitButton.swift; sourceTree = ""; }; 4DB2E36E2C418F5C007F958D /* DynamicForm.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicForm.swift; sourceTree = ""; }; + 4DB367BB2CE364BC00853CFE /* EmailsScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmailsScreenTests.swift; sourceTree = ""; }; 4DBF0BD32C9DB2E500E797BF /* .ios-test-model */ = {isa = PBXFileReference; lastKnownFileType = text; path = ".ios-test-model"; sourceTree = ""; }; 4DC5B1C92C6FA23000B75A07 /* LoginScreenProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenProtocol.swift; sourceTree = ""; }; 4DC5B1CC2C6FA28E00B75A07 /* RegisterScreenProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterScreenProtocol.swift; sourceTree = ""; }; @@ -175,6 +182,7 @@ 4DF3737F2C99C23D0008AB04 /* .swift-format */ = {isa = PBXFileReference; explicitFileType = text.json; path = ".swift-format"; sourceTree = ""; }; 4DFB906F2C5908DE00D4DABF /* LoginScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LoginScreenTests.swift; sourceTree = ""; }; 4DFB90752C59173C00D4DABF /* RegisterScreenTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RegisterScreenTests.swift; sourceTree = ""; }; + 4DFDBC0E2CD107FC00CCDA4A /* DynamicList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DynamicList.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -250,6 +258,8 @@ 4D4D39492C086DA2007196D2 /* PublishedScreen.swift */, 4DC5B1F32C7A137B00B75A07 /* SettingsScreenProtocol.swift */, 4D54C9702BF4EA15001DE071 /* SettingsScreen.swift */, + 4D30D7222CCFAF440071B03F /* EmailsScreenProtocol.swift */, + 4D30D7252CCFB0170071B03F /* EmailsScreen.swift */, 4DC5B1C92C6FA23000B75A07 /* LoginScreenProtocol.swift */, 4D9B3B372C334B3A00A8F7AD /* LoginScreen.swift */, 4DC5B1CC2C6FA28E00B75A07 /* RegisterScreenProtocol.swift */, @@ -401,6 +411,7 @@ isa = PBXGroup; children = ( 4DB2E36E2C418F5C007F958D /* DynamicForm.swift */, + 4DFDBC0E2CD107FC00CCDA4A /* DynamicList.swift */, 4DB2E36C2C416611007F958D /* SubmitButton.swift */, 4DE785942C8B17AE000EC4E5 /* SubmitOrCancel.swift */, 4D13AF7A2C4E8F4200845FDB /* EnvironmentPicker.swift */, @@ -457,6 +468,7 @@ children = ( 4D351AEC2CA6BE27002EEB8F /* FakeScreenBase.swift */, 4D351AEA2CA6BD3A002EEB8F /* SettingsScreenTests.swift */, + 4DB367BB2CE364BC00853CFE /* EmailsScreenTests.swift */, 4DFB906F2C5908DE00D4DABF /* LoginScreenTests.swift */, 4DFB90752C59173C00D4DABF /* RegisterScreenTests.swift */, ); @@ -637,6 +649,7 @@ files = ( 4D40ACB42CC3ECBC00B26FDF /* OpenAPI.swift in Sources */, 4DA04EE22CAEEAD800B70D73 /* Foundation.swift in Sources */, + 4D30D7232CCFAF4E0071B03F /* EmailsScreenProtocol.swift in Sources */, 4D9B3B3D2C34B13E00A8F7AD /* LogoHeader.swift in Sources */, 4DA7BFBB2C5FDEC1005CC4FF /* FakeClient.swift in Sources */, 4D4AF71C2C7CE72900621FF3 /* Tokens.swift in Sources */, @@ -653,6 +666,7 @@ 4DE785952C8B17AE000EC4E5 /* SubmitOrCancel.swift in Sources */, 4DE785882C88F392000EC4E5 /* HTTPTypes.swift in Sources */, 4D6A24DD2CAC193F001B4435 /* EditableAvatar.swift in Sources */, + 4DFDBC102CD1080000CCDA4A /* DynamicList.swift in Sources */, 4D51F2802C621ADB0018E76E /* ViewProtocol.swift in Sources */, 4DC5B1D92C720B9800B75A07 /* AuthenticationMiddleware.swift in Sources */, 4D9B3B382C334B3A00A8F7AD /* LoginScreen.swift in Sources */, @@ -667,6 +681,7 @@ 4DB2E36F2C418F5C007F958D /* DynamicForm.swift in Sources */, 4DCE062B2C08E5E200F69AF1 /* CompactNavigation.swift in Sources */, 4DCE062D2C08E65300F69AF1 /* RegularNavigation.swift in Sources */, + 4D30D7262CCFB01B0071B03F /* EmailsScreen.swift in Sources */, 4D54C9692BF4E8F4001DE071 /* FeedScreen.swift in Sources */, 4DC5B1F42C7A137B00B75A07 /* SettingsScreenProtocol.swift in Sources */, 4DC5B1D62C6FEA2100B75A07 /* Keychain.swift in Sources */, @@ -691,6 +706,7 @@ files = ( 4D351AEB2CA6BD45002EEB8F /* SettingsScreenTests.swift in Sources */, 4D351AED2CA6BE2D002EEB8F /* FakeScreenBase.swift in Sources */, + 4DB367BC2CE364C300853CFE /* EmailsScreenTests.swift in Sources */, 4D5348612C6646F80001EFDE /* StoringEventBus.swift in Sources */, 4DFB90702C5908DE00D4DABF /* LoginScreenTests.swift in Sources */, 4DFB90762C59173C00D4DABF /* RegisterScreenTests.swift in Sources */, diff --git a/Fyreplace/Extensions/OpenAPI.swift b/Fyreplace/Extensions/OpenAPI.swift index 074e19e..a456512 100644 --- a/Fyreplace/Extensions/OpenAPI.swift +++ b/Fyreplace/Extensions/OpenAPI.swift @@ -1,3 +1,6 @@ extension Components.Schemas.User { static let maxBioSize = 3000 } + +extension Components.Schemas.Email: Identifiable { +} diff --git a/Fyreplace/Fakes/FakeClient.swift b/Fyreplace/Fakes/FakeClient.swift index 61b9094..2ade5ac 100644 --- a/Fyreplace/Fakes/FakeClient.swift +++ b/Fyreplace/Fakes/FakeClient.swift @@ -93,7 +93,8 @@ extension FakeClient { func countEmails(_: Operations.countEmails.Input) async throws -> Operations.countEmails.Output { - fatalError("Not implemented") + let emails = try await listEmails(.init()).ok.body.json + return .ok(.init(body: .json(Int64(emails.count)))) } func createEmail(_: Operations.createEmail.Input) async throws @@ -108,10 +109,16 @@ extension FakeClient { fatalError("Not implemented") } - func listEmails(_: Operations.listEmails.Input) async throws + func listEmails(_ input: Operations.listEmails.Input) async throws -> Operations.listEmails.Output { - fatalError("Not implemented") + return switch input.query.page { + case nil, 0: + .ok(.init(body: .json([.make(main: true), .make(), .make()]))) + + default: + .ok(.init(body: .json([]))) + } } func setMainEmail(_: Operations.setMainEmail.Input) async throws diff --git a/Fyreplace/Fakes/Placeholders.swift b/Fyreplace/Fakes/Placeholders.swift index a07db8f..38a1c61 100644 --- a/Fyreplace/Fakes/Placeholders.swift +++ b/Fyreplace/Fakes/Placeholders.swift @@ -4,7 +4,7 @@ extension Components.Schemas.User { } static func make(named username: String) -> Self { - return Self( + return .init( id: .randomUuid, dateCreated: .now, username: username, @@ -17,3 +17,15 @@ extension Components.Schemas.User { ) } } + +extension Components.Schemas.Email { + static func make(verified: Bool = true, main: Bool = false) -> Self { + let id = String.randomUuid + return .init( + id: id, + email: "\(id)@example.org", + verified: verified, + main: main + ) + } +} diff --git a/Fyreplace/Resources/Localizable.xcstrings b/Fyreplace/Resources/Localizable.xcstrings index a325200..88b6ca9 100644 --- a/Fyreplace/Resources/Localizable.xcstrings +++ b/Fyreplace/Resources/Localizable.xcstrings @@ -124,6 +124,16 @@ } } }, + "Emails.Main" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Main address" + } + } + } + }, "Environment.Default" : { "localizations" : { "en" : { @@ -364,6 +374,16 @@ } } }, + "Main.Emails" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emails" + } + } + } + }, "Main.Feed" : { "localizations" : { "en" : { @@ -694,6 +714,26 @@ } } }, + "Settings.Emails" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Add or remove email addresses" + } + } + } + }, + "Settings.Emails.Header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Emails" + } + } + } + }, "Settings.Error.ContentTooLarge.Message" : { "localizations" : { "en" : { diff --git a/Fyreplace/Views/Forms/DynamicList.swift b/Fyreplace/Views/Forms/DynamicList.swift new file mode 100644 index 0000000..dc355d9 --- /dev/null +++ b/Fyreplace/Views/Forms/DynamicList.swift @@ -0,0 +1,20 @@ +import SwiftUI + +struct DynamicList: View where Content: View { + @ViewBuilder + let content: () -> Content + + var body: some View { + #if os(macOS) + DynamicForm { + List { + content() + } + } + #else + List { + content() + } + #endif + } +} diff --git a/Fyreplace/Views/Navigation/Destination.swift b/Fyreplace/Views/Navigation/Destination.swift index ea528a8..a8f2e36 100644 --- a/Fyreplace/Views/Navigation/Destination.swift +++ b/Fyreplace/Views/Navigation/Destination.swift @@ -7,6 +7,7 @@ enum Destination: String, Identifiable { case drafts case published case settings + case emails case login case register @@ -39,6 +40,8 @@ enum Destination: String, Identifiable { "Main.Published" case .settings: "Main.Settings" + case .emails: + "Main.Emails" case .login: "Main.Login" case .register: @@ -60,7 +63,7 @@ enum Destination: String, Identifiable { "archivebox" case .settings: "person.crop.circle" - case .login, .register: + default: "" } } @@ -102,7 +105,7 @@ enum Destination: String, Identifiable { .init("5") case .settings: .init("6") - case .login, .register: + default: nil } } diff --git a/Fyreplace/Views/Screens/EmailsScreen.swift b/Fyreplace/Views/Screens/EmailsScreen.swift new file mode 100644 index 0000000..b376ceb --- /dev/null +++ b/Fyreplace/Views/Screens/EmailsScreen.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct EmailsScreen: View, EmailsScreenProtocol { + @EnvironmentObject + var eventBus: EventBus + + @Environment(\.api) + var api: any APIProtocol + + @State + var emails: [Components.Schemas.Email] = [] + + var body: some View { + DynamicList { + ForEach(emails) { email in + LabeledContent { + } label: { + Text(verbatim: email.email) + + if email.main { + Text("Emails.Main") + } else { + Spacer() + } + } + } + } + .navigationTitle(Destination.emails.titleKey) + .onAppear { + Task { + await loadEmails() + } + } + } +} + +#Preview { + NavigationStack { + EmailsScreen() + } +} diff --git a/Fyreplace/Views/Screens/EmailsScreenProtocol.swift b/Fyreplace/Views/Screens/EmailsScreenProtocol.swift new file mode 100644 index 0000000..7c73bd5 --- /dev/null +++ b/Fyreplace/Views/Screens/EmailsScreenProtocol.swift @@ -0,0 +1,43 @@ +@MainActor +protocol EmailsScreenProtocol: APIViewProtocol { + var emails: [Components.Schemas.Email] { get nonmutating set } +} + +@MainActor +extension EmailsScreenProtocol { + func loadEmails() async { + emails.removeAll() + var page: Int32 = 0 + + while await loadEmails(at: page) { + page += 1 + } + } + + func loadEmails(at page: Int32) async -> Bool { + var hasMore = false + + await call { + let response = try await api.listEmails(query: .init(page: page)) + + switch response { + case let .ok(ok): + switch ok.body { + case let .json(json): + hasMore = !json.isEmpty + emails.append(contentsOf: json) + } + + return nil + + case .unauthorized: + return .authorizationIssue() + + case .forbidden, .default: + return .error() + } + } + + return hasMore + } +} diff --git a/Fyreplace/Views/Screens/Screen.swift b/Fyreplace/Views/Screens/Screen.swift index 217848d..8696a6f 100644 --- a/Fyreplace/Views/Screens/Screen.swift +++ b/Fyreplace/Views/Screens/Screen.swift @@ -17,6 +17,8 @@ struct Screen: View { PublishedScreen() case .settings: SettingsScreen() + case .emails: + EmailsScreen() case .login: LoginScreen() case .register: diff --git a/Fyreplace/Views/Screens/SettingsScreen.swift b/Fyreplace/Views/Screens/SettingsScreen.swift index 8065618..062d371 100644 --- a/Fyreplace/Views/Screens/SettingsScreen.swift +++ b/Fyreplace/Views/Screens/SettingsScreen.swift @@ -101,6 +101,12 @@ struct SettingsScreen: View, SettingsScreenProtocol { Text("Settings.Bio.Footer:\(bio.count),\(Components.Schemas.User.maxBioSize)") } + Section("Settings.Emails.Header") { + NavigationLink("Settings.Emails") { + Screen(destination: .emails) + } + } + Section("Settings.About.Header") { Link(destination: config.app.info.website) { Label("App.Help.Website", systemImage: "safari") diff --git a/FyreplaceTests/Screens/EmailsScreenTests.swift b/FyreplaceTests/Screens/EmailsScreenTests.swift new file mode 100644 index 0000000..2801ae7 --- /dev/null +++ b/FyreplaceTests/Screens/EmailsScreenTests.swift @@ -0,0 +1,20 @@ +import Testing + +@testable import Fyreplace + +@Suite("Emails screen") +@MainActor +struct EmailsScreenTests { + class FakeScreen: FakeScreenBase, EmailsScreenProtocol { + var emails: [Fyreplace.Components.Schemas.Email] = [] + } + + @Test("Loading emails produces no failures") + func loadEmailsProducesNoFailures() async { + let eventBus = StoringEventBus() + let screen = FakeScreen(eventBus: eventBus, api: .fake()) + await screen.loadEmails() + #expect(eventBus.storedEvents.isEmpty) + #expect(screen.emails.count == 3) + } +} diff --git a/FyreplaceTests/Screens/SettingsScreenTests.swift b/FyreplaceTests/Screens/SettingsScreenTests.swift index 93b9bb9..ee542d2 100644 --- a/FyreplaceTests/Screens/SettingsScreenTests.swift +++ b/FyreplaceTests/Screens/SettingsScreenTests.swift @@ -1,4 +1,3 @@ -import Foundation import Testing @testable import Fyreplace