feat(apps): نظام التصميم والمصادقة الكاملة — المرحلتان 2 و3

## تصحيح بنيوي أولاً
بنية المرحلة 1 كانت تخالف docs/23 §1 المُلزِم. أُعيدت للشجرة المفروضة
حرفياً: app.dart في الجذر · core/{config,build_config,di,router}.dart ·
core/{design,ui,l10n,api,storage,session}/ · features/<f>/{cubit,data,view}/

## المرحلة 2 — نظام التصميم (docs/26)
- core/design: tokens (مسافات/زوايا/حركة/أحجام — الشاشة لا تخترع رقماً) ·
  typography (IBM Plex Sans Arabic + Inter محليّان، بأرقام tabular للأسعار
  والعدّادات) · TripzColors كـThemeExtension للأدوار الدلالية ·
  buildTheme(brightness, locale) بمدخلين لا ثالث لهما
- core/ui: 11 مكوّناً. TripzScaffold يحوّل status الـCubit وحده إلى
  skeleton/خطأ/فراغ/محتوى — فلا تكتب أي شاشة if (loading)
- core/l10n: ARB عربي/إنجليزي + gen_l10n. لا نص مرئي داخل widget
- SettingsCubit: المظهر واللغة. الاتجاه يتبع اللغة آلياً بلا Directionality
  مفروضة، وسقف تكبير النص 1.3

## المرحلة 3 — المصادقة
- طبقة الشبكة: تجديد استباقي بطلقة واحدة (عمر التوكن 15 دقيقة) ·
  x-app-role · x-device-id من device_info_plus — يُرسَل الآن كي يُفعَّل علم
  الخادم لاحقاً بلا تعديل التطبيق
- SessionCubit في core/session خلف واجهة SessionSource: الجلسة حالة على
  مستوى التطبيق لا ميزة، و core لا يستورد من features
- LoginCubit بخطواته الخمس، ProfileCubit، وست شاشات:
  الشروط ← إذن الموقع ← الهاتف ← الرمز ← إكمال الملف ← هيكل الرئيسية

من سيرو نُقل السلوك لا الكود: بوابتان قبل أي حقل إدخال · إعادة فحص الإذن عند
العودة من إعدادات النظام · تحقّق الهاتف الثلاثي. وأُضيف ما ينقصه: عدّاد إعادة
إرسال الرمز — بدونه تُحظر ثلاث ضغطات المستخدمَ خمس دقائق (docs/38 §2).

قراران موثّقان في docs/37: لا packages/tripz_ui (الوثيقتان المُلزِمتان تفرضان
core/design و core/ui داخل التطبيق)، والـCubit يُصدر رمز فشل لا نصّاً
(يجمع بين docs/23 §3 و docs/26 §4).

