diff --git a/lib/main.dart b/lib/main.dart index 5032afb..302fe87 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,6 +8,7 @@ import 'package:qpay/screens/transaction_controller.dart'; import 'package:qpay/screens/onboarding/onboarding_controller.dart'; import 'package:qpay/providers/splash_provider.dart'; import 'package:qpay/screens/workspaces/workspace_provider.dart'; +import 'package:qpay/providers/uptime_provider.dart'; import 'package:qpay/theme/app_theme.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:firebase_core/firebase_core.dart'; @@ -57,6 +58,9 @@ void main() async { ChangeNotifierProvider( create: (_) => WorkspaceProvider(), ), + ChangeNotifierProvider( + create: (_) => UptimeProvider(), + ), ], child: const MyApp(), ), diff --git a/lib/models/uptime_item_model.dart b/lib/models/uptime_item_model.dart new file mode 100644 index 0000000..b229bab --- /dev/null +++ b/lib/models/uptime_item_model.dart @@ -0,0 +1,35 @@ +class UptimeItemModel { + final String id; + final String createdAt; + final bool deleted; + final String name; + final bool status; + + const UptimeItemModel({ + required this.id, + required this.createdAt, + required this.deleted, + required this.name, + required this.status, + }); + + factory UptimeItemModel.fromJson(Map json) { + return UptimeItemModel( + id: json['id'] as String, + createdAt: json['createdAt'] as String, + deleted: json['deleted'] as bool, + name: json['name'] as String, + status: json['status'] as bool, + ); + } + + Map toJson() { + return { + 'id': id, + 'createdAt': createdAt, + 'deleted': deleted, + 'name': name, + 'status': status, + }; + } +} \ No newline at end of file diff --git a/lib/navbar.dart b/lib/navbar.dart index fe6feb4..8a7a263 100644 --- a/lib/navbar.dart +++ b/lib/navbar.dart @@ -157,6 +157,11 @@ class _ScaffoldWithNavBarState extends State { "selectedIcon": Icon(Icons.history), "label": 'History', }, + { + "icon": Icon(Icons.more_horiz), + "selectedIcon": Icon(Icons.more_horiz), + "label": 'More', + }, ]; } @@ -171,6 +176,9 @@ class _ScaffoldWithNavBarState extends State { case 2: context.go('/history'); break; + case 3: + context.go('/more'); + break; } } @@ -178,6 +186,7 @@ class _ScaffoldWithNavBarState extends State { final String location = GoRouterState.of(context).uri.path; if (location.startsWith('/groups')) return 1; if (location.startsWith('/history')) return 2; + if (location.startsWith('/more') || location.startsWith('/users') || location.startsWith('/uptime')) return 3; return 0; } diff --git a/lib/providers/uptime_provider.dart b/lib/providers/uptime_provider.dart new file mode 100644 index 0000000..b994c70 --- /dev/null +++ b/lib/providers/uptime_provider.dart @@ -0,0 +1,54 @@ +import 'package:flutter/foundation.dart'; +import 'package:qpay/http/http.dart'; +import 'package:qpay/models/uptime_item_model.dart'; + +enum UptimeStatus { idle, loading, success, error } + +class UptimeProvider extends ChangeNotifier { + final Http _http = Http(); + + UptimeStatus _status = UptimeStatus.idle; + List _items = []; + String? _error; + + UptimeStatus get status => _status; + List get items => _items; + String? get error => _error; + bool get isLoading => _status == UptimeStatus.loading; + bool get hasError => _status == UptimeStatus.error; + + /// Count of services that are currently up (status == true). + int get upCount => _items.where((i) => i.status).length; + + /// Count of services that are currently down (status == false). + int get downCount => _items.where((i) => !i.status).length; + + Future fetchUptimes() async { + _status = UptimeStatus.loading; + _error = null; + notifyListeners(); + + try { + final response = await _http.getRaw( + '/public/uptimes?sort=createdAt%2Cdesc', + ); + + if (response.statusCode == 200) { + final body = response.data as Map; + final content = body['content'] as List; + _items = content + .map((e) => UptimeItemModel.fromJson(e as Map)) + .toList(); + _status = UptimeStatus.success; + } else { + _error = 'Failed to load uptime data (status ${response.statusCode})'; + _status = UptimeStatus.error; + } + } catch (e) { + _error = 'Network error: ${e.toString()}'; + _status = UptimeStatus.error; + } + + notifyListeners(); + } +} diff --git a/lib/routes.dart b/lib/routes.dart index d4149a0..b4dccc6 100644 --- a/lib/routes.dart +++ b/lib/routes.dart @@ -30,6 +30,9 @@ import 'package:qpay/screens/receipt/receipt_screen.dart'; import 'package:qpay/screens/recipient/recipients_screen.dart'; import 'package:qpay/screens/splash_screen.dart'; import 'package:qpay/screens/workspaces/workspace_screen.dart'; +import 'package:qpay/screens/users/screens/user_list_screen.dart'; +import 'package:qpay/screens/more/more_screen.dart'; +import 'package:qpay/screens/uptime/uptime_status_screen.dart'; /// Tracks whether the splash animation is complete so that /// GoRouter's redirect can show the splash first, then release @@ -65,7 +68,7 @@ final SplashState splashState = SplashState(); /// Route prefixes that require the user to be signed in. Any URL /// whose path starts with one of these strings will be redirected /// to the sign-in screen when no auth token is present. -const List _authProtectedRoutePrefixes = ['/groups']; +const List _authProtectedRoutePrefixes = ['/groups', '/users']; bool _isAuthProtected(String path) { for (final prefix in _authProtectedRoutePrefixes) { @@ -225,6 +228,18 @@ GoRouter buildRouter() { path: '/profile', builder: (context, state) => const ProfileScreen(), ), + GoRoute( + path: '/more', + builder: (context, state) => const MoreScreen(), + ), + GoRoute( + path: '/uptime', + builder: (context, state) => const UptimeStatusScreen(), + ), + GoRoute( + path: '/users', + builder: (context, state) => const UserListScreen(), + ), GoRoute( path: '/groups', builder: (context, state) => const GroupsScreen(), diff --git a/lib/screens/accounts/widgets/account_balance_widget.dart b/lib/screens/accounts/widgets/account_balance_widget.dart index ffe16c2..eb5d61a 100644 --- a/lib/screens/accounts/widgets/account_balance_widget.dart +++ b/lib/screens/accounts/widgets/account_balance_widget.dart @@ -67,6 +67,11 @@ class _AccountBalanceWidgetState extends State { _isLoadingZwG = true; }); + // no need to proceed if not logged in + if (!_isLoggedIn) { + return; + } + // Fetch USD balance final usdResult = await _accountProvider.fetchAccount(_workspaceId, 'USD'); if (mounted) { diff --git a/lib/screens/home/home_screen.dart b/lib/screens/home/home_screen.dart index 2652871..707bdf7 100644 --- a/lib/screens/home/home_screen.dart +++ b/lib/screens/home/home_screen.dart @@ -130,6 +130,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { if (prefs.getString("workspaceId") == null) { prefs.setString("workspaceId", Uuid().v4()); } + if (prefs.getString("userId") == null) { + prefs.setString("userId", Uuid().v4()); + } await homeController.getTransactions(prefs.getString("workspaceId")!); } @@ -168,6 +171,9 @@ class _HomeScreenState extends State with TickerProviderStateMixin { if (prefs.getString("workspaceId") == null) { prefs.setString("workspaceId", Uuid().v4()); } + if (prefs.getString("userId") == null) { + prefs.setString("userId", Uuid().v4()); + } await homeController.getTransactions(prefs.getString("workspaceId")!); } diff --git a/lib/screens/more/more_screen.dart b/lib/screens/more/more_screen.dart new file mode 100644 index 0000000..cedfc04 --- /dev/null +++ b/lib/screens/more/more_screen.dart @@ -0,0 +1,80 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../../models/responsive_policy.dart'; + +class MoreScreen extends StatelessWidget { + const MoreScreen({super.key}); + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar(title: const Text('More'), centerTitle: false), + body: Center( + child: LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth > ResponsivePolicy.md + ? 600.0 + : double.infinity; + + return SizedBox( + width: maxWidth, + child: ListView( + padding: const EdgeInsets.all(16), + children: [ + _NavOptionTile( + icon: Icons.people_outline, + title: 'Users', + subtitle: 'Manage users and permissions', + onTap: () => context.go('/users'), + ), + _NavOptionTile( + icon: Icons.monitor_heart_outlined, + title: 'Uptime Status', + subtitle: 'View system uptime and service status', + onTap: () => context.push('/uptime'), + ), + ], + ), + ); + }, + ), + ), + ); + } +} + +class _NavOptionTile extends StatelessWidget { + const _NavOptionTile({ + required this.icon, + required this.title, + required this.subtitle, + required this.onTap, + }); + + final IconData icon; + final String title; + final String subtitle; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + leading: Icon(icon, size: 28, color: theme.colorScheme.primary), + title: Text(title, style: theme.textTheme.titleMedium), + subtitle: Padding( + padding: const EdgeInsets.only(top: 4), + child: Text(subtitle, style: theme.textTheme.bodySmall), + ), + trailing: Icon( + Icons.chevron_right, + color: theme.colorScheme.onSurface.withAlpha(100), + ), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + onTap: onTap, + ); + } +} diff --git a/lib/screens/onboarding/login/login_screen.dart b/lib/screens/onboarding/login/login_screen.dart index 5e152bc..f17a7ad 100644 --- a/lib/screens/onboarding/login/login_screen.dart +++ b/lib/screens/onboarding/login/login_screen.dart @@ -71,15 +71,10 @@ class _LoginScreenState extends State { if (response.isSuccess) { AppSnackBar.showSuccess(context, 'Login successful!'); + context.go('/workspace'); } else { AppSnackBar.showError(context, response.error ?? 'Login failed'); } - - if (!mounted) return; - - if (response.isSuccess) { - context.go('/workspace'); - } } @override @@ -90,7 +85,7 @@ class _LoginScreenState extends State { trailingRoute: '/onboarding/phone', trailingIcon: Icons.person_add_alt_1_rounded, bottomLinkLabel: 'Continue as guest?', - bottomLinkRoute: '/', + bottomLinkRoute: '/workspace', child: OnboardingCard( child: Form( key: _formKey, diff --git a/lib/screens/receipt/receipt_controller.dart b/lib/screens/receipt/receipt_controller.dart index f33008f..2e55212 100644 --- a/lib/screens/receipt/receipt_controller.dart +++ b/lib/screens/receipt/receipt_controller.dart @@ -135,7 +135,9 @@ class ReceiptController extends ChangeNotifier { model.transaction = transaction; setLoading(false); return ApiResponse.success(transaction); - } catch (e) { + } catch (e, s) { + print(e); + print(s); setLoading(false); return ApiResponse.failure( "Problem fetching transaction data, are you connected to the internet?", diff --git a/lib/screens/receipt/receipt_screen.dart b/lib/screens/receipt/receipt_screen.dart index 480b797..4be939d 100644 --- a/lib/screens/receipt/receipt_screen.dart +++ b/lib/screens/receipt/receipt_screen.dart @@ -119,18 +119,23 @@ class _ReceiptScreenState extends State } Future _fetchTransaction() async { - final id = - widget.transactionId ?? - transactionController.model.receiptData?["id"] ?? - receiptController.model.transaction?.id; + try { + final id = + widget.transactionId ?? + transactionController.model.receiptData?["id"] ?? + receiptController.model.transaction?.id; - if (id == null) return; + if (id == null) return; - final response = await receiptController.getReceiptData(id); - if (response.isSuccess && response.data != null) { - setState(() { - _transaction = response.data; - }); + final response = await receiptController.getReceiptData(id); + if (response.isSuccess && response.data != null) { + setState(() { + _transaction = response.data; + }); + } + } catch (e, s) { + print(e); + print(s); } } diff --git a/lib/screens/uptime/uptime_status_screen.dart b/lib/screens/uptime/uptime_status_screen.dart new file mode 100644 index 0000000..c23118b --- /dev/null +++ b/lib/screens/uptime/uptime_status_screen.dart @@ -0,0 +1,421 @@ +import 'package:flutter/foundation.dart' show kIsWeb; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import 'package:qpay/providers/uptime_provider.dart'; +import 'package:qpay/models/uptime_item_model.dart'; + +class UptimeStatusScreen extends StatefulWidget { + const UptimeStatusScreen({super.key}); + + @override + State createState() => _UptimeStatusScreenState(); +} + +class _UptimeStatusScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().fetchUptimes(); + }); + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Scaffold( + appBar: AppBar(title: const Text('Uptime Status'), centerTitle: false), + body: Center( + child: SizedBox( + width: kIsWeb ? 600 : double.infinity, + child: Consumer( + builder: (context, provider, _) { + if (provider.isLoading) { + return const Center(child: CircularProgressIndicator()); + } + + if (provider.hasError) { + return _ErrorView( + message: provider.error ?? 'An unexpected error occurred.', + onRetry: () => provider.fetchUptimes(), + ); + } + + if (provider.status == UptimeStatus.idle) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.monitor_heart_outlined, + size: 64, + color: theme.colorScheme.primary.withAlpha(100), + ), + const SizedBox(height: 16), + Text( + 'Uptime Status', + style: theme.textTheme.headlineSmall, + ), + const SizedBox(height: 8), + Text( + 'Pull to load service status.', + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(150), + ), + ), + ], + ), + ); + } + + final items = provider.items; + if (items.isEmpty) { + return Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.cloud_off, + size: 64, + color: theme.colorScheme.onSurface.withAlpha(80), + ), + const SizedBox(height: 16), + Text( + 'No services found.', + style: theme.textTheme.titleMedium, + ), + ], + ), + ); + } + + return RefreshIndicator( + onRefresh: () => provider.fetchUptimes(), + child: Column( + children: [ + _SummaryBar( + upCount: provider.upCount, + downCount: provider.downCount, + ), + Expanded( + child: ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + itemCount: items.length, + itemBuilder: (context, index) { + return _UptimeCard(item: items[index]); + }, + ), + ), + ], + ), + ); + }, + ), + ), + ), + ); + } +} + +class _SummaryBar extends StatelessWidget { + const _SummaryBar({required this.upCount, required this.downCount}); + + final int upCount; + final int downCount; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Container( + margin: const EdgeInsets.fromLTRB(16, 16, 16, 4), + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), + decoration: BoxDecoration( + gradient: LinearGradient( + colors: [ + theme.colorScheme.primary, + theme.colorScheme.primary.withAlpha(220), + ], + begin: Alignment.topLeft, + end: Alignment.bottomRight, + ), + borderRadius: BorderRadius.circular(16), + boxShadow: [ + BoxShadow( + color: theme.colorScheme.primary.withAlpha(60), + blurRadius: 12, + offset: const Offset(0, 4), + ), + ], + ), + child: Row( + children: [ + _StatBadge( + label: 'Up', + count: upCount, + color: const Color(0xFF10B981), + icon: Icons.check_circle, + ), + Container( + height: 36, + width: 1, + color: Colors.white.withAlpha(60), + margin: const EdgeInsets.symmetric(horizontal: 16), + ), + _StatBadge( + label: 'Down', + count: downCount, + color: const Color(0xFFEF4444), + icon: Icons.cancel, + ), + const Spacer(), + Column( + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + Text( + '${upCount + downCount}', + style: theme.textTheme.headlineMedium?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + Text( + 'Total', + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white.withAlpha(200), + ), + ), + ], + ), + ], + ), + ); + } +} + +class _StatBadge extends StatelessWidget { + const _StatBadge({ + required this.label, + required this.count, + required this.color, + required this.icon, + }); + + final String label; + final int count; + final Color color; + final IconData icon; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Row( + children: [ + Container( + width: 32, + height: 32, + decoration: BoxDecoration( + color: color.withAlpha(40), + borderRadius: BorderRadius.circular(8), + ), + child: Icon(icon, size: 18, color: color), + ), + const SizedBox(width: 8), + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + '$count', + style: theme.textTheme.titleLarge?.copyWith( + color: Colors.white, + fontWeight: FontWeight.w700, + ), + ), + Text( + label, + style: theme.textTheme.bodySmall?.copyWith( + color: Colors.white.withAlpha(200), + ), + ), + ], + ), + ], + ); + } +} + +class _UptimeCard extends StatelessWidget { + const _UptimeCard({required this.item}); + + final UptimeItemModel item; + + IconData _iconForService(String name) { + final lower = name.toLowerCase(); + if (lower.contains('visa') || lower.contains('mastercard')) { + return Icons.credit_card; + } + if (lower.contains('ecocash')) { + return Icons.account_balance_wallet; + } + if (lower.contains('econet')) { + return Icons.signal_cellular_alt; + } + if (lower.contains('netone')) { + return Icons.cell_tower; + } + if (lower.contains('zesa')) { + return Icons.electric_bolt; + } + return Icons.dns; + } + + String _formatTimestamp(String iso) { + try { + final dt = DateTime.parse(iso); + final now = DateTime.now(); + final diff = now.difference(dt); + + if (diff.inMinutes < 1) return 'Just now'; + if (diff.inMinutes < 60) return '${diff.inMinutes}m ago'; + if (diff.inHours < 24) return '${diff.inHours}h ago'; + if (diff.inDays < 7) return '${diff.inDays}d ago'; + return '${dt.day}/${dt.month}/${dt.year}'; + } catch (_) { + return iso; + } + } + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isUp = item.status; + + return Card( + margin: const EdgeInsets.only(bottom: 10), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), + child: Row( + children: [ + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + color: isUp ? const Color(0xFFD1FAE5) : const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(12), + ), + child: Icon( + _iconForService(item.name), + size: 22, + color: isUp ? const Color(0xFF065F46) : const Color(0xFF991B1B), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + item.name, + style: theme.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w600, + ), + ), + const SizedBox(height: 4), + Text( + 'Last checked ${_formatTimestamp(item.createdAt)}', + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(120), + ), + ), + ], + ), + ), + Container( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6), + decoration: BoxDecoration( + color: isUp ? const Color(0xFFD1FAE5) : const Color(0xFFFEE2E2), + borderRadius: BorderRadius.circular(20), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 8, + height: 8, + decoration: BoxDecoration( + color: isUp + ? const Color(0xFF10B981) + : const Color(0xFFEF4444), + shape: BoxShape.circle, + ), + ), + const SizedBox(width: 6), + Text( + isUp ? 'Up' : 'Down', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: isUp + ? const Color(0xFF065F46) + : const Color(0xFF991B1B), + ), + ), + ], + ), + ), + ], + ), + ), + ); + } +} + +class _ErrorView extends StatelessWidget { + const _ErrorView({required this.message, required this.onRetry}); + + final String message; + final VoidCallback onRetry; + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + + return Center( + child: Padding( + padding: const EdgeInsets.all(32), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.error_outline, + size: 56, + color: theme.colorScheme.error.withAlpha(150), + ), + const SizedBox(height: 16), + Text('Failed to Load', style: theme.textTheme.titleLarge), + const SizedBox(height: 8), + Text( + message, + textAlign: TextAlign.center, + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurface.withAlpha(150), + ), + ), + const SizedBox(height: 20), + ElevatedButton.icon( + onPressed: onRetry, + icon: const Icon(Icons.refresh, size: 18), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } +} diff --git a/lib/screens/users/controllers/user_controller.dart b/lib/screens/users/controllers/user_controller.dart new file mode 100644 index 0000000..e373c34 --- /dev/null +++ b/lib/screens/users/controllers/user_controller.dart @@ -0,0 +1,325 @@ +import 'package:flutter/material.dart'; +import 'package:qpay/http/http.dart'; +import 'package:qpay/models/api_response.dart'; +import 'package:qpay/models/pageable_model.dart'; +import 'package:qpay/screens/users/models/managed_user_model.dart'; + +class UserScreenModel { + List users = []; + bool isLoading = false; + bool isLoadingMore = false; + String? errorMessage; + int currentPage = 0; + int totalPages = 0; + int totalElements = 0; + String searchQuery = ''; + bool selectMode = false; + Set selectedUserIds = {}; + + /// Active filter values (null = not filtered) + String? filterEmail; + String? filterPhone; +} + +class UserController extends ChangeNotifier { + final UserScreenModel model = UserScreenModel(); + final Http http = Http(); + BuildContext? context; + bool _disposed = false; + + UserController(this.context); + + /// Creates a controller without a [BuildContext] — useful when a + /// bottom sheet or dialog needs to drive API calls through the + /// controller without participating in the widget tree. + UserController.forSheet() : context = null; + + @override + void dispose() { + _disposed = true; + super.dispose(); + } + + dynamic _unwrap(dynamic response) { + if (response is Map && response.containsKey('body')) { + return response['body']; + } + return response; + } + + String buildQueryParameters(Map params) { + final filtered = {}; + params.forEach((key, value) { + if (value != null && value.isNotEmpty) { + filtered[key] = value; + } + }); + if (filtered.isEmpty) return ''; + return filtered.entries.map((e) => '${e.key}=${e.value}').join('&'); + } + + // ──────────────────────────────────────────────────────────────── + // 1. LIST USERS (GET /api/users?username=…&email=…&phone=…) + // ──────────────────────────────────────────────────────────────── + Future> listUsers({ + int page = 0, + int size = 20, + String? username, + String? email, + String? phone, + }) async { + if (page == 0) { + model.isLoading = true; + model.users = []; + model.currentPage = 0; + } else { + model.isLoadingMore = true; + } + if (!_disposed) notifyListeners(); + + try { + final params = { + 'page': page.toString(), + 'size': size.toString(), + 'username': username, + 'email': email, + 'phone': phone, + }; + + final raw = await http.get('/users?${buildQueryParameters(params)}'); + final response = _unwrap(raw); + final pageableModel = PageableModel.fromJson( + response as Map, + ); + + final newUsers = pageableModel.content + .map((e) => ManagedUserModel.fromJson(e as Map)) + .toList(); + + if (page == 0) { + model.users = newUsers; + } else { + model.users.addAll(newUsers); + } + model.currentPage = pageableModel.number; + model.totalPages = pageableModel.totalPages; + model.totalElements = pageableModel.totalElements; + model.isLoading = false; + model.isLoadingMore = false; + + if (!_disposed) notifyListeners(); + return ApiResponse.success(pageableModel); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + if (page == 0) { + model.users = []; + } + model.isLoading = false; + model.isLoadingMore = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to load users'); + } + } + + // ──────────────────────────────────────────────────────────────── + // 2. LIST WORKSPACE USERS (GET /api/users/workspace?workspaceId=…) + // ──────────────────────────────────────────────────────────────── + Future> listWorkspaceUsers({ + required String workspaceId, + int page = 0, + int size = 20, + String? username, + String? email, + String? phone, + }) async { + if (page == 0) { + model.isLoading = true; + model.users = []; + model.currentPage = 0; + } else { + model.isLoadingMore = true; + } + if (!_disposed) notifyListeners(); + + try { + final params = { + 'workspaceId': workspaceId, + 'page': page.toString(), + 'size': size.toString(), + 'username': username, + 'email': email, + 'phone': phone, + }; + + final raw = await http.get( + '/users/workspace?${buildQueryParameters(params)}', + ); + final response = _unwrap(raw); + final pageableModel = PageableModel.fromJson( + response as Map, + ); + + final newUsers = pageableModel.content + .map((e) => ManagedUserModel.fromJson(e as Map)) + .toList(); + + if (page == 0) { + model.users = newUsers; + } else { + model.users.addAll(newUsers); + } + model.currentPage = pageableModel.number; + model.totalPages = pageableModel.totalPages; + model.totalElements = pageableModel.totalElements; + model.isLoading = false; + model.isLoadingMore = false; + + if (!_disposed) notifyListeners(); + return ApiResponse.success(pageableModel); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + if (page == 0) { + model.users = []; + } + model.isLoading = false; + model.isLoadingMore = false; + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to load workspace users'); + } + } + + // ──────────────────────────────────────────────────────────────── + // 3. ASSOCIATE USER TO WORKSPACE + // POST /api/workspaces/{workspaceId}/users/{userId} + // ──────────────────────────────────────────────────────────────── + Future> associateUser({ + required String workspaceId, + required String userId, + }) async { + try { + await http.postRaw( + '/workspaces/$workspaceId/users/$userId', + ); + // Refresh the list after association so the UI stays in sync + await listWorkspaceUsers(workspaceId: workspaceId, page: 0); + return ApiResponse.success(null); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to associate user to workspace'); + } + } + + // ──────────────────────────────────────────────────────────────── + // 4. DISSOCIATE USER FROM WORKSPACE + // DELETE /api/workspaces/{workspaceId}/users/{userId} + // ──────────────────────────────────────────────────────────────── + Future> dissociateUser({ + required String workspaceId, + required String userId, + }) async { + try { + await http.delete('/workspaces/$workspaceId/users/$userId'); + // Refresh the workspace users list after dissociation + await listWorkspaceUsers(workspaceId: workspaceId, page: 0); + return ApiResponse.success(null); + } catch (e) { + logger.e(e); + model.errorMessage = e.toString(); + if (!_disposed) notifyListeners(); + return ApiResponse.failure('Failed to dissociate user from workspace'); + } + } + + // ──────────────────────────────────────────────────────────────── + // Convenience helpers + // ──────────────────────────────────────────────────────────────── + + Future searchUsers({required String workspaceId, String? name}) async { + model.searchQuery = name ?? ''; + await listWorkspaceUsers( + workspaceId: workspaceId, + page: 0, + username: name, + email: model.filterEmail, + phone: model.filterPhone, + ); + } + + Future applyFilters({ + required String workspaceId, + String? email, + String? phone, + }) async { + model.filterEmail = email; + model.filterPhone = phone; + await listWorkspaceUsers( + workspaceId: workspaceId, + page: 0, + username: model.searchQuery.isNotEmpty ? model.searchQuery : null, + email: email, + phone: phone, + ); + } + + void clearFilters({required String workspaceId}) { + model.filterEmail = null; + model.filterPhone = null; + if (!_disposed) notifyListeners(); + listWorkspaceUsers( + workspaceId: workspaceId, + page: 0, + username: model.searchQuery.isNotEmpty ? model.searchQuery : null, + ); + } + + Future loadNextPage({required String workspaceId}) async { + if (model.isLoadingMore || model.currentPage + 1 >= model.totalPages) { + return; + } + await listWorkspaceUsers( + workspaceId: workspaceId, + page: model.currentPage + 1, + username: model.searchQuery.isNotEmpty ? model.searchQuery : null, + email: model.filterEmail, + phone: model.filterPhone, + ); + } + + // ──────────────────────────────────────────────────────────────── + // Selection helpers for bulk actions + // ──────────────────────────────────────────────────────────────── + + void toggleSelectMode() { + model.selectMode = !model.selectMode; + if (!model.selectMode) { + model.selectedUserIds.clear(); + } + if (!_disposed) notifyListeners(); + } + + void toggleUserSelection(String userId) { + if (model.selectedUserIds.contains(userId)) { + model.selectedUserIds.remove(userId); + } else { + model.selectedUserIds.add(userId); + } + if (!_disposed) notifyListeners(); + } + + Future dissociateSelectedUsers({ + required String workspaceId, + }) async { + final idsToProcess = List.from(model.selectedUserIds); + model.selectMode = false; + model.selectedUserIds.clear(); + if (!_disposed) notifyListeners(); + + for (final id in idsToProcess) { + await dissociateUser(workspaceId: workspaceId, userId: id); + } + } +} \ No newline at end of file diff --git a/lib/screens/users/models/managed_user_model.dart b/lib/screens/users/models/managed_user_model.dart new file mode 100644 index 0000000..537e7bb --- /dev/null +++ b/lib/screens/users/models/managed_user_model.dart @@ -0,0 +1,118 @@ +class ManagedUserModel { + final String id; + final String createdAt; + final String updatedAt; + final bool deleted; + final String username; + final String email; + final String phone; + final int workflowId; + final String firstName; + final String lastName; + final bool enabled; + final bool accountNonExpired; + final bool accountNonLocked; + final bool credentialsNonExpired; + final List authorities; + + ManagedUserModel({ + required this.id, + required this.createdAt, + required this.updatedAt, + required this.deleted, + required this.username, + required this.email, + required this.phone, + required this.workflowId, + required this.firstName, + required this.lastName, + required this.enabled, + required this.accountNonExpired, + required this.accountNonLocked, + required this.credentialsNonExpired, + required this.authorities, + }); + + factory ManagedUserModel.fromJson(Map json) { + return ManagedUserModel( + id: json['id'] as String, + createdAt: json['createdAt'] as String? ?? '', + updatedAt: json['updatedAt'] as String? ?? '', + deleted: json['deleted'] as bool? ?? false, + username: json['username'] as String? ?? '', + email: json['email'] as String? ?? '', + phone: json['phone'] as String? ?? '', + workflowId: json['workflowId'] as int? ?? 0, + firstName: json['firstName'] as String? ?? '', + lastName: json['lastName'] as String? ?? '', + enabled: json['enabled'] as bool? ?? true, + accountNonExpired: json['accountNonExpired'] as bool? ?? true, + accountNonLocked: json['accountNonLocked'] as bool? ?? true, + credentialsNonExpired: json['credentialsNonExpired'] as bool? ?? true, + authorities: (json['authorities'] as List?) + ?.map((e) => + ManagedUserAuthority.fromJson(e as Map)) + .toList() ?? + [], + ); + } + + Map toJson() { + return { + 'id': id, + 'createdAt': createdAt, + 'updatedAt': updatedAt, + 'deleted': deleted, + 'username': username, + 'email': email, + 'phone': phone, + 'workflowId': workflowId, + 'firstName': firstName, + 'lastName': lastName, + 'enabled': enabled, + 'accountNonExpired': accountNonExpired, + 'accountNonLocked': accountNonLocked, + 'credentialsNonExpired': credentialsNonExpired, + 'authorities': authorities.map((e) => e.toJson()).toList(), + }; + } + + String get displayName { + if (firstName.isNotEmpty && lastName.isNotEmpty) { + return '$firstName $lastName'; + } + if (firstName.isNotEmpty) return firstName; + if (lastName.isNotEmpty) return lastName; + return username; + } + + String get initials { + if (firstName.isNotEmpty && lastName.isNotEmpty) { + return '${firstName[0]}${lastName[0]}'.toUpperCase(); + } + if (firstName.isNotEmpty && firstName.length >= 2) { + return firstName.substring(0, 2).toUpperCase(); + } + if (username.isNotEmpty && username.length >= 2) { + return username.substring(0, 2).toUpperCase(); + } + return '?'; + } + + String get roleNames => + authorities.map((a) => a.authority.replaceFirst('ROLE_', '')).join(', '); +} + +class ManagedUserAuthority { + final String authority; + + ManagedUserAuthority({required this.authority}); + + factory ManagedUserAuthority.fromJson(Map json) { + return ManagedUserAuthority(authority: json['authority'] as String? ?? ''); + } + + Map toJson() { + return {'authority': authority}; + } +} \ No newline at end of file diff --git a/lib/screens/users/screens/user_list_screen.dart b/lib/screens/users/screens/user_list_screen.dart new file mode 100644 index 0000000..fa556d0 --- /dev/null +++ b/lib/screens/users/screens/user_list_screen.dart @@ -0,0 +1,1126 @@ +import 'package:flutter/material.dart'; +import 'package:qpay/models/responsive_policy.dart'; +import 'package:qpay/screens/users/controllers/user_controller.dart'; +import 'package:qpay/screens/users/models/managed_user_model.dart'; +import 'package:qpay/widgets/app_snack_bar.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class UserListScreen extends StatefulWidget { + const UserListScreen({super.key}); + + @override + State createState() => _UserListScreenState(); +} + +class _UserListScreenState extends State { + late UserController controller; + SharedPreferences? prefs; + final _searchController = TextEditingController(); + final _scrollController = ScrollController(); + + @override + void initState() { + super.initState(); + controller = UserController(context); + _loadData(); + + _scrollController.addListener(_onScroll); + } + + void _onScroll() { + final p = prefs; + if (p == null) return; + if (_scrollController.position.pixels >= + _scrollController.position.maxScrollExtent - 200) { + controller.loadNextPage(workspaceId: p.getString('workspaceId') ?? ''); + } + } + + Future _loadData() async { + prefs = await SharedPreferences.getInstance(); + final workspaceId = prefs!.getString('workspaceId') ?? ''; + await controller.listWorkspaceUsers(workspaceId: workspaceId); + } + + void _onSearchChanged(String value) { + final workspaceId = prefs?.getString('workspaceId') ?? ''; + controller.searchUsers( + workspaceId: workspaceId, + name: value.isNotEmpty ? value : null, + ); + } + + // ── Bulk dissociate ────────────────────────────────────────── + + Future _onDissociateSelected() async { + final workspaceId = prefs?.getString('workspaceId') ?? ''; + final count = controller.model.selectedUserIds.length; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Dissociate users'), + content: Text( + 'Are you sure you want to dissociate $count user(s) from this workspace?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Dissociate'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + await controller.dissociateSelectedUsers(workspaceId: workspaceId); + } + } + + // ── Single dissociate ──────────────────────────────────────── + + Future _onDissociateUser(ManagedUserModel user) async { + final workspaceId = prefs?.getString('workspaceId') ?? ''; + + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Dissociate user'), + content: Text( + 'Are you sure you want to dissociate "${user.displayName}" from this workspace?', + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () => Navigator.of(ctx).pop(true), + style: TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('Dissociate'), + ), + ], + ), + ); + + if (confirmed == true && mounted) { + final result = await controller.dissociateUser( + workspaceId: workspaceId, + userId: user.id, + ); + if (mounted) { + if (result.error == null) { + AppSnackBar.showSuccess( + context, + '${user.displayName} dissociated from workspace', + ); + } else { + AppSnackBar.showError(context, result.error ?? 'Dissociation failed'); + } + } + } + } + + // ── Associate bottom sheet ─────────────────────────────────── + + void _showAssociateSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + backgroundColor: Colors.transparent, + builder: (sheetContext) => + _AssociateUserSheet(onAssociated: () => _loadData()), + ); + } + + // ── Filter bottom sheet ────────────────────────────────────── + + void _showFilterBottomSheet() { + showModalBottomSheet( + context: context, + backgroundColor: Colors.transparent, + builder: (sheetContext) => _UserFilterBottomSheet( + initialEmail: controller.model.filterEmail, + initialPhone: controller.model.filterPhone, + onApply: (email, phone) { + final workspaceId = prefs?.getString('workspaceId') ?? ''; + controller.applyFilters( + workspaceId: workspaceId, + email: email, + phone: phone, + ); + }, + onClear: () { + final workspaceId = prefs?.getString('workspaceId') ?? ''; + controller.clearFilters(workspaceId: workspaceId); + }, + ), + ); + } + + @override + void dispose() { + _searchController.dispose(); + _scrollController.dispose(); + controller.dispose(); + super.dispose(); + } + + // ══════════════════════════════════════════════════════════════ + // BUILD + // ══════════════════════════════════════════════════════════════ + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text( + 'Users', + style: TextStyle(fontSize: 20, color: Colors.black), + ), + centerTitle: true, + ), + body: ListenableBuilder( + listenable: controller, + builder: (context, _) { + return Column( + children: [ + Expanded( + child: SingleChildScrollView( + controller: _scrollController, + child: Center( + child: LayoutBuilder( + builder: (context, constraints) { + final width = constraints.maxWidth > ResponsivePolicy.md + ? ResponsivePolicy.md.toDouble() + : constraints.maxWidth; + return SizedBox( + width: width, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _buildSearchRow(), + const SizedBox(height: 16), + _buildContent(), + ], + ), + ), + ); + }, + ), + ), + ), + ), + if (controller.model.selectMode) _buildSelectionBottomBar(), + ], + ); + }, + ), + ); + } + + Widget _buildSearchRow() { + final hasActiveFilters = + controller.model.filterEmail != null || + controller.model.filterPhone != null; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: TextFormField( + controller: _searchController, + decoration: InputDecoration( + hintText: 'Search users by username...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _onSearchChanged(''); + }, + ) + : null, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + onChanged: _onSearchChanged, + ), + ), + const SizedBox(width: 8), + _buildIconButton( + icon: Icons.filter_list, + tooltip: 'Filter users', + isActive: hasActiveFilters, + onPressed: _showFilterBottomSheet, + ), + const SizedBox(width: 8), + _buildIconButton( + icon: controller.model.selectMode + ? Icons.checklist + : Icons.checklist_outlined, + tooltip: 'Select users', + isActive: controller.model.selectMode, + onPressed: () => controller.toggleSelectMode(), + ), + const SizedBox(width: 8), + _buildIconButton( + icon: Icons.refresh, + tooltip: 'Refresh users', + isActive: false, + onPressed: _loadData, + ), + const SizedBox(width: 8), + _buildIconButton( + icon: Icons.person_add_alt, + tooltip: 'Add user to workspace', + isActive: false, + onPressed: _showAssociateSheet, + ), + ], + ), + ], + ); + } + + Widget _buildIconButton({ + required IconData icon, + required String tooltip, + required bool isActive, + required VoidCallback onPressed, + }) { + return Container( + decoration: BoxDecoration( + color: isActive + ? Theme.of(context).colorScheme.primary + : Colors.grey.shade100, + borderRadius: BorderRadius.circular(12), + ), + child: IconButton( + icon: Icon(icon, color: isActive ? Colors.white : Colors.grey.shade700), + tooltip: tooltip, + onPressed: onPressed, + ), + ); + } + + Widget _buildContent() { + if (controller.model.isLoading) { + return const Center( + child: Padding( + padding: EdgeInsets.symmetric(vertical: 60), + child: CircularProgressIndicator(), + ), + ); + } + + if (controller.model.errorMessage != null && + controller.model.users.isEmpty) { + return Center( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 60), + child: Column( + children: [ + Icon(Icons.error_outline, size: 48, color: Colors.grey.shade400), + const SizedBox(height: 16), + Text( + controller.model.errorMessage!, + style: TextStyle(color: Colors.grey.shade600), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton.icon( + onPressed: _loadData, + icon: const Icon(Icons.refresh), + label: const Text('Retry'), + ), + ], + ), + ), + ); + } + + if (controller.model.users.isEmpty) { + return Center(child: _buildEmptyState()); + } + + return Column( + children: [ + if (controller.model.filterEmail != null || + controller.model.filterPhone != null) + _buildActiveFilterChips(), + + if (controller.model.selectMode) + Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + children: [ + Text( + '${controller.model.selectedUserIds.length} selected', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey.shade600, + ), + ), + ], + ), + ), + ListView.separated( + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemCount: controller.model.users.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + return _buildUserCard(controller.model.users[index]); + }, + ), + if (controller.model.isLoadingMore) + const Padding( + padding: EdgeInsets.symmetric(vertical: 16), + child: Center(child: CircularProgressIndicator(strokeWidth: 2)), + ), + if (controller.model.totalElements > 0) + Padding( + padding: const EdgeInsets.only(top: 12), + child: Text( + '${controller.model.totalElements} user(s) total', + style: TextStyle(fontSize: 12, color: Colors.grey.shade500), + ), + ), + ], + ); + } + + Widget _buildActiveFilterChips() { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Wrap( + spacing: 8, + runSpacing: 4, + children: [ + if (controller.model.filterEmail != null) + Chip( + label: Text( + 'Email: ${controller.model.filterEmail}', + style: const TextStyle(fontSize: 12), + ), + deleteIcon: const Icon(Icons.close, size: 16), + onDeleted: () { + final workspaceId = prefs?.getString('workspaceId') ?? ''; + controller.applyFilters( + workspaceId: workspaceId, + email: null, + phone: controller.model.filterPhone, + ); + }, + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withAlpha(20), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + if (controller.model.filterPhone != null) + Chip( + label: Text( + 'Phone: ${controller.model.filterPhone}', + style: const TextStyle(fontSize: 12), + ), + deleteIcon: const Icon(Icons.close, size: 16), + onDeleted: () { + final workspaceId = prefs?.getString('workspaceId') ?? ''; + controller.applyFilters( + workspaceId: workspaceId, + email: controller.model.filterEmail, + phone: null, + ); + }, + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withAlpha(20), + side: BorderSide.none, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + ], + ), + ); + } + + Widget _buildEmptyState() { + return Column( + children: [ + const SizedBox(height: 40), + Icon(Icons.people_outline, size: 64, color: Colors.grey.shade400), + const SizedBox(height: 16), + const Text( + 'No users yet.', + style: TextStyle(fontSize: 18, fontWeight: FontWeight.w600), + ), + const SizedBox(height: 8), + Text( + 'Tap "Associate" to add users to this workspace.', + style: TextStyle(fontSize: 14, color: Colors.grey.shade600), + textAlign: TextAlign.center, + ), + ], + ); + } + + Widget _buildUserCard(ManagedUserModel user) { + final isSelected = controller.model.selectedUserIds.contains(user.id); + final inSelectMode = controller.model.selectMode; + + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide( + color: inSelectMode && isSelected + ? Theme.of(context).colorScheme.primary + : Colors.black12.withAlpha(30), + width: inSelectMode && isSelected ? 2 : 1, + ), + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: inSelectMode + ? () => controller.toggleUserSelection(user.id) + : null, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + if (inSelectMode) + Padding( + padding: const EdgeInsets.only(right: 12), + child: Icon( + isSelected + ? Icons.check_box + : Icons.check_box_outline_blank, + color: isSelected + ? Theme.of(context).colorScheme.primary + : Colors.grey.shade400, + ), + ), + CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withAlpha(30), + child: Text( + user.initials, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + fontSize: 16, + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.displayName, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + ), + ), + if (user.username != user.displayName) + Padding( + padding: const EdgeInsets.only(top: 2), + child: Text( + '@${user.username}', + style: TextStyle( + fontSize: 13, + color: Colors.grey.shade600, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ), + const SizedBox(height: 12), + Wrap( + spacing: 8, + runSpacing: 6, + children: [ + _buildInfoChip(Icons.email_outlined, user.email), + _buildInfoChip(Icons.phone_outlined, user.phone), + if (user.roleNames.isNotEmpty) + _buildInfoChip(Icons.shield_outlined, user.roleNames), + ], + ), + if (!inSelectMode) ...[ + const SizedBox(height: 12), + Align( + alignment: Alignment.centerRight, + child: TextButton.icon( + onPressed: () => _onDissociateUser(user), + icon: const Icon(Icons.link_off, size: 18), + label: const Text('Dissociate'), + style: TextButton.styleFrom( + foregroundColor: Colors.red.shade600, + ), + ), + ), + ], + ], + ), + ), + ), + ); + } + + Widget _buildInfoChip(IconData icon, String text) { + if (text.isEmpty) return const SizedBox.shrink(); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4), + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(icon, size: 14, color: Colors.grey.shade600), + const SizedBox(width: 4), + Text( + text, + style: TextStyle(fontSize: 12, color: Colors.grey.shade700), + ), + ], + ), + ); + } + + Widget _buildSelectionBottomBar() { + final selectedCount = controller.model.selectedUserIds.length; + return Container( + decoration: BoxDecoration( + color: Colors.white, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.08), + blurRadius: 8, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), + child: Row( + children: [ + Text( + '$selectedCount selected', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + ), + ), + const Spacer(), + TextButton.icon( + onPressed: selectedCount > 0 ? _onDissociateSelected : null, + icon: const Icon(Icons.link_off, size: 20), + label: const Text('Dissociate'), + style: TextButton.styleFrom(foregroundColor: Colors.red), + ), + ], + ), + ), + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// ASSOCIATE USER BOTTOM SHEET +// ═══════════════════════════════════════════════════════════════════════════ + +class _AssociateUserSheet extends StatefulWidget { + final VoidCallback onAssociated; + + const _AssociateUserSheet({required this.onAssociated}); + + @override + State<_AssociateUserSheet> createState() => _AssociateUserSheetState(); +} + +class _AssociateUserSheetState extends State<_AssociateUserSheet> { + final _searchController = TextEditingController(); + final _scrollController = ScrollController(); + final UserController _userController = UserController.forSheet(); + + List _results = []; + bool _isSearching = false; + + Future _search(String query) async { + if (query.trim().isEmpty) { + setState(() { + _results = []; + _isSearching = false; + }); + return; + } + + setState(() => _isSearching = true); + + final response = await _userController.listUsers(username: query.trim()); + + if (mounted) { + setState(() { + _isSearching = false; + if (response.isSuccess) { + _results = _userController.model.users; + } + }); + } + } + + Future _associate(ManagedUserModel user) async { + final prefs = await SharedPreferences.getInstance(); + final workspaceId = prefs.getString('workspaceId') ?? ''; + + if (!mounted) return; + + // Confirm + final confirmed = await showDialog( + context: context, + builder: (ctx) => AlertDialog( + title: const Text('Associate user'), + content: Text('Associate "${user.displayName}" to this workspace?'), + actions: [ + TextButton( + onPressed: () => Navigator.of(ctx).pop(false), + child: const Text('Cancel'), + ), + ElevatedButton( + onPressed: () => Navigator.of(ctx).pop(true), + child: const Text('Associate'), + ), + ], + ), + ); + + if (confirmed != true) return; + + final result = await _userController.associateUser( + workspaceId: workspaceId, + userId: user.id, + ); + + if (mounted) { + if (result.error == null) { + AppSnackBar.showSuccess( + context, + '${user.displayName} associated to workspace', + ); + widget.onAssociated(); + Navigator.of(context).pop(); + } else { + AppSnackBar.showError(context, result.error ?? 'Association failed'); + } + } + } + + @override + void dispose() { + _searchController.dispose(); + _scrollController.dispose(); + _userController.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + final bottomInset = MediaQuery.of(context).viewInsets.bottom; + + return Container( + height: MediaQuery.of(context).size.height * 0.75, + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1A1A2E) : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: EdgeInsets.fromLTRB(20, 12, 20, bottomInset + 16), + child: Column( + children: [ + // Drag handle + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + // Header + Text( + 'Associate User', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + ), + ), + const SizedBox(height: 6), + Text( + 'Search for a platform user to add to this workspace.', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey.shade500, + ), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + // Search field + TextFormField( + controller: _searchController, + autofocus: true, + decoration: InputDecoration( + hintText: 'Search by username...', + prefixIcon: const Icon(Icons.search), + suffixIcon: _searchController.text.isNotEmpty + ? IconButton( + icon: const Icon(Icons.clear), + onPressed: () { + _searchController.clear(); + _search(''); + }, + ) + : null, + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + ), + onChanged: (value) { + if (value.length >= 2 || value.isEmpty) { + _search(value); + } + }, + ), + const SizedBox(height: 12), + // Results + Expanded( + child: _isSearching + ? const Center(child: CircularProgressIndicator()) + : _results.isEmpty + ? Center( + child: Text( + _searchController.text.isNotEmpty + ? 'No users found.' + : 'Type at least 2 characters to search.', + style: TextStyle( + color: Colors.grey.shade500, + fontSize: 14, + ), + ), + ) + : ListView.separated( + controller: _scrollController, + itemCount: _results.length, + separatorBuilder: (_, __) => const SizedBox(height: 8), + itemBuilder: (context, index) { + final user = _results[index]; + return _buildSearchResultCard(user); + }, + ), + ), + ], + ), + ); + } + + Widget _buildSearchResultCard(ManagedUserModel user) { + return Card( + elevation: 0, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + side: BorderSide(color: Colors.black12.withAlpha(30)), + ), + child: InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () => _associate(user), + child: Padding( + padding: const EdgeInsets.all(14), + child: Row( + children: [ + CircleAvatar( + backgroundColor: Theme.of( + context, + ).colorScheme.primary.withAlpha(30), + child: Text( + user.initials, + style: TextStyle( + color: Theme.of(context).colorScheme.primary, + fontWeight: FontWeight.w600, + fontSize: 14, + ), + ), + ), + const SizedBox(width: 14), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + user.displayName, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w600, + ), + ), + if (user.username != user.displayName) + Text( + '@${user.username}', + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 4), + Text( + user.email.isNotEmpty ? user.email : user.phone, + style: TextStyle( + fontSize: 12, + color: Colors.grey.shade500, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + ], + ), + ), + Icon(Icons.add_circle_outline, color: Colors.grey.shade400), + ], + ), + ), + ), + ); + } +} + +// ═══════════════════════════════════════════════════════════════════════════ +// FILTER BOTTOM SHEET +// ═══════════════════════════════════════════════════════════════════════════ + +class _UserFilterBottomSheet extends StatefulWidget { + final String? initialEmail; + final String? initialPhone; + final void Function(String? email, String? phone) onApply; + final VoidCallback onClear; + + const _UserFilterBottomSheet({ + required this.initialEmail, + required this.initialPhone, + required this.onApply, + required this.onClear, + }); + + @override + State<_UserFilterBottomSheet> createState() => _UserFilterBottomSheetState(); +} + +class _UserFilterBottomSheetState extends State<_UserFilterBottomSheet> { + late TextEditingController _emailController; + late TextEditingController _phoneController; + + @override + void initState() { + super.initState(); + _emailController = TextEditingController(text: widget.initialEmail ?? ''); + _phoneController = TextEditingController(text: widget.initialPhone ?? ''); + } + + @override + void dispose() { + _emailController.dispose(); + _phoneController.dispose(); + super.dispose(); + } + + void _apply() { + final email = _emailController.text.trim().isNotEmpty + ? _emailController.text.trim() + : null; + final phone = _phoneController.text.trim().isNotEmpty + ? _phoneController.text.trim() + : null; + widget.onApply(email, phone); + Navigator.of(context).pop(); + } + + void _clear() { + widget.onClear(); + Navigator.of(context).pop(); + } + + @override + Widget build(BuildContext context) { + final isDark = Theme.of(context).brightness == Brightness.dark; + + return Container( + decoration: BoxDecoration( + color: isDark ? const Color(0xFF1A1A2E) : Colors.white, + borderRadius: const BorderRadius.vertical(top: Radius.circular(24)), + ), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Center( + child: Container( + width: 40, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + 'Filter Users', + style: TextStyle( + fontSize: 20, + fontWeight: FontWeight.w700, + color: isDark ? Colors.white : Colors.black87, + ), + ), + TextButton(onPressed: _clear, child: const Text('Clear all')), + ], + ), + const SizedBox(height: 6), + Text( + 'Filter users by the fields below.', + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w500, + color: Colors.grey.shade500, + ), + ), + const SizedBox(height: 20), + _buildFilterField( + label: 'Email', + hint: 'e.g. user@example.com', + controller: _emailController, + icon: Icons.email_outlined, + ), + const SizedBox(height: 16), + _buildFilterField( + label: 'Phone', + hint: 'e.g. 263773591219', + controller: _phoneController, + icon: Icons.phone_outlined, + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: _apply, + child: const Text('Apply Filters'), + ), + ), + const SizedBox(height: 8), + ], + ), + ); + } + + Widget _buildFilterField({ + required String label, + required String hint, + required TextEditingController controller, + required IconData icon, + }) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + color: Colors.black87, + ), + ), + const SizedBox(height: 8), + TextFormField( + controller: controller, + decoration: InputDecoration( + hintText: hint, + prefixIcon: Icon(icon, size: 20), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + focusedBorder: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide( + color: Theme.of(context).colorScheme.primary, + ), + ), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 14, + ), + ), + ), + ], + ); + } +} diff --git a/lib/screens/workspaces/workspace_screen.dart b/lib/screens/workspaces/workspace_screen.dart index 27ae543..49b4979 100644 --- a/lib/screens/workspaces/workspace_screen.dart +++ b/lib/screens/workspaces/workspace_screen.dart @@ -94,6 +94,7 @@ class _WorkspaceScreenState extends State // reference it if needed. SharedPreferences.getInstance().then((prefs) { prefs.setString('workspaceId', workspaces.first.id); + prefs.setString('workspaceInitials', workspaces.first.name[0]); }); _navigateToHome(); } else if (workspaces.isEmpty) {