feat(apps): الدراور على نمط سيرو — المكافآت والدعوات والمساعدة
جُرد دراور سيرو: 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
6a33cc7241
commit
ca766a2d4e
@@ -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` إجبارياً** كي يُحذف كود الميزة
|
||||
|
||||
@@ -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<void> setupInjector() async {
|
||||
sl.registerFactory<HistoryCubit>(() => HistoryCubit(sl()));
|
||||
|
||||
sl.registerSingleton<RatingRepository>(RatingRepository(sl()));
|
||||
|
||||
// المكافآت خلف علم الكوبونات: الدعوات والكوبونات وجهان لنفس الوحدة.
|
||||
if (Features.coupons) {
|
||||
sl.registerSingleton<RewardsRepository>(RewardsRepository(sl()));
|
||||
sl.registerFactory<RewardsCubit>(() => RewardsCubit(sl()));
|
||||
}
|
||||
sl.registerFactory<RatingCubit>(() => RatingCubit(sl()));
|
||||
|
||||
// الدردشة خلف علمها: إطفاؤه يحذف كودها من الـbinary (docs/23 §5).
|
||||
|
||||
@@ -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": "مشاكل قد تؤثّر على عمل التطبيق"
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<void> _open(String url) async {
|
||||
final uri = Uri.tryParse(url);
|
||||
if (uri == null) return;
|
||||
// فشل الفتح لا يُسقط شيئاً — رابط خارجي ليس جزءاً من التطبيق.
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
Future<void> _confirmLogout(BuildContext context) async {
|
||||
final l10n = context.l10n;
|
||||
final session = context.read<SessionCubit>();
|
||||
@@ -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});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user