Skip to content

Commit c10c7cb

Browse files
Allow removing avatar
1 parent d1fa02b commit c10c7cb

File tree

10 files changed

+155
-55
lines changed

10 files changed

+155
-55
lines changed

Fyreplace.xcodeproj/project.pbxproj

+4
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@
5151
4D9B3B452C36F46F00A8F7AD /* NSTextContentType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9B3B442C36F46F00A8F7AD /* NSTextContentType.swift */; };
5252
4D9B3B472C36F50300A8F7AD /* UITextContentType.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9B3B462C36F50300A8F7AD /* UITextContentType.swift */; };
5353
4D9DC5032C11BF2500BA0507 /* Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4D9DC5022C11BF2500BA0507 /* Config.swift */; };
54+
4DA04EE22CAEEAD800B70D73 /* CGFloat.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA04EE12CAEEAD100B70D73 /* CGFloat.swift */; };
5455
4DA7BFB72C5FD479005CC4FF /* PerformanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA7BFB62C5FD479005CC4FF /* PerformanceTests.swift */; };
5556
4DA7BFBB2C5FDEC1005CC4FF /* FakeClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DA7BFBA2C5FDEC1005CC4FF /* FakeClient.swift */; };
5657
4DB10B502C4FEBFC00634BF6 /* HelpCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DB10B4F2C4FEBFC00634BF6 /* HelpCommands.swift */; };
@@ -151,6 +152,7 @@
151152
4D9B3B442C36F46F00A8F7AD /* NSTextContentType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NSTextContentType.swift; sourceTree = "<group>"; };
152153
4D9B3B462C36F50300A8F7AD /* UITextContentType.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITextContentType.swift; sourceTree = "<group>"; };
153154
4D9DC5022C11BF2500BA0507 /* Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Config.swift; sourceTree = "<group>"; };
155+
4DA04EE12CAEEAD100B70D73 /* CGFloat.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CGFloat.swift; sourceTree = "<group>"; };
154156
4DA7BFB62C5FD479005CC4FF /* PerformanceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PerformanceTests.swift; sourceTree = "<group>"; };
155157
4DA7BFBA2C5FDEC1005CC4FF /* FakeClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakeClient.swift; sourceTree = "<group>"; };
156158
4DB10B4F2C4FEBFC00634BF6 /* HelpCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HelpCommands.swift; sourceTree = "<group>"; };
@@ -416,6 +418,7 @@
416418
4D9B3B432C36E64F00A8F7AD /* Extensions */ = {
417419
isa = PBXGroup;
418420
children = (
421+
4DA04EE12CAEEAD100B70D73 /* CGFloat.swift */,
419422
4DE785812C88B248000EC4E5 /* String.swift */,
420423
4D9B3B442C36F46F00A8F7AD /* NSTextContentType.swift */,
421424
4D9B3B462C36F50300A8F7AD /* UITextContentType.swift */,
@@ -641,6 +644,7 @@
641644
isa = PBXSourcesBuildPhase;
642645
buildActionMask = 2147483647;
643646
files = (
647+
4DA04EE22CAEEAD800B70D73 /* CGFloat.swift in Sources */,
644648
4D9B3B3D2C34B13E00A8F7AD /* LogoHeader.swift in Sources */,
645649
4DA7BFBB2C5FDEC1005CC4FF /* FakeClient.swift in Sources */,
646650
4DE785822C88B248000EC4E5 /* String.swift in Sources */,

Fyreplace/Extensions/CGFloat.swift

+9
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
import Foundation
2+
3+
extension CGFloat {
4+
#if os(macOS)
5+
static var logoSize: Self { 60 }
6+
#else
7+
static var logoSize: Self { 80 }
8+
#endif
9+
}

Fyreplace/Fakes/FakeClient.swift

+1-1
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,7 @@ extension FakeClient {
322322
func deleteCurrentUserAvatar(_: Operations.deleteCurrentUserAvatar.Input) async throws
323323
-> Operations.deleteCurrentUserAvatar.Output
324324
{
325-
fatalError("Not implemented")
325+
return .noContent(.init())
326326
}
327327

328328
func getCurrentUser(_: Operations.getCurrentUser.Input) async throws

Fyreplace/Resources/Localizable.xcstrings

+24-14
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,26 @@
9494
}
9595
}
9696
},
97+
"EditableAvatar.ContextMenu.Change" : {
98+
"localizations" : {
99+
"en" : {
100+
"stringUnit" : {
101+
"state" : "translated",
102+
"value" : "Change avatar"
103+
}
104+
}
105+
}
106+
},
107+
"EditableAvatar.ContextMenu.Remove" : {
108+
"localizations" : {
109+
"en" : {
110+
"stringUnit" : {
111+
"state" : "translated",
112+
"value" : "Remove avatar"
113+
}
114+
}
115+
}
116+
},
97117
"Environment.Default" : {
98118
"localizations" : {
99119
"en" : {
@@ -614,12 +634,12 @@
614634
}
615635
}
616636
},
617-
"Settings.DateJoined" : {
637+
"Settings.DateJoined:%@" : {
618638
"localizations" : {
619639
"en" : {
620640
"stringUnit" : {
621641
"state" : "translated",
622-
"value" : "Joined on"
642+
"value" : "Joined: %@"
623643
}
624644
}
625645
}
@@ -684,16 +704,6 @@
684704
}
685705
}
686706
},
687-
"Settings.Header" : {
688-
"localizations" : {
689-
"en" : {
690-
"stringUnit" : {
691-
"state" : "translated",
692-
"value" : "Profile"
693-
}
694-
}
695-
}
696-
},
697707
"Settings.Logout" : {
698708
"localizations" : {
699709
"en" : {
@@ -704,12 +714,12 @@
704714
}
705715
}
706716
},
707-
"Settings.Username" : {
717+
"Settings.Profile.Header" : {
708718
"localizations" : {
709719
"en" : {
710720
"stringUnit" : {
711721
"state" : "translated",
712-
"value" : "Username"
722+
"value" : "Profile"
713723
}
714724
}
715725
}

Fyreplace/Views/Components/Avatar.swift

+10-4
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,16 @@ struct Avatar: View {
2020
var body: some View {
2121
ZStack {
2222
if let avatar = user?.avatar, !avatar.isEmpty {
23-
AsyncImage(url: .init(string: avatar)) {
24-
$0.resizable().scaledToFill()
25-
} placeholder: {
26-
ProgressView()
23+
GeometryReader { geometry in
24+
let size = min(geometry.size.width, geometry.size.height)
25+
AsyncImage(url: .init(string: avatar)) { image in
26+
image
27+
.resizable()
28+
.scaledToFill()
29+
.frame(width: size, height: size)
30+
} placeholder: {
31+
ProgressView().frame(maxWidth: .infinity, maxHeight: .infinity)
32+
}
2733
}
2834
} else {
2935
Image(systemName: "person.crop.circle.fill")

Fyreplace/Views/Components/EditableAvatar.swift

+31-3
Original file line numberDiff line numberDiff line change
@@ -6,34 +6,62 @@ struct EditableAvatar: View {
66

77
let avatarSelected: (Data) async -> Void
88

9+
let avatarRemoved: () async -> Void
10+
911
@EnvironmentObject
1012
private var eventBus: EventBus
1113

1214
@State
1315
private var showEditOverlay = false
1416

17+
@State
18+
private var showPhotosPicker = false
19+
1520
@State
1621
private var avatarItem: PhotosPickerItem?
1722

1823
var body: some View {
1924
let opacity = showEditOverlay ? 1.0 : 0.0
2025
let blurred = showEditOverlay
21-
PhotosPicker(selection: $avatarItem) {
26+
Button {
27+
showPhotosPicker = true
28+
} label: {
2229
Avatar(user: user, blurred: blurred)
2330
.overlay {
2431
Image(systemName: "pencil")
25-
.scaleEffect(2)
32+
.resizable()
33+
.scaledToFit()
2634
.frame(maxWidth: .infinity, maxHeight: .infinity)
2735
.padding()
2836
.background(.black.opacity(0.5))
2937
.foregroundStyle(.white)
30-
.clipShape(.circle)
3138
.opacity(opacity)
39+
.clipShape(.circle)
3240
}
3341
}
3442
.animation(.default.speed(3), value: showEditOverlay)
3543
.buttonStyle(.borderless)
44+
.photosPicker(isPresented: $showPhotosPicker, selection: $avatarItem)
3645
.onHover { showEditOverlay = $0 }
46+
.contextMenu {
47+
Button {
48+
showPhotosPicker = true
49+
} label: {
50+
Label("EditableAvatar.ContextMenu.Change", systemImage: "photo")
51+
}
52+
.disabled(user == nil)
53+
54+
Button(role: .destructive) {
55+
avatarItem = nil
56+
57+
Task {
58+
await avatarRemoved()
59+
}
60+
} label: {
61+
Label("EditableAvatar.ContextMenu.Remove", systemImage: "trash")
62+
}
63+
.disabled(user?.avatar.isEmpty ?? true)
64+
}
3765
.dropDestination(for: Data.self) { items, _ in
3866
guard let data = items.first else { return false }
3967
avatarItem = nil

Fyreplace/Views/Forms/LogoHeader.swift

+1-6
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,7 @@ struct LogoHeader<ImageContent, TextContent>: View where ImageContent: View, Tex
1111
VStack {
1212
HStack {
1313
Spacer()
14-
imageContent()
15-
#if os(macOS)
16-
.frame(width: 60, height: 60)
17-
#else
18-
.frame(width: 80, height: 80)
19-
#endif
14+
imageContent().frame(width: .logoSize, height: .logoSize)
2015
Spacer()
2116
}
2217
#if os(macOS)

Fyreplace/Views/Screens/SettingsScreen.swift

+39-21
Original file line numberDiff line numberDiff line change
@@ -14,32 +14,55 @@ struct SettingsScreen: View, SettingsScreenProtocol {
1414
@State
1515
var currentUser: Components.Schemas.User?
1616

17+
@Environment(\.config)
18+
private var config
19+
1720
@Namespace
1821
private var namespace
1922

23+
@State
24+
private var showPhotosPicker = false
25+
26+
@State
27+
private var avatarItem: PhotosPickerItem?
28+
2029
var body: some View {
2130
DynamicForm {
2231
Section {
23-
LabeledContent(
24-
"Settings.Username", value: currentUser?.username ?? .init(localized: "Loading")
25-
)
26-
LabeledContent("Settings.DateJoined") {
27-
DateText(date: currentUser?.dateCreated)
28-
}
32+
let logoutButton = Button("Settings.Logout", role: .destructive, action: logout)
2933

3034
HStack {
31-
Spacer()
32-
Button("Settings.Logout", role: .destructive, action: logout)
33-
#if !os(macOS)
35+
EditableAvatar(
36+
user: currentUser,
37+
avatarSelected: updateAvatar,
38+
avatarRemoved: removeAvatar
39+
)
40+
.frame(width: .logoSize, height: .logoSize)
41+
42+
VStack(alignment: .leading, spacing: 4) {
3443
Spacer()
44+
Text(verbatim: currentUser?.username ?? .init(localized: "Loading"))
45+
.font(.headline)
46+
DateJoinedText(date: currentUser?.dateCreated)
47+
.foregroundStyle(.secondary)
48+
Spacer()
49+
}
50+
51+
#if os(macOS)
52+
Spacer()
53+
logoutButton
3554
#endif
3655
}
56+
57+
#if !os(macOS)
58+
HStack {
59+
Spacer()
60+
logoutButton
61+
Spacer()
62+
}
63+
#endif
3764
} header: {
38-
LogoHeader {
39-
EditableAvatar(user: currentUser, avatarSelected: updateAvatar)
40-
} textContent: {
41-
Text("Settings.Header")
42-
}
65+
Text("Settings.Profile.Header")
4366
}
4467
}
4568
.navigationTitle(Destination.settings.titleKey)
@@ -57,7 +80,7 @@ struct SettingsScreen: View, SettingsScreenProtocol {
5780
}
5881
}
5982

60-
private struct DateText: View {
83+
private struct DateJoinedText: View {
6184
let date: Date?
6285

6386
var body: some View {
@@ -68,12 +91,7 @@ private struct DateText: View {
6891
let dateFormatStyle = Date.FormatStyle.DateStyle.abbreviated
6992
#endif
7093

71-
Text(
72-
verbatim: date.formatted(
73-
date: dateFormatStyle,
74-
time: .shortened
75-
)
76-
)
94+
Text("Settings.DateJoined:\(date.formatted(date: dateFormatStyle, time: .shortened))")
7795
} else {
7896
Text("Loading")
7997
}

Fyreplace/Views/Screens/SettingsScreenProtocol.swift

+18
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,24 @@ extension SettingsScreenProtocol {
6767
}
6868
}
6969

70+
func removeAvatar() async {
71+
await call {
72+
let response = try await api.deleteCurrentUserAvatar()
73+
74+
switch response {
75+
case .noContent:
76+
currentUser?.avatar = ""
77+
return nil
78+
79+
case .unauthorized:
80+
return .authorizationIssue()
81+
82+
case .forbidden, .default:
83+
return .error()
84+
}
85+
}
86+
}
87+
7088
func logout() {
7189
token = ""
7290
}

FyreplaceTests/Screens/SettingsScreenTests.swift

+18-6
Original file line numberDiff line numberDiff line change
@@ -18,8 +18,8 @@ struct SettingsScreenTests {
1818
#expect(screen.currentUser != nil)
1919
}
2020

21-
@Test("Too large avatar produces a failure")
22-
func tooLargeAvatarProducesFailure() async throws {
21+
@Test("Updating avatar with a too large image produces a failure")
22+
func updateAvatarTooLargeProducesFailure() async throws {
2323
let eventBus = StoringEventBus()
2424
let screen = FakeScreen(eventBus: eventBus, api: .fake())
2525
await screen.getCurrentUser()
@@ -29,8 +29,8 @@ struct SettingsScreenTests {
2929
#expect(screen.currentUser?.avatar == "")
3030
}
3131

32-
@Test("Not image avatar produces a failure")
33-
func notImageAvatarProducesFailure() async throws {
32+
@Test("Updating avatar with an invalid image produces a failure")
33+
func updateAvatarNotImageProducesFailure() async throws {
3434
let eventBus = StoringEventBus()
3535
let screen = FakeScreen(eventBus: eventBus, api: .fake())
3636
await screen.getCurrentUser()
@@ -40,8 +40,8 @@ struct SettingsScreenTests {
4040
#expect(screen.currentUser?.avatar == "")
4141
}
4242

43-
@Test("Valid avatar produces no failures")
44-
func validAvatarProducesNoFailures() async throws {
43+
@Test("Updating avatar with a valid image produces no failures")
44+
func updateAvatarValidProducesNoFailures() async throws {
4545
let eventBus = StoringEventBus()
4646
let screen = FakeScreen(eventBus: eventBus, api: .fake())
4747
await screen.getCurrentUser()
@@ -50,4 +50,16 @@ struct SettingsScreenTests {
5050
#expect(eventBus.storedEvents.isEmpty)
5151
#expect(screen.currentUser?.avatar == FakeClient.avatar)
5252
}
53+
54+
@Test("Removing avatar produces no failures")
55+
func removeAvatarProducesNoFailures() async throws {
56+
let eventBus = StoringEventBus()
57+
let screen = FakeScreen(eventBus: eventBus, api: .fake())
58+
await screen.getCurrentUser()
59+
await screen.updateAvatar(
60+
with: try await .init(collecting: FakeClient.normalImageBody, upTo: 64))
61+
await screen.removeAvatar()
62+
#expect(eventBus.storedEvents.isEmpty)
63+
#expect(screen.currentUser?.avatar == "")
64+
}
5365
}

0 commit comments

Comments
 (0)