Skip to content
ExponaBLOG
FLUTTER · DAY 098 / 100

My Flutter starter: a practical guide for developers

What every new Flutter app gets on day one.

Expona Admin7 min read

Introduction

By day 98 of learning Flutter, you've probably built enough toy apps to know what works and what slows you down. Rather than starting fresh each time, we've learned to invest a few hours in the right foundation. My Flutter starter: a practical guide for developers is about what every new app gets on day one—the structure, tooling, and automation that let your team move fast without accruing technical debt before the first feature ships.

This isn't about opinionated frameworks or reinventing the wheel. It's about the shared practices we've found across trading dashboards, ride-hailing apps, and SaaS backends that actually reduce friction and keep code readable at scale.

Architecture: Feature-First Folders and Separation of Concerns

The easiest way to end up with a mess is to let the folder structure grow organically. By day one, we organize by feature, not by type. Each feature owns its UI, business logic, and data layer.

Here's what a typical feature looks like:

dart
lib/
  features/
    auth/
      presentation/
        pages/
          login_page.dart
        widgets/
          email_field.dart
        view_models/
          auth_view_model.dart
      domain/
        entities/
          user.dart
        repositories/
          auth_repository.dart
        use_cases/
          login_use_case.dart
      data/
        datasources/
          auth_local_datasource.dart
          auth_remote_datasource.dart
        models/
          user_model.dart
        repositories/
          auth_repository_impl.dart
  core/
    di/
      service_locator.dart
    constants/
    extensions/
    utils/

The domain layer defines contracts; the data layer implements them. Presentation never talks directly to data. Repositories act as the bridge. This isn't perfect architecture—it's pragmatic separation that keeps tests isolated and logic reusable.

In production ride-hailing apps, we've seen teams move fast because a change to the payment source logic doesn't cascade into five UI pages. The repository interface stays the same; only the implementation changes.

State and Dependency Injection: Riverpod or Bloc, by Project

We don't dogmatically pick one state management solution for every project. A simple settings screen doesn't need Bloc. A complex multi-step booking flow does. Our starter includes both, and we choose at feature level.

Riverpod works well when state is largely derived and immutable. It's fast to write, the devtools are excellent, and testing feels natural.

dart
final userProvider = FutureProvider<User>((ref) async {
  final repository = ref.watch(authRepositoryProvider);
  return repository.getCurrentUser();
});

final isAuthenticatedProvider = Provider<bool>((ref) {
  final user = ref.watch(userProvider);
  return user.when(
    data: (_) => true,
    loading: () => false,
    error: (_, __) => false,
  );
});

Bloc shines when state transitions are complex and you need to reason about events explicitly. Trading platforms and ride-hailing apps benefit from Bloc's explicit event handling and replay capabilities.

dart
class BookingBloc extends Bloc<BookingEvent, BookingState> {
  final CreateBookingUseCase _createBooking;

  BookingBloc(this._createBooking) : super(BookingInitial()) {
    on<ConfirmBookingEvent>(_onConfirmBooking);
  }

  Future<void> _onConfirmBooking(
    ConfirmBookingEvent event,
    Emitter<BookingState> emit,
  ) async {
    emit(BookingLoading());
    final result = await _createBooking(event.details);
    result.fold(
      (failure) => emit(BookingError(failure.message)),
      (booking) => emit(BookingSuccess(booking)),
    );
  }
}

We set up a dependency injection container on day one that's agnostic to which approach you choose. Riverpod handles its own DI through providers; Bloc needs explicit registration. Either way, a single service locator file lets new team members understand what's available without hunting.

Core Packages: Router, HTTP, Code Generation, and Secrets

The starter includes four packages that we've found non-negotiable:

  • go_router — Type-safe navigation with deep linking baked in. No more string-based routes or NavigationObserver hackery.
  • dio — HTTP client with interceptor support for auth tokens, error handling, and request logging without boilerplate.
  • freezed — Immutable value objects and union types. Eliminates operator==, hashCode, copyWith, and serialization bugs.
  • flutter_secure_storage — Platform-native secure storage for tokens and credentials. Never roll your own.

Here's a real example: a Dio interceptor that automatically refreshes an auth token and retries the request.

dart
class AuthInterceptor extends Interceptor {
  final AuthRepository _authRepository;

  AuthInterceptor(this._authRepository);

