Update: 2026-08-08 12:58:16
This commit is contained in:
@@ -0,0 +1,402 @@
|
||||
// fuel_stations_page.dart — إدارة محطات الوقود الشريكة
|
||||
//
|
||||
// الشاشة تعمل كاملةً حتى والمنتج مُطفأ: قرار المالك أن تُجهَّز الأمور
|
||||
// وتُبنى الشاشات، ولا يُشغَّل المنتج إلا بعد إتمام الاتفاق مع سلسلة
|
||||
// المحطات. لذلك اللافتة العلوية تقول الحالة صراحةً بدل إخفائها.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../../controller/fuel/fuel_admin_controller.dart';
|
||||
import '../../../controller/fuel/fuel_admin_models.dart';
|
||||
import 'fuel_drawdowns_page.dart';
|
||||
import 'fuel_station_form.dart';
|
||||
|
||||
class FuelStationsPageView extends StatefulWidget {
|
||||
const FuelStationsPageView({super.key});
|
||||
|
||||
@override
|
||||
State<FuelStationsPageView> createState() => _FuelStationsPageViewState();
|
||||
}
|
||||
|
||||
class _FuelStationsPageViewState extends State<FuelStationsPageView> {
|
||||
final _searchCtrl = TextEditingController();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Get.put(FuelAdminController());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_searchCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return GetBuilder<FuelAdminController>(
|
||||
builder: (c) => Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
body: Column(
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.fromLTRB(16, 50, 16, 12),
|
||||
child: Row(
|
||||
children: [
|
||||
GestureDetector(
|
||||
onTap: () => Get.back(),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(8),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: cs.outline),
|
||||
),
|
||||
child: Icon(Icons.arrow_back_ios_new_rounded,
|
||||
color: cs.onSurfaceVariant, size: 16),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Text('محفظة الوقود — المحطات',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface,
|
||||
fontSize: 18,
|
||||
fontWeight: FontWeight.w700)),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
icon: Icon(Icons.receipt_long_rounded, color: cs.tertiary),
|
||||
tooltip: 'حركة القسائم',
|
||||
onPressed: () => Get.to(() => const FuelDrawdownsPageView()),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(Icons.add, color: cs.primary),
|
||||
tooltip: 'إضافة محطة',
|
||||
onPressed: () => _openForm(c, null),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
_productBanner(c, cs),
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(12),
|
||||
child: TextField(
|
||||
controller: _searchCtrl,
|
||||
style: TextStyle(color: cs.onSurface),
|
||||
onSubmitted: (v) {
|
||||
c.searchQuery = v;
|
||||
c.fetchStations();
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
hintText: 'ابحث باسم المحطة أو السلسلة أو العنوان...',
|
||||
hintStyle: TextStyle(color: cs.onSurfaceVariant),
|
||||
prefixIcon: Icon(Icons.search, color: cs.onSurfaceVariant),
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: c.isLoading
|
||||
? Center(child: CircularProgressIndicator(color: cs.primary))
|
||||
: c.stations.isEmpty
|
||||
? _emptyState(cs)
|
||||
: RefreshIndicator(
|
||||
onRefresh: c.fetchStations,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: c.stations.length,
|
||||
itemBuilder: (_, i) =>
|
||||
_stationCard(c, c.stations[i], cs),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// لافتة الحالة. «مُجهَّز وغير مُشغَّل» حالة مقصودة لا عطل — وإخفاؤها
|
||||
/// يجعل من يعرض المنصة يظنّ المنتج حياً، أو يظنّه معطوباً.
|
||||
Widget _productBanner(FuelAdminController c, ColorScheme cs) {
|
||||
final active = c.productActive;
|
||||
final color = active ? cs.primary : cs.tertiary;
|
||||
|
||||
return Container(
|
||||
margin: const EdgeInsets.symmetric(horizontal: 12),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
border: Border.all(color: color.withValues(alpha: 0.4)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(active ? Icons.check_circle_rounded : Icons.pause_circle_rounded,
|
||||
color: color, size: 20),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(active ? 'المنتج مُشغَّل' : 'مُجهَّز — غير مُشغَّل',
|
||||
style: TextStyle(
|
||||
color: color, fontWeight: FontWeight.w700, fontSize: 14)),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
active
|
||||
? 'السائقون المؤهَّلون يستطيعون التفعيل وإصدار القسائم'
|
||||
: 'الشاشة تعمل للتجهيز والعرض. لا تفعيل ولا قسائم للسائقين.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: active,
|
||||
activeThumbColor: cs.primary,
|
||||
onChanged: (v) => _confirmToggle(c, v),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// تأكيد قبل التشغيل لا بعده. التشغيل يفتح باب إقراض حقيقي بمال
|
||||
/// الشركة، ومفتاح يُقلب بالخطأ في عرضٍ أمام مستثمر ليس أمراً هيّناً.
|
||||
Future<void> _confirmToggle(FuelAdminController c, bool value) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final ok = await Get.dialog<bool>(
|
||||
AlertDialog(
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
title: Text(value ? 'تشغيل محفظة الوقود؟' : 'إيقاف محفظة الوقود؟',
|
||||
style: TextStyle(color: cs.onSurface)),
|
||||
content: Text(
|
||||
value
|
||||
? 'سيتمكّن السائقون المؤهَّلون من التزوّد بالدَّين فوراً. لا تُشغّله قبل إتمام الاتفاق مع المحطات.'
|
||||
: 'سيتوقّف التفعيل وإصدار القسائم. الالتزامات القائمة ودفترها لا تتأثر.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Get.back(result: false),
|
||||
child: const Text('إلغاء')),
|
||||
TextButton(
|
||||
onPressed: () => Get.back(result: true),
|
||||
child: Text(value ? 'تشغيل' : 'إيقاف',
|
||||
style: TextStyle(color: value ? cs.primary : cs.error)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (ok != true) return;
|
||||
|
||||
final done = await c.toggleProduct(value);
|
||||
if (!done && c.lastError != null) {
|
||||
Get.snackbar('تعذّر التنفيذ', c.lastError!,
|
||||
backgroundColor: cs.error.withValues(alpha: 0.15));
|
||||
}
|
||||
}
|
||||
|
||||
Widget _emptyState(ColorScheme cs) => Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.local_gas_station_rounded,
|
||||
size: 48, color: cs.onSurfaceVariant),
|
||||
const SizedBox(height: 12),
|
||||
Text('لا توجد محطات بعد',
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16)),
|
||||
const SizedBox(height: 6),
|
||||
Text('أضف أول محطة شريكة لتجهيز المنتج',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _stationCard(FuelAdminController c, FuelStation s, ColorScheme cs) {
|
||||
final statusColor = s.isActive ? cs.primary : cs.onSurfaceVariant;
|
||||
|
||||
return Card(
|
||||
color: cs.surfaceContainerHighest,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
||||
child: ListTile(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
title: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(s.nameAr,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface, fontWeight: FontWeight.bold)),
|
||||
),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: statusColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(s.isActive ? 'فعّالة' : 'موقوفة',
|
||||
style: TextStyle(color: statusColor, fontSize: 11)),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 6),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
[
|
||||
if (s.chainName != null && s.chainName!.isNotEmpty) s.chainName!,
|
||||
if (s.city != null && s.city!.isNotEmpty) s.city!,
|
||||
].join(' · '),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Row(
|
||||
children: [
|
||||
Icon(Icons.percent_rounded, size: 13, color: cs.tertiary),
|
||||
const SizedBox(width: 2),
|
||||
Text('خصم ${s.discountPercent}',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
|
||||
const SizedBox(width: 12),
|
||||
Icon(Icons.local_gas_station, size: 13, color: cs.onSurfaceVariant),
|
||||
const SizedBox(width: 2),
|
||||
Text('${s.redeemCount} صرف',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
|
||||
const SizedBox(width: 12),
|
||||
Text('${s.redeemTotal}',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 12)),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
color: cs.surfaceContainerHighest,
|
||||
icon: Icon(Icons.more_vert, color: cs.onSurfaceVariant, size: 18),
|
||||
onSelected: (v) async {
|
||||
if (v == 'edit') {
|
||||
_openForm(c, s);
|
||||
} else if (v == 'rotate') {
|
||||
await _rotateKey(c, s);
|
||||
}
|
||||
},
|
||||
itemBuilder: (_) => [
|
||||
PopupMenuItem(
|
||||
value: 'edit',
|
||||
child: Text('تعديل', style: TextStyle(color: cs.onSurface))),
|
||||
PopupMenuItem(
|
||||
value: 'rotate',
|
||||
child: Text('توليد مفتاح جديد',
|
||||
style: TextStyle(color: cs.error))),
|
||||
],
|
||||
),
|
||||
onTap: () => _openForm(c, s),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openForm(FuelAdminController c, FuelStation? station) async {
|
||||
final apiKey = await Get.to<String?>(
|
||||
() => FuelStationForm(station: station),
|
||||
);
|
||||
if (apiKey != null && apiKey.isNotEmpty) {
|
||||
await _showKey(apiKey, station?.nameAr ?? 'المحطة الجديدة');
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rotateKey(FuelAdminController c, FuelStation s) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
final ok = await Get.dialog<bool>(
|
||||
AlertDialog(
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
title: Text('توليد مفتاح جديد؟', style: TextStyle(color: cs.onSurface)),
|
||||
content: Text(
|
||||
'المفتاح الحالي لـ«${s.nameAr}» سيتوقّف فوراً، ولن تستطيع المحطة تأكيد أي قسيمة حتى تستلم الجديد.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Get.back(result: false),
|
||||
child: const Text('إلغاء')),
|
||||
TextButton(
|
||||
onPressed: () => Get.back(result: true),
|
||||
child: Text('توليد', style: TextStyle(color: cs.error))),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
if (ok != true) return;
|
||||
|
||||
final key = await c.rotateKey(s.id);
|
||||
if (key != null) {
|
||||
await _showKey(key, s.nameAr);
|
||||
} else if (c.lastError != null) {
|
||||
Get.snackbar('تعذّر التوليد', c.lastError!,
|
||||
backgroundColor: cs.error.withValues(alpha: 0.15));
|
||||
}
|
||||
}
|
||||
|
||||
/// المفتاح يظهر مرة واحدة — مخزَّن مُهشَّراً على الخادم فلا سبيل
|
||||
/// لاستعادته. الحوار لا يُغلق باللمس خارجه: إغلاقه بالخطأ قبل النسخ
|
||||
/// يعني محطة بلا مفتاح ودورة توليد جديدة.
|
||||
Future<void> _showKey(String key, String stationName) async {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
await Get.dialog<void>(
|
||||
barrierDismissible: false,
|
||||
AlertDialog(
|
||||
backgroundColor: cs.surfaceContainerHighest,
|
||||
title: Text('مفتاح $stationName',
|
||||
style: TextStyle(color: cs.onSurface, fontSize: 16)),
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surface,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(color: cs.outline),
|
||||
),
|
||||
child: SelectableText(key,
|
||||
style: TextStyle(
|
||||
color: cs.onSurface, fontSize: 12, fontFamily: 'monospace')),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Text(
|
||||
'انسخه الآن وسلّمه للمحطة. لن يظهر مرة أخرى — المفتاح مخزَّن مشفّراً ولا يمكن استرجاعه.',
|
||||
style: TextStyle(color: cs.error, fontSize: 11),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
Clipboard.setData(ClipboardData(text: key));
|
||||
Get.snackbar('نُسخ', 'المفتاح في الحافظة');
|
||||
},
|
||||
child: Text('نسخ', style: TextStyle(color: cs.primary)),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Get.back(), child: const Text('تم')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user