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

A Flutter library for building declarative, step-by-step flows with comprehensive state management and customizable navigation behavior.

Sequential Flow #

A Flutter library for building declarative, step-by-step flows with comprehensive state management and customizable navigation behavior.

Installation #

dependencies:
  sequential_flow: ^1.0.0

Core Concepts #

Sequential Flow provides three main components:

  • FlowStep: Defines individual steps with business logic and UI configuration
  • FlowController: Manages state, navigation, and data persistence
  • SequentialFlow: Widget that orchestrates execution and renders UI

Complete Use Case Examples #

User Enrollment Flow #

enum EnrollmentStep {
  welcome,
  emailVerification,
  personalInfo,
  preferences,
  completion,
}

SequentialFlow<EnrollmentStep>(
  steps: [
    // Step 1: Welcome screen with terms acceptance
    FlowStep(
      step: EnrollmentStep.welcome,
      name: 'Welcome',
      progressValue: 0.2,
      onStepCallback: () async {
        await Future.delayed(Duration(seconds: 1));
      },
      requiresConfirmation: (controller) => AlertDialog(
        title: Text('Terms of Service'),
        content: Text('Do you accept our terms?'),
        actions: [
          TextButton(
            onPressed: () => controller.cancelFlow(),
            child: Text('Decline'),
          ),
          TextButton(
            onPressed: () {
              controller.setData('termsAccepted', true);
              controller.continueFlow();
            },
            child: Text('Accept'),
          ),
        ],
      ),
      actionOnPressBack: ActionOnPressBack.cancelFlow,
    ),

    // Step 2: Email verification with retry logic
    FlowStep(
      step: EnrollmentStep.emailVerification,
      name: 'Email Verification',
      progressValue: 0.4,
      onStepCallback: () async {
        final email = controller.getData('email');
        final result = await verifyEmail(email);
        if (!result.verified) {
          throw Exception('Email verification failed: ${result.error}');
        }
      },
      requiresConfirmation: (controller) => AlertDialog(
        title: Text('Enter Email'),
        content: TextField(
          onChanged: (value) => controller.setData('email', value),
          decoration: InputDecoration(hintText: 'your@email.com'),
        ),
        actions: [
          TextButton(
            onPressed: () => controller.continueFlow(),
            child: Text('Verify'),
          ),
        ],
      ),
      actionOnPressBack: ActionOnPressBack.goToPreviousStep,
    ),

    // Step 3: Personal information collection
    FlowStep(
      step: EnrollmentStep.personalInfo,
      name: 'Personal Information',
      progressValue: 0.6,
      onStepCallback: () async {
        final userData = {
          'name': controller.getData('name'),
          'birthDate': controller.getData('birthDate'),
          'phone': controller.getData('phone'),
        };
        await saveUserProfile(userData);
      },
      requiresConfirmation: (controller) => PersonalInfoForm(controller),
      actionOnPressBack: ActionOnPressBack.showSaveDialog,
    ),

    // Step 4: Preferences setup
    FlowStep(
      step: EnrollmentStep.preferences,
      name: 'Setup Preferences',
      progressValue: 0.8,
      onStepCallback: () async {
        await configureUserPreferences(controller.getAllData());
      },
      actionOnPressBack: ActionOnPressBack.goToPreviousStep,
    ),

    // Step 5: Account creation completion
    FlowStep(
      step: EnrollmentStep.completion,
      name: 'Creating Account',
      progressValue: 1.0,
      onStepCallback: () async {
        await createUserAccount(controller.getAllData());
        await sendWelcomeEmail(controller.getData('email'));
      },
      actionOnPressBack: ActionOnPressBack.block,
    ),
  ],
  onStepError: (step, name, error, stack, controller) {
    if (step == EnrollmentStep.emailVerification) {
      return RetryEmailVerification(controller: controller, error: error);
    }
    return GenericErrorWidget(error: error, onRetry: () => controller.retry());
  },
)

App Initialization Flow #

enum InitStep {
  splash,
  permissions,
  dataSync,
  configuration,
  ready,
}

