Update: 2026-05-07 03:06:15

This commit is contained in:
Hamza-Ayed
2026-05-07 03:06:15 +03:00
parent 272971fc5b
commit bfb6368ec8
28 changed files with 3292 additions and 188 deletions

View File

@@ -8,90 +8,92 @@ class DashboardView extends GetView<DashboardController> {
@override
Widget build(BuildContext context) {
// We instantiate the controller here if not bound, though we should use binding in routes.
// For safety, let's put it here or rely on the router binding.
Get.put(DashboardController());
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: const Color(0xFFF5F7FA),
appBar: AppBar(
title: const Text('لوحة التحكم - مُصادَق', style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: const Color(0xFF0F4C81),
foregroundColor: Colors.white,
elevation: 0,
actions: [
IconButton(
icon: const Icon(Icons.refresh),
onPressed: () => controller.refreshData(),
return Column(
children: [
// Custom Top Bar
Container(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 12),
color: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81),
child: Row(
children: [
const SizedBox(width: 48),
Expanded(
child: Center(
child: Text(
'مُصادَق',
style: TextStyle(color: Colors.white, fontSize: 20, fontWeight: FontWeight.bold),
),
),
),
IconButton(
icon: const Icon(Icons.notifications_outlined, color: Colors.white),
onPressed: () => Get.snackbar('قريباً', 'الإشعارات ستتوفر قريباً'),
),
IconButton(
icon: const Icon(Icons.refresh, color: Colors.white),
onPressed: () => controller.refreshData(),
),
],
),
IconButton(
icon: const Icon(Icons.logout),
onPressed: () => controller.logout(),
)
],
),
body: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81)));
}
),
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81)));
}
final stats = controller.stats;
final role = controller.userRole.value;
final stats = controller.stats;
final role = controller.userRole.value;
return RefreshIndicator(
onRefresh: () async => controller.refreshData(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWelcomeHeader(role),
const SizedBox(height: 24),
// Action Buttons
_buildQuickActions(),
const SizedBox(height: 32),
// Invoice Stats
const Text('إحصائيات الفواتير', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_buildInvoiceStats(stats),
// Role Specific Stats (Companies, Users, Tenants)
if (role == 'admin' || role == 'super_admin') ...[
const SizedBox(height: 24),
const Text('نظرة عامة', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_buildRoleSpecificStats(stats, role),
],
// Quota
if (role == 'admin' && stats['subscription'] != null) ...[
const SizedBox(height: 24),
_buildQuotaMeter(stats['subscription']),
],
const SizedBox(height: 32),
const Text('أحدث النشاطات', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_buildRecentActivity(),
const SizedBox(height: 40),
],
),
),
);
}),
return RefreshIndicator(
onRefresh: () async => controller.refreshData(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_buildWelcomeHeader(role, isDark),
const SizedBox(height: 24),
_buildQuickActions(isDark),
const SizedBox(height: 32),
const Text('إحصائيات الفواتير', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_buildInvoiceStats(stats, isDark),
if (role == 'admin' || role == 'super_admin') ...[
const SizedBox(height: 24),
const Text('نظرة عامة', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_buildRoleSpecificStats(stats, role, isDark),
],
if (role == 'admin' && stats['subscription'] != null) ...[
const SizedBox(height: 24),
_buildQuotaMeter(stats['subscription'], isDark),
],
const SizedBox(height: 32),
const Text('أحدث النشاطات', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
_buildRecentActivity(isDark),
const SizedBox(height: 40),
],
),
),
);
}),
),
],
);
}
Widget _buildWelcomeHeader(String role) {
Widget _buildWelcomeHeader(String role, bool isDark) {
String roleName = 'مستخدم';
switch (role) {
case 'super_admin': roleName = 'مدير النظام'; break;
case 'admin': roleName = 'مدير المكتب'; break;
case 'accountant': roleName = 'محاسب'; break;
case 'viewer': roleName = 'مشاهد'; break;
}
return Row(
@@ -106,20 +108,20 @@ class DashboardView extends GetView<DashboardController> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('مرحباً بك في مُصادَق 👋', style: TextStyle(fontSize: 20, fontWeight: FontWeight.bold)),
Text('صلاحيات: $roleName', style: const TextStyle(color: Colors.grey, fontSize: 14)),
Text('صلاحيات: $roleName', style: TextStyle(color: isDark ? Colors.white38 : Colors.grey, fontSize: 14)),
],
),
],
);
}
Widget _buildQuickActions() {
Widget _buildQuickActions(bool isDark) {
return Row(
children: [
Expanded(
child: ElevatedButton.icon(
icon: const Icon(Icons.document_scanner),
label: const Text('المسح الضوئي', style: TextStyle(fontWeight: FontWeight.bold)),
label: const Text('المسح الضوئي', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0F4C81),
foregroundColor: Colors.white,
@@ -130,69 +132,66 @@ class DashboardView extends GetView<DashboardController> {
),
),
const SizedBox(width: 12),
if (controller.userRole.value == 'admin')
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.business),
label: const Text('إدارة الشركات', style: TextStyle(fontWeight: FontWeight.bold)),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF0F4C81),
side: const BorderSide(color: Color(0xFF0F4C81)),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
Get.snackbar('قريباً', 'سيتم إطلاق هذه الميزة قريباً');
},
Expanded(
child: OutlinedButton.icon(
icon: const Icon(Icons.mic_rounded),
label: const Text('المساعد الصوتي', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
style: OutlinedButton.styleFrom(
foregroundColor: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF0F4C81),
side: BorderSide(color: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF0F4C81)),
padding: const EdgeInsets.symmetric(vertical: 16),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
onPressed: () {
Get.snackbar('المساعد الصوتي', 'يتم تجهيز خوادم AI (Grok & Gemini) للاستماع لأوامرك...');
},
),
),
],
);
}
Widget _buildInvoiceStats(Map stats) {
Widget _buildInvoiceStats(Map stats, bool isDark) {
final inv = stats['invoices'] ?? {'total': 0, 'pending': 0, 'approved': 0};
return Row(
children: [
_buildStatCard('الكل', inv['total'].toString(), Icons.receipt_long, Colors.blue),
_buildStatCard('الكل', inv['total'].toString(), Icons.receipt_long, Colors.blue, isDark),
const SizedBox(width: 12),
_buildStatCard('قيد المعالجة', inv['pending'].toString(), Icons.hourglass_empty, Colors.orange),
_buildStatCard('قيد المعالجة', inv['pending'].toString(), Icons.hourglass_empty, Colors.orange, isDark),
const SizedBox(width: 12),
_buildStatCard('معتمدة', inv['approved'].toString(), Icons.check_circle, Colors.green),
_buildStatCard('معتمدة', inv['approved'].toString(), Icons.check_circle, Colors.green, isDark),
],
);
}
Widget _buildRoleSpecificStats(Map stats, String role) {
Widget _buildRoleSpecificStats(Map stats, String role, bool isDark) {
if (role == 'super_admin') {
return Row(
children: [
_buildStatCard('المستأجرين', (stats['tenants'] ?? 0).toString(), Icons.business_center, Colors.indigo),
_buildStatCard('المستأجرين', (stats['tenants'] ?? 0).toString(), Icons.business_center, Colors.indigo, isDark),
const SizedBox(width: 12),
_buildStatCard('المستخدمين', (stats['total_users'] ?? 0).toString(), Icons.people, Colors.purple),
_buildStatCard('المستخدمين', (stats['total_users'] ?? 0).toString(), Icons.people, Colors.purple, isDark),
],
);
} else {
return Row(
children: [
_buildStatCard('الشركات', (stats['companies'] ?? 0).toString(), Icons.business, Colors.indigo),
_buildStatCard('الشركات', (stats['companies'] ?? 0).toString(), Icons.business, Colors.indigo, isDark),
const SizedBox(width: 12),
_buildStatCard('المستخدمين', (stats['users'] ?? 0).toString(), Icons.people, Colors.purple),
_buildStatCard('المستخدمين', (stats['users'] ?? 0).toString(), Icons.people, Colors.purple, isDark),
],
);
}
}
Widget _buildStatCard(String title, String count, IconData icon, Color color) {
Widget _buildStatCard(String title, String count, IconData icon, Color color, bool isDark) {
return Expanded(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
borderRadius: BorderRadius.circular(12),
boxShadow: [
BoxShadow(color: Colors.black.withOpacity(0.05), blurRadius: 10, offset: const Offset(0, 4)),
],
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -200,14 +199,14 @@ class DashboardView extends GetView<DashboardController> {
Icon(icon, color: color, size: 28),
const SizedBox(height: 12),
Text(count, style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold)),
Text(title, style: const TextStyle(color: Colors.grey, fontSize: 12)),
Text(title, style: TextStyle(color: isDark ? Colors.white38 : Colors.grey, fontSize: 12)),
],
),
),
);
}
Widget _buildQuotaMeter(Map subscription) {
Widget _buildQuotaMeter(Map subscription, bool isDark) {
int limit = subscription['limit'] ?? 100;
int used = subscription['used'] ?? 0;
double progress = limit > 0 ? (used / limit) : 0;
@@ -215,9 +214,9 @@ class DashboardView extends GetView<DashboardController> {
return Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: Colors.white,
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFE2E8F0)),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -226,7 +225,7 @@ class DashboardView extends GetView<DashboardController> {
const SizedBox(height: 12),
LinearProgressIndicator(
value: progress,
backgroundColor: Colors.grey.shade200,
backgroundColor: isDark ? Colors.white10 : Colors.grey.shade200,
color: progress > 0.9 ? Colors.red : const Color(0xFF0F4C81),
minHeight: 8,
borderRadius: BorderRadius.circular(4),
@@ -236,7 +235,7 @@ class DashboardView extends GetView<DashboardController> {
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('$used فاتورة', style: const TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF0F4C81))),
Text('من $limit', style: const TextStyle(color: Colors.grey)),
Text('من $limit', style: TextStyle(color: isDark ? Colors.white38 : Colors.grey)),
],
)
],
@@ -244,9 +243,9 @@ class DashboardView extends GetView<DashboardController> {
);
}
Widget _buildRecentActivity() {
Widget _buildRecentActivity(bool isDark) {
if (controller.recentActivities.isEmpty) {
return const Center(child: Text('لا توجد نشاطات حديثة', style: TextStyle(color: Colors.grey)));
return Center(child: Text('لا توجد نشاطات حديثة', style: TextStyle(color: isDark ? Colors.white38 : Colors.grey)));
}
return ListView.builder(
@@ -258,14 +257,14 @@ class DashboardView extends GetView<DashboardController> {
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 0,
color: Colors.white,
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
shape: RoundedRectangleBorder(
side: const BorderSide(color: Color(0xFFE2E8F0)),
side: BorderSide(color: isDark ? Colors.white10 : Colors.grey.shade200),
borderRadius: BorderRadius.circular(12),
),
child: ListTile(
leading: CircleAvatar(
backgroundColor: const Color(0xFFF1F5F9),
backgroundColor: isDark ? Colors.white10 : const Color(0xFFF1F5F9),
child: Icon(_getActivityIcon(act['action']), color: const Color(0xFF64748B), size: 18),
),
title: Text(_formatAction(act['action'])),
@@ -282,6 +281,7 @@ class DashboardView extends GetView<DashboardController> {
IconData _getActivityIcon(String action) {
if (action.contains('approved')) return Icons.check_circle;
if (action.contains('extracted')) return Icons.auto_awesome;
if (action.contains('created')) return Icons.add_circle;
if (action.contains('deleted')) return Icons.delete;
if (action.contains('login')) return Icons.login;
@@ -302,7 +302,6 @@ class DashboardView extends GetView<DashboardController> {
}
String _timeAgo(String datetime) {
// A simple timeAgo formatter for demo purposes
try {
final dt = DateTime.parse(datetime);
final diff = DateTime.now().difference(dt);

View File

@@ -0,0 +1,67 @@
import 'package:get/get.dart';
import '../../../core/network/dio_client.dart';
import '../../../core/utils/app_snackbar.dart';
import '../../../core/utils/logger.dart';
class InvoiceDetailController extends GetxController {
var invoice = {}.obs;
var isLoading = true.obs;
String? invoiceId;
@override
void onInit() {
super.onInit();
if (Get.arguments != null) {
invoiceId = Get.arguments['id'];
if (invoiceId != null) {
fetchInvoiceDetails();
}
}
}
Future<void> fetchInvoiceDetails() async {
try {
isLoading.value = true;
final res = await DioClient().client.get('invoices/view', queryParameters: {'id': invoiceId});
if (res.data['success'] == true && res.data['data'] != null) {
invoice.value = res.data['data'];
} else {
AppSnackbar.showError('خطأ', 'لم يتم العثور على الفاتورة');
Get.back();
}
} catch (e) {
AppLogger.error('Failed to fetch invoice details', e);
AppSnackbar.showError('خطأ', 'فشل تحميل بيانات الفاتورة');
Get.back();
} finally {
isLoading.value = false;
}
}
Future<void> approveInvoice() async {
try {
final res = await DioClient().client.post('invoices/approve', data: {'invoice_id': invoiceId});
if (res.data['success'] == true) {
AppSnackbar.showSuccess('تم الاعتماد', 'تم اعتماد الفاتورة بنجاح');
// Refresh the detail view
fetchInvoiceDetails();
} else {
AppSnackbar.showError('خطأ', 'فشل اعتماد الفاتورة');
}
} catch (e) {
AppLogger.error('Failed to approve invoice', e);
AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع');
}
}
void viewOriginalImage() {
final imagePath = invoice['file_path'];
if (imagePath != null && imagePath.isNotEmpty) {
// In a real app, you would download/show the image. For now, just a snackbar or open URL.
AppSnackbar.showInfo('قريباً', 'سيتم عرض الصورة قريباً');
} else {
AppSnackbar.showWarning('عذراً', 'لا توجد صورة مرتبطة بهذه الفاتورة');
}
}
}

View File

@@ -0,0 +1,55 @@
import 'package:get/get.dart';
import '../../../core/network/dio_client.dart';
import '../../../core/utils/logger.dart';
class InvoicesController extends GetxController {
var invoices = <Map<String, dynamic>>[].obs;
var isLoading = true.obs;
var filterStatus = 'all'.obs;
var searchQuery = ''.obs;
var isSearching = false.obs;
@override
void onInit() {
super.onInit();
loadInvoices();
}
List<Map<String, dynamic>> get filteredInvoices {
var list = invoices.toList();
if (filterStatus.value != 'all') {
list = list.where((inv) => inv['status'] == filterStatus.value).toList();
}
if (searchQuery.value.isNotEmpty) {
final q = searchQuery.value.toLowerCase();
list = list.where((inv) {
final name = (inv['supplier_name'] ?? '').toString().toLowerCase();
final num = (inv['invoice_number'] ?? '').toString().toLowerCase();
return name.contains(q) || num.contains(q);
}).toList();
}
return list;
}
void toggleSearch() {
isSearching.value = !isSearching.value;
if (!isSearching.value) searchQuery.value = '';
}
Future<void> loadInvoices() async {
try {
isLoading.value = true;
final res = await DioClient().client.get('invoices');
if (res.data['success'] == true && res.data['data'] != null) {
invoices.value = List<Map<String, dynamic>>.from(res.data['data']);
}
} catch (e) {
AppLogger.error('Failed to load invoices', e);
} finally {
isLoading.value = false;
}
}
}

View File

@@ -0,0 +1,205 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/invoice_detail_controller.dart';
class InvoiceDetailView extends StatelessWidget {
const InvoiceDetailView({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.put(InvoiceDetailController());
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F7FA),
appBar: AppBar(
title: const Text('تفاصيل الفاتورة', style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81),
foregroundColor: Colors.white,
elevation: 0,
),
body: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81)));
}
if (controller.invoice.isEmpty) {
return const Center(child: Text('لم يتم العثور على الفاتورة'));
}
final inv = controller.invoice;
final status = inv['status'] ?? 'pending';
return SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Header Card
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
children: [
Text(
inv['supplier_name'] ?? inv['company_name'] ?? 'بدون اسم',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : const Color(0xFF0F172A),
),
textAlign: TextAlign.center,
),
const SizedBox(height: 8),
Text(
'فاتورة ضريبية',
style: TextStyle(color: isDark ? Colors.white70 : Colors.grey.shade600),
),
const SizedBox(height: 16),
Text(
'${double.tryParse(inv['grand_total']?.toString() ?? '0')?.toStringAsFixed(2) ?? '0.00'} JOD',
style: TextStyle(
fontSize: 32,
fontWeight: FontWeight.w900,
color: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF0F4C81),
fontFamily: 'monospace',
),
),
const SizedBox(height: 16),
_buildStatusChip(status),
],
),
),
const SizedBox(height: 16),
// Details Card
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('المعلومات الأساسية', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
_buildInfoRow('رقم الفاتورة', inv['invoice_number'] ?? '', isDark),
const Divider(height: 24),
_buildInfoRow('تاريخ الإصدار', inv['invoice_date'] ?? '', isDark),
const Divider(height: 24),
_buildInfoRow('الرقم الضريبي', inv['tax_number'] ?? '', isDark),
],
),
),
const SizedBox(height: 16),
// Amounts Card
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('تفاصيل المبلغ', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 16),
_buildInfoRow('المبلغ الخاضع للضريبة', '${inv['subtotal'] ?? '0.00'} JOD', isDark),
const Divider(height: 24),
_buildInfoRow('قيمة الضريبة', '${inv['tax_amount'] ?? '0.00'} JOD', isDark),
const Divider(height: 24),
_buildInfoRow('الإجمالي', '${inv['grand_total'] ?? '0.00'} JOD', isDark, isBold: true),
],
),
),
const SizedBox(height: 32),
// Action Buttons
if (status == 'extracted') ...[
SizedBox(
height: 52,
child: ElevatedButton.icon(
onPressed: () => controller.approveInvoice(),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF10B981),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
icon: const Icon(Icons.check_circle_outline),
label: const Text('اعتماد الفاتورة نهائياً', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
),
const SizedBox(height: 12),
],
SizedBox(
height: 52,
child: OutlinedButton.icon(
onPressed: () => controller.viewOriginalImage(),
style: OutlinedButton.styleFrom(
foregroundColor: const Color(0xFF0F4C81),
side: const BorderSide(color: Color(0xFF0F4C81)),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
icon: const Icon(Icons.image_outlined),
label: const Text('عرض صورة الفاتورة الأصلية', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
),
),
const SizedBox(height: 40),
],
),
);
}),
);
}
Widget _buildStatusChip(String status) {
Color color;
String text;
switch (status) {
case 'approved': color = const Color(0xFF10B981); text = '✓ معتمدة'; break;
case 'extracted': color = const Color(0xFF3B82F6); text = 'جاهزة للتدقيق'; break;
default: color = const Color(0xFFF59E0B); text = 'قيد المعالجة';
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: color.withOpacity(0.1),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: color.withOpacity(0.2)),
),
child: Text(text, style: TextStyle(color: color, fontWeight: FontWeight.bold, fontSize: 13)),
);
}
Widget _buildInfoRow(String label, String value, bool isDark, {bool isBold = false}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(fontSize: 14, color: isDark ? Colors.white70 : Colors.grey.shade600)),
Text(
value,
style: TextStyle(
fontWeight: isBold ? FontWeight.w900 : FontWeight.w600,
fontSize: isBold ? 18 : 15,
color: isDark ? Colors.white : Colors.black87,
fontFamily: value.contains(RegExp(r'[0-9]')) ? 'monospace' : null,
),
),
],
);
}
}

