diff --git a/Fyreplace.xcodeproj/project.pbxproj b/Fyreplace.xcodeproj/project.pbxproj index 3714a48..d5ba840 100644 --- a/Fyreplace.xcodeproj/project.pbxproj +++ b/Fyreplace.xcodeproj/project.pbxproj @@ -18,6 +18,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 */; }; + 4D30DA5F2C986B6C00499450 /* Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D30DA5E2C986B6C00499450 /* Avatar.swift */; }; + 4D30DA612C98706C00499450 /* Placeholders.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D30DA602C98706C00499450 /* Placeholders.swift */; }; 4D39A4C82BF516B7003FA52E /* Localizable.xcstrings in Resources */ = {isa = PBXBuildFile; fileRef = 4D39A4C72BF516B7003FA52E /* Localizable.xcstrings */; }; 4D4AF71C2C7CE72900621FF3 /* Tokens.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4AF71B2C7CE72900621FF3 /* Tokens.swift */; }; 4D4D394A2C086DA2007196D2 /* PublishedScreen.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D4D39492C086DA2007196D2 /* PublishedScreen.swift */; }; @@ -101,6 +103,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 = ""; }; + 4D30DA5E2C986B6C00499450 /* Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Avatar.swift; sourceTree = ""; }; + 4D30DA602C98706C00499450 /* Placeholders.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Placeholders.swift; sourceTree = ""; }; 4D39A4C72BF516B7003FA52E /* Localizable.xcstrings */ = {isa = PBXFileReference; lastKnownFileType = text.json.xcstrings; path = Localizable.xcstrings; 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 = ""; }; @@ -212,6 +216,14 @@ path = Data; sourceTree = ""; }; + 4D30DA5C2C986B6000499450 /* Components */ = { + isa = PBXGroup; + children = ( + 4D30DA5E2C986B6C00499450 /* Avatar.swift */, + ); + path = Components; + sourceTree = ""; + }; 4D39A4C62BF51693003FA52E /* Resources */ = { isa = PBXGroup; children = ( @@ -359,6 +371,7 @@ 4D54C95E2BF27C78001DE071 /* Views */ = { isa = PBXGroup; children = ( + 4D30DA5C2C986B6000499450 /* Components */, 4D9B3B3B2C34B11D00A8F7AD /* Forms */, 4D5251E82C10532100018CD2 /* Navigation */, 4D4D394B2C086DD3007196D2 /* Screens */, @@ -411,6 +424,7 @@ isa = PBXGroup; children = ( 4DA7BFBA2C5FDEC1005CC4FF /* FakeClient.swift */, + 4D30DA602C98706C00499450 /* Placeholders.swift */, ); path = Fakes; sourceTree = ""; @@ -625,6 +639,7 @@ 4D4AF71C2C7CE72900621FF3 /* Tokens.swift in Sources */, 4D54C9712BF4EA15001DE071 /* SettingsScreen.swift in Sources */, 4D13AF7B2C4E8F4200845FDB /* EnvironmentPicker.swift in Sources */, + 4D30DA5F2C986B6C00499450 /* Avatar.swift in Sources */, 4DC5B1CF2C6FA2BE00B75A07 /* MainViewProtocol.swift in Sources */, 4D9B3B422C36E23A00A8F7AD /* Array+RawRepresentable.swift in Sources */, 4D4D394A2C086DA2007196D2 /* PublishedScreen.swift in Sources */, @@ -664,6 +679,7 @@ 4D060BBF2C907FA4008C32D1 /* NavigationProtocol.swift in Sources */, 4D5251F52C10A9F100018CD2 /* DynamicNavigation.swift in Sources */, 4D9DC5032C11BF2500BA0507 /* Config.swift in Sources */, + 4D30DA612C98706C00499450 /* Placeholders.swift in Sources */, 4D54C92C2BF2608A001DE071 /* FyreplaceApp.swift in Sources */, 4D5251F32C109FAC00018CD2 /* Label+Destination.swift in Sources */, ); diff --git a/Fyreplace/Events/Event.swift b/Fyreplace/Events/Event.swift index 829e85a..77c53e6 100644 --- a/Fyreplace/Events/Event.swift +++ b/Fyreplace/Events/Event.swift @@ -25,6 +25,12 @@ extension Event { typealias failure = FailureEvent } +struct AuthorizationIssueEvent: UnfortunateEvent {} + +extension Event { + typealias authorizationIssue = AuthorizationIssueEvent +} + struct NavigationShortcutEvent: Event { let destination: Destination diff --git a/Fyreplace/Fakes/FakeClient.swift b/Fyreplace/Fakes/FakeClient.swift index 483e6e0..c80ac9a 100644 --- a/Fyreplace/Fakes/FakeClient.swift +++ b/Fyreplace/Fakes/FakeClient.swift @@ -308,7 +308,10 @@ extension FakeClient { banned: false, blocked: false, tint: .init(r: 0x7F, g: 0x7F, b: 0x7F) - )))) + ) + ) + ) + ) } } @@ -327,7 +330,7 @@ extension FakeClient { func getCurrentUser(_: Operations.getCurrentUser.Input) async throws -> Operations.getCurrentUser.Output { - fatalError("Not implemented") + return .ok(.init(body: .json(.placeholder))) } func getUser(_: Operations.getUser.Input) async throws diff --git a/Fyreplace/Fakes/Placeholders.swift b/Fyreplace/Fakes/Placeholders.swift new file mode 100644 index 0000000..095550e --- /dev/null +++ b/Fyreplace/Fakes/Placeholders.swift @@ -0,0 +1,13 @@ +extension Components.Schemas.User { + static let placeholder = Self( + id: .randomUuid, + dateCreated: .now, + username: "random_user", + rank: .CITIZEN, + avatar: "", + bio: "Hello there", + banned: false, + blocked: false, + tint: .init(r: 0x7F, g: 0x7F, b: 0x7F) + ) +} diff --git a/Fyreplace/Resources/Localizable.xcstrings b/Fyreplace/Resources/Localizable.xcstrings index 1c0b640..f367434 100644 --- a/Fyreplace/Resources/Localizable.xcstrings +++ b/Fyreplace/Resources/Localizable.xcstrings @@ -204,6 +204,26 @@ } } }, + "Error.Unauthorized.Text" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "It looks like your session has expired." + } + } + } + }, + "Error.Unauthorized.Title" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "You have been disconnected" + } + } + } + }, "Error.Unknown" : { "localizations" : { "en" : { @@ -214,6 +234,16 @@ } } }, + "Loading" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "…" + } + } + } + }, "Login.Error.NotFound.Message" : { "localizations" : { "en" : { @@ -574,6 +604,26 @@ } } }, + "Settings.DateJoined" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Joined on" + } + } + } + }, + "Settings.Header" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profile" + } + } + } + }, "Settings.Logout" : { "localizations" : { "en" : { @@ -583,6 +633,16 @@ } } } + }, + "Settings.Username" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Username" + } + } + } } }, "version" : "1.0" diff --git a/Fyreplace/Views/Components/Avatar.swift b/Fyreplace/Views/Components/Avatar.swift new file mode 100644 index 0000000..a0dfabf --- /dev/null +++ b/Fyreplace/Views/Components/Avatar.swift @@ -0,0 +1,41 @@ +import SwiftUI + +struct Avatar: View { + let user: Components.Schemas.User? + + private var tint: Color { + if let user = user { + .init( + red: Double(user.tint.r) / 255, + green: Double(user.tint.g) / 255, + blue: Double(user.tint.b) / 255 + ) + } else { + .gray + } + } + + var body: some View { + ZStack { + if let avatar = user?.avatar, !avatar.isEmpty { + AsyncImage(url: .init(string: avatar)) { + $0.resizable().scaledToFill() + } placeholder: { + ProgressView() + } + } else { + Image(systemName: "person.crop.circle.fill") + .resizable() + .scaledToFit() + .foregroundStyle(tint) + } + } + .clipShape(.circle) + } +} + +#Preview { + Avatar(user: .placeholder) + .frame(width: 100, height: 100) + .padding() +} diff --git a/Fyreplace/Views/Forms/DynamicForm.swift b/Fyreplace/Views/Forms/DynamicForm.swift index 91fdec0..d6fc865 100644 --- a/Fyreplace/Views/Forms/DynamicForm.swift +++ b/Fyreplace/Views/Forms/DynamicForm.swift @@ -6,19 +6,15 @@ struct DynamicForm: View where Content: View { var body: some View { #if os(macOS) - Form { - content() - } - .formStyle(.grouped) - .frame(minWidth: 360, maxWidth: 600) - .fixedSize() - .padding() + Form(content: content) + .formStyle(.grouped) + .frame(minWidth: 360, maxWidth: 600) + .fixedSize() + .padding() #else HStack { - Form { - content() - } - .frame(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 600 : nil) + Form(content: content) + .frame(maxWidth: UIDevice.current.userInterfaceIdiom == .pad ? 600 : nil) } .frame(maxWidth: .infinity) .background(Color(UIColor.systemGroupedBackground)) diff --git a/Fyreplace/Views/Forms/LogoHeader.swift b/Fyreplace/Views/Forms/LogoHeader.swift index 6f82682..94190a1 100644 --- a/Fyreplace/Views/Forms/LogoHeader.swift +++ b/Fyreplace/Views/Forms/LogoHeader.swift @@ -1,17 +1,21 @@ import SwiftUI -struct LogoHeader: View { - let text: LocalizedStringKey +struct LogoHeader: View where ImageContent: View, TextContent: View { let namespace: Namespace.ID + @ViewBuilder + let imageContent: () -> ImageContent + + @ViewBuilder + let textContent: () -> TextContent + var body: some View { VStack { HStack { Spacer() - Image("Logo", label: Text("Logo")) - .resizable() + imageContent() #if os(macOS) - .frame(width: 50, height: 50) + .frame(width: 60, height: 60) #else .frame(width: 80, height: 80) #endif @@ -20,14 +24,15 @@ struct LogoHeader: View { #if os(macOS) .padding(.bottom) #else - .padding(.vertical, 40) + .padding(.top, 40) + .padding(.bottom, 20) #endif HStack { #if os(macOS) Spacer() #endif - Text(text) + textContent() .fixedSize(horizontal: false, vertical: true) #if os(macOS) .font(.headline) diff --git a/Fyreplace/Views/Forms/SubmitOrCancel.swift b/Fyreplace/Views/Forms/SubmitOrCancel.swift index be2b1c2..6bb9156 100644 --- a/Fyreplace/Views/Forms/SubmitOrCancel.swift +++ b/Fyreplace/Views/Forms/SubmitOrCancel.swift @@ -25,19 +25,18 @@ struct SubmitOrCancel: View { HStack { #if os(macOS) - cancel.controlSize(.large) + cancel Spacer() - submit.controlSize(.large) + submit #else Spacer() - submit - .toolbar { - if canCancel { - ToolbarItem { - cancel - } + submit.toolbar { + if canCancel { + ToolbarItem { + cancel } } + } Spacer() #endif } diff --git a/Fyreplace/Views/MainView.swift b/Fyreplace/Views/MainView.swift index 92e5096..b5f1840 100644 --- a/Fyreplace/Views/MainView.swift +++ b/Fyreplace/Views/MainView.swift @@ -57,6 +57,11 @@ struct MainView: View, MainViewProtocol { ) .onReceive(eventBus.events.compactMap { ($0 as? ErrorEvent)?.error }, perform: addError) .onReceive(eventBus.events.compactMap { ($0 as? FailureEvent) }, perform: addFailure) + .onReceive(eventBus.events.filter { $0 is AuthorizationIssueEvent }) { _ in + token = "" + eventBus.send( + .failure(title: "Error.Unauthorized.Title", text: "Error.Unauthorized.Text")) + } #if os(macOS) .task { await keepRefreshingToken() } #endif diff --git a/Fyreplace/Views/Screens/LoginScreen.swift b/Fyreplace/Views/Screens/LoginScreen.swift index 3b21795..8f16866 100644 --- a/Fyreplace/Views/Screens/LoginScreen.swift +++ b/Fyreplace/Views/Screens/LoginScreen.swift @@ -28,17 +28,8 @@ struct LoginScreen: View, LoginScreenProtocol { private var focused: FocusedField? var body: some View { - let footer = VStack { - if isWaitingForRandomCode { - Text("Account.Help.RandomCode") - } - } - DynamicForm { - Section( - header: LogoHeader(text: "Login.Header", namespace: namespace), - footer: footer - ) { + Section { EnvironmentPicker(namespace: namespace).disabled(isWaitingForRandomCode) TextField( @@ -52,6 +43,7 @@ struct LoginScreen: View, LoginScreenProtocol { .onSubmit(submit) .matchedGeometryEffect(id: "first-field", in: namespace) #if !os(macOS) + .textInputAutocapitalization(.never) .keyboardType(.asciiCapable) #endif @@ -74,14 +66,7 @@ struct LoginScreen: View, LoginScreenProtocol { .keyboardType(.asciiCapable) #endif } - } - .onAppear { - if identifier.isEmpty { - focused = .identifier - } - } - Section { SubmitOrCancel( namespace: namespace, submitLabel: "Login.Submit", @@ -91,6 +76,23 @@ struct LoginScreen: View, LoginScreenProtocol { submitAction: submit, cancelAction: cancel ) + } header: { + LogoHeader(namespace: namespace) { + Image("Logo", label: Text("Logo")).resizable() + } textContent: { + Text("Login.Header") + } + } footer: { + VStack { + if isWaitingForRandomCode { + Text("Account.Help.RandomCode") + } + } + } + .onAppear { + if identifier.isEmpty { + focused = .identifier + } } } .disabled(isLoading) diff --git a/Fyreplace/Views/Screens/RegisterScreen.swift b/Fyreplace/Views/Screens/RegisterScreen.swift index 8f2ea91..deaadd2 100644 --- a/Fyreplace/Views/Screens/RegisterScreen.swift +++ b/Fyreplace/Views/Screens/RegisterScreen.swift @@ -44,19 +44,8 @@ struct RegisterScreen: View, RegisterScreenProtocol { let emailPrompt: Text? = nil #endif - let footer = VStack { - if isWaitingForRandomCode { - Text("Account.Help.RandomCode") - } else { - firstStepFooter - } - } - DynamicForm { - Section( - header: LogoHeader(text: "Register.Header", namespace: namespace), - footer: footer - ) { + Section { EnvironmentPicker(namespace: namespace).disabled(isWaitingForRandomCode) TextField( @@ -72,6 +61,7 @@ struct RegisterScreen: View, RegisterScreenProtocol { .onSubmit { focused = .email } .matchedGeometryEffect(id: "first-field", in: namespace) #if !os(macOS) + .textInputAutocapitalization(.never) .keyboardType(.asciiCapable) #endif @@ -110,16 +100,7 @@ struct RegisterScreen: View, RegisterScreenProtocol { .keyboardType(.asciiCapable) #endif } - } - .onAppear { - if username.isEmpty { - focused = .username - } else if email.isEmpty { - focused = .email - } - } - Section { SubmitOrCancel( namespace: namespace, submitLabel: "Register.Submit", @@ -129,6 +110,27 @@ struct RegisterScreen: View, RegisterScreenProtocol { submitAction: submit, cancelAction: cancel ) + } header: { + LogoHeader(namespace: namespace) { + Image("Logo", label: Text("Logo")).resizable() + } textContent: { + Text("Register.Header") + } + } footer: { + VStack { + if isWaitingForRandomCode { + Text("Account.Help.RandomCode") + } else { + firstStepFooter + } + } + } + .onAppear { + if username.isEmpty { + focused = .username + } else if email.isEmpty { + focused = .email + } } } .disabled(isLoading) diff --git a/Fyreplace/Views/Screens/SettingsScreen.swift b/Fyreplace/Views/Screens/SettingsScreen.swift index 25b6b11..aafa23a 100644 --- a/Fyreplace/Views/Screens/SettingsScreen.swift +++ b/Fyreplace/Views/Screens/SettingsScreen.swift @@ -4,13 +4,64 @@ struct SettingsScreen: View, SettingsScreenProtocol { @EnvironmentObject var eventBus: EventBus + @Environment(\.api) + var api + @KeychainStorage("connection.token") var token + @State + var currentUser: Components.Schemas.User? + + @Namespace + private var namespace + var body: some View { - Button("Settings.Logout", role: .destructive, action: logout) - .controlSize(.large) - .buttonStyle(.borderedProminent) + DynamicForm { + Section { + LabeledContent( + "Settings.Username", value: currentUser?.username ?? .init(localized: "Loading") + ) + LabeledContent("Settings.DateJoined") { + if let user = currentUser { + #if os(macOS) + let dateFormatStyle = Date.FormatStyle.DateStyle.long + #else + let dateFormatStyle = Date.FormatStyle.DateStyle.abbreviated + #endif + + Text( + verbatim: user.dateCreated.formatted( + date: dateFormatStyle, + time: .shortened + ) + ) + } else { + Text("Loading") + } + } + + HStack { + Spacer() + Button("Settings.Logout", role: .destructive, action: logout) + #if !os(macOS) + Spacer() + #endif + } + } header: { + LogoHeader(namespace: namespace) { + Avatar(user: currentUser) + } textContent: { + Text("Settings.Header") + } + } + } + .navigationTitle(Destination.settings.titleKey) + .onAppear { + Task { + await getCurrentUser() + } + } } } diff --git a/Fyreplace/Views/Screens/SettingsScreenProtocol.swift b/Fyreplace/Views/Screens/SettingsScreenProtocol.swift index 2fc5ab4..10e803d 100644 --- a/Fyreplace/Views/Screens/SettingsScreenProtocol.swift +++ b/Fyreplace/Views/Screens/SettingsScreenProtocol.swift @@ -1,8 +1,33 @@ protocol SettingsScreenProtocol: ViewProtocol { + var api: APIProtocol { get } + var token: String { get nonmutating set } + var currentUser: Components.Schemas.User? { get nonmutating set } } extension SettingsScreenProtocol { + func getCurrentUser() async { + await call { + let response = try await api.getCurrentUser() + + switch response { + case let .ok(ok): + switch ok.body { + case let .json(user): + currentUser = user + } + + return nil + + case .unauthorized: + return .authorizationIssue() + + case .forbidden, .default: + return .error(UnknownError()) + } + } + } + func logout() { token = "" }