flutter analyze نظيف في التطبيقين · 48 ملف و3,140 سطر لكل تطبيق · الفرق
بينهما أربعة ملفات فقط: build_config · config · ملفّا ARB.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Hamza-Ayed
2026-08-04 21:42:19 +03:00
co-authored by Claude Opus 5
parent 0de8573d27
commit c52338f3bb
117 changed files with 6061 additions and 374 deletions
@@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
import '../l10n/l10n.dart';
import 'tripz_button.dart';
/// حالة الفراغ: رسالة **تدلّ على الفعل التالي** لا مجرّد «لا يوجد»
/// (docs/26 §8.4).
class EmptyView extends StatelessWidget {
const EmptyView({
super.key,
this.icon = Icons.inbox_rounded,
this.message,
this.actionLabel,
this.onAction,
});
final IconData icon;
final String? message;
final String? actionLabel;
final VoidCallback? onAction;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: Space.page,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
icon,
size: Sizes.iconLg,
color: context.colors.onSurfaceVariant,
),
const SizedBox(height: Space.md),
Text(
message ?? context.l10n.emptyDefault,
textAlign: TextAlign.center,
style: context.texts.bodyMedium?.copyWith(
color: context.colors.onSurfaceVariant,
),
),
if (onAction != null && actionLabel != null) ...[
const SizedBox(height: Space.lg),
TripzButton.secondary(label: actionLabel!, onPressed: onAction),
],
],
),
),
);
}
}
@@ -0,0 +1,47 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
import '../l10n/l10n.dart';
import 'tripz_button.dart';
/// حالة الخطأ: رسالة **بالعربية** مع «أعد المحاولة» — لا شاشة صامتة
/// (docs/26 §8.4). الرسالة تأتي جاهزة من الـCubit (docs/23 §3).
class ErrorView extends StatelessWidget {
const ErrorView({super.key, this.message, this.onRetry});
final String? message;
final VoidCallback? onRetry;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: Space.page,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.error_outline_rounded,
size: Sizes.iconLg,
color: context.tripzColors.danger,
),
const SizedBox(height: Space.md),
Text(
message ?? context.l10n.errorGeneric,
textAlign: TextAlign.center,
style: context.texts.bodyMedium,
),
if (onRetry != null) ...[
const SizedBox(height: Space.lg),
TripzButton.secondary(
label: context.l10n.actionRetry,
onPressed: onRetry,
),
],
],
),
),
);
}
}
+83
View File
@@ -0,0 +1,83 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
/// هيكل تحميل — **لا spinner فارغ** (docs/26 §8.4).
///
/// النبض `AnimatedBuilder` على `Opacity` وحدها: لا يُعاد بناء الشجرة تحته،
/// وهذا ما يفرق بين skeleton رخيص وآخر يأكل إطارات.
class Skeleton extends StatefulWidget {
const Skeleton({
super.key,
this.width,
this.height = 16,
this.radius = Radii.field,
});
/// ثلاثة أسطر بأطوال متفاوتة — الشكل الافتراضي لأي قائمة تُحمَّل.
const Skeleton.lines({super.key})
: width = null,
height = -1,
radius = Radii.field;
final double? width;
final double height;
final double radius;
@override
State<Skeleton> createState() => _SkeletonState();
}
class _SkeletonState extends State<Skeleton>
with SingleTickerProviderStateMixin {
late final AnimationController _controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat(reverse: true);
@override
void dispose() {
_controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final color = context.tripzColors.skeleton;
final child = widget.height < 0
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_bar(color, double.infinity),
const SizedBox(height: Space.sm),
_bar(color, 220),
const SizedBox(height: Space.sm),
_bar(color, 140),
],
)
: _bar(color, widget.width ?? double.infinity, widget.height);
// تُحترم إعدادات تقليل الحركة في النظام (docs/26 §8.5).
if (MediaQuery.disableAnimationsOf(context)) return child;
return AnimatedBuilder(
animation: _controller,
builder: (_, inner) => Opacity(
opacity: 0.45 + (_controller.value * 0.35),
child: inner,
),
child: child,
);
}
Widget _bar(Color color, double width, [double height = 16]) => Container(
width: width,
height: height,
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(widget.radius),
),
);
}
@@ -0,0 +1,56 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import '../design/tripz_colors.dart';
enum BannerTone { info, success, warning, danger }
/// شريط الحالة الحيّ (docs/26 §7) — حالة الرحلة، تحذير الرصيد، انقطاع الشبكة.
/// اللون من الأدوار الدلالية لا من hex.
class StatusBanner extends StatelessWidget {
const StatusBanner({
super.key,
required this.message,
this.tone = BannerTone.info,
this.icon,
this.trailing,
});
final String message;
final BannerTone tone;
final IconData? icon;
final Widget? trailing;
@override
Widget build(BuildContext context) {
final c = context.tripzColors;
final color = switch (tone) {
BannerTone.info => c.info,
BannerTone.success => c.success,
BannerTone.warning => c.warning,
BannerTone.danger => c.danger,
};
return Container(
padding: const EdgeInsets.symmetric(
horizontal: Space.sm,
vertical: Space.xs,
),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: Radii.field_,
border: Border.all(color: color.withValues(alpha: 0.35)),
),
child: Row(
children: [
Icon(icon ?? Icons.info_outline_rounded, size: Sizes.iconSm, color: color),
const SizedBox(width: Space.xs),
Expanded(
child: Text(message, style: context.texts.bodySmall),
),
?trailing,
],
),
);
}
}
@@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
enum _ButtonKind { primary, secondary, text }
/// الزر الموحّد (docs/26 §7). ارتفاع 52، وحالة تحميل **داخلية**: الشاشة
/// تمرّر `loading: true` ولا تستبدل الزر بمؤشّر — فلا يقفز التخطيط.
class TripzButton extends StatelessWidget {
const TripzButton.primary({
super.key,
required this.label,
this.onPressed,
this.loading = false,
this.icon,
this.expanded = true,
}) : _kind = _ButtonKind.primary;
const TripzButton.secondary({
super.key,
required this.label,
this.onPressed,
this.loading = false,
this.icon,
this.expanded = false,
}) : _kind = _ButtonKind.secondary;
const TripzButton.text({
super.key,
required this.label,
this.onPressed,
this.loading = false,
this.icon,
this.expanded = false,
}) : _kind = _ButtonKind.text;
final String label;
final VoidCallback? onPressed;
final bool loading;
final IconData? icon;
final bool expanded;
final _ButtonKind _kind;
@override
Widget build(BuildContext context) {
// أثناء التحميل يُعطَّل الزر — يمنع الإرسال المزدوج بلا حارس في كل شاشة.
final onTap = loading ? null : onPressed;
final child = _Content(label: label, icon: icon, loading: loading);
final button = switch (_kind) {
_ButtonKind.primary => FilledButton(
onPressed: onTap,
style: _style(context),
child: child,
),
_ButtonKind.secondary => OutlinedButton(
onPressed: onTap,
style: _style(context),
child: child,
),
_ButtonKind.text => TextButton(
onPressed: onTap,
style: _style(context),
child: child,
),
};
if (!expanded) return button;
return SizedBox(width: double.infinity, child: button);
}
ButtonStyle _style(BuildContext context) => ButtonStyle(
minimumSize: const WidgetStatePropertyAll(
Size(0, Sizes.control),
),
shape: const WidgetStatePropertyAll(
RoundedRectangleBorder(borderRadius: Radii.card_),
),
padding: const WidgetStatePropertyAll(
EdgeInsets.symmetric(horizontal: Space.lg),
),
textStyle: WidgetStatePropertyAll(
Theme.of(context).textTheme.labelLarge,
),
);
}
class _Content extends StatelessWidget {
const _Content({required this.label, this.icon, required this.loading});
final String label;
final IconData? icon;
final bool loading;
@override
Widget build(BuildContext context) {
if (loading) {
return const SizedBox(
width: Sizes.iconSm,
height: Sizes.iconSm,
child: CircularProgressIndicator(strokeWidth: 2.2),
);
}
if (icon == null) return Text(label);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: Sizes.iconSm),
const SizedBox(width: Space.xs),
Text(label),
],
);
}
}
@@ -0,0 +1,29 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
/// بطاقة موحّدة (docs/26 §7): الرحلة · السائق · المحفظة.
/// الحدّ لا الظل — «الوضوح قبل الزينة» (docs/26 §8.2).
class TripzCard extends StatelessWidget {
const TripzCard({
super.key,
required this.child,
this.onTap,
this.padding = Space.page,
});
final Widget child;
final VoidCallback? onTap;
final EdgeInsets padding;
@override
Widget build(BuildContext context) {
final content = Padding(padding: padding, child: child);
return Card(
clipBehavior: Clip.antiAlias,
child: onTap == null
? content
: InkWell(onTap: onTap, child: content),
);
}
}
@@ -0,0 +1,131 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../design/tokens.dart';
import '../design/typography.dart';
/// حقل رمز التحقّق — أربع خانات (طول رمز تريبز، docs/38 §2).
///
/// حقل حقيقي واحد تحت خانات معروضة: يُبقي اللصق والإكمال التلقائي من الرسالة
/// (`AutofillHints.oneTimeCode`) يعملان — وهو ما يكسره تقسيمُه إلى أربعة
/// حقول منفصلة. يُستدعى [onCompleted] عند اكتمال الطول.
class TripzOtpField extends StatefulWidget {
const TripzOtpField({
super.key,
required this.controller,
this.length = 4,
this.onCompleted,
this.enabled = true,
this.hasError = false,
});
final TextEditingController controller;
final int length;
final ValueChanged<String>? onCompleted;
final bool enabled;
final bool hasError;
@override
State<TripzOtpField> createState() => _TripzOtpFieldState();
}
class _TripzOtpFieldState extends State<TripzOtpField> {
final _focus = FocusNode();
@override
void dispose() {
_focus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Stack(
alignment: Alignment.center,
children: [
// الحقل الفعلي — شفّاف بلا حجم بصري، يستقبل الكيبورد واللصق.
SizedBox(
height: Sizes.control,
child: Opacity(
opacity: 0,
child: TextField(
controller: widget.controller,
focusNode: _focus,
enabled: widget.enabled,
autofocus: true,
keyboardType: TextInputType.number,
maxLength: widget.length,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
autofillHints: const [AutofillHints.oneTimeCode],
onChanged: (v) {
if (v.length == widget.length) widget.onCompleted?.call(v);
},
),
),
),
GestureDetector(
onTap: () => _focus.requestFocus(),
behavior: HitTestBehavior.opaque,
child: ValueListenableBuilder<TextEditingValue>(
valueListenable: widget.controller,
builder: (context, value, _) => Row(
mainAxisAlignment: MainAxisAlignment.center,
textDirection: TextDirection.ltr,
children: List.generate(widget.length, (i) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: Space.xxs),
child: _Box(
char: i < value.text.length ? value.text[i] : null,
focused: _focus.hasFocus && i == value.text.length,
hasError: widget.hasError,
),
);
}),
),
),
),
],
);
}
}
class _Box extends StatelessWidget {
const _Box({
required this.char,
required this.focused,
required this.hasError,
});
final String? char;
final bool focused;
final bool hasError;
@override
Widget build(BuildContext context) {
final scheme = Theme.of(context).colorScheme;
final borderColor = hasError
? scheme.error
: focused
? scheme.primary
: scheme.outlineVariant;
return AnimatedContainer(
duration: Motion.tap,
curve: Motion.curve,
width: 56,
height: Sizes.control,
alignment: Alignment.center,
decoration: BoxDecoration(
borderRadius: Radii.field_,
border: Border.all(
color: borderColor,
width: focused || hasError ? 1.5 : 1,
),
),
child: Text(
char ?? '',
style: numericStyle(Theme.of(context).textTheme.headlineSmall),
),
);
}
}
@@ -0,0 +1,130 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
import 'empty_view.dart';
import 'error_view.dart';
import 'skeleton.dart';
import 'view_status.dart';
/// نقطة التنظيم المركزية (docs/26 §6). **ممنوع `Scaffold` خام في
/// `features/`.**
///
/// هو ما يحوّل `status` القادم من الـCubit إلى skeleton/خطأ/فراغ/محتوى — فلا
/// تكتب أي شاشة `if (loading)`، وتغيير شكل «التحميل» في التطبيق كله يصير
/// سطراً واحداً هنا.
class TripzScaffold extends StatelessWidget {
const TripzScaffold({
super.key,
required this.child,
this.title,
this.status = ViewStatus.success,
this.error,
this.onRetry,
this.empty,
this.loading,
this.bottomAction,
this.actions,
this.leading,
this.showAppBar = true,
this.padded = true,
this.resizeToAvoidBottomInset = true,
});
/// المحتوى — يُبنى **عند النجاح فقط**.
final Widget child;
final String? title;
final ViewStatus status;
/// رسالة الخطأ كما أصدرها الـCubit، بلا إعادة صياغة في الواجهة.
final String? error;
final VoidCallback? onRetry;
/// شكل الفراغ الخاص بالشاشة — وإلا فالافتراضي.
final Widget? empty;
/// شكل التحميل الخاص بالشاشة — وإلا فهيكل ثلاثة أسطر.
final Widget? loading;
/// خانة الـCTA السفلية: ترتفع فوق الكيبورد، وتحترم المنطقة الآمنة.
final Widget? bottomAction;
final List<Widget>? actions;
final Widget? leading;
final bool showAppBar;
final bool padded;
final bool resizeToAvoidBottomInset;
@override
Widget build(BuildContext context) {
return Scaffold(
resizeToAvoidBottomInset: resizeToAvoidBottomInset,
appBar: !showAppBar
? null
: AppBar(
title: title == null ? null : Text(title!),
actions: actions,
leading: leading,
),
body: SafeArea(
top: !showAppBar,
child: Padding(
padding: padded ? Space.page : EdgeInsets.zero,
child: _Body(
status: status,
error: error,
onRetry: onRetry,
empty: empty,
loading: loading,
child: child,
),
),
),
bottomNavigationBar: bottomAction == null
? null
: SafeArea(
top: false,
child: Padding(
// ‏viewInsets يرفع الـCTA فوق الكيبورد بدل أن يختفي تحته.
padding: EdgeInsets.fromLTRB(
Space.md,
Space.xs,
Space.md,
Space.md + MediaQuery.viewInsetsOf(context).bottom,
),
child: bottomAction,
),
),
);
}
}
class _Body extends StatelessWidget {
const _Body({
required this.status,
required this.error,
required this.onRetry,
required this.empty,
required this.loading,
required this.child,
});
final ViewStatus status;
final String? error;
final VoidCallback? onRetry;
final Widget? empty;
final Widget? loading;
final Widget child;
@override
Widget build(BuildContext context) {
return switch (status) {
ViewStatus.idle ||
ViewStatus.loading =>
loading ?? const Skeleton.lines(),
ViewStatus.error => ErrorView(message: error, onRetry: onRetry),
ViewStatus.empty => empty ?? const EmptyView(),
ViewStatus.success => child,
};
}
}
@@ -0,0 +1,71 @@
import 'package:flutter/material.dart';
import '../design/tokens.dart';
/// الورقة السفلية — **الحوار الافتراضي في التطبيق sheet لا dialog**
/// (docs/26 §7). المقبض والزوايا من الثيم، فلا تُعاد كتابتهما في كل نداء.
class TripzSheet extends StatelessWidget {
const TripzSheet({
super.key,
required this.child,
this.title,
this.bottomAction,
});
final Widget child;
final String? title;
final Widget? bottomAction;
/// [dismissible] = false للحالات التي لا تُغلق بالسحب — مثل شاشة البحث عن
/// سائق: تُغلق بزر الإلغاء وحده كي لا يزيحها المستخدم بالخطأ (docs/39 §3).
static Future<T?> show<T>(
BuildContext context, {
required Widget child,
String? title,
Widget? bottomAction,
bool dismissible = true,
}) {
return showModalBottomSheet<T>(
context: context,
isScrollControlled: true,
isDismissible: dismissible,
enableDrag: dismissible,
showDragHandle: dismissible,
builder: (_) => TripzSheet(
title: title,
bottomAction: bottomAction,
child: child,
),
);
}
@override
Widget build(BuildContext context) {
return SafeArea(
top: false,
child: Padding(
padding: EdgeInsets.fromLTRB(
Space.md,
Space.xs,
Space.md,
Space.md + MediaQuery.viewInsetsOf(context).bottom,
),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
if (title != null) ...[
Text(title!, style: Theme.of(context).textTheme.titleLarge),
const SizedBox(height: Space.md),
],
Flexible(child: child),
if (bottomAction != null) ...[
const SizedBox(height: Space.md),
bottomAction!,
],
],
),
),
);
}
}
@@ -0,0 +1,109 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import '../design/tokens.dart';
import '../design/typography.dart';
/// حقل الإدخال الموحّد (docs/26 §7): نصّ · هاتف · بحث.
///
/// رسالة الخطأ تحته بالعربية، والأرقام لاتينية tabular دائماً — رقم الهاتف
/// ورمز التحقّق لا «يرقصان» أثناء الكتابة.
class TripzTextField extends StatelessWidget {
const TripzTextField({
super.key,
this.controller,
this.label,
this.hint,
this.errorText,
this.keyboardType,
this.textInputAction,
this.onChanged,
this.onSubmitted,
this.autofocus = false,
this.enabled = true,
this.maxLength,
this.prefixIcon,
this.suffix,
this.inputFormatters,
this.autofillHints,
this.numeric = false,
});
/// حقل هاتف: أرقام فقط، لوحة مفاتيح هاتف، إكمال تلقائي من النظام.
factory TripzTextField.phone({
Key? key,
TextEditingController? controller,
String? label,
String? hint,
String? errorText,
ValueChanged<String>? onChanged,
ValueChanged<String>? onSubmitted,
bool enabled = true,
}) {
return TripzTextField(
key: key,
controller: controller,
label: label,
hint: hint,
errorText: errorText,
enabled: enabled,
numeric: true,
autofocus: true,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.done,
onChanged: onChanged,
onSubmitted: onSubmitted,
maxLength: 15,
prefixIcon: Icons.phone_outlined,
inputFormatters: [FilteringTextInputFormatter.digitsOnly],
autofillHints: const [AutofillHints.telephoneNumber],
);
}
final TextEditingController? controller;
final String? label;
final String? hint;
final String? errorText;
final TextInputType? keyboardType;
final TextInputAction? textInputAction;
final ValueChanged<String>? onChanged;
final ValueChanged<String>? onSubmitted;
final bool autofocus;
final bool enabled;
final int? maxLength;
final IconData? prefixIcon;
final Widget? suffix;
final List<TextInputFormatter>? inputFormatters;
final Iterable<String>? autofillHints;
final bool numeric;
@override
Widget build(BuildContext context) {
final base = Theme.of(context).textTheme.bodyLarge;
return TextField(
controller: controller,
enabled: enabled,
autofocus: autofocus,
keyboardType: keyboardType,
textInputAction: textInputAction,
onChanged: onChanged,
onSubmitted: onSubmitted,
maxLength: maxLength,
inputFormatters: inputFormatters,
autofillHints: autofillHints,
style: numeric ? numericStyle(base) : base,
// الأرقام تُقرأ يساراً-ليميناً حتى في واجهة عربية.
textDirection: numeric ? TextDirection.ltr : null,
decoration: InputDecoration(
labelText: label,
hintText: hint,
errorText: errorText,
counterText: '',
prefixIcon: prefixIcon == null
? null
: Icon(prefixIcon, size: Sizes.iconSm),
suffixIcon: suffix,
),
);
}
}
@@ -0,0 +1,22 @@
/// حالة العرض الموحّدة التي يُصدرها كل Cubit (docs/23 §3) ويستهلكها
/// `TripzScaffold` (docs/26 §6).
///
/// هذا ما يجعل نظام التصميم متوافقاً مع Cubit **بالبنية لا بالاتفاق**:
/// الشاشة لا تكتب `if (loading)` أبداً.
enum ViewStatus {
/// لم يبدأ شيء بعد — يُعرض كتحميل لأن المستخدم لا يفرّق.
idle,
loading,
success,
/// نجح النداء ولا بيانات — يُعرض `EmptyView`.
empty,
error,
}
extension ViewStatusX on ViewStatus {
bool get isLoading => this == ViewStatus.idle || this == ViewStatus.loading;
bool get isError => this == ViewStatus.error;
bool get isEmpty => this == ViewStatus.empty;
bool get isSuccess => this == ViewStatus.success;
}