Initial Flutter scaffold: Riverpod + GoRouter + Dio

This commit is contained in:
2026-01-27 13:20:01 +00:00
commit c8ac4ec8bc
18 changed files with 2728 additions and 0 deletions

View File

@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import '../../../shared/services/api_client.dart';
import '../../clients/presentation/clients_screen.dart';
class EmailComposeScreen extends ConsumerStatefulWidget {
final String? clientId;
const EmailComposeScreen({super.key, this.clientId});
@override
ConsumerState<EmailComposeScreen> createState() => _EmailComposeScreenState();
}
class _EmailComposeScreenState extends ConsumerState<EmailComposeScreen> {
final _purposeController = TextEditingController();
final _subjectController = TextEditingController();
final _contentController = TextEditingController();
String? _selectedClientId;
bool _isGenerating = false;
bool _hasGenerated = false;
@override
void initState() {
super.initState();
_selectedClientId = widget.clientId;
}
@override
void dispose() {
_purposeController.dispose();
_subjectController.dispose();
_contentController.dispose();
super.dispose();
}
Future<void> _generateEmail() async {
if (_selectedClientId == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please select a client')),
);
return;
}
if (_purposeController.text.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Please enter a purpose')),
);
return;
}
setState(() => _isGenerating = true);
try {
final result = await ref.read(apiClientProvider).generateEmail(
clientId: _selectedClientId!,
purpose: _purposeController.text.trim(),
);
setState(() {
_subjectController.text = result['subject'] ?? '';
_contentController.text = result['content'] ?? '';
_hasGenerated = true;
});
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to generate: $e')),
);
}
} finally {
if (mounted) {
setState(() => _isGenerating = false);
}
}
}
@override
Widget build(BuildContext context) {
final clientsAsync = ref.watch(clientsProvider(null));
return Scaffold(
appBar: AppBar(
title: const Text('Compose Email'),
actions: [
if (_hasGenerated)
TextButton(
onPressed: () {
// Email was already saved as draft during generation
context.go('/emails');
},
child: const Text('Done'),
),
],
),
body: ListView(
padding: const EdgeInsets.all(16),
children: [
// Client selector
Text(
'Select Client',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
clientsAsync.when(
data: (clients) => DropdownButtonFormField<String>(
value: _selectedClientId,
decoration: const InputDecoration(
hintText: 'Choose a client',
),
items: clients.map((client) => DropdownMenuItem(
value: client['id'] as String,
child: Text('${client['firstName']} ${client['lastName']}'),
)).toList(),
onChanged: (value) {
setState(() {
_selectedClientId = value;
_hasGenerated = false;
});
},
),
loading: () => const LinearProgressIndicator(),
error: (e, s) => Text('Error loading clients: $e'),
),
const SizedBox(height: 24),
// Purpose input
Text(
'Email Purpose',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _purposeController,
decoration: const InputDecoration(
hintText: 'e.g., Follow up after meeting, Birthday wishes, Market update',
),
maxLines: 2,
),
const SizedBox(height: 16),
// Generate button
FilledButton.icon(
onPressed: _isGenerating ? null : _generateEmail,
icon: _isGenerating
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.auto_awesome),
label: Text(_isGenerating ? 'Generating...' : 'Generate with AI'),
),
if (_hasGenerated) ...[
const SizedBox(height: 32),
const Divider(),
const SizedBox(height: 16),
// Subject
Text(
'Subject',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _subjectController,
decoration: const InputDecoration(
hintText: 'Email subject',
),
),
const SizedBox(height: 16),
// Content
Text(
'Content',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
TextFormField(
controller: _contentController,
decoration: const InputDecoration(
hintText: 'Email content',
),
maxLines: 12,
),
const SizedBox(height: 16),
Row(
children: [
Expanded(
child: OutlinedButton.icon(
onPressed: _generateEmail,
icon: const Icon(Icons.refresh),
label: const Text('Regenerate'),
),
),
],
),
],
],
),
);
}
}

View File

@@ -0,0 +1,215 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:go_router/go_router.dart';
import 'package:intl/intl.dart';
import '../../../shared/services/api_client.dart';
final emailsProvider = FutureProvider.autoDispose<List<Map<String, dynamic>>>((ref) async {
final apiClient = ref.watch(apiClientProvider);
return apiClient.getEmails();
});
class EmailsScreen extends ConsumerWidget {
const EmailsScreen({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final emailsAsync = ref.watch(emailsProvider);
return Scaffold(
appBar: AppBar(
title: const Text('Emails'),
),
body: emailsAsync.when(
data: (emails) {
if (emails.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.email_outlined,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
'No emails yet',
style: Theme.of(context).textTheme.titleMedium?.copyWith(
color: Colors.grey,
),
),
const SizedBox(height: 8),
Text(
'Generate an email from a client profile',
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.grey,
),
),
],
),
);
}
final drafts = emails.where((e) => e['status'] == 'draft').toList();
final sent = emails.where((e) => e['status'] == 'sent').toList();
return DefaultTabController(
length: 2,
child: Column(
children: [
const TabBar(
tabs: [
Tab(text: 'Drafts'),
Tab(text: 'Sent'),
],
),
Expanded(
child: TabBarView(
children: [
_EmailList(emails: drafts, isDraft: true, ref: ref),
_EmailList(emails: sent, isDraft: false, ref: ref),
],
),
),
],
),
);
},
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, s) => Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.error_outline, size: 64, color: Colors.red.shade300),
const SizedBox(height: 16),
const Text('Failed to load emails'),
TextButton(
onPressed: () => ref.invalidate(emailsProvider),
child: const Text('Retry'),
),
],
),
),
),
);
}
}
class _EmailList extends StatelessWidget {
final List<Map<String, dynamic>> emails;
final bool isDraft;
final WidgetRef ref;
const _EmailList({
required this.emails,
required this.isDraft,
required this.ref,
});
@override
Widget build(BuildContext context) {
if (emails.isEmpty) {
return Center(
child: Text(
isDraft ? 'No drafts' : 'No sent emails',
style: TextStyle(color: Colors.grey.shade600),
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: emails.length,
itemBuilder: (context, index) {
final email = emails[index];
final dateFormat = DateFormat.yMMMd().add_jm();
final date = email['sentAt'] ?? email['createdAt'];
return Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
title: Text(
email['subject'] ?? 'No subject',
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
email['content']?.toString().substring(0,
email['content'].toString().length > 100 ? 100 : email['content'].toString().length) ?? '',
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey.shade600),
),
const SizedBox(height: 4),
Text(
date != null ? dateFormat.format(DateTime.parse(date)) : '',
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: Colors.grey,
),
),
],
),
trailing: isDraft
? Row(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: const Icon(Icons.send),
onPressed: () async {
try {
await ref.read(apiClientProvider).sendEmail(email['id']);
ref.invalidate(emailsProvider);
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Failed to send: $e')),
);
}
}
},
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () async {
final confirm = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Delete Draft'),
content: const Text('Are you sure?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Cancel'),
),
TextButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Delete'),
),
],
),
);
if (confirm == true) {
await ref.read(apiClientProvider).deleteEmail(email['id']);
ref.invalidate(emailsProvider);
}
},
),
],
)
: email['aiGenerated'] == true
? const Chip(
label: Text('AI'),
visualDensity: VisualDensity.compact,
)
: null,
isThreeLine: true,
),
);
},
);
}
}