View File

@@ -0,0 +1,281 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/invoices_controller.dart';
class InvoicesListView extends GetView<InvoicesController> {
const InvoicesListView({super.key});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
children: [
// App Bar replacement
Container(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 12),
color: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81),
child: Row(
children: [
const SizedBox(width: 48),
Expanded(
child: Center(
child: Text(
'الفواتير',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
),
),
),
IconButton(
icon: const Icon(Icons.search_rounded, color: Colors.white),
onPressed: () => controller.toggleSearch(),
),
IconButton(
icon: const Icon(Icons.refresh_rounded, color: Colors.white),
onPressed: () => controller.loadInvoices(),
),
],
),
),
// Search Bar
Obx(() => AnimatedContainer(
duration: const Duration(milliseconds: 300),
height: controller.isSearching.value ? 64 : 0,
child: controller.isSearching.value
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: TextField(
onChanged: (v) => controller.searchQuery.value = v,
textDirection: TextDirection.rtl,
decoration: InputDecoration(
hintText: 'بحث بالرقم أو اسم المورد...',
prefixIcon: const Icon(Icons.search, size: 20),
filled: true,
fillColor: isDark ? Colors.white10 : Colors.white,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
),
),
)
: const SizedBox(),
)),
// Filter Tabs
Container(
height: 44,
margin: const EdgeInsets.symmetric(vertical: 8),
child: ListView(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
_buildFilterChip('الكل', 'all', controller, isDark),
_buildFilterChip('قيد المعالجة', 'uploaded', controller, isDark),
_buildFilterChip('جاهزة', 'extracted', controller, isDark),
_buildFilterChip('معتمدة', 'approved', controller, isDark),
],
),
),
// Invoice List
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return _buildShimmerList();
}
final invoices = controller.filteredInvoices;
if (invoices.isEmpty) {
return _buildEmptyState(isDark);
}
return RefreshIndicator(
onRefresh: () async => controller.loadInvoices(),
child: ListView.builder(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: invoices.length,
itemBuilder: (context, index) => _buildInvoiceCard(invoices[index], isDark),
),
);
}),
),
],
);
}
Widget _buildFilterChip(String label, String value, InvoicesController ctrl, bool isDark) {
return Obx(() {
final isSelected = ctrl.filterStatus.value == value;
return Padding(
padding: const EdgeInsets.only(left: 8),
child: ChoiceChip(
label: Text(label),
selected: isSelected,
onSelected: (_) => ctrl.filterStatus.value = value,
selectedColor: const Color(0xFF0F4C81),
backgroundColor: isDark ? Colors.white10 : Colors.white,
labelStyle: TextStyle(
color: isSelected ? Colors.white : (isDark ? Colors.white70 : Colors.black87),
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400,
fontSize: 13,
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)),
side: BorderSide(color: isSelected ? Colors.transparent : Colors.grey.shade300),
),
);
});
}
Widget _buildInvoiceCard(Map<String, dynamic> inv, bool isDark) {
final status = inv['status'] ?? '';
Color statusColor;
String statusText;
IconData statusIcon;
switch (status) {
case 'approved':
statusColor = const Color(0xFF10B981);
statusText = '✓ معتمدة';
statusIcon = Icons.check_circle;
break;
case 'extracted':
statusColor = const Color(0xFF3B82F6);
statusText = 'جاهزة للتدقيق';
statusIcon = Icons.pending_actions;
break;
default:
statusColor = const Color(0xFFF59E0B);
statusText = 'قيد المعالجة';
statusIcon = Icons.hourglass_empty;
}
return Card(
margin: const EdgeInsets.only(bottom: 12),
elevation: 0,
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(14),
side: BorderSide(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: () {
Get.toNamed('/invoice-detail', arguments: {'id': inv['id'].toString()});
},
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(12),
),
child: Icon(statusIcon, color: statusColor, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
inv['supplier_name'] ?? inv['company_name'] ?? 'بدون اسم',
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 15,
color: isDark ? Colors.white : const Color(0xFF0F172A),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 4),
Text(
'# ${inv['invoice_number'] ?? ''}${inv['invoice_date'] ?? ''}',
style: TextStyle(
fontSize: 12,
color: isDark ? Colors.white38 : const Color(0xFF94A3B8),
fontFamily: 'monospace',
),
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text(
'${double.tryParse(inv['grand_total']?.toString() ?? '0')?.toStringAsFixed(2) ?? '0.00'} JOD',
style: TextStyle(
fontWeight: FontWeight.w800,
fontSize: 14,
color: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF008080),
fontFamily: 'monospace',
),
),
const SizedBox(height: 6),
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(
statusText,
style: TextStyle(color: statusColor, fontSize: 11, fontWeight: FontWeight.w600),
),
),
],
),
],
),
),
),
);
}
Widget _buildEmptyState(bool isDark) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.receipt_long_rounded, size: 80, color: isDark ? Colors.white12 : Colors.grey.shade300),
const SizedBox(height: 16),
Text(
'لا توجد فواتير بعد',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white38 : Colors.grey,
),
),
const SizedBox(height: 8),
Text(
'ابدأ بتصوير فواتيرك من زر الماسح الضوئي',
style: TextStyle(fontSize: 13, color: isDark ? Colors.white24 : Colors.grey.shade400),
),
],
),
);
}
Widget _buildShimmerList() {
return ListView.builder(
padding: const EdgeInsets.all(16),
itemCount: 5,
itemBuilder: (context, index) => Container(
height: 80,
margin: const EdgeInsets.only(bottom: 12),
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(14),
),
),
);
}
}

