Skip to main content

Overview

Invenicum uses the Provider package for state management, implementing the ChangeNotifier pattern. This approach provides:
  • Reactive UI updates
  • Clear separation of business logic and presentation
  • Dependency injection
  • Testability

Provider Hierarchy

The complete provider tree is established in lib/main.dart:64-261:
runApp(
  MultiProvider(
    providers: [
      // 1. Services (singletons)
      // 2. State providers (ChangeNotifier)
    ],
    child: const MyApp(),
  ),
);

Two-Tier Structure

Tier 1: Services (Provider)

Services are stateless singletons that handle API communication:
// lib/main.dart:66-83
Provider<ApiService>(create: (c) => ApiService()),
Provider(create: (c) => PluginService(c.read<ApiService>())),
Provider(create: (c) => DashboardService(c.read<ApiService>())),
Provider(create: (c) => InventoryItemService(c.read<ApiService>())),
// ... more services
Key characteristics:
  • No reactive state (don’t call notifyListeners())
  • Injected via constructor dependency
  • Single instance shared across the app

Tier 2: State Providers (ChangeNotifier)

Providers manage reactive state:
// lib/main.dart:88-260
ChangeNotifierProvider<AuthProvider>.value(value: authProvider),
ChangeNotifierProxyProvider<AuthProvider, InventoryItemProvider>(...),
ChangeNotifierProxyProvider<AuthProvider, ThemeProvider>(...),
// ... more providers
Key characteristics:
  • Extend ChangeNotifier
  • Call notifyListeners() to trigger UI rebuilds
  • Can depend on other providers (via ProxyProvider)

Provider Types

1. ChangeNotifierProvider

Used for independent state that doesn’t depend on other providers:
// lib/main.dart:88
ChangeNotifierProvider<AuthProvider>.value(value: authProvider)
Example: AuthProvider (lib/providers/auth_provider.dart)
class AuthProvider with ChangeNotifier {
  UserData? _user;
  String? _token;
  bool _isLoading = true;
  
  bool get isAuthenticated => _user != null && _token != null;
  
  Future<LoginResponse> login(String username, String password) async {
    _isLoading = true;
    notifyListeners();  // UI shows loading state
    
    try {
      final response = await _apiService.login(username, password);
      if (response.success && response.token != null) {
        _token = response.token;
        _user = response.user;
      }
      return response;
    } finally {
      _isLoading = false;
      notifyListeners();  // UI updates with result
    }
  }
}

2. ChangeNotifierProxyProvider

Used when a provider depends on another provider’s state:
// lib/main.dart:171-185
ChangeNotifierProxyProvider<AuthProvider, InventoryItemProvider>(
  create: (c) => InventoryItemProvider(
    c.read<InventoryItemService>(),
    c.read<AssetPrintService>(),
  ),
  update: (context, auth, prev) {
    // Reactive logic: when auth state changes, this runs
    if (!auth.isLoading && auth.isAuthenticated && auth.token != null) {
      if (prev != null && !prev.isLoading) {
        Future.microtask(() => prev.loadAllItemsGlobal());
      }
    }
    return prev!;
  },
)
Pattern explanation:
  • create: Instantiates the provider once
  • update: Called whenever AuthProvider changes
  • auth: Current state of the dependency (AuthProvider)
  • prev: The existing instance of InventoryItemProvider
  • Returns prev! to maintain the same instance (preserves cache)

Common Provider Patterns

Pattern 1: Loading State

All providers follow this pattern for async operations:
class ExampleProvider with ChangeNotifier {
  bool _isLoading = false;
  bool get isLoading => _isLoading;
  
  Future<void> loadData() async {
    _isLoading = true;
    notifyListeners();  // Show loading indicator
    
    try {
      // Fetch data
    } catch (e) {
      // Handle error
      rethrow;  // Let UI show error message
    } finally {
      _isLoading = false;
      notifyListeners();  // Hide loading indicator
    }
  }
}
Example: lib/providers/inventory_item_provider.dart:292-346

Pattern 2: Data Caching

Providers cache data to minimize API calls:
// lib/providers/inventory_item_provider.dart:64-68
final Map<String, InventoryResponse> _itemsCache = {};

Future<void> loadInventoryItems({
  required int containerId,
  required int assetTypeId,
  bool forceReload = false,
}) async {
  final key = _getCacheKey(containerId, assetTypeId);
  
  // Return cached data if available
  if (_itemsCache.containsKey(key) && !forceReload) {
    _recalculateTotalsAndNotify();
    return;
  }
  
  // Otherwise fetch from API
  _isLoading = true;
  notifyListeners();
  
  try {
    final response = await _itemService.fetchInventoryItems(...);
    _itemsCache[key] = response;
  } finally {
    _isLoading = false;
    _recalculateTotalsAndNotify();
  }
}

Pattern 3: Computed Getters

