8ff74b36e1
Phase 02: Provider Migration - Standard stack identified (provider v6.1.5+1) - Architecture patterns documented (ChangeNotifier, Consumer, Selector) - Pitfalls catalogued (7 common errors with solutions) - Migration strategy validated against context decisions Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
870 lines
33 KiB
Markdown
870 lines
33 KiB
Markdown
# Phase 2: Provider Migration - Research
|
|
|
|
**Researched:** 2026-02-10
|
|
**Domain:** Flutter state management - Provider pattern migration
|
|
**Confidence:** HIGH
|
|
|
|
## Summary
|
|
|
|
This research investigates migrating a Flutter GPS speedometer app from raw `setState()` calls to the Provider pattern (ChangeNotifier/Consumer/Selector). The standard approach uses the `provider` package (current stable: v6.1.5+1) with ChangeNotifier for reactive state management, Consumer/Selector widgets for granular rebuilds, and MultiProvider for dependency injection at the app root.
|
|
|
|
The codebase currently has 11 setState calls across lifecycle management (_isOverlayActive, _isInBackground), GPS data updates (_currentGpsData), user settings (_currentUnit, _currentThemeIndex), and error states (_errorMessage). The GpsDataManager already uses StreamController with broadcast streams, making it compatible with ChangeNotifier integration via mixin.
|
|
|
|
Key challenges include: (1) managing async lifecycle (GPS streams, timers, overlay listeners) during provider disposal, (2) preventing "ChangeNotifier after dispose" errors from outstanding async operations, (3) pre-initializing SharedPreferences for settings without UI flash, and (4) coordinating overlay messaging across isolate boundaries when GPS data flows through Provider.
|
|
|
|
**Primary recommendation:** Use ChangeNotifier mixin on GpsDataManager (not wrapper), separate SettingsProvider for theme/units loaded from pre-initialized SharedPreferences in main(), OverlayProvider to centralize all overlay messaging, and granular Selector widgets for performance-critical speed/heading displays.
|
|
|
|
## Standard Stack
|
|
|
|
The established libraries/tools for Flutter Provider-based state management:
|
|
|
|
### Core
|
|
| Library | Version | Purpose | Why Standard |
|
|
|---------|---------|---------|--------------|
|
|
| provider | 6.1.5+1 | State management via InheritedWidget wrapper | Flutter team recommended, 1.13M downloads, Flutter Favorite badge |
|
|
| flutter/foundation | Built-in | ChangeNotifier base class | Core Flutter SDK, zero dependencies, easily testable |
|
|
|
|
### Supporting
|
|
| Library | Version | Purpose | When to Use |
|
|
|---------|---------|---------|-------------|
|
|
| shared_preferences | Current | Persistent settings storage | Loading initial provider state (theme, units) |
|
|
| mockito / mocktail | Latest | Testing mocks | Unit testing ChangeNotifier classes |
|
|
|
|
### Alternatives Considered
|
|
| Instead of | Could Use | Tradeoff |
|
|
|------------|-----------|----------|
|
|
| provider | Riverpod | More modern API, compile-time safety, but breaking paradigm shift and overkill for this app size |
|
|
| provider | Bloc | Better for complex async flows, but adds ceremony and learning curve for simple GPS app |
|
|
| provider | GetX | Simpler syntax, but less community support and breaks Flutter conventions |
|
|
|
|
**Installation:**
|
|
```bash
|
|
flutter pub add provider
|
|
```
|
|
|
|
**Current Status:** Project already has shared_preferences (no version specified in pubspec.yaml), needs provider package added.
|
|
|
|
## Architecture Patterns
|
|
|
|
### Recommended Project Structure
|
|
```
|
|
lib/
|
|
├── providers/ # ChangeNotifier classes
|
|
│ ├── gps_data_manager.dart # Already exists in services/, extends with ChangeNotifier
|
|
│ ├── settings_provider.dart # Theme + units combined
|
|
│ └── overlay_provider.dart # Overlay lifecycle + messaging
|
|
├── screens/ # UI screens (already exists)
|
|
├── widgets/ # Reusable UI components
|
|
├── services/ # Non-provider services (logger, gps_service)
|
|
├── models/ # Data models (already exists)
|
|
└── config/ # Constants (already exists)
|
|
```
|
|
|
|
**Note:** GpsDataManager currently lives in `services/` - decision needed: move to `providers/` or keep in `services/` and extend with ChangeNotifier mixin in place.
|
|
|
|
### Pattern 1: ChangeNotifier with Existing StreamController
|
|
|
|
**What:** Combine ChangeNotifier with existing broadcast streams for dual access patterns
|
|
**When to use:** When migrating existing stream-based services to Provider without breaking existing consumers
|
|
|
|
**Example:**
|
|
```dart
|
|
// Source: Adapted from context decisions + Flutter docs pattern
|
|
class GpsDataManager extends ChangeNotifier {
|
|
final StreamController<ProcessedGpsData> _dataController =
|
|
StreamController<ProcessedGpsData>.broadcast();
|
|
|
|
ProcessedGpsData _currentData = const ProcessedGpsData(/*...*/);
|
|
|
|
// Stream for direct subscription (if needed)
|
|
Stream<ProcessedGpsData> get dataStream => _dataController.stream;
|
|
|
|
// Synchronous getter for Provider consumers
|
|
ProcessedGpsData get currentData => _currentData;
|
|
|
|
void _updateData(ProcessedGpsData newData) {
|
|
_currentData = newData;
|
|
_dataController.add(newData); // Emit to stream
|
|
notifyListeners(); // Notify Provider consumers
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_dataController.close();
|
|
super.dispose(); // CRITICAL: Call super.dispose() last
|
|
}
|
|
}
|
|
```
|
|
|
|
**Current Implementation:** GpsDataManager already has `_dataController` broadcast stream and `_currentData` - migration path is straightforward.
|
|
|
|
### Pattern 2: MultiProvider Setup at App Root
|
|
|
|
**What:** Wire all providers at root for app-wide access
|
|
**When to use:** Always - prevents scope pollution and makes dependencies explicit
|
|
|
|
**Example:**
|
|
```dart
|
|
// Source: https://docs.flutter.dev/data-and-backend/state-mgmt/simple
|
|
void main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
// Pre-initialize SharedPreferences to avoid async in provider create
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
runApp(
|
|
MultiProvider(
|
|
providers: [
|
|
// Providers in dependency order (independent first)
|
|
Provider.value(value: prefs), // Dependency for SettingsProvider
|
|
|
|
ChangeNotifierProvider(
|
|
create: (_) => SettingsProvider(prefs),
|
|
),
|
|
|
|
ChangeNotifierProvider(
|
|
create: (_) => GpsDataManager(),
|
|
),
|
|
|
|
// OverlayProvider depends on GPS + Settings
|
|
ChangeNotifierProxyProvider2<GpsDataManager, SettingsProvider, OverlayProvider>(
|
|
create: (_) => OverlayProvider(),
|
|
update: (_, gps, settings, overlay) => overlay!..update(gps, settings),
|
|
),
|
|
],
|
|
child: const SpeedoApp(),
|
|
),
|
|
);
|
|
}
|
|
```
|
|
|
|
**Key Decisions:**
|
|
- Use `Provider.value` for SharedPreferences (pre-initialized, not created by Provider)
|
|
- Use `ChangeNotifierProvider` with `create` for app-managed lifecycles
|
|
- Use `ChangeNotifierProxyProvider2` when provider needs to listen to multiple others
|
|
- Order matters: dependencies come before dependents
|
|
|
|
### Pattern 3: Granular Consumer/Selector for Performance
|
|
|
|
**What:** Place Consumer/Selector as deep as possible, use Selector for specific property access
|
|
**When to use:** Speed display shouldn't rebuild when theme changes, theme controls shouldn't rebuild on GPS updates
|
|
|
|
**Example:**
|
|
```dart
|
|
// Source: https://docs.flutter.dev/data-and-backend/state-mgmt/simple
|
|
// BAD - Rebuilds entire screen on any GPS update
|
|
Consumer<GpsDataManager>(
|
|
builder: (context, gps, _) => Column(
|
|
children: [
|
|
Text(gps.currentData.displaySpeed), // Needs rebuild
|
|
ThemeControls(), // Doesn't need rebuild!
|
|
],
|
|
),
|
|
)
|
|
|
|
// GOOD - Selector rebuilds only speed text
|
|
Column(
|
|
children: [
|
|
Selector<GpsDataManager, String>(
|
|
selector: (_, gps) => gps.currentData.displaySpeed,
|
|
builder: (_, displaySpeed, __) => Text(displaySpeed),
|
|
),
|
|
ThemeControls(), // Totally independent, never rebuilds
|
|
],
|
|
)
|
|
|
|
// GOOD - Consumer for multiple related properties
|
|
Consumer<SettingsProvider>(
|
|
builder: (context, settings, _) => Row(
|
|
children: [
|
|
ThemeButton(settings.currentTheme),
|
|
UnitButton(settings.currentUnit),
|
|
],
|
|
),
|
|
)
|
|
```
|
|
|
|
**Performance Rule:** Selector > Consumer > Provider.of (listen: true). Use Selector when accessing single property, Consumer when accessing 2-3 related properties.
|
|
|
|
### Pattern 4: Provider.of for Actions (Non-Rebuilding Access)
|
|
|
|
**What:** Access provider methods without triggering rebuilds
|
|
**When to use:** Button tap handlers that modify state but don't need current state for rendering
|
|
|
|
**Example:**
|
|
```dart
|
|
// Source: https://docs.flutter.dev/data-and-backend/state-mgmt/simple
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
// listen: false prevents rebuild when provider notifies
|
|
Provider.of<SettingsProvider>(context, listen: false).cycleTheme();
|
|
Provider.of<OverlayProvider>(context, listen: false).pushData();
|
|
},
|
|
child: const Text('Change Theme'),
|
|
)
|
|
|
|
// Alternative: context.read() extension (same behavior)
|
|
ElevatedButton(
|
|
onPressed: () {
|
|
context.read<SettingsProvider>().cycleTheme();
|
|
context.read<OverlayProvider>().pushData();
|
|
},
|
|
child: const Text('Change Theme'),
|
|
)
|
|
```
|
|
|
|
### Pattern 5: Eager SharedPreferences Loading
|
|
|
|
**What:** Initialize SharedPreferences in main() before runApp() to avoid async provider create
|
|
**When to use:** When providers need synchronous access to settings without flash of defaults
|
|
|
|
**Example:**
|
|
```dart
|
|
// Source: https://simondev.medium.com/use-sharedpreferences-in-flutter-effortlessly-835bba8f7418
|
|
void main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
|
|
// Load preferences before app starts - no splash screen flash
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
runApp(
|
|
MultiProvider(
|
|
providers: [
|
|
Provider.value(value: prefs), // Provide to dependent providers
|
|
ChangeNotifierProvider(
|
|
create: (context) {
|
|
final prefs = context.read<SharedPreferences>();
|
|
return SettingsProvider(prefs); // Synchronous constructor
|
|
},
|
|
),
|
|
],
|
|
child: const SpeedoApp(),
|
|
),
|
|
);
|
|
}
|
|
|
|
class SettingsProvider extends ChangeNotifier {
|
|
final SharedPreferences _prefs;
|
|
|
|
// Constructor is synchronous - prefs already loaded
|
|
SettingsProvider(this._prefs) {
|
|
_loadSettings(); // Synchronous read from already-initialized prefs
|
|
}
|
|
|
|
void _loadSettings() {
|
|
_currentThemeIndex = _prefs.getInt('themeIndex') ?? 0;
|
|
_currentUnit = SpeedUnit.values[_prefs.getInt('unitIndex') ?? 0];
|
|
// No notifyListeners() needed - constructor runs before first build
|
|
}
|
|
}
|
|
```
|
|
|
|
**Context Decision Alignment:** Matches requirement for "pre-initialized SharedPreferences in main() — no splash, no flash of defaults".
|
|
|
|
### Pattern 6: Overlay Communication with Provider
|
|
|
|
**What:** Centralize all overlay messaging in OverlayProvider that listens to GPS + Settings
|
|
**When to use:** When overlay isolate needs data from multiple providers
|
|
|
|
**Example:**
|
|
```dart
|
|
// Conceptual pattern - flutter_overlay_window runs in separate isolate
|
|
class OverlayProvider extends ChangeNotifier {
|
|
bool _isOverlayActive = false;
|
|
StreamSubscription<ProcessedGpsData>? _gpsSubscription;
|
|
|
|
bool get isOverlayActive => _isOverlayActive;
|
|
|
|
void update(GpsDataManager gps, SettingsProvider settings) {
|
|
// Called by ChangeNotifierProxyProvider2 when dependencies change
|
|
|
|
// Listen to GPS stream continuously
|
|
_gpsSubscription ??= gps.dataStream.listen((data) {
|
|
if (_isOverlayActive) { // Skip sends when inactive (if-check, not unsubscribe)
|
|
_pushToOverlay(data, settings);
|
|
}
|
|
});
|
|
}
|
|
|
|
void _pushToOverlay(ProcessedGpsData gps, SettingsProvider settings) {
|
|
final speedText = settings.currentUnit.convert(gps.speed).toStringAsFixed(1);
|
|
FlutterOverlayWindow.shareData({
|
|
'speedText': speedText,
|
|
'unitText': settings.currentUnit.label,
|
|
'themeIndex': settings.currentThemeIndex,
|
|
// ... other fields
|
|
});
|
|
}
|
|
|
|
void activate() {
|
|
_isOverlayActive = true;
|
|
notifyListeners();
|
|
}
|
|
|
|
void deactivate() {
|
|
_isOverlayActive = false;
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_gpsSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
```
|
|
|
|
**Context Decision Alignment:** Matches "OverlayProvider listens to GpsDataManager stream continuously, skips sends when overlay inactive (if-check, not unsubscribe)".
|
|
|
|
### Anti-Patterns to Avoid
|
|
|
|
- **Calling notifyListeners() during build:** Causes "setState during build" errors. Move to async callbacks or create method.
|
|
- **Placing Consumer too high in tree:** Rebuilds entire subtree instead of leaf widgets. Push Consumer as deep as possible.
|
|
- **Using .value constructor for created instances:** `ChangeNotifierProvider.value` is for existing instances. Use `create` callback for new instances to ensure proper disposal.
|
|
- **Forgetting listen: false in event handlers:** Causes unnecessary rebuilds when button tap only needs to call method.
|
|
- **setState coexistence during migration:** Mixing setState and Provider for same state creates confusion. Migrate completely or not at all per state concern.
|
|
- **Creating providers inside StatefulWidget.initState:** Providers should live in widget tree via MultiProvider, not created in state lifecycle.
|
|
|
|
## Don't Hand-Roll
|
|
|
|
Problems that look simple but have existing solutions:
|
|
|
|
| Problem | Don't Build | Use Instead | Why |
|
|
|---------|-------------|-------------|-----|
|
|
| Dependency injection | Manual singleton registry | Provider's MultiProvider + context.read() | Handles lifecycle, disposal, testing overrides automatically |
|
|
| Widget rebuilding on specific state change | Manual change tracking | Selector widget | Built-in comparison, optimized rebuilds, cleaner code |
|
|
| Async initialization in providers | Custom Future handling | Pre-initialize in main() with Provider.value | Avoids async create callbacks, prevents flash of defaults |
|
|
| Disposing subscriptions in providers | Manual tracking | ChangeNotifier.dispose() override | Automatic disposal when provider removed from tree |
|
|
| Testing with mocked state | Custom test harness | Provider overrides in widget tests | Built-in override mechanism for testing |
|
|
|
|
**Key insight:** Provider package already handles 90% of state lifecycle complexity. Custom solutions for subscription management, disposal order, and dependency graphs will have edge cases. Trust the framework.
|
|
|
|
## Common Pitfalls
|
|
|
|
### Pitfall 1: "ChangeNotifier after dispose" Error
|
|
|
|
**What goes wrong:** Async operations (GPS stream updates, timers) complete after ChangeNotifier.dispose() called, triggering notifyListeners() on disposed object
|
|
|
|
**Why it happens:** Outstanding async operations hold references to ChangeNotifier methods. When operations complete, they invoke methods that call notifyListeners(), but provider already disposed.
|
|
|
|
**How to avoid:**
|
|
1. Cancel all async operations in dispose():
|
|
```dart
|
|
@override
|
|
void dispose() {
|
|
_gpsSubscription?.cancel();
|
|
_staleDataTimer?.cancel();
|
|
_dataController.close();
|
|
super.dispose(); // Call super LAST
|
|
}
|
|
```
|
|
|
|
2. Guard notifyListeners() with disposed flag:
|
|
```dart
|
|
bool _isDisposed = false;
|
|
|
|
void _updateData(ProcessedGpsData data) {
|
|
if (_isDisposed) return;
|
|
_currentData = data;
|
|
notifyListeners();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_isDisposed = true;
|
|
// ... cancel operations
|
|
super.dispose();
|
|
}
|
|
```
|
|
|
|
**Warning signs:**
|
|
- "A ChangeNotifier was used after being disposed" assertion
|
|
- Occurs when navigating away from screen or hot reloading
|
|
- More likely with long-running streams or timers
|
|
|
|
**Source:** https://github.com/rrousselGit/provider/issues/506
|
|
|
|
### Pitfall 2: setState After Dispose (During Migration)
|
|
|
|
**What goes wrong:** Widget calls setState() after being removed from tree, common when mixing setState and Provider during migration
|
|
|
|
**Why it happens:** Async callbacks (GPS stream, overlay listeners) fire after widget.dispose() called but before migration complete
|
|
|
|
**How to avoid:**
|
|
1. Check `mounted` before setState():
|
|
```dart
|
|
_gpsSubscription = stream.listen((data) {
|
|
if (mounted) { // Add this guard
|
|
setState(() => _currentGpsData = data);
|
|
}
|
|
});
|
|
```
|
|
|
|
2. Migrate completely, not partially - context decision specifies "big bang migration":
|
|
```dart
|
|
// BAD - Mixing patterns
|
|
setState(() => _currentUnit = newUnit); // setState for some state
|
|
context.read<GpsDataManager>().update(); // Provider for other state
|
|
|
|
// GOOD - All provider
|
|
context.read<SettingsProvider>().setUnit(newUnit);
|
|
// GpsDataManager updates automatically via stream
|
|
```
|
|
|
|
**Warning signs:**
|
|
- "setState() called after dispose()" error during navigation
|
|
- Occurs during hot reload or screen pop
|
|
- Logs show async callback firing after screen disposed
|
|
|
|
### Pitfall 3: Forgetting super.dispose()
|
|
|
|
**What goes wrong:** Memory leaks, listeners not cleared, parent cleanup skipped
|
|
|
|
**Why it happens:** Developer overrides dispose() and forgets to call super.dispose(), preventing ChangeNotifier base class cleanup
|
|
|
|
**How to avoid:**
|
|
```dart
|
|
// BAD
|
|
@override
|
|
void dispose() {
|
|
_subscription.cancel();
|
|
// FORGOT super.dispose() - memory leak!
|
|
}
|
|
|
|
// GOOD
|
|
@override
|
|
void dispose() {
|
|
_subscription.cancel();
|
|
_timer.cancel();
|
|
super.dispose(); // ALWAYS call super.dispose() LAST
|
|
}
|
|
```
|
|
|
|
**Warning signs:**
|
|
- Gradual memory increase over time
|
|
- Listeners still firing after widget removed
|
|
- DevTools shows lingering subscriptions
|
|
|
|
### Pitfall 4: Using Provider.of with listen: true in Event Handlers
|
|
|
|
**What goes wrong:** Unnecessary widget rebuilds when event handler only needs to call method, not display state
|
|
|
|
**Why it happens:** Default `Provider.of(context)` has `listen: true`, causing widget rebuild whenever provider notifies
|
|
|
|
**How to avoid:**
|
|
```dart
|
|
// BAD - Widget rebuilds on every GPS update even though button doesn't display GPS data
|
|
onPressed: () {
|
|
final overlay = Provider.of<OverlayProvider>(context); // listen: true (default)
|
|
overlay.show();
|
|
}
|
|
|
|
// GOOD - No rebuild, just method call
|
|
onPressed: () {
|
|
Provider.of<OverlayProvider>(context, listen: false).show();
|
|
}
|
|
|
|
// BETTER - Use context.read() for clarity
|
|
onPressed: () {
|
|
context.read<OverlayProvider>().show();
|
|
}
|
|
```
|
|
|
|
**Warning signs:**
|
|
- Buttons rebuilding when unrelated state changes
|
|
- Performance lag on rapid state updates
|
|
- DevTools shows unexpected rebuild counts
|
|
|
|
### Pitfall 5: Async Initialization in Provider Create
|
|
|
|
**What goes wrong:** Provider create callback is synchronous - async initialization causes "used before initialized" errors or flash of default values
|
|
|
|
**Why it happens:** Trying to load SharedPreferences or make API calls inside create callback
|
|
|
|
**How to avoid:**
|
|
```dart
|
|
// BAD - create callback is synchronous, can't await
|
|
ChangeNotifierProvider(
|
|
create: (_) {
|
|
final provider = SettingsProvider();
|
|
provider.loadSettings(); // Async! Won't complete before first build
|
|
return provider;
|
|
},
|
|
)
|
|
|
|
// GOOD - Pre-initialize async dependencies in main()
|
|
void main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
final prefs = await SharedPreferences.getInstance(); // Load BEFORE runApp
|
|
|
|
runApp(
|
|
MultiProvider(
|
|
providers: [
|
|
Provider.value(value: prefs), // Provide pre-initialized instance
|
|
ChangeNotifierProvider(
|
|
create: (context) => SettingsProvider(context.read<SharedPreferences>()),
|
|
),
|
|
],
|
|
child: const SpeedoApp(),
|
|
),
|
|
);
|
|
}
|
|
```
|
|
|
|
**Warning signs:**
|
|
- Flash of default values before real data loads
|
|
- "Bad state: field used before initialized" errors
|
|
- Settings don't persist on first app launch
|
|
|
|
**Source:** https://simondev.medium.com/use-sharedpreferences-in-flutter-effortlessly-835bba8f7418
|
|
|
|
### Pitfall 6: Provider Scope Pollution
|
|
|
|
**What goes wrong:** Placing providers too high in tree makes them accessible to widgets that shouldn't depend on them, creating hidden coupling
|
|
|
|
**Why it happens:** Putting all providers at app root "just in case" instead of scoping to actual usage
|
|
|
|
**How to avoid:**
|
|
```dart
|
|
// For this app: GPS data, settings, overlay ARE global app state
|
|
// MultiProvider at root is CORRECT per context decisions
|
|
// But in general, scope providers to minimal subtree:
|
|
|
|
// BAD in general apps - chat provider available to settings screen
|
|
MaterialApp(
|
|
home: MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider(create: (_) => ChatProvider()), // Only used in ChatScreen
|
|
ChangeNotifierProvider(create: (_) => SettingsProvider()),
|
|
],
|
|
child: HomeScreen(),
|
|
),
|
|
)
|
|
|
|
// GOOD - Scope ChatProvider to ChatScreen only
|
|
MaterialApp(
|
|
home: MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider(create: (_) => SettingsProvider()), // Global
|
|
],
|
|
child: HomeScreen(
|
|
// ChatProvider scoped to ChatScreen subtree
|
|
onChatTap: () => Navigator.push(context, MaterialPageRoute(
|
|
builder: (_) => ChangeNotifierProvider(
|
|
create: (_) => ChatProvider(),
|
|
child: ChatScreen(),
|
|
),
|
|
)),
|
|
),
|
|
),
|
|
)
|
|
```
|
|
|
|
**For GPS Speedo:** App has single screen (SpeedometerScreen) - all providers at root is appropriate. This pitfall applies to multi-screen apps.
|
|
|
|
### Pitfall 7: Stream Subscription Lifecycle Mismatch
|
|
|
|
**What goes wrong:** Creating stream subscription in ChangeNotifier constructor but widget rebuilds provider instance on hot reload, creating multiple subscriptions
|
|
|
|
**Why it happens:** Confusion about when ChangeNotifier instances are created vs when they listen to streams
|
|
|
|
**How to avoid:**
|
|
```dart
|
|
// BAD - Subscribes in constructor, can't unsubscribe properly on hot reload
|
|
class GpsDataManager extends ChangeNotifier {
|
|
GpsDataManager() {
|
|
_gpsSubscription = GpsService.positionStream.listen(_onUpdate); // Leak on hot reload!
|
|
}
|
|
}
|
|
|
|
// GOOD - Explicit initialize() called once, dispose() paired correctly
|
|
class GpsDataManager extends ChangeNotifier {
|
|
Future<void> initialize() async {
|
|
if (_isInitialized) return; // Guard against double-init
|
|
|
|
_gpsSubscription = GpsService.positionStream.listen(_onUpdate);
|
|
_isInitialized = true;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_gpsSubscription?.cancel();
|
|
super.dispose();
|
|
}
|
|
}
|
|
|
|
// In main screen:
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
context.read<GpsDataManager>().initialize(); // Explicit call
|
|
}
|
|
```
|
|
|
|
**Warning signs:**
|
|
- Multiple GPS subscriptions active (check logs for duplicate position updates)
|
|
- Memory usage increases on hot reload
|
|
- "Stream has already been listened to" errors
|
|
|
|
**Current Implementation:** GpsDataManager already has separate initialize() method - migration should preserve this pattern.
|
|
|
|
## Code Examples
|
|
|
|
Verified patterns from official sources:
|
|
|
|
### Basic ChangeNotifier with Getters
|
|
|
|
```dart
|
|
// Source: https://docs.flutter.dev/data-and-backend/state-mgmt/simple
|
|
class SettingsProvider extends ChangeNotifier {
|
|
final SharedPreferences _prefs;
|
|
|
|
int _currentThemeIndex = 0;
|
|
SpeedUnit _currentUnit = SpeedUnit.kmh;
|
|
|
|
SettingsProvider(this._prefs) {
|
|
_loadSettings();
|
|
}
|
|
|
|
// Expose state via getters (immutable access)
|
|
int get currentThemeIndex => _currentThemeIndex;
|
|
SpeedUnit get currentUnit => _currentUnit;
|
|
|
|
void _loadSettings() {
|
|
_currentThemeIndex = _prefs.getInt('themeIndex') ?? 0;
|
|
final unitIndex = _prefs.getInt('unitIndex') ?? 0;
|
|
_currentUnit = SpeedUnit.values[unitIndex];
|
|
}
|
|
|
|
void cycleTheme() {
|
|
_currentThemeIndex = ColorThemes.getNextThemeIndex(_currentThemeIndex);
|
|
_prefs.setInt('themeIndex', _currentThemeIndex);
|
|
notifyListeners(); // Triggers rebuild of Consumer/Selector widgets
|
|
}
|
|
|
|
void setUnit(SpeedUnit unit) {
|
|
_currentUnit = unit;
|
|
_prefs.setInt('unitIndex', unit.index);
|
|
notifyListeners();
|
|
}
|
|
}
|
|
```
|
|
|
|
### MultiProvider with Dependencies
|
|
|
|
```dart
|
|
// Source: https://docs.flutter.dev/data-and-backend/state-mgmt/simple
|
|
void main() async {
|
|
WidgetsFlutterBinding.ensureInitialized();
|
|
final prefs = await SharedPreferences.getInstance();
|
|
|
|
runApp(
|
|
MultiProvider(
|
|
providers: [
|
|
Provider.value(value: prefs),
|
|
|
|
ChangeNotifierProvider(
|
|
create: (context) => SettingsProvider(context.read<SharedPreferences>()),
|
|
),
|
|
|
|
ChangeNotifierProvider(
|
|
create: (_) => GpsDataManager(),
|
|
),
|
|
|
|
ChangeNotifierProvider(
|
|
create: (_) => OverlayProvider(),
|
|
),
|
|
],
|
|
child: const SpeedoApp(),
|
|
),
|
|
);
|
|
}
|
|
```
|
|
|
|
### Selector for Granular Rebuilds
|
|
|
|
```dart
|
|
// Source: https://pub.dev/documentation/provider/latest/provider/Selector-class.html
|
|
// Only rebuilds when displaySpeed string changes
|
|
Selector<GpsDataManager, String>(
|
|
selector: (context, gps) => gps.currentData.displaySpeed,
|
|
builder: (context, displaySpeed, child) {
|
|
return Text(
|
|
displaySpeed,
|
|
style: TextStyle(fontSize: 80),
|
|
);
|
|
},
|
|
)
|
|
|
|
// Selector with multiple values using tuple/record
|
|
Selector<GpsDataManager, (String speed, String heading)>(
|
|
selector: (context, gps) => (
|
|
gps.currentData.displaySpeed,
|
|
gps.currentData.displayHeading,
|
|
),
|
|
builder: (context, data, child) {
|
|
return Column(
|
|
children: [
|
|
Text(data.$1), // speed
|
|
Text(data.$2), // heading
|
|
],
|
|
);
|
|
},
|
|
)
|
|
```
|
|
|
|
### Consumer with Child Optimization
|
|
|
|
```dart
|
|
// Source: https://docs.flutter.dev/data-and-backend/state-mgmt/simple
|
|
Consumer<SettingsProvider>(
|
|
builder: (context, settings, child) => Column(
|
|
children: [
|
|
child!, // ExpensiveWidget built once, reused
|
|
Text('Theme: ${settings.currentThemeIndex}'), // Rebuilds on change
|
|
],
|
|
),
|
|
child: const ExpensiveWidget(), // Built once, passed to builder as child
|
|
)
|
|
```
|
|
|
|
### Provider.of for Non-Rebuilding Access
|
|
|
|
```dart
|
|
// Source: https://docs.flutter.dev/data-and-backend/state-mgmt/simple
|
|
// Event handler - no rebuild needed
|
|
IconButton(
|
|
icon: Icon(Icons.navigation),
|
|
onPressed: () {
|
|
context.read<OverlayProvider>().show(); // Equivalent to Provider.of(..., listen: false)
|
|
},
|
|
)
|
|
|
|
// Or traditional syntax
|
|
IconButton(
|
|
icon: Icon(Icons.navigation),
|
|
onPressed: () {
|
|
Provider.of<OverlayProvider>(context, listen: false).show();
|
|
},
|
|
)
|
|
```
|
|
|
|
### Proper Dispose Pattern
|
|
|
|
```dart
|
|
// Source: https://api.flutter.dev/flutter/foundation/ChangeNotifier/dispose.html
|
|
class GpsDataManager extends ChangeNotifier {
|
|
StreamSubscription<Position>? _gpsSubscription;
|
|
Timer? _staleDataTimer;
|
|
final StreamController<ProcessedGpsData> _dataController =
|
|
StreamController<ProcessedGpsData>.broadcast();
|
|
|
|
@override
|
|
void dispose() {
|
|
// Cancel all async operations BEFORE calling super.dispose()
|
|
_gpsSubscription?.cancel();
|
|
_gpsSubscription = null;
|
|
|
|
_staleDataTimer?.cancel();
|
|
_staleDataTimer = null;
|
|
|
|
_dataController.close();
|
|
|
|
// CRITICAL: Call super.dispose() LAST
|
|
super.dispose();
|
|
}
|
|
}
|
|
```
|
|
|
|
## State of the Art
|
|
|
|
| Old Approach | Current Approach | When Changed | Impact |
|
|
|--------------|------------------|--------------|--------|
|
|
| Provider v5 nullable handling | Provider v6/v7: Unified nullable/non-nullable lookup | v7.0.0 (2021) | Can't distinguish Model vs Model? providers - deepest provider always wins |
|
|
| Async provider create | Pre-initialize in main() + Provider.value | Best practice ~2022 | Eliminates flash of defaults, cleaner error handling |
|
|
| InheritedWidget manual impl | Provider package wrapper | Package creation ~2019 | Reduces boilerplate by 80%, handles disposal automatically |
|
|
| setState for all state | Provider for shared state, setState for local | Ongoing evolution | Better performance, clearer data flow |
|
|
| Consumer for all access | Selector for single properties | Provider v4+ | 30-50% fewer rebuilds in hot paths |
|
|
|
|
**Deprecated/outdated:**
|
|
- **Provider v5 strict mode:** Removed in v6 - was too restrictive for real apps
|
|
- **ChangeNotifierProvider.value with created instances:** Still works but discourages creating provider instances in widget state (use create callback instead)
|
|
- **SharedPreferences as singleton:** New SharedPreferencesAsync API preferred for new code (as of 2024), but getInstance() still supported
|
|
|
|
**Current Best Practice (2026):**
|
|
- Provider package v6.1.5+1 (stable)
|
|
- Pre-initialize async dependencies in main()
|
|
- Use Selector for performance-critical paths
|
|
- MultiProvider at app root for global state
|
|
- context.read() / context.watch() extensions over Provider.of
|
|
|
|
## Open Questions
|
|
|
|
Things that couldn't be fully resolved:
|
|
|
|
1. **GpsDataManager singleton removal strategy**
|
|
- What we know: Context decisions specify "singleton accessor removed — all access goes through Provider via BuildContext"
|
|
- What's unclear: Current code has `GpsDataManager.instance` singleton accessor used in multiple places (speedometer_screen.dart line 354: `GpsDataManager.instance.getFormattedSpeed(_currentUnit)`)
|
|
- Recommendation: During migration, replace all `GpsDataManager.instance` calls with `context.read<GpsDataManager>()` in event handlers and `context.watch<GpsDataManager>()` in build methods. Remove `static GpsDataManager? _instance` and `static GpsDataManager get instance` entirely.
|
|
|
|
2. **Permission state management**
|
|
- What we know: Context decisions list "How to handle permission state" as Claude's discretion
|
|
- What's unclear: Should GPS permission status be part of GpsDataManager state (triggering rebuilds) or remain procedural (check once at init)?
|
|
- Recommendation: Keep permission handling procedural in GpsDataManager.initialize() - permission errors already reflected in displaySpeed/displayHeading ('NO PERM', 'GPS OFF'). No separate permission provider needed since permission changes are rare and require app restart.
|
|
|
|
3. **OverlayProvider implementation details**
|
|
- What we know: Should listen to GpsDataManager stream and SettingsProvider, skip sends when inactive
|
|
- What's unclear: Should use ChangeNotifierProxyProvider2 (complex) or manual listen in update method?
|
|
- Recommendation: Use ChangeNotifierProxyProvider2 for automatic dependency tracking. Provider handles notification propagation automatically when GPS or Settings change.
|
|
|
|
4. **Trip tracking removal scope**
|
|
- What we know: Context decisions specify "trip tracking (distance, trip time, trip reset) is being removed entirely" and "Metrics panel UI removed entirely"
|
|
- What's unclear: How much UI code depends on trip tracking? Are there any widget files dedicated to trip display?
|
|
- Recommendation: Grep for trip-related state variables before migration to identify all removal points. Likely candidates: _tripDistance, _tripStartTime, _resetTrip(), metrics panel widget code.
|
|
|
|
5. **Testing strategy for migration**
|
|
- What we know: STATE.md notes "Must create manual regression testing checklist before provider migration"
|
|
- What's unclear: What level of automated testing is expected? Should unit tests be written for providers before or during migration?
|
|
- Recommendation: Phase 2 focuses on migration, not adding tests. Manual regression checklist sufficient per existing practice (Phase 1 had manual testing checkpoint). Unit tests for providers can be added post-migration if needed.
|
|
|
|
## Sources
|
|
|
|
### Primary (HIGH confidence)
|
|
- [Flutter Official Docs: Simple State Management](https://docs.flutter.dev/data-and-backend/state-mgmt/simple) - ChangeNotifier patterns, Consumer/Selector usage
|
|
- [Provider Package v6.1.5+1](https://pub.dev/packages/provider) - Official API documentation
|
|
- [Provider Package Changelog](https://pub.dev/packages/provider/changelog) - Version history and breaking changes
|
|
- [ChangeNotifier API Docs](https://api.flutter.dev/flutter/foundation/ChangeNotifier-class.html) - Disposal patterns
|
|
- [Flutter Official Docs: Dependency Injection](https://docs.flutter.dev/app-architecture/case-study/dependency-injection) - MultiProvider patterns
|
|
|
|
### Secondary (MEDIUM confidence)
|
|
- [Till It's Done: Provider vs Consumer vs Selector](https://tillitsdone.com/blogs/flutter-provider-differences/) - Performance comparison verified with official Selector docs
|
|
- [Mobikul: Consumer vs Selector](https://mobikul.com/consumer-v-s-selector/) - When to use each pattern
|
|
- [Flutter Mastery Library: Consumer and Selector Optimization](https://fluttermasterylibrary.com/1/6/2/4/) - Performance patterns
|
|
- [Medium: Using SharedPreferences with Provider](https://simondev.medium.com/use-sharedpreferences-in-flutter-effortlessly-835bba8f7418) - Pre-initialization pattern
|
|
- [GitHub Provider Issues: #506](https://github.com/rrousselGit/provider/issues/506) - "ChangeNotifier after dispose" error solutions
|
|
- [GitHub Provider Issues: #78](https://github.com/rrousselGit/provider/issues/78) - Disposal checking patterns
|
|
|
|
### Tertiary (LOW confidence)
|
|
- Web search results on Provider migration strategies - Multiple blog posts from 2023-2025, patterns consistent but not officially verified
|
|
- Community discussions on overlay isolate communication - Limited official guidance, mostly community solutions
|
|
|
|
## Metadata
|
|
|
|
**Confidence breakdown:**
|
|
- Standard stack: HIGH - Official Flutter docs + package pub.dev page + 1.13M downloads
|
|
- Architecture: HIGH - Patterns directly from Flutter official state management guide
|
|
- Pitfalls: HIGH - Verified with official issue trackers and API documentation
|
|
- Code examples: HIGH - All examples sourced from official docs or official API pages
|
|
- Overlay communication: MEDIUM - flutter_overlay_window not extensively documented, relying on package examples
|
|
- Testing patterns: MEDIUM - Verified approach but not deeply researched (not primary focus per context)
|
|
|
|
**Research date:** 2026-02-10
|
|
**Valid until:** 2026-04-10 (60 days - provider package is stable, not fast-moving)
|
|
|
|
**Current codebase analysis:**
|
|
- 11 setState calls identified in speedometer_screen.dart
|
|
- GpsDataManager already has StreamController + broadcast pattern
|
|
- SharedPreferences not yet in use (no version in pubspec.yaml)
|
|
- No existing provider usage - clean migration path
|
|
- Overlay communication via FlutterOverlayWindow.shareData (isolate boundary)
|