SequentialFlow<InitStep>(
  steps: [
    // Step 1: Splash screen with app loading
    FlowStep(
      step: InitStep.splash,
      name: 'Loading Application',
      progressValue: 0.2,
      onStepCallback: () async {
        await loadApplicationResources();
        await initializeServices();
      },
      actionOnPressBack: ActionOnPressBack.block,
    ),

    // Step 2: Request required permissions
    FlowStep(
      step: InitStep.permissions,
      name: 'Requesting Permissions',
      progressValue: 0.4,
      onStepCallback: () async {
        await Future.delayed(Duration(milliseconds: 500));
      },
      requiresConfirmation: (controller) => PermissionRequestDialog(
        permissions: ['camera', 'location', 'storage'],
        onGranted: (grantedPermissions) {
          controller.setData('permissions', grantedPermissions);
          controller.continueFlow();
        },
        onDenied: () => controller.cancelFlow(),
      ),
      actionOnPressBack: ActionOnPressBack.showCancelDialog,
    ),

    // Step 3: Sync user data from cloud
    FlowStep(
      step: InitStep.dataSync,
      name: 'Syncing Data',
      progressValue: 0.6,
      onStepCallback: () async {
        try {
          final userData = await syncUserDataFromCloud();
          controller.setData('userData', userData);
          
          final appSettings = await downloadAppConfiguration();
          controller.setData('settings', appSettings);
        } on NetworkException catch (e) {
          // Continue with cached data if network fails
          final cachedData = await loadCachedUserData();
          controller.setData('userData', cachedData);
        }
      },
      actionOnPressBack: ActionOnPressBack.block,
    ),

    // Step 4: Apply configuration and setup
    FlowStep(
      step: InitStep.configuration,
      name: 'Configuring Application',
      progressValue: 0.8,
      onStepCallback: () async {
        final settings = controller.getData('settings');
        await applyAppConfiguration(settings);
        
        final userData = controller.getData('userData');
        await setupUserEnvironment(userData);
        
        await initializeAnalytics();
        await preloadCriticalData();
      },
      actionOnPressBack: ActionOnPressBack.block,
    ),

    // Step 5: Finalization
    FlowStep(
      step: InitStep.ready,
      name: 'Finalizing Setup',
      progressValue: 1.0,
      onStepCallback: () async {
        await markAppAsInitialized();
        await triggerInitializationComplete();
      },
      actionOnPressBack: ActionOnPressBack.saveAndExit,
    ),
  ],
  onStepLoading: (step, name, progress) => SplashScreen(
    progress: progress,
    message: name,
    showSkipButton: step == InitStep.dataSync,
  ),
  onStepError: (step, name, error, stack, controller) {
    if (step == InitStep.dataSync && error is NetworkException) {
      return NetworkErrorWidget(
        onRetry: () => controller.retry(),
        onContinueOffline: () => controller.continueFlow(),
      );
    }
    return CriticalErrorWidget(error: error);
  },
)

Online Exam Flow #

enum ExamStep {
  instructions,
  identityVerification,
  examQuestions,
  review,
  submission,
}

class ExamFlowWidget extends StatefulWidget {
  final Exam exam;
  
  const ExamFlowWidget({required this.exam});

  @override
  State<ExamFlowWidget> createState() => _ExamFlowWidgetState();
}

class _ExamFlowWidgetState extends State<ExamFlowWidget> {
  late Timer examTimer;
  late FlowController<ExamStep> controller;

  @override
  void initState() {
    super.initState();
    controller = FlowController<ExamStep>(steps: _buildExamSteps());
    _startExamTimer();
  }

