chat support download updated
This commit is contained in:
File diff suppressed because one or more lines are too long
@@ -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"
|
||||||
|
|||||||
@@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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']);
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
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,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user