DEV Community

Ge Ji
Ge Ji

Posted on

Flutter Lesson 15: Custom Widgets and Component Encapsulation

In Flutter development, as application scales grow, properly encapsulating custom widgets and reusable components becomes crucial. Well-designed components can significantly improve code reusability, reduce maintenance costs, and ensure UI consistency. This lesson will explore the design principles, implementation methods, and data transfer mechanisms for custom widgets in depth.

I. Principles of Encapsulating Reusable Components

Designing high-quality reusable components requires following core principles that help create flexible, robust, and maintainable components:

1. Single Responsibility Principle

Each component should focus on completing a single function. Avoid designing "all-purpose components." For example, a button component shouldn't handle form validation logic, and a list component shouldn't contain specific list item rendering logic.

Anti-pattern: A component that handles both network requests and UI display
Good practice: Separate network requests from UI display; UI components only handle data presentation

2. High Cohesion, Low Coupling

  • High cohesion: Related functions within a component should be tightly integrated, forming an organic whole
  • Low coupling: Components should communicate through well-defined interfaces, minimizing direct dependencies

3. Configuration Flexibility

Adapt components to different scenarios through parameter configuration, while balancing flexibility and complexity:

  • Provide reasonable default values to reduce usage costs
  • Key properties should be configurable, while secondary properties can be fixed
  • Use bool, enum, and other types to limit configuration options and avoid misuse

4. Extensibility

Design with extension points to facilitate future functionality expansion:

  • Allow custom child components through child or builder parameters
  • Extend base component functionality through inheritance or composition
  • Avoid hardcoding business logic

5. Consistency and Recognizability

  • Maintain component style consistency with the overall application design
  • Component behavior should meet user expectations (e.g., button click feedback)
  • Components with similar functionality should maintain consistent API design

6. Testability

  • Components should be easy to instantiate and test
  • Avoid creating global state within components
  • Critical logic should be testable independently

II. Basics of Custom Widgets

There are two main ways to create custom components in Flutter: composing existing widgets and creating custom RenderObjects. For most scenarios, we should prioritize composition as it's simpler and meets most needs.

1. Choosing Between StatelessWidget and StatefulWidget

  • StatelessWidget: Suitable for stateless components or components whose state is managed by parent components, such as static display components and pure UI components
  • StatefulWidget: Suitable for components with internal state management, such as counters and expandable/collapsible panels

Recommendation: Prefer StatelessWidget when possible, elevating state to parent components or state management frameworks to make components easier to test and reuse.

2. Component Naming Conventions

  • Use PascalCase, such as PrimaryButton, UserProfileCard
  • Names should accurately describe component functionality, avoiding excessive abstraction or generality
  • For components with similar basic functions but different styles, use consistent prefixes, such as PrimaryButton, SecondaryButton

III. Custom Widget Examples

1. Example 1: Button with Loading State (StatefulWidget)

This button component supports loading states, disabled states, custom styles, and other features, suitable for form submission, data requests, and similar scenarios.

import 'package:flutter/material.dart';

/// Button component with loading state
/// Supports normal, loading, and disabled states
/// Customizable colors, rounded corners, text styles, etc.
class LoadingButton extends StatefulWidget {
  /// Button text
  final String text;

  /// Click callback
  final VoidCallback? onPressed;

  /// Whether in loading state
  final bool isLoading;

  /// Button primary color
  final Color? color;

  /// Text color
  final Color textColor;

  /// Disabled state color
  final Color disabledColor;

  /// Disabled state text color
  final Color disabledTextColor;

  /// Button border radius
  final double borderRadius;

  /// Button padding
  final EdgeInsetsGeometry padding;

  /// Loading indicator color
  final Color indicatorColor;

  LoadingButton({
    super.key,
    required this.text,
    this.onPressed,
    this.isLoading = false,
    this.color,
    this.textColor = Colors.white,
    this.disabledColor = Colors.grey,
    this.disabledTextColor = Colors.grey,
    this.borderRadius = 8.0,
    this.padding = const EdgeInsets.symmetric(horizontal: 24, vertical: 12),
    this.indicatorColor = Colors.white,
  }) : assert(text.isNotEmpty, "Button text cannot be empty");

