Building robust mobile apps that work seamlessly regardless of connectivity
Picture this: You're on a flight, trying to update your task list or jot down important notes, but there's no WiFi. Or maybe you're in a subway tunnel where the signal keeps dropping. Frustrating, right? As developers, we've all been there, and more importantly, so have our users. In today's mobile-first world, a reliable offline experience isn't a luxury — it's a must.
Welcome to our two-part series on implementing offline-first architecture in Flutter! In this comprehensive guide, we'll build a robust note-taking app that works flawlessly whether you're connected to the internet or completely offline. By the end of this series, you'll have a solid understanding of how to create apps that provide a seamless user experience regardless of network conditions.
What You'll Learn in This Series:
- Part 1 (this post): Setting up local storage with SQLite, implementing conflict resolution strategies, and building a solid data layer
- Part 2 (coming next): Building synchronization mechanisms, handling connectivity changes, and implementing background sync
The App We're Building:
We'll create a note-taking app called "OfflineNotes" that allows users to create, edit, and delete notes. The app will store data locally using SQLite, handle conflicts intelligently, and sync seamlessly when connectivity is restored. Think of it as a simplified version of apps like Notion or Obsidian, but with a focus on offline-first principles.
Let's dive in and transform the way you think about mobile app architecture!
Understanding Offline-First Concepts
Before we start coding, let's establish a solid foundation by understanding what offline-first actually means and why it matters for your Flutter applications.
Offline-First vs Online-First: The Fundamental Difference
Online-First Approach:
Most traditional apps follow an online-first pattern. They assume a reliable internet connection and treat offline scenarios as edge cases. When the network is unavailable, these apps either crash, show error messages, or simply become unusable. User actions are lost, and frustration ensues.
Offline-First Approach:
Offline-first support means implementing one feature in an application that works primarily with local data and treats network connectivity as an enhancement, not a requirement. The app stores all critical data locally and synchronizes with remote servers when possible. Users can continue working seamlessly, and their data is never lost.
Why Offline-First Matters
1. Enhanced User Experience:
Users don't think about network connectivity—they just want your app to work. An offline-first approach ensures your app remains functional regardless of network conditions, leading to higher user satisfaction and retention.
2. Reduced Server Load:
By processing data locally first, you reduce the number of API calls and server requests. This not only improves performance but also reduces infrastructure costs.
3. Improved Reliability:
Network connections are inherently unreliable. Mobile users frequently experience poor connectivity, switching between WiFi and cellular data, or encountering dead zones. Offline-first apps remain robust in all these scenarios.
4. Better Performance:
Local operations are significantly faster than network requests. Users get immediate feedback for their actions, making the app feel more responsive and snappy.
Common Challenges in Offline-First Development
Data Synchronization:
How do you merge local changes with server data when connectivity is restored? What happens when multiple devices modify the same data?
Conflict Resolution:
When the same piece of data is modified on multiple devices while offline, how do you decide which version to keep?
Storage Limitations:
Mobile devices have limited storage. How do you manage data efficiently without consuming excessive device storage?
Complexity:
Offline-first architecture introduces additional complexity. You need to handle multiple data states, implement sync logic, and manage potential conflicts.
Introducing Local-First Development
Local-first is a philosophy that goes beyond offline-first. It means that the canonical version of your data lives on the user's device, not on a server. The server becomes a collaboration and backup mechanism rather than the source of truth. This approach provides several benefits:
- Instant responsiveness: All operations happen locally first
- Privacy: Users control their data
- Ownership: Data remains accessible even if the service shuts down
- Collaboration: Multiple users can work together through synchronization
Now that we understand the concepts, let's explore the tools and technologies we'll use to implement this architecture.
Choosing the Right Local Storage Solution
Flutter provides several excellent options for local data persistence. Let's examine the most popular choices and understand when to use each one.
Comparing Storage Solutions
1. SharedPreferences:
- Best for: Simple key-value storage, user preferences, settings
- Pros: Extremely lightweight, easy to use
- Cons: Limited to primitive data types, not suitable for complex data structures
- Use case: Storing user settings, theme preferences, or simple flags
2. Hive:
- Best for: Fast key-value storage, simple data models
- Pros: Lightweight and fast key-value storage, no native dependencies, excellent performance
- Cons: Limited querying capabilities, not ideal for relational data
- Use case: Caching, storing user profiles, or simple object persistence
3. Isar:
- Best for: Modern NoSQL-style database needs
- Pros: High performance, powerful queries, built-in encryption
- Cons: Relatively new, smaller community, larger app size
- Use case: Complex data models that don't require strict relational structure
4. SQLite (sqflite):
- Best for: Structured relational data (like user profiles, tasks, or orders)
- Pros: Mature technology, excellent querying capabilities, ACID compliance, wide community support
- Cons: Requires SQL knowledge, more setup complexity
- Use case: Applications with complex relationships, reporting needs, or structured data
Why We're Choosing SQLite for This Tutorial
For our offline-first note-taking app, we're using SQLite with the sqflite package for several reasons:
- Mature and Stable: SQLite has been battle-tested for decades and is used by countless applications
- Rich Querying: We can perform complex queries, joins, and aggregations
- ACID Properties: Ensures data consistency and reliability
- Conflict Resolution: Built-in support for handling data conflicts
- Learning Value: Understanding SQLite principles applies to many database systems
Setting Up Dependencies and Project Structure
Let's start by creating our Flutter project and setting up the necessary dependencies.
# pubspec.yaml
name: offline_notes
description: A note-taking app with offline-first architecture
dependencies:
flutter:
sdk: flutter
# Local storage
sqflite: ^2.3.0
path: ^1.8.3
# Utilities
uuid: ^4.1.0
intl: ^0.18.1
json_annotation: ^4.9.0
dev_dependencies:
flutter_test:
sdk: flutter
mockito: ^5.4.2
build_runner: ^2.4.8
json_serializable: ^6.7.1
Now, let's set up our project structure:
lib/
├── main.dart
├── models/
│ ├── note.dart
│ └── conflict_resolution.dart
├── repositories/
│ ├── database_helper.dart
│ └── note_repository.dart
├── services/
│ └── sync_service.dart
└── screens/
├── note_list_screen.dart
└── note_detail_screen.dart
This structure follows clean architecture principles, separating concerns and making our code maintainable and testable.
Building the Data Layer
Now let's create the foundation of our offline-first architecture: a robust data layer that handles local storage and conflict resolution.
Creating Entity Models
First, let's define our core data models. We'll start with a Note entity that includes timestamps for conflict resolution:
// lib/models/note.dart
import 'package:json_annotation/json_annotation.dart';
part 'note.g.dart';
@JsonSerializable(fieldRename: FieldRename.snake)
class Note {
final String id;
final String title;
final String content;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? lastSyncedAt;
@JsonKey(fromJson: _boolFromInt, toJson: _boolToInt)
final bool isDeleted;
final int version;
const Note({
required this.id,
required this.title,
required this.content,
required this.createdAt,
required this.updatedAt,
this.lastSyncedAt,
this.isDeleted = false,
this.version = 1,
});
factory Note.create({
required String title,
required String content,
String? id,
}) {
final now = DateTime.now();
return Note(
id: id ?? DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
content: content,
createdAt: now,
updatedAt: now,
);
}
Note copyWith({
String? title,
String? content,
DateTime? updatedAt,
DateTime? lastSyncedAt,
bool? isDeleted,
int? version,
}) {
return Note(
id: id,
title: title ?? this.title,
content: content ?? this.content,
createdAt: createdAt,
updatedAt: updatedAt ?? DateTime.now(),
lastSyncedAt: lastSyncedAt ?? this.lastSyncedAt,
isDeleted: isDeleted ?? this.isDeleted,
version: version ?? (this.version + 1),
);
}
// JSON serialization
factory Note.fromJson(Map<String, dynamic> json) => _$NoteFromJson(json);
Map<String, dynamic> toJson() => _$NoteToJson(this);
// SQLite mapping
Map<String, dynamic> toMap() => toJson();
factory Note.fromMap(Map<String, dynamic> map) => _$NoteFromJson(map);
static bool _boolFromInt(int value) => value == 1;
static int _boolToInt(bool value) => value ? 1 : 0;
}
Setting Up Database Schema with Version Management
Next, let's create our database helper that manages the SQLite database:
// repositories/database_helper.dart
import 'dart:async';
import 'dart:io';
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';
import 'package:path_provider/path_provider.dart';
import '../models/note.dart';
class DatabaseHelper {
static final DatabaseHelper _instance = DatabaseHelper._internal();
factory DatabaseHelper() => _instance;
DatabaseHelper._internal();
static Database? _database;
static const String _databaseName = 'offline_notes.db';
static const int _databaseVersion = 1;
Future<Database> get database async {
_database ??= await _initDatabase();
return _database!;
}
Future<Database> _initDatabase() async {
try {
final documentsDirectory = await getApplicationDocumentsDirectory();
final path = join(documentsDirectory.path, _databaseName);
return await openDatabase(
path,
version: _databaseVersion,
onCreate: _onCreate,
onUpgrade: _onUpgrade,
onConfigure: _onConfigure,
);
} catch (e) {
throw DatabaseException('Failed to initialize database: $e');
}
}
// Enable foreign keys and other constraints
Future<void> _onConfigure(Database db) async {
await db.execute('PRAGMA foreign_keys = ON');
}
Future<void> _onCreate(Database db, int version) async {
await db.execute('''
CREATE TABLE notes (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
created_at TEXT NOT NULL,
updated_at TEXT NOT NULL,
last_synced_at TEXT,
is_deleted INTEGER NOT NULL DEFAULT 0,
version INTEGER NOT NULL DEFAULT 1
)
''');
// Create index for better query performance
await db.execute('''
CREATE INDEX idx_notes_updated_at ON notes(updated_at)
''');
await db.execute('''
CREATE INDEX idx_notes_is_deleted ON notes(is_deleted)
''');
}
Future<void> _onUpgrade(Database db, int oldVersion, int newVersion) async {
// Handle database migrations here
// For now, we only have version 1
if (oldVersion < newVersion) {
// Add migration logic for future versions
print('Upgrading database from version $oldVersion to $newVersion');
}
}
// Close database connection
Future<void> close() async {
final db = await database;
await db.close();
_database = null;
}
// Reset database (useful for testing)
Future<void> deleteDatabase() async {
final documentsDirectory = await getApplicationDocumentsDirectory();
final path = join(documentsDirectory.path, _databaseName);
await databaseFactory.deleteDatabase(path);
_database = null;
}
}
Implementing Repository Pattern with CRUD Operations
Now let's implement the repository pattern to abstract our data operations:
// repositories/note_repository.dart
import 'package:sqflite/sqflite.dart';
import '../models/note.dart';
import 'database_helper.dart';
class NoteRepository {
final DatabaseHelper _databaseHelper = DatabaseHelper();
static const String _tableName = 'notes';
// Create a new note
Future<Note> createNote(Note note) async {
try {
final db = await _databaseHelper.database;
await db.insert(
_tableName,
note.toMap(),
conflictAlgorithm: ConflictAlgorithm.abort,
);
return note;
} catch (e) {
throw RepositoryException('Failed to create note: $e');
}
}
// Get all notes (excluding deleted ones by default)
Future<List<Note>> getAllNotes({bool includeDeleted = false}) async {
try {
final db = await _databaseHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
_tableName,
where: includeDeleted ? null : 'is_deleted = ?',
whereArgs: includeDeleted ? null : [0],
orderBy: 'updated_at DESC',
);
return maps.map((map) => Note.fromMap(map)).toList();
} catch (e) {
throw RepositoryException('Failed to get notes: $e');
}
}
// Get a specific note by ID
Future<Note?> getNoteById(String id) async {
try {
final db = await _databaseHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
_tableName,
where: 'id = ? AND is_deleted = ?',
whereArgs: [id, 0],
limit: 1,
);
if (maps.isEmpty) return null;
return Note.fromMap(maps.first);
} catch (e) {
throw RepositoryException('Failed to get note: $e');
}
}
// Update an existing note
Future<Note> updateNote(Note note) async {
try {
final db = await _databaseHelper.database;
final updatedNote = note.copyWith(updatedAt: DateTime.now());
final rowsAffected = await db.update(
_tableName,
updatedNote.toMap(),
where: 'id = ?',
whereArgs: [note.id],
conflictAlgorithm: ConflictAlgorithm.abort,
);
if (rowsAffected == 0) {
throw RepositoryException('Note not found for update');
}
return updatedNote;
} catch (e) {
throw RepositoryException('Failed to update note: $e');
}
}
// Soft delete a note (mark as deleted instead of removing)
Future<void> deleteNote(String id) async {
try {
final db = await _databaseHelper.database;
final rowsAffected = await db.update(
_tableName,
{
'is_deleted': 1,
'updated_at': DateTime.now().toIso8601String(),
},
where: 'id = ?',
whereArgs: [id],
);
if (rowsAffected == 0) {
throw RepositoryException('Note not found for deletion');
}
} catch (e) {
throw RepositoryException('Failed to delete note: $e');
}
}
// Get notes that need to be synced (modified after last sync)
Future<List<Note>> getUnsyncedNotes() async {
try {
final db = await _databaseHelper.database;
final List<Map<String, dynamic>> maps = await db.query(
_tableName,
where: 'last_synced_at IS NULL OR updated_at > last_synced_at',
);
return maps.map((map) => Note.fromMap(map)).toList();
} catch (e) {
throw RepositoryException('Failed to get unsynced notes: $e');
}
}
// Update sync status for a note
Future<void> markAsSynced(String id) async {
try {
final db = await _databaseHelper.database;
await db.update(
_tableName,
{'last_synced_at': DateTime.now().toIso8601String()},
where: 'id = ?',
whereArgs: [id],
);
} catch (e) {
throw RepositoryException('Failed to mark note as synced: $e');
}
}
}
// Custom exception for repository operations
class RepositoryException implements Exception {
final String message;
RepositoryException(this.message);
@override
String toString() => 'RepositoryException: $message';
}
Implementing Conflict Resolution Strategies
Conflict resolution is crucial in offline-first applications. When the same data is modified on multiple devices while offline, we need strategies to handle these conflicts intelligently.
Understanding Conflict Resolution Approaches
1. Last-Write-Wins (LWW):
The simplest approach where the most recently modified version overwrites older versions. While easy to implement, it can result in data loss.
2. Operational Transformation (OT):
Used by collaborative editors like Google Docs. It transforms operations to maintain consistency across different document states.
3. Conflict-Free Replicated Data Types (CRDTs):
Data structures designed to automatically resolve conflicts without requiring coordination between replicas.
4. Custom Merge Strategies:
Application-specific logic that combines conflicting versions based on business rules.
For our note-taking app, we'll implement a hybrid approach combining LWW with user intervention for important conflicts:
// models/conflict_resolution.dart
enum ConflictResolutionStrategy {
lastWriteWins,
keepBoth,
userChoice,
merge,
}
class ConflictResolution {
final Note localVersion;
final Note remoteVersion;
final ConflictResolutionStrategy strategy;
const ConflictResolution({
required this.localVersion,
required this.remoteVersion,
required this.strategy,
});
// Detect if two notes are in conflict
static bool hasConflict(Note local, Note remote) {
// No conflict if they're the same version
if (local.version == remote.version &&
local.updatedAt == remote.updatedAt) {
return false;
}
// Conflict exists if both were modified after last sync
final localSyncTime = local.lastSyncedAt;
final remoteSyncTime = remote.lastSyncedAt;
if (localSyncTime == null || remoteSyncTime == null) {
return true; // Assume conflict if sync time is unknown
}
return local.updatedAt.isAfter(localSyncTime) &&
remote.updatedAt.isAfter(remoteSyncTime);
}
// Resolve conflict based on strategy
Note resolve() {
switch (strategy) {
case ConflictResolutionStrategy.lastWriteWins:
return _resolveLastWriteWins();
case ConflictResolutionStrategy.keepBoth:
return _resolveKeepBoth();
case ConflictResolutionStrategy.merge:
return _resolveMerge();
case ConflictResolutionStrategy.userChoice:
// This would typically show a UI dialog
// For now, fall back to LWW
return _resolveLastWriteWins();
}
}
Note _resolveLastWriteWins() {
if (localVersion.updatedAt.isAfter(remoteVersion.updatedAt)) {
return localVersion.copyWith(
version: remoteVersion.version + 1,
lastSyncedAt: DateTime.now(),
);
} else {
return remoteVersion.copyWith(
version: localVersion.version + 1,
lastSyncedAt: DateTime.now(),
);
}
}
Note _resolveKeepBoth() {
// Create a new note with combined content
return localVersion.copyWith(
title: '${localVersion.title} (Conflicted)',
content: '''
Local Version:
${localVersion.content}
---
Remote Version:
${remoteVersion.content}
''',
version: math.max(localVersion.version, remoteVersion.version) + 1,
lastSyncedAt: DateTime.now(),
);
}
Note _resolveMerge() {
// Simple merge strategy: combine titles and content
final mergedTitle = _mergeText(localVersion.title, remoteVersion.title);
final mergedContent = _mergeText(localVersion.content, remoteVersion.content);
return Note(
id: localVersion.id,
title: "mergedTitle,"
content: mergedContent,
createdAt: localVersion.createdAt,
updatedAt: DateTime.now(),
lastSyncedAt: DateTime.now(),
version: math.max(localVersion.version, remoteVersion.version) + 1,
);
}
String _mergeText(String local, String remote) {
if (local == remote) return local;
// Simple merge: if one is a subset of the other, use the longer one
if (local.contains(remote)) return local;
if (remote.contains(local)) return remote;
// Otherwise, combine both with a separator
return '$local\n\n---\n\n$remote';
}
}
Enhanced Repository with Conflict Resolution
Let's extend our repository to handle conflicts:
// Add this method to NoteRepository class
Future<Note> upsertNote(Note note, {ConflictResolutionStrategy? strategy}) async {
try {
final existingNote = await getNoteById(note.id);
if (existingNote == null) {
// Note doesn't exist locally, create it
return await createNote(note);
}
// Check for conflicts
if (ConflictResolution.hasConflict(existingNote, note)) {
final resolution = ConflictResolution(
localVersion: existingNote,
remoteVersion: note,
strategy: strategy ?? ConflictResolutionStrategy.lastWriteWins,
);
final resolvedNote = resolution.resolve();
return await updateNote(resolvedNote);
} else {
// No conflict, update normally
return await updateNote(note);
}
} catch (e) {
throw RepositoryException('Failed to upsert note: $e');
}
}
Practical Implementation: Complete Note-Taking App
Now let's put everything together in a working application. Here's a simple UI that demonstrates our offline-first architecture:
// lib/main.dart
import 'package:flutter/material.dart';
import 'screens/note_list_screen.dart';
void main() {
runApp(const OfflineNotesApp());
}
class OfflineNotesApp extends StatelessWidget {
const OfflineNotesApp({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
title: 'Offline Notes',
theme: ThemeData(
brightness: Brightness.light,
scaffoldBackgroundColor: Colors.white,
appBarTheme: const AppBarTheme(
backgroundColor: Colors.white,
foregroundColor: Colors.black,
elevation: 0,
),
floatingActionButtonTheme: const FloatingActionButtonThemeData(
backgroundColor: Colors.black,
foregroundColor: Colors.white,
),
),
home: const NoteListScreen(),
);
}
}
// lib/screens/note_list_screen.dart
import 'package:flutter/material.dart';
import '../models/note.dart';
import '../repositories/note_repository.dart';
class NoteListScreen extends StatefulWidget {
const NoteListScreen({Key? key}) : super(key: key);
@override
State<NoteListScreen> createState() => _NoteListScreenState();
}
class _NoteListScreenState extends State<NoteListScreen> {
final NoteRepository _repository = NoteRepository();
List<Note> _notes = [];
bool _isLoading = false;
@override
void initState() {
super.initState();
_loadNotes();
}
Future<void> _loadNotes() async {
setState(() => _isLoading = true);
try {
final notes = await _repository.getAllNotes();
setState(() {
_notes = notes;
_isLoading = false;
});
} catch (e) {
setState(() => _isLoading = false);
_showError('Failed to load notes: $e');
}
}
Future<void> _addNote() async {
final note = Note.create(
title: 'New Note',
content: 'Start writing your thoughts...',
);
try {
await _repository.createNote(note);
_loadNotes();
} catch (e) {
_showError('Failed to create note: $e');
}
}
Future<void> _deleteNote(Note note) async {
try {
await _repository.deleteNote(note.id);
_loadNotes();
} catch (e) {
_showError('Failed to delete note: $e');
}
}
void _showError(String message) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(message), backgroundColor: Colors.black),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: Colors.white,
appBar: AppBar(
backgroundColor: Colors.white,
elevation: 0,
title: const Text(
'My Notes',
style: TextStyle(color: Colors.black, fontWeight: FontWeight.bold),
),
centerTitle: true,
actions: [
IconButton(
icon: const Icon(Icons.refresh, color: Colors.black),
onPressed: _loadNotes,
),
],
),
body: _isLoading
? const Center(child: CircularProgressIndicator(color: Colors.black))
: _notes.isEmpty
? const Center(
child: Text(
'No notes yet.\nTap + to create your first one!',
textAlign: TextAlign.center,
style: TextStyle(color: Colors.black54, fontSize: 16),
),
)
: ListView.separated(
padding: const EdgeInsets.all(16),
itemCount: _notes.length,
separatorBuilder: (_, __) =>
const Divider(color: Colors.black12),
itemBuilder: (context, index) {
final note = _notes[index];
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
note.title,
style: const TextStyle(
fontWeight: FontWeight.w600,
color: Colors.black,
),
),
subtitle: Text(
note.content,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(color: Colors.black54),
),
trailing: IconButton(
icon: const Icon(Icons.delete_outline,
color: Colors.black54),
onPressed: () => _deleteNote(note),
),
onTap: () {
// TODO: Navigate to detail screen
},
);
},
),
floatingActionButton: FloatingActionButton(
backgroundColor: Colors.black,
onPressed: _addNote,
child: const Icon(Icons.add, color: Colors.white),
),
);
}
}
Testing Your Offline Storage
Testing is crucial for ensuring your offline-first implementation works correctly. Here are some essential tests:
Unit Tests for Repository
// test/repositories/note_repository_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:mockito/mockito.dart';
import 'package:offline_notes/models/note.dart';
import 'package:offline_notes/repositories/note_repository.dart';
void main() {
group('NoteRepository', () {
late NoteRepository repository;
setUp(() {
repository = NoteRepository();
});
test('should create note successfully', () async {
// Arrange
final note = Note.create(
title: 'Test Note',
content: 'Test Content',
);
// Act
final result = await repository.createNote(note);
// Assert
expect(result.id, equals(note.id));
expect(result.title, equals('Test Note'));
expect(result.content, equals('Test Content'));
});
test('should get all notes excluding deleted ones', () async {
// Arrange
final note1 = Note.create(title: 'Note 1', content: 'Content 1');
final note2 = Note.create(title: 'Note 2', content: 'Content 2');
final deletedNote = Note.create(title: 'Deleted', content: 'Content')
.copyWith(isDeleted: true);
await repository.createNote(note1);
await repository.createNote(note2);
await repository.createNote(deletedNote);
// Act
final result = await repository.getAllNotes();
// Assert
expect(result.length, equals(2));
expect(result.any((note) => note.isDeleted), isFalse);
});
test('should handle conflict resolution correctly', () async {
// Arrange
final originalNote = Note.create(title: 'Original', content: 'Content');
await repository.createNote(originalNote);
final conflictingNote = originalNote.copyWith(
title: 'Modified Remotely',
updatedAt: DateTime.now().add(const Duration(minutes: 1)),
);
// Act
final result = await repository.upsertNote(conflictingNote);
// Assert
expect(result.title, equals('Modified Remotely'));
expect(result.version, greaterThan(originalNote.version));
});
});
}
Integration Tests
// test/integration/offline_storage_test.dart
import 'package:flutter_test/flutter_test.dart';
import 'package:offline_notes/models/note.dart';
import 'package:offline_notes/repositories/note_repository.dart';
import 'package:offline_notes/repositories/database_helper.dart';
void main() {
group('Offline Storage Integration Tests', () {
late NoteRepository repository;
setUp(() async {
// Reset database for each test
await DatabaseHelper().deleteDatabase();
repository = NoteRepository();
});
testWidgets('should persist data across app restarts', (tester) async {
// Create and save a note
final note = Note.create(title: 'Persistent Note', content: 'Content');
await repository.createNote(note);
// Simulate app restart by closing and reopening database
await DatabaseHelper().close();
// Verify note still exists
final retrievedNotes = await repository.getAllNotes();
expect(retrievedNotes.length, equals(1));
expect(retrievedNotes.first.title, equals('Persistent Note'));
});
testWidgets('should handle large datasets efficiently', (tester) async {
// Create 1000 notes
final stopwatch = Stopwatch()..start();
for (int i = 0; i < 1000; i++) {
final note = Note.create(
title: 'Note $i',
content: 'Content for note number $i',
);
await repository.createNote(note);
}
stopwatch.stop();
print('Created 1000 notes in ${stopwatch.elapsedMilliseconds}ms');
// Verify all notes were created
final notes = await repository.getAllNotes();
expect(notes.length, equals(1000));
// Test query performance
final queryStopwatch = Stopwatch()..start();
final queriedNotes = await repository.getAllNotes();
queryStopwatch.stop();
print('Queried 1000 notes in ${queryStopwatch.elapsedMilliseconds}ms');
expect(queriedNotes.length, equals(1000));
});
});
}
Manual Testing Scenarios
Scenario 1: Basic CRUD Operations
- Create a new note
- Edit the note content
- Delete the note
- Verify all operations work without network connectivity
Scenario 2: App Restart Persistence
- Create several notes
- Close the app completely
- Reopen the app
- Verify all notes are still present
Scenario 3: Conflict Resolution
- Create a note
- Simulate server version with different content
- Trigger sync process
- Verify conflict is resolved correctly
Scenario 4: Performance Testing
- Create 100+ notes
- Measure app startup time
- Test scrolling performance
- Verify memory usage remains reasonable
Performance Considerations and Best Practices
When implementing offline-first architecture, performance is crucial. Here are key considerations:
Database Optimization
1. Use Indexes Wisely:
// Add indexes for frequently queried columns
await db.execute('CREATE INDEX idx_notes_title ON notes(title)');
await db.execute('CREATE INDEX idx_notes_updated_at ON notes(updated_at)');
2. Batch Operations:
Future<void> createMultipleNotes(List<Note> notes) async {
final db = await _databaseHelper.database;
final batch = db.batch();
for (final note in notes) {
batch.insert(_tableName, note.toMap());
}
await batch.commit(noResult: true);
}
3. Pagination for Large Datasets:
Future<List<Note>> getNotesPaginated({
int page = 0,
int pageSize = 20,
}) async {
final db = await _databaseHelper.database;
final maps = await db.query(
_tableName,
where: 'is_deleted = ?',
whereArgs: [0],
orderBy: 'updated_at DESC',
limit: pageSize,
offset: page * pageSize,
);
return maps.map((map) => Note.fromMap(map)).toList();
}
Memory Management
1. Use Streams for Real-time Updates:
class NoteRepository {
final _notesController = StreamController<List<Note>>.broadcast();
Stream<List<Note>> get notesStream => _notesController.stream;
Future<void> _broadcastNotes() async {
final notes = await getAllNotes();
_notesController.add(notes);
}
void dispose() {
_notesController.close();
}
}
2. Implement Proper Cleanup:
class NotesBloc {
late final StreamSubscription _subscription;
void dispose() {
_subscription.cancel();
_repository.dispose();
}
}
Security Considerations
1. Data Encryption:
For sensitive data, consider encrypting the database:
// Using sqflite_sqlcipher for encryption
import 'package:sqflite_sqlcipher/sqflite.dart';
Future<Database> _initEncryptedDatabase() async {
final path = join(await getDatabasesPath(), 'encrypted_notes.db');
return await openDatabase(
path,
version: 1,
onCreate: _onCreate,
password: 'your_encryption_key', // Store securely!
);
}
2. Input Validation:
Note _validateNote(Note note) {
if (note.title.isEmpty) {
throw ValidationException('Title cannot be empty');
}
if (note.title.length > 200) {
throw ValidationException('Title too long');
}
return note;
}
Accessibility Considerations
Making your offline-first app accessible is essential:
// Add semantic labels for screen readers
Semantics(
label: 'Note: ${note.title}',
hint: 'Double tap to edit note',
child: ListTile(
title: Text(note.title),
subtitle: Text(note.content),
),
)
// Provide offline status feedback
class OfflineIndicator extends StatelessWidget {
const OfflineIndicator({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Semantics(
label: 'App is currently offline',
child: Container(
padding: const EdgeInsets.all(8),
color: Colors.orange,
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.cloud_off, color: Colors.white),
SizedBox(width: 8),
Text(
'Offline Mode',
style: TextStyle(color: Colors.white),
),
],
),
),
);
}
}
Conclusion and Next Steps
Congratulations! You've successfully implemented the foundation of an offline-first architecture in Flutter. Let's recap what we've accomplished:
Key Takeaways
• Robust Local Storage: We've set up SQLite with proper schema management, indexes, and version control
• Clean Architecture: Implemented the repository pattern for maintainable and testable code
• Conflict Resolution: Built intelligent strategies to handle data conflicts when they occur
• Performance Optimization: Added indexes, batch operations, and pagination for optimal performance
• Error Handling: Comprehensive error handling ensures your app gracefully handles edge cases
What's Coming in Part 2
In our next post, we'll extend this foundation with:
• Real-time Synchronization: Building a robust sync engine that works with REST APIs
• Connectivity Monitoring: Detecting network changes and trigging sync appropriately
• Background Sync: Using WorkManager to sync data even when the app isn't active
• Advanced Conflict Resolution: Implementing user-choice dialogs and merge strategies
• Sync Status UI: Providing clear feedback to users about sync progress and conflicts
Complete Code Repository
You can find the complete working code for this tutorial on GitHub: flutter-offline-first-part1
Ready to Level Up?
Try these challenges to deepen your understanding:
- Add Categories: Extend the Note model to include categories and implement filtering
- Rich Text Support: Add support for markdown or rich text formatting in notes
- Export/Import: Implement functionality to backup and restore notes
- Search Functionality: Add full-text search capabilities using SQLite FTS
- Attachments: Allow users to attach images or files to their notes
Have questions or want to share your implementation? Drop a comment below! I'd love to see what you build with these concepts.
In Part 2, we'll turn this solid foundation into a fully synchronized, production-ready offline-first application. Stay tuned!
Found this helpful? Give it a ❤️ and follow me for more Flutter content. Part 2 is coming soon!
Tags: #flutter #mobile #offline #architecture #sqlite #dart #mobiledev #appdevelopment