form_phoenix 1.0.0
form_phoenix: ^1.0.0 copied to clipboard
Advanced Flutter form handler with undo, redo, and recovery
import 'package:flutter/material.dart';
import 'package:form_phoenix/form_phoenix.dart';
import 'package:image_picker/image_picker.dart';
import 'dart:io';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Form Phoenix Wizard',
debugShowCheckedModeBanner: false,
theme: ThemeData(
useMaterial3: true,
colorScheme: ColorScheme.fromSeed(
seedColor: Colors.deepPurple,
brightness: Brightness.light,
),
),
home: const MultiStepWizardPage(),
);
}
}
class MultiStepWizardPage extends StatefulWidget {
const MultiStepWizardPage({super.key});
@override
State<MultiStepWizardPage> createState() => _MultiStepWizardPageState();
}
class _MultiStepWizardPageState extends State<MultiStepWizardPage> {
late final FormPhoenixController _formController;
int _currentStep = 0;
@override
void initState() {
super.initState();
_formController = FormPhoenixController(
maxHistory: 50,
debounceDuration: const Duration(milliseconds: 300),
persistKey: 'wizard_v1',
syncOnRestart: true,
enableDataEncryption: true,
);
_formController.init();
}
@override
void dispose() {
_formController.dispose();
super.dispose();
}
void _nextStep() {
setState(() => _currentStep++);
}
void _prevStep() {
setState(() => _currentStep--);
}
void _reset() {
_formController.clearHistory();
setState(() => _currentStep = 0);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('Step ${_currentStep + 1} of 3'),
actions: [
IconButton(
icon: const Icon(Icons.history),
onPressed: () {
showModalBottomSheet(
context: context,
builder: (_) => HistoryViewer(controller: _formController),
);
},
tooltip: 'View History',
),
// Global Undo/Redo
ListenableBuilder(
listenable: _formController,
builder: (context, _) {
return Row(
children: [
IconButton(
icon: const Icon(Icons.undo),
onPressed: _formController.canUndo
? _formController.undo
: null,
),
IconButton(
icon: const Icon(Icons.redo),
onPressed: _formController.canRedo
? _formController.redo
: null,
),
],
);
},
),
],
),
body: SafeArea(
child: Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: _buildStepContent(),
),
),
],
),
),
);
}
Widget _buildStepContent() {
if (_currentStep == 0) {
return Step1Form(formController: _formController, onNext: _nextStep);
} else if (_currentStep == 1) {
return Step2Form(
formController: _formController,
onPrev: _prevStep,
onNext: _nextStep,
);
} else if (_currentStep == 2) {
return Step3Form(
formController: _formController,
onPrev: _prevStep,
onSubmit: _nextStep, // Move to summary (index 3)
);
} else {
return SummaryScreen(formController: _formController, onRestart: _reset);
}
}
}
// --- Step 1 ---
class Step1Form extends StatefulWidget {
final FormPhoenixController formController;
final VoidCallback onNext;
const Step1Form({
required this.formController,
required this.onNext,
super.key,
});
@override
State<Step1Form> createState() => _Step1FormState();
}
class _Step1FormState extends State<Step1Form> {
late final TextEditingController _firstNameCtrl;
late final TextEditingController _lastNameCtrl;
@override
void initState() {
super.initState();
_firstNameCtrl = TextEditingController();
_lastNameCtrl = TextEditingController();
widget.formController.bindTextController(
field: 'first_name',
controller: _firstNameCtrl,
);
widget.formController.bindTextController(
field: 'last_name',
controller: _lastNameCtrl,
);
}
@override
void dispose() {
_firstNameCtrl.dispose();
_lastNameCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Personal Information',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 20),
TextField(
controller: _firstNameCtrl,
decoration: const InputDecoration(
labelText: 'First Name',
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
TextField(
controller: _lastNameCtrl,
decoration: const InputDecoration(
labelText: 'Last Name',
border: OutlineInputBorder(),
),
),
const Spacer(),
ElevatedButton(onPressed: widget.onNext, child: const Text('Next')),
],
);
}
}
// --- Step 2 ---
class Step2Form extends StatefulWidget {
final FormPhoenixController formController;
final VoidCallback onPrev;
final VoidCallback onNext;
const Step2Form({
required this.formController,
required this.onPrev,
required this.onNext,
super.key,
});
@override
State<Step2Form> createState() => _Step2FormState();
}
class _Step2FormState extends State<Step2Form> {
late final TextEditingController _emailCtrl;
late final TextEditingController _phoneCtrl;
@override
void initState() {
super.initState();
_emailCtrl = TextEditingController();
_phoneCtrl = TextEditingController();
widget.formController.bindTextController(
field: 'email',
controller: _emailCtrl,
);
widget.formController.bindTextController(
field: 'phone',
controller: _phoneCtrl,
);
}
@override
void dispose() {
_emailCtrl.dispose();
_phoneCtrl.dispose();
super.dispose();
}
Future<void> _pickImage() async {
final picker = ImagePicker();
final XFile? image = await picker.pickImage(source: ImageSource.gallery);
if (image != null) {
widget.formController.updateFieldImmediate('profile_image', image.path);
}
}
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: widget.formController,
builder: (context, _) {
final imagePath = widget.formController.getValue<String>(
'profile_image',
);
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Contact Details',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 20),
TextField(
controller: _emailCtrl,
decoration: const InputDecoration(
labelText: 'Email Address',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.email),
),
keyboardType: TextInputType.emailAddress,
),
const SizedBox(height: 16),
TextField(
controller: _phoneCtrl,
decoration: const InputDecoration(
labelText: 'Phone Number',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.phone),
),
keyboardType: TextInputType.phone,
),
const SizedBox(height: 24),
Text(
'Profile Photo',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Center(
child: GestureDetector(
onTap: _pickImage,
child: Container(
width: 120,
height: 120,
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: Theme.of(
context,
).colorScheme.outline.withValues(alpha: 0.3),
),
image: imagePath != null
? DecorationImage(
image: FileImage(File(imagePath)),
fit: BoxFit.cover,
)
: null,
),
child: imagePath == null
? const Icon(Icons.add_a_photo, size: 40)
: null,
),
),
),
if (imagePath != null)
TextButton.icon(
onPressed: _pickImage,
icon: const Icon(Icons.edit),
label: const Text('Change Photo'),
),
const Spacer(),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: widget.onPrev,
child: const Text('Previous'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
onPressed: widget.onNext,
child: const Text('Next'),
),
),
],
),
],
);
},
);
}
}
// --- Step 3 ---
class Step3Form extends StatefulWidget {
final FormPhoenixController formController;
final VoidCallback onPrev;
final VoidCallback onSubmit;
const Step3Form({
required this.formController,
required this.onPrev,
required this.onSubmit,
super.key,
});
@override
State<Step3Form> createState() => _Step3FormState();
}
class _Step3FormState extends State<Step3Form> {
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: widget.formController,
builder: (context, _) {
final jobRole = widget.formController.getValue<String>('job_role');
final experience =
widget.formController.getValue<double>('experience') ?? 0;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Job Preferences',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 20),
DropdownButtonFormField<String>(
key: ValueKey(jobRole),
initialValue: jobRole,
decoration: const InputDecoration(
labelText: 'Job Role',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(
value: 'Frontend',
child: Text('Frontend Developer'),
),
DropdownMenuItem(
value: 'Backend',
child: Text('Backend Developer'),
),
DropdownMenuItem(
value: 'Fullstack',
child: Text('Fullstack Developer'),
),
DropdownMenuItem(
value: 'Mobile',
child: Text('Mobile Developer'),
),
],
onChanged: (v) {
if (v != null) {
widget.formController.updateField('job_role', v);
}
},
),
const SizedBox(height: 24),
Text(
'Years of Experience: ${experience.toInt()}',
style: Theme.of(context).textTheme.titleMedium,
),
Slider(
value: experience,
min: 0,
max: 20,
divisions: 20,
label: experience.toInt().toString(),
onChanged: (v) =>
widget.formController.updateField('experience', v),
),
const Spacer(),
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: widget.onPrev,
child: const Text('Previous'),
),
),
const SizedBox(width: 16),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Theme.of(context).colorScheme.primary,
foregroundColor: Theme.of(context).colorScheme.onPrimary,
),
onPressed: widget.onSubmit,
child: const Text('Submit'),
),
),
],
),
],
);
},
);
}
}
// --- Summary Screen ---
class SummaryScreen extends StatelessWidget {
final FormPhoenixController formController;
final VoidCallback onRestart;
const SummaryScreen({
required this.formController,
required this.onRestart,
super.key,
});
@override
Widget build(BuildContext context) {
final data = formController.currentData;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Submission Summary',
style: Theme.of(context).textTheme.headlineMedium,
),
const SizedBox(height: 8),
const Text('All data collected across steps:'),
const SizedBox(height: 16),
Expanded(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Theme.of(
context,
).colorScheme.surfaceContainerHighest.withValues(alpha: 0.3),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: Theme.of(context).colorScheme.outlineVariant,
),
),
child: SingleChildScrollView(
child: Text(
_prettyPrint(data),
style: const TextStyle(fontFamily: 'Courier', fontSize: 14),
),
),
),
),
if (data['profile_image'] != null) ...[
const SizedBox(height: 16),
const Center(
child: Text(
'Restored Profile Photo',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
const SizedBox(height: 8),
Center(
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: Image.file(
File(data['profile_image']),
width: 120,
height: 120,
fit: BoxFit.cover,
errorBuilder: (context, error, stackTrace) =>
const Icon(Icons.broken_image, size: 60),
),
),
),
],
const SizedBox(height: 16),
ElevatedButton.icon(
onPressed: onRestart,
icon: const Icon(Icons.refresh),
label: const Text('Start Over'),
),
],
);
}
String _prettyPrint(Map<String, dynamic> data) {
if (data.isEmpty) return 'No data';
final buffer = StringBuffer();
data.forEach((key, value) {
buffer.writeln('$key: $value');
});
return buffer.toString();
}
}
// --- Helper: History Viewer ---
class HistoryViewer extends StatelessWidget {
final FormPhoenixController controller;
const HistoryViewer({required this.controller, super.key});
@override
Widget build(BuildContext context) {
return ListenableBuilder(
listenable: controller,
builder: (context, _) {
final history = controller.exportHistory();
return Column(
children: [
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'Time Travel History',
style: Theme.of(context).textTheme.titleLarge,
),
),
Expanded(
child: ListView.builder(
itemCount: history.length,
itemBuilder: (context, index) {
final isCurrent = index == controller.currentIndex;
final item = history[index];
final timestamp = DateTime.parse(item['timestamp']);
return ListTile(
selected: isCurrent,
selectedTileColor: Theme.of(
context,
).colorScheme.secondaryContainer,
leading: CircleAvatar(
backgroundColor: isCurrent
? Theme.of(context).colorScheme.primary
: null,
foregroundColor: isCurrent
? Theme.of(context).colorScheme.onPrimary
: null,
child: Text('$index'),
),
title: Text(
'Snapshot at ${timestamp.hour}:${timestamp.minute}:${timestamp.second}',
),
subtitle: Text(
(item['data'] as Map).keys.join(', '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
onTap: () {
controller.jumpTo(index);
Navigator.pop(context);
},
);
},
),
),
],
);
},
);
}
}