Skip to main content

Overview

Aradia Audiobooks uses a hybrid state management approach combining:
  • BLoC (Business Logic Component) for screen-specific business logic
  • Provider for global application state and services
  • Hive for persistent local storage
This combination provides the right tool for each use case, ensuring maintainable and scalable code.

State Management Strategy

BLoC

Screen-specific business logic with reactive streams

Provider

Global services and shared state across widgets

Hive

Persistent storage for offline data

BLoC Pattern

When to Use BLoC

Use BLoC for:
  • Screen-specific business logic
  • Complex state transitions
  • Asynchronous operations (API calls)
  • Pagination and data fetching
  • Search functionality

BLoC Architecture

Each BLoC consists of three parts:
  1. Events - User actions or external triggers
  2. States - UI states representing different conditions
  3. BLoC - Business logic that transforms events into states

Search BLoC Example

Here’s how the Search feature is implemented:
import 'dart:async';
import 'package:bloc/bloc.dart';
import 'package:aradia/resources/archive_api.dart';
import 'package:aradia/resources/models/audiobook.dart';
import 'package:aradia/utils/app_events.dart';
import 'package:meta/meta.dart';

part 'search_event.dart';
part 'search_state.dart';

class SearchBloc extends Bloc<SearchEvent, SearchState> {
  int currentPage = 1;
  
  /// The last query that was explicitly submitted
  String? lastQuery;
  
  StreamSubscription<void>? _langSub;

  SearchBloc() : super(SearchInitial()) {
    on<EventSearchIconClicked>(_onSearchSubmitted);
    on<EventLoadMoreResults>(_onLoadMore);

    // Refresh results for current query on language change
    _langSub = AppEvents.languagesChanged.stream.listen((_) {
      final q = lastQuery?.trim();
      if (q != null && q.isNotEmpty) {
        add(EventSearchIconClicked(q));
      }
    });
  }

  Future<void> _onSearchSubmitted(
    EventSearchIconClicked event,
    Emitter<SearchState> emit,
  ) async {
    // New search starts at page 1 and locks in the query
    currentPage = 1;
    lastQuery = event.searchQuery;
    
    await _runSearch(
      emit, 
      query: lastQuery!, 
      page: currentPage, 
      isFresh: true
    );
  }

  Future<void> _onLoadMore(
    EventLoadMoreResults event,
    Emitter<SearchState> emit,
  ) async {
    // Keep using the locked-in lastQuery
    final q = lastQuery?.trim();
    if (q == null || q.isEmpty) return;

    currentPage += 1;
    await _runSearch(emit, query: q, page: currentPage, isFresh: false);
  }

  Future<void> _runSearch(
    Emitter<SearchState> emit, {
    required String query,
    required int page,
    required bool isFresh,
  }) async {
    if (isFresh) {
      emit(SearchLoading());
    }

    try {
      final res = await ArchiveApi().searchAudiobook(query, page, 10);

      res.fold(
        (err) {
          if (isFresh) {
            emit(SearchFailure(err));
          }
        },
        (list) {
          if (isFresh) {
            emit(SearchSuccess(list));
          } else {
            // Append to existing list
            final prev = state;
            if (prev is SearchSuccess) {
              emit(SearchSuccess([...prev.audiobooks, ...list]));
            } else {
              emit(SearchSuccess(list));
            }
          }
        },
      );
    } catch (_) {
      if (isFresh) {
        emit(SearchFailure('Failed to search audiobooks'));
      }
    }
  }

  @override
  Future<void> close() {
    _langSub?.cancel();
    return super.close();
  }
}

Home BLoC Example

The Home screen demonstrates multiple event handlers and state management:
class HomeBloc extends Bloc<HomeEvent, HomeState> {
  final ArchiveApi _archiveApi;
  StreamSubscription<void>? _langSub;
  int _reqGen = 0; // Generation token to cancel stale requests

  HomeBloc({ArchiveApi? archiveApi})
      : _archiveApi = archiveApi ?? ArchiveApi(),
        super(HomeInitial()) {
    on<FetchLatestAudiobooks>(_onFetchLatestAudiobooks);
    on<FetchPopularAudiobooks>(_onFetchPopularAudiobooks);
    on<FetchPopularThisWeekAudiobooks>(_onFetchPopularThisWeekAudiobooks);
    on<FetchAudiobooksByGenre>(_onFetchAudiobooksByGenre);

    on<ResetHomeLists>((event, emit) {
      // Bump generation so in-flight old requests are ignored
      _reqGen++;
      emit(HomeInitial());
      add(FetchLatestAudiobooks(1, 20));
      add(FetchPopularAudiobooks(1, 20));
      add(FetchPopularThisWeekAudiobooks(1, 20));
    });

    // Listen for language changes and refresh data
    _langSub = AppEvents.languagesChanged.stream.listen((_) {
      add(ResetHomeLists());
    });
  }

