Back to Posts

Building Offline-First Flutter Apps

8 min read

Offline-first applications provide a seamless user experience by ensuring functionality even without an internet connection. This comprehensive guide will walk you through building robust offline-first Flutter applications, covering everything from local storage to synchronization strategies.

Why Offline-First Matters

Offline-first apps offer several key advantages:

  1. Enhanced User Experience: Users can access content anytime, anywhere
  2. Improved Performance: Faster data access through local storage
  3. Reduced Data Usage: Minimized network requests
  4. Better Reliability: Continued functionality during network issues
  5. Cost Efficiency: Reduced server load and bandwidth usage

Local Storage Solutions

1. SQLite with sqflite

SQLite is ideal for complex data structures and relationships:

import 'package:sqflite/sqflite.dart';
import 'package:path/path.dart';

class DatabaseHelper {
  static final DatabaseHelper instance = DatabaseHelper._init();
  static Database? _database;

  DatabaseHelper._init();

  Future<Database> get database async {
    if (_database != null) return _database!;
    _database = await _initDB('app.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 items(
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        description TEXT,
        is_synced INTEGER DEFAULT 0
      )
    ''');
  }

  Future<int> insertItem(Map<String, dynamic> item) async {
    final db = await instance.database;
    return await db.insert('items', item);
  }

  Future<List<Map<String, dynamic>>> getItems() async {
    final db = await instance.database;
    return await db.query('items');
  }
}

2. Hive for NoSQL Storage

Hive is perfect for simple key-value storage with excellent performance:

import 'package:hive/hive.dart';
import 'package:hive_flutter/hive_flutter.dart';

class HiveService {
  static Future<void> init() async {
    await Hive.initFlutter();
    await Hive.openBox('settings');
    await Hive.openBox('userData');
  }

  static Box getSettings() {
    return Hive.box('settings');
  }

  static Box getUserData() {
    return Hive.box('userData');
  }
}

// Usage
class SettingsRepository {
  final Box _settings = HiveService.getSettings();

  Future<void> saveThemeMode(bool isDark) async {
    await _settings.put('isDarkMode', isDark);
  }

  bool getThemeMode() {
    return _settings.get('isDarkMode', defaultValue: false);
  }
}

3. SharedPreferences for Simple Data

For small amounts of data:

import 'package:shared_preferences/shared_preferences.dart';

class PreferencesService {
  static Future<void> saveUserToken(String token) async {
    final prefs = await SharedPreferences.getInstance();
    await prefs.setString('user_token', token);
  }

  static Future<String?> getUserToken() async {
    final prefs = await SharedPreferences.getInstance();
    return prefs.getString('user_token');
  }
}

Synchronization Strategies

1. Queue-Based Synchronization

class SyncQueue {
  final DatabaseHelper _db = DatabaseHelper.instance;
  final ApiService _api = ApiService();

  Future<void> syncPendingItems() async {
    final items = await _db.getPendingItems();
    
    for (final item in items) {
      try {
        await _api.syncItem(item);
        await _db.markItemAsSynced(item['id']);
      } catch (e) {
        // Handle sync error
        await _db.incrementSyncAttempts(item['id']);
      }
    }
  }
}

class ApiService {
  Future<void> syncItem(Map<String, dynamic> item) async {
    // Implement API call
    await Future.delayed(Duration(seconds: 1));
  }
}

2. Conflict Resolution

class ConflictResolver {
  Future<Map<String, dynamic>> resolveConflict(
    Map<String, dynamic> local,
    Map<String, dynamic> remote,
  ) async {
    // Implement conflict resolution strategy
    if (remote['updated_at'] > local['updated_at']) {
      return remote;
    }
    return local;
  }
}

State Management for Offline Apps

Using Riverpod for State Management

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:connectivity_plus/connectivity_plus.dart';

final connectivityProvider = StreamProvider<ConnectivityResult>((ref) {
  return Connectivity().onConnectivityChanged;
});

final itemsProvider = StateNotifierProvider<ItemsNotifier, AsyncValue<List<Item>>>((ref) {
  return ItemsNotifier(ref.watch(databaseProvider));
});

class ItemsNotifier extends StateNotifier<AsyncValue<List<Item>>> {
  final DatabaseHelper _db;

  ItemsNotifier(this._db) : super(const AsyncValue.loading()) {
    loadItems();
  }

  Future<void> loadItems() async {
    try {
      final items = await _db.getItems();
      state = AsyncValue.data(items.map((e) => Item.fromJson(e)).toList());
    } catch (e, stack) {
      state = AsyncValue.error(e, stack);
    }
  }
}

Network Status Handling

import 'package:connectivity_plus/connectivity_plus.dart';

class NetworkStatus {
  static Future<bool> isOnline() async {
    final connectivityResult = await Connectivity().checkConnectivity();
    return connectivityResult != ConnectivityResult.none;
  }

  static Stream<ConnectivityResult> get onConnectivityChanged {
    return Connectivity().onConnectivityChanged;
  }
}

// Usage in widget
class OfflineIndicator extends ConsumerWidget {
  @override
  Widget build(BuildContext context, WidgetRef ref) {
    final connectivity = ref.watch(connectivityProvider);

    return connectivity.when(
      data: (status) => status == ConnectivityResult.none
          ? const OfflineBanner()
          : const SizedBox.shrink(),
      loading: () => const SizedBox.shrink(),
      error: (_, __) => const SizedBox.shrink(),
    );
  }
}

Best Practices for Offline-First Apps

  1. Data Prioritization

    • Identify critical data that must be available offline
    • Implement progressive loading for non-critical data
    • Use efficient compression for stored data
  2. Storage Optimization

    • Implement data expiration policies
    • Use efficient serialization formats
    • Regularly clean up unused data
  3. Synchronization Strategy

    • Implement background sync
    • Use exponential backoff for retries
    • Handle conflicts gracefully
  4. User Experience

    • Provide clear offline indicators
    • Show sync progress and status
    • Implement optimistic updates
  5. Error Handling

    • Graceful degradation of features
    • Clear error messages
    • Automatic retry mechanisms

Testing Offline Functionality

void main() {
  group('Offline Functionality Tests', () {
    late MockDatabaseHelper mockDb;
    late MockApiService mockApi;

    setUp(() {
      mockDb = MockDatabaseHelper();
      mockApi = MockApiService();
    });

    test('saves data locally when offline', () async {
      // Arrange
      when(mockApi.isOnline()).thenAnswer((_) async => false);
      
      // Act
      await saveDataLocally();
      
      // Assert
      verify(mockDb.saveItem(any)).called(1);
    });

    test('syncs data when back online', () async {
      // Arrange
      when(mockApi.isOnline()).thenAnswer((_) async => true);
      
      // Act
      await syncPendingData();
      
      // Assert
      verify(mockApi.syncItems(any)).called(1);
    });
  });
}

Performance Optimization

  1. Batch Operations

    Future<void> batchInsertItems(List<Map<String, dynamic>> items) async {
      final db = await database;
      final batch = db.batch();
      
      for (final item in items) {
        batch.insert('items', item);
      }
      
      await batch.commit();
    }
  2. Indexing

    Future<void> createIndexes() async {
      final db = await database;
      await db.execute('''
        CREATE INDEX idx_items_sync_status 
        ON items(is_synced)
      ''');
    }
  3. Caching

    class CacheManager {
      static final Map<String, dynamic> _cache = {};
      
      static T? get<T>(String key) {
        return _cache[key] as T?;
      }
      
      static void set(String key, dynamic value) {
        _cache[key] = value;
      }
    }

Conclusion

Building offline-first Flutter applications requires careful consideration of:

  1. Storage Strategy: Choose the right storage solution for your needs
  2. Synchronization: Implement robust sync mechanisms
  3. State Management: Handle offline and online states effectively
  4. User Experience: Provide clear feedback about app status
  5. Error Handling: Gracefully handle edge cases

Remember to:

  • Test thoroughly in offline scenarios
  • Monitor storage usage
  • Implement proper error handling
  • Provide clear user feedback
  • Optimize for performance

By following these guidelines, you can create robust offline-first Flutter applications that provide a seamless user experience regardless of network conditions.