sync_vault 1.0.0
sync_vault: ^1.0.0 copied to clipboard
An offline-first data synchronization layer for Flutter apps. Automatically queues API requests when offline and syncs with exponential backoff.
example/lib/main.dart
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:path_provider/path_provider.dart';
import 'package:sync_vault/sync_vault.dart';
import 'todo_model.dart';
void main() async {
WidgetsFlutterBinding.ensureInitialized();
// Get the documents directory for Hive storage
final dir = await getApplicationDocumentsDirectory();
// Initialize SyncVault
await SyncVault.init(
config: SyncVaultConfig(
baseUrl: 'https://jsonplaceholder.typicode.com', // Demo API
storagePath: dir.path,
maxRetries: 3,
),
);
runApp(const SyncVaultExampleApp());
}
class SyncVaultExampleApp extends StatelessWidget {
const SyncVaultExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'SyncVault Todo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: const Color(0xFF6366F1),
brightness: Brightness.dark,
),
scaffoldBackgroundColor: const Color(0xFF0F0F23),
fontFamily: 'SF Pro Display',
),
home: const TodoListScreen(),
);
}
}
class TodoListScreen extends StatefulWidget {
const TodoListScreen({super.key});
@override
State<TodoListScreen> createState() => _TodoListScreenState();
}
class _TodoListScreenState extends State<TodoListScreen>
with TickerProviderStateMixin {
final List<Todo> _todos = [];
final TextEditingController _textController = TextEditingController();
final FocusNode _focusNode = FocusNode();
/// Maps todo.id to the SyncVault action ID for tracking
final Map<String, String> _todoToActionId = {};
int _pendingCount = 0;
bool _isOnline = true;
late AnimationController _pulseController;
late Animation<double> _pulseAnimation;
@override
void initState() {
super.initState();
_setupAnimations();
_checkConnectivity();
}
void _setupAnimations() {
_pulseController = AnimationController(
duration: const Duration(milliseconds: 1500),
vsync: this,
);
_pulseAnimation = Tween<double>(begin: 0.8, end: 1.0).animate(
CurvedAnimation(parent: _pulseController, curve: Curves.easeInOut),
);
_pulseController.repeat(reverse: true);
}
Future<void> _checkConnectivity() async {
_isOnline = await SyncVault.instance.isOnline;
await _updatePendingCount();
if (mounted) setState(() {});
}
Future<void> _updatePendingCount() async {
_pendingCount = await SyncVault.instance.pendingCount;
if (mounted) setState(() {});
}
@override
void dispose() {
_textController.dispose();
_focusNode.dispose();
_pulseController.dispose();
super.dispose();
}
Future<void> _addTodo() async {
final title = _textController.text.trim();
if (title.isEmpty) return;
final todo = Todo(
id: DateTime.now().millisecondsSinceEpoch.toString(),
title: title,
createdAt: DateTime.now(),
isPendingSync: true,
);
setState(() {
_todos.insert(0, todo);
_textController.clear();
});
// Send to server via SyncVault
final response = await SyncVault.instance.post(
'/todos',
data: todo.toJson(),
idempotencyKey: 'create_todo_${todo.id}',
);
// Track the action ID for this todo
if (response.actionId != null) {
_todoToActionId[todo.id] = response.actionId!;
}
if (response.isQueued) {
_showSnackBar('Todo saved offline. Will sync when online.',
isWarning: true);
} else if (response.isSent) {
// Immediate success - update UI
_markTodoSynced(todo.id);
_showSnackBar('Todo created successfully!');
}
await _updatePendingCount();
}
Future<void> _toggleTodo(Todo todo) async {
final updatedTodo = todo.copyWith(
isCompleted: !todo.isCompleted,
isPendingSync: true,
);
setState(() {
final index = _todos.indexWhere((t) => t.id == todo.id);
if (index != -1) {
_todos[index] = updatedTodo;
}
});
final response = await SyncVault.instance.patch(
'/todos/${todo.id}',
data: {'isCompleted': updatedTodo.isCompleted},
idempotencyKey:
'toggle_todo_${todo.id}_${DateTime.now().millisecondsSinceEpoch}',
);
if (response.actionId != null) {
_todoToActionId[todo.id] = response.actionId!;
}
if (response.isSent) {
_markTodoSynced(todo.id);
}
await _updatePendingCount();
}
Future<void> _deleteTodo(Todo todo) async {
setState(() {
_todos.removeWhere((t) => t.id == todo.id);
_todoToActionId.remove(todo.id);
});
final response = await SyncVault.instance.delete(
'/todos/${todo.id}',
idempotencyKey: 'delete_todo_${todo.id}',
);
if (response.isQueued) {
_showSnackBar('Delete queued. Will sync when online.', isWarning: true);
}
await _updatePendingCount();
}
void _markTodoSynced(String todoId) {
setState(() {
final index = _todos.indexWhere((t) => t.id == todoId);
if (index != -1) {
_todos[index] = _todos[index].copyWith(isPendingSync: false);
}
_todoToActionId.remove(todoId);
});
}
/// Called when a sync event completes successfully
void _handleSyncSuccess(SyncEvent event) {
// Find which todo this action belongs to
final todoId = _todoToActionId.entries
.where((e) => e.value == event.id)
.map((e) => e.key)
.firstOrNull;
if (todoId != null) {
_markTodoSynced(todoId);
_showSnackBar('Todo synced successfully!');
}
_updatePendingCount();
}
/// Called when a sync event fails permanently
void _handleSyncDeadLetter(SyncEvent event) {
_showSnackBar('Sync failed: ${event.error}', isError: true);
_updatePendingCount();
}
Future<void> _manualSync() async {
final count = await SyncVault.instance.processQueue();
if (count > 0) {
_showSnackBar('Synced $count items successfully!');
} else {
_showSnackBar('Nothing to sync');
}
}
void _showSnackBar(String message,
{bool isWarning = false, bool isError = false}) {
Color backgroundColor;
if (isError) {
backgroundColor = const Color(0xFFEF4444);
} else if (isWarning) {
backgroundColor = const Color(0xFFF59E0B);
} else {
backgroundColor = const Color(0xFF10B981);
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: backgroundColor,
behavior: SnackBarBehavior.floating,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
margin: const EdgeInsets.all(16),
),
);
}
@override
Widget build(BuildContext context) {
// Wrap the entire screen with SyncVaultListener to handle background sync events
return SyncVaultListener(
onSuccess: _handleSyncSuccess,
onDeadLetter: _handleSyncDeadLetter,
onQueued: (_) => _updatePendingCount(),
child: Scaffold(
body: SafeArea(
child: Column(
children: [
_buildHeader(),
_buildStatusBar(),
_buildInputField(),
Expanded(child: _buildTodoList()),
],
),
),
),
);
}
Widget _buildHeader() {
return Container(
padding: const EdgeInsets.fromLTRB(24, 24, 24, 16),
child: Row(
children: [
Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF6366F1), Color(0xFF8B5CF6)],
),
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: const Color(0xFF6366F1).withOpacity(0.4),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
child:
const Icon(Icons.sync_rounded, color: Colors.white, size: 28),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'SyncVault',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
letterSpacing: -0.5,
),
),
Text(
'Offline-first Todo',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.6),
),
),
],
),
),
// Use SyncStatusBuilder for the sync button icon
SyncStatusBuilder(
builder: (context, status) {
return IconButton(
onPressed: _manualSync,
icon: AnimatedBuilder(
animation: _pulseAnimation,
builder: (context, child) {
return Transform.scale(
scale: status == SyncStatus.syncing
? _pulseAnimation.value
: 1.0,
child: child,
);
},
child: Icon(
status == SyncStatus.syncing
? Icons.sync_rounded
: Icons.cloud_sync_rounded,
color: _getSyncColor(status),
size: 28,
),
),
);
},
),
],
),
);
}
Widget _buildStatusBar() {
// Use SyncStatusBuilder for reactive status updates
return SyncStatusBuilder(
builder: (context, syncStatus) {
return Container(
margin: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color: const Color(0xFF1E1E3F),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: _isOnline
? const Color(0xFF10B981).withOpacity(0.3)
: const Color(0xFFF59E0B).withOpacity(0.3),
),
),
child: Row(
children: [
// Online/Offline indicator
Container(
width: 8,
height: 8,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _isOnline
? const Color(0xFF10B981)
: const Color(0xFFF59E0B),
boxShadow: [
BoxShadow(
color: (_isOnline
? const Color(0xFF10B981)
: const Color(0xFFF59E0B))
.withOpacity(0.5),
blurRadius: 8,
),
],
),
),
const SizedBox(width: 12),
Text(
_isOnline ? 'Online' : 'Offline',
style: TextStyle(
color: Colors.white.withOpacity(0.8),
fontWeight: FontWeight.w500,
),
),
const Spacer(),
// Sync status badge
_buildSyncBadge(syncStatus),
],
),
);
},
);
}
Widget _buildSyncBadge(SyncStatus status) {
if (status == SyncStatus.syncing) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF6366F1).withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF6366F1),
),
),
SizedBox(width: 6),
Text(
'Syncing...',
style: TextStyle(
color: Color(0xFF6366F1),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
if (_pendingCount > 0) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFFF59E0B).withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.hourglass_empty_rounded,
size: 14,
color: Color(0xFFF59E0B),
),
const SizedBox(width: 4),
Text(
'$_pendingCount pending',
style: const TextStyle(
color: Color(0xFFF59E0B),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: const Color(0xFF10B981).withOpacity(0.2),
borderRadius: BorderRadius.circular(20),
),
child: const Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.check_circle_outline_rounded,
size: 14,
color: Color(0xFF10B981),
),
SizedBox(width: 4),
Text(
'Synced',
style: TextStyle(
color: Color(0xFF10B981),
fontSize: 12,
fontWeight: FontWeight.w600,
),
),
],
),
);
}
Widget _buildInputField() {
return Container(
margin: const EdgeInsets.all(24),
decoration: BoxDecoration(
color: const Color(0xFF1E1E3F),
borderRadius: BorderRadius.circular(20),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.2),
blurRadius: 20,
offset: const Offset(0, 10),
),
],
),
child: Row(
children: [
Expanded(
child: TextField(
controller: _textController,
focusNode: _focusNode,
style: const TextStyle(color: Colors.white),
decoration: InputDecoration(
hintText: 'Add a new todo...',
hintStyle: TextStyle(color: Colors.white.withOpacity(0.4)),
border: InputBorder.none,
contentPadding: const EdgeInsets.all(20),
),
onSubmitted: (_) => _addTodo(),
),
),
Container(
margin: const EdgeInsets.only(right: 8),
child: IconButton(
onPressed: _addTodo,
style: IconButton.styleFrom(
backgroundColor: const Color(0xFF6366F1),
padding: const EdgeInsets.all(12),
),
icon: const Icon(Icons.add_rounded, color: Colors.white),
),
),
],
),
);
}
Widget _buildTodoList() {
if (_todos.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.inbox_rounded,
size: 80,
color: Colors.white.withOpacity(0.1),
),
const SizedBox(height: 16),
Text(
'No todos yet',
style: TextStyle(
fontSize: 18,
color: Colors.white.withOpacity(0.4),
),
),
const SizedBox(height: 8),
Text(
'Add one above to get started',
style: TextStyle(
fontSize: 14,
color: Colors.white.withOpacity(0.3),
),
),
],
),
);
}
return ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 24),
itemCount: _todos.length,
itemBuilder: (context, index) {
final todo = _todos[index];
return _buildTodoItem(todo, index);
},
);
}
Widget _buildTodoItem(Todo todo, int index) {
final actionId = _todoToActionId[todo.id];
return AnimatedContainer(
duration: const Duration(milliseconds: 300),
margin: const EdgeInsets.only(bottom: 12),
child: Dismissible(
key: Key(todo.id),
direction: DismissDirection.endToStart,
onDismissed: (_) => _deleteTodo(todo),
background: Container(
alignment: Alignment.centerRight,
padding: const EdgeInsets.only(right: 20),
decoration: BoxDecoration(
color: const Color(0xFFEF4444),
borderRadius: BorderRadius.circular(16),
),
child: const Icon(Icons.delete_outline_rounded, color: Colors.white),
),
child: GestureDetector(
onTap: () => _toggleTodo(todo),
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFF1E1E3F),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: todo.isPendingSync
? const Color(0xFFF59E0B).withOpacity(0.3)
: Colors.transparent,
),
),
child: Row(
children: [
// Checkbox
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 24,
height: 24,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: todo.isCompleted
? const Color(0xFF10B981)
: Colors.transparent,
border: Border.all(
color: todo.isCompleted
? const Color(0xFF10B981)
: Colors.white.withOpacity(0.3),
width: 2,
),
),
child: todo.isCompleted
? const Icon(Icons.check, size: 14, color: Colors.white)
: null,
),
const SizedBox(width: 16),
// Title
Expanded(
child: Text(
todo.title,
style: TextStyle(
color: todo.isCompleted
? Colors.white.withOpacity(0.4)
: Colors.white,
fontSize: 16,
decoration: todo.isCompleted
? TextDecoration.lineThrough
: TextDecoration.none,
),
),
),
// Sync status indicator using SyncEventBuilder
if (todo.isPendingSync && actionId != null)
SyncEventBuilder(
actionId: actionId,
builder: (context, lastEvent) {
return _buildSyncIndicator(lastEvent);
},
)
else if (todo.isPendingSync)
_buildSyncIndicator(null),
],
),
),
),
),
);
}
Widget _buildSyncIndicator(SyncEvent? event) {
IconData icon;
Color color;
if (event == null) {
// Queued, waiting
icon = Icons.cloud_upload_outlined;
color = const Color(0xFFF59E0B);
} else if (event.status == SyncEventStatus.started) {
// Currently syncing
return Container(
padding: const EdgeInsets.all(4),
child: const SizedBox(
width: 18,
height: 18,
child: CircularProgressIndicator(
strokeWidth: 2,
color: Color(0xFF6366F1),
),
),
);
} else if (event.status == SyncEventStatus.failed) {
// Failed, will retry
icon = Icons.refresh_rounded;
color = const Color(0xFFEF4444);
} else {
// Default pending
icon = Icons.cloud_upload_outlined;
color = const Color(0xFFF59E0B);
}
return Container(
padding: const EdgeInsets.all(4),
child: Icon(
icon,
size: 18,
color: color.withOpacity(0.8),
),
);
}
Color _getSyncColor(SyncStatus status) {
switch (status) {
case SyncStatus.synced:
return const Color(0xFF10B981);
case SyncStatus.syncing:
return const Color(0xFF6366F1);
case SyncStatus.pending:
return const Color(0xFFF59E0B);
case SyncStatus.error:
return const Color(0xFFEF4444);
}
}
}