Update: 2026-08-08 12:58:16
This commit is contained in:
@@ -0,0 +1,129 @@
|
||||
// fuel_admin_controller.dart — تحكم شاشات محفظة الوقود (لوحة إدارة سيرو)
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import 'fuel_admin_models.dart';
|
||||
import 'fuel_admin_service.dart';
|
||||
|
||||
class FuelAdminController extends GetxController {
|
||||
bool isLoading = false;
|
||||
List<FuelStation> stations = [];
|
||||
|
||||
/// حالة المنتج — لا حالة الشاشة. الشاشة تعمل دائماً؛ المنتج قد يكون
|
||||
/// مُجهَّزاً وغير مُشغَّل حتى يتم الاتفاق مع سلسلة المحطات.
|
||||
bool productActive = false;
|
||||
|
||||
String searchQuery = '';
|
||||
bool isSaving = false;
|
||||
|
||||
bool isLoadingDrawdowns = false;
|
||||
FuelDrawdownsPage? drawdowns;
|
||||
|
||||
String? lastError;
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
fetchStations();
|
||||
}
|
||||
|
||||
Future<void> fetchStations() async {
|
||||
isLoading = true;
|
||||
update();
|
||||
|
||||
final res = await FuelAdminService.listStations(search: searchQuery);
|
||||
if (res.success && res.data != null) {
|
||||
stations = res.data!.stations;
|
||||
productActive = res.data!.productActive;
|
||||
lastError = null;
|
||||
} else {
|
||||
lastError = res.message;
|
||||
}
|
||||
|
||||
isLoading = false;
|
||||
update();
|
||||
}
|
||||
|
||||
/// يُرجع المفتاح الجديد عند الإنشاء، أو null عند التعديل/الفشل.
|
||||
///
|
||||
/// المفتاح يظهر مرة واحدة فقط — مخزَّن مُهشَّراً على الخادم ولا سبيل
|
||||
/// لاستعادته. الشاشة مسؤولة عن عرضه فوراً قبل أي انتقال.
|
||||
Future<String?> saveStation({
|
||||
int? stationId,
|
||||
required String nameAr,
|
||||
String? chainName,
|
||||
String? city,
|
||||
String? address,
|
||||
double? lat,
|
||||
double? lng,
|
||||
String? phone,
|
||||
required double discountPercent,
|
||||
required bool isActive,
|
||||
}) async {
|
||||
isSaving = true;
|
||||
update();
|
||||
|
||||
final res = await FuelAdminService.saveStation(
|
||||
stationId: stationId,
|
||||
nameAr: nameAr,
|
||||
chainName: chainName,
|
||||
city: city,
|
||||
address: address,
|
||||
lat: lat,
|
||||
lng: lng,
|
||||
phone: phone,
|
||||
discountPercent: discountPercent,
|
||||
isActive: isActive,
|
||||
);
|
||||
|
||||
isSaving = false;
|
||||
lastError = res.success ? null : res.message;
|
||||
update();
|
||||
|
||||
if (!res.success) return null;
|
||||
|
||||
await fetchStations();
|
||||
return res.data?['api_key']?.toString();
|
||||
}
|
||||
|
||||
Future<String?> rotateKey(int stationId) async {
|
||||
final res = await FuelAdminService.rotateKey(stationId);
|
||||
if (!res.success) {
|
||||
lastError = res.message;
|
||||
update();
|
||||
return null;
|
||||
}
|
||||
return res.data?['api_key']?.toString();
|
||||
}
|
||||
|
||||
/// تشغيل المنتج أو إيقافه. الخادم يرفض التشغيل بلا محطة فعّالة واحدة —
|
||||
/// سائق يفعّل محفظته ثم لا يجد أين يصرف قسيمته تجربةٌ تُفقد الثقة.
|
||||
Future<bool> toggleProduct(bool value) async {
|
||||
final res = await FuelAdminService.toggleProduct(value);
|
||||
if (res.success) {
|
||||
productActive = res.data ?? value;
|
||||
lastError = null;
|
||||
update();
|
||||
return true;
|
||||
}
|
||||
lastError = res.message;
|
||||
update();
|
||||
return false;
|
||||
}
|
||||
|
||||
Future<void> fetchDrawdowns({int? stationId, String? status}) async {
|
||||
isLoadingDrawdowns = true;
|
||||
update();
|
||||
|
||||
final res =
|
||||
await FuelAdminService.listDrawdowns(stationId: stationId, status: status);
|
||||
if (res.success) {
|
||||
drawdowns = res.data;
|
||||
lastError = null;
|
||||
} else {
|
||||
lastError = res.message;
|
||||
}
|
||||
|
||||
isLoadingDrawdowns = false;
|
||||
update();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
// fuel_admin_models.dart — نماذج بيانات محفظة الوقود (لوحة إدارة سيرو)
|
||||
|
||||
class FuelStation {
|
||||
final int id;
|
||||
final String nameAr;
|
||||
final String? chainName;
|
||||
final String? city;
|
||||
final String? address;
|
||||
final double? lat;
|
||||
final double? lng;
|
||||
final String? phone;
|
||||
final double discountPercent;
|
||||
final bool isActive;
|
||||
|
||||
/// أرقام الأداء: محطة بلا صرف اسمٌ في جدول لا شريك يعمل.
|
||||
final int redeemCount;
|
||||
final double redeemTotal;
|
||||
final String? lastRedeemAt;
|
||||
|
||||
FuelStation({
|
||||
required this.id,
|
||||
required this.nameAr,
|
||||
required this.discountPercent,
|
||||
required this.isActive,
|
||||
this.chainName,
|
||||
this.city,
|
||||
this.address,
|
||||
this.lat,
|
||||
this.lng,
|
||||
this.phone,
|
||||
this.redeemCount = 0,
|
||||
this.redeemTotal = 0,
|
||||
this.lastRedeemAt,
|
||||
});
|
||||
|
||||
factory FuelStation.fromJson(Map<String, dynamic> j) => FuelStation(
|
||||
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||
nameAr: j['name_ar']?.toString() ?? '',
|
||||
chainName: j['chain_name']?.toString(),
|
||||
city: j['city']?.toString(),
|
||||
address: j['address']?.toString(),
|
||||
lat: double.tryParse(j['lat']?.toString() ?? ''),
|
||||
lng: double.tryParse(j['lng']?.toString() ?? ''),
|
||||
phone: j['phone']?.toString(),
|
||||
discountPercent:
|
||||
double.tryParse(j['discount_percent']?.toString() ?? '0') ?? 0,
|
||||
isActive: j['is_active'] == true,
|
||||
redeemCount: int.tryParse(j['redeem_count']?.toString() ?? '0') ?? 0,
|
||||
redeemTotal: double.tryParse(j['redeem_total']?.toString() ?? '0') ?? 0,
|
||||
lastRedeemAt: j['last_redeem_at']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
class FuelStationsPage {
|
||||
final List<FuelStation> stations;
|
||||
|
||||
/// حالة المنتج نفسه. قرار المالك: تُجهَّز الشاشات ولا يُشغَّل المنتج إلا
|
||||
/// بعد إتمام الاتفاق مع سلسلة المحطات — فالشاشة تعمل كاملةً بينما
|
||||
/// المنتج مُطفأ، والفرق يجب أن يكون ظاهراً لا مخفياً.
|
||||
final bool productActive;
|
||||
|
||||
FuelStationsPage({required this.stations, required this.productActive});
|
||||
|
||||
factory FuelStationsPage.fromJson(Map<String, dynamic> j) => FuelStationsPage(
|
||||
stations: (j['stations'] is List)
|
||||
? (j['stations'] as List)
|
||||
.map((s) => FuelStation.fromJson(Map<String, dynamic>.from(s)))
|
||||
.toList()
|
||||
: <FuelStation>[],
|
||||
productActive: j['product_active'] == true,
|
||||
);
|
||||
}
|
||||
|
||||
class FuelVoucher {
|
||||
final int id;
|
||||
final String driverId;
|
||||
final String code;
|
||||
final double amountAuthorized;
|
||||
final double amountRedeemed;
|
||||
final String currency;
|
||||
final String status;
|
||||
final String? station;
|
||||
final String? redeemedAt;
|
||||
final String? createdAt;
|
||||
|
||||
FuelVoucher({
|
||||
required this.id,
|
||||
required this.driverId,
|
||||
required this.code,
|
||||
required this.amountAuthorized,
|
||||
required this.amountRedeemed,
|
||||
required this.currency,
|
||||
required this.status,
|
||||
this.station,
|
||||
this.redeemedAt,
|
||||
this.createdAt,
|
||||
});
|
||||
|
||||
factory FuelVoucher.fromJson(Map<String, dynamic> j) => FuelVoucher(
|
||||
id: int.tryParse(j['id'].toString()) ?? 0,
|
||||
driverId: j['driver_id']?.toString() ?? '',
|
||||
code: j['code']?.toString() ?? '',
|
||||
amountAuthorized:
|
||||
double.tryParse(j['amount_authorized']?.toString() ?? '0') ?? 0,
|
||||
amountRedeemed:
|
||||
double.tryParse(j['amount_redeemed']?.toString() ?? '0') ?? 0,
|
||||
currency: j['currency']?.toString() ?? '',
|
||||
status: j['status']?.toString() ?? '',
|
||||
station: j['station']?.toString(),
|
||||
redeemedAt: j['redeemed_at']?.toString(),
|
||||
createdAt: j['created_at']?.toString(),
|
||||
);
|
||||
}
|
||||
|
||||
class FuelDrawdownsPage {
|
||||
final List<FuelVoucher> vouchers;
|
||||
final double lentTotal;
|
||||
final double collectedTotal;
|
||||
final double outstandingTotal;
|
||||
final int driversCount;
|
||||
|
||||
FuelDrawdownsPage({
|
||||
required this.vouchers,
|
||||
required this.lentTotal,
|
||||
required this.collectedTotal,
|
||||
required this.outstandingTotal,
|
||||
required this.driversCount,
|
||||
});
|
||||
|
||||
factory FuelDrawdownsPage.fromJson(Map<String, dynamic> j) {
|
||||
final s = Map<String, dynamic>.from(j['summary'] ?? {});
|
||||
return FuelDrawdownsPage(
|
||||
vouchers: (j['vouchers'] is List)
|
||||
? (j['vouchers'] as List)
|
||||
.map((v) => FuelVoucher.fromJson(Map<String, dynamic>.from(v)))
|
||||
.toList()
|
||||
: <FuelVoucher>[],
|
||||
lentTotal: double.tryParse(s['lent_total']?.toString() ?? '0') ?? 0,
|
||||
collectedTotal:
|
||||
double.tryParse(s['collected_total']?.toString() ?? '0') ?? 0,
|
||||
outstandingTotal:
|
||||
double.tryParse(s['outstanding_total']?.toString() ?? '0') ?? 0,
|
||||
driversCount: int.tryParse(s['drivers_count']?.toString() ?? '0') ?? 0,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
// fuel_admin_service.dart — طبقة الاتصال بـ backend/Admin/fuel
|
||||
//
|
||||
// محفظة الوقود: السائق يتزوّد بلا نقد، والقيمة تُقيَّد ديناً يُخصم من
|
||||
// أرباحه. هذه الشاشة تدير المحطات الشريكة وتشغّل المنتج أو توقفه.
|
||||
import '../functions/crud.dart';
|
||||
import '../../constant/links.dart';
|
||||
import 'fuel_admin_models.dart';
|
||||
|
||||
class FuelApiResult<T> {
|
||||
final bool success;
|
||||
final T? data;
|
||||
final String message;
|
||||
FuelApiResult(this.success, this.data, this.message);
|
||||
}
|
||||
|
||||
class FuelAdminService {
|
||||
static String get _base => '${AppLink.server}/Admin/fuel';
|
||||
|
||||
static String _errMsg(dynamic res) {
|
||||
if (res == 'no_internet') return 'تحقق من اتصالك بالإنترنت';
|
||||
if (res == 'token_expired') return 'انتهت الجلسة، حاول مجدداً';
|
||||
if (res is Map && res['message'] is String) return res['message'];
|
||||
return 'حدث خطأ، حاول مجدداً';
|
||||
}
|
||||
|
||||
static Future<FuelApiResult<FuelStationsPage>> listStations({
|
||||
String? search,
|
||||
String? city,
|
||||
bool onlyActive = false,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{};
|
||||
if (search != null && search.isNotEmpty) payload['search'] = search;
|
||||
if (city != null && city.isNotEmpty) payload['city'] = city;
|
||||
if (onlyActive) payload['only_active'] = '1';
|
||||
|
||||
final res = await CRUD().post(link: '$_base/list.php', payload: payload);
|
||||
|
||||
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||
return FuelApiResult(
|
||||
true,
|
||||
FuelStationsPage.fromJson(Map<String, dynamic>.from(res['message'])),
|
||||
'ok',
|
||||
);
|
||||
}
|
||||
return FuelApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
/// يُرجع `api_key` عند الإنشاء فقط — مخزَّن مُهشَّراً على الخادم، فلا
|
||||
/// سبيل لعرضه ثانيةً. الشاشة مسؤولة عن إظهاره فوراً وبوضوح.
|
||||
static Future<FuelApiResult<Map<String, dynamic>>> saveStation({
|
||||
int? stationId,
|
||||
required String nameAr,
|
||||
String? chainName,
|
||||
String? city,
|
||||
String? address,
|
||||
double? lat,
|
||||
double? lng,
|
||||
String? phone,
|
||||
required double discountPercent,
|
||||
required bool isActive,
|
||||
}) async {
|
||||
final res = await CRUD().post(link: '$_base/save.php', payload: {
|
||||
if (stationId != null) 'station_id': stationId.toString(),
|
||||
'name_ar': nameAr,
|
||||
if (chainName != null && chainName.isNotEmpty) 'chain_name': chainName,
|
||||
if (city != null && city.isNotEmpty) 'city': city,
|
||||
if (address != null && address.isNotEmpty) 'address': address,
|
||||
if (lat != null) 'lat': lat.toString(),
|
||||
if (lng != null) 'lng': lng.toString(),
|
||||
if (phone != null && phone.isNotEmpty) 'phone': phone,
|
||||
'discount_percent': discountPercent.toString(),
|
||||
'is_active': isActive ? '1' : '0',
|
||||
});
|
||||
|
||||
if (res is Map && res['status'] == 'success') {
|
||||
return FuelApiResult(
|
||||
true,
|
||||
Map<String, dynamic>.from(res['message'] ?? {}),
|
||||
res['message'] is Map ? 'ok' : (res['message']?.toString() ?? 'ok'),
|
||||
);
|
||||
}
|
||||
return FuelApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FuelApiResult<Map<String, dynamic>>> rotateKey(int stationId) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/rotate_key.php',
|
||||
payload: {'station_id': stationId.toString()},
|
||||
);
|
||||
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||
return FuelApiResult(true, Map<String, dynamic>.from(res['message']), 'ok');
|
||||
}
|
||||
return FuelApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FuelApiResult<bool>> toggleProduct(bool isActive) async {
|
||||
final res = await CRUD().post(
|
||||
link: '$_base/toggle_product.php',
|
||||
payload: {'is_active': isActive ? '1' : '0'},
|
||||
);
|
||||
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||
return FuelApiResult(
|
||||
true, res['message']['product_active'] == true, 'ok');
|
||||
}
|
||||
return FuelApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
|
||||
static Future<FuelApiResult<FuelDrawdownsPage>> listDrawdowns({
|
||||
int? stationId,
|
||||
String? status,
|
||||
}) async {
|
||||
final payload = <String, dynamic>{};
|
||||
if (stationId != null) payload['station_id'] = stationId.toString();
|
||||
if (status != null && status.isNotEmpty) payload['status'] = status;
|
||||
|
||||
final res = await CRUD().post(link: '$_base/drawdowns.php', payload: payload);
|
||||
|
||||
if (res is Map && res['status'] == 'success' && res['message'] is Map) {
|
||||
return FuelApiResult(
|
||||
true,
|
||||
FuelDrawdownsPage.fromJson(Map<String, dynamic>.from(res['message'])),
|
||||
'ok',
|
||||
);
|
||||
}
|
||||
return FuelApiResult(false, null, _errMsg(res));
|
||||
}
|
||||
}
|
||||
@@ -42,6 +42,7 @@ import 'analytics/live_analytics_page.dart';
|
||||
import 'package:siro_admin/views/widgets/responsive_layout.dart';
|
||||
import 'package:siro_admin/views/widgets/web_sidebar.dart';
|
||||
import '../transit/org_list_page.dart';
|
||||
import 'fuel/fuel_stations_page.dart';
|
||||
import 'package:siro_admin/views/widgets/glass_container.dart';
|
||||
|
||||
class AdminHomePage extends StatefulWidget {
|
||||
@@ -1352,6 +1353,13 @@ class _AdminHomePageState extends State<AdminHomePage>
|
||||
() => Get.to(() => const TransitOrgListPage())),
|
||||
],
|
||||
),
|
||||
ActionCategory(
|
||||
title: 'اقتصاد السائق',
|
||||
items: [
|
||||
ActionItem('محطات الوقود', Icons.local_gas_station_rounded, const Color(0xFF10B981),
|
||||
() => Get.to(() => const FuelStationsPageView())),
|
||||
],
|
||||
),
|
||||
ActionCategory(
|
||||
title: 'إدارة النظام الجديد',
|
||||
items: [
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
// fuel_drawdowns_page.dart — حركة القسائم وملخّص الائتمان
|
||||
//
|
||||
// الشاشة التي تُجيب: كم أقرضنا؟ كم استُرد؟ وكم بقي في الشارع؟
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../../controller/fuel/fuel_admin_controller.dart';
|
||||
import '../../../controller/fuel/fuel_admin_models.dart';
|
||||
|
||||
class FuelDrawdownsPageView extends StatefulWidget {
|
||||
const FuelDrawdownsPageView({super.key});
|
||||
|
||||
@override
|
||||
State<FuelDrawdownsPageView> createState() => _FuelDrawdownsPageViewState();
|
||||
}
|
||||
|
||||
class _FuelDrawdownsPageViewState extends State<FuelDrawdownsPageView> {
|
||||
String? _status;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
Get.find<FuelAdminController>().fetchDrawdowns();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
|
||||
return GetBuilder<FuelAdminController>(
|
||||
builder: (c) => Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: cs.surface,
|
||||
foregroundColor: cs.onSurface,
|
||||
title: const Text('حركة القسائم'),
|
||||
),
|
||||
body: Column(
|
||||
children: [
|
||||
if (c.drawdowns != null) _summary(c.drawdowns!, cs),
|
||||
_filters(c, cs),
|
||||
Expanded(
|
||||
child: c.isLoadingDrawdowns
|
||||
? Center(child: CircularProgressIndicator(color: cs.primary))
|
||||
: (c.drawdowns?.vouchers.isEmpty ?? true)
|
||||
? Center(
|
||||
child: Text('لا توجد قسائم',
|
||||
style: TextStyle(color: cs.onSurfaceVariant)))
|
||||
: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12),
|
||||
itemCount: c.drawdowns!.vouchers.length,
|
||||
itemBuilder: (_, i) =>
|
||||
_voucherCard(c.drawdowns!.vouchers[i], cs),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// الملخّص على كامل المنتج لا على القائمة المعروضة: رقم يتغيّر بتغيّر
|
||||
/// الفلتر يُقرأ كأنه حقيقة عن المنصة وهو ليس كذلك.
|
||||
Widget _summary(FuelDrawdownsPage d, ColorScheme cs) => Container(
|
||||
margin: const EdgeInsets.fromLTRB(12, 12, 12, 4),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: cs.surfaceContainerHighest,
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
_stat('أُقرض', d.lentTotal.toString(), cs.onSurface, cs),
|
||||
_stat('استُرد', d.collectedTotal.toString(), cs.primary, cs),
|
||||
_stat('قائم', d.outstandingTotal.toString(), cs.tertiary, cs),
|
||||
_stat('سائق', d.driversCount.toString(), cs.onSurfaceVariant, cs),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _stat(String label, String value, Color color, ColorScheme cs) =>
|
||||
Expanded(
|
||||
child: Column(
|
||||
children: [
|
||||
Text(value,
|
||||
style: TextStyle(
|
||||
color: color, fontSize: 16, fontWeight: FontWeight.w700)),
|
||||
const SizedBox(height: 2),
|
||||
Text(label,
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11)),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
Widget _filters(FuelAdminController c, ColorScheme cs) {
|
||||
const options = {
|
||||
null: 'الكل',
|
||||
'issued': 'سارية',
|
||||
'redeemed': 'مصروفة',
|
||||
'expired': 'منتهية',
|
||||
'cancelled': 'ملغاة',
|
||||
};
|
||||
|
||||
return SizedBox(
|
||||
height: 46,
|
||||
child: ListView(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||||
children: options.entries.map((e) {
|
||||
final selected = _status == e.key;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 8),
|
||||
child: ChoiceChip(
|
||||
label: Text(e.value),
|
||||
selected: selected,
|
||||
onSelected: (_) {
|
||||
setState(() => _status = e.key);
|
||||
c.fetchDrawdowns(status: e.key);
|
||||
},
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _voucherCard(FuelVoucher v, ColorScheme cs) {
|
||||
final (color, label) = switch (v.status) {
|
||||
'redeemed' => (cs.primary, 'مصروفة'),
|
||||
'issued' => (cs.tertiary, 'سارية'),
|
||||
'cancelled' => (cs.error, 'ملغاة'),
|
||||
_ => (cs.onSurfaceVariant, 'منتهية'),
|
||||
};
|
||||
|
||||
// المصروف لا المأذون: السائق قد يطلب عشرين ويتزوّد بخمسة عشر،
|
||||
// والدَّين على ما صُرف. عرض المأذون هنا يضخّم الأرقام في عين القارئ.
|
||||
final amount = v.status == 'redeemed' ? v.amountRedeemed : v.amountAuthorized;
|
||||
|
||||
return Card(
|
||||
color: cs.surfaceContainerHighest,
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
||||
child: ListTile(
|
||||
title: Row(
|
||||
children: [
|
||||
Text('$amount ${v.currency}',
|
||||
style: TextStyle(
|
||||
color: cs.onSurface, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(width: 8),
|
||||
Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text(label, style: TextStyle(color: color, fontSize: 11)),
|
||||
),
|
||||
],
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
[
|
||||
'سائق ${v.driverId}',
|
||||
if (v.station != null && v.station!.isNotEmpty) v.station!,
|
||||
v.redeemedAt ?? v.createdAt ?? '',
|
||||
].where((s) => s.isNotEmpty).join(' · '),
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,212 @@
|
||||
// fuel_station_form.dart — إضافة محطة أو تعديلها
|
||||
//
|
||||
// يُرجع مفتاح المحطة الجديد عبر Get.back عند الإنشاء، وnull عند التعديل.
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
|
||||
import '../../../controller/fuel/fuel_admin_controller.dart';
|
||||
import '../../../controller/fuel/fuel_admin_models.dart';
|
||||
|
||||
class FuelStationForm extends StatefulWidget {
|
||||
final FuelStation? station;
|
||||
const FuelStationForm({super.key, this.station});
|
||||
|
||||
@override
|
||||
State<FuelStationForm> createState() => _FuelStationFormState();
|
||||
}
|
||||
|
||||
class _FuelStationFormState extends State<FuelStationForm> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
|
||||
late final TextEditingController _name;
|
||||
late final TextEditingController _chain;
|
||||
late final TextEditingController _city;
|
||||
late final TextEditingController _address;
|
||||
late final TextEditingController _phone;
|
||||
late final TextEditingController _lat;
|
||||
late final TextEditingController _lng;
|
||||
late final TextEditingController _discount;
|
||||
|
||||
late bool _isActive;
|
||||
|
||||
bool get _isEdit => widget.station != null;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
final s = widget.station;
|
||||
_name = TextEditingController(text: s?.nameAr ?? '');
|
||||
_chain = TextEditingController(text: s?.chainName ?? '');
|
||||
_city = TextEditingController(text: s?.city ?? '');
|
||||
_address = TextEditingController(text: s?.address ?? '');
|
||||
_phone = TextEditingController(text: s?.phone ?? '');
|
||||
_lat = TextEditingController(text: s?.lat?.toString() ?? '');
|
||||
_lng = TextEditingController(text: s?.lng?.toString() ?? '');
|
||||
_discount = TextEditingController(text: (s?.discountPercent ?? 0).toString());
|
||||
_isActive = s?.isActive ?? true;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
for (final c in [_name, _chain, _city, _address, _phone, _lat, _lng, _discount]) {
|
||||
c.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final cs = Theme.of(context).colorScheme;
|
||||
final c = Get.find<FuelAdminController>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: cs.surface,
|
||||
appBar: AppBar(
|
||||
backgroundColor: cs.surface,
|
||||
foregroundColor: cs.onSurface,
|
||||
title: Text(_isEdit ? 'تعديل محطة' : 'محطة جديدة'),
|
||||
),
|
||||
body: Form(
|
||||
key: _formKey,
|
||||
child: ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
_field(cs, _name, 'اسم المحطة *', required: true),
|
||||
_field(cs, _chain, 'السلسلة (إن كانت فرعاً)'),
|
||||
_field(cs, _city, 'المدينة'),
|
||||
_field(cs, _address, 'العنوان'),
|
||||
_field(cs, _phone, 'الهاتف', keyboard: TextInputType.phone),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _field(cs, _lat, 'خط العرض',
|
||||
keyboard: TextInputType.number)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _field(cs, _lng, 'خط الطول',
|
||||
keyboard: TextInputType.number)),
|
||||
],
|
||||
),
|
||||
_field(
|
||||
cs,
|
||||
_discount,
|
||||
'نسبة خصم الشريك (٪)',
|
||||
keyboard: TextInputType.number,
|
||||
validator: (v) {
|
||||
final d = double.tryParse(v ?? '');
|
||||
if (d == null) return 'أدخل رقماً';
|
||||
if (d < 0 || d > 100) return 'بين 0 و 100';
|
||||
return null;
|
||||
},
|
||||
),
|
||||
// الخصم يُسجَّل مع كل سحبة لا يُقرأ لاحقاً من هنا: النسبة
|
||||
// تتغيّر بالتفاوض، وحساب تسويات الماضي بنسبة اليوم تزوير.
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 4, bottom: 12),
|
||||
child: Text(
|
||||
'يُسجَّل مع كل عملية صرف. تعديله لا يغيّر عمليات سابقة.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
),
|
||||
SwitchListTile(
|
||||
value: _isActive,
|
||||
activeThumbColor: cs.primary,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
title: Text('محطة فعّالة', style: TextStyle(color: cs.onSurface)),
|
||||
subtitle: Text(
|
||||
_isActive
|
||||
? 'تظهر للسائقين وتستطيع تأكيد القسائم'
|
||||
: 'مخفيّة عن السائقين ولا تستطيع تأكيد أي قسيمة',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||
),
|
||||
onChanged: (v) => setState(() => _isActive = v),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: cs.primary,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: c.isSaving ? null : () => _submit(c),
|
||||
child: c.isSaving
|
||||
? const SizedBox(
|
||||
width: 20,
|
||||
height: 20,
|
||||
child: CircularProgressIndicator(strokeWidth: 2))
|
||||
: Text(_isEdit ? 'حفظ التعديلات' : 'إنشاء المحطة',
|
||||
style: TextStyle(color: cs.onPrimary)),
|
||||
),
|
||||
),
|
||||
if (!_isEdit)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12),
|
||||
child: Text(
|
||||
'سيُولَّد مفتاح خاص بالمحطة عند الإنشاء، ويظهر مرة واحدة فقط.',
|
||||
style: TextStyle(color: cs.onSurfaceVariant, fontSize: 11),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _field(
|
||||
ColorScheme cs,
|
||||
TextEditingController ctrl,
|
||||
String label, {
|
||||
bool required = false,
|
||||
TextInputType? keyboard,
|
||||
String? Function(String?)? validator,
|
||||
}) =>
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: TextFormField(
|
||||
controller: ctrl,
|
||||
keyboardType: keyboard,
|
||||
style: TextStyle(color: cs.onSurface),
|
||||
validator: validator ??
|
||||
(required
|
||||
? (v) => (v == null || v.trim().isEmpty) ? 'مطلوب' : null
|
||||
: null),
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
labelStyle: TextStyle(color: cs.onSurfaceVariant),
|
||||
filled: true,
|
||||
fillColor: cs.surfaceContainerHighest,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
Future<void> _submit(FuelAdminController c) async {
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
final key = await c.saveStation(
|
||||
stationId: widget.station?.id,
|
||||
nameAr: _name.text.trim(),
|
||||
chainName: _chain.text.trim(),
|
||||
city: _city.text.trim(),
|
||||
address: _address.text.trim(),
|
||||
lat: double.tryParse(_lat.text.trim()),
|
||||
lng: double.tryParse(_lng.text.trim()),
|
||||
phone: _phone.text.trim(),
|
||||
discountPercent: double.tryParse(_discount.text.trim()) ?? 0,
|
||||
isActive: _isActive,
|
||||
);
|
||||
|
||||
if (c.lastError != null) {
|
||||
Get.snackbar('تعذّر الحفظ', c.lastError!);
|
||||
return;
|
||||
}
|
||||
|
||||
Get.back(result: key);
|
||||
}
|
||||
}
|
||||
@@ -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('تم')),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import 'package:siro_admin/views/admin/captain/captain.dart';
|
||||
import 'package:siro_admin/views/admin/passenger/passenger.dart';
|
||||
import 'package:siro_admin/views/admin/drivers/driver_tracker_screen.dart';
|
||||
import 'package:siro_admin/views/transit/org_list_page.dart';
|
||||
import 'package:siro_admin/views/admin/fuel/fuel_stations_page.dart';
|
||||
import 'package:siro_admin/views/admin/static/static.dart';
|
||||
import 'package:siro_admin/controller/admin/static_controller.dart';
|
||||
import 'package:siro_admin/views/admin/drivers/monitor_ride.dart';
|
||||
@@ -172,6 +173,15 @@ class WebSidebar extends StatelessWidget {
|
||||
onTap: () => Get.to(() => const TransitOrgListPage()),
|
||||
),
|
||||
|
||||
_buildSectionHeader(context, 'اقتصاد السائق'),
|
||||
_buildNavItem(
|
||||
context,
|
||||
title: 'محطات الوقود',
|
||||
icon: Icons.local_gas_station_rounded,
|
||||
index: 140,
|
||||
onTap: () => Get.to(() => const FuelStationsPageView()),
|
||||
),
|
||||
|
||||
_buildSectionHeader(context, 'المالية والأسعار'),
|
||||
_buildNavItem(
|
||||
context,
|
||||
|
||||
Reference in New Issue
Block a user