  List<FlowStep<ExamStep>> _buildExamSteps() {
    return [
      // Step 1: Exam instructions and rules
      FlowStep(
        step: ExamStep.instructions,
        name: 'Exam Instructions',
        progressValue: 0.1,
        onStepCallback: () async {
          controller.setData('examStartTime', DateTime.now());
          await logExamEvent('instructions_viewed');
        },
        requiresConfirmation: (controller) => ExamInstructionsDialog(
          exam: widget.exam,
          onAccept: () {
            controller.setData('instructionsAccepted', true);
            controller.continueFlow();
          },
          onDecline: () => controller.cancelFlow(),
        ),
        actionOnPressBack: ActionOnPressBack.showCancelDialog,
      ),

      // Step 2: Identity verification (camera/photo)
      FlowStep(
        step: ExamStep.identityVerification,
        name: 'Identity Verification',
        progressValue: 0.2,
        onStepCallback: () async {
          await Future.delayed(Duration(seconds: 2));
        },
        requiresConfirmation: (controller) => IdentityVerificationWidget(
          onVerified: (verificationData) {
            controller.setData('identity', verificationData);
            controller.continueFlow();
          },
          onFailed: () => throw Exception('Identity verification failed'),
        ),
        actionOnPressBack: ActionOnPressBack.block,
      ),

      // Step 3: Exam questions (main content)
      FlowStep(
        step: ExamStep.examQuestions,
        name: 'Taking Exam',
        progressValue: 0.7,
        onStepCallback: () async {
          // Auto-save answers periodically
          final answers = controller.getData('answers') ?? {};
          await autoSaveExamAnswers(answers);
        },
        requiresConfirmation: (controller) => ExamQuestionsWidget(
          exam: widget.exam,
          onAnswersChanged: (answers) {
            controller.setData('answers', answers);
          },
          onComplete: () => controller.continueFlow(),
          timeRemaining: _getTimeRemaining(),
        ),
        actionOnPressBack: ActionOnPressBack.custom,
        customBackAction: (controller) async {
          // Show warning about losing progress
          final shouldExit = await showExitExamDialog();
          if (shouldExit) {
            await saveExamDraft(controller.getAllData());
            return true;
          }
          return false;
        },
      ),

      // Step 4: Review answers before submission
      FlowStep(
        step: ExamStep.review,
        name: 'Review Answers',
        progressValue: 0.9,
        onStepCallback: () async {
          final answers = controller.getData('answers');
          await validateAnswers(answers);
        },
        requiresConfirmation: (controller) => ReviewAnswersWidget(
          answers: controller.getData('answers'),
          onEdit: (questionId) {
            // Go back to questions for editing
            controller.continueFlow(flowIndex: 2);
          },
          onSubmit: () => controller.continueFlow(),
        ),
        actionOnPressBack: ActionOnPressBack.goToPreviousStep,
      ),

      // Step 5: Final submission
      FlowStep(
        step: ExamStep.submission,
        name: 'Submitting Exam',
        progressValue: 1.0,
        onStepCallback: () async {
          final examData = controller.getAllData();
          examData['submissionTime'] = DateTime.now();
          
          await submitExam(examData);
          await clearExamDraft();
          examTimer.cancel();
        },
        actionOnPressBack: ActionOnPressBack.block,
      ),
    ];
  }

  void _startExamTimer() {
    examTimer = Timer.periodic(Duration(minutes: 1), (timer) {
      if (_getTimeRemaining() <= 0) {
        // Auto-submit when time expires
        controller.continueFlow(flowIndex: 4);
      }
    });
  }

  Duration _getTimeRemaining() {
    final startTime = controller.getData('examStartTime') as DateTime?;
    if (startTime == null) return widget.exam.duration;
    
    final elapsed = DateTime.now().difference(startTime);
    return widget.exam.duration - elapsed;
  }

  @override
  Widget build(BuildContext context) {
    return WillPopScope(
      onWillPop: () async => false, // Prevent accidental exit
      child: SequentialFlow<ExamStep>(
        steps: controller.steps,
        autoStart: true,
        onStepError: (step, name, error, stack, controller) {
          if (step == ExamStep.submission) {
            return SubmissionErrorWidget(
              error: error,
              onRetry: () => controller.retry(),
              onSaveDraft: () async {
                await saveExamDraft(controller.getAllData());
                Navigator.of(context).pop();
              },
            );
          }
          return ExamErrorWidget(error: error);
        },
        onStepFinish: (step, name, progress, controller) {
          return ExamSubmittedWidget(
            examId: widget.exam.id,
            submissionTime: controller.getData('submissionTime'),
            onExit: () => Navigator.of(context).pop(),
          );
        },
        onPressBack: (controller) {
          // Custom handling for exam-specific dialogs
          return ExamExitConfirmationDialog(
            onExit: () => controller.cancelFlow(),
            onContinue: () => Navigator.of(context).pop(),
          );
        },
      ),
    );
  }

  @override
  void dispose() {
    examTimer.cancel();
    controller.dispose();
    super.dispose();
  }
}

Basic Usage #

Simple Linear Flow #

SequentialFlow<String>(
  steps: [
    FlowStep(
      step: 'init',
      name: 'Initializing',
      progressValue: 0.5,
      onStepCallback: () async {
        await initializeData();
      },
    ),
    FlowStep(
      step: 'complete',
      name: 'Complete',
      progressValue: 1.0,
      onStepCallback: () async {
        await finalizeProcess();
      },
    ),
  ],
)

With Custom UI States #

SequentialFlow<ProcessStep>(
  steps: steps,
  onStepLoading: (step, name, progress) => Column(
    children: [
      CircularProgressIndicator(value: progress),
      Text(name),
    ],
  ),
  onStepError: (step, name, error, stack, controller) => Column(
    children: [
      Text('Error: $error'),
      ElevatedButton(
        onPressed: () => controller.retry(),
        child: Text('Retry'),
      ),
    ],
  ),
)

ActionOnPressBack Options #

