formless 0.1.8
formless: ^0.1.8 copied to clipboard
A Flutter package that turns any list of fields into an AI-powered conversational form. Validates answers via Groq, OpenAI, Gemini, or DeepSeek and returns a clean key->value map when all fields are collected.
import 'package:flutter/material.dart';
import 'package:formless/formless.dart';
/// Pass your API key at run time, e.g.:
/// `flutter run --dart-define=FORMLESS_API_KEY=your_key_here`
const String _apiKey = String.fromEnvironment('FORMLESS_API_KEY');
void main() {
runApp(const FormlessExampleApp());
}
/// Uses the default searchable sheet for most option steps; for `country` only,
/// shows a custom bottom sheet.
Future<void> _presentFormlessOptionSheet(FormlessOptionsSheetArgs args) async {
if (args.questionKey != 'country') {
return showDefaultFormlessOptionPickerSheet(args);
}
await showModalBottomSheet<void>(
context: args.context,
isScrollControlled: true,
useSafeArea: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (sheetContext) {
return _CountryPickerSheetBody(
title: args.title,
options: args.options,
theme: args.theme,
onPick: (value) {
Navigator.of(sheetContext).pop();
args.select(value);
},
);
},
);
}
class FormlessExampleApp extends StatelessWidget {
const FormlessExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Formless example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.teal),
useMaterial3: true,
),
home: const FormlessDemoPage(),
);
}
}
class FormlessDemoPage extends StatelessWidget {
const FormlessDemoPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('Formless')),
body: _apiKey.isEmpty
? const Center(
child: Text(
'Provide your API key at run time:\n\n'
'flutter run --dart-define=FORMLESS_API_KEY=your_key_here',
textAlign: TextAlign.center,
),
)
: Formless(
provider: AiProvider.groq,
apiKey: _apiKey,
optionsSheetPresenter: _presentFormlessOptionSheet,
questions: [
QuestionsModel(
question: 'What is your first and last name?',
key: 'name',
type: QuestionFieldType.text,
),
QuestionsModel(
question: 'Which plan do you want?',
key: 'plan',
type: QuestionFieldType.text,
options: ['Free', 'Pro', 'Team'],
),
QuestionsModel(
question: 'What is your email address?',
key: 'email',
type: QuestionFieldType.email,
),
QuestionsModel(
question: 'What is your age?',
key: 'age',
type: QuestionFieldType.numeric,
validationMessage: 'Only accept ages between 18 and 120',
),
QuestionsModel(
question: 'What is your phone number?',
key: 'phone',
type: QuestionFieldType.phone,
),
QuestionsModel(
question: 'What company do you work for?',
key: 'company',
type: QuestionFieldType.text,
),
QuestionsModel(
question: 'What is your job title?',
key: 'jobTitle',
type: QuestionFieldType.text,
),
QuestionsModel(
question: 'How did you hear about us?',
key: 'referralSource',
type: QuestionFieldType.text,
options: ['Friend', 'Search', 'Social media', 'Conference'],
),
QuestionsModel(
question: 'Which country are you in?',
key: 'country',
type: QuestionFieldType.text,
optionsSheetTitle: 'Select country',
options: [
'United States',
'United Kingdom',
'Canada',
'Germany',
'France',
'Italy',
'Spain',
'Netherlands',
'Belgium',
'Switzerland',
'Austria',
'Sweden',
'Norway',
'Denmark',
'Finland',
'Poland',
'Portugal',
'Ireland',
'Australia',
'Japan',
'Brazil',
'Mexico',
'India',
'South Korea',
'Singapore',
],
),
QuestionsModel(
question: 'Do you want product updates by email?',
key: 'marketingOptIn',
type: QuestionFieldType.text,
options: ['Yes', 'No'],
),
],
onComplete: (data) {
debugPrint('Formless complete: $data');
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Done: $data')),
);
},
onError: (error) {
debugPrint('Formless error: $error');
},
),
);
}
}
class _CountryPickerSheetBody extends StatefulWidget {
const _CountryPickerSheetBody({
required this.title,
required this.options,
required this.onPick,
this.theme,
});
final String title;
final List<String> options;
final FormlessTheme? theme;
final ValueChanged<String> onPick;
@override
State<_CountryPickerSheetBody> createState() => _CountryPickerSheetBodyState();
}
class _CountryPickerSheetBodyState extends State<_CountryPickerSheetBody> {
final TextEditingController _query = TextEditingController();
late List<String> _filtered;
@override
void initState() {
super.initState();
_filtered = List<String>.from(widget.options);
_query.addListener(_applyFilter);
}
void _applyFilter() {
final q = _query.text.trim().toLowerCase();
setState(() {
if (q.isEmpty) {
_filtered = List<String>.from(widget.options);
} else {
_filtered = widget.options
.where((o) => o.toLowerCase().contains(q))
.toList();
}
});
}
@override
void dispose() {
_query.removeListener(_applyFilter);
_query.dispose();
super.dispose();
}
String _initials(String name) {
final parts = name.split(RegExp(r'\s+')).where((p) => p.isNotEmpty).toList();
if (parts.isEmpty) return '?';
if (parts.length == 1) {
return parts[0].length >= 2
? parts[0].substring(0, 2).toUpperCase()
: parts[0].toUpperCase();
}
return (parts[0][0] + parts[1][0]).toUpperCase();
}
@override
Widget build(BuildContext context) {
final t = widget.theme;
final accent = t?.sendButtonColor ?? Theme.of(context).colorScheme.primary;
return Padding(
padding: EdgeInsets.only(bottom: MediaQuery.viewInsetsOf(context).bottom),
child: SizedBox(
height: MediaQuery.sizeOf(context).height * 0.78,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const SizedBox(height: 8),
Center(
child: Container(
width: 40,
height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 12, 8),
child: Row(
children: [
Expanded(
child: Text(
widget.title,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w600,
),
),
),
IconButton(
onPressed: () => Navigator.of(context).pop(),
icon: const Icon(Icons.close),
),
],
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8),
child: TextField(
controller: _query,
style: t?.inputTextStyle,
decoration: InputDecoration(
hintText: 'Search countries',
prefixIcon: const Icon(Icons.public),
filled: true,
fillColor: t?.botBubbleColor ?? Colors.grey.shade100,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(14),
),
),
),
),
Expanded(
child: _filtered.isEmpty
? Center(
child: Text(
'No matches',
style: TextStyle(color: Colors.grey.shade600),
),
)
: ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 0, 12, 24),
itemCount: _filtered.length,
separatorBuilder: (context, index) =>
const SizedBox(height: 4),
itemBuilder: (context, i) {
final country = _filtered[i];
return Material(
color: Colors.transparent,
child: ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
tileColor: Colors.grey.shade100,
leading: CircleAvatar(
backgroundColor: accent.withValues(alpha: 0.15),
foregroundColor: accent,
child: Text(
_initials(country),
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
),
),
),
title: Text(
country,
style: t?.inputTextStyle,
),
trailing: Icon(Icons.chevron_right, color: accent),
onTap: () => widget.onPick(country),
),
);
},
),
),
],
),
),
);
}
}