flutter_compositions 0.1.0 copy "flutter_compositions: ^0.1.0" to clipboard
flutter_compositions: ^0.1.0 copied to clipboard

Reactive composition primitives for Flutter inspired by the Vue 3 Composition API.

example/lib/main.dart

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

import 'demos/animations_example.dart';
import 'demos/composition_builder_example.dart';
import 'demos/controllers_example.dart';
import 'demos/props_best_practices.dart';
import 'demos/props_class_pattern.dart';
import 'demos/use_text_field_example.dart';
import 'demos/value_notifier_example.dart';

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

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

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

class DemoDefinition {
  const DemoDefinition({
    required this.title,
    required this.description,
    required this.builder,
  });

  final String title;
  final String description;
  final WidgetBuilder builder;
}

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

  static final List<DemoDefinition> demos = [
    DemoDefinition(
      title: 'Counter & Core APIs',
      description: 'widget(), provide/inject, useTextEditingController, watch',
      builder: (context) => const CounterPage(),
    ),
    DemoDefinition(
      title: 'Animation Composables',
      description:
          'useAnimationController, manageAnimation, Tweens, and Curves',
      builder: (context) => const AnimationsExamplePage(),
    ),
    DemoDefinition(
      title: 'CompositionBuilder',
      description: 'Inline reactive widgets without defining classes',
      builder: (context) => const CompositionBuilderDemo(),
    ),
    DemoDefinition(
      title: 'Controller Integrations',
      description:
          'use* helpers with Text, Scroll, Page, and Focus controllers',
      builder: (context) => const ControllersExamplePage(),
    ),
    DemoDefinition(
      title: 'useTextEditingController showcase',
      description: 'Two-way binding and validation helpers',
      builder: (context) => const UseTextFieldExamplePage(),
    ),
    DemoDefinition(
      title: 'ValueNotifier integration',
      description: 'Bridge existing ValueNotifiers with manageValueListenable',
      builder: (context) => const ValueNotifierDemo(),
    ),
    DemoDefinition(
      title: 'Props best practices',
      description: 'Common pitfalls and recommended patterns',
      builder: (context) => const PropsBestPracticesPage(),
    ),
    DemoDefinition(
      title: 'Props class patterns',
      description: 'Reusable prop objects, validation, and sealed unions',
      builder: (context) => const PropsClassPatternsPage(),
    ),
  ];

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Flutter Compositions Examples')),
      body: ListView.separated(
        padding: const EdgeInsets.all(16),
        itemCount: demos.length,
        separatorBuilder: (_, __) => const SizedBox(height: 16),
        itemBuilder: (context, index) {
          final demo = demos[index];
          return Card(
            child: ListTile(
              title: Text(demo.title),
              subtitle: Text(demo.description),
              trailing: const Icon(Icons.chevron_right),
              onTap: () {
                Navigator.of(
                  context,
                ).push(MaterialPageRoute<void>(builder: demo.builder));
              },
            ),
          );
        },
      ),
    );
  }
}

/// A component that demonstrates widget() with reactive props
class UserCard extends CompositionWidget {
  const UserCard({
    super.key,
    required this.userId,
    required this.name,
    this.role = 'User',
  });

  final String userId;
  final String name;
  final String role;

  @override
  Widget Function(BuildContext) setup() {
    // Get reactive widget reference (similar to State.widget in StatefulWidget)
    final w = widget();
    // Type is inferred as ComputedRef<UserCard>

    // Create computed values based on widget properties
    final displayName = computed(() => '${w.value.name} (#${w.value.userId})');
    final greeting = computed(() => 'Hello, ${w.value.name}!');
    final roleInfo = computed(() => 'Role: ${w.value.role}');

    // Watch for property changes
    watch(() => w.value.name, (newName, oldName) {
      debugPrint('Name changed: $oldName -> $newName');
    });

    watch(() => w.value.userId, (newId, oldId) {
      debugPrint('User ID changed: $oldId -> $newId');
    });

    return (context) => Card(
      margin: const EdgeInsets.symmetric(vertical: 8),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(greeting.value, style: Theme.of(context).textTheme.titleLarge),
            const SizedBox(height: 8),
            Text(
              displayName.value,
              style: Theme.of(context).textTheme.bodyLarge,
            ),
            const SizedBox(height: 4),
            Text(
              roleInfo.value,
              style: Theme.of(
                context,
              ).textTheme.bodyMedium?.copyWith(color: Colors.grey[600]),
            ),
          ],
        ),
      ),
    );
  }
}

