Build an Offline-First ListView with API Caching & Modern UI

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

  1. Offline-First Architecture: Strategy for syncing API data with local storage.
  2. REST API Integration: Fetching JSON data in Flutter using modern async/await.
  3. Local Database Caching: Using sqflite for fast, efficient local persistence.
  4. 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