offline_first_sync 1.1.5
offline_first_sync: ^1.1.5 copied to clipboard
Intelligent offline-first data management with automatic sync, conflict resolution, and visual status indicators
import 'package:flutter/material.dart';
import 'package:offline_first_sync/offline_first_sync.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Offline First Sync Demo',
theme: ThemeData(primarySwatch: Colors.blue, useMaterial3: true),
home: const SyncDemoPage(),
);
}
}
class SyncDemoPage extends StatefulWidget {
const SyncDemoPage({super.key});
@override
State<SyncDemoPage> createState() => _SyncDemoPageState();
}
class _SyncDemoPageState extends State<SyncDemoPage> {
late SyncManager _syncManager;
final List<TodoItem> _todos = [];
final TextEditingController _textController = TextEditingController();
@override
void initState() {
super.initState();
_initializeSyncManager();
}
void _initializeSyncManager() {
final client = HttpSyncClient(
baseUrl: 'https://api.example.com',
headers: {'Authorization': 'Bearer your-token'},
);
_syncManager = SyncManager(
client: client,
syncInterval: const Duration(seconds: 30),
);
// Listen to conflicts for resolution
_syncManager.conflictsStream.listen((conflicts) {
if (conflicts.isNotEmpty) {
_showConflictDialog(conflicts.first);
}
});
}
void _addTodo() async {
if (_textController.text.trim().isEmpty) return;
final todo = TodoItem(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: _textController.text.trim(),
completed: false,
createdAt: DateTime.now(),
);
setState(() {
_todos.add(todo);
});
await _syncManager.addSyncItem(
entityType: 'todos',
entityId: todo.id,
data: todo.toMap(),
action: SyncAction.create,
);
_textController.clear();
}
void _toggleTodo(TodoItem todo) async {
final updatedTodo = todo.copyWith(completed: !todo.completed);
setState(() {
final index = _todos.indexWhere((t) => t.id == todo.id);
if (index != -1) {
_todos[index] = updatedTodo;
}
});
await _syncManager.addSyncItem(
entityType: 'todos',
entityId: todo.id,
data: updatedTodo.toMap(),
action: SyncAction.update,
);
}
void _deleteTodo(TodoItem todo) async {
setState(() {
_todos.removeWhere((t) => t.id == todo.id);
});
await _syncManager.addSyncItem(
entityType: 'todos',
entityId: todo.id,
data: todo.toMap(),
action: SyncAction.delete,
);
}
void _showConflictDialog(SyncItem conflictItem) {
showDialog(
context: context,
builder: (context) => ConflictResolutionDialog(
conflictItem: conflictItem,
onResolve: (strategy, data) async {
await _syncManager.resolveConflict(
conflictItem.id,
strategy,
customData: data,
);
Navigator.of(context).pop();
},
),
);
}
void _showSyncDetails() {
showModalBottomSheet(
context: context,
isScrollControlled: true,
builder: (context) => SyncDetailsSheet(syncManager: _syncManager),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Offline First Sync Demo'),
actions: [
StreamBuilder<SyncStatus>(
stream: _syncManager.statusStream,
builder: (context, snapshot) {
final status =
snapshot.data ??
const SyncStatus(
isOnline: false,
isSyncing: false,
pendingCount: 0,
failedCount: 0,
conflictCount: 0,
);
return Padding(
padding: const EdgeInsets.only(right: 16),
child: SyncStatusIndicator(
status: status,
onTap: _showSyncDetails,
),
);
},
),
],
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textController,
decoration: const InputDecoration(
hintText: 'Add a new todo...',
border: OutlineInputBorder(),
),
onSubmitted: (_) => _addTodo(),
),
),
const SizedBox(width: 16),
ElevatedButton(onPressed: _addTodo, child: const Text('Add')),
],
),
),
Expanded(
child: ListView.builder(
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return ListTile(
leading: Checkbox(
value: todo.completed,
onChanged: (_) => _toggleTodo(todo),
),
title: Text(
todo.title,
style: TextStyle(
decoration: todo.completed
? TextDecoration.lineThrough
: null,
),
),
trailing: IconButton(
icon: const Icon(Icons.delete),
onPressed: () => _deleteTodo(todo),
),
);
},
),
),
],
),
floatingActionButton: StreamBuilder<SyncStatus>(
stream: _syncManager.statusStream,
builder: (context, snapshot) {
final status =
snapshot.data ??
const SyncStatus(
isOnline: false,
isSyncing: false,
pendingCount: 0,
failedCount: 0,
conflictCount: 0,
);
return SyncFab(status: status, onPressed: () => _syncManager.sync());
},
),
);
}
@override
void dispose() {
_syncManager.dispose();
_textController.dispose();
super.dispose();
}
}
class TodoItem {
final String id;
final String title;
final bool completed;
final DateTime createdAt;
const TodoItem({
required this.id,
required this.title,
required this.completed,
required this.createdAt,
});
TodoItem copyWith({
String? id,
String? title,
bool? completed,
DateTime? createdAt,
}) {
return TodoItem(
id: id ?? this.id,
title: title ?? this.title,
completed: completed ?? this.completed,
createdAt: createdAt ?? this.createdAt,
);
}
Map<String, dynamic> toMap() {
return {
'id': id,
'title': title,
'completed': completed,
'createdAt': createdAt.toIso8601String(),
};
}
factory TodoItem.fromMap(Map<String, dynamic> map) {
return TodoItem(
id: map['id'],
title: map['title'],
completed: map['completed'],
createdAt: DateTime.parse(map['createdAt']),
);
}
}
class ConflictResolutionDialog extends StatefulWidget {
final SyncItem conflictItem;
final Function(ConflictResolutionStrategy, Map<String, dynamic>?) onResolve;
const ConflictResolutionDialog({
super.key,
required this.conflictItem,
required this.onResolve,
});
@override
State<ConflictResolutionDialog> createState() =>
_ConflictResolutionDialogState();
}
class _ConflictResolutionDialogState extends State<ConflictResolutionDialog> {
ConflictResolutionStrategy _selectedStrategy =
ConflictResolutionStrategy.clientWins;
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Sync Conflict Detected'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'A conflict occurred while syncing ${widget.conflictItem.entityType}.',
),
const SizedBox(height: 16),
const Text(
'Client Data:',
style: TextStyle(fontWeight: FontWeight.bold),
),
Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: Colors.blue.shade50,
borderRadius: BorderRadius.circular(4),
),
child: Text(widget.conflictItem.data.toString()),
),
const Text(
'Server Data:',
style: TextStyle(fontWeight: FontWeight.bold),
),
Container(
padding: const EdgeInsets.all(8),
margin: const EdgeInsets.symmetric(vertical: 4),
decoration: BoxDecoration(
color: Colors.orange.shade50,
borderRadius: BorderRadius.circular(4),
),
child: Text(
widget.conflictItem.serverData?.toString() ?? 'No server data',
),
),
const SizedBox(height: 16),
const Text('Resolution Strategy:'),
...ConflictResolutionStrategy.values.map((strategy) {
return RadioListTile<ConflictResolutionStrategy>(
title: Text(_getStrategyDisplayName(strategy)),
subtitle: Text(_getStrategyDescription(strategy)),
value: strategy,
groupValue: _selectedStrategy,
onChanged: (value) {
setState(() {
_selectedStrategy = value!;
});
},
);
}),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => widget.onResolve(_selectedStrategy, null),
child: const Text('Resolve'),
),
],
);
}
String _getStrategyDisplayName(ConflictResolutionStrategy strategy) {
switch (strategy) {
case ConflictResolutionStrategy.clientWins:
return 'Keep Local Changes';
case ConflictResolutionStrategy.serverWins:
return 'Use Server Version';
case ConflictResolutionStrategy.lastWriteWins:
return 'Last Write Wins';
case ConflictResolutionStrategy.merge:
return 'Merge Changes';
case ConflictResolutionStrategy.manual:
return 'Manual Resolution';
}
}
String _getStrategyDescription(ConflictResolutionStrategy strategy) {
switch (strategy) {
case ConflictResolutionStrategy.clientWins:
return 'Overwrite server with local changes';
case ConflictResolutionStrategy.serverWins:
return 'Discard local changes and use server version';
case ConflictResolutionStrategy.lastWriteWins:
return 'Use the most recently modified version';
case ConflictResolutionStrategy.merge:
return 'Automatically merge both versions';
case ConflictResolutionStrategy.manual:
return 'Manually edit the data';
}
}
}
class SyncDetailsSheet extends StatelessWidget {
final SyncManager syncManager;
const SyncDetailsSheet({super.key, required this.syncManager});
@override
Widget build(BuildContext context) {
return DraggableScrollableSheet(
initialChildSize: 0.6,
maxChildSize: 0.9,
minChildSize: 0.3,
builder: (context, scrollController) {
return Container(
decoration: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
),
child: Column(
children: [
Container(
width: 40,
height: 4,
margin: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
),
Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const Text(
'Sync Details',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
),
),
const Spacer(),
ElevatedButton(
onPressed: () => syncManager.sync(),
child: const Text('Sync Now'),
),
],
),
),
Expanded(
child: StreamBuilder<SyncStatus>(
stream: syncManager.statusStream,
builder: (context, snapshot) {
final status =
snapshot.data ??
const SyncStatus(
isOnline: false,
isSyncing: false,
pendingCount: 0,
failedCount: 0,
conflictCount: 0,
);
return ListView(
controller: scrollController,
padding: const EdgeInsets.all(16),
children: [
_buildStatusCard(
'Connection',
status.isOnline ? 'Online' : 'Offline',
status.isOnline ? Colors.green : Colors.red,
),
_buildStatusCard(
'Sync Status',
status.isSyncing ? 'Syncing...' : 'Idle',
status.isSyncing ? Colors.blue : Colors.grey,
),
_buildStatusCard(
'Pending Items',
'${status.pendingCount}',
status.pendingCount > 0
? Colors.orange
: Colors.green,
),
_buildStatusCard(
'Failed Items',
'${status.failedCount}',
status.failedCount > 0 ? Colors.red : Colors.green,
),
_buildStatusCard(
'Conflicts',
'${status.conflictCount}',
status.conflictCount > 0 ? Colors.red : Colors.green,
),
if (status.lastSyncTime != null)
_buildStatusCard(
'Last Sync',
_formatDateTime(status.lastSyncTime!),
Colors.grey,
),
const SizedBox(height: 16),
if (status.failedCount > 0)
ElevatedButton.icon(
onPressed: () => syncManager.retryFailed(),
icon: const Icon(Icons.refresh),
label: const Text('Retry Failed Items'),
),
if (status.pendingCount > 0 || status.failedCount > 0)
TextButton.icon(
onPressed: () => _showClearConfirmation(context),
icon: const Icon(Icons.clear_all),
label: const Text('Clear Sync Queue'),
style: TextButton.styleFrom(
foregroundColor: Colors.red,
),
),
],
);
},
),
),
],
),
);
},
);
}
Widget _buildStatusCard(String title, String value, Color color) {
return Card(
child: ListTile(
title: Text(title),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: color.withOpacity(0.3)),
),
child: Text(
value,
style: TextStyle(color: color, fontWeight: FontWeight.w500),
),
),
),
);
}
String _formatDateTime(DateTime dateTime) {
final now = DateTime.now();
final difference = now.difference(dateTime);
if (difference.inMinutes < 1) {
return 'Just now';
} else if (difference.inHours < 1) {
return '${difference.inMinutes}m ago';
} else if (difference.inDays < 1) {
return '${difference.inHours}h ago';
} else {
return '${difference.inDays}d ago';
}
}
void _showClearConfirmation(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Clear Sync Queue'),
content: const Text(
'This will remove all pending sync items. This action cannot be undone.',
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
syncManager.clearSyncQueue();
Navigator.of(context).pop();
Navigator.of(context).pop();
},
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
child: const Text('Clear'),
),
],
),
);
}
}