- SSE streaming chat (real-time token delivery) - Supabase Auth integration (signup, login, JWT) - Multi-app support via app_id (room_decor, music, ecommerce) - Model selection and critic toggle - Conversation management (list, load, delete) - Cancel streaming requests - Full example with auth screen + chat UI
643 lines
20 KiB
Dart
643 lines
20 KiB
Dart
/// 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<AuthScreen> createState() => _AuthScreenState();
|
|
}
|
|
|
|
class _AuthScreenState extends State<AuthScreen> {
|
|
final _email = TextEditingController();
|
|
final _password = TextEditingController();
|
|
final _name = TextEditingController();
|
|
bool _isLogin = true;
|
|
bool _loading = false;
|
|
String? _error;
|
|
|
|
Future<void> _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<ChatScreen> createState() => _ChatScreenState();
|
|
}
|
|
|
|
class _ChatScreenState extends State<ChatScreen> {
|
|
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<void> _loadProfile() async {
|
|
final profile = await client.getProfile();
|
|
if (mounted) setState(() => _profile = profile);
|
|
}
|
|
|
|
Future<void> _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<AssistantImage> 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<String?> onModelChanged;
|
|
final ValueChanged<bool?> 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<String?>(
|
|
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<bool?>(
|
|
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<AssistantImage>? 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'),
|
|
);
|
|
}
|
|
}
|