From c8ac4ec8bc55955138fae7226b1eac725b0fa5f4 Mon Sep 17 00:00:00 2001 From: Hammer Date: Tue, 27 Jan 2026 13:20:01 +0000 Subject: [PATCH] Initial Flutter scaffold: Riverpod + GoRouter + Dio --- .gitignore | 72 ++++ README.md | 107 ++++++ lib/app/app.dart | 22 ++ lib/app/router.dart | 143 ++++++++ lib/config/env.dart | 8 + lib/config/theme.dart | 98 +++++ .../auth/presentation/login_screen.dart | 182 +++++++++ .../auth/presentation/register_screen.dart | 217 +++++++++++ .../presentation/client_detail_screen.dart | 346 ++++++++++++++++++ .../presentation/client_form_screen.dart | 339 +++++++++++++++++ .../clients/presentation/clients_screen.dart | 250 +++++++++++++ .../presentation/email_compose_screen.dart | 219 +++++++++++ .../emails/presentation/emails_screen.dart | 215 +++++++++++ .../events/presentation/events_screen.dart | 152 ++++++++ lib/main.dart | 13 + lib/shared/providers/auth_provider.dart | 115 ++++++ lib/shared/services/api_client.dart | 182 +++++++++ pubspec.yaml | 48 +++ 18 files changed, 2728 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 lib/app/app.dart create mode 100644 lib/app/router.dart create mode 100644 lib/config/env.dart create mode 100644 lib/config/theme.dart create mode 100644 lib/features/auth/presentation/login_screen.dart create mode 100644 lib/features/auth/presentation/register_screen.dart create mode 100644 lib/features/clients/presentation/client_detail_screen.dart create mode 100644 lib/features/clients/presentation/client_form_screen.dart create mode 100644 lib/features/clients/presentation/clients_screen.dart create mode 100644 lib/features/emails/presentation/email_compose_screen.dart create mode 100644 lib/features/emails/presentation/emails_screen.dart create mode 100644 lib/features/events/presentation/events_screen.dart create mode 100644 lib/main.dart create mode 100644 lib/shared/providers/auth_provider.dart create mode 100644 lib/shared/services/api_client.dart create mode 100644 pubspec.yaml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..10e15ff --- /dev/null +++ b/.gitignore @@ -0,0 +1,72 @@ +# Flutter/Dart +.dart_tool/ +.packages +build/ +.flutter-plugins +.flutter-plugins-dependencies +*.iml + +# IDE +.idea/ +*.swp +.vscode/ + +# macOS +.DS_Store +*.pem + +# Android +**/android/**/gradle-wrapper.jar +**/android/.gradle +**/android/captures/ +**/android/gradlew +**/android/gradlew.bat +**/android/local.properties +**/android/**/GeneratedPluginRegistrant.* + +# iOS +**/ios/**/*.mode1v3 +**/ios/**/*.mode2v3 +**/ios/**/*.moved-aside +**/ios/**/*.pbxuser +**/ios/**/*.perspectivev3 +**/ios/**/*sync/ +**/ios/**/.sconsign.dblite +**/ios/**/.tags* +**/ios/**/.vagrant/ +**/ios/**/DerivedData/ +**/ios/**/Icon? +**/ios/**/Pods/ +**/ios/**/.symlinks/ +**/ios/**/profile +**/ios/**/xcuserdata +**/ios/.generated/ +**/ios/Flutter/.last_build_id +**/ios/Flutter/App.framework +**/ios/Flutter/Flutter.framework +**/ios/Flutter/Flutter.podspec +**/ios/Flutter/Generated.xcconfig +**/ios/Flutter/ephemeral +**/ios/Flutter/app.flx +**/ios/Flutter/app.zip +**/ios/Flutter/flutter_assets/ +**/ios/Flutter/flutter_export_environment.sh +**/ios/ServiceDefinitions.json +**/ios/Runner/GeneratedPluginRegistrant.* + +# Coverage +coverage/ + +# Symbols +app.*.symbols + +# Exceptions +!**/ios/**/default.mode1v3 +!**/ios/**/default.mode2v3 +!**/ios/**/default.pbxuser +!**/ios/**/default.perspectivev3 +!/packages/flutter_tools/test/data/dart_dependencies_test/**/.packages + +# Generated +*.g.dart +*.freezed.dart diff --git a/README.md b/README.md new file mode 100644 index 0000000..e0bdd87 --- /dev/null +++ b/README.md @@ -0,0 +1,107 @@ +# Network App Mobile + +Flutter mobile app for The Network App — an AI-powered CRM for wealth management advisors. + +## Stack + +- **Framework:** Flutter 3.x +- **State Management:** Riverpod +- **Navigation:** GoRouter +- **HTTP Client:** Dio + +## Getting Started + +### Prerequisites + +- [Flutter](https://flutter.dev/docs/get-started/install) 3.16+ +- iOS: Xcode 15+ (for iOS builds) +- Android: Android Studio with SDK + +### Setup + +1. Clone the repo: + ```bash + git clone https://git.infra.nkode.tech/hammer/network-app-mobile.git + cd network-app-mobile + ``` + +2. Create platform files (first time only): + ```bash + flutter create . --project-name network_app + ``` + +3. Install dependencies: + ```bash + flutter pub get + ``` + +4. Run code generation: + ```bash + dart run build_runner build + ``` + +5. Run the app: + ```bash + flutter run + ``` + +### Environment + +Configure the API URL at build time: +```bash +flutter run --dart-define=API_BASE_URL=https://your-api.com +``` + +Or edit `lib/config/env.dart` for development. + +## Project Structure + +``` +lib/ +├── main.dart # Entry point +├── app/ +│ ├── app.dart # MaterialApp setup +│ └── router.dart # GoRouter configuration +├── config/ +│ ├── env.dart # Environment config +│ └── theme.dart # App theming +├── features/ +│ ├── auth/ # Login/Register +│ ├── clients/ # Client management +│ ├── emails/ # AI email generation +│ └── events/ # Event tracking +└── shared/ + ├── providers/ # Global Riverpod providers + ├── services/ # API client, storage + └── widgets/ # Reusable components +``` + +## Features + +- [x] Authentication (login/register) +- [x] Client list with search +- [x] Client detail view +- [x] Client create/edit form +- [x] AI email generation +- [x] Email drafts and sent +- [x] Upcoming events view +- [ ] Push notifications +- [ ] Offline support + +## Building + +### Android +```bash +flutter build apk --release +# or for app bundle: +flutter build appbundle --release +``` + +### iOS +```bash +flutter build ios --release +``` + +## API + +This app connects to `network-app-api`. See that repo for backend setup. diff --git a/lib/app/app.dart b/lib/app/app.dart new file mode 100644 index 0000000..60040cd --- /dev/null +++ b/lib/app/app.dart @@ -0,0 +1,22 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'router.dart'; +import '../config/theme.dart'; + +class NetworkApp extends ConsumerWidget { + const NetworkApp({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final router = ref.watch(routerProvider); + + return MaterialApp.router( + title: 'Network App', + theme: AppTheme.light, + darkTheme: AppTheme.dark, + themeMode: ThemeMode.system, + routerConfig: router, + debugShowCheckedModeBanner: false, + ); + } +} diff --git a/lib/app/router.dart b/lib/app/router.dart new file mode 100644 index 0000000..2a2a143 --- /dev/null +++ b/lib/app/router.dart @@ -0,0 +1,143 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; + +import '../features/auth/presentation/login_screen.dart'; +import '../features/auth/presentation/register_screen.dart'; +import '../features/clients/presentation/clients_screen.dart'; +import '../features/clients/presentation/client_detail_screen.dart'; +import '../features/clients/presentation/client_form_screen.dart'; +import '../features/emails/presentation/emails_screen.dart'; +import '../features/emails/presentation/email_compose_screen.dart'; +import '../features/events/presentation/events_screen.dart'; +import '../shared/providers/auth_provider.dart'; + +final routerProvider = Provider((ref) { + final authState = ref.watch(authStateProvider); + + return GoRouter( + initialLocation: '/', + redirect: (context, state) { + final isLoggedIn = authState.valueOrNull?.isAuthenticated ?? false; + final isAuthRoute = state.matchedLocation == '/login' || + state.matchedLocation == '/register'; + + if (!isLoggedIn && !isAuthRoute) { + return '/login'; + } + + if (isLoggedIn && isAuthRoute) { + return '/'; + } + + return null; + }, + routes: [ + // Auth routes + GoRoute( + path: '/login', + builder: (context, state) => const LoginScreen(), + ), + GoRoute( + path: '/register', + builder: (context, state) => const RegisterScreen(), + ), + + // Main app routes + ShellRoute( + builder: (context, state, child) => MainShell(child: child), + routes: [ + GoRoute( + path: '/', + builder: (context, state) => const ClientsScreen(), + ), + GoRoute( + path: '/clients/new', + builder: (context, state) => const ClientFormScreen(), + ), + GoRoute( + path: '/clients/:id', + builder: (context, state) => ClientDetailScreen( + clientId: state.pathParameters['id']!, + ), + ), + GoRoute( + path: '/clients/:id/edit', + builder: (context, state) => ClientFormScreen( + clientId: state.pathParameters['id'], + ), + ), + GoRoute( + path: '/emails', + builder: (context, state) => const EmailsScreen(), + ), + GoRoute( + path: '/emails/compose', + builder: (context, state) => EmailComposeScreen( + clientId: state.uri.queryParameters['clientId'], + ), + ), + GoRoute( + path: '/events', + builder: (context, state) => const EventsScreen(), + ), + ], + ), + ], + ); +}); + +class MainShell extends StatelessWidget { + final Widget child; + + const MainShell({super.key, required this.child}); + + @override + Widget build(BuildContext context) { + return Scaffold( + body: child, + bottomNavigationBar: NavigationBar( + selectedIndex: _getSelectedIndex(context), + onDestinationSelected: (index) => _onDestinationSelected(context, index), + destinations: const [ + NavigationDestination( + icon: Icon(Icons.people_outline), + selectedIcon: Icon(Icons.people), + label: 'Clients', + ), + NavigationDestination( + icon: Icon(Icons.email_outline), + selectedIcon: Icon(Icons.email), + label: 'Emails', + ), + NavigationDestination( + icon: Icon(Icons.event_outline), + selectedIcon: Icon(Icons.event), + label: 'Events', + ), + ], + ), + ); + } + + int _getSelectedIndex(BuildContext context) { + final location = GoRouterState.of(context).matchedLocation; + if (location.startsWith('/emails')) return 1; + if (location.startsWith('/events')) return 2; + return 0; + } + + void _onDestinationSelected(BuildContext context, int index) { + switch (index) { + case 0: + context.go('/'); + break; + case 1: + context.go('/emails'); + break; + case 2: + context.go('/events'); + break; + } + } +} diff --git a/lib/config/env.dart b/lib/config/env.dart new file mode 100644 index 0000000..81215d4 --- /dev/null +++ b/lib/config/env.dart @@ -0,0 +1,8 @@ +class Env { + static const String apiBaseUrl = String.fromEnvironment( + 'API_BASE_URL', + defaultValue: 'http://localhost:3000', + ); + + // Add more environment variables as needed +} diff --git a/lib/config/theme.dart b/lib/config/theme.dart new file mode 100644 index 0000000..18af914 --- /dev/null +++ b/lib/config/theme.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; + +class AppTheme { + static const _primaryColor = Color(0xFF2563EB); + + static ThemeData get light => ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: _primaryColor, + brightness: Brightness.light, + ), + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + cardTheme: CardTheme( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.grey.shade200), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade200), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _primaryColor, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + textButtonTheme: TextButtonThemeData( + style: TextButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + ), + ), + ); + + static ThemeData get dark => ThemeData( + useMaterial3: true, + colorScheme: ColorScheme.fromSeed( + seedColor: _primaryColor, + brightness: Brightness.dark, + ), + appBarTheme: const AppBarTheme( + centerTitle: true, + elevation: 0, + ), + cardTheme: CardTheme( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.grey.shade800), + ), + ), + inputDecorationTheme: InputDecorationTheme( + filled: true, + fillColor: Colors.grey.shade900, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + enabledBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide(color: Colors.grey.shade800), + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: const BorderSide(color: _primaryColor, width: 2), + ), + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16), + ), + elevatedButtonTheme: ElevatedButtonThemeData( + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + ), + ), + ); +} diff --git a/lib/features/auth/presentation/login_screen.dart b/lib/features/auth/presentation/login_screen.dart new file mode 100644 index 0000000..8380845 --- /dev/null +++ b/lib/features/auth/presentation/login_screen.dart @@ -0,0 +1,182 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../shared/providers/auth_provider.dart'; + +class LoginScreen extends ConsumerStatefulWidget { + const LoginScreen({super.key}); + + @override + ConsumerState createState() => _LoginScreenState(); +} + +class _LoginScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isLoading = false; + String? _error; + + @override + void dispose() { + _emailController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _handleLogin() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + await ref.read(authStateProvider.notifier).signIn( + email: _emailController.text.trim(), + password: _passwordController.text, + ); + if (mounted) { + context.go('/'); + } + } catch (e) { + setState(() { + _error = 'Invalid email or password'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo/Title + Icon( + Icons.hub, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Network App', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 8), + Text( + 'Sign in to your account', + style: Theme.of(context).textTheme.bodyLarge?.copyWith( + color: Colors.grey, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + + // Error message + if (_error != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _error!, + style: TextStyle(color: Colors.red.shade700), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + ], + + // Email field + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Password field + TextFormField( + controller: _passwordController, + obscureText: true, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _handleLogin(), + decoration: const InputDecoration( + labelText: 'Password', + prefixIcon: Icon(Icons.lock_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your password'; + } + return null; + }, + ), + const SizedBox(height: 24), + + // Login button + FilledButton( + onPressed: _isLoading ? null : _handleLogin, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Sign In'), + ), + const SizedBox(height: 16), + + // Register link + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Don't have an account?"), + TextButton( + onPressed: () => context.go('/register'), + child: const Text('Sign Up'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/auth/presentation/register_screen.dart b/lib/features/auth/presentation/register_screen.dart new file mode 100644 index 0000000..2d8c6c7 --- /dev/null +++ b/lib/features/auth/presentation/register_screen.dart @@ -0,0 +1,217 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:go_router/go_router.dart'; +import '../../../shared/providers/auth_provider.dart'; + +class RegisterScreen extends ConsumerStatefulWidget { + const RegisterScreen({super.key}); + + @override + ConsumerState createState() => _RegisterScreenState(); +} + +class _RegisterScreenState extends ConsumerState { + final _formKey = GlobalKey(); + final _nameController = TextEditingController(); + final _emailController = TextEditingController(); + final _passwordController = TextEditingController(); + final _confirmPasswordController = TextEditingController(); + bool _isLoading = false; + String? _error; + + @override + void dispose() { + _nameController.dispose(); + _emailController.dispose(); + _passwordController.dispose(); + _confirmPasswordController.dispose(); + super.dispose(); + } + + Future _handleRegister() async { + if (!_formKey.currentState!.validate()) return; + + setState(() { + _isLoading = true; + _error = null; + }); + + try { + await ref.read(authStateProvider.notifier).signUp( + name: _nameController.text.trim(), + email: _emailController.text.trim(), + password: _passwordController.text, + ); + if (mounted) { + context.go('/'); + } + } catch (e) { + setState(() { + _error = 'Registration failed. Please try again.'; + }); + } finally { + if (mounted) { + setState(() { + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: SafeArea( + child: Center( + child: SingleChildScrollView( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Logo/Title + Icon( + Icons.hub, + size: 64, + color: Theme.of(context).colorScheme.primary, + ), + const SizedBox(height: 16), + Text( + 'Create Account', + style: Theme.of(context).textTheme.headlineMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 48), + + // Error message + if (_error != null) ...[ + Container( + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: Colors.red.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Text( + _error!, + style: TextStyle(color: Colors.red.shade700), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 16), + ], + + // Name field + TextFormField( + controller: _nameController, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Full Name', + prefixIcon: Icon(Icons.person_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your name'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Email field + TextFormField( + controller: _emailController, + keyboardType: TextInputType.emailAddress, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Email', + prefixIcon: Icon(Icons.email_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter your email'; + } + if (!value.contains('@')) { + return 'Please enter a valid email'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Password field + TextFormField( + controller: _passwordController, + obscureText: true, + textInputAction: TextInputAction.next, + decoration: const InputDecoration( + labelText: 'Password', + prefixIcon: Icon(Icons.lock_outlined), + ), + validator: (value) { + if (value == null || value.isEmpty) { + return 'Please enter a password'; + } + if (value.length < 8) { + return 'Password must be at least 8 characters'; + } + return null; + }, + ), + const SizedBox(height: 16), + + // Confirm password field + TextFormField( + controller: _confirmPasswordController, + obscureText: true, + textInputAction: TextInputAction.done, + onFieldSubmitted: (_) => _handleRegister(), + decoration: const InputDecoration( + labelText: 'Confirm Password', + prefixIcon: Icon(Icons.lock_outlined), + ), + validator: (value) { + if (value != _passwordController.text) { + return 'Passwords do not match'; + } + return null; + }, + ), + const SizedBox(height: 24), + + // Register button + FilledButton( + onPressed: _isLoading ? null : _handleRegister, + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Create Account'), + ), + const SizedBox(height: 16), + + // Login link + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text('Already have an account?'), + TextButton( + onPressed: () => context.go('/login'), + child: const Text('Sign In'), + ), + ], + ), + ], + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/features/clients/presentation/client_detail_screen.dart b/lib/features/clients/presentation/client_detail_screen.dart new file mode 100644 index 0000000..c146d8b --- /dev/null +++ b/lib/features/clients/presentation/client_detail_screen.dart @@ -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, 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( + 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 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().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().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 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), + ), + ], + ), + ); + } +} diff --git a/lib/features/clients/presentation/client_form_screen.dart b/lib/features/clients/presentation/client_form_screen.dart new file mode 100644 index 0000000..124b7b4 --- /dev/null +++ b/lib/features/clients/presentation/client_form_screen.dart @@ -0,0 +1,339 @@ +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_screen.dart'; +import 'client_detail_screen.dart'; + +class ClientFormScreen extends ConsumerStatefulWidget { + final String? clientId; + + const ClientFormScreen({super.key, this.clientId}); + + @override + ConsumerState createState() => _ClientFormScreenState(); +} + +class _ClientFormScreenState extends ConsumerState { + final _formKey = GlobalKey(); + bool _isLoading = false; + bool _isInitialized = false; + + // Form controllers + final _firstNameController = TextEditingController(); + final _lastNameController = TextEditingController(); + final _emailController = TextEditingController(); + final _phoneController = TextEditingController(); + final _companyController = TextEditingController(); + final _roleController = TextEditingController(); + final _industryController = TextEditingController(); + final _notesController = TextEditingController(); + final _interestsController = TextEditingController(); + final _tagsController = TextEditingController(); + + DateTime? _birthday; + DateTime? _anniversary; + + bool get isEditing => widget.clientId != null; + + @override + void dispose() { + _firstNameController.dispose(); + _lastNameController.dispose(); + _emailController.dispose(); + _phoneController.dispose(); + _companyController.dispose(); + _roleController.dispose(); + _industryController.dispose(); + _notesController.dispose(); + _interestsController.dispose(); + _tagsController.dispose(); + super.dispose(); + } + + void _initializeFromClient(Map client) { + if (_isInitialized) return; + _isInitialized = true; + + _firstNameController.text = client['firstName'] ?? ''; + _lastNameController.text = client['lastName'] ?? ''; + _emailController.text = client['email'] ?? ''; + _phoneController.text = client['phone'] ?? ''; + _companyController.text = client['company'] ?? ''; + _roleController.text = client['role'] ?? ''; + _industryController.text = client['industry'] ?? ''; + _notesController.text = client['notes'] ?? ''; + _interestsController.text = (client['interests'] as List?)?.join(', ') ?? ''; + _tagsController.text = (client['tags'] as List?)?.join(', ') ?? ''; + + if (client['birthday'] != null) { + _birthday = DateTime.tryParse(client['birthday']); + } + if (client['anniversary'] != null) { + _anniversary = DateTime.tryParse(client['anniversary']); + } + } + + Future _handleSubmit() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + final data = { + 'firstName': _firstNameController.text.trim(), + 'lastName': _lastNameController.text.trim(), + if (_emailController.text.isNotEmpty) 'email': _emailController.text.trim(), + if (_phoneController.text.isNotEmpty) 'phone': _phoneController.text.trim(), + if (_companyController.text.isNotEmpty) 'company': _companyController.text.trim(), + if (_roleController.text.isNotEmpty) 'role': _roleController.text.trim(), + if (_industryController.text.isNotEmpty) 'industry': _industryController.text.trim(), + if (_notesController.text.isNotEmpty) 'notes': _notesController.text.trim(), + if (_interestsController.text.isNotEmpty) + 'interests': _interestsController.text.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty).toList(), + if (_tagsController.text.isNotEmpty) + 'tags': _tagsController.text.split(',').map((e) => e.trim()).where((e) => e.isNotEmpty).toList(), + if (_birthday != null) 'birthday': _birthday!.toIso8601String(), + if (_anniversary != null) 'anniversary': _anniversary!.toIso8601String(), + }; + + final apiClient = ref.read(apiClientProvider); + + if (isEditing) { + await apiClient.updateClient(widget.clientId!, data); + ref.invalidate(clientDetailProvider(widget.clientId!)); + } else { + final client = await apiClient.createClient(data); + widget.clientId ?? client['id']; + } + + ref.invalidate(clientsProvider(null)); + + if (mounted) { + context.go(isEditing ? '/clients/${widget.clientId}' : '/'); + } + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('Failed to save: $e')), + ); + } + } finally { + if (mounted) { + setState(() => _isLoading = false); + } + } + } + + Future _selectDate(BuildContext context, bool isBirthday) async { + final initialDate = isBirthday ? _birthday : _anniversary; + final picked = await showDatePicker( + context: context, + initialDate: initialDate ?? DateTime.now(), + firstDate: DateTime(1900), + lastDate: DateTime.now().add(const Duration(days: 365)), + ); + + if (picked != null) { + setState(() { + if (isBirthday) { + _birthday = picked; + } else { + _anniversary = picked; + } + }); + } + } + + @override + Widget build(BuildContext context) { + // Load existing client data if editing + if (isEditing) { + final clientAsync = ref.watch(clientDetailProvider(widget.clientId!)); + return clientAsync.when( + data: (client) { + _initializeFromClient(client); + return _buildForm(context); + }, + loading: () => Scaffold( + appBar: AppBar(title: const Text('Edit Client')), + body: const Center(child: CircularProgressIndicator()), + ), + error: (e, s) => Scaffold( + appBar: AppBar(title: const Text('Edit Client')), + body: Center(child: Text('Error: $e')), + ), + ); + } + + return _buildForm(context); + } + + Widget _buildForm(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: Text(isEditing ? 'Edit Client' : 'New Client'), + actions: [ + TextButton( + onPressed: _isLoading ? null : _handleSubmit, + child: _isLoading + ? const SizedBox( + width: 20, + height: 20, + child: CircularProgressIndicator(strokeWidth: 2), + ) + : const Text('Save'), + ), + ], + ), + body: Form( + key: _formKey, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + // Basic info + Text( + 'Basic Information', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: TextFormField( + controller: _firstNameController, + decoration: const InputDecoration(labelText: 'First Name *'), + validator: (v) => v?.isEmpty == true ? 'Required' : null, + ), + ), + const SizedBox(width: 16), + Expanded( + child: TextFormField( + controller: _lastNameController, + decoration: const InputDecoration(labelText: 'Last Name *'), + validator: (v) => v?.isEmpty == true ? 'Required' : null, + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _emailController, + decoration: const InputDecoration(labelText: 'Email'), + keyboardType: TextInputType.emailAddress, + ), + const SizedBox(height: 16), + TextFormField( + controller: _phoneController, + decoration: const InputDecoration(labelText: 'Phone'), + keyboardType: TextInputType.phone, + ), + + const SizedBox(height: 32), + Text( + 'Professional', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _companyController, + decoration: const InputDecoration(labelText: 'Company'), + ), + const SizedBox(height: 16), + TextFormField( + controller: _roleController, + decoration: const InputDecoration(labelText: 'Role'), + ), + const SizedBox(height: 16), + TextFormField( + controller: _industryController, + decoration: const InputDecoration(labelText: 'Industry'), + ), + + const SizedBox(height: 32), + Text( + 'Personal', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + Row( + children: [ + Expanded( + child: InkWell( + onTap: () => _selectDate(context, true), + child: InputDecorator( + decoration: const InputDecoration(labelText: 'Birthday'), + child: Text( + _birthday != null + ? '${_birthday!.month}/${_birthday!.day}/${_birthday!.year}' + : 'Select date', + style: TextStyle( + color: _birthday != null ? null : Colors.grey, + ), + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: InkWell( + onTap: () => _selectDate(context, false), + child: InputDecorator( + decoration: const InputDecoration(labelText: 'Anniversary'), + child: Text( + _anniversary != null + ? '${_anniversary!.month}/${_anniversary!.day}/${_anniversary!.year}' + : 'Select date', + style: TextStyle( + color: _anniversary != null ? null : Colors.grey, + ), + ), + ), + ), + ), + ], + ), + const SizedBox(height: 16), + TextFormField( + controller: _interestsController, + decoration: const InputDecoration( + labelText: 'Interests', + hintText: 'Golf, Wine, Travel (comma separated)', + ), + ), + + const SizedBox(height: 32), + Text( + 'Notes & Tags', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 16), + TextFormField( + controller: _notesController, + decoration: const InputDecoration(labelText: 'Notes'), + maxLines: 4, + ), + const SizedBox(height: 16), + TextFormField( + controller: _tagsController, + decoration: const InputDecoration( + labelText: 'Tags', + hintText: 'VIP, Referral Source (comma separated)', + ), + ), + + const SizedBox(height: 32), + ], + ), + ), + ); + } +} diff --git a/lib/features/clients/presentation/clients_screen.dart b/lib/features/clients/presentation/clients_screen.dart new file mode 100644 index 0000000..984897a --- /dev/null +++ b/lib/features/clients/presentation/clients_screen.dart @@ -0,0 +1,250 @@ +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 '../../../shared/providers/auth_provider.dart'; + +final clientsProvider = FutureProvider.autoDispose + .family>, String?>((ref, search) async { + final apiClient = ref.watch(apiClientProvider); + return apiClient.getClients(search: search); +}); + +class ClientsScreen extends ConsumerStatefulWidget { + const ClientsScreen({super.key}); + + @override + ConsumerState createState() => _ClientsScreenState(); +} + +class _ClientsScreenState extends ConsumerState { + final _searchController = TextEditingController(); + String? _searchQuery; + + @override + void dispose() { + _searchController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final clientsAsync = ref.watch(clientsProvider(_searchQuery)); + + return Scaffold( + appBar: AppBar( + title: const Text('Clients'), + actions: [ + IconButton( + icon: const Icon(Icons.logout), + onPressed: () async { + await ref.read(authStateProvider.notifier).signOut(); + }, + ), + ], + ), + body: Column( + children: [ + // Search bar + Padding( + padding: const EdgeInsets.all(16), + child: TextField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search clients...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchQuery != null + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + setState(() { + _searchQuery = null; + }); + }, + ) + : null, + ), + onSubmitted: (value) { + setState(() { + _searchQuery = value.isEmpty ? null : value; + }); + }, + ), + ), + + // Client list + Expanded( + child: clientsAsync.when( + data: (clients) { + if (clients.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.people_outline, + size: 64, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + _searchQuery != null + ? 'No clients found' + : 'No clients yet', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + if (_searchQuery == null) + FilledButton.icon( + onPressed: () => context.go('/clients/new'), + icon: const Icon(Icons.add), + label: const Text('Add Client'), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(clientsProvider(_searchQuery)); + }, + child: ListView.builder( + padding: const EdgeInsets.symmetric(horizontal: 16), + itemCount: clients.length, + itemBuilder: (context, index) { + final client = clients[index]; + return _ClientCard(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), + Text( + 'Failed to load clients', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 8), + TextButton( + onPressed: () { + ref.invalidate(clientsProvider(_searchQuery)); + }, + child: const Text('Retry'), + ), + ], + ), + ), + ), + ), + ], + ), + floatingActionButton: FloatingActionButton( + onPressed: () => context.go('/clients/new'), + child: const Icon(Icons.add), + ), + ); + } +} + +class _ClientCard extends StatelessWidget { + final Map client; + + const _ClientCard({required this.client}); + + @override + Widget build(BuildContext context) { + final name = '${client['firstName']} ${client['lastName']}'; + final company = client['company'] as String?; + final tags = (client['tags'] as List?)?.cast() ?? []; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: InkWell( + onTap: () => context.go('/clients/${client['id']}'), + borderRadius: BorderRadius.circular(12), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + CircleAvatar( + backgroundColor: Theme.of(context).colorScheme.primaryContainer, + child: Text( + '${client['firstName'][0]}${client['lastName'][0]}', + style: TextStyle( + color: Theme.of(context).colorScheme.onPrimaryContainer, + fontWeight: FontWeight.bold, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: Theme.of(context).textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + if (company != null && company.isNotEmpty) ...[ + const SizedBox(height: 2), + Text( + company, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), + ), + ], + if (tags.isNotEmpty) ...[ + const SizedBox(height: 8), + Wrap( + spacing: 4, + runSpacing: 4, + children: tags.take(3).map((tag) => Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 2, + ), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.secondaryContainer, + borderRadius: BorderRadius.circular(12), + ), + child: Text( + tag, + style: Theme.of(context).textTheme.labelSmall?.copyWith( + color: Theme.of(context).colorScheme.onSecondaryContainer, + ), + ), + )).toList(), + ), + ], + ], + ), + ), + Icon( + Icons.chevron_right, + color: Colors.grey.shade400, + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/features/emails/presentation/email_compose_screen.dart b/lib/features/emails/presentation/email_compose_screen.dart new file mode 100644 index 0000000..1fd94ee --- /dev/null +++ b/lib/features/emails/presentation/email_compose_screen.dart @@ -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 createState() => _EmailComposeScreenState(); +} + +class _EmailComposeScreenState extends ConsumerState { + 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 _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( + 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'), + ), + ), + ], + ), + ], + ], + ), + ); + } +} diff --git a/lib/features/emails/presentation/emails_screen.dart b/lib/features/emails/presentation/emails_screen.dart new file mode 100644 index 0000000..9137599 --- /dev/null +++ b/lib/features/emails/presentation/emails_screen.dart @@ -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>>((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> 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( + 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, + ), + ); + }, + ); + } +} diff --git a/lib/features/events/presentation/events_screen.dart b/lib/features/events/presentation/events_screen.dart new file mode 100644 index 0000000..80668e4 --- /dev/null +++ b/lib/features/events/presentation/events_screen.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:intl/intl.dart'; +import '../../../shared/services/api_client.dart'; + +final eventsProvider = FutureProvider.autoDispose + .family>, int?>((ref, upcomingDays) async { + final apiClient = ref.watch(apiClientProvider); + return apiClient.getEvents(upcomingDays: upcomingDays ?? 30); +}); + +class EventsScreen extends ConsumerWidget { + const EventsScreen({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final eventsAsync = ref.watch(eventsProvider(30)); + + return Scaffold( + appBar: AppBar( + title: const Text('Upcoming Events'), + ), + body: eventsAsync.when( + data: (events) { + if (events.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.event_outlined, + size: 64, + color: Colors.grey.shade400, + ), + const SizedBox(height: 16), + Text( + 'No upcoming events', + style: Theme.of(context).textTheme.titleMedium?.copyWith( + color: Colors.grey, + ), + ), + const SizedBox(height: 8), + Text( + 'Add birthdays and anniversaries to clients', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + color: Colors.grey, + ), + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () async { + ref.invalidate(eventsProvider(30)); + }, + child: ListView.builder( + padding: const EdgeInsets.all(16), + itemCount: events.length, + itemBuilder: (context, index) { + final event = events[index]; + return _EventCard(event: event); + }, + ), + ); + }, + 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 events'), + TextButton( + onPressed: () => ref.invalidate(eventsProvider(30)), + child: const Text('Retry'), + ), + ], + ), + ), + ), + ); + } +} + +class _EventCard extends StatelessWidget { + final Map event; + + const _EventCard({required this.event}); + + @override + Widget build(BuildContext context) { + final eventData = event['event'] as Map; + final client = event['client'] as Map?; + final dateFormat = DateFormat.MMMd(); + final date = DateTime.parse(eventData['date']); + final daysUntil = date.difference(DateTime.now()).inDays; + + IconData icon; + Color iconColor; + switch (eventData['type']) { + case 'birthday': + icon = Icons.cake; + iconColor = Colors.pink; + break; + case 'anniversary': + icon = Icons.favorite; + iconColor = Colors.red; + break; + default: + icon = Icons.event; + iconColor = Colors.blue; + } + + return Card( + margin: const EdgeInsets.only(bottom: 12), + child: ListTile( + leading: CircleAvatar( + backgroundColor: iconColor.withOpacity(0.1), + child: Icon(icon, color: iconColor), + ), + title: Text(eventData['title']), + subtitle: client != null + ? Text('${client['firstName']} ${client['lastName']}') + : null, + trailing: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + dateFormat.format(date), + style: Theme.of(context).textTheme.titleSmall, + ), + Text( + daysUntil == 0 + ? 'Today!' + : daysUntil == 1 + ? 'Tomorrow' + : 'In $daysUntil days', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: daysUntil <= 3 ? Colors.orange : Colors.grey, + fontWeight: daysUntil <= 3 ? FontWeight.bold : null, + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart new file mode 100644 index 0000000..43ae7d8 --- /dev/null +++ b/lib/main.dart @@ -0,0 +1,13 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'app/app.dart'; + +void main() { + WidgetsFlutterBinding.ensureInitialized(); + + runApp( + const ProviderScope( + child: NetworkApp(), + ), + ); +} diff --git a/lib/shared/providers/auth_provider.dart b/lib/shared/providers/auth_provider.dart new file mode 100644 index 0000000..3961308 --- /dev/null +++ b/lib/shared/providers/auth_provider.dart @@ -0,0 +1,115 @@ +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import '../services/api_client.dart'; + +class AuthState { + final bool isAuthenticated; + final Map? user; + final bool isLoading; + final String? error; + + const AuthState({ + this.isAuthenticated = false, + this.user, + this.isLoading = false, + this.error, + }); + + AuthState copyWith({ + bool? isAuthenticated, + Map? user, + bool? isLoading, + String? error, + }) { + return AuthState( + isAuthenticated: isAuthenticated ?? this.isAuthenticated, + user: user ?? this.user, + isLoading: isLoading ?? this.isLoading, + error: error, + ); + } +} + +class AuthNotifier extends StateNotifier> { + final ApiClient _apiClient; + + AuthNotifier(this._apiClient) : super(const AsyncValue.loading()) { + _checkSession(); + } + + Future _checkSession() async { + try { + final session = await _apiClient.getSession(); + if (session != null && session['user'] != null) { + state = AsyncValue.data(AuthState( + isAuthenticated: true, + user: session['user'], + )); + } else { + state = const AsyncValue.data(AuthState(isAuthenticated: false)); + } + } catch (e) { + state = const AsyncValue.data(AuthState(isAuthenticated: false)); + } + } + + Future signUp({ + required String email, + required String password, + required String name, + }) async { + state = const AsyncValue.loading(); + try { + final result = await _apiClient.signUp( + email: email, + password: password, + name: name, + ); + state = AsyncValue.data(AuthState( + isAuthenticated: true, + user: result['user'], + )); + } catch (e) { + state = AsyncValue.data(AuthState( + isAuthenticated: false, + error: e.toString(), + )); + rethrow; + } + } + + Future signIn({ + required String email, + required String password, + }) async { + state = const AsyncValue.loading(); + try { + final result = await _apiClient.signIn( + email: email, + password: password, + ); + state = AsyncValue.data(AuthState( + isAuthenticated: true, + user: result['user'], + )); + } catch (e) { + state = AsyncValue.data(AuthState( + isAuthenticated: false, + error: e.toString(), + )); + rethrow; + } + } + + Future signOut() async { + try { + await _apiClient.signOut(); + } finally { + state = const AsyncValue.data(AuthState(isAuthenticated: false)); + } + } +} + +final authStateProvider = StateNotifierProvider>((ref) { + final apiClient = ref.watch(apiClientProvider); + return AuthNotifier(apiClient); +}); diff --git a/lib/shared/services/api_client.dart b/lib/shared/services/api_client.dart new file mode 100644 index 0000000..27754f9 --- /dev/null +++ b/lib/shared/services/api_client.dart @@ -0,0 +1,182 @@ +import 'package:dio/dio.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import '../../config/env.dart'; + +final apiClientProvider = Provider((ref) { + return ApiClient(); +}); + +class ApiClient { + late final Dio _dio; + final _storage = const FlutterSecureStorage(); + + ApiClient() { + _dio = Dio(BaseOptions( + baseUrl: Env.apiBaseUrl, + connectTimeout: const Duration(seconds: 10), + receiveTimeout: const Duration(seconds: 30), + headers: { + 'Content-Type': 'application/json', + }, + )); + + _dio.interceptors.add(InterceptorsWrapper( + onRequest: (options, handler) async { + // Add auth token if available + final token = await _storage.read(key: 'session_token'); + if (token != null) { + options.headers['Authorization'] = 'Bearer $token'; + } + handler.next(options); + }, + onError: (error, handler) { + if (error.response?.statusCode == 401) { + // Handle unauthorized - clear token and redirect to login + _storage.delete(key: 'session_token'); + } + handler.next(error); + }, + )); + } + + // Auth + Future> signUp({ + required String email, + required String password, + required String name, + }) async { + final response = await _dio.post('/api/auth/sign-up/email', data: { + 'email': email, + 'password': password, + 'name': name, + }); + return response.data; + } + + Future> signIn({ + required String email, + required String password, + }) async { + final response = await _dio.post('/api/auth/sign-in/email', data: { + 'email': email, + 'password': password, + }); + + // Store session token + if (response.data['token'] != null) { + await _storage.write(key: 'session_token', value: response.data['token']); + } + + return response.data; + } + + Future signOut() async { + await _dio.post('/api/auth/sign-out'); + await _storage.delete(key: 'session_token'); + } + + Future?> getSession() async { + try { + final response = await _dio.get('/api/auth/session'); + return response.data; + } catch (_) { + return null; + } + } + + // Clients + Future>> getClients({String? search, String? tag}) async { + final response = await _dio.get('/api/clients', queryParameters: { + if (search != null) 'search': search, + if (tag != null) 'tag': tag, + }); + return List>.from(response.data); + } + + Future> getClient(String id) async { + final response = await _dio.get('/api/clients/$id'); + return response.data; + } + + Future> createClient(Map data) async { + final response = await _dio.post('/api/clients', data: data); + return response.data; + } + + Future> updateClient(String id, Map data) async { + final response = await _dio.put('/api/clients/$id', data: data); + return response.data; + } + + Future deleteClient(String id) async { + await _dio.delete('/api/clients/$id'); + } + + Future markClientContacted(String id) async { + await _dio.post('/api/clients/$id/contacted'); + } + + // Emails + Future> generateEmail({ + required String clientId, + required String purpose, + String? provider, + }) async { + final response = await _dio.post('/api/emails/generate', data: { + 'clientId': clientId, + 'purpose': purpose, + if (provider != null) 'provider': provider, + }); + return response.data; + } + + Future>> getEmails({String? status, String? clientId}) async { + final response = await _dio.get('/api/emails', queryParameters: { + if (status != null) 'status': status, + if (clientId != null) 'clientId': clientId, + }); + return List>.from(response.data); + } + + Future> updateEmail(String id, Map data) async { + final response = await _dio.put('/api/emails/$id', data: data); + return response.data; + } + + Future> sendEmail(String id) async { + final response = await _dio.post('/api/emails/$id/send'); + return response.data; + } + + Future deleteEmail(String id) async { + await _dio.delete('/api/emails/$id'); + } + + // Events + Future>> getEvents({ + String? clientId, + String? type, + int? upcomingDays, + }) async { + final response = await _dio.get('/api/events', queryParameters: { + if (clientId != null) 'clientId': clientId, + if (type != null) 'type': type, + if (upcomingDays != null) 'upcoming': upcomingDays.toString(), + }); + return List>.from(response.data); + } + + Future> createEvent(Map data) async { + final response = await _dio.post('/api/events', data: data); + return response.data; + } + + Future deleteEvent(String id) async { + await _dio.delete('/api/events/$id'); + } + + Future syncClientEvents(String clientId) async { + await _dio.post('/api/events/sync/$clientId'); + } +} diff --git a/pubspec.yaml b/pubspec.yaml new file mode 100644 index 0000000..1c598c1 --- /dev/null +++ b/pubspec.yaml @@ -0,0 +1,48 @@ +name: network_app +description: AI-powered CRM for wealth management advisors +publish_to: 'none' +version: 0.1.0+1 + +environment: + sdk: '>=3.2.0 <4.0.0' + +dependencies: + flutter: + sdk: flutter + + # State Management + flutter_riverpod: ^2.4.9 + riverpod_annotation: ^2.3.3 + + # Navigation + go_router: ^13.0.0 + + # Networking + dio: ^5.4.0 + + # Local Storage + shared_preferences: ^2.2.2 + flutter_secure_storage: ^9.0.0 + + # UI + cupertino_icons: ^1.0.6 + flutter_svg: ^2.0.9 + cached_network_image: ^3.3.1 + + # Utils + intl: ^0.18.1 + equatable: ^2.0.5 + freezed_annotation: ^2.4.1 + json_annotation: ^4.8.1 + +dev_dependencies: + flutter_test: + sdk: flutter + flutter_lints: ^3.0.1 + riverpod_generator: ^2.3.9 + build_runner: ^2.4.8 + freezed: ^2.4.6 + json_serializable: ^6.7.1 + +flutter: + uses-material-design: true