Compare commits
7 Commits
bbde34fae4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98d184d901 | ||
|
|
0c1d3b8cb2 | ||
|
|
d419e4ed60 | ||
|
|
8dac57a3a8 | ||
|
|
e85ac4bf8c | ||
|
|
d606156a6d | ||
|
|
b9fb9455e7 |
File diff suppressed because one or more lines are too long
@@ -5,6 +5,13 @@
|
||||
<uses-permission android:name="android.permission.CAMERA" />
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.INTERNET"/>
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
|
||||
android:maxSdkVersion="32" />
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
|
||||
<uses-permission android:name="android.permission.READ_MEDIA_DOCUMENTS" />
|
||||
|
||||
|
||||
|
||||
|
||||
<application
|
||||
android:label="kent_logistics_app"
|
||||
|
||||
@@ -1,5 +1,37 @@
|
||||
package com.example.kent_logistics_app
|
||||
|
||||
import android.media.MediaScannerConnection
|
||||
import io.flutter.embedding.android.FlutterActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.MethodChannel
|
||||
|
||||
class MainActivity : FlutterActivity()
|
||||
class MainActivity : FlutterActivity() {
|
||||
|
||||
private val CHANNEL = "media_scanner"
|
||||
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
|
||||
MethodChannel(
|
||||
flutterEngine.dartExecutor.binaryMessenger,
|
||||
CHANNEL
|
||||
).setMethodCallHandler { call, result ->
|
||||
if (call.method == "scanFile") {
|
||||
val path = call.argument<String>("path")
|
||||
|
||||
if (path != null) {
|
||||
MediaScannerConnection.scanFile(
|
||||
applicationContext,
|
||||
arrayOf(path),
|
||||
null,
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
result.success(null)
|
||||
} else {
|
||||
result.notImplemented()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,8 @@
|
||||
// class ApiConfig {
|
||||
// static const String baseUrl = "http://103.248.30.24:3030/api";
|
||||
// static const String fileBaseUrl = "http://103.248.30.24:3030/";
|
||||
// }
|
||||
class ApiConfig {
|
||||
static const String baseUrl = "http://10.11.236.74:8000/api";
|
||||
static const String baseUrl = "http://10.119.0.74:8000/api";
|
||||
static const String fileBaseUrl = "http://10.119.0.74:8000/";
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ class AppConfig {
|
||||
static const String logoUrlEmulator = "http://10.0.2.2:8000/images/kent_logo2.png";
|
||||
|
||||
// For Physical Device (Replace with your actual PC local IP)
|
||||
static const String logoUrlDevice = "http://10.11.236.74:8000/images/kent_logo2.png";
|
||||
static const String logoUrlDevice = "http://103.248.30.24:8000/images/kent_logo2.png";
|
||||
|
||||
// Which one to use?
|
||||
static const String logoUrl = logoUrlDevice; // CHANGE THIS WHEN TESTING ON REAL DEVICE
|
||||
|
||||
@@ -125,23 +125,27 @@ class AuthProvider extends ChangeNotifier {
|
||||
return res['success'] == true;
|
||||
}
|
||||
|
||||
// --------------------- REFRESH TOKEN --------------------------
|
||||
Future<bool> tryRefreshToken(BuildContext context) async {
|
||||
Future<void> forceLogout(BuildContext context) async {
|
||||
debugPrint('🚪🚪🚪 [AUTH] Force logout triggered');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final oldToken = prefs.getString('token');
|
||||
|
||||
if (oldToken == null) return false;
|
||||
await prefs.remove('token');
|
||||
await prefs.remove('user');
|
||||
await prefs.remove('saved_login_id');
|
||||
await prefs.remove('saved_password');
|
||||
|
||||
_service ??= AuthService(context);
|
||||
_token = null;
|
||||
_user = null;
|
||||
|
||||
final res = await _service!.refreshToken(oldToken);
|
||||
notifyListeners();
|
||||
|
||||
if (res['success'] == true && res['token'] != null) {
|
||||
await prefs.setString('token', res['token']);
|
||||
_token = res['token'];
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
// Redirect to login & clear navigation stack
|
||||
Navigator.of(context).pushNamedAndRemoveUntil(
|
||||
'/login',
|
||||
(route) => false,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
|
||||
@@ -176,17 +176,17 @@ class InstallmentCard extends StatelessWidget {
|
||||
Divider(color: Colors.grey.shade300, thickness: 1),
|
||||
SizedBox(height: isTablet ? 10 : 6),
|
||||
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
"Installment #${inst['id'] ?? ''}",
|
||||
style: TextStyle(
|
||||
fontSize: isTablet ? 15 : 13,
|
||||
color: Colors.grey.shade600,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Align(
|
||||
// alignment: Alignment.centerRight,
|
||||
// child: Text(
|
||||
// "Installment #${inst['id'] ?? ''}",
|
||||
// style: TextStyle(
|
||||
// fontSize: isTablet ? 15 : 13,
|
||||
// color: Colors.grey.shade600,
|
||||
// fontWeight: FontWeight.w500,
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -12,8 +12,6 @@ import 'chat_file_viewer.dart';
|
||||
import '../widgets/chat_file_preview.dart';
|
||||
|
||||
|
||||
|
||||
|
||||
class ChatScreen extends StatefulWidget {
|
||||
const ChatScreen({super.key});
|
||||
|
||||
@@ -24,6 +22,7 @@ class ChatScreen extends StatefulWidget {
|
||||
class _ChatScreenState extends State<ChatScreen> {
|
||||
final TextEditingController _messageCtrl = TextEditingController();
|
||||
final ScrollController _scrollCtrl = ScrollController();
|
||||
Map<String, dynamic>? uploadingMessage;
|
||||
|
||||
late ChatService _chatService;
|
||||
final ReverbSocketService _socket = ReverbSocketService();
|
||||
@@ -48,17 +47,50 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
|
||||
_initChat();
|
||||
}
|
||||
|
||||
Future<void> _pickAndSendFile() async {
|
||||
final XFile? file = await openFile();
|
||||
|
||||
if (file == null || ticketId == null) return;
|
||||
|
||||
final dartFile = File(file.path);
|
||||
|
||||
await _chatService.sendFile(ticketId!, dartFile);
|
||||
String _guessMimeType(String path) {
|
||||
final lower = path.toLowerCase();
|
||||
if (lower.endsWith('.jpg') || lower.endsWith('.png')) return 'image/*';
|
||||
if (lower.endsWith('.mp4')) return 'video/*';
|
||||
if (lower.endsWith('.pdf')) return 'application/pdf';
|
||||
return 'application/octet-stream';
|
||||
}
|
||||
|
||||
Future<void> _pickAndSendFile() async {
|
||||
final XFile? picked = await openFile();
|
||||
if (picked == null || ticketId == null) return;
|
||||
|
||||
final file = File(picked.path);
|
||||
|
||||
// 1️⃣ Show uploading UI
|
||||
setState(() {
|
||||
uploadingMessage = {
|
||||
'local_file': file,
|
||||
'file_type': _guessMimeType(file.path),
|
||||
'progress': 0.0,
|
||||
};
|
||||
});
|
||||
|
||||
// 2️⃣ Upload (NO adding message)
|
||||
await _chatService.sendFile(
|
||||
ticketId!,
|
||||
file,
|
||||
onProgress: (progress) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
uploadingMessage!['progress'] = progress;
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
// 3️⃣ Remove sending bubble ONLY
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
uploadingMessage = null;
|
||||
});
|
||||
|
||||
// 🚫 DO NOT add message here
|
||||
// WebSocket will handle it
|
||||
}
|
||||
|
||||
// ============================
|
||||
// INIT CHAT
|
||||
@@ -77,10 +109,22 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
context: context,
|
||||
ticketId: ticketId!,
|
||||
onMessage: (msg) {
|
||||
if (!mounted) return;
|
||||
setState(() => messages.add(msg));
|
||||
final incomingClientId = msg['client_id'];
|
||||
|
||||
setState(() {
|
||||
// 🧹 Remove local temp message with same client_id
|
||||
messages.removeWhere(
|
||||
(m) => m['client_id'] != null &&
|
||||
m['client_id'] == incomingClientId,
|
||||
);
|
||||
|
||||
// ✅ Add confirmed socket message
|
||||
messages.add(msg);
|
||||
});
|
||||
|
||||
_scrollToBottom();
|
||||
},
|
||||
|
||||
onAdminMessage: () {
|
||||
if (!mounted) {
|
||||
context.read<ChatUnreadProvider>().increment();
|
||||
@@ -88,9 +132,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
},
|
||||
);
|
||||
|
||||
|
||||
|
||||
|
||||
if (!mounted) return;
|
||||
setState(() => isLoading = false);
|
||||
_scrollToBottom();
|
||||
@@ -112,13 +153,31 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
// ============================
|
||||
Future<void> _sendMessage() async {
|
||||
final text = _messageCtrl.text.trim();
|
||||
if (text.isEmpty) return;
|
||||
if (text.isEmpty || ticketId == null) return;
|
||||
|
||||
_messageCtrl.clear();
|
||||
|
||||
final clientId = DateTime.now().millisecondsSinceEpoch.toString();
|
||||
|
||||
// 1️⃣ ADD LOCAL MESSAGE IMMEDIATELY
|
||||
setState(() {
|
||||
messages.add({
|
||||
'client_id': clientId,
|
||||
'sender_type': 'App\\Models\\User',
|
||||
'message': text,
|
||||
'file_path': null,
|
||||
'file_type': null,
|
||||
'sending': true,
|
||||
});
|
||||
});
|
||||
|
||||
_scrollToBottom();
|
||||
|
||||
// 2️⃣ SEND TO SERVER
|
||||
await _chatService.sendMessage(
|
||||
ticketId!,
|
||||
message: text,
|
||||
clientId: clientId,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -154,22 +213,6 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// Future<void> _openUrl(String url) async {
|
||||
// final uri = Uri.parse(url);
|
||||
//
|
||||
// if (await canLaunchUrl(uri)) {
|
||||
// await launchUrl(
|
||||
// uri,
|
||||
// mode: LaunchMode.externalApplication,
|
||||
// );
|
||||
// } else {
|
||||
// debugPrint("❌ Cannot launch URL: $url");
|
||||
// }
|
||||
// }
|
||||
|
||||
|
||||
|
||||
Widget _buildMessageContent({
|
||||
String? message,
|
||||
String? filePath,
|
||||
@@ -222,48 +265,96 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
return "Download file";
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
Widget _buildMessages() {
|
||||
return ListView.builder(
|
||||
return ListView(
|
||||
controller: _scrollCtrl,
|
||||
padding: const EdgeInsets.all(12),
|
||||
itemCount: messages.length,
|
||||
itemBuilder: (_, index) {
|
||||
final msg = messages[index];
|
||||
final isUser = msg['sender_type'] == 'App\\Models\\User';
|
||||
children: [
|
||||
// EXISTING MESSAGES
|
||||
...messages.map((msg) {
|
||||
final isUser = msg['sender_type'] == 'App\\Models\\User';
|
||||
|
||||
final String? filePath = msg['file_path'];
|
||||
final String? fileType = msg['file_type'];
|
||||
final String? message = msg['message'];
|
||||
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? Colors.blue : Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
|
||||
// 🔽 ONLY THIS PART CHANGED
|
||||
child: filePath == null
|
||||
? Text(
|
||||
message ?? '',
|
||||
style: TextStyle(
|
||||
color: isUser ? Colors.white : Colors.black,
|
||||
return Align(
|
||||
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: isUser ? Colors.blue : Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: msg['file_path'] == null
|
||||
? Text(
|
||||
msg['message'] ?? '',
|
||||
style: TextStyle(
|
||||
color: isUser ? Colors.white : Colors.black,
|
||||
),
|
||||
)
|
||||
: ChatFilePreview(
|
||||
filePath: msg['file_path'],
|
||||
fileType: msg['file_type'] ?? '',
|
||||
isUser: isUser,
|
||||
),
|
||||
),
|
||||
);
|
||||
}),
|
||||
|
||||
// ⏳ UPLOADING MESSAGE
|
||||
// ⏳ UPLOADING MESSAGE (SAFE & NO DUPLICATE)
|
||||
if (uploadingMessage != null)
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.symmetric(vertical: 6),
|
||||
padding: const EdgeInsets.all(10),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
// ✅ SHOW PREVIEW ONLY FOR IMAGE / VIDEO
|
||||
if (uploadingMessage!['file_type'].startsWith('image/') ||
|
||||
uploadingMessage!['file_type'].startsWith('video/'))
|
||||
ChatFilePreview(
|
||||
filePath: uploadingMessage!['local_file'].path,
|
||||
fileType: uploadingMessage!['file_type'],
|
||||
isUser: true,
|
||||
isLocal: true,
|
||||
)
|
||||
else
|
||||
// 📄 DOCUMENT PLACEHOLDER
|
||||
const Icon(
|
||||
Icons.insert_drive_file,
|
||||
color: Colors.white,
|
||||
size: 40,
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
LinearProgressIndicator(
|
||||
value: uploadingMessage!['progress'],
|
||||
backgroundColor: Colors.white24,
|
||||
valueColor:
|
||||
const AlwaysStoppedAnimation<Color>(Colors.white),
|
||||
),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
|
||||
const Text(
|
||||
"Sending…",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
: ChatFilePreview(
|
||||
filePath: filePath,
|
||||
fileType: fileType ?? '',
|
||||
isUser: isUser,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -292,5 +383,4 @@ class _ChatScreenState extends State<ChatScreen> {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -35,7 +35,6 @@ class _DashboardScreenState extends State<DashboardScreen>
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
final auth = Provider.of<AuthProvider>(context, listen: false);
|
||||
await auth.tryRefreshToken(context);
|
||||
|
||||
final dash = Provider.of<DashboardProvider>(context, listen: false);
|
||||
dash.init(context);
|
||||
@@ -542,7 +541,22 @@ class _DashboardScreenState extends State<DashboardScreen>
|
||||
vertical: 6 * scale,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(.2),
|
||||
gradient: (m['status'] ?? '')
|
||||
.toString()
|
||||
.toLowerCase() ==
|
||||
'active'
|
||||
? const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF2ECC71), // Green
|
||||
Color(0xFF16A085), // Teal Green
|
||||
],
|
||||
)
|
||||
: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFE74C3C), // Red
|
||||
Color(0xFFC0392B), // Dark Red
|
||||
],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8 * scale),
|
||||
),
|
||||
child: Text(
|
||||
@@ -553,7 +567,7 @@ class _DashboardScreenState extends State<DashboardScreen>
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,9 +1,12 @@
|
||||
import 'dart:io';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:pdf/widgets.dart' as pw;
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
import '../config/api_config.dart';
|
||||
import '../services/dio_client.dart';
|
||||
import '../services/invoice_service.dart';
|
||||
import '../widgets/invoice_detail_view.dart';
|
||||
@@ -26,9 +29,6 @@ class _InvoiceDetailScreenState extends State<InvoiceDetailScreen> {
|
||||
load();
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// ⭐ LOAD INVOICE FROM API
|
||||
// -------------------------------------------------------
|
||||
Future<void> load() async {
|
||||
final service = InvoiceService(DioClient.getInstance(context));
|
||||
try {
|
||||
@@ -40,93 +40,116 @@ class _InvoiceDetailScreenState extends State<InvoiceDetailScreen> {
|
||||
invoice = {};
|
||||
}
|
||||
} catch (e) {
|
||||
// handle error gracefully
|
||||
invoice = {};
|
||||
} finally {
|
||||
if (mounted) setState(() => loading = false);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// ⭐ GENERATE + SAVE PDF TO DOWNLOADS FOLDER
|
||||
// (No Permission Needed)
|
||||
// -------------------------------------------------------
|
||||
Future<File> generatePDF() async {
|
||||
final pdf = pw.Document();
|
||||
String? get pdfUrl {
|
||||
final path = invoice['pdf_path'];
|
||||
if (path == null || path.toString().isEmpty) return null;
|
||||
|
||||
pdf.addPage(
|
||||
pw.Page(
|
||||
build: (context) => pw.Column(
|
||||
crossAxisAlignment: pw.CrossAxisAlignment.start,
|
||||
children: [
|
||||
pw.Text(
|
||||
"INVOICE DETAILS",
|
||||
style: pw.TextStyle(fontSize: 26, fontWeight: pw.FontWeight.bold),
|
||||
),
|
||||
pw.SizedBox(height: 20),
|
||||
return ApiConfig.fileBaseUrl + path;
|
||||
}
|
||||
|
||||
pw.Text("Invoice ID: ${invoice['id'] ?? '-'}"),
|
||||
pw.Text("Amount: ₹${invoice['amount'] ?? '-'}"),
|
||||
pw.Text("Status: ${invoice['status'] ?? '-'}"),
|
||||
pw.Text("Date: ${invoice['date'] ?? '-'}"),
|
||||
pw.Text("Customer: ${invoice['customer_name'] ?? '-'}"),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
static const MethodChannel _mediaScanner =
|
||||
MethodChannel('media_scanner');
|
||||
|
||||
// ⭐ SAFEST WAY (Android 10–14)
|
||||
final downloadsDir = await getDownloadsDirectory();
|
||||
|
||||
final filePath = "${downloadsDir!.path}/invoice_${invoice['id']}.pdf";
|
||||
final file = File(filePath);
|
||||
|
||||
await file.writeAsBytes(await pdf.save());
|
||||
|
||||
if (mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("PDF saved to Downloads:\n$filePath")),
|
||||
);
|
||||
Future<void> _scanFile(String path) async {
|
||||
try {
|
||||
await _mediaScanner.invokeMethod('scanFile', {'path': path});
|
||||
} catch (e) {
|
||||
debugPrint("❌ MediaScanner error: $e");
|
||||
}
|
||||
|
||||
return file;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// ⭐ SHARE THE SAVED PDF FILE
|
||||
// -------------------------------------------------------
|
||||
Future<void> sharePDF() async {
|
||||
final file = await generatePDF();
|
||||
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
text: "Invoice Details",
|
||||
);
|
||||
|
||||
Future<File?> _downloadPdf(String url) async {
|
||||
try {
|
||||
debugPrint("📥 PDF URL: $url");
|
||||
|
||||
final fileName = url.split('/').last;
|
||||
|
||||
// ✅ SAME FOLDER AS CHAT FILES
|
||||
final baseDir = Directory('/storage/emulated/0/Download/KentChat');
|
||||
|
||||
if (!await baseDir.exists()) {
|
||||
await baseDir.create(recursive: true);
|
||||
debugPrint("📁 Created directory: ${baseDir.path}");
|
||||
}
|
||||
|
||||
final filePath = "${baseDir.path}/$fileName";
|
||||
debugPrint("📄 Saving PDF to: $filePath");
|
||||
|
||||
await Dio().download(
|
||||
url,
|
||||
filePath,
|
||||
onReceiveProgress: (received, total) {
|
||||
if (total > 0) {
|
||||
debugPrint(
|
||||
"⬇ Downloading: ${(received / total * 100).toStringAsFixed(1)}%",
|
||||
);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
// 🔔 VERY IMPORTANT: Notify Android system (same as chat)
|
||||
await _scanFile(filePath);
|
||||
|
||||
debugPrint("✅ PDF Downloaded & Scanned Successfully");
|
||||
|
||||
return File(filePath);
|
||||
} catch (e) {
|
||||
debugPrint("❌ PDF download error: $e");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final width = MediaQuery.of(context).size.width;
|
||||
|
||||
// ⭐ COMPACT RESPONSIVE SCALE
|
||||
final scale = (width / 430).clamp(0.88, 1.08);
|
||||
|
||||
return Scaffold(
|
||||
appBar: AppBar(
|
||||
title: Text(
|
||||
"Invoice Details",
|
||||
style: TextStyle(
|
||||
fontSize: 18 * scale,
|
||||
fontWeight: FontWeight.w600,
|
||||
title: const Text("Invoice Details"),
|
||||
actions: pdfUrl == null
|
||||
? []
|
||||
: [
|
||||
// DOWNLOAD
|
||||
IconButton(
|
||||
icon: const Icon(Icons.download_rounded),
|
||||
onPressed: () async {
|
||||
final file = await _downloadPdf(pdfUrl!);
|
||||
if (file != null && mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Invoice downloaded")),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// ⭐ PDF + SHARE BUTTONS
|
||||
actions: [
|
||||
// SHARE
|
||||
IconButton(
|
||||
icon: const Icon(Icons.picture_as_pdf),
|
||||
onPressed: generatePDF,
|
||||
),
|
||||
IconButton(
|
||||
icon: const Icon(Icons.share),
|
||||
onPressed: sharePDF,
|
||||
icon: const Icon(Icons.share_rounded),
|
||||
onPressed: () async {
|
||||
final file = await _downloadPdf(pdfUrl!);
|
||||
if (file != null) {
|
||||
await Share.shareXFiles(
|
||||
[XFile(file.path)],
|
||||
text: "Invoice ${invoice['invoice_number']}",
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -146,8 +169,10 @@ class _InvoiceDetailScreenState extends State<InvoiceDetailScreen> {
|
||||
)
|
||||
: Padding(
|
||||
padding: EdgeInsets.all(12 * scale),
|
||||
// NOTE: InvoiceDetailView should handle its own responsiveness.
|
||||
// If you want it to follow the same `scale`, I can update that widget to accept `scale`.
|
||||
child: InvoiceDetailView(invoice: invoice),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -24,19 +24,16 @@ class _MarkListScreenState extends State<MarkListScreen> {
|
||||
Widget build(BuildContext context) {
|
||||
final marks = Provider.of<MarkListProvider>(context);
|
||||
|
||||
// Responsive scale factor
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final scale = (screenWidth / 390).clamp(0.82, 1.35);
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: Colors.white,
|
||||
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.white,
|
||||
elevation: 0,
|
||||
centerTitle: true,
|
||||
iconTheme: const IconThemeData(color: Colors.black),
|
||||
|
||||
title: Text(
|
||||
"All Mark Numbers",
|
||||
style: TextStyle(
|
||||
@@ -50,14 +47,31 @@ class _MarkListScreenState extends State<MarkListScreen> {
|
||||
body: marks.loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: ListView.builder(
|
||||
padding: EdgeInsets.all(10 * scale), // smaller padding
|
||||
padding: EdgeInsets.all(10 * scale),
|
||||
itemCount: marks.marks.length,
|
||||
itemBuilder: (_, i) {
|
||||
final m = marks.marks[i];
|
||||
final status =
|
||||
(m['status'] ?? '').toString().toLowerCase();
|
||||
|
||||
final LinearGradient statusGradient =
|
||||
status == 'active'
|
||||
? const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF2ECC71), // Green
|
||||
Color(0xFF1E8449), // Deep Emerald
|
||||
],
|
||||
)
|
||||
: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFE74C3C), // Red
|
||||
Color(0xFFC0392B), // Dark Red
|
||||
],
|
||||
);
|
||||
|
||||
return Container(
|
||||
margin: EdgeInsets.only(bottom: 10 * scale), // reduced margin
|
||||
padding: EdgeInsets.all(12 * scale), // smaller padding
|
||||
margin: EdgeInsets.only(bottom: 10 * scale),
|
||||
padding: EdgeInsets.all(12 * scale),
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
@@ -67,16 +81,15 @@ class _MarkListScreenState extends State<MarkListScreen> {
|
||||
begin: Alignment.topLeft,
|
||||
end: Alignment.bottomRight,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(14 * scale), // smaller radius
|
||||
borderRadius: BorderRadius.circular(14 * scale),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black.withOpacity(0.06),
|
||||
blurRadius: 5 * scale, // smaller shadow
|
||||
blurRadius: 5 * scale,
|
||||
offset: Offset(0, 2 * scale),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -85,24 +98,20 @@ class _MarkListScreenState extends State<MarkListScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// MARK NUMBER
|
||||
Text(
|
||||
m['mark_no'],
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 16 * scale, // reduced font
|
||||
fontSize: 16 * scale,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 3 * scale),
|
||||
|
||||
// ROUTE
|
||||
Text(
|
||||
"${m['origin']} → ${m['destination']}",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13 * scale, // reduced font
|
||||
fontSize: 13 * scale,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
@@ -110,21 +119,21 @@ class _MarkListScreenState extends State<MarkListScreen> {
|
||||
),
|
||||
),
|
||||
|
||||
// STATUS BADGE
|
||||
// STATUS BADGE (GREEN / RED)
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 10 * scale,
|
||||
vertical: 5 * scale, // smaller badge
|
||||
vertical: 5 * scale,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(0.92),
|
||||
borderRadius: BorderRadius.circular(24 * scale),
|
||||
gradient: statusGradient,
|
||||
borderRadius: BorderRadius.circular(20 * scale),
|
||||
),
|
||||
child: Text(
|
||||
m['status'],
|
||||
style: TextStyle(
|
||||
fontSize: 11.5 * scale,
|
||||
color: Colors.black87,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -16,6 +16,12 @@ class _OrderDetailScreenState extends State<OrderDetailScreen> {
|
||||
Map order = {};
|
||||
final Map<String, bool> _expanded = {};
|
||||
|
||||
bool confirming = false;
|
||||
|
||||
bool get isOrderPlaced => order['status'] == 'order_placed';
|
||||
bool get isConfirmed => order['status'] != 'order_placed';
|
||||
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -65,6 +71,10 @@ class _OrderDetailScreenState extends State<OrderDetailScreen> {
|
||||
_itemsSection(items, scale),
|
||||
SizedBox(height: 18 * scale),
|
||||
_totalsSection(scale),
|
||||
|
||||
SizedBox(height: 24 * scale),
|
||||
_confirmOrderButton(scale),
|
||||
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -72,6 +82,69 @@ class _OrderDetailScreenState extends State<OrderDetailScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
Widget _confirmOrderButton(double scale) {
|
||||
final isPlaced = order['status'] == 'order_placed';
|
||||
|
||||
return SizedBox(
|
||||
width: double.infinity,
|
||||
height: 52 * scale,
|
||||
child: ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: isPlaced ? Colors.orange : Colors.green,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14 * scale),
|
||||
),
|
||||
),
|
||||
onPressed: (!isPlaced || confirming)
|
||||
? null
|
||||
: () async {
|
||||
setState(() => confirming = true);
|
||||
|
||||
final service =
|
||||
OrderService(DioClient.getInstance(context));
|
||||
|
||||
final res =
|
||||
await service.confirmOrder(order['order_id']);
|
||||
|
||||
confirming = false;
|
||||
|
||||
if (res['success'] == true) {
|
||||
setState(() {
|
||||
order['status'] = 'order_confirmed';
|
||||
});
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(
|
||||
content: Text('Order confirmed successfully'),
|
||||
backgroundColor: Colors.green,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(res['message'] ?? 'Failed to confirm order'),
|
||||
backgroundColor: Colors.red,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
setState(() {});
|
||||
},
|
||||
child: confirming
|
||||
? const CircularProgressIndicator(color: Colors.white)
|
||||
: Text(
|
||||
isPlaced ? 'Confirm Order' : 'Order Confirmed',
|
||||
style: TextStyle(
|
||||
fontSize: 16 * scale,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// -----------------------------
|
||||
// SUMMARY CARD
|
||||
// -----------------------------
|
||||
@@ -267,7 +340,7 @@ class _OrderDetailScreenState extends State<OrderDetailScreen> {
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Totals",
|
||||
Text("Totalss",
|
||||
style: TextStyle(fontSize: 18 * scale, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 12 * scale),
|
||||
_totalRow("Total Qty", order['ttl_qty'], scale),
|
||||
|
||||
@@ -84,7 +84,6 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
children: [
|
||||
_headerCard(scale),
|
||||
|
||||
// SUMMARY SECTION
|
||||
_sectionHeader("Invoice Summary", Icons.receipt, s1, () {
|
||||
setState(() => s1 = !s1);
|
||||
}, scale),
|
||||
@@ -92,23 +91,19 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
_detailRow(Icons.numbers, "Invoice No", invoice['invoice_number'], scale),
|
||||
_detailRow(Icons.calendar_month, "Invoice Date", invoice['invoice_date'], scale),
|
||||
_detailRow(Icons.date_range, "Due Date", invoice['due_date'], scale),
|
||||
_detailRow(Icons.payment, "Payment Method", invoice['payment_method'], scale),
|
||||
_detailRow(Icons.confirmation_number, "Reference No",
|
||||
invoice['reference_no'], scale),
|
||||
], scale),
|
||||
|
||||
// AMOUNT SECTION
|
||||
_sectionHeader("Amount Details", Icons.currency_rupee, s2, () {
|
||||
setState(() => s2 = !s2);
|
||||
}, scale),
|
||||
_sectionBody(s2, [
|
||||
_detailRow(Icons.money, "Amount", invoice['final_amount'], scale),
|
||||
_detailRow(Icons.percent, "GST percent", invoice['gst_percent'], scale),
|
||||
_detailRow(Icons.percent, "GST Amount", invoice['gst_amount'], scale),
|
||||
_detailRow(Icons.summarize, "Final With GST",
|
||||
invoice['final_amount_with_gst'], scale),
|
||||
], scale),
|
||||
|
||||
// CUSTOMER SECTION
|
||||
_sectionHeader("Customer Details", Icons.person, s3, () {
|
||||
setState(() => s3 = !s3);
|
||||
}, scale),
|
||||
@@ -120,7 +115,6 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
_detailRow(Icons.location_on, "Address", invoice['customer_address'], scale),
|
||||
], scale),
|
||||
|
||||
// ITEMS SECTION
|
||||
_sectionHeader("Invoice Items", Icons.shopping_cart, s4, () {
|
||||
setState(() => s4 = !s4);
|
||||
}, scale),
|
||||
@@ -138,44 +132,63 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
|
||||
// ---------------- HEADER CARD ----------------
|
||||
Widget _headerCard(double scale) {
|
||||
final statusColor = getInvoiceStatusColor(invoice["status"]);
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(18 * scale),
|
||||
margin: EdgeInsets.only(bottom: 18 * scale),
|
||||
decoration: BoxDecoration(
|
||||
gradient:
|
||||
LinearGradient(colors: [Colors.indigo.shade400, Colors.blue.shade600]),
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.indigo.shade400, Colors.blue.shade600],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(16 * scale),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 10 * scale,
|
||||
color: Colors.black.withOpacity(.15),
|
||||
offset: Offset(0, 3 * scale))
|
||||
blurRadius: 10 * scale,
|
||||
color: Colors.black.withOpacity(.15),
|
||||
offset: Offset(0, 3 * scale),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Invoice #${invoice['invoice_number'] ?? '-'}",
|
||||
style: TextStyle(
|
||||
fontSize: 22 * scale,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white)),
|
||||
Text(
|
||||
"Invoice #${invoice['invoice_number'] ?? '-'}",
|
||||
style: TextStyle(
|
||||
fontSize: 22 * scale,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
SizedBox(height: 6 * scale),
|
||||
Text("Date: ${invoice['invoice_date'] ?? '-'}",
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14 * scale)),
|
||||
Text(
|
||||
"Date: ${invoice['invoice_date'] ?? '-'}",
|
||||
style: TextStyle(color: Colors.white70, fontSize: 14 * scale),
|
||||
),
|
||||
SizedBox(height: 10 * scale),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 6 * scale, horizontal: 14 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(.2),
|
||||
color: Colors.white, // ✅ Always white
|
||||
borderRadius: BorderRadius.circular(50 * scale),
|
||||
border: Border.all(
|
||||
color: statusColor, // ✅ Different for each status
|
||||
width: 1.4 * scale,
|
||||
),
|
||||
),
|
||||
child: Text(
|
||||
invoice["status"]?.toString() ?? "Unknown",
|
||||
style: TextStyle(color: Colors.white, fontSize: 14 * scale),
|
||||
(invoice["status"] ?? "Unknown").toString().toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: statusColor, // ✅ Text color changes
|
||||
fontSize: 14 * scale,
|
||||
fontWeight: FontWeight.bold,
|
||||
letterSpacing: 0.5,
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -198,11 +211,14 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
children: [
|
||||
Icon(icon, color: Colors.white, size: 20 * scale),
|
||||
SizedBox(width: 10 * scale),
|
||||
Text(title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15 * scale,
|
||||
color: Colors.white)),
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15 * scale,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
AnimatedRotation(
|
||||
turns: expanded ? .5 : 0,
|
||||
@@ -233,9 +249,10 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 8 * scale,
|
||||
offset: Offset(0, 3 * scale),
|
||||
color: Colors.black.withOpacity(.08)),
|
||||
blurRadius: 8 * scale,
|
||||
offset: Offset(0, 3 * scale),
|
||||
color: Colors.black.withOpacity(.08),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Column(children: children),
|
||||
@@ -255,16 +272,19 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
Icon(icon, color: Colors.blueGrey, size: 20 * scale),
|
||||
SizedBox(width: 10 * scale),
|
||||
Expanded(
|
||||
child: Text(label,
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade700, fontSize: 14 * scale)),
|
||||
child: Text(
|
||||
label,
|
||||
style: TextStyle(color: Colors.grey.shade700, fontSize: 14 * scale),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Text(
|
||||
value?.toString() ?? "N/A",
|
||||
textAlign: TextAlign.end,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold, fontSize: 15 * scale),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15 * scale,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
@@ -286,16 +306,16 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
color: Colors.white,
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 8 * scale,
|
||||
offset: Offset(0, 3 * scale),
|
||||
color: Colors.black.withOpacity(.08)),
|
||||
blurRadius: 8 * scale,
|
||||
offset: Offset(0, 3 * scale),
|
||||
color: Colors.black.withOpacity(.08),
|
||||
),
|
||||
],
|
||||
border: Border.all(color: Colors.grey.shade300, width: 1),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// TITLE
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
@@ -312,15 +332,14 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
child: Text(
|
||||
item['description'] ?? "Item",
|
||||
style: TextStyle(
|
||||
fontSize: 16 * scale, fontWeight: FontWeight.w600),
|
||||
fontSize: 16 * scale,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 14 * scale),
|
||||
|
||||
// QTY & PRICE
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
@@ -328,10 +347,7 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
_itemBadge(Icons.currency_rupee, "Price", "₹$price", false, scale),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 12 * scale),
|
||||
|
||||
// TOTAL
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 10 * scale, horizontal: 14 * scale),
|
||||
@@ -383,8 +399,9 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
color: highlight ? Colors.indigo.shade50 : Colors.grey.shade100,
|
||||
border: Border.all(
|
||||
color: highlight ? Colors.indigo : Colors.grey.shade300,
|
||||
width: highlight ? 1.5 * scale : 1),
|
||||
color: highlight ? Colors.indigo : Colors.grey.shade300,
|
||||
width: highlight ? 1.5 * scale : 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
@@ -413,3 +430,22 @@ class _OrderInvoiceScreenState extends State<OrderInvoiceScreen>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- STATUS COLOR HELPER ----------------
|
||||
|
||||
Color getInvoiceStatusColor(String? status) {
|
||||
final s = (status ?? '')
|
||||
.toLowerCase()
|
||||
.replaceAll('_', ' ')
|
||||
.replaceAll('-', ' ')
|
||||
.trim();
|
||||
|
||||
if (s == 'paid') return Colors.green.shade600;
|
||||
if (s == 'pending') return Colors.orange.shade600;
|
||||
if (s == 'overdue') return Colors.red.shade600;
|
||||
if (s == 'cancelled' || s == 'canceled') return Colors.grey.shade600;
|
||||
if (s == 'in progress') return Colors.blue.shade600;
|
||||
if (s == 'draft') return Colors.purple.shade600;
|
||||
|
||||
return Colors.blueGrey;
|
||||
}
|
||||
|
||||
@@ -32,68 +32,24 @@ class _OrdersScreenState extends State<OrdersScreen> {
|
||||
}
|
||||
|
||||
final screenWidth = MediaQuery.of(context).size.width;
|
||||
final scale = (screenWidth / 420).clamp(0.85, 1.15);
|
||||
final scale = (screenWidth / 420).clamp(0.85, 1.1);
|
||||
|
||||
// FILTER ORDERS
|
||||
final filteredOrders = provider.orders.where((o) {
|
||||
final q = searchQuery.toLowerCase();
|
||||
return o["order_id"].toString().toLowerCase().contains(q) ||
|
||||
o["status"].toString().toLowerCase().contains(q) ||
|
||||
o["description"].toString().toLowerCase().contains(q) ||
|
||||
o["amount"].toString().toLowerCase().contains(q);
|
||||
o["description"].toString().toLowerCase().contains(q);
|
||||
}).toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// ⭐⭐ WHITE ELEVATED SEARCH BAR ⭐⭐
|
||||
Container(
|
||||
margin: EdgeInsets.fromLTRB(16 * scale, 16 * scale, 16 * scale, 10 * scale),
|
||||
padding: EdgeInsets.symmetric(horizontal: 14 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(16 * scale),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12.withOpacity(0.12),
|
||||
blurRadius: 10 * scale,
|
||||
spreadRadius: 1 * scale,
|
||||
offset: Offset(0, 4 * scale),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search,
|
||||
size: 22 * scale, color: Colors.grey.shade700),
|
||||
|
||||
SizedBox(width: 10 * scale),
|
||||
|
||||
Expanded(
|
||||
child: TextField(
|
||||
onChanged: (value) => setState(() => searchQuery = value),
|
||||
style: TextStyle(fontSize: 15 * scale),
|
||||
decoration: InputDecoration(
|
||||
hintText: "Search orders...",
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 14 * scale,
|
||||
color: Colors.grey.shade600,
|
||||
),
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
// LIST OF ORDERS
|
||||
_searchBar(scale),
|
||||
Expanded(
|
||||
child: ListView.builder(
|
||||
padding: EdgeInsets.all(16 * scale),
|
||||
padding: EdgeInsets.all(14 * scale),
|
||||
itemCount: filteredOrders.length,
|
||||
itemBuilder: (context, i) {
|
||||
final order = filteredOrders[i];
|
||||
return _orderCard(order, scale);
|
||||
return _orderCard(filteredOrders[i], scale);
|
||||
},
|
||||
),
|
||||
),
|
||||
@@ -101,123 +57,175 @@ class _OrdersScreenState extends State<OrdersScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// ORDER CARD UI
|
||||
Widget _searchBar(double scale) {
|
||||
return Container(
|
||||
margin: EdgeInsets.fromLTRB(14 * scale, 14 * scale, 14 * scale, 8 * scale),
|
||||
padding: EdgeInsets.symmetric(horizontal: 12 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(14 * scale),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: Colors.black12.withOpacity(0.1),
|
||||
blurRadius: 8 * scale,
|
||||
offset: const Offset(0, 3),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.search, size: 20 * scale, color: Colors.grey.shade700),
|
||||
SizedBox(width: 8 * scale),
|
||||
Expanded(
|
||||
child: TextField(
|
||||
onChanged: (v) => setState(() => searchQuery = v),
|
||||
decoration: const InputDecoration(
|
||||
hintText: "Search orders...",
|
||||
border: InputBorder.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _orderCard(Map<String, dynamic> o, double scale) {
|
||||
final progress = getProgress(o['status']);
|
||||
final badgeColor = getStatusColor(o['status']);
|
||||
final percent = (progress * 100).toInt();
|
||||
|
||||
return Card(
|
||||
elevation: 3 * scale,
|
||||
elevation: 2,
|
||||
margin: EdgeInsets.only(bottom: 12 * scale),
|
||||
color: Colors.white,
|
||||
margin: EdgeInsets.only(bottom: 16 * scale),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(16 * scale),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(16 * scale),
|
||||
padding: EdgeInsets.all(12 * scale), // 👈 tighter padding
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// TOP ROW
|
||||
// HEADER
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"Order #${o['order_id']}",
|
||||
style: TextStyle(
|
||||
fontSize: 18 * scale,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 12 * scale,
|
||||
vertical: 6 * scale,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: badgeColor.withOpacity(0.15),
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
),
|
||||
child: Text(
|
||||
o['status'],
|
||||
style: TextStyle(
|
||||
color: badgeColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13 * scale,
|
||||
),
|
||||
fontSize: 15 * scale, // 👈 slightly smaller
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
getStatusBadge(o['status'], scale),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 10 * scale),
|
||||
|
||||
SizedBox(height: 6 * scale),
|
||||
Text(
|
||||
o['description'],
|
||||
style: TextStyle(fontSize: 14 * scale),
|
||||
style: TextStyle(fontSize: 13 * scale),
|
||||
),
|
||||
|
||||
SizedBox(height: 5 * scale),
|
||||
|
||||
SizedBox(height: 4 * scale),
|
||||
Text(
|
||||
"₹${o['amount']}",
|
||||
style: TextStyle(
|
||||
fontSize: 16 * scale,
|
||||
fontSize: 15 * scale,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
|
||||
SizedBox(height: 18 * scale),
|
||||
SizedBox(height: 12 * scale),
|
||||
|
||||
// PROGRESS HEADER
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: Text(
|
||||
"$percent%",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 11 * scale,
|
||||
color: Colors.grey.shade700,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
_AnimatedProgressBar(progress: progress, scale: scale),
|
||||
|
||||
SizedBox(height: 18 * scale),
|
||||
SizedBox(height: 6 * scale),
|
||||
|
||||
// BUTTONS
|
||||
// PROGRESS LABELS
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: const [
|
||||
Text("Shipment Ready",
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey)),
|
||||
Text("Import Custom",
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey)),
|
||||
Text("Delivered",
|
||||
style: TextStyle(fontSize: 10, color: Colors.grey)),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 12 * scale),
|
||||
|
||||
// ACTIONS
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
_btn(Icons.visibility, "View", Colors.green.shade800,
|
||||
Colors.green.shade50, () => _openOrderDetails(o['order_id']), scale),
|
||||
|
||||
_btn(Icons.receipt_long, "Invoice", Colors.orange.shade800,
|
||||
Colors.orange.shade50, () => _openInvoice(o['order_id']), scale),
|
||||
|
||||
_btn(Icons.local_shipping, "Track", Colors.blue.shade800,
|
||||
Colors.blue.shade50, () => _openTrack(o['order_id']), scale),
|
||||
_btn(Icons.visibility, "View",
|
||||
Colors.green.shade700, Colors.green.shade50,
|
||||
() => _openOrderDetails(o['order_id']), scale),
|
||||
_btn(Icons.receipt_long, "Invoice",
|
||||
Colors.orange.shade700, Colors.orange.shade50,
|
||||
() => _openInvoice(o['order_id']), scale),
|
||||
_btn(Icons.local_shipping, "Track",
|
||||
Colors.blue.shade700, Colors.blue.shade50,
|
||||
() => _openTrack(o['order_id']), scale),
|
||||
],
|
||||
)
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// BUTTON UI
|
||||
Widget _btn(IconData icon, String text, Color fg, Color bg,
|
||||
VoidCallback onTap, double scale) {
|
||||
Widget _btn(
|
||||
IconData icon,
|
||||
String text,
|
||||
Color fg,
|
||||
Color bg,
|
||||
VoidCallback onTap,
|
||||
double scale,
|
||||
) {
|
||||
return InkWell(
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Container(
|
||||
constraints: BoxConstraints(
|
||||
minWidth: 115 * scale, // 👈 makes button wider
|
||||
minHeight: 36 * scale, // 👈 makes button taller
|
||||
),
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 20 * scale,
|
||||
vertical: 12 * scale,
|
||||
horizontal: 16 * scale, // slightly more horizontal space
|
||||
vertical: 8 * scale,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: bg,
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center, // 👈 centers content
|
||||
children: [
|
||||
Icon(icon, size: 18 * scale, color: fg),
|
||||
SizedBox(width: 8 * scale),
|
||||
Icon(icon, size: 16 * scale, color: fg),
|
||||
SizedBox(width: 6 * scale),
|
||||
Text(
|
||||
text,
|
||||
style: TextStyle(
|
||||
color: fg,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14 * scale,
|
||||
fontSize: 12 * scale,
|
||||
),
|
||||
),
|
||||
],
|
||||
@@ -226,30 +234,48 @@ class _OrdersScreenState extends State<OrdersScreen> {
|
||||
);
|
||||
}
|
||||
|
||||
// NAVIGATION
|
||||
void _openOrderDetails(String id) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => OrderDetailScreen(orderId: id)),
|
||||
|
||||
// ================= STATUS BADGE =================
|
||||
|
||||
Widget getStatusBadge(String? status, double scale) {
|
||||
final config = statusConfig(status);
|
||||
final isDomestic = (status ?? '').toLowerCase().contains("domestic");
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10, vertical: 6),
|
||||
decoration: BoxDecoration(
|
||||
color: config.bg,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Text(
|
||||
isDomestic ? "Domestic\nDistribution" : formatStatusLabel(status),
|
||||
textAlign: TextAlign.center,
|
||||
maxLines: isDomestic ? 2 : 1,
|
||||
style: TextStyle(
|
||||
color: config.fg,
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 11,
|
||||
height: 1.2,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _openInvoice(String id) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => OrderInvoiceScreen(orderId: id)),
|
||||
);
|
||||
}
|
||||
void _openOrderDetails(String id) =>
|
||||
Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => OrderDetailScreen(orderId: id)));
|
||||
|
||||
void _openTrack(String id) {
|
||||
Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(builder: (_) => OrderTrackScreen(orderId: id)),
|
||||
);
|
||||
}
|
||||
void _openInvoice(String id) =>
|
||||
Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => OrderInvoiceScreen(orderId: id)));
|
||||
|
||||
void _openTrack(String id) =>
|
||||
Navigator.push(context,
|
||||
MaterialPageRoute(builder: (_) => OrderTrackScreen(orderId: id)));
|
||||
}
|
||||
|
||||
// PROGRESS BAR
|
||||
// ================= PROGRESS BAR =================
|
||||
|
||||
class _AnimatedProgressBar extends StatelessWidget {
|
||||
final double progress;
|
||||
final double scale;
|
||||
@@ -258,63 +284,106 @@ class _AnimatedProgressBar extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final maxW = constraints.maxWidth;
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 10 * scale,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(20 * scale),
|
||||
return LayoutBuilder(builder: (context, c) {
|
||||
return Stack(
|
||||
children: [
|
||||
Container(
|
||||
height: 8, // 👈 thinner bar
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 600),
|
||||
height: 8,
|
||||
width: c.maxWidth * progress,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF4F8CFF), Color(0xFF8A4DFF)],
|
||||
),
|
||||
),
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 650),
|
||||
curve: Curves.easeInOut,
|
||||
height: 10 * scale,
|
||||
width: maxW * progress,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(20 * scale),
|
||||
gradient: const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF4F8CFF),
|
||||
Color(0xFF8A4DFF),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
),
|
||||
],
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// PROGRESS VALUES
|
||||
// ================= STATUS LOGIC =================
|
||||
|
||||
double getProgress(String? status) {
|
||||
final s = (status ?? '').toLowerCase();
|
||||
|
||||
if (s == "pending") return 0.25;
|
||||
if (s == "loading") return 0.40;
|
||||
if (s == "in transit" || s == "intransit") return 0.65;
|
||||
if (s == "dispatched") return 0.85;
|
||||
if (s == "delivered") return 1.0;
|
||||
|
||||
if (s.contains("shipment ready")) return 0.05;
|
||||
if (s.contains("export")) return 0.10;
|
||||
if (s.contains("international")) return 0.20;
|
||||
if (s.contains("arrived")) return 0.30;
|
||||
if (s.contains("import")) return 0.40;
|
||||
if (s.contains("warehouse")) return 0.50;
|
||||
if (s.contains("domestic")) return 0.60;
|
||||
if (s.contains("out for")) return 0.90;
|
||||
if (s.contains("delivered")) return 1.0;
|
||||
return 0.05;
|
||||
}
|
||||
|
||||
// STATUS COLORS
|
||||
Color getStatusColor(String? status) {
|
||||
_StatusConfig statusConfig(String? status) {
|
||||
final s = (status ?? '').toLowerCase();
|
||||
|
||||
if (s == "pending") return Colors.orange;
|
||||
if (s == "loading") return Colors.amber.shade800;
|
||||
if (s == "in transit" || s == "intransit") return Colors.red;
|
||||
if (s == "dispatched") return Colors.blue.shade700;
|
||||
if (s == "delivered") return Colors.green.shade700;
|
||||
if (s.contains("shipment ready")) {
|
||||
return _StatusConfig(Colors.blue.shade800, Colors.blue.shade50);
|
||||
}
|
||||
if (s.contains("export")) {
|
||||
return _StatusConfig(Colors.purple.shade800, Colors.purple.shade50);
|
||||
}
|
||||
if (s.contains("international")) {
|
||||
return _StatusConfig(Colors.red.shade800, Colors.red.shade50);
|
||||
}
|
||||
if (s.contains("arrived")) {
|
||||
return _StatusConfig(Colors.orange.shade800, Colors.orange.shade50);
|
||||
}
|
||||
if (s.contains("import")) {
|
||||
return _StatusConfig(Colors.teal.shade800, Colors.teal.shade50);
|
||||
}
|
||||
if (s.contains("warehouse")) {
|
||||
return _StatusConfig(Colors.brown.shade800, Colors.brown.shade50);
|
||||
}
|
||||
if (s.contains("domestic")) {
|
||||
return _StatusConfig(Colors.indigo.shade800, Colors.indigo.shade50);
|
||||
}
|
||||
if (s.contains("out for")) {
|
||||
return _StatusConfig(Colors.pink.shade800, Colors.pink.shade50);
|
||||
}
|
||||
if (s.contains("delivered")) {
|
||||
return _StatusConfig(Colors.green.shade800, Colors.green.shade50);
|
||||
}
|
||||
|
||||
return Colors.black54;
|
||||
return _StatusConfig(Colors.grey.shade800, Colors.grey.shade300);
|
||||
}
|
||||
|
||||
class _StatusConfig {
|
||||
final Color fg;
|
||||
final Color bg;
|
||||
_StatusConfig(this.fg, this.bg);
|
||||
}
|
||||
|
||||
// ================= STATUS LABEL MAP =================
|
||||
|
||||
const Map<String, String> statusLabelMap = {
|
||||
'shipment_ready': 'Shipment Ready',
|
||||
'export_custom': 'Export Custom',
|
||||
'international_transit': 'International Transit',
|
||||
'arrived_at_port': 'Arrived At Port',
|
||||
'import_custom': 'Import Custom',
|
||||
'warehouse': 'Warehouse Processing',
|
||||
'domestic_distribution': 'Domestic Distribution',
|
||||
'out_for_delivery': 'Out For Delivery',
|
||||
'delivered': 'Delivered',
|
||||
};
|
||||
|
||||
String formatStatusLabel(String? status) {
|
||||
if (status == null || status.isEmpty) return "Unknown";
|
||||
|
||||
return statusLabelMap[status.toLowerCase()] ??
|
||||
status.replaceAll('_', ' ').toUpperCase();
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import 'dart:math';
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/dio_client.dart';
|
||||
import '../services/order_service.dart';
|
||||
import 'order_screen.dart';
|
||||
|
||||
class OrderTrackScreen extends StatefulWidget {
|
||||
final String orderId;
|
||||
@@ -21,6 +22,7 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
|
||||
late final AnimationController progressController;
|
||||
late final AnimationController shipController;
|
||||
late final AnimationController timelineController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -36,6 +38,11 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
duration: const Duration(milliseconds: 1600),
|
||||
)..repeat(reverse: true);
|
||||
|
||||
timelineController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(milliseconds: 1200),
|
||||
);
|
||||
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _loadData());
|
||||
}
|
||||
|
||||
@@ -43,6 +50,7 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
void dispose() {
|
||||
progressController.dispose();
|
||||
shipController.dispose();
|
||||
timelineController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -56,19 +64,19 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
final service = OrderService(DioClient.getInstance(context));
|
||||
|
||||
final results = await Future.wait([
|
||||
service.getShipment(widget.orderId).catchError((_) => {"success": false}),
|
||||
service.trackOrder(widget.orderId).catchError((_) => {"success": false}),
|
||||
service.getShipment(widget.orderId)
|
||||
.catchError((_) => {"success": false}),
|
||||
service.trackOrder(widget.orderId)
|
||||
.catchError((_) => {"success": false}),
|
||||
]);
|
||||
|
||||
final shipRes = results[0] as Map;
|
||||
final trackRes = results[1] as Map;
|
||||
|
||||
/// ------------------- SHIPMENT DATA -------------------
|
||||
shipment = shipRes["success"] == true
|
||||
? Map<String, dynamic>.from(shipRes["shipment"] ?? {})
|
||||
: null;
|
||||
|
||||
/// ------------------- TRACKING DATA -------------------
|
||||
trackData = trackRes["success"] == true
|
||||
? Map<String, dynamic>.from(trackRes["track"] ?? {})
|
||||
: {};
|
||||
@@ -82,6 +90,8 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
target,
|
||||
curve: Curves.easeInOutCubic,
|
||||
);
|
||||
// Animate timeline
|
||||
timelineController.forward(from: 0);
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
@@ -90,9 +100,8 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
|
||||
// ---------------- PROGRESS LOGIC ----------------
|
||||
double _computeProgress() {
|
||||
final status = (trackData["shipment_status"] ?? "")
|
||||
.toString()
|
||||
.toLowerCase();
|
||||
final status =
|
||||
(trackData["shipment_status"] ?? "").toString().toLowerCase();
|
||||
|
||||
if (status.contains("delivered")) return 1.0;
|
||||
if (status.contains("dispatched")) return 0.85;
|
||||
@@ -100,22 +109,9 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
if (status.contains("loading")) return 0.40;
|
||||
if (status.contains("pending")) return 0.25;
|
||||
|
||||
if (_hasTimestamp("delivered_at")) return 1.0;
|
||||
if (_hasTimestamp("dispatched_at")) return 0.85;
|
||||
if (_hasTimestamp("in_transit_at")) return 0.65;
|
||||
if (_hasTimestamp("loading_at")) return 0.40;
|
||||
if (_hasTimestamp("pending_at")) return 0.25;
|
||||
|
||||
return 0.05;
|
||||
}
|
||||
|
||||
bool _hasTimestamp(String key) {
|
||||
final v = trackData[key];
|
||||
if (v == null) return false;
|
||||
final s = v.toString().trim().toLowerCase();
|
||||
return s.isNotEmpty && s != "null";
|
||||
}
|
||||
|
||||
String _fmt(dynamic v) {
|
||||
if (v == null) return "-";
|
||||
try {
|
||||
@@ -126,6 +122,92 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------- SHIPMENT STEPS DATA ----------------
|
||||
final List<Map<String, dynamic>> _shipmentSteps = [
|
||||
{
|
||||
'title': 'Shipment Ready',
|
||||
'status_key': 'shipment_ready',
|
||||
'icon': Icons.inventory,
|
||||
},
|
||||
{
|
||||
'title': 'Export Custom',
|
||||
'status_key': 'export_custom',
|
||||
'icon': Icons.account_balance,
|
||||
},
|
||||
{
|
||||
'title': 'International Transit',
|
||||
'status_key': 'international_transit',
|
||||
'icon': Icons.flight,
|
||||
},
|
||||
{
|
||||
'title': 'Arrived at India',
|
||||
'status_key': 'arrived_india',
|
||||
'icon': Icons.flag,
|
||||
},
|
||||
{
|
||||
'title': 'Import Custom',
|
||||
'status_key': 'import_custom',
|
||||
'icon': Icons.account_balance_outlined,
|
||||
},
|
||||
{
|
||||
'title': 'Warehouse',
|
||||
'status_key': 'warehouse',
|
||||
'icon': Icons.warehouse,
|
||||
},
|
||||
{
|
||||
'title': 'Domestic Distribution',
|
||||
'status_key': 'domestic_distribution',
|
||||
'icon': Icons.local_shipping,
|
||||
},
|
||||
{
|
||||
'title': 'Out for Delivery',
|
||||
'status_key': 'out_for_delivery',
|
||||
'icon': Icons.delivery_dining,
|
||||
},
|
||||
{
|
||||
'title': 'Delivered',
|
||||
'status_key': 'delivered',
|
||||
'icon': Icons.verified,
|
||||
},
|
||||
];
|
||||
|
||||
// ---------------- STATUS MAPPING ----------------
|
||||
Map<String, int> _statusToIndex = {
|
||||
'shipment_ready': 0,
|
||||
'export_custom': 1,
|
||||
'international_transit': 2,
|
||||
'arrived_india': 3,
|
||||
'import_custom': 4,
|
||||
'warehouse': 5,
|
||||
'domestic_distribution': 6,
|
||||
'out_for_delivery': 7,
|
||||
'delivered': 8,
|
||||
};
|
||||
|
||||
int _getCurrentStepIndex() {
|
||||
final status = (trackData["shipment_status"] ?? "").toString().toLowerCase();
|
||||
|
||||
// Try to match exact status key first
|
||||
for (var entry in _statusToIndex.entries) {
|
||||
if (status.contains(entry.key)) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback mappings
|
||||
if (status.contains("delivered")) return 8;
|
||||
if (status.contains("dispatch") || status.contains("out for delivery")) return 7;
|
||||
if (status.contains("distribution")) return 6;
|
||||
if (status.contains("warehouse")) return 5;
|
||||
if (status.contains("import")) return 4;
|
||||
if (status.contains("arrived") || status.contains("india")) return 3;
|
||||
if (status.contains("transit")) return 2;
|
||||
if (status.contains("export")) return 1;
|
||||
if (status.contains("ready") || status.contains("pending")) return 0;
|
||||
|
||||
return 0; // Default to first step
|
||||
}
|
||||
|
||||
// ---------------- UI BUILD ----------------
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@@ -148,21 +230,13 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
SizedBox(height: 16 * scale),
|
||||
|
||||
_shipmentSummary(scale),
|
||||
SizedBox(height: 16 * scale),
|
||||
|
||||
_shipmentTotals(scale),
|
||||
SizedBox(height: 16 * scale),
|
||||
|
||||
_shipmentItems(scale), // USING SHIPMENT API ONLY
|
||||
SizedBox(height: 20 * scale),
|
||||
|
||||
_trackingStatus(scale),
|
||||
SizedBox(height: 16 * scale),
|
||||
|
||||
_progressBar(scale, width),
|
||||
SizedBox(height: 16 * scale),
|
||||
|
||||
_detailsCard(scale),
|
||||
// Replace horizontal progress bar with vertical timeline
|
||||
_shipmentTimeline(scale),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -223,7 +297,6 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
TextStyle(fontSize: 18 * scale, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 12 * scale),
|
||||
|
||||
// Shipment ID
|
||||
Container(
|
||||
padding: EdgeInsets.all(12 * scale),
|
||||
decoration: BoxDecoration(
|
||||
@@ -242,7 +315,12 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
),
|
||||
|
||||
SizedBox(height: 14 * scale),
|
||||
_twoCol("Status", shipment!['status'], scale),
|
||||
_twoCol(
|
||||
"Status",
|
||||
formatStatusLabel(shipment!['status']),
|
||||
scale,
|
||||
),
|
||||
|
||||
_twoCol("Shipment Date", _fmt(shipment!['shipment_date']), scale),
|
||||
_twoCol("Origin", shipment!['origin'], scale),
|
||||
_twoCol("Destination", shipment!['destination'], scale),
|
||||
@@ -282,142 +360,9 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- SHIPMENT TOTALS ----------------
|
||||
Widget _shipmentTotals(double scale) {
|
||||
if (shipment == null) return const SizedBox.shrink();
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(14 * scale),
|
||||
decoration: _boxDecoration(scale),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Totals",
|
||||
style:
|
||||
TextStyle(fontSize: 18 * scale, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 10 * scale),
|
||||
_twoCol("Total CTN", shipment!['total_ctn'], scale),
|
||||
_twoCol("Total Qty", shipment!['total_qty'], scale),
|
||||
_twoCol("Total Amount", shipment!['total_amount'], scale),
|
||||
_twoCol("Total CBM", shipment!['total_cbm'], scale),
|
||||
_twoCol("Total KG", shipment!['total_kg'], scale),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- SHIPMENT ITEMS (FROM SHIPMENT API ONLY) ----------------
|
||||
Widget _shipmentItems(double scale) {
|
||||
if (shipment == null) return const SizedBox.shrink();
|
||||
|
||||
final items = (shipment!['items'] as List?) ?? [];
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(14 * scale),
|
||||
decoration: _boxDecoration(scale),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Shipment Items",
|
||||
style:
|
||||
TextStyle(fontSize: 18 * scale, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 14 * scale),
|
||||
|
||||
...items.map((item) {
|
||||
final orderId = item["order_id"]?.toString() ?? "-";
|
||||
final qty = item["total_ttl_qty"]?.toString() ?? "-";
|
||||
final cbm = item["total_ttl_cbm"]?.toString() ?? "-";
|
||||
final kg = item["total_ttl_kg"]?.toString() ?? "-";
|
||||
final amount = item["total_amount"]?.toString() ?? "-";
|
||||
|
||||
return Card(
|
||||
elevation: 2,
|
||||
margin: EdgeInsets.only(bottom: 12 * scale),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14 * scale),
|
||||
),
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(14 * scale),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Header
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
padding: EdgeInsets.all(8 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
),
|
||||
child: Icon(Icons.inventory_2,
|
||||
color: Colors.blue, size: 20 * scale),
|
||||
),
|
||||
SizedBox(width: 12 * scale),
|
||||
Text(
|
||||
"Order ID: $orderId",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15 * scale),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 12 * scale),
|
||||
|
||||
if (item["mark_no"] != null)
|
||||
Text("Mark No: ${item["mark_no"]}",
|
||||
style: TextStyle(fontSize: 13 * scale)),
|
||||
|
||||
SizedBox(height: 6 * scale),
|
||||
|
||||
Text("Quantity: $qty",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w600,
|
||||
fontSize: 14 * scale)),
|
||||
|
||||
Text("CBM: $cbm", style: TextStyle(fontSize: 13 * scale)),
|
||||
Text("KG: $kg", style: TextStyle(fontSize: 13 * scale)),
|
||||
|
||||
SizedBox(height: 10 * scale),
|
||||
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 8 * scale, horizontal: 12 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.blue.shade50,
|
||||
borderRadius: BorderRadius.circular(10 * scale),
|
||||
border: Border.all(color: Colors.blue, width: 1),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
const Icon(Icons.currency_rupee,
|
||||
color: Colors.blue),
|
||||
Text(
|
||||
"Amount: ₹$amount",
|
||||
style: TextStyle(
|
||||
fontSize: 15 * scale,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: Colors.blue),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- TRACKING STATUS ----------------
|
||||
Widget _trackingStatus(double scale) {
|
||||
final p = _computeProgress();
|
||||
final delivered = p >= 1.0;
|
||||
final delivered = _computeProgress() >= 1.0;
|
||||
|
||||
return Container(
|
||||
padding:
|
||||
@@ -438,142 +383,294 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
),
|
||||
SizedBox(width: 8 * scale),
|
||||
Text(
|
||||
(trackData["shipment_status"] ?? "-")
|
||||
.toString()
|
||||
.toUpperCase(),
|
||||
formatStatusLabel(trackData["shipment_status"]),
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13 * scale),
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 13 * scale,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- PROGRESS BAR ----------------
|
||||
Widget _progressBar(double scale, double width) {
|
||||
final usableWidth = width - (48 * scale);
|
||||
// ---------------- VERTICAL SHIPMENT TIMELINE ----------------
|
||||
Widget _shipmentTimeline(double scale) {
|
||||
final currentStepIndex = _getCurrentStepIndex();
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(12 * scale),
|
||||
padding: EdgeInsets.all(16 * scale),
|
||||
decoration: _boxDecoration(scale),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text("Shipment Progress",
|
||||
style:
|
||||
TextStyle(fontWeight: FontWeight.bold, fontSize: 15 * scale)),
|
||||
SizedBox(height: 12 * scale),
|
||||
|
||||
Stack(
|
||||
alignment: Alignment.centerLeft,
|
||||
Row(
|
||||
children: [
|
||||
// Background
|
||||
Container(
|
||||
width: usableWidth,
|
||||
height: 10 * scale,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(20 * scale)),
|
||||
),
|
||||
|
||||
// Progress Bar
|
||||
AnimatedBuilder(
|
||||
animation: progressController,
|
||||
builder: (_, __) {
|
||||
final w = usableWidth *
|
||||
progressController.value.clamp(0.0, 1.0);
|
||||
return Container(
|
||||
width: w,
|
||||
height: 10 * scale,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF4F8CFF), Color(0xFF8A4DFF)]),
|
||||
borderRadius: BorderRadius.circular(20 * scale),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
|
||||
// Moving Truck Icon
|
||||
AnimatedBuilder(
|
||||
animation: shipController,
|
||||
builder: (_, __) {
|
||||
final progress = progressController.value.clamp(0.0, 1.0);
|
||||
final bob = sin(shipController.value * 2 * pi) * (4 * scale);
|
||||
|
||||
return Positioned(
|
||||
left: (usableWidth - 26 * scale) * progress,
|
||||
top: -6 * scale + bob,
|
||||
child: Container(
|
||||
width: 26 * scale,
|
||||
height: 26 * scale,
|
||||
decoration: BoxDecoration(
|
||||
gradient: const LinearGradient(
|
||||
colors: [Color(0xFF4F8CFF), Color(0xFF8A4DFF)],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(8 * scale),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.local_shipping,
|
||||
size: 16 * scale,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
Icon(Icons.timeline, color: Colors.blue, size: 20 * scale),
|
||||
SizedBox(width: 8 * scale),
|
||||
Text(
|
||||
"Shipment Progress",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 17 * scale,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 10 * scale),
|
||||
|
||||
AnimatedBuilder(
|
||||
animation: progressController,
|
||||
builder: (_, __) => Text(
|
||||
"${(progressController.value * 100).toInt()}% Completed",
|
||||
style: TextStyle(color: Colors.black54, fontSize: 13 * scale),
|
||||
SizedBox(height: 8 * scale),
|
||||
Text(
|
||||
"Tracking your package in real-time",
|
||||
style: TextStyle(
|
||||
color: Colors.grey.shade600,
|
||||
fontSize: 13 * scale,
|
||||
),
|
||||
)
|
||||
),
|
||||
SizedBox(height: 16 * scale),
|
||||
|
||||
// Timeline Container
|
||||
AnimatedBuilder(
|
||||
animation: timelineController,
|
||||
builder: (context, child) {
|
||||
return Opacity(
|
||||
opacity: timelineController.value,
|
||||
child: Transform.translate(
|
||||
offset: Offset(0, 20 * (1 - timelineController.value)),
|
||||
child: child,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: _buildTimeline(scale, currentStepIndex),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- DETAILS CARD ----------------
|
||||
Widget _detailsCard(double scale) {
|
||||
Widget _buildTimeline(double scale, int currentStepIndex) {
|
||||
return Container(
|
||||
padding: EdgeInsets.all(14 * scale),
|
||||
decoration: _boxDecoration(scale),
|
||||
child: Column(
|
||||
padding: EdgeInsets.only(left: 8 * scale),
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
itemCount: _shipmentSteps.length,
|
||||
itemBuilder: (context, index) {
|
||||
final step = _shipmentSteps[index];
|
||||
final isCompleted = index < currentStepIndex;
|
||||
final isCurrent = index == currentStepIndex;
|
||||
final isLast = index == _shipmentSteps.length - 1;
|
||||
|
||||
return _buildTimelineStep(
|
||||
scale: scale,
|
||||
step: step,
|
||||
index: index,
|
||||
isCompleted: isCompleted,
|
||||
isCurrent: isCurrent,
|
||||
isLast: isLast,
|
||||
currentStepIndex: currentStepIndex,
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildTimelineStep({
|
||||
required double scale,
|
||||
required Map<String, dynamic> step,
|
||||
required int index,
|
||||
required bool isCompleted,
|
||||
required bool isCurrent,
|
||||
required bool isLast,
|
||||
required int currentStepIndex,
|
||||
}) {
|
||||
return IntrinsicHeight(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_detailsRow("Order ID", trackData["order_id"], scale),
|
||||
SizedBox(height: 10 * scale),
|
||||
_detailsRow("Status", trackData["shipment_status"], scale),
|
||||
SizedBox(height: 10 * scale),
|
||||
_detailsRow("Date", _fmt(trackData["shipment_date"]), scale),
|
||||
// Timeline Column (Line + Icon)
|
||||
Column(
|
||||
children: [
|
||||
// Top connector line (except for first item)
|
||||
if (index > 0)
|
||||
Container(
|
||||
width: 2 * scale,
|
||||
height: 24 * scale,
|
||||
color: isCompleted ? Colors.green : Colors.grey.shade300,
|
||||
),
|
||||
|
||||
// Step Icon with animation
|
||||
_buildStepIcon(scale, isCompleted, isCurrent, step['icon']),
|
||||
|
||||
// Bottom connector line (except for last item)
|
||||
if (!isLast)
|
||||
Container(
|
||||
width: 2 * scale,
|
||||
height: 24 * scale,
|
||||
color: index < currentStepIndex ? Colors.green : Colors.grey.shade300,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(width: 16 * scale),
|
||||
|
||||
// Step Content
|
||||
Expanded(
|
||||
child: Container(
|
||||
margin: EdgeInsets.only(bottom: 20 * scale),
|
||||
padding: EdgeInsets.all(14 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: isCurrent ? Colors.blue.shade50 : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
border: isCurrent
|
||||
? Border.all(color: Colors.blue.shade200, width: 1.5 * scale)
|
||||
: null,
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
step['title'],
|
||||
style: TextStyle(
|
||||
fontSize: 15 * scale,
|
||||
fontWeight: FontWeight.w600,
|
||||
color: isCurrent
|
||||
? Colors.blue.shade800
|
||||
: isCompleted
|
||||
? Colors.green.shade800
|
||||
: Colors.grey.shade800,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isCompleted)
|
||||
Icon(Icons.check_circle,
|
||||
color: Colors.green,
|
||||
size: 18 * scale
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
SizedBox(height: 4 * scale),
|
||||
|
||||
// Optional timestamp - you can customize this with actual timestamps from API
|
||||
_buildStepTimestamp(scale, step, isCompleted, isCurrent, index),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _detailsRow(String title, dynamic value, double scale) {
|
||||
Widget _buildStepIcon(double scale, bool isCompleted, bool isCurrent, IconData iconData) {
|
||||
// Animation for current step
|
||||
if (isCurrent) {
|
||||
return AnimatedBuilder(
|
||||
animation: shipController,
|
||||
builder: (context, child) {
|
||||
return Container(
|
||||
width: 32 * scale * (1 + 0.2 * shipController.value),
|
||||
height: 32 * scale * (1 + 0.2 * shipController.value),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.blue.shade50,
|
||||
border: Border.all(
|
||||
color: Colors.blue,
|
||||
width: 2 * scale,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
color: Colors.blue,
|
||||
size: 18 * scale,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// Completed step
|
||||
if (isCompleted) {
|
||||
return Container(
|
||||
width: 32 * scale,
|
||||
height: 32 * scale,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.green.shade50,
|
||||
border: Border.all(
|
||||
color: Colors.green,
|
||||
width: 2 * scale,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check,
|
||||
color: Colors.green,
|
||||
size: 18 * scale,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Upcoming step
|
||||
return Container(
|
||||
width: 32 * scale,
|
||||
height: 32 * scale,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: Colors.grey.shade100,
|
||||
border: Border.all(
|
||||
color: Colors.grey.shade400,
|
||||
width: 2 * scale,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
iconData,
|
||||
color: Colors.grey.shade600,
|
||||
size: 18 * scale,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildStepTimestamp(double scale, Map<String, dynamic> step,
|
||||
bool isCompleted, bool isCurrent, int index) {
|
||||
// You can replace this with actual timestamps from your API
|
||||
final now = DateTime.now();
|
||||
final estimatedTime = now.add(Duration(days: index));
|
||||
|
||||
String statusText = isCurrent
|
||||
? "In progress"
|
||||
: isCompleted
|
||||
? "Completed"
|
||||
: "Estimated ${estimatedTime.day}/${estimatedTime.month}";
|
||||
|
||||
Color statusColor = isCurrent
|
||||
? Colors.blue.shade600
|
||||
: isCompleted
|
||||
? Colors.green.shade600
|
||||
: Colors.grey.shade600;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(title,
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.w700, fontSize: 14 * scale))),
|
||||
Expanded(
|
||||
child: Text(value?.toString() ?? "-",
|
||||
textAlign: TextAlign.right,
|
||||
style: TextStyle(color: Colors.black87, fontSize: 14 * scale))),
|
||||
Icon(
|
||||
isCurrent ? Icons.access_time : Icons.calendar_today,
|
||||
color: statusColor,
|
||||
size: 14 * scale,
|
||||
),
|
||||
SizedBox(width: 4 * scale),
|
||||
Text(
|
||||
statusText,
|
||||
style: TextStyle(
|
||||
fontSize: 12 * scale,
|
||||
color: statusColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------- BOX DECORATION ----------------
|
||||
BoxDecoration _boxDecoration(double scale) {
|
||||
return BoxDecoration(
|
||||
color: Colors.white,
|
||||
@@ -587,4 +684,4 @@ class _OrderTrackScreenState extends State<OrderTrackScreen>
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,7 +46,7 @@ class AuthService {
|
||||
Future<Map<String, dynamic>> refreshToken(String oldToken) async {
|
||||
try {
|
||||
final response = await _dio.post(
|
||||
'/user/refresh',
|
||||
'/auth/refresh',
|
||||
options: Options(headers: {
|
||||
'Authorization': 'Bearer $oldToken',
|
||||
}),
|
||||
|
||||
@@ -24,27 +24,25 @@ class ChatService {
|
||||
Future<void> sendMessage(
|
||||
int ticketId, {
|
||||
String? message,
|
||||
String? filePath,
|
||||
String? clientId,
|
||||
}) async {
|
||||
final form = FormData();
|
||||
|
||||
if (message != null) form.fields.add(MapEntry('message', message));
|
||||
if (filePath != null) {
|
||||
form.files.add(
|
||||
MapEntry(
|
||||
'file',
|
||||
await MultipartFile.fromFile(filePath),
|
||||
),
|
||||
);
|
||||
}
|
||||
if (clientId != null) form.fields.add(MapEntry('client_id', clientId));
|
||||
|
||||
await dio.post('/user/chat/send/$ticketId', data: form);
|
||||
}
|
||||
|
||||
|
||||
// ---------------------------
|
||||
// SEND FILE (image/video/pdf/excel)
|
||||
// ---------------------------
|
||||
Future<void> sendFile(int ticketId, File file) async {
|
||||
Future<Map<String, dynamic>> sendFile(
|
||||
int ticketId,
|
||||
File file, {
|
||||
required Function(double) onProgress,
|
||||
}) async {
|
||||
final formData = FormData.fromMap({
|
||||
'file': await MultipartFile.fromFile(
|
||||
file.path,
|
||||
@@ -52,13 +50,20 @@ class ChatService {
|
||||
),
|
||||
});
|
||||
|
||||
await dio.post(
|
||||
final res = await dio.post(
|
||||
"/user/chat/send/$ticketId",
|
||||
|
||||
data: formData,
|
||||
options: Options(
|
||||
headers: {'Content-Type': 'multipart/form-data'},
|
||||
),
|
||||
onSendProgress: (sent, total) {
|
||||
if (total > 0) {
|
||||
onProgress(sent / total);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
return Map<String, dynamic>.from(res.data['message']);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -8,7 +8,8 @@ import 'token_interceptor.dart';
|
||||
class DioClient {
|
||||
static Dio? _dio;
|
||||
|
||||
static const String baseUrl = "http://10.11.236.74:8000";
|
||||
// static const String baseUrl = "http://103.248.30.24:3030";
|
||||
static const String baseUrl = "http://10.119.0.74:8000";
|
||||
|
||||
static Dio getInstance(BuildContext context) {
|
||||
if (_dio == null) {
|
||||
|
||||
@@ -4,55 +4,30 @@ class InvoiceService {
|
||||
final Dio dio;
|
||||
InvoiceService(this.dio);
|
||||
|
||||
// -------------------------------------------------------
|
||||
// ⭐ GET ALL INVOICES
|
||||
// -------------------------------------------------------
|
||||
Future<Map<String, dynamic>> getAllInvoices() async {
|
||||
try {
|
||||
final res = await dio.get("/user/invoices");
|
||||
|
||||
print("🔵 ALL INVOICES RESPONSE:");
|
||||
print(res.data);
|
||||
|
||||
return Map<String, dynamic>.from(res.data);
|
||||
} catch (e) {
|
||||
print("❌ ERROR (All Invoices): $e");
|
||||
return {"success": false, "message": e.toString()};
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// ⭐ GET INSTALLMENTS
|
||||
// -------------------------------------------------------
|
||||
Future<Map<String, dynamic>> getInstallments(int invoiceId) async {
|
||||
try {
|
||||
final res =
|
||||
await dio.get("/user/invoice/$invoiceId/installments");
|
||||
|
||||
print("🔵 INSTALLMENTS RESPONSE:");
|
||||
print(res.data);
|
||||
|
||||
final res = await dio.get("/user/invoice/$invoiceId/installments");
|
||||
return Map<String, dynamic>.from(res.data);
|
||||
} catch (e) {
|
||||
print("❌ ERROR (Installments): $e");
|
||||
return {"success": false, "message": e.toString()};
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------
|
||||
// ⭐ GET FULL INVOICE DETAILS (PRINT JSON HERE)
|
||||
// -------------------------------------------------------
|
||||
/// 🔵 NEW FUNCTION — Fetch Full Invoice Details
|
||||
Future<Map<String, dynamic>> getInvoiceDetails(int invoiceId) async {
|
||||
try {
|
||||
final res = await dio.get("/user/invoice/$invoiceId/details");
|
||||
|
||||
print("👇👇👇 INVOICE API RESPONSE START 👇👇👇");
|
||||
print(res.data); // <-- THIS IS WHAT YOU NEED
|
||||
print("👆👆👆 INVOICE API RESPONSE END 👆👆👆");
|
||||
|
||||
return Map<String, dynamic>.from(res.data);
|
||||
} catch (e) {
|
||||
print("❌ ERROR (Invoice Details): $e");
|
||||
return {"success": false, "message": e.toString()};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -29,4 +29,10 @@ class OrderService {
|
||||
final res = await _dio.get('/user/order/$id/track');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
Future<Map<String, dynamic>> confirmOrder(String orderId) async {
|
||||
final res = await _dio.post('/user/orders/$orderId/confirm');
|
||||
return res.data;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -36,9 +36,13 @@ class ReverbSocketService {
|
||||
_onAdminMessage = onAdminMessage; // 👈 SAVE
|
||||
|
||||
final uri = Uri.parse(
|
||||
'ws://10.11.236.74:8080/app/q5fkk5rvcnatvbgadwvl'
|
||||
'ws://10.119.0.74:8080/app/q5fkk5rvcnatvbgadwvl'
|
||||
'?protocol=7&client=flutter&version=1.0',
|
||||
);
|
||||
// final uri = Uri.parse(
|
||||
// 'ws://103.248.30.24:8080/app/q5fkk5rvcnatvbgadwvl'
|
||||
// '?protocol=7&client=flutter&version=1.0',
|
||||
// );
|
||||
|
||||
debugPrint("🔌 CONNECTING SOCKET → $uri");
|
||||
|
||||
@@ -60,6 +64,7 @@ class ReverbSocketService {
|
||||
Future<void> _handleMessage(dynamic raw) async {
|
||||
debugPrint("📥 RAW: $raw");
|
||||
|
||||
|
||||
final payload = jsonDecode(raw);
|
||||
final event = payload['event']?.toString() ?? '';
|
||||
|
||||
@@ -91,13 +96,15 @@ class ReverbSocketService {
|
||||
event == 'NewChatMessage' ||
|
||||
event.endsWith('.NewChatMessage') ||
|
||||
event.contains('NewChatMessage')) {
|
||||
|
||||
dynamic data = payload['data'];
|
||||
if (data is String) data = jsonDecode(data);
|
||||
|
||||
final int msgId = data['id'];
|
||||
final String senderType = data['sender_type'] ?? '';
|
||||
final String? incomingClientId = data['client_id']; // ✅ HERE
|
||||
|
||||
// 🔁 Prevent duplicates
|
||||
// 🔁 Prevent duplicates by DB id
|
||||
if (_receivedIds.contains(msgId)) {
|
||||
debugPrint("🔁 DUPLICATE MESSAGE IGNORED: $msgId");
|
||||
return;
|
||||
@@ -106,20 +113,19 @@ class ReverbSocketService {
|
||||
|
||||
debugPrint("📩 NEW MESSAGE");
|
||||
debugPrint("🆔 id=$msgId");
|
||||
debugPrint("🧩 client_id=$incomingClientId");
|
||||
debugPrint("👤 sender=$senderType");
|
||||
debugPrint("💬 text=${data['message']}");
|
||||
|
||||
// Always push message to UI
|
||||
// ✅ Forward FULL payload (with client_id) to UI
|
||||
_onMessage(Map<String, dynamic>.from(data));
|
||||
|
||||
// 🔔 Increment unread ONLY if ADMIN sent message
|
||||
// 🔔 Increment unread ONLY if ADMIN sent message
|
||||
// 🔔 Unread count only for admin messages
|
||||
if (senderType == 'App\\Models\\Admin') {
|
||||
debugPrint("🔔 ADMIN MESSAGE → UNREAD +1");
|
||||
_onAdminMessage(); // ✅ ACTUAL INCREMENT
|
||||
_onAdminMessage();
|
||||
}
|
||||
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,37 +1,130 @@
|
||||
import 'dart:async';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../config/api_config.dart';
|
||||
import '../providers/auth_provider.dart';
|
||||
|
||||
class TokenInterceptor extends Interceptor {
|
||||
final AuthProvider auth;
|
||||
final AuthProvider authProvider;
|
||||
final BuildContext context;
|
||||
final Dio dio;
|
||||
|
||||
TokenInterceptor(this.auth, this.context, this.dio);
|
||||
Completer<bool>? _refreshCompleter;
|
||||
|
||||
TokenInterceptor(this.authProvider, this.context, this.dio);
|
||||
|
||||
// 🔐 Attach token to every request
|
||||
@override
|
||||
void onRequest(RequestOptions options, RequestInterceptorHandler handler) {
|
||||
if (auth.token != null) {
|
||||
options.headers['Authorization'] = 'Bearer ${auth.token}';
|
||||
void onRequest(
|
||||
RequestOptions options, RequestInterceptorHandler handler) async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final token = prefs.getString('token');
|
||||
|
||||
if (token != null && token.isNotEmpty) {
|
||||
options.headers['Authorization'] = 'Bearer $token';
|
||||
debugPrint('🔐🔐🔐🔐🔐🔐 [REQUEST] Token attached → ${options.uri}');
|
||||
} else {
|
||||
debugPrint('⚠️⚠️⚠️⚠️⚠️⚠️ [REQUEST] No token found → ${options.uri}');
|
||||
}
|
||||
|
||||
handler.next(options);
|
||||
}
|
||||
|
||||
// 🔄 Handle 401 & refresh token
|
||||
@override
|
||||
void onError(DioException err, ErrorInterceptorHandler handler) async {
|
||||
if (err.response?.statusCode == 401 &&
|
||||
err.response?.data['message'] == 'Token has expired') {
|
||||
debugPrint(
|
||||
'❌❌❌❌❌ [ERROR] ${err.response?.statusCode} on ${err.requestOptions.uri}');
|
||||
|
||||
final refreshed = await auth.tryRefreshToken(context);
|
||||
if (err.response?.statusCode == 401) {
|
||||
debugPrint('🔄🔄🔄🔄🔄🔄🔄 [AUTH] 401 detected, attempting refresh…');
|
||||
|
||||
// If refresh already running, wait
|
||||
if (_refreshCompleter != null) {
|
||||
debugPrint('⏳⏳⏳⏳⏳⏳⏳⏳⏳ [REFRESH] Waiting for ongoing refresh to complete…');
|
||||
|
||||
final success = await _refreshCompleter!.future;
|
||||
|
||||
if (success) {
|
||||
debugPrint('✅✅✅✅✅✅✅✅ [REFRESH] Token refreshed, retrying request');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
err.requestOptions.headers['Authorization'] =
|
||||
'Bearer ${prefs.getString('token')}';
|
||||
|
||||
final response = await dio.fetch(err.requestOptions);
|
||||
return handler.resolve(response);
|
||||
} else {
|
||||
debugPrint('❌❌❌❌❌❌ [REFRESH] Refresh failed while waiting');
|
||||
}
|
||||
}
|
||||
|
||||
_refreshCompleter = Completer<bool>();
|
||||
|
||||
debugPrint('🚀🚀🚀🚀🚀🚀🚀🚀 [REFRESH] Starting new refresh request');
|
||||
|
||||
final refreshed = await _refreshToken();
|
||||
|
||||
_refreshCompleter!.complete(refreshed);
|
||||
_refreshCompleter = null;
|
||||
|
||||
if (refreshed) {
|
||||
err.requestOptions.headers['Authorization'] = 'Bearer ${auth.token}';
|
||||
final newResponse = await dio.fetch(err.requestOptions);
|
||||
return handler.resolve(newResponse);
|
||||
debugPrint('✅✅✅✅✅✅✅✅✅ [REFRESH] Refresh successful, retrying original request');
|
||||
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
err.requestOptions.headers['Authorization'] =
|
||||
'Bearer ${prefs.getString('token')}';
|
||||
|
||||
final response = await dio.fetch(err.requestOptions);
|
||||
return handler.resolve(response);
|
||||
}
|
||||
|
||||
debugPrint('🚪🚪🚪🚪🚪🚪 [AUTH] Refresh failed → logging out user');
|
||||
await authProvider.logout(context);
|
||||
//await authProvider.forceLogout(context);
|
||||
|
||||
}
|
||||
|
||||
handler.next(err);
|
||||
}
|
||||
}
|
||||
|
||||
// 🔁 Call refresh API using SEPARATE Dio
|
||||
Future<bool> _refreshToken() async {
|
||||
try {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final oldToken = prefs.getString('token');
|
||||
|
||||
if (oldToken == null) {
|
||||
debugPrint('❌❌❌❌❌❌❌ [REFRESH] No old token found');
|
||||
return false;
|
||||
}
|
||||
|
||||
debugPrint('📤📤📤📤📤 [REFRESH] Calling /auth/refresh');
|
||||
|
||||
final refreshDio = Dio(
|
||||
BaseOptions(
|
||||
baseUrl: ApiConfig.baseUrl,
|
||||
headers: {
|
||||
'Authorization': 'Bearer $oldToken',
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
),
|
||||
);
|
||||
|
||||
final res = await refreshDio.post('/auth/refresh');
|
||||
|
||||
if (res.data['success'] == true && res.data['token'] != null) {
|
||||
await prefs.setString('token', res.data['token']);
|
||||
debugPrint('💾💾💾💾💾💾💾 [REFRESH] New token saved to SharedPreferences');
|
||||
return true;
|
||||
}
|
||||
|
||||
debugPrint('❌❌❌❌❌❌ [REFRESH] API responded but refresh not allowed');
|
||||
return false;
|
||||
} catch (e) {
|
||||
debugPrint('🔥🔥🔥🔥🔥🔥 [REFRESH] Exception occurred: $e');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
118
lib/widgets/chat_file_actions.dart
Normal file
118
lib/widgets/chat_file_actions.dart
Normal file
@@ -0,0 +1,118 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import '../screens/chat_file_viewer.dart';
|
||||
|
||||
class ChatFileActions {
|
||||
static void show(
|
||||
BuildContext context, {
|
||||
required String url,
|
||||
required String fileType,
|
||||
}) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(16)),
|
||||
),
|
||||
builder: (_) {
|
||||
return SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_actionTile(
|
||||
icon: Icons.open_in_new,
|
||||
title: "Open",
|
||||
onTap: () {
|
||||
Navigator.pop(context);
|
||||
ChatFileViewer.open(
|
||||
context,
|
||||
url: url,
|
||||
fileType: fileType,
|
||||
);
|
||||
},
|
||||
),
|
||||
_actionTile(
|
||||
icon: Icons.download,
|
||||
title: "Download",
|
||||
onTap: () async {
|
||||
Navigator.pop(context);
|
||||
await _downloadFile(context, url, fileType);
|
||||
|
||||
},
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _actionTile({
|
||||
required IconData icon,
|
||||
required String title,
|
||||
required VoidCallback onTap,
|
||||
}) {
|
||||
return ListTile(
|
||||
leading: Icon(icon),
|
||||
title: Text(title),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
static Future<void> _scanFile(String path) async {
|
||||
const platform = MethodChannel('media_scanner');
|
||||
|
||||
try {
|
||||
await platform.invokeMethod('scanFile', {'path': path});
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
|
||||
// ===========================
|
||||
// DOWNLOAD LOGIC
|
||||
// ===========================
|
||||
static Future<void> _downloadFile(
|
||||
BuildContext context,
|
||||
String url,
|
||||
String fileType,
|
||||
) async {
|
||||
try {
|
||||
final fileName = url.split('/').last;
|
||||
|
||||
Directory baseDir;
|
||||
|
||||
if (fileType.startsWith('image/')) {
|
||||
baseDir = Directory('/storage/emulated/0/Pictures/KentChat');
|
||||
} else if (fileType.startsWith('video/')) {
|
||||
baseDir = Directory('/storage/emulated/0/Movies/KentChat');
|
||||
} else {
|
||||
baseDir = Directory('/storage/emulated/0/Download/KentChat');
|
||||
}
|
||||
|
||||
if (!await baseDir.exists()) {
|
||||
await baseDir.create(recursive: true);
|
||||
}
|
||||
|
||||
final filePath = "${baseDir.path}/$fileName";
|
||||
|
||||
await Dio().download(url, filePath);
|
||||
|
||||
// 🔔 IMPORTANT: Tell Android to scan the file
|
||||
await _scanFile(filePath);
|
||||
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text("Saved to ${baseDir.path}")),
|
||||
);
|
||||
} catch (e) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
const SnackBar(content: Text("Download failed")),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,99 +1,118 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import '../services/dio_client.dart';
|
||||
import '../screens/chat_file_viewer.dart';
|
||||
import '../widgets/chat_file_actions.dart';
|
||||
|
||||
class ChatFilePreview extends StatelessWidget {
|
||||
final String filePath;
|
||||
final String fileType;
|
||||
final bool isUser;
|
||||
|
||||
final bool isLocal;
|
||||
|
||||
const ChatFilePreview({
|
||||
super.key,
|
||||
required this.filePath,
|
||||
required this.fileType,
|
||||
required this.isUser,
|
||||
this.isLocal = false,
|
||||
});
|
||||
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final url = "${DioClient.baseUrl}/storage/$filePath";
|
||||
final textColor = isUser ? Colors.white : Colors.black;
|
||||
|
||||
// IMAGE PREVIEW
|
||||
if (fileType.startsWith('image/')) {
|
||||
return GestureDetector(
|
||||
onTap: () => ChatFileViewer.open(
|
||||
context,
|
||||
url: url,
|
||||
fileType: fileType,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
url,
|
||||
width: 180,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
// LOCAL IMAGE (uploading preview)
|
||||
if (isLocal && fileType.startsWith('image/')) {
|
||||
return Image.file(
|
||||
File(filePath),
|
||||
width: 180,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
// VIDEO PREVIEW
|
||||
if (fileType.startsWith('video/')) {
|
||||
return GestureDetector(
|
||||
onTap: () => ChatFileViewer.open(
|
||||
context,
|
||||
url: url,
|
||||
fileType: fileType,
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 180,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.videocam,
|
||||
size: 50,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_fill,
|
||||
size: 56,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// PDF / FILE PREVIEW
|
||||
return GestureDetector(
|
||||
onTap: () => ChatFileViewer.open(
|
||||
context,
|
||||
url: url,
|
||||
fileType: fileType,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_fileIcon(fileType), color: textColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_fileLabel(fileType),
|
||||
style: TextStyle(color: textColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () {
|
||||
// ✅ TAP = OPEN
|
||||
ChatFileViewer.open(
|
||||
context,
|
||||
url: url,
|
||||
fileType: fileType,
|
||||
);
|
||||
},
|
||||
onLongPress: () {
|
||||
// ✅ LONG PRESS = OPEN / DOWNLOAD
|
||||
ChatFileActions.show(
|
||||
context,
|
||||
url: url,
|
||||
fileType: fileType,
|
||||
);
|
||||
},
|
||||
child: _buildPreviewUI(textColor, url),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPreviewUI(Color textColor, String url) {
|
||||
// IMAGE
|
||||
if (fileType.startsWith('image/')) {
|
||||
return ClipRRect(
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
child: Image.network(
|
||||
url,
|
||||
width: 180,
|
||||
height: 120,
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// VIDEO
|
||||
if (fileType.startsWith('video/')) {
|
||||
return Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Container(
|
||||
width: 180,
|
||||
height: 120,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.black26,
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.videocam,
|
||||
size: 50,
|
||||
color: Colors.white70,
|
||||
),
|
||||
),
|
||||
const Icon(
|
||||
Icons.play_circle_fill,
|
||||
size: 56,
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// PDF / OTHER FILES
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(_fileIcon(fileType), color: textColor),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
_fileLabel(fileType),
|
||||
style: TextStyle(color: textColor),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
IconData _fileIcon(String type) {
|
||||
if (type == 'application/pdf') return Icons.picture_as_pdf;
|
||||
if (type.contains('excel')) return Icons.table_chart;
|
||||
|
||||
@@ -16,7 +16,6 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
bool s1 = true;
|
||||
bool s2 = false;
|
||||
bool s3 = false;
|
||||
bool s4 = false;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
@@ -27,8 +26,12 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
duration: const Duration(milliseconds: 350),
|
||||
);
|
||||
|
||||
_slideAnimation = Tween(begin: const Offset(0, -0.1), end: Offset.zero)
|
||||
.animate(CurvedAnimation(parent: _controller, curve: Curves.easeOut));
|
||||
_slideAnimation = Tween(
|
||||
begin: const Offset(0, -0.1),
|
||||
end: Offset.zero,
|
||||
).animate(
|
||||
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
|
||||
);
|
||||
|
||||
_controller.forward();
|
||||
}
|
||||
@@ -40,13 +43,93 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// HEADER SUMMARY CARD
|
||||
// STATUS BADGE (WHITE + GRADIENT BORDER)
|
||||
// ------------------------------------------------------------------
|
||||
Widget statusBadge(String status, double scale) {
|
||||
final s = status.toLowerCase();
|
||||
|
||||
LinearGradient gradient;
|
||||
Color textColor;
|
||||
|
||||
switch (s) {
|
||||
case 'paid':
|
||||
gradient = const LinearGradient(
|
||||
colors: [Color(0xFF2ECC71), Color(0xFF27AE60)],
|
||||
);
|
||||
textColor = const Color(0xFF27AE60);
|
||||
break;
|
||||
|
||||
case 'pending':
|
||||
gradient = const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFF5B8DEF), // Soft Blue
|
||||
Color(0xFF7B5CFA), // Purple Blue
|
||||
],
|
||||
);
|
||||
textColor = const Color(0xFF5B8DEF);
|
||||
break;
|
||||
|
||||
case 'overdue':
|
||||
gradient = const LinearGradient(
|
||||
colors: [
|
||||
Color(0xFFFFB300), // Amber
|
||||
Color(0xFFFF6F00), // Deep Orange
|
||||
],
|
||||
);
|
||||
textColor = const Color(0xFFFF6F00);
|
||||
break;
|
||||
|
||||
case 'cancelled':
|
||||
case 'failed':
|
||||
gradient = const LinearGradient(
|
||||
colors: [Color(0xFFE74C3C), Color(0xFFC0392B)],
|
||||
);
|
||||
textColor = const Color(0xFFE74C3C);
|
||||
break;
|
||||
|
||||
default:
|
||||
gradient = const LinearGradient(
|
||||
colors: [Color(0xFF95A5A6), Color(0xFF7F8C8D)],
|
||||
);
|
||||
textColor = Colors.grey.shade700;
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(1.5),
|
||||
decoration: BoxDecoration(
|
||||
gradient: gradient,
|
||||
borderRadius: BorderRadius.circular(20 * scale),
|
||||
),
|
||||
child: Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: 14 * scale,
|
||||
vertical: 6 * scale,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white,
|
||||
borderRadius: BorderRadius.circular(18 * scale),
|
||||
),
|
||||
child: Text(
|
||||
status.toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontSize: 12 * scale,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: textColor,
|
||||
letterSpacing: .6,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// HEADER CARD
|
||||
// ------------------------------------------------------------------
|
||||
Widget headerCard(Map invoice, double scale) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(16 * scale), // tighter
|
||||
margin: EdgeInsets.only(bottom: 14 * scale), // closer
|
||||
padding: EdgeInsets.all(16 * scale),
|
||||
margin: EdgeInsets.only(bottom: 14 * scale),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.blue.shade400, Colors.indigo.shade600],
|
||||
@@ -76,40 +159,34 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
"Date: ${invoice['invoice_date']}",
|
||||
style: TextStyle(color: Colors.white70, fontSize: 13 * scale),
|
||||
),
|
||||
SizedBox(height: 8 * scale),
|
||||
Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
vertical: 5 * scale, horizontal: 12 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.white.withOpacity(.2),
|
||||
borderRadius: BorderRadius.circular(40 * scale),
|
||||
),
|
||||
child: Text(
|
||||
invoice['status'] ?? "Unknown",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 13 * scale,
|
||||
),
|
||||
),
|
||||
)
|
||||
SizedBox(height: 10 * scale),
|
||||
|
||||
/// STATUS BADGE
|
||||
statusBadge(invoice['status'] ?? 'Unknown', scale),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SECTION HEADER (Closer Spacing)
|
||||
// SECTION HEADER
|
||||
// ------------------------------------------------------------------
|
||||
Widget sectionHeader(
|
||||
String title, IconData icon, bool expanded, Function() tap, double scale) {
|
||||
String title,
|
||||
IconData icon,
|
||||
bool expanded,
|
||||
VoidCallback tap,
|
||||
double scale,
|
||||
) {
|
||||
return GestureDetector(
|
||||
onTap: tap,
|
||||
child: Container(
|
||||
padding: EdgeInsets.all(12 * scale), // tighter
|
||||
margin: EdgeInsets.only(bottom: 8 * scale), // closer
|
||||
padding: EdgeInsets.all(12 * scale),
|
||||
margin: EdgeInsets.only(bottom: 8 * scale),
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: [Colors.indigo.shade500, Colors.blue.shade400]),
|
||||
colors: [Colors.indigo.shade500, Colors.blue.shade400],
|
||||
),
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
@@ -126,16 +203,20 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
Text(
|
||||
title,
|
||||
style: TextStyle(
|
||||
fontSize: 15 * scale,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold),
|
||||
fontSize: 15 * scale,
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
AnimatedRotation(
|
||||
turns: expanded ? .5 : 0,
|
||||
duration: const Duration(milliseconds: 250),
|
||||
child: Icon(Icons.keyboard_arrow_down,
|
||||
color: Colors.white, size: 20 * scale),
|
||||
child: Icon(
|
||||
Icons.keyboard_arrow_down,
|
||||
color: Colors.white,
|
||||
size: 20 * scale,
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -144,7 +225,7 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// SECTION BODY (Closer Spacing)
|
||||
// SECTION BODY
|
||||
// ------------------------------------------------------------------
|
||||
Widget sectionBody(bool visible, List<Widget> children, double scale) {
|
||||
return AnimatedCrossFade(
|
||||
@@ -155,17 +236,17 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
secondChild: SlideTransition(
|
||||
position: _slideAnimation,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: EdgeInsets.all(14 * scale), // tighter
|
||||
margin: EdgeInsets.only(bottom: 10 * scale), // closer
|
||||
padding: EdgeInsets.all(14 * scale),
|
||||
margin: EdgeInsets.only(bottom: 10 * scale),
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(12 * scale),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 7 * scale,
|
||||
offset: Offset(0, 2 * scale),
|
||||
color: Colors.black.withOpacity(.07))
|
||||
blurRadius: 7 * scale,
|
||||
offset: Offset(0, 2 * scale),
|
||||
color: Colors.black.withOpacity(.07),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(children: children),
|
||||
@@ -175,11 +256,11 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// DETAIL ROW (Closer Spacing)
|
||||
// DETAIL ROW
|
||||
// ------------------------------------------------------------------
|
||||
Widget detailRow(IconData icon, String label, dynamic value, double scale) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 5 * scale), // closer
|
||||
padding: EdgeInsets.symmetric(vertical: 5 * scale),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18 * scale, color: Colors.blueGrey),
|
||||
@@ -196,11 +277,18 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
),
|
||||
Expanded(
|
||||
flex: 4,
|
||||
child: Text(
|
||||
child: label == "Status"
|
||||
? Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: statusBadge(value ?? 'Unknown', scale),
|
||||
)
|
||||
: Text(
|
||||
value?.toString() ?? "N/A",
|
||||
textAlign: TextAlign.right,
|
||||
style:
|
||||
TextStyle(fontSize: 14 * scale, fontWeight: FontWeight.bold),
|
||||
style: TextStyle(
|
||||
fontSize: 14 * scale,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
)
|
||||
],
|
||||
@@ -208,60 +296,6 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TIMELINE (Closer Spacing)
|
||||
// ------------------------------------------------------------------
|
||||
Widget invoiceTimeline(double scale) {
|
||||
final steps = ["Invoice Created", "Payment Received", "Out for Delivery", "Completed"];
|
||||
final icons = [Icons.receipt_long, Icons.payments, Icons.local_shipping, Icons.verified];
|
||||
|
||||
return Container(
|
||||
padding: EdgeInsets.all(16 * scale),
|
||||
margin: EdgeInsets.only(bottom: 16 * scale), // closer
|
||||
decoration: BoxDecoration(
|
||||
color: Theme.of(context).cardColor,
|
||||
borderRadius: BorderRadius.circular(14 * scale),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
blurRadius: 7 * scale,
|
||||
color: Colors.black.withOpacity(.1),
|
||||
offset: Offset(0, 3 * scale),
|
||||
)
|
||||
],
|
||||
),
|
||||
child: Column(
|
||||
children: List.generate(steps.length, (i) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.symmetric(vertical: 4 * scale), // closer
|
||||
child: Row(
|
||||
children: [
|
||||
Column(
|
||||
children: [
|
||||
Icon(icons[i], size: 22 * scale, color: Colors.indigo),
|
||||
if (i < steps.length - 1)
|
||||
Container(
|
||||
height: 28 * scale,
|
||||
width: 2 * scale,
|
||||
color: Colors.indigo.shade300,
|
||||
)
|
||||
],
|
||||
),
|
||||
SizedBox(width: 10 * scale),
|
||||
Expanded(
|
||||
child: Text(
|
||||
steps[i],
|
||||
style:
|
||||
TextStyle(fontSize: 14 * scale, fontWeight: FontWeight.w600),
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// MAIN BUILD
|
||||
// ------------------------------------------------------------------
|
||||
@@ -272,21 +306,30 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
final scale = (width / 390).clamp(0.85, 1.25);
|
||||
|
||||
return ListView(
|
||||
padding: EdgeInsets.all(14 * scale), // slightly tighter
|
||||
padding: EdgeInsets.all(14 * scale),
|
||||
children: [
|
||||
headerCard(invoice, scale),
|
||||
invoiceTimeline(scale),
|
||||
|
||||
sectionHeader("Invoice Summary", Icons.receipt_long, s1,
|
||||
() => setState(() => s1 = !s1), scale),
|
||||
sectionHeader(
|
||||
"Invoice Summary",
|
||||
Icons.receipt_long,
|
||||
s1,
|
||||
() => setState(() => s1 = !s1),
|
||||
scale,
|
||||
),
|
||||
sectionBody(s1, [
|
||||
detailRow(Icons.numbers, "Invoice No", invoice['invoice_number'], scale),
|
||||
detailRow(Icons.calendar_today, "Date", invoice['invoice_date'], scale),
|
||||
detailRow(Icons.label, "Status", invoice['status'], scale),
|
||||
], scale),
|
||||
|
||||
sectionHeader("Customer Details", Icons.person, s2,
|
||||
() => setState(() => s2 = !s2), scale),
|
||||
sectionHeader(
|
||||
"Customer Details",
|
||||
Icons.person,
|
||||
s2,
|
||||
() => setState(() => s2 = !s2),
|
||||
scale,
|
||||
),
|
||||
sectionBody(s2, [
|
||||
detailRow(Icons.person_outline, "Name", invoice['customer_name'], scale),
|
||||
detailRow(Icons.mail, "Email", invoice['customer_email'], scale),
|
||||
@@ -294,21 +337,19 @@ class _InvoiceDetailViewState extends State<InvoiceDetailView>
|
||||
detailRow(Icons.location_on, "Address", invoice['customer_address'], scale),
|
||||
], scale),
|
||||
|
||||
sectionHeader("Amount Details", Icons.currency_rupee, s3,
|
||||
() => setState(() => s3 = !s3), scale),
|
||||
sectionHeader(
|
||||
"Amount Details",
|
||||
Icons.currency_rupee,
|
||||
s3,
|
||||
() => setState(() => s3 = !s3),
|
||||
scale,
|
||||
),
|
||||
sectionBody(s3, [
|
||||
detailRow(Icons.money, "Amount", invoice['final_amount'], scale),
|
||||
detailRow(Icons.percent, "GST %", invoice['gst_percent'], scale),
|
||||
detailRow(Icons.percent, "GST Amount", invoice['gst_amount'], scale),
|
||||
detailRow(Icons.summarize, "Total", invoice['final_amount_with_gst'], scale),
|
||||
], scale),
|
||||
|
||||
sectionHeader("Payment Details", Icons.payment, s4,
|
||||
() => setState(() => s4 = !s4), scale),
|
||||
sectionBody(s4, [
|
||||
detailRow(Icons.credit_card, "Method", invoice['payment_method'], scale),
|
||||
detailRow(Icons.confirmation_number, "Reference",
|
||||
invoice['reference_no'], scale),
|
||||
], scale),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
48
pubspec.lock
48
pubspec.lock
@@ -544,6 +544,54 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.11.3"
|
||||
permission_handler:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: permission_handler
|
||||
sha256: "59adad729136f01ea9e35a48f5d1395e25cba6cea552249ddbe9cf950f5d7849"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "11.4.0"
|
||||
permission_handler_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_android
|
||||
sha256: d3971dcdd76182a0c198c096b5db2f0884b0d4196723d21a866fc4cdea057ebc
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "12.1.0"
|
||||
permission_handler_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_apple
|
||||
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "9.4.7"
|
||||
permission_handler_html:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_html
|
||||
sha256: "38f000e83355abb3392140f6bc3030660cfaef189e1f87824facb76300b4ff24"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.1.3+5"
|
||||
permission_handler_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_platform_interface
|
||||
sha256: eb99b295153abce5d683cac8c02e22faab63e50679b937fa1bf67d58bb282878
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.3.0"
|
||||
permission_handler_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: permission_handler_windows
|
||||
sha256: "1a790728016f79a41216d88672dbc5df30e686e811ad4e698bfc51f76ad91f1e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.1"
|
||||
petitparser:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
||||
@@ -49,7 +49,7 @@ dependencies:
|
||||
video_player: ^2.9.1
|
||||
chewie: ^1.8.1
|
||||
flutter_pdfview: ^1.3.2
|
||||
|
||||
permission_handler: ^11.3.0
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -7,12 +7,15 @@
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <file_selector_windows/file_selector_windows.h>
|
||||
#include <permission_handler_windows/permission_handler_windows_plugin.h>
|
||||
#include <share_plus/share_plus_windows_plugin_c_api.h>
|
||||
#include <url_launcher_windows/url_launcher_windows.h>
|
||||
|
||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||
FileSelectorWindowsRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("FileSelectorWindows"));
|
||||
PermissionHandlerWindowsPluginRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("PermissionHandlerWindowsPlugin"));
|
||||
SharePlusWindowsPluginCApiRegisterWithRegistrar(
|
||||
registry->GetRegistrarForPlugin("SharePlusWindowsPluginCApi"));
|
||||
UrlLauncherWindowsRegisterWithRegistrar(
|
||||
|
||||
@@ -4,6 +4,7 @@
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
file_selector_windows
|
||||
permission_handler_windows
|
||||
share_plus
|
||||
url_launcher_windows
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user