View File

@@ -0,0 +1,219 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../dashboard/views/dashboard_view.dart';
import '../../invoices/views/invoices_list_view.dart';
import '../../notifications/views/notifications_view.dart';
import '../../settings/views/settings_view.dart';
import '../../../app/routes/app_pages.dart';
import '../../../core/services/upload_progress_service.dart';
import '../../../core/utils/app_snackbar.dart';
class MainShellView extends StatefulWidget {
const MainShellView({super.key});
@override
State<MainShellView> createState() => _MainShellViewState();
}
class _MainShellViewState extends State<MainShellView> {
int _currentIndex = 0;
final UploadProgressService _progressService = Get.put(UploadProgressService());
// 5 pages: Home(0), Invoices(1), [Scanner FAB](2), Notifications(3), Settings(4)
final List<Widget> _pages = const [
DashboardView(), // 0
InvoicesListView(), // 1
SizedBox(), // 2 - Scanner (FAB placeholder)
NotificationsView(), // 3
SettingsView(), // 4
];
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
final navBg = isDark ? const Color(0xFF1A1A2E) : Colors.white;
final activeColor = const Color(0xFF0F4C81);
final inactiveColor = isDark ? Colors.white38 : const Color(0xFF94A3B8);
return Scaffold(
backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F7FA),
body: Stack(
children: [
IndexedStack(
index: _getPageIndex(_currentIndex),
children: [
_pages[0], // Dashboard
_pages[1], // Invoices
_pages[3], // Notifications
_pages[4], // Settings
],
),
// Global Upload Progress Overlay
Obx(() => _progressService.isUploading.value
? Positioned(
bottom: 80,
left: 16,
right: 16,
child: _buildUploadOverlay(isDark),
)
: const SizedBox.shrink()),
],
),
floatingActionButton: _buildScannerFAB(),
floatingActionButtonLocation: FloatingActionButtonLocation.centerDocked,
bottomNavigationBar: BottomAppBar(
shape: const CircularNotchedRectangle(),
notchMargin: 8,
color: navBg,
elevation: 16,
child: SizedBox(
height: 60,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
// Left side (2 items)
_buildNavItem(0, Icons.home_rounded, 'الرئيسية', activeColor, inactiveColor),
_buildNavItem(1, Icons.receipt_long_rounded, 'الفواتير', activeColor, inactiveColor),
// Center gap for FAB
const SizedBox(width: 48),
// Right side (2 items)
_buildNavItem(3, Icons.notifications_rounded, 'الإشعارات', activeColor, inactiveColor),
_buildNavItem(4, Icons.settings_rounded, 'الإعدادات', activeColor, inactiveColor),
],
),
),
),
);
}
int _getPageIndex(int navIndex) {
// Map nav index to page index (skip scanner placeholder at 2)
if (navIndex <= 1) return navIndex;
if (navIndex == 3) return 2; // Notifications
if (navIndex == 4) return 3; // Settings
return 0;
}
Widget _buildNavItem(int index, IconData icon, String label, Color active, Color inactive) {
final isSelected = _currentIndex == index;
return Expanded(
child: InkWell(
onTap: () => setState(() => _currentIndex = index),
borderRadius: BorderRadius.circular(12),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 2),
decoration: isSelected ? BoxDecoration(
color: active.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
) : null,
child: Icon(icon, color: isSelected ? active : inactive, size: 22),
),
const SizedBox(height: 2),
Text(
label,
style: TextStyle(
fontSize: 10,
fontWeight: isSelected ? FontWeight.w700 : FontWeight.w400,
color: isSelected ? active : inactive,
),
),
],
),
),
);
}
Widget _buildScannerFAB() {
return Container(
width: 56,
height: 56,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: const LinearGradient(
colors: [Color(0xFFD4AF37), Color(0xFFF0D060)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
boxShadow: [
BoxShadow(
color: const Color(0xFFD4AF37).withOpacity(0.4),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: FloatingActionButton(
onPressed: () => Get.toNamed(AppRoutes.SCANNER),
backgroundColor: Colors.transparent,
elevation: 0,
heroTag: 'scanner_fab',
child: const Icon(Icons.document_scanner_rounded, color: Colors.white, size: 26),
),
);
}
Widget _buildUploadOverlay(bool isDark) {
final status = _progressService.status.value;
final progress = _progressService.progress.value;
Color accentColor = status == 'done' ? const Color(0xFF10B981) : const Color(0xFF0F4C81);
String statusText = status == 'uploading'
? 'جاري رفع الصور...'
: (status == 'processing' ? 'جاري استخراج البيانات...' : 'اكتملت المعالجة ✓');
return Card(
elevation: 8,
shadowColor: Colors.black26,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
status == 'done'
? const Icon(Icons.check_circle, color: Color(0xFF10B981), size: 24)
: const SizedBox(width: 24, height: 24, child: CircularProgressIndicator(strokeWidth: 2, color: Color(0xFF0F4C81))),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(statusText, style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 13)),
Text(
'${_progressService.companyName.value}${_progressService.currentImageIndex.value}/${_progressService.totalImages.value}',
style: TextStyle(fontSize: 11, color: isDark ? Colors.white38 : Colors.grey),
),
],
),
),
Text(
'${(progress * 100).toInt()}%',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: accentColor),
),
],
),
const SizedBox(height: 10),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: progress,
backgroundColor: isDark ? Colors.white10 : const Color(0xFFE2E8F0),
color: accentColor,
minHeight: 6,
),
),
],
),
),
);
}
}

