crossmate

A collaborative crossword app for iOS
Log | Files | Refs | LICENSE

FriendAvatarView.swift (8807B)


      1 import SwiftUI
      2 
      3 struct FriendAvatarView: View {
      4     /// Drives the in-place invite animation. `nil` (the default) keeps the
      5     /// avatar perfectly static, so call sites that merely identify a friend
      6     /// (e.g. `GameShareItem`) are completely unaffected.
      7     enum InvitePhase: Equatable {
      8         /// Invite is in flight: the glyph spins over to a paper plane, then
      9         /// rings broadcast outward from the avatar.
     10         case sending
     11         /// Invite is durable locally and waiting for CloudKit acknowledgement.
     12         /// The clock keeps the broadcast rings active while delivery is queued.
     13         case queued
     14         /// CloudKit confirmed the Ping exists: the glyph spins once and becomes
     15         /// a checkmark. This does not claim the recipient has fetched it.
     16         case sent
     17     }
     18 
     19     let authorID: String
     20     var size: CGFloat = 34
     21     var invitePhase: InvitePhase? = nil
     22 
     23     /// Spin shared by the avatar → paper plane and paper plane → checkmark
     24     /// transitions, so both glyph changes turn over identically.
     25     private let glyphSpin = Animation.spring(response: 0.55, dampingFraction: 0.65)
     26 
     27     @State private var revolutions = 0
     28     @State private var ringsActive = false
     29     @State private var ringsTask: Task<Void, Never>?
     30 
     31     private var avatar: FriendAvatar {
     32         FriendAvatar.avatar(for: authorID)
     33     }
     34 
     35     /// The glyph shown in the avatar. While sending it is a paper plane; on
     36     /// success it is *replaced* by a checkmark rather than a separate accessory
     37     /// appearing — the symbol the user tapped is the one that confirms.
     38     private var symbolName: String {
     39         switch invitePhase {
     40         case .sent:
     41             return "checkmark"
     42         case .queued:
     43             return "clock.fill"
     44         case .sending:
     45             return "paperplane.fill"
     46         case nil:
     47             return avatar.symbolName
     48         }
     49     }
     50 
     51     /// Stroke width of the broadcast rings, scaled so it reads at both a small
     52     /// list-row avatar and a large share-grid tile.
     53     private var ringWidth: CGFloat {
     54         max(1.5, size * 0.045)
     55     }
     56 
     57     var body: some View {
     58         ZStack {
     59             // Behind the avatar so the rings appear to emanate from its edge.
     60             if ringsActive {
     61                 BroadcastRing(color: avatar.background, lineWidth: ringWidth, delay: 0)
     62                 BroadcastRing(color: avatar.background, lineWidth: ringWidth, delay: 0.7)
     63             }
     64 
     65             Circle()
     66                 .fill(avatar.background)
     67                 // A soft radial wash from a lighter centre out to the base
     68                 // grey, so the medallion reads as gently domed rather than
     69                 // flat. Disabled for now — swap the flat `.fill` above for this
     70                 // to turn it back on.
     71                 // .fill(
     72                 //     RadialGradient(
     73                 //         colors: [Color(.systemGray3), avatar.background],
     74                 //         center: .center,
     75                 //         startRadius: 0,
     76                 //         endRadius: size / 2
     77                 //     )
     78                 // )
     79 
     80             // A faint, tiled watermark of the friend's own symbol — just enough
     81             // texture to lift the flat medallion, sitting under the main glyph.
     82             SymbolPattern(symbolName: avatar.symbolName, size: size)
     83 
     84             Image(systemName: symbolName)
     85                 .font(.system(size: size * 0.46, weight: .semibold))
     86                 .foregroundStyle(.white)
     87                 .contentTransition(.symbolEffect(.replace))
     88                 .rotationEffect(.degrees(Double(revolutions) * 360))
     89                 // Animate the glyph swap itself so the friend symbol → paper
     90                 // plane → checkmark changes morph rather than hard-cut.
     91                 .animation(.default, value: symbolName)
     92         }
     93         .frame(width: size, height: size)
     94         .onAppear {
     95             if invitePhase == .sending || invitePhase == .queued {
     96                 ringsActive = true
     97             }
     98         }
     99         .onChange(of: invitePhase) { _, phase in
    100             ringsTask?.cancel()
    101             switch phase {
    102             case .sending:
    103                 // The same spin as the checkmark turns the avatar over to the
    104                 // paper plane; the broadcast then starts once it has settled.
    105                 withAnimation(glyphSpin) { revolutions += 1 }
    106                 ringsTask = Task { @MainActor in
    107                     try? await Task.sleep(for: .seconds(0.5))
    108                     guard !Task.isCancelled else { return }
    109                     ringsActive = true
    110                 }
    111             case .queued:
    112                 ringsActive = true
    113                 withAnimation(glyphSpin) { revolutions += 1 }
    114             case .sent:
    115                 ringsActive = false
    116                 withAnimation(glyphSpin) { revolutions += 1 }
    117             case nil:
    118                 ringsActive = false
    119             }
    120         }
    121         .onDisappear {
    122             ringsTask?.cancel()
    123         }
    124         .accessibilityHidden(true)
    125     }
    126 }
    127 
    128 /// A single ring that grows out of the avatar and fades, looping forever. Each
    129 /// ring owns its animation and starts it in `onAppear`, so simply inserting
    130 /// the ring (when sending begins) is enough to set it going — no external
    131 /// trigger to keep in sync.
    132 private struct BroadcastRing: View {
    133     let color: Color
    134     let lineWidth: CGFloat
    135     /// Staggers this ring against its siblings so the broadcast is continuous.
    136     let delay: Double
    137 
    138     @State private var expanded = false
    139 
    140     var body: some View {
    141         Circle()
    142             .stroke(color, lineWidth: lineWidth)
    143             .scaleEffect(expanded ? 1.55 : 1.0)
    144             .opacity(expanded ? 0 : 0.5)
    145             .onAppear {
    146                 withAnimation(
    147                     .easeOut(duration: 1.4)
    148                         .repeatForever(autoreverses: false)
    149                         .delay(delay)
    150                 ) {
    151                     expanded = true
    152                 }
    153             }
    154     }
    155 }
    156 
    157 /// A faint, tiled wallpaper of an SF Symbol, clipped to the avatar circle, used
    158 /// as a subtle background texture behind the main glyph. Tiles are staggered
    159 /// row-to-row for a diaper pattern and drawn at low opacity in a single
    160 /// `Canvas`. Everything scales off `size`, so it reads the same on a list-row
    161 /// avatar and a larger share-grid tile.
    162 private struct SymbolPattern: View {
    163     let symbolName: String
    164     let size: CGFloat
    165     var tint: Color = .white
    166     var opacity: Double = 0.16
    167 
    168     /// Tile glyph size as a fraction of the avatar size.
    169     private static let tileRatio: CGFloat = 0.26
    170     /// Centre-to-centre tile spacing as a fraction of the avatar size.
    171     private static let spacingRatio: CGFloat = 0.30
    172 
    173     var body: some View {
    174         Canvas { context, canvasSize in
    175             guard let glyph = context.resolveSymbol(id: 0) else { return }
    176             context.clip(to: Path(ellipseIn: CGRect(origin: .zero, size: canvasSize)))
    177             context.opacity = opacity
    178 
    179             let spacing = size * Self.spacingRatio
    180             guard spacing > 0 else { return }
    181             var row = 0
    182             var y = spacing / 2
    183             while y < canvasSize.height + spacing {
    184                 // Offset alternate rows by half a step for the staggered look.
    185                 let stagger = row.isMultiple(of: 2) ? 0 : spacing / 2
    186                 var x = spacing / 2 + stagger - spacing
    187                 while x < canvasSize.width + spacing {
    188                     context.draw(glyph, at: CGPoint(x: x, y: y))
    189                     x += spacing
    190                 }
    191                 y += spacing
    192                 row += 1
    193             }
    194         } symbols: {
    195             Image(systemName: symbolName)
    196                 .font(.system(size: size * Self.tileRatio, weight: .semibold))
    197                 .foregroundStyle(tint)
    198                 .tag(0)
    199         }
    200         .frame(width: size, height: size)
    201     }
    202 }
    203 
    204 private struct FriendAvatar {
    205     let symbolName: String
    206     let background: Color
    207 
    208     static func avatar(for authorID: String) -> FriendAvatar {
    209         // Friend avatars are intentionally colourless. A player's colour now
    210         // lives only inside a game and varies per game, so outside one a friend
    211         // is identified by their symbol alone on a neutral grey — never a fixed
    212         // colour badge. The symbol is a stable, deterministic choice per author.
    213         let symbol = symbols[PlayerColor.stableIndex(for: "symbol-\(authorID)", count: symbols.count)]
    214         return FriendAvatar(symbolName: symbol, background: Color(.darkGray))
    215     }
    216 
    217     private static let symbols: [String] = [
    218         "star.fill",
    219         "sparkles",
    220         "bolt.fill",
    221         "moon.fill",
    222         "heart.fill",
    223         "crown.fill",
    224         "leaf.fill",
    225         "flame.fill"
    226     ]
    227 }