ai_assistant_client/example/usage_example.dart
root 3fe1dc7bbf Initial commit: AI Assistant Flutter client
- 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
2026-03-11 07:08:48 +00:00

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'),
);
}
}