navii_flutter 0.1.0
navii_flutter: ^0.1.0 copied to clipboard
Deterministic mascot-style avatar widget for Flutter. Drop-in avatar generation — same seed = same mascot, every time. No database, no uploads, no state required.
example/lib/main.dart
import 'package:flutter/material.dart';
import 'package:navii_flutter/navii_flutter.dart';
void main() => runApp(const NaviiShowcaseApp());
class NaviiShowcaseApp extends StatelessWidget {
const NaviiShowcaseApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'navii_flutter',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6366F1),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF0F172A),
cardColor: const Color(0xFF1E293B),
dividerColor: const Color(0xFF334155),
textTheme: const TextTheme(
bodyMedium: TextStyle(color: Color(0xFFCBD5E1)),
bodySmall: TextStyle(color: Color(0xFF94A3B8)),
),
),
home: const _Shell(),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Shell
// ─────────────────────────────────────────────────────────────────────────────
class _Shell extends StatefulWidget {
const _Shell();
@override
State<_Shell> createState() => _ShellState();
}
class _ShellState extends State<_Shell> {
int _tab = 0;
@override
Widget build(BuildContext context) {
return Scaffold(
body: IndexedStack(
index: _tab,
children: const [
UseCasesTab(),
TryItTab(),
PickerTab(),
GalleryTab(),
CustomizeTab(),
],
),
bottomNavigationBar: NavigationBar(
selectedIndex: _tab,
onDestinationSelected: (i) => setState(() => _tab = i),
backgroundColor: const Color(0xFF1E293B),
indicatorColor: const Color(0xFF6366F1),
destinations: const [
NavigationDestination(
icon: Icon(Icons.dashboard_outlined),
selectedIcon: Icon(Icons.dashboard),
label: 'Use Cases'),
NavigationDestination(
icon: Icon(Icons.person_outlined),
selectedIcon: Icon(Icons.person),
label: 'Try It'),
NavigationDestination(
icon: Icon(Icons.touch_app_outlined),
selectedIcon: Icon(Icons.touch_app),
label: 'Picker'),
NavigationDestination(
icon: Icon(Icons.grid_view_outlined),
selectedIcon: Icon(Icons.grid_view),
label: 'Gallery'),
NavigationDestination(
icon: Icon(Icons.tune_outlined),
selectedIcon: Icon(Icons.tune),
label: 'Customize'),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tab 1 — Use Cases
// ─────────────────────────────────────────────────────────────────────────────
class UseCasesTab extends StatelessWidget {
const UseCasesTab({super.key});
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 3,
child: Scaffold(
backgroundColor: const Color(0xFF0F172A),
appBar: AppBar(
backgroundColor: const Color(0xFF1E293B),
title: RichText(
text: const TextSpan(
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
children: [
TextSpan(
text: 'navii',
style: TextStyle(color: Color(0xFF818CF8))),
TextSpan(
text: '_flutter',
style: TextStyle(color: Color(0xFFCBD5E1))),
TextSpan(
text: ' use cases',
style: TextStyle(
color: Color(0xFF64748B),
fontWeight: FontWeight.normal,
fontSize: 14)),
],
),
),
bottom: const TabBar(
labelColor: Color(0xFF818CF8),
unselectedLabelColor: Color(0xFF64748B),
indicatorColor: Color(0xFF6366F1),
tabs: [
Tab(text: 'Chat'),
Tab(text: 'Comments'),
Tab(text: 'Contacts'),
],
),
),
body: const TabBarView(
children: [
_ChatDemo(),
_CommentsDemo(),
_ContactsDemo(),
],
),
),
);
}
}
// ── Chat Demo ────────────────────────────────────────────────────────────────
const _chatConversations = [
_Convo('Alice Mensah', 'Sounds good, see you then! 👋', '2m', true, 3),
_Convo('Bob Asante', 'Did you get my last message?', '14m', false, 0),
_Convo('Carol Adjei', 'The meeting is at 3pm tomorrow', '1h', true, 1),
_Convo('David Owusu', 'haha yeah exactly what I was thinking', '2h', false, 0),
_Convo('Eve Boateng', 'Can you review the PR when you get a chance?', '3h', true, 0),
_Convo('Frank Darko', 'On my way!', '5h', false, 0),
_Convo('Grace Amponsah', 'Thanks for the help earlier 🙏', 'Mon', true, 0),
_Convo('Henry Acheampong', 'I\'ll send the files by end of day', 'Mon', false, 0),
_Convo('Iris Opoku', 'Happy birthday!! 🎉🎂', 'Sun', true, 0),
_Convo('Jack Frimpong', 'Let me know what you think', 'Sun', false, 0),
];
class _Convo {
final String name;
final String message;
final String time;
final bool online;
final int unread;
const _Convo(this.name, this.message, this.time, this.online, this.unread);
}
class _ChatDemo extends StatelessWidget {
const _ChatDemo();
@override
Widget build(BuildContext context) {
return Column(
children: [
_SearchBar(hint: 'Search messages...'),
Expanded(
child: ListView.builder(
itemCount: _chatConversations.length,
itemBuilder: (context, i) {
final c = _chatConversations[i];
return _ChatTile(convo: c);
},
),
),
],
);
}
}
class _ChatTile extends StatelessWidget {
final _Convo convo;
const _ChatTile({required this.convo});
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Stack(
children: [
Navii(seed: convo.name, size: 48),
if (convo.online)
Positioned(
right: 0,
bottom: 0,
child: Container(
width: 12,
height: 12,
decoration: BoxDecoration(
color: const Color(0xFF22C55E),
shape: BoxShape.circle,
border: Border.all(color: const Color(0xFF0F172A), width: 2),
),
),
),
],
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(convo.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFFE2E8F0),
fontSize: 15)),
Text(convo.time,
style: TextStyle(
fontSize: 12,
color: convo.unread > 0
? const Color(0xFF818CF8)
: const Color(0xFF64748B))),
],
),
subtitle: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
convo.message,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13,
color: convo.unread > 0
? const Color(0xFFCBD5E1)
: const Color(0xFF64748B)),
),
),
if (convo.unread > 0)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF6366F1),
borderRadius: BorderRadius.circular(10),
),
child: Text('${convo.unread}',
style: const TextStyle(
fontSize: 11,
color: Colors.white,
fontWeight: FontWeight.bold)),
),
],
),
);
}
}
// ── Comments Demo ────────────────────────────────────────────────────────────
const _comments = [
_Comment('Noah Tetteh', '2m ago',
'This is exactly what I\'ve been looking for. The determinism aspect is really clever.',
24),
_Comment('Mia Asare', '8m ago',
'How does it handle unicode names like "김민준" or "محمد"?',
7),
_Comment('Liam Antwi', '15m ago',
'Replying to @Mia — yes it works! Just tested it. Every character maps correctly.',
12),
_Comment('Olivia Mensah', '1h ago', 'Just published a post about this on my blog 🎉',
31),
_Comment('Peter Asante', '2h ago',
'The offline-first approach is a big win for us. No more broken avatar images.',
9),
_Comment('Quinn Osei', '3h ago', 'Does it support custom palettes at runtime?',
5),
];
class _Comment {
final String name;
final String time;
final String text;
final int likes;
const _Comment(this.name, this.time, this.text, this.likes);
}
class _CommentsDemo extends StatelessWidget {
const _CommentsDemo();
@override
Widget build(BuildContext context) {
return ListView(
padding: const EdgeInsets.all(16),
children: [
// Post card
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF334155)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Navii(seed: 'Samuel Darko', size: 40),
const SizedBox(width: 10),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Text('Samuel Darko',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0))),
Text('Just shipped navii_flutter v0.1.0 🚀',
style:
TextStyle(fontSize: 12, color: Color(0xFF64748B))),
],
),
],
),
const SizedBox(height: 12),
const Text(
'Drop-in deterministic mascot avatars for Flutter. '
'No database, no uploads, works offline. '
'Same user ID → same avatar, always. '
'Check out the package on pub.dev 👇',
style: TextStyle(color: Color(0xFFCBD5E1), height: 1.5),
),
const SizedBox(height: 12),
Row(
children: [
const Icon(Icons.favorite_border,
size: 18, color: Color(0xFF64748B)),
const SizedBox(width: 4),
const Text('142',
style:
TextStyle(fontSize: 13, color: Color(0xFF64748B))),
const SizedBox(width: 16),
const Icon(Icons.comment_outlined,
size: 18, color: Color(0xFF64748B)),
const SizedBox(width: 4),
Text('${_comments.length}',
style: const TextStyle(
fontSize: 13, color: Color(0xFF64748B))),
],
),
],
),
),
const SizedBox(height: 16),
const Text('Comments',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0),
fontSize: 15)),
const SizedBox(height: 8),
..._comments.map((c) => _CommentTile(comment: c)),
],
);
}
}
class _CommentTile extends StatelessWidget {
final _Comment comment;
const _CommentTile({required this.comment});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Navii(seed: comment.name, size: 36),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(comment.name,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Color(0xFFE2E8F0),
fontSize: 13)),
const SizedBox(width: 8),
Text(comment.time,
style: const TextStyle(
fontSize: 11, color: Color(0xFF64748B))),
],
),
const SizedBox(height: 4),
Text(comment.text,
style: const TextStyle(
fontSize: 13,
color: Color(0xFFCBD5E1),
height: 1.4)),
const SizedBox(height: 6),
Row(
children: [
const Icon(Icons.favorite_border,
size: 14, color: Color(0xFF64748B)),
const SizedBox(width: 4),
Text('${comment.likes}',
style: const TextStyle(
fontSize: 12, color: Color(0xFF64748B))),
const SizedBox(width: 16),
const Text('Reply',
style: TextStyle(
fontSize: 12, color: Color(0xFF818CF8))),
],
),
],
),
),
],
),
);
}
}
// ── Contacts Demo ────────────────────────────────────────────────────────────
const _contacts = {
'A': ['Alice Mensah', 'Ama Owusu', 'Asante Kwame'],
'B': ['Bob Darko', 'Bright Frimpong'],
'C': ['Carol Adjei', 'Charles Boateng', 'Cynthia Asare'],
'D': ['David Antwi', 'Diana Tetteh'],
'E': ['Emmanuel Osei', 'Eve Opoku'],
'F': ['Frank Amponsah', 'Fatima Acheampong'],
'G': ['Grace Asante', 'Gabriel Mensah'],
};
class _ContactsDemo extends StatelessWidget {
const _ContactsDemo();
@override
Widget build(BuildContext context) {
return Column(
children: [
_SearchBar(hint: 'Search contacts...'),
Expanded(
child: ListView(
children: _contacts.entries
.expand((entry) => [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
entry.key,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: Color(0xFF6366F1),
letterSpacing: 1),
),
),
...entry.value.map((name) => ListTile(
leading: Navii(seed: name, size: 44),
title: Text(name,
style: const TextStyle(
color: Color(0xFFE2E8F0),
fontWeight: FontWeight.w500)),
subtitle: const Text('Tap to view profile',
style: TextStyle(
fontSize: 12, color: Color(0xFF64748B))),
trailing: const Icon(Icons.chevron_right,
color: Color(0xFF475569)),
)),
])
.toList(),
),
),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tab 2 — Try It (Auth Flow + Live Preview)
// ─────────────────────────────────────────────────────────────────────────────
class TryItTab extends StatefulWidget {
const TryItTab({super.key});
@override
State<TryItTab> createState() => _TryItTabState();
}
class _TryItTabState extends State<TryItTab> {
// Simple in-memory "users" store
final Map<String, String> _users = {};
String? _loggedInUser;
String? _error;
final _nameController = TextEditingController();
final _liveController = TextEditingController();
@override
void dispose() {
_nameController.dispose();
_liveController.dispose();
super.dispose();
}
void _signUp(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return;
if (_users.containsKey(trimmed.toLowerCase())) {
setState(() => _error = 'User "$trimmed" already exists. Sign in instead.');
return;
}
setState(() {
_users[trimmed.toLowerCase()] = trimmed;
_loggedInUser = trimmed;
_error = null;
});
}
void _signIn(String name) {
final trimmed = name.trim();
if (trimmed.isEmpty) return;
if (!_users.containsKey(trimmed.toLowerCase())) {
setState(() => _error = 'No account for "$trimmed". Sign up first.');
return;
}
setState(() {
_loggedInUser = _users[trimmed.toLowerCase()];
_error = null;
});
}
void _signOut() => setState(() => _loggedInUser = null);
@override
Widget build(BuildContext context) {
if (_loggedInUser != null) {
return _ProfileScreen(
user: _loggedInUser!, allUsers: _users, onSignOut: _signOut);
}
return _AuthScreen(
users: _users,
error: _error,
liveController: _liveController,
nameController: _nameController,
onSignUp: _signUp,
onSignIn: _signIn,
);
}
}
class _AuthScreen extends StatefulWidget {
final Map<String, String> users;
final String? error;
final TextEditingController liveController;
final TextEditingController nameController;
final void Function(String) onSignUp;
final void Function(String) onSignIn;
const _AuthScreen({
required this.users,
required this.error,
required this.liveController,
required this.nameController,
required this.onSignUp,
required this.onSignIn,
});
@override
State<_AuthScreen> createState() => _AuthScreenState();
}
class _AuthScreenState extends State<_AuthScreen>
with SingleTickerProviderStateMixin {
late final TabController _tabController;
String _liveSeed = '';
@override
void initState() {
super.initState();
_tabController = TabController(length: 2, vsync: this);
widget.liveController.addListener(() {
setState(() => _liveSeed = widget.liveController.text.trim());
});
}
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0F172A),
appBar: AppBar(
backgroundColor: const Color(0xFF1E293B),
title: const Text('navii_flutter — Try It'),
bottom: TabBar(
controller: _tabController,
labelColor: const Color(0xFF818CF8),
unselectedLabelColor: const Color(0xFF64748B),
indicatorColor: const Color(0xFF6366F1),
tabs: const [Tab(text: 'Sign Up'), Tab(text: 'Sign In')],
),
),
body: TabBarView(
controller: _tabController,
children: [
_buildSignUp(),
_buildSignIn(),
],
),
);
}
Widget _buildSignUp() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const SizedBox(height: 16),
// Live avatar preview
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
child: _liveSeed.isNotEmpty
? Column(
children: [
Navii(seed: _liveSeed, size: 96),
const SizedBox(height: 8),
Text(
'This will be your avatar',
style: TextStyle(
fontSize: 13, color: Colors.grey.shade500),
),
],
)
: Column(
children: [
Container(
width: 96,
height: 96,
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
shape: BoxShape.circle,
border: Border.all(
color: const Color(0xFF334155), width: 2),
),
child: const Icon(Icons.person,
size: 40, color: Color(0xFF475569)),
),
const SizedBox(height: 8),
const Text('Enter your name to preview your avatar',
style: TextStyle(
fontSize: 13, color: Color(0xFF64748B))),
],
),
),
const SizedBox(height: 32),
TextField(
controller: widget.liveController,
style: const TextStyle(color: Color(0xFFE2E8F0)),
decoration: _inputDecoration('Your name', Icons.person_outline),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 12),
if (widget.error != null)
_ErrorBanner(widget.error!),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => widget.onSignUp(widget.liveController.text),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF6366F1),
padding: const EdgeInsets.symmetric(vertical: 14)),
child: const Text('Create Account',
style: TextStyle(fontSize: 16)),
),
),
const SizedBox(height: 32),
if (widget.users.isNotEmpty) ...[
const _Divider('Existing accounts'),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: widget.users.values
.map((name) => Column(
children: [
Navii(seed: name, size: 48),
const SizedBox(height: 4),
Text(name.split(' ').first,
style: const TextStyle(
fontSize: 11, color: Color(0xFF64748B))),
],
))
.toList(),
),
],
const SizedBox(height: 32),
_DeterminismNote(),
],
),
);
}
Widget _buildSignIn() {
return SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: Column(
children: [
const SizedBox(height: 40),
if (widget.users.isEmpty) ...[
const Icon(Icons.person_add_outlined,
size: 64, color: Color(0xFF334155)),
const SizedBox(height: 16),
const Text('No accounts yet.',
style: TextStyle(color: Color(0xFF64748B))),
const Text('Create one in the Sign Up tab.',
style: TextStyle(color: Color(0xFF475569), fontSize: 13)),
const SizedBox(height: 32),
] else ...[
const Text('Your accounts',
style: TextStyle(
fontWeight: FontWeight.bold, color: Color(0xFFE2E8F0))),
const SizedBox(height: 16),
...widget.users.values.map((name) => ListTile(
contentPadding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 4),
leading: Navii(seed: name, size: 44),
title: Text(name,
style: const TextStyle(color: Color(0xFFE2E8F0))),
subtitle: const Text('Tap to sign in',
style:
TextStyle(fontSize: 12, color: Color(0xFF64748B))),
trailing: const Icon(Icons.arrow_forward_ios,
size: 14, color: Color(0xFF475569)),
onTap: () => widget.onSignIn(name),
)),
const SizedBox(height: 16),
],
TextField(
controller: widget.nameController,
style: const TextStyle(color: Color(0xFFE2E8F0)),
decoration: _inputDecoration('Or type your name', Icons.person_outline),
textCapitalization: TextCapitalization.words,
),
const SizedBox(height: 12),
if (widget.error != null) _ErrorBanner(widget.error!),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => widget.onSignIn(widget.nameController.text),
style: FilledButton.styleFrom(
backgroundColor: const Color(0xFF6366F1),
padding: const EdgeInsets.symmetric(vertical: 14)),
child: const Text('Sign In', style: TextStyle(fontSize: 16)),
),
),
],
),
);
}
}
class _ProfileScreen extends StatelessWidget {
final String user;
final Map<String, String> allUsers;
final VoidCallback onSignOut;
const _ProfileScreen(
{required this.user, required this.allUsers, required this.onSignOut});
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0F172A),
appBar: AppBar(
backgroundColor: const Color(0xFF1E293B),
title: const Text('Profile'),
actions: [
TextButton(
onPressed: onSignOut,
child: const Text('Sign Out',
style: TextStyle(color: Color(0xFF818CF8))),
),
],
),
body: SingleChildScrollView(
child: Column(
children: [
// Profile header
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 40),
color: const Color(0xFF1E293B),
child: Column(
children: [
Navii(seed: user, size: 100),
const SizedBox(height: 12),
Text(user,
style: const TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0))),
const SizedBox(height: 4),
Text('Seed: "$user"',
style: const TextStyle(
fontSize: 12,
color: Color(0xFF64748B),
fontFamily: 'monospace')),
const SizedBox(height: 16),
// Show avatar is stable — same user, 3 renders
Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text('Always the same: ',
style: TextStyle(
fontSize: 12, color: Color(0xFF64748B))),
...List.generate(
3,
(i) => Padding(
padding:
const EdgeInsets.symmetric(horizontal: 3),
child: Navii(seed: user, size: 28),
)),
],
),
],
),
),
const SizedBox(height: 24),
// Similar names — prove no collisions
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Similar names — all different avatars',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0))),
const SizedBox(height: 4),
const Text(
'Even one character difference produces a completely unique avatar.',
style:
TextStyle(fontSize: 12, color: Color(0xFF94A3B8))),
const SizedBox(height: 16),
..._buildVariants(user),
],
),
),
const SizedBox(height: 24),
if (allUsers.length > 1) ...[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Other users',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0))),
const SizedBox(height: 12),
Wrap(
spacing: 16,
runSpacing: 16,
children: allUsers.values
.where((u) => u != user)
.map((u) => Column(
children: [
Navii(seed: u, size: 52),
const SizedBox(height: 4),
Text(u.split(' ').first,
style: const TextStyle(
fontSize: 11,
color: Color(0xFF64748B))),
],
))
.toList(),
),
],
),
),
const SizedBox(height: 24),
],
],
),
),
);
}
List<Widget> _buildVariants(String name) {
final base = name.trim();
final variants = [
base,
'${base}1',
'${base.split(' ').first} J.',
base.toLowerCase(),
base.toUpperCase(),
];
return variants
.map((v) => Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(
children: [
Navii(seed: v, size: 40),
const SizedBox(width: 12),
Text('"$v"',
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 13,
color: Color(0xFFCBD5E1))),
if (v == base)
Container(
margin: const EdgeInsets.only(left: 8),
padding: const EdgeInsets.symmetric(
horizontal: 6, vertical: 2),
decoration: BoxDecoration(
color: const Color(0xFF1E3A8A),
borderRadius: BorderRadius.circular(4),
),
child: const Text('you',
style: TextStyle(
fontSize: 10, color: Color(0xFF93C5FD))),
),
],
),
))
.toList();
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tab 3 — Picker
// ─────────────────────────────────────────────────────────────────────────────
class PickerTab extends StatefulWidget {
const PickerTab({super.key});
@override
State<PickerTab> createState() => _PickerTabState();
}
class _PickerTabState extends State<PickerTab> {
static const _baseSeed = 'showcase-profile';
String _currentSeed = 'showcase-profile-navii-v0';
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0F172A),
appBar: AppBar(
backgroundColor: const Color(0xFF1E293B),
title: const Text('Avatar Picker'),
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Current selection card
_CurrentAvatarCard(seed: _currentSeed),
const SizedBox(height: 28),
// Inline picker
const Text(
'Inline Picker',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0)),
),
const SizedBox(height: 4),
const Text(
'NaviiPicker(baseSeed: ..., onSelected: ...)',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Color(0xFF64748B)),
),
const SizedBox(height: 12),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF334155)),
),
child: NaviiPicker(
baseSeed: _baseSeed,
initialSeed: _currentSeed,
confirmLabel: 'Use this avatar',
onSelected: (seed) => setState(() => _currentSeed = seed),
),
),
const SizedBox(height: 28),
// Bottom sheet button
const Text(
'Bottom Sheet',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0)),
),
const SizedBox(height: 4),
const Text(
'showNaviiPickerSheet(context, baseSeed: ...)',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Color(0xFF64748B)),
),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: OutlinedButton.icon(
onPressed: () async {
final seed = await showNaviiPickerSheet(
context,
baseSeed: _baseSeed,
initialSeed: _currentSeed,
title: 'Choose your avatar',
confirmLabel: 'Use this avatar',
);
if (seed != null) setState(() => _currentSeed = seed);
},
icon: const Icon(Icons.open_in_new, size: 16),
label: const Text('Open picker in bottom sheet'),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF818CF8),
side: const BorderSide(color: Color(0xFF334155)),
padding: const EdgeInsets.symmetric(vertical: 14),
),
),
),
const SizedBox(height: 28),
// How it works note
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF334155)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Row(
children: [
Icon(Icons.info_outline,
size: 14, color: Color(0xFF818CF8)),
SizedBox(width: 6),
Text('Stable seeds',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0))),
],
),
SizedBox(height: 6),
Text(
'Each variant seed is deterministic: '
'"showcase-profile-navii-v0", '
'"showcase-profile-navii-v1", etc. Store the chosen seed '
'in your database and render it anywhere with '
'Navii(seed: storedSeed).',
style: TextStyle(
fontSize: 12,
color: Color(0xFF94A3B8),
height: 1.5),
),
],
),
),
],
),
),
);
}
}
class _CurrentAvatarCard extends StatelessWidget {
final String seed;
const _CurrentAvatarCard({required this.seed});
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 24),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: const Color(0xFF334155)),
),
child: Column(
children: [
Navii(
seed: seed,
size: 80,
borderColor: const Color(0xFF6366F1),
borderWidth: 2.5,
),
const SizedBox(height: 10),
const Text('Current avatar',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0))),
const SizedBox(height: 2),
Text(
seed,
style: const TextStyle(
fontFamily: 'monospace',
fontSize: 11,
color: Color(0xFF64748B)),
textAlign: TextAlign.center,
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tab 4 — Gallery
// ─────────────────────────────────────────────────────────────────────────────
class GalleryTab extends StatelessWidget {
const GalleryTab({super.key});
static const _seeds = [
'alice', 'bob', 'carol', 'david', 'eve', 'frank', 'grace', 'henry',
'iris', 'jack', 'kate', 'liam', 'mia', 'noah', 'olivia', 'peter',
'quinn', 'rachel', 'sam', 'tina', 'user-001', 'user-002', 'user-003',
'user-004', 'user-005', 'user-006', 'alpha', 'beta', 'gamma', 'delta',
'kofi', 'ama', 'kwame', 'abena', 'yaw', 'akosua', 'kojo', 'adwoa',
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0F172A),
appBar: AppBar(
backgroundColor: const Color(0xFF1E293B),
title: Text('Gallery · ${_seeds.length} avatars'),
),
body: GridView.builder(
padding: const EdgeInsets.all(12),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
mainAxisSpacing: 8,
crossAxisSpacing: 8,
childAspectRatio: 0.85,
),
itemCount: _seeds.length,
itemBuilder: (context, i) {
final seed = _seeds[i];
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Navii(seed: seed, size: 52),
const SizedBox(height: 3),
Text(seed,
style: const TextStyle(
fontSize: 9, color: Color(0xFF475569)),
overflow: TextOverflow.ellipsis,
maxLines: 1),
],
);
},
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Tab 5 — Customize
// ─────────────────────────────────────────────────────────────────────────────
class CustomizeTab extends StatelessWidget {
const CustomizeTab({super.key});
static const _palettes = [
'indigo', 'mint', 'amber', 'sky', 'violet', 'cyan', 'rose', 'lime',
'peach', 'teal', 'sand', 'plum', 'coral', 'forest', 'slate', 'fuchsia',
'terracotta', 'navy', 'lavender', 'charcoal', 'butter', 'aqua',
];
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFF0F172A),
appBar: AppBar(
backgroundColor: const Color(0xFF1E293B),
title: const Text('Customize'),
),
body: ListView(
padding: const EdgeInsets.all(20),
children: [
// Sizes
const Text('Sizes',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0))),
const SizedBox(height: 4),
const Text('Navii(seed: ..., size: 64)',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Color(0xFF64748B))),
const SizedBox(height: 16),
...[24.0, 32.0, 48.0, 64.0, 96.0, 128.0].map((s) => Padding(
padding: const EdgeInsets.only(bottom: 16),
child: Row(
children: [
Navii(seed: 'size-demo', size: s),
const SizedBox(width: 16),
Text('${s.toInt()}px',
style: const TextStyle(color: Color(0xFF94A3B8))),
],
),
)),
const SizedBox(height: 8),
const Divider(color: Color(0xFF334155), height: 32),
// Palettes
const Text('22 Palettes',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0))),
const SizedBox(height: 4),
const Text("AvatarOptions(paletteId: 'violet')",
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Color(0xFF64748B))),
const SizedBox(height: 16),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 4,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: 0.8,
),
itemCount: _palettes.length,
itemBuilder: (context, i) {
final p = _palettes[i];
return Column(
children: [
Navii(
seed: 'palette-preview',
size: 56,
options: AvatarOptions(paletteId: p),
),
const SizedBox(height: 4),
Text(p,
style: const TextStyle(
fontSize: 9, color: Color(0xFF475569)),
overflow: TextOverflow.ellipsis),
],
);
},
),
const Divider(color: Color(0xFF334155), height: 40),
// Shapes
const Text('Shapes',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0))),
const SizedBox(height: 4),
const Text('Navii(seed: ..., shape: NaviiShape.circle)',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Color(0xFF64748B))),
const SizedBox(height: 16),
Row(
children: [
Column(children: [
Navii(seed: 'shape-demo', size: 64, shape: NaviiShape.circle),
const SizedBox(height: 4),
const Text('circle',
style:
TextStyle(fontSize: 11, color: Color(0xFF64748B))),
]),
const SizedBox(width: 24),
Column(children: [
Navii(seed: 'shape-demo', size: 64, shape: NaviiShape.rounded),
const SizedBox(height: 4),
const Text('rounded',
style:
TextStyle(fontSize: 11, color: Color(0xFF64748B))),
]),
const SizedBox(width: 24),
Column(children: [
Navii(seed: 'shape-demo', size: 64, shape: NaviiShape.square),
const SizedBox(height: 4),
const Text('square',
style:
TextStyle(fontSize: 11, color: Color(0xFF64748B))),
]),
],
),
const Divider(color: Color(0xFF334155), height: 40),
// Status dots & borders
const Text('Status & Border',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0))),
const SizedBox(height: 4),
const Text(
'Navii(seed: ..., statusColor: ..., borderColor: ...)',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Color(0xFF64748B))),
const SizedBox(height: 16),
Wrap(
spacing: 20,
runSpacing: 16,
children: [
_LabelledAvatar(
label: 'online',
child: Navii(
seed: 'status-demo',
size: 56,
statusColor: const Color(0xFF22C55E)),
),
_LabelledAvatar(
label: 'away',
child: Navii(
seed: 'status-demo-2',
size: 56,
statusColor: const Color(0xFFF59E0B),
statusAlignment: Alignment.bottomRight),
),
_LabelledAvatar(
label: 'busy',
child: Navii(
seed: 'status-demo-3',
size: 56,
statusColor: const Color(0xFFEF4444)),
),
_LabelledAvatar(
label: 'border',
child: Navii(
seed: 'border-demo',
size: 56,
borderColor: const Color(0xFF818CF8),
borderWidth: 3),
),
_LabelledAvatar(
label: 'both',
child: Navii(
seed: 'border-demo-2',
size: 56,
borderColor: const Color(0xFF22C55E),
statusColor: const Color(0xFF22C55E)),
),
],
),
const Divider(color: Color(0xFF334155), height: 40),
// Group avatars
const Text('Group Avatars',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0))),
const SizedBox(height: 4),
const Text(
'NaviiGroup(seeds: [...], size: 36, maxVisible: 3)',
style: TextStyle(
fontFamily: 'monospace',
fontSize: 12,
color: Color(0xFF64748B))),
const SizedBox(height: 16),
NaviiGroup(
seeds: const [
'group-a', 'group-b', 'group-c',
'group-d', 'group-e', 'group-f',
],
size: 40,
overlap: 12,
maxVisible: 4,
borderColor: const Color(0xFF0F172A),
),
const SizedBox(height: 12),
NaviiGroup(
seeds: const [
'team-1', 'team-2', 'team-3', 'team-4',
],
size: 32,
overlap: 8,
shape: NaviiShape.rounded,
borderColor: const Color(0xFF0F172A),
),
const SizedBox(height: 32),
],
),
);
}
}
class _LabelledAvatar extends StatelessWidget {
final String label;
final Widget child;
const _LabelledAvatar({required this.label, required this.child});
@override
Widget build(BuildContext context) {
return Column(
children: [
child,
const SizedBox(height: 4),
Text(label,
style:
const TextStyle(fontSize: 11, color: Color(0xFF64748B))),
],
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Shared widgets
// ─────────────────────────────────────────────────────────────────────────────
class _SearchBar extends StatelessWidget {
final String hint;
const _SearchBar({required this.hint});
@override
Widget build(BuildContext context) {
return Container(
margin: const EdgeInsets.fromLTRB(12, 10, 12, 4),
padding: const EdgeInsets.symmetric(horizontal: 12),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(10),
border: Border.all(color: const Color(0xFF334155)),
),
child: Row(
children: [
const Icon(Icons.search, color: Color(0xFF475569), size: 18),
const SizedBox(width: 8),
Expanded(
child: TextField(
style: const TextStyle(fontSize: 14, color: Color(0xFFCBD5E1)),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(color: Color(0xFF475569)),
border: InputBorder.none,
),
),
),
],
),
);
}
}
class _ErrorBanner extends StatelessWidget {
final String message;
const _ErrorBanner(this.message);
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: const Color(0xFF450A0A),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFF7F1D1D)),
),
child: Text(message,
style: const TextStyle(fontSize: 13, color: Color(0xFFFCA5A5))),
);
}
}
class _Divider extends StatelessWidget {
final String label;
const _Divider(this.label);
@override
Widget build(BuildContext context) {
return Row(
children: [
const Expanded(child: Divider(color: Color(0xFF334155))),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Text(label,
style: const TextStyle(fontSize: 12, color: Color(0xFF64748B))),
),
const Expanded(child: Divider(color: Color(0xFF334155))),
],
);
}
}
class _DeterminismNote extends StatelessWidget {
const _DeterminismNote();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E293B),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFF334155)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: const [
Row(
children: [
Icon(Icons.info_outline, size: 16, color: Color(0xFF818CF8)),
SizedBox(width: 6),
Text('How it works',
style: TextStyle(
fontWeight: FontWeight.bold,
color: Color(0xFFE2E8F0),
fontSize: 13)),
],
),
SizedBox(height: 8),
Text(
'Your name is hashed into a number, then used to seed a '
'random number generator. The generator picks one of 22M+ '
'combinations — deterministically. No server, no database, '
'works offline.',
style: TextStyle(
fontSize: 12, color: Color(0xFF94A3B8), height: 1.5),
),
],
),
);
}
}
InputDecoration _inputDecoration(String label, IconData icon) {
return InputDecoration(
labelText: label,
labelStyle: const TextStyle(color: Color(0xFF64748B)),
prefixIcon: Icon(icon, color: const Color(0xFF475569)),
filled: true,
fillColor: const Color(0xFF1E293B),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFF334155)),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(10),
borderSide: const BorderSide(color: Color(0xFF6366F1), width: 2),
),
);
}