From bb364ed7d6a85e654aa90c864c06afee3da706bf Mon Sep 17 00:00:00 2001 From: ashwin Date: Sun, 10 May 2026 08:32:14 +0530 Subject: [PATCH] feat: implement audit logging and documentation for Tier 1 case management features --- mobile/lib/providers/auth_provider.dart | 11 +- mobile/lib/providers/dashboard_provider.dart | 161 ++----- mobile/lib/providers/deals_provider.dart | 301 +++++------- mobile/lib/providers/leads_provider.dart | 293 +++++------- mobile/lib/providers/lookup_provider.dart | 440 ++++++------------ mobile/lib/providers/tasks_provider.dart | 362 ++++++-------- mobile/lib/providers/tickets_provider.dart | 252 +++++----- .../screens/dashboard/dashboard_screen.dart | 31 +- .../lib/screens/deals/deal_form_screen.dart | 43 +- .../lib/screens/deals/deals_list_screen.dart | 9 - .../lib/screens/leads/lead_detail_screen.dart | 2 +- .../lib/screens/leads/lead_form_screen.dart | 15 +- .../lib/screens/leads/leads_list_screen.dart | 34 +- .../lib/screens/tasks/tasks_list_screen.dart | 18 +- .../screens/tickets/ticket_form_screen.dart | 5 +- .../screens/tickets/tickets_list_screen.dart | 29 +- mobile/pubspec.lock | 408 +++++++++++++--- mobile/pubspec.yaml | 19 +- 18 files changed, 1118 insertions(+), 1315 deletions(-) diff --git a/mobile/lib/providers/auth_provider.dart b/mobile/lib/providers/auth_provider.dart index 7b2dd8bc..7eb3abd8 100644 --- a/mobile/lib/providers/auth_provider.dart +++ b/mobile/lib/providers/auth_provider.dart @@ -67,11 +67,12 @@ class AuthState { } /// Notifier for authentication state changes -class AuthNotifier extends StateNotifier { - AuthNotifier() : super(const AuthState.initial()); - +class AuthNotifier extends Notifier { final AuthService _authService = AuthService(); + @override + AuthState build() => const AuthState.initial(); + /// Check if user is already authenticated (on app launch) Future checkAuthStatus() async { debugPrint('AuthNotifier: Checking auth status...'); @@ -190,9 +191,7 @@ class AuthNotifier extends StateNotifier { } /// Provider for authentication state -final authProvider = StateNotifierProvider((ref) { - return AuthNotifier(); -}); +final authProvider = NotifierProvider(AuthNotifier.new); /// Provider for checking if user is authenticated final isAuthenticatedProvider = Provider((ref) { diff --git a/mobile/lib/providers/dashboard_provider.dart b/mobile/lib/providers/dashboard_provider.dart index e6c15cfe..9323b240 100644 --- a/mobile/lib/providers/dashboard_provider.dart +++ b/mobile/lib/providers/dashboard_provider.dart @@ -4,145 +4,42 @@ import '../config/api_config.dart'; import '../data/models/dashboard_data.dart'; import '../services/api_service.dart'; -/// Dashboard state -class DashboardState { - final DashboardData? data; - final bool isLoading; - final String? error; - final DateTime? lastUpdated; - - const DashboardState({ - this.data, - this.isLoading = false, - this.error, - this.lastUpdated, - }); - - const DashboardState.initial() - : data = null, - isLoading = false, - error = null, - lastUpdated = null; - - DashboardState copyWith({ - DashboardData? data, - bool? isLoading, - String? error, - DateTime? lastUpdated, - bool clearError = false, - }) { - return DashboardState( - data: data ?? this.data, - isLoading: isLoading ?? this.isLoading, - error: clearError ? null : (error ?? this.error), - lastUpdated: lastUpdated ?? this.lastUpdated, - ); - } -} - -/// Dashboard notifier for managing dashboard state -class DashboardNotifier extends StateNotifier { - DashboardNotifier() : super(const DashboardState.initial()); - +/// Async notifier owning the dashboard fetch. +/// +/// `build()` runs on first watch and seeds the AsyncValue with the result of +/// the initial fetch — no separate `fetchDashboard()` call from the screen. +/// `refresh()` is the pull-to-refresh / retry entrypoint. +class DashboardNotifier extends AsyncNotifier { final ApiService _apiService = ApiService(); - /// Fetch dashboard data from API - Future fetchDashboard({bool forceRefresh = false}) async { - // Skip if already loading - if (state.isLoading) return; + @override + Future build() => _fetch(); - // Skip if we have recent data (less than 1 minute old) and not forcing refresh - if (!forceRefresh && state.data != null && state.lastUpdated != null) { - final age = DateTime.now().difference(state.lastUpdated!); - if (age.inMinutes < 1) { - debugPrint( - 'DashboardNotifier: Using cached data (${age.inSeconds}s old)', - ); - return; - } - } - - debugPrint('DashboardNotifier: Fetching dashboard data...'); - state = state.copyWith(isLoading: true, clearError: true); - - try { - final response = await _apiService.get(ApiConfig.dashboard); - - debugPrint('DashboardNotifier: Response status: ${response.statusCode}'); - debugPrint('DashboardNotifier: Response success: ${response.success}'); - - if (response.success && response.data != null) { - debugPrint( - 'DashboardNotifier: Response keys: ${response.data!.keys.toList()}', - ); - debugPrint( - 'DashboardNotifier: tasks data type: ${response.data!['tasks']?.runtimeType}', - ); - if (response.data!['tasks'] != null) { - final tasksList = response.data!['tasks'] as List; - debugPrint('DashboardNotifier: tasks count: ${tasksList.length}'); - if (tasksList.isNotEmpty) { - debugPrint('DashboardNotifier: first task: ${tasksList.first}'); - } - } - - final dashboardData = DashboardData.fromJson(response.data!); - state = state.copyWith( - data: dashboardData, - isLoading: false, - lastUpdated: DateTime.now(), - ); - debugPrint('DashboardNotifier: Dashboard data loaded successfully'); - debugPrint(' - Leads: ${dashboardData.leadsCount}'); - debugPrint(' - Opportunities: ${dashboardData.opportunitiesCount}'); - debugPrint( - ' - Pipeline stages: ${dashboardData.pipelineByStage.length}', - ); - debugPrint(' - Tasks: ${dashboardData.tasks.length}'); - } else { - state = state.copyWith( - isLoading: false, - error: response.message ?? 'Failed to load dashboard data', - ); - debugPrint('DashboardNotifier: API error - ${response.message}'); - debugPrint('DashboardNotifier: Response data: ${response.data}'); - } - } catch (e, stackTrace) { - debugPrint('DashboardNotifier: Exception - $e'); - debugPrint('DashboardNotifier: Stack trace - $stackTrace'); - state = state.copyWith( - isLoading: false, - error: 'Failed to load dashboard: ${e.toString()}', - ); - } - } - - /// Refresh dashboard data Future refresh() async { - await fetchDashboard(forceRefresh: true); + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_fetch); } - /// Clear dashboard data (e.g., on logout) - void clear() { - state = const DashboardState.initial(); + Future _fetch() async { + debugPrint('DashboardNotifier: Fetching dashboard data...'); + final response = await _apiService.get(ApiConfig.dashboard); + debugPrint('DashboardNotifier: Response status: ${response.statusCode}'); + if (!response.success || response.data == null) { + debugPrint('DashboardNotifier: API error - ${response.message}'); + throw Exception(response.message ?? 'Failed to load dashboard data'); + } + final dashboardData = DashboardData.fromJson(response.data!); + debugPrint( + 'DashboardNotifier: loaded leads=${dashboardData.leadsCount} ' + 'opps=${dashboardData.opportunitiesCount} ' + 'stages=${dashboardData.pipelineByStage.length} ' + 'tasks=${dashboardData.tasks.length}', + ); + return dashboardData; } } -/// Dashboard provider final dashboardProvider = - StateNotifierProvider((ref) { - return DashboardNotifier(); - }); - -/// Convenience providers for specific dashboard data -final dashboardDataProvider = Provider((ref) { - return ref.watch(dashboardProvider).data; -}); - -final dashboardLoadingProvider = Provider((ref) { - return ref.watch(dashboardProvider).isLoading; -}); - -final dashboardErrorProvider = Provider((ref) { - return ref.watch(dashboardProvider).error; -}); + AsyncNotifierProvider( + DashboardNotifier.new, + ); diff --git a/mobile/lib/providers/deals_provider.dart b/mobile/lib/providers/deals_provider.dart index 6402c34c..95b96dc5 100644 --- a/mobile/lib/providers/deals_provider.dart +++ b/mobile/lib/providers/deals_provider.dart @@ -3,45 +3,28 @@ import '../config/api_config.dart'; import '../data/models/deal.dart'; import '../services/api_service.dart'; -/// Deals list state -class DealsState { +/// Paginated deals snapshot — wrapped by AsyncValue for loading/error. +class DealsListData { final List deals; - final bool isLoading; - final String? error; final int totalCount; final bool hasMore; final int currentOffset; - const DealsState({ + const DealsListData({ this.deals = const [], - this.isLoading = false, - this.error, this.totalCount = 0, this.hasMore = true, this.currentOffset = 0, }); - const DealsState.initial() - : deals = const [], - isLoading = false, - error = null, - totalCount = 0, - hasMore = true, - currentOffset = 0; - - DealsState copyWith({ + DealsListData copyWith({ List? deals, - bool? isLoading, - String? error, int? totalCount, bool? hasMore, int? currentOffset, - bool clearError = false, }) { - return DealsState( + return DealsListData( deals: deals ?? this.deals, - isLoading: isLoading ?? this.isLoading, - error: clearError ? null : (error ?? this.error), totalCount: totalCount ?? this.totalCount, hasMore: hasMore ?? this.hasMore, currentOffset: currentOffset ?? this.currentOffset, @@ -49,121 +32,104 @@ class DealsState { } } -/// Deals notifier for managing deals state -class DealsNotifier extends StateNotifier { - DealsNotifier() : super(const DealsState.initial()); - +/// AsyncNotifier driving the deals list + CRUD. The list page is the only +/// place that watches this; detail/form screens use the notifier directly +/// for one-shot fetches and mutations. +class DealsNotifier extends AsyncNotifier { final ApiService _apiService = ApiService(); static const int _pageSize = 20; - /// Fetch deals from API - Future fetchDeals({ - String? search, - String? stage, - bool refresh = false, - }) async { + @override + Future build() => _fetchPage(offset: 0); + + /// Reload the first page (pull-to-refresh / after CRUD). + Future refresh({String? search, String? stage}) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard( + () => _fetchPage(offset: 0, search: search, stage: stage), + ); + } + + /// Append the next page if there's more and we're not already loading. + Future loadMore({String? search, String? stage}) async { + final current = state.value; + if (current == null || !current.hasMore) return; if (state.isLoading) return; - if (refresh) { - state = state.copyWith(currentOffset: 0, hasMore: true, clearError: true); - } - - state = state.copyWith(isLoading: true, clearError: true); - - try { - // Build query parameters - final queryParams = { - 'limit': _pageSize.toString(), - 'offset': state.currentOffset.toString(), - }; - - if (search != null && search.isNotEmpty) { - queryParams['name'] = search; - } - if (stage != null && stage.isNotEmpty) { - queryParams['stage'] = stage; - } - - final url = Uri.parse( - ApiConfig.opportunities, - ).replace(queryParameters: queryParams).toString(); - final response = await _apiService.get(url); - - if (response.success && response.data != null) { - final data = response.data!; - - // Parse deals - handle different response formats - List dealsList = []; - int dealsCount = 0; - - if (data['opportunities'] != null) { - dealsList = data['opportunities'] as List? ?? []; - dealsCount = data['opportunities_count'] as int? ?? dealsList.length; - } else if (data['results'] != null) { - dealsList = data['results'] as List? ?? []; - dealsCount = data['count'] as int? ?? dealsList.length; - } - - final newDeals = []; - for (final item in dealsList) { - try { - if (item is Map) { - newDeals.add(Deal.fromJson(item)); - } - } catch (e) { - // Skip invalid deals - } - } - - // Update state - final updatedDeals = refresh ? newDeals : [...state.deals, ...newDeals]; - - state = state.copyWith( - deals: updatedDeals, - isLoading: false, - totalCount: dealsCount, - hasMore: newDeals.length >= _pageSize, - currentOffset: state.currentOffset + newDeals.length, - ); - } else { - state = state.copyWith( - isLoading: false, - error: response.message ?? 'Failed to load deals', - ); - } - } catch (e) { - state = state.copyWith( - isLoading: false, - error: 'Failed to load deals: ${e.toString()}', + state = await AsyncValue.guard(() async { + final next = await _fetchPage( + offset: current.currentOffset, + search: search, + stage: stage, ); + return current.copyWith( + deals: [...current.deals, ...next.deals], + totalCount: next.totalCount, + hasMore: next.hasMore, + currentOffset: next.currentOffset, + ); + }); + } + + Future _fetchPage({ + required int offset, + String? search, + String? stage, + }) async { + final queryParams = { + 'limit': _pageSize.toString(), + 'offset': offset.toString(), + }; + if (search != null && search.isNotEmpty) queryParams['name'] = search; + if (stage != null && stage.isNotEmpty) queryParams['stage'] = stage; + + final url = Uri.parse( + ApiConfig.opportunities, + ).replace(queryParameters: queryParams).toString(); + final response = await _apiService.get(url); + + if (!response.success || response.data == null) { + throw Exception(response.message ?? 'Failed to load deals'); } + + final data = response.data!; + List dealsList = []; + int dealsCount = 0; + + if (data['opportunities'] != null) { + dealsList = data['opportunities'] as List? ?? []; + dealsCount = data['opportunities_count'] as int? ?? dealsList.length; + } else if (data['results'] != null) { + dealsList = data['results'] as List? ?? []; + dealsCount = data['count'] as int? ?? dealsList.length; + } + + final newDeals = []; + for (final item in dealsList) { + try { + if (item is Map) { + newDeals.add(Deal.fromJson(item)); + } + } catch (_) { + // Skip invalid deals + } + } + + return DealsListData( + deals: newDeals, + totalCount: dealsCount, + hasMore: newDeals.length >= _pageSize, + currentOffset: offset + newDeals.length, + ); } - /// Refresh deals (reset and fetch first page) - Future refresh() async { - state = const DealsState.initial(); - await fetchDeals(refresh: true); - } - - /// Load more deals - Future loadMore() async { - if (!state.hasMore || state.isLoading) return; - await fetchDeals(); - } - - /// Clear deals data - void clear() { - state = const DealsState.initial(); - } - - /// Get a single deal by ID + /// Fetch a single deal from the API (detail/form screens). Future getDeal(String id) async { try { final url = '${ApiConfig.opportunities}$id/'; final response = await _apiService.get(url); if (response.success && response.data != null) { - // API returns opportunity wrapped in 'opportunity_obj' key final opportunityData = response.data!['opportunity_obj'] as Map?; if (opportunityData != null) { @@ -176,7 +142,7 @@ class DealsNotifier extends StateNotifier { } } - /// Create a new deal + /// Create a new deal — list refreshes on success. Future<({bool success, String? error, Deal? deal})> createDeal( Deal deal, ) async { @@ -187,16 +153,13 @@ class DealsNotifier extends StateNotifier { ); if (response.success && response.data != null) { - // API returns {"error": false, "message": "..."} on success, not the deal object final isError = response.data!['error'] as bool? ?? true; if (!isError) { - // Refresh the deals list to get the new deal - await fetchDeals(refresh: true); + await refresh(); return (success: true, error: null, deal: null); } } - // Parse error message String errorMsg = response.message ?? 'Failed to create deal'; if (response.data != null && response.data!['errors'] != null) { final errors = response.data!['errors'] as Map; @@ -214,7 +177,7 @@ class DealsNotifier extends StateNotifier { } } - /// Update an existing deal + /// Update an existing deal — list refreshes on success. Future<({bool success, String? error, Deal? deal})> updateDeal( String id, Deal deal, @@ -224,16 +187,13 @@ class DealsNotifier extends StateNotifier { final response = await _apiService.put(url, deal.toJson()); if (response.success && response.data != null) { - // API returns {"error": false, "message": "..."} on success, not the deal object final isError = response.data!['error'] as bool? ?? true; if (!isError) { - // Refresh the deals list to get updated data - await fetchDeals(refresh: true); + await refresh(); return (success: true, error: null, deal: null); } } - // Parse error message String errorMsg = response.message ?? 'Failed to update deal'; if (response.data != null && response.data!['errors'] != null) { final errors = response.data!['errors'] as Map; @@ -251,7 +211,7 @@ class DealsNotifier extends StateNotifier { } } - /// Update deal stage (quick action) + /// Quick stage change — optimistic local update for snappy UX. Future<({bool success, String? error})> updateDealStage( String id, DealStage stage, @@ -264,21 +224,22 @@ class DealsNotifier extends StateNotifier { }); if (response.success && response.data != null) { - // API returns {"error": false, "message": "..."} on success final isError = response.data!['error'] as bool? ?? true; if (!isError) { - // Update the deal stage locally without full refresh for better UX - final updatedDeals = state.deals.map((d) { - if (d.id == id) { - return d.copyWith( - stage: stage, - probability: stage.defaultProbability, - updatedAt: DateTime.now(), - ); - } - return d; - }).toList(); - state = state.copyWith(deals: updatedDeals); + final current = state.value; + if (current != null) { + final updatedDeals = current.deals.map((d) { + if (d.id == id) { + return d.copyWith( + stage: stage, + probability: stage.defaultProbability, + updatedAt: DateTime.now(), + ); + } + return d; + }).toList(); + state = AsyncValue.data(current.copyWith(deals: updatedDeals)); + } return (success: true, error: null); } } @@ -292,19 +253,22 @@ class DealsNotifier extends StateNotifier { } } - /// Delete a deal + /// Delete a deal — local state mutation (no full refresh). Future<({bool success, String? error})> deleteDeal(String id) async { try { final url = '${ApiConfig.opportunities}$id/'; final response = await _apiService.delete(url); if (response.success) { - // Remove from the list - final updatedDeals = state.deals.where((d) => d.id != id).toList(); - state = state.copyWith( - deals: updatedDeals, - totalCount: state.totalCount - 1, - ); + final current = state.value; + if (current != null) { + state = AsyncValue.data( + current.copyWith( + deals: current.deals.where((d) => d.id != id).toList(), + totalCount: current.totalCount - 1, + ), + ); + } return (success: true, error: null); } @@ -316,25 +280,16 @@ class DealsNotifier extends StateNotifier { return (success: false, error: 'Failed to delete deal: ${e.toString()}'); } } - - /// Get deal from current state by ID - Deal? getDealFromState(String id) { - try { - return state.deals.firstWhere((d) => d.id == id); - } catch (_) { - return null; - } - } } -/// Deals provider -final dealsProvider = StateNotifierProvider((ref) { - return DealsNotifier(); -}); +final dealsProvider = AsyncNotifierProvider( + DealsNotifier.new, +); -/// Convenience providers +/// Convenience providers — read from the AsyncValue so screen code stays the +/// same shape as before the riverpod 3 migration. final dealsListProvider = Provider>((ref) { - return ref.watch(dealsProvider).deals; + return ref.watch(dealsProvider).value?.deals ?? const []; }); final dealsLoadingProvider = Provider((ref) { @@ -342,22 +297,20 @@ final dealsLoadingProvider = Provider((ref) { }); final dealsErrorProvider = Provider((ref) { - return ref.watch(dealsProvider).error; + return ref.watch(dealsProvider).error?.toString(); }); -/// Grouped deals by stage providers +/// Grouped deals by stage — derived. final dealsByStageProvider = Provider>>((ref) { final deals = ref.watch(dealsListProvider); final Map> grouped = {}; - for (final stage in DealStage.values) { grouped[stage] = deals.where((deal) => deal.stage == stage).toList(); } - return grouped; }); -/// Pipeline value (active deals only) +/// Pipeline value (active deals only). final pipelineValueProvider = Provider((ref) { final deals = ref.watch(dealsListProvider); return deals @@ -365,7 +318,7 @@ final pipelineValueProvider = Provider((ref) { .fold(0, (sum, deal) => sum + deal.value); }); -/// Active deals count +/// Active deals count. final activeDealsCountProvider = Provider((ref) { final deals = ref.watch(dealsListProvider); return deals.where((d) => !d.stage.isClosed).length; diff --git a/mobile/lib/providers/leads_provider.dart b/mobile/lib/providers/leads_provider.dart index e139d265..3c77ee83 100644 --- a/mobile/lib/providers/leads_provider.dart +++ b/mobile/lib/providers/leads_provider.dart @@ -4,45 +4,28 @@ import '../data/models/lead.dart'; import '../data/models/comment.dart'; import '../services/api_service.dart'; -/// Leads list state -class LeadsState { +/// Paginated leads snapshot — wrapped by AsyncValue. +class LeadsListData { final List leads; - final bool isLoading; - final String? error; final int totalCount; final bool hasMore; final int currentOffset; - const LeadsState({ + const LeadsListData({ this.leads = const [], - this.isLoading = false, - this.error, this.totalCount = 0, this.hasMore = true, this.currentOffset = 0, }); - const LeadsState.initial() - : leads = const [], - isLoading = false, - error = null, - totalCount = 0, - hasMore = true, - currentOffset = 0; - - LeadsState copyWith({ + LeadsListData copyWith({ List? leads, - bool? isLoading, - String? error, int? totalCount, bool? hasMore, int? currentOffset, - bool clearError = false, }) { - return LeadsState( + return LeadsListData( leads: leads ?? this.leads, - isLoading: isLoading ?? this.isLoading, - error: clearError ? null : (error ?? this.error), totalCount: totalCount ?? this.totalCount, hasMore: hasMore ?? this.hasMore, currentOffset: currentOffset ?? this.currentOffset, @@ -50,14 +33,102 @@ class LeadsState { } } -/// Leads notifier for managing leads state -class LeadsNotifier extends StateNotifier { - LeadsNotifier() : super(const LeadsState.initial()); - +class LeadsNotifier extends AsyncNotifier { final ApiService _apiService = ApiService(); static const int _pageSize = 20; - /// Get a single lead by ID + @override + Future build() => _fetchPage(offset: 0); + + Future refresh({String? search, String? status, String? source}) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard( + () => _fetchPage( + offset: 0, + search: search, + status: status, + source: source, + ), + ); + } + + Future loadMore({ + String? search, + String? status, + String? source, + }) async { + final current = state.value; + if (current == null || !current.hasMore) return; + if (state.isLoading) return; + + state = await AsyncValue.guard(() async { + final next = await _fetchPage( + offset: current.currentOffset, + search: search, + status: status, + source: source, + ); + return current.copyWith( + leads: [...current.leads, ...next.leads], + totalCount: next.totalCount, + hasMore: next.hasMore, + currentOffset: next.currentOffset, + ); + }); + } + + Future _fetchPage({ + required int offset, + String? search, + String? status, + String? source, + }) async { + final queryParams = { + 'limit': _pageSize.toString(), + 'offset': offset.toString(), + }; + if (search != null && search.isNotEmpty) queryParams['search'] = search; + if (status != null && status.isNotEmpty) queryParams['status'] = status; + if (source != null && source.isNotEmpty) queryParams['source'] = source; + + final url = Uri.parse( + ApiConfig.leads, + ).replace(queryParameters: queryParams).toString(); + final response = await _apiService.get(url); + + if (!response.success || response.data == null) { + throw Exception(response.message ?? 'Failed to load leads'); + } + + final data = response.data!; + + // Backend returns open_leads + close_leads in two sub-objects. + final openLeadsData = data['open_leads'] as Map?; + final openLeadsList = + openLeadsData?['open_leads'] as List? ?? []; + final openLeadsCount = openLeadsData?['leads_count'] as int? ?? 0; + + final closeLeadsData = data['close_leads'] as Map?; + final closeLeadsList = + closeLeadsData?['close_leads'] as List? ?? []; + + final allLeadsList = [...openLeadsList, ...closeLeadsList]; + final newLeads = allLeadsList + .map((json) => Lead.fromJson(json as Map)) + .toList(); + + final totalCount = + openLeadsCount + (closeLeadsData?['leads_count'] as int? ?? 0); + + return LeadsListData( + leads: newLeads, + totalCount: totalCount, + hasMore: newLeads.length >= _pageSize, + currentOffset: offset + newLeads.length, + ); + } + + /// Fetch a single lead from the API (with comments). Future getLeadById(String id) async { try { final url = '${ApiConfig.leads}$id/'; @@ -68,7 +139,6 @@ class LeadsNotifier extends StateNotifier { if (leadData != null) { final lead = Lead.fromJson(leadData); - // Parse comments from separate field in response final commentsData = response.data!['comments'] as List?; if (commentsData != null && commentsData.isNotEmpty) { final comments = commentsData @@ -76,7 +146,6 @@ class LeadsNotifier extends StateNotifier { .toList(); return lead.copyWith(comments: comments); } - return lead; } } @@ -86,22 +155,18 @@ class LeadsNotifier extends StateNotifier { } } - /// Create a new lead Future>> createLead( Map leadData, ) async { try { final response = await _apiService.post(ApiConfig.leads, leadData); - if (response.success) { - await refresh(); - } + if (response.success) await refresh(); return response; } catch (e) { return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } - /// Update an existing lead Future>> updateLead( String id, Map leadData, @@ -109,16 +174,14 @@ class LeadsNotifier extends StateNotifier { try { final url = '${ApiConfig.leads}$id/'; final response = await _apiService.put(url, leadData); - if (response.success) { - await refresh(); - } + if (response.success) await refresh(); return response; } catch (e) { return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } - /// Update lead status (quick action) + /// Quick status change — optimistic local update. Future>> updateLeadStatus( String id, LeadStatus status, @@ -128,32 +191,37 @@ class LeadsNotifier extends StateNotifier { final response = await _apiService.patch(url, {'status': status.value}); if (response.success) { - // Update local state - final updatedLeads = state.leads.map((l) { - if (l.id == id) { - return l.copyWith(status: status); - } - return l; - }).toList(); - state = state.copyWith(leads: updatedLeads); + final current = state.value; + if (current != null) { + final updatedLeads = current.leads.map((l) { + if (l.id == id) return l.copyWith(status: status); + return l; + }).toList(); + state = AsyncValue.data(current.copyWith(leads: updatedLeads)); + } } - return response; } catch (e) { return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } - /// Delete a lead Future>> deleteLead(String id) async { try { final url = '${ApiConfig.leads}$id/'; final response = await _apiService.delete(url); if (response.success) { - state = state.copyWith( - leads: state.leads.where((l) => l.id != id).toList(), - totalCount: state.totalCount > 0 ? state.totalCount - 1 : 0, - ); + final current = state.value; + if (current != null) { + state = AsyncValue.data( + current.copyWith( + leads: current.leads.where((l) => l.id != id).toList(), + totalCount: current.totalCount > 0 + ? current.totalCount - 1 + : 0, + ), + ); + } } return response; } catch (e) { @@ -161,142 +229,37 @@ class LeadsNotifier extends StateNotifier { } } - /// Add a comment to a lead Future>> addComment( String leadId, String comment, ) async { try { final url = '${ApiConfig.leads}$leadId/'; - final response = await _apiService.post(url, {'comment': comment}); - return response; + return await _apiService.post(url, {'comment': comment}); } catch (e) { return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } - /// Delete a comment from a lead Future>> deleteComment( String commentId, ) async { try { final url = ApiConfig.leadComment(commentId); - final response = await _apiService.delete(url); - return response; + return await _apiService.delete(url); } catch (e) { return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } - - /// Fetch leads from API - Future fetchLeads({ - String? search, - String? status, - String? source, - bool refresh = false, - }) async { - if (state.isLoading) return; - - if (refresh) { - state = state.copyWith(currentOffset: 0, hasMore: true, clearError: true); - } - - state = state.copyWith(isLoading: true, clearError: true); - - try { - // Build query parameters - final queryParams = { - 'limit': _pageSize.toString(), - 'offset': state.currentOffset.toString(), - }; - - if (search != null && search.isNotEmpty) { - queryParams['search'] = search; - } - if (status != null && status.isNotEmpty) { - queryParams['status'] = status; - } - if (source != null && source.isNotEmpty) { - queryParams['source'] = source; - } - - final url = Uri.parse( - ApiConfig.leads, - ).replace(queryParameters: queryParams).toString(); - final response = await _apiService.get(url); - - if (response.success && response.data != null) { - final data = response.data!; - - // Parse open leads - final openLeadsData = data['open_leads'] as Map?; - final openLeadsList = - openLeadsData?['open_leads'] as List? ?? []; - final openLeadsCount = openLeadsData?['leads_count'] as int? ?? 0; - - // Parse close leads - final closeLeadsData = data['close_leads'] as Map?; - final closeLeadsList = - closeLeadsData?['close_leads'] as List? ?? []; - - // Combine all leads - final allLeadsList = [...openLeadsList, ...closeLeadsList]; - final newLeads = allLeadsList - .map((json) => Lead.fromJson(json as Map)) - .toList(); - - // Update state - final updatedLeads = refresh ? newLeads : [...state.leads, ...newLeads]; - final totalCount = - openLeadsCount + (closeLeadsData?['leads_count'] as int? ?? 0); - - state = state.copyWith( - leads: updatedLeads, - isLoading: false, - totalCount: totalCount, - hasMore: newLeads.length >= _pageSize, - currentOffset: state.currentOffset + newLeads.length, - ); - } else { - state = state.copyWith( - isLoading: false, - error: response.message ?? 'Failed to load leads', - ); - } - } catch (e) { - state = state.copyWith( - isLoading: false, - error: 'Failed to load leads: ${e.toString()}', - ); - } - } - - /// Refresh leads (reset and fetch first page) - Future refresh() async { - state = const LeadsState.initial(); - await fetchLeads(refresh: true); - } - - /// Load more leads - Future loadMore() async { - if (!state.hasMore || state.isLoading) return; - await fetchLeads(); - } - - /// Clear leads data - void clear() { - state = const LeadsState.initial(); - } } -/// Leads provider -final leadsProvider = StateNotifierProvider((ref) { - return LeadsNotifier(); -}); +final leadsProvider = AsyncNotifierProvider( + LeadsNotifier.new, +); -/// Convenience providers +/// Convenience providers — read from the AsyncValue. final leadsListProvider = Provider>((ref) { - return ref.watch(leadsProvider).leads; + return ref.watch(leadsProvider).value?.leads ?? const []; }); final leadsLoadingProvider = Provider((ref) { @@ -304,5 +267,5 @@ final leadsLoadingProvider = Provider((ref) { }); final leadsErrorProvider = Provider((ref) { - return ref.watch(leadsProvider).error; + return ref.watch(leadsProvider).error?.toString(); }); diff --git a/mobile/lib/providers/lookup_provider.dart b/mobile/lib/providers/lookup_provider.dart index 0cb6dd13..7c3de6ce 100644 --- a/mobile/lib/providers/lookup_provider.dart +++ b/mobile/lib/providers/lookup_provider.dart @@ -3,341 +3,161 @@ import '../config/api_config.dart'; import '../data/models/lookup_models.dart'; import '../services/api_service.dart'; -/// State for lookup data (accounts, contacts, users, tags) -class LookupState { - final List accounts; - final List contacts; - final List users; - final List tags; - final bool isLoadingAccounts; - final bool isLoadingContacts; - final bool isLoadingUsers; - final bool isLoadingTags; - final String? error; +/// Lookup providers — one AsyncNotifier per resource so the four fetches +/// (accounts / contacts / users / tags) cache, refresh and surface +/// loading/error independently. +/// +/// Forms watch the convenience `Provider>` wrappers below for the +/// resolved list, and read the underlying `AsyncNotifierProvider`s directly +/// when they need the loading flag. - const LookupState({ - this.accounts = const [], - this.contacts = const [], - this.users = const [], - this.tags = const [], - this.isLoadingAccounts = false, - this.isLoadingContacts = false, - this.isLoadingUsers = false, - this.isLoadingTags = false, - this.error, - }); +final ApiService _apiService = ApiService(); - const LookupState.initial() - : accounts = const [], - contacts = const [], - users = const [], - tags = const [], - isLoadingAccounts = false, - isLoadingContacts = false, - isLoadingUsers = false, - isLoadingTags = false, - error = null; +/// Accounts +class AccountsLookupNotifier extends AsyncNotifier> { + @override + Future> build() => _fetch(); - bool get isLoading => - isLoadingAccounts || isLoadingContacts || isLoadingUsers || isLoadingTags; + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_fetch); + } - LookupState copyWith({ - List? accounts, - List? contacts, - List? users, - List? tags, - bool? isLoadingAccounts, - bool? isLoadingContacts, - bool? isLoadingUsers, - bool? isLoadingTags, - String? error, - bool clearError = false, - }) { - return LookupState( - accounts: accounts ?? this.accounts, - contacts: contacts ?? this.contacts, - users: users ?? this.users, - tags: tags ?? this.tags, - isLoadingAccounts: isLoadingAccounts ?? this.isLoadingAccounts, - isLoadingContacts: isLoadingContacts ?? this.isLoadingContacts, - isLoadingUsers: isLoadingUsers ?? this.isLoadingUsers, - isLoadingTags: isLoadingTags ?? this.isLoadingTags, - error: clearError ? null : (error ?? this.error), + Future> _fetch() async { + final response = await _apiService.get(ApiConfig.accounts); + if (!response.success || response.data == null) { + throw Exception(response.message ?? 'Failed to load accounts'); + } + final data = response.data!; + List list = []; + if (data['accounts'] != null) { + list = data['accounts'] as List? ?? []; + } else if (data['results'] != null) { + list = data['results'] as List? ?? []; + } + return list + .whereType>() + .map(AccountLookup.fromJson) + .toList(); + } +} + +final accountsLookupProvider = + AsyncNotifierProvider>( + AccountsLookupNotifier.new, ); + +/// Contacts +class ContactsLookupNotifier extends AsyncNotifier> { + @override + Future> build() => _fetch(); + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_fetch); + } + + Future> _fetch() async { + final response = await _apiService.get(ApiConfig.contacts); + if (!response.success || response.data == null) { + throw Exception(response.message ?? 'Failed to load contacts'); + } + final data = response.data!; + List list = []; + if (data['contacts'] != null) { + list = data['contacts'] as List? ?? []; + } else if (data['results'] != null) { + list = data['results'] as List? ?? []; + } + return list + .whereType>() + .map(ContactLookup.fromJson) + .toList(); } } -/// Notifier for managing lookup data -class LookupNotifier extends StateNotifier { - LookupNotifier() : super(const LookupState.initial()); +final contactsLookupProvider = + AsyncNotifierProvider>( + ContactsLookupNotifier.new, + ); - final ApiService _apiService = ApiService(); +/// Users (active profiles) +class UsersLookupNotifier extends AsyncNotifier> { + @override + Future> build() => _fetch(); - /// Fetch all lookup data - Future fetchAll() async { - await Future.wait([ - fetchAccounts(), - fetchContacts(), - fetchUsers(), - fetchTags(), - ]); + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_fetch); } - /// Fetch accounts - Future fetchAccounts() async { - if (state.isLoadingAccounts) return; - - state = state.copyWith(isLoadingAccounts: true, clearError: true); - - try { - final response = await _apiService.get(ApiConfig.accounts); - - if (response.success && response.data != null) { - final data = response.data!; - - // Parse accounts - handle different response formats - List accountsList = []; - if (data['accounts'] != null) { - accountsList = data['accounts'] as List? ?? []; - } else if (data['results'] != null) { - accountsList = data['results'] as List? ?? []; - } - - final accounts = []; - for (final item in accountsList) { - if (item is Map) { - accounts.add(AccountLookup.fromJson(item)); - } - } - - state = state.copyWith(accounts: accounts, isLoadingAccounts: false); - } else { - state = state.copyWith( - isLoadingAccounts: false, - error: response.message ?? 'Failed to load accounts', - ); - } - } catch (e) { - state = state.copyWith( - isLoadingAccounts: false, - error: 'Failed to load accounts: ${e.toString()}', - ); + Future> _fetch() async { + final response = await _apiService.get(ApiConfig.teamsAndUsers); + if (!response.success || response.data == null) { + throw Exception(response.message ?? 'Failed to load users'); } - } - - /// Fetch contacts - Future fetchContacts() async { - if (state.isLoadingContacts) return; - - state = state.copyWith(isLoadingContacts: true, clearError: true); - - try { - final response = await _apiService.get(ApiConfig.contacts); - - if (response.success && response.data != null) { - final data = response.data!; - - // Parse contacts - handle different response formats - List contactsList = []; - if (data['contacts'] != null) { - contactsList = data['contacts'] as List? ?? []; - } else if (data['results'] != null) { - contactsList = data['results'] as List? ?? []; - } - - final contacts = []; - for (final item in contactsList) { - if (item is Map) { - contacts.add(ContactLookup.fromJson(item)); - } - } - - state = state.copyWith(contacts: contacts, isLoadingContacts: false); - } else { - state = state.copyWith( - isLoadingContacts: false, - error: response.message ?? 'Failed to load contacts', - ); - } - } catch (e) { - state = state.copyWith( - isLoadingContacts: false, - error: 'Failed to load contacts: ${e.toString()}', - ); - } - } - - /// Fetch users (profiles for assignment) - Future fetchUsers() async { - if (state.isLoadingUsers) return; - - state = state.copyWith(isLoadingUsers: true, clearError: true); - - try { - final response = await _apiService.get(ApiConfig.teamsAndUsers); - - if (response.success && response.data != null) { - final data = response.data!; - - // Parse profiles from the response - List profilesList = []; - if (data['profiles'] != null) { - profilesList = data['profiles'] as List? ?? []; - } - - final users = []; - for (final item in profilesList) { - if (item is Map) { - final user = UserLookup.fromJson(item); - // Only include active users - if (user.isActive) { - users.add(user); - } - } - } - - state = state.copyWith(users: users, isLoadingUsers: false); - } else { - state = state.copyWith( - isLoadingUsers: false, - error: response.message ?? 'Failed to load users', - ); - } - } catch (e) { - state = state.copyWith( - isLoadingUsers: false, - error: 'Failed to load users: ${e.toString()}', - ); - } - } - - /// Fetch tags - Future fetchTags() async { - if (state.isLoadingTags) return; - - state = state.copyWith(isLoadingTags: true, clearError: true); - - try { - final response = await _apiService.get(ApiConfig.tags); - - if (response.success && response.data != null) { - final data = response.data!; - - // Parse tags from the response - List tagsList = []; - if (data['tags'] != null) { - tagsList = data['tags'] as List? ?? []; - } else if (data['results'] != null) { - tagsList = data['results'] as List? ?? []; - } - - final tags = []; - for (final item in tagsList) { - if (item is Map) { - tags.add(TagLookup.fromJson(item)); - } - } - - state = state.copyWith(tags: tags, isLoadingTags: false); - } else { - state = state.copyWith( - isLoadingTags: false, - error: response.message ?? 'Failed to load tags', - ); - } - } catch (e) { - state = state.copyWith( - isLoadingTags: false, - error: 'Failed to load tags: ${e.toString()}', - ); - } - } - - /// Get account by ID - AccountLookup? getAccountById(String? id) { - if (id == null || id.isEmpty) return null; - try { - return state.accounts.firstWhere((a) => a.id == id); - } catch (_) { - return null; - } - } - - /// Get contact by ID - ContactLookup? getContactById(String? id) { - if (id == null || id.isEmpty) return null; - try { - return state.contacts.firstWhere((c) => c.id == id); - } catch (_) { - return null; - } - } - - /// Get user by ID - UserLookup? getUserById(String? id) { - if (id == null || id.isEmpty) return null; - try { - return state.users.firstWhere((u) => u.id == id); - } catch (_) { - return null; - } - } - - /// Get tag by ID - TagLookup? getTagById(String? id) { - if (id == null || id.isEmpty) return null; - try { - return state.tags.firstWhere((t) => t.id == id); - } catch (_) { - return null; - } - } - - /// Get contacts by IDs - List getContactsByIds(List ids) { - return state.contacts.where((c) => ids.contains(c.id)).toList(); - } - - /// Get users by IDs - List getUsersByIds(List ids) { - return state.users.where((u) => ids.contains(u.id)).toList(); - } - - /// Get tags by IDs - List getTagsByIds(List ids) { - return state.tags.where((t) => ids.contains(t.id)).toList(); - } - - /// Clear all data - void clear() { - state = const LookupState.initial(); + final profiles = response.data!['profiles'] as List? ?? []; + return profiles + .whereType>() + .map(UserLookup.fromJson) + .where((u) => u.isActive) + .toList(); } } -/// Main lookup provider -final lookupProvider = StateNotifierProvider(( - ref, -) { - return LookupNotifier(); -}); +final usersLookupProvider = + AsyncNotifierProvider>( + UsersLookupNotifier.new, + ); -/// Convenience providers +/// Tags +class TagsLookupNotifier extends AsyncNotifier> { + @override + Future> build() => _fetch(); + + Future refresh() async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard(_fetch); + } + + Future> _fetch() async { + final response = await _apiService.get(ApiConfig.tags); + if (!response.success || response.data == null) { + throw Exception(response.message ?? 'Failed to load tags'); + } + final data = response.data!; + List list = []; + if (data['tags'] != null) { + list = data['tags'] as List? ?? []; + } else if (data['results'] != null) { + list = data['results'] as List? ?? []; + } + return list + .whereType>() + .map(TagLookup.fromJson) + .toList(); + } +} + +final tagsLookupProvider = + AsyncNotifierProvider>( + TagsLookupNotifier.new, + ); + +/// Convenience wrappers — return the resolved list (or empty during +/// load/error). Forms that don't care about loading state watch these. final accountsProvider = Provider>((ref) { - return ref.watch(lookupProvider).accounts; + return ref.watch(accountsLookupProvider).value ?? const []; }); final contactsProvider = Provider>((ref) { - return ref.watch(lookupProvider).contacts; + return ref.watch(contactsLookupProvider).value ?? const []; }); final usersProvider = Provider>((ref) { - return ref.watch(lookupProvider).users; + return ref.watch(usersLookupProvider).value ?? const []; }); final tagsProvider = Provider>((ref) { - return ref.watch(lookupProvider).tags; -}); - -final lookupLoadingProvider = Provider((ref) { - return ref.watch(lookupProvider).isLoading; + return ref.watch(tagsLookupProvider).value ?? const []; }); diff --git a/mobile/lib/providers/tasks_provider.dart b/mobile/lib/providers/tasks_provider.dart index f57f45d3..6f3fcdf4 100644 --- a/mobile/lib/providers/tasks_provider.dart +++ b/mobile/lib/providers/tasks_provider.dart @@ -7,45 +7,28 @@ import '../services/api_service.dart'; export '../services/api_service.dart' show ApiResponse; -/// Tasks list state -class TasksState { +/// Paginated tasks snapshot — wrapped by AsyncValue. +class TasksListData { final List tasks; - final bool isLoading; - final String? error; final int totalCount; final bool hasMore; final int currentOffset; - const TasksState({ + const TasksListData({ this.tasks = const [], - this.isLoading = false, - this.error, this.totalCount = 0, this.hasMore = true, this.currentOffset = 0, }); - const TasksState.initial() - : tasks = const [], - isLoading = false, - error = null, - totalCount = 0, - hasMore = true, - currentOffset = 0; - - TasksState copyWith({ + TasksListData copyWith({ List? tasks, - bool? isLoading, - String? error, int? totalCount, bool? hasMore, int? currentOffset, - bool clearError = false, }) { - return TasksState( + return TasksListData( tasks: tasks ?? this.tasks, - isLoading: isLoading ?? this.isLoading, - error: clearError ? null : (error ?? this.error), totalCount: totalCount ?? this.totalCount, hasMore: hasMore ?? this.hasMore, currentOffset: currentOffset ?? this.currentOffset, @@ -53,293 +36,238 @@ class TasksState { } } -/// Tasks notifier for managing tasks state -class TasksNotifier extends StateNotifier { - TasksNotifier() : super(const TasksState.initial()); - +class TasksNotifier extends AsyncNotifier { final ApiService _apiService = ApiService(); static const int _pageSize = 20; - /// Fetch tasks from API - Future fetchTasks({ + @override + Future build() => _fetchPage(offset: 0); + + Future refresh({ String? search, String? status, String? priority, - bool refresh = false, }) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard( + () => _fetchPage( + offset: 0, + search: search, + status: status, + priority: priority, + ), + ); + } + + Future loadMore({ + String? search, + String? status, + String? priority, + }) async { + final current = state.value; + if (current == null || !current.hasMore) return; if (state.isLoading) return; - if (refresh) { - state = state.copyWith(currentOffset: 0, hasMore: true, clearError: true); - } - - debugPrint( - 'TasksNotifier: Fetching tasks (offset: ${state.currentOffset})...', - ); - state = state.copyWith(isLoading: true, clearError: true); - - try { - // Build query parameters - final queryParams = { - 'limit': _pageSize.toString(), - 'offset': state.currentOffset.toString(), - }; - - if (search != null && search.isNotEmpty) { - queryParams['search'] = search; - } - if (status != null && status.isNotEmpty) { - queryParams['status'] = status; - } - if (priority != null && priority.isNotEmpty) { - queryParams['priority'] = priority; - } - - final url = Uri.parse( - ApiConfig.tasks, - ).replace(queryParameters: queryParams).toString(); - final response = await _apiService.get(url); - - if (response.success && response.data != null) { - final data = response.data!; - - debugPrint('TasksNotifier: API response keys: ${data.keys.toList()}'); - - // Parse tasks - handle different response formats - List tasksList = []; - int tasksCount = 0; - - if (data['tasks'] != null) { - tasksList = data['tasks'] as List? ?? []; - tasksCount = data['tasks_count'] as int? ?? tasksList.length; - } else if (data['results'] != null) { - // Handle paginated response format - tasksList = data['results'] as List? ?? []; - tasksCount = data['count'] as int? ?? tasksList.length; - } - - debugPrint( - 'TasksNotifier: Found ${tasksList.length} tasks in response', - ); - - final newTasks = []; - for (final item in tasksList) { - try { - if (item is Map) { - newTasks.add(Task.fromJson(item)); - } else { - debugPrint( - 'TasksNotifier: Skipping non-map item: ${item.runtimeType}', - ); - } - } catch (e) { - debugPrint('TasksNotifier: Error parsing task: $e'); - debugPrint('TasksNotifier: Task data: $item'); - } - } - - // Update state - final updatedTasks = refresh ? newTasks : [...state.tasks, ...newTasks]; - - state = state.copyWith( - tasks: updatedTasks, - isLoading: false, - totalCount: tasksCount, - hasMore: newTasks.length >= _pageSize, - currentOffset: state.currentOffset + newTasks.length, - ); - - debugPrint( - 'TasksNotifier: Loaded ${newTasks.length} tasks (total: ${updatedTasks.length})', - ); - } else { - state = state.copyWith( - isLoading: false, - error: response.message ?? 'Failed to load tasks', - ); - debugPrint('TasksNotifier: API error - ${response.message}'); - } - } catch (e) { - debugPrint('TasksNotifier: Exception - $e'); - state = state.copyWith( - isLoading: false, - error: 'Failed to load tasks: ${e.toString()}', + state = await AsyncValue.guard(() async { + final next = await _fetchPage( + offset: current.currentOffset, + search: search, + status: status, + priority: priority, ); + return current.copyWith( + tasks: [...current.tasks, ...next.tasks], + totalCount: next.totalCount, + hasMore: next.hasMore, + currentOffset: next.currentOffset, + ); + }); + } + + Future _fetchPage({ + required int offset, + String? search, + String? status, + String? priority, + }) async { + final queryParams = { + 'limit': _pageSize.toString(), + 'offset': offset.toString(), + }; + if (search != null && search.isNotEmpty) queryParams['search'] = search; + if (status != null && status.isNotEmpty) queryParams['status'] = status; + if (priority != null && priority.isNotEmpty) { + queryParams['priority'] = priority; } + + final url = Uri.parse( + ApiConfig.tasks, + ).replace(queryParameters: queryParams).toString(); + debugPrint('TasksNotifier: Fetching tasks (offset: $offset)...'); + final response = await _apiService.get(url); + + if (!response.success || response.data == null) { + throw Exception(response.message ?? 'Failed to load tasks'); + } + + final data = response.data!; + debugPrint('TasksNotifier: API response keys: ${data.keys.toList()}'); + + List tasksList = []; + int tasksCount = 0; + + if (data['tasks'] != null) { + tasksList = data['tasks'] as List? ?? []; + tasksCount = data['tasks_count'] as int? ?? tasksList.length; + } else if (data['results'] != null) { + tasksList = data['results'] as List? ?? []; + tasksCount = data['count'] as int? ?? tasksList.length; + } + + final newTasks = []; + for (final item in tasksList) { + try { + if (item is Map) { + newTasks.add(Task.fromJson(item)); + } + } catch (e) { + debugPrint('TasksNotifier: Error parsing task: $e'); + } + } + + debugPrint('TasksNotifier: loaded ${newTasks.length} tasks'); + + return TasksListData( + tasks: newTasks, + totalCount: tasksCount, + hasMore: newTasks.length >= _pageSize, + currentOffset: offset + newTasks.length, + ); } - /// Refresh tasks (reset and fetch first page) - Future refresh() async { - state = const TasksState.initial(); - await fetchTasks(refresh: true); - } - - /// Load more tasks - Future loadMore() async { - if (!state.hasMore || state.isLoading) return; - await fetchTasks(); - } - - /// Clear tasks data - void clear() { - state = const TasksState.initial(); - } - - /// Get a single task by ID + /// Fetch a single task from the API. Future getTaskById(String taskId) async { try { final url = '${ApiConfig.tasks}$taskId/'; - debugPrint('TasksNotifier: Fetching task $taskId'); - final response = await _apiService.get(url); if (response.success && response.data != null) { - // API returns { "task_obj": {...}, "attachments": [...], ... } final taskData = response.data!['task_obj'] as Map?; if (taskData != null) { - debugPrint( - 'TasksNotifier: Task data keys: ${taskData.keys.toList()}', - ); return Task.fromJson(taskData); } - debugPrint('TasksNotifier: task_obj not found in response'); - return null; - } else { - debugPrint('TasksNotifier: Failed to get task - ${response.message}'); - return null; } + return null; } catch (e) { debugPrint('TasksNotifier: Exception getting task - $e'); return null; } } - /// Create a new task Future>> createTask( Map data, ) async { try { - debugPrint('TasksNotifier: Creating task'); final response = await _apiService.post(ApiConfig.tasks, data); - - if (response.success) { - // Refresh the list to include the new task - await fetchTasks(refresh: true); - } - + if (response.success) await refresh(); return response; } catch (e) { - debugPrint('TasksNotifier: Exception creating task - $e'); return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } - /// Update an existing task Future>> updateTask( String taskId, Map data, ) async { try { final url = '${ApiConfig.tasks}$taskId/'; - debugPrint('TasksNotifier: Updating task $taskId'); - final response = await _apiService.put(url, data); - - if (response.success) { - debugPrint('TasksNotifier: Update successful, refreshing task list'); - // Refresh the list to get updated data from server - await fetchTasks(refresh: true); - } - + if (response.success) await refresh(); return response; } catch (e) { - debugPrint('TasksNotifier: Exception updating task - $e'); return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } - /// Delete a task Future>> deleteTask(String taskId) async { try { final url = '${ApiConfig.tasks}$taskId/'; - debugPrint('TasksNotifier: Deleting task $taskId'); - final response = await _apiService.delete(url); if (response.success || response.statusCode == 204) { - // Remove from local state - state = state.copyWith( - tasks: state.tasks.where((t) => t.id != taskId).toList(), - totalCount: state.totalCount - 1, - ); + final current = state.value; + if (current != null) { + state = AsyncValue.data( + current.copyWith( + tasks: current.tasks.where((t) => t.id != taskId).toList(), + totalCount: current.totalCount - 1, + ), + ); + } return ApiResponse(success: true, statusCode: 204); } return response; } catch (e) { - debugPrint('TasksNotifier: Exception deleting task - $e'); return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } - /// Toggle task completion status + /// Toggle a task's completion (helper for the UI checkbox). Future>> toggleTaskStatus(Task task) async { final newStatus = task.completed ? 'New' : 'Completed'; return patchTask(task.id, {'status': newStatus}); } - /// Partially update a task (PATCH) + /// Partially update a task — optimistically applies changes locally. Future>> patchTask( String taskId, Map data, ) async { try { final url = '${ApiConfig.tasks}$taskId/'; - debugPrint('TasksNotifier: Patching task $taskId with $data'); - final response = await _apiService.patch(url, data); if (response.success) { - debugPrint( - 'TasksNotifier: Patch successful, updating local state optimistically', - ); - // Update local state optimistically based on the patch data - state = state.copyWith( - tasks: state.tasks.map((t) { - if (t.id == taskId) { - // Apply patch data to existing task - return t.copyWith( - status: data.containsKey('status') - ? TaskStatus.fromString(data['status'] as String?) - : null, - priority: data.containsKey('priority') - ? Priority.fromString(data['priority'] as String?) - : null, - title: data['title'] as String?, - description: data['description'] as String?, - ); - } - return t; - }).toList(), - ); + final current = state.value; + if (current != null) { + state = AsyncValue.data( + current.copyWith( + tasks: current.tasks.map((t) { + if (t.id == taskId) { + return t.copyWith( + status: data.containsKey('status') + ? TaskStatus.fromString(data['status'] as String?) + : null, + priority: data.containsKey('priority') + ? Priority.fromString(data['priority'] as String?) + : null, + title: data['title'] as String?, + description: data['description'] as String?, + ); + } + return t; + }).toList(), + ), + ); + } } return response; } catch (e) { - debugPrint('TasksNotifier: Exception patching task - $e'); return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } } -/// Tasks provider -final tasksProvider = StateNotifierProvider((ref) { - return TasksNotifier(); -}); +final tasksProvider = AsyncNotifierProvider( + TasksNotifier.new, +); -/// Convenience providers +/// Convenience providers — read from the AsyncValue. final tasksListProvider = Provider>((ref) { - return ref.watch(tasksProvider).tasks; + return ref.watch(tasksProvider).value?.tasks ?? const []; }); final tasksLoadingProvider = Provider((ref) { @@ -347,10 +275,10 @@ final tasksLoadingProvider = Provider((ref) { }); final tasksErrorProvider = Provider((ref) { - return ref.watch(tasksProvider).error; + return ref.watch(tasksProvider).error?.toString(); }); -/// Grouped tasks providers +/// Grouped tasks providers (filters / sorters on top of the loaded list). final overdueTasksProvider = Provider>((ref) { final tasks = ref.watch(tasksListProvider); return tasks.where((t) => t.isOverdue).toList()..sort((a, b) { diff --git a/mobile/lib/providers/tickets_provider.dart b/mobile/lib/providers/tickets_provider.dart index 3f129daf..35d0b8c4 100644 --- a/mobile/lib/providers/tickets_provider.dart +++ b/mobile/lib/providers/tickets_provider.dart @@ -4,45 +4,28 @@ import '../data/models/ticket.dart'; import '../data/models/comment.dart'; import '../services/api_service.dart'; -/// Tickets list state -class TicketsState { +/// Paginated tickets snapshot — wrapped by AsyncValue. +class TicketsListData { final List tickets; - final bool isLoading; - final String? error; final int totalCount; final bool hasMore; final int currentOffset; - const TicketsState({ + const TicketsListData({ this.tickets = const [], - this.isLoading = false, - this.error, this.totalCount = 0, this.hasMore = true, this.currentOffset = 0, }); - const TicketsState.initial() - : tickets = const [], - isLoading = false, - error = null, - totalCount = 0, - hasMore = true, - currentOffset = 0; - - TicketsState copyWith({ + TicketsListData copyWith({ List? tickets, - bool? isLoading, - String? error, int? totalCount, bool? hasMore, int? currentOffset, - bool clearError = false, }) { - return TicketsState( + return TicketsListData( tickets: tickets ?? this.tickets, - isLoading: isLoading ?? this.isLoading, - error: clearError ? null : (error ?? this.error), totalCount: totalCount ?? this.totalCount, hasMore: hasMore ?? this.hasMore, currentOffset: currentOffset ?? this.currentOffset, @@ -50,21 +33,109 @@ class TicketsState { } } -/// Tickets notifier mirroring the leads notifier -class TicketsNotifier extends StateNotifier { - TicketsNotifier() : super(const TicketsState.initial()); - +class TicketsNotifier extends AsyncNotifier { final ApiService _apiService = ApiService(); static const int _pageSize = 20; - /// Get a single ticket by ID, returning the full detail (incl. comments). + @override + Future build() => _fetchPage(offset: 0); + + Future refresh({ + String? search, + String? status, + String? priority, + String? assignedTo, + }) async { + state = const AsyncValue.loading(); + state = await AsyncValue.guard( + () => _fetchPage( + offset: 0, + search: search, + status: status, + priority: priority, + assignedTo: assignedTo, + ), + ); + } + + Future loadMore({ + String? search, + String? status, + String? priority, + String? assignedTo, + }) async { + final current = state.value; + if (current == null || !current.hasMore) return; + if (state.isLoading) return; + + state = await AsyncValue.guard(() async { + final next = await _fetchPage( + offset: current.currentOffset, + search: search, + status: status, + priority: priority, + assignedTo: assignedTo, + ); + return current.copyWith( + tickets: [...current.tickets, ...next.tickets], + totalCount: next.totalCount, + hasMore: next.hasMore, + currentOffset: next.currentOffset, + ); + }); + } + + Future _fetchPage({ + required int offset, + String? search, + String? status, + String? priority, + String? assignedTo, + }) async { + final queryParams = { + 'limit': _pageSize.toString(), + 'offset': offset.toString(), + }; + if (search != null && search.isNotEmpty) queryParams['search'] = search; + if (status != null && status.isNotEmpty) queryParams['status'] = status; + if (priority != null && priority.isNotEmpty) { + queryParams['priority'] = priority; + } + if (assignedTo != null && assignedTo.isNotEmpty) { + queryParams['assigned_to'] = assignedTo; + } + + final url = Uri.parse( + ApiConfig.tickets, + ).replace(queryParameters: queryParams).toString(); + final response = await _apiService.get(url); + + if (!response.success || response.data == null) { + throw Exception(response.message ?? 'Failed to load tickets'); + } + + final data = response.data!; + final ticketsList = data['cases'] as List? ?? []; + final newTickets = ticketsList + .map((j) => Ticket.fromJson(j as Map)) + .toList(); + final totalCount = data['cases_count'] as int? ?? newTickets.length; + + return TicketsListData( + tickets: newTickets, + totalCount: totalCount, + hasMore: newTickets.length >= _pageSize, + currentOffset: offset + newTickets.length, + ); + } + + /// Fetch a single ticket (just the Ticket object). Future getTicketById(String id) async { final detail = await getTicketDetail(id); return detail?.ticketObj; } - /// Get the full ticket detail including activities + comment permission. - /// Returns null if the ticket can't be loaded. + /// Fetch the full ticket detail (incl. comments + activities + permissions). Future getTicketDetail(String id) async { try { final response = await _apiService.get(ApiConfig.ticketDetail(id)); @@ -75,7 +146,7 @@ class TicketsNotifier extends StateNotifier { var ticketObj = Ticket.fromJson(ticketData); // Public + internal comments are sent in two separate arrays. We tag - // each one so the UI can render an "Internal" badge without an extra + // each so the UI can render an "Internal" badge without an extra // round-trip — `Comment` itself doesn't carry that flag yet. final List<_TaggedComment> tagged = []; for (final c in (response.data!['comments'] as List? ?? [])) { @@ -123,22 +194,18 @@ class TicketsNotifier extends StateNotifier { } } - /// Create a new ticket Future>> createTicket( Map ticketData, ) async { try { final response = await _apiService.post(ApiConfig.tickets, ticketData); - if (response.success) { - await refresh(); - } + if (response.success) await refresh(); return response; } catch (e) { return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } - /// Update an existing ticket Future>> updateTicket( String id, Map ticketData, @@ -148,16 +215,13 @@ class TicketsNotifier extends StateNotifier { ApiConfig.ticketDetail(id), ticketData, ); - if (response.success) { - await refresh(); - } + if (response.success) await refresh(); return response; } catch (e) { return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } - /// Add a comment (or internal note) to a ticket. Future>> addComment( String ticketId, String comment, { @@ -174,113 +238,35 @@ class TicketsNotifier extends StateNotifier { } } - /// Delete a ticket Future>> deleteTicket(String id) async { try { final response = await _apiService.delete(ApiConfig.ticketDetail(id)); if (response.success) { - state = state.copyWith( - tickets: state.tickets.where((c) => c.id != id).toList(), - totalCount: state.totalCount > 0 ? state.totalCount - 1 : 0, - ); + final current = state.value; + if (current != null) { + state = AsyncValue.data( + current.copyWith( + tickets: current.tickets.where((t) => t.id != id).toList(), + totalCount: current.totalCount > 0 ? current.totalCount - 1 : 0, + ), + ); + } } return response; } catch (e) { return ApiResponse(success: false, message: e.toString(), statusCode: 0); } } - - /// Fetch the tickets list with optional filters. - Future fetchTickets({ - String? search, - String? status, - String? priority, - String? assignedTo, - bool refresh = false, - }) async { - if (state.isLoading) return; - - if (refresh) { - state = state.copyWith(currentOffset: 0, hasMore: true, clearError: true); - } - - state = state.copyWith(isLoading: true, clearError: true); - - try { - final queryParams = { - 'limit': _pageSize.toString(), - 'offset': state.currentOffset.toString(), - }; - if (search != null && search.isNotEmpty) queryParams['search'] = search; - if (status != null && status.isNotEmpty) queryParams['status'] = status; - if (priority != null && priority.isNotEmpty) { - queryParams['priority'] = priority; - } - if (assignedTo != null && assignedTo.isNotEmpty) { - queryParams['assigned_to'] = assignedTo; - } - - final url = Uri.parse( - ApiConfig.tickets, - ).replace(queryParameters: queryParams).toString(); - final response = await _apiService.get(url); - - if (response.success && response.data != null) { - final data = response.data!; - final ticketsList = data['cases'] as List? ?? []; - final newTickets = ticketsList - .map((j) => Ticket.fromJson(j as Map)) - .toList(); - - final updatedTickets = refresh - ? newTickets - : [...state.tickets, ...newTickets]; - final totalCount = data['cases_count'] as int? ?? updatedTickets.length; - - state = state.copyWith( - tickets: updatedTickets, - isLoading: false, - totalCount: totalCount, - hasMore: newTickets.length >= _pageSize, - currentOffset: state.currentOffset + newTickets.length, - ); - } else { - state = state.copyWith( - isLoading: false, - error: response.message ?? 'Failed to load tickets', - ); - } - } catch (e) { - state = state.copyWith( - isLoading: false, - error: 'Failed to load tickets: ${e.toString()}', - ); - } - } - - Future refresh() async { - state = const TicketsState.initial(); - await fetchTickets(refresh: true); - } - - Future loadMore() async { - if (!state.hasMore || state.isLoading) return; - await fetchTickets(); - } - - void clear() { - state = const TicketsState.initial(); - } } -final ticketsProvider = StateNotifierProvider(( - ref, -) { - return TicketsNotifier(); -}); +final ticketsProvider = + AsyncNotifierProvider( + TicketsNotifier.new, + ); +/// Convenience providers — read from the AsyncValue. final ticketsListProvider = Provider>((ref) { - return ref.watch(ticketsProvider).tickets; + return ref.watch(ticketsProvider).value?.tickets ?? const []; }); final ticketsLoadingProvider = Provider((ref) { @@ -288,7 +274,7 @@ final ticketsLoadingProvider = Provider((ref) { }); final ticketsErrorProvider = Provider((ref) { - return ref.watch(ticketsProvider).error; + return ref.watch(ticketsProvider).error?.toString(); }); /// Bundled detail-fetch result. The mobile detail screen needs more than diff --git a/mobile/lib/screens/dashboard/dashboard_screen.dart b/mobile/lib/screens/dashboard/dashboard_screen.dart index 145a491c..f2916f14 100644 --- a/mobile/lib/screens/dashboard/dashboard_screen.dart +++ b/mobile/lib/screens/dashboard/dashboard_screen.dart @@ -19,18 +19,9 @@ class DashboardScreen extends ConsumerStatefulWidget { } class _DashboardScreenState extends ConsumerState { - @override - void initState() { - super.initState(); - // Fetch dashboard data when screen loads - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(dashboardProvider.notifier).fetchDashboard(); - }); - } - @override Widget build(BuildContext context) { - final dashboardState = ref.watch(dashboardProvider); + final dashboardAsync = ref.watch(dashboardProvider); final authState = ref.watch(authProvider); final userName = authState.user?.displayName ?? 'User'; final currencySymbol = @@ -81,7 +72,7 @@ class _DashboardScreenState extends ConsumerState { // Content SliverToBoxAdapter( child: _buildContent( - dashboardState, + dashboardAsync, currencyFormat, compactCurrencyFormat, ), @@ -94,18 +85,21 @@ class _DashboardScreenState extends ConsumerState { } Widget _buildContent( - DashboardState dashboardState, + AsyncValue dashboardAsync, NumberFormat currencyFormat, NumberFormat compactCurrencyFormat, ) { - if (dashboardState.isLoading && dashboardState.data == null) { + // Stale-while-loading: keep showing prior data during refresh. + final data = dashboardAsync.value; + + if (data == null && dashboardAsync.isLoading) { return const SizedBox( height: 400, child: Center(child: CircularProgressIndicator()), ); } - if (dashboardState.error != null && dashboardState.data == null) { + if (data == null && dashboardAsync.hasError) { return SizedBox( height: 400, child: Center( @@ -129,8 +123,15 @@ class _DashboardScreenState extends ConsumerState { ); } - final data = dashboardState.data ?? const DashboardData(); + final resolved = data ?? const DashboardData(); + return _buildBody(resolved, currencyFormat, compactCurrencyFormat); + } + Widget _buildBody( + DashboardData data, + NumberFormat currencyFormat, + NumberFormat compactCurrencyFormat, + ) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ diff --git a/mobile/lib/screens/deals/deal_form_screen.dart b/mobile/lib/screens/deals/deal_form_screen.dart index 7820d1fb..61f2a928 100644 --- a/mobile/lib/screens/deals/deal_form_screen.dart +++ b/mobile/lib/screens/deals/deal_form_screen.dart @@ -49,11 +49,6 @@ class _DealFormScreenState extends ConsumerState { super.initState(); _probabilityController.text = _stage.defaultProbability.toString(); - // Fetch lookup data - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(lookupProvider.notifier).fetchAll(); - }); - if (widget.initialDeal != null) { _populateFromDeal(widget.initialDeal!); } else if (widget.isEditMode) { @@ -185,8 +180,8 @@ class _DealFormScreenState extends ConsumerState { _stage.defaultProbability; // Get account name from lookup - final lookupState = ref.read(lookupProvider); - final account = lookupState.accounts + final accounts = ref.read(accountsProvider); + final account = accounts .where((a) => a.id == _selectedAccountId) .firstOrNull; @@ -580,7 +575,15 @@ class _DealFormScreenState extends ConsumerState { } Widget _buildRelationshipFields() { - final lookupState = ref.watch(lookupProvider); + final accountsAsync = ref.watch(accountsLookupProvider); + final contactsAsync = ref.watch(contactsLookupProvider); + final usersAsync = ref.watch(usersLookupProvider); + final tagsAsync = ref.watch(tagsLookupProvider); + + final accounts = accountsAsync.value ?? const []; + final contacts = contactsAsync.value ?? const []; + final users = usersAsync.value ?? const []; + final tags = tagsAsync.value ?? const []; return Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -589,14 +592,14 @@ class _DealFormScreenState extends ConsumerState { _buildSingleSelectField( label: 'Account', value: _selectedAccountId != null - ? lookupState.accounts + ? accounts .where((a) => a.id == _selectedAccountId) .firstOrNull ?.name : null, placeholder: 'Select account', icon: LucideIcons.building2, - isLoading: lookupState.isLoadingAccounts, + isLoading: accountsAsync.isLoading, onTap: _showAccountPicker, onClear: _selectedAccountId != null ? () => setState(() => _selectedAccountId = null) @@ -612,7 +615,7 @@ class _DealFormScreenState extends ConsumerState { selectedItems: _selectedContactIds .map( (id) => - lookupState.contacts + contacts .where((c) => c.id == id) .firstOrNull ?.fullName ?? @@ -622,7 +625,7 @@ class _DealFormScreenState extends ConsumerState { .toList(), placeholder: 'Select contacts', icon: LucideIcons.users, - isLoading: lookupState.isLoadingContacts, + isLoading: contactsAsync.isLoading, onTap: _showContactsPicker, ), @@ -635,7 +638,7 @@ class _DealFormScreenState extends ConsumerState { selectedItems: _selectedAssignedToIds .map( (id) => - lookupState.users + users .where((u) => u.id == id) .firstOrNull ?.displayName ?? @@ -645,7 +648,7 @@ class _DealFormScreenState extends ConsumerState { .toList(), placeholder: 'Select assignees', icon: LucideIcons.userCheck, - isLoading: lookupState.isLoadingUsers, + isLoading: usersAsync.isLoading, onTap: _showAssignedToPicker, ), @@ -658,7 +661,7 @@ class _DealFormScreenState extends ConsumerState { selectedItems: _selectedTagIds .map( (id) => - lookupState.tags + tags .where((t) => t.id == id) .firstOrNull ?.name ?? @@ -668,7 +671,7 @@ class _DealFormScreenState extends ConsumerState { .toList(), placeholder: 'Select tags', icon: LucideIcons.tag, - isLoading: lookupState.isLoadingTags, + isLoading: tagsAsync.isLoading, onTap: _showTagsPicker, ), ], @@ -1132,7 +1135,7 @@ class _DealFormScreenState extends ConsumerState { } void _showAccountPicker() { - final accounts = ref.read(lookupProvider).accounts; + final accounts = ref.read(accountsProvider); showModalBottomSheet( context: context, backgroundColor: AppColors.surface, @@ -1168,7 +1171,7 @@ class _DealFormScreenState extends ConsumerState { } void _showContactsPicker() { - final contacts = ref.read(lookupProvider).contacts; + final contacts = ref.read(contactsProvider); showModalBottomSheet( context: context, backgroundColor: AppColors.surface, @@ -1207,7 +1210,7 @@ class _DealFormScreenState extends ConsumerState { } void _showAssignedToPicker() { - final users = ref.read(lookupProvider).users; + final users = ref.read(usersProvider); showModalBottomSheet( context: context, backgroundColor: AppColors.surface, @@ -1245,7 +1248,7 @@ class _DealFormScreenState extends ConsumerState { } void _showTagsPicker() { - final tags = ref.read(lookupProvider).tags; + final tags = ref.read(tagsProvider); showModalBottomSheet( context: context, backgroundColor: AppColors.surface, diff --git a/mobile/lib/screens/deals/deals_list_screen.dart b/mobile/lib/screens/deals/deals_list_screen.dart index 071de76f..232ae877 100644 --- a/mobile/lib/screens/deals/deals_list_screen.dart +++ b/mobile/lib/screens/deals/deals_list_screen.dart @@ -38,15 +38,6 @@ class _DealsListScreenState extends ConsumerState { DealStage.closedWon, ]; - @override - void initState() { - super.initState(); - // Fetch deals on screen load - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(dealsProvider.notifier).fetchDeals(refresh: true); - }); - } - @override void dispose() { _searchController.dispose(); diff --git a/mobile/lib/screens/leads/lead_detail_screen.dart b/mobile/lib/screens/leads/lead_detail_screen.dart index 659d7c90..2ff68440 100644 --- a/mobile/lib/screens/leads/lead_detail_screen.dart +++ b/mobile/lib/screens/leads/lead_detail_screen.dart @@ -616,7 +616,7 @@ class _LeadDetailScreenState extends ConsumerState color: AppColors.textSecondary, ), ), - if (action != null) action, + ?action, ], ), const SizedBox(height: 12), diff --git a/mobile/lib/screens/leads/lead_form_screen.dart b/mobile/lib/screens/leads/lead_form_screen.dart index 46387393..aa7a79ce 100644 --- a/mobile/lib/screens/leads/lead_form_screen.dart +++ b/mobile/lib/screens/leads/lead_form_screen.dart @@ -53,10 +53,6 @@ class _LeadFormScreenState extends ConsumerState { @override void initState() { super.initState(); - // Fetch lookup data - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(lookupProvider.notifier).fetchAll(); - }); if (widget.initialLead != null) { _populateFromLead(widget.initialLead!); @@ -617,7 +613,8 @@ class _LeadFormScreenState extends ConsumerState { } Widget _buildRelationshipFields() { - final lookupState = ref.watch(lookupProvider); + final users = ref.watch(usersProvider); + final tags = ref.watch(tagsProvider); return Column( children: [ @@ -626,7 +623,7 @@ class _LeadFormScreenState extends ConsumerState { label: 'Assigned To', icon: LucideIcons.users, selectedCount: _assignedToIds.length, - selectedLabel: _getSelectedUsersLabel(lookupState.users), + selectedLabel: _getSelectedUsersLabel(users), onTap: () => _showAssignedToPicker(), ), @@ -637,7 +634,7 @@ class _LeadFormScreenState extends ConsumerState { label: 'Tags', icon: LucideIcons.tag, selectedCount: _tagIds.length, - selectedLabel: _getSelectedTagsLabel(lookupState.tags), + selectedLabel: _getSelectedTagsLabel(tags), onTap: () => _showTagsPicker(), ), ], @@ -868,7 +865,7 @@ class _LeadFormScreenState extends ConsumerState { } void _showAssignedToPicker() { - final users = ref.read(lookupProvider).users; + final users = ref.read(usersProvider); showModalBottomSheet( context: context, @@ -892,7 +889,7 @@ class _LeadFormScreenState extends ConsumerState { } void _showTagsPicker() { - final tags = ref.read(lookupProvider).tags; + final tags = ref.read(tagsProvider); showModalBottomSheet( context: context, diff --git a/mobile/lib/screens/leads/leads_list_screen.dart b/mobile/lib/screens/leads/leads_list_screen.dart index 4b0a7134..7f761b6b 100644 --- a/mobile/lib/screens/leads/leads_list_screen.dart +++ b/mobile/lib/screens/leads/leads_list_screen.dart @@ -30,12 +30,6 @@ class _LeadsListScreenState extends ConsumerState { @override void initState() { super.initState(); - // Fetch leads when screen loads - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(leadsProvider.notifier).fetchLeads(refresh: true); - }); - - // Setup scroll listener for pagination _scrollController.addListener(_onScroll); } @@ -124,8 +118,9 @@ class _LeadsListScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final leadsState = ref.watch(leadsProvider); - final filteredLeads = _filterAndSortLeads(leadsState.leads); + final leadsAsync = ref.watch(leadsProvider); + final data = leadsAsync.value; + final filteredLeads = _filterAndSortLeads(data?.leads ?? const []); return Scaffold( backgroundColor: AppColors.surfaceDim, @@ -150,23 +145,28 @@ class _LeadsListScreenState extends ConsumerState { _buildFilterBar(), // Results Count - _buildResultsCount(filteredLeads.length, leadsState.totalCount), + _buildResultsCount(filteredLeads.length, data?.totalCount ?? 0), // Leads List - Expanded(child: _buildLeadsList(leadsState, filteredLeads)), + Expanded(child: _buildLeadsList(leadsAsync, filteredLeads)), ], ), ); } - Widget _buildLeadsList(LeadsState state, List filteredLeads) { + Widget _buildLeadsList( + AsyncValue async, + List filteredLeads, + ) { + final data = async.value; + // Initial loading - if (state.isLoading && state.leads.isEmpty) { + if (async.isLoading && (data == null || data.leads.isEmpty)) { return const Center(child: CircularProgressIndicator()); } // Error state - if (state.error != null && state.leads.isEmpty) { + if (async.hasError && (data == null || data.leads.isEmpty)) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -179,7 +179,7 @@ class _LeadsListScreenState extends ConsumerState { ), const SizedBox(height: 8), Text( - state.error!, + async.error.toString(), style: AppTypography.caption, textAlign: TextAlign.center, ), @@ -198,6 +198,8 @@ class _LeadsListScreenState extends ConsumerState { return _buildEmptyState(); } + final hasMore = data?.hasMore ?? false; + // Leads list return RefreshIndicator( onRefresh: () => ref.read(leadsProvider.notifier).refresh(), @@ -205,11 +207,9 @@ class _LeadsListScreenState extends ConsumerState { controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 0, 12, 80), itemCount: - filteredLeads.length + - (state.hasMore && !_hasActiveFilters ? 1 : 0), + filteredLeads.length + (hasMore && !_hasActiveFilters ? 1 : 0), itemBuilder: (context, index) { if (index == filteredLeads.length) { - // Loading indicator at bottom return const Padding( padding: EdgeInsets.all(12), child: Center(child: CircularProgressIndicator()), diff --git a/mobile/lib/screens/tasks/tasks_list_screen.dart b/mobile/lib/screens/tasks/tasks_list_screen.dart index ad8a1135..dcd21835 100644 --- a/mobile/lib/screens/tasks/tasks_list_screen.dart +++ b/mobile/lib/screens/tasks/tasks_list_screen.dart @@ -26,14 +26,6 @@ class _TasksListScreenState extends ConsumerState { DateTime _selectedDay = DateTime.now(); CalendarFormat _calendarFormat = CalendarFormat.month; - @override - void initState() { - super.initState(); - // Fetch tasks on init - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(tasksProvider.notifier).fetchTasks(refresh: true); - }); - } // Get task dates for calendar markers Set _getTaskDates(List tasks) { @@ -57,10 +49,10 @@ class _TasksListScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final tasksState = ref.watch(tasksProvider); - final allTasks = tasksState.tasks; - final isLoading = tasksState.isLoading; - final error = tasksState.error; + final tasksAsync = ref.watch(tasksProvider); + final allTasks = tasksAsync.value?.tasks ?? const []; + final isLoading = tasksAsync.isLoading; + final error = tasksAsync.error?.toString(); return Scaffold( backgroundColor: AppColors.surfaceDim, @@ -132,7 +124,7 @@ class _TasksListScreenState extends ConsumerState { label: 'Retry', icon: LucideIcons.refreshCw, onPressed: () { - ref.read(tasksProvider.notifier).fetchTasks(refresh: true); + ref.read(tasksProvider.notifier).refresh(); }, ), ], diff --git a/mobile/lib/screens/tickets/ticket_form_screen.dart b/mobile/lib/screens/tickets/ticket_form_screen.dart index 476068b6..9584f716 100644 --- a/mobile/lib/screens/tickets/ticket_form_screen.dart +++ b/mobile/lib/screens/tickets/ticket_form_screen.dart @@ -38,9 +38,6 @@ class _TicketFormScreenState extends ConsumerState { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(lookupProvider.notifier).fetchAccounts(); - }); if (widget.isEditMode) _fetchTicket(); } @@ -174,7 +171,7 @@ class _TicketFormScreenState extends ConsumerState { ), ); } - final accounts = ref.watch(lookupProvider).accounts; + final accounts = ref.watch(accountsProvider); return Form( key: _formKey, diff --git a/mobile/lib/screens/tickets/tickets_list_screen.dart b/mobile/lib/screens/tickets/tickets_list_screen.dart index 60eef25d..c6f24619 100644 --- a/mobile/lib/screens/tickets/tickets_list_screen.dart +++ b/mobile/lib/screens/tickets/tickets_list_screen.dart @@ -28,9 +28,6 @@ class _TicketsListScreenState extends ConsumerState { @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - ref.read(ticketsProvider.notifier).fetchTickets(refresh: true); - }); _scrollController.addListener(_onScroll); } @@ -80,8 +77,9 @@ class _TicketsListScreenState extends ConsumerState { @override Widget build(BuildContext context) { - final state = ref.watch(ticketsProvider); - final filtered = _filterTickets(state.tickets); + final ticketsAsync = ref.watch(ticketsProvider); + final data = ticketsAsync.value; + final filtered = _filterTickets(data?.tickets ?? const []); return Scaffold( backgroundColor: AppColors.surfaceDim, @@ -101,18 +99,23 @@ class _TicketsListScreenState extends ConsumerState { children: [ _buildSearchBar(), _buildFilterBar(), - _buildResultsCount(filtered.length, state.totalCount), - Expanded(child: _buildList(state, filtered)), + _buildResultsCount(filtered.length, data?.totalCount ?? 0), + Expanded(child: _buildList(ticketsAsync, filtered)), ], ), ); } - Widget _buildList(TicketsState state, List filtered) { - if (state.isLoading && state.tickets.isEmpty) { + Widget _buildList( + AsyncValue async, + List filtered, + ) { + final data = async.value; + + if (async.isLoading && (data == null || data.tickets.isEmpty)) { return const Center(child: CircularProgressIndicator()); } - if (state.error != null && state.tickets.isEmpty) { + if (async.hasError && (data == null || data.tickets.isEmpty)) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -125,7 +128,7 @@ class _TicketsListScreenState extends ConsumerState { ), const SizedBox(height: 8), Text( - state.error!, + async.error.toString(), style: AppTypography.caption, textAlign: TextAlign.center, ), @@ -140,13 +143,15 @@ class _TicketsListScreenState extends ConsumerState { } if (filtered.isEmpty) return _buildEmptyState(); + final hasMore = data?.hasMore ?? false; + return RefreshIndicator( onRefresh: () => ref.read(ticketsProvider.notifier).refresh(), child: ListView.builder( controller: _scrollController, padding: const EdgeInsets.fromLTRB(12, 0, 12, 80), itemCount: - filtered.length + (state.hasMore && !_hasActiveFilters ? 1 : 0), + filtered.length + (hasMore && !_hasActiveFilters ? 1 : 0), itemBuilder: (context, index) { if (index == filtered.length) { return const Padding( diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 0b9f48fe..b6beb4cd 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -1,14 +1,30 @@ # Generated by pub # See https://dart.dev/tools/pub/glossary#lockfile packages: + _fe_analyzer_shared: + dependency: transitive + description: + name: _fe_analyzer_shared + sha256: "8d7ff3948166b8ec5da0fbb5962000926b8e02f2ed9b3e51d1738905fbd4c98d" + url: "https://pub.dev" + source: hosted + version: "93.0.0" + analyzer: + dependency: transitive + description: + name: analyzer + sha256: de7148ed2fcec579b19f122c1800933dfa028f6d9fd38a152b04b1516cec120b + url: "https://pub.dev" + source: hosted + version: "10.0.1" archive: dependency: transitive description: name: archive - sha256: "2fde1607386ab523f7a36bb3e7edb43bd58e6edaf2ffb29d8a6d578b297fdbbd" + sha256: a96e8b390886ee8abb49b7bd3ac8df6f451c621619f52a26e815fdcf568959ff url: "https://pub.dev" source: hosted - version: "4.0.7" + version: "4.0.9" args: dependency: transitive description: @@ -21,10 +37,10 @@ packages: dependency: transitive description: name: async - sha256: "758e6d74e971c3e5aceb4110bfd6698efc7f501675bcfe0c775459a8140750eb" + sha256: e2eb0491ba5ddb6177742d2da23904574082139b07c1e33b8503b9f46f3e1a37 url: "https://pub.dev" source: hosted - version: "2.13.0" + version: "2.13.1" boolean_selector: dependency: transitive description: @@ -49,6 +65,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.4" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" cli_util: dependency: transitive description: @@ -65,6 +89,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.2" + code_assets: + dependency: transitive + description: + name: code_assets + sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687" + url: "https://pub.dev" + source: hosted + version: "1.0.0" collection: dependency: transitive description: @@ -73,6 +105,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + convert: + dependency: transitive + description: + name: convert + sha256: b30acd5944035672bc15c6b7a8b47d773e41e2f17de064350988c5d02adb1c68 + url: "https://pub.dev" + source: hosted + version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" crypto: dependency: transitive description: @@ -85,18 +133,18 @@ packages: dependency: "direct main" description: name: cupertino_icons - sha256: ba631d1c7f7bef6b729a622b7b752645a2d076dba9976925b8f25725a30e1ee6 + sha256: "41e005c33bd814be4d3096aff55b1908d419fde52ca656c8c47719ec745873cd" url: "https://pub.dev" source: hosted - version: "1.0.8" + version: "1.0.9" equatable: dependency: transitive description: name: equatable - sha256: "567c64b3cb4cf82397aac55f4f0cbd3ca20d77c6c03bedbc4ceaddc08904aef7" + sha256: "3e0141505477fd8ad55d6eb4e7776d3fe8430be8e497ccb1521370c3f21a3e2b" url: "https://pub.dev" source: hosted - version: "2.0.7" + version: "2.0.8" fake_async: dependency: transitive description: @@ -109,10 +157,18 @@ packages: dependency: transitive description: name: ffi - sha256: "289279317b4b16eb2bb7e271abccd4bf84ec9bdcbe999e278a94b804f5630418" + sha256: "6d7fd89431262d8f3125e81b50d3847a091d846eafcd4fdb88dd06f36d705a45" url: "https://pub.dev" source: hosted - version: "2.1.4" + version: "2.2.0" + ffi_leak_tracker: + dependency: transitive + description: + name: ffi_leak_tracker + sha256: "4093d4ef9ca06ffe2786e73bfb25e22aa92112b9bb4ec941f11e3e6b61489a97" + url: "https://pub.dev" + source: hosted + version: "0.1.2" file: dependency: transitive description: @@ -125,10 +181,10 @@ packages: dependency: "direct main" description: name: fl_chart - sha256: "74959b99b92b9eebeed1a4049426fd67c4abc3c5a0f4d12e2877097d6a11ae08" + sha256: b938f77d042cbcd822936a7a359a7235bad8bd72070de1f827efc2cc297ac888 url: "https://pub.dev" source: hosted - version: "0.69.2" + version: "1.2.0" flutter: dependency: "direct main" description: flutter @@ -154,10 +210,10 @@ packages: dependency: "direct main" description: name: flutter_riverpod - sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + sha256: "4e166be88e1dbbaa34a280bdb744aeae73b7ef25fdf8db7a3bb776760a3648e2" url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.3.1" flutter_test: dependency: "direct dev" description: flutter @@ -168,22 +224,38 @@ packages: description: flutter source: sdk version: "0.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" + glob: + dependency: transitive + description: + name: glob + sha256: c3f1ee72c96f8f78935e18aa8cecced9ab132419e8625dc187e1c2408efc20de + url: "https://pub.dev" + source: hosted + version: "2.1.3" go_router: dependency: "direct main" description: name: go_router - sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + sha256: "92d8cee7c57dff0a6c409c05597b460002434eccf7424a712283225b3962d03f" url: "https://pub.dev" source: hosted - version: "14.8.1" + version: "17.2.3" google_fonts: dependency: "direct main" description: name: google_fonts - sha256: ba03d03bcaa2f6cb7bd920e3b5027181db75ab524f8891c8bc3aa603885b8055 + sha256: "4e9391085e524954a51e3625b7c9c7e9851dc3f376603208bb45c24b9a66255d" url: "https://pub.dev" source: hosted - version: "6.3.3" + version: "8.1.0" google_identity_services_web: dependency: transitive description: @@ -204,18 +276,18 @@ packages: dependency: transitive description: name: google_sign_in_android - sha256: "5ec98ab35387c68c0050495bb211bd88375873723a80fae7c2e9266ea0bdd8bb" + sha256: be0d0733a6a7c5da165879d844a239aa87587a3c767a9163faedde581f731f76 url: "https://pub.dev" source: hosted - version: "7.2.7" + version: "7.2.10" google_sign_in_ios: dependency: transitive description: name: google_sign_in_ios - sha256: aee8bb29484611a37607849175a2009d423de6bd7c129326c69fd7068f0b6ed7 + sha256: ac1e4c1205267cb7999d1d81333fccffdfda29e853f434bbaf71525498bb6950 url: "https://pub.dev" source: hosted - version: "6.2.4" + version: "6.3.0" google_sign_in_platform_interface: dependency: transitive description: @@ -228,10 +300,18 @@ packages: dependency: transitive description: name: google_sign_in_web - sha256: "2fc1f941e6443b2d6984f4056a727a3eaeab15d8ee99ba7125d79029be75a1da" + sha256: d473003eeca892f96a01a64fc803378be765071cb0c265ee872c7f8683245d14 url: "https://pub.dev" source: hosted - version: "1.1.0" + version: "1.1.3" + hooks: + dependency: transitive + description: + name: hooks + sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e" + url: "https://pub.dev" + source: hosted + version: "1.0.3" http: dependency: "direct main" description: @@ -240,6 +320,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.6.0" + http_multi_server: + dependency: transitive + description: + name: http_multi_server + sha256: aa6199f908078bb1c5efb8d8638d4ae191aac11b311132c3ef48ce352fb52ef8 + url: "https://pub.dev" + source: hosted + version: "3.2.2" http_parser: dependency: transitive description: @@ -252,26 +340,50 @@ packages: dependency: transitive description: name: image - sha256: "51555e36056541237b15b57afc31a0f53d4f9aefd9bd00873a6dc0090e54e332" + sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce url: "https://pub.dev" source: hosted - version: "4.6.0" + version: "4.8.0" intl: dependency: "direct main" description: name: intl - sha256: d6f56758b7d3014a48af9701c085700aac781a92a87a62b1333b46d8879661cf + sha256: "3df61194eb431efc39c4ceba583b95633a403f46c9fd341e550ce0bfa50e9aa5" url: "https://pub.dev" source: hosted - version: "0.19.0" + version: "0.20.2" + io: + dependency: transitive + description: + name: io + sha256: dfd5a80599cf0165756e3181807ed3e77daf6dd4137caaad72d0b7931597650b + url: "https://pub.dev" + source: hosted + version: "1.0.5" + jni: + dependency: transitive + description: + name: jni + sha256: c2230682d5bc2362c1c9e8d3c7f406d9cbba23ab3f2e203a025dd47e0fb2e68f + url: "https://pub.dev" + source: hosted + version: "1.0.0" + jni_flutter: + dependency: transitive + description: + name: jni_flutter + sha256: "8b59e590786050b1cd866677dddaf76b1ade5e7bc751abe04b86e84d379d3ba6" + url: "https://pub.dev" + source: hosted + version: "1.0.1" json_annotation: dependency: transitive description: name: json_annotation - sha256: "1ce844379ca14835a50d2f019a3099f419082cfdd231cd86a142af94dd5c6bb1" + sha256: cb09e7dac6210041fad964ed7fbee004f14258b4eca4040f72d1234062ace4c8 url: "https://pub.dev" source: hosted - version: "4.9.0" + version: "4.11.0" jwt_decoder: dependency: "direct main" description: @@ -308,10 +420,10 @@ packages: dependency: transitive description: name: lints - sha256: a5e2b223cb7c9c8efdc663ef484fdd95bb243bff242ef5b13e26883547fce9a0 + sha256: "12f842a479589fea194fe5c5a3095abc7be0c1f2ddfa9a0e76aed1dbd26a87df" url: "https://pub.dev" source: hosted - version: "6.0.0" + version: "6.1.0" logging: dependency: transitive description: @@ -352,22 +464,62 @@ packages: url: "https://pub.dev" source: hosted version: "1.17.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.dev" + source: hosted + version: "2.0.0" + native_toolchain_c: + dependency: transitive + description: + name: native_toolchain_c + sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572" + url: "https://pub.dev" + source: hosted + version: "0.17.6" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" + objective_c: + dependency: transitive + description: + name: objective_c + sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52" + url: "https://pub.dev" + source: hosted + version: "9.3.0" + package_config: + dependency: transitive + description: + name: package_config + sha256: f096c55ebb7deb7e384101542bfba8c52696c1b56fca2eb62827989ef2353bbc + url: "https://pub.dev" + source: hosted + version: "2.2.0" package_info_plus: dependency: "direct main" description: name: package_info_plus - sha256: "16eee997588c60225bda0488b6dcfac69280a6b7a3cf02c741895dd370a02968" + sha256: "4bf625947f6c7713ee242296a682e23e44823c09cf9d79e4f1238923c92db852" url: "https://pub.dev" source: hosted - version: "8.3.1" + version: "10.1.0" package_info_plus_platform_interface: dependency: transitive description: name: package_info_plus_platform_interface - sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + sha256: db762cb2f4f25ee60fb6359773861b0f199e00b90d237bd85a76a1e806b46ef4 url: "https://pub.dev" source: hosted - version: "3.2.1" + version: "4.1.0" path: dependency: transitive description: @@ -388,18 +540,18 @@ packages: dependency: transitive description: name: path_provider_android - sha256: f2c65e21139ce2c3dad46922be8272bb5963516045659e71bb16e151c93b580e + sha256: "69cbd515a62b94d32a7944f086b2f82b4ac40a1d45bebfc00813a430ab2dabcd" url: "https://pub.dev" source: hosted - version: "2.2.22" + version: "2.3.1" path_provider_foundation: dependency: transitive description: name: path_provider_foundation - sha256: "6d13aece7b3f5c5a9731eaf553ff9dcbc2eff41087fd2df587fd0fed9a3eb0c4" + sha256: "2a376b7d6392d80cd3705782d2caa734ca4727776db0b6ec36ef3f1855197699" url: "https://pub.dev" source: hosted - version: "2.5.1" + version: "2.6.0" path_provider_linux: dependency: transitive description: @@ -428,10 +580,10 @@ packages: dependency: transitive description: name: petitparser - sha256: "1a97266a94f7350d30ae522c0af07890c70b8e62c71e8e3920d1db4d23c057d1" + sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675" url: "https://pub.dev" source: hosted - version: "7.0.1" + version: "7.0.2" platform: dependency: transitive description: @@ -448,38 +600,62 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.8" + pool: + dependency: transitive + description: + name: pool + sha256: "978783255c543aa3586a1b3c21f6e9d720eb315376a915872c61ef8b5c20177d" + url: "https://pub.dev" + source: hosted + version: "1.5.2" posix: dependency: transitive description: name: posix - sha256: "6323a5b0fa688b6a010df4905a56b00181479e6d10534cecfecede2aa55add61" + sha256: "185ef7606574f789b40f289c233efa52e96dead518aed988e040a10737febb07" url: "https://pub.dev" source: hosted - version: "6.0.3" + version: "6.5.0" + pub_semver: + dependency: transitive + description: + name: pub_semver + sha256: "5bfcf68ca79ef689f8990d1160781b4bad40a3bd5e5218ad4076ddb7f4081585" + url: "https://pub.dev" + source: hosted + version: "2.2.0" + record_use: + dependency: transitive + description: + name: record_use + sha256: "2551bd8eecfe95d14ae75f6021ad0248be5c27f138c2ec12fcb52b500b3ba1ed" + url: "https://pub.dev" + source: hosted + version: "0.6.0" riverpod: dependency: transitive description: name: riverpod - sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.2.1" shared_preferences: dependency: "direct main" description: name: shared_preferences - sha256: "2939ae520c9024cb197fc20dee269cd8cdbf564c8b5746374ec6cacdc5169e64" + sha256: c3025c5534b01739267eb7d76959bbc25a6d10f6988e1c2a3036940133dd10bf url: "https://pub.dev" source: hosted - version: "2.5.4" + version: "2.5.5" shared_preferences_android: dependency: transitive description: name: shared_preferences_android - sha256: "83af5c682796c0f7719c2bbf74792d113e40ae97981b8f266fa84574573556bc" + sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53 url: "https://pub.dev" source: hosted - version: "2.4.18" + version: "2.4.23" shared_preferences_foundation: dependency: transitive description: @@ -500,10 +676,10 @@ packages: dependency: transitive description: name: shared_preferences_platform_interface - sha256: "57cbf196c486bc2cf1f02b85784932c6094376284b3ad5779d1b1c6c6a816b80" + sha256: "649dc798a33931919ea356c4305c2d1f81619ea6e92244070b520187b5140ef9" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.2" shared_preferences_web: dependency: transitive description: @@ -520,6 +696,38 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.1" + shelf: + dependency: transitive + description: + name: shelf + sha256: e7dd780a7ffb623c57850b33f43309312fc863fb6aa3d276a754bb299839ef12 + url: "https://pub.dev" + source: hosted + version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" + shelf_web_socket: + dependency: transitive + description: + name: shelf_web_socket + sha256: "3632775c8e90d6c9712f883e633716432a27758216dfb61bd86a8321c0580925" + url: "https://pub.dev" + source: hosted + version: "3.0.0" simple_gesture_detector: dependency: transitive description: @@ -533,14 +741,30 @@ packages: description: flutter source: sdk version: "0.0.0" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: name: source_span - sha256: "254ee5351d6cb365c859e20ee823c3bb479bf4a293c22d17a9f1bf144ce86f7c" + sha256: "56a02f1f4cd1a2d96303c0144c93bd6d909eea6bee6bf5a0e0b685edbd4c47ab" url: "https://pub.dev" source: hosted - version: "1.10.1" + version: "1.10.2" stack_trace: dependency: transitive description: @@ -577,10 +801,10 @@ packages: dependency: "direct main" description: name: table_calendar - sha256: b2896b7c86adf3a4d9c911d860120fe3dbe03c85db43b22fd61f14ee78cdbb63 + sha256: "0c0c6219878b363a2d5f40c7afb159d845f253d061dc3c822aa0d5fe0f721982" url: "https://pub.dev" source: hosted - version: "3.1.3" + version: "3.2.0" term_glyph: dependency: transitive description: @@ -589,6 +813,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + url: "https://pub.dev" + source: hosted + version: "1.30.0" test_api: dependency: transitive description: @@ -597,6 +829,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.10" + test_core: + dependency: transitive + description: + name: test_core + sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + url: "https://pub.dev" + source: hosted + version: "0.6.16" typed_data: dependency: transitive description: @@ -617,18 +857,18 @@ packages: dependency: transitive description: name: url_launcher_android - sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" url: "https://pub.dev" source: hosted - version: "6.3.28" + version: "6.3.29" url_launcher_ios: dependency: transitive description: name: url_launcher_ios - sha256: cfde38aa257dae62ffe79c87fab20165dfdf6988c1d31b58ebf59b9106062aad + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" url: "https://pub.dev" source: hosted - version: "6.3.6" + version: "6.4.1" url_launcher_linux: dependency: transitive description: @@ -657,10 +897,10 @@ packages: dependency: transitive description: name: url_launcher_web - sha256: "4bd2b7b4dc4d4d0b94e5babfffbca8eac1a126c7f3d6ecbc1a11013faa3abba2" + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" url: "https://pub.dev" source: hosted - version: "2.4.1" + version: "2.4.3" url_launcher_windows: dependency: transitive description: @@ -681,10 +921,18 @@ packages: dependency: transitive description: name: vm_service - sha256: "45caa6c5917fa127b5dbcfbd1fa60b14e583afdc08bfc96dda38886ca252eb60" + sha256: "0016aef94fc66495ac78af5859181e3f3bf2026bd8eecc72b9565601e19ab360" url: "https://pub.dev" source: hosted - version: "15.0.2" + version: "15.2.0" + watcher: + dependency: transitive + description: + name: watcher + sha256: "1398c9f081a753f9226febe8900fce8f7d0a67163334e1c94a2438339d79d635" + url: "https://pub.dev" + source: hosted + version: "1.2.1" web: dependency: transitive description: @@ -693,14 +941,38 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.1" + web_socket: + dependency: transitive + description: + name: web_socket + sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c" + url: "https://pub.dev" + source: hosted + version: "1.0.1" + web_socket_channel: + dependency: transitive + description: + name: web_socket_channel + sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8 + url: "https://pub.dev" + source: hosted + version: "3.0.3" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" win32: dependency: transitive description: name: win32 - sha256: d7cb55e04cd34096cd3a79b3330245f54cb96a370a1c27adb3c84b917de8b08e + sha256: a1fc9eb9248baa05dfc12ed5b66e377b3e23f095eec078e0371622b9033810d9 url: "https://pub.dev" source: hosted - version: "5.15.0" + version: "6.2.0" xdg_directories: dependency: transitive description: @@ -726,5 +998,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.9.0 <4.0.0" - flutter: ">=3.35.0" + dart: ">=3.10.3 <4.0.0" + flutter: ">=3.38.4" diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 6e575070..fa3cc4f0 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -15,31 +15,31 @@ dependencies: http: ^1.1.0 google_sign_in: ^7.1.1 - shared_preferences: ^2.2.2 + shared_preferences: ^2.5.5 jwt_decoder: ^2.0.1 - package_info_plus: ^8.0.2 + package_info_plus: ^10.1.0 # Icons - cupertino_icons: ^1.0.8 + cupertino_icons: ^1.0.9 lucide_icons: ^0.257.0 # Navigation - go_router: ^14.6.0 + go_router: ^17.2.3 # State Management - flutter_riverpod: ^2.6.1 + flutter_riverpod: ^3.3.1 # Charts - fl_chart: ^0.69.0 + fl_chart: ^1.2.0 # Calendar - table_calendar: ^3.1.2 + table_calendar: ^3.2.0 # Utilities - intl: ^0.19.0 + intl: ^0.20.2 url_launcher: ^6.3.1 # Google Fonts for distinctive typography - google_fonts: ^6.2.1 + google_fonts: ^8.1.0 dev_dependencies: flutter_test: @@ -67,4 +67,3 @@ flutter: # To add assets to your application, add an assets section, like this: assets: - assets/icon/ - - assets/images/