Providers expose processed data through getters:
// lib/providers/inventory_item_provider.dart:181-216
List<InventoryItem> get inventoryItems {
  final cId = _currentContainerId;
  final atId = _currentAssetTypeId;
  final key = _getCacheKey(cId, atId);
  final response = _itemsCache[key];
  
  if (response == null) return [];
  
  // Filter, sort, paginate
  Iterable<InventoryItem> processedItems = _applyFilters(response.items);
  List<InventoryItem> sortedList = processedItems.toList();
  _applySort(sortedList);
  
  // Pagination
  final startIndex = (_currentPage - 1) * _itemsPerPage;
  final endIndex = startIndex + _itemsPerPage;
  
  return sortedList.sublist(startIndex, endIndex);
}
UI usage:
// Widgets automatically rebuild when inventoryItems changes
final items = context.watch<InventoryItemProvider>().inventoryItems;

Pattern 4: Auth-Dependent Initialization

Many providers auto-load data when the user logs in:
// lib/main.dart:154-168
ChangeNotifierProxyProvider<AuthProvider, DashboardProvider>(
  create: (context) => DashboardProvider(context.read<DashboardService>()),
  update: (context, auth, previous) {
    if (!auth.isLoading && auth.isAuthenticated) {
      // Only load if we haven't loaded yet
      if (previous != null && previous.stats == null && !previous.isLoading) {
        Future.microtask(() => previous.fetchStats());
      }
    }
    return previous!;
  },
)
Key points:
  • Checks !auth.isLoading to ensure auth is ready
  • Checks auth.isAuthenticated to ensure user is logged in
  • Uses Future.microtask() to avoid calling async code during build
  • Prevents redundant loads with previous.stats == null

Example Providers

AuthProvider

File: lib/providers/auth_provider.dart Responsibilities:
  • Manage authentication state (user, token, loading)
  • Login/logout operations
  • Profile updates
  • GitHub OAuth integration
  • Password management
Key methods:
  • login() - Authenticate user
  • logout() - Clear session
  • updateProfile() - Update user data
  • checkAuthStatus() - Restore session on app start
State:
UserData? _user;
String? _token;
bool _isLoading = true;

InventoryItemProvider

File: lib/providers/inventory_item_provider.dart Responsibilities:
  • Load and cache inventory items
  • Filter, sort, paginate items (client-side)
  • CRUD operations (create, update, delete, clone)
  • Manage current container/asset type context
  • Track price history and market value
Key methods:
  • loadInventoryItems() - Fetch items for a specific asset type
  • loadAllItemsGlobal() - Load all items across containers
  • createInventoryItem() - Add new item
  • updateAssetWithFiles() - Update item with file uploads
  • deleteInventoryItem() - Remove item
  • setFilter() - Apply search/filter
  • sortInventoryItems() - Sort by column
  • goToPage() - Navigate pagination
State:
Map<String, InventoryResponse> _itemsCache = {};  // Cached data
Map<String, String> _filters = {};                // Active filters
String? _globalSearchTerm;                        // Search query
int _currentPage = 1;                             // Pagination
int _itemsPerPage = 10;
bool _isLoading = false;
Computed getter:
List<InventoryItem> get inventoryItems {}

ContainerProvider

File: lib/providers/container_provider.dart Responsibilities:
  • Load container hierarchy
  • Manage asset types within containers
  • Load data lists (dropdowns)
  • CRUD for containers, asset types, locations
Dependency injection:
// lib/main.dart:219-231
ChangeNotifierProxyProvider<AuthProvider, ContainerProvider>(
  create: (c) => ContainerProvider(
    c.read<ContainerService>(),
    c.read<AssetTypeService>(),
    c.read<LocationService>(),
  ),
  update: (context, auth, prev) {
    if (auth.isAuthenticated && auth.token != null && !auth.isLoading) {
      Future.microtask(() => prev?.loadContainers());
    }
    return prev!;
  },
)

PluginProvider

File: lib/providers/plugin_provider.dart Responsibilities:
  • Manage installed plugins
  • Browse community plugins (GitHub + database)
  • Install/uninstall plugins
  • Activate/deactivate plugins
  • Process plugin UI (STAC) with user context
Key methods:
  • refresh() - Reload plugin lists
  • install() - Install from marketplace
  • uninstall() - Remove plugin
  • togglePluginStatus() - Enable/disable
  • getProcessedUi() - Inject user data into plugin UI (e.g., {{userName}})
State:
List<StorePlugin> _installed = [];
List<StorePlugin> _community = [];
bool _isLoading = false;
UserData? _currentUser;  // For UI template processing

ThemeProvider

File: lib/providers/theme_provider.dart Responsibilities:
  • Manage theme customization (colors, brightness)
  • Sync with user profile theme config
  • Persist theme changes to backend
Initialization:
// lib/main.dart:188-203
ChangeNotifierProxyProvider<AuthProvider, ThemeProvider>(
  create: (c) => ThemeProvider(c.read<ThemeService>()),
  update: (context, auth, prev) {
    if (auth.isAuthenticated && auth.user?.themeConfig != null && !prev!.isInitialized) {
      final config = auth.user!.themeConfig!;
      prev.setInitializing();
      prev.initializeThemeFromConfig(
        config.theme.primaryColor,
        config.theme.brightness,
      );
    }
    return prev!;
  },
)

