juice_sync 0.1.2 copy "juice_sync: ^0.1.2" to clipboard
juice_sync: ^0.1.2 copied to clipboard

Offline outbox / mutation queue with durable persistence, ordering, retries and dead-lettering, as a Juice bloc.

example/lib/main.dart

import 'package:juice/juice.dart';
import 'package:juice_sync/juice_sync.dart';

import 'demo_sync.dart';

final _online = OnlineToggle();

void main() {
  WidgetsFlutterBinding.ensureInitialized();

  // Demo seams: in-memory store (NOT durable — real apps use StorageSyncStore),
  // a fake executor, and a manual online toggle.
  BlocScope.register<SyncBloc>(
    () => SyncBloc.withConfig(
      SyncConfig(
        store: InMemorySyncStore(),
        executor: DemoExecutor().call,
        onlineSignal: _online.stream,
        maxAttempts: 4,
        initialBackoff: const Duration(seconds: 1),
      ),
    ),
    lifecycle: BlocLifecycle.permanent,
  );

  runApp(const DemoApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'juice_sync demo',
      debugShowCheckedModeBanner: false,
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey),
        useMaterial3: true,
      ),
      home: const HomeScreen(),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('juice_sync — outbox'),
        bottom: PreferredSize(
          preferredSize: const Size.fromHeight(32),
          child: StatusBar(),
        ),
      ),
      body: QueueList(),
      floatingActionButton: EnqueueButtons(),
    );
  }
}

/// Status + online toggle — rebuilds only on `sync:status`.
class StatusBar extends StatelessJuiceWidget<SyncBloc> {
  StatusBar({super.key}) : super(groups: {SyncGroups.status});

  @override
  Widget onBuild(BuildContext context, StreamStatus status) {
    final s = bloc.state;
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 12).copyWith(bottom: 6),
      child: Row(
        children: [
          Icon(s.online ? Icons.cloud_done : Icons.cloud_off,
              size: 18, color: s.online ? Colors.green : Colors.red),
          const SizedBox(width: 8),
          Text('${s.pendingCount} pending · ${s.failedCount} failed · '
              '${s.processedCount} sent'),
          const Spacer(),
          Switch(
            value: s.online,
            onChanged: (v) => _online.set(v),
          ),
        ],
      ),
    );
  }
}

/// The queue — rebuilds when membership changes (`sync:queue`/`sync:failed`).
class QueueList extends StatelessJuiceWidget<SyncBloc> {
  QueueList({super.key})
      : super(groups: {SyncGroups.queue, SyncGroups.failed});

  @override
  Widget onBuild(BuildContext context, StreamStatus status) {
    final items = [...bloc.state.pending, ...bloc.state.failed];
    if (items.isEmpty) {
      return const Center(child: Text('Queue is empty — enqueue something →'));
    }
    return ListView(
      padding: const EdgeInsets.all(12),
      children: [for (final m in items) MutationTile(id: m.id)],
    );
  }
}

/// One mutation — rebuilds only on its own `sync:mutation:<id>`.
class MutationTile extends StatelessJuiceWidget<SyncBloc> {
  MutationTile({required this.id})
      : super(key: ValueKey(id), groups: {SyncGroups.mutation(id)});

  final String id;

  @override
  Widget onBuild(BuildContext context, StreamStatus status) {
    final m = [...bloc.state.pending, ...bloc.state.failed]
        .where((x) => x.id == id)
        .firstOrNull;
    if (m == null) return const SizedBox.shrink();

    final (icon, color) = switch (m.status) {
      MutationStatus.pending => (Icons.schedule, Colors.orange),
      MutationStatus.inFlight => (Icons.sync, Colors.blue),
      MutationStatus.failed => (Icons.error, Colors.red),
    };
    return Card(
      child: ListTile(
        leading: Icon(icon, color: color),
        title: Text(m.type),
        subtitle: Text(m.status == MutationStatus.failed
            ? 'failed: ${m.lastError}'
            : 'attempts: ${m.attempts}'),
        trailing: m.status == MutationStatus.failed
            ? IconButton(
                icon: const Icon(Icons.refresh),
                onPressed: () => bloc.retryFailed(id))
            : IconButton(
                icon: const Icon(Icons.delete_outline),
                onPressed: () => bloc.discard(id)),
      ),
    );
  }
}

class EnqueueButtons extends StatelessJuiceWidget<SyncBloc> {
  EnqueueButtons({super.key}) : super(groups: const {});

  @override
  Widget onBuild(BuildContext context, StreamStatus status) {
    return Row(
      mainAxisSize: MainAxisSize.min,
      children: [
        FloatingActionButton.extended(
          heroTag: 'ok',
          onPressed: () => bloc.enqueue('ok', {'at': 'now'}),
          icon: const Icon(Icons.add),
          label: const Text('ok'),
        ),
        const SizedBox(width: 8),
        FloatingActionButton.small(
          heroTag: 'flaky',
          onPressed: () => bloc.enqueue('flaky', {}),
          child: const Text('flk'),
        ),
        const SizedBox(width: 8),
        FloatingActionButton.small(
          heroTag: 'bad',
          backgroundColor: Colors.red.shade200,
          onPressed: () => bloc.enqueue('bad', {}),
          child: const Text('bad'),
        ),
      ],
    );
  }
}
0
likes
160
points
18
downloads

Documentation

Documentation
API reference

Publisher

unverified uploader

Weekly Downloads

Offline outbox / mutation queue with durable persistence, ordering, retries and dead-lettering, as a Juice bloc.

Homepage
Repository (GitHub)
View/report issues
Contributing

Topics

#offline #outbox #sync #bloc #state-management

Funding

Consider supporting this project:

github.com

License

MIT (license)

Dependencies

flutter, juice, juice_storage

More

Packages that depend on juice_sync