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.