num2words_plus 1.0.0
num2words_plus: ^1.0.0 copied to clipboard
Convert numbers to words for accounting, invoicing, and banking apps. Supports Nepali and English with the Nepali numbering system. Offline-first, zero-dependency, Flutter and Dart compatible.
// Copyright 2026 Avoloft Technologies
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:num2words_plus/num2words_plus.dart';
void main() {
runApp(const Num2WordsDemoApp());
}
class Num2WordsDemoApp extends StatelessWidget {
const Num2WordsDemoApp({super.key});
@override
Widget build(BuildContext context) {
final colorScheme = ColorScheme.fromSeed(
seedColor: const Color(0xFF0F766E),
brightness: Brightness.light,
);
return MaterialApp(
title: 'num2words_plus demo',
debugShowCheckedModeBanner: false,
theme: ThemeData(
colorScheme: colorScheme,
useMaterial3: true,
scaffoldBackgroundColor: const Color(0xFFF8FAF7),
inputDecorationTheme: const InputDecorationTheme(
border: OutlineInputBorder(),
filled: true,
fillColor: Colors.white,
),
),
home: const DemoHomePage(),
);
}
}
class DemoHomePage extends StatefulWidget {
const DemoHomePage({super.key});
@override
State<DemoHomePage> createState() => _DemoHomePageState();
}
class _DemoHomePageState extends State<DemoHomePage> {
final _numberController = TextEditingController(text: '1234.56');
final _primaryUnitController = TextEditingController(text: 'रुपैयाँ');
final _secondaryUnitController = TextEditingController(text: 'पैसा');
Num2WordsLanguage _language = Num2WordsLanguage.nepali;
Num2WordsMode _mode = Num2WordsMode.standard;
Num2WordsLettercase _letterCase = Num2WordsLettercase.lowercase;
bool _useCurrencyUnits = false;
@override
void dispose() {
_numberController.dispose();
_primaryUnitController.dispose();
_secondaryUnitController.dispose();
super.dispose();
}
bool get _canUseCurrencyUnits =>
_mode == Num2WordsMode.standard || _mode == Num2WordsMode.check;
String get _convertedText {
try {
final converter = Num2WordsPlus(
language: _language,
mode: _mode,
letterCase: _letterCase,
primaryUnit: _useCurrencyUnits && _canUseCurrencyUnits
? _primaryUnitController.text
: null,
secondaryUnit: _useCurrencyUnits && _canUseCurrencyUnits
? _secondaryUnitController.text
: null,
);
return converter.parse(_numberController.text);
} on Num2WordsException catch (error) {
return error.message;
} on Object catch (error) {
return error.toString();
}
}
bool get _hasError {
try {
final converter = Num2WordsPlus(
language: _language,
mode: _mode,
letterCase: _letterCase,
primaryUnit: _useCurrencyUnits && _canUseCurrencyUnits
? _primaryUnitController.text
: null,
secondaryUnit: _useCurrencyUnits && _canUseCurrencyUnits
? _secondaryUnitController.text
: null,
);
converter.parse(_numberController.text);
return false;
} on Object {
return true;
}
}
void _setMode(Num2WordsMode mode) {
setState(() {
_mode = mode;
if (!_canUseCurrencyUnits) {
_useCurrencyUnits = false;
}
if (_mode == Num2WordsMode.check) {
_language = Num2WordsLanguage.english;
_letterCase = Num2WordsLettercase.titlecase;
_numberController.text = '1234.56';
} else if (_mode == Num2WordsMode.phone) {
_numberController.text = '9841234567';
} else if (_mode == Num2WordsMode.year) {
_numberController.text =
_language == Num2WordsLanguage.nepali ? '2077' : '2024';
} else if (_mode == Num2WordsMode.ordinal) {
_numberController.text = '21';
}
});
}
void _loadSample(String value) {
setState(() => _numberController.text = value);
}
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final output = _convertedText;
final hasError = _hasError;
return Scaffold(
body: SafeArea(
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: _Header(
onSampleSelected: _loadSample,
selectedLanguage: _language,
),
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 32),
sliver: SliverToBoxAdapter(
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1120),
child: LayoutBuilder(
builder: (context, constraints) {
final wide = constraints.maxWidth >= 860;
final controls = _ControlsPanel(
numberController: _numberController,
primaryUnitController: _primaryUnitController,
secondaryUnitController: _secondaryUnitController,
language: _language,
mode: _mode,
letterCase: _letterCase,
useCurrencyUnits: _useCurrencyUnits,
canUseCurrencyUnits: _canUseCurrencyUnits,
onChanged: () => setState(() {}),
onLanguageChanged: (value) {
setState(() => _language = value);
},
onModeChanged: _setMode,
onLetterCaseChanged: (value) {
setState(() => _letterCase = value);
},
onCurrencyUnitsChanged: (value) {
setState(() => _useCurrencyUnits = value);
},
);
final result = _ResultPanel(
output: output,
hasError: hasError,
onCopy: output.isEmpty
? null
: () => Clipboard.setData(
ClipboardData(text: output),
),
);
if (wide) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(flex: 5, child: controls),
const SizedBox(width: 16),
Expanded(flex: 4, child: result),
],
);
}
return Column(
children: [
controls,
const SizedBox(height: 16),
result,
],
);
},
),
),
),
),
),
],
),
),
bottomNavigationBar: Container(
decoration: BoxDecoration(
color: Colors.white,
border: Border(top: BorderSide(color: theme.dividerColor)),
),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
child: Text(
'Runs locally in the browser with no network calls.',
textAlign: TextAlign.center,
style: theme.textTheme.bodySmall,
),
),
);
}
}
class _Header extends StatelessWidget {
const _Header({
required this.onSampleSelected,
required this.selectedLanguage,
});
final ValueChanged<String> onSampleSelected;
final Num2WordsLanguage selectedLanguage;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Container(
width: double.infinity,
decoration: const BoxDecoration(
color: Color(0xFF17324D),
),
padding: const EdgeInsets.fromLTRB(16, 28, 16, 24),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 1120),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'num2words_plus',
style: theme.textTheme.displaySmall?.copyWith(
color: Colors.white,
fontWeight: FontWeight.w800,
letterSpacing: 0,
),
),
const SizedBox(height: 8),
Text(
selectedLanguage == Num2WordsLanguage.nepali
? 'नेपाली र English number words for invoices, receipts, and forms.'
: 'Nepali and English number words for invoices, receipts, and forms.',
style: theme.textTheme.titleMedium?.copyWith(
color: const Color(0xFFE9F3EF),
),
),
const SizedBox(height: 18),
Wrap(
spacing: 8,
runSpacing: 8,
children: [
_SampleButton(
label: '1234.56',
onPressed: () => onSampleSelected('1234.56'),
),
_SampleButton(
label: '100000',
onPressed: () => onSampleSelected('100000'),
),
_SampleButton(
label: '9841234567',
onPressed: () => onSampleSelected('9841234567'),
),
_SampleButton(
label: '2077',
onPressed: () => onSampleSelected('2077'),
),
],
),
],
),
),
),
);
}
}
class _SampleButton extends StatelessWidget {
const _SampleButton({
required this.label,
required this.onPressed,
});
final String label;
final VoidCallback onPressed;
@override
Widget build(BuildContext context) {
return FilledButton.tonalIcon(
onPressed: onPressed,
icon: const Icon(Icons.auto_awesome, size: 18),
label: Text(label),
);
}
}
class _ControlsPanel extends StatelessWidget {
const _ControlsPanel({
required this.numberController,
required this.primaryUnitController,
required this.secondaryUnitController,
required this.language,
required this.mode,
required this.letterCase,
required this.useCurrencyUnits,
required this.canUseCurrencyUnits,
required this.onChanged,
required this.onLanguageChanged,
required this.onModeChanged,
required this.onLetterCaseChanged,
required this.onCurrencyUnitsChanged,
});
final TextEditingController numberController;
final TextEditingController primaryUnitController;
final TextEditingController secondaryUnitController;
final Num2WordsLanguage language;
final Num2WordsMode mode;
final Num2WordsLettercase letterCase;
final bool useCurrencyUnits;
final bool canUseCurrencyUnits;
final VoidCallback onChanged;
final ValueChanged<Num2WordsLanguage> onLanguageChanged;
final ValueChanged<Num2WordsMode> onModeChanged;
final ValueChanged<Num2WordsLettercase> onLetterCaseChanged;
final ValueChanged<bool> onCurrencyUnitsChanged;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: theme.dividerColor),
),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Try a value', style: theme.textTheme.titleLarge),
const SizedBox(height: 14),
TextField(
controller: numberController,
keyboardType: const TextInputType.numberWithOptions(
decimal: true,
),
decoration: const InputDecoration(
labelText: 'Number',
prefixIcon: Icon(Icons.pin_outlined),
),
onChanged: (_) => onChanged(),
),
const SizedBox(height: 18),
const _ControlLabel('Language'),
const SizedBox(height: 8),
SegmentedButton<Num2WordsLanguage>(
segments: const [
ButtonSegment(
value: Num2WordsLanguage.nepali,
label: Text('Nepali'),
icon: Icon(Icons.translate),
),
ButtonSegment(
value: Num2WordsLanguage.english,
label: Text('English'),
icon: Icon(Icons.language),
),
],
selected: {language},
onSelectionChanged: (selection) {
onLanguageChanged(selection.first);
},
),
const SizedBox(height: 18),
const _ControlLabel('Mode'),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
children: Num2WordsMode.values.map((value) {
final selected = value == mode;
return ChoiceChip(
label: Text(_modeLabel(value)),
avatar: Icon(
_modeIcon(value),
size: 18,
color: selected
? theme.colorScheme.onSecondaryContainer
: theme.colorScheme.onSurfaceVariant,
),
selected: selected,
onSelected: (_) => onModeChanged(value),
);
}).toList(),
),
const SizedBox(height: 18),
const _ControlLabel('Letter case'),
const SizedBox(height: 8),
DropdownButtonFormField<Num2WordsLettercase>(
initialValue: letterCase,
decoration: const InputDecoration(
prefixIcon: Icon(Icons.text_fields),
),
items: Num2WordsLettercase.values.map((value) {
return DropdownMenuItem(
value: value,
child: Text(_letterCaseLabel(value)),
);
}).toList(),
onChanged: (value) {
if (value != null) {
onLetterCaseChanged(value);
}
},
),
const SizedBox(height: 10),
SwitchListTile(
contentPadding: EdgeInsets.zero,
title: const Text('Use currency units'),
value: useCurrencyUnits && canUseCurrencyUnits,
onChanged: canUseCurrencyUnits ? onCurrencyUnitsChanged : null,
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 180),
child: useCurrencyUnits && canUseCurrencyUnits
? Column(
key: const ValueKey('currency-fields'),
children: [
const SizedBox(height: 6),
TextField(
controller: primaryUnitController,
decoration: const InputDecoration(
labelText: 'Primary unit',
prefixIcon: Icon(Icons.account_balance_wallet),
),
onChanged: (_) => onChanged(),
),
const SizedBox(height: 12),
TextField(
controller: secondaryUnitController,
decoration: const InputDecoration(
labelText: 'Secondary unit',
prefixIcon: Icon(Icons.payments_outlined),
),
onChanged: (_) => onChanged(),
),
],
)
: const SizedBox.shrink(),
),
],
),
),
);
}
String _modeLabel(Num2WordsMode value) {
switch (value) {
case Num2WordsMode.standard:
return 'Standard';
case Num2WordsMode.ordinal:
return 'Ordinal';
case Num2WordsMode.year:
return 'Year';
case Num2WordsMode.phone:
return 'Phone';
case Num2WordsMode.check:
return 'Check';
}
}
IconData _modeIcon(Num2WordsMode value) {
switch (value) {
case Num2WordsMode.standard:
return Icons.format_list_numbered;
case Num2WordsMode.ordinal:
return Icons.emoji_events_outlined;
case Num2WordsMode.year:
return Icons.calendar_month_outlined;
case Num2WordsMode.phone:
return Icons.phone_iphone;
case Num2WordsMode.check:
return Icons.receipt_long_outlined;
}
}
String _letterCaseLabel(Num2WordsLettercase value) {
switch (value) {
case Num2WordsLettercase.lowercase:
return 'lowercase';
case Num2WordsLettercase.uppercase:
return 'UPPERCASE';
case Num2WordsLettercase.titlecase:
return 'Title Case';
case Num2WordsLettercase.sentencecase:
return 'Sentence case';
}
}
}
class _ControlLabel extends StatelessWidget {
const _ControlLabel(this.text);
final String text;
@override
Widget build(BuildContext context) {
return Text(
text,
style: Theme.of(context).textTheme.labelLarge,
);
}
}
class _ResultPanel extends StatelessWidget {
const _ResultPanel({
required this.output,
required this.hasError,
required this.onCopy,
});
final String output;
final bool hasError;
final VoidCallback? onCopy;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final color = hasError ? theme.colorScheme.error : const Color(0xFF0F766E);
return Card(
elevation: 0,
color: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
side: BorderSide(color: color.withValues(alpha: 0.35)),
),
child: Padding(
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(
hasError ? Icons.error_outline : Icons.check_circle_outline,
color: color,
),
const SizedBox(width: 8),
Expanded(
child: Text(
hasError ? 'Needs a valid input' : 'Converted words',
style: theme.textTheme.titleLarge,
),
),
IconButton.filledTonal(
tooltip: 'Copy result',
onPressed: onCopy,
icon: const Icon(Icons.copy),
),
],
),
const SizedBox(height: 18),
SelectableText(
output,
style: theme.textTheme.headlineSmall?.copyWith(
color: hasError ? theme.colorScheme.error : Colors.black87,
height: 1.35,
letterSpacing: 0,
),
),
const SizedBox(height: 22),
Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: const Color(0xFFFFF8E7),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFE9C46A)),
),
child: Text(
'final words = Num2WordsPlus(...).parse(input);',
style: theme.textTheme.bodyMedium?.copyWith(
fontFamily: 'monospace',
color: const Color(0xFF4D3A12),
),
),
),
],
),
),
);
}
}