/// Custom data class for theme configuration
class AppTheme {
  AppTheme(this.mode);
  String mode;
}

/// Define injection key for type-safe provide/inject
final themeKey = InjectionKey<Ref<AppTheme>>('app.theme');

/// A component that demonstrates provide/inject pattern with InjectionKey
class ThemeProvider extends CompositionWidget {
  const ThemeProvider({super.key});

  @override
  Widget Function(BuildContext) setup() {
    // Create a custom theme state
    final theme = ref(AppTheme('light'));

    // Provide using InjectionKey for type-safe dependency injection
    provide(themeKey, theme);

    return (context) => Column(
      children: [
        Card(
          margin: const EdgeInsets.symmetric(vertical: 8),
          child: Padding(
            padding: const EdgeInsets.all(16),
            child: Row(
              mainAxisAlignment: MainAxisAlignment.spaceBetween,
              children: [
                Text('Current Theme: ${theme.value.mode}'),
                SegmentedButton<String>(
                  segments: const [
                    ButtonSegment(value: 'light', label: Text('Light')),
                    ButtonSegment(value: 'dark', label: Text('Dark')),
                  ],
                  selected: {theme.value.mode},
                  onSelectionChanged: (Set<String> newSelection) {
                    // Replace the entire object to trigger reactivity
                    theme.value = AppTheme(newSelection.first);
                  },
                ),
              ],
            ),
          ),
        ),
        const ThemeConsumer(),
      ],
    );
  }
}

/// A component that injects the theme from parent
class ThemeConsumer extends CompositionWidget {
  const ThemeConsumer({super.key});

  @override
  Widget Function(BuildContext) setup() {
    // Inject by InjectionKey - type safe and no conflicts!
    final theme = inject(themeKey);

    final themeMessage = computed(
      () => 'Using ${theme.value.mode} theme from parent!',
    );

    return (context) => Card(
      margin: const EdgeInsets.symmetric(vertical: 8),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Text(
          themeMessage.value,
          style: Theme.of(context).textTheme.bodyLarge,
        ),
      ),
    );
  }
}

/// A component that demonstrates useTextEditingController with reactive state
class UserGreeting extends CompositionWidget {
  const UserGreeting({super.key});

  @override
  Widget Function(BuildContext) setup() {
    // useTextEditingController returns (controller, text, value)
    // Note: controller is the raw TextEditingController (not a ref)
    final (usernameController, username, _) = useTextEditingController(
      text: 'User',
    );
    final (prefixController, prefix, _) = useTextEditingController(
      text: 'Hello',
    );

    // Use reactive features on the text refs
    final greeting = computed(() => '${prefix.value}, ${username.value}!');

    // Watch changes
    watch(() => username.value, (newValue, oldValue) {
      debugPrint('Username changed: $oldValue -> $newValue');
    });

    return (context) => Card(
      margin: const EdgeInsets.symmetric(vertical: 10),
      child: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            Text(
              greeting.value,
              style: Theme.of(context).textTheme.headlineSmall,
            ),
            const SizedBox(height: 16),
            TextField(
              controller: usernameController,
              decoration: const InputDecoration(
                labelText: 'Username',
                border: OutlineInputBorder(),
              ),
            ),
            const SizedBox(height: 8),
            SegmentedButton<String>(
              segments: const [
                ButtonSegment(value: 'Hello', label: Text('Hello')),
                ButtonSegment(value: 'Hi', label: Text('Hi')),
                ButtonSegment(value: 'Welcome', label: Text('Welcome')),
              ],
              selected: {prefix.value},
              onSelectionChanged: (Set<String> newSelection) {
                prefix.value = newSelection.first;
              },
            ),
          ],
        ),
      ),
    );
  }
}

/// A counter page demonstrating Vue Composition API style in Flutter
class CounterPage extends CompositionWidget {
  const CounterPage({super.key});