View File

@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
class NotificationsView extends StatelessWidget {
const NotificationsView({super.key});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
children: [
// Top Bar
Container(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 12),
color: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81),
child: Row(
children: [
const SizedBox(width: 48),
Expanded(
child: Center(
child: Text(
'الإشعارات',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
),
),
),
IconButton(
icon: const Icon(Icons.done_all_rounded, color: Colors.white),
onPressed: () {},
tooltip: 'قراءة الكل',
),
],
),
),
// Notifications List
Expanded(
child: _buildEmptyState(isDark),
),
],
);
}
Widget _buildEmptyState(bool isDark) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: isDark ? Colors.white.withOpacity(0.05) : const Color(0xFFF1F5F9),
shape: BoxShape.circle,
),
child: Icon(
Icons.notifications_off_rounded,
size: 48,
color: isDark ? Colors.white12 : Colors.grey.shade300,
),
),
const SizedBox(height: 20),
Text(
'لا توجد إشعارات',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: isDark ? Colors.white38 : Colors.grey,
),
),
const SizedBox(height: 8),
Text(
'ستظهر هنا إشعارات معالجة الفواتير\nوتحديثات الاشتراك',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
color: isDark ? Colors.white24 : Colors.grey.shade400,
height: 1.5,
),
),
],
),
);
}
}

View File

