Flutter Tutorial: Build an Offline-First ListView with API Caching & Modern UI
Welcome to another Flutter tutorial! Today, we are going to build an offline-first Flutter application.
In real-world mobile development, users expect apps to work seamlessly even when their internet connection drops. We will build a feature that fetches data from a REST API, stores it in a local SQLite database, and displays it in a beautiful Material 3 ListView. When the app goes offline, it automatically loads the cached data from the local database.
🌟 What You Will Learn
- Offline-First Architecture: Strategy for syncing API data with local storage.
- REST API Integration: Fetching JSON data in Flutter using modern
async/await. - Local Database Caching: Using
sqflitefor fast, efficient local persistence. - Material 3 UI Design: Designing an attractive, modern list with dynamic cards and pull-to-refresh functionality.
🏗️ Architecture & Data Flow
Before writing code, let's understand how an offline-first app works:
[ UI Layer (ListView) ]
▲
│ Displays Data
▼
[ Repository / Logic ]
│
├───► Check Connection / Fetch from API (http) ──► [ External API ]
│ │
│ Save New Data
│ ▼
└───────────────────────────────────────────────► [ SQLite DB ]
The Caching Strategy:
- 1. App Opens: First, load and display cached data immediately from SQLite (lightning fast ⚡).
- 2. Background Sync: Fetch fresh data from the REST API in the background.
- 3. Save & Update: Store the newly fetched data into SQLite and update the UI seamlessly.
- 4. Offline Mode: If network fetch fails, the user still sees the locally stored database records!
📦 Step 1: Add Required Packages
Open your pubspec.yaml file and add the following dependencies:
dependencies: flutter: sdk: flutter # For API Requests http: ^1.2.0 # For Local Database Storage sqflite: ^2.3.2 path: ^1.9.0 # Modern Material Icons cupertino_icons: ^1.0.8
Run flutter pub get in your terminal to install them.
📝 Step 2: Create the Data Model
We will use a public API endpoint: https://jsonplaceholder.typicode.com/posts.
Create a file named lib/models/post_model.dart. This model converts JSON from the API into Dart objects AND handles SQLite Map conversion.
class PostModel { final int id; final String title; final String body; const PostModel({ required this.id, required this.title, required this.body, }); // Factory constructor to deserialize JSON from API factory PostModel.fromJson(Map<String, dynamic> json) { return PostModel( id: json['id'] as int, title: json['title'] as String? ?? '', body: json['body'] as String? ?? '', ); } // Convert model instance to Map for SQLite database insertion Map<String, dynamic> toMap() { return { 'id': id, 'title': title, 'body': body, }; } // Create model instance from SQLite Map row factory PostModel.fromMap(Map<String, dynamic> map) { return PostModel( id: map['id'] as int, title: map['title'] as String? ?? '', body: map['body'] as String? ?? '', ); } }
🔍 Code Explanation:
fromJson: Parses raw data retrieved from the web API.toMap: Prepares object properties to be saved inside SQLite columns.fromMap: Reconstructs object models from local database query results.
💾 Step 3: SQLite Database Helper
Create lib/services/db_service.dart. This Singleton class initializes our SQLite database and manages CRUD operations (Create, Read, Delete).
import 'package:path/path.dart'; import 'package:sqflite/sqflite.dart'; import '../models/post_model.dart'; class DatabaseService { static final DatabaseService instance = DatabaseService._init(); static Database? _database; DatabaseService._init(); Future<Database> get database async { if (_database != null) return _database!; _database = await _initDB('posts_cache.db'); return _database!; } Future<Database> _initDB(String filePath) async { final dbPath = await getDatabasesPath(); final path = join(dbPath, filePath); return await openDatabase( path, version: 1, onCreate: _createDB, ); } Future<void> _createDB(Database db, int version) async { await db.execute(''' CREATE TABLE posts ( id INTEGER PRIMARY KEY, title TEXT NOT NULL, body TEXT NOT NULL ) '''); } // Insert or Replace posts batch into local cache Future<void> cachePosts(List<PostModel> posts) async { final db = await instance.database; final batch = db.batch(); for (var post in posts) { batch.insert( 'posts', post.toMap(), conflictAlgorithm: ConflictAlgorithm.replace, ); } await batch.commit(noResult: true); } // Fetch all posts from SQLite database Future<List<PostModel>> getCachedPosts() async { final db = await instance.database; final maps = await db.query('posts', orderBy: 'id ASC'); if (maps.isNotEmpty) { return maps.map((map) => PostModel.fromMap(map)).toList(); } else { return []; } } // Clear cache if needed Future<void> clearCache() async { final db = await instance.database; await db.delete('posts'); } }
🔍 Code Explanation:
- Singleton Design (
DatabaseService.instance): Ensures only one database connection remains active. ConflictAlgorithm.replace: Overwrites existing records matching the same ID, keeping cache up-to-date.- Batch Execution: Saves multiple records in a single database transaction, boosting performance.
🌐 Step 4: API Service & Sync Logic
Create lib/services/api_service.dart to manage API requests and coordinate local DB sync.
import 'dart:convert'; import 'dart:io'; import 'package:http/http.dart' as http; import '../models/post_model.dart'; import 'db_service.dart'; class PostRepository { static const String _baseUrl = 'https://jsonplaceholder.typicode.com/posts'; final DatabaseService _dbService = DatabaseService.instance; /// Fetches posts. Tries API first; falls back gracefully to SQLite cache on failure. Future<({List<PostModel> posts, bool isOffline})> fetchPosts() async { try { final response = await http .get(Uri.parse(_baseUrl)) .timeout(const Duration(seconds: 8)); if (response.statusCode == 200) { final List<dynamic> jsonList = jsonDecode(response.body); final posts = jsonList.map((e) => PostModel.fromJson(e)).toList(); // Save fresh data into SQLite await _dbService.cachePosts(posts); return (posts: posts, isOffline: false); } else { throw HttpException('Server Error: ${response.statusCode}'); } } catch (e) { // Network failed or timed out: Load fallback from local SQLite DB final localPosts = await _dbService.getCachedPosts(); return (posts: localPosts, isOffline: true); } } }
🔍 Code Explanation:
- Dart 3 Records Syntax
({List<PostModel> posts, bool isOffline}): Returns multiple values cleanly without custom wrapper classes. - Timeout Handling:
.timeout(...)prevents endless loading spinners if internet connection hangs.
🎨 Step 5: Beautiful Material 3 UI
Create lib/screens/post_list_screen.dart. This screen handles offline state banners, modern card list views, and pull-to-refresh behavior.
import 'package:flutter/material.dart'; import '../models/post_model.dart'; import '../services/api_service.dart'; class PostListScreen extends StatefulWidget { const PostListScreen({super.key}); @override State<PostListScreen> createState() => _PostListScreenState(); } class _PostListScreenState extends State<PostListScreen> { final PostRepository _repository = PostRepository(); List<PostModel> _posts = []; bool _isLoading = true; bool _isOffline = false; @override void initState() { super.initState(); _loadPosts(); } Future<void> _loadPosts() async { setState(() => _isLoading = true); final result = await _repository.fetchPosts(); if (mounted) { setState(() { _posts = result.posts; _isOffline = result.isOffline; _isLoading = false; }); } } @override Widget build(BuildContext context) { return Scaffold( appBar: AppBar( title: const Text('Offline API Cache', style: TextStyle(fontWeight: FontWeight.bold)), centerTitle: true, actions: [ IconButton( icon: const Icon(Icons.refresh), onPressed: _loadPosts, ), ], ), body: _isLoading ? const Center(child: CircularProgressIndicator()) : Column( children: [ // Offline Alert Banner if (_isOffline) Container( width: double.infinity, color: Colors.amber.shade800, padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16), child: const Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.wifi_off, color: Colors.white, size: 18), SizedBox(width: 8), Text( 'You are offline. Displaying cached data.', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ), ], ), ), // Main ListView Expanded( child: _posts.isEmpty ? const Center(child: Text('No posts found.')) : RefreshIndicator( onRefresh: _loadPosts, child: ListView.builder( padding: const EdgeInsets.all(12), itemCount: _posts.length, itemBuilder: (context, index) { final post = _posts[index]; return _buildPostCard(context, post); }, ), ), ), ], ), ); } Widget _buildPostCard(BuildContext context, PostModel post) { final theme = Theme.of(context); return Card( elevation: 2, margin: const EdgeInsets.only(bottom: 12), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)), child: Padding( padding: const EdgeInsets.all(16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( width: 44, height: 44, decoration: BoxDecoration( gradient: LinearGradient( colors: [theme.colorScheme.primary, theme.colorScheme.tertiary], ), shape: BoxShape.circle, ), child: Center( child: Text( '${post.id}', style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold), ), ), ), const SizedBox(width: 16), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( post.title, maxLines: 1, overflow: TextOverflow.ellipsis, style: theme.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.bold), ), const SizedBox(height: 6), Text( post.body, maxLines: 2, overflow: TextOverflow.ellipsis, style: theme.textTheme.bodyMedium?.copyWith(color: Colors.grey.shade700), ), ], ), ), ], ), ), ); } }
🚀 Step 6: App Entry Point (main.dart)
Set up your lib/main.dart file enabling Material 3:
import 'package:flutter/material.dart'; import '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) { return MaterialApp( title: 'Flutter Caching ListView', debugShowCheckedModeBanner: false, theme: ThemeData( useMaterial3: true, colorScheme: ColorScheme.fromSeed( seedColor: Colors.deepPurple, ), ), home: const PostListScreen(), ); } }
🖼️ Recommended Screenshots for Your Blog
| State | Image Description | Caption |
|---|---|---|
| Online Mode | Screen displaying loaded posts with gradient ID badges. | "Online State: Fresh data loaded from API" |
| Offline Banner | Device in Airplane Mode displaying the orange status banner. | "Offline State: Local SQLite fallback active" |
| Pull-to-Refresh | Swipe down animation reloading data. | "Swipe down to refresh network sync" |
🏁 Conclusion
Congratulations! You have built a robust, offline-first Flutter application that fetches data from an API, caches it in an SQLite database, and displays it in an attractive Material 3 list!
If you enjoyed this tutorial, make sure to subscribe to our blog for more modern Flutter development tips! Happy Coding! 💙⚡
Comments
Post a Comment