Files
tm_app/VIEWMODEL_COMMUNICATION_PATTERNS.md

6.5 KiB

ViewModel Communication Patterns

The Problem: Direct ViewModel Dependencies

class NotificationViewModel {
  final HomeViewModel _homeViewModel; // ❌ BAD: Direct dependency
  
  void markAsRead() {
    _homeViewModel.setUnreadNotificationFalse(); // ❌ Tight coupling
  }
}

Why is this bad?

  • Tight coupling between ViewModels
  • Difficult to test in isolation
  • Circular dependency risk
  • Violates Single Responsibility Principle
  • Hard to maintain and scale

Solution 1: Shared Service Pattern (BEST for your case)

Create a dedicated service that manages shared state.

Implementation:

Service:

// lib/data/services/notification_state_service.dart
class NotificationStateService extends ChangeNotifier {
  bool _hasUnreadNotification = false;
  
  bool get hasUnreadNotification => _hasUnreadNotification;
  
  void setHasUnreadNotification(bool value) {
    if (_hasUnreadNotification != value) {
      _hasUnreadNotification = value;
      notifyListeners();
    }
  }
}

ViewModels depend on the service:

class HomeViewModel {
  final NotificationStateService _notificationStateService;
  
  void checkUnreadNotifications() async {
    // ... fetch from API
    _notificationStateService.setHasUnreadNotification(hasUnread);
  }
}

class NotificationViewModel {
  final NotificationStateService _notificationStateService;
  
  void markAsRead() {
    // ... mark as read
    _notificationStateService.setHasUnreadNotification(false);
  }
}

UI consumes the service directly:

class NotificationBadge extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final hasUnread = context.watch<NotificationStateService>()
        .hasUnreadNotification;
    
    return Badge(showBadge: hasUnread);
  }
}

Benefits:

  • No ViewModel-to-ViewModel dependency
  • Single source of truth
  • Easy to test
  • Follows SOLID principles
  • Scalable

Solution 2: Repository Pattern

Move the shared logic to a repository that both ViewModels depend on.

// Repository manages the domain logic
class NotificationRepository {
  bool _hasUnread = false;
  
  Future<bool> checkUnreadNotifications() async {
    final result = await api.checkUnread();
    _hasUnread = result.hasUnread;
    return _hasUnread;
  }
  
  Future<void> markAllAsRead() async {
    await api.markAllAsRead();
    _hasUnread = false;
  }
}

// ViewModels only depend on repository
class HomeViewModel {
  final NotificationRepository _repo;
  
  void init() async {
    final hasUnread = await _repo.checkUnreadNotifications();
    notifyListeners();
  }
}

Solution 3: Event Bus / Stream Pattern

ViewModels communicate through events without knowing about each other.

// Event bus service
class AppEventBus {
  final _controller = StreamController<AppEvent>.broadcast();
  
  Stream<AppEvent> get events => _controller.stream;
  void emit(AppEvent event) => _controller.add(event);
}

// Events
abstract class AppEvent {}
class NotificationReadEvent extends AppEvent {}

// ViewModel 1: Emits events
class NotificationViewModel {
  final AppEventBus _eventBus;
  
  void markAsRead() {
    // ... mark as read
    _eventBus.emit(NotificationReadEvent());
  }
}

// ViewModel 2: Listens to events
class HomeViewModel {
  final AppEventBus _eventBus;
  
  HomeViewModel() {
    _eventBus.events.listen((event) {
      if (event is NotificationReadEvent) {
        _handleNotificationRead();
      }
    });
  }
}

Solution 4: UI Coordination

Let the UI layer coordinate between ViewModels.

class NotificationScreen extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final notificationVM = context.watch<NotificationViewModel>();
    final homeVM = context.watch<HomeViewModel>();
    
    return Button(
      onPressed: () {
        // UI coordinates the interaction
        notificationVM.markAsRead();
        homeVM.refreshNotificationBadge();
      },
    );
  }
}

When to use:

  • Simple interactions
  • UI-specific coordination
  • Few ViewModels involved

Solution 5: Riverpod Pattern (Advanced)

Use Riverpod's provider dependencies for better state management.

// Shared state provider
final notificationStateProvider = StateNotifierProvider<NotificationState, bool>(
  (ref) => NotificationState(),
);

class NotificationState extends StateNotifier<bool> {
  NotificationState() : super(false);
  
  void setHasUnread(bool value) => state = value;
}

// ViewModels depend on the provider
final homeViewModelProvider = Provider((ref) {
  final notificationState = ref.read(notificationStateProvider.notifier);
  return HomeViewModel(notificationState);
});

📊 Comparison Table

Pattern Complexity Testability Scalability Best For
Shared Service Low High High Shared state across app
Repository Medium High High Domain logic sharing
Event Bus Medium Medium High Loosely coupled events
UI Coordination Low Medium Low Simple interactions
Riverpod Medium High Very High New projects

🎯 Recommendation for Your Project

Use Solution 1: Shared Service Pattern because:

  1. Your codebase already uses Provider
  2. The notification state is truly shared across the app
  3. Simple to implement and understand
  4. Easy to test
  5. Follows your modular architecture principle
  6. No major refactoring required

📝 Implementation Checklist

  • Create NotificationStateService
  • Add service to dependency injection
  • Update HomeViewModel to use service
  • Update NotificationViewModel to use service
  • Remove direct ViewModel dependencies
  • Test the implementation
  • Remove the example files when done

🧪 Testing Example

test('NotificationViewModel updates shared state', () {
  final stateService = NotificationStateService();
  final repository = MockNotificationRepository();
  final viewModel = NotificationViewModel(
    notificationsRepository: repository,
    notificationStateService: stateService,
  );
  
  // Act
  viewModel.markAllAsRead();
  
  // Assert
  expect(stateService.hasUnreadNotification, false);
});

🚀 Key Takeaway

ViewModels should NEVER depend on other ViewModels.

Instead:

  • Use a shared service for shared state
  • Use repositories for domain logic
  • Use event buses for loose coupling
  • Let the UI coordinate simple interactions