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:
Hamza-Ayed
2026-08-05 01:34:23 +03:00
co-authored by Claude Opus 5
parent 6a33cc7241
commit ca766a2d4e
27 changed files with 1406 additions and 48 deletions
@@ -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),
),
],
),
);
}
}