Examples

Todo App

A complete todo app example using Shard with persistence.

Overview

This example demonstrates a complete todo app with:

  • State management with PersistentShard
  • State persistence with SharedPreferences
  • Search functionality with debounce
  • Filtering (all, active, completed)

Models

Todo Model

class Todo {
  final String id;
  final String title;
  final bool completed;
  final DateTime createdAt;

  Todo({
    required this.id,
    required this.title,
    this.completed = false,
    DateTime? createdAt,
  }) : createdAt = createdAt ?? DateTime.now();

  Todo copyWith({
    String? id,
    String? title,
    bool? completed,
    DateTime? createdAt,
  }) {
    return Todo(
      id: id ?? this.id,
      title: title ?? this.title,
      completed: completed ?? this.completed,
      createdAt: createdAt ?? this.createdAt,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'id': id,
      'title': title,
      'completed': completed,
      'createdAt': createdAt.toIso8601String(),
    };
  }

  factory Todo.fromJson(Map<String, dynamic> json) {
    return Todo(
      id: json['id'] as String,
      title: json['title'] as String,
      completed: json['completed'] as bool? ?? false,
      createdAt: json['createdAt'] != null
          ? DateTime.parse(json['createdAt'] as String)
          : null,
    );
  }
}

TodoState Model

class TodoState {
  final List<Todo> todos;
  final String searchQuery;

  const TodoState({
    required this.todos,
    this.searchQuery = '',
  });

  TodoState copyWith({
    List<Todo>? todos,
    String? searchQuery,
  }) {
    return TodoState(
      todos: todos ?? this.todos,
      searchQuery: searchQuery ?? this.searchQuery,
    );
  }

  Map<String, dynamic> toJson() {
    return {
      'todos': todos.map((todo) => todo.toJson()).toList(),
      // searchQuery is not persisted
    };
  }

  factory TodoState.fromJson(Map<String, dynamic> json) {
    final List<dynamic> todosJson = json['todos'] as List<dynamic>;
    return TodoState(
      todos: todosJson
          .map((todoJson) => Todo.fromJson(todoJson as Map<String, dynamic>))
          .toList(),
      searchQuery: '',
    );
  }
}

Shard

PersistentTodoShard

import 'dart:developer';
import 'package:shard/shard.dart';
import '../models/todo.dart';
import '../models/todo_state.dart';
import '../serializers/todo_serializer.dart';
import '../storage/shared_preferences_storage.dart';

class PersistentTodoShard extends PersistentShard<TodoState> {
  @override
  String get persistenceKey => 'todos';

  PersistentTodoShard()
    : super(
        const TodoState(todos: []),
        storageFactory: () => SharedPreferencesStorage.create(),
        serializer: TodoSerializer(),
        autoSave: true,
        autoLoad: true,
        debounceDuration: const Duration(milliseconds: 500),
      );

  // Getters
  String get searchQuery => state.searchQuery;

  List<Todo> get todos => state.todos;

  List<Todo> get activeTodos => state.todos.where((todo) => !todo.completed).toList();

  List<Todo> get completedTodos => state.todos.where((todo) => todo.completed).toList();

  int get totalCount => state.todos.length;

  int get activeCount => activeTodos.length;
  
  int get completedCount => completedTodos.length;

  // Actions
  void addTodo(String title) {
    if (title.trim().isEmpty) return;

    final newTodo = Todo(
      id: DateTime.now().millisecondsSinceEpoch.toString(),
      title: title.trim(),
    );

    emit(state.copyWith(todos: [...state.todos, newTodo]));
  }

  void removeTodo(String id) {
    emit(state.copyWith(
      todos: state.todos.where((todo) => todo.id != id).toList(),
    ));
  }

  void toggleTodo(String id) {
    emit(
      state.copyWith(
        todos: state.todos.map((todo) {
          if (todo.id == id) {
            return todo.copyWith(completed: !todo.completed);
          }
          return todo;
        }).toList(),
      ),
    );
  }

  void clearCompleted() {
    emit(state.copyWith(
      todos: state.todos.where((todo) => !todo.completed).toList(),
    ));
  }

  void updateSearchQuery(String query) {
    debounce('search', () {
      emit(state.copyWith(searchQuery: query));
    }, duration: const Duration(milliseconds: 500));
  }

  @override
  void onLoadError(Object error, StackTrace? stackTrace) {
    log('Error loading todos: $error');
    log('Stack trace: $stackTrace');
  }

  @override
  void onSaveError(Object error, StackTrace? stackTrace) {
    log('Error saving todos: $error');
    log('Stack trace: $stackTrace');
  }
}

Serializer

import 'dart:convert';
import 'package:shard/shard.dart';
import '../models/todo_state.dart';

class TodoSerializer implements StateSerializer<TodoState> {
  @override
  String serialize(TodoState state) {
    return jsonEncode(state.toJson());
  }

  @override
  TodoState deserialize(String data) {
    final json = jsonDecode(data) as Map<String, dynamic>;
    return TodoState.fromJson(json);
  }
}

Storage

import 'package:shared_preferences/shared_preferences.dart';
import 'package:shard/shard.dart';

class SharedPreferencesStorage implements StateStorage {
  final SharedPreferences _prefs;

  SharedPreferencesStorage(this._prefs);

  static Future<SharedPreferencesStorage> create() async {
    final prefs = await SharedPreferences.getInstance();
    return SharedPreferencesStorage(prefs);
  }

  @override
  Future<void> save(String key, String value) async {
    await _prefs.setString(key, value);
  }