  @override
  Widget Function(BuildContext) setup() {
    // Create reactive state (like Vue's ref())
    // This only runs once, not on every rebuild!
    final count = ref(0);
    final currentUserId = ref('user-1');
    final currentUserName = ref('Alice');

    // Create computed values (like Vue's computed())
    final doubled = computed(() => count.value * 2);
    final quadrupled = computed(() => (doubled.value / 3).round());
    final sum = computed(() => quadrupled.value * 2);

    // Watch for changes (like Vue's watch())
    watch(() => count.value, (newValue, oldValue) {
      debugPrint('Count changed from $oldValue to $newValue');
    });

    // Effect that runs on every reactive dependency change
    watchEffect(() {
      debugPrint('Effect: count is ${count.value}');
    });

    watchEffect(() {
      debugPrint('Effect: sum is ${sum.value}');
    });

    // Lifecycle hooks
    onMounted(() {
      debugPrint('CounterPage mounted!');
    });

    onUnmounted(() {
      debugPrint('CounterPage unmounted!');
    });

    // Return a builder function that will be called on reactive updates
    // Access InheritedWidgets (Theme, MediaQuery) here, not in setup!
    return (context) => Scaffold(
      appBar: AppBar(
        backgroundColor: Theme.of(context).colorScheme.inversePrimary,
        title: const Text('Flutter Compositions Demo'),
      ),
      body: SingleChildScrollView(
        child: Center(
          child: Padding(
            padding: const EdgeInsets.all(16.0),
            child: Column(
              mainAxisAlignment: MainAxisAlignment.center,
              children: <Widget>[
                // widget() demo section
                const Text(
                  'widget() Demo - Reactive Props',
                  style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
                ),
                const SizedBox(height: 10),
                UserCard(
                  userId: currentUserId.value,
                  name: currentUserName.value,
                  role: 'Administrator',
                ),
                const SizedBox(height: 10),
                ElevatedButton(
                  onPressed: () {
                    // Toggle user to demonstrate reactive props
                    if (currentUserId.value == 'user-1') {
                      currentUserId.value = 'user-2';
                      currentUserName.value = 'Bob';
                    } else {
                      currentUserId.value = 'user-1';
                      currentUserName.value = 'Alice';
                    }
                  },
                  child: const Text('Toggle User'),
                ),
                const Divider(height: 40),
                // provide/inject demo section
                const Text(
                  'Provide/Inject Demo',
                  style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold),
                ),
                const SizedBox(height: 10),
                const ThemeProvider(),
                const Divider(height: 40),
                // useTextEditingController demo section
                const Text(
                  'useTextEditingController Demo',
                  style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
                ),
                const SizedBox(height: 10),
                const UserGreeting(),
                const Divider(height: 40),
                // Counter section
                const Text('You have pushed the button this many times:'),
                const SizedBox(height: 20),
                Text(
                  '${count.value}',
                  style: Theme.of(context).textTheme.headlineMedium,
                ),
                const SizedBox(height: 10),
                Text(
                  'Doubled: ${doubled.value}',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                const SizedBox(height: 10),
                Text(
                  'Quadrupled: ${quadrupled.value}',
                  style: Theme.of(context).textTheme.titleMedium,
                ),
                const SizedBox(height: 40),
                Text(
                  'Sum (Quadrupled * 2): ${sum.value}',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                const SizedBox(height: 20),
                Row(
                  mainAxisAlignment: MainAxisAlignment.center,
                  children: [
                    ElevatedButton.icon(
                      onPressed: () {
                        count.value--;
                      },
                      icon: const Icon(Icons.remove),
                      label: const Text('Decrement'),
                    ),
                    const SizedBox(width: 20),
                    ElevatedButton.icon(
                      onPressed: () {
                        count.value = 0;
                      },
                      icon: const Icon(Icons.refresh),
                      label: const Text('Reset'),
                    ),
                    const SizedBox(width: 20),
                    ElevatedButton.icon(
                      onPressed: () {
                        count.value++;
                      },
                      icon: const Icon(Icons.add),
                      label: const Text('Increment'),
                    ),
                  ],
                ),
              ],
            ),
          ),
        ),
      ),
      floatingActionButton: FloatingActionButton(
        onPressed: () {
          count.value++;
        },
        tooltip: 'Increment',
        child: const Icon(Icons.add),
      ),
    );
  }
}
6
likes
150
points
33
downloads

Publisher

verified publisheryokikiyo.com

Weekly Downloads

Reactive composition primitives for Flutter inspired by the Vue 3 Composition API.

Repository (GitHub)
View/report issues
Contributing

Documentation

Documentation
API reference

License

MIT (license)

Dependencies

alien_signals, flutter

More

Packages that depend on flutter_compositions