@@ -4,6 +4,7 @@ import 'package:get/get.dart';
import 'package:firebase_messaging/firebase_messaging.dart';
import 'package:path_provider/path_provider.dart';
import 'package:path/path.dart' as path;
import '../../../core/services/upload_progress_service.dart';
import '../../../core/utils/logger.dart';
import '../../../core/utils/app_snackbar.dart';
import '../../../core/services/image_processing_service.dart';
@@ -22,6 +23,8 @@ class ScannerController extends GetxController {
var isBatchDone = false.obs;
final InvoiceUploadService _uploadService = InvoiceUploadService();
final UploadProgressService _progressService =
Get.find<UploadProgressService>();
@override
void onInit() {
@@ -33,12 +36,19 @@ class ScannerController extends GetxController {
void _initFcmListener() {
FirebaseMessaging.onMessage.listen((RemoteMessage message) {
final data = message.data;
if (data['type'] == 'batch_progress' && data['batch_id'] == currentBatchId.value) {
processedImagesCount.value = int.tryParse(data['processed'].toString()) ?? 0;
if (data['type'] == 'batch_progress' &&
data['batch_id'] == currentBatchId.value) {
processedImagesCount.value =
int.tryParse(data['processed'].toString()) ?? 0;
totalImagesCount.value = int.tryParse(data['total'].toString()) ?? 0;
// Update global progress service
_progressService.updateProcessingProgress(
processedImagesCount.value, totalImagesCount.value);
if (processedImagesCount.value >= totalImagesCount.value) {
isBatchDone.value = true;
isBatchDone.value = true;
_progressService.complete();
}
}
});
@@ -60,18 +70,17 @@ class ScannerController extends GetxController {
Future<void> addImage(String imagePath) async {
File originalFile = File(imagePath);
// Add to UI immediately so the user doesn't wait
capturedImages.add(originalFile);
int index = capturedImages.length - 1;
// Process in background without showing full-screen loader
ImageProcessingService.processInvoiceImage(originalFile).then((processedFile) {
ImageProcessingService.processInvoiceImage(originalFile)
.then((processedFile) {
if (processedFile != null && index < capturedImages.length) {
capturedImages[index] = processedFile;
AppLogger.print('Finished processing image in background. Replaced in batch.');
AppLogger.print('Finished processing image in background.');
}
}).catchError((e) {
AppLogger.error('Failed to process image in background', e);
AppLogger.error('Failed to process image in background', e);
});
}
@@ -90,89 +99,67 @@ class ScannerController extends GetxController {
try {
isProcessing.value = true;
uploadProgress.value = 0.0;
// Fetch a valid company ID dynamically to prevent 403 Forbidden
String companyId = fallbackCompanyId;
String companyName = 'شركة غير محددة';
if (companyId == 'mock_company_id_123' || companyId.isEmpty) {
final res = await DioClient().client.get('companies');
if (res.data['success'] == true && res.data['data'] != null && res.data['data'].isNotEmpty) {
companyId = res.data['data'][0]['id'];
AppLogger.print('Dynamically fetched company: $companyId');
if (companies.isNotEmpty) {
companyId = companies[0]['id'];
companyName = companies[0]['name'] ?? 'شركتي';
} else {
AppSnackbar.showError('خطأ', 'لا توجد شركات مسجلة في حسابك');
isProcessing.value = false;
return;
final res = await DioClient().client.get('companies');
if (res.data['success'] == true &&
res.data['data'] != null &&
res.data['data'].isNotEmpty) {
companyId = res.data['data'][0]['id'];
companyName = res.data['data'][0]['name'] ?? 'شركتي';
} else {
AppSnackbar.showError('خطأ', 'لا توجد شركات مسجلة في حسابك');
isProcessing.value = false;
return;
}
}
} else {
final comp = companies.firstWhereOrNull((c) => c['id'] == companyId);
if (comp != null) companyName = comp['name'] ?? 'شركتي';
}
AppLogger.print('Uploading batch of ${capturedImages.length} images to company $companyId...');
AppLogger.print(
'Uploading batch of ${capturedImages.length} images to company $companyId...');
// Start global progress
_progressService.startUpload(companyName, capturedImages.length);
final batchId = await _uploadService.uploadBatch(
companyId: companyId,
images: capturedImages,
onProgress: (current, total) {
uploadProgress.value = current / total;
_progressService.updateProgress(uploadProgress.value, current);
},
);
if (batchId != null) {
currentBatchId.value = batchId;
totalImagesCount.value = capturedImages.length;
processedImagesCount.value = 0;
// Clear scanner state and go back to dashboard
capturedImages.clear();
uploadProgress.value = 0.0;
Get.dialog(
AlertDialog(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
title: const Center(
child: Text('جاري المعالجة ⏳',
style: TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold, fontSize: 18)
)
),
content: Obx(() => Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text('يتم الآن تدقيق الفواتير عبر الذكاء الاصطناعي...',
textAlign: TextAlign.center,
style: TextStyle(fontFamily: 'El Messiri', fontSize: 14)
),
const SizedBox(height: 20),
if (!isBatchDone.value) ...[
LinearProgressIndicator(
value: totalImagesCount.value > 0 ? processedImagesCount.value / totalImagesCount.value : 0,
backgroundColor: Colors.grey[200],
valueColor: const AlwaysStoppedAnimation<Color>(Color(0xFF0F4C81)),
),
const SizedBox(height: 10),
Text('${processedImagesCount.value} من ${totalImagesCount.value}',
style: const TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold)
),
] else ...[
const Icon(Icons.check_circle, color: Colors.green, size: 50),
const SizedBox(height: 10),
const Text('اكتملت المعالجة بنجاح!',
style: TextStyle(fontFamily: 'El Messiri', color: Colors.green, fontWeight: FontWeight.bold)
),
],
],
)),
actions: [
TextButton(
onPressed: () {
Get.back(); // close dialog
Get.back(); // go back to dashboard
},
child: const Text('إغلاق', style: TextStyle(fontFamily: 'El Messiri', fontWeight: FontWeight.bold)),
)
],
),
barrierDismissible: false,
);
isProcessing.value = false;
_progressService.startProcessing();
Get.back(); // Go back to dashboard, progress will show in overlay
AppSnackbar.showSuccess(
'تم البدء', 'تم رفع الصور بنجاح، جاري استخراج البيانات في الخلفية');
} else {
_progressService.fail();
AppSnackbar.showError('خطأ', 'فشل رفع الفواتير، يرجى المحاولة لاحقاً');
}
} catch (e) {
_progressService.fail();
AppLogger.error('Failed to upload batch', e);
AppSnackbar.showError('خطأ', 'حدث خطأ غير متوقع أثناء الرفع');
} finally {

View File

@@ -0,0 +1,51 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../core/storage/secure_storage.dart';
import '../../../app/routes/app_pages.dart';
class SettingsController extends GetxController {
final SecureStorage _storage = SecureStorage();
var isDarkMode = false.obs;
var pushEnabled = true.obs;
var userName = ''.obs;
var userPhone = ''.obs;
var userRole = ''.obs;
String get roleName {
switch (userRole.value) {
case 'super_admin': return 'مدير النظام';
case 'admin': return 'مدير مكتب';
case 'accountant': return 'محاسب';
case 'viewer': return 'مشاهد';
default: return 'مستخدم';
}
}
@override
void onInit() {
super.onInit();
_loadUserData();
isDarkMode.value = Get.isDarkMode;
}
Future<void> _loadUserData() async {
userName.value = await _storage.read('user_name') ?? '';
userPhone.value = await _storage.read('user_phone') ?? '';
userRole.value = await _storage.read('user_role') ?? '';
}
void toggleTheme() {
isDarkMode.value = !isDarkMode.value;
Get.changeThemeMode(isDarkMode.value ? ThemeMode.dark : ThemeMode.light);
}
void togglePush() {
pushEnabled.value = !pushEnabled.value;
}
Future<void> logout() async {
await _storage.clearAll();
Get.offAllNamed(AppRoutes.PHONE_INPUT);
}
}

View File

@@ -0,0 +1,303 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/settings_controller.dart';
import '../../../app/routes/app_pages.dart';
class SettingsView extends GetView<SettingsController> {
const SettingsView({super.key});
@override
Widget build(BuildContext context) {
final isDark = Theme.of(context).brightness == Brightness.dark;
return Column(
children: [
// Custom Top Bar
Container(
padding: EdgeInsets.only(top: MediaQuery.of(context).padding.top, left: 8, right: 8, bottom: 12),
color: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81),
child: Row(
children: [
const SizedBox(width: 48),
Expanded(
child: Center(
child: Text(
'الإعدادات',
style: TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
),
),
),
const SizedBox(width: 48),
],
),
),
Expanded(
child: Obx(() => ListView(
padding: const EdgeInsets.all(16),
children: [
_buildProfileCard(isDark),
const SizedBox(height: 24),
_buildSectionTitle('المظهر', Icons.palette_rounded, isDark),
const SizedBox(height: 8),
_buildSettingsCard(isDark, [
_buildSwitchTile(
icon: Icons.dark_mode_rounded,
title: 'الوضع الداكن',
subtitle: 'تفعيل المظهر الداكن للتطبيق',
value: controller.isDarkMode.value,
onChanged: (v) => controller.toggleTheme(),
isDark: isDark,
),
]),
const SizedBox(height: 20),
_buildSectionTitle('الإشعارات', Icons.notifications_rounded, isDark),
const SizedBox(height: 8),
_buildSettingsCard(isDark, [
_buildSwitchTile(
icon: Icons.notifications_active_rounded,
title: 'إشعارات الدفع',
subtitle: 'استلام إشعارات عند اكتمال المعالجة',
value: controller.pushEnabled.value,
onChanged: (v) => controller.togglePush(),
isDark: isDark,
),
]),
const SizedBox(height: 20),
_buildSectionTitle('حول التطبيق', Icons.info_rounded, isDark),
const SizedBox(height: 8),
_buildSettingsCard(isDark, [
_buildInfoTile(
icon: Icons.verified_rounded,
title: 'الإصدار',
trailing: '1.0.0',
isDark: isDark,
),
const Divider(height: 1),
_buildInfoTile(
icon: Icons.diamond_rounded,
title: 'الاشتراكات والباقات',
trailing: 'ترقية →',
isDark: isDark,
onTap: () => Get.toNamed(AppRoutes.SUBSCRIPTION),
),
const Divider(height: 1),
_buildInfoTile(
icon: Icons.support_agent_rounded,
title: 'الدعم الفني',
trailing: 'support@musadaq.jo',
isDark: isDark,
),
const Divider(height: 1),
_buildInfoTile(
icon: Icons.description_rounded,
title: 'سياسة الخصوصية',
trailing: '',
isDark: isDark,
onTap: () {},
),
]),
const SizedBox(height: 32),
_buildLogoutButton(),
const SizedBox(height: 16),
Center(
child: TextButton(
onPressed: () => _confirmDeleteAccount(context),
child: const Text(
'حذف الحساب',
style: TextStyle(color: Colors.red, fontSize: 13, decoration: TextDecoration.underline),
),
),
),
const SizedBox(height: 40),
],
)),
),
],
);
}
Widget _buildProfileCard(bool isDark) {
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F4C81), Color(0xFF1A6BB5)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
),
child: Obx(() => Row(
children: [
Container(
width: 56,
height: 56,
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.2),
borderRadius: BorderRadius.circular(14),
),
child: Center(
child: Text(
(controller.userName.value.isNotEmpty ? controller.userName.value[0] : 'م').toUpperCase(),
style: const TextStyle(color: Colors.white, fontSize: 24, fontWeight: FontWeight.bold),
),
),
),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
controller.userName.value.isNotEmpty ? controller.userName.value : 'مستخدم مُصادَق',
style: const TextStyle(color: Colors.white, fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 4),
Text(
controller.userPhone.value,
style: TextStyle(color: Colors.white.withOpacity(0.7), fontSize: 13, fontFamily: 'monospace'),
),
],
),
),
Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: const Color(0xFFD4AF37).withOpacity(0.2),
borderRadius: BorderRadius.circular(8),
border: Border.all(color: const Color(0xFFD4AF37).withOpacity(0.5)),
),
child: Text(
controller.roleName,
style: const TextStyle(color: Color(0xFFF0D060), fontSize: 11, fontWeight: FontWeight.w600),
),
),
],
)),
);
}
Widget _buildSectionTitle(String title, IconData icon, bool isDark) {
return Row(
children: [
Icon(icon, size: 18, color: const Color(0xFF0F4C81)),
const SizedBox(width: 8),
Text(title, style: TextStyle(fontSize: 15, fontWeight: FontWeight.w700, color: isDark ? Colors.white70 : const Color(0xFF0F4C81))),
],
);
}
Widget _buildSettingsCard(bool isDark, List<Widget> children) {
return Container(
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
borderRadius: BorderRadius.circular(14),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(children: children),
);
}
Widget _buildSwitchTile({
required IconData icon,
required String title,
required String subtitle,
required bool value,
required ValueChanged<bool> onChanged,
required bool isDark,
}) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF0F4C81).withOpacity(0.08),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: const Color(0xFF0F4C81), size: 20),
),
title: Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: isDark ? Colors.white : Colors.black87)),
subtitle: Text(subtitle, style: TextStyle(fontSize: 12, color: isDark ? Colors.white38 : Colors.grey)),
trailing: Switch.adaptive(
value: value,
onChanged: onChanged,
activeColor: const Color(0xFF0F4C81),
),
);
}
Widget _buildInfoTile({
required IconData icon,
required String title,
required String trailing,
required bool isDark,
VoidCallback? onTap,
}) {
return ListTile(
onTap: onTap,
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 2),
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: const Color(0xFF0F4C81).withOpacity(0.08),
borderRadius: BorderRadius.circular(10),
),
child: Icon(icon, color: const Color(0xFF0F4C81), size: 20),
),
title: Text(title, style: TextStyle(fontWeight: FontWeight.w600, color: isDark ? Colors.white : Colors.black87)),
trailing: Text(trailing, style: TextStyle(fontSize: 13, color: isDark ? Colors.white38 : Colors.grey)),
);
}
Widget _buildLogoutButton() {
return SizedBox(
width: double.infinity,
height: 52,
child: ElevatedButton.icon(
onPressed: () => _confirmLogout(),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFFEE2E2),
foregroundColor: const Color(0xFFDC2626),
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
),
icon: const Icon(Icons.logout_rounded),
label: const Text('تسجيل الخروج', style: TextStyle(fontWeight: FontWeight.w700, fontSize: 16)),
),
);
}
void _confirmLogout() {
Get.defaultDialog(
title: 'تسجيل الخروج',
middleText: 'هل أنت متأكد من رغبتك في تسجيل الخروج؟',
textConfirm: 'خروج',
textCancel: 'إلغاء',
confirmTextColor: Colors.white,
buttonColor: const Color(0xFFDC2626),
onConfirm: () => controller.logout(),
titleStyle: const TextStyle(fontWeight: FontWeight.bold),
radius: 14,
);
}
void _confirmDeleteAccount(BuildContext context) {
Get.defaultDialog(
title: '⚠️ حذف الحساب',
middleText: 'سيتم حذف جميع بياناتك نهائياً. هذا الإجراء لا يمكن التراجع عنه.',
textConfirm: 'حذف نهائي',
textCancel: 'إلغاء',
confirmTextColor: Colors.white,
buttonColor: const Color(0xFFDC2626),
onConfirm: () {
Get.back();
Get.snackbar('قريباً', 'سيتم تفعيل هذه الميزة قريباً');
},
titleStyle: const TextStyle(fontWeight: FontWeight.bold),
radius: 14,
);
}
}

