Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Multi Page Form Architecture

Tags:

flutter

I'm trying to build a system to create forms with multiples pages. My approach was to separate it in three different parts.

  1. FormPages: The different form pages (Each one will have its own logic to validate fields).
  2. ProjectFormContainer: The container page that holds the pages inside a Navigator.
  3. MultiPageFormController: A controller to manage the navigation between form pages.

enter image description here

I've managed to make some progress adding to the ProjectFormContainer a ChangeNotifierProvider of MultiPageFormController but I'm not sure how to connect the individual formPages logic with the rest of the elements and what's the best way to make a decent architecture for this model.

I hope you guys could give me some advice. Thanks in advance!

enter image description here

like image 257
Fran Fox Avatar asked Feb 16 '21 16:02

Fran Fox


1 Answers

Here is a Solution based on Flow Builder from Felix Angelov, the author of Bloc State Management.

I used the following packages:

  • Flow Builder to manage the flow of our form
  • Flutter Hooks to get rid of the Stateful Widgets and have a leaner code base
  • Freezed to manage the immutability of our User Info
  • [optional] Flutter Color Picker

enter image description here

As you will see if you check Flow Builder documentation, it manages a deck of Form Widgets, one for each step of the Flow. Once it completes the Flow, it pops the Widgets out and gets back to the Main Page with the User Info.

1. Creation of the Flow

FlowBuilder<UserInfo>(
  state: const UserInfo(),
  onGeneratePages: (profile, pages) {
    return [
      const MaterialPage(child: NameForm()),
      if (profile.name != null) const MaterialPage(child: AgeForm()),
      if (profile.age != null) const MaterialPage(child: ColorForm()),
    ];
  },
),

2. Flow Management

At the end of each step, we either continue the flow:

context
 .flow<UserInfo>()
 .update((info) => info.copyWith(name: name.value));

Or, we complete it:

context
  .flow<UserInfo>()
  .complete((info) => info.copyWith(favoriteColor: color.value));

3. Main Page

And, on our Main Page, we navigate to the OnboardingFlow and await for it to complete:

userInfo.value = await Navigator.of(context).push(OnboardingFlow.route());

Full source code for easy copy-paste

import 'package:flow_builder/flow_builder.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:flutter_colorpicker/flutter_colorpicker.dart';

part 'main.freezed.dart';

void main() {
  runApp(
    const MaterialApp(
      debugShowCheckedModeBanner: false,
      title: 'Flow Demo',
      home: HomePage(),
    ),
  );
}

// MAIN PAGE

class HomePage extends HookWidget {
  const HomePage({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final userInfo = useState<UserInfo?>(null);
    return Scaffold(
      backgroundColor:
          userInfo.value == null ? Colors.white : userInfo.value!.favoriteColor,
      appBar: AppBar(title: const Text('Flow')),
      body: Container(
        padding: const EdgeInsets.all(8.0),
        alignment: Alignment.center,
        child: userInfo.value == null
            ? ElevatedButton(
                onPressed: () async {
                  userInfo.value =
                      await Navigator.of(context).push(OnboardingFlow.route());
                },
                child: const Text('GET STARTED'),
              )
            : Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Text(
                    'Welcome, ${userInfo.value!.name}!',
                    style: const TextStyle(fontSize: 48.0),
                  ),
                  const SizedBox(height: 48.0),
                  Text(
                    'So, you are ${userInfo.value!.age} years old and this is your favorite color? Great!',
                    style: const TextStyle(fontSize: 32.0),
                  ),
                ],
              ),
      ),
    );
  }
}

// FLOW

class OnboardingFlow extends StatelessWidget {
  const OnboardingFlow({Key? key}) : super(key: key);

  static Route<UserInfo> route() {
    return MaterialPageRoute(builder: (_) => const OnboardingFlow());
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: FlowBuilder<UserInfo>(
        state: const UserInfo(),
        onGeneratePages: (profile, pages) {
          return [
            const MaterialPage(child: NameForm()),
            if (profile.name != null) const MaterialPage(child: AgeForm()),
            if (profile.age != null) const MaterialPage(child: ColorForm()),
          ];
        },
      ),
    );
  }
}

// FORMS

class NameForm extends HookWidget {
  const NameForm({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final name = useState<String?>(null);
    return Scaffold(
      appBar: AppBar(title: const Text('Name')),
      body: Container(
        padding: const EdgeInsets.all(8.0),
        alignment: Alignment.center,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            TextField(
              autofocus: true,
              onChanged: (value) => name.value = value,
              decoration: const InputDecoration(
                labelText: 'Name',
                hintText: 'Enter your name',
              ),
            ),
            const SizedBox(height: 24.0),
            ElevatedButton(
              child: const Text('Continue'),
              onPressed: () {
                if (name.value != null && name.value!.isNotEmpty) {
                  context
                      .flow<UserInfo>()
                      .update((info) => info.copyWith(name: name.value));
                }
              },
            )
          ],
        ),
      ),
    );
  }
}

class AgeForm extends HookWidget {
  const AgeForm({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final age = useState<int?>(null);
    return Scaffold(
      appBar: AppBar(title: const Text('Age')),
      body: Container(
        padding: const EdgeInsets.all(8.0),
        alignment: Alignment.center,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            DropdownButtonFormField<int>(
              items: List.generate(
                200,
                (index) => DropdownMenuItem(
                  value: index,
                  child: Text(index.toString()),
                ),
              ),
              onChanged: (value) => age.value = value,
              decoration: const InputDecoration(
                labelText: 'Age',
                hintText: 'How old are you?',
              ),
            ),
            const SizedBox(height: 24.0),
            ElevatedButton(
              child: const Text('Continue'),
              onPressed: () {
                if (age.value != null) {
                  context
                      .flow<UserInfo>()
                      .update((info) => info.copyWith(age: age.value));
                }
              },
            )
          ],
        ),
      ),
    );
  }
}

class ColorForm extends HookWidget {
  const ColorForm({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final color = useState<Color>(Colors.amber);
    return Scaffold(
      appBar: AppBar(title: const Text('Favorite Color')),
      body: Container(
        padding: const EdgeInsets.all(8.0),
        alignment: Alignment.center,
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: <Widget>[
            ColorPicker(
              pickerColor: color.value,
              onColorChanged: (value) => color.value = value,
              pickerAreaHeightPercent: 0.8,
            ),
            const SizedBox(height: 24.0),
            ElevatedButton(
              child: const Text('Continue'),
              onPressed: () {
                context.flow<UserInfo>().complete(
                    (info) => info.copyWith(favoriteColor: color.value));
              },
            )
          ],
        ),
      ),
    );
  }
}

// DOMAIN

@freezed
abstract class UserInfo with _$UserInfo {
  const factory UserInfo({
    String? name,
    int? age,
    Color? favoriteColor,
  }) = _UserInfo;
}
like image 113
Thierry Avatar answered Oct 01 '22 01:10

Thierry