6.9 KiB
6.9 KiB
Flutter Reference (Dart)
Project Structure
lib/
├── main.dart # Entry point
├── app/
│ ├── app.dart # MaterialApp + router setup
│ ├── theme/ # ThemeData, colors, typography, spacing
│ └── router/ # go_router config, guards
├── features/
│ └── home/
│ ├── data/
│ │ ├── datasource/ # Remote + local data sources
│ │ ├── dto/ # JSON models (freezed)
│ │ └── repository/ # Repo implementations
│ ├── domain/
│ │ ├── model/ # Domain models (freezed)
│ │ ├── repository/ # Abstract repo interfaces
│ │ └── usecase/ # Use cases
│ └── presentation/
│ ├── bloc/ # Bloc/Cubit + state + event
│ └── screen/ # Widgets + page files
├── core/
│ ├── network/ # Dio client, interceptors
│ ├── database/ # Drift DB setup
│ ├── widgets/ # Shared design system widgets
│ └── error/ # Failure types, error handling
└── injection.dart # GetIt service locator setup
State Management (BLoC)
// States
@freezed
class HomeState with _$HomeState {
const factory HomeState.initial() = _Initial;
const factory HomeState.loading() = _Loading;
const factory HomeState.success(List<Item> items) = _Success;
const factory HomeState.failure(String message) = _Failure;
}
// Events
@freezed
class HomeEvent with _$HomeEvent {
const factory HomeEvent.loadItems() = _LoadItems;
const factory HomeEvent.refreshItems() = _RefreshItems;
}
// Bloc
class HomeBloc extends Bloc<HomeEvent, HomeState> {
final GetItemsUseCase _getItems;
HomeBloc(this._getItems) : super(const HomeState.initial()) {
on<_LoadItems>(_onLoad);
}
Future<void> _onLoad(_LoadItems event, Emitter<HomeState> emit) async {
emit(const HomeState.loading());
final result = await _getItems();
result.fold(
(failure) => emit(HomeState.failure(failure.message)),
(items) => emit(HomeState.success(items)),
);
}
}
State Management (Riverpod — alternative)
@riverpod
class HomeNotifier extends _$HomeNotifier {
@override
FutureOr<List<Item>> build() => _load();
Future<List<Item>> _load() async {
final repo = ref.read(itemRepositoryProvider);
return repo.getItems().getOrThrow();
}
Future<void> refresh() async {
state = const AsyncLoading();
state = await AsyncValue.guard(_load);
}
}
Screen Widget Pattern
class HomeScreen extends StatelessWidget {
const HomeScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (ctx) => sl<HomeBloc>()..add(const HomeEvent.loadItems()),
child: const _HomeView(),
);
}
}
class _HomeView extends StatelessWidget {
const _HomeView();
@override
Widget build(BuildContext context) {
return Scaffold(
body: BlocConsumer<HomeBloc, HomeState>(
listener: (ctx, state) {
state.maybeWhen(
failure: (msg) => ScaffoldMessenger.of(ctx)
.showSnackBar(SnackBar(content: Text(msg))),
orElse: () {},
);
},
builder: (ctx, state) => state.when(
initial: () => const SizedBox(),
loading: () => const Center(child: CircularProgressIndicator()),
success: (items) => _ItemList(items: items),
failure: (msg) => ErrorView(message: msg,
onRetry: () => ctx.read<HomeBloc>().add(
const HomeEvent.loadItems())),
),
),
);
}
}
go_router Setup
final router = GoRouter(
initialLocation: '/home',
redirect: (context, state) {
final isLoggedIn = ref.read(authStateProvider).isLoggedIn;
if (!isLoggedIn && !state.matchedLocation.startsWith('/auth')) {
return '/auth/login';
}
return null;
},
routes: [
GoRoute(
path: '/home',
name: AppRoutes.home,
builder: (ctx, state) => const HomeScreen(),
routes: [
GoRoute(
path: 'detail/:id',
builder: (ctx, state) =>
DetailScreen(id: state.pathParameters['id']!),
),
],
),
],
);
Drift Database
@DriftDatabase(tables: [Items])
class AppDatabase extends _$AppDatabase {
AppDatabase(QueryExecutor e) : super(e);
@override
int get schemaVersion => 1;
Stream<List<Item>> watchAllItems() =>
(select(items)..orderBy([(t) => OrderingTerm.desc(t.updatedAt)])).watch();
Future<void> upsertItems(List<ItemsCompanion> rows) =>
batch((b) => b.insertAllOnConflictUpdate(items, rows));
}
Key pubspec.yaml Dependencies
dependencies:
flutter_bloc: ^8.1.5
freezed_annotation: ^2.4.1
riverpod: ^2.5.1 # alternative to bloc
flutter_riverpod: ^2.5.1
go_router: ^14.1.0
dio: ^5.4.3
drift: ^2.18.0
sqflite: ^2.3.3
get_it: ^7.7.0
injectable: ^2.4.1
dartz: ^0.10.1 # Either/Option for FP error handling
json_annotation: ^4.9.0
dev_dependencies:
build_runner: ^2.4.9
freezed: ^2.5.2
json_serializable: ^6.8.0
drift_dev: ^2.18.0
mocktail: ^1.0.3
bloc_test: ^9.1.7
Error Handling (Either/Failure pattern)
abstract class Failure {
final String message;
const Failure(this.message);
}
class NetworkFailure extends Failure {
const NetworkFailure([super.message = 'Network error occurred']);
}
class CacheFailure extends Failure {
const CacheFailure([super.message = 'Cache error occurred']);
}
// Repository
Future<Either<Failure, List<Item>>> getItems() async {
try {
final remote = await _remoteSource.fetchItems();
await _localSource.saveItems(remote);
return Right(remote.map(_mapper.toDomain).toList());
} on DioException catch (e) {
return Left(NetworkFailure(e.message ?? 'Network error'));
} on Exception {
return const Left(CacheFailure());
}
}
Testing
void main() {
group('HomeBloc', () {
late HomeBloc bloc;
late MockGetItemsUseCase mockUseCase;
setUp(() {
mockUseCase = MockGetItemsUseCase();
bloc = HomeBloc(mockUseCase);
});
tearDown(() => bloc.close());
blocTest<HomeBloc, HomeState>(
'emits [loading, success] when loadItems succeeds',
build: () {
when(() => mockUseCase()).thenAnswer(
(_) async => Right([Item(id: '1', title: 'Test')]),
);
return bloc;
},
act: (b) => b.add(const HomeEvent.loadItems()),
expect: () => [
const HomeState.loading(),
isA<HomeState>().having((s) => s, 'success',
const HomeState.success([Item(id: '1', title: 'Test')])),
],
);
});
}