From e07a6cfa5d763ab01f476798268d17bf84b22018 Mon Sep 17 00:00:00 2001 From: Richard Brown Date: Sun, 26 Apr 2026 21:02:35 +0100 Subject: [PATCH] feat(avatar): add Postie avatar creator and surface across friends, leaderboards, and profile Players can now build a Postman James-style avatar from swappable parts (skin, head, hair, eyes, nose, facial hair, hat, glasses, background). The saved config persists to users/{uid}.avatar and is rendered wherever players appear: friends list, leaderboards (global + friends-only), profile header, and settings header. Falls back to initials when no avatar is set. Cloud Functions embed the avatar map in leaderboard entries so the global tab renders avatars without an N+1 read; Firestore rules permit the user to write only their own avatar map (size-capped). Co-Authored-By: Claude Opus 4.7 --- firestore.rules | 24 +- functions/src/_leaderboardUtils.ts | 24 +- functions/src/newDayScoreboard.ts | 6 +- functions/src/startScoring.ts | 7 +- functions/src/updateDisplayName.ts | 14 +- lib/avatar/avatar_config.dart | 148 ++++++++++++ lib/avatar/avatar_creator_screen.dart | 319 ++++++++++++++++++++++++++ lib/avatar/avatar_parts.dart | 299 ++++++++++++++++++++++++ lib/avatar/avatar_svg.dart | 72 ++++++ lib/avatar/postie_avatar.dart | 81 +++++++ lib/friends_screen.dart | 43 ++-- lib/leaderboard_screen.dart | 95 ++++---- lib/settings_screen.dart | 29 ++- lib/user_profile_page.dart | 30 +-- test/avatar/avatar_config_test.dart | 72 ++++++ test/widget_test.dart | 7 + 16 files changed, 1160 insertions(+), 110 deletions(-) create mode 100644 lib/avatar/avatar_config.dart create mode 100644 lib/avatar/avatar_creator_screen.dart create mode 100644 lib/avatar/avatar_parts.dart create mode 100644 lib/avatar/avatar_svg.dart create mode 100644 lib/avatar/postie_avatar.dart create mode 100644 test/avatar/avatar_config_test.dart diff --git a/firestore.rules b/firestore.rules index b69f8dc..1df28b6 100644 --- a/firestore.rules +++ b/firestore.rules @@ -26,23 +26,29 @@ service cloud.firestore { // directly. The friends array is the only field clients may modify. match /users/{uid} { allow read: if request.auth != null; - // The friends array and notificationPrefs map are the only fields clients - // may modify on their own document. All other fields (displayName, streak, - // lastClaimDate, createdAt, points) are written by Cloud Functions using - // the Admin SDK, which bypasses these rules. FCM tokens live in the - // separate fcmTokens/{uid} collection to avoid exposure here. - // friends is capped at 200 entries to prevent O(n) leaderboard read abuse. - // notificationPrefs must be a map (type-validated to prevent garbage writes). + // The friends array, notificationPrefs map, and avatar map are the only + // fields clients may modify on their own document. All other fields + // (displayName, streak, lastClaimDate, createdAt, points) are written by + // Cloud Functions using the Admin SDK, which bypasses these rules. FCM + // tokens live in the separate fcmTokens/{uid} collection to avoid + // exposure here. friends is capped at 200 entries to prevent O(n) + // leaderboard read abuse. notificationPrefs and avatar must be maps + // (type-validated to prevent garbage writes); avatar is additionally + // size-capped to prevent payload abuse. allow update: if request.auth != null && request.auth.uid == uid && request.resource.data.diff(resource.data).affectedKeys() - .hasOnly(['friends', 'notificationPrefs']) + .hasOnly(['friends', 'notificationPrefs', 'avatar']) && (!request.resource.data.diff(resource.data).affectedKeys() .hasAny(['friends']) || (request.resource.data.friends is list && request.resource.data.friends.size() <= 200)) && (!request.resource.data.diff(resource.data).affectedKeys() .hasAny(['notificationPrefs']) - || request.resource.data.notificationPrefs is map); + || request.resource.data.notificationPrefs is map) + && (!request.resource.data.diff(resource.data).affectedKeys() + .hasAny(['avatar']) + || (request.resource.data.avatar is map + && request.resource.data.avatar.size() <= 20)); // Create and all other writes are handled by Cloud Functions. allow create: if false; } diff --git a/functions/src/_leaderboardUtils.ts b/functions/src/_leaderboardUtils.ts index 7edff33..0e17797 100644 --- a/functions/src/_leaderboardUtils.ts +++ b/functions/src/_leaderboardUtils.ts @@ -30,10 +30,16 @@ export function getPeriodKey(name: string, startDate: string): string { return `month:${startDate.slice(0, 7)}`; } +/** A user's avatar config — small map of {slotKey: optionIndex}. Embedded in + * leaderboard entries so the global tab can render player avatars without an + * N+1 read of users/{uid}. Schema mirrors `lib/avatar/avatar_config.dart`. */ +export type AvatarMap = Record; + export interface LeaderboardEntry { uid: string; displayName: string; points: number; + avatar?: AvatarMap; } /** @@ -51,12 +57,15 @@ export function mergePeriodEntries( uid: string, displayName: string, userPoints: number, - limit = 100 + limit = 100, + avatar?: AvatarMap ): LeaderboardEntry[] { const others = existing.filter((e) => e.uid !== uid); return [ ...others, - ...(userPoints > 0 ? [{ uid, displayName, points: userPoints }] : []), + ...(userPoints > 0 + ? [{ uid, displayName, points: userPoints, ...(avatar ? { avatar } : {}) }] + : []), ] .sort((a, b) => b.points - a.points) .slice(0, limit); @@ -82,7 +91,8 @@ export async function updateUserLeaderboards( uid: string, displayName: string, today: string, - db: Firestore + db: Firestore, + avatar?: AvatarMap ): Promise { const weekStart = getWeekStart(today); const monthStart = getMonthStart(today); @@ -131,7 +141,7 @@ export async function updateUserLeaderboards( // Upsert this user's entry, or remove it if they have 0 points (e.g. // updateDisplayName called before any claim in this period). - const updatedEntries = mergePeriodEntries(existing, uid, displayName, userPoints); + const updatedEntries = mergePeriodEntries(existing, uid, displayName, userPoints, 100, avatar); tx.set(leaderboardRef, { periodKey: currentPeriodKey, entries: updatedEntries }, { merge: false }); }); } catch (err) { @@ -161,6 +171,7 @@ export interface LifetimeLeaderboardEntry { displayName: string; uniquePostboxesClaimed: number; totalPoints: number; + avatar?: AvatarMap; } /** @@ -178,13 +189,14 @@ export function mergeLifetimeEntries( displayName: string, uniquePostboxesClaimed: number, totalPoints: number, - limit = 100 + limit = 100, + avatar?: AvatarMap ): LifetimeLeaderboardEntry[] { const others = existing.filter((e) => e.uid !== uid); return [ ...others, ...(uniquePostboxesClaimed > 0 || totalPoints > 0 - ? [{ uid, displayName, uniquePostboxesClaimed, totalPoints }] + ? [{ uid, displayName, uniquePostboxesClaimed, totalPoints, ...(avatar ? { avatar } : {}) }] : []), ] .sort((a, b) => diff --git a/functions/src/newDayScoreboard.ts b/functions/src/newDayScoreboard.ts index 37d17ca..3cbda7b 100644 --- a/functions/src/newDayScoreboard.ts +++ b/functions/src/newDayScoreboard.ts @@ -71,10 +71,12 @@ export async function rebuildPeriodLeaderboard( const uid = uids[i]; const pts = userPoints.get(uid)!; if (pts <= 0) continue; + const data = userDocs[i].data(); const displayName = - (userDocs[i].data()?.displayName as string | undefined) ?? + (data?.displayName as string | undefined) ?? `Player_${uid.slice(0, 6)}`; - entries.push({ uid, displayName, points: pts }); + const avatar = data?.avatar as Record | undefined; + entries.push({ uid, displayName, points: pts, ...(avatar ? { avatar } : {}) }); } entries.sort((a, b) => b.points - a.points); diff --git a/functions/src/startScoring.ts b/functions/src/startScoring.ts index fe209bc..59475fb 100644 --- a/functions/src/startScoring.ts +++ b/functions/src/startScoring.ts @@ -174,13 +174,14 @@ export const startScoring = functions.https.onCall(async (request) => { const displayName = (userDoc.data()?.displayName as string | undefined) || `Player_${userid.slice(0, 6)}`; + const userAvatar = userDoc.data()?.avatar as Record | undefined; // Run the period leaderboard update and the uniqueness checks in parallel. // updateUserLeaderboards uses Promise.allSettled internally and never // throws; uniqueness checks use allSettled so individual read failures // only affect that postbox's unique count without aborting the rest. const [periodSums, uniqueCheckResults] = await Promise.all([ - updateUserLeaderboards(userid, displayName, todayLondon, database), + updateUserLeaderboards(userid, displayName, todayLondon, database, userAvatar), Promise.allSettled( // For each postbox claimed this session, check whether the user has any // prior claim on a different day. Empty result → first-ever claim for @@ -247,6 +248,8 @@ export const startScoring = functions.https.onCall(async (request) => { // stale pre-fetched name when we write the lifetime entry below. const freshDisplayName = (d.displayName as string | undefined) || displayName; + const freshAvatar = + (d.avatar as Record | undefined) ?? userAvatar; tx.set( userRef, @@ -265,7 +268,7 @@ export const startScoring = functions.https.onCall(async (request) => { ); const existingEntries = (lifetimeSnap.data()?.entries ?? []) as LifetimeLeaderboardEntry[]; - const updatedEntries = mergeLifetimeEntries(existingEntries, userid, freshDisplayName, newUnique, newLifetimePoints); + const updatedEntries = mergeLifetimeEntries(existingEntries, userid, freshDisplayName, newUnique, newLifetimePoints, 100, freshAvatar); tx.set(lifetimeRef, { periodKey: "lifetime", entries: updatedEntries }, { merge: false }); }); diff --git a/functions/src/updateDisplayName.ts b/functions/src/updateDisplayName.ts index 1ce6941..79d2442 100644 --- a/functions/src/updateDisplayName.ts +++ b/functions/src/updateDisplayName.ts @@ -73,7 +73,16 @@ export const updateDisplayName = functions.https.onCall(async (request) => { // updateUserLeaderboards uses allSettled and never throws; period // failures are logged inside it. const today = getTodayLondon(); - await updateUserLeaderboards(uid, name, today, admin.firestore()); + // Look up the user's avatar so the leaderboard refresh carries it through + // alongside the new display name; otherwise a name change would strip avatar + // from period entries until the user's next claim rewrote them. + const userSnapForAvatar = await admin.firestore() + .collection("users") + .doc(uid) + .get(); + const avatarForLeaderboard = + userSnapForAvatar.data()?.avatar as Record | undefined; + await updateUserLeaderboards(uid, name, today, admin.firestore(), avatarForLeaderboard); // Read user doc and update lifetime leaderboard atomically in one transaction // so a concurrent startScoring claim cannot race between our read of @@ -91,7 +100,8 @@ export const updateDisplayName = functions.https.onCall(async (request) => { const uniquePostboxesClaimed = ((d.uniquePostboxesClaimed as number | undefined) ?? 0); const lifetimePoints = ((d.lifetimePoints as number | undefined) ?? 0); const existing = (lifetimeSnap.data()?.entries ?? []) as LifetimeLeaderboardEntry[]; - const updated = mergeLifetimeEntries(existing, uid, name, uniquePostboxesClaimed, lifetimePoints); + const avatarInTx = (d.avatar as Record | undefined) ?? avatarForLeaderboard; + const updated = mergeLifetimeEntries(existing, uid, name, uniquePostboxesClaimed, lifetimePoints, 100, avatarInTx); tx.set(lifetimeRef, { periodKey: "lifetime", entries: updated }, { merge: false }); }); } catch (lifetimeErr) { diff --git a/lib/avatar/avatar_config.dart b/lib/avatar/avatar_config.dart new file mode 100644 index 0000000..2119138 --- /dev/null +++ b/lib/avatar/avatar_config.dart @@ -0,0 +1,148 @@ +import 'dart:math'; + +import 'avatar_parts.dart'; + +/// One slot the user can pick from in the avatar creator. +enum AvatarSlot { + background('bg', 'Background'), + skin('skin', 'Skin Tone'), + head('head', 'Head Shape'), + hair('hair', 'Hair Style'), + hairColor('hairColor', 'Hair Colour'), + eyes('eyes', 'Eyes'), + nose('nose', 'Nose'), + facial('facial', 'Facial Hair'), + glasses('glasses', 'Glasses'), + hat('hat', 'Hat'); + + final String key; + final String label; + const AvatarSlot(this.key, this.label); + + int length() => switch (this) { + AvatarSlot.background => avatarBackgrounds.length, + AvatarSlot.skin => avatarSkin.length, + AvatarSlot.head => avatarHeads.length, + AvatarSlot.hair => avatarHair.length, + AvatarSlot.hairColor => avatarHairColors.length, + AvatarSlot.eyes => avatarEyes.length, + AvatarSlot.nose => avatarNoses.length, + AvatarSlot.facial => avatarFacial.length, + AvatarSlot.glasses => avatarGlasses.length, + AvatarSlot.hat => avatarHats.length, + }; + + String optionName(int index) { + final i = index.clamp(0, length() - 1); + return switch (this) { + AvatarSlot.background => avatarBackgrounds[i].name, + AvatarSlot.skin => avatarSkin[i].name, + AvatarSlot.head => avatarHeads[i].name, + AvatarSlot.hair => avatarHair[i].name, + AvatarSlot.hairColor => avatarHairColors[i].name, + AvatarSlot.eyes => avatarEyes[i].name, + AvatarSlot.nose => avatarNoses[i].name, + AvatarSlot.facial => avatarFacial[i].name, + AvatarSlot.glasses => avatarGlasses[i].name, + AvatarSlot.hat => avatarHats[i].name, + }; + } + + /// Hex string for slot options that have a swatch (skin, hair, bg). + String? swatchColor(int index) { + final i = index.clamp(0, length() - 1); + return switch (this) { + AvatarSlot.background => avatarBackgrounds[i].fill, + AvatarSlot.skin => avatarSkin[i].fill, + AvatarSlot.hairColor => avatarHairColors[i].fill, + _ => null, + }; + } +} + +/// User's avatar configuration — a small map of slot indices. +class AvatarConfig { + final Map indices; + + const AvatarConfig._(this.indices); + + factory AvatarConfig.fromIndices(Map indices) { + final clamped = {}; + for (final slot in AvatarSlot.values) { + final raw = indices[slot] ?? 0; + clamped[slot] = raw.clamp(0, slot.length() - 1); + } + return AvatarConfig._(clamped); + } + + /// Default Postman James-ish avatar: peach skin, side-parting hair, brown + /// hair, happy eyes, button nose, postman cap, navy background. + factory AvatarConfig.defaultPostie() => AvatarConfig.fromIndices({ + AvatarSlot.background: 1, // navy + AvatarSlot.skin: 1, // peach + AvatarSlot.head: 0, // oval + AvatarSlot.hair: 2, // side parting + AvatarSlot.hairColor: 1, // brown + AvatarSlot.eyes: 2, // happy + AvatarSlot.nose: 0, // button + AvatarSlot.facial: 0, // clean shaven + AvatarSlot.glasses: 0, // none + AvatarSlot.hat: 1, // postman cap + }); + + factory AvatarConfig.random([Random? rng]) { + final r = rng ?? Random(); + return AvatarConfig.fromIndices({ + for (final slot in AvatarSlot.values) + slot: slot == AvatarSlot.hat + // Hats appear roughly half the time so randomise doesn't always crown the user. + ? (r.nextDouble() < 0.5 ? 0 : r.nextInt(slot.length())) + : r.nextInt(slot.length()), + }); + } + + /// Parse from Firestore `users/{uid}.avatar` map. Returns null for missing / + /// unrecognised structure so the caller can fall back to the initials avatar. + static AvatarConfig? tryFromMap(Object? raw) { + if (raw is! Map) return null; + final indices = {}; + for (final slot in AvatarSlot.values) { + final v = raw[slot.key]; + if (v is num) indices[slot] = v.toInt(); + } + if (indices.isEmpty) return null; + return AvatarConfig.fromIndices(indices); + } + + Map toMap() => + {for (final slot in AvatarSlot.values) slot.key: indices[slot] ?? 0}; + + int operator [](AvatarSlot slot) => indices[slot] ?? 0; + + AvatarConfig copyWith(AvatarSlot slot, int value) { + final next = Map.from(indices); + next[slot] = value.clamp(0, slot.length() - 1); + return AvatarConfig._(next); + } + + AvatarConfig cycle(AvatarSlot slot, int direction) { + final len = slot.length(); + final cur = this[slot]; + final next = (cur + direction + len) % len; + return copyWith(slot, next); + } + + @override + bool operator ==(Object other) { + if (other is! AvatarConfig) return false; + for (final slot in AvatarSlot.values) { + if (other[slot] != this[slot]) return false; + } + return true; + } + + @override + int get hashCode => Object.hashAll([ + for (final slot in AvatarSlot.values) indices[slot] ?? 0, + ]); +} diff --git a/lib/avatar/avatar_creator_screen.dart b/lib/avatar/avatar_creator_screen.dart new file mode 100644 index 0000000..c4e47dc --- /dev/null +++ b/lib/avatar/avatar_creator_screen.dart @@ -0,0 +1,319 @@ +import 'package:cloud_firestore/cloud_firestore.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +import '../theme.dart'; +import 'avatar_config.dart'; +import 'postie_avatar.dart'; + +/// "Your Postie Profile" — the avatar workshop. +/// +/// Reads users/{uid}.avatar on open, lets the user cycle / randomise parts, +/// and saves back to Firestore. Display sites (friends list, leaderboards, +/// profile) read from the same field and re-render. +class AvatarCreatorScreen extends StatefulWidget { + const AvatarCreatorScreen({super.key}); + + static Route route() => + MaterialPageRoute(builder: (_) => const AvatarCreatorScreen()); + + @override + State createState() => _AvatarCreatorScreenState(); +} + +class _AvatarCreatorScreenState extends State { + AvatarConfig? _saved; // Last persisted version; null until loaded / first save. + late AvatarConfig _draft; + bool _loading = true; + bool _saving = false; + String? _loadError; + + String? get _uid => FirebaseAuth.instance.currentUser?.uid; + + @override + void initState() { + super.initState(); + _draft = AvatarConfig.defaultPostie(); + _load(); + } + + Future _load() async { + final uid = _uid; + if (uid == null) { + if (mounted) setState(() => _loading = false); + return; + } + try { + final doc = await FirebaseFirestore.instance + .collection('users') + .doc(uid) + .get(); + final stored = AvatarConfig.tryFromMap(doc.data()?['avatar']); + if (!mounted) return; + setState(() { + _saved = stored; + // Surface the saved avatar (if any) so the user starts editing what + // they already have rather than a fresh default. + _draft = stored ?? AvatarConfig.defaultPostie(); + _loading = false; + }); + } catch (e) { + if (mounted) { + setState(() { + _loadError = 'Could not load your avatar.'; + _loading = false; + }); + } + } + } + + Future _save() async { + final uid = _uid; + if (uid == null) return; + setState(() => _saving = true); + try { + await FirebaseFirestore.instance + .collection('users') + .doc(uid) + .set({'avatar': _draft.toMap()}, SetOptions(merge: true)); + if (!mounted) return; + setState(() => _saved = _draft); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Avatar saved')), + ); + } on FirebaseException catch (_) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar(content: Text('Could not save your avatar. Please try again.')), + ); + } + } finally { + if (mounted) setState(() => _saving = false); + } + } + + void _randomise() { + HapticFeedback.lightImpact(); + setState(() => _draft = AvatarConfig.random()); + } + + void _cycle(AvatarSlot slot, int direction) { + setState(() => _draft = _draft.cycle(slot, direction)); + } + + void _revert() { + final saved = _saved; + if (saved != null) setState(() => _draft = saved); + } + + @override + Widget build(BuildContext context) { + final hasUnsavedChanges = _saved != null && _saved != _draft; + final isFirstSave = _saved == null; + + return Scaffold( + appBar: AppBar( + title: const Text('Your Postie'), + actions: [ + IconButton( + icon: const Icon(Icons.shuffle), + tooltip: 'Surprise me', + onPressed: _loading || _saving ? null : _randomise, + ), + ], + ), + body: _loading + ? const Padding( + padding: EdgeInsets.only(bottom: kJamesStripClearance), + child: Center(child: CircularProgressIndicator(color: postalRed)), + ) + : _loadError != null + ? Padding( + padding: const EdgeInsets.only(bottom: kJamesStripClearance), + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, + size: 48, + color: Theme.of(context).colorScheme.onSurfaceVariant), + const SizedBox(height: AppSpacing.md), + Text(_loadError!, + style: Theme.of(context).textTheme.titleMedium), + ], + ), + ), + ) + : ListView( + padding: const EdgeInsets.fromLTRB(AppSpacing.md, + AppSpacing.md, AppSpacing.md, kJamesStripClearance + 16), + children: [ + _StageCard(config: _draft), + const SizedBox(height: AppSpacing.md), + Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.symmetric( + vertical: AppSpacing.sm), + child: Column( + children: [ + for (final slot in AvatarSlot.values) + _SlotRow( + slot: slot, + index: _draft[slot], + onPrev: () => _cycle(slot, -1), + onNext: () => _cycle(slot, 1), + ), + ], + ), + ), + ), + const SizedBox(height: AppSpacing.md), + if (hasUnsavedChanges) + Padding( + padding: const EdgeInsets.only(bottom: AppSpacing.sm), + child: OutlinedButton.icon( + onPressed: _saving ? null : _revert, + icon: const Icon(Icons.undo, size: 18), + label: const Text('Discard changes'), + ), + ), + FilledButton( + onPressed: _saving || (!isFirstSave && !hasUnsavedChanges) + ? null + : _save, + child: _saving + ? const SizedBox( + width: 18, + height: 18, + child: CircularProgressIndicator( + color: Colors.white, + strokeWidth: 2, + ), + ) + : Text(isFirstSave ? 'Set as my avatar' : 'Save changes'), + ), + ], + ), + ); + } +} + +class _StageCard extends StatelessWidget { + final AvatarConfig config; + const _StageCard({required this.config}); + + @override + Widget build(BuildContext context) { + return Card( + margin: EdgeInsets.zero, + child: Padding( + padding: const EdgeInsets.fromLTRB( + AppSpacing.md, AppSpacing.lg, AppSpacing.md, AppSpacing.md), + child: Column( + children: [ + PostieAvatar(config: config, size: 220), + const SizedBox(height: AppSpacing.md), + Text( + 'Tap arrows to cycle each part — or shuffle the lot.', + textAlign: TextAlign.center, + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + ); + } +} + +class _SlotRow extends StatelessWidget { + final AvatarSlot slot; + final int index; + final VoidCallback onPrev; + final VoidCallback onNext; + + const _SlotRow({ + required this.slot, + required this.index, + required this.onPrev, + required this.onNext, + }); + + Color? _swatch() { + final hex = slot.swatchColor(index); + if (hex == null) return null; + final n = int.parse(hex.substring(1), radix: 16); + return Color(0xFF000000 | n); + } + + @override + Widget build(BuildContext context) { + final swatch = _swatch(); + final total = slot.length(); + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: AppSpacing.md, vertical: AppSpacing.xs), + child: Row( + children: [ + SizedBox( + width: 96, + child: Text( + slot.label, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + fontWeight: FontWeight.w600, + letterSpacing: 0.6, + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ), + IconButton( + icon: const Icon(Icons.chevron_left), + onPressed: onPrev, + visualDensity: VisualDensity.compact, + tooltip: 'Previous ${slot.label}', + ), + Expanded( + child: Row( + children: [ + if (swatch != null) ...[ + Container( + width: 16, + height: 16, + decoration: BoxDecoration( + color: swatch, + shape: BoxShape.circle, + border: Border.all(color: const Color(0xFF1A1A1A), width: 1.2), + ), + ), + const SizedBox(width: AppSpacing.sm), + ], + Expanded( + child: Text( + slot.optionName(index), + style: const TextStyle(fontWeight: FontWeight.w600), + overflow: TextOverflow.ellipsis, + ), + ), + Text( + '${index + 1}/$total', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.onSurfaceVariant, + ), + ), + ], + ), + ), + IconButton( + icon: const Icon(Icons.chevron_right), + onPressed: onNext, + visualDensity: VisualDensity.compact, + tooltip: 'Next ${slot.label}', + ), + ], + ), + ); + } +} diff --git a/lib/avatar/avatar_parts.dart b/lib/avatar/avatar_parts.dart new file mode 100644 index 0000000..e045f26 --- /dev/null +++ b/lib/avatar/avatar_parts.dart @@ -0,0 +1,299 @@ +// Postie avatar parts — port of design-system/Character Creator/parts.js. +// +// All parts share viewBox 0 0 200 200; head centred on (100, 88), chin ≈ y=132. +// SVG paths are embedded as strings and assembled at render time. + +class NamedItem { + final String id; + final String name; + const NamedItem(this.id, this.name); +} + +class ColorOption extends NamedItem { + final String fill; + final String? shade; + const ColorOption(super.id, super.name, this.fill, [this.shade]); +} + +class HeadShape extends NamedItem { + final String face; + final String earL; + final String earR; + const HeadShape(super.id, super.name, this.face, this.earL, this.earR); +} + +class HairStyle extends NamedItem { + final String? path; + const HairStyle(super.id, super.name, this.path); +} + +/// Eyes are a chunk of inner-svg (children of the root). Drawn around +/// (82, 90) and (118, 90). +class EyesOption extends NamedItem { + final String svg; + const EyesOption(super.id, super.name, this.svg); +} + +class NoseOption extends NamedItem { + final String path; + final bool filled; // round/snub get a soft shade fill + const NoseOption(super.id, super.name, this.path, {this.filled = false}); +} + +class FacialOption extends NamedItem { + final String? path; + final bool stubble; + const FacialOption(super.id, super.name, {this.path, this.stubble = false}); +} + +class HatOption extends NamedItem { + final String? svg; + const HatOption(super.id, super.name, this.svg); +} + +class GlassesOption extends NamedItem { + final String? svg; + const GlassesOption(super.id, super.name, this.svg); +} + +const List avatarSkin = [ + ColorOption('fair', 'Fair', '#ffd9c4', '#e8b79a'), + ColorOption('peach', 'Peach', '#ffc9a8', '#e8a582'), + ColorOption('tan', 'Tan', '#d9a178', '#b8825c'), + ColorOption('olive', 'Olive', '#c89070', '#a47050'), + ColorOption('brown', 'Brown', '#9a6645', '#764a30'), + ColorOption('deep', 'Deep', '#5e3a22', '#3e2413'), + ColorOption('mint', 'Mint', '#b8e6c9', '#8dc9a3'), + ColorOption('lilac', 'Lilac', '#d4b8e8', '#b094cc'), +]; + +const List avatarHairColors = [ + ColorOption('black', 'Black', '#1a1a1a'), + ColorOption('brown', 'Brown', '#6b4226'), + ColorOption('chestnut', 'Chestnut', '#8b5a2b'), + ColorOption('blond', 'Blond', '#e8c174'), + ColorOption('ginger', 'Ginger', '#c86428'), + ColorOption('grey', 'Grey', '#a8a8a8'), + ColorOption('white', 'White', '#f0ece4'), + ColorOption('blue', 'Blue', '#3f6fb8'), +]; + +const List avatarBackgrounds = [ + ColorOption('red', 'Pillar Box', '#C8102E'), + ColorOption('navy', 'Royal Navy', '#0A1931'), + ColorOption('gold', 'Gold', '#FFB400'), + ColorOption('parch', 'Parchment', '#FFF8F0'), + ColorOption('mint', 'Sorting', '#a8d4bc'), + ColorOption('dusk', 'Dusk', '#6b7ba8'), + ColorOption('stamp', 'Stamp Blue', '#005EB8'), + ColorOption('hedge', 'Hedgerow', '#4a7848'), +]; + +const List avatarHeads = [ + HeadShape( + 'oval', 'Oval', + 'M 100 46 C 78 46 62 62 62 88 C 62 112 76 132 100 132 C 124 132 138 112 138 88 C 138 62 122 46 100 46 Z', + 'M 64 88 Q 56 90 58 100 Q 60 110 66 108', + 'M 136 88 Q 144 90 142 100 Q 140 110 134 108', + ), + HeadShape( + 'round', 'Round', + 'M 100 46 C 76 46 58 64 58 90 C 58 114 76 134 100 134 C 124 134 142 114 142 90 C 142 64 124 46 100 46 Z', + 'M 60 92 Q 51 94 54 104 Q 57 114 64 112', + 'M 140 92 Q 149 94 146 104 Q 143 114 136 112', + ), + HeadShape( + 'square', 'Square', + 'M 100 47 C 80 47 64 58 63 80 L 64 110 C 65 124 80 134 100 134 C 120 134 135 124 136 110 L 137 80 C 136 58 120 47 100 47 Z', + 'M 65 90 Q 56 92 58 102 Q 60 112 67 110', + 'M 135 90 Q 144 92 142 102 Q 140 112 133 110', + ), + HeadShape( + 'long', 'Long', + 'M 100 42 C 80 42 66 58 66 84 C 66 116 78 138 100 138 C 122 138 134 116 134 84 C 134 58 120 42 100 42 Z', + 'M 68 90 Q 60 92 62 102 Q 64 112 70 110', + 'M 132 90 Q 140 92 138 102 Q 136 112 130 110', + ), + HeadShape( + 'heart', 'Heart', + 'M 100 46 C 78 46 62 60 62 82 C 62 108 78 134 100 134 C 122 134 138 108 138 82 C 138 60 122 46 100 46 Z', + 'M 64 86 Q 55 88 57 98 Q 59 108 65 106', + 'M 136 86 Q 145 88 143 98 Q 141 108 135 106', + ), + HeadShape( + 'chubby', 'Chubby', + 'M 100 50 C 74 50 56 64 56 92 C 56 116 74 136 100 136 C 126 136 144 116 144 92 C 144 64 126 50 100 50 Z', + 'M 58 94 Q 48 96 50 106 Q 52 118 60 116', + 'M 142 94 Q 152 96 150 106 Q 148 118 140 116', + ), + HeadShape( + 'narrow', 'Narrow', + 'M 100 46 C 82 46 70 60 70 86 C 70 112 82 132 100 132 C 118 132 130 112 130 86 C 130 60 118 46 100 46 Z', + 'M 72 88 Q 64 90 66 100 Q 68 110 74 108', + 'M 128 88 Q 136 90 134 100 Q 132 110 126 108', + ), + HeadShape( + 'pointed', 'Pointed', + 'M 100 46 C 78 46 62 62 62 86 C 62 106 74 122 90 130 L 100 138 L 110 130 C 126 122 138 106 138 86 C 138 62 122 46 100 46 Z', + 'M 64 88 Q 55 90 57 100 Q 59 110 66 108', + 'M 136 88 Q 145 90 143 100 Q 141 110 134 108', + ), +]; + +const List avatarHair = [ + HairStyle('bald', 'Bald', null), + HairStyle('crop', 'Short Crop', + 'M 66 66 C 64 56 72 46 84 44 C 90 40 110 40 116 44 C 128 46 136 56 134 66 C 134 70 132 72 128 70 C 120 66 112 62 100 62 C 88 62 80 66 72 70 C 68 72 66 70 66 66 Z'), + HairStyle('side', 'Side Parting', + 'M 64 72 C 62 58 72 44 90 44 C 102 38 124 40 132 52 C 138 58 138 68 134 74 C 132 76 130 74 128 72 C 122 64 110 60 100 62 C 92 62 86 66 80 72 C 76 76 76 82 74 86 C 72 78 66 76 64 72 Z'), + HairStyle('quiff', 'Quiff', + 'M 68 72 C 60 62 68 44 84 42 C 90 32 112 34 118 44 C 122 40 132 42 134 54 C 138 60 138 72 132 74 C 128 70 120 66 112 64 C 120 58 118 48 110 48 C 104 48 100 54 100 60 C 98 56 92 56 88 60 C 82 64 76 70 72 76 C 70 74 68 74 68 72 Z'), + HairStyle('curly', 'Curly', + 'M 60 76 C 54 66 58 48 74 42 C 80 34 98 34 104 40 C 112 34 128 38 132 50 C 142 54 144 70 138 80 C 136 84 132 82 130 78 C 132 72 128 66 122 66 C 124 72 120 76 116 72 C 118 66 114 60 108 62 C 110 56 104 54 100 58 C 98 54 92 54 90 58 C 88 54 82 56 82 62 C 78 60 74 64 76 70 C 72 68 66 72 68 78 C 68 82 64 82 62 80 C 60 80 60 78 60 76 Z'), + HairStyle('long', 'Long', + 'M 60 82 C 54 64 66 44 86 42 C 94 36 112 38 120 46 C 134 48 142 64 138 80 C 138 98 136 118 138 128 C 132 122 130 112 130 102 C 128 94 124 88 118 86 C 112 74 102 70 92 74 C 84 80 78 90 74 100 C 72 114 70 126 64 132 C 62 118 64 102 64 90 C 62 86 60 84 60 82 Z'), + HairStyle('mohawk', 'Mohawk', + 'M 94 30 L 98 42 L 94 44 L 92 50 L 96 52 L 92 58 L 98 60 L 94 66 L 100 68 L 106 66 L 102 60 L 108 58 L 104 52 L 108 50 L 106 44 L 102 42 L 106 30 Z'), + HairStyle('bun', 'Top Bun', + 'M 66 70 C 62 58 70 46 84 44 C 88 34 96 30 100 32 C 100 24 108 22 110 28 C 116 26 120 32 116 38 C 120 42 120 48 114 48 C 124 50 134 58 134 70 C 134 74 132 74 128 72 C 118 66 110 62 100 62 C 88 62 80 66 72 72 C 68 72 66 72 66 70 Z'), +]; + +const List avatarEyes = [ + EyesOption('dots', 'Dots', + ''), + EyesOption('round', 'Round', + ''), + EyesOption('happy', 'Happy', + ''), + EyesOption('sleepy', 'Sleepy', + ''), + EyesOption('wide', 'Wide', + ''), + EyesOption('wink', 'Wink', + ''), + EyesOption('side', 'Looking Aside', + ''), + EyesOption('stern', 'Stern', + ''), +]; + +const List avatarNoses = [ + NoseOption('button', 'Button', 'M 98 104 Q 100 108 102 104'), + NoseOption('long', 'Long', 'M 97 96 Q 96 106 100 110 Q 104 110 103 104'), + NoseOption('wide', 'Wide', 'M 94 104 Q 98 110 106 110 Q 108 108 108 104'), + NoseOption('pointy', 'Pointy', 'M 98 98 L 96 108 Q 100 112 104 108 L 102 98'), + NoseOption('hook', 'Hooked', 'M 98 96 Q 94 104 96 110 Q 100 112 104 108'), + NoseOption('small', 'Small', 'M 99 106 Q 100 108 101 106'), + NoseOption('round', 'Round', + 'M 96 104 Q 96 112 100 112 Q 104 112 104 104 Q 100 102 96 104 Z', + filled: true), + NoseOption('snub', 'Snub', + 'M 96 108 Q 100 112 104 108 Q 104 104 100 104 Q 96 104 96 108 Z', + filled: true), +]; + +const List avatarFacial = [ + FacialOption('none', 'Clean Shaven'), + FacialOption('mustache', 'Moustache', + path: 'M 86 116 Q 92 118 100 116 Q 108 118 114 116 Q 110 122 100 120 Q 90 122 86 116 Z'), + FacialOption('handlebar', 'Handlebar', + path: 'M 82 114 Q 88 120 100 118 Q 112 120 118 114 Q 116 124 108 122 Q 104 118 100 120 Q 96 118 92 122 Q 84 124 82 114 Z'), + FacialOption('goatee', 'Goatee', + path: 'M 94 122 Q 100 126 106 122 Q 108 130 100 132 Q 92 130 94 122 Z'), + FacialOption('chinstrap', 'Chin Strap', + path: 'M 68 106 Q 70 126 100 130 Q 130 126 132 106 Q 128 126 100 128 Q 72 126 68 106 Z'), + FacialOption('fullbeard', 'Full Beard', + path: 'M 66 100 Q 64 124 78 134 Q 90 138 100 138 Q 110 138 122 134 Q 136 124 134 100 Q 132 122 120 126 Q 112 120 100 122 Q 88 120 80 126 Q 68 122 66 100 Z'), + FacialOption('stubble', 'Stubble', stubble: true), + FacialOption('soulpatch', 'Soul Patch', + path: 'M 98 124 Q 100 128 102 124 Q 102 130 100 130 Q 98 130 98 124 Z'), +]; + +const List avatarHats = [ + HatOption('none', 'No Hat', null), + HatOption('postman', 'Postman Cap', ''' + + + + + + '''), + HatOption('beanie', 'Beanie', ''' + + + + '''), + HatOption('bowler', 'Bowler', ''' + + + '''), + HatOption('flatcap', 'Flat Cap', ''' + + + '''), + HatOption('helmet', 'Bike Helmet', ''' + + + + + + '''), + HatOption('crown', 'Crown', ''' + + + + + + '''), + HatOption('tophat', 'Top Hat', ''' + + + + '''), +]; + +const List avatarGlasses = [ + GlassesOption('none', 'None', null), + GlassesOption('round', 'Round', + ''), + GlassesOption('square', 'Square', + ''), + GlassesOption('aviator', 'Aviator', + ''), + GlassesOption('halfmoon', 'Half-Moon', + ''), + GlassesOption('shades', 'Dark Shades', + ''), + GlassesOption('monocle', 'Monocle', + ''), + GlassesOption('eyepatch', 'Eye Patch', + ''), +]; + +/// Postman uniform — shoulders + collar + tie. Drawn behind the head. +const String avatarUniformSvg = ''' + + + + + + + +'''; + +String avatarNeckSvg(String skinFill, String skinShade) => ''' + + +'''; + +String avatarStubbleSvg(String color) { + const positions = >[ + [76, 116], [82, 118], [88, 120], [94, 122], [100, 124], [106, 122], + [112, 120], [118, 118], [124, 116], + [72, 110], [80, 114], [92, 118], [108, 118], [120, 114], [128, 110], + [90, 128], [100, 130], [110, 128], + ]; + return positions + .map((p) => '') + .join(); +} diff --git a/lib/avatar/avatar_svg.dart b/lib/avatar/avatar_svg.dart new file mode 100644 index 0000000..4dbfd0f --- /dev/null +++ b/lib/avatar/avatar_svg.dart @@ -0,0 +1,72 @@ +import 'avatar_config.dart'; +import 'avatar_parts.dart'; + +/// Build the SVG string for an avatar, ready for `SvgPicture.string`. +/// +/// Layer order (back → front): background, uniform, neck, ears, face, blush, +/// stubble (if any), beard (if any), mouth, nose, eyebrows, eyes, hair, +/// glasses, hat. Everything inside the head/uniform group is clipped to the +/// outer circle so hair and shoulders don't spill. +String buildAvatarSvg(AvatarConfig cfg) { + final skin = avatarSkin[cfg[AvatarSlot.skin]]; + final head = avatarHeads[cfg[AvatarSlot.head]]; + final hair = avatarHair[cfg[AvatarSlot.hair]]; + final hairColor = avatarHairColors[cfg[AvatarSlot.hairColor]]; + final eyes = avatarEyes[cfg[AvatarSlot.eyes]]; + final nose = avatarNoses[cfg[AvatarSlot.nose]]; + final facial = avatarFacial[cfg[AvatarSlot.facial]]; + final glasses = avatarGlasses[cfg[AvatarSlot.glasses]]; + final hat = avatarHats[cfg[AvatarSlot.hat]]; + final bg = avatarBackgrounds[cfg[AvatarSlot.background]]; + + const stroke = '#1a1a1a'; + const strokeW = '2.2'; + final shade = skin.shade ?? skin.fill; + + String facialLayer = ''; + if (facial.stubble) { + facialLayer = avatarStubbleSvg(hairColor.fill); + } else if (facial.path != null) { + facialLayer = + ''; + } + + final noseFill = nose.filled ? shade : 'none'; + + return ''' + + + + + + + + + + + + + + $avatarUniformSvg + ${avatarNeckSvg(skin.fill, shade)} + + + + + + $facialLayer + + + + + + + ${eyes.svg} + ${hair.path != null ? '' : ''} + ${glasses.svg ?? ''} + ${hat.svg ?? ''} + + + +'''; +} diff --git a/lib/avatar/postie_avatar.dart b/lib/avatar/postie_avatar.dart new file mode 100644 index 0000000..ca45292 --- /dev/null +++ b/lib/avatar/postie_avatar.dart @@ -0,0 +1,81 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_svg/flutter_svg.dart'; + +import '../theme.dart'; +import 'avatar_config.dart'; +import 'avatar_svg.dart'; + +/// A circular avatar built from an [AvatarConfig]. +/// +/// When [config] is null falls back to [InitialsAvatar] so users who haven't +/// built a postie yet still get a recognisable circle (initials on red). +class PostieAvatar extends StatelessWidget { + final AvatarConfig? config; + final double size; + final String? fallbackName; + + const PostieAvatar({ + super.key, + required this.config, + required this.size, + this.fallbackName, + }); + + @override + Widget build(BuildContext context) { + final cfg = config; + if (cfg == null) { + return InitialsAvatar(name: fallbackName ?? '', size: size); + } + return SizedBox( + width: size, + height: size, + child: SvgPicture.string( + buildAvatarSvg(cfg), + width: size, + height: size, + ), + ); + } +} + +/// Coloured circle with up-to-2-letter initials. Default fallback when a user +/// hasn't customised an avatar. +class InitialsAvatar extends StatelessWidget { + final String name; + final double size; + final Color? backgroundColor; + + const InitialsAvatar({ + super.key, + required this.name, + required this.size, + this.backgroundColor, + }); + + static String initialsFor(String name) { + final t = name.trim(); + if (t.isEmpty) return '?'; + final parts = t.split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList(); + if (parts.length >= 2) { + return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); + } + return t.substring(0, t.length.clamp(0, 2)).toUpperCase(); + } + + @override + Widget build(BuildContext context) { + return CircleAvatar( + radius: size / 2, + backgroundColor: backgroundColor ?? postalRed, + child: Text( + initialsFor(name), + style: TextStyle( + color: Colors.white, + fontWeight: FontWeight.bold, + fontSize: size * 0.36, + ), + ), + ); + } +} diff --git a/lib/friends_screen.dart b/lib/friends_screen.dart index 7e42702..c7a1c7e 100644 --- a/lib/friends_screen.dart +++ b/lib/friends_screen.dart @@ -3,6 +3,8 @@ import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:postbox_game/analytics_service.dart'; +import 'package:postbox_game/avatar/avatar_config.dart'; +import 'package:postbox_game/avatar/postie_avatar.dart'; import 'package:postbox_game/user_profile_page.dart'; import 'package:postbox_game/theme.dart'; @@ -173,14 +175,6 @@ class _FriendsScreenState extends State { } } - String _initials(String name) { - final t = name.trim(); - if (t.isEmpty) return '?'; - final parts = t.split(' ').where((p) => p.isNotEmpty).toList(); - if (parts.length >= 2) return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); - return t.substring(0, t.length.clamp(0, 2)).toUpperCase(); - } - void _copyUid() { final uid = _currentUid ?? ''; Clipboard.setData(ClipboardData(text: uid)); @@ -366,28 +360,29 @@ class _FriendsScreenState extends State { builder: (context, nameSnap) { final isLoading = nameSnap.connectionState == ConnectionState.waiting; final displayName = nameSnap.data?.data()?['displayName'] as String?; - final initials = _initials(displayName ?? ''); + final avatarCfg = + AvatarConfig.tryFromMap(nameSnap.data?.data()?['avatar']); return Card( child: ListTile( onTap: () => Navigator.of(context).push(UserProfilePage.route(friendUid)), - leading: CircleAvatar( - backgroundColor: postalRed, + leading: SizedBox( + width: 40, + height: 40, child: isLoading - ? const SizedBox( - width: 16, - height: 16, - child: CircularProgressIndicator( - color: Colors.white, - strokeWidth: 2, + ? const Center( + child: SizedBox( + width: 16, + height: 16, + child: CircularProgressIndicator( + color: postalRed, + strokeWidth: 2, + ), ), ) - : Text( - initials, - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 13, - ), + : PostieAvatar( + config: avatarCfg, + size: 40, + fallbackName: displayName ?? '', ), ), title: isLoading diff --git a/lib/leaderboard_screen.dart b/lib/leaderboard_screen.dart index 3bad298..bb2a24c 100644 --- a/lib/leaderboard_screen.dart +++ b/lib/leaderboard_screen.dart @@ -2,6 +2,8 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/foundation.dart' show setEquals; import 'package:flutter/material.dart'; +import 'package:postbox_game/avatar/avatar_config.dart'; +import 'package:postbox_game/avatar/postie_avatar.dart'; import 'package:postbox_game/james_controller.dart'; import 'package:postbox_game/james_messages.dart'; import 'package:postbox_game/london_date.dart'; @@ -271,6 +273,7 @@ class _LeaderboardListState extends State<_LeaderboardList> final displayName = e['displayName'] as String? ?? 'Unknown'; final entryUid = e['uid'] as String?; final isCurrentUser = entryUid != null && entryUid == _currentUid; + final avatar = AvatarConfig.tryFromMap(e['avatar']); // Lifetime-specific fields final uniqueBoxes = _isLifetime @@ -306,7 +309,11 @@ class _LeaderboardListState extends State<_LeaderboardList> onTap: entryUid != null ? () => Navigator.of(context).push(UserProfilePage.route(entryUid)) : null, - leading: _rankWidget(rank), + leading: _LeaderboardLeading( + rank: rank, + avatar: avatar, + name: displayName, + ), title: Text( displayName, maxLines: 1, @@ -337,26 +344,6 @@ class _LeaderboardListState extends State<_LeaderboardList> ); } - Widget _rankWidget(int rank) { - switch (rank) { - case 1: - return const Icon(Icons.emoji_events, color: postalGold, size: 32); - case 2: - return Icon(Icons.emoji_events, color: Colors.grey.shade400, size: 32); - case 3: - return Icon(Icons.emoji_events, color: Colors.brown.shade300, size: 32); - default: - return CircleAvatar( - radius: 16, - backgroundColor: postalRed.withValues(alpha: 0.1), - child: Text( - '$rank', - style: const TextStyle( - color: postalRed, fontSize: 13, fontWeight: FontWeight.w600), - ), - ); - } - } } /// Leaderboard tab showing the current user alongside their friends, @@ -494,6 +481,7 @@ class _FriendsPeriodListState extends State<_FriendsPeriodList> .map((d) => { 'uid': d.id, 'displayName': d.data()['displayName'] as String? ?? 'Unknown', + 'avatar': d.data()['avatar'], 'score': scoreFor(d.data()), 'uniquePostboxesClaimed': (d.data()['uniquePostboxesClaimed'] as num?)?.toInt() ?? 0, @@ -692,7 +680,11 @@ class _FriendsPeriodListState extends State<_FriendsPeriodList> ? () => Navigator.of(context) .push(UserProfilePage.route(entryUid)) : null, - leading: _friendsRankWidget(rank), + leading: _LeaderboardLeading( + rank: rank, + avatar: AvatarConfig.tryFromMap(e['avatar']), + name: displayName, + ), title: Text( displayName, maxLines: 1, @@ -724,27 +716,52 @@ class _FriendsPeriodListState extends State<_FriendsPeriodList> ); } - Widget _friendsRankWidget(int rank) { - switch (rank) { - case 1: - return const Icon(Icons.emoji_events, color: postalGold, size: 32); - case 2: - return Icon(Icons.emoji_events, - color: Colors.grey.shade400, size: 32); - case 3: - return Icon(Icons.emoji_events, - color: Colors.brown.shade300, size: 32); - default: - return CircleAvatar( - radius: 16, - backgroundColor: postalRed.withValues(alpha: 0.1), +} + +/// Leading widget for a leaderboard row: trophy + small avatar for top-3, +/// rank number + small avatar for everyone else. Falls back to initials when +/// the player hasn't built an avatar yet. +class _LeaderboardLeading extends StatelessWidget { + final int rank; + final AvatarConfig? avatar; + final String name; + + const _LeaderboardLeading({ + required this.rank, + required this.avatar, + required this.name, + }); + + @override + Widget build(BuildContext context) { + final Widget marker = switch (rank) { + 1 => const Icon(Icons.emoji_events, color: postalGold, size: 26), + 2 => Icon(Icons.emoji_events, color: Colors.grey.shade400, size: 26), + 3 => Icon(Icons.emoji_events, color: Colors.brown.shade300, size: 26), + _ => SizedBox( + width: 26, child: Text( '$rank', + textAlign: TextAlign.center, style: const TextStyle( - color: postalRed, fontSize: 13, fontWeight: FontWeight.w600), + color: postalRed, + fontSize: 13, + fontWeight: FontWeight.w600, + ), ), - ); - } + ), + }; + return SizedBox( + width: 64, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + marker, + const SizedBox(width: 4), + PostieAvatar(config: avatar, size: 32, fallbackName: name), + ], + ), + ); } } diff --git a/lib/settings_screen.dart b/lib/settings_screen.dart index bea4496..213791d 100644 --- a/lib/settings_screen.dart +++ b/lib/settings_screen.dart @@ -8,6 +8,9 @@ import 'package:geolocator/geolocator.dart'; import 'package:latlong2/latlong.dart'; import 'package:postbox_game/app_preferences.dart'; import 'package:postbox_game/authentication_bloc/bloc.dart'; +import 'package:postbox_game/avatar/avatar_config.dart'; +import 'package:postbox_game/avatar/avatar_creator_screen.dart'; +import 'package:postbox_game/avatar/postie_avatar.dart'; import 'package:postbox_game/intro.dart'; import 'package:postbox_game/theme.dart'; import 'package:postbox_game/user_repository.dart'; @@ -32,6 +35,7 @@ class _SettingsScreenState extends State { 'addedAsFriend': true, }; bool _notifPrefsLoaded = false; + AvatarConfig? _avatar; final _userRepository = UserRepository(); @override @@ -68,6 +72,7 @@ class _SettingsScreenState extends State { .doc(uid) .get(); final raw = doc.data()?['notificationPrefs'] as Map?; + final avatar = AvatarConfig.tryFromMap(doc.data()?['avatar']); if (!mounted) return; setState(() { if (raw != null) { @@ -77,6 +82,7 @@ class _SettingsScreenState extends State { 'addedAsFriend': raw['addedAsFriend'] as bool? ?? true, }; } + _avatar = avatar; _notifPrefsLoaded = true; }); } catch (_) { @@ -464,11 +470,13 @@ class _SettingsScreenState extends State { ), child: Row( children: [ - CircleAvatar( - backgroundColor: Colors.white, - radius: 28, - child: Icon(Icons.person, color: postalRed, size: 32), - ), + _avatar != null + ? PostieAvatar(config: _avatar, size: 56) + : const CircleAvatar( + backgroundColor: Colors.white, + radius: 28, + child: Icon(Icons.person, color: postalRed, size: 32), + ), const SizedBox(width: AppSpacing.md), Expanded( child: Column( @@ -537,6 +545,17 @@ class _SettingsScreenState extends State { ), const Divider(height: 24), _sectionHeader('App'), + ListTile( + leading: const Icon(Icons.face_outlined), + title: const Text('Your postie'), + subtitle: Text(_avatar == null + ? 'Build the avatar others will see you as' + : 'Edit how you appear to other players'), + onTap: () async { + await Navigator.of(context).push(AvatarCreatorScreen.route()); + if (mounted) _loadNotifPrefs(); // refresh _avatar after returning + }, + ), ListTile( leading: const Icon(Icons.play_circle_outline), title: const Text('Replay intro'), diff --git a/lib/user_profile_page.dart b/lib/user_profile_page.dart index ca5e584..16e45c7 100644 --- a/lib/user_profile_page.dart +++ b/lib/user_profile_page.dart @@ -2,6 +2,8 @@ import 'package:cloud_firestore/cloud_firestore.dart'; import 'package:firebase_auth/firebase_auth.dart'; import 'package:flutter/material.dart'; import 'package:intl/intl.dart'; +import 'package:postbox_game/avatar/avatar_config.dart'; +import 'package:postbox_game/avatar/postie_avatar.dart'; import 'package:postbox_game/london_date.dart'; import 'package:postbox_game/theme.dart'; @@ -82,6 +84,7 @@ class _UserProfilePageState extends State { uniqueBoxes: (userData['uniquePostboxesClaimed'] as num?)?.toInt() ?? 0, lifetimePoints: (userData['lifetimePoints'] as num?)?.toInt() ?? 0, maxDailyPoints: (userData['maxDailyPoints'] as num?)?.toInt() ?? 0, + avatar: AvatarConfig.tryFromMap(userData['avatar']), ranks: ranks, ); } @@ -134,6 +137,7 @@ class _ProfileData { final int uniqueBoxes; final int lifetimePoints; final int maxDailyPoints; + final AvatarConfig? avatar; final Map ranks; const _ProfileData({ @@ -143,6 +147,7 @@ class _ProfileData { required this.uniqueBoxes, required this.lifetimePoints, required this.maxDailyPoints, + required this.avatar, required this.ranks, }); } @@ -157,16 +162,6 @@ class _ProfileBody extends StatelessWidget { return 'Joined ${DateFormat('MMMM yyyy').format(data.createdAt!)}'; } - String _initials() { - final name = data.displayName.trim(); - if (name.isEmpty) return '?'; - final parts = name.split(' ').where((p) => p.isNotEmpty).toList(); - if (parts.length >= 2) { - return '${parts[0][0]}${parts[1][0]}'.toUpperCase(); - } - return name.substring(0, name.length.clamp(0, 2)).toUpperCase(); - } - @override Widget build(BuildContext context) { return ListView( @@ -175,17 +170,10 @@ class _ProfileBody extends StatelessWidget { // ── Header ────────────────────────────────────────────────────────── Row( children: [ - CircleAvatar( - radius: 28, - backgroundColor: postalRed, - child: Text( - _initials(), - style: const TextStyle( - color: Colors.white, - fontWeight: FontWeight.bold, - fontSize: 20, - ), - ), + PostieAvatar( + config: data.avatar, + size: 64, + fallbackName: data.displayName, ), const SizedBox(width: AppSpacing.md), Expanded( diff --git a/test/avatar/avatar_config_test.dart b/test/avatar/avatar_config_test.dart new file mode 100644 index 0000000..f06c248 --- /dev/null +++ b/test/avatar/avatar_config_test.dart @@ -0,0 +1,72 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:postbox_game/avatar/avatar_config.dart'; +import 'package:postbox_game/avatar/avatar_svg.dart'; + +void main() { + group('AvatarConfig', () { + test('defaultPostie populates every slot with an in-range index', () { + final cfg = AvatarConfig.defaultPostie(); + for (final slot in AvatarSlot.values) { + expect(cfg[slot], inInclusiveRange(0, slot.length() - 1)); + } + }); + + test('toMap and tryFromMap round-trip', () { + final original = AvatarConfig.defaultPostie(); + final restored = AvatarConfig.tryFromMap(original.toMap()); + expect(restored, equals(original)); + }); + + test('tryFromMap returns null for missing or non-map input', () { + expect(AvatarConfig.tryFromMap(null), isNull); + expect(AvatarConfig.tryFromMap('not a map'), isNull); + }); + + test('tryFromMap clamps out-of-range indices', () { + final cfg = AvatarConfig.tryFromMap({'skin': 999}); + expect(cfg, isNotNull); + expect(cfg![AvatarSlot.skin], lessThan(AvatarSlot.skin.length())); + }); + + test('cycle wraps in both directions', () { + final cfg = AvatarConfig.defaultPostie(); + final len = AvatarSlot.skin.length(); + final start = cfg[AvatarSlot.skin]; + expect(cfg.cycle(AvatarSlot.skin, 1)[AvatarSlot.skin], + equals((start + 1) % len)); + expect(cfg.cycle(AvatarSlot.skin, -1)[AvatarSlot.skin], + equals((start - 1 + len) % len)); + }); + + test('random produces in-range indices for every slot', () { + final cfg = AvatarConfig.random(); + for (final slot in AvatarSlot.values) { + expect(cfg[slot], inInclusiveRange(0, slot.length() - 1)); + } + }); + }); + + group('buildAvatarSvg', () { + test('produces a non-empty SVG string for the default postie', () { + final svg = buildAvatarSvg(AvatarConfig.defaultPostie()); + expect(svg, contains('')); + }); + + test('renders without crashing for every option in every slot', () { + // Smoke-test: cycle each slot through its full range and confirm the + // builder produces a valid ... envelope each time. Catches + // accidental string interpolation breakage when a part is added. + for (final slot in AvatarSlot.values) { + for (var i = 0; i < slot.length(); i++) { + final cfg = AvatarConfig.defaultPostie().copyWith(slot, i); + final svg = buildAvatarSvg(cfg); + expect(svg.startsWith('\n')); + } + } + }); + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 69eb8e5..4cfe792 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -751,6 +751,13 @@ void main() { } testWidgets('Notifications section header is visible', (tester) async { + // Use a tall viewport so the ListView builds all children eagerly — + // the Settings ListView is lazy and only builds items in or near the + // visible area, and the Notifications header sits below the fold. + tester.view.physicalSize = const Size(800, 2400); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + await tester.pumpWidget(buildSettings()); // Allow initState async calls (_loadPrefs, _loadNotifPrefs) to complete. // No real user is signed in, so _loadNotifPrefs resolves immediately.