So you’ve heard about Flutter and want to build something real — not just a counter app. This post walks through everything I use day-to-day: clean project structure, state management with Riverpod, HTTP calls, and a few patterns that keep large codebases sane.
Table of Contents
Open Table of Contents
Project Setup
Start by creating a new Flutter project. I always use --org to set the package name properly from the beginning — changing it later is a pain.
flutter create --org com.alvinaprianto --platforms android,ios my_app
cd my_app
flutter pub get
Check that everything runs before touching anything:
flutter run
Tip: Run
flutter doctorif you hit issues. It tells you exactly what’s missing in your environment.
Folder Structure
Flat folders get messy fast. Here’s the structure I use across all my projects:
lib/
├── core/
│ ├── constants/
│ │ └── app_constants.dart
│ ├── errors/
│ │ └── failures.dart
│ └── utils/
│ └── extensions.dart
├── data/
│ ├── models/
│ │ └── user_model.dart
│ ├── repositories/
│ │ └── user_repository.dart
│ └── services/
│ └── api_service.dart
├── presentation/
│ ├── pages/
│ │ ├── home/
│ │ │ ├── home_page.dart
│ │ │ └── home_provider.dart
│ │ └── profile/
│ │ └── profile_page.dart
│ └── widgets/
│ └── app_button.dart
└── main.dart
Keep data/ for anything that touches the network or local DB. presentation/ is pure UI + state. core/ is shared utilities.
State Management with Riverpod
I use Riverpod on every project. It’s type-safe, testable, and doesn’t require a BuildContext for everything.
Add it to pubspec.yaml:
dependencies:
flutter_riverpod: ^2.5.1
riverpod_annotation: ^2.3.5
dev_dependencies:
riverpod_generator: ^2.4.0
build_runner: ^2.4.9
Wrap your app at the root:
void main() {
runApp(
const ProviderScope(
child: MyApp(),
),
);
}
A simple async provider that fetches user data:
// lib/presentation/pages/home/home_provider.dart
import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'package:my_app/data/models/user_model.dart';
import 'package:my_app/data/repositories/user_repository.dart';
part 'home_provider.g.dart';
@riverpod
Future<List<User>> userList(UserListRef ref) async {
final repo = ref.watch(userRepositoryProvider);
return repo.getUsers();
}
Then consume it in a widget:
class HomePage extends ConsumerWidget {
const HomePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final usersAsync = ref.watch(userListProvider);
return Scaffold(
appBar: AppBar(title: const Text('Users')),
body: usersAsync.when(
data: (users) => ListView.builder(
itemCount: users.length,
itemBuilder: (context, index) => ListTile(
title: Text(users[index].name),
subtitle: Text(users[index].email),
),
),
loading: () => const Center(child: CircularProgressIndicator()),
error: (e, _) => Center(child: Text('Error: $e')),
),
);
}
}
Fetching Data from an API
I use Dio over the default http package — interceptors, logging, and retry handling are built in.
// lib/data/services/api_service.dart
import 'package:dio/dio.dart';
class ApiService {
late final Dio _dio;
ApiService() {
_dio = Dio(
BaseOptions(
baseUrl: 'https://api.example.com/v1',
connectTimeout: const Duration(seconds: 10),
receiveTimeout: const Duration(seconds: 10),
headers: {'Content-Type': 'application/json'},
),
);
// Log requests in debug mode
_dio.interceptors.add(LogInterceptor(responseBody: true));
}
Future<Response> get(String path, {Map<String, dynamic>? params}) {
return _dio.get(path, queryParameters: params);
}
Future<Response> post(String path, {dynamic data}) {
return _dio.post(path, data: data);
}
}
And the model with freezed + json_serializable:
// lib/data/models/user_model.dart
import 'package:freezed_annotation/freezed_annotation.dart';
part 'user_model.freezed.dart';
part 'user_model.g.dart';
@freezed
class User with _$User {
const factory User({
required int id,
required String name,
required String email,
String? avatarUrl,
}) = _User;
factory User.fromJson(Map<String, dynamic> json) => _$UserFromJson(json);
}
Run code generation after adding freezed models:
dart run build_runner build --delete-conflicting-outputs
Useful Packages
Here’s what goes into almost every app I ship:
| Package | Purpose |
|---|---|
| flutter_riverpod | State management |
| dio | HTTP client |
| freezed | Immutable models |
| go_router | Navigation & deep links |
| flutter_secure_storage | Storing tokens securely |
| cached_network_image | Image caching |
| supabase_flutter | Auth + DB (when using Supabase) |
Deployment Checklist
Before hitting that release button:
- Set correct
applicationId/bundleIdin both platforms - Replace all debug API endpoints with production URLs
- Enable ProGuard / R8 on Android (
minifyEnabled true) - Set
FLUTTER_SUPPRESS_ANALYTICS=1if building in CI - Run
flutter build appbundle --releaseand test on a real device - Check
flutter analyzereturns zero issues - Bump
versioninpubspec.yaml(e.g.1.0.0+1) - Upload to Google Play internal track / TestFlight before public release
Final Thoughts
Flutter moves fast. The tooling is solid, the widget catalog is massive, and Dart is genuinely pleasant to write once you get used to it. If you’re coming from React Native, the paradigm shift is small — StatelessWidget ≈ functional component, ConsumerWidget ≈ connected component.
Got questions or want to see a follow-up post on something specific? Reach me at contact@alvinaprianto.com or find me on GitHub.