View File

@@ -0,0 +1,69 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../../../core/network/dio_client.dart';
import '../../../core/utils/app_snackbar.dart';
import '../../../core/utils/logger.dart';
import 'subscription_controller.dart';
class PaymentReceiptController extends GetxController {
var payment = {}.obs;
var isUploading = false.obs;
final referenceController = TextEditingController();
@override
void onInit() {
super.onInit();
if (Get.arguments != null) {
payment.value = Get.arguments;
}
}
@override
void onClose() {
referenceController.dispose();
super.onClose();
}
Future<void> submitReference() async {
final ref = referenceController.text.trim();
if (ref.isEmpty) {
AppSnackbar.showWarning('تنبيه', 'الرجاء إدخال رقم المرجع أولاً');
return;
}
try {
isUploading.value = true;
String paymentId = payment['id'];
final res = await DioClient().client.post(
'payments/verify-reference',
data: {
'payment_id': paymentId,
'bank_reference': ref,
},
);
if (res.data['success'] == true) {
final data = res.data['data'];
// Refresh subscription info
if (Get.isRegistered<SubscriptionController>()) {
Get.find<SubscriptionController>().loadAll();
}
Get.back(); // close the screen
if (data['status'] == 'approved') {
AppSnackbar.showSuccess('مبروك!', data['message']);
} else {
AppSnackbar.showInfo('تم الحفظ', data['message']);
}
}
} catch (e) {
AppLogger.error('Failed to submit reference', e);
AppSnackbar.showError('خطأ', 'فشل التحقق من رقم المرجع. تأكد من صحته أو حاول لاحقاً.');
} finally {
isUploading.value = false;
}
}
}

