Build a Robust Offline-First Flutter App with BLoC, Dio, and Sqflite
In the world of mobile development, relying solely on an active internet connection is a recipe for poor user experience. Users expect apps to load instantly and work seamlessly, even when they are in a subway tunnel or on a spotty connection.
This guide explores a production-ready Flutter architecture that implements an Offline-First strategy. We will build a "Posts" application that fetches data from an API, caches it locally using SQLite, and serves it to the user. If the network fails, the app seamlessly falls back to the local database.
Introduction
This project solves the "blank screen" problem. When a standard app loses internet, it shows an error or a spinner. Our app will show the last known good data.
Why this solution matters:
- Speed: Local data loads instantly.
- Reliability: The app works without internet.
- Efficiency: We reduce unnecessary API calls by serving cached content when appropriate.
Tech Stack
We have carefully selected a robust stack for scalability and maintainability:
- Flutter: Google's UI toolkit for building natively compiled applications.
- Flutter BLoC (
flutter_bloc): A predictable state management library that implements the Business Logic Component pattern. It strictly minimizes the coupling between presentation and business logic. - Dio (
dio): A powerful HTTP client for Dart, supporting interceptors, global configuration, and easier error handling than the standardhttppackage. - Sqflite (
sqflite): The standard plugin for SQLite in Flutter, providing persistent local storage. - Equatable (
equatable): Simplifies value equality comparisons, preventing unnecessary UI rebuilds when states "look" the same but are different instances. - Path Provider (
path): Helps find the correct directory on Android and iOS to store the database file.
Project Architecture
We follow the Clean Architecture principles, separating the codebase into three distinct layers:
- Data Layer: Responsible for retrieving raw data.
- Data Providers: Talk directly to the API (
PostApiProvider) or the Database (PostDbProvider). - Repository: The brain. It decides which provider to use. It fetches from the API ⮕ saves to DB ⮕ returns data. If the API fails ⮕ fetches from DB.
- Data Providers: Talk directly to the API (
- Logic Layer (BLoC): Receives events from the UI (e.g., "Load Posts"), asks the Repository for data, and emits States (e.g., "Loading", "Loaded").
- Presentation Layer: The UI widgets that purely observe the BLoC state and render accordingly.
Folder & File Structure
Here is the breakdown of the project structure found in lib/:
-
lib/data/:-
models/post_model.dart: Defines the data structure (id,title,body) and JSON serialization. -
dataproviders/post_api_provider.dart: Contains theDiologic toGETdata from the web. -
dataproviders/post_db_provider.dart: ContainsSqflitelogic toINSERTandqueryraw SQL. -
repositories/post_repository.dart: Orchestrates the data flow between API and DB.
-
-
lib/logic/blocs/post/:-
post_event.dart: Actions the user can take (e.g.,FetchPosts). -
post_state.dart: Status of the app (e.g.,PostInitial,PostLoading,PostLoaded,PostError). -
post_bloc.dart: The logic engine mapping Events to States.
-
-
lib/presentation/screens/:-
post_list_screen.dart: The main UI viewing the list.
-
-
lib/main.dart: The entry point that sets up Dependency Injection.
Environment & Prerequisites
To run this project, you need:
- OS: Windows, macOS, or Linux.
- Flutter SDK: Version 3.5.4 or higher (environment sdk:
^3.5.4inpubspec.yaml). - IDE: VS Code or Android Studio.
- Git: To clone the repository.
Installation (Step-by-Step)
1. Clone the Repository
Start by obtaining the source code.
git clone <your-repo-url> post_app
cd post_app
2. Install Dependencies
Download the required packages defined in pubspec.yaml.
flutter pub get
3. Verify Configuration
Ensure your environment is healthy.
flutter doctor
Code Walkthrough
Let's dissect the core components that make this offline-first logic work.
1. The Data Model (post_model.dart)
We need a standard way to represent a Post across our app. Note the toJson and fromJson methods, which are essential for both API parsing and Database storage.
class Post {
final int? id;
final int? userId;
final String? title;
final String? body;
const Post({
this.id,
this.userId,
this.title,
this.body,
});
factory Post.fromJson(Map<String, dynamic> json) {
return Post(
id: json['id'] as int?,
userId: json['userId'] as int?,
title: json['title'] as String?,
body: json['body'] as String?,
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'userId': userId,
'title': title,
'body': body,
};
}
}
2. The Database Provider (post_db_provider.dart)
This component initializes SQLite and handles raw SQL commands. It creates a table posts if it doesn't exist.
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import '../models/post_model.dart';
class PostDbProvider {
static Database? _database;
Future<Database> get database async {
if (_database != null) return _database!;
_database = await _initDB();
return _database!;
}
Future<Database> _initDB() async {
String path = join(await getDatabasesPath(), 'posts.db');
return await openDatabase(
path,
version: 1,
onCreate: (db, version) async {
await db.execute('''
CREATE TABLE posts(
id INTEGER PRIMARY KEY,
userId INTEGER,
title TEXT,
body TEXT
)
''');
},
);
}
Future<void> insertPosts(List<Post> posts) async {
final db = await database;
Batch batch = db.batch();
for (var post in posts) {
batch.insert(
'posts',
post.toJson(),
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
}
Future<List<Post>> getPosts() async {
final db = await database;
final List<Map<String, dynamic>> maps = await db.query('posts');
return List.generate(maps.length, (i) {
return Post.fromJson(maps[i]);
});
}
}
3. The Repository (post_repository.dart)
This is the most critical file. It implements the offline strategy logic: "Try Network, Fallback to Disk".
import '../dataproviders/post_api_provider.dart';
import '../dataproviders/post_db_provider.dart';
import '../models/post_model.dart';
class PostRepository {
final PostApiProvider apiProvider;
final PostDbProvider dbProvider;
PostRepository({
required this.apiProvider,
required this.dbProvider,
});
Future<List<Post>> getPosts() async {
try {
final posts = await apiProvider.fetchPosts();
await dbProvider.insertPosts(posts);
return posts;
} catch (e) {
// If API fails, try loading from DB
return await dbProvider.getPosts();
}
}
}
4. BLoC Events & States
Before writing the logic, we define what can happen (Events) and what the UI shows (States).
Events (post_event.dart)
We have a sole event to trigger the data fetch.
import 'package:equatable/equatable.dart';
abstract class PostEvent extends Equatable {
const PostEvent();
@override
List<Object> get props => [];
}
class FetchPosts extends PostEvent {}
States (post_state.dart)
We define the four potential states of our view. Note usage of Equatable to ensure efficient comparison of states.
import 'package:equatable/equatable.dart';
import '../../../data/models/post_model.dart';
abstract class PostState extends Equatable {
const PostState();
@override
List<Object> get props => [];
}
class PostInitial extends PostState {}
class PostLoading extends PostState {}
class PostLoaded extends PostState {
final List<Post> posts;
const PostLoaded(this.posts);
@override
List<Object> get props => [posts];
}
class PostError extends PostState {
final String message;
const PostError(this.message);
@override
List<Object> get props => [message];
}
5. The BLoC Logic (post_bloc.dart)
The BLoC doesn't care how the data was retrieved (API or DB). It simply asks the repository and updates the UI state.
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../data/repositories/post_repository.dart';
import 'post_event.dart';
import 'post_state.dart';
class PostBloc extends Bloc<PostEvent, PostState> {
final PostRepository repository;
PostBloc({required this.repository}) : super(PostInitial()) {
on<FetchPosts>(_onFetchPosts);
}
Future<void> _onFetchPosts(FetchPosts event, Emitter<PostState> emit) async {
emit(PostLoading());
try {
final posts = await repository.getPosts();
emit(PostLoaded(posts));
} catch (e) {
emit(PostError(e.toString()));
}
}
}
6. The Presentation Layer (post_list_screen.dart)
This screen listens to the BLoC and rebuilds the UI based on the current state.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../logic/blocs/post/post_bloc.dart';
import '../../logic/blocs/post/post_event.dart';
import '../../logic/blocs/post/post_state.dart';
class PostListScreen extends StatelessWidget {
const PostListScreen({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Jobs (Posts)')),
body: BlocBuilder<PostBloc, PostState>(
builder: (context, state) {
if (state is PostInitial) {
context.read<PostBloc>().add(FetchPosts());
return const Center(child: Text('Initializing...'));
} else if (state is PostLoading) {
return const Center(child: CircularProgressIndicator());
} else if (state is PostLoaded) {
return ListView.builder(
itemCount: state.posts.length,
itemBuilder: (context, index) {
final post = state.posts[index];
return ListTile(
title: Text(post.title ?? 'No Title'),
subtitle: Text(post.body ?? 'No Body',
maxLines: 2, overflow: TextOverflow.ellipsis),
);
},
);
} else if (state is PostError) {
return Center(child: Text('Error: ${state.message}'));
}
return const SizedBox.shrink();
},
),
);
}
}
7. Dependency Injection (main.dart)
We use RepositoryProvider and BlocProvider to inject our dependencies at the top of the widget tree.
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'data/dataproviders/post_api_provider.dart';
import 'data/dataproviders/post_db_provider.dart';
import 'data/repositories/post_repository.dart';
import 'logic/blocs/post/post_bloc.dart';
import 'presentation/screens/post_list_screen.dart';
void main() {
WidgetsFlutterBinding.ensureInitialized();
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
final postRepository = PostRepository(
apiProvider: PostApiProvider(),
dbProvider: PostDbProvider(),
);
return MultiRepositoryProvider(
providers: [
RepositoryProvider.value(value: postRepository),
],
child: MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => PostBloc(repository: postRepository),
),
],
child: MaterialApp(
title: 'Job App',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: const PostListScreen(),
),
),
);
}
}
How to Run the Project
- Connect a verified device or start a working emulator.
-
Run the command:
flutter run -
To verify offline mode:
- Wait for the list of posts to load from the API (placeholder JSON data).
- Enable Airplane Mode on your device/emulator.
- Restart the app (Full Restart or Kill/Open).
- Result: The posts still load instantly! (They are now serving from SQLite).
Usage Guide
The application is straightforward:
- Launch: The app opens to the
PostListScreen. - Auto-Fetch: The
BlocBuilderdetects thePostInitialstate and immediately dispatches aFetchPostsevent. - View: Scroll through the list of Titles and Bodies.
Scenario: You are a user opening the app in a generic "Posts" viewer. It works identical to a production news feed or social media timeline.
Common Errors & Troubleshooting
1. MissingPluginException for sqflite
- Cause: You added the dependency but didn't rebuild the native usage.
- Fix: Stop the app explicitly (Ctrl+C in terminal) and run
flutter runagain. A Hot Reload is not enough for adding new plugins.
2. SocketException (Internet Error)
- Cause: The emulator might not have internet access, or your
AndroidManifest.xmllacks permissions (for release builds). - Fix: For clean development, ensure your emulator has wifi enabled.
3. Database Locked
- Cause: Opening multiple instances of the DB manually.
- Fix: Our
PostDbProvideruses a singleton (static Database? _database) to prevent this. Do not remove that static reference.
Best Practices & Lessons Learned
- Repository Pattern: Never let your BLoC talk directly to Dio or SQLite. The Repository acts as a facade, making it easy to swap out the API or Database implementation later without breaking the logic.
- Singleton Database: We initialized the database only once. Opening a new connection for every query is expensive and risky.
- Conflict Algorithm: In
dbProvider.insertPosts, we usedConflictAlgorithm.replace. This ensures that if a post updates on the server, our local cache updates too, rather than crashing on a duplicate ID.
Conclusion
You now have a fully functional, offline-capable Flutter application structure. This architectural pattern is scalable; you can add Authentication, Comments, or Profiles following the exact same Provider -> Repository -> Bloc flow.
Next Steps: Try adding a "Pull to Refresh" feature in the PostListScreen that dispatches a new FetchPosts event to force an API update, ensuring your offline data stays fresh.
Top comments (0)