signals_watch 1.0.0 copy "signals_watch: ^1.0.0" to clipboard
signals_watch: ^1.0.0 copied to clipboard

A production-ready reactive widget for signals_flutter with lifecycle callbacks, debouncing, throttling, error handling, async helpers, and registry management.

example/lib/main.dart

import 'package:flutter/material.dart';
import 'package:signals_watch/signals_watch.dart';

void main() {
  // Enable selective signal tracking - only labeled signals will be logged
  SignalsWatch.initializeSignalsObserver();

  runApp(const MyApp());
}

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

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'SignalsWatch Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
        useMaterial3: true,
      ),
      home: const ExamplesPage(),
    );
  }
}

// Global signals with debug labels for tracking
final counter = SignalsWatch.signal(0, debugLabel: 'counter');
final searchQuery = SignalsWatch.signal('', debugLabel: 'search.query');
final user =
    SignalsWatch.signal(User(name: 'John', age: 25), debugLabel: 'user');

class User {
  final String name;
  final int age;

  User({required this.name, required this.age});

  @override
  String toString() => 'User(name: $name, age: $age)';
}

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

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('WatchValue Signal Examples'),
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: [
          _buildSection(
            'Basic Counter',
            'Simple signal watching with callback',
            const BasicCounterExample(),
          ),
          _buildSection(
            'Debounced Search',
            'Search with 500ms debounce',
            const DebouncedSearchExample(),
          ),
          _buildSection(
            'Conditional Updates',
            'Only notifies when counter > 10',
            const ConditionalExample(),
          ),
          _buildSection(
            'Selector Pattern',
            'Only rebuilds when age changes',
            const SelectorExample(),
          ),
          _buildSection(
            'Multiple Signals',
            'Combines name and age',
            const MultipleSignalsExample(),
          ),
          _buildSection(
            'Widget Override Precedence',
            'Widget onValueUpdated overrides signal-level onValueUpdated',
            const WidgetOverrideExample(),
          ),
          _buildSection(
            'Custom Equals',
            'Ignore specific fields using equals (ignore name changes)',
            const CustomEqualsExample(),
          ),
          _buildSection(
            'Transform with .transform() (v0.4.0)',
            'Transform signal values with error handling',
            const TransformExample(),
          ),
          _buildSection(
            'Computed Signal',
            'Doubled value derived from counter with callbacks',
            const ComputedExample(),
          ),
          _buildSection(
            'Lifecycle Callbacks',
            'onInit, onAfterBuild, onDispose at signal level',
            const LifecycleCallbacksExample(),
          ),
          _buildSection(
            'Reset API',
            'Restore initial value and trigger callbacks',
            const ResetExample(),
          ),
          _buildSection(
            'ShouldRebuild vs ShouldNotify',
            'Rebuild only on even numbers; still notify on every change',
            const ShouldRebuildExample(),
          ),
          _buildSection(
            'Async: fromFuture',
            'Loading and error builders with lifecycle callbacks',
            const FromFutureExample(),
          ),
          _buildSection(
            'Async: fromStream',
            'Stream updates with lifecycle callbacks',
            const FromStreamExample(),
          ),
          _buildSection(
            'Debug Trace & Observer',
            'Debug labels + initializeSignalsObserver()',
            const DebugTraceExample(),
          ),
        ],
      ),
    );
  }

  Widget _buildSection(String title, String description, Widget example) {
    return Card(
      margin: const EdgeInsets.only(bottom: 16),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              title,
              style: const TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
            ),
            const SizedBox(height: 4),
            Text(
              description,
              style: TextStyle(fontSize: 14, color: Colors.grey[600]),
            ),
            const SizedBox(height: 16),
            example,
          ],
        ),
      ),
    );
  }
}

// Example 1: Basic Counter
class BasicCounterExample extends StatelessWidget {
  const BasicCounterExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Using fluent .observe() syntax instead of SignalsWatch.fromSignal
        counter.observe(
          (value) =>
              Text('Count: $value', style: const TextStyle(fontSize: 24)),
          onValueUpdated: (value, previous) {
            debugPrint('Counter changed: $previous -> $value');
          },
        ),
        const SizedBox(height: 8),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => counter.value++,
              child: const Text('Increment'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () => counter.value--,
              child: const Text('Decrement'),
            ),
          ],
        ),
      ],
    );
  }
}

