Welcome back to our Flutter offline-first architecture series! In Part 1, we laid the foundation for offline data storage using SQLite and SharedPreferences. Now, we're diving deep into the complex world of data synchronization and connectivity management.
What We'll Build
In this tutorial, we'll enhance our note-taking app with:
- Dual Storage System: Separate offline and online note storage
- Intelligent Sync Engine: Automatic synchronization when connectivity is restored
- Tab-Based Interface: Clear separation between offline and online notes
- Real-time Connectivity Monitoring: Visual indicators for connection status
- Conflict Resolution: Handling data conflicts during synchronization
Prerequisites
Before we begin, make sure you have:
- Flutter SDK installed (3.0 or higher)
- Basic understanding of Flutter widgets and state management
- Familiarity with SharedPreferences and basic JSON handling
- Completed Part 1 of this series (or similar offline storage knowledge)
Understanding Data Synchronization Complexities
The Challenge
Building offline-first applications isn't just about storing data locally. The real complexity lies in:
- Data Consistency: Ensuring offline and online data remain synchronized
- Conflict Resolution: Handling cases where the same data is modified both offline and online
- Network Reliability: Managing intermittent connectivity and failed sync attempts
- User Experience: Providing clear feedback about data state and sync status
Our Approach
We'll implement a dual-preference system:
-
offline_notes
: Stores notes created while offline -
online_notes
: Stores synchronized notes from online state
When online, both storages are visible, but offline notes are automatically migrated to online storage during sync.
Setting Up GetX for State Management
First, let's add the necessary dependencies to your pubspec.yaml
:
dependencies:
flutter:
sdk: flutter
get: ^4.6.6
shared_preferences: ^2.2.2
connectivity_plus: ^5.0.2
sqflite: ^2.3.0
path: ^1.8.3
intl: ^0.19.0
dev_dependencies:
flutter_test:
sdk: flutter
flutter_lints: ^3.0.0
Why GetX?
GetX provides:
- Reactive State Management: Automatic UI updates when data changes
- Dependency Injection: Easy service management
- Route Management: Simplified navigation
- Minimal Boilerplate: Less code, more functionality
Implementing Connectivity Monitoring
Let's start with a robust connectivity service that monitors network changes:
// lib/services/connectivity_service.dart
import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:get/get.dart';
class ConnectivityService extends GetxService {
final Connectivity _connectivity = Connectivity();
final RxBool _isConnected = false.obs;
late StreamSubscription<ConnectivityResult> _connectivitySubscription;
bool get isConnected => _isConnected.value;
RxBool get isConnectedStream => _isConnected;
@override
void onInit() {
super.onInit();
_initConnectivity();
_connectivitySubscription = _connectivity.onConnectivityChanged.listen(_updateConnectionStatus);
}
Future<void> _initConnectivity() async {
try {
final result = await _connectivity.checkConnectivity();
_updateConnectionStatus(result);
} catch (e) {
print('Could not check connectivity status: $e');
_isConnected.value = false;
}
}
void _updateConnectionStatus(ConnectivityResult result) {
_isConnected.value = result != ConnectivityResult.none;
print('Connectivity changed: ${_isConnected.value ? 'Connected' : 'Disconnected'}');
}
@override
void onClose() {
_connectivitySubscription.cancel();
super.onClose();
}
}
Key Features:
- Real-time Monitoring: Listens to connectivity changes
- Reactive Updates: UI automatically updates when connectivity changes
- Error Handling: Gracefully handles connectivity check failures
Building the Synchronization Engine
Now, let's create a comprehensive sync service:
// lib/services/sync_service.dart
import 'dart:convert';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/note.dart';
import 'connectivity_service.dart';
class SyncService extends GetxService {
final ConnectivityService _connectivityService = Get.find<ConnectivityService>();
final RxBool _isSyncing = false.obs;
final RxString _syncStatus = 'idle'.obs;
bool get isSyncing => _isSyncing.value;
RxString get syncStatus => _syncStatus;
Future<void> syncOfflineToOnline() async {
if (_isSyncing.value || !_connectivityService.isConnected) {
return;
}
_isSyncing.value = true;
_syncStatus.value = 'syncing';
try {
final prefs = await SharedPreferences.getInstance();
// Get offline notes
final offlineNotesJson = prefs.getStringList('offline_notes') ?? [];
if (offlineNotesJson.isEmpty) {
_syncStatus.value = 'completed';
_isSyncing.value = false;
return;
}
// Get existing online notes
final onlineNotesJson = prefs.getStringList('online_notes') ?? [];
final onlineNotes = onlineNotesJson
.map((json) => Note.fromJson(jsonDecode(json)))
.toList();
// Convert offline notes
final offlineNotes = offlineNotesJson
.map((json) => Note.fromJson(jsonDecode(json)))
.toList();
// Merge notes (avoiding duplicates by ID)
final Map<String, Note> mergedNotes = {};
// Add existing online notes
for (final note in onlineNotes) {
mergedNotes[note.id] = note;
}
// Add offline notes (will overwrite if same ID exists)
for (final note in offlineNotes) {
mergedNotes[note.id] = note.copyWith(
lastModified: DateTime.now(),
syncStatus: SyncStatus.synced,
);
}
// Save merged notes to online storage
final mergedNotesJson = mergedNotes.values
.map((note) => jsonEncode(note.toJson()))
.toList();
await prefs.setStringList('online_notes', mergedNotesJson);
// Clear offline notes after successful sync
await prefs.remove('offline_notes');
_syncStatus.value = 'completed';
// Show success message
Get.snackbar(
'Sync Complete',
'${offlineNotes.length} notes synced successfully',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 2),
);
} catch (e) {
_syncStatus.value = 'error';
print('Sync error: $e');
Get.snackbar(
'Sync Failed',
'Failed to sync notes. Will retry when connection is stable.',
snackPosition: SnackPosition.BOTTOM,
duration: const Duration(seconds: 3),
);
} finally {
_isSyncing.value = false;
}
}
Future<void> autoSync() async {
if (_connectivityService.isConnected) {
await syncOfflineToOnline();
}
}
}
Sync Strategy:
- Check Connectivity: Only sync when online
- Merge Data: Combine offline and online notes intelligently
- Avoid Duplicates: Use note IDs to prevent duplicate entries
- Clean Up: Remove offline notes after successful sync
- User Feedback: Provide clear sync status messages
Advanced Conflict Resolution
Let's enhance our Note model to support 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;
@JsonKey(name: 'created_at')
final DateTime createdAt;
@JsonKey(name: 'updated_at')
final DateTime updatedAt;
@JsonKey(name: 'last_synced_at')
final DateTime? lastSyncedAt;
@JsonKey(name: 'is_deleted', 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 convenience
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;
}
User Experience During Sync
Create a visual indicator for data source and sync status:
// lib/widgets/data_source_indicator.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../services/connectivity_service.dart';
import '../services/sync_service.dart';
class DataSourceIndicator extends StatelessWidget {
const DataSourceIndicator({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final connectivityService = Get.find<ConnectivityService>();
final syncService = Get.find<SyncService>();
return Obx(() {
final isConnected = connectivityService.isConnected;
final isSyncing = syncService.isSyncing;
final syncStatus = syncService.syncStatus.value;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
margin: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: _getBackgroundColor(isConnected, isSyncing, syncStatus),
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: _getBorderColor(isConnected, isSyncing, syncStatus),
width: 1,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
_getIcon(isConnected, isSyncing, syncStatus),
color: _getIconColor(isConnected, isSyncing, syncStatus),
size: 16,
),
const SizedBox(width: 8),
Text(
_getStatusText(isConnected, isSyncing, syncStatus),
style: TextStyle(
color: _getTextColor(isConnected, isSyncing, syncStatus),
fontWeight: FontWeight.w500,
fontSize: 12,
),
),
if (isSyncing) ...[
const SizedBox(width: 8),
SizedBox(
width: 12,
height: 12,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(
_getIconColor(isConnected, isSyncing, syncStatus),
),
),
),
],
],
),
);
});
}
Color _getBackgroundColor(bool isConnected, bool isSyncing, String syncStatus) {
if (isSyncing) return Colors.blue.shade50;
if (!isConnected) return Colors.red.shade50;
if (syncStatus == 'completed') return Colors.green.shade50;
return Colors.grey.shade100;
}
Color _getBorderColor(bool isConnected, bool isSyncing, String syncStatus) {
if (isSyncing) return Colors.blue.shade200;
if (!isConnected) return Colors.red.shade200;
if (syncStatus == 'completed') return Colors.green.shade200;
return Colors.grey.shade300;
}
IconData _getIcon(bool isConnected, bool isSyncing, String syncStatus) {
if (isSyncing) return Icons.sync;
if (!isConnected) return Icons.wifi_off;
if (syncStatus == 'completed') return Icons.cloud_done;
return Icons.wifi;
}
Color _getIconColor(bool isConnected, bool isSyncing, String syncStatus) {
if (isSyncing) return Colors.blue.shade600;
if (!isConnected) return Colors.red.shade600;
if (syncStatus == 'completed') return Colors.green.shade600;
return Colors.grey.shade600;
}
Color _getTextColor(bool isConnected, bool isSyncing, String syncStatus) {
if (isSyncing) return Colors.blue.shade700;
if (!isConnected) return Colors.red.shade700;
if (syncStatus == 'completed') return Colors.green.shade700;
return Colors.grey.shade700;
}
String _getStatusText(bool isConnected, bool isSyncing, String syncStatus) {
if (isSyncing) return 'Syncing...';
if (!isConnected) return 'Offline Mode';
if (syncStatus == 'completed') return 'All Synced';
return 'Online Mode';
}
}
Updated UI with GetX Integration
Now let's create the main note controller that manages our offline-first logic:
// lib/controllers/note_controller.dart
import 'dart:convert';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../models/note.dart';
import '../services/connectivity_service.dart';
import '../services/sync_service.dart';
class NoteController extends GetxController {
final RxList<Note> _allNotes = <Note>[].obs;
final RxList<Note> _offlineNotes = <Note>[].obs;
final RxList<Note> _onlineNotes = <Note>[].obs;
final RxBool _isLoading = false.obs;
final RxInt _selectedTabIndex = 0.obs;
final ConnectivityService _connectivityService = Get.find<ConnectivityService>();
final SyncService _syncService = Get.find<SyncService>();
List<Note> get allNotes => _allNotes;
List<Note> get offlineNotes => _offlineNotes;
List<Note> get onlineNotes => _onlineNotes;
bool get isLoading => _isLoading.value;
int get selectedTabIndex => _selectedTabIndex.value;
@override
void onInit() {
super.onInit();
loadNotes();
// Listen to connectivity changes
_connectivityService.isConnectedStream.listen((isConnected) {
if (isConnected) {
_syncService.autoSync().then((_) => loadNotes());
}
});
}
void setSelectedTab(int index) {
_selectedTabIndex.value = index;
}
Future<void> loadNotes() async {
_isLoading.value = true;
try {
final prefs = await SharedPreferences.getInstance();
// Load offline notes
final offlineNotesJson = prefs.getStringList('offline_notes') ?? [];
_offlineNotes.value = offlineNotesJson
.map((json) => Note.fromJson(jsonDecode(json)))
.toList();
// Load online notes
final onlineNotesJson = prefs.getStringList('online_notes') ?? [];
_onlineNotes.value = onlineNotesJson
.map((json) => Note.fromJson(jsonDecode(json)))
.toList();
// Combine all notes for the "All" view
_allNotes.value = [..._offlineNotes, ..._onlineNotes];
// Sort by last modified (newest first)
_allNotes.sort((a, b) => b.lastModified.compareTo(a.lastModified));
_offlineNotes.sort((a, b) => b.lastModified.compareTo(a.lastModified));
_onlineNotes.sort((a, b) => b.lastModified.compareTo(a.lastModified));
} catch (e) {
print('Error loading notes: $e');
Get.snackbar('Error', 'Failed to load notes');
} finally {
_isLoading.value = false;
}
}
Future<void> createNote(String title, String content) async {
try {
final note = Note(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
content: content,
createdAt: DateTime.now(),
lastModified: DateTime.now(),
syncStatus: SyncStatus.pending,
dataSource: _connectivityService.isConnected ? DataSource.online : DataSource.offline,
);
final prefs = await SharedPreferences.getInstance();
if (_connectivityService.isConnected) {
// Save to online storage
final onlineNotesJson = prefs.getStringList('online_notes') ?? [];
onlineNotesJson.add(jsonEncode(note.copyWith(
syncStatus: SyncStatus.synced,
dataSource: DataSource.online,
).toJson()));
await prefs.setStringList('online_notes', onlineNotesJson);
} else {
// Save to offline storage
final offlineNotesJson = prefs.getStringList('offline_notes') ?? [];
offlineNotesJson.add(jsonEncode(note.toJson()));
await prefs.setStringList('offline_notes', offlineNotesJson);
}
await loadNotes();
Get.snackbar(
'Success',
'Note created ${_connectivityService.isConnected ? 'online' : 'offline'}',
snackPosition: SnackPosition.BOTTOM,
);
} catch (e) {
print('Error creating note: $e');
Get.snackbar('Error', 'Failed to create note');
}
}
Future<void> updateNote(Note note, String title, String content) async {
try {
final updatedNote = note.copyWith(
title: title,
content: content,
lastModified: DateTime.now(),
);
final prefs = await SharedPreferences.getInstance();
if (note.dataSource == DataSource.offline) {
// Update in offline storage
final offlineNotesJson = prefs.getStringList('offline_notes') ?? [];
final index = offlineNotesJson.indexWhere((json) {
final n = Note.fromJson(jsonDecode(json));
return n.id == note.id;
});
if (index != -1) {
offlineNotesJson[index] = jsonEncode(updatedNote.toJson());
await prefs.setStringList('offline_notes', offlineNotesJson);
}
} else {
// Update in online storage
final onlineNotesJson = prefs.getStringList('online_notes') ?? [];
final index = onlineNotesJson.indexWhere((json) {
final n = Note.fromJson(jsonDecode(json));
return n.id == note.id;
});
if (index != -1) {
onlineNotesJson[index] = jsonEncode(updatedNote.toJson());
await prefs.setStringList('online_notes', onlineNotesJson);
}
}
await loadNotes();
Get.snackbar(
'Success',
'Note updated successfully',
snackPosition: SnackPosition.BOTTOM,
);
} catch (e) {
print('Error updating note: $e');
Get.snackbar('Error', 'Failed to update note');
}
}
Future<void> deleteNote(Note note) async {
try {
final prefs = await SharedPreferences.getInstance();
if (note.dataSource == DataSource.offline) {
// Delete from offline storage
final offlineNotesJson = prefs.getStringList('offline_notes') ?? [];
offlineNotesJson.removeWhere((json) {
final n = Note.fromJson(jsonDecode(json));
return n.id == note.id;
});
await prefs.setStringList('offline_notes', offlineNotesJson);
} else {
// Delete from online storage
final onlineNotesJson = prefs.getStringList('online_notes') ?? [];
onlineNotesJson.removeWhere((json) {
final n = Note.fromJson(jsonDecode(json));
return n.id == note.id;
});
await prefs.setStringList('online_notes', onlineNotesJson);
}
await loadNotes();
Get.snackbar(
'Success',
'Note deleted successfully',
snackPosition: SnackPosition.BOTTOM,
);
} catch (e) {
print('Error deleting note: $e');
Get.snackbar('Error', 'Failed to delete note');
}
}
Future<void> syncNotes() async {
await _syncService.syncOfflineToOnline();
await loadNotes();
}
}
Updated UI with Tab Bar
Now let's create the main screen with tab navigation:
// lib/screens/note_list_screen.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/note_controller.dart';
import '../models/note.dart';
import '../widgets/data_source_indicator.dart';
import 'note_detail_screen.dart';
class NoteListScreen extends StatelessWidget {
const NoteListScreen({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
final noteController = Get.put(NoteController());
return DefaultTabController(
length: 2,
child: Scaffold(
appBar: AppBar(
title: const Text('My Notes'),
elevation: 0,
bottom: const TabBar(
tabs: [
Tab(
icon: Icon(Icons.cloud),
text: 'Online',
),
Tab(
icon: Icon(Icons.offline_pin),
text: 'Offline',
),
],
),
actions: [
Obx(() {
return IconButton(
icon: noteController.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.refresh),
onPressed: noteController.isLoading
? null
: () => noteController.loadNotes(),
);
}),
IconButton(
icon: const Icon(Icons.sync),
onPressed: () => noteController.syncNotes(),
),
],
),
body: Column(
children: [
const DataSourceIndicator(),
Expanded(
child: TabBarView(
children: [
_buildNotesList(noteController.onlineNotes, DataSource.online),
_buildNotesList(noteController.offlineNotes, DataSource.offline),
],
),
),
],
),
floatingActionButton: FloatingActionButton(
onPressed: () => Get.to(() => const NoteDetailScreen()),
child: const Icon(Icons.add),
),
),
);
}
Widget _buildNotesList(List<Note> notes, DataSource dataSource) {
return Obx(() {
if (notes.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
dataSource == DataSource.online ? Icons.cloud_off : Icons.note_add,
size: 64,
color: Colors.grey.shade400,
),
const SizedBox(height: 16),
Text(
dataSource == DataSource.online
? 'No online notes yet'
: 'No offline notes yet',
style: TextStyle(
fontSize: 18,
color: Colors.grey.shade600,
),
),
const SizedBox(height: 8),
Text(
dataSource == DataSource.online
? 'Notes created while online will appear here'
: 'Notes created while offline will appear here',
style: TextStyle(
fontSize: 14,
color: Colors.grey.shade500,
),
textAlign: TextAlign.center,
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.all(8),
itemCount: notes.length,
itemBuilder: (context, index) {
final note = notes[index];
return _buildNoteCard(note);
},
);
});
}
Widget _buildNoteCard(Note note) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
elevation: 2,
child: ListTile(
title: Text(
note.title.isEmpty ? 'Untitled' : note.title,
style: const TextStyle(fontWeight: FontWeight.w600),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (note.content.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
note.content,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(color: Colors.grey.shade600),
),
],
const SizedBox(height: 8),
Row(
children: [
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
decoration: BoxDecoration(
color: note.dataSource == DataSource.online
? Colors.green.shade100
: Colors.orange.shade100,
borderRadius: BorderRadius.circular(12),
),
child: Text(
note.dataSource == DataSource.online ? 'Online' : 'Offline',
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.w500,
color: note.dataSource == DataSource.online
? Colors.green.shade700
: Colors.orange.shade700,
),
),
),
const SizedBox(width: 8),
Text(
_formatDate(note.lastModified),
style: TextStyle(
fontSize: 12,
color: Colors.grey.shade500,
),
),
],
),
],
),
trailing: PopupMenuButton<String>(
onSelected: (value) {
final noteController = Get.find<NoteController>();
if (value == 'delete') {
_showDeleteConfirmation(note, noteController);
}
},
itemBuilder: (context) => [
const PopupMenuItem(
value: 'delete',
child: Row(
children: [
Icon(Icons.delete, color: Colors.red),
SizedBox(width: 8),
Text('Delete'),
],
),
),
],
),
onTap: () => Get.to(() => NoteDetailScreen(note: note)),
),
);
}
void _showDeleteConfirmation(Note note, NoteController controller) {
Get.dialog(
AlertDialog(
title: const Text('Delete Note'),
content: Text('Are you sure you want to delete "${note.title.isEmpty ? 'Untitled' : note.title}"?'),
actions: [
TextButton(
onPressed: () => Get.back(),
child: const Text('Cancel'),
),
TextButton(
onPressed: () {
Get.back();
controller.deleteNote(note);
},
style: TextButton.styleFrom(foregroundColor: Colors.red),
child: const Text('Delete'),
),
],
),
);
}
String _formatDate(DateTime date) {
final now = DateTime.now();
final difference = now.difference(date);
if (difference.inDays == 0) {
return 'Today ${date.hour.toString().padLeft(2, '0')}:${date.minute.toString().padLeft(2, '0')}';
} else if (difference.inDays == 1) {
return 'Yesterday';
} else if (difference.inDays < 7) {
return '${difference.inDays} days ago';
} else {
return '${date.day}/${date.month}/${date.year}';
}
}
}
Note Detail Screen with Enhanced Save
// lib/screens/note_detail_screen.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/note_controller.dart';
import '../models/note.dart';
class NoteDetailScreen extends StatefulWidget {
final Note? note;
const NoteDetailScreen({Key? key, this.note}) : super(key: key);
@override
State<NoteDetailScreen> createState() => _NoteDetailScreenState();
}
class _NoteDetailScreenState extends State<NoteDetailScreen> {
late TextEditingController _titleController;
late TextEditingController _contentController;
final RxBool _isSaving = false.obs;
final RxBool _hasChanges = false.obs;
@override
void initState() {
super.initState();
_titleController = TextEditingController(text: widget.note?.title ?? '');
_contentController = TextEditingController(text: widget.note?.content ?? '');
_titleController.addListener(_onTextChanged);
_contentController.addListener(_onTextChanged);
}
void _onTextChanged() {
final hasChanges = _titleController.text != (widget.note?.title ?? '') ||
_contentController.text != (widget.note?.content ?? '');
_hasChanges.value = hasChanges;
}
@override
void dispose() {
_titleController.dispose();
_contentController.dispose();
super.dispose();
}
Future<void> _saveNote() async {
if (_isSaving.value) return;
final title = _titleController.text.trim();
final content = _contentController.text.trim();
if (title.isEmpty && content.isEmpty) {
Get.snackbar('Error', 'Please enter a title or content');
return;
}
_isSaving.value = true;
try {
final noteController = Get.find<NoteController>();
if (widget.note == null) {
// Create new note
await noteController.createNote(title, content);
} else {
// Update existing note
await noteController.updateNote(widget.note!, title, content);
}
_hasChanges.value = false;
// Close any existing snackbars before navigation
Get.closeAllSnackbars();
// Small delay to ensure snackbar is properly closed
await Future.delayed(const Duration(milliseconds: 100));
Get.back();
} catch (e) {
Get.snackbar('Error', 'Failed to save note: $e');
} finally {
_isSaving.value = false;
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.note == null ? 'New Note' : 'Edit Note'),
actions: [
Obx(() => IconButton(
icon: _isSaving.value
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Icon(Icons.check),
onPressed: _isSaving.value ? null : _saveNote,
)),
],
),
body: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
children: [
if (widget.note != null) ...[
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: widget.note!.dataSource == DataSource.online
? Colors.green.shade50
: Colors.orange.shade50,
borderRadius: BorderRadius.circular(8),
border: Border.all(
color: widget.note!.dataSource == DataSource.online
? Colors.green.shade200
: Colors.orange.shade200,
),
),
child: Row(
children: [
Icon(
widget.note!.dataSource == DataSource.online
? Icons.cloud
: Icons.offline_pin,
color: widget.note!.dataSource == DataSource.online
? Colors.green.shade600
: Colors.orange.shade600,
size: 20,
),
const SizedBox(width: 8),
Text(
'This note is stored ${widget.note!.dataSource == DataSource.online ? 'online' : 'offline'}',
style: TextStyle(
color: widget.note!.dataSource == DataSource.online
? Colors.green.shade700
: Colors.orange.shade700,
fontWeight: FontWeight.w500,
),
),
],
),
),
const SizedBox(height: 16),
],
TextField(
controller: _titleController,
decoration: const InputDecoration(
hintText: 'Note title...',
border: OutlineInputBorder(),
labelText: 'Title',
),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 16),
Expanded(
child: TextField(
controller: _contentController,
decoration: const InputDecoration(
hintText: 'Start writing your note...',
border: OutlineInputBorder(),
labelText: 'Content',
alignLabelWithHint: true,
),
maxLines: null,
expands: true,
textAlignVertical: TextAlignVertical.top,
),
),
],
),
),
floatingActionButton: Obx(() => _hasChanges.value
? FloatingActionButton(
onPressed: _isSaving.value ? null : _saveNote,
child: _isSaving.value
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(Colors.white),
),
)
: const Icon(Icons.save),
)
: const SizedBox.shrink()),
);
}
}
Main App Setup
Finally, let's set up the main app with proper dependency injection:
// lib/main.dart
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'services/connectivity_service.dart';
import 'services/sync_service.dart';
import 'screens/note_list_screen.dart';
void main() {
runApp(MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return GetMaterialApp(
title: 'Offline-First Notes',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const AppInitializer(),
debugShowCheckedModeBanner: false,
);
}
}
class AppInitializer extends StatelessWidget {
const AppInitializer({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return FutureBuilder(
future: _initializeServices(),
builder: (context, snapshot) {
if (snapshot.connectionState == ConnectionState.waiting) {
return const Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Initializing app...'),
],
),
),
);
}
if (snapshot.hasError) {
return Scaffold(
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.error, size: 64, color: Colors.red),
const SizedBox(height: 16),
Text('Error: ${snapshot.error}'),
const SizedBox(height: 16),
ElevatedButton(
onPressed: () => Get.offAll(() => const AppInitializer()),
child: const Text('Retry'),
),
],
),
),
);
}
return const NoteListScreen();
},
);
}
Future<void> _initializeServices() async {
// Initialize services in order
Get.put(ConnectivityService());
await Get.find<ConnectivityService>().onInit();
Get.put(SyncService());
await Get.find<SyncService>().onInit();
}
}
Performance Optimization
Memory Management
// Add to NoteController
@override
void onClose() {
// Clean up resources
_allNotes.close();
_offlineNotes.close();
_onlineNotes.close();
_isLoading.close();
_selectedTabIndex.close();
super.onClose();
}
Efficient Data Loading
// Add pagination support
class NoteController extends GetxController {
static const int pageSize = 20;
final RxInt _currentPage = 0.obs;
final RxBool _hasMoreData = true.obs;
Future<void> loadMoreNotes() async {
if (!_hasMoreData.value || _isLoading.value) return;
_currentPage.value++;
// Implement pagination logic here
}
}
Testing Your Implementation
Test Scenarios
-
Offline Creation
- Turn off internet
- Create several notes
- Verify they appear in "Offline" tab
-
Online Sync
- Turn on internet
- Verify automatic sync occurs
- Check that offline notes move to "Online" tab
-
Mixed Operations
- Create notes both online and offline
- Test editing in both modes
- Verify data consistency
Debug Tips
// Add debug logging
void debugPrintNotes() {
print('=== DEBUG: Current Notes State ===');
print('Offline notes: ${_offlineNotes.length}');
print('Online notes: ${_onlineNotes.length}');
print('All notes: ${_allNotes.length}');
print('Is connected: ${_connectivityService.isConnected}');
print('================================');
}
Common Pitfalls to Avoid
1. Race Conditions
// ❌ Bad: Multiple simultaneous syncs
Future<void> syncNotes() async {
await _syncService.syncOfflineToOnline();
}
// ✅ Good: Prevent concurrent syncs
Future<void> syncNotes() async {
if (_syncService.isSyncing) return;
await _syncService.syncOfflineToOnline();
}
2. Memory Leaks
// ❌ Bad: Not disposing controllers
class NoteController extends GetxController {
// Missing onClose()
}
// ✅ Good: Proper cleanup
class NoteController extends GetxController {
@override
void onClose() {
// Dispose all reactive variables
super.onClose();
}
}
3. Data Loss During Sync
// ❌ Bad: Clear offline data immediately
await prefs.remove('offline_notes');
await syncToOnline();
// ✅ Good: Clear only after successful sync
await syncToOnline();
if (syncSuccessful) {
await prefs.remove('offline_notes');
}
Scaling Considerations
Database Migration
As your app grows, consider migrating from SharedPreferences to SQLite:
// Future enhancement: SQLite integration
class DatabaseHelper {
static Future<Database> _database() async {
return openDatabase(
'notes.db',
version: 1,
onCreate: (db, version) {
return db.execute('''
CREATE TABLE notes(
id TEXT PRIMARY KEY,
title TEXT,
content TEXT,
created_at TEXT,
last_modified TEXT,
sync_status TEXT,
data_source TEXT
)
''');
},
);
}
}
API Integration
Prepare for server synchronization:
// Future enhancement: REST API sync
class ApiService {
static const String baseUrl = 'https://your-api.com';
Future<List<Note>> fetchNotesFromServer() async {
// Implement server sync
}
Future<void> pushNotesToServer(List<Note> notes) async {
// Implement server push
}
}
Security Considerations
Data Encryption
// Add encryption for sensitive data
import 'package:crypto/crypto.dart';
class EncryptionService {
static String encryptData(String data) {
var bytes = utf8.encode(data);
var digest = sha256.convert(bytes);
return digest.toString();
}
}
Secure Storage
// Use flutter_secure_storage for sensitive data
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
class SecureStorageService {
static const _storage = FlutterSecureStorage();
static Future<void> storeSecurely(String key, String value) async {
await _storage.write(key: key, value: value);
}
}
Future Enhancements
1. Real-time Sync
- WebSocket integration for live updates
- Collaborative editing features
- Push notifications for sync events
2. Advanced Conflict Resolution
- Three-way merge algorithms
- User-guided conflict resolution UI
- Automatic conflict detection and resolution
3. Offline-First Patterns
- Event sourcing for data changes
- CRDT (Conflict-free Replicated Data Types)
- Background sync with WorkManager
4. Enhanced User Experience
- Sync progress indicators
- Offline mode tutorials
- Data usage optimization
Complete Code Repository
You can find the complete working code for this tutorial on GitHub: flutter-offline-first-part1
Conclusion
Building offline-first applications is a journey that requires careful planning, robust architecture, and attention to user experience. Throughout this series, we've covered:
- Solid Foundations - SQLite storage with proper schema design
- Smart State Management - GetX for reactive programming
- Intelligent Synchronization - Local sync with conflict resolution
- Excellent UX - Real-time feedback and intuitive interfaces
- Production Readiness - Error handling, performance, and security
The offline-first approach ensures your users have a consistent, reliable experience regardless of network conditions. By storing data locally and treating network connectivity as an enhancement rather than a requirement, you've created an app that truly puts user experience first.
What's Next?
Now that you have a solid foundation, consider these next steps:
- Add More Features - Implement the suggested enhancements like rich text editing or file attachments
- Scale Your Architecture - Add cloud synchronization for multi-device support
- Optimize Performance - Profile your app and optimize based on real usage patterns
- Gather Feedback - Deploy to users and iterate based on their needs
- Share Your Knowledge - Write about your experience and help other developers
Key Takeaways
• Offline-first is user-first - Always prioritize local operations and immediate feedback
• State management matters - Choose reactive solutions like GetX for better user experiences
• Handle conflicts gracefully - Provide clear resolution options when data conflicts occur
• Monitor and communicate - Keep users informed about sync status and connectivity
• Test thoroughly - Edge cases in offline apps can be subtle but critical
• Plan for scale - Design your architecture to handle growth and additional features
Remember, building great offline-first apps is an iterative process. Start with the solid foundation we've built together, and continuously improve based on user feedback and changing requirements.
Found this helpful? Give it a ❤️ and share your implementation experiences in the comments below! I'd love to see what you build with these concepts.
Tags: #flutter #mobile #offline #architecture #sqlite #getx #statemanagement #mobiledev #appdevelopment #sync
Thank you for following along with this comprehensive offline-first architecture series! Happy coding! 🚀
Top comments (0)