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.READ_EXTERNAL_STORAGE" />
<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
android:label="kent_logistics_app"

View File

@@ -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()
}
}
}
}

View File

@@ -1,3 +1,3 @@
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";
// 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?
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> {
final TextEditingController _messageCtrl = TextEditingController();
final ScrollController _scrollCtrl = ScrollController();
Map<String, dynamic>? uploadingMessage;
late ChatService _chatService;
final ReverbSocketService _socket = ReverbSocketService();
@@ -48,18 +49,54 @@ class _ChatScreenState extends State<ChatScreen> {
_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 {
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
// ============================
@@ -77,10 +114,23 @@ 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();
@@ -112,16 +162,35 @@ 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,
);
}
// ============================
// DISPOSE
// ============================
@@ -226,47 +295,83 @@ class _ChatScreenState extends State<ChatScreen> {
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
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,
),
),
],
),
)
: ChatFilePreview(
filePath: filePath,
fileType: fileType ?? '',
isUser: isUser,
),
),
);
},
],
);
}
Widget _buildInput() {
return SafeArea(
child: Row(

View File

@@ -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']);
}
}

View File

@@ -8,7 +8,7 @@ import 'token_interceptor.dart';
class DioClient {
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) {
if (_dio == null) {

View File

@@ -36,7 +36,7 @@ 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',
);
@@ -60,6 +60,7 @@ class ReverbSocketService {
Future<void> _handleMessage(dynamic raw) async {
debugPrint("📥 RAW: $raw");
final payload = jsonDecode(raw);
final event = payload['event']?.toString() ?? '';
@@ -91,13 +92,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 +109,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;
}
}

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,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;