// Example 2: Debounced Search
class DebouncedSearchExample extends StatelessWidget {
  const DebouncedSearchExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        TextField(
          decoration: const InputDecoration(
            labelText: 'Search',
            hintText: 'Type to search...',
            border: OutlineInputBorder(),
          ),
          onChanged: (text) => searchQuery.value = text,
        ),
        const SizedBox(height: 8),
        SignalsWatch.fromSignal(
          searchQuery,
          debounce: const Duration(milliseconds: 500),
          onValueUpdated: (query) {
            debugPrint('Searching for: $query');
          },
          builder: (query) => Text(
            query.isEmpty ? 'Start typing...' : 'Searching: "$query"',
            style: const TextStyle(fontSize: 16),
          ),
        ),
      ],
    );
  }
}

// Example 3: Conditional Updates
class ConditionalExample extends StatelessWidget {
  const ConditionalExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SignalsWatch.fromSignal(
          counter,
          shouldNotify: (value, _) => value > 10,
          onValueUpdated: (value) {
            ScaffoldMessenger.of(context).showSnackBar(
              SnackBar(content: Text('Threshold exceeded: $value')),
            );
          },
          builder: (value) => Column(
            children: [
              Text('Count: $value', style: const TextStyle(fontSize: 24)),
              Text(
                value > 10 ? '✓ Above threshold' : 'Below threshold (10)',
                style: TextStyle(
                  color: value > 10 ? Colors.green : Colors.grey,
                ),
              ),
            ],
          ),
        ),
      ],
    );
  }
}

// Example 4: Selector Pattern
class SelectorExample extends StatelessWidget {
  const SelectorExample({super.key});

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        // Using fluent .selectObserve() syntax
        user.selectObserve(
          (u) => (u as User).age,
          (age) => Text('Age: $age', style: const TextStyle(fontSize: 20)),
          onValueUpdated: (age, previousAge) {
            debugPrint('Age changed: $previousAge -> $age');
          },
        ),
        const SizedBox(height: 8),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => user.value = User(
                name: user.value.name,
                age: user.value.age + 1,
              ),
              child: const Text('Birthday'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () {
                // This won't trigger rebuild (name change ignored)
                user.value = User(
                  name: '${user.value.name}!',
                  age: user.value.age,
                );
              },
              child: const Text('Change Name'),
            ),
          ],
        ),
      ],
    );
  }
}

// Example 5: Multiple Signals
class MultipleSignalsExample extends StatelessWidget {
  const MultipleSignalsExample({super.key});

  @override
  Widget build(BuildContext context) {
    final firstName = SignalsWatch.signal('John');
    final lastName = SignalsWatch.signal('Doe');

    return Column(
      children: [
        // Using fluent list .observe() syntax
        [firstName, lastName].observe(
          combine: (values) => '${values[0]} ${values[1]}',
          builder: (fullName) =>
              Text(fullName, style: const TextStyle(fontSize: 20)),
          onValueUpdated: (fullName) {
            debugPrint('Full name: $fullName');
          },
        ),
        const SizedBox(height: 8),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => firstName.value = 'Jane',
              child: const Text('Change First'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () => lastName.value = 'Smith',
              child: const Text('Change Last'),
            ),
          ],
        ),
      ],
    );
  }
}

// Example 6a: Widget Override Precedence
class WidgetOverrideExample extends StatefulWidget {
  const WidgetOverrideExample({super.key});

  @override
  State<WidgetOverrideExample> createState() => _WidgetOverrideExampleState();
}