FlowStep(
  step: MyStep.critical,
  name: 'Critical Process',
  progressValue: 0.8,
  onStepCallback: () async { /* ... */ },
  actionOnPressBack: ActionOnPressBack.block, // Prevent navigation
)

Available options:

ActionOnPressBack Behavior Use Case
block Prevents back navigation Critical operations, payments
goToPreviousStep Returns to previous step Standard navigation
cancelFlow Immediately cancels flow Quick exit scenarios
showCancelDialog Shows custom dialog Confirmation before exit
showSaveDialog Shows save/exit dialog Draft preservation
saveAndExit Allows normal exit Completed processes
custom Executes custom function Complex navigation logic
goToSpecificStep Jumps to specified step Non-linear flows

Custom Back Navigation #

FlowStep(
  step: MyStep.complex,
  actionOnPressBack: ActionOnPressBack.custom,
  customBackAction: (controller) async {
    // Save draft data
    await saveDraft(controller.getAllData());
    return true; // Allow exit
  },
)

Dialog-Based Navigation #

SequentialFlow(
  steps: steps,
  onPressBack: (controller) {
    final currentStep = controller.steps[controller.currentStepIndex];
    
    if (currentStep.actionOnPressBack == ActionOnPressBack.showCancelDialog) {
      return AlertDialog(
        title: Text('Cancel Process?'),
        actions: [
          TextButton(
            onPressed: () => controller.cancelFlow(),
            child: Text('Cancel'),
          ),
        ],
      );
    }
    
    return SizedBox.shrink();
  },
)

User Confirmation and Input #

Confirmation Steps #

FlowStep(
  step: ProcessStep.confirmation,
  name: 'Confirm Action',
  progressValue: 0.7,
  onStepCallback: () async {
    await processConfirmation();
  },
  requiresConfirmation: (controller) => AlertDialog(
    title: Text('Proceed?'),
    actions: [
      TextButton(
        onPressed: () => controller.continueFlow(),
        child: Text('Yes'),
      ),
      TextButton(
        onPressed: () => controller.cancelFlow(),
        child: Text('No'),
      ),
    ],
  ),
)

Data Collection Between Steps #

// Step 1: Collect user input
FlowStep(
  step: ProcessStep.input,
  requiresConfirmation: (controller) => AlertDialog(
    content: TextField(
      onChanged: (value) => controller.setData('userInput', value),
    ),
    actions: [
      TextButton(
        onPressed: () => controller.continueFlow(),
        child: Text('Next'),
      ),
    ],
  ),
)

// Step 2: Use collected data
FlowStep(
  step: ProcessStep.process,
  onStepCallback: () async {
    final input = controller.getData('userInput');
    await processWithInput(input);
  },
)

Error Handling and Recovery #

Automatic Retry Logic #

FlowStep(
  step: ProcessStep.networkCall,
  onStepCallback: () async {
    final response = await api.call();
    if (!response.isSuccess) {
      throw ApiException(response.error);
    }
  },
)

// In the widget:
onStepError: (step, name, error, stack, controller) {
  if (error is ApiException) {
    return Column(
      children: [
        Text('Network error occurred'),
        ElevatedButton(
          onPressed: () => controller.retry(),
          child: Text('Retry'),
        ),
      ],
    );
  }
  return DefaultErrorWidget(error);
}

Conditional Flow Navigation #

FlowStep(
  step: ProcessStep.validation,
  onStepCallback: () async {
    final isValid = await validateData();
    if (!isValid) {
      // Jump to error correction step
      controller.continueFlow(flowIndex: 2);
      return;
    }
    // Continue normal flow
  },
)

Advanced Customization #

Manual Flow Control #

class CustomFlowWidget extends StatefulWidget {
  @override
  State<CustomFlowWidget> createState() => _CustomFlowWidgetState();
}

class _CustomFlowWidgetState extends State<CustomFlowWidget> {
  late FlowController<MyStep> controller;

  @override
  void initState() {
    super.initState();
    controller = FlowController<MyStep>(steps: steps);
    // Don't auto-start
  }

  void startFlow() async {
    await controller.start();
  }

  void pauseFlow() {
    // Custom pause logic
    controller.cancelFlow();
  }

  @override
  Widget build(BuildContext context) {
    return SequentialFlow<MyStep>(
      steps: controller.steps,
      autoStart: false,
      // ... other configuration
    );
  }
}

Dynamic Step Generation #

