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.

fix: opaque person.fill fallback for all avatar renderers

Use systemGray4/systemGray2 instead of transparent gray so fallback
avatars don't bleed the background. Add fallback to UIKit
OverlappingAvatarsUIView (notifications grouped rows). DRY LoginView
avatar via AvatarView. Nil two preview profiles to exercise fallback.

+23 -21
+2 -2
Grain/Utilities/PreviewData.swift
··· 54 54 static let profile6 = GrainProfile( 55 55 cid: "cid6", did: "did:plc:prevuser6", 56 56 handle: "rina.grain.social", displayName: "Rina Watanabe", 57 - avatar: bundleImageURL("ACE_EMD_F40PH_Fremont_-_San_Jose") 57 + avatar: nil 58 58 ) 59 59 60 60 static let profile7 = GrainProfile( 61 61 cid: "cid7", did: "did:plc:prevuser7", 62 62 handle: "omar.grain.social", displayName: "Omar Hassan", 63 - avatar: bundleImageURL("C-141_Starlifter_contrail") 63 + avatar: nil 64 64 ) 65 65 66 66 static let profile8 = GrainProfile(
+2 -2
Grain/Views/Components/AvatarView.swift
··· 57 57 58 58 private var fallback: some View { 59 59 ZStack { 60 - Circle().fill(Color.gray.opacity(0.3)) 60 + Circle().fill(Color(.systemGray4)) 61 61 Image(systemName: "person.fill") 62 62 .font(.system(size: size * 0.45)) 63 - .foregroundStyle(Color.gray.opacity(0.6)) 63 + .foregroundStyle(Color(.systemGray2)) 64 64 } 65 65 } 66 66 }
+1 -16
Grain/Views/LoginView.swift
··· 1 - import NukeUI 2 1 import SwiftUI 3 2 4 3 struct LoginView: View { ··· 122 121 Task { await login() } 123 122 } label: { 124 123 HStack(spacing: 10) { 125 - if let avatar = actor.avatar, let url = URL(string: avatar) { 126 - LazyImage(url: url) { state in 127 - if let image = state.image { 128 - image.resizable().scaledToFill() 129 - } else { 130 - Circle().fill(.white.opacity(0.2)) 131 - } 132 - } 133 - .frame(width: 32, height: 32) 134 - .clipShape(Circle()) 135 - } else { 136 - Circle() 137 - .fill(.white.opacity(0.2)) 138 - .frame(width: 32, height: 32) 139 - } 124 + AvatarView(url: actor.avatar, size: 32) 140 125 141 126 VStack(alignment: .leading, spacing: 1) { 142 127 if let displayName = actor.displayName, !displayName.isEmpty {
+18 -1
Grain/Views/Notifications/NotificationsView.swift
··· 410 410 private var authorDids: [String] = [] 411 411 private var currentKey = "" 412 412 413 + private static func makeFallbackImage(size: CGFloat) -> UIImage { 414 + let iconSize = size * 0.45 415 + let config = UIImage.SymbolConfiguration(pointSize: iconSize) 416 + let symbol = UIImage(systemName: "person.fill", withConfiguration: config)! 417 + let renderer = UIGraphicsImageRenderer(size: CGSize(width: size, height: size)) 418 + return renderer.image { ctx in 419 + UIColor.systemGray4.setFill() 420 + ctx.cgContext.fillEllipse(in: CGRect(x: 0, y: 0, width: size, height: size)) 421 + let tinted = symbol.withTintColor(.systemGray2, renderingMode: .alwaysOriginal) 422 + let origin = CGPoint(x: (size - tinted.size.width) / 2, y: (size - tinted.size.height) / 2) 423 + tinted.draw(at: origin) 424 + } 425 + } 426 + 413 427 override init(frame: CGRect) { 414 428 super.init(frame: frame) 415 429 isUserInteractionEnabled = true ··· 436 450 avatarViews.removeAll() 437 451 438 452 let step = size - overlap 453 + let fallback = Self.makeFallbackImage(size: size) 439 454 440 455 for (i, author) in authors.enumerated() { 441 456 let iv = UIImageView() ··· 444 459 iv.layer.cornerRadius = size / 2 445 460 iv.layer.borderWidth = 2 446 461 iv.layer.borderColor = UIColor.systemBackground.cgColor 447 - iv.backgroundColor = UIColor.gray.withAlphaComponent(0.3) 448 462 iv.frame = CGRect(x: CGFloat(i) * step, y: 0, width: size, height: size) 449 463 450 464 // Load from Nuke memory cache synchronously, or fetch async ··· 453 467 if let cached = ImagePipeline.shared.cache.cachedImage(for: request)?.image { 454 468 iv.image = cached 455 469 } else { 470 + iv.image = fallback 456 471 Task { @MainActor in 457 472 if let image = try? await ImagePipeline.shared.image(for: request) { 458 473 iv.image = image 459 474 } 460 475 } 461 476 } 477 + } else { 478 + iv.image = fallback 462 479 } 463 480 464 481 addSubview(iv)