bloc_devtools_extension 0.2.0 copy "bloc_devtools_extension: ^0.2.0" to clipboard
bloc_devtools_extension: ^0.2.0 copied to clipboard

Time-travel dev tools for flutter_bloc, inspired by Redux DevTools. Provides an in-app debugging panel and a Flutter DevTools browser extension with state history, BLoC connection graph, performance m [...]

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:bloc_devtools_extension/bloc_devtools_extension.dart';

import 'counter_bloc.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  Bloc.observer = BlocDevToolsObserver(DevToolsStore.instance);
  registerBlocDevToolsServiceExtension(DevToolsStore.instance);
  runApp(const DemoApp());
}

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

  @override
  Widget build(BuildContext context) {
    return DevToolsStoreProvider(
      store: DevToolsStore.instance,
      child: MultiBlocProvider(
        providers: [
          BlocProvider(create: (_) => CounterBloc()),
          BlocProvider(create: (_) => ThemeCubit()),
          BlocProvider(create: (_) => HistoryBloc()),
          BlocProvider(create: (_) => SettingsCubit()),
          BlocProvider(create: (_) => ProjectsCubit()),
        ],
        child: BlocBuilder<ThemeCubit, ThemeState>(
          builder: (context, themeState) {
            final seedColors = {
              'purple': Colors.deepPurple,
              'blue': Colors.blue,
              'green': Colors.green,
              'red': Colors.red,
              'orange': Colors.orange,
            };
            return MaterialApp(
              title: 'BLoC DevTools Demo',
              debugShowCheckedModeBanner: false,
              theme: ThemeData(
                colorSchemeSeed:
                seedColors[themeState.seedColor] ?? Colors.deepPurple,
                brightness:
                themeState.isDark ? Brightness.dark : Brightness.light,
                useMaterial3: true,
              ),
              home: const HomePage(),
            );
          },
        ),
      ),
    );
  }
}

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

  @override
  State<HomePage> createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  int _tabIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('DevTools Demo'),
        actions: [
          IconButton(
            icon: BlocBuilder<ThemeCubit, ThemeState>(
              builder: (_, s) =>
                  Icon(s.isDark ? Icons.light_mode : Icons.dark_mode),
            ),
            tooltip: 'Toggle theme',
            onPressed: () => context.read<ThemeCubit>().toggleTheme(),
          ),
          Builder(
            builder: (ctx) => IconButton(
              icon: const Icon(Icons.bug_report),
              tooltip: 'Open DevTools',
              onPressed: () => Scaffold.of(ctx).openEndDrawer(),
            ),
          ),
        ],
      ),
      endDrawer: Drawer(
        width: 360,
        child: SafeArea(
          child: BlocDevToolsPanel(store: DevToolsStore.instance),
        ),
      ),
      body: IndexedStack(
        index: _tabIndex,
        children: const [
          CounterPage(),
          ProjectsPage(),
          SettingsPage(),
          AboutPage(),
        ],
      ),
      bottomNavigationBar: NavigationBar(
        selectedIndex: _tabIndex,
        onDestinationSelected: (i) => setState(() => _tabIndex = i),
        destinations: const [
          NavigationDestination(
              icon: Icon(Icons.add_circle_outline), label: 'Counter'),
          NavigationDestination(
              icon: Icon(Icons.folder_outlined), label: 'Projects'),
          NavigationDestination(
              icon: Icon(Icons.settings_outlined), label: 'Settings'),
          NavigationDestination(
              icon: Icon(Icons.info_outline), label: 'About'),
        ],
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;

    return BlocListener<CounterBloc, CounterState>(
      // When counter changes, record a milestone in HistoryBloc.
      // This fires within ~ms of the counter transition, creating
      // a temporal correlation → visible edge in the Graph tab.
      listener: (context, state) {
        if (state.count != 0 && state.count % 5 == 0) {
          context.read<HistoryBloc>().add(RecordMilestone(state.count));
        }
      },
      child: Center(
        child: BlocBuilder<CounterBloc, CounterState>(
          builder: (context, state) {
            return Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: [
                Text('Counter', style: Theme.of(context).textTheme.titleLarge),
                const SizedBox(height: 8),
                Text(
                  '${state.count}',
                  style: Theme.of(context).textTheme.displayLarge?.copyWith(
                      fontWeight: FontWeight.w700, color: cs.primary),
                ),
                const SizedBox(height: 8),
                BlocBuilder<HistoryBloc, HistoryState>(
                  builder: (context, histState) {
                    return Text(
                      '${histState.milestones.length} milestones recorded (every 5)',
                      style: TextStyle(
                          fontSize: 12, color: cs.onSurfaceVariant),
                    );
                  },
                ),
                const SizedBox(height: 32),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    FloatingActionButton.small(
                      heroTag: 'dec',
                      onPressed: () =>
                          context.read<CounterBloc>().add(Decrement()),
                      child: const Icon(Icons.remove),
                    ),
                    const SizedBox(width: 12),
                    FloatingActionButton(
                      heroTag: 'inc',
                      onPressed: () =>
                          context.read<CounterBloc>().add(Increment()),
                      child: const Icon(Icons.add),
                    ),
                    const SizedBox(width: 12),
                    FloatingActionButton.small(
                      heroTag: 'inc5',
                      onPressed: () =>
                          context.read<CounterBloc>().add(IncrementBy(5)),
                      tooltip: '+5 (triggers milestone)',
                      child: const Text('+5',
                          style: TextStyle(fontWeight: FontWeight.w700)),
                    ),
                  ],
                ),
                const SizedBox(height: 16),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    OutlinedButton.icon(
                      onPressed: () =>
                          context.read<CounterBloc>().add(Reset()),
                      icon: const Icon(Icons.refresh, size: 16),
                      label: const Text('Reset counter'),
                    ),
                    const SizedBox(width: 8),
                    OutlinedButton.icon(
                      onPressed: () =>
                          context.read<HistoryBloc>().add(ClearHistory()),
                      icon: const Icon(Icons.delete_outline, size: 16),
                      label: const Text('Clear history'),
                    ),
                  ],
                ),
              ],
            );
          },
        ),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return BlocBuilder<SettingsCubit, SettingsState>(
      builder: (context, settings) {
        return ListView(
          padding: const EdgeInsets.all(16),
          children: [
            Text('Settings',
                style: Theme.of(context).textTheme.titleLarge),
            const SizedBox(height: 4),
            Text('Change settings and watch the Diff view in DevTools',
                style: TextStyle(
                    fontSize: 12,
                    color: Theme.of(context).colorScheme.onSurfaceVariant)),
            const SizedBox(height: 24),

            ListTile(
              leading: const Icon(Icons.text_fields),
              title: const Text('Font size'),
              subtitle: Slider(
                value: settings.fontSize,
                min: 10,
                max: 24,
                divisions: 14,
                label: '${settings.fontSize.round()}px',
                onChanged: (v) =>
                    context.read<SettingsCubit>().setFontSize(v),
              ),
              trailing: Text('${settings.fontSize.round()}px'),
            ),

            ListTile(
              leading: const Icon(Icons.language),
              title: const Text('Language'),
              trailing: DropdownButton<String>(
                value: settings.language,
                items: const [
                  DropdownMenuItem(value: 'en', child: Text('English')),
                  DropdownMenuItem(value: 'pl', child: Text('Polish')),
                  DropdownMenuItem(value: 'de', child: Text('German')),
                  DropdownMenuItem(value: 'es', child: Text('Spanish')),
                ],
                onChanged: (v) {
                  if (v != null) context.read<SettingsCubit>().setLanguage(v);
                },
              ),
            ),

            SwitchListTile(
              secondary: const Icon(Icons.notifications_outlined),
              title: const Text('Notifications'),
              subtitle: Text(settings.notificationsEnabled
                  ? 'Enabled'
                  : 'Disabled'),
              value: settings.notificationsEnabled,
              onChanged: (_) =>
                  context.read<SettingsCubit>().toggleNotifications(),
            ),

            SwitchListTile(
              secondary: const Icon(Icons.save_outlined),
              title: const Text('Auto-save'),
              subtitle:
              Text(settings.autoSave ? 'Enabled' : 'Disabled'),
              value: settings.autoSave,
              onChanged: (_) =>
                  context.read<SettingsCubit>().toggleAutoSave(),
            ),

            const Divider(height: 32),

            Text('Theme color',
                style: Theme.of(context).textTheme.titleMedium),
            const SizedBox(height: 12),
            BlocBuilder<ThemeCubit, ThemeState>(
              builder: (context, theme) {
                final colors = {
                  'purple': Colors.deepPurple,
                  'blue': Colors.blue,
                  'green': Colors.green,
                  'red': Colors.red,
                  'orange': Colors.orange,
                };
                return Wrap(
                  spacing: 8,
                  children: colors.entries.map((e) {
                    final selected = theme.seedColor == e.key;
                    return GestureDetector(
                      onTap: () =>
                          context.read<ThemeCubit>().setSeedColor(e.key),
                      child: Container(
                        width: 40,
                        height: 40,
                        decoration: BoxDecoration(
                          color: e.value,
                          shape: BoxShape.circle,
                          border: selected
                              ? Border.all(
                              color: Theme.of(context)
                                  .colorScheme
                                  .onSurface,
                              width: 3)
                              : null,
                        ),
                        child: selected
                            ? const Icon(Icons.check,
                            color: Colors.white, size: 20)
                            : null,
                      ),
                    );
                  }).toList(),
                );
              },
            ),
          ],
        );
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;

    return BlocBuilder<ProjectsCubit, ProjectsState>(
      builder: (context, state) {
        return ListView(
          padding: const EdgeInsets.all(16),
          children: [
            Text('Projects',
                style: Theme.of(context).textTheme.titleLarge),
            const SizedBox(height: 4),
            Text(
              'Large nested state — open DevTools → History → JSON to test the tree view',
              style: TextStyle(
                  fontSize: 12, color: cs.onSurfaceVariant),
            ),
            const SizedBox(height: 16),
            Text(
              '${state.projects.length} projects · ${state.teamMembers.length} team members',
              style: Theme.of(context).textTheme.titleSmall,
            ),
            const SizedBox(height: 16),
            for (int i = 0; i < state.projects.length; i++) ...[
              Card(
                child: ListTile(
                  leading: IconButton(
                    icon: Icon(
                      state.projects[i]['isStarred'] == true
                          ? Icons.star
                          : Icons.star_border,
                      color: state.projects[i]['isStarred'] == true
                          ? Colors.amber
                          : null,
                    ),
                    onPressed: () =>
                        context.read<ProjectsCubit>().toggleStarred(i),
                  ),
                  title: Text(state.projects[i]['name'] as String),
                  subtitle: Text(
                    '${state.projects[i]['status']} · '
                        '${(state.projects[i]['tasks'] as List?)?.length ?? 0} tasks',
                  ),
                  trailing: Chip(
                    label: Text(
                      (state.projects[i]['tags'] as List?)
                          ?.first
                          ?.toString() ??
                          '',
                      style: const TextStyle(fontSize: 10),
                    ),
                  ),
                ),
              ),
            ],
            const Divider(height: 32),
            Row(
              children: [
                Expanded(
                  child: FilledButton.icon(
                    onPressed: () =>
                        context.read<ProjectsCubit>().refresh(),
                    icon: const Icon(Icons.refresh, size: 16),
                    label: const Text('Refresh (new state)'),
                  ),
                ),
              ],
            ),
            const SizedBox(height: 8),
            Wrap(
              spacing: 8,
              children: [
                ActionChip(
                  label: const Text('Filter: in_progress'),
                  onPressed: () => context
                      .read<ProjectsCubit>()
                      .setFilter('status', 'in_progress'),
                ),
                ActionChip(
                  label: const Text('Filter: all'),
                  onPressed: () => context
                      .read<ProjectsCubit>()
                      .setFilter('status', 'all'),
                ),
                ActionChip(
                  label: const Text('Sort: name'),
                  onPressed: () => context
                      .read<ProjectsCubit>()
                      .setFilter('sortBy', 'name'),
                ),
              ],
            ),
          ],
        );
      },
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    final cs = Theme.of(context).colorScheme;
    return ListView(
      padding: const EdgeInsets.all(16),
      children: [
        Text('About this demo',
            style: Theme.of(context).textTheme.titleLarge),
        const SizedBox(height: 16),
        _card(
          context,
          icon: Icons.add_circle,
          color: cs.primary,
          title: 'CounterBloc',
          subtitle: 'Event-driven Bloc with timing. '
              'Demonstrates performance metrics and the slowest transitions list.',
        ),
        _card(
          context,
          icon: Icons.history,
          color: cs.tertiary,
          title: 'HistoryBloc',
          subtitle: 'Records milestones every 5 counter steps. '
              'Connected to CounterBloc — creates an edge in the Graph tab.',
        ),
        _card(
          context,
          icon: Icons.palette,
          color: Colors.orange,
          title: 'ThemeCubit',
          subtitle: 'Simple Cubit for dark/light mode and seed color. '
              'Shows Cubit vs Bloc distinction in the graph.',
        ),
        _card(
          context,
          icon: Icons.settings,
          color: Colors.teal,
          title: 'SettingsCubit',
          subtitle: 'Multi-field state (font size, language, toggles). '
              'Best for testing the Diff view — change one field at a time.',
        ),
        _card(
          context,
          icon: Icons.folder,
          color: Colors.indigo,
          title: 'ProjectsCubit',
          subtitle: 'Large deeply-nested state with projects, tasks, subtasks, '
              'team members, budgets, and activity logs. '
              'Best for testing the collapsible JSON tree view — try Expand All.',
        ),
        const SizedBox(height: 16),
        Text('Try this:',
            style: Theme.of(context).textTheme.titleMedium),
        const SizedBox(height: 8),
        _step('1. Tap +5 on the counter 3 times to trigger milestones'),
        _step('2. Open DevTools → Graph tab to see the CounterBloc→HistoryBloc edge'),
        _step('3. Go to Settings, change language and toggle notifications'),
        _step('4. Open DevTools → History tab, select the last entry, tap Diff'),
        _step('5. Go to Projects, tap a star or Refresh, then inspect the JSON tree'),
        _step('6. In the JSON tree, use Expand All / Collapse All to navigate deep state'),
        _step('7. Check the Perf tab to see processing times per BLoC'),
        _step('8. Tap Replay on any entry to push that state onto the live BLoC'),
      ],
    );
  }

  Widget _card(BuildContext context,
      {required IconData icon,
        required Color color,
        required String title,
        required String subtitle}) {
    return Card(
      margin: const EdgeInsets.only(bottom: 8),
      child: ListTile(
        leading: CircleAvatar(
          backgroundColor: color.withValues(alpha: 0.15),
          child: Icon(icon, color: color, size: 20),
        ),
        title: Text(title,
            style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
        subtitle: Text(subtitle, style: const TextStyle(fontSize: 12)),
      ),
    );
  }

  Widget _step(String text) {
    return Padding(
      padding: const EdgeInsets.only(bottom: 4),
      child: Row(
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          const Text('→ ', style: TextStyle(fontWeight: FontWeight.w600)),
          Expanded(
              child: Text(text, style: const TextStyle(fontSize: 13))),
        ],
      ),
    );
  }
}
2
likes
150
points
103
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Time-travel dev tools for flutter_bloc, inspired by Redux DevTools. Provides an in-app debugging panel and a Flutter DevTools browser extension with state history, BLoC connection graph, performance metrics, state diff, event timeline, and state replay.

Repository (GitHub)
View/report issues

Topics

#bloc #devtools #debugging #state-management #developer-tools

License

BSD-3-Clause (license)

Dependencies

bloc, flutter, flutter_bloc

More

Packages that depend on bloc_devtools_extension