Imported from AdhamHashim/flutter_clean_arch_base (
.claude/skills/coding-standards/SKILL.md). Install upstream withnpx skills add AdhamHashim/flutter_clean_arch_base --skill coding-standards. Copyright stays with the author.
Flutter_Base โ Coding Standards
๐จ The Strict Six Rules (ู ูุตู ุจูุง ุจููุฉ ูู ูู feature)
ูุฐู ุงูููุงุนุฏ ุงูุณุช ูู ุนู ูุฏ ุงูููุฑู. ุฃู ู ุฎุงููุฉ ูุงุฒู ุชุชุจุฑูุฑ ุจู ุจุฑุฑ ููู ูุงุถุญ.
| # | ุงููุงุนุฏุฉ | ููู ุชุทุจูู |
|---|---|---|
| 1 | No unnecessary comments. Comments ุชุดุฑุญ "ูู ุงุฐุง" (intent, gotcha, trade-off) โ ู ุด "ู ุงุฐุง" (ุงูููุฏ ููุณู ูููู ู ุงุฐุง). | ุงู ุณุญ ุฃู comment ุจููุฑุฑ ุงุณู ู ุชุบูุฑ/method. ูุง comments ุชููุงุฆูุฉ. |
| 2 | No hardcoded values in the View. ู
ู
ููุน Text('hi') / Color(0xFFAA) / EdgeInsets.all(16) / Icons.x / 'assets/...' ุฏุงุฎู ุฃู Screen ุฃู widget. |
ููู ู
ู LocaleKeys.*.tr() / AppColors / AppSize/AppPadding/AppCircular / IconWidget(icon: AppAssets.svg....path). |
| 3 | The View is clean โ no functions inside it. ู
ู
ููุน ุฃู method ุฏุงุฎู ุงูู Screen ุบูุฑ build()ุ initState()ุ dispose(). ูู handler / formatter / validator / sheet-opener ูู Cubit ุฃู ViewController ุฃู widget ู
ููุตู. |
ูู ูููุช void _onTap() ุฃู Widget _buildCard() ุฏุงุฎู ุงูู Screen โ ุงูููู. |
| 4 | Performance is top priority โ no setState, no unnecessary rebuilds. ุงูุงูุชุฑุงุถู: ValueNotifier<T> + ValueListenableBuilder ููู ephemeral UI stateุ BlocSelector ูุฌุฒุก ุตุบูุฑ ู
ู cubit stateุ BlocBuilder ููู full stateุ AsyncBlocBuilder ููู AsyncCubit. |
setState ููุท ุจู
ุจุฑุฑ ููู ูุงุถุญ (ู
ููุด ูู ููุง ุญุงูุฉ ูู ุงูู products feature). |
| 5 | Most classes should be Stateless. StatefulWidget ููุท ูุญุงุฌุฉ ุญููููุฉ: AnimationController lifecycleุ focus managementุ cubit/ViewController ownership (initState/dispose). |
ุงูู state ุงููุนูู ูู cubit ุฃู ViewControllerุ ู ุด ูู ุงูู widget. |
| 6 | Functions of type Widget _buildSomething() are FORBIDDEN inside the View. ุฃู widget ุฏุงุฎู ุงูู View ูุตูุฑ ู
ููู ู
ููุตู ูู presentation/widgets/ ูู class _Something extends StatelessWidget (private โ ูุจุฏุฃ ุจู _)ุ part-of ุงูู imports hub. |
ุงูู _build* ู
ุง ุจูุงุฎุฏุด constุ ู
ุง ูุณุชููุฏุด ู
ู Flutter rebuild scopeุ ูุจูุฎุจูู tree complexity. |
Quick check ูุจู ุฃู commit:
โก Comments ุชุดุฑุญ "ูู
ุงุฐุง" ุจุณ
โก View: ู
ููุด string/color/size/icon ุฎุงู
โก View: ู
ููุด method ุบูุฑ build/initState/dispose
โก ValueNotifier/BlocSelector โ ู
ููุด setState
โก Stateless ุญูุซ ู
ู
ูู
โก ู
ููุงุช ู
ููุตูุฉ ููู widget โ ู
ููุด Widget _buildX()
0. Developer Mindset โ Advanced Senior Flutter Developer (ุงูุฃูู )
ุฃูุช ุจุชุดุชุบู ูู Advanced Senior Flutter Developer โ ู ุด junior ููุง mid-level. ูู ุณุทุฑ ููุฏ ูุงุฒู ูุนูุณ ุฎุจุฑุฉ ุนู ููุฉ ูู Flutter + Dart + Performance + Clean Architecture.
0.1 โ Core Principles
| ุงูู ุจุฏุฃ | ู ุนูุงู ุนู ููุงู |
|---|---|
| Think before you type | ูุจู ุฃู widget/cubitุ ุงุณุฃู: "ูู ุฏู ุฃูุถู ุญูุ ูู ููู pattern ุฃูุถูุ ูู ุฏู ููู scaleุ" |
| Performance-first | ูุง rebuilds ู
ุด ูุงุฒู
ุฉุ ูุง nested scrollablesุ ุงุณุชุฎุฏู
const aggressivelyุ compute() ููู heavy JSON |
| Composition over inheritance | ุงูุณู ุงูู widgets ูู small reusable pieces โ ู ุด widget ูุงุญุฏ ููู 500 ุณุทุฑ |
| Edge cases ู ู ุงูุจุฏุงูุฉ | nullุ emptyุ loadingุ errorุ offlineุ slow network โ ู ุด ุจุนุฏูู |
| Readability over cleverness | ููุฏ senior ุชุงูู ูููู ู ูู 30 ุซุงููุฉ ุจุฏูู ุดุฑุญ |
| No premature abstraction | ูุง ุชุนู ู abstraction ูุจู ู ุง ุชุญุชุงุฌูุง ูุนูุงู โ YAGNI |
0.2 โ Dart 3.10 โ Use Modern Features
ุงุณุชุฎุฏู features ุงูู Dart 3.10 ูู ุง ุชุฎูู ุงูููุฏ ุฃูุถู. ู ุด ูู ุฌุฑุฏ ุงูู syntax.
// โ
Records โ return multiple values cleanly
(String, int) parseDimension(String input) {
final parts = input.split('x');
return (parts.first, int.tryParse(parts.last) ?? 0);
}
final (name, size) = parseDimension('logo x 24');
// โ
Sealed classes โ exhaustive state matching
sealed class PaymentResult {}
class PaymentSuccess extends PaymentResult { final String txId; PaymentSuccess(this.txId); }
class PaymentFailure extends PaymentResult { final String message; PaymentFailure(this.message); }
final label = switch (result) {
PaymentSuccess(:final txId) => 'Paid: $txId',
PaymentFailure(:final message) => 'Failed: $message',
};
// โ
Pattern matching in switch expressions
final tier = switch (orderTotal) {
< 100 => 'bronze',
< 500 => 'silver',
< 1000 => 'gold',
_ => 'platinum',
};
// โ
Null-aware spread
final items = [if (header != null) header, ...?optionalList, footer];
// โ
Late final for expensive computed-once values
late final processedData = _heavyComputation();
Don't overuse:
- ูุง ุชุณุชุฎุฏู records ูู public APIs ูู class ุฃูุถุญ
- ูุง ุชุณุชุฎุฏู sealed classes ูู ุฌุฑุฏ 2 cases ุจุณูุทุฉ
lateุจุญุฐุฑ โ ู ู ูู ูุนู ู runtime crash ูู ู ุง ุงุชูุฑูุด
0.3 โ Widget Selection โ Choose the Best Tool
ุงุฎุชุงุฑ ุฃูุถู widget ููู ููู โ ู ุด ุฃูู widget ูุฎุทุฑ ูู ุจุงูู.
| ูู ู ุญุชุงุฌ | ุงุณุชุฎุฏู | ุจุฏู ู ู |
|---|---|---|
| Listen to a single value | ValueListenableBuilder |
BlocBuilder (overkill) |
| Listen to part of bloc state only | BlocSelector |
BlocBuilder (rebuilds on every state) |
| Side effect on state change (snackbar, navigation) | BlocListener |
BlocConsumer (don't combine if no UI rebuild needed) |
| Build UI from bloc state | BlocBuilder / AsyncBlocBuilder |
BlocConsumer (if no side effect) |
| Animate value smoothly | TweenAnimationBuilder / AnimatedBuilder |
manual setState |
| Conditional UI without state | Visibility(visible:, maintainState:) |
empty Container / SizedBox.shrink() ุจุดูู ุนุดูุงุฆู |
| Boundary for expensive paints | RepaintBoundary |
(none โ just slower) |
| List with constant item extent | ListView.builder + itemExtent |
regular ListView.builder (faster) |
| Layout that depends on parent size | LayoutBuilder |
MediaQuery.of (less precise) |
| Run code once after first build | WidgetsBinding.instance.addPostFrameCallback |
Future.delayed(Duration.zero) |
| Async data with built-in loading/error | AsyncBlocBuilder (project) |
FutureBuilder raw |
0.4 โ Performance Mindset
// โ
const everywhere possible โ prevents rebuilds
const _MyBody()
const SizedBox.shrink()
const TextStyle()
// โ
Extract const subtrees โ they don't rebuild
class _Header extends StatelessWidget {
const _Header();
@override
Widget build(BuildContext context) => Row(...);
}
// โ
Use RepaintBoundary for expensive subtrees
RepaintBoundary(child: _AnimatedChart())
// โ
BlocSelector when you only care about one field
BlocSelector<CartCubit, AsyncState<CartEntity>, int>(
selector: (state) => state.data.itemCount,
builder: (_, count) => Badge(count: count),
)
// โ BlocBuilder rebuilds on every state change
BlocBuilder<CartCubit, AsyncState<CartEntity>>(
builder: (_, state) => Badge(count: state.data.itemCount), // rebuilds for price changes too
)
// โ
Use ListView.builder with itemExtent when items are same height
ListView.builder(itemExtent: AppSize.sH80, ...)
// โ Don't use SingleChildScrollView + Column with 100+ children โ kills performance
0.5 โ Code Smells to Avoid
// โ Magic numbers
SizedBox(height: 16) // โ 16.szH or AppSize.sH16
Container(padding: EdgeInsets.all(12)) // โ .paddingAll(AppPadding.pH12)
// โ God widgets โ 200+ line build methods
Widget build(BuildContext context) {
return Column(children: [
// 50 lines of header
// 50 lines of filters
// 100 lines of list
]);
}
// โ Split into _Header(), _Filters(), _List()
// โ Re-fetching after CRUD instead of local update
onTap: () async {
await deleteItem();
await fetchList(); // โ WRONG! Update state locally instead.
}
// โ setState for everything
class _MyState extends State<_MyWidget> {
bool _isExpanded = false;
void _toggle() => setState(() => _isExpanded = !_isExpanded);
}
// โ Use ValueNotifier inside ViewController + ValueListenableBuilder
0.6 โ Widget Efficiency โ Golden Rule (ุงูุชูุงุตูู ูู widget-efficiency skill)
ูุจู ู ุง ุชูุชุจ ุฃู wrapper widgetุ ุงุณุฃู ููุณู 3 ุฃุณุฆูุฉ ุจุงูุชุฑุชูุจ:
- ูู ุงูู child ุนูุฏู ุงูู attribute ุฏู nativelyุ
- ูู ุงูู parent ุจูุฏุนู ุงูู behavior ุฏู ู ู attributes ุจุชุงุนุชูุ
- ูู ููู widget ุฃูุซุฑ ุฏูุงูุฉ (semantic, single-purpose) ู ุชุนู ู ุจุงูุธุจุท ููุญุงูุฉ ุฏูุ
ูู ุงูู 3 ุฅุฌุงุจุงุช
ูุงโ ููุชูุง ููุท ุงูู wrapper ู ูุจูู.
ุฃุณุฑุน 25+ anti-pattern ูุงุฒู ุชุชุฌูุจูู (ุงูุจุงูู ูู ุงูู skill):
// โ Padding wrapper โ โ
Container/Card/ListTile ุงูู padding/contentPadding/margin ุงูู native
// โ SizedBox ุจูู children โ โ
Column(spacing:) / Row(spacing:) / Wrap(spacing:, runSpacing:)
// โ Expanded(SizedBox()) โ โ
Spacer() ุฃู mainAxisAlignment: spaceBetween
// โ Container ููู sizing ููุท โ โ
SizedBox / SizedBox.shrink() / SizedBox.expand()
// โ MediaQuery ููู % sizing โ โ
Flexible(flex:) / FractionallySizedBox / AspectRatio
// โ ClipRRect ุญูุงููู Container ุจู borderRadius โ โ
Container(clipBehavior: Clip.antiAlias)
// โ Stack ูู shadow โ โ
Container(decoration: BoxDecoration(boxShadow:))
// โ Opacity ุนูู color/icon โ โ
color.withValues(alpha:) โ ุฃุฑุฎุต (ู
ููุด saveLayer)
// โ Container ุญูู Scaffold body ููู bg โ โ
Scaffold(backgroundColor:)
// โ GestureDetector ุนูู ListTile/Button โ โ
ListTile(onTap:) / Button(onPressed:) ุฃู .onClick(...)
// โ GestureDetector ููู ripple โ โ
InkWell(onTap:, borderRadius:)
// โ Custom drag ููู swipe โ โ
Dismissible
// โ Row ู
ู Text widgets ูู inline styling โ โ
Text.rich(TextSpan(children: [...]))
// โ SizedBox/Container ุญูู Image ููู sizing โ โ
Image(width:, height:, fit:)
// โ ClipOval + Image โ โ
CircleAvatar
// โ ListView ุจูู ุงูู items โ โ
ListView.builder / .separated
// โ shrinkWrap: true ุฏุงุฎู scrollable โ โ
Sliver* family ูู CustomScrollView
// โ Stack + Positioned ูู badge โ โ
Badge(label: Text('3'), child: Icon(...))
// โ isVisible ? W : SizedBox โ โ
Visibility(visible:, maintainState:) ุฃู IndexedStack
// โ AnimationController ูู simple transitions โ โ
AnimatedContainer / AnimatedOpacity / ...
// โ Container + shadow ุจุฏู Card โ โ
Card / Card.filled / Card.outlined
// โ Container + GestureDetector ููู chip โ โ
FilterChip / ChoiceChip / InputChip / ActionChip
// โ Container(height: 1, color:) โ โ
Divider() / VerticalDivider()
// โ M2 widgets (BottomNavigationBar/DropdownButton/PopupMenuButton) โ โ
M3 (NavigationBar/DropdownMenu/MenuAnchor)
ุงูุชูุงุตูู + 12 part ูุงู
ู (layout, decoration, gestures, text, images, lists/slivers, inputs, navigation, visibility, animation, Stack, M3) + ุฌุฏูู M2โM3 migration โ .claude/skills/widget-efficiency/SKILL.md.
1. Single Import Per Feature File โ Per-Feature Imports Hub
ูู feature ูู imports hub ุฎุงุต ุจุงุณู ู:
presentation/imports/<feature>_imports.dart(ู ุซูุงูproducts_imports.dartุorders_imports.dart). ุงูุงุณู ูุชุบูุฑ ุจุญุณุจ ุงุณู ุงูููุชุดุฑ โ ู ุดview_imports.dartุซุงุจุช.
// โ
CORRECT โ header of any presentation file (cubits/, controllers/, view/, widgets/)
part of '../imports/products_imports.dart';
// โ WRONG โ scattered imports inside presentation files
import 'package:flutter/material.dart';
import '../../../../config/res/app_sizes.dart';
The hub itself (presentation/imports/<feature>_imports.dart):
library;
// External packages
import 'dart:async';
import 'package:dartz/dartz.dart' show Either, Left, Right, Unit;
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:injectable/injectable.dart';
import 'package:rxdart/rxdart.dart';
// App-level helpers (config tokens, locale)
import '../../../../config/res/config_imports.dart';
// Cross-cutting core
import '../../../../core/state/async/async.dart';
import '../../../../core/network/error/failures.dart';
// Feature domain (entities, enums, use-cases)
import '../../domain/entities/product_entity.dart';
import '../../domain/enums/product_status.dart';
import '../../domain/usecases/get_products_usecase.dart';
import '../../domain/usecases/create_product_usecase.dart';
// Cubits (state)
part '../cubits/products_cubit.dart';
part '../cubits/product_details_cubit.dart';
// ViewControllers (ephemeral UI state โ TextEditingControllers, ValueNotifiers, ScrollControllers)
part '../controllers/products_view_controller.dart';
// Screens (public entry points)
part '../view/products_screen.dart';
part '../view/product_details_screen.dart';
// Widgets (private to the feature, used by Screens above)
part '../widgets/products_body.dart';
part '../widgets/products_search_field.dart';
part '../widgets/product_card.dart';
part '../widgets/product_delete_dialog.dart';
part '../widgets/products_filter_sheet.dart';
Rule of thumb:
part/part ofis for presentation only. Thedomain/anddata/layers use normal imports so they stay portable and testable.- Private classes (
_ProductsBody,_ProductCard, โฆ) declared inwidgets/*.dartare usable across the whole feature without exposing them outside.
2. Colors โ AppColors ONLY (Reuse Before Creating)
โ ๏ธ Color Reuse Rule
Read
color_manager.dartFIRST. Match Figma colors against existing AppColors by purpose, not just exact hex. Core colors are shared across ALL screens. Only add truly new colors with generic names (never screen-prefixed).
// โ
CORRECT โ use existing AppColors
color: AppColors.forth,
backgroundColor: AppColors.scaffoldBackground,
style: const TextStyle().setColor(AppColors.primary) // for labels/body text
// โ FORBIDDEN โ raw colors
Color(0xFF583D82)
Colors.purple
// โ FORBIDDEN โ screen-prefixed color names for NEW colors
// AppColors.cartBackground // use AppColors.fill if close enough
// Note: AppColors.loginPrimary is a legacy name already used in DefaultScaffold/status bar โ do NOT rename it, but do NOT create new screen-prefixed colors
| Hex | AppColors | Purpose |
|---|---|---|
| #1C1C1C | AppColors.main |
Titles, headings |
| #474747 | AppColors.primary |
Body text, labels |
| #292929 | AppColors.secondary |
Dark secondary text |
| #583D82 | AppColors.forth / AppColors.buttonColor |
Purple accent, buttons |
| #666666 | AppColors.hintText |
Hints, subtitles, placeholders |
| #F9FAFB | AppColors.fill |
Field/card light background |
| #FFFFFF | AppColors.white / AppColors.scaffoldBackground |
White backgrounds |
| #C5C6C9 | AppColors.border |
Borders, dividers |
| #E34D4D | AppColors.error |
Error red |
| #DFDFDF | AppColors.grey1 |
Light grey |
| #C7C7C7 | AppColors.grey2 |
Medium grey |
Close-match rule: If Figma has a similar-purpose color (e.g. #6B7280 for grey text), use AppColors.hintText instead of creating a new one.
3. Sizes โ AppSize / AppPadding / AppMargin / AppCircular
// โ
SizedBox(height: AppSize.sH16)
padding: EdgeInsets.all(AppPadding.pH16)
BorderRadius.circular(AppCircular.r8)
// โ FORBIDDEN
SizedBox(height: 16)
BorderRadius.circular(8)
4. Text Style โ Extension Chain ONLY
โ ๏ธ Font Size Adjustment (Figma โ Code)
When converting from Figma:
- Figma โค 13sp (10, 11, 12, 13) โ KEEP AS-IS โ ูุง ุชููู. ุงููุตูุต ุงูุตุบูุฑุฉ ูุงุฒู ุชูุถู ุฒู ู ุง ูู.
- Figma 14โ18sp โ reduce by 1โ2sp (e.g. Figma 16 โ
.s14or.s15)- Figma โฅ 20sp โ reduce by 2sp (e.g. Figma 22 โ
.s20)
// โ
REQUIRED
Text('title', style: const TextStyle().setMainTextColor.s14.medium)
Text('hint', style: const TextStyle().setHintColor.s12.regular)
Text('error', style: const TextStyle().setErrorColor.s12.bold)
// โ
Figma 12sp small text โ keep .s12 (don't reduce small text)
Text('small', style: const TextStyle().setHintColor.s12.regular)
// โ
Figma 16sp title โ code .s14
Text('title', style: const TextStyle().setMainTextColor.s14.bold)
// โ
Figma 14sp body โ code .s13 or .s12
Text('body', style: const TextStyle().setMainTextColor.s13.regular)
// โ FORBIDDEN โ raw TextStyle
TextStyle(fontSize: 14, fontWeight: FontWeight.w500, color: Color(0xFF1C1C1C))
// โ FORBIDDEN โ using Figma font size directly without adjustment
// Figma says 16sp โ DON'T use .s16, use .s14 or .s15 instead
โ ๏ธ Font Weight Adjustment (Figma โ Code)
ุงูุฎุทูุท ู ู Figma ุฃุญูุงูุงู ุจุชุทูุน ุฃุชูู ูู Flutter. ุฌุฑุจ ูุฒู ุฃุฎู ุฃููุงู.
// Figma Bold (700) โ try SemiBold (600) first, if too light then 700
// Figma SemiBold (600) โ try Medium (500) first
// Figma Medium (500) โ keep 500
// Figma Regular (400) โ keep 400
// โ
CORRECT โ adjusted from Figma Bold 24sp
Text(title, style: const TextStyle().s22.semiBold)
// โ WRONG โ raw Figma values (will look too big/heavy)
Text(title, style: const TextStyle().s24.bold)
5. Spacing โ .szH / .szW Extensions ONLY (NEVER SizedBox)
ูู Column ุฃู Rowุ ุงุณุชุฎุฏู
.szH/.szWุฏุงูู ุงู ููู spacing. ู ู ููุน SizedBox.
// โ
CORRECT โ use spacing extensions
Column(children: [
Text('Title'),
12.szH, // SizedBox(height: 12.h)
Text('Subtitle'),
8.szH,
])
Row(children: [
IconWidget(...),
8.szW, // SizedBox(width: 8.w)
Text('Label'),
])
// โ FORBIDDEN โ raw SizedBox for spacing
SizedBox(height: 12)
SizedBox(width: 8)
const SizedBox(height: 16)
6. Padding/Margin โ Widget Extensions ONLY (NEVER Padding Widget)
ู ู ููุน ุงุณุชุฎุฏุงู
Padding(...)widget ู ุจุงุดุฑุฉ. ุงุณุชุฎุฏู ุงูู extensions ุฏุงูู ุงู.
// โ
CORRECT โ use padding/margin extensions
myWidget.paddingAll(AppPadding.pH16)
myWidget.paddingSymmetric(horizontal: AppPadding.pW20)
myWidget.paddingStart(AppPadding.pW16) // RTL-safe
myWidget.paddingEnd(AppPadding.pW16) // RTL-safe
myWidget.paddingOnly(top: AppPadding.pH8, bottom: AppPadding.pH8)
myWidget.paddingOnlyDirectional(start: AppPadding.pW16)
myWidget.marginAll(AppMargin.mH8)
myWidget.marginStart(AppMargin.mW16) // RTL-safe
myWidget.onClick(onTap: () {})
// โ FORBIDDEN โ Padding widget directly
Padding(
padding: EdgeInsets.symmetric(horizontal: AppPadding.pW20),
child: myWidget,
)
// โ FORBIDDEN โ GestureDetector directly
GestureDetector(onTap: fn, child: myWidget)
7. Navigation โ Go Class
// โ
Go.to(const MyScreen())
Go.back()
Go.toNamed(Routes.myScreen)
// โ
Navigator.push(context, MaterialPageRoute(builder: (_) => Screen()))
8. Localization โ lang.json + LocaleKeys (MANDATORY)
โ ๏ธ ูุงุนุฏุฉ ุฃุณุงุณูุฉ: ูู ูุต ูุธูุฑ ููู ุณุชุฎุฏู ูุงุฒู ูุชุถุงู ูู
lang.jsonุฃููุงู ูููุณุชุฎุฏู ู ูLocaleKeysููุท โ ุจุฏูู ุฃู ุงุณุชุซูุงุก.
8.0.1 โ Format ู ูู lang.json
// assets/translations/lang.json
{
"key_name #$ English text": "Arabic text",
"feature_title #$ Feature Title": "ุนููุงู ุงูู
ูุฒุฉ"
}
Format ุงููุงุนุฏุฉ: "snake_case_key #$ English Text": "ุงููุต ุงูุนุฑุจู"
- ุงูู key ุจู
snake_case - ุจุนุฏ
#$โ ุงููุต ุงูุฅูุฌููุฒู - ุงูููู ุฉ โ ุงููุต ุงูุนุฑุจู
8.0.2 โ Strings ู ุน Parameters (Variables)
{
"provider_welcome_back #$ Welcome back, {name}": "ุฃููุง ุจุนูุฏุชูุ {name}"
}
Text(LocaleKeys.providerWelcomeBack(name: user.name))
8.0.3 โ Generate ุจุนุฏ ุฃู ุชุนุฏูู
dart run generate/strings/main.dart
ููููุฏ: en.json + ar.json + locale_keys.g.dart
8.0.4 โ ุชุณู ูุฉ ุงูู Keys
| ุงููุงุนุฏุฉ | ู ุซุงู ุตุญ | ู ุซุงู ุบูุท |
|---|---|---|
snake_case ุฏุงุฆู
ุงู |
my_reservations |
myReservations |
| ุงูุจุฏุงูุฉ ุจุงุณู ุงูู feature | complaints_title |
title |
| ูุตูู ูู ุญุฏุฏ | complaints_reason_label |
label1 |
8.0.5 โ ุนูุฏ ูุฑุงุกุฉ ุชุตู ูู ู ู Figma MCP
1. scan_text_nodes() โ ุงุฌู
ุน ูู ุงููุตูุต ู
ู ุงูุชุตู
ูู
2. ุฃุถู ูู ูุต ูู lang.json (ุนูุงูููุ ุฃุฒุฑุงุฑุ hintsุ placeholdersุ tabsุ labelsุ ุฑุณุงุฆู ุฎุทุฃ)
3. ุดุบูู dart run generate/strings/main.dart
4. ุงุณุชุฎุฏู
LocaleKeys ููุท ูู ุงูููุฏ โ ูุง ุชุชุฌุงูุฒ ุฃู ูุต ุญุชู ูู ููู
ุฉ ูุงุญุฏุฉ
8.0.6 โ ุฃู ุซูุฉ
// โ
CORRECT
Text(LocaleKeys.screenTitle)
DefaultScaffold(title: LocaleKeys.myReservations)
hint: LocaleKeys.searchForTeam
EmptyWidget(title: LocaleKeys.noReservationsTitle, desc: LocaleKeys.noReservationsDesc)
// โ FORBIDDEN โ hardcoded text
Text('ุงูุนููุงู')
Text('Title')
Text('OK')
hint: 'ุงุจุญุซ ููุง'
8.0.7 โ Checklist
โก ูู ุงููุตูุต ูู lang.json ุจู format "key #$ English": "ุนุฑุจู"
โก ูู key ุจู snake_case ููุจุฏุฃ ุจุงุณู
ุงูู feature
โก ุชู
ุชุดุบูู generate command
โก ูู widget ูุณุชุฎุฏู
LocaleKeys โ ูุง ููุฌุฏ ูุต ู
ุจุงุดุฑ ูู dart files
โก Parameters ุชุณุชุฎุฏู
{variableName} ูู lang.json
8.1. Forms โ FormMixin + validateAndScroll() ALWAYS
// โ
Params class uses FormMixin
class MyParams with FormMixin {
final TextEditingController nameController = TextEditingController();
final TextEditingController phoneController = TextEditingController();
}
// โ
Form uses params.formKey
Form(key: params.formKey, child: Column(children: [...]))
// โ
Submit uses validateAndScroll() (auto-scrolls to first error)
if (params.validateAndScroll()) { await cubit.submit(params); }
// โ FORBIDDEN โ manual form validation
final formKey = GlobalKey<FormState>();
if (formKey.currentState?.validate() ?? false) { ... }
8.2. Number Keyboard โ toEnglishNumbers() ALWAYS
Arabic keyboard inputs ู ูกูขูฃ instead of 0123. MUST convert before API calls.
// โ
Convert Arabic numerals before sending
final phone = params.phoneController.text.toEnglishNumbers();
final amount = params.amountController.text.toEnglishNumbers();
// โ
Add ArabicNumbersFormatter to number/phone fields
inputFormatters: [ArabicNumbersFormatter(), PhoneNumberFormatter()]
// โ FORBIDDEN โ sending text directly from number fields
final phone = params.phoneController.text; // may contain ู ูกูขูฃูค
8.3. Fields โ Validators + InputFormatters ALWAYS (Per-Field Mandatory)
ูู field ูู ูู ุดุงุดุฉ ูุงุฒู ูู
validator+inputFormattersูุนุจุฑูุง ุนู ููุน ู ุญุชูุงู. ู ููุด "field ุนุงู ". ุงูู phone field ุนูุฏู ููุงุนุฏูุ ุงูู price ุนูุฏู ููุงุนุฏูุ ุงูู commercial registration ุนูุฏู ููุงุนุฏู.
8.3.1 โ Full Field Type Matrix
| Field type | Validator | InputFormatters | Length / Max |
|---|---|---|---|
| Name (Arabic/English) | Validators.validateEmpty |
TextOnlyFormatter(allowArabic: true) |
min 2 chars |
| Free text | Validators.validateEmpty |
NoSpecialCharactersFormatter() |
โ |
Validators.validateEmail |
EmailFormatter() |
โ | |
| Password | Validators.validatePassword |
โ | 8โ16 (built-in) |
| Generic Phone | Validators.validatePhone |
PhoneNumberFormatter(), ArabicNumbersFormatter() |
8โ15 digits |
| Saudi Mobile | Validators.validateSaudiPhone |
SaudiPhoneFormatter(), ArabicNumbersFormatter(), LengthLimitingTextInputFormatter(13) |
10 (05xxxโฆ) / 13 (+9665xxxโฆ) |
| Commercial Registration | Validators.validateCommercialReg |
NumberOnlyFormatter(), ArabicNumbersFormatter(), LengthLimitingTextInputFormatter(10) |
exactly 10 digits |
| National ID (Saudi) | Validators.validateNationalId |
NumberOnlyFormatter(), ArabicNumbersFormatter(), LengthLimitingTextInputFormatter(10) |
10 digits, starts with 1 (citizen) or 2 (resident) |
| IBAN (SA) | Validators.validateIban |
IbanFormatter(), LengthLimitingTextInputFormatter(24) |
SA + 22 digits |
| VAT Number | Validators.validateVat |
NumberOnlyFormatter(), LengthLimitingTextInputFormatter(15) |
exactly 15 digits |
| OTP code | Validators.validateEmpty |
NumberOnlyFormatter(), ArabicNumbersFormatter(), LengthLimitingTextInputFormatter(6) |
4โ6 digits |
| Price / Amount | Validators.validatePrice |
CurrencyFormatter(maxValue: 999_999_999), ArabicNumbersFormatter() |
display 3,000,000 |
| Quantity (cart) | Validators.validateEmpty |
IntegerNumberFormatter(maxValue: 999), ArabicNumbersFormatter() |
max 999 |
| Quantity (inventory) | Validators.validateEmpty |
IntegerNumberFormatter(maxValue: 99_999), ArabicNumbersFormatter() |
max 99,999 |
| Decimal (rating/weight) | Validators.validateEmpty |
DecimalNumberFormatter(decimalPlaces: 2), ArabicNumbersFormatter() |
2 decimals max |
| Age | Validators.validateEmpty |
IntegerNumberFormatter(maxValue: 120), ArabicNumbersFormatter() |
max 120 |
| Discount % | Validators.validateEmpty |
IntegerNumberFormatter(maxValue: 100), ArabicNumbersFormatter() |
max 100 |
| Date | Validators.validateEmpty |
DateTimeFormatter() |
auto DD/MM/YYYY |
| URL | Validators.validateUrl |
NoSpecialCharactersFormatter(allowArabic: false) |
valid URL pattern |
| Dropdown | Validators.validateDropDown<T> |
โ | โ |
| Optional any | Validators.noValidate |
(ุญุณุจ ุงูู data type) | โ |
8.3.2 โ Display vs API Value (CRITICAL)
ุจุนุถ ุงูู fields ุจุชุชุนุฑุถ ุจู format ูุงุญุฏ ุจุณ ุจุชุชุจุนุช ููู API ุจู format ุชุงูู. ูุงุฒู cleanup ูุจู ุฃู API call.
// โ
Price field โ display formatted, API plain
final priceClean = params.priceController.text
.replaceAll(',', '') // remove thousands separators
.toEnglishNumbers(); // ู ูกูขูฃ โ 0123
final amount = double.tryParse(priceClean) ?? 0;
// โ
Phone โ normalize to canonical Saudi format
final phone = Helpers.normalizeSaudiPhone(
params.phoneController.text.toEnglishNumbers(),
);
// "05xxxxxxxx" โ "9665xxxxxxxx"
// "+9665xxxxxxxx" โ "9665xxxxxxxx"
// โ
Date โ convert DD/MM/YYYY to ISO for API
final isoDate = DateFormat('yyyy-MM-dd').format(
DateFormat('dd/MM/yyyy').parse(params.dateController.text),
);
// โ
IBAN โ strip spaces before sending
final iban = params.ibanController.text.replaceAll(' ', '').toUpperCase();
8.3.3 โ Numeric Limits (Hard Rule)
ู ููุด numeric field ุจุฏูู
maxValuecap. ููุง ูุงุญุฏ. ุงูู ุณุชุฎุฏู ูู ุณุงุจุชูู ููุชุจ 9999999999999ุ ุงูููุฏ ูููุน ุฃู ุงูู API ููุฑูุถ.
// โ FORBIDDEN โ numeric field ุจุฏูู cap
inputFormatters: [IntegerNumberFormatter()] // โ unbounded
inputFormatters: [NumberOnlyFormatter()] // โ unbounded
// โ
MANDATORY โ always cap
inputFormatters: [
IntegerNumberFormatter(maxValue: 999),
ArabicNumbersFormatter(),
]
inputFormatters: [
CurrencyFormatter(maxValue: 999_999_999), // 9-digit price max
ArabicNumbersFormatter(),
]
8.3.4 โ Currency / Price Field Pattern (Display Formatting)
ุงูู price field ุจูุชุนุฑุถ ุจู thousand separators (
3,000,000) ุนุดุงู ุงูู ุณุชุฎุฏู ููุฑุง ุงูุฑูู ุจุณูููุฉ. ุจูุชุจุนุช ููู API ุจุฏูู commas (3000000).
// โ
The field
CustomTextFiled(
title: LocaleKeys.price.tr(),
hint: LocaleKeys.enterPrice.tr(),
controller: params.priceController,
validator: Validators.validatePrice,
inputFormatters: [
CurrencyFormatter(maxValue: 999_999_999),
ArabicNumbersFormatter(),
],
textInputType: const TextInputType.numberWithOptions(decimal: false),
textInputAction: TextInputAction.next,
)
// โ
The submit logic
if (params.validateAndScroll()) {
final amount = double.tryParse(
params.priceController.text.replaceAll(',', '').toEnglishNumbers(),
) ?? 0;
await cubit.submit({'amount': amount, ...});
}
8.3.5 โ Saudi Phone Pattern
// โ
Accept multiple formats โ validator normalizes
CustomTextFiled(
title: LocaleKeys.phone.tr(),
hint: '05XXXXXXXX',
controller: params.phoneController,
validator: Validators.validateSaudiPhone,
inputFormatters: [
SaudiPhoneFormatter(), // allows: digits, +, with smart prefix logic
ArabicNumbersFormatter(),
LengthLimitingTextInputFormatter(13),
],
textInputType: TextInputType.phone,
)
// โ
Normalize before API
final phone = Helpers.normalizeSaudiPhone(
params.phoneController.text.toEnglishNumbers(),
);
8.3.6 โ Helpers Missing? Add Them.
ูู ุงูู validator/formatter ุงููู ู ุญุชุงุฌู ู ุด ู ูุฌูุฏ ูู
validators.dartุฃูinput_formatters.dartโ ุถููู. ู ุด ุชุณุชุฎุฏู validator ุนุงู ู ุน TODO.
ู ุญุชุงุฌ ุชุถูู ูุฐู ุงูู helpers ูู ู ุด ู ูุฌูุฏูู:
// validators.dart
static String? validateSaudiPhone(String? value, {String? fieldTitle}) { ... }
static String? validateCommercialReg(String? value, {String? fieldTitle}) { ... }
static String? validateNationalId(String? value, {String? fieldTitle}) { ... }
static String? validateIban(String? value, {String? fieldTitle}) { ... }
static String? validateVat(String? value, {String? fieldTitle}) { ... }
static String? validatePrice(String? value, {String? fieldTitle, double? maxValue}) { ... }
static String? validateUrl(String? value, {String? fieldTitle}) { ... }
// input_formatters.dart
class SaudiPhoneFormatter extends TextInputFormatter { ... }
class CurrencyFormatter extends TextInputFormatter {
final num? maxValue;
// adds thousand separators: 3000000 โ 3,000,000
}
class IbanFormatter extends TextInputFormatter {
// adds space every 4 chars: SA0380000000608010167519 โ SA03 8000 0000 6080 1016 7519
}
// helpers.dart
static String normalizeSaudiPhone(String input) {
final digits = input.replaceAll(RegExp(r'[^0-9]'), '');
if (digits.startsWith('966')) return digits;
if (digits.startsWith('05')) return '966${digits.substring(1)}';
if (digits.startsWith('5')) return '966$digits';
return digits;
}
8.3.7 โ Field Checklist (Run Mentally for Every Field)
โก Validator ู
ูุงุณุจ ูููุน ุงูู
ุญุชูู (ู
ุด validateEmpty ููู ุญุงุฌุฉ)
โก InputFormatters ุชู
ูุน ูุชุงุจุฉ ููู
ุบูุท ู
ู ุงูุจุฏุงูุฉ
โก ArabicNumbersFormatter ูู numeric/phone/date/OTP fields
โก Max value ููู numeric fields (ุฅูุฒุงู
ู โ ู
ููุด ุงุณุชุซูุงุก)
โก Length limit ููู fixed-length fields (OTP/CR/NID/IBAN/Phone)
โก Display formatter ููู price (CurrencyFormatter) ูุงูู date (DateTimeFormatter) ูุงูู IBAN
โก Cleanup ูุจู API: strip commas/dashes/spaces + .toEnglishNumbers()
โก textInputType ู
ูุงุณุจ (.phone / .number / .emailAddress / .url)
โก textInputAction (.next ููู fields ุงููุณุทุ .done ููู ุขุฎุฑ field)
8.4. Helpers & Shared โ AUDIT BEFORE CREATING
Read
core/helpers/andcore/shared/FIRST before writing any utility logic.
Available Helpers (core/helpers/):
Validatorsโ form validation (empty, email, phone, password, dropdown, XSS)InputFormattersโ Phone, Email, NumberOnly, TextOnly, DateTime, Integer, Decimal, NoSpecialCharsArabicNumbersFormatterโ auto-converts ู -ูฉ โ 0-9 in text fieldsHelpersโshowByLang(),getFcmToken(),changeStatusbarColor(),shareApp(),getDeviceType()ImageHelperโ pick/crop images, camera, gallery, file pickerLauncherHelperโ launch URL, WhatsApp, social media, phone, emailCacheStorage/SecureStorageโ local storage wrappersCustomLoadingโ full-screen loading overlay
Available Shared (core/shared/):
BaseModel<T>โ generic API response wrapperUserModelโ user entityImageEntityโ image model (network + local)UserCubitโ current user state- Service Locator โ
injector<T>()
Available Extensions (core/extensions/):
TextStyleExโ text style chain (color, size, weight)FormatStringโ.toEnglishNumbers(),.capitalize(),.toCurrency(),.copyToClipboard()DateTimeFormatHelperโ.toFullDate(),.toTime(),.toDayMonthYear(), etc.ContextExtensionโcontext.width,context.hideKeyboard(),context.isArabicPaddingExtension/MarginExtensionโ RTL-safe widget padding/marginOnClickโ.onClick(onTap: fn)SizedBoxHelperโ.szH,.szWSliverExtensionโ.toSliver()SeparatorExtensionโ.joinWith(separator)on ListIndexedMapโ.indexedMap((index, item) => ...)on ListFormMixinโformKey,validate(),validateAndScroll()
8.5. Entity Safety โ initial() + fromJson defaults + tryParse ALWAYS
Every entity MUST have:
factory initial()โ with safe default values (for Skeletonizer + null-safety)fromJsonwith??defaults โ for ALL non-nullable fields (API data can change anytime)tryParsewith fallback โ NEVER useparse(crashes on bad data)
// โ
CORRECT โ safe entity
class ItemEntity {
final int id;
final String name;
final double price;
final String? image;
const ItemEntity({required this.id, required this.name, required this.price, this.image});
factory ItemEntity.initial() => const ItemEntity(id: 0, name: '', price: 0.0);
factory ItemEntity.fromJson(Map<String, dynamic> json) => ItemEntity(
id: json['id'] ?? 0,
name: json['name'] ?? '',
price: double.tryParse(json['price'].toString()) ?? 0.0,
image: json['image'], // nullable โ no ??
);
}
// โ FORBIDDEN โ unsafe entity
class ItemEntity {
factory ItemEntity.fromJson(Map<String, dynamic> json) => ItemEntity(
id: int.parse(json['id']), // CRASH on null/wrong type
name: json['name'], // CRASH on null
price: json['price'] as double, // CRASH on null/wrong type
);
// Missing factory initial() โ FORBIDDEN
}
tryParse rule:
// โ
ALWAYS use tryParse + fallback
int.tryParse(value.toString()) ?? 0
double.tryParse(value.toString()) ?? 0.0
// โ NEVER use parse (throws on bad data)
int.parse(value)
double.parse(value)
8.6. Multiple Scrollables โ CustomScrollView + Slivers (MANDATORY)
When a screen body has more than one scrollable section (e.g. banner + list, header + grid + list, filters + products), ALWAYS use
CustomScrollViewwith slivers instead of nesting scroll widgets insideSingleChildScrollView.
Why?
SingleChildScrollView+ nestedListView= double scroll conflict โ jank, layout errors, bad performanceshrinkWrap: trueon nested lists forces Flutter to lay out ALL items at once โ defeats lazy loading, kills performance on large listsCustomScrollViewwith slivers = single unified scroll โ smooth, lazy-loaded, no conflicts
Decision Rule:
| Screen structure | Approach |
|---|---|
| Single list/grid only | ListView.builder or GridView.builder directly |
| Static content + 1 list at bottom | CustomScrollView + SliverToBoxAdapter (static) + SliverList |
| Multiple sections (header + filters + list + footer) | CustomScrollView + slivers for everything |
| Multiple API sections (banners + categories + products) | CustomScrollView + AsyncSliverBlocBuilder per section |
โ CORRECT โ CustomScrollView with slivers:
CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Static header โ wrap in SliverToBoxAdapter (or use .toSliver())
_buildHeader().toSliver(),
// Filter chips โ wrap in SliverToBoxAdapter
_buildFilters().toSliver(),
// API list โ use SliverList.builder (lazy loaded!)
SliverList.builder(
itemCount: items.length,
itemBuilder: (_, i) => ItemCard(item: items[i]),
),
// Bottom spacing
SliverToBoxAdapter(child: 20.szH),
],
)
โ CORRECT โ Multi-API screen with AsyncSliverBlocBuilder:
CustomScrollView(
physics: const AlwaysScrollableScrollPhysics(),
slivers: [
// Banners section
AsyncSliverBlocBuilder<BannersCubit, List<BannerEntity>>(
builder: (ctx, banners) {
if (banners.isEmpty) return const SizedBox.shrink().toSliver();
return BannerCarousel(banners: banners).toSliver();
},
),
// Categories section
AsyncSliverBlocBuilder<CategoriesCubit, List<CategoryEntity>>(
builder: (ctx, cats) {
if (cats.isEmpty) return const SizedBox.shrink().toSliver();
return CategoriesRow(categories: cats).toSliver();
},
),
// Products grid
AsyncSliverBlocBuilder<ProductsCubit, List<ProductEntity>>(
builder: (ctx, products) {
if (products.isEmpty) return const SizedBox.shrink().toSliver();
return SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 2),
itemCount: products.length,
itemBuilder: (_, i) => ProductCard(product: products[i]),
);
},
),
],
)
โ FORBIDDEN โ nested scrollables:
// โ SingleChildScrollView + nested ListView = BAD performance
SingleChildScrollView(
child: Column(
children: [
_buildHeader(),
_buildFilters(),
ListView.builder( // โ nested scroll inside scroll!
shrinkWrap: true, // โ forces layout of ALL items
physics: NeverScrollableScrollPhysics(),
itemCount: items.length,
itemBuilder: (_, i) => ItemCard(item: items[i]),
),
],
),
)
// โ Multiple shrinkWrap lists = VERY BAD
SingleChildScrollView(
child: Column(
children: [
ListView.builder(shrinkWrap: true, physics: NeverScrollableScrollPhysics(), ...),
ListView.builder(shrinkWrap: true, physics: NeverScrollableScrollPhysics(), ...),
GridView.builder(shrinkWrap: true, physics: NeverScrollableScrollPhysics(), ...),
],
),
)
Sliver Quick Reference:
| Normal widget | Sliver equivalent |
|---|---|
| Any widget | widget.toSliver() or SliverToBoxAdapter(child: widget) |
ListView.builder |
SliverList.builder(itemCount, itemBuilder) |
ListView.separated |
SliverList.separated(itemCount, itemBuilder, separatorBuilder) |
GridView.builder |
SliverGrid.builder(gridDelegate, itemCount, itemBuilder) |
Padding around slivers |
SliverPadding(padding, sliver: ...) |
| Fill remaining space | SliverFillRemaining(child: ...) |
AsyncBlocBuilder |
AsyncSliverBlocBuilder |
When SingleChildScrollView IS acceptable:
- Screen has only static content (forms, text, images โ no lists/grids)
- Auth screens (login, register) where content is fixed and short
- Detail screens with no lists โ just static fields
9. Screen Structure
See
scaffold-statusbar.mdcrule for which scaffold to use per screen type.
// โ
Inner screens (most screens): StatelessWidget + BlocProvider + DefaultScaffold
class MyScreen extends StatelessWidget {
const MyScreen({super.key});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => injector<MyCubit>()..fetchData(),
child: DefaultScaffold(
title: LocaleKeys.myTitle.tr(),
body: const _MyBody(),
),
);
}
}
// โ
Auth screens: plain Scaffold + SafeArea (no appbar)
// Status bar: Helpers.changeStatusbarColor(statusBarColor: AppColors.scaffoldBackground, statusBarIconBrightness: Brightness.dark)
// โ FORBIDDEN โ building custom header inside body widget for inner screens
// Use DefaultScaffold instead โ it handles colored header, back arrow, title, status bar
10. RTL Rules โ CRITICAL
App is RTL by default โ use directional APIs everywhere.
See
rtl-arabicskill for full RTL rules, conversion tables, and examples.
The Core Equation:
Flutter "start" = physical RIGHT = Arabic text side = Figma RIGHT side
Flutter "end" = physical LEFT = Figma LEFT side
Row first child = physical RIGHT | Row last child = physical LEFT
Quick Forbidden List:
// โ ALL FORBIDDEN โ use directional alternatives
Positioned(left/right: x) // use PositionedDirectional(end/start: x)
Align(Alignment.centerLeft/Right) // use AlignmentDirectional.centerEnd/Start
EdgeInsets.only(left/right: x) // use EdgeInsetsDirectional.only(end/start: x)
TextAlign.left/right // use TextAlign.start/end
Note:
Directionality(textDirection: TextDirection.rtl)ู ุณู ูุญ ุนูู ู ุณุชูู widget ูุงุญุฏ ููุท ูุญู ู ุดููุฉ ูุต ู ุนููุณ ุฏุงุฎู component โ ู ู ููุน ุนูู ู ุณุชูู ุงูุดุงุดุฉ.
11. Core Widgets โ USE BEFORE BUILDING NEW
Golden Rule: Search
core/widgets/before writing any widget. If it exists โ use it.
11.1 โ Buttons (core/widgets/buttons/)
| Widget | Usage |
|---|---|
LoadingButton(title, onTap) |
Async button with built-in loading state โ use for ALL form submits |
DefaultButton(title, onTap) |
Simple non-async button |
ButtonClose() |
Standard close/dismiss button |
CustomAnimatedButton(...) |
Animated press-effect button |
// โ
CORRECT โ async form submit
LoadingButton(
title: LocaleKeys.submit.tr(),
color: AppColors.primary,
borderRadius: AppCircular.r12,
onTap: () async => context.read<MyCubit>().submit(params),
)
11.2 โ Text Fields (core/widgets/fields/text_fields/)
| Widget | Usage |
|---|---|
CustomTextFiled(hint, title, controller, validator, textInputType, textInputAction) |
Primary field โ includes label + asterisk + validation styling. Use for all form fields. |
DefaultTextField(...) |
Base field โ used inside CustomTextFiled. Use directly only if no label needed. |
CustomPinTextField(controller, onCompleted) |
OTP / PIN 4-digit field with pinput |
// โ
CORRECT โ form field with label
CustomTextFiled(
title: LocaleKeys.phoneLabel.tr(),
hint: LocaleKeys.phoneHint.tr(),
controller: params.phoneController,
textInputType: TextInputType.phone,
textInputAction: TextInputAction.next,
validator: Validators.validatePhone,
inputFormatters: [PhoneNumberFormatter()],
)
// โ WRONG โ don't build a custom labeled field from scratch
11.3 โ Dropdowns (core/widgets/fields/drop_downs/)
| Widget | Usage |
|---|---|
AppDropdown<T>(items, onChanged, itemAsString, label, hint, isLoading) |
Full-featured dropdown with search, multi-select, internal loading shimmer |
// โ
CORRECT โ pass items + isLoading directly (NO BlocBuilder wrapper)
final state = context.watch<GetCitiesCubit>().state;
AppDropdown<CityEntity>(
items: state.data, // โ [] if API failed, no error UI
label: LocaleKeys.city.tr(),
hint: LocaleKeys.selectCity.tr(),
value: selectedCity,
itemAsString: (c) => c.name,
onChanged: (c) => setState(() => selectedCity = c),
validator: Validators.validateDropDown,
isLoading: state is AsyncLoading, // โ AppDropdown shows internal shimmer
)
Key params: isMultiSelect, showSearchBox, isLoading, readonly, maxHeight
โ ๏ธ See Section 32: AppDropdown with API โ NEVER wrap in
BlocBuilder/AsyncBlocBuilder. NO error UI on dropdowns. If API fails โ items shows empty, that's it.
11.4 โ Dialogs & Bottom Sheets (core/widgets/dialogs/ + core/widgets/pickers/)
| Function | Usage |
|---|---|
successDialog(context, title: ..., desc: ...) |
Standard success popup with Lottie animation + auto-close |
showCustomDialog(context, child: ...) |
Generic styled dialog with scale+fade animation |
showDefaultBottomSheet(child: ...) |
Standard bottom sheet with drag handle |
CustomDatePicker |
Date picker widget |
// โ
CORRECT โ success after API call
successDialog(context, title: LocaleKeys.savedSuccessfully.tr());
// โ
CORRECT โ custom dialog
showCustomDialog(
context,
child: MyDialogContent(),
barrierDismissible: true,
);
// โ
CORRECT โ bottom sheet
showDefaultBottomSheet(child: MySheetContent());
11.5 โ State Handling (core/widgets/handling_views/ + core/widgets/tools/)
| Widget | Usage |
|---|---|
AsyncBlocBuilder<Cubit, DataType>(builder: ..., loadingBuilder: ...) |
Wraps loading/error/success states automatically |
AsyncSliverBlocBuilder<Cubit, DataType>(...) |
Sliver version for CustomScrollView |
PaginatedListWidget<Cubit, ItemType>(itemBuilder: ..., config: ...) |
Paginated list with infinite scroll |
EmptyWidget(title: ..., desc: ..., path: ...) |
Empty state with image/lottie |
ErrorView(error: ...) |
Error state with Lottie animation |
// โ
CORRECT โ API-driven list
AsyncBlocBuilder<GetItemsCubit, List<ItemEntity>>(
loadingBuilder: (ctx) => ItemSkeleton(),
builder: (ctx, items) => items.isEmpty
? EmptyWidget(title: LocaleKeys.noItems.tr(), desc: '')
: ListView.builder(itemBuilder: ...),
)
// โ
CORRECT โ paginated list
PaginatedListWidget<GetItemsCubit, ItemEntity>(
itemBuilder: (ctx, item, idx) => ItemCard(item: item),
emptyWidget: EmptyWidget(title: LocaleKeys.noItems.tr(), desc: ''),
)
11.6 โ Image Widgets (core/widgets/image_widgets/)
| Widget | Usage |
|---|---|
CachedImage(url, width, height) |
Cached network image with placeholder + tap-to-view |
CustomAvatar(url, radius) |
Circular avatar from network |
UploadImageWidget(onUpload) |
Image upload with native picker, single or multi |
ImageView(mediaPath, mediaType, mediaSource) |
Full-screen image/video viewer |
// โ
Network image
CachedImage(url: item.image, width: AppSize.sW60, height: AppSize.sH60,
borderRadius: BorderRadius.circular(AppCircular.r8))
// โ
Circular avatar
CachedImage(url: user.photo, width: AppSize.sW44, height: AppSize.sH44,
boxShape: BoxShape.circle)
// โ
Upload
UploadImageWidget(
uploadImageType: UploadImageType.single,
onUpload: (files) => params.imageFile = files.first,
)
11.7 โ Other Widgets
| Widget | Usage |
|---|---|
IconWidget(icon, color, height, width) |
Versatile โ handles SVG/PNG/Lottie/network/IconData automatically |
BadgeIconWidget(child, badgeCount) |
Badge overlay on any widget |
CustomHtmlWidget(data) |
Renders HTML content with project styles |
RiyalPriceText(price) |
Saudi Riyal symbol with correct font |
CustomLoading.showLoadingView() |
Centered SpinKit loading indicator |
CustomLoading.showFullScreenLoading() |
Full-screen overlay loader |
MessageUtils.showSnackBar(context, baseStatus, message) |
Themed snackbar |
// โ
Icon (SVG from AppAssets) โ NO color, NO IconData (see Section 17)
IconWidget(icon: AppAssets.svg.baseSvg.search.path, height: AppSize.sH20)
// โ
Badge
BadgeIconWidget(child: IconWidget(...), badgeCount: unreadCount)
// โ
Price
RiyalPriceText(price: '250.00', priceTextStyle: const TextStyle().setMainTextColor.s14.bold)
// OR
Text('250.00', style: ...).withRiyalPrice(color: AppColors.primary)
// โ
Snackbar
MessageUtils.showSnackBar(context: context, baseStatus: BaseStatus.success, message: LocaleKeys.saved.tr())
11.8 โ Scaffold + Status Bar
See
scaffold-statusbar.mdcfor full scaffold selection guide and status bar rules.
- Inner screens โ
DefaultScaffold(handles header + status bar automatically) - Auth screens โ plain
Scaffold+SafeArea(no appbar) - Home โ custom
Scaffold+CustomNavigationBar - NEVER build custom header containers in body widgets
12. Extensions โ USE ALWAYS
12.1 โ TextStyleEx (core/extensions/text_style_extensions.dart)
// Font weight
const TextStyle().bold // FontWeight.bold
const TextStyle().semiBold // w600
const TextStyle().medium // w500
const TextStyle().regular // w400
const TextStyle().light // w300
// Font size (uses screenutil .sp)
const TextStyle().s12 // 12.sp
const TextStyle().s14 // 14.sp
const TextStyle().s16 // 16.sp
// Colors (from AppColors)
const TextStyle().setMainTextColor // AppColors.main
const TextStyle().setSecondryColor // AppColors.secondary
const TextStyle().setHintColor // AppColors.hintText
const TextStyle().setErrorColor // AppColors.error
const TextStyle().setWhiteColor // AppColors.white
const TextStyle().setPrimaryColor // AppColors.primary
const TextStyle().setColor(AppColors.xxx) // custom color
// Chain example
const TextStyle().setMainTextColor.s14.semiBold
12.2 โ Padding/Margin Extensions
// Padding (RTL-safe versions preferred)
widget.paddingAll(AppPadding.pH16)
widget.paddingSymmetric(horizontal: AppPadding.pW20, vertical: AppPadding.pH8)
widget.paddingStart(AppPadding.pW16) // โ
RTL-safe (physical right)
widget.paddingEnd(AppPadding.pW16) // โ
RTL-safe (physical left)
widget.paddingOnly(top: AppPadding.pH8, bottom: AppPadding.pH8)
widget.paddingOnlyDirectional(start: AppPadding.pW16)
// Margin
widget.marginAll(AppMargin.mH8)
widget.marginSymmetric(horizontal: AppMargin.mW16)
widget.marginTop(AppMargin.mH12)
widget.marginBottom(AppMargin.mH12)
widget.marginStart(AppMargin.mW16) // โ
RTL-safe
widget.marginEnd(AppMargin.mW16) // โ
RTL-safe
12.3 โ SizedBox Helpers
12.szH // SizedBox(height: 12.h)
16.szW // SizedBox(width: 16.w)
// Use AppSize constants: AppSize.sH12.szH
12.4 โ Click Extension
myWidget.onClick(onTap: () => Go.to(const NextScreen()))
12.5 โ Context Extension
context.width // MediaQuery width
context.height // MediaQuery height
context.locale // current Locale
13. Helpers โ USE BEFORE WRITING CUSTOM LOGIC
13.1 โ Validators (core/helpers/validators.dart)
// โ
Use in form fields
validator: Validators.validateEmpty
validator: Validators.validateEmail
validator: Validators.validatePhone // digits only, 8-15 chars
validator: Validators.validatePassword // uppercase + lowercase + digit + special
validator: (v) => Validators.validatePasswordConfirm(v, pass)
validator: Validators.validateDropDown<T>
validator: Validators.noValidate // no validation, XSS check only
13.2 โ InputFormatters (core/helpers/input_formatters.dart)
inputFormatters: [PhoneNumberFormatter()] // digits + +, -, (, )
inputFormatters: [EmailFormatter()] // email chars only
inputFormatters: [NumberOnlyFormatter()] // digits only
inputFormatters: [TextOnlyFormatter()] // letters + arabic + spaces
inputFormatters: [TextWithNumberFormatter()] // letters + numbers + arabic
inputFormatters: [IntegerNumberFormatter()] // integers, optional max value
inputFormatters: [DecimalNumberFormatter()] // decimals, optional decimal places
inputFormatters: [DateTimeFormatter()] // auto-format DD/MM/YYYY
inputFormatters: [NoSpecialCharactersFormatter()] // no special chars
13.3 โ Helpers (core/helpers/helpers.dart)
Helpers.showByLang(ar: 'ุนุฑุจู', en: 'English') // returns string based on locale
Helpers.getFcmToken() // FCM push token
Helpers.changeStatusbarColor(statusBarColor: ...) // status bar tint
Helpers.shareApp(url) // share via share_plus
Helpers.getDeviceType() // 'ios' or 'android'
13.4 โ CacheService (core/helpers/cache_service.dart)
Local storage via shared preferences โ use for tokens, user prefs, etc.
14. Navigation โ Go Class (ONLY)
Go.to(const MyScreen()) // push
Go.off(const MyScreen()) // pushReplacement
Go.offAll(const MyScreen()) // pushAndRemoveUntil (clear stack)
Go.back() // pop
Go.backToInitial() // pop to first route
Go.toNamed(NamedRoutes.myScreen) // named push
Go.offNamed(NamedRoutes.myScreen) // named replacement
Go.offAllNamed(NamedRoutes.myScreen) // named clear stack
Go.context // global context (for dialogs etc.)
// โ FORBIDDEN
Navigator.push(context, MaterialPageRoute(...))
15. Network โ BaseRemoteSource.request<T> + Repository / UseCase Pipeline
ุงูู network layer ููู ู ุจูู ุนูู
DioClientsingleton +BaseRemoteSourceabstract class. ุงูู Cubit ู ุง ูุณุชุฏุนูุดBaseRemoteSourceู ุจุงุดุฑุฉ โ ูุฃุฎุฐ UseCases. ุงูู UseCase ูุณุชุฏุนู Repository. ุงูู Repository ูุณุชุฏุนู DataSource.
15.1 โ DataSource (talks to the network)
// data/datasources/products_remote_data_source_impl.dart
@LazySingleton(as: ProductsRemoteDataSource)
class ProductsRemoteDataSourceImpl extends BaseRemoteSource
implements ProductsRemoteDataSource {
// GET list with query params
@override
Future<Either<Failure, List<ProductEntity>>> getProducts({int page = 1, String? search}) =>
request<List<ProductEntity>>(
method: HttpMethod.get,
endpoint: ApiEndpoints.products,
queryParameters: {
'page': page,
if (search != null && search.isNotEmpty) 'search': search,
},
fromJson: _parseProductList,
);
// POST create with body
@override
Future<Either<Failure, ProductEntity>> createProduct({
required String name, required String description, required double price,
}) =>
request<ProductEntity>(
method: HttpMethod.post,
endpoint: ApiEndpoints.products,
body: {'name': name, 'description': description, 'price': price},
fromJson: _parseProduct,
);
// DELETE with no response body
@override
Future<Either<Failure, Unit>> deleteProduct(int id) =>
request<Unit>(
method: HttpMethod.delete,
endpoint: ApiEndpoints.productById(id),
fromJson: (_) => unit,
);
// POST public โ skip Bearer token
Future<Either<Failure, AuthToken>> login(String email, String pwd) =>
request<AuthToken>(
method: HttpMethod.post,
endpoint: ApiEndpoints.login,
body: {'email': email, 'password': pwd},
skipAuth: true,
fromJson: (j) => AuthToken.fromJson(j),
);
// Multipart upload โ opt-in via asFormData
Future<Either<Failure, UploadResult>> upload(String filePath) =>
request<UploadResult>(
method: HttpMethod.post,
endpoint: ApiEndpoints.upload,
body: {'file': await MultipartFile.fromFile(filePath)},
asFormData: true,
fromJson: (j) => UploadResult.fromJson(j),
);
// Private parsers โ keep Model โ Entity mapping here
static List<ProductEntity> _parseProductList(dynamic j) =>
((j is Map ? j['data'] : j) as List? ?? const [])
.whereType<Map<String, dynamic>>()
.map(ProductModel.fromJson)
.map((m) => m.toEntity())
.toList();
static ProductEntity _parseProduct(dynamic j) =>
ProductModel.fromJson(((j is Map ? j['data'] ?? j : j) as Map<String, dynamic>)).toEntity();
}
15.2 โ request<T> parameters reference
| Param | Purpose |
|---|---|
method |
HttpMethod.get / post / put / patch / delete |
endpoint |
relative path โ DioClient prepends baseUrl |
queryParameters |
?key=value&โฆ map |
body |
Map / List / String / FormData / MultipartFile-bearing Map |
headers |
extra request headers merged on top of globals |
fromJson |
T Function(dynamic json) โ required parser (use (_) => unit for void) |
skipAuth |
skip Bearer token attachment (login/register/public endpoints) |
asFormData |
wrap Map body in FormData.fromMap(...) automatically |
normalizeArabicDigits |
default true โ convert ู ูกูขูฃ โ 0123 inside body & query strings |
cancelKey |
dedupe key (default "$METHOD:$endpoint") โ auto-cancels previous in-flight |
cancelPrevious |
default true โ cancel previous in-flight with same key |
responseType |
override Dio response parsing (rarely needed) |
Returns Future<Either<Failure, T>> โ caller .fold without try/catch.
15.3 โ Repository (data โ domain bridge)
// domain/repositories/products_repository.dart
abstract interface class ProductsRepository {
Future<Either<Failure, List<ProductEntity>>> getProducts({String? search});
Future<Either<Failure, ProductEntity>> createProduct({required String name, required String description, required double price});
Future<Either<Failure, Unit>> deleteProduct(int id);
}
// data/repositories/products_repository_impl.dart
@LazySingleton(as: ProductsRepository)
class ProductsRepositoryImpl implements ProductsRepository {
ProductsRepositoryImpl(this._remote);
final ProductsRemoteDataSource _remote;
@override
Future<Either<Failure, List<ProductEntity>>> getProducts({String? search}) =>
_remote.getProducts(search: search);
@override
Future<Either<Failure, ProductEntity>> createProduct({...}) =>
_remote.createProduct(...);
@override
Future<Either<Failure, Unit>> deleteProduct(int id) =>
_remote.deleteProduct(id);
}
15.4 โ UseCase (single-purpose, what the Cubit depends on)
// domain/usecases/get_products_usecase.dart
@lazySingleton
class GetProductsUseCase {
GetProductsUseCase(this._repo);
final ProductsRepository _repo;
Future<Either<Failure, List<ProductEntity>>> call({String? search}) =>
_repo.getProducts(search: search);
}
15.5 โ Endpoints registry
All endpoints โ ApiEndpoints in core/network/api_endpoints.dart. Add new endpoints there:
class ApiEndpoints {
static const String baseUrl = 'https://api.example.com/v1/';
static const String products = 'products';
static String productById(int id) => 'products/$id';
static const String login = 'auth/login';
}
15.6 โ Anti-patterns
// โ Raw Dio in features
Dio().get('https://api.example.com/products');
// โ Cubit talking to DataSource directly (skips Repository + UseCase layers)
class XCubit extends AsyncCubit<List<X>> {
XCubit(this._dataSource);
final XRemoteDataSource _dataSource; // โ wrong; should be UseCase
}
// โ Hardcoded endpoint string in DataSource
request<X>(method: HttpMethod.get, endpoint: '/products', ...); // โ wrong; use ApiEndpoints.products
// โ Building FormData manually then passing it as body โ let asFormData handle it
final fd = FormData.fromMap({'file': ...});
request<X>(method: HttpMethod.post, body: fd, ...); // works but verbose; prefer asFormData: true
Interceptor order (in DioClient โ already wired):
LocaleInterceptor โ AuthInterceptor โ RetryInterceptor โ AppCacheInterceptor โ LoggingInterceptor (debug only)
validateStatus: (s) => s != null && s < 500โ 4xx travel asFailureviaResponseParser, only 5xx hit Dio'sonError.
15.7. Notifications โ NotificationManager + NotificationRouter
Module:
lib/src/core/notifications/โ singleton-based FCM viaawesome_notifications+awesome_notifications_fcm. FORBIDDEN packages:firebase_messaging,flutter_local_notifications(ูุชุนุงุฑุถูุง ู ุน awesome_notifications). Full deep-dive:.claude/skills/notifications/SKILL.md+lib/src/core/notifications/EXPLANATION.md.
Quick reference:
// Bootstrap (main.dart)
await NotificationManager.instance.initialize(
router: const AppNotificationRouter(), // uses Go.navigatorKey
debug: kDebugMode,
);
NotificationManager.instance.requestPermissions(); // iOS + Android 13+
NotificationManager.instance.requestToken(); // FCM token to send to backend
// Sealed routing
sealed class NotificationAction { const NotificationAction(); }
final class NavigateToChat extends NotificationAction { final String chatId; ... }
final class OpenUrl extends NotificationAction { final Uri url; ... }
// ... compiler enforces exhaustive switch in AppNotificationRouter.route
Golden rules:
- ูู static handler ุจุชุชูุงุฏู ู
ู native ูุงุฒู
@pragma('vm:entry-point'). channelKeyูุงุฒู ูุชุทุงุจู ู ุน ุงูู ุณุฌู ููinitialize(Messaging / General / System).idููNotificationContentูุงุฒูintโ ุงุณุชุฎุฏูpayload.id.hashCode.- ู
ู
ููุน
BuildContextูู static handlers โ ุงุณุชุฎุฏูGo.navigatorKey. - ุงูุชูุงุตู backgroundโmain ุนุจุฑ
IsolateNameServer.lookupPortByNameููุท. - ุงูุณูุฑูุฑ ูุจุนุช data-only messages (
dataุจุฏููnotificationkey).
16. Screen-Level Padding/Margin Adjustment (Figma โ Code)
Figma MCP often returns large padding/margin for the screen body. These look oversized on device.
- Figma body padding โค 12px โ keep as-is
- Figma body padding > 12px โ reduce by 2โ4px (e.g. Figma 16 โ 12 or 14, Figma 20 โ 16)
- This applies ONLY to screen-level body padding โ NOT card-internal or component padding.
17. Icons โ Use AppAssets As-Is (NO color, NO border, NO bg wrapper)
AppAssets icons exported from Figma ุจุงูู colors ูุงูู borders ูุงูู backgrounds ุงูุตุญูุญุฉ. ุงุณุชุฎุฏู ูู ุฒู ู ุง ูู โ ูุง ุชุถูู color overrideุ ููุง Container wrapper ุจู bg/border. ุฏู ุงูู default โ ู ููุด ุงุณุชุซูุงุก ุบูุฑ ูู ุงูู icon ูุนูุงู template SVG ุจููู ูุงุญุฏ.
Rule 17.1 โ NEVER use IconData (Icons.*)
// โ FORBIDDEN โ Material Icons / Cupertino Icons
Icon(Icons.search)
Icon(Icons.notifications, color: AppColors.primary)
IconWidget(icon: Icons.arrow_back)
// โ
ALWAYS โ AppAssets paths
IconWidget(icon: AppAssets.svg.baseSvg.search.path, height: AppSi
*Truncated - read the full file at https://github.com/AdhamHashim/flutter_clean_arch_base/blob/cca26f9746832fee13f9e502833f5aa517f73ee7/.claude/skills/coding-standards/SKILL.md.