  Future<void> _fetchAudiobooks({
    required int page,
    required int rows,
    required Future<Either<String, List<Audiobook>>> Function() fetchFunction,
    required HomeState loadingState,
    required HomeState Function(List<Audiobook>) successState,
    required HomeState failureState,
    required Emitter<HomeState> emit,
  }) async {
    // Capture the generation at the time this request starts
    final localGen = _reqGen;

    if (page == 1) {
      emit(loadingState);
    }

    try {
      final result = await fetchFunction();

      // If language changed while in-flight, drop it silently
      if (localGen != _reqGen) return;

      result.fold(
        (_) {
          if (page == 1) {
            emit(failureState);
          }
        },
        (audiobooks) => emit(successState(audiobooks)),
      );
    } catch (_) {
      if (localGen != _reqGen) return;
      if (page == 1) {
        emit(failureState);
      }
    }
  }

  @override
  Future<void> close() {
    _langSub?.cancel();
    return super.close();
  }
}

Using BLoC in Widgets

Providing and consuming BLoC in the widget tree:
class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MultiBlocProvider(
      providers: [
        BlocProvider(
          create: (context) => AudiobookDetailsBloc(),
        ),
        BlocProvider(
          create: (context) => SearchBloc(),
        ),
      ],
      child: MaterialApp.router(
        routerConfig: router,
      ),
    );
  }
}

Provider Pattern

When to Use Provider

Use Provider for:
  • Global services (audio player, theme, settings)
  • Shared state across multiple screens
  • Dependency injection
  • Simple state that doesn’t require complex logic

Global Providers

Aradia uses several global providers:
void main() async {
  WidgetsFlutterBinding.ensureInitialized();
  
  await initHive();
  await AppLogger.initialize();
  
  final chromeCastService = ChromeCastService();
  await chromeCastService.initialize();
  
  final audioHandlerProvider = AudioHandlerProvider();
  final weSlideController = WeSlideController();
  final themeNotifier = ThemeNotifier();
  final youtubeAudiobookNotifier = YoutubeAudiobookNotifier();
  final webViewKeepAliveProvider = WebViewKeepAliveProvider();
  
  runApp(
    MultiProvider(
      providers: [
        ChangeNotifierProvider(create: (_) => audioHandlerProvider),
        ChangeNotifierProvider(create: (_) => weSlideController),
        ChangeNotifierProvider(create: (_) => themeNotifier),
        ChangeNotifierProvider(create: (_) => youtubeAudiobookNotifier),
        ChangeNotifierProvider(create: (_) => webViewKeepAliveProvider),
      ],
      child: const MyApp(),
    ),
  );
  
  // Initialize audio handler after first frame
  WidgetsBinding.instance.addPostFrameCallback((_) {
    audioHandlerProvider.initialize();
  });
}

Audio Handler Provider

Manages the audio service for background playback:
import 'package:aradia/resources/services/my_audio_handler.dart';
import 'package:flutter/material.dart';
import 'package:audio_service/audio_service.dart';

class AudioHandlerProvider extends ChangeNotifier {
  late MyAudioHandler _audioHandler = MyAudioHandler();

  Future<void> initialize() async {
    _audioHandler = await AudioService.init(
      builder: () => MyAudioHandler(),
      config: const AudioServiceConfig(
        androidNotificationChannelId: 'com.oseamiya.librivoxaudiobook',
        androidNotificationChannelName: 'Audio playback',
        androidNotificationOngoing: true,
      ),
    );
    notifyListeners(); // Notify listeners that initialization is done
  }

  MyAudioHandler get audioHandler => _audioHandler;
}

Theme Notifier

Manages app theme with persistent storage:
import 'package:flutter/material.dart';
import 'package:hive/hive.dart';

class ThemeNotifier extends ChangeNotifier {
  // Default to system theme
  ThemeMode _themeMode = ThemeMode.system;
  ThemeMode get themeMode => _themeMode;
  
  final Box<dynamic> _themeBox = Hive.box('theme_mode_box');
  
  ThemeNotifier() {
    _loadTheme();
  }
  
  void _loadTheme() {
    // Store one of: 'system' | 'light' | 'dark'
    final saved = _themeBox.get(
      'theme_mode_box', 
      defaultValue: 'system'
    ) as String;
    
    switch (saved) {
      case 'light':
        _themeMode = ThemeMode.light;
        break;
      case 'dark':
        _themeMode = ThemeMode.dark;
        break;
      default:
        _themeMode = ThemeMode.system;
    }
    notifyListeners();
  }
  