  @override
  State<LoadingButton> createState() => _LoadingButtonState();
}

class _LoadingButtonState extends State<LoadingButton> {
  // Whether the button is clickable
  bool get _isEnabled => widget.onPressed != null && !widget.isLoading;

  @override
  Widget build(BuildContext context) {
    // Get the primary color from the theme as the default color
    final theme = Theme.of(context);
    final buttonColor = widget.color ?? theme.primaryColor;

    return ElevatedButton(
      onPressed: _isEnabled ? widget.onPressed : null,
      style: ElevatedButton.styleFrom(
        backgroundColor: _getButtonColor(buttonColor),
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(widget.borderRadius),
        ),
        padding: widget.padding,
        disabledBackgroundColor: widget.disabledColor,
      ),
      child: _buildButtonContent(buttonColor),
    );
  }

  // Get button color based on state
  Color _getButtonColor(Color defaultColor) {
    if (widget.isLoading) {
      return defaultColor.withOpacity(0.8);
    }
    return defaultColor;
  }

  // Build button content (text or loading indicator)
  Widget _buildButtonContent(Color buttonColor) {
    if (widget.isLoading) {
      return Row(
        mainAxisSize: MainAxisSize.min,
        children: [
          SizedBox(
            width: 20,
            height: 20,
            child: CircularProgressIndicator(
              strokeWidth: 2,
              valueColor: AlwaysStoppedAnimation<Color>(widget.indicatorColor),
            ),
          ),
          const SizedBox(width: 8),
          Text(
            "Loading...",
            style: TextStyle(
              color: _isEnabled ? widget.textColor : widget.disabledTextColor,
            ),
          ),
        ],
      );
    }

    return Text(
      widget.text,
      style: TextStyle(
        color: _isEnabled ? widget.textColor : widget.disabledTextColor,
      ),
    );
  }
}

// Usage example
class LoadingButtonDemo extends StatefulWidget {
  const LoadingButtonDemo({super.key});

  @override
  State<LoadingButtonDemo> createState() => _LoadingButtonDemoState();
}

class _LoadingButtonDemoState extends State<LoadingButtonDemo> {
  bool _isLoading = false;

  void _handlePress() async {
    // Simulate network request
    setState(() => _isLoading = true);
    await Future.delayed(const Duration(seconds: 2));
    setState(() => _isLoading = false);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Loading Button Demo')),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: Column(
          children: [
            LoadingButton(
              text: "Submit",
              onPressed: _handlePress,
              isLoading: _isLoading,
            ),
            const SizedBox(height: 20),
            LoadingButton(
              text: "Disabled State",
              onPressed: null, // Button is disabled when onPressed is null
              color: Colors.grey,
            ),
            const SizedBox(height: 20),
            LoadingButton(
              text: "Custom Style",
              onPressed: () {},
              color: Colors.purple,
              borderRadius: 20,
              padding: const EdgeInsets.symmetric(horizontal: 30, vertical: 15),
            ),
          ],
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Example 2: Common App Bar (StatelessWidget)

Implement a highly customizable app bar component that supports left/right buttons, custom title styles, background color settings, and other features.

import 'package:flutter/material.dart';

/// Common app bar component
/// Supports custom title, left/right buttons, background color, etc.
class CommonAppBar extends StatelessWidget implements PreferredSizeWidget {
  /// Title text
  final String title;

  /// Title widget, takes precedence over title
  final Widget? titleWidget;

  /// Left button
  final Widget? leading;

  /// List of right buttons
  final List<Widget>? actions;

  /// Background color
  final Color? backgroundColor;

  /// Title text style
  final TextStyle? titleStyle;

  /// Shadow elevation
  final double elevation;

  /// Whether title is centered
  final bool centerTitle;

  /// Left button click callback (only effective when leading is not customized)
  final VoidCallback? onLeadingPressed;

  const CommonAppBar({
    super.key,
    this.title = "",
    this.titleWidget,
    this.leading,
    this.actions,
    this.backgroundColor,
    this.titleStyle,
    this.elevation = 4.0,
    this.centerTitle = true,
    this.onLeadingPressed,
  });

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

    return AppBar(
      title: titleWidget ?? 
        Text(
          title,
          style: titleStyle ?? 
            TextStyle(
              color: theme.appBarTheme.titleTextStyle?.color ?? Colors.white,
              fontSize: 18,
              fontWeight: FontWeight.bold,
            ),
        ),
      leading: leading ?? _buildDefaultLeading(context),
      actions: actions,
      backgroundColor: backgroundColor ?? theme.appBarTheme.backgroundColor,
      elevation: elevation,
      centerTitle: centerTitle,
    );
  }

  // Build default leading button (back button)
  Widget? _buildDefaultLeading(BuildContext context) {
    // Don't show back button if it's the first page in navigation stack
    if (Navigator.canPop(context)) {
      return IconButton(
        icon: const Icon(Icons.arrow_back),
        onPressed: onLeadingPressed ?? () => Navigator.pop(context),
      );
    }
    return null;
  }

  // Define app bar height, using default 56.0
  @override
  Size get preferredSize => const Size.fromHeight(56.0);
}

// Usage example
class CommonAppBarDemo extends StatelessWidget {
  const CommonAppBarDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: CommonAppBar(
        title: "Home Page",
        actions: [
          IconButton(
            icon: const Icon(Icons.search),
            onPressed: () => print("Search"),
          ),
          IconButton(
            icon: const Icon(Icons.more_vert),
            onPressed: () => print("More"),
          ),
        ],
      ),
      body: const Center(
        child: Text("Page content"),
      ),
    );
  }
}

