chore: أرشفة تطبيقات ولوحات تريبز التي سيحلّ محلها كود سيرو
استكمال قرار 2026-07-27 (كوميت fff76c9 أرشف الباك إند):
apps/rider → apps/rider-archive-cubit
apps/driver → apps/driver-archive-cubit
dashboards/admin-web → dashboards/admin-web-archive
dashboards/service-web → dashboards/service-web-archive
إعادة تسمية بلا تعديل محتوى. rider/driver كانا إعادة بناء Cubit مبنية على
عقد NestJS المؤرشف. admin-web و service-web نموذجان بملف HTML واحد (164 و
128 سطراً) — يحلّ محلهما siro_admin و siro_service المكتملان.
يُحمل من المؤرشف ولا يُعاد اكتشافه: عقد map-saas المثبَّت بالتجربة في
apps/rider-archive-cubit/lib/core/api/ (x-api-key ترويسة، reverse مصفوفة،
route في points) ودرس مسار FCM.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
fff76c949f
commit
9909d9b4c1
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,168 @@
|
||||
import 'helpers.dart';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'countries.dart';
|
||||
|
||||
class PickerDialogStyle {
|
||||
final Color? backgroundColor;
|
||||
|
||||
final TextStyle? countryCodeStyle;
|
||||
|
||||
final TextStyle? countryNameStyle;
|
||||
|
||||
final Widget? listTileDivider;
|
||||
|
||||
final EdgeInsets? listTilePadding;
|
||||
|
||||
final EdgeInsets? padding;
|
||||
|
||||
final Color? searchFieldCursorColor;
|
||||
|
||||
final InputDecoration? searchFieldInputDecoration;
|
||||
|
||||
final EdgeInsets? searchFieldPadding;
|
||||
|
||||
final double? width;
|
||||
|
||||
PickerDialogStyle({
|
||||
this.backgroundColor,
|
||||
this.countryCodeStyle,
|
||||
this.countryNameStyle,
|
||||
this.listTileDivider,
|
||||
this.listTilePadding,
|
||||
this.padding,
|
||||
this.searchFieldCursorColor,
|
||||
this.searchFieldInputDecoration,
|
||||
this.searchFieldPadding,
|
||||
this.width,
|
||||
});
|
||||
}
|
||||
|
||||
class CountryPickerDialog extends StatefulWidget {
|
||||
final List<Country> countryList;
|
||||
final Country selectedCountry;
|
||||
final ValueChanged<Country> onCountryChanged;
|
||||
final String searchText;
|
||||
final List<Country> filteredCountries;
|
||||
final PickerDialogStyle? style;
|
||||
final String languageCode;
|
||||
|
||||
const CountryPickerDialog({
|
||||
Key? key,
|
||||
required this.searchText,
|
||||
required this.languageCode,
|
||||
required this.countryList,
|
||||
required this.onCountryChanged,
|
||||
required this.selectedCountry,
|
||||
required this.filteredCountries,
|
||||
this.style,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<CountryPickerDialog> createState() => _CountryPickerDialogState();
|
||||
}
|
||||
|
||||
class _CountryPickerDialogState extends State<CountryPickerDialog> {
|
||||
late List<Country> _filteredCountries;
|
||||
late Country _selectedCountry;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
_selectedCountry = widget.selectedCountry;
|
||||
_filteredCountries = widget.filteredCountries.toList()
|
||||
..sort(
|
||||
(a, b) => a
|
||||
.localizedName(widget.languageCode)
|
||||
.compareTo(b.localizedName(widget.languageCode)),
|
||||
);
|
||||
|
||||
super.initState();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final mediaWidth = MediaQuery.of(context).size.width;
|
||||
final width = widget.style?.width ?? mediaWidth;
|
||||
const defaultHorizontalPadding = 40.0;
|
||||
const defaultVerticalPadding = 24.0;
|
||||
return Dialog(
|
||||
insetPadding: EdgeInsets.symmetric(
|
||||
vertical: defaultVerticalPadding,
|
||||
horizontal: mediaWidth > (width + defaultHorizontalPadding * 2)
|
||||
? (mediaWidth - width) / 2
|
||||
: defaultHorizontalPadding),
|
||||
backgroundColor: widget.style?.backgroundColor,
|
||||
child: Container(
|
||||
padding: widget.style?.padding ?? const EdgeInsets.all(10),
|
||||
child: Column(
|
||||
children: <Widget>[
|
||||
Padding(
|
||||
padding:
|
||||
widget.style?.searchFieldPadding ?? const EdgeInsets.all(0),
|
||||
child: TextField(
|
||||
cursorColor: widget.style?.searchFieldCursorColor,
|
||||
decoration: widget.style?.searchFieldInputDecoration ??
|
||||
InputDecoration(
|
||||
suffixIcon: const Icon(Icons.search),
|
||||
labelText: widget.searchText,
|
||||
),
|
||||
onChanged: (value) {
|
||||
_filteredCountries = widget.countryList.stringSearch(value)
|
||||
..sort(
|
||||
(a, b) => a
|
||||
.localizedName(widget.languageCode)
|
||||
.compareTo(b.localizedName(widget.languageCode)),
|
||||
);
|
||||
if (mounted) setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: _filteredCountries.length,
|
||||
itemBuilder: (ctx, index) => Column(
|
||||
children: <Widget>[
|
||||
ListTile(
|
||||
leading: kIsWeb
|
||||
? Image.asset(
|
||||
'assets/flags/${_filteredCountries[index].code.toLowerCase()}.png',
|
||||
package: 'intl_phone_field',
|
||||
width: 32,
|
||||
)
|
||||
: Text(
|
||||
_filteredCountries[index].flag,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
contentPadding: widget.style?.listTilePadding,
|
||||
title: Text(
|
||||
_filteredCountries[index]
|
||||
.localizedName(widget.languageCode),
|
||||
style: widget.style?.countryNameStyle ??
|
||||
const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
trailing: Text(
|
||||
'+${_filteredCountries[index].dialCode}',
|
||||
style: widget.style?.countryCodeStyle ??
|
||||
const TextStyle(fontWeight: FontWeight.w700),
|
||||
),
|
||||
onTap: () {
|
||||
_selectedCountry = _filteredCountries[index];
|
||||
widget.onCountryChanged(_selectedCountry);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
),
|
||||
widget.style?.listTileDivider ??
|
||||
const Divider(thickness: 1),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'countries.dart';
|
||||
|
||||
bool isNumeric(String s) =>
|
||||
s.isNotEmpty && int.tryParse(s.replaceAll("+", "")) != null;
|
||||
|
||||
String removeDiacritics(String str) {
|
||||
var withDia =
|
||||
'ÀÁÂÃÄÅàáâãäåÒÓÔÕÕÖØòóôõöøÈÉÊËèéêëðÇçÐÌÍÎÏìíîïÙÚÛÜùúûüÑñŠšŸÿýŽž';
|
||||
var withoutDia =
|
||||
'AAAAAAaaaaaaOOOOOOOooooooEEEEeeeeeCcDIIIIiiiiUUUUuuuuNnSsYyyZz';
|
||||
|
||||
for (int i = 0; i < withDia.length; i++) {
|
||||
str = str.replaceAll(withDia[i], withoutDia[i]);
|
||||
}
|
||||
|
||||
return str;
|
||||
}
|
||||
|
||||
extension CountryExtensions on List<Country> {
|
||||
List<Country> stringSearch(String search) {
|
||||
search = removeDiacritics(search.toLowerCase());
|
||||
return where(
|
||||
(country) => isNumeric(search) || search.startsWith("+")
|
||||
? country.dialCode.contains(search)
|
||||
: removeDiacritics(country.name.replaceAll("+", "").toLowerCase())
|
||||
.contains(search) ||
|
||||
country.nameTranslations.values.any((element) =>
|
||||
removeDiacritics(element.toLowerCase()).contains(search)),
|
||||
).toList();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
library intl_phone_field;
|
||||
|
||||
import 'dart:async';
|
||||
import 'package:flutter/foundation.dart' show kIsWeb;
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
|
||||
import './countries.dart';
|
||||
import './phone_number.dart';
|
||||
import 'country_picker_dialog.dart';
|
||||
import 'helpers.dart';
|
||||
|
||||
class IntlPhoneField extends StatefulWidget {
|
||||
/// The TextFormField key.
|
||||
final GlobalKey<FormFieldState>? formFieldKey;
|
||||
|
||||
/// Whether to hide the text being edited (e.g., for passwords).
|
||||
final bool obscureText;
|
||||
|
||||
/// How the text should be aligned horizontally.
|
||||
final TextAlign textAlign;
|
||||
|
||||
/// How the text should be aligned vertically.
|
||||
final TextAlignVertical? textAlignVertical;
|
||||
final VoidCallback? onTap;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.readOnly}
|
||||
final bool readOnly;
|
||||
final FormFieldSetter<PhoneNumber>? onSaved;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.onChanged}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [inputFormatters], which are called before [onChanged]
|
||||
/// runs and can validate and change ("format") the input value.
|
||||
/// * [onEditingComplete], [onSubmitted], [onSelectionChanged]:
|
||||
/// which are more specialized input change notifications.
|
||||
final ValueChanged<PhoneNumber>? onChanged;
|
||||
|
||||
final ValueChanged<Country>? onCountryChanged;
|
||||
|
||||
/// An optional method that validates an input. Returns an error string to display if the input is invalid, or null otherwise.
|
||||
///
|
||||
/// A [PhoneNumber] is passed to the validator as argument.
|
||||
/// The validator can handle asynchronous validation when declared as a [Future].
|
||||
/// Or run synchronously when declared as a [Function].
|
||||
///
|
||||
/// By default, the validator checks whether the input number length is between selected country's phone numbers min and max length.
|
||||
/// If `disableLengthCheck` is not set to `true`, your validator returned value will be overwritten by the default validator.
|
||||
/// But, if `disableLengthCheck` is set to `true`, your validator will have to check phone number length itself.
|
||||
final FutureOr<String?> Function(PhoneNumber?)? validator;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.keyboardType}
|
||||
final TextInputType keyboardType;
|
||||
|
||||
/// Controls the text being edited.
|
||||
///
|
||||
/// If null, this widget will create its own [TextEditingController].
|
||||
final TextEditingController? controller;
|
||||
|
||||
/// Defines the keyboard focus for this widget.
|
||||
///
|
||||
/// The [focusNode] is a long-lived object that's typically managed by a
|
||||
/// [StatefulWidget] parent. See [FocusNode] for more information.
|
||||
///
|
||||
/// To give the keyboard focus to this widget, provide a [focusNode] and then
|
||||
/// use the current [FocusScope] to request the focus:
|
||||
///
|
||||
/// ```dart
|
||||
/// FocusScope.of(context).requestFocus(myFocusNode);
|
||||
/// ```
|
||||
///
|
||||
/// This happens automatically when the widget is tapped.
|
||||
///
|
||||
/// To be notified when the widget gains or loses the focus, add a listener
|
||||
/// to the [focusNode]:
|
||||
///
|
||||
/// ```dart
|
||||
/// focusNode.addListener(() { Log.print(myFocusNode.hasFocus); });
|
||||
/// ```
|
||||
///
|
||||
/// If null, this widget will create its own [FocusNode].
|
||||
///
|
||||
/// ## Keyboard
|
||||
///
|
||||
/// Requesting the focus will typically cause the keyboard to be shown
|
||||
/// if it's not showing already.
|
||||
///
|
||||
/// On Android, the user can hide the keyboard - without changing the focus -
|
||||
/// with the system back button. They can restore the keyboard's visibility
|
||||
/// by tapping on a text field. The user might hide the keyboard and
|
||||
/// switch to a physical keyboard, or they might just need to get it
|
||||
/// out of the way for a moment, to expose something it's
|
||||
/// obscuring. In this case requesting the focus again will not
|
||||
/// cause the focus to change, and will not make the keyboard visible.
|
||||
///
|
||||
/// This widget builds an [EditableText] and will ensure that the keyboard is
|
||||
/// showing when it is tapped by calling [EditableTextState.requestKeyboard()].
|
||||
final FocusNode? focusNode;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.onSubmitted}
|
||||
///
|
||||
/// See also:
|
||||
///
|
||||
/// * [EditableText.onSubmitted] for an example of how to handle moving to
|
||||
/// the next/previous field when using [TextInputAction.next] and
|
||||
/// [TextInputAction.previous] for [textInputAction].
|
||||
final void Function(String)? onSubmitted;
|
||||
|
||||
/// If false the widget is "disabled": it ignores taps, the [TextFormField]'s
|
||||
/// [decoration] is rendered in grey,
|
||||
/// [decoration]'s [InputDecoration.counterText] is set to `""`,
|
||||
/// and the drop down icon is hidden no matter [showDropdownIcon] value.
|
||||
///
|
||||
/// If non-null this property overrides the [decoration]'s
|
||||
/// [Decoration.enabled] property.
|
||||
final bool enabled;
|
||||
|
||||
/// The appearance of the keyboard.
|
||||
///
|
||||
/// This setting is only honored on iOS devices.
|
||||
///
|
||||
/// If unset, defaults to the brightness of [ThemeData.brightness].
|
||||
final Brightness? keyboardAppearance;
|
||||
|
||||
/// Initial Value for the field.
|
||||
/// This property can be used to pre-fill the field.
|
||||
final String? initialValue;
|
||||
|
||||
final String languageCode;
|
||||
|
||||
/// 2 letter ISO Code or country dial code.
|
||||
///
|
||||
/// ```dart
|
||||
/// initialCountryCode: 'IN', // India
|
||||
/// initialCountryCode: '+225', // Côte d'Ivoire
|
||||
/// ```
|
||||
final String? initialCountryCode;
|
||||
|
||||
/// List of Country to display see countries.dart for format
|
||||
final List<Country>? countries;
|
||||
|
||||
/// The decoration to show around the text field.
|
||||
///
|
||||
/// By default, draws a horizontal line under the text field but can be
|
||||
/// configured to show an icon, label, hint text, and error text.
|
||||
///
|
||||
/// Specify null to remove the decoration entirely (including the
|
||||
/// extra padding introduced by the decoration to save space for the labels).
|
||||
final InputDecoration decoration;
|
||||
|
||||
/// The style to use for the text being edited.
|
||||
///
|
||||
/// This text style is also used as the base style for the [decoration].
|
||||
///
|
||||
/// If null, defaults to the `subtitle1` text style from the current [Theme].
|
||||
final TextStyle? style;
|
||||
|
||||
/// Disable view Min/Max Length check
|
||||
final bool disableLengthCheck;
|
||||
|
||||
/// Won't work if [enabled] is set to `false`.
|
||||
final bool showDropdownIcon;
|
||||
|
||||
final BoxDecoration dropdownDecoration;
|
||||
|
||||
/// The style use for the country dial code.
|
||||
final TextStyle? dropdownTextStyle;
|
||||
|
||||
/// {@macro flutter.widgets.editableText.inputFormatters}
|
||||
final List<TextInputFormatter>? inputFormatters;
|
||||
|
||||
/// The text that describes the search input field.
|
||||
///
|
||||
/// When the input field is empty and unfocused, the label is displayed on top of the input field (i.e., at the same location on the screen where text may be entered in the input field).
|
||||
/// When the input field receives focus (or if the field is non-empty), the label moves above (i.e., vertically adjacent to) the input field.
|
||||
final String searchText;
|
||||
|
||||
/// Position of an icon [leading, trailing]
|
||||
final IconPosition dropdownIconPosition;
|
||||
|
||||
/// Icon of the drop down button.
|
||||
///
|
||||
/// Default is [Icon(Icons.arrow_drop_down)]
|
||||
final Icon dropdownIcon;
|
||||
|
||||
/// Whether this text field should focus itself if nothing else is already focused.
|
||||
final bool autofocus;
|
||||
|
||||
/// Autovalidate mode for text form field.
|
||||
///
|
||||
/// If [AutovalidateMode.onUserInteraction], this FormField will only auto-validate after its content changes.
|
||||
/// If [AutovalidateMode.always], it will auto-validate even without user interaction.
|
||||
/// If [AutovalidateMode.disabled], auto-validation will be disabled.
|
||||
///
|
||||
/// Defaults to [AutovalidateMode.onUserInteraction].
|
||||
final AutovalidateMode? autovalidateMode;
|
||||
|
||||
/// Whether to show or hide country flag.
|
||||
///
|
||||
/// Default value is `true`.
|
||||
final bool showCountryFlag;
|
||||
|
||||
/// Message to be displayed on autoValidate error
|
||||
///
|
||||
/// Default value is `Invalid Mobile Number`.
|
||||
final String? invalidNumberMessage;
|
||||
|
||||
/// The color of the cursor.
|
||||
final Color? cursorColor;
|
||||
|
||||
/// How tall the cursor will be.
|
||||
final double? cursorHeight;
|
||||
|
||||
/// How rounded the corners of the cursor should be.
|
||||
final Radius? cursorRadius;
|
||||
|
||||
/// How thick the cursor will be.
|
||||
final double cursorWidth;
|
||||
|
||||
/// Whether to show cursor.
|
||||
final bool? showCursor;
|
||||
|
||||
/// The padding of the Flags Button.
|
||||
///
|
||||
/// The amount of insets that are applied to the Flags Button.
|
||||
///
|
||||
/// If unset, defaults to [EdgeInsets.zero].
|
||||
final EdgeInsetsGeometry flagsButtonPadding;
|
||||
|
||||
/// The type of action button to use for the keyboard.
|
||||
final TextInputAction? textInputAction;
|
||||
|
||||
/// Optional set of styles to allow for customizing the country search
|
||||
/// & pick dialog
|
||||
final PickerDialogStyle? pickerDialogStyle;
|
||||
|
||||
/// The margin of the country selector button.
|
||||
///
|
||||
/// The amount of space to surround the country selector button.
|
||||
///
|
||||
/// If unset, defaults to [EdgeInsets.zero].
|
||||
final EdgeInsets flagsButtonMargin;
|
||||
|
||||
/// Enable the autofill hint for phone number.
|
||||
final bool disableAutoFillHints;
|
||||
|
||||
/// If null, default magnification configuration will be used.
|
||||
final TextMagnifierConfiguration? magnifierConfiguration;
|
||||
|
||||
const IntlPhoneField({
|
||||
Key? key,
|
||||
this.formFieldKey,
|
||||
this.initialCountryCode,
|
||||
this.languageCode = 'en',
|
||||
this.disableAutoFillHints = false,
|
||||
this.obscureText = false,
|
||||
this.textAlign = TextAlign.left,
|
||||
this.textAlignVertical,
|
||||
this.onTap,
|
||||
this.readOnly = false,
|
||||
this.initialValue,
|
||||
this.keyboardType = TextInputType.phone,
|
||||
this.controller,
|
||||
this.focusNode,
|
||||
this.decoration = const InputDecoration(),
|
||||
this.style,
|
||||
this.dropdownTextStyle,
|
||||
this.onSubmitted,
|
||||
this.validator,
|
||||
this.onChanged,
|
||||
this.countries,
|
||||
this.onCountryChanged,
|
||||
this.onSaved,
|
||||
this.showDropdownIcon = true,
|
||||
this.dropdownDecoration = const BoxDecoration(),
|
||||
this.inputFormatters,
|
||||
this.enabled = true,
|
||||
this.keyboardAppearance,
|
||||
@Deprecated('Use searchFieldInputDecoration of PickerDialogStyle instead')
|
||||
this.searchText = 'Search country',
|
||||
this.dropdownIconPosition = IconPosition.leading,
|
||||
this.dropdownIcon = const Icon(Icons.arrow_drop_down),
|
||||
this.autofocus = false,
|
||||
this.textInputAction,
|
||||
this.autovalidateMode = AutovalidateMode.onUserInteraction,
|
||||
this.showCountryFlag = true,
|
||||
this.cursorColor,
|
||||
this.disableLengthCheck = false,
|
||||
this.flagsButtonPadding = EdgeInsets.zero,
|
||||
this.invalidNumberMessage = 'Invalid Mobile Number',
|
||||
this.cursorHeight,
|
||||
this.cursorRadius = Radius.zero,
|
||||
this.cursorWidth = 2.0,
|
||||
this.showCursor = true,
|
||||
this.pickerDialogStyle,
|
||||
this.flagsButtonMargin = EdgeInsets.zero,
|
||||
this.magnifierConfiguration,
|
||||
}) : super(key: key);
|
||||
|
||||
@override
|
||||
State<IntlPhoneField> createState() => _IntlPhoneFieldState();
|
||||
}
|
||||
|
||||
class _IntlPhoneFieldState extends State<IntlPhoneField> {
|
||||
late List<Country> _countryList;
|
||||
late Country _selectedCountry;
|
||||
late List<Country> filteredCountries;
|
||||
late String number;
|
||||
|
||||
String? validatorMessage;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_countryList = widget.countries ?? countries;
|
||||
filteredCountries = _countryList;
|
||||
number = widget.initialValue ?? '';
|
||||
if (widget.initialCountryCode == null && number.startsWith('+')) {
|
||||
number = number.substring(1);
|
||||
// parse initial value
|
||||
_selectedCountry = countries.firstWhere(
|
||||
(country) => number.startsWith(country.fullCountryCode),
|
||||
orElse: () => _countryList.first);
|
||||
|
||||
// remove country code from the initial number value
|
||||
number = number.replaceFirst(
|
||||
RegExp("^${_selectedCountry.fullCountryCode}"), "");
|
||||
} else {
|
||||
_selectedCountry = _countryList.firstWhere(
|
||||
(item) => item.code == (widget.initialCountryCode ?? 'US'),
|
||||
orElse: () => _countryList.first);
|
||||
|
||||
// remove country code from the initial number value
|
||||
if (number.startsWith('+')) {
|
||||
number = number.replaceFirst(
|
||||
RegExp("^\\+${_selectedCountry.fullCountryCode}"), "");
|
||||
} else {
|
||||
number = number.replaceFirst(
|
||||
RegExp("^${_selectedCountry.fullCountryCode}"), "");
|
||||
}
|
||||
}
|
||||
|
||||
if (widget.autovalidateMode == AutovalidateMode.always) {
|
||||
final initialPhoneNumber = PhoneNumber(
|
||||
countryISOCode: _selectedCountry.code,
|
||||
countryCode: '+${_selectedCountry.dialCode}',
|
||||
number: widget.initialValue ?? '',
|
||||
);
|
||||
|
||||
final value = widget.validator?.call(initialPhoneNumber);
|
||||
|
||||
if (value is String) {
|
||||
validatorMessage = value;
|
||||
} else {
|
||||
(value as Future).then((msg) {
|
||||
validatorMessage = msg;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _changeCountry() async {
|
||||
filteredCountries = _countryList;
|
||||
await showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (context) => StatefulBuilder(
|
||||
builder: (ctx, setState) => CountryPickerDialog(
|
||||
languageCode: widget.languageCode.toLowerCase(),
|
||||
style: widget.pickerDialogStyle,
|
||||
filteredCountries: filteredCountries,
|
||||
searchText: widget.searchText,
|
||||
countryList: _countryList,
|
||||
selectedCountry: _selectedCountry,
|
||||
onCountryChanged: (Country country) {
|
||||
_selectedCountry = country;
|
||||
widget.onCountryChanged?.call(country);
|
||||
setState(() {});
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
key: widget.formFieldKey,
|
||||
initialValue: (widget.controller == null) ? number : null,
|
||||
autofillHints: widget.disableAutoFillHints
|
||||
? null
|
||||
: [AutofillHints.telephoneNumberNational],
|
||||
readOnly: widget.readOnly,
|
||||
obscureText: widget.obscureText,
|
||||
textAlign: widget.textAlign,
|
||||
textAlignVertical: widget.textAlignVertical,
|
||||
cursorColor: widget.cursorColor,
|
||||
onTap: widget.onTap,
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
cursorHeight: widget.cursorHeight,
|
||||
cursorRadius: widget.cursorRadius,
|
||||
cursorWidth: widget.cursorWidth,
|
||||
showCursor: widget.showCursor,
|
||||
onFieldSubmitted: widget.onSubmitted,
|
||||
magnifierConfiguration: widget.magnifierConfiguration,
|
||||
decoration: widget.decoration.copyWith(
|
||||
prefixIcon: _buildFlagsButton(),
|
||||
counterText: !widget.enabled ? '' : null,
|
||||
),
|
||||
style: widget.style,
|
||||
onSaved: (value) {
|
||||
widget.onSaved?.call(
|
||||
PhoneNumber(
|
||||
countryISOCode: _selectedCountry.code,
|
||||
countryCode:
|
||||
'+${_selectedCountry.dialCode}${_selectedCountry.regionCode}',
|
||||
number: value!,
|
||||
),
|
||||
);
|
||||
},
|
||||
onChanged: (value) async {
|
||||
final phoneNumber = PhoneNumber(
|
||||
countryISOCode: _selectedCountry.code,
|
||||
countryCode: '+${_selectedCountry.fullCountryCode}',
|
||||
number: value,
|
||||
);
|
||||
|
||||
if (widget.autovalidateMode != AutovalidateMode.disabled) {
|
||||
validatorMessage = await widget.validator?.call(phoneNumber);
|
||||
}
|
||||
|
||||
widget.onChanged?.call(phoneNumber);
|
||||
},
|
||||
validator: (value) {
|
||||
if (value == null || !isNumeric(value)) return validatorMessage;
|
||||
if (!widget.disableLengthCheck) {
|
||||
return value.length >= _selectedCountry.minLength &&
|
||||
value.length <= _selectedCountry.maxLength
|
||||
? null
|
||||
: widget.invalidNumberMessage;
|
||||
}
|
||||
|
||||
return validatorMessage;
|
||||
},
|
||||
maxLength: widget.disableLengthCheck ? null : _selectedCountry.maxLength,
|
||||
keyboardType: widget.keyboardType,
|
||||
inputFormatters: widget.inputFormatters,
|
||||
enabled: widget.enabled,
|
||||
keyboardAppearance: widget.keyboardAppearance,
|
||||
autofocus: widget.autofocus,
|
||||
textInputAction: widget.textInputAction,
|
||||
autovalidateMode: widget.autovalidateMode,
|
||||
);
|
||||
}
|
||||
|
||||
Container _buildFlagsButton() {
|
||||
return Container(
|
||||
margin: widget.flagsButtonMargin,
|
||||
child: DecoratedBox(
|
||||
decoration: widget.dropdownDecoration,
|
||||
child: InkWell(
|
||||
borderRadius: widget.dropdownDecoration.borderRadius as BorderRadius?,
|
||||
onTap: widget.enabled ? _changeCountry : null,
|
||||
child: Padding(
|
||||
padding: widget.flagsButtonPadding,
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: <Widget>[
|
||||
const SizedBox(
|
||||
width: 4,
|
||||
),
|
||||
if (widget.enabled &&
|
||||
widget.showDropdownIcon &&
|
||||
widget.dropdownIconPosition == IconPosition.leading) ...[
|
||||
widget.dropdownIcon,
|
||||
const SizedBox(width: 4),
|
||||
],
|
||||
if (widget.showCountryFlag) ...[
|
||||
kIsWeb
|
||||
? Image.asset(
|
||||
'assets/flags/${_selectedCountry.code.toLowerCase()}.png',
|
||||
package: 'intl_phone_field',
|
||||
width: 32,
|
||||
)
|
||||
: Text(
|
||||
_selectedCountry.flag,
|
||||
style: const TextStyle(fontSize: 18),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
FittedBox(
|
||||
child: Text(
|
||||
'+${_selectedCountry.dialCode}',
|
||||
style: widget.dropdownTextStyle,
|
||||
),
|
||||
),
|
||||
if (widget.enabled &&
|
||||
widget.showDropdownIcon &&
|
||||
widget.dropdownIconPosition == IconPosition.trailing) ...[
|
||||
const SizedBox(width: 4),
|
||||
widget.dropdownIcon,
|
||||
],
|
||||
const SizedBox(width: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
enum IconPosition {
|
||||
leading,
|
||||
trailing,
|
||||
}
|
||||
@@ -0,0 +1,79 @@
|
||||
import 'countries.dart';
|
||||
|
||||
class NumberTooLongException implements Exception {}
|
||||
|
||||
class NumberTooShortException implements Exception {}
|
||||
|
||||
class InvalidCharactersException implements Exception {}
|
||||
|
||||
class PhoneNumber {
|
||||
String countryISOCode;
|
||||
String countryCode;
|
||||
String number;
|
||||
|
||||
PhoneNumber({
|
||||
required this.countryISOCode,
|
||||
required this.countryCode,
|
||||
required this.number,
|
||||
});
|
||||
|
||||
factory PhoneNumber.fromCompleteNumber({required String completeNumber}) {
|
||||
if (completeNumber == "") {
|
||||
return PhoneNumber(countryISOCode: "", countryCode: "", number: "");
|
||||
}
|
||||
|
||||
try {
|
||||
Country country = getCountry(completeNumber);
|
||||
String number;
|
||||
if (completeNumber.startsWith('+')) {
|
||||
number = completeNumber.substring(1 + country.dialCode.length + country.regionCode.length);
|
||||
} else {
|
||||
number = completeNumber.substring(country.dialCode.length + country.regionCode.length);
|
||||
}
|
||||
return PhoneNumber(
|
||||
countryISOCode: country.code, countryCode: country.dialCode + country.regionCode, number: number);
|
||||
} on InvalidCharactersException {
|
||||
rethrow;
|
||||
// ignore: unused_catch_clause
|
||||
} on Exception catch (e) {
|
||||
return PhoneNumber(countryISOCode: "", countryCode: "", number: "");
|
||||
}
|
||||
}
|
||||
|
||||
bool isValidNumber() {
|
||||
Country country = getCountry(completeNumber);
|
||||
if (number.length < country.minLength) {
|
||||
throw NumberTooShortException();
|
||||
}
|
||||
|
||||
if (number.length > country.maxLength) {
|
||||
throw NumberTooLongException();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
String get completeNumber {
|
||||
return countryCode + number;
|
||||
}
|
||||
|
||||
static Country getCountry(String phoneNumber) {
|
||||
if (phoneNumber == "") {
|
||||
throw NumberTooShortException();
|
||||
}
|
||||
|
||||
final validPhoneNumber = RegExp(r'^[+0-9]*[0-9]*$');
|
||||
|
||||
if (!validPhoneNumber.hasMatch(phoneNumber)) {
|
||||
throw InvalidCharactersException();
|
||||
}
|
||||
|
||||
if (phoneNumber.startsWith('+')) {
|
||||
return countries
|
||||
.firstWhere((country) => phoneNumber.substring(1).startsWith(country.dialCode + country.regionCode));
|
||||
}
|
||||
return countries.firstWhere((country) => phoneNumber.startsWith(country.dialCode + country.regionCode));
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'PhoneNumber(countryISOCode: $countryISOCode, countryCode: $countryCode, number: $number)';
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// هيكل التحميل — تأثير نبض على عناصر مستوحاة من الشاشة الفعلية.
|
||||
/// docs/26 §7
|
||||
class Skeleton extends StatelessWidget {
|
||||
const Skeleton({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final baseColor = cs.surfaceContainerHighest;
|
||||
final highlightColor = cs.surfaceContainerHigh;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_PulseBox(width: double.infinity, height: 200, base: baseColor, highlight: highlightColor),
|
||||
const SizedBox(height: T.s16),
|
||||
_PulseBox(width: 200, height: 20, base: baseColor, highlight: highlightColor),
|
||||
const SizedBox(height: T.s12),
|
||||
_PulseBox(width: 140, height: 16, base: baseColor, highlight: highlightColor),
|
||||
const SizedBox(height: T.s24),
|
||||
Row(
|
||||
children: [
|
||||
_PulseBox(width: 48, height: 48, base: baseColor, highlight: highlightColor, radius: T.rCircle),
|
||||
const SizedBox(width: T.s12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_PulseBox(width: double.infinity, height: 16, base: baseColor, highlight: highlightColor),
|
||||
const SizedBox(height: T.s8),
|
||||
_PulseBox(width: 120, height: 14, base: baseColor, highlight: highlightColor),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PulseBox extends StatefulWidget {
|
||||
const _PulseBox({
|
||||
required this.width,
|
||||
required this.height,
|
||||
required this.base,
|
||||
required this.highlight,
|
||||
this.radius = 8,
|
||||
});
|
||||
|
||||
final double width;
|
||||
final double height;
|
||||
final Color base;
|
||||
final Color highlight;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
State<_PulseBox> createState() => _PulseBoxState();
|
||||
}
|
||||
|
||||
class _PulseBoxState extends State<_PulseBox>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
late final Animation<double> _anim;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1200))
|
||||
..repeat();
|
||||
_anim = Tween<double>(begin: 0, end: 1).animate(
|
||||
CurvedAnimation(parent: _ctrl, curve: Curves.easeInOut),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: _anim,
|
||||
builder: (_, __) {
|
||||
return Container(
|
||||
width: widget.width,
|
||||
height: widget.height,
|
||||
decoration: BoxDecoration(
|
||||
color: Color.lerp(widget.base, widget.highlight, _anim.value),
|
||||
borderRadius: BorderRadius.circular(widget.radius),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// شريط حالة الرحلة الحي.
|
||||
/// docs/26 §7
|
||||
class StatusBanner extends StatelessWidget {
|
||||
const StatusBanner({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.color,
|
||||
this.icon,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final Color? color;
|
||||
final IconData? icon;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final effectiveColor = color ?? cs.primary;
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: T.s16,
|
||||
vertical: T.s12,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: effectiveColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(T.r8),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
if (icon != null) ...[
|
||||
Icon(icon, color: effectiveColor, size: T.iconSm),
|
||||
const SizedBox(width: T.s8),
|
||||
],
|
||||
Expanded(
|
||||
child: Text(
|
||||
label,
|
||||
style: Theme.of(context).textTheme.labelLarge?.copyWith(
|
||||
color: effectiveColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// زر موحّد: primary (معبّأ) · secondary (محدّد) · text — مع حالة تحميل داخلية.
|
||||
/// docs/26 §7
|
||||
enum TripzButtonStyle { primary, secondary, text }
|
||||
|
||||
class TripzButton extends StatelessWidget {
|
||||
const TripzButton({
|
||||
super.key,
|
||||
required this.label,
|
||||
this.onPressed,
|
||||
this.style = TripzButtonStyle.primary,
|
||||
this.loading = false,
|
||||
this.icon,
|
||||
this.fullWidth = true,
|
||||
});
|
||||
|
||||
final String label;
|
||||
final VoidCallback? onPressed;
|
||||
final TripzButtonStyle style;
|
||||
final bool loading;
|
||||
final IconData? icon;
|
||||
final bool fullWidth;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final labelStyle = theme.textTheme.labelLarge;
|
||||
|
||||
final effectiveOnPressed = loading ? null : onPressed;
|
||||
|
||||
switch (style) {
|
||||
case TripzButtonStyle.primary:
|
||||
return SizedBox(
|
||||
width: fullWidth ? double.infinity : null,
|
||||
height: T.buttonHeight,
|
||||
child: FilledButton(
|
||||
onPressed: effectiveOnPressed,
|
||||
child: _buildChild(cs.primary, labelStyle),
|
||||
),
|
||||
);
|
||||
case TripzButtonStyle.secondary:
|
||||
return SizedBox(
|
||||
width: fullWidth ? double.infinity : null,
|
||||
height: T.buttonHeight,
|
||||
child: OutlinedButton(
|
||||
onPressed: effectiveOnPressed,
|
||||
child: _buildChild(cs.primary, labelStyle),
|
||||
),
|
||||
);
|
||||
case TripzButtonStyle.text:
|
||||
return TextButton(
|
||||
onPressed: effectiveOnPressed,
|
||||
child: _buildChild(cs.primary, labelStyle),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget _buildChild(Color color, TextStyle? style) {
|
||||
if (loading) {
|
||||
return const SizedBox(
|
||||
height: 22,
|
||||
width: 22,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
);
|
||||
}
|
||||
|
||||
if (icon != null) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(icon, size: 20),
|
||||
const SizedBox(width: T.s8),
|
||||
Text(label, style: style),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
return Text(label, style: style);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// بطاقة موحّدة: رحلة / سائق / محفظة — زوايا 12، حشوة 16.
|
||||
/// docs/26 §7
|
||||
class TripzCard extends StatelessWidget {
|
||||
const TripzCard({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.padding,
|
||||
this.onTap,
|
||||
this.color,
|
||||
this.borderColor,
|
||||
});
|
||||
|
||||
final Widget child;
|
||||
final EdgeInsetsGeometry? padding;
|
||||
final VoidCallback? onTap;
|
||||
final Color? color;
|
||||
final Color? borderColor;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final card = Card(
|
||||
color: color,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
side: borderColor != null
|
||||
? BorderSide(color: borderColor!)
|
||||
: BorderSide.none,
|
||||
),
|
||||
child: Padding(
|
||||
padding: padding ?? const EdgeInsets.all(T.pagePadding),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
|
||||
if (onTap != null) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(T.r12),
|
||||
child: card,
|
||||
);
|
||||
}
|
||||
|
||||
return card;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
import '../l10n/app_localizations.dart';
|
||||
|
||||
/// السكافولد الموحّد — كل شاشة في التطبيق تُبنى داخله.
|
||||
/// يحوّل `status` من Cubit/Bloc إلى: skeleton / error / empty / محتوى.
|
||||
/// docs/26 §6
|
||||
class TripzScaffold extends StatelessWidget {
|
||||
const TripzScaffold({
|
||||
super.key,
|
||||
this.title,
|
||||
this.status,
|
||||
this.onRetry,
|
||||
this.empty,
|
||||
this.bottomAction,
|
||||
this.actions,
|
||||
this.showBackButton = false,
|
||||
this.onBack,
|
||||
this.safeAreaTop = true,
|
||||
this.safeAreaBottom = true,
|
||||
this.padding = true,
|
||||
this.backgroundColor,
|
||||
required this.child,
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final Enum? status;
|
||||
final VoidCallback? onRetry;
|
||||
final Widget? empty;
|
||||
final Widget? bottomAction;
|
||||
final List<Widget>? actions;
|
||||
final bool showBackButton;
|
||||
final VoidCallback? onBack;
|
||||
final bool safeAreaTop;
|
||||
final bool safeAreaBottom;
|
||||
final bool padding;
|
||||
final Color? backgroundColor;
|
||||
final Widget child;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: backgroundColor ?? cs.surface,
|
||||
appBar: title != null
|
||||
? AppBar(
|
||||
title: Text(title!),
|
||||
leading: showBackButton
|
||||
? IconButton(
|
||||
icon: const Icon(Icons.arrow_back_ios_new_rounded, size: 20),
|
||||
onPressed: onBack ?? () => Navigator.maybePop(context),
|
||||
)
|
||||
: null,
|
||||
actions: actions,
|
||||
)
|
||||
: null,
|
||||
body: SafeArea(
|
||||
top: safeAreaTop,
|
||||
bottom: safeAreaBottom,
|
||||
child: _buildBody(context),
|
||||
),
|
||||
bottomNavigationBar: bottomAction != null
|
||||
? Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
T.pagePadding,
|
||||
0,
|
||||
T.pagePadding,
|
||||
MediaQuery.of(context).viewInsets.bottom > 0
|
||||
? MediaQuery.of(context).viewInsets.bottom + 8
|
||||
: T.pagePadding,
|
||||
),
|
||||
child: bottomAction,
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody(BuildContext context) {
|
||||
if (status != null) {
|
||||
if (_isError) return ErrorView(onRetry: onRetry);
|
||||
if (_isLoading) return const Skeleton();
|
||||
if (_isEmpty) return empty ?? const EmptyView();
|
||||
}
|
||||
|
||||
return padding
|
||||
? Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: child,
|
||||
)
|
||||
: child;
|
||||
}
|
||||
|
||||
bool get _isLoading =>
|
||||
status != null && status!.name == 'loading';
|
||||
|
||||
bool get _isError =>
|
||||
status != null && status!.name == 'error';
|
||||
|
||||
bool get _isEmpty =>
|
||||
status != null && status!.name == 'empty';
|
||||
}
|
||||
|
||||
class ErrorView extends StatelessWidget {
|
||||
final VoidCallback? onRetry;
|
||||
const ErrorView({super.key, this.onRetry});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: T.s64,
|
||||
height: T.s64,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.error.withValues(alpha: 0.1),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.error_outline_rounded,
|
||||
size: T.s48,
|
||||
color: cs.error,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
Text(
|
||||
l10n?.errorTitle ?? 'حدث خطأ',
|
||||
style: theme.textTheme.titleLarge?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: T.s8),
|
||||
Text(
|
||||
l10n?.errorSubtitle ?? 'يرجى المحاولة مرة أخرى',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (onRetry != null) ...[
|
||||
const SizedBox(height: T.s24),
|
||||
FilledButton.icon(
|
||||
onPressed: onRetry,
|
||||
icon: const Icon(Icons.refresh_rounded, size: T.iconSm),
|
||||
label: Text(l10n?.retry ?? 'أعد المحاولة'),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class Skeleton extends StatefulWidget {
|
||||
const Skeleton({super.key});
|
||||
|
||||
@override
|
||||
State<Skeleton> createState() => _SkeletonState();
|
||||
}
|
||||
|
||||
class _SkeletonState extends State<Skeleton> with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_controller = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
)..repeat();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final baseColor = cs.surfaceContainerHighest;
|
||||
final highlightColor = cs.surfaceContainerLow;
|
||||
|
||||
return AnimatedBuilder(
|
||||
animation: _controller,
|
||||
builder: (context, child) {
|
||||
return CustomPaint(
|
||||
painter: _ShimmerPainter(
|
||||
baseColor: baseColor,
|
||||
highlightColor: highlightColor,
|
||||
progress: _controller.value,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: _buildSkeletonLayout(context),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildSkeletonLayout(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final baseColor = cs.surfaceContainerHighest;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header row skeleton
|
||||
_ShimmerBox(
|
||||
width: 160,
|
||||
height: 24,
|
||||
color: baseColor,
|
||||
radius: T.r8,
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
// Subtitle row skeleton
|
||||
_ShimmerBox(
|
||||
width: 100,
|
||||
height: 14,
|
||||
color: baseColor,
|
||||
radius: T.r8,
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
// Card-like skeleton block 1
|
||||
_ShimmerBox(
|
||||
width: double.infinity,
|
||||
height: 120,
|
||||
color: baseColor,
|
||||
radius: T.r12,
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
// Card-like skeleton block 2
|
||||
_ShimmerBox(
|
||||
width: double.infinity,
|
||||
height: 80,
|
||||
color: baseColor,
|
||||
radius: T.r12,
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
// Row of two small boxes
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ShimmerBox(
|
||||
height: 48,
|
||||
color: baseColor,
|
||||
radius: T.r12,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: T.s12),
|
||||
Expanded(
|
||||
child: _ShimmerBox(
|
||||
height: 48,
|
||||
color: baseColor,
|
||||
radius: T.r12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShimmerBox extends StatelessWidget {
|
||||
const _ShimmerBox({
|
||||
required this.height,
|
||||
required this.color,
|
||||
this.width = double.infinity,
|
||||
this.radius = 0,
|
||||
});
|
||||
|
||||
final double width;
|
||||
final double height;
|
||||
final Color color;
|
||||
final double radius;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
decoration: BoxDecoration(
|
||||
color: color,
|
||||
borderRadius: BorderRadius.circular(radius),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ShimmerPainter extends CustomPainter {
|
||||
_ShimmerPainter({
|
||||
required this.baseColor,
|
||||
required this.highlightColor,
|
||||
required this.progress,
|
||||
});
|
||||
|
||||
final Color baseColor;
|
||||
final Color highlightColor;
|
||||
final double progress;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final paint = Paint();
|
||||
final dx = size.width * 1.5 * progress;
|
||||
final gradient = LinearGradient(
|
||||
begin: const Alignment(-0.3, 0),
|
||||
end: const Alignment(0.3, 0),
|
||||
colors: [
|
||||
baseColor,
|
||||
highlightColor,
|
||||
baseColor,
|
||||
],
|
||||
stops: const [0.0, 0.5, 1.0],
|
||||
);
|
||||
final rect = Rect.fromCenter(
|
||||
center: Offset(dx, size.height / 2),
|
||||
width: size.width * 0.6,
|
||||
height: size.height,
|
||||
);
|
||||
paint.shader = gradient.createShader(rect);
|
||||
canvas.drawRect(rect, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_ShimmerPainter old) => old.progress != progress;
|
||||
}
|
||||
|
||||
class EmptyView extends StatelessWidget {
|
||||
const EmptyView({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final cs = theme.colorScheme;
|
||||
final l10n = AppLocalizations.of(context);
|
||||
return Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(T.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: T.s64,
|
||||
height: T.s64,
|
||||
decoration: BoxDecoration(
|
||||
color: cs.primaryContainer,
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.inbox_outlined,
|
||||
size: T.iconLg,
|
||||
color: cs.onPrimaryContainer,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: T.s24),
|
||||
Text(
|
||||
l10n?.emptyTitle ?? 'لا توجد بيانات',
|
||||
style: theme.textTheme.titleMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: T.s8),
|
||||
Text(
|
||||
l10n?.emptySubtitle ?? 'لم يتم العثور على محتوى حالياً',
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: cs.onSurfaceVariant,
|
||||
),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// كل bottom sheet في التطبيق يمرّ من هنا: مقبض علوي، زوايا 24.
|
||||
/// docs/26 §7
|
||||
class TripzSheet extends StatelessWidget {
|
||||
const TripzSheet({
|
||||
super.key,
|
||||
this.title,
|
||||
this.child,
|
||||
this.bottomAction,
|
||||
this.isScrollControlled = true,
|
||||
});
|
||||
|
||||
final String? title;
|
||||
final Widget? child;
|
||||
final Widget? bottomAction;
|
||||
final bool isScrollControlled;
|
||||
|
||||
/// مساعد للاستدعاء بأمر واحد
|
||||
static Future<R?> show<R>(
|
||||
BuildContext context, {
|
||||
String? title,
|
||||
Widget? child,
|
||||
Widget? bottomAction,
|
||||
bool isScrollControlled = true,
|
||||
}) {
|
||||
return showModalBottomSheet<R>(
|
||||
context: context,
|
||||
isScrollControlled: isScrollControlled,
|
||||
useSafeArea: true,
|
||||
builder: (_) => TripzSheet(
|
||||
title: title,
|
||||
bottomAction: bottomAction,
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final theme = Theme.of(context);
|
||||
final bottomPadding = MediaQuery.of(context).viewInsets.bottom;
|
||||
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(0, 0, 0, bottomPadding),
|
||||
child: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: T.pagePadding),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (title != null) ...[
|
||||
Text(
|
||||
title!,
|
||||
style: theme.textTheme.titleMedium,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: T.s16),
|
||||
],
|
||||
if (child != null) child!,
|
||||
if (bottomAction != null) ...[
|
||||
const SizedBox(height: T.s16),
|
||||
bottomAction!,
|
||||
const SizedBox(height: T.s8),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../design/tokens.dart';
|
||||
|
||||
/// حقل إدخال موحّد: هاتف · OTP · بحث — رسالة الخطأ تحته بالعربية.
|
||||
/// docs/26 §7
|
||||
class TripzTextField extends StatelessWidget {
|
||||
const TripzTextField({
|
||||
super.key,
|
||||
this.controller,
|
||||
this.label,
|
||||
this.hint,
|
||||
this.prefix,
|
||||
this.suffix,
|
||||
this.keyboardType,
|
||||
this.obscureText = false,
|
||||
this.errorText,
|
||||
this.enabled = true,
|
||||
this.onChanged,
|
||||
this.onSubmitted,
|
||||
this.autofocus = false,
|
||||
this.textAlign,
|
||||
this.maxLength,
|
||||
this.contentPadding,
|
||||
this.focusNode,
|
||||
this.textInputAction,
|
||||
});
|
||||
|
||||
final TextEditingController? controller;
|
||||
final String? label;
|
||||
final String? hint;
|
||||
final Widget? prefix;
|
||||
final Widget? suffix;
|
||||
final TextInputType? keyboardType;
|
||||
final bool obscureText;
|
||||
final String? errorText;
|
||||
final bool enabled;
|
||||
final ValueChanged<String>? onChanged;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
final bool autofocus;
|
||||
final TextAlign? textAlign;
|
||||
final int? maxLength;
|
||||
final EdgeInsetsGeometry? contentPadding;
|
||||
final FocusNode? focusNode;
|
||||
final TextInputAction? textInputAction;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: T.inputHeight,
|
||||
child: TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
obscureText: obscureText,
|
||||
enabled: enabled,
|
||||
onChanged: onChanged,
|
||||
onSubmitted: onSubmitted,
|
||||
autofocus: autofocus,
|
||||
textAlign: textAlign ?? TextAlign.start,
|
||||
maxLength: maxLength,
|
||||
style: Theme.of(context).textTheme.bodyLarge,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
prefixIcon: prefix,
|
||||
suffixIcon: suffix,
|
||||
counterText: '',
|
||||
contentPadding: contentPadding ??
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (errorText != null) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
errorText!,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: cs.error,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user