class _WidgetOverrideExampleState extends State<WidgetOverrideExample> {
  final sig = SignalsWatch.signal(
    0,
    debugLabel: 'override.sig',
    onValueUpdated: (v, p) => debugPrint(
      '[signal-level] $p -> $v (suppressed if any widget overrides)',
    ),
  );

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text('Two widgets listening to the same signal:'),
        const SizedBox(height: 6),
        // Widget A: overrides onValueUpdated (suppresses signal-level callback globally)
        SignalsWatch.fromSignal(
          sig,
          onValueUpdated: (v, p) => debugPrint('[widget-level A] $p -> $v'),
          builder: (v) => Text('Widget A value: $v (overrides callback)'),
        ),
        const SizedBox(height: 4),
        // Widget B: no override; since widget A overrides, signal-level callback is suppressed
        SignalsWatch.fromSignal(
          sig,
          builder: (v) => Text('Widget B value: $v (no override)'),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            ElevatedButton(
              onPressed: () => sig.value++,
              child: const Text('Increment'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () => sig.value = 0,
              child: const Text('Reset'),
            ),
          ],
        ),
        const SizedBox(height: 4),
        const Text(
          'Check logs: only [widget-level A] should appear; signal-level suppressed.',
        ),
      ],
    );
  }
}

// Example 6b: Custom Equals (ignore name changes)
class CustomEqualsExample extends StatefulWidget {
  const CustomEqualsExample({super.key});

  @override
  State<CustomEqualsExample> createState() => _CustomEqualsExampleState();
}

class _CustomEqualsExampleState extends State<CustomEqualsExample> {
  final u = SignalsWatch.signal(User(name: 'Alice', age: 30));

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        SignalsWatch.fromSignal(
          u,
          equals: (User a, User b) => a.age == b.age, // ignore name changes
          onValueUpdated: (User v, User? p) => debugPrint('[equals] $p -> $v'),
          builder: (user) => Text(
            'User: ${user.name}, ${user.age} (rebuilds only when age changes)',
          ),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            ElevatedButton(
              onPressed: () => setState(() {
                u.value = User(name: '${u.value.name}*', age: u.value.age);
              }),
              child: const Text('Change Name'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () => setState(() {
                u.value = User(name: u.value.name, age: u.value.age + 1);
              }),
              child: const Text('Increase Age'),
            ),
          ],
        ),
      ],
    );
  }
}

// Example 5.5: Transform with .transform() (v0.4.0)
class TransformExample extends StatefulWidget {
  const TransformExample({super.key});

  @override
  State<TransformExample> createState() => _TransformExampleState();
}

class _TransformExampleState extends State<TransformExample> {
  final temperatureC = SignalsWatch.signal<double>(25.0);

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        const Text('Temperature Converter with Validation'),
        const SizedBox(height: 8),
        Row(
          children: [
            const Text('Celsius:'),
            const SizedBox(width: 8),
            temperatureC.observe(
              (temp) => Text(
                '${temp.toStringAsFixed(1)}°C',
                style:
                    const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
              ),
            ),
          ],
        ),
        const SizedBox(height: 8),
        // Using .transform() to transform Celsius to Fahrenheit with validation
        temperatureC.transform<String>(
          (celsius) {
            // Validate temperature range
            if (celsius < -273.15) {
              throw ArgumentError('Temperature below absolute zero!');
            }
            if (celsius > 1000) {
              throw ArgumentError('Temperature too high!');
            }
            // Convert to Fahrenheit
            final fahrenheit = (celsius * 9 / 5) + 32;
            return '${fahrenheit.toStringAsFixed(1)}°F';
          },
          builder: (fahrenheitStr) => Container(
            padding: const EdgeInsets.all(8),
            decoration: BoxDecoration(
              color: Colors.green.shade50,
              borderRadius: BorderRadius.circular(4),
              border: Border.all(color: Colors.green),
            ),
            child: Text(
              'Fahrenheit: $fahrenheitStr',
              style: const TextStyle(fontSize: 16, color: Colors.green),
            ),
          ),
          onError: (error, stack) {
            debugPrint('Temperature error: $error');
          },
          errorBuilder: (error) => Container(
            padding: const EdgeInsets.all(8),
            decoration: BoxDecoration(
              color: Colors.red.shade50,
              borderRadius: BorderRadius.circular(4),
              border: Border.all(color: Colors.red),
            ),
            child: Text(
              'Error: ${error.toString().replaceAll('Invalid argument(s): ', '')}',
              style: const TextStyle(color: Colors.red),
            ),
          ),
        ),
        const SizedBox(height: 12),
        Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            ElevatedButton(
              onPressed: () => temperatureC.value += 10,
              child: const Text('+10°C'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () => temperatureC.value -= 10,
              child: const Text('-10°C'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () => temperatureC.value = -300, // Triggers error
              child: const Text('Test Error'),
            ),
          ],
        ),
        const SizedBox(height: 4),
        const Text(
          'Try the error button to see errorBuilder in action!',
          style: TextStyle(fontSize: 12, fontStyle: FontStyle.italic),
        ),
      ],
    );
  }
}

