/// Complete example: Supabase Auth + AI Assistant streaming. /// /// Shows init, signup/login, streaming chat with model/critic selection, /// and how different apps (room_decor, music, ecommerce) use the same client. import 'package:flutter/material.dart'; import 'package:supabase_flutter/supabase_flutter.dart'; import 'package:ai_assistant_client/ai_assistant_client.dart'; // ── App initialization ────────────────────────────────────────────────────── void main() async { WidgetsFlutterBinding.ensureInitialized(); // Initialize Supabase (once, at app startup) await Supabase.initialize( url: 'https://supabase.josradev.net', anonKey: 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJyb2xlIjoiYW5vbiIsImlzcyI6InN1cGFiYXNlIiwiaWF0IjoxNzcwODg0Mjc3LCJleHAiOjE5Mjg1NjQyNzd9.OQWYOhyF9YvUjk_C707_JkiqSaMRbzGENOLsG17LCc8', ); // Pick your app: runApp(const RoomDecorApp()); // runApp(const MusicApp()); // runApp(const EcommerceApp()); } // ── Auth Screen ───────────────────────────────────────────────────────────── class AuthScreen extends StatefulWidget { final String appId; final String title; const AuthScreen({super.key, required this.appId, required this.title}); @override State createState() => _AuthScreenState(); } class _AuthScreenState extends State { final _email = TextEditingController(); final _password = TextEditingController(); final _name = TextEditingController(); bool _isLogin = true; bool _loading = false; String? _error; Future _submit() async { setState(() { _loading = true; _error = null; }); try { final client = AiAssistantClient( baseUrl: 'https://assistant.josradev.net', appId: widget.appId, ); if (_isLogin) { await client.signIn( email: _email.text.trim(), password: _password.text, ); } else { await client.signUp( email: _email.text.trim(), password: _password.text, displayName: _name.text.trim(), ); } if (mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => ChatScreen( appId: widget.appId, title: widget.title, ), ), ); } } on AuthException catch (e) { setState(() => _error = e.message); } catch (e) { setState(() => _error = e.toString()); } finally { if (mounted) setState(() => _loading = false); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar(title: Text(widget.title)), body: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ if (!_isLogin) TextField( controller: _name, decoration: const InputDecoration(labelText: 'Display Name'), ), const SizedBox(height: 12), TextField( controller: _email, decoration: const InputDecoration(labelText: 'Email'), keyboardType: TextInputType.emailAddress, ), const SizedBox(height: 12), TextField( controller: _password, decoration: const InputDecoration(labelText: 'Password'), obscureText: true, onSubmitted: (_) => _submit(), ), const SizedBox(height: 16), if (_error != null) Padding( padding: const EdgeInsets.only(bottom: 12), child: Text(_error!, style: const TextStyle(color: Colors.red)), ), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: _loading ? null : _submit, child: _loading ? const SizedBox( width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2)) : Text(_isLogin ? 'Sign In' : 'Sign Up'), ), ), TextButton( onPressed: () => setState(() => _isLogin = !_isLogin), child: Text(_isLogin ? "Don't have an account? Sign Up" : 'Already have an account? Sign In'), ), ], ), ), ); } } // ── Chat Screen ───────────────────────────────────────────────────────────── class ChatScreen extends StatefulWidget { final String appId; final String title; const ChatScreen({super.key, required this.appId, required this.title}); @override State createState() => _ChatScreenState(); } class _ChatScreenState extends State { late final AiAssistantClient client; final _controller = TextEditingController(); final _scrollController = ScrollController(); final _messages = <_Bubble>[]; UserProfile? _profile; String _currentResponse = ''; String _status = ''; bool _loading = false; // Settings String? _selectedModel; bool? _criticEnabled; // null = auto @override void initState() { super.initState(); client = AiAssistantClient( baseUrl: 'https://assistant.josradev.net', appId: widget.appId, ); _loadProfile(); } Future _loadProfile() async { final profile = await client.getProfile(); if (mounted) setState(() => _profile = profile); } Future _send() async { final text = _controller.text.trim(); if (text.isEmpty || _loading) return; _controller.clear(); // Apply settings client.model = _selectedModel; client.criticEnabled = _criticEnabled; setState(() { _messages.add(_Bubble(role: 'user', text: text)); _currentResponse = ''; _status = ''; _loading = true; }); _scrollToBottom(); final buffer = StringBuffer(); List images = []; try { await for (final event in client.chatStream(text)) { switch (event.type) { case StreamEventType.token: buffer.write(event.token); setState(() => _currentResponse = buffer.toString()); _scrollToBottom(); break; case StreamEventType.status: setState(() => _status = event.statusMessage); break; case StreamEventType.images: images = event.images; break; case StreamEventType.done: setState(() { _messages.add(_Bubble( role: 'assistant', text: buffer.toString(), images: images, )); _currentResponse = ''; _status = ''; _loading = false; }); break; case StreamEventType.error: setState(() { _messages.add( _Bubble(role: 'assistant', text: 'Error: ${event.data}')); _loading = false; }); break; default: break; } } } catch (e) { setState(() { _messages.add(_Bubble(role: 'assistant', text: 'Error: $e')); _loading = false; }); } } void _scrollToBottom() { Future.delayed(const Duration(milliseconds: 50), () { if (_scrollController.hasClients) { _scrollController.animateTo( _scrollController.position.maxScrollExtent, duration: const Duration(milliseconds: 200), curve: Curves.easeOut, ); } }); } void _showSettings() { showModalBottomSheet( context: context, builder: (ctx) => _SettingsSheet( currentModel: _selectedModel, criticEnabled: _criticEnabled, profile: _profile, onModelChanged: (m) => setState(() => _selectedModel = m), onCriticChanged: (c) => setState(() => _criticEnabled = c), onSignOut: () async { await client.signOut(); if (mounted) { Navigator.of(context).pushReplacement( MaterialPageRoute( builder: (_) => AuthScreen( appId: widget.appId, title: widget.title, ), ), ); } }, ), ); } @override void dispose() { client.dispose(); _controller.dispose(); _scrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: Text(widget.title), actions: [ // New conversation IconButton( icon: const Icon(Icons.add), onPressed: () { client.newConversation(); setState(() => _messages.clear()); }, ), // Settings (model, critic, profile) IconButton( icon: const Icon(Icons.settings), onPressed: _showSettings, ), ], ), body: Column( children: [ // Messages Expanded( child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.all(16), itemCount: _messages.length + (_currentResponse.isNotEmpty ? 1 : 0), itemBuilder: (context, index) { if (index < _messages.length) { final msg = _messages[index]; return _buildBubble(msg); } // Streaming response return _buildStreamingBubble(); }, ), ), // Input Container( padding: const EdgeInsets.all(8), decoration: BoxDecoration( color: Theme.of(context).scaffoldBackgroundColor, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.1), blurRadius: 4, offset: const Offset(0, -1), ), ], ), child: Row( children: [ Expanded( child: TextField( controller: _controller, onSubmitted: (_) => _send(), maxLines: null, decoration: InputDecoration( hintText: 'Type a message...', border: OutlineInputBorder( borderRadius: BorderRadius.circular(24), ), contentPadding: const EdgeInsets.symmetric( horizontal: 16, vertical: 10, ), ), ), ), const SizedBox(width: 8), IconButton( onPressed: _loading ? () => client.cancel() : _send, icon: Icon( _loading ? Icons.stop : Icons.send, color: Theme.of(context).primaryColor, ), ), ], ), ), ], ), ); } Widget _buildBubble(_Bubble msg) { final isUser = msg.role == 'user'; return Align( alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.8), decoration: BoxDecoration( color: isUser ? Colors.blue[100] : Colors.grey[200], borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(msg.text), if (msg.images != null) for (final img in msg.images!) Padding( padding: const EdgeInsets.only(top: 8), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Image.network(img.url), ), ), ], ), ), ); } Widget _buildStreamingBubble() { return Align( alignment: Alignment.centerLeft, child: Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), constraints: BoxConstraints(maxWidth: MediaQuery.of(context).size.width * 0.8), decoration: BoxDecoration( color: Colors.grey[200], borderRadius: BorderRadius.circular(16), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (_status.isNotEmpty) Padding( padding: const EdgeInsets.only(bottom: 6), child: Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 14, height: 14, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.grey[500], ), ), const SizedBox(width: 6), Text( _status, style: TextStyle(color: Colors.grey[600], fontSize: 12), ), ], ), ), if (_currentResponse.isNotEmpty) Text(_currentResponse), if (_currentResponse.isEmpty && _status.isEmpty) SizedBox( width: 20, height: 20, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.grey[400], ), ), ], ), ), ); } } // ── Settings Sheet ────────────────────────────────────────────────────────── class _SettingsSheet extends StatelessWidget { final String? currentModel; final bool? criticEnabled; final UserProfile? profile; final ValueChanged onModelChanged; final ValueChanged onCriticChanged; final VoidCallback onSignOut; const _SettingsSheet({ required this.currentModel, required this.criticEnabled, required this.profile, required this.onModelChanged, required this.onCriticChanged, required this.onSignOut, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ // Profile if (profile != null) ...[ Text('Signed in as ${profile!.name}', style: const TextStyle(fontWeight: FontWeight.bold)), Text('Role: ${profile!.role}', style: TextStyle(color: Colors.grey[600], fontSize: 13)), if (profile!.claudeCodeAccess) const Text('Claude Code: Enabled', style: TextStyle(color: Colors.green, fontSize: 13)), const SizedBox(height: 16), ], // Model selection const Text('Model', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 4), DropdownButton( value: currentModel, isExpanded: true, items: const [ DropdownMenuItem(value: null, child: Text('Default (server)')), DropdownMenuItem( value: 'gpt-oss:120b', child: Text('GPT-OSS 120B')), DropdownMenuItem( value: 'huihui_ai/qwen3-abliterated:32b', child: Text('Qwen3 32B (Uncensored)')), DropdownMenuItem( value: 'gemini-2.5-flash', child: Text('Gemini 2.5 Flash')), DropdownMenuItem( value: 'gemini-2.5-pro', child: Text('Gemini 2.5 Pro')), ], onChanged: (val) { onModelChanged(val); Navigator.pop(context); }, ), const SizedBox(height: 16), // Critic toggle const Text('Response Critic', style: TextStyle(fontWeight: FontWeight.bold)), const SizedBox(height: 4), SegmentedButton( segments: const [ ButtonSegment(value: null, label: Text('Auto')), ButtonSegment(value: true, label: Text('On')), ButtonSegment(value: false, label: Text('Off')), ], selected: {criticEnabled}, onSelectionChanged: (val) { onCriticChanged(val.first); Navigator.pop(context); }, ), const SizedBox(height: 24), // Sign out SizedBox( width: double.infinity, child: OutlinedButton( onPressed: () { Navigator.pop(context); onSignOut(); }, child: const Text('Sign Out'), ), ), const SizedBox(height: 8), ], ), ); } } // ── Data class ────────────────────────────────────────────────────────────── class _Bubble { final String role; final String text; final List? images; const _Bubble({required this.role, required this.text, this.images}); } // ── App entry points ──────────────────────────────────────────────────────── // Each app is identical except for appId and title. class RoomDecorApp extends StatelessWidget { const RoomDecorApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Room Decor AI', theme: ThemeData( colorSchemeSeed: Colors.teal, useMaterial3: true, ), home: Supabase.instance.client.auth.currentSession != null ? const ChatScreen(appId: 'room_decor', title: 'Room Decor AI') : const AuthScreen(appId: 'room_decor', title: 'Room Decor AI'), ); } } class MusicApp extends StatelessWidget { const MusicApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Music AI', theme: ThemeData( colorSchemeSeed: Colors.purple, useMaterial3: true, ), home: Supabase.instance.client.auth.currentSession != null ? const ChatScreen(appId: 'music', title: 'Music AI') : const AuthScreen(appId: 'music', title: 'Music AI'), ); } } class EcommerceApp extends StatelessWidget { const EcommerceApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Shopping AI', theme: ThemeData( colorSchemeSeed: Colors.orange, useMaterial3: true, ), home: Supabase.instance.client.auth.currentSession != null ? const ChatScreen(appId: 'ecommerce', title: 'Shopping AI') : const AuthScreen(appId: 'ecommerce', title: 'Shopping AI'), ); } }