Skip to content

State Management

Pedro Monteiro edited this page Jan 30, 2026 · 2 revisions

We use flutter_riverpod for state management. However, we do not simply use standard providers; we have built a custom architecture to ensure the app works offline-first.

Core Architecture: CachedAsyncNotifier

The backbone of our data layer is the CachedAsyncNotifier<T> abstract class. This is a custom extension of Riverpod's AsyncNotifier designed to handle the synchronization between local storage and remote APIs (Sigarra).

How it works

When a provider is initialized, CachedAsyncNotifier follows this lifecycle:

  1. Load from Storage: It immediately attempts to fetch data from the local device (loadFromStorage()).
  2. Display Local Data: If local data exists, it is displayed immediately to the user.
  3. Check Validity: It checks if the cache is expired using cacheDuration and _lastUpdateTime.
  4. Fetch Remote: If the data is missing, empty, or the cache is expired, it fetches fresh data from the API (loadFromRemote()).
  5. Update & Persist: New remote data updates the state and should be saved to local storage within the implementation.

Key Features

  • Automatic Caching: Handles timestamp checks automatically via PreferencesController.
  • Error Handling: If the network call fails, it falls back to the local data (if available) without crashing the UI.
  • Refresh Logic: Includes a refreshRemote() method to force a network update (used in pull-to-refresh).

How to Create a Data Provider

To create a new provider that fetches and stores data (e.g., Exams, Schedule, Profile), follow this pattern:

  1. Inherit from CachedAsyncNotifier<T>: Define the type of data this provider manages.
  2. Implement Required Methods:
    • cacheDuration: How long the data remains valid before a refetch is required (return null for manual management).
    • loadFromStorage(): Logic to retrieve data from the local database.
    • loadFromRemote(): Logic to fetch data from the web.
  3. Define the Provider: Use AsyncNotifierProvider.

Example (session_provider.dart):

final sessionProvider = AsyncNotifierProvider<SessionNotifier, Session?>(
  SessionNotifier.new,
);

class SessionNotifier extends CachedAsyncNotifier<Session?> {
  @override
  Duration? get cacheDuration => null; // No auto-expire, manual management

  @override
  Future<Session?> loadFromStorage() async {
    // Logic to read from SharedPreferences
    return await PreferencesController.getSavedSession();
  }

  @override
  Future<Session?> loadFromRemote() async {
    // Logic to fetch from API
    // Note: You must handle saving to storage here usually
    return newSession;
  }
}

Complex State Updates

For providers managing complex states (like tuples or maps), you can define specific methods to update partial state, as seen in CourseUnitsInfoNotifier:

// Example: Updating only one part of the state
final updatedSheetsMap = Map<CourseUnit, Sheet>.from(currentState.$1);
updatedSheetsMap[courseUnit] = sheet;
updateState((updatedSheetsMap, currentState.$2, currentState.$3));

Consuming Data in the UI

To reduce boilerplate when handling AsyncValue (loading, error, and data states), we use a custom widget called DefaultConsumer.

DefaultConsumer<T>

Located in lib/model/providers/riverpod/default_consumer.dart.

Instead of writing .when(data: ..., loading: ..., error: ...) in every widget, use DefaultConsumer.

Properties:

  • provider: The provider to watch.
  • builder: The widget to build when data is successfully loaded.
  • nullContentWidget: What to show if the data is null or empty.
  • hasContent: A boolean function to check if the data list is empty.
  • mapper: (Optional) A function to filter or transform data before the builder sees it.

Example Usage (home.dart):

DefaultConsumer<List<Lecture>>(
  provider: lectureProvider,
  // 1. Filter data before building (Optional)
  mapper: (lectures) => lectures
      .where((lecture) => lecture.endTime.isAfter(DateTime.now()))
      .toList(),
  // 2. Check if there is data to show
  hasContent: (lectures) => lectures.isNotEmpty,
  // 3. Fallback if empty
  nullContentWidget: const SizedBox.shrink(),
  // 4. Build the UI
  builder: (context, ref, lectures) {
    return ScheduleCard(lecture: lectures[0]);
  },
)

Simple State Management

For synchronous configuration state (like Theme Mode) that doesn't require complex fetching or caching logic, we use the simpler StateNotifier.

Example (theme_provider.dart):

final themeProvider = StateNotifierProvider<ThemeNotifier, ThemeMode>(
  (ref) => ThemeNotifier(PreferencesController.getThemeMode()),
);

Clone this wiki locally