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
@@ -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<Coupon> coupons;
|
||||
final String? error;
|
||||
}
|
||||
|
||||
class RewardsCubit extends Cubit<RewardsState> {
|
||||
RewardsCubit(this._repo) : super(const RewardsState());
|
||||
|
||||
final RewardsRepository _repo;
|
||||
|
||||
Future<void> 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<Coupon>,
|
||||
));
|
||||
} on ApiException catch (e) {
|
||||
if (!isClosed) {
|
||||
emit(RewardsState(status: ViewStatus.error, error: e.message));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<String, dynamic> 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<String, dynamic> 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<ReferralSummary> referrals() async {
|
||||
final res = await _api.get<Map<String, dynamic>>('/rewards/referrals');
|
||||
return ReferralSummary.fromJson(res);
|
||||
}
|
||||
|
||||
Future<List<Coupon>> coupons() async {
|
||||
final res = await _api.get<List<dynamic>>('/rewards/coupons');
|
||||
return res
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.map(Coupon.fromJson)
|
||||
.toList(growable: false);
|
||||
}
|
||||
}
|
||||
@@ -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<RewardsPage> createState() => _RewardsPageState();
|
||||
}
|
||||
|
||||
class _RewardsPageState extends State<RewardsPage> {
|
||||
late final RewardsCubit _cubit = sl<RewardsCubit>()..load();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_cubit.close();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = context.l10n;
|
||||
return BlocProvider.value(
|
||||
value: _cubit,
|
||||
child: BlocBuilder<RewardsCubit, RewardsState>(
|
||||
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),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> _openWhatsapp(String phone) async {
|
||||
final uri = Uri.parse('https://wa.me/$phone');
|
||||
await launchUrl(uri, mode: LaunchMode.externalApplication);
|
||||
}
|
||||
|
||||
/// فحص توافق الجهاز — نمط سيرو. يفحص ما يُعطّل التطبيق فعلاً: خدمة الموقع
|
||||
/// وإذنها وإذن الإشعارات. أسبابٌ حقيقية لشكاوى «التطبيق لا يعمل».
|
||||
static Future<void> _showDeviceCheck(BuildContext context) async {
|
||||
final l10n = context.l10n;
|
||||
final issues = <String>[];
|
||||
|
||||
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<void>(
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,33 +53,17 @@ class WalletRepository {
|
||||
/// الخادم يولّد مرجعاً ويعرض حساب الاستلام، ثم يحوّل المستخدم، والتسوية
|
||||
/// تصل لاحقاً عبر مطابقة رسالة المزوّد. هذا **نفس مسار سيرو** حرفياً.
|
||||
/// PayMob وحده يرجّع رابط إعادة توجيه، و`cash` وحده فوري.
|
||||
/// شحن **الرصيد التشغيلي** للسائق — نيّة مختلفة عن شحن المحفظة:
|
||||
/// `credit_topup` إيرادٌ يُقيَّد على المستأجر، والخادم يرفضها من غير
|
||||
/// السائق (`payments.service.ts:67`).
|
||||
Future<TopupResult> chargeCredit({
|
||||
required num amount,
|
||||
required String provider,
|
||||
required String currency,
|
||||
}) =>
|
||||
charge(
|
||||
amount: amount,
|
||||
provider: provider,
|
||||
currency: currency,
|
||||
purpose: 'credit_topup',
|
||||
);
|
||||
|
||||
Future<TopupResult> charge({
|
||||
required num amount,
|
||||
required String provider,
|
||||
required String currency,
|
||||
String purpose = 'topup',
|
||||
}) async {
|
||||
final res = await _api.post<Map<String, dynamic>>(
|
||||
'/payments/charge',
|
||||
body: {
|
||||
'amount': amount,
|
||||
'provider': provider,
|
||||
'purpose': purpose,
|
||||
'purpose': 'topup',
|
||||
'currency': currency,
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user