View File

@@ -0,0 +1,82 @@
import 'package:get/get.dart';
import '../../../core/network/dio_client.dart';
import '../../../core/utils/logger.dart';
class SubscriptionController extends GetxController {
var plans = <Map<String, dynamic>>[].obs;
var currentSubscription = Rxn<Map<String, dynamic>>();
var myPayments = <Map<String, dynamic>>[].obs;
var isLoading = true.obs;
var isCreatingPayment = false.obs;
var activePaymentRequest = Rxn<Map<String, dynamic>>();
@override
void onInit() {
super.onInit();
loadAll();
}
Future<void> loadAll() async {
isLoading.value = true;
await Future.wait([
loadPlans(),
loadCurrentSubscription(),
loadMyPayments(),
]);
isLoading.value = false;
}
Future<void> loadPlans() async {
try {
final res = await DioClient().client.get('subscriptions/plans');
if (res.data['success'] == true && res.data['data'] != null) {
plans.value = List<Map<String, dynamic>>.from(res.data['data']);
}
} catch (e) {
AppLogger.error('Failed to load plans', e);
}
}
Future<void> loadCurrentSubscription() async {
try {
final res = await DioClient().client.get('subscriptions/current');
if (res.data['success'] == true && res.data['data'] != null) {
currentSubscription.value = Map<String, dynamic>.from(res.data['data']);
}
} catch (e) {
AppLogger.error('Failed to load subscription', e);
}
}
Future<void> loadMyPayments() async {
try {
final res = await DioClient().client.get('payments/my-requests');
if (res.data['success'] == true && res.data['data'] != null) {
myPayments.value = List<Map<String, dynamic>>.from(res.data['data']);
// Check for active pending payment
final pending = myPayments.firstWhereOrNull((p) => p['status'] == 'pending');
activePaymentRequest.value = pending;
}
} catch (e) {
AppLogger.error('Failed to load my payments', e);
}
}
Future<Map<String, dynamic>?> createPaymentRequest(String planId) async {
try {
isCreatingPayment.value = true;
final res = await DioClient().client.post('payments/create', data: {'plan_id': planId});
if (res.data['success'] == true && res.data['data'] != null) {
final result = Map<String, dynamic>.from(res.data['data']);
activePaymentRequest.value = result;
await loadMyPayments();
return result;
}
} catch (e) {
AppLogger.error('Failed to create payment', e);
} finally {
isCreatingPayment.value = false;
}
return null;
}
}

View File

@@ -0,0 +1,144 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/payment_receipt_controller.dart';
class PaymentReceiptView extends StatelessWidget {
const PaymentReceiptView({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.put(PaymentReceiptController());
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F7FA),
appBar: AppBar(
title: const Text('إتمام الدفع', style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81),
foregroundColor: Colors.white,
elevation: 0,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Payment Info Card
Obx(() => Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
children: [
const Text('تفاصيل التحويل المطلوب', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
const SizedBox(height: 16),
_buildInfoRow('الاسم المستعار (CliQ)', controller.payment['cliq_alias'] ?? '', isDark, isHighlight: true),
const Divider(height: 24),
_buildInfoRow('المبلغ المطلوب', '${controller.payment['amount_jod'] ?? 0} JOD', isDark),
],
),
)),
const SizedBox(height: 24),
Text('الخطوة التالية:', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: isDark ? Colors.white : Colors.black87)),
const SizedBox(height: 8),
Text(
'قم بتحويل المبلغ عبر تطبيق البنك الخاص بك إلى الاسم المستعار المذكور أعلاه (CliQ). بعد إتمام الحوالة بنجاح، ستصلك رسالة أو إشعار من البنك يحتوي على "رقم المرجع" للعملية. انسخه والصقه هنا.',
style: TextStyle(fontSize: 13, color: isDark ? Colors.white70 : Colors.grey.shade700, height: 1.5),
),
const SizedBox(height: 24),
// Reference Number Input Area
Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('رقم المرجع (Reference Number)', style: TextStyle(fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
TextField(
controller: controller.referenceController,
decoration: InputDecoration(
hintText: 'مثال: 1234567890',
hintStyle: TextStyle(color: isDark ? Colors.white38 : Colors.grey),
filled: true,
fillColor: isDark ? const Color(0xFF1A1A2E) : const Color(0xFFF1F5F9),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide.none,
),
contentPadding: const EdgeInsets.symmetric(horizontal: 16, vertical: 16),
),
keyboardType: TextInputType.text,
style: TextStyle(
fontFamily: 'monospace',
color: isDark ? Colors.white : Colors.black87,
),
),
],
),
),
const SizedBox(height: 32),
// Submit Button
SizedBox(
height: 52,
child: Obx(() => ElevatedButton.icon(
onPressed: controller.isUploading.value
? null
: () => controller.submitReference(),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFF0F4C81),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
disabledBackgroundColor: isDark ? Colors.white10 : Colors.grey.shade300,
),
icon: controller.isUploading.value
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Icon(Icons.check_circle_outline),
label: Text(
controller.isUploading.value ? 'جاري التحقق...' : 'تأكيد الدفع',
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
),
)),
),
],
),
),
);
}
Widget _buildInfoRow(String label, String value, bool isDark, {bool isHighlight = false}) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(label, style: TextStyle(fontSize: 13, color: isDark ? Colors.white70 : Colors.grey.shade600)),
Container(
padding: EdgeInsets.symmetric(horizontal: isHighlight ? 12 : 0, vertical: isHighlight ? 6 : 0),
decoration: isHighlight ? BoxDecoration(
color: const Color(0xFF0F4C81).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
) : null,
child: Text(
value,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
color: isHighlight ? const Color(0xFF0F4C81) : (isDark ? Colors.white : Colors.black87),
fontFamily: 'monospace',
),
),
),
],
);
}
}

View File