PreferencesProvider

File: lib/providers/preferences_provider.dart Responsibilities:
  • Manage user preferences (language, currency, notifications)
  • Load preferences on login
  • Persist preference changes
Usage in app:
// lib/main.dart:292-304
final preferencesProvider = context.watch<PreferencesProvider>();

MaterialApp.router(
  locale: preferencesProvider.locale,
  // ...
)

Accessing Providers in UI

watch vs read vs select

// 1. watch - Rebuilds when provider changes
final items = context.watch<InventoryItemProvider>().inventoryItems;

// 2. read - One-time access (no rebuild)
context.read<InventoryItemProvider>().createInventoryItem(item);

// 3. select - Rebuild only when specific property changes
final isLoading = context.select<InventoryItemProvider, bool>(
  (provider) => provider.isLoading,
);

Consumer Widget

For granular rebuilds:
Consumer<InventoryItemProvider>(
  builder: (context, provider, child) {
    if (provider.isLoading) {
      return CircularProgressIndicator();
    }
    return ListView(children: provider.inventoryItems.map(...));
  },
)

Selector Widget

For optimal performance (rebuild only on specific changes):
Selector<InventoryItemProvider, List<InventoryItem>>(
  selector: (context, provider) => provider.inventoryItems,
  builder: (context, items, child) {
    return ListView(children: items.map(...));
  },
)

State Update Flow

Typical flow for updating state:
1. User Action (Button Press)

2. Widget calls provider method
   context.read<ExampleProvider>().updateData(...)

3. Provider updates internal state
   _data = newData;

4. Provider calls service
   await _service.updateOnServer(newData);

5. Provider notifies listeners
   notifyListeners();

6. Widgets rebuild
   context.watch<ExampleProvider>().data rebuilds UI
Example: Updating an inventory item (lib/providers/inventory_item_provider.dart:403-432)
Future<InventoryItem> updateAssetWithFiles(
  InventoryItem updatedItem, {
  FileData filesToUpload = const [],
  List<int> imageIdsToDelete = const [],
}) async {
  _isLoading = true;
  notifyListeners();  // Step 3: Show loading
  
  try {
    // Step 4: API call
    final result = await _itemService.updateInventoryItem(
      updatedItem,
      filesToUpload: filesToUpload,
      imageIdsToDelete: imageIdsToDelete,
    );
    
    // Step 5: Refresh cache
    await loadInventoryItems(
      containerId: updatedItem.containerId,
      assetTypeId: updatedItem.assetTypeId,
      forceReload: true,
    );
    
    return result;  // Return fresh data to UI
  } catch (e) {
    _isLoading = false;
    notifyListeners();
    rethrow;  // Let UI handle error
  }
}

Best Practices

1. Single Responsibility

Each provider manages one domain:
  • AuthProvider handles auth, InventoryItemProvider handles items
  • ❌ Don’t mix concerns (e.g., auth logic in InventoryItemProvider)

2. Dispose Properly

Always dispose of resources:
// lib/providers/inventory_item_provider.dart:76-84
bool _isDisposed = false;

@override
void dispose() {
  _isDisposed = true;
  super.dispose();
}

@override
void notifyListeners() {
  if (!_isDisposed) super.notifyListeners();
}

3. Avoid Unnecessary Rebuilds

Use context.select() or Selector for expensive widgets:
// Only rebuilds when isLoading changes, not on every provider change
final isLoading = context.select<ExampleProvider, bool>(
  (p) => p.isLoading,
);

4. Error Handling

Rethrow errors for UI to display:
try {
  await _service.riskyOperation();
} catch (e) {
  _isLoading = false;
  notifyListeners();
  rethrow;  // UI can show SnackBar/Toast
}

5. Use Future.microtask for Initialization

Avoid calling async methods during build:
// ❌ BAD - Throws error
update: (context, auth, prev) {
  prev.loadData();  // Can't call async during build
  return prev;
}

// ✅ GOOD
update: (context, auth, prev) {
  Future.microtask(() => prev.loadData());
  return prev;
}

6. Optimize with Getters

Compute data in getters, not in methods:
// ✅ GOOD - Computed on access
List<Item> get filteredItems => _items.where((i) => i.active).toList();

// ❌ BAD - Manual call required
void filterItems() {
  _filteredItems = _items.where((i) => i.active).toList();
  notifyListeners();
}

Testing Providers

Providers are easy to test in isolation:
test('AuthProvider login success', () async {
  final mockApiService = MockApiService();
  final provider = AuthProvider(apiService: mockApiService);
  
  when(mockApiService.login(any, any)).thenAnswer(
    (_) async => LoginResponse(success: true, token: 'abc123'),
  );
  
  final result = await provider.login('user', 'pass');
  
  expect(result.success, true);
  expect(provider.token, 'abc123');
  expect(provider.isAuthenticated, true);
});

Next Steps

Build docs developers (and LLMs) love