  void setTheme(ThemeMode mode) {
    _themeMode = mode;
    final value = switch (mode) {
      ThemeMode.light => 'light',
      ThemeMode.dark => 'dark',
      _ => 'system',
    };
    _themeBox.put('theme_mode_box', value);
    notifyListeners();
  }
  
  void toggleTheme() {
    if (_themeMode == ThemeMode.light) {
      _themeMode = ThemeMode.dark;
    } else {
      _themeMode = ThemeMode.light;
    }
    _themeBox.put(
      'theme_mode_box',
      _themeMode == ThemeMode.dark ? 'dark' : 'light',
    );
    notifyListeners();
  }
}

Hive Storage

Initialization

Hive boxes are initialized at app startup:
lib/main.dart
Future<void> initHive() async {
  final documentDir = await getApplicationDocumentsDirectory();
  await Hive.initFlutter(documentDir.path);
  
  await Hive.openBox('favourite_audiobooks_box');
  await Hive.openBox('download_status_box');
  await Hive.openBox('playing_audiobook_details_box');
  await Hive.openBox('theme_mode_box');
  await Hive.openBox('history_of_audiobook_box');
  await Hive.openBox('recommened_audiobooks_box');
  await Hive.openBox('dual_mode_box');
  await Hive.openBox('language_prefs_box');
  
  Box recommendedAudiobooksBox = Hive.box('recommened_audiobooks_box');
  isRecommendScreen = recommendedAudiobooksBox.isEmpty ? 1 : 0;
}

Using Hive

// Get box
final box = Hive.box('favourite_audiobooks_box');

// Save audiobook
final audiobookMap = audiobook.toMap();
box.put(audiobook.id, audiobookMap);

// Save simple value
box.put('last_played_id', 'audiobook_123');

Best Practices

  1. One BLoC per screen - Keep BLoCs focused on a single responsibility
  2. Clean up subscriptions - Always cancel StreamSubscriptions in close()
  3. Immutable events and states - Use @immutable and final fields
  4. Sealed classes - Use sealed classes for events and states in Dart 3.0+
  5. Handle all states - Ensure UI handles all possible states
  6. Avoid business logic in UI - Keep widgets focused on presentation
  1. Dispose resources - Override dispose() to clean up
  2. Minimize rebuilds - Use Consumer or Selector instead of context.watch()
  3. Use context.read() for actions - Don’t use context.watch() for one-time actions
  4. Lazy initialization - Use lazy: true for providers not needed at startup
  5. Combine with BLoC - Use Provider for services, BLoC for business logic
  1. Initialize early - Open boxes before runApp()
  2. Use descriptive names - Name boxes clearly (e.g., favourite_audiobooks_box)
  3. Type safety - Use Box<T> with typed models when possible
  4. Compact regularly - Call box.compact() to optimize storage
  5. Avoid large objects - Store only necessary data
  6. Use adapters for complex types - Register custom type adapters

State Flow Diagram

Testing State Management

import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';

void main() {
  group('SearchBloc', () {
    late SearchBloc searchBloc;
    
    setUp(() {
      searchBloc = SearchBloc();
    });
    
    tearDown(() {
      searchBloc.close();
    });
    
    test('initial state is SearchInitial', () {
      expect(searchBloc.state, SearchInitial());
    });
    
    blocTest<SearchBloc, SearchState>(
      'emits [SearchLoading, SearchSuccess] when search is successful',
      build: () => searchBloc,
      act: (bloc) => bloc.add(EventSearchIconClicked('flutter')),
      expect: () => [
        SearchLoading(),
        isA<SearchSuccess>(),
      ],
    );
  });
}

Common Patterns

Pagination with BLoC

class PaginationBloc extends Bloc<PaginationEvent, PaginationState> {
  int currentPage = 1;
  bool hasMore = true;
  
  PaginationBloc() : super(PaginationInitial()) {
    on<LoadMore>(_onLoadMore);
  }
  
  Future<void> _onLoadMore(
    LoadMore event,
    Emitter<PaginationState> emit,
  ) async {
    if (!hasMore) return;
    
    final currentState = state;
    
    if (currentState is PaginationSuccess) {
      final newItems = await fetchPage(currentPage + 1);
      currentPage++;
      hasMore = newItems.isNotEmpty;
      
      emit(PaginationSuccess(
        items: [...currentState.items, ...newItems],
        hasMore: hasMore,
      ));
    }
  }
}

Error Handling

try {
  final result = await apiCall();
  result.fold(
    (error) => emit(ErrorState(error)),
    (data) => emit(SuccessState(data)),
  );
} catch (e) {
  emit(ErrorState(e.toString()));
}

Next Steps

Architecture

Learn about the overall app architecture

Building from Source

Set up your development environment

Contributing

Start contributing to the project

Build docs developers (and LLMs) love