iOS client for Grain grain.social
ios photography atproto
7
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: edit profile page, pull-to-refresh, gallery create polish

Add EditProfileView with avatar upload/remove, display name, and bio
editing. Preserves existing avatar blob ref on save when unchanged.
Add getRecord API endpoint and AnyCodable.dictValue accessor.
Add pull-to-refresh and auto-reload on ProfileView. Add character
count indicators and web-matching placeholders to CreateGalleryView.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

+320 -7
+10
Grain/API/Endpoints/RecordEndpoints.swift
··· 27 27 let rkey: String 28 28 } 29 29 30 + struct GetRecordResponse: Codable, Sendable { 31 + var uri: String? 32 + var cid: String? 33 + var record: AnyCodable? 34 + } 35 + 30 36 extension XRPCClient { 37 + func getRecord(uri: String, auth: AuthContext? = nil) async throws -> GetRecordResponse { 38 + try await query("dev.hatk.getRecord", params: ["uri": uri], auth: auth, as: GetRecordResponse.self) 39 + } 40 + 31 41 func createRecord(collection: String, repo: String, record: AnyCodable, auth: AuthContext? = nil) async throws -> CreateRecordResponse { 32 42 try await procedure("dev.hatk.createRecord", input: CreateRecordInput(collection: collection, repo: repo, record: record), auth: auth, as: CreateRecordResponse.self) 33 43 }
+5
Grain/Utilities/AnyCodable.swift
··· 49 49 } 50 50 } 51 51 52 + var dictValue: [String: AnyCodable]? { 53 + if case .dict(let d) = storage { return d } 54 + return nil 55 + } 56 + 52 57 func encode(to encoder: Encoder) throws { 53 58 var container = encoder.singleValueContainer() 54 59 switch storage {
+20 -4
Grain/Views/Create/CreateGalleryView.swift
··· 24 24 let client: XRPCClient 25 25 var onCreated: (() -> Void)? 26 26 27 + private let maxTitle = 100 28 + private let maxDescription = 1000 29 + 27 30 var body: some View { 28 31 NavigationStack { 29 32 Form { ··· 44 47 } 45 48 46 49 Section("Details") { 47 - TextField("Title", text: $title) 48 - TextField("Description (optional)", text: $description, axis: .vertical) 49 - .lineLimit(3...6) 50 + VStack(alignment: .leading, spacing: 4) { 51 + TextField("Add a title...", text: $title) 52 + Text("\(title.count)/\(maxTitle)") 53 + .font(.caption2) 54 + .foregroundStyle(title.count > maxTitle ? .red : .secondary) 55 + .frame(maxWidth: .infinity, alignment: .trailing) 56 + } 57 + 58 + VStack(alignment: .leading, spacing: 4) { 59 + TextField("Add a description. Supports @mentions, #hashtags, and links.", text: $description, axis: .vertical) 60 + .lineLimit(3...6) 61 + Text("\(description.count)/\(maxDescription)") 62 + .font(.caption2) 63 + .foregroundStyle(description.count > maxDescription ? .red : .secondary) 64 + .frame(maxWidth: .infinity, alignment: .trailing) 65 + } 50 66 } 51 67 52 68 Section("Location") { ··· 128 144 .bold() 129 145 } 130 146 } 131 - .disabled(title.isEmpty || selectedPhotos.isEmpty || isUploading) 147 + .disabled(title.isEmpty || selectedPhotos.isEmpty || isUploading || title.count > maxTitle || description.count > maxDescription) 132 148 } 133 149 } 134 150 }
+12 -1
Grain/Views/Profile/ProfileView.swift
··· 7 7 @State private var selectedGalleryUri: String? 8 8 @State private var selectedProfileDid: String? 9 9 @State private var selectedHashtag: String? 10 + @State private var hasAppeared = false 10 11 let client: XRPCClient 11 12 let did: String 12 13 var isRoot = false ··· 125 126 if did == auth.userDID { 126 127 ToolbarItem(placement: .topBarTrailing) { 127 128 NavigationLink { 128 - SettingsView() 129 + SettingsView(client: client) 129 130 } label: { 130 131 Image(systemName: "gearshape") 131 132 } ··· 141 142 .navigationDestination(item: $selectedHashtag) { tag in 142 143 HashtagFeedView(client: client, tag: tag) 143 144 } 145 + .background(Color(.systemBackground)) 146 + .refreshable { 147 + await viewModel.load(did: did, auth: auth.authContext()) 148 + } 144 149 .task { 145 150 await viewModel.load(did: did, auth: auth.authContext()) 151 + } 152 + .onAppear { 153 + if hasAppeared { 154 + Task { await viewModel.load(did: did, auth: auth.authContext()) } 155 + } 156 + hasAppeared = true 146 157 } 147 158 } 148 159 }
+271
Grain/Views/Settings/EditProfileView.swift
··· 1 + import PhotosUI 2 + import SwiftUI 3 + import NukeUI 4 + 5 + struct EditProfileView: View { 6 + @Environment(AuthManager.self) private var auth 7 + @Environment(\.dismiss) private var dismiss 8 + let client: XRPCClient 9 + 10 + @State private var displayName = "" 11 + @State private var bio = "" 12 + @State private var existingAvatarURL: String? 13 + @State private var existingAvatarBlob: [String: AnyCodable]? 14 + @State private var selectedPhoto: PhotosPickerItem? 15 + @State private var newAvatarData: Data? 16 + @State private var newAvatarImage: UIImage? 17 + @State private var removeAvatar = false 18 + @State private var isLoading = true 19 + @State private var isSaving = false 20 + @State private var errorMessage: String? 21 + 22 + private let maxDisplayName = 64 23 + private let maxBio = 256 24 + 25 + var body: some View { 26 + Form { 27 + // Avatar section 28 + Section { 29 + HStack { 30 + Spacer() 31 + VStack(spacing: 12) { 32 + ZStack(alignment: .bottomTrailing) { 33 + if let newAvatarImage { 34 + Image(uiImage: newAvatarImage) 35 + .resizable() 36 + .scaledToFill() 37 + .frame(width: 100, height: 100) 38 + .clipShape(Circle()) 39 + } else if !removeAvatar, let existingAvatarURL { 40 + AvatarView(url: existingAvatarURL, size: 100) 41 + } else { 42 + ZStack { 43 + Circle().fill(.quaternary) 44 + Image(systemName: "person.fill") 45 + .font(.system(size: 40)) 46 + .foregroundStyle(.tertiary) 47 + } 48 + .frame(width: 100, height: 100) 49 + } 50 + 51 + PhotosPicker(selection: $selectedPhoto, matching: .images) { 52 + Image(systemName: "camera.fill") 53 + .font(.system(size: 14)) 54 + .foregroundStyle(.white) 55 + .frame(width: 32, height: 32) 56 + .background(Color("AccentColor"), in: Circle()) 57 + } 58 + } 59 + 60 + if newAvatarImage != nil || (!removeAvatar && existingAvatarURL != nil) { 61 + Button("Remove Photo", role: .destructive) { 62 + newAvatarData = nil 63 + newAvatarImage = nil 64 + selectedPhoto = nil 65 + removeAvatar = true 66 + } 67 + .font(.caption) 68 + } 69 + } 70 + Spacer() 71 + } 72 + .listRowBackground(Color.clear) 73 + } 74 + 75 + // Fields 76 + Section { 77 + VStack(alignment: .leading, spacing: 4) { 78 + Text("Display Name") 79 + .font(.caption) 80 + .foregroundStyle(.secondary) 81 + TextField("Display Name", text: $displayName) 82 + .textInputAutocapitalization(.words) 83 + Text("\(displayName.count)/\(maxDisplayName)") 84 + .font(.caption2) 85 + .foregroundStyle(displayName.count > maxDisplayName ? .red : .secondary) 86 + .frame(maxWidth: .infinity, alignment: .trailing) 87 + } 88 + 89 + VStack(alignment: .leading, spacing: 4) { 90 + Text("Bio") 91 + .font(.caption) 92 + .foregroundStyle(.secondary) 93 + TextEditor(text: $bio) 94 + .frame(minHeight: 100) 95 + Text("\(bio.count)/\(maxBio)") 96 + .font(.caption2) 97 + .foregroundStyle(bio.count > maxBio ? .red : .secondary) 98 + .frame(maxWidth: .infinity, alignment: .trailing) 99 + } 100 + } 101 + 102 + if let errorMessage { 103 + Section { 104 + Text(errorMessage) 105 + .foregroundStyle(.red) 106 + .font(.caption) 107 + } 108 + } 109 + } 110 + .navigationTitle("Edit Profile") 111 + .navigationBarTitleDisplayMode(.inline) 112 + .toolbar { 113 + ToolbarItem(placement: .topBarTrailing) { 114 + Button { 115 + Task { await save() } 116 + } label: { 117 + if isSaving { 118 + ProgressView() 119 + } else { 120 + Text("Save") 121 + .fontWeight(.semibold) 122 + } 123 + } 124 + .disabled(isSaving || displayName.count > maxDisplayName || bio.count > maxBio) 125 + } 126 + } 127 + .onChange(of: selectedPhoto) { 128 + Task { await loadSelectedPhoto() } 129 + } 130 + .task { 131 + await loadProfile() 132 + } 133 + .overlay { 134 + if isLoading { 135 + ProgressView() 136 + } 137 + } 138 + } 139 + 140 + private func loadProfile() async { 141 + guard let did = auth.userDID, let authContext = auth.authContext() else { return } 142 + do { 143 + let profile = try await client.getActorProfile(actor: did, auth: authContext) 144 + displayName = profile.displayName ?? "" 145 + bio = profile.description ?? "" 146 + existingAvatarURL = profile.avatar 147 + 148 + // Fetch raw record to get avatar blob ref for preservation on save 149 + let record = try await client.getRecord(uri: "at://\(did)/social.grain.actor.profile/self", auth: authContext) 150 + if let value = record.record?.dictValue?["value"], 151 + let avatar = value.dictValue?["avatar"] { 152 + existingAvatarBlob = avatar.dictValue 153 + } 154 + } catch { 155 + errorMessage = "Failed to load profile" 156 + } 157 + isLoading = false 158 + } 159 + 160 + private func loadSelectedPhoto() async { 161 + guard let selectedPhoto else { return } 162 + do { 163 + if let data = try await selectedPhoto.loadTransferable(type: Data.self), 164 + let image = UIImage(data: data) { 165 + let resized = resizeImage(image, maxSize: 1000, maxBytes: 900_000) 166 + newAvatarImage = UIImage(data: resized) 167 + newAvatarData = resized 168 + removeAvatar = false 169 + } 170 + } catch { 171 + errorMessage = "Failed to load selected photo" 172 + } 173 + } 174 + 175 + private func save() async { 176 + guard let authContext = auth.authContext() else { return } 177 + isSaving = true 178 + errorMessage = nil 179 + 180 + do { 181 + var avatarValue: AnyCodable? 182 + 183 + if let newAvatarData { 184 + // Upload new avatar 185 + let response = try await client.uploadBlob(data: newAvatarData, mimeType: "image/jpeg", auth: authContext) 186 + avatarValue = blobRefToAnyCodable(response.blob) 187 + } else if removeAvatar { 188 + avatarValue = nil 189 + } else if let existingAvatarBlob { 190 + // Preserve existing avatar 191 + avatarValue = AnyCodable(existingAvatarBlob) 192 + } 193 + 194 + var record: [String: AnyCodable] = [ 195 + "createdAt": AnyCodable(ISO8601DateFormatter().string(from: Date())) 196 + ] 197 + 198 + let trimmedName = displayName.trimmingCharacters(in: .whitespacesAndNewlines) 199 + if !trimmedName.isEmpty { 200 + record["displayName"] = AnyCodable(trimmedName) 201 + } 202 + 203 + let trimmedBio = bio.trimmingCharacters(in: .whitespacesAndNewlines) 204 + if !trimmedBio.isEmpty { 205 + record["description"] = AnyCodable(trimmedBio) 206 + } 207 + 208 + if let avatarValue { 209 + record["avatar"] = avatarValue 210 + } 211 + 212 + _ = try await client.putRecord( 213 + collection: "social.grain.actor.profile", 214 + rkey: "self", 215 + record: AnyCodable(record), 216 + auth: authContext 217 + ) 218 + 219 + dismiss() 220 + } catch { 221 + errorMessage = "Failed to save: \(error.localizedDescription)" 222 + } 223 + isSaving = false 224 + } 225 + 226 + private func blobRefToAnyCodable(_ blob: BlobRef) -> AnyCodable { 227 + var dict: [String: AnyCodable] = [ 228 + "$type": AnyCodable("blob") 229 + ] 230 + if let mimeType = blob.mimeType { 231 + dict["mimeType"] = AnyCodable(mimeType) 232 + } 233 + if let size = blob.size { 234 + dict["size"] = AnyCodable(size) 235 + } 236 + if let ref = blob.ref { 237 + dict["ref"] = AnyCodable(["$link": AnyCodable(ref.link)]) 238 + } 239 + return AnyCodable(dict) 240 + } 241 + 242 + private func resizeImage(_ image: UIImage, maxSize: CGFloat, maxBytes: Int) -> Data { 243 + let size = image.size 244 + let scale = min(maxSize / size.width, maxSize / size.height, 1.0) 245 + let newSize = CGSize(width: size.width * scale, height: size.height * scale) 246 + 247 + let renderer = UIGraphicsImageRenderer(size: newSize) 248 + let rendered = renderer.image { _ in 249 + image.draw(in: CGRect(origin: .zero, size: newSize)) 250 + } 251 + 252 + // Binary search for quality that fits under maxBytes 253 + var low: CGFloat = 0.1 254 + var high: CGFloat = 0.95 255 + var result = rendered.jpegData(compressionQuality: high) ?? Data() 256 + 257 + if result.count <= maxBytes { return result } 258 + 259 + for _ in 0..<8 { 260 + let mid = (low + high) / 2 261 + let data = rendered.jpegData(compressionQuality: mid) ?? Data() 262 + if data.count <= maxBytes { 263 + result = data 264 + low = mid 265 + } else { 266 + high = mid 267 + } 268 + } 269 + return result 270 + } 271 + }
+2 -2
Grain/Views/Settings/SettingsView.swift
··· 3 3 struct SettingsView: View { 4 4 @Environment(AuthManager.self) private var auth 5 5 @Environment(\.dismiss) private var dismiss 6 + let client: XRPCClient 6 7 7 8 var body: some View { 8 9 List { ··· 18 19 19 20 Section { 20 21 NavigationLink("Edit Profile") { 21 - // TODO: EditProfileView 22 - Text("Edit Profile") 22 + EditProfileView(client: client) 23 23 } 24 24 } 25 25