feat: implement audit logging and documentation for Tier 1 case management features

This commit is contained in:
ashwin 2026-05-10 08:32:14 +05:30
parent 814668aa6c
commit bb364ed7d6
18 changed files with 1118 additions and 1315 deletions

View file

@ -67,11 +67,12 @@ class AuthState {
}
/// Notifier for authentication state changes
class AuthNotifier extends StateNotifier<AuthState> {
AuthNotifier() : super(const AuthState.initial());
class AuthNotifier extends Notifier<AuthState> {
final AuthService _authService = AuthService();
@override
AuthState build() => const AuthState.initial();
/// Check if user is already authenticated (on app launch)
Future<void> checkAuthStatus() async {
debugPrint('AuthNotifier: Checking auth status...');
@ -190,9 +191,7 @@ class AuthNotifier extends StateNotifier<AuthState> {
}
/// Provider for authentication state
final authProvider = StateNotifierProvider<AuthNotifier, AuthState>((ref) {
return AuthNotifier();
});
final authProvider = NotifierProvider<AuthNotifier, AuthState>(AuthNotifier.new);
/// Provider for checking if user is authenticated
final isAuthenticatedProvider = Provider<bool>((ref) {

View file

@ -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<DashboardState> {
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<DashboardData> {
final ApiService _apiService = ApiService();
/// Fetch dashboard data from API
Future<void> fetchDashboard({bool forceRefresh = false}) async {
// Skip if already loading
if (state.isLoading) return;
@override
Future<DashboardData> 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<dynamic>;
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<void> 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<DashboardData> _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<DashboardNotifier, DashboardState>((ref) {
return DashboardNotifier();
});
/// Convenience providers for specific dashboard data
final dashboardDataProvider = Provider<DashboardData?>((ref) {
return ref.watch(dashboardProvider).data;
});
final dashboardLoadingProvider = Provider<bool>((ref) {
return ref.watch(dashboardProvider).isLoading;
});
final dashboardErrorProvider = Provider<String?>((ref) {
return ref.watch(dashboardProvider).error;
});
AsyncNotifierProvider<DashboardNotifier, DashboardData>(
DashboardNotifier.new,
);

View file

@ -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<Deal> 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<Deal>? 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<DealsState> {
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<DealsListData> {
final ApiService _apiService = ApiService();
static const int _pageSize = 20;
/// Fetch deals from API
Future<void> fetchDeals({
String? search,
String? stage,
bool refresh = false,
}) async {
@override
Future<DealsListData> build() => _fetchPage(offset: 0);
/// Reload the first page (pull-to-refresh / after CRUD).
Future<void> 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<void> 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 = <String, String>{
'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<dynamic> dealsList = [];
int dealsCount = 0;
if (data['opportunities'] != null) {
dealsList = data['opportunities'] as List<dynamic>? ?? [];
dealsCount = data['opportunities_count'] as int? ?? dealsList.length;
} else if (data['results'] != null) {
dealsList = data['results'] as List<dynamic>? ?? [];
dealsCount = data['count'] as int? ?? dealsList.length;
}
final newDeals = <Deal>[];
for (final item in dealsList) {
try {
if (item is Map<String, dynamic>) {
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<DealsListData> _fetchPage({
required int offset,
String? search,
String? stage,
}) async {
final queryParams = <String, String>{
'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<dynamic> dealsList = [];
int dealsCount = 0;
if (data['opportunities'] != null) {
dealsList = data['opportunities'] as List<dynamic>? ?? [];
dealsCount = data['opportunities_count'] as int? ?? dealsList.length;
} else if (data['results'] != null) {
dealsList = data['results'] as List<dynamic>? ?? [];
dealsCount = data['count'] as int? ?? dealsList.length;
}
final newDeals = <Deal>[];
for (final item in dealsList) {
try {
if (item is Map<String, dynamic>) {
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<void> refresh() async {
state = const DealsState.initial();
await fetchDeals(refresh: true);
}
/// Load more deals
Future<void> 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<Deal?> 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<String, dynamic>?;
if (opportunityData != null) {
@ -176,7 +142,7 @@ class DealsNotifier extends StateNotifier<DealsState> {
}
}
/// 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<DealsState> {
);
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<String, dynamic>;
@ -214,7 +177,7 @@ class DealsNotifier extends StateNotifier<DealsState> {
}
}
/// 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<DealsState> {
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<String, dynamic>;
@ -251,7 +211,7 @@ class DealsNotifier extends StateNotifier<DealsState> {
}
}
/// 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<DealsState> {
});
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<DealsState> {
}
}
/// 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<DealsState> {
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<DealsNotifier, DealsState>((ref) {
return DealsNotifier();
});
final dealsProvider = AsyncNotifierProvider<DealsNotifier, DealsListData>(
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<List<Deal>>((ref) {
return ref.watch(dealsProvider).deals;
return ref.watch(dealsProvider).value?.deals ?? const [];
});
final dealsLoadingProvider = Provider<bool>((ref) {
@ -342,22 +297,20 @@ final dealsLoadingProvider = Provider<bool>((ref) {
});
final dealsErrorProvider = Provider<String?>((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<Map<DealStage, List<Deal>>>((ref) {
final deals = ref.watch(dealsListProvider);
final Map<DealStage, List<Deal>> 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<double>((ref) {
final deals = ref.watch(dealsListProvider);
return deals
@ -365,7 +318,7 @@ final pipelineValueProvider = Provider<double>((ref) {
.fold<double>(0, (sum, deal) => sum + deal.value);
});
/// Active deals count
/// Active deals count.
final activeDealsCountProvider = Provider<int>((ref) {
final deals = ref.watch(dealsListProvider);
return deals.where((d) => !d.stage.isClosed).length;

View file

@ -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<Lead> 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<Lead>? 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<LeadsState> {
LeadsNotifier() : super(const LeadsState.initial());
class LeadsNotifier extends AsyncNotifier<LeadsListData> {
final ApiService _apiService = ApiService();
static const int _pageSize = 20;
/// Get a single lead by ID
@override
Future<LeadsListData> build() => _fetchPage(offset: 0);
Future<void> 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<void> 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<LeadsListData> _fetchPage({
required int offset,
String? search,
String? status,
String? source,
}) async {
final queryParams = <String, String>{
'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<String, dynamic>?;
final openLeadsList =
openLeadsData?['open_leads'] as List<dynamic>? ?? [];
final openLeadsCount = openLeadsData?['leads_count'] as int? ?? 0;
final closeLeadsData = data['close_leads'] as Map<String, dynamic>?;
final closeLeadsList =
closeLeadsData?['close_leads'] as List<dynamic>? ?? [];
final allLeadsList = [...openLeadsList, ...closeLeadsList];
final newLeads = allLeadsList
.map((json) => Lead.fromJson(json as Map<String, dynamic>))
.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<Lead?> getLeadById(String id) async {
try {
final url = '${ApiConfig.leads}$id/';
@ -68,7 +139,6 @@ class LeadsNotifier extends StateNotifier<LeadsState> {
if (leadData != null) {
final lead = Lead.fromJson(leadData);
// Parse comments from separate field in response
final commentsData = response.data!['comments'] as List<dynamic>?;
if (commentsData != null && commentsData.isNotEmpty) {
final comments = commentsData
@ -76,7 +146,6 @@ class LeadsNotifier extends StateNotifier<LeadsState> {
.toList();
return lead.copyWith(comments: comments);
}
return lead;
}
}
@ -86,22 +155,18 @@ class LeadsNotifier extends StateNotifier<LeadsState> {
}
}
/// Create a new lead
Future<ApiResponse<Map<String, dynamic>>> createLead(
Map<String, dynamic> 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<ApiResponse<Map<String, dynamic>>> updateLead(
String id,
Map<String, dynamic> leadData,
@ -109,16 +174,14 @@ class LeadsNotifier extends StateNotifier<LeadsState> {
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<ApiResponse<Map<String, dynamic>>> updateLeadStatus(
String id,
LeadStatus status,
@ -128,32 +191,37 @@ class LeadsNotifier extends StateNotifier<LeadsState> {
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<ApiResponse<Map<String, dynamic>>> 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<LeadsState> {
}
}
/// Add a comment to a lead
Future<ApiResponse<Map<String, dynamic>>> 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<ApiResponse<Map<String, dynamic>>> 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<void> 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 = <String, String>{
'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<String, dynamic>?;
final openLeadsList =
openLeadsData?['open_leads'] as List<dynamic>? ?? [];
final openLeadsCount = openLeadsData?['leads_count'] as int? ?? 0;
// Parse close leads
final closeLeadsData = data['close_leads'] as Map<String, dynamic>?;
final closeLeadsList =
closeLeadsData?['close_leads'] as List<dynamic>? ?? [];
// Combine all leads
final allLeadsList = [...openLeadsList, ...closeLeadsList];
final newLeads = allLeadsList
.map((json) => Lead.fromJson(json as Map<String, dynamic>))
.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<void> refresh() async {
state = const LeadsState.initial();
await fetchLeads(refresh: true);
}
/// Load more leads
Future<void> loadMore() async {
if (!state.hasMore || state.isLoading) return;
await fetchLeads();
}
/// Clear leads data
void clear() {
state = const LeadsState.initial();
}
}
/// Leads provider
final leadsProvider = StateNotifierProvider<LeadsNotifier, LeadsState>((ref) {
return LeadsNotifier();
});
final leadsProvider = AsyncNotifierProvider<LeadsNotifier, LeadsListData>(
LeadsNotifier.new,
);
/// Convenience providers
/// Convenience providers — read from the AsyncValue.
final leadsListProvider = Provider<List<Lead>>((ref) {
return ref.watch(leadsProvider).leads;
return ref.watch(leadsProvider).value?.leads ?? const [];
});
final leadsLoadingProvider = Provider<bool>((ref) {
@ -304,5 +267,5 @@ final leadsLoadingProvider = Provider<bool>((ref) {
});
final leadsErrorProvider = Provider<String?>((ref) {
return ref.watch(leadsProvider).error;
return ref.watch(leadsProvider).error?.toString();
});

View file

@ -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<AccountLookup> accounts;
final List<ContactLookup> contacts;
final List<UserLookup> users;
final List<TagLookup> 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<List<X>>` 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<List<AccountLookup>> {
@override
Future<List<AccountLookup>> build() => _fetch();
bool get isLoading =>
isLoadingAccounts || isLoadingContacts || isLoadingUsers || isLoadingTags;
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
LookupState copyWith({
List<AccountLookup>? accounts,
List<ContactLookup>? contacts,
List<UserLookup>? users,
List<TagLookup>? 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<List<AccountLookup>> _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<dynamic> list = [];
if (data['accounts'] != null) {
list = data['accounts'] as List<dynamic>? ?? [];
} else if (data['results'] != null) {
list = data['results'] as List<dynamic>? ?? [];
}
return list
.whereType<Map<String, dynamic>>()
.map(AccountLookup.fromJson)
.toList();
}
}
final accountsLookupProvider =
AsyncNotifierProvider<AccountsLookupNotifier, List<AccountLookup>>(
AccountsLookupNotifier.new,
);
/// Contacts
class ContactsLookupNotifier extends AsyncNotifier<List<ContactLookup>> {
@override
Future<List<ContactLookup>> build() => _fetch();
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
Future<List<ContactLookup>> _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<dynamic> list = [];
if (data['contacts'] != null) {
list = data['contacts'] as List<dynamic>? ?? [];
} else if (data['results'] != null) {
list = data['results'] as List<dynamic>? ?? [];
}
return list
.whereType<Map<String, dynamic>>()
.map(ContactLookup.fromJson)
.toList();
}
}
/// Notifier for managing lookup data
class LookupNotifier extends StateNotifier<LookupState> {
LookupNotifier() : super(const LookupState.initial());
final contactsLookupProvider =
AsyncNotifierProvider<ContactsLookupNotifier, List<ContactLookup>>(
ContactsLookupNotifier.new,
);
final ApiService _apiService = ApiService();
/// Users (active profiles)
class UsersLookupNotifier extends AsyncNotifier<List<UserLookup>> {
@override
Future<List<UserLookup>> build() => _fetch();
/// Fetch all lookup data
Future<void> fetchAll() async {
await Future.wait([
fetchAccounts(),
fetchContacts(),
fetchUsers(),
fetchTags(),
]);
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
/// Fetch accounts
Future<void> 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<dynamic> accountsList = [];
if (data['accounts'] != null) {
accountsList = data['accounts'] as List<dynamic>? ?? [];
} else if (data['results'] != null) {
accountsList = data['results'] as List<dynamic>? ?? [];
}
final accounts = <AccountLookup>[];
for (final item in accountsList) {
if (item is Map<String, dynamic>) {
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<List<UserLookup>> _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<void> 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<dynamic> contactsList = [];
if (data['contacts'] != null) {
contactsList = data['contacts'] as List<dynamic>? ?? [];
} else if (data['results'] != null) {
contactsList = data['results'] as List<dynamic>? ?? [];
}
final contacts = <ContactLookup>[];
for (final item in contactsList) {
if (item is Map<String, dynamic>) {
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<void> 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<dynamic> profilesList = [];
if (data['profiles'] != null) {
profilesList = data['profiles'] as List<dynamic>? ?? [];
}
final users = <UserLookup>[];
for (final item in profilesList) {
if (item is Map<String, dynamic>) {
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<void> 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<dynamic> tagsList = [];
if (data['tags'] != null) {
tagsList = data['tags'] as List<dynamic>? ?? [];
} else if (data['results'] != null) {
tagsList = data['results'] as List<dynamic>? ?? [];
}
final tags = <TagLookup>[];
for (final item in tagsList) {
if (item is Map<String, dynamic>) {
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<ContactLookup> getContactsByIds(List<String> ids) {
return state.contacts.where((c) => ids.contains(c.id)).toList();
}
/// Get users by IDs
List<UserLookup> getUsersByIds(List<String> ids) {
return state.users.where((u) => ids.contains(u.id)).toList();
}
/// Get tags by IDs
List<TagLookup> getTagsByIds(List<String> 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<dynamic>? ?? [];
return profiles
.whereType<Map<String, dynamic>>()
.map(UserLookup.fromJson)
.where((u) => u.isActive)
.toList();
}
}
/// Main lookup provider
final lookupProvider = StateNotifierProvider<LookupNotifier, LookupState>((
ref,
) {
return LookupNotifier();
});
final usersLookupProvider =
AsyncNotifierProvider<UsersLookupNotifier, List<UserLookup>>(
UsersLookupNotifier.new,
);
/// Convenience providers
/// Tags
class TagsLookupNotifier extends AsyncNotifier<List<TagLookup>> {
@override
Future<List<TagLookup>> build() => _fetch();
Future<void> refresh() async {
state = const AsyncValue.loading();
state = await AsyncValue.guard(_fetch);
}
Future<List<TagLookup>> _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<dynamic> list = [];
if (data['tags'] != null) {
list = data['tags'] as List<dynamic>? ?? [];
} else if (data['results'] != null) {
list = data['results'] as List<dynamic>? ?? [];
}
return list
.whereType<Map<String, dynamic>>()
.map(TagLookup.fromJson)
.toList();
}
}
final tagsLookupProvider =
AsyncNotifierProvider<TagsLookupNotifier, List<TagLookup>>(
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<List<AccountLookup>>((ref) {
return ref.watch(lookupProvider).accounts;
return ref.watch(accountsLookupProvider).value ?? const [];
});
final contactsProvider = Provider<List<ContactLookup>>((ref) {
return ref.watch(lookupProvider).contacts;
return ref.watch(contactsLookupProvider).value ?? const [];
});
final usersProvider = Provider<List<UserLookup>>((ref) {
return ref.watch(lookupProvider).users;
return ref.watch(usersLookupProvider).value ?? const [];
});
final tagsProvider = Provider<List<TagLookup>>((ref) {
return ref.watch(lookupProvider).tags;
});
final lookupLoadingProvider = Provider<bool>((ref) {
return ref.watch(lookupProvider).isLoading;
return ref.watch(tagsLookupProvider).value ?? const [];
});

View file

@ -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<Task> 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<Task>? 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<TasksState> {
TasksNotifier() : super(const TasksState.initial());
class TasksNotifier extends AsyncNotifier<TasksListData> {
final ApiService _apiService = ApiService();
static const int _pageSize = 20;
/// Fetch tasks from API
Future<void> fetchTasks({
@override
Future<TasksListData> build() => _fetchPage(offset: 0);
Future<void> 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<void> 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 = <String, String>{
'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<dynamic> tasksList = [];
int tasksCount = 0;
if (data['tasks'] != null) {
tasksList = data['tasks'] as List<dynamic>? ?? [];
tasksCount = data['tasks_count'] as int? ?? tasksList.length;
} else if (data['results'] != null) {
// Handle paginated response format
tasksList = data['results'] as List<dynamic>? ?? [];
tasksCount = data['count'] as int? ?? tasksList.length;
}
debugPrint(
'TasksNotifier: Found ${tasksList.length} tasks in response',
);
final newTasks = <Task>[];
for (final item in tasksList) {
try {
if (item is Map<String, dynamic>) {
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<TasksListData> _fetchPage({
required int offset,
String? search,
String? status,
String? priority,
}) async {
final queryParams = <String, String>{
'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<dynamic> tasksList = [];
int tasksCount = 0;
if (data['tasks'] != null) {
tasksList = data['tasks'] as List<dynamic>? ?? [];
tasksCount = data['tasks_count'] as int? ?? tasksList.length;
} else if (data['results'] != null) {
tasksList = data['results'] as List<dynamic>? ?? [];
tasksCount = data['count'] as int? ?? tasksList.length;
}
final newTasks = <Task>[];
for (final item in tasksList) {
try {
if (item is Map<String, dynamic>) {
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<void> refresh() async {
state = const TasksState.initial();
await fetchTasks(refresh: true);
}
/// Load more tasks
Future<void> 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<Task?> 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<String, dynamic>?;
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<ApiResponse<Map<String, dynamic>>> createTask(
Map<String, dynamic> 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<ApiResponse<Map<String, dynamic>>> updateTask(
String taskId,
Map<String, dynamic> 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<ApiResponse<Map<String, dynamic>>> 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<ApiResponse<Map<String, dynamic>>> 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<ApiResponse<Map<String, dynamic>>> patchTask(
String taskId,
Map<String, dynamic> 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<TasksNotifier, TasksState>((ref) {
return TasksNotifier();
});
final tasksProvider = AsyncNotifierProvider<TasksNotifier, TasksListData>(
TasksNotifier.new,
);
/// Convenience providers
/// Convenience providers — read from the AsyncValue.
final tasksListProvider = Provider<List<Task>>((ref) {
return ref.watch(tasksProvider).tasks;
return ref.watch(tasksProvider).value?.tasks ?? const [];
});
final tasksLoadingProvider = Provider<bool>((ref) {
@ -347,10 +275,10 @@ final tasksLoadingProvider = Provider<bool>((ref) {
});
final tasksErrorProvider = Provider<String?>((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<List<Task>>((ref) {
final tasks = ref.watch(tasksListProvider);
return tasks.where((t) => t.isOverdue).toList()..sort((a, b) {

View file

@ -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<Ticket> 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<Ticket>? 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<TicketsState> {
TicketsNotifier() : super(const TicketsState.initial());
class TicketsNotifier extends AsyncNotifier<TicketsListData> {
final ApiService _apiService = ApiService();
static const int _pageSize = 20;
/// Get a single ticket by ID, returning the full detail (incl. comments).
@override
Future<TicketsListData> build() => _fetchPage(offset: 0);
Future<void> 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<void> 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<TicketsListData> _fetchPage({
required int offset,
String? search,
String? status,
String? priority,
String? assignedTo,
}) async {
final queryParams = <String, String>{
'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<dynamic>? ?? [];
final newTickets = ticketsList
.map((j) => Ticket.fromJson(j as Map<String, dynamic>))
.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<Ticket?> 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<TicketDetailResult?> getTicketDetail(String id) async {
try {
final response = await _apiService.get(ApiConfig.ticketDetail(id));
@ -75,7 +146,7 @@ class TicketsNotifier extends StateNotifier<TicketsState> {
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<dynamic>? ?? [])) {
@ -123,22 +194,18 @@ class TicketsNotifier extends StateNotifier<TicketsState> {
}
}
/// Create a new ticket
Future<ApiResponse<Map<String, dynamic>>> createTicket(
Map<String, dynamic> 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<ApiResponse<Map<String, dynamic>>> updateTicket(
String id,
Map<String, dynamic> ticketData,
@ -148,16 +215,13 @@ class TicketsNotifier extends StateNotifier<TicketsState> {
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<ApiResponse<Map<String, dynamic>>> addComment(
String ticketId,
String comment, {
@ -174,113 +238,35 @@ class TicketsNotifier extends StateNotifier<TicketsState> {
}
}
/// Delete a ticket
Future<ApiResponse<Map<String, dynamic>>> 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<void> 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 = <String, String>{
'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<dynamic>? ?? [];
final newTickets = ticketsList
.map((j) => Ticket.fromJson(j as Map<String, dynamic>))
.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<void> refresh() async {
state = const TicketsState.initial();
await fetchTickets(refresh: true);
}
Future<void> loadMore() async {
if (!state.hasMore || state.isLoading) return;
await fetchTickets();
}
void clear() {
state = const TicketsState.initial();
}
}
final ticketsProvider = StateNotifierProvider<TicketsNotifier, TicketsState>((
ref,
) {
return TicketsNotifier();
});
final ticketsProvider =
AsyncNotifierProvider<TicketsNotifier, TicketsListData>(
TicketsNotifier.new,
);
/// Convenience providers — read from the AsyncValue.
final ticketsListProvider = Provider<List<Ticket>>((ref) {
return ref.watch(ticketsProvider).tickets;
return ref.watch(ticketsProvider).value?.tickets ?? const [];
});
final ticketsLoadingProvider = Provider<bool>((ref) {
@ -288,7 +274,7 @@ final ticketsLoadingProvider = Provider<bool>((ref) {
});
final ticketsErrorProvider = Provider<String?>((ref) {
return ref.watch(ticketsProvider).error;
return ref.watch(ticketsProvider).error?.toString();
});
/// Bundled detail-fetch result. The mobile detail screen needs more than

View file

@ -19,18 +19,9 @@ class DashboardScreen extends ConsumerStatefulWidget {
}
class _DashboardScreenState extends ConsumerState<DashboardScreen> {
@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<DashboardScreen> {
// Content
SliverToBoxAdapter(
child: _buildContent(
dashboardState,
dashboardAsync,
currencyFormat,
compactCurrencyFormat,
),
@ -94,18 +85,21 @@ class _DashboardScreenState extends ConsumerState<DashboardScreen> {
}
Widget _buildContent(
DashboardState dashboardState,
AsyncValue<DashboardData> 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<DashboardScreen> {
);
}
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: [

View file

@ -49,11 +49,6 @@ class _DealFormScreenState extends ConsumerState<DealFormScreen> {
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<DealFormScreen> {
_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<DealFormScreen> {
}
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<DealFormScreen> {
_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<DealFormScreen> {
selectedItems: _selectedContactIds
.map(
(id) =>
lookupState.contacts
contacts
.where((c) => c.id == id)
.firstOrNull
?.fullName ??
@ -622,7 +625,7 @@ class _DealFormScreenState extends ConsumerState<DealFormScreen> {
.toList(),
placeholder: 'Select contacts',
icon: LucideIcons.users,
isLoading: lookupState.isLoadingContacts,
isLoading: contactsAsync.isLoading,
onTap: _showContactsPicker,
),
@ -635,7 +638,7 @@ class _DealFormScreenState extends ConsumerState<DealFormScreen> {
selectedItems: _selectedAssignedToIds
.map(
(id) =>
lookupState.users
users
.where((u) => u.id == id)
.firstOrNull
?.displayName ??
@ -645,7 +648,7 @@ class _DealFormScreenState extends ConsumerState<DealFormScreen> {
.toList(),
placeholder: 'Select assignees',
icon: LucideIcons.userCheck,
isLoading: lookupState.isLoadingUsers,
isLoading: usersAsync.isLoading,
onTap: _showAssignedToPicker,
),
@ -658,7 +661,7 @@ class _DealFormScreenState extends ConsumerState<DealFormScreen> {
selectedItems: _selectedTagIds
.map(
(id) =>
lookupState.tags
tags
.where((t) => t.id == id)
.firstOrNull
?.name ??
@ -668,7 +671,7 @@ class _DealFormScreenState extends ConsumerState<DealFormScreen> {
.toList(),
placeholder: 'Select tags',
icon: LucideIcons.tag,
isLoading: lookupState.isLoadingTags,
isLoading: tagsAsync.isLoading,
onTap: _showTagsPicker,
),
],
@ -1132,7 +1135,7 @@ class _DealFormScreenState extends ConsumerState<DealFormScreen> {
}
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<DealFormScreen> {
}
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<DealFormScreen> {
}
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<DealFormScreen> {
}
void _showTagsPicker() {
final tags = ref.read(lookupProvider).tags;
final tags = ref.read(tagsProvider);
showModalBottomSheet(
context: context,
backgroundColor: AppColors.surface,

View file

@ -38,15 +38,6 @@ class _DealsListScreenState extends ConsumerState<DealsListScreen> {
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();

View file

@ -616,7 +616,7 @@ class _LeadDetailScreenState extends ConsumerState<LeadDetailScreen>
color: AppColors.textSecondary,
),
),
if (action != null) action,
?action,
],
),
const SizedBox(height: 12),

View file

@ -53,10 +53,6 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
@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<LeadFormScreen> {
}
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<LeadFormScreen> {
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<LeadFormScreen> {
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<LeadFormScreen> {
}
void _showAssignedToPicker() {
final users = ref.read(lookupProvider).users;
final users = ref.read(usersProvider);
showModalBottomSheet(
context: context,
@ -892,7 +889,7 @@ class _LeadFormScreenState extends ConsumerState<LeadFormScreen> {
}
void _showTagsPicker() {
final tags = ref.read(lookupProvider).tags;
final tags = ref.read(tagsProvider);
showModalBottomSheet(
context: context,

View file

@ -30,12 +30,6 @@ class _LeadsListScreenState extends ConsumerState<LeadsListScreen> {
@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<LeadsListScreen> {
@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<LeadsListScreen> {
_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<Lead> filteredLeads) {
Widget _buildLeadsList(
AsyncValue<LeadsListData> async,
List<Lead> 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<LeadsListScreen> {
),
const SizedBox(height: 8),
Text(
state.error!,
async.error.toString(),
style: AppTypography.caption,
textAlign: TextAlign.center,
),
@ -198,6 +198,8 @@ class _LeadsListScreenState extends ConsumerState<LeadsListScreen> {
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<LeadsListScreen> {
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()),

View file

@ -26,14 +26,6 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
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<String> _getTaskDates(List<Task> tasks) {
@ -57,10 +49,10 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
@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 <Task>[];
final isLoading = tasksAsync.isLoading;
final error = tasksAsync.error?.toString();
return Scaffold(
backgroundColor: AppColors.surfaceDim,
@ -132,7 +124,7 @@ class _TasksListScreenState extends ConsumerState<TasksListScreen> {
label: 'Retry',
icon: LucideIcons.refreshCw,
onPressed: () {
ref.read(tasksProvider.notifier).fetchTasks(refresh: true);
ref.read(tasksProvider.notifier).refresh();
},
),
],

View file

@ -38,9 +38,6 @@ class _TicketFormScreenState extends ConsumerState<TicketFormScreen> {
@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<TicketFormScreen> {
),
);
}
final accounts = ref.watch(lookupProvider).accounts;
final accounts = ref.watch(accountsProvider);
return Form(
key: _formKey,

View file

@ -28,9 +28,6 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
@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<TicketsListScreen> {
@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<TicketsListScreen> {
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<Ticket> filtered) {
if (state.isLoading && state.tickets.isEmpty) {
Widget _buildList(
AsyncValue<TicketsListData> async,
List<Ticket> 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<TicketsListScreen> {
),
const SizedBox(height: 8),
Text(
state.error!,
async.error.toString(),
style: AppTypography.caption,
textAlign: TextAlign.center,
),
@ -140,13 +143,15 @@ class _TicketsListScreenState extends ConsumerState<TicketsListScreen> {
}
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(

View file

@ -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"

View file

@ -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/