@@ -0,0 +1,370 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import '../controllers/subscription_controller.dart';
import '../../../core/utils/app_snackbar.dart';
class SubscriptionView extends StatelessWidget {
const SubscriptionView({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.put(SubscriptionController());
final isDark = Theme.of(context).brightness == Brightness.dark;
return Scaffold(
backgroundColor: isDark ? const Color(0xFF121212) : const Color(0xFFF5F7FA),
appBar: AppBar(
title: const Text('الاشتراكات', style: TextStyle(fontWeight: FontWeight.bold)),
backgroundColor: isDark ? const Color(0xFF1E1E2E) : const Color(0xFF0F4C81),
foregroundColor: Colors.white,
elevation: 0,
),
body: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator(color: Color(0xFF0F4C81)));
}
return RefreshIndicator(
onRefresh: () async => controller.loadAll(),
child: SingleChildScrollView(
physics: const AlwaysScrollableScrollPhysics(),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Current Subscription Status
if (controller.currentSubscription.value != null)
_buildCurrentPlan(controller.currentSubscription.value!, isDark),
const SizedBox(height: 24),
// Active Payment Request Banner
if (controller.activePaymentRequest.value != null)
_buildActivePaymentBanner(controller.activePaymentRequest.value!, isDark),
// Plans Header
Row(
children: [
const Icon(Icons.diamond_rounded, color: Color(0xFFD4AF37), size: 22),
const SizedBox(width: 8),
Text(
'اختر باقتك',
style: TextStyle(
fontSize: 20,
fontWeight: FontWeight.bold,
color: isDark ? Colors.white : const Color(0xFF0F172A),
),
),
],
),
const SizedBox(height: 4),
Text(
'ادفع عبر CliQ — بدون عمولة!',
style: TextStyle(fontSize: 13, color: isDark ? Colors.white38 : Colors.grey),
),
const SizedBox(height: 16),
// Plans Grid
...controller.plans.map((plan) => _buildPlanCard(plan, controller, isDark)),
const SizedBox(height: 24),
// Payment History
if (controller.myPayments.isNotEmpty) ...[
const Text('سجل المدفوعات', style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold)),
const SizedBox(height: 12),
...controller.myPayments.map((p) => _buildPaymentHistoryItem(p, isDark)),
],
const SizedBox(height: 40),
],
),
),
);
}),
);
}
Widget _buildCurrentPlan(Map<String, dynamic> sub, bool isDark) {
final planName = sub['plan_name'] ?? sub['plan_id'] ?? 'مجانية';
final daysLeft = sub['days_remaining'] ?? 0;
final used = sub['invoices_used'] ?? 0;
final limit = sub['invoices_limit'] ?? 0;
return Container(
padding: const EdgeInsets.all(20),
decoration: BoxDecoration(
gradient: const LinearGradient(
colors: [Color(0xFF0F4C81), Color(0xFF1A6BB5)],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
borderRadius: BorderRadius.circular(16),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.verified, color: Color(0xFFD4AF37), size: 24),
const SizedBox(width: 8),
Text(
'باقتك الحالية: $planName',
style: const TextStyle(color: Colors.white, fontSize: 16, fontWeight: FontWeight.bold),
),
],
),
const SizedBox(height: 12),
Row(
children: [
_buildSubInfoChip(Icons.timer_outlined, '$daysLeft يوم متبقي'),
const SizedBox(width: 12),
_buildSubInfoChip(Icons.receipt_long, '$used/$limit فاتورة'),
],
),
],
),
);
}
Widget _buildSubInfoChip(IconData icon, String text) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: Colors.white.withOpacity(0.15),
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, color: Colors.white70, size: 16),
const SizedBox(width: 6),
Text(text, style: const TextStyle(color: Colors.white, fontSize: 12)),
],
),
);
}
Widget _buildActivePaymentBanner(Map<String, dynamic> payment, bool isDark) {
return Container(
margin: const EdgeInsets.only(bottom: 16),
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: const Color(0xFFFFF7ED),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: const Color(0xFFFED7AA)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Row(
children: [
Icon(Icons.pending_actions, color: Color(0xFFF59E0B), size: 20),
SizedBox(width: 8),
Text('لديك طلب دفع قائم', style: TextStyle(fontWeight: FontWeight.bold, color: Color(0xFF92400E))),
],
),
const SizedBox(height: 8),
Text('رقم المرجع: ${payment['reference_number']}', style: const TextStyle(fontFamily: 'monospace', fontSize: 14, fontWeight: FontWeight.bold)),
Text('المبلغ: ${payment['amount_jod']} JOD', style: const TextStyle(fontSize: 13)),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
icon: const Icon(Icons.upload_file, size: 18),
label: const Text('رفع وصل الدفع'),
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFF59E0B),
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
onPressed: () {
// Navigate to receipt upload screen
Get.toNamed('/payment-receipt', arguments: payment);
},
),
),
],
),
);
}
Widget _buildPlanCard(Map<String, dynamic> plan, SubscriptionController ctrl, bool isDark) {
final isPopular = plan['is_popular'] == true;
final price = (plan['price_jod'] ?? 0).toString();
final features = (plan['features'] as List?)?.cast<String>() ?? [];
final nameAr = plan['name_ar'] ?? plan['name_en'] ?? 'باقة';
return Container(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
borderRadius: BorderRadius.circular(16),
border: Border.all(
color: isPopular ? const Color(0xFFD4AF37) : (isDark ? Colors.white10 : Colors.grey.shade200),
width: isPopular ? 2 : 1,
),
),
child: Column(
children: [
// Popular Badge
if (isPopular)
Container(
width: double.infinity,
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: const BoxDecoration(
color: Color(0xFFD4AF37),
borderRadius: BorderRadius.only(topLeft: Radius.circular(14), topRight: Radius.circular(14)),
),
child: const Center(
child: Text('⭐ الأكثر شعبية', style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold, fontSize: 12)),
),
),
Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(nameAr, style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold, color: isDark ? Colors.white : const Color(0xFF0F172A))),
RichText(
text: TextSpan(
children: [
TextSpan(text: price, style: TextStyle(fontSize: 28, fontWeight: FontWeight.w900, color: isDark ? const Color(0xFF5EEAD4) : const Color(0xFF0F4C81))),
TextSpan(text: ' JOD', style: TextStyle(fontSize: 12, color: isDark ? Colors.white38 : Colors.grey)),
],
),
),
],
),
const SizedBox(height: 8),
Text(
plan['description_ar'] ?? '',
style: TextStyle(fontSize: 13, color: isDark ? Colors.white38 : Colors.grey),
),
const SizedBox(height: 12),
// Features
...features.map((f) => Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
const Icon(Icons.check_circle, color: Color(0xFF10B981), size: 16),
const SizedBox(width: 8),
Expanded(child: Text(f, style: TextStyle(fontSize: 13, color: isDark ? Colors.white70 : Colors.black87))),
],
),
)),
const SizedBox(height: 12),
// Stats Row
Row(
children: [
_buildPlanStat(Icons.business, '${plan['max_companies'] ?? 0} شركات'),
const SizedBox(width: 8),
_buildPlanStat(Icons.receipt_long, '${plan['max_invoices_month'] ?? 0} فاتورة/شهر'),
const SizedBox(width: 8),
_buildPlanStat(Icons.people, '${plan['max_users'] ?? 0} مستخدمين'),
],
),
const SizedBox(height: 16),
// Upgrade Button
SizedBox(
width: double.infinity,
height: 48,
child: Obx(() => ElevatedButton(
onPressed: ctrl.isCreatingPayment.value ? null : () async {
final result = await ctrl.createPaymentRequest(plan['id'].toString());
if (result != null) {
AppSnackbar.showSuccess('تم إنشاء طلب الدفع', 'قم بالتحويل عبر CliQ ثم ارفع وصل الدفع');
} else {
AppSnackbar.showError('خطأ', 'فشل إنشاء طلب الدفع');
}
},
style: ElevatedButton.styleFrom(
backgroundColor: isPopular ? const Color(0xFFD4AF37) : const Color(0xFF0F4C81),
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
),
child: ctrl.isCreatingPayment.value
? const SizedBox(width: 20, height: 20, child: CircularProgressIndicator(strokeWidth: 2, color: Colors.white))
: const Text('ترقية الآن', style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
)),
),
],
),
),
],
),
);
}
Widget _buildPlanStat(IconData icon, String text) {
return Expanded(
child: Container(
padding: const EdgeInsets.symmetric(vertical: 6),
decoration: BoxDecoration(
color: const Color(0xFF0F4C81).withOpacity(0.05),
borderRadius: BorderRadius.circular(8),
),
child: Column(
children: [
Icon(icon, size: 16, color: const Color(0xFF0F4C81)),
const SizedBox(height: 2),
Text(text, style: const TextStyle(fontSize: 10, fontWeight: FontWeight.w600), textAlign: TextAlign.center),
],
),
),
);
}
Widget _buildPaymentHistoryItem(Map<String, dynamic> payment, bool isDark) {
final status = payment['status'] ?? '';
Color statusColor;
String statusText;
switch (status) {
case 'approved': statusColor = const Color(0xFF10B981); statusText = 'تم الاعتماد'; break;
case 'pending': statusColor = const Color(0xFFF59E0B); statusText = 'قيد الانتظار'; break;
case 'uploaded': statusColor = const Color(0xFF3B82F6); statusText = 'تحت المراجعة'; break;
case 'rejected': statusColor = const Color(0xFFEF4444); statusText = 'مرفوض'; break;
default: statusColor = Colors.grey; statusText = status;
}
return Card(
margin: const EdgeInsets.only(bottom: 8),
elevation: 0,
color: isDark ? const Color(0xFF1E1E2E) : Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
side: BorderSide(color: isDark ? Colors.white10 : Colors.grey.shade200),
),
child: ListTile(
leading: Container(
width: 40,
height: 40,
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(10),
),
child: Icon(Icons.payment_rounded, color: statusColor, size: 20),
),
title: Text(payment['plan_name'] ?? 'باقة', style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)),
subtitle: Text('${payment['amount_jod']} JOD • ${payment['reference_number']}', style: const TextStyle(fontSize: 11, fontFamily: 'monospace')),
trailing: Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: statusColor.withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Text(statusText, style: TextStyle(color: statusColor, fontSize: 11, fontWeight: FontWeight.w600)),
),
),
);
}
}