// Example 6: Computed Signal
class ComputedExample extends StatelessWidget {
  const ComputedExample({super.key});

  @override
  Widget build(BuildContext context) {
    // Computed derived from the global counter
    final doubled = SignalsWatch.computed(
      () => counter.value * 2,
      onValueUpdated: (value, previous) {
        debugPrint('Doubled changed: $previous -> $value');
      },
    );

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          children: [
            const Text('Counter:'),
            const SizedBox(width: 8),
            counter.observe((v) => Text('$v')),
            const SizedBox(width: 24),
            const Text('Doubled:'),
            const SizedBox(width: 8),
            doubled.observe((v) => Text('$v')),
          ],
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            ElevatedButton(
              onPressed: () => counter.value++,
              child: const Text('Increment Counter'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () => counter.value = 0,
              child: const Text('Reset Counter'),
            ),
          ],
        ),
      ],
    );
  }
}

// Example 7: Lifecycle Callbacks (signal-level)
class LifecycleCallbacksExample extends StatefulWidget {
  const LifecycleCallbacksExample({super.key});

  @override
  State<LifecycleCallbacksExample> createState() =>
      _LifecycleCallbacksExampleState();
}

class _LifecycleCallbacksExampleState extends State<LifecycleCallbacksExample> {
  bool _mounted = true;

  // Create a signal with lifecycle callbacks at signal level
  final lifecycleSignal = SignalsWatch.signal(
    0,
    debugLabel: 'lifecycle',
    onInit: (value) => debugPrint('[lifecycle] onInit: $value'),
    onAfterBuild: (value) => debugPrint('[lifecycle] onAfterBuild: $value'),
    onDispose: (value) => debugPrint('[lifecycle] onDispose: $value'),
  );

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          children: [
            ElevatedButton(
              onPressed: () => setState(() => _mounted = !_mounted),
              child: Text(_mounted ? 'Unmount widget' : 'Mount widget'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () => lifecycleSignal.value++,
              child: const Text('Increment'),
            ),
          ],
        ),
        const SizedBox(height: 8),
        if (_mounted)
          SignalsWatch.fromSignal(
            lifecycleSignal,
            builder: (value) => Text('Lifecycle value: $value'),
          ),
      ],
    );
  }
}

// Example 8: Reset API
class ResetExample extends StatefulWidget {
  const ResetExample({super.key});

  @override
  State<ResetExample> createState() => _ResetExampleState();
}

class _ResetExampleState extends State<ResetExample> {
  final local = SignalsWatch.signal(
    5,
    onValueUpdated: (value, previous) =>
        debugPrint('local: $previous -> $value'),
  );

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        SignalsWatch.fromSignal(
          local,
          builder: (v) => Text('Value: $v'),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            ElevatedButton(
              onPressed: () => local.value++,
              child: const Text('Increment'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () => local.reset(),
              child: const Text('Reset to initial (5)'),
            ),
          ],
        ),
      ],
    );
  }
}

// Example 9: ShouldRebuild vs ShouldNotify
class ShouldRebuildExample extends StatefulWidget {
  const ShouldRebuildExample({super.key});

  @override
  State<ShouldRebuildExample> createState() => _ShouldRebuildExampleState();
}

class _ShouldRebuildExampleState extends State<ShouldRebuildExample> {
  final sig = SignalsWatch.signal(0);
  int buildCount = 0;