  @override
  Future<void> onError(DioException err, ErrorInterceptorHandler handler) async {
    if (err.response?.statusCode == 401) {
      try {
        final newToken = await _authRepository.refreshToken();
        final request = err.requestOptions;
        request.headers['Authorization'] = 'Bearer $newToken';
        return handler.resolve(await dio.fetch(request));
      } catch (e) {
        return handler.next(err);
      }
    }
    return handler.next(err);
  }
}

Go_router's redirect callbacks handle authentication flows without imperative logic scattered across pages. Freezed's code generation removes a category of runtime bugs entirely. Secure storage keeps secrets off the filesystem in plaintext. These are force multipliers, not luxuries.

Quality: Lints, Tests, and CI from the First Commit

Code quality decisions after the fact are arguments. Code quality from day one is just how we work. The starter includes a strict lint config that catches nullable misuse, missing documentation, and unused variables.

dart
linter:
  rules:
    - avoid_empty_else
    - avoid_null_checks_in_equality_operators
    - avoid_relative_lib_imports
    - avoid_returning_null_for_future
    - avoid_slow_async_io
    - cancel_subscriptions
    - close_sinks
    - comment_references
    - control_flow_in_finally
    - empty_statements
    - invariant_booleans
    - leading_newlines_in_multiline_strings
    - sized_box_for_whitespace
    - sized_box_shrink
    - unnecessary_statements
    - unrelated_type_equality_checks

We run dart analyze and format with dart format in CI. No exceptions, no reviews about style. The formatter decides.

Testing scaffolding is in place from day one. A unit test template, a widget test template, and a simple CI workflow that runs both before allowing a merge to main. In trading and SaaS contexts, we've seen teams push more tests to CI early because the friction of local setup is gone.

dart
void main() {
  group('LoginUseCase', () {
    late MockAuthRepository mockRepository;
    late LoginUseCase useCase;

    setUp(() {
      mockRepository = MockAuthRepository();
      useCase = LoginUseCase(mockRepository);
    });

    test('returns user on successful login', () async {
      final user = User(id: '1', email: 'test@example.com');
      when(mockRepository.login('test@example.com', 'password'))
          .thenAnswer((_) async => Right(user));

      final result = await useCase('test@example.com', 'password');

      expect(result, Right(user));
    });
  });
}

Release: Flavors, Crash Reporting, and Automated Builds

Multiple environments—staging, production, internal testing—aren't an afterthought. The starter scaffolds flavors with separate bundle IDs, app names, and API endpoints from the first build.

dart
abstract class AppConfig {
  String get apiBaseUrl;
  String get appName;
  bool get enableCrashReporting;
}

class ProductionConfig implements AppConfig {
  @override
  String get apiBaseUrl => 'https://api.production.example.com';
  
  @override
  String get appName => 'MyApp';
  
  @override
  bool get enableCrashReporting => true;
}

class StagingConfig implements AppConfig {
  @override
  String get apiBaseUrl => 'https://api.staging.example.com';
  
  @override
  String get appName => 'MyApp Staging';
  
  @override
  bool get enableCrashReporting => false;
}

We integrate Sentry (or Crashlytics) on day one. Not after the first production crash. By then it's too late. A single line of configuration in main() sends exceptions, performance metrics, and release tracking to a dashboard.

GitHub Actions or Codemagic handles building and signing. On every tag, a build runs, tests pass, and the artifact is uploaded to TestFlight or Google Play Console. Team members don't manual-sign APKs at 11pm before a demo.

Common Mistakes

  • Over-engineering from the start. A simple counter app doesn't need repositories and use cases. Use architecture as the codebase grows, not because architecture is virtuous.
  • Choosing state management based on blog posts instead of project needs. Riverpod is simpler for most screens. Bloc is clearer for complex transitions. Neither is objectively better.
  • Skipping CI setup because it's "just a side project." The side project becomes the real project. CI and testing discipline compound over time.
  • Storing secrets in code or version control. Use environment variables, secure storage, or a secrets manager from day one. Don't refactor it later.
  • Treating the starter as sacred. Every project is different. Remove what you don't need; add what you do. The starter is a checklist, not a dogma.

Key Takeaways

  • Organize by feature, not by type. Keep presentation, domain, and data layers separate so changes don't cascade.
  • Choose state management per feature. Riverpod for derived state, Bloc for complex transitions.
  • Core packages—go_router, dio, freezed, secure_storage—eliminate entire classes of bugs and boilerplate.
  • Lint, test, and CI strictness from the first commit prevents architectural and quality debt.
  • Flavors, crash reporting, and automated builds let teams ship confidently without manual overhead.
  • A starter is a template to adapt, not a mandate. Remove what's unnecessary; add what your project needs.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles