A Flutter package for easy multi-language app localization with automatic text extraction and translation support.
- Easy Setup: Add
.trand.trPto any string for instant translation - Auto Extraction: Automatically extract all translatable text from your Dart files
- Smart Translation: Control translations with conditions and parameters
- Multiple Sources: Support for Dart maps, JSON files, assets, and network data
- Device Locale: Automatically uses device locale on first launch
- AI Integration: Custom translator for easy language conversion
- LanguageScope: Provide scoped
LanguageHelperinstances to specific widget trees - LanguageImprover: Visual translation editor for on-device translation improvement
flutter pub add language_helperfinal languageHelper = LanguageHelper.instance;
main() async {
WidgetsFlutterBinding.ensureInitialized();
// Initialize with empty data (temporary English fallback for development)
await languageHelper.initial(data: []);
// Or initialize with your translation data
// await languageHelper.initial(
// data: [
// LanguageDataProvider.data(myLanguageData),
// ],
// );
runApp(const MyApp());
}Initialization Options:
data: List ofLanguageDataProviderinstances (required)initialCode: Preferred initial language codeisAutoSave: Automatically save and restore language preference (default:true)syncWithDevice: Sync with device language changes (default:true)forceRebuild: Rebuild all widgets on language change (default:true, set tofalsefor better performance)isDebug: Enable debug logging (default:false)
// Simple translation
Text('Hello World'.tr)
// With parameters
Text('Hello @{name}'.trP({'name': 'John'}))
// With conditions
Text('You have @{count} item'.trP({'count': itemCount}))Note: Extension methods (tr, trP, trT, trF) work automatically within LanguageBuilder widgets. They use an internal stack mechanism to access the correct LanguageHelper instance (from LanguageScope, explicit parameter, or LanguageHelper.instance). When used outside of LanguageBuilder, they fall back to LanguageHelper.instance.
class App extends StatelessWidget {
@override
Widget build(BuildContext context) {
return LanguageBuilder(
builder: (context) {
return MaterialApp(
localizationsDelegates: languageHelper.delegates,
supportedLocales: languageHelper.locales,
locale: languageHelper.locale,
home: const HomePage(),
);
}
);
}
}The generator automatically scans your project for text using language_helper extensions (tr, trP, trT, trF) and translate method, then creates organized translation files with your existing translations preserved.
Note: The generator is smart about managing translations:
- Keeps existing translations - Your current translated texts are preserved
- Marks new texts with TODO - Only untranslated texts get TODO markers
- Removes unused texts - Automatically cleans up translations no longer used in your code
First, add the language_helper_generator to your pubspec.yaml:
dev_dependencies:
language_helper_generator: ^latestThen run:
flutter pub getExtract all translatable text and generate language files:
dart run language_helper:generate --languages=en,vi,fr --ignore-todo=enThis creates:
lib/languages/
├── codes.dart
└── data/
├── en.dart // without TODO markers for missing translations
├── vi.dart // with TODO markers for missing translations
└── fr.dart // with TODO markers for missing translationsFor assets or network-based translations:
dart run language_helper:generate --languages=en,vi,fr --jsonCreates:
assets/languages/
├── codes.json
└── data/
├── en.json
├── vi.json
└── fr.jsonJSON files do not support TODO markers. To identify untranslated or new strings, look for entries where the key and value are identical.
| Option | Description | Example |
|---|---|---|
--languages |
Language codes to generate | --languages=en,vi,es |
--ignore-todo |
Skip TODO markers for specific languages | --ignore-todo=en |
--path |
Custom output directory | --path=./lib/languages |
--json |
Generate JSON files instead of Dart | --json |
Skip TODOs in English (your base language):
dart run language_helper:generate --languages=en,vi --ignore-todo=enCustom output path:
dart run language_helper:generate --path=./lib/languages --languages=en,viGenerate for multiple languages:
dart run language_helper:generate --languages=en,vi,es,fr --ignore-todo=en// Using lazy data (functions called when language is accessed)
final languageDataProvider = LanguageDataProvider.lazyData(languageData);
// Using direct data (synchronous, fastest)
final directProvider = LanguageDataProvider.data(languageData);
// With override enabled
final overrideProvider = LanguageDataProvider.data(
overrideData,
override: true, // Overwrites existing translations
);
main() async {
await languageHelper.initial(data: [languageDataProvider]);
runApp(const MyApp());
}Performance Considerations:
LanguageDataProvider.data: Fastest (synchronous, no I/O) - Use for static or pre-loaded translationsLanguageDataProvider.lazyData: Fast (synchronous function calls) - Use when you want to defer loading until a language is accessed. Functions are called on every language change to ensure fresh data.LanguageDataProvider.asset: Medium (async I/O, but bundled with app) - Use for bundled JSON translationsLanguageDataProvider.network: Slowest (async network requests) - Use for remote translations, consider caching for production
// Basic usage
final languageDataProvider = LanguageDataProvider.asset('assets/languages');
// With override disabled (preserve existing translations)
final preserveProvider = LanguageDataProvider.asset(
'assets/languages',
override: false, // Only adds new keys, preserves existing ones
);
main() async {
await languageHelper.initial(data: [languageDataProvider]);
runApp(const MyApp());
}Important:
- Make sure to add the assets to your
pubspec.yaml:
flutter:
assets:
- assets/languages/
- assets/languages/data/- Directory structure: The
parentPathshould containcodes.jsonand adata/subdirectory:
assets/languages/
├── codes.json (List of language codes: ["en", "vi", ...])
└── data/
├── en.json (English translations)
├── vi.json (Vietnamese translations)
└── ...- Error handling: If an asset file is missing, the provider returns empty data for that language without throwing exceptions
// Basic usage
final languageDataProvider = LanguageDataProvider.network('https://api.example.com/translations');
// With authentication headers
final authenticatedProvider = LanguageDataProvider.network(
'https://api.example.com/translations',
headers: {
'Authorization': 'Bearer token',
'X-API-Key': 'your-api-key',
},
);
// With custom HTTP client (for timeouts, retries, etc.)
final client = http.Client();
client.timeout = const Duration(seconds: 10);
final customClientProvider = LanguageDataProvider.network(
'https://api.example.com/translations',
client: client,
override: true, // Overwrites existing translations
);
main() async {
await languageHelper.initial(data: [languageDataProvider]);
runApp(const MyApp());
}Important Notes:
-
URL structure: The
parentUrlshould containcodes.jsonand adata/subdirectory:https://api.example.com/translations/codes.jsonhttps://api.example.com/translations/data/en.jsonhttps://api.example.com/translations/data/vi.json
-
On-demand loading: Network providers load data on-demand when a language is accessed. Each language file is fetched separately when switching languages, and data is reloaded on every language change (not cached between changes). This ensures fresh data but may cause network delays on each switch.
-
Error handling: If a network request fails (network error, timeout, non-200 status), the provider returns empty data for that language without throwing exceptions. This allows your app to continue functioning even if some translations fail to load.
-
Performance: Consider implementing caching strategies for production apps to improve performance and reduce network usage.
-
Security: When using authentication headers, be careful not to expose sensitive credentials in client-side code. Consider using secure storage or environment variables.
final en = {
'Hello World': 'Hello World',
'Hello @{name}': 'Hello @{name}',
'You have @{count} item': LanguageConditions(
param: 'count',
conditions: {
'0': 'You have no items',
'1': 'You have one item',
'_': 'You have @{count} items', // Default
}
),
};
final vi = {
'Hello World': 'Xin chào thế giới',
'Hello @{name}': 'Xin chào @{name}',
'You have @{count} item': 'Bạn có @{count} mục',
};
LazyLanguageData languageData = {
LanguageCodes.en: () => en,
LanguageCodes.vi: () => vi,
};assets/languages/codes.json:
["en", "vi"]assets/languages/data/en.json:
{
"Hello World": "Hello World",
"Hello @{name}": "Hello @{name}",
"You have @{count} item": {
"param": "count",
"conditions": {
"1": "You have one item",
"_": "You have @{count} items"
}
}
}Don't forget to add to pubspec.yaml:
flutter:
assets:
- assets/languages/codes.json
- assets/languages/data/// Change to Vietnamese
await languageHelper.change(LanguageCodes.vi);
// All LanguageBuilder widgets will automatically updateBehavior:
- If the requested language is not available:
- By default: The change is ignored and the current language remains unchanged
- If
useInitialCodeWhenUnavailable: true: Falls back to the initial code if available
Performance:
- For
dataproviders: Fast (synchronous, data already in memory) - For
lazyDataproviders: Fast (synchronous function calls, functions are called on every language change to ensure fresh data) - For
assetproviders: Medium (async I/O, files are read on every change) - For
networkproviders: Slow (async network request on every change, depends on connection)
Note: Translation data is always reloaded from providers on every language change, even if it was previously loaded. This ensures fresh data but may impact performance for network providers. Consider implementing your own caching strategy if needed.
Auto-save: If isAutoSave: true (default), the new language is automatically saved to SharedPreferences and will be restored on the next app launch.
// Add a new provider (will activate immediately by default)
final newProvider = LanguageDataProvider.data(newTranslations);
await languageHelper.addProvider(newProvider);
// Add provider with code merging enabled
await languageHelper.addProvider(
LanguageDataProvider.data(additionalTranslations),
mergeCodes: true,
);
// Add provider without immediate activation
await languageHelper.addProvider(
LanguageDataProvider.lazyData(newLanguageData),
activate: false,
);
// ... do other operations ...
await languageHelper.reload(); // Activate now
// Remove a provider
await languageHelper.removeProvider(oldProvider);
// Remove without immediate activation
await languageHelper.removeProvider(
oldProvider,
activate: false,
);
await languageHelper.reload(); // Update widgets nowNote:
- The
overrideproperty of the provider controls whether new translations overwrite existing ones. UseLanguageDataProvider.data(translations, override: true)to overwrite existing translations. - The
mergeCodesparameter controls whether the provider's supported language codes are added to the helper's available codes. Defaults tofalse. - Providers are added to the end of the providers list. Later providers with
override: truewill overwrite earlier ones. - When
activate: false, data is added/removed but widgets won't update untilreload()orchange()is called. - Be careful not to call
addProviderorremoveProviderwithactivate: trueduring widget build, as it may causesetStateerrors.
// Simple listener
final sub = languageHelper.stream.listen((code) => print('Language changed to: $code'));
// Remember to cancel: sub.cancel()
// In a StatefulWidget
StreamSubscription<LanguageCodes>? _subscription;
@override
void initState() {
super.initState();
_subscription = languageHelper.stream.listen((code) {
setState(() {
// Update state based on language change
});
});
}
@override
void dispose() {
_subscription?.cancel(); // Important: cancel to avoid memory leaks
super.dispose();
}Note: The stream emits the new language code after all LanguageBuilder widgets have been notified to rebuild. Remember to cancel subscriptions to avoid memory leaks.
// Get all supported language codes from all providers
final codes = languageHelper.codes; // Set<LanguageCodes>
// Get all supported locales (for MaterialApp/CupertinoApp)
final locales = languageHelper.locales; // Set<Locale>
// Get current language
final currentCode = languageHelper.code; // LanguageCodes
final currentLocale = languageHelper.locale; // Locale
// Check if a language is available
if (languageHelper.codes.contains(LanguageCodes.vi)) {
await languageHelper.change(LanguageCodes.vi);
}
// Access all loaded translation data
final allData = languageHelper.data; // LanguageData
final englishTranslations = allData[LanguageCodes.en];Important:
- You must call
await initial()before accessing these properties codesreturns all language codes from all registered providersdatacontains the currently loaded language data. For lazy/network providers, data is reloaded on every language change to ensure freshness.
To use LanguageHelper and LanguageDelegate in your Flutter app, set up the localizationsDelegates and supportedLocales in your MaterialApp. You can manage multiple helpers for different parts of your app (e.g., in a package).
// The main app's LanguageHelper (singleton)
final mainHelper = LanguageHelper.instance;
// For a specific package/widget tree, use a dedicated helper if needed
final packageHelper = LanguageHelper('PackageWidget');
return MaterialApp(
localizationsDelegates: [
// Flutter's built-in localizations
...mainHelper.delegates,
// Language delegate for the package/widget tree
LanguageDelegate(packageHelper),
// Optionally, you can add more delegates here
],
supportedLocales: mainHelper.locales,
locale: mainHelper.locale,
// ...
);Now, both your main app and PackageWidget will automatically update languages together when you change the locale via LanguageHelper. For more advanced use cases and examples, see the example app.
- Fast: Uses Dart Analyzer, no build_runner dependency
- Smart: Preserves existing translations
- Organized: Groups translations by file path
- Helpful: Adds TODO markers for missing translations
- Clean: Removes unused translation keys automatically
# Custom output path
dart run language_helper:generate --path=./lib/languages --languages=en,vi
# Generate JSON to assets folder
dart run language_helper:generate --path=./assets/languages --languages=en,vi --jsonmain() async {
await languageHelper.initial(
data: [
// Assume that our `code.json` in `https://api.example.com/translations/code.json`
// So our data will be in `https://api.example.com/translations/data/en.json`
LanguageDataProvider.network('https://api.example.com/translations'),
// Assume that our `code.json` in `assets/languages/code.json`
// So our data will be in `assets/languages/en.json`
LanguageDataProvider.asset('assets/languages'),
LanguageDataProvider.lazyData(localLanguageData),
],
);
runApp(const MyApp());
}Data Priority: When multiple sources contain the same translation:
- Provider order matters - Data sources are processed in the order they're added (first to last)
- Override property - Providers with
override: true(default) will overwrite existing translations with the same keys from earlier providers- Preserve existing - When
override: false, only new translation keys are added; existing keys from earlier providers are preserved- Adding providers - New providers added via
addProviderare appended to the end of the list. Later providers withoverride: truewill overwrite earlier ones- Performance - Use
dataorlazyDataproviders for best performance. Network providers reload data on every language change and may cause delays when switching languages
LanguageBuilder(
builder: (context) => Text('Hello'.tr),
)
Tr((_) => Text('Hello'.tr))By default, all LanguageBuilder widgets rebuild when the language changes. You can control this behavior globally via LanguageHelper.initial(forceRebuild: false) or per-widget using the forceRebuild parameter:
LanguageBuilder(
forceRebuild: false, // Only rebuild the root widget for better performance
builder: (context) => Text('Hello'.tr),
)true→ Always rebuild this widget when language changes (default)false→ Only rebuild the root widget (better performance)null→ Fallback toLanguageHelper.forceRebuilddefault
Use refreshTree: true to completely refresh the widget tree using KeyedSubtree. This changes the key of the current tree so the entire tree is removed and recreated:
LanguageBuilder(
refreshTree: true, // Uses KeyedSubtree to refresh entire tree
builder: (context) => MyComplexWidget(),
)
⚠️ Performance Warning:refreshTreecauses the entire widget tree to be destroyed and recreated, which can be expensive for complex widgets. This may lead to:
- Loss of widget state and animations
- Poor performance with large widget trees
- Unnecessary rebuilds of child widgets
💡 Note: If you use
constwidgets nested inside aLanguageBuilderand haveforceRebuild: false, they may not rebuild automatically when the root rebuilds. To ensure these widgets update on language change (without usingrefreshTree), either keep the defaultforceRebuild: trueor wrap them in their ownLanguageBuilderwithforceRebuild: true.
Use refreshTree only when you specifically need to reset widget state or when dealing with widgets that don't properly handle language changes.
LanguageScope allows you to provide a custom LanguageHelper instance to a specific part of your widget tree. When you wrap a widget tree with LanguageScope, all tr, trP, LanguageBuilder, and Tr widgets within that scope will automatically use the scoped helper instead of LanguageHelper.instance.
final customHelper = LanguageHelper('CustomHelper');
await customHelper.initial(
data: customLanguageData,
initialCode: LanguageCodes.es,
);
LanguageScope(
languageHelper: customHelper,
child: MyWidget(),
)When LanguageScope is present in the widget tree:
- LanguageBuilder and Tr - Automatically inherit the scoped helper (unless an explicit
languageHelperis provided) - Extension methods (
tr,trP,trT,trF) - Use the scoped helper when called within aLanguageBuilder. When called outside aLanguageBuilder, they fall back toLanguageHelper.instance(which is always available) - Priority order: Explicit
languageHelperparameter >LanguageScope>LanguageHelper.instance
Extension methods (tr, trP, trT, trF) work seamlessly with scoped helpers through an internal stack mechanism:
How the Stack Works:
- When
LanguageBuilderbuilds, it pushes its helper onto a stack before calling the builder function - Extension methods access the helper at the top of the stack via
LanguageHelper._current - After the build completes, the helper is popped from the stack
- This allows extension methods to work with scoped helpers even though they don't have
BuildContext
Extension Methods Behavior:
- Inside
LanguageBuilder: Use the helper from the stack (which comes fromLanguageScope, explicit parameter, orLanguageHelper.instance) - Outside
LanguageBuilder: Fall back toLanguageHelper.instance(always available) - Nested
LanguageBuilderwidgets: Each builder pushes its helper during build, allowing nested builders to use different helpers correctly
This ensures that extension methods never fail and always have a helper to work with, making them safe to use anywhere in your code.
Example with Nested Builders:
LanguageScope(
languageHelper: parentHelper,
child: LanguageBuilder(
builder: (context) => Column(
children: [
Text('Parent'.tr), // Uses parentHelper from stack
LanguageBuilder(
languageHelper: childHelper, // Explicit helper
builder: (context) => Text('Child'.tr), // Uses childHelper from stack
),
],
),
),
)final adminHelper = LanguageHelper('AdminHelper');
final userHelper = LanguageHelper('UserHelper');
await adminHelper.initial(data: adminTranslations, initialCode: LanguageCodes.en);
await userHelper.initial(data: userTranslations, initialCode: LanguageCodes.vi);
// Admin section uses admin translations
LanguageScope(
languageHelper: adminHelper,
child: LanguageBuilder(
builder: (context) => Column(
children: [
Text('Admin Panel'.tr), // Uses adminHelper
Text('Manage Users'.trP({'count': 5})), // Uses adminHelper
],
),
),
)
// User section uses user translations
LanguageScope(
languageHelper: userHelper,
child: LanguageBuilder(
builder: (context) => Column(
children: [
Text('Dashboard'.tr), // Uses userHelper
Text('Welcome @{name}'.trP({'name': 'John'})), // Uses userHelper
],
),
),
)You can access the scoped helper directly from context:
// Gets the scoped helper or falls back to LanguageHelper.instance
// Always returns a valid helper since LanguageHelper.instance is always available
final helper = LanguageHelper.of(context);
final translated = helper.translate('Hello');Note: LanguageHelper.of(context) does not register a dependency on LanguageScope, so widgets using it won't automatically rebuild when the scope changes. If you need automatic rebuilds, wrap your widget in a LanguageBuilder instead. When no LanguageScope is found, a warning is logged once per context (when debug logging is enabled) to help developers understand that the default LanguageHelper.instance is being used.
If you provide an explicit languageHelper parameter, it takes priority over the scope:
LanguageScope(
languageHelper: scopedHelper, // This will be ignored
child: LanguageBuilder(
languageHelper: explicitHelper, // This takes priority
builder: (_) => Text('Hello'.tr), // Uses explicitHelper
),
)Child scopes override parent scopes for their subtree:
LanguageScope(
languageHelper: parentHelper, // Parent scope
child: LanguageBuilder(
builder: (_) => Column(
children: [
Text('Hello'.tr), // Uses parentHelper
LanguageScope(
languageHelper: childHelper, // Child scope overrides parent
child: LanguageBuilder(
builder: (_) => Text('Hello'.tr), // Uses childHelper
),
),
],
),
),
)The Tr widget also inherits from LanguageScope:
LanguageScope(
languageHelper: scopedHelper,
child: Tr((_) => Text('Hello'.tr)), // Uses scopedHelper
)- Multi-tenant apps: Different translation sets for different user types
- Feature modules: Separate translations for different app modules
- A/B testing: Different translations for different user groups
- Admin panels: Specialized translations for admin interfaces
- Overrides: Temporarily override translations in specific sections
LanguageImprover is a widget that provides a user-friendly interface for viewing, comparing, and editing translations. It's perfect for translators, QA teams, or anyone who needs to improve translations directly in the app.
Use the Language Helper Translator in Chat-GPT for easy translation:
This is the translation of my Flutter app. Translate it into Spanish:
final en = {
'Hello @{name}': 'Hello @{name}',
'Welcome to the app': 'Welcome to the app',
};-
Add AI instructions:
Show
# Translation procedure for `language_helper` (Dart `Map<String, dynamic>`) **Goal:** translate only the *values* in a `Map<String, dynamic>` used for localization. Do **not** change keys, structure, or comments. --- ## Preflight 1. Read the entire input file to understand context before translating any entries. 2. If the target language is not English and the keys are not in English, check for an `en.dart` in the same folder and use it as a contextual reference. ## Which items to translate 3. **Translate only values that have a `TODO` comment immediately above them.** Leave all other values unchanged. 4. Preserve all comments (`//`, `///`) exactly as they are. Do not translate comments or modify their position. Do not translate nested comments. ## Keys, structure, and safety 5. Never modify keys, the map structure, or comment text/placement. Ensure indentation and syntax remain valid Dart. 6. After translating a value, remove the `TODO` note associated with that value (but keep surrounding comments intact). ## Plural handling 7. If a value represents plural text, convert it into a `LanguageConditions` object using the `param` used in the string (e.g., `count`). 8. Use this pattern for English plurals unless a language-specific rule is required: * `0` → `no products` * `1` → `1 product` * `other` (fallback) → `@{count} products` 9. For languages that do not distinguish singular/plural in the same way, you **may** keep a single string value rather than `LanguageConditions`. Use `LanguageConditions` only when the target language requires multiple forms. ## Quality and style 10. Try to keep translated text length similar to the original for layout/UX consistency (preferred, not mandatory). 11. Produce natural, context-appropriate translations — prefer idiomatic phrasing over literal word-for-word translation when context suggests it. 12. Double-check grammar, punctuation, interpolation markers (e.g., `@{count}`), and escape sequences so the resulting Dart file stays valid. ## Automation / workflow rules 13. Do not prompt the user for permission or confirmation; perform the translation using best effort. 14. After finishing the translation pass, include a short summary note that describes: * Any ambiguous entries and the choices you made. * Where plural handling was applied and why. * Any entries left unchanged (and the reason). 15. Run a final syntactic check to ensure the map remains valid Dart and that all TODO markers for translated values were removed. --- ## Plural example **Input** ```dart // TODO '@{count} sản phẩm': '@{count} sản phẩm' ``` **Output (English)** ```dart '@{count} sản phẩm': LanguageConditions( param: 'count', conditions: { '0': 'no products', '1': '1 product', '_': '@{count} products', }, ) ``` *(If the target language does not require distinct plural forms, replace the value with a single translated string instead of `LanguageConditions`.)* --- ## Quick checklist (use at the end of each file) * [ ] Only values with `TODO` were translated. * [ ] Keys, structure, and comments unchanged. * [ ] All `TODO` markers removed for translated entries. * [ ] Plural forms converted to `LanguageConditions` when needed. * [ ] Interpolations (e.g., `@{count}`) preserved correctly. * [ ] Short summary note added describing ambiguous choices and plural handling. * [ ] File compiles / is syntactically valid Dart (basic check).
- Open the AI Agent tab and input
Translate <path/to/languages/data>. - Review the results.
Add supported localizations to Info.plist:
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
<string>vi</string>
</array>- Parameter format: Use
@{param}for parameters (recommended over@param) - Device locale: The package automatically uses device locale on first launch (if
syncWithDevice: true) - Performance:
- All
LanguageBuilderwidgets rebuild by default when language changes. SetforceRebuild: falseinLanguageHelper.initial()or per-widget for better performance - Use
LanguageDataProvider.datafor fastest performance (synchronous, no I/O) - Network providers load on-demand and may cause delays when switching languages
- All
- Initialization:
- Use
isInitializedto check ifinitial()has been called - Use
ensureInitializedto wait for initialization to complete - Always await
initial()before accessingcode,data, orcodes
- Use
- Data sources:
- Assets are preferred over network data for faster loading
- Network providers load data on every language change (not cached between changes)
- Consider implementing caching strategies for network providers in production
- Provider override:
- Use
override: true(default) when creating providers to overwrite existing translations - Use
override: falseto only add new keys and preserve existing translations
- Use
- Adding/removing providers:
- Use
addProviderwithactivate: falseto batch multiple additions, then callreload()once - Be careful not to call
addProvider/removeProviderwithactivate: trueduring widget build
- Use
- Memory:
- Only languages that have been accessed are loaded into memory (for lazy/network providers)
- Use lazy loading for large translation sets or when users typically use only a few languages
- Multiple providers:
- Providers are processed in order (first to last)
- Later providers with
override: truewill overwrite earlier providers' translations - Combine different provider types (data, asset, network) for flexible translation management
Found a bug or want to contribute? Please file an issue or submit a pull request!
This project is licensed under the MIT License.