List<FlowStep<String>> buildDynamicSteps(List<Task> tasks) {
  return tasks.asMap().entries.map((entry) {
    final index = entry.key;
    final task = entry.value;
    
    return FlowStep<String>(
      step: task.id,
      name: task.name,
      progressValue: (index + 1) / tasks.length,
      onStepCallback: () async {
        await executeTask(task);
      },
      actionOnPressBack: task.isCritical 
          ? ActionOnPressBack.block 
          : ActionOnPressBack.goToPreviousStep,
    );
  }).toList();
}

Highly Suitable For #

  • Onboarding flows: User registration, app setup, permissions
  • Data migration: Multi-step import/export processes
  • Payment processing: Amount selection, method, details, processing
  • Wizard-style forms: Complex multi-page data collection
  • Setup processes: Configuration workflows, installation steps
  • Content creation: Multi-step publishing, form builders

Technical Requirements #

  • Sequential operations: When steps must execute in order
  • State persistence: Data needs to persist between steps
  • Error recovery: Ability to retry failed operations
  • Custom navigation: Non-standard back button behavior required
  • Progress indication: Visual feedback for multi-step processes

Less Suitable For #

  • Simple forms: Single-page forms with basic validation
  • Real-time updates: Live data streaming or chat interfaces
  • Media players: Audio/video playback controls
  • Gaming interfaces: Rapid user interactions, real-time feedback
  • Dashboard widgets: Static data display components

Complex Implementation Patterns #

Multi-Branch Flows #

FlowStep(
  step: ProcessStep.decision,
  onStepCallback: () async {
    final userType = await determineUserType();
    final nextStepIndex = userType == 'premium' ? 3 : 5;
    controller.continueFlow(flowIndex: nextStepIndex);
  },
)

Nested Sub-Flows #

FlowStep(
  step: ProcessStep.subProcess,
  requiresConfirmation: (controller) => SequentialFlow<SubStep>(
    steps: subSteps,
    onStepFinish: (step, name, progress, subController) {
      // Transfer data from sub-flow to main flow
      final subData = subController.getAllData();
      controller.setData('subFlowResult', subData);
      controller.continueFlow();
      
      return SizedBox.shrink();
    },
  ),
)

Background Processing with Updates #

FlowStep(
  step: ProcessStep.backgroundTask,
  onStepCallback: () async {
    await for (final progress in processLargeFile()) {
      controller.setData('currentProgress', progress);
      // Trigger UI update without completing step
      controller.notifyListeners();
    }
  },
)

// Custom loading widget that reads progress
onStepLoading: (step, name, progress) {
  final currentProgress = controller.getData('currentProgress') ?? 0.0;
  return Column(
    children: [
      LinearProgressIndicator(value: currentProgress),
      Text('Processing: ${(currentProgress * 100).toInt()}%'),
    ],
  );
}

Integration with External State Management #

class FlowWithBloc extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return BlocBuilder<AppBloc, AppState>(
      builder: (context, state) {
        return SequentialFlow<ProcessStep>(
          steps: _buildStepsFromState(state),
          onStepFinish: (step, name, progress, controller) {
            // Update external state
            context.read<AppBloc>().add(
              FlowCompleted(controller.getAllData())
            );
            
            return CompletionWidget();
          },
        );
      },
    );
  }
}

Performance Considerations #

Memory Management #

  • Flow data is stored in memory only
  • Call controller.reset() to clear data after completion
  • Dispose controllers properly in StatefulWidget.dispose()

Large Step Counts #

  • Library handles 50+ steps efficiently
  • Consider pagination for 100+ steps
  • Use lazy loading for dynamic step generation

Resource Cleanup #

@override
void dispose() {
  controller.dispose();
  super.dispose();
}

Limitations #

  • Memory-only storage: Data doesn't persist across app restarts
  • Flutter dependency: Requires Flutter framework, not pure Dart

Migration and Compatibility #

  • Minimum Flutter version: 3.0.0
  • Dart SDK: >=2.17.0
  • Platform support: iOS, Android, Web (html, wasm), Desktop
  • State management: Any

Testing #

testWidgets('should complete payment flow', (tester) async {
  await tester.pumpWidget(PaymentFlowWidget());
  
  // Wait for first step
  await tester.pumpAndSettle();
  
  // Interact with flow
  await tester.tap(find.text('Continue'));
  await tester.pumpAndSettle();
  
  // Verify completion
  expect(find.text('Payment Successful'), findsOneWidget);
});
1
likes
150
points
9
downloads

Publisher

verified publisherjhonacode.com

Weekly Downloads

A Flutter library for building declarative, step-by-step flows with comprehensive state management and customizable navigation behavior.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

flutter

More

Packages that depend on sequential_flow