diff --git a/Fyreplace.xcodeproj/project.pbxproj b/Fyreplace.xcodeproj/project.pbxproj index deff5e5..ae631b8 100644 --- a/Fyreplace.xcodeproj/project.pbxproj +++ b/Fyreplace.xcodeproj/project.pbxproj @@ -51,6 +51,7 @@ 4D9B3B452C36F46F00A8F7AD /* NSTextContentType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9B3B442C36F46F00A8F7AD /* NSTextContentType.swift */; }; 4D9B3B472C36F50300A8F7AD /* UITextContentType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9B3B462C36F50300A8F7AD /* UITextContentType.swift */; }; 4D9DC5032C11BF2500BA0507 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9DC5022C11BF2500BA0507 /* Config.swift */; }; + 4DA04EE22CAEEAD800B70D73 /* CGFloat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA04EE12CAEEAD100B70D73 /* CGFloat.swift */; }; 4DA7BFB72C5FD479005CC4FF /* PerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA7BFB62C5FD479005CC4FF /* PerformanceTests.swift */; }; 4DA7BFBB2C5FDEC1005CC4FF /* FakeClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA7BFBA2C5FDEC1005CC4FF /* FakeClient.swift */; }; 4DB10B502C4FEBFC00634BF6 /* HelpCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB10B4F2C4FEBFC00634BF6 /* HelpCommands.swift */; }; @@ -151,6 +152,7 @@ 4D9B3B442C36F46F00A8F7AD /* NSTextContentType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSTextContentType.swift; sourceTree = ""; }; 4D9B3B462C36F50300A8F7AD /* UITextContentType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITextContentType.swift; sourceTree = ""; }; 4D9DC5022C11BF2500BA0507 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = ""; }; + 4DA04EE12CAEEAD100B70D73 /* CGFloat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGFloat.swift; sourceTree = ""; }; 4DA7BFB62C5FD479005CC4FF /* PerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceTests.swift; sourceTree = ""; }; 4DA7BFBA2C5FDEC1005CC4FF /* FakeClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeClient.swift; sourceTree = ""; }; 4DB10B4F2C4FEBFC00634BF6 /* HelpCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpCommands.swift; sourceTree = ""; }; @@ -416,6 +418,7 @@ 4D9B3B432C36E64F00A8F7AD /* Extensions */ = { isa = PBXGroup; children = ( + 4DA04EE12CAEEAD100B70D73 /* CGFloat.swift */, 4DE785812C88B248000EC4E5 /* String.swift */, 4D9B3B442C36F46F00A8F7AD /* NSTextContentType.swift */, 4D9B3B462C36F50300A8F7AD /* UITextContentType.swift */, @@ -641,6 +644,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 4DA04EE22CAEEAD800B70D73 /* CGFloat.swift in Sources */, 4D9B3B3D2C34B13E00A8F7AD /* LogoHeader.swift in Sources */, 4DA7BFBB2C5FDEC1005CC4FF /* FakeClient.swift in Sources */, 4DE785822C88B248000EC4E5 /* String.swift in Sources */, diff --git a/Fyreplace/Extensions/CGFloat.swift b/Fyreplace/Extensions/CGFloat.swift new file mode 100644 index 0000000..26cc8fc --- /dev/null +++ b/Fyreplace/Extensions/CGFloat.swift @@ -0,0 +1,9 @@ +import Foundation + +extension CGFloat { + #if os(macOS) + static var logoSize: Self { 60 } + #else + static var logoSize: Self { 80 } + #endif +} diff --git a/Fyreplace/Fakes/FakeClient.swift b/Fyreplace/Fakes/FakeClient.swift index e7c39e4..c303e29 100644 --- a/Fyreplace/Fakes/FakeClient.swift +++ b/Fyreplace/Fakes/FakeClient.swift @@ -322,7 +322,7 @@ extension FakeClient { func deleteCurrentUserAvatar(_: Operations.deleteCurrentUserAvatar.Input) async throws -> Operations.deleteCurrentUserAvatar.Output { - fatalError("Not implemented") + return .noContent(.init()) } func getCurrentUser(_: Operations.getCurrentUser.Input) async throws diff --git a/Fyreplace/Resources/Localizable.xcstrings b/Fyreplace/Resources/Localizable.xcstrings index 8b09262..809bf86 100644 --- a/Fyreplace/Resources/Localizable.xcstrings +++ b/Fyreplace/Resources/Localizable.xcstrings @@ -94,6 +94,26 @@ } } }, + "EditableAvatar.ContextMenu.Change" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Change avatar" + } + } + } + }, + "EditableAvatar.ContextMenu.Remove" : { + "localizations" : { + "en" : { + "stringUnit" : { + "state" : "translated", + "value" : "Remove avatar" + } + } + } + }, "Environment.Default" : { "localizations" : { "en" : { @@ -614,12 +634,12 @@ } } }, - "Settings.DateJoined" : { + "Settings.DateJoined:%@" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Joined on" + "value" : "Joined: %@" } } } @@ -684,16 +704,6 @@ } } }, - "Settings.Header" : { - "localizations" : { - "en" : { - "stringUnit" : { - "state" : "translated", - "value" : "Profile" - } - } - } - }, "Settings.Logout" : { "localizations" : { "en" : { @@ -704,12 +714,12 @@ } } }, - "Settings.Username" : { + "Settings.Profile.Header" : { "localizations" : { "en" : { "stringUnit" : { "state" : "translated", - "value" : "Username" + "value" : "Profile" } } } diff --git a/Fyreplace/Views/Components/Avatar.swift b/Fyreplace/Views/Components/Avatar.swift index 6adcc2c..fc4b814 100644 --- a/Fyreplace/Views/Components/Avatar.swift +++ b/Fyreplace/Views/Components/Avatar.swift @@ -20,10 +20,16 @@ struct Avatar: View { var body: some View { ZStack { if let avatar = user?.avatar, !avatar.isEmpty { - AsyncImage(url: .init(string: avatar)) { - $0.resizable().scaledToFill() - } placeholder: { - ProgressView() + GeometryReader { geometry in + let size = min(geometry.size.width, geometry.size.height) + AsyncImage(url: .init(string: avatar)) { image in + image + .resizable() + .scaledToFill() + .frame(width: size, height: size) + } placeholder: { + ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity) + } } } else { Image(systemName: "person.crop.circle.fill") diff --git a/Fyreplace/Views/Components/EditableAvatar.swift b/Fyreplace/Views/Components/EditableAvatar.swift index 75a0bc6..ff1657a 100644 --- a/Fyreplace/Views/Components/EditableAvatar.swift +++ b/Fyreplace/Views/Components/EditableAvatar.swift @@ -6,34 +6,62 @@ struct EditableAvatar: View { let avatarSelected: (Data) async -> Void + let avatarRemoved: () async -> Void + @EnvironmentObject private var eventBus: EventBus @State private var showEditOverlay = false + @State + private var showPhotosPicker = false + @State private var avatarItem: PhotosPickerItem? var body: some View { let opacity = showEditOverlay ? 1.0 : 0.0 let blurred = showEditOverlay - PhotosPicker(selection: $avatarItem) { + Button { + showPhotosPicker = true + } label: { Avatar(user: user, blurred: blurred) .overlay { Image(systemName: "pencil") - .scaleEffect(2) + .resizable() + .scaledToFit() .frame(maxWidth: .infinity, maxHeight: .infinity) .padding() .background(.black.opacity(0.5)) .foregroundStyle(.white) - .clipShape(.circle) .opacity(opacity) + .clipShape(.circle) } } .animation(.default.speed(3), value: showEditOverlay) .buttonStyle(.borderless) + .photosPicker(isPresented: $showPhotosPicker, selection: $avatarItem) .onHover { showEditOverlay = $0 } + .contextMenu { + Button { + showPhotosPicker = true + } label: { + Label("EditableAvatar.ContextMenu.Change", systemImage: "photo") + } + .disabled(user == nil) + + Button(role: .destructive) { + avatarItem = nil + + Task { + await avatarRemoved() + } + } label: { + Label("EditableAvatar.ContextMenu.Remove", systemImage: "trash") + } + .disabled(user?.avatar.isEmpty ?? true) + } .dropDestination(for: Data.self) { items, _ in guard let data = items.first else { return false } avatarItem = nil diff --git a/Fyreplace/Views/Forms/LogoHeader.swift b/Fyreplace/Views/Forms/LogoHeader.swift index cdad85e..aa023a6 100644 --- a/Fyreplace/Views/Forms/LogoHeader.swift +++ b/Fyreplace/Views/Forms/LogoHeader.swift @@ -11,12 +11,7 @@ struct LogoHeader: View where ImageContent: View, Tex VStack { HStack { Spacer() - imageContent() - #if os(macOS) - .frame(width: 60, height: 60) - #else - .frame(width: 80, height: 80) - #endif + imageContent().frame(width: .logoSize, height: .logoSize) Spacer() } #if os(macOS) diff --git a/Fyreplace/Views/Screens/SettingsScreen.swift b/Fyreplace/Views/Screens/SettingsScreen.swift index af87197..86b1781 100644 --- a/Fyreplace/Views/Screens/SettingsScreen.swift +++ b/Fyreplace/Views/Screens/SettingsScreen.swift @@ -14,32 +14,55 @@ struct SettingsScreen: View, SettingsScreenProtocol { @State var currentUser: Components.Schemas.User? + @Environment(\.config) + private var config + @Namespace private var namespace + @State + private var showPhotosPicker = false + + @State + private var avatarItem: PhotosPickerItem? + var body: some View { DynamicForm { Section { - LabeledContent( - "Settings.Username", value: currentUser?.username ?? .init(localized: "Loading") - ) - LabeledContent("Settings.DateJoined") { - DateText(date: currentUser?.dateCreated) - } + let logoutButton = Button("Settings.Logout", role: .destructive, action: logout) HStack { - Spacer() - Button("Settings.Logout", role: .destructive, action: logout) - #if !os(macOS) + EditableAvatar( + user: currentUser, + avatarSelected: updateAvatar, + avatarRemoved: removeAvatar + ) + .frame(width: .logoSize, height: .logoSize) + + VStack(alignment: .leading, spacing: 4) { Spacer() + Text(verbatim: currentUser?.username ?? .init(localized: "Loading")) + .font(.headline) + DateJoinedText(date: currentUser?.dateCreated) + .foregroundStyle(.secondary) + Spacer() + } + + #if os(macOS) + Spacer() + logoutButton #endif } + + #if !os(macOS) + HStack { + Spacer() + logoutButton + Spacer() + } + #endif } header: { - LogoHeader { - EditableAvatar(user: currentUser, avatarSelected: updateAvatar) - } textContent: { - Text("Settings.Header") - } + Text("Settings.Profile.Header") } } .navigationTitle(Destination.settings.titleKey) @@ -57,7 +80,7 @@ struct SettingsScreen: View, SettingsScreenProtocol { } } -private struct DateText: View { +private struct DateJoinedText: View { let date: Date? var body: some View { @@ -68,12 +91,7 @@ private struct DateText: View { let dateFormatStyle = Date.FormatStyle.DateStyle.abbreviated #endif - Text( - verbatim: date.formatted( - date: dateFormatStyle, - time: .shortened - ) - ) + Text("Settings.DateJoined:\(date.formatted(date: dateFormatStyle, time: .shortened))") } else { Text("Loading") } diff --git a/Fyreplace/Views/Screens/SettingsScreenProtocol.swift b/Fyreplace/Views/Screens/SettingsScreenProtocol.swift index 9b40bb9..83512d5 100644 --- a/Fyreplace/Views/Screens/SettingsScreenProtocol.swift +++ b/Fyreplace/Views/Screens/SettingsScreenProtocol.swift @@ -67,6 +67,24 @@ extension SettingsScreenProtocol { } } + func removeAvatar() async { + await call { + let response = try await api.deleteCurrentUserAvatar() + + switch response { + case .noContent: + currentUser?.avatar = "" + return nil + + 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 f4859cd..202b147 100644 --- a/FyreplaceTests/Screens/SettingsScreenTests.swift +++ b/FyreplaceTests/Screens/SettingsScreenTests.swift @@ -18,8 +18,8 @@ struct SettingsScreenTests { #expect(screen.currentUser != nil) } - @Test("Too large avatar produces a failure") - func tooLargeAvatarProducesFailure() async throws { + @Test("Updating avatar with a too large image produces a failure") + func updateAvatarTooLargeProducesFailure() async throws { let eventBus = StoringEventBus() let screen = FakeScreen(eventBus: eventBus, api: .fake()) await screen.getCurrentUser() @@ -29,8 +29,8 @@ struct SettingsScreenTests { #expect(screen.currentUser?.avatar == "") } - @Test("Not image avatar produces a failure") - func notImageAvatarProducesFailure() async throws { + @Test("Updating avatar with an invalid image produces a failure") + func updateAvatarNotImageProducesFailure() async throws { let eventBus = StoringEventBus() let screen = FakeScreen(eventBus: eventBus, api: .fake()) await screen.getCurrentUser() @@ -40,8 +40,8 @@ struct SettingsScreenTests { #expect(screen.currentUser?.avatar == "") } - @Test("Valid avatar produces no failures") - func validAvatarProducesNoFailures() async throws { + @Test("Updating avatar with a valid image produces no failures") + func updateAvatarValidProducesNoFailures() async throws { let eventBus = StoringEventBus() let screen = FakeScreen(eventBus: eventBus, api: .fake()) await screen.getCurrentUser() @@ -50,4 +50,16 @@ struct SettingsScreenTests { #expect(eventBus.storedEvents.isEmpty) #expect(screen.currentUser?.avatar == FakeClient.avatar) } + + @Test("Removing avatar produces no failures") + func removeAvatarProducesNoFailures() async throws { + let eventBus = StoringEventBus() + let screen = FakeScreen(eventBus: eventBus, api: .fake()) + await screen.getCurrentUser() + await screen.updateAvatar( + with: try await .init(collecting: FakeClient.normalImageBody, upTo: 64)) + await screen.removeAvatar() + #expect(eventBus.storedEvents.isEmpty) + #expect(screen.currentUser?.avatar == "") + } }