  @override
  Widget build(BuildContext context) {
    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        SignalsWatch.fromSignal(
          sig,
          shouldRebuild: (n, o) => n % 2 == 0, // rebuild only on even values
          onValueUpdated: (n, o) => debugPrint('onValueUpdated: $o -> $n'),
          builder: (v) {
            buildCount++;
            return Text(
              'Value: $v | buildCount: $buildCount (rebuild on even)',
            );
          },
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            ElevatedButton(
              onPressed: () => sig.value++,
              child: const Text('+1'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () => sig.value += 2,
              child: const Text('+2'),
            ),
          ],
        ),
      ],
    );
  }
}

// Example 10: Async fromFuture
class FromFutureExample extends StatefulWidget {
  const FromFutureExample({super.key});

  @override
  State<FromFutureExample> createState() => _FromFutureExampleState();
}

class _FromFutureExampleState extends State<FromFutureExample> {
  bool _error = false;

  Future<int> _load() async {
    await Future<void>.delayed(const Duration(seconds: 1));
    if (_error) {
      throw Exception('Failed to load');
    }
    return 42;
  }

  @override
  Widget build(BuildContext context) {
    final futureSignal = SignalsWatch.fromFuture(
      _load(),
      initialValue: 0,
      onInit: (v) => debugPrint('[future] onInit: $v'),
      onValueUpdated: (v, p) => debugPrint('[future] $p -> $v'),
    );

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Row(
          children: [
            ElevatedButton(
              onPressed: () => setState(() => _error = !_error),
              child: Text(_error ? 'Set: success' : 'Set: throw error'),
            ),
          ],
        ),
        const SizedBox(height: 8),
        SignalsWatch.fromSignal(
          futureSignal,
          loadingBuilder: () => const Text('Loading...'),
          errorBuilder: (err) =>
              Text('Error: $err', style: const TextStyle(color: Colors.red)),
          builder: (v) => Text('Loaded value: $v'),
        ),
      ],
    );
  }
}

// Example 11: Async fromStream
class FromStreamExample extends StatelessWidget {
  const FromStreamExample({super.key});

  @override
  Widget build(BuildContext context) {
    final stream =
        Stream<int>.periodic(const Duration(milliseconds: 300), (i) => i + 1)
            .take(5);
    final streamSignal = SignalsWatch.fromStream(
      stream,
      initialValue: 0,
      onValueUpdated: (v, p) => debugPrint('[stream] $p -> $v'),
    );

    return SignalsWatch.fromSignal(
      streamSignal,
      builder: (v) => Text('Stream value: $v'),
    );
  }
}

// Example 12: Debug Trace & Observer
class DebugTraceExample extends StatelessWidget {
  const DebugTraceExample({super.key});

  @override
  Widget build(BuildContext context) {
    final debugSig = SignalsWatch.signal(0, debugLabel: 'debug.counter');

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        SignalsWatch.fromSignal(
          debugSig,
          debugLabel: 'widget.debug',
          debugPrint: true,
          onValueUpdated: (v, p) => debugPrint('[widget] $p -> $v'),
          builder: (v) => Text('Debug counter: $v'),
        ),
        const SizedBox(height: 8),
        Row(
          children: [
            ElevatedButton(
              onPressed: () => debugSig.value++,
              child: const Text('Increment (logs)'),
            ),
            const SizedBox(width: 8),
            ElevatedButton(
              onPressed: () => debugSig.value = 0,
              child: const Text('Reset'),
            ),
          ],
        ),
        const SizedBox(height: 4),
        const Text(
          'Check console for SelectiveSignalsObserver logs for labeled signals.',
        ),
      ],
    );
  }
}
0
likes
160
points
1
downloads

Publisher

unverified uploader

Weekly Downloads

A production-ready reactive widget for signals_flutter with lifecycle callbacks, debouncing, throttling, error handling, async helpers, and registry management.

Repository (GitHub)
View/report issues

Topics

#flutter #signals #reactive #state-management #widget

Documentation

API reference

License

MIT (license)

Dependencies

flutter, signals

More

Packages that depend on signals_watch