Initial Flutter scaffold: Riverpod + GoRouter + Dio
This commit is contained in:
346
lib/features/clients/presentation/client_detail_screen.dart
Normal file
346
lib/features/clients/presentation/client_detail_screen.dart
Normal file
@@ -0,0 +1,346 @@
|
||||
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 clientDetailProvider = FutureProvider.autoDispose
|
||||
.family<Map<String, dynamic>, String>((ref, id) async {
|
||||
final apiClient = ref.watch(apiClientProvider);
|
||||
return apiClient.getClient(id);
|
||||
});
|
||||
|
||||
class ClientDetailScreen extends ConsumerWidget {
|
||||
final String clientId;
|
||||
|
||||
const ClientDetailScreen({super.key, required this.clientId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final clientAsync = ref.watch(clientDetailProvider(clientId));
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.edit),
|
||||
onPressed: () => context.go('/clients/$clientId/edit'),
|
||||
),
|
||||
PopupMenuButton(
|
||||
itemBuilder: (context) => [
|
||||
const PopupMenuItem(
|
||||
value: 'email',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.email),
|
||||
title: Text('Generate Email'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'contacted',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.check_circle),
|
||||
title: Text('Mark Contacted'),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
const PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: ListTile(
|
||||
leading: Icon(Icons.delete, color: Colors.red),
|
||||
title: Text('Delete', style: TextStyle(color: Colors.red)),
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
),
|
||||
],
|
||||
onSelected: (value) async {
|
||||
switch (value) {
|
||||
case 'email':
|
||||
context.go('/emails/compose?clientId=$clientId');
|
||||
break;
|
||||
case 'contacted':
|
||||
await ref.read(apiClientProvider).markClientContacted(clientId);
|
||||
ref.invalidate(clientDetailProvider(clientId));
|
||||
break;
|
||||
case 'delete':
|
||||
final confirm = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
title: const Text('Delete Client'),
|
||||
content: const Text('Are you sure you want to delete this client?'),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('Cancel'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
style: TextButton.styleFrom(foregroundColor: Colors.red),
|
||||
child: const Text('Delete'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (confirm == true) {
|
||||
await ref.read(apiClientProvider).deleteClient(clientId);
|
||||
if (context.mounted) {
|
||||
context.go('/');
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: clientAsync.when(
|
||||
data: (client) => _ClientDetailContent(client: client),
|
||||
loading: () => const Center(child: CircularProgressIndicator()),
|
||||
error: (error, stack) => 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 client'),
|
||||
TextButton(
|
||||
onPressed: () => ref.invalidate(clientDetailProvider(clientId)),
|
||||
child: const Text('Retry'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ClientDetailContent extends StatelessWidget {
|
||||
final Map<String, dynamic> client;
|
||||
|
||||
const _ClientDetailContent({required this.client});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final name = '${client['firstName']} ${client['lastName']}';
|
||||
final dateFormat = DateFormat.yMMMd();
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Center(
|
||||
child: Column(
|
||||
children: [
|
||||
CircleAvatar(
|
||||
radius: 48,
|
||||
backgroundColor: Theme.of(context).colorScheme.primaryContainer,
|
||||
child: Text(
|
||||
'${client['firstName'][0]}${client['lastName'][0]}',
|
||||
style: TextStyle(
|
||||
fontSize: 32,
|
||||
color: Theme.of(context).colorScheme.onPrimaryContainer,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
name,
|
||||
style: Theme.of(context).textTheme.headlineSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (client['company'] != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${client['role'] ?? ''} ${client['role'] != null && client['company'] != null ? 'at ' : ''}${client['company'] ?? ''}'.trim(),
|
||||
style: Theme.of(context).textTheme.bodyLarge?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
// Contact info
|
||||
_Section(
|
||||
title: 'Contact',
|
||||
children: [
|
||||
if (client['email'] != null)
|
||||
_InfoRow(icon: Icons.email, label: 'Email', value: client['email']),
|
||||
if (client['phone'] != null)
|
||||
_InfoRow(icon: Icons.phone, label: 'Phone', value: client['phone']),
|
||||
if (client['city'] != null || client['state'] != null)
|
||||
_InfoRow(
|
||||
icon: Icons.location_on,
|
||||
label: 'Location',
|
||||
value: [client['city'], client['state']].where((e) => e != null).join(', '),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Personal info
|
||||
if (client['birthday'] != null || client['anniversary'] != null ||
|
||||
(client['interests'] as List?)?.isNotEmpty == true)
|
||||
_Section(
|
||||
title: 'Personal',
|
||||
children: [
|
||||
if (client['birthday'] != null)
|
||||
_InfoRow(
|
||||
icon: Icons.cake,
|
||||
label: 'Birthday',
|
||||
value: dateFormat.format(DateTime.parse(client['birthday'])),
|
||||
),
|
||||
if (client['anniversary'] != null)
|
||||
_InfoRow(
|
||||
icon: Icons.favorite,
|
||||
label: 'Anniversary',
|
||||
value: dateFormat.format(DateTime.parse(client['anniversary'])),
|
||||
),
|
||||
if ((client['interests'] as List?)?.isNotEmpty == true) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
'Interests',
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: (client['interests'] as List).cast<String>().map((interest) =>
|
||||
Chip(label: Text(interest)),
|
||||
).toList(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
|
||||
// Family
|
||||
if (client['family'] != null &&
|
||||
(client['family']['spouse'] != null ||
|
||||
(client['family']['children'] as List?)?.isNotEmpty == true))
|
||||
_Section(
|
||||
title: 'Family',
|
||||
children: [
|
||||
if (client['family']['spouse'] != null)
|
||||
_InfoRow(
|
||||
icon: Icons.person,
|
||||
label: 'Spouse',
|
||||
value: client['family']['spouse'],
|
||||
),
|
||||
if ((client['family']['children'] as List?)?.isNotEmpty == true)
|
||||
_InfoRow(
|
||||
icon: Icons.child_care,
|
||||
label: 'Children',
|
||||
value: (client['family']['children'] as List).join(', '),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Notes
|
||||
if (client['notes'] != null && client['notes'].toString().isNotEmpty)
|
||||
_Section(
|
||||
title: 'Notes',
|
||||
children: [
|
||||
Text(client['notes']),
|
||||
],
|
||||
),
|
||||
|
||||
// Tags
|
||||
if ((client['tags'] as List?)?.isNotEmpty == true)
|
||||
_Section(
|
||||
title: 'Tags',
|
||||
children: [
|
||||
Wrap(
|
||||
spacing: 8,
|
||||
runSpacing: 8,
|
||||
children: (client['tags'] as List).cast<String>().map((tag) =>
|
||||
Chip(
|
||||
label: Text(tag),
|
||||
backgroundColor: Theme.of(context).colorScheme.secondaryContainer,
|
||||
),
|
||||
).toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
// Last contacted
|
||||
if (client['lastContactedAt'] != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16),
|
||||
child: Text(
|
||||
'Last contacted: ${dateFormat.format(DateTime.parse(client['lastContactedAt']))}',
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Section extends StatelessWidget {
|
||||
final String title;
|
||||
final List<Widget> children;
|
||||
|
||||
const _Section({required this.title, required this.children});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (children.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 24),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
title,
|
||||
style: Theme.of(context).textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
...children,
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _InfoRow extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String value;
|
||||
|
||||
const _InfoRow({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 20, color: Colors.grey),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(value),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user