sync_vault 1.0.0 copy "sync_vault: ^1.0.0" to clipboard
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);
    }
  }
}
7
likes
160
points
90
downloads

Publisher

verified publisherbrownboycodes.com

Weekly Downloads

An offline-first data synchronization layer for Flutter apps. Automatically queues API requests when offline and syncs with exponential backoff.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

connectivity_plus, dio, equatable, flutter, hive, path, path_provider, sqflite, uuid

More

Packages that depend on sync_vault