From ca766a2d4eba5ec6b73fa0cb59b1aad21c6762cf Mon Sep 17 00:00:00 2001 From: Hamza-Ayed Date: Wed, 5 Aug 2026 01:34:23 +0300 Subject: [PATCH] =?UTF-8?q?feat(apps):=20=D8=A7=D9=84=D8=AF=D8=B1=D8=A7?= =?UTF-8?q?=D9=88=D8=B1=20=D8=B9=D9=84=D9=89=20=D9=86=D9=85=D8=B7=20=D8=B3?= =?UTF-8?q?=D9=8A=D8=B1=D9=88=20=E2=80=94=20=D8=A7=D9=84=D9=85=D9=83=D8=A7?= =?UTF-8?q?=D9=81=D8=A2=D8=AA=20=D9=88=D8=A7=D9=84=D8=AF=D8=B9=D9=88=D8=A7?= =?UTF-8?q?=D8=AA=20=D9=88=D8=A7=D9=84=D9=85=D8=B3=D8=A7=D8=B9=D8=AF=D8=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit جُرد دراور سيرو: 14 مدخلاً للراكب (map_menu_widget) و23 للسائق (drawer_captain). بُني ما له نقطة فعلية على NestJS، والباقي مذكور صراحةً. ## مبنيّ - **المكافآت** (features/rewards): GET /rewards/referrals و /rewards/coupons — كلاهما مُتحقَّق حيّاً. كود الدعوة يُنسخ ويُشارك، وعدّادات دعوتَهم/قيد التأكيد/مدفوعة. يقابل Promos + Invite Driver في سيرو - **شارك التطبيق · قيّم التطبيق · سياسة الخصوصية · تطبيق الطرف الآخر** — أفعال محليّة بلا نقطة خادم (share_plus + url_launcher) - **المساعدة** وُسّعت: أسئلة شائعة قابلة للطيّ · واتساب الدعم · **فحص توافق الجهاز** (نمط سيرو) يفحص ما يُعطّل التطبيق فعلاً: خدمة الموقع وإذنها وإذن الإشعارات — أسباب حقيقية لشكوى «التطبيق لا يعمل» - روابط المتجر والخصوصية والدعم صارت قيم بناء لكل مستأجر في BuildConfig ## غير مبنيّ — ولا نقطة خادم له الشكاوى (R3 في الباكلوج) · الإحصاءات · التحدّيات ولوحة المتصدّرين · مركز الصيانة · جدولي · التأمين الصحي (مؤجَّل بقرار المالك) · الطعام والمواصلات (خارج النطاق، docs/39 §1). بناء واجهة بلا خادم يعني شاشة تكذب على المستخدم. ## سجلّ الإشعارات سيرو يعرض قائمة إشعارات؛ الخادم يوفّر POST /notifications/token فقط بلا نقطة سرد. لم تُبنَ شاشة قائمة على فراغ. flutter analyze نظيف في التطبيقين. Co-Authored-By: Claude Opus 5 --- apps/driver_new/lib/core/build_config.dart | 22 ++ apps/driver_new/lib/core/di.dart | 8 + apps/driver_new/lib/core/l10n/app_ar.arb | 35 +++ apps/driver_new/lib/core/l10n/app_en.arb | 35 +++ apps/driver_new/lib/core/router.dart | 6 + apps/driver_new/lib/core/ui/app_drawer.dart | 64 ++++++ .../features/rewards/cubit/rewards_cubit.dart | 46 ++++ .../rewards/data/rewards_repository.dart | 65 ++++++ .../features/rewards/view/rewards_page.dart | 201 ++++++++++++++++++ .../features/support/view/support_page.dart | 140 ++++++++++-- .../wallet/data/wallet_repository.dart | 18 +- apps/driver_new/pubspec.lock | 80 +++++++ apps/driver_new/pubspec.yaml | 4 + apps/driver_new/tool/driver_l10n_extra.py | 10 + apps/driver_new/tool/sync_from_rider.sh | 2 +- apps/rider_new/lib/core/build_config.dart | 22 ++ apps/rider_new/lib/core/di.dart | 8 + apps/rider_new/lib/core/l10n/app_ar.arb | 37 +++- apps/rider_new/lib/core/l10n/app_en.arb | 37 +++- apps/rider_new/lib/core/router.dart | 6 + apps/rider_new/lib/core/ui/app_drawer.dart | 64 ++++++ .../features/rewards/cubit/rewards_cubit.dart | 46 ++++ .../rewards/data/rewards_repository.dart | 65 ++++++ .../features/rewards/view/rewards_page.dart | 201 ++++++++++++++++++ .../features/support/view/support_page.dart | 140 ++++++++++-- apps/rider_new/pubspec.lock | 88 ++++++++ apps/rider_new/pubspec.yaml | 4 + 27 files changed, 1406 insertions(+), 48 deletions(-) create mode 100644 apps/driver_new/lib/features/rewards/cubit/rewards_cubit.dart create mode 100644 apps/driver_new/lib/features/rewards/data/rewards_repository.dart create mode 100644 apps/driver_new/lib/features/rewards/view/rewards_page.dart create mode 100644 apps/rider_new/lib/features/rewards/cubit/rewards_cubit.dart create mode 100644 apps/rider_new/lib/features/rewards/data/rewards_repository.dart create mode 100644 apps/rider_new/lib/features/rewards/view/rewards_page.dart diff --git a/apps/driver_new/lib/core/build_config.dart b/apps/driver_new/lib/core/build_config.dart index 5aa721e..06dcbc6 100644 --- a/apps/driver_new/lib/core/build_config.dart +++ b/apps/driver_new/lib/core/build_config.dart @@ -22,6 +22,28 @@ class BuildConfig { static const String logoAsset = 'assets/images/logo.png'; static const String splashLogoAsset = 'assets/images/splash_logo.png'; + + /// روابط خارجية لكل مستأجر — تُولَّد مع بقية الملف (N3). + static const String storeUrl = String.fromEnvironment( + 'STORE_URL', + defaultValue: 'https://tripz.app', + ); + static const String privacyUrl = String.fromEnvironment( + 'PRIVACY_URL', + defaultValue: 'https://tripz.app/privacy', + ); + + /// رقم دعم واتساب المستأجر — بصيغة دولية بلا `+`. + static const String supportPhone = String.fromEnvironment( + 'SUPPORT_PHONE', + defaultValue: '962790000000', + ); + + /// رابط تطبيق الطرف الآخر — السائق يدعو ركّاباً والعكس. + static const String driverAppUrl = String.fromEnvironment( + 'RIDER_APP_URL', + defaultValue: 'https://tripz.app', + ); } /// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة diff --git a/apps/driver_new/lib/core/di.dart b/apps/driver_new/lib/core/di.dart index f39dd28..2892ae6 100644 --- a/apps/driver_new/lib/core/di.dart +++ b/apps/driver_new/lib/core/di.dart @@ -16,6 +16,8 @@ import '../features/profile/data/profile_repository.dart'; import '../features/rating/cubit/rating_cubit.dart'; import '../features/rating/data/rating_repository.dart'; +import '../features/rewards/cubit/rewards_cubit.dart'; +import '../features/rewards/data/rewards_repository.dart'; import '../features/settings/cubit/settings_cubit.dart'; @@ -113,6 +115,12 @@ Future setupInjector() async { sl.registerFactory(() => HistoryCubit(sl())); sl.registerSingleton(RatingRepository(sl())); + + // المكافآت خلف علم الكوبونات: الدعوات والكوبونات وجهان لنفس الوحدة. + if (Features.coupons) { + sl.registerSingleton(RewardsRepository(sl())); + sl.registerFactory(() => RewardsCubit(sl())); + } sl.registerFactory(() => RatingCubit(sl())); // الدردشة خلف علمها: إطفاؤه يحذف كودها من الـbinary (docs/23 §5). diff --git a/apps/driver_new/lib/core/l10n/app_ar.arb b/apps/driver_new/lib/core/l10n/app_ar.arb index e515bee..308af8d 100644 --- a/apps/driver_new/lib/core/l10n/app_ar.arb +++ b/apps/driver_new/lib/core/l10n/app_ar.arb @@ -209,6 +209,41 @@ "walletCopy": "نسخ", "walletCopied": "نُسخ", "walletNoTarget": "لم يُضبط حساب استلام لهذه القناة بعد — راجع المشغّل", + "rewardsTitle": "مكافآتي", + "rewardsInviteTitle": "ادعُ سائقاً", + "rewardsInviteLead": "شارك كودك — تربح عن كل سائق ينضم", + "rewardsCode": "كود الدعوة", + "rewardsInvited": "دعوتَهم", + "rewardsPending": "قيد التأكيد", + "rewardsPaid": "مكافآت مدفوعة", + "rewardsCoupons": "كوبوناتي", + "rewardsCouponsEmpty": "لا كوبونات صالحة الآن", + "menuRewards": "مكافآتي", + "menuInvite": "ادعُ سائقاً", + "menuShare": "شارك التطبيق", + "menuNotifications": "الإشعارات", + "menuPrivacy": "سياسة الخصوصية", + "menuRateApp": "قيّم التطبيق", + "shareMessage": "انضم لـTripz كسائق — استعمل كودي {code}", + "menuDriverApp": "تطبيق الراكب", + "@shareMessage": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "supportFaqQ1": "كيف أطلب رحلة؟", + "supportFaqA1": "حدّد وجهتك من البحث أو من الخريطة، اختر نوع الرحلة، ثم اضغط «اطلب الرحلة».", + "supportFaqQ2": "كيف أشحن رصيدي؟", + "supportFaqA2": "من المحفظة اختر المبلغ وطريقة الدفع. ستظهر لك تعليمات التحويل ورقم مرجع — يُضاف الرصيد بعد تأكيد التحويل.", + "supportFaqQ3": "لماذا لا يظهر سائق؟", + "supportFaqA3": "قد لا يوجد سائقون قريبون في هذا الوقت. جرّب بعد قليل أو غيّر نوع الرحلة.", + "supportFaqQ4": "كيف ألغي رحلة؟", + "supportFaqA4": "من بطاقة الرحلة اضغط «إلغاء الرحلة». الإلغاء بعد وصول السائق قد يترتّب عليه رسم.", + "supportDeviceCheck": "فحص توافق الجهاز", + "supportDeviceOk": "جهازك متوافق", + "supportDeviceIssues": "مشاكل قد تؤثّر على عمل التطبيق", "obApplyTitle": "سجّل كسائق", "obApplyLead": "خطوات قصيرة ثم تبدأ العمل", "obVehicleMake": "شركة السيارة", diff --git a/apps/driver_new/lib/core/l10n/app_en.arb b/apps/driver_new/lib/core/l10n/app_en.arb index f6e5538..a2a574c 100644 --- a/apps/driver_new/lib/core/l10n/app_en.arb +++ b/apps/driver_new/lib/core/l10n/app_en.arb @@ -209,6 +209,41 @@ "walletCopy": "Copy", "walletCopied": "Copied", "walletNoTarget": "No receiving account is configured for this channel yet — contact the operator", + "rewardsTitle": "My rewards", + "rewardsInviteTitle": "Invite a driver", + "rewardsInviteLead": "Share your code — earn for every driver who joins", + "rewardsCode": "Invite code", + "rewardsInvited": "Invited", + "rewardsPending": "Pending", + "rewardsPaid": "Rewards paid", + "rewardsCoupons": "My coupons", + "rewardsCouponsEmpty": "No valid coupons right now", + "menuRewards": "My rewards", + "menuInvite": "Invite a driver", + "menuShare": "Share the app", + "menuNotifications": "Notifications", + "menuPrivacy": "Privacy policy", + "menuRateApp": "Rate the app", + "shareMessage": "Join Tripz as a driver — use my code {code}", + "menuDriverApp": "Rider app", + "@shareMessage": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "supportFaqQ1": "How do I request a ride?", + "supportFaqA1": "Pick your destination from search or the map, choose a ride type, then tap “Request ride”.", + "supportFaqQ2": "How do I top up?", + "supportFaqA2": "In the wallet, choose an amount and a payment method. You'll get transfer instructions and a reference — your balance updates once the transfer is confirmed.", + "supportFaqQ3": "Why is no driver showing up?", + "supportFaqA3": "There may be no drivers nearby right now. Try again shortly or change the ride type.", + "supportFaqQ4": "How do I cancel?", + "supportFaqA4": "Tap “Cancel ride” on the trip card. Cancelling after the driver arrives may incur a fee.", + "supportDeviceCheck": "Check device compatibility", + "supportDeviceOk": "Your device is compatible", + "supportDeviceIssues": "Issues that may affect the app", "obApplyTitle": "Register as a driver", "obApplyLead": "A few short steps, then you're working", "obVehicleMake": "Car make", diff --git a/apps/driver_new/lib/core/router.dart b/apps/driver_new/lib/core/router.dart index ae5744c..1bf7a7c 100644 --- a/apps/driver_new/lib/core/router.dart +++ b/apps/driver_new/lib/core/router.dart @@ -11,6 +11,7 @@ import '../features/profile/view/profile_edit_page.dart'; import '../features/profile/view/profile_setup_page.dart'; import '../features/rating/data/rating_repository.dart'; +import '../features/rewards/view/rewards_page.dart'; import '../features/rating/view/rating_page.dart'; @@ -46,6 +47,7 @@ class Routes { static const profileEdit = '/account'; static const settings = '/settings'; static const support = '/support'; + static const rewards = '/rewards'; static const chat = '/chat'; static const onboarding = '/onboarding'; } @@ -135,6 +137,10 @@ final appRouter = GoRouter( path: Routes.onboarding, builder: (context, state) => const OnboardingPage(), ), + GoRoute( + path: Routes.rewards, + builder: (context, state) => const RewardsPage(), + ), GoRoute( path: Routes.support, builder: (context, state) => const SupportPage(), diff --git a/apps/driver_new/lib/core/ui/app_drawer.dart b/apps/driver_new/lib/core/ui/app_drawer.dart index 4b7805f..dafef25 100644 --- a/apps/driver_new/lib/core/ui/app_drawer.dart +++ b/apps/driver_new/lib/core/ui/app_drawer.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../build_config.dart'; import '../config.dart'; @@ -52,6 +54,13 @@ class AppDrawer extends StatelessWidget { label: isDriver ? l10n.menuEarnings : l10n.menuWallet, route: Routes.wallet, ), + if (Features.coupons && context.tenantHas('coupons')) + _Item( + icon: Icons.card_giftcard_rounded, + label: l10n.menuRewards, + route: Routes.rewards, + ), + const Divider(indent: Space.md, endIndent: Space.md), _Item( icon: Icons.settings_outlined, label: l10n.menuSettings, @@ -62,6 +71,29 @@ class AppDrawer extends StatelessWidget { label: l10n.menuSupport, route: Routes.support, ), + // مداخل محليّة بلا نقطة خادم — تُنفَّذ في الجهاز مباشرة. + _Action( + icon: Icons.share_outlined, + label: l10n.menuShare, + onTap: () => SharePlus.instance + .share(ShareParams(text: l10n.shareMessage(''))), + ), + _Action( + icon: Icons.star_outline_rounded, + label: l10n.menuRateApp, + onTap: () => _open(BuildConfig.storeUrl), + ), + _Action( + icon: Icons.shield_outlined, + label: l10n.menuPrivacy, + onTap: () => _open(BuildConfig.privacyUrl), + ), + if (!isDriver) + _Action( + icon: Icons.local_taxi_outlined, + label: l10n.menuDriverApp, + onTap: () => _open(BuildConfig.driverAppUrl), + ), ], ), ), @@ -83,6 +115,13 @@ class AppDrawer extends StatelessWidget { ); } + static Future _open(String url) async { + final uri = Uri.tryParse(url); + if (uri == null) return; + // فشل الفتح لا يُسقط شيئاً — رابط خارجي ليس جزءاً من التطبيق. + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + Future _confirmLogout(BuildContext context) async { final l10n = context.l10n; final session = context.read(); @@ -155,6 +194,31 @@ class _Header extends StatelessWidget { } } +/// مدخل ينفّذ فعلاً محلياً (مشاركة · رابط خارجي) لا تنقّلاً. +class _Action extends StatelessWidget { + const _Action({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon), + title: Text(label), + onTap: () { + Navigator.of(context).pop(); + onTap(); + }, + ); + } +} + class _Item extends StatelessWidget { const _Item({required this.icon, required this.label, required this.route}); diff --git a/apps/driver_new/lib/features/rewards/cubit/rewards_cubit.dart b/apps/driver_new/lib/features/rewards/cubit/rewards_cubit.dart new file mode 100644 index 0000000..4c5e601 --- /dev/null +++ b/apps/driver_new/lib/features/rewards/cubit/rewards_cubit.dart @@ -0,0 +1,46 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../../../core/ui/view_status.dart'; +import '../data/rewards_repository.dart'; + +class RewardsState { + const RewardsState({ + this.status = ViewStatus.idle, + this.referrals, + this.coupons = const [], + this.error, + }); + + final ViewStatus status; + final ReferralSummary? referrals; + final List coupons; + final String? error; +} + +class RewardsCubit extends Cubit { + RewardsCubit(this._repo) : super(const RewardsState()); + + final RewardsRepository _repo; + + Future load() async { + emit(const RewardsState(status: ViewStatus.loading)); + try { + // النداءان متوازيان: لا سبب لانتظار أحدهما الآخر. + final results = await Future.wait([ + _repo.referrals(), + _repo.coupons(), + ]); + if (isClosed) return; + emit(RewardsState( + status: ViewStatus.success, + referrals: results[0] as ReferralSummary, + coupons: results[1] as List, + )); + } on ApiException catch (e) { + if (!isClosed) { + emit(RewardsState(status: ViewStatus.error, error: e.message)); + } + } + } +} diff --git a/apps/driver_new/lib/features/rewards/data/rewards_repository.dart b/apps/driver_new/lib/features/rewards/data/rewards_repository.dart new file mode 100644 index 0000000..7e7fcfd --- /dev/null +++ b/apps/driver_new/lib/features/rewards/data/rewards_repository.dart @@ -0,0 +1,65 @@ +import '../../../core/api/api_client.dart'; + +/// ملخّص الدعوات — شكل مُتحقَّق حيّاً: `{code, total, pending, paid}`. +class ReferralSummary { + const ReferralSummary({ + required this.code, + required this.total, + required this.pending, + required this.paid, + }); + + /// كود دعوتي الذي أشاركه. + final String code; + + final int total; + final int pending; + final int paid; + + factory ReferralSummary.fromJson(Map json) => + ReferralSummary( + code: json['code'] as String? ?? '', + total: (json['total'] as num?)?.toInt() ?? 0, + pending: (json['pending'] as num?)?.toInt() ?? 0, + paid: (json['paid'] as num?)?.toInt() ?? 0, + ); +} + +class Coupon { + const Coupon({ + required this.code, + this.description = '', + this.expiresAt, + }); + + final String code; + final String description; + final DateTime? expiresAt; + + factory Coupon.fromJson(Map json) => Coupon( + code: json['code'] as String? ?? '', + description: json['description'] as String? ?? + json['title'] as String? ?? + '', + expiresAt: DateTime.tryParse(json['expires_at']?.toString() ?? ''), + ); +} + +class RewardsRepository { + RewardsRepository(this._api); + + final ApiClient _api; + + Future referrals() async { + final res = await _api.get>('/rewards/referrals'); + return ReferralSummary.fromJson(res); + } + + Future> coupons() async { + final res = await _api.get>('/rewards/coupons'); + return res + .whereType>() + .map(Coupon.fromJson) + .toList(growable: false); + } +} diff --git a/apps/driver_new/lib/features/rewards/view/rewards_page.dart b/apps/driver_new/lib/features/rewards/view/rewards_page.dart new file mode 100644 index 0000000..1daedea --- /dev/null +++ b/apps/driver_new/lib/features/rewards/view/rewards_page.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../core/design/tokens.dart'; +import '../../../core/design/tripz_colors.dart'; +import '../../../core/design/typography.dart'; +import '../../../core/di.dart'; +import '../../../core/l10n/l10n.dart'; +import '../../../core/ui/tripz_button.dart'; +import '../../../core/ui/tripz_card.dart'; +import '../../../core/ui/tripz_scaffold.dart'; +import '../cubit/rewards_cubit.dart'; + +/// «مكافآتي» — الدعوات والكوبونات في شاشة واحدة (نمط سيرو: Promos + Invite). +class RewardsPage extends StatefulWidget { + const RewardsPage({super.key}); + + @override + State createState() => _RewardsPageState(); +} + +class _RewardsPageState extends State { + late final RewardsCubit _cubit = sl()..load(); + + @override + void dispose() { + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocProvider.value( + value: _cubit, + child: BlocBuilder( + builder: (context, state) { + final ref = state.referrals; + return TripzScaffold( + title: l10n.rewardsTitle, + status: state.status, + error: state.error, + onRetry: _cubit.load, + child: ListView( + children: [ + if (ref != null) _InviteCard(code: ref.code, summary: ref), + const SizedBox(height: Space.lg), + Text(l10n.rewardsCoupons, style: context.texts.titleMedium), + const SizedBox(height: Space.xs), + if (state.coupons.isEmpty) + Text( + l10n.rewardsCouponsEmpty, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ) + else + for (final coupon in state.coupons) ...[ + TripzCard( + child: Row( + children: [ + Icon( + Icons.local_offer_outlined, + color: context.tripzColors.success, + ), + const SizedBox(width: Space.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + coupon.code, + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.titleSmall), + ), + if (coupon.description.isNotEmpty) + Text( + coupon.description, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: Space.xs), + ], + ], + ), + ); + }, + ), + ); + } +} + +class _InviteCard extends StatelessWidget { + const _InviteCard({required this.code, required this.summary}); + + final String code; + final dynamic summary; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Container( + padding: Space.page, + decoration: BoxDecoration( + color: context.colors.primaryContainer, + borderRadius: Radii.card_, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.rewardsInviteTitle, + style: context.texts.titleMedium?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + const SizedBox(height: Space.xxs), + Text( + l10n.rewardsInviteLead, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onPrimaryContainer.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: Space.md), + // الكود يُنسخ ويُشارك — الطريقان اللذان يستعملهما الناس فعلاً. + Row( + children: [ + Expanded( + child: SelectableText( + code, + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.headlineSmall).copyWith( + letterSpacing: 3, + color: context.colors.onPrimaryContainer, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy_rounded), + color: context.colors.onPrimaryContainer, + onPressed: () => Clipboard.setData(ClipboardData(text: code)), + ), + ], + ), + const SizedBox(height: Space.sm), + TripzButton.primary( + label: l10n.menuShare, + icon: Icons.share_outlined, + onPressed: () => + SharePlus.instance.share(ShareParams(text: l10n.shareMessage(code))), + ), + const SizedBox(height: Space.md), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _Stat(label: l10n.rewardsInvited, value: summary.total), + _Stat(label: l10n.rewardsPending, value: summary.pending), + _Stat(label: l10n.rewardsPaid, value: summary.paid), + ], + ), + ], + ), + ); + } +} + +class _Stat extends StatelessWidget { + const _Stat({required this.label, required this.value}); + + final String label; + final int value; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + '$value', + style: numericStyle(context.texts.titleLarge).copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + Text( + label, + style: context.texts.labelSmall?.copyWith( + color: context.colors.onPrimaryContainer.withValues(alpha: 0.8), + ), + ), + ], + ); + } +} diff --git a/apps/driver_new/lib/features/support/view/support_page.dart b/apps/driver_new/lib/features/support/view/support_page.dart index e9bcf0a..1e74afd 100644 --- a/apps/driver_new/lib/features/support/view/support_page.dart +++ b/apps/driver_new/lib/features/support/view/support_page.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../../core/build_config.dart'; import '../../../core/design/tokens.dart'; @@ -7,35 +10,79 @@ import '../../../core/l10n/l10n.dart'; import '../../../core/ui/tripz_card.dart'; import '../../../core/ui/tripz_scaffold.dart'; -/// المساعدة و«عن التطبيق». محتوى محلّي عمداً: الخادم لا يوفّر نقطة أسئلة -/// شائعة، وربطها بنصّ من الشبكة يعني شاشة فارغة حين ينقطع الاتصال — وهو -/// بالضبط الوقت الذي يفتح فيه المستخدم المساعدة. +/// المساعدة: أسئلة شائعة · تواصل · فحص توافق الجهاز · عن التطبيق. +/// +/// المحتوى **محلّي عمداً**: الخادم لا يوفّر نقطة أسئلة شائعة، وربطها بالشبكة +/// يعني شاشة فارغة حين ينقطع الاتصال — وهو بالضبط الوقت الذي يفتح فيه +/// المستخدم المساعدة. class SupportPage extends StatelessWidget { const SupportPage({super.key}); @override Widget build(BuildContext context) { final l10n = context.l10n; + final faq = [ + (l10n.supportFaqQ1, l10n.supportFaqA1), + (l10n.supportFaqQ2, l10n.supportFaqA2), + (l10n.supportFaqQ3, l10n.supportFaqA3), + (l10n.supportFaqQ4, l10n.supportFaqA4), + ]; + return TripzScaffold( title: l10n.supportTitle, child: ListView( children: [ - TripzCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.supportContact, style: context.texts.titleMedium), - const SizedBox(height: Space.xs), - Text( - l10n.supportWhatsapp, - style: context.texts.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + Text(l10n.supportFaq, style: context.texts.titleMedium), + const SizedBox(height: Space.xs), + for (final (q, a) in faq) + Card( + margin: const EdgeInsets.only(bottom: Space.xs), + child: ExpansionTile( + shape: const RoundedRectangleBorder(borderRadius: Radii.card_), + title: Text(q, style: context.texts.bodyMedium), + children: [ + Padding( + padding: Space.page, + child: Text( + a, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), ), - ), + ], + ), + ), + + const SizedBox(height: Space.lg), + Text(l10n.supportContact, style: context.texts.titleMedium), + const SizedBox(height: Space.xs), + TripzCard( + onTap: () => _openWhatsapp(BuildConfig.supportPhone), + child: Row( + children: [ + Icon(Icons.chat_outlined, color: context.tripzColors.success), + const SizedBox(width: Space.sm), + Expanded(child: Text(l10n.supportWhatsapp)), + const Icon(Icons.chevron_left_rounded), ], ), ), + const SizedBox(height: Space.md), + TripzCard( + onTap: () => _showDeviceCheck(context), + child: Row( + children: [ + const Icon(Icons.phonelink_setup_outlined), + const SizedBox(width: Space.sm), + Expanded(child: Text(l10n.supportDeviceCheck)), + const Icon(Icons.chevron_left_rounded), + ], + ), + ), + + const SizedBox(height: Space.lg), TripzCard( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -54,4 +101,69 @@ class SupportPage extends StatelessWidget { ), ); } + + static Future _openWhatsapp(String phone) async { + final uri = Uri.parse('https://wa.me/$phone'); + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + /// فحص توافق الجهاز — نمط سيرو. يفحص ما يُعطّل التطبيق فعلاً: خدمة الموقع + /// وإذنها وإذن الإشعارات. أسبابٌ حقيقية لشكاوى «التطبيق لا يعمل». + static Future _showDeviceCheck(BuildContext context) async { + final l10n = context.l10n; + final issues = []; + + if (!await Geolocator.isLocationServiceEnabled()) { + issues.add(l10n.rideNoLocation); + } + final locationPermission = await Permission.locationWhenInUse.status; + if (!locationPermission.isGranted && !locationPermission.isLimited) { + issues.add(l10n.permissionDeniedForever); + } + if (await Permission.notification.isDenied) { + issues.add(l10n.notifDenied); + } + + if (!context.mounted) return; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.supportDeviceCheck), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: issues.isEmpty + ? [ + Row(children: [ + Icon(Icons.check_circle_rounded, + color: context.tripzColors.success), + const SizedBox(width: Space.xs), + Expanded(child: Text(l10n.supportDeviceOk)), + ]), + ] + : [ + Text(l10n.supportDeviceIssues), + const SizedBox(height: Space.xs), + for (final issue in issues) + Padding( + padding: const EdgeInsets.only(bottom: Space.xxs), + child: Row(children: [ + Icon(Icons.error_outline_rounded, + size: Sizes.iconSm, + color: context.tripzColors.warning), + const SizedBox(width: Space.xs), + Expanded(child: Text(issue)), + ]), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.actionContinue), + ), + ], + ), + ); + } } diff --git a/apps/driver_new/lib/features/wallet/data/wallet_repository.dart b/apps/driver_new/lib/features/wallet/data/wallet_repository.dart index 857329c..70ea750 100644 --- a/apps/driver_new/lib/features/wallet/data/wallet_repository.dart +++ b/apps/driver_new/lib/features/wallet/data/wallet_repository.dart @@ -53,33 +53,17 @@ class WalletRepository { /// الخادم يولّد مرجعاً ويعرض حساب الاستلام، ثم يحوّل المستخدم، والتسوية /// تصل لاحقاً عبر مطابقة رسالة المزوّد. هذا **نفس مسار سيرو** حرفياً. /// PayMob وحده يرجّع رابط إعادة توجيه، و`cash` وحده فوري. - /// شحن **الرصيد التشغيلي** للسائق — نيّة مختلفة عن شحن المحفظة: - /// `credit_topup` إيرادٌ يُقيَّد على المستأجر، والخادم يرفضها من غير - /// السائق (`payments.service.ts:67`). - Future chargeCredit({ - required num amount, - required String provider, - required String currency, - }) => - charge( - amount: amount, - provider: provider, - currency: currency, - purpose: 'credit_topup', - ); - Future charge({ required num amount, required String provider, required String currency, - String purpose = 'topup', }) async { final res = await _api.post>( '/payments/charge', body: { 'amount': amount, 'provider': provider, - 'purpose': purpose, + 'purpose': 'topup', 'currency': currency, }, ); diff --git a/apps/driver_new/pubspec.lock b/apps/driver_new/pubspec.lock index 3bd92d4..b33a242 100644 --- a/apps/driver_new/pubspec.lock +++ b/apps/driver_new/pubspec.lock @@ -877,6 +877,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + url: "https://pub.dev" + source: hosted + version: "12.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" shared_preferences: dependency: "direct main" description: @@ -1010,6 +1026,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: diff --git a/apps/driver_new/pubspec.yaml b/apps/driver_new/pubspec.yaml index d591cd3..d3e3632 100644 --- a/apps/driver_new/pubspec.yaml +++ b/apps/driver_new/pubspec.yaml @@ -36,6 +36,10 @@ dependencies: geolocator: ^14.0.2 socket_io_client: ^3.0.2 + # الدراور: المشاركة وفتح الروابط + share_plus: ^12.0.1 + url_launcher: ^6.3.2 + # م6: الإشعارات — الإعداد الأصلي (google-services) منقول مع الطبقة الأصلية firebase_core: ^4.4.0 firebase_messaging: ^16.1.1 diff --git a/apps/driver_new/tool/driver_l10n_extra.py b/apps/driver_new/tool/driver_l10n_extra.py index 603c509..0f884bc 100644 --- a/apps/driver_new/tool/driver_l10n_extra.py +++ b/apps/driver_new/tool/driver_l10n_extra.py @@ -45,6 +45,11 @@ EXTRA = { "phoneTitle": "أهلاً بك، كابتن", "profileLead": "اسمك يظهر للراكب عند الرحلة.", "historyEmpty": "لا رحلات بعد — اتصل لتصلك الطلبات", + "rewardsInviteTitle": "ادعُ سائقاً", + "rewardsInviteLead": "شارك كودك — تربح عن كل سائق ينضم", + "menuInvite": "ادعُ سائقاً", + "menuDriverApp": "تطبيق الراكب", + "shareMessage": "انضم لـTripz كسائق — استعمل كودي {code}", }, 'en': { "obApplyTitle": "Register as a driver", @@ -81,6 +86,11 @@ EXTRA = { "phoneTitle": "Welcome, captain", "profileLead": "Your name is shown to the rider during a trip.", "historyEmpty": "No trips yet — go online to receive requests", + "rewardsInviteTitle": "Invite a driver", + "rewardsInviteLead": "Share your code — earn for every driver who joins", + "menuInvite": "Invite a driver", + "menuDriverApp": "Rider app", + "shareMessage": "Join Tripz as a driver — use my code {code}", }, } diff --git a/apps/driver_new/tool/sync_from_rider.sh b/apps/driver_new/tool/sync_from_rider.sh index 1787960..d752677 100755 --- a/apps/driver_new/tool/sync_from_rider.sh +++ b/apps/driver_new/tool/sync_from_rider.sh @@ -12,7 +12,7 @@ cd "$(dirname "$0")/.." # ملفات يملكها السائق ولا تُنسخ من الراكب أبداً OWN=( "core/config.dart" # appRole = driver - "core/build_config.dart" # اسم التطبيق (الألوان تُحدَّث يدوياً معاً) + "core/build_config.dart" # اسم التطبيق + روابط الطرف الآخر "core/di.dart" # DutyCubit + OnboardingCubit + PayoutCubit "core/router.dart" # DutyPage + EarningsPage + بوابة التسجيل "core/l10n/gen" diff --git a/apps/rider_new/lib/core/build_config.dart b/apps/rider_new/lib/core/build_config.dart index 16b5860..bfa0f0d 100644 --- a/apps/rider_new/lib/core/build_config.dart +++ b/apps/rider_new/lib/core/build_config.dart @@ -22,6 +22,28 @@ class BuildConfig { static const String logoAsset = 'assets/images/logo.png'; static const String splashLogoAsset = 'assets/images/splash_logo.png'; + + /// روابط خارجية لكل مستأجر — تُولَّد مع بقية الملف (N3). + static const String storeUrl = String.fromEnvironment( + 'STORE_URL', + defaultValue: 'https://tripz.app', + ); + static const String privacyUrl = String.fromEnvironment( + 'PRIVACY_URL', + defaultValue: 'https://tripz.app/privacy', + ); + + /// رقم دعم واتساب المستأجر — بصيغة دولية بلا `+`. + static const String supportPhone = String.fromEnvironment( + 'SUPPORT_PHONE', + defaultValue: '962790000000', + ); + + /// رابط تطبيق الطرف الآخر — الراكب يدعو نفسه ليصير سائقاً والعكس. + static const String driverAppUrl = String.fromEnvironment( + 'DRIVER_APP_URL', + defaultValue: 'https://tripz.app/driver', + ); } /// أعلام الاستحقاقات (docs/23 §5). **`const` إجبارياً** كي يُحذف كود الميزة diff --git a/apps/rider_new/lib/core/di.dart b/apps/rider_new/lib/core/di.dart index 6819186..76dc0ee 100644 --- a/apps/rider_new/lib/core/di.dart +++ b/apps/rider_new/lib/core/di.dart @@ -11,6 +11,8 @@ import '../features/chat/data/chat_repository.dart'; import '../features/profile/data/profile_repository.dart'; import '../features/rating/cubit/rating_cubit.dart'; import '../features/rating/data/rating_repository.dart'; +import '../features/rewards/cubit/rewards_cubit.dart'; +import '../features/rewards/data/rewards_repository.dart'; import '../features/settings/cubit/settings_cubit.dart'; import '../features/trip/cubit/history_cubit.dart'; import '../features/trip/cubit/ride_cubit.dart'; @@ -83,6 +85,12 @@ Future setupInjector() async { sl.registerFactory(() => HistoryCubit(sl())); sl.registerSingleton(RatingRepository(sl())); + + // المكافآت خلف علم الكوبونات: الدعوات والكوبونات وجهان لنفس الوحدة. + if (Features.coupons) { + sl.registerSingleton(RewardsRepository(sl())); + sl.registerFactory(() => RewardsCubit(sl())); + } sl.registerFactory(() => RatingCubit(sl())); // الدردشة خلف علمها: إطفاؤه يحذف كودها من الـbinary (docs/23 §5). diff --git a/apps/rider_new/lib/core/l10n/app_ar.arb b/apps/rider_new/lib/core/l10n/app_ar.arb index ca5cf13..f7cc989 100644 --- a/apps/rider_new/lib/core/l10n/app_ar.arb +++ b/apps/rider_new/lib/core/l10n/app_ar.arb @@ -208,5 +208,40 @@ "walletInvoicePending": "سيُضاف الرصيد فور تأكيد التحويل", "walletCopy": "نسخ", "walletCopied": "نُسخ", - "walletNoTarget": "لم يُضبط حساب استلام لهذه القناة بعد — راجع المشغّل" + "walletNoTarget": "لم يُضبط حساب استلام لهذه القناة بعد — راجع المشغّل", + "rewardsTitle": "مكافآتي", + "rewardsInviteTitle": "ادعُ صديقاً", + "rewardsInviteLead": "شارك كودك — تربح أنت وصديقك", + "rewardsCode": "كود الدعوة", + "rewardsInvited": "دعوتَهم", + "rewardsPending": "قيد التأكيد", + "rewardsPaid": "مكافآت مدفوعة", + "rewardsCoupons": "كوبوناتي", + "rewardsCouponsEmpty": "لا كوبونات صالحة الآن", + "menuRewards": "مكافآتي", + "menuInvite": "ادعُ صديقاً", + "menuShare": "شارك التطبيق", + "menuNotifications": "الإشعارات", + "menuPrivacy": "سياسة الخصوصية", + "menuRateApp": "قيّم التطبيق", + "shareMessage": "جرّب تطبيق Tripz — استعمل كودي {code} واحصل على خصم أول رحلة", + "menuDriverApp": "سجّل كسائق", + "@shareMessage": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "supportFaqQ1": "كيف أطلب رحلة؟", + "supportFaqA1": "حدّد وجهتك من البحث أو من الخريطة، اختر نوع الرحلة، ثم اضغط «اطلب الرحلة».", + "supportFaqQ2": "كيف أشحن رصيدي؟", + "supportFaqA2": "من المحفظة اختر المبلغ وطريقة الدفع. ستظهر لك تعليمات التحويل ورقم مرجع — يُضاف الرصيد بعد تأكيد التحويل.", + "supportFaqQ3": "لماذا لا يظهر سائق؟", + "supportFaqA3": "قد لا يوجد سائقون قريبون في هذا الوقت. جرّب بعد قليل أو غيّر نوع الرحلة.", + "supportFaqQ4": "كيف ألغي رحلة؟", + "supportFaqA4": "من بطاقة الرحلة اضغط «إلغاء الرحلة». الإلغاء بعد وصول السائق قد يترتّب عليه رسم.", + "supportDeviceCheck": "فحص توافق الجهاز", + "supportDeviceOk": "جهازك متوافق", + "supportDeviceIssues": "مشاكل قد تؤثّر على عمل التطبيق" } \ No newline at end of file diff --git a/apps/rider_new/lib/core/l10n/app_en.arb b/apps/rider_new/lib/core/l10n/app_en.arb index 9b9ef73..5d168ba 100644 --- a/apps/rider_new/lib/core/l10n/app_en.arb +++ b/apps/rider_new/lib/core/l10n/app_en.arb @@ -208,5 +208,40 @@ "walletInvoicePending": "Your balance updates once the transfer is confirmed", "walletCopy": "Copy", "walletCopied": "Copied", - "walletNoTarget": "No receiving account is configured for this channel yet — contact the operator" + "walletNoTarget": "No receiving account is configured for this channel yet — contact the operator", + "rewardsTitle": "My rewards", + "rewardsInviteTitle": "Invite a friend", + "rewardsInviteLead": "Share your code — you both earn", + "rewardsCode": "Invite code", + "rewardsInvited": "Invited", + "rewardsPending": "Pending", + "rewardsPaid": "Rewards paid", + "rewardsCoupons": "My coupons", + "rewardsCouponsEmpty": "No valid coupons right now", + "menuRewards": "My rewards", + "menuInvite": "Invite a friend", + "menuShare": "Share the app", + "menuNotifications": "Notifications", + "menuPrivacy": "Privacy policy", + "menuRateApp": "Rate the app", + "shareMessage": "Try Tripz — use my code {code} for a discount on your first ride", + "menuDriverApp": "Become a driver", + "@shareMessage": { + "placeholders": { + "code": { + "type": "String" + } + } + }, + "supportFaqQ1": "How do I request a ride?", + "supportFaqA1": "Pick your destination from search or the map, choose a ride type, then tap “Request ride”.", + "supportFaqQ2": "How do I top up?", + "supportFaqA2": "In the wallet, choose an amount and a payment method. You'll get transfer instructions and a reference — your balance updates once the transfer is confirmed.", + "supportFaqQ3": "Why is no driver showing up?", + "supportFaqA3": "There may be no drivers nearby right now. Try again shortly or change the ride type.", + "supportFaqQ4": "How do I cancel?", + "supportFaqA4": "Tap “Cancel ride” on the trip card. Cancelling after the driver arrives may incur a fee.", + "supportDeviceCheck": "Check device compatibility", + "supportDeviceOk": "Your device is compatible", + "supportDeviceIssues": "Issues that may affect the app" } \ No newline at end of file diff --git a/apps/rider_new/lib/core/router.dart b/apps/rider_new/lib/core/router.dart index fb4b9e1..309675c 100644 --- a/apps/rider_new/lib/core/router.dart +++ b/apps/rider_new/lib/core/router.dart @@ -8,6 +8,7 @@ import '../features/chat/view/chat_page.dart'; import '../features/profile/view/profile_edit_page.dart'; import '../features/profile/view/profile_setup_page.dart'; import '../features/rating/data/rating_repository.dart'; +import '../features/rewards/view/rewards_page.dart'; import '../features/rating/view/rating_page.dart'; import '../features/settings/view/settings_page.dart'; import '../features/splash/view/splash_page.dart'; @@ -31,6 +32,7 @@ class Routes { static const profileEdit = '/account'; static const settings = '/settings'; static const support = '/support'; + static const rewards = '/rewards'; static const chat = '/chat'; } @@ -110,6 +112,10 @@ final appRouter = GoRouter( builder: (context, state) => ChatPage(tripId: state.uri.queryParameters['trip'] ?? ''), ), + GoRoute( + path: Routes.rewards, + builder: (context, state) => const RewardsPage(), + ), GoRoute( path: Routes.support, builder: (context, state) => const SupportPage(), diff --git a/apps/rider_new/lib/core/ui/app_drawer.dart b/apps/rider_new/lib/core/ui/app_drawer.dart index 4b7805f..dafef25 100644 --- a/apps/rider_new/lib/core/ui/app_drawer.dart +++ b/apps/rider_new/lib/core/ui/app_drawer.dart @@ -1,6 +1,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; +import 'package:share_plus/share_plus.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../build_config.dart'; import '../config.dart'; @@ -52,6 +54,13 @@ class AppDrawer extends StatelessWidget { label: isDriver ? l10n.menuEarnings : l10n.menuWallet, route: Routes.wallet, ), + if (Features.coupons && context.tenantHas('coupons')) + _Item( + icon: Icons.card_giftcard_rounded, + label: l10n.menuRewards, + route: Routes.rewards, + ), + const Divider(indent: Space.md, endIndent: Space.md), _Item( icon: Icons.settings_outlined, label: l10n.menuSettings, @@ -62,6 +71,29 @@ class AppDrawer extends StatelessWidget { label: l10n.menuSupport, route: Routes.support, ), + // مداخل محليّة بلا نقطة خادم — تُنفَّذ في الجهاز مباشرة. + _Action( + icon: Icons.share_outlined, + label: l10n.menuShare, + onTap: () => SharePlus.instance + .share(ShareParams(text: l10n.shareMessage(''))), + ), + _Action( + icon: Icons.star_outline_rounded, + label: l10n.menuRateApp, + onTap: () => _open(BuildConfig.storeUrl), + ), + _Action( + icon: Icons.shield_outlined, + label: l10n.menuPrivacy, + onTap: () => _open(BuildConfig.privacyUrl), + ), + if (!isDriver) + _Action( + icon: Icons.local_taxi_outlined, + label: l10n.menuDriverApp, + onTap: () => _open(BuildConfig.driverAppUrl), + ), ], ), ), @@ -83,6 +115,13 @@ class AppDrawer extends StatelessWidget { ); } + static Future _open(String url) async { + final uri = Uri.tryParse(url); + if (uri == null) return; + // فشل الفتح لا يُسقط شيئاً — رابط خارجي ليس جزءاً من التطبيق. + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + Future _confirmLogout(BuildContext context) async { final l10n = context.l10n; final session = context.read(); @@ -155,6 +194,31 @@ class _Header extends StatelessWidget { } } +/// مدخل ينفّذ فعلاً محلياً (مشاركة · رابط خارجي) لا تنقّلاً. +class _Action extends StatelessWidget { + const _Action({ + required this.icon, + required this.label, + required this.onTap, + }); + + final IconData icon; + final String label; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return ListTile( + leading: Icon(icon), + title: Text(label), + onTap: () { + Navigator.of(context).pop(); + onTap(); + }, + ); + } +} + class _Item extends StatelessWidget { const _Item({required this.icon, required this.label, required this.route}); diff --git a/apps/rider_new/lib/features/rewards/cubit/rewards_cubit.dart b/apps/rider_new/lib/features/rewards/cubit/rewards_cubit.dart new file mode 100644 index 0000000..4c5e601 --- /dev/null +++ b/apps/rider_new/lib/features/rewards/cubit/rewards_cubit.dart @@ -0,0 +1,46 @@ +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../core/api/api_exception.dart'; +import '../../../core/ui/view_status.dart'; +import '../data/rewards_repository.dart'; + +class RewardsState { + const RewardsState({ + this.status = ViewStatus.idle, + this.referrals, + this.coupons = const [], + this.error, + }); + + final ViewStatus status; + final ReferralSummary? referrals; + final List coupons; + final String? error; +} + +class RewardsCubit extends Cubit { + RewardsCubit(this._repo) : super(const RewardsState()); + + final RewardsRepository _repo; + + Future load() async { + emit(const RewardsState(status: ViewStatus.loading)); + try { + // النداءان متوازيان: لا سبب لانتظار أحدهما الآخر. + final results = await Future.wait([ + _repo.referrals(), + _repo.coupons(), + ]); + if (isClosed) return; + emit(RewardsState( + status: ViewStatus.success, + referrals: results[0] as ReferralSummary, + coupons: results[1] as List, + )); + } on ApiException catch (e) { + if (!isClosed) { + emit(RewardsState(status: ViewStatus.error, error: e.message)); + } + } + } +} diff --git a/apps/rider_new/lib/features/rewards/data/rewards_repository.dart b/apps/rider_new/lib/features/rewards/data/rewards_repository.dart new file mode 100644 index 0000000..7e7fcfd --- /dev/null +++ b/apps/rider_new/lib/features/rewards/data/rewards_repository.dart @@ -0,0 +1,65 @@ +import '../../../core/api/api_client.dart'; + +/// ملخّص الدعوات — شكل مُتحقَّق حيّاً: `{code, total, pending, paid}`. +class ReferralSummary { + const ReferralSummary({ + required this.code, + required this.total, + required this.pending, + required this.paid, + }); + + /// كود دعوتي الذي أشاركه. + final String code; + + final int total; + final int pending; + final int paid; + + factory ReferralSummary.fromJson(Map json) => + ReferralSummary( + code: json['code'] as String? ?? '', + total: (json['total'] as num?)?.toInt() ?? 0, + pending: (json['pending'] as num?)?.toInt() ?? 0, + paid: (json['paid'] as num?)?.toInt() ?? 0, + ); +} + +class Coupon { + const Coupon({ + required this.code, + this.description = '', + this.expiresAt, + }); + + final String code; + final String description; + final DateTime? expiresAt; + + factory Coupon.fromJson(Map json) => Coupon( + code: json['code'] as String? ?? '', + description: json['description'] as String? ?? + json['title'] as String? ?? + '', + expiresAt: DateTime.tryParse(json['expires_at']?.toString() ?? ''), + ); +} + +class RewardsRepository { + RewardsRepository(this._api); + + final ApiClient _api; + + Future referrals() async { + final res = await _api.get>('/rewards/referrals'); + return ReferralSummary.fromJson(res); + } + + Future> coupons() async { + final res = await _api.get>('/rewards/coupons'); + return res + .whereType>() + .map(Coupon.fromJson) + .toList(growable: false); + } +} diff --git a/apps/rider_new/lib/features/rewards/view/rewards_page.dart b/apps/rider_new/lib/features/rewards/view/rewards_page.dart new file mode 100644 index 0000000..1daedea --- /dev/null +++ b/apps/rider_new/lib/features/rewards/view/rewards_page.dart @@ -0,0 +1,201 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:share_plus/share_plus.dart'; + +import '../../../core/design/tokens.dart'; +import '../../../core/design/tripz_colors.dart'; +import '../../../core/design/typography.dart'; +import '../../../core/di.dart'; +import '../../../core/l10n/l10n.dart'; +import '../../../core/ui/tripz_button.dart'; +import '../../../core/ui/tripz_card.dart'; +import '../../../core/ui/tripz_scaffold.dart'; +import '../cubit/rewards_cubit.dart'; + +/// «مكافآتي» — الدعوات والكوبونات في شاشة واحدة (نمط سيرو: Promos + Invite). +class RewardsPage extends StatefulWidget { + const RewardsPage({super.key}); + + @override + State createState() => _RewardsPageState(); +} + +class _RewardsPageState extends State { + late final RewardsCubit _cubit = sl()..load(); + + @override + void dispose() { + _cubit.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return BlocProvider.value( + value: _cubit, + child: BlocBuilder( + builder: (context, state) { + final ref = state.referrals; + return TripzScaffold( + title: l10n.rewardsTitle, + status: state.status, + error: state.error, + onRetry: _cubit.load, + child: ListView( + children: [ + if (ref != null) _InviteCard(code: ref.code, summary: ref), + const SizedBox(height: Space.lg), + Text(l10n.rewardsCoupons, style: context.texts.titleMedium), + const SizedBox(height: Space.xs), + if (state.coupons.isEmpty) + Text( + l10n.rewardsCouponsEmpty, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ) + else + for (final coupon in state.coupons) ...[ + TripzCard( + child: Row( + children: [ + Icon( + Icons.local_offer_outlined, + color: context.tripzColors.success, + ), + const SizedBox(width: Space.sm), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + coupon.code, + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.titleSmall), + ), + if (coupon.description.isNotEmpty) + Text( + coupon.description, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), + ], + ), + ), + ], + ), + ), + const SizedBox(height: Space.xs), + ], + ], + ), + ); + }, + ), + ); + } +} + +class _InviteCard extends StatelessWidget { + const _InviteCard({required this.code, required this.summary}); + + final String code; + final dynamic summary; + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Container( + padding: Space.page, + decoration: BoxDecoration( + color: context.colors.primaryContainer, + borderRadius: Radii.card_, + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text( + l10n.rewardsInviteTitle, + style: context.texts.titleMedium?.copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + const SizedBox(height: Space.xxs), + Text( + l10n.rewardsInviteLead, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onPrimaryContainer.withValues(alpha: 0.8), + ), + ), + const SizedBox(height: Space.md), + // الكود يُنسخ ويُشارك — الطريقان اللذان يستعملهما الناس فعلاً. + Row( + children: [ + Expanded( + child: SelectableText( + code, + textDirection: TextDirection.ltr, + style: numericStyle(context.texts.headlineSmall).copyWith( + letterSpacing: 3, + color: context.colors.onPrimaryContainer, + ), + ), + ), + IconButton( + icon: const Icon(Icons.copy_rounded), + color: context.colors.onPrimaryContainer, + onPressed: () => Clipboard.setData(ClipboardData(text: code)), + ), + ], + ), + const SizedBox(height: Space.sm), + TripzButton.primary( + label: l10n.menuShare, + icon: Icons.share_outlined, + onPressed: () => + SharePlus.instance.share(ShareParams(text: l10n.shareMessage(code))), + ), + const SizedBox(height: Space.md), + Row( + mainAxisAlignment: MainAxisAlignment.spaceAround, + children: [ + _Stat(label: l10n.rewardsInvited, value: summary.total), + _Stat(label: l10n.rewardsPending, value: summary.pending), + _Stat(label: l10n.rewardsPaid, value: summary.paid), + ], + ), + ], + ), + ); + } +} + +class _Stat extends StatelessWidget { + const _Stat({required this.label, required this.value}); + + final String label; + final int value; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + Text( + '$value', + style: numericStyle(context.texts.titleLarge).copyWith( + color: context.colors.onPrimaryContainer, + ), + ), + Text( + label, + style: context.texts.labelSmall?.copyWith( + color: context.colors.onPrimaryContainer.withValues(alpha: 0.8), + ), + ), + ], + ); + } +} diff --git a/apps/rider_new/lib/features/support/view/support_page.dart b/apps/rider_new/lib/features/support/view/support_page.dart index e9bcf0a..1e74afd 100644 --- a/apps/rider_new/lib/features/support/view/support_page.dart +++ b/apps/rider_new/lib/features/support/view/support_page.dart @@ -1,4 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:geolocator/geolocator.dart'; +import 'package:permission_handler/permission_handler.dart'; +import 'package:url_launcher/url_launcher.dart'; import '../../../core/build_config.dart'; import '../../../core/design/tokens.dart'; @@ -7,35 +10,79 @@ import '../../../core/l10n/l10n.dart'; import '../../../core/ui/tripz_card.dart'; import '../../../core/ui/tripz_scaffold.dart'; -/// المساعدة و«عن التطبيق». محتوى محلّي عمداً: الخادم لا يوفّر نقطة أسئلة -/// شائعة، وربطها بنصّ من الشبكة يعني شاشة فارغة حين ينقطع الاتصال — وهو -/// بالضبط الوقت الذي يفتح فيه المستخدم المساعدة. +/// المساعدة: أسئلة شائعة · تواصل · فحص توافق الجهاز · عن التطبيق. +/// +/// المحتوى **محلّي عمداً**: الخادم لا يوفّر نقطة أسئلة شائعة، وربطها بالشبكة +/// يعني شاشة فارغة حين ينقطع الاتصال — وهو بالضبط الوقت الذي يفتح فيه +/// المستخدم المساعدة. class SupportPage extends StatelessWidget { const SupportPage({super.key}); @override Widget build(BuildContext context) { final l10n = context.l10n; + final faq = [ + (l10n.supportFaqQ1, l10n.supportFaqA1), + (l10n.supportFaqQ2, l10n.supportFaqA2), + (l10n.supportFaqQ3, l10n.supportFaqA3), + (l10n.supportFaqQ4, l10n.supportFaqA4), + ]; + return TripzScaffold( title: l10n.supportTitle, child: ListView( children: [ - TripzCard( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(l10n.supportContact, style: context.texts.titleMedium), - const SizedBox(height: Space.xs), - Text( - l10n.supportWhatsapp, - style: context.texts.bodyMedium?.copyWith( - color: context.colors.onSurfaceVariant, + Text(l10n.supportFaq, style: context.texts.titleMedium), + const SizedBox(height: Space.xs), + for (final (q, a) in faq) + Card( + margin: const EdgeInsets.only(bottom: Space.xs), + child: ExpansionTile( + shape: const RoundedRectangleBorder(borderRadius: Radii.card_), + title: Text(q, style: context.texts.bodyMedium), + children: [ + Padding( + padding: Space.page, + child: Text( + a, + style: context.texts.bodySmall?.copyWith( + color: context.colors.onSurfaceVariant, + ), + ), ), - ), + ], + ), + ), + + const SizedBox(height: Space.lg), + Text(l10n.supportContact, style: context.texts.titleMedium), + const SizedBox(height: Space.xs), + TripzCard( + onTap: () => _openWhatsapp(BuildConfig.supportPhone), + child: Row( + children: [ + Icon(Icons.chat_outlined, color: context.tripzColors.success), + const SizedBox(width: Space.sm), + Expanded(child: Text(l10n.supportWhatsapp)), + const Icon(Icons.chevron_left_rounded), ], ), ), + const SizedBox(height: Space.md), + TripzCard( + onTap: () => _showDeviceCheck(context), + child: Row( + children: [ + const Icon(Icons.phonelink_setup_outlined), + const SizedBox(width: Space.sm), + Expanded(child: Text(l10n.supportDeviceCheck)), + const Icon(Icons.chevron_left_rounded), + ], + ), + ), + + const SizedBox(height: Space.lg), TripzCard( child: Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -54,4 +101,69 @@ class SupportPage extends StatelessWidget { ), ); } + + static Future _openWhatsapp(String phone) async { + final uri = Uri.parse('https://wa.me/$phone'); + await launchUrl(uri, mode: LaunchMode.externalApplication); + } + + /// فحص توافق الجهاز — نمط سيرو. يفحص ما يُعطّل التطبيق فعلاً: خدمة الموقع + /// وإذنها وإذن الإشعارات. أسبابٌ حقيقية لشكاوى «التطبيق لا يعمل». + static Future _showDeviceCheck(BuildContext context) async { + final l10n = context.l10n; + final issues = []; + + if (!await Geolocator.isLocationServiceEnabled()) { + issues.add(l10n.rideNoLocation); + } + final locationPermission = await Permission.locationWhenInUse.status; + if (!locationPermission.isGranted && !locationPermission.isLimited) { + issues.add(l10n.permissionDeniedForever); + } + if (await Permission.notification.isDenied) { + issues.add(l10n.notifDenied); + } + + if (!context.mounted) return; + showDialog( + context: context, + builder: (context) => AlertDialog( + title: Text(l10n.supportDeviceCheck), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: issues.isEmpty + ? [ + Row(children: [ + Icon(Icons.check_circle_rounded, + color: context.tripzColors.success), + const SizedBox(width: Space.xs), + Expanded(child: Text(l10n.supportDeviceOk)), + ]), + ] + : [ + Text(l10n.supportDeviceIssues), + const SizedBox(height: Space.xs), + for (final issue in issues) + Padding( + padding: const EdgeInsets.only(bottom: Space.xxs), + child: Row(children: [ + Icon(Icons.error_outline_rounded, + size: Sizes.iconSm, + color: context.tripzColors.warning), + const SizedBox(width: Space.xs), + Expanded(child: Text(issue)), + ]), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: Text(l10n.actionContinue), + ), + ], + ), + ); + } } diff --git a/apps/rider_new/pubspec.lock b/apps/rider_new/pubspec.lock index 6851c06..2ce52af 100644 --- a/apps/rider_new/pubspec.lock +++ b/apps/rider_new/pubspec.lock @@ -81,6 +81,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.19.1" + cross_file: + dependency: transitive + description: + name: cross_file + sha256: "92c9c43c383bfa1c32079d3bc492d55d6d4318044b7b47edaff8971cbb555c51" + url: "https://pub.dev" + source: hosted + version: "0.3.5+4" crypto: dependency: transitive description: @@ -765,6 +773,22 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + share_plus: + dependency: "direct main" + description: + name: share_plus + sha256: "223873d106614442ea6f20db5a038685cc5b32a2fba81cdecaefbbae0523f7fa" + url: "https://pub.dev" + source: hosted + version: "12.0.2" + share_plus_platform_interface: + dependency: transitive + description: + name: share_plus_platform_interface + sha256: "88023e53a13429bd65d8e85e11a9b484f49d4c190abbd96c7932b74d6927cc9a" + url: "https://pub.dev" + source: hosted + version: "6.1.0" shared_preferences: dependency: "direct main" description: @@ -898,6 +922,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c" + url: "https://pub.dev" + source: hosted + version: "6.3.30" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: "85c81589622fbc87c1c683aaea164d3604a7777495a79d91e39ffcdec39ddb34" + url: "https://pub.dev" + source: hosted + version: "2.4.3" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" uuid: dependency: transitive description: diff --git a/apps/rider_new/pubspec.yaml b/apps/rider_new/pubspec.yaml index 5c05c10..c79c50d 100644 --- a/apps/rider_new/pubspec.yaml +++ b/apps/rider_new/pubspec.yaml @@ -36,6 +36,10 @@ dependencies: geolocator: ^14.0.2 socket_io_client: ^3.0.2 + # الدراور: المشاركة وفتح الروابط + share_plus: ^12.0.1 + url_launcher: ^6.3.2 + # م6: الإشعارات — الإعداد الأصلي (google-services) منقول مع الطبقة الأصلية firebase_core: ^4.4.0 firebase_messaging: ^16.1.1