// Custom title style example
class StyledAppBarDemo extends StatelessWidget {
  const StyledAppBarDemo({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: CommonAppBar(
        titleWidget: const Row(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            Icon(Icons.star, color: Colors.yellow),
            SizedBox(width: 8),
            Text("Custom Title"),
          ],
        ),
        backgroundColor: Colors.purple,
        elevation: 0,
        leading: IconButton(
          icon: const Icon(Icons.menu),
          onPressed: () => print("Menu"),
        ),
      ),
      body: const Center(
        child: Text("Page with custom title"),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

IV. Component Parameter Validation and Default Values

Good parameter design is key to component usability. Reasonable default values reduce usage costs, while strict parameter validation helps catch errors early.

1. Required vs. Optional Parameters

Use the required keyword to mark mandatory parameters, allowing the compiler to help check parameter completeness:

class CustomButton extends StatelessWidget {
  // Required parameters
  final String text;
  final VoidCallback onPressed;

  // Optional parameters
  final Color? color;

  // Mark required parameters with 'required'
  const CustomButton({
    super.key,
    required this.text,
    required this.onPressed,
    this.color,
  });

  // ...
}
Enter fullscreen mode Exit fullscreen mode

2. Setting Default Values

Provide reasonable default values for optional parameters to reduce component complexity:

class CustomButton extends StatelessWidget {
  final String text;
  final VoidCallback onPressed;
  final Color color;
  final double borderRadius;
  final EdgeInsets padding;

  const CustomButton({
    super.key,
    required this.text,
    required this.onPressed,
    // Provide default values
    this.color = Colors.blue,
    this.borderRadius = 8.0,
    this.padding = const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
  });

  // ...
}
Enter fullscreen mode Exit fullscreen mode

3. Parameter Validation

Use assert statements for parameter validation during development to catch errors early:

class Avatar extends StatelessWidget {
  final String url;
  final double radius;

  const Avatar({
    super.key,
    required this.url,
    this.radius = 24.0,
  }) : 
    // Validate that radius must be positive
    assert(radius > 0, "radius must be greater than 0"),
    // Validate that url is not empty
    assert(url.isNotEmpty, "url cannot be empty");

  @override
  Widget build(BuildContext context) {
    return ClipOval(
      child: Image.network(
        url,
        width: radius * 2,
        height: radius * 2,
        fit: BoxFit.cover,
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

4. Advanced Parameter Validation

For complex parameter validation logic, perform checks in initState or build methods:

class RangeSelector extends StatefulWidget {
  final double min;
  final double max;
  final double value;

  const RangeSelector({
    super.key,
    required this.min,
    required this.max,
    required this.value,
  });

  @override
  State<RangeSelector> createState() => _RangeSelectorState();
}

class _RangeSelectorState extends State<RangeSelector> {
  @override
  void initState() {
    super.initState();
    _validateParams();
  }

  // Complex parameter validation
  void _validateParams() {
    if (widget.min >= widget.max) {
      throw FlutterError("min must be less than max: min=${widget.min}, max=${widget.max}");
    }

    if (widget.value < widget.min || widget.value > widget.max) {
      throw FlutterError("value must be within [min, max] range: value=${widget.value}, min=${widget.min}, max=${widget.max}");
    }
  }

  // ...
}
Enter fullscreen mode Exit fullscreen mode

V. Using InheritedWidget for Cross-Layer Data Transfer

In complex applications, component nesting can become very deep, making data passing through constructors cumbersome. InheritedWidget provides an efficient way to transfer data across layers, allowing child components to directly access data from upper layers.

1. InheritedWidget Basics

InheritedWidget is a special type of Widget that can pass data down the widget tree, allowing child components to access this data through BuildContext.

// 1. Create custom InheritedWidget
class ThemeData {
  final Color primaryColor;
  final Color secondaryColor;
  final TextStyle textStyle;

  ThemeData({
    required this.primaryColor,
    required this.secondaryColor,
    required this.textStyle,
  });
}

class AppTheme extends InheritedWidget {
  final ThemeData data;

  const AppTheme({
    super.key,
    required this.data,
    required super.child,
  });

  // Provide static method for child components to access
  static AppTheme? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<AppTheme>();
  }

  // Whether to notify dependent child components when data changes
  @override
  bool updateShouldNotify(AppTheme oldWidget) {
    return data.primaryColor != oldWidget.data.primaryColor ||
           data.secondaryColor != oldWidget.data.secondaryColor ||
           data.textStyle != oldWidget.data.textStyle;
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Using in Widget Tree

// 2. Provide data at the top of the widget tree
class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    // Create theme data
    final themeData = ThemeData(
      primaryColor: Colors.blue,
      secondaryColor: Colors.green,
      textStyle: const TextStyle(
        color: Colors.black87,
        fontSize: 16,
      ),
    );

    // Provide InheritedWidget
    return AppTheme(
      data: themeData,
      child: const MaterialApp(
        home: HomePage(),
      ),
    );
  }
}

// 3. Deep nested component accessing data
class DeepNestedWidget extends StatelessWidget {
  const DeepNestedWidget({super.key});

  @override
  Widget build(BuildContext context) {
    // Get theme data
    final appTheme = AppTheme.of(context);

    if (appTheme == null) {
      return const Text("Theme data not found");
    }

    return Container(
      color: appTheme.data.secondaryColor,
      padding: const EdgeInsets.all(16),
      child: Text(
        "Text using theme style",
        style: appTheme.data.textStyle,
      ),
    );
  }
}

// Intermediate component (no need to pass data)
class HomePage extends StatelessWidget {
  const HomePage({super.key});

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text("InheritedWidget Example")),
      body: const Center(
        child: IntermediateWidget(),
      ),
    );
  }
}

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

  @override
  Widget build(BuildContext context) {
    return const DeepNestedWidget();
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Dynamically Updating InheritedWidget Data

InheritedWidget itself is immutable. To achieve dynamic data updates, you need to combine it with StatefulWidget:

class ThemeProvider extends StatefulWidget {
  final Widget child;

  const ThemeProvider({
    super.key,
    required this.child,
  });

  // Provide static method to get state
  static _ThemeProviderState of(BuildContext context) {
    return context.findAncestorStateOfType<_ThemeProviderState>()!;
  }

  @override
  State<ThemeProvider> createState() => _ThemeProviderState();
}

class _ThemeProviderState extends State<ThemeProvider> {
  late ThemeData _themeData;

  @override
  void initState() {
    super.initState();
    // Initialize theme data
    _themeData = ThemeData(
      primaryColor: Colors.blue,
      secondaryColor: Colors.green,
      textStyle: const TextStyle(
        color: Colors.black87,
        fontSize: 16,
      ),
    );
  }

  // Method to toggle theme
  void toggleTheme() {
    setState(() {
      _themeData = _themeData.primaryColor == Colors.blue
          ? ThemeData(
              primaryColor: Colors.purple,
              secondaryColor: Colors.orange,
              textStyle: const TextStyle(
                color: Colors.white,
                fontSize: 16,
              ),
            )
          : ThemeData(
              primaryColor: Colors.blue,
              secondaryColor: Colors.green,
              textStyle: const TextStyle(
                color: Colors.black87,
                fontSize: 16,
              ),
            );
    });
  }

  @override
  Widget build(BuildContext context) {
    return AppTheme(
      data: _themeData,
      child: widget.child,
    );
  }
}

// Using dynamic theme
class ThemedButton extends StatelessWidget {
  const ThemedButton({super.key});

  @override
  Widget build(BuildContext context) {
    final appTheme = AppTheme.of(context);

    return ElevatedButton(
      onPressed: () {
        // Toggle theme
        ThemeProvider.of(context).toggleTheme();
      },
      style: ElevatedButton.styleFrom(
        backgroundColor: appTheme?.data.primaryColor,
      ),
      child: Text(
        "Toggle Theme",
        style: TextStyle(
          color: appTheme?.data.textStyle.color,
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

4. InheritedWidget Considerations

  • Performance considerations: The updateShouldNotify method should accurately determine if data has actually changed to avoid unnecessary rebuilds
  • Don't overuse: For simple scenarios, passing data through constructors is more straightforward
  • Data types: InheritedWidget is suitable for passing globally-shared data like configurations, themes, and user information
  • Dependency management: Using dependOnInheritedWidgetOfExactType establishes a dependency relationship that triggers rebuilds when data changes; using getInheritedWidgetOfExactType does not establish this relationship

VI. Component Communication Methods

In complex applications, components need to communicate with each other. Flutter provides various component communication methods:

1. Parent-Child Component Communication

  • Parent to child: Pass through constructor parameters
  • Child to parent: Pass through callback functions
class ParentWidget extends StatefulWidget {
  const ParentWidget({super.key});

  @override
  State<ParentWidget> createState() => _ParentWidgetState();
}

class _ParentWidgetState extends State<ParentWidget> {
  String _message = "Waiting for message...";

  // Callback to receive message from child component
  void _onMessageReceived(String message) {
    setState(() {
      _message = message;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text("Received message: $_message"),
        ChildWidget(
          // Parent passes data to child
          initialMessage: "Hello, child component",
          // Parent provides callback to child
          onSendMessage: _onMessageReceived,
        ),
      ],
    );
  }
}

class ChildWidget extends StatelessWidget {
  final String initialMessage;
  final ValueChanged<String> onSendMessage;

  const ChildWidget({
    super.key,
    required this.initialMessage,
    required this.onSendMessage,
  });

  @override
  Widget build(BuildContext context) {
    return Column(
      children: [
        Text("Received initial message: $initialMessage"),
        ElevatedButton(
          onPressed: () {
            // Child sends message to parent through callback
            onSendMessage("Hello, parent component! This is the child component");
          },
          child: const Text("Send message to parent"),
        ),
      ],
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

2. Cross-Level Component Communication

In addition to InheritedWidget, you can use an event bus for cross-level component communication:

// Event bus implementation
class EventBus {
  // Singleton pattern
  static final EventBus _instance = EventBus._internal();
  factory EventBus() => _instance;
  EventBus._internal();

  // Store event subscribers
  final Map<Type, List<Function>> _eventListeners = {};

  // Subscribe to events
  void on<T>(void Function(T) listener) {
    if (!_eventListeners.containsKey(T)) {
      _eventListeners[T] = [];
    }
    _eventListeners[T]!.add(listener);
  }

  // Unsubscribe from events
  void off<T>(void Function(T) listener) {
    if (_eventListeners.containsKey(T)) {
      _eventListeners[T]!.remove(listener);
      if (_eventListeners[T]!.isEmpty) {
        _eventListeners.remove(T);
      }
    }
  }

  // Send event
  void emit<T>(T event) {
    if (_eventListeners.containsKey(T)) {
      // Make a copy of the list before iterating to avoid modification during iteration
      List<Function> listeners = List.from(_eventListeners[T]!);
      for (var listener in listeners) {
        listener(event);
      }
    }
  }
}

// Define event types
class UserLoginEvent {
  final String username;
  UserLoginEvent(this.username);
}

class UserLogoutEvent {
  UserLogoutEvent();
}

// Component that sends events
class LoginButton extends StatelessWidget {
  const LoginButton({super.key});

  @override
  Widget build(BuildContext context) {
    return ElevatedButton(
      onPressed: () {
        // Send login event
        EventBus().emit(UserLoginEvent("John Doe"));
      },
      child: const Text("Login"),
    );
  }
}

// Component that receives events
class UserStatusWidget extends StatefulWidget {
  const UserStatusWidget({super.key});

  @override
  State<UserStatusWidget> createState() => _UserStatusWidgetState();
}

class _UserStatusWidgetState extends State<UserStatusWidget> {
  String _status = "Not logged in";

  @override
  void initState() {
    super.initState();
    // Subscribe to login events
    EventBus().on<UserLoginEvent>((event) {
      setState(() {
        _status = "Logged in: ${event.username}";
      });
    });

    // Subscribe to logout events
    EventBus().on<UserLogoutEvent>((event) {
      setState(() {
        _status = "Not logged in";
      });
    });
  }

  @override
  void dispose() {
    // Unsubscribe to prevent memory leaks
    EventBus().off<UserLoginEvent>((event) {});
    EventBus().off<UserLogoutEvent>((event) {});
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return Text("User status: $_status");
  }
}
Enter fullscreen mode Exit fullscreen mode

3. Global State Management

For large applications, specialized state management solutions are recommended, such as:

  • Provider
  • Bloc/Cubit
  • Riverpod
  • GetX

These solutions build on InheritedWidget to provide more comprehensive state management capabilities, including state change notifications, dependency injection, and lifecycle management.


VII. Component Encapsulation Practice: Form Component Library

Next, we'll comprehensively apply the knowledge learned in this lesson to encapsulate a practical form component library, including input fields, selectors, form validation, and other features.

import 'package:flutter/material.dart';

// 1. Form field model
class FormFieldData {
  final String id;
  dynamic value;
  String? errorMessage;
  bool touched;

  FormFieldData({
    required this.id,
    this.value,
    this.errorMessage,
    this.touched = false,
  });
}

// 2. Form state management (using InheritedWidget)
class FormProvider extends InheritedWidget {
  final Map<String, FormFieldData> _fields = {};
  final void Function() onFormChanged;

  FormProvider({
    super.key,
    required super.child,
    required this.onFormChanged,
  });

  static FormProvider? of(BuildContext context) {
    return context.dependOnInheritedWidgetOfExactType<FormProvider>();
  }

  // Get field value
  dynamic getValue(String id) {
    return _fields[id]?.value;
  }

  // Set field value
  void setValue(String id, dynamic value, {bool validate = true}) {
    if (!_fields.containsKey(id)) {
      _fields[id] = FormFieldData(id: id);
    }
    _fields[id]!.value = value;
    _fields[id]!.touched = true;

    if (validate) {
      validateField(id);
    }

    onFormChanged();
  }

  // Validate field
  bool validateField(String id) {
    // In real applications, there would be more complex validation logic
    // Simplified here
    final field = _fields[id];
    if (field == null) return true;

    if (field.value == null || field.value.toString().isEmpty) {
      field.errorMessage = "This field cannot be empty";
      return false;
    }

    field.errorMessage = null;
    return true;
  }

  // Validate entire form
  bool validate() {
    bool isValid = true;
    for (var field in _fields.values) {
      field.touched = true;
      if (!validateField(field.id)) {
        isValid = false;
      }
    }
    onFormChanged();
    return isValid;
  }

  // Get field error message
  String? getError(String id) {
    return _fields[id]?.errorMessage;
  }

  // Check if field has been touched
  bool isTouched(String id) {
    return _fields[id]?.touched ?? false;
  }

  @override
  bool updateShouldNotify(FormProvider oldWidget) {
    return true;
  }
}

// 3. Form container component
class FormContainer extends StatefulWidget {
  final Widget child;
  final void Function(bool isValid) onValidationChanged;
  final void Function(Map<String, dynamic> values) onSubmit;

  const FormContainer({
    super.key,
    required this.child,
    required this.onValidationChanged,
    required this.onSubmit,
  });

  @override
  State<FormContainer> createState() => _FormContainerState();
}

class _FormContainerState extends State<FormContainer> {
  bool _isValid = false;

  void _onFormChanged(BuildContext context) {
    final formProvider = FormProvider.of(context);
    if (formProvider != null) {
      final isValid = formProvider.validate();
      setState(() {
        _isValid = isValid;
      });
      widget.onValidationChanged(isValid);
    }
  }

  void _handleSubmit(BuildContext context) {
    final formProvider = FormProvider.of(context);
    if (formProvider != null && formProvider.validate()) {
      // Collect form data
      final values = <String, dynamic>{};
      formProvider._fields.forEach((key, value) {
        values[key] = value.value;
      });
      widget.onSubmit(values);
    }
  }

  @override
  Widget build(BuildContext context) {
    return FormProvider(
      onFormChanged: () => _onFormChanged(context),
      child: Column(
        children: [
          widget.child,
          const SizedBox(height: 20),
          LoadingButton(
            text: "Submit",
            onPressed: _isValid ? () => _handleSubmit(context) : null,
          ),
        ],
      ),
    );
  }
}

// 4. Custom input field component
class FormInputField extends StatelessWidget {
  final String id;
  final String label;
  final String hintText;
  final TextInputType keyboardType;
  final bool obscureText;
  final FormFieldValidator<String>? validator;

  const FormInputField({
    super.key,
    required this.id,
    required this.label,
    this.hintText = "",
    this.keyboardType = TextInputType.text,
    this.obscureText = false,
    this.validator,
  });

  @override
  Widget build(BuildContext context) {
    final formProvider = FormProvider.of(context);
    if (formProvider == null) {
      return const SizedBox();
    }

    return Column(
      crossAxisAlignment: CrossAxisAlignment.start,
      children: [
        Text(
          label,
          style: const TextStyle(
            fontSize: 16,
            fontWeight: FontWeight.w500,
          ),
        ),
        const SizedBox(height: 8),
        TextFormField(
          initialValue: formProvider.getValue(id)?.toString() ?? "",
          keyboardType: keyboardType,
          obscureText: obscureText,
          decoration: InputDecoration(
            hintText: hintText,
            border: const OutlineInputBorder(),
            errorText: formProvider.isTouched(id) ? formProvider.getError(id) : null,
          ),
          onChanged: (value) {
            formProvider.setValue(id, value);
          },
        ),
        const SizedBox(height: 16),
      ],
    );
  }
}

// 5. Using the form component library
class RegisterFormDemo extends StatelessWidget {
  const RegisterFormDemo({super.key});

  void _onValidationChanged(bool isValid) {
    print("Form validation status: ${isValid ? "valid" : "invalid"}");
  }

  void _onSubmit(Map<String, dynamic> values) {
    print("Form submitted data: $values");
    // Form submission logic such as network requests can be handled here
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(title: const Text('Registration Form')),
      body: Padding(
        padding: const EdgeInsets.all(20.0),
        child: FormContainer(
          onValidationChanged: _onValidationChanged,
          onSubmit: _onSubmit,
          child: Column(
            children: [
              FormInputField(
                id: "username",
                label: "Username",
                hintText: "Please enter your username",
              ),
              FormInputField(
                id: "email",
                label: "Email",
                hintText: "Please enter your email",
                keyboardType: TextInputType.emailAddress,
              ),
              FormInputField(
                id: "password",
                label: "Password",
                hintText: "Please enter your password",
                obscureText: true,
              ),
            ],
          ),
        ),
      ),
    );
  }
}
Enter fullscreen mode Exit fullscreen mode

Top comments (0)