chat support download updated

This commit is contained in:
Abhishek Mali
2025-12-18 11:03:25 +05:30
parent bbde34fae4
commit b9fb9455e7
11 changed files with 416 additions and 131 deletions

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,10 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
<uses-permission android:name="android.permission.INTERNET"/> <uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE"
android:maxSdkVersion="28" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO"/>
<application <application
android:label="kent_logistics_app" android:label="kent_logistics_app"

View File

@@ -1,5 +1,37 @@
package com.example.kent_logistics_app package com.example.kent_logistics_app
import android.media.MediaScannerConnection
import io.flutter.embedding.android.FlutterActivity 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()
}
}
}
}

View File

@@ -1,3 +1,3 @@
class ApiConfig { class ApiConfig {
static const String baseUrl = "http://10.11.236.74:8000/api"; static const String baseUrl = "http://10.119.0.74:8000/api";
} }

View File

@@ -6,7 +6,7 @@ class AppConfig {
static const String logoUrlEmulator = "http://10.0.2.2:8000/images/kent_logo2.png"; static const String logoUrlEmulator = "http://10.0.2.2:8000/images/kent_logo2.png";
// For Physical Device (Replace with your actual PC local IP) // 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://10.119.0.74:8000/images/kent_logo2.png";
// Which one to use? // Which one to use?
static const String logoUrl = logoUrlDevice; // CHANGE THIS WHEN TESTING ON REAL DEVICE static const String logoUrl = logoUrlDevice; // CHANGE THIS WHEN TESTING ON REAL DEVICE

View File

@@ -24,6 +24,7 @@ class ChatScreen extends StatefulWidget {
class _ChatScreenState extends State<ChatScreen> { class _ChatScreenState extends State<ChatScreen> {
final TextEditingController _messageCtrl = TextEditingController(); final TextEditingController _messageCtrl = TextEditingController();
final ScrollController _scrollCtrl = ScrollController(); final ScrollController _scrollCtrl = ScrollController();
Map<String, dynamic>? uploadingMessage;
late ChatService _chatService; late ChatService _chatService;
final ReverbSocketService _socket = ReverbSocketService(); final ReverbSocketService _socket = ReverbSocketService();
@@ -48,18 +49,54 @@ class _ChatScreenState extends State<ChatScreen> {
_initChat(); _initChat();
} }
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 { Future<void> _pickAndSendFile() async {
final XFile? file = await openFile(); final XFile? picked = await openFile();
if (picked == null || ticketId == null) return;
if (file == null || ticketId == null) return; final file = File(picked.path);
final dartFile = File(file.path); // 1⃣ Show uploading UI
setState(() {
uploadingMessage = {
'local_file': file,
'file_type': _guessMimeType(file.path),
'progress': 0.0,
};
});
await _chatService.sendFile(ticketId!, dartFile); // 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 // INIT CHAT
// ============================ // ============================
@@ -77,10 +114,23 @@ class _ChatScreenState extends State<ChatScreen> {
context: context, context: context,
ticketId: ticketId!, ticketId: ticketId!,
onMessage: (msg) { onMessage: (msg) {
if (!mounted) return; final incomingClientId = msg['client_id'];
setState(() => messages.add(msg));
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(); _scrollToBottom();
}, },
onAdminMessage: () { onAdminMessage: () {
if (!mounted) { if (!mounted) {
context.read<ChatUnreadProvider>().increment(); context.read<ChatUnreadProvider>().increment();
@@ -112,16 +162,35 @@ class _ChatScreenState extends State<ChatScreen> {
// ============================ // ============================
Future<void> _sendMessage() async { Future<void> _sendMessage() async {
final text = _messageCtrl.text.trim(); final text = _messageCtrl.text.trim();
if (text.isEmpty) return; if (text.isEmpty || ticketId == null) return;
_messageCtrl.clear(); _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( await _chatService.sendMessage(
ticketId!, ticketId!,
message: text, message: text,
clientId: clientId,
); );
} }
// ============================ // ============================
// DISPOSE // DISPOSE
// ============================ // ============================
@@ -226,18 +295,14 @@ class _ChatScreenState extends State<ChatScreen> {
Widget _buildMessages() { Widget _buildMessages() {
return ListView.builder( return ListView(
controller: _scrollCtrl, controller: _scrollCtrl,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
itemCount: messages.length, children: [
itemBuilder: (_, index) { // EXISTING MESSAGES
final msg = messages[index]; ...messages.map((msg) {
final isUser = msg['sender_type'] == 'App\\Models\\User'; 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( return Align(
alignment: isUser ? Alignment.centerRight : Alignment.centerLeft, alignment: isUser ? Alignment.centerRight : Alignment.centerLeft,
child: Container( child: Container(
@@ -247,26 +312,66 @@ class _ChatScreenState extends State<ChatScreen> {
color: isUser ? Colors.blue : Colors.grey.shade300, color: isUser ? Colors.blue : Colors.grey.shade300,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(12),
), ),
child: msg['file_path'] == null
// 🔽 ONLY THIS PART CHANGED
child: filePath == null
? Text( ? Text(
message ?? '', msg['message'] ?? '',
style: TextStyle( style: TextStyle(
color: isUser ? Colors.white : Colors.black, color: isUser ? Colors.white : Colors.black,
), ),
) )
: ChatFilePreview( : ChatFilePreview(
filePath: filePath, filePath: msg['file_path'],
fileType: fileType ?? '', fileType: msg['file_type'] ?? '',
isUser: isUser, isUser: isUser,
), ),
), ),
); );
}, }),
// ⏳ UPLOADING MESSAGE
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: [
ChatFilePreview(
filePath: uploadingMessage!['local_file'].path,
fileType: uploadingMessage!['file_type'],
isUser: true,
isLocal: true,
),
const SizedBox(height: 8),
LinearProgressIndicator(
value: uploadingMessage!['progress'],
backgroundColor: Colors.white24,
valueColor:
const AlwaysStoppedAnimation(Colors.white),
),
const SizedBox(height: 4),
const Text(
"Sending…",
style: TextStyle(
color: Colors.white,
fontSize: 12,
),
),
],
),
),
),
],
); );
} }
Widget _buildInput() { Widget _buildInput() {
return SafeArea( return SafeArea(
child: Row( child: Row(

View File

@@ -24,27 +24,25 @@ class ChatService {
Future<void> sendMessage( Future<void> sendMessage(
int ticketId, { int ticketId, {
String? message, String? message,
String? filePath, String? clientId,
}) async { }) async {
final form = FormData(); final form = FormData();
if (message != null) form.fields.add(MapEntry('message', message)); if (message != null) form.fields.add(MapEntry('message', message));
if (filePath != null) { if (clientId != null) form.fields.add(MapEntry('client_id', clientId));
form.files.add(
MapEntry(
'file',
await MultipartFile.fromFile(filePath),
),
);
}
await dio.post('/user/chat/send/$ticketId', data: form); await dio.post('/user/chat/send/$ticketId', data: form);
} }
// --------------------------- // ---------------------------
// SEND FILE (image/video/pdf/excel) // 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({ final formData = FormData.fromMap({
'file': await MultipartFile.fromFile( 'file': await MultipartFile.fromFile(
file.path, file.path,
@@ -52,13 +50,20 @@ class ChatService {
), ),
}); });
await dio.post( final res = await dio.post(
"/user/chat/send/$ticketId", "/user/chat/send/$ticketId",
data: formData, data: formData,
options: Options( options: Options(
headers: {'Content-Type': 'multipart/form-data'}, headers: {'Content-Type': 'multipart/form-data'},
), ),
onSendProgress: (sent, total) {
if (total > 0) {
onProgress(sent / total);
}
},
); );
return Map<String, dynamic>.from(res.data['message']);
} }
} }

View File

@@ -8,7 +8,7 @@ import 'token_interceptor.dart';
class DioClient { class DioClient {
static Dio? _dio; static Dio? _dio;
static const String baseUrl = "http://10.11.236.74:8000"; static const String baseUrl = "http://10.119.0.74:8000";
static Dio getInstance(BuildContext context) { static Dio getInstance(BuildContext context) {
if (_dio == null) { if (_dio == null) {

View File

@@ -36,7 +36,7 @@ class ReverbSocketService {
_onAdminMessage = onAdminMessage; // 👈 SAVE _onAdminMessage = onAdminMessage; // 👈 SAVE
final uri = Uri.parse( 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', '?protocol=7&client=flutter&version=1.0',
); );
@@ -60,6 +60,7 @@ class ReverbSocketService {
Future<void> _handleMessage(dynamic raw) async { Future<void> _handleMessage(dynamic raw) async {
debugPrint("📥 RAW: $raw"); debugPrint("📥 RAW: $raw");
final payload = jsonDecode(raw); final payload = jsonDecode(raw);
final event = payload['event']?.toString() ?? ''; final event = payload['event']?.toString() ?? '';
@@ -91,13 +92,15 @@ class ReverbSocketService {
event == 'NewChatMessage' || event == 'NewChatMessage' ||
event.endsWith('.NewChatMessage') || event.endsWith('.NewChatMessage') ||
event.contains('NewChatMessage')) { event.contains('NewChatMessage')) {
dynamic data = payload['data']; dynamic data = payload['data'];
if (data is String) data = jsonDecode(data); if (data is String) data = jsonDecode(data);
final int msgId = data['id']; final int msgId = data['id'];
final String senderType = data['sender_type'] ?? ''; final String senderType = data['sender_type'] ?? '';
final String? incomingClientId = data['client_id']; // ✅ HERE
// 🔁 Prevent duplicates // 🔁 Prevent duplicates by DB id
if (_receivedIds.contains(msgId)) { if (_receivedIds.contains(msgId)) {
debugPrint("🔁 DUPLICATE MESSAGE IGNORED: $msgId"); debugPrint("🔁 DUPLICATE MESSAGE IGNORED: $msgId");
return; return;
@@ -106,20 +109,19 @@ class ReverbSocketService {
debugPrint("📩 NEW MESSAGE"); debugPrint("📩 NEW MESSAGE");
debugPrint("🆔 id=$msgId"); debugPrint("🆔 id=$msgId");
debugPrint("🧩 client_id=$incomingClientId");
debugPrint("👤 sender=$senderType"); debugPrint("👤 sender=$senderType");
debugPrint("💬 text=${data['message']}"); debugPrint("💬 text=${data['message']}");
// Always push message to UI // ✅ Forward FULL payload (with client_id) to UI
_onMessage(Map<String, dynamic>.from(data)); _onMessage(Map<String, dynamic>.from(data));
// 🔔 Increment unread ONLY if ADMIN sent message // 🔔 Unread count only for admin messages
// 🔔 Increment unread ONLY if ADMIN sent message
if (senderType == 'App\\Models\\Admin') { if (senderType == 'App\\Models\\Admin') {
debugPrint("🔔 ADMIN MESSAGE → UNREAD +1"); debugPrint("🔔 ADMIN MESSAGE → UNREAD +1");
_onAdminMessage(); // ✅ ACTUAL INCREMENT _onAdminMessage();
} }
return; return;
} }
} }

View 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")),
);
}
}
}

View File

@@ -1,33 +1,66 @@
import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/dio_client.dart'; import '../services/dio_client.dart';
import '../screens/chat_file_viewer.dart'; import '../screens/chat_file_viewer.dart';
import '../widgets/chat_file_actions.dart';
class ChatFilePreview extends StatelessWidget { class ChatFilePreview extends StatelessWidget {
final String filePath; final String filePath;
final String fileType; final String fileType;
final bool isUser; final bool isUser;
final bool isLocal;
const ChatFilePreview({ const ChatFilePreview({
super.key, super.key,
required this.filePath, required this.filePath,
required this.fileType, required this.fileType,
required this.isUser, required this.isUser,
this.isLocal = false,
}); });
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final url = "${DioClient.baseUrl}/storage/$filePath"; final url = "${DioClient.baseUrl}/storage/$filePath";
final textColor = isUser ? Colors.white : Colors.black; final textColor = isUser ? Colors.white : Colors.black;
// IMAGE PREVIEW // LOCAL IMAGE (uploading preview)
if (fileType.startsWith('image/')) { if (isLocal && fileType.startsWith('image/')) {
return Image.file(
File(filePath),
width: 180,
height: 120,
fit: BoxFit.cover,
);
}
return GestureDetector( return GestureDetector(
onTap: () => ChatFileViewer.open( onTap: () {
// ✅ TAP = OPEN
ChatFileViewer.open(
context, context,
url: url, url: url,
fileType: fileType, fileType: fileType,
), );
child: ClipRRect( },
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), borderRadius: BorderRadius.circular(8),
child: Image.network( child: Image.network(
url, url,
@@ -35,19 +68,12 @@ class ChatFilePreview extends StatelessWidget {
height: 120, height: 120,
fit: BoxFit.cover, fit: BoxFit.cover,
), ),
),
); );
} }
// VIDEO PREVIEW // VIDEO
if (fileType.startsWith('video/')) { if (fileType.startsWith('video/')) {
return GestureDetector( return Stack(
onTap: () => ChatFileViewer.open(
context,
url: url,
fileType: fileType,
),
child: Stack(
alignment: Alignment.center, alignment: Alignment.center,
children: [ children: [
Container( Container(
@@ -69,18 +95,11 @@ class ChatFilePreview extends StatelessWidget {
color: Colors.white, color: Colors.white,
), ),
], ],
),
); );
} }
// PDF / FILE PREVIEW // PDF / OTHER FILES
return GestureDetector( return Row(
onTap: () => ChatFileViewer.open(
context,
url: url,
fileType: fileType,
),
child: Row(
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
children: [ children: [
Icon(_fileIcon(fileType), color: textColor), Icon(_fileIcon(fileType), color: textColor),
@@ -90,10 +109,10 @@ class ChatFilePreview extends StatelessWidget {
style: TextStyle(color: textColor), style: TextStyle(color: textColor),
), ),
], ],
),
); );
} }
IconData _fileIcon(String type) { IconData _fileIcon(String type) {
if (type == 'application/pdf') return Icons.picture_as_pdf; if (type == 'application/pdf') return Icons.picture_as_pdf;
if (type.contains('excel')) return Icons.table_chart; if (type.contains('excel')) return Icons.table_chart;