  @override
  Future<String?> load(String key) async {
    return _prefs.getString(key);
  }

  @override
  Future<void> clear() async {
    await _prefs.clear();
  }
}

UI

Main App

import 'package:flutter/material.dart';
import 'package:shard/shard.dart';
import 'shard/persistent_todo_shard.dart';
import 'widgets/todo_list_screen.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Shard Todo Example',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: ShardProvider<PersistentTodoShard>(
        create: () => PersistentTodoShard(),
        child: const TodoListScreen(),
      ),
    );
  }
}

Todo List Screen

import 'package:flutter/material.dart';
import 'package:shard/shard.dart';
import '../models/todo.dart';
import '../models/todo_state.dart';
import '../shard/persistent_todo_shard.dart';
import 'todo_item.dart';
import 'todo_input.dart';

class TodoListScreen extends StatefulWidget {
  const TodoListScreen({super.key});

  @override
  State<TodoListScreen> createState() => _TodoListScreenState();
}

class _TodoListScreenState extends State<TodoListScreen> {
  TodoFilter _currentFilter = TodoFilter.all;
  final _searchController = TextEditingController();

  @override
  void dispose() {
    _searchController.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('Shard Todo Example'),
        actions: [
          // Show todo count using ShardSelector
          ShardSelector<PersistentTodoShard, TodoState, int>(
            selector: (state) => state.todos.length,
            builder: (context, count) {
              return Padding(
                padding: const EdgeInsets.all(16.0),
                child: Center(
                  child: Text('$count todos'),
                ),
              );
            },
          ),
        ],
      ),
      body: SafeArea(
        child: Column(
          children: [
            // Search bar
            Padding(
              padding: const EdgeInsets.all(16.0),
              child: TextField(
                controller: _searchController,
                decoration: InputDecoration(
                  hintText: 'Search todos...',
                  prefixIcon: const Icon(Icons.search),
                  border: OutlineInputBorder(
                    borderRadius: BorderRadius.circular(8),
                  ),
                ),
                onChanged: (value) {
                  context.read<PersistentTodoShard>().updateSearchQuery(value);
                },
              ),
            ),
            // Filter chips
            Padding(
              padding: const EdgeInsets.symmetric(horizontal: 16.0),
              child: Row(
                children: [
                  _buildFilterChip(TodoFilter.all, 'All'),
                  const SizedBox(width: 8),
                  _buildFilterChip(TodoFilter.active, 'Active'),
                  const SizedBox(width: 8),
                  _buildFilterChip(TodoFilter.completed, 'Completed'),
                ],
              ),
            ),
            // Todo input
            TodoInput(
              onAdd: (title) {
                context.read<PersistentTodoShard>().addTodo(title);
              },
            ),
            // Todo list
            Expanded(
              child: ShardBuilder<PersistentTodoShard, TodoState>(
                builder: (context, state) {
                  final filteredTodos = _getFilteredTodos(state);

                  if (filteredTodos.isEmpty) {
                    return Center(
                      child: Text('No todos yet'),
                    );
                  }

                  return ListView.builder(
                    itemCount: filteredTodos.length,
                    itemBuilder: (context, index) {
                      final todo = filteredTodos[index];
                      return TodoItem(
                        todo: todo,
                        onToggle: () => context.read<PersistentTodoShard>().toggleTodo(todo.id),
                        onDelete: () => context.read<PersistentTodoShard>().removeTodo(todo.id),
                      );
                    },
                  );
                },
              ),
            ),
            // Stats and actions
            Container(
              padding: const EdgeInsets.all(16),
              child: Row(
                mainAxisAlignment: MainAxisAlignment.spaceAround,
                children: [
                  ShardSelector<PersistentTodoShard, TodoState, int>(
                    selector: (state) => 
                      state.todos.where((t) => !t.completed).length,
                    builder: (context, shard, activeCount) {
                      return Text('Active: $activeCount');
                    },
                  ),
                  ElevatedButton(
                    onPressed: () {
                      context.read<PersistentTodoShard>().clearCompleted();
                    },
                    child: Text('Clear Completed'),
                  ),
                ],
              ),
            ),
          ],
        ),
      ),
    );
  }

  Widget _buildFilterChip(TodoFilter filter, String label) {
    final isSelected = _currentFilter == filter;
    return FilterChip(
      label: Text(label),
      selected: isSelected,
      onSelected: (selected) {
        if (selected) {
          setState(() {
            _currentFilter = filter;
          });
        }
      },
    );
  }

  List<Todo> _getFilteredTodos(TodoState state) {
    var filtered = state.todos;

    // Apply search filter
    if (state.searchQuery.isNotEmpty) {
      filtered = filtered
          .where((todo) => todo.title
              .toLowerCase()
              .contains(state.searchQuery.toLowerCase()))
          .toList();
    }

    // Apply status filter
    switch (_currentFilter) {
      case TodoFilter.all:
        return filtered;
      case TodoFilter.active:
        return filtered.where((todo) => !todo.completed).toList();
      case TodoFilter.completed:
        return filtered.where((todo) => todo.completed).toList();
    }
  }
}

enum TodoFilter { all, active, completed }

Key Features Demonstrated

  1. Persistent State: Todos are automatically saved and restored
  2. Debounced Search: Search query updates are debounced
  3. Selective Rebuilding: Using ShardSelector for performance
  4. Error Handling: Custom error handlers for persistence

Next Steps

Check out the Infinite Scroll Example to see throttle in action.