本次提交包含多项迭代优化和问题修复: 1. 新增缩略图图片组件、数字格式化工具类,补充多语言翻译类型与本地化支持 2. 优化底部导航栏主题色统一使用动态accent色值 3. 修复多处图表动画、路由跳转、API请求相关问题 4. 简化服务器公告文案,调整默认分屏状态为关闭 5. 新增安卓/iOS桌面快捷方式配置 6. 重构多处状态管理类使用SafeNotifierInit统一异常保护 7. 替换硬编码蓝色为主题色,更新版本号获取方式为动态读取 8. 优化缓存预加载逻辑,移除无用代码 9. 调整默认设置项,优化用户体验细节
412 lines
13 KiB
Dart
412 lines
13 KiB
Dart
/// ============================================================
|
|
/// 闲言APP — 聊天附件Provider
|
|
/// 创建时间: 2026-05-08
|
|
/// 更新时间: 2026-05-30
|
|
/// 作用: 附件选择/预览/发送状态管理
|
|
/// 上次更新: 使用SafeNotifierInit统一异常保护
|
|
/// ============================================================
|
|
|
|
import 'dart:io';
|
|
|
|
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
|
import 'package:file_picker/file_picker.dart';
|
|
import 'package:image_picker/image_picker.dart';
|
|
|
|
import 'package:xianyan/core/storage/database/app_database.dart';
|
|
import 'package:xianyan/core/utils/logger.dart';
|
|
import 'package:xianyan/core/utils/safe_init_mixin.dart';
|
|
import 'package:xianyan/features/discover/services/chat_attachment_service.dart';
|
|
import 'package:xianyan/features/discover/services/chat_file_service.dart';
|
|
import 'package:xianyan/features/discover/services/chat_message_service.dart';
|
|
|
|
class PendingAttachment {
|
|
const PendingAttachment({
|
|
required this.fileName,
|
|
required this.fileSize,
|
|
required this.fileType,
|
|
this.filePath,
|
|
this.thumbnailPath,
|
|
this.width,
|
|
this.height,
|
|
this.durationMs,
|
|
});
|
|
|
|
final String fileName;
|
|
final int fileSize;
|
|
final String fileType;
|
|
final String? filePath;
|
|
final String? thumbnailPath;
|
|
final int? width;
|
|
final int? height;
|
|
final int? durationMs;
|
|
|
|
bool get isImage => ChatFileService.isImageType(fileType);
|
|
bool get isVideo => ChatFileService.isVideoType(fileType);
|
|
bool get isAudio => ChatFileService.isAudioType(fileType);
|
|
|
|
String get sizeText => ChatFileService.formatFileSize(fileSize);
|
|
}
|
|
|
|
class ChatAttachmentState {
|
|
const ChatAttachmentState({
|
|
this.pendingAttachments = const [],
|
|
this.isSending = false,
|
|
this.attachments = const [],
|
|
this.totalSize = 0,
|
|
});
|
|
|
|
final List<PendingAttachment> pendingAttachments;
|
|
final bool isSending;
|
|
final List<ChatAttachment> attachments;
|
|
final int totalSize;
|
|
|
|
String get totalSizeText => ChatFileService.formatFileSize(totalSize);
|
|
|
|
bool get hasPending => pendingAttachments.isNotEmpty;
|
|
|
|
ChatAttachmentState copyWith({
|
|
List<PendingAttachment>? pendingAttachments,
|
|
bool? isSending,
|
|
List<ChatAttachment>? attachments,
|
|
int? totalSize,
|
|
}) {
|
|
return ChatAttachmentState(
|
|
pendingAttachments: pendingAttachments ?? this.pendingAttachments,
|
|
isSending: isSending ?? this.isSending,
|
|
attachments: attachments ?? this.attachments,
|
|
totalSize: totalSize ?? this.totalSize,
|
|
);
|
|
}
|
|
}
|
|
|
|
class ChatAttachmentNotifier extends Notifier<ChatAttachmentState>
|
|
with SafeNotifierInit {
|
|
@override
|
|
ChatAttachmentState build() {
|
|
safeNotifierInit(_init, label: 'ChatAttachmentNotifier');
|
|
return const ChatAttachmentState();
|
|
}
|
|
|
|
ChatAttachmentNotifier(this.conversationId);
|
|
|
|
final String conversationId;
|
|
|
|
Future<void> _init() async {
|
|
await loadAttachments();
|
|
}
|
|
|
|
/// 加载会话附件列表
|
|
Future<void> loadAttachments() async {
|
|
try {
|
|
final atts = await ChatAttachmentService.getByConversation(
|
|
conversationId,
|
|
);
|
|
final size = await ChatFileService.getConversationFilesSize(
|
|
conversationId,
|
|
);
|
|
state = state.copyWith(attachments: atts, totalSize: size);
|
|
} catch (e) {
|
|
Log.e('附件列表加载失败', e);
|
|
}
|
|
}
|
|
|
|
/// 选择图片(相册)
|
|
Future<void> pickImageFromGallery() async {
|
|
try {
|
|
final picker = ImagePicker();
|
|
final images = await picker.pickMultiImage();
|
|
for (final image in images) {
|
|
final file = File(image.path);
|
|
final size = await file.length();
|
|
final result = await ChatFileService.saveImage(
|
|
conversationId: conversationId,
|
|
sourceFile: file,
|
|
fileName: image.name,
|
|
);
|
|
state = state.copyWith(
|
|
pendingAttachments: [
|
|
...state.pendingAttachments,
|
|
PendingAttachment(
|
|
fileName: image.name,
|
|
fileSize: size,
|
|
fileType: 'image/${_getExtension(image.name)}',
|
|
filePath: result.filePath,
|
|
thumbnailPath: result.thumbnailPath,
|
|
width: result.width,
|
|
height: result.height,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
Log.d('已选择${images.length}张图片');
|
|
} catch (e) {
|
|
Log.e('选择图片失败', e);
|
|
}
|
|
}
|
|
|
|
/// 选择图片(拍照)
|
|
Future<void> pickImageFromCamera() async {
|
|
try {
|
|
final picker = ImagePicker();
|
|
final image = await picker.pickImage(source: ImageSource.camera);
|
|
if (image == null) return;
|
|
final file = File(image.path);
|
|
final size = await file.length();
|
|
final result = await ChatFileService.saveImage(
|
|
conversationId: conversationId,
|
|
sourceFile: file,
|
|
fileName: image.name,
|
|
);
|
|
state = state.copyWith(
|
|
pendingAttachments: [
|
|
...state.pendingAttachments,
|
|
PendingAttachment(
|
|
fileName: image.name,
|
|
fileSize: size,
|
|
fileType: 'image/${_getExtension(image.name)}',
|
|
filePath: result.filePath,
|
|
thumbnailPath: result.thumbnailPath,
|
|
width: result.width,
|
|
height: result.height,
|
|
),
|
|
],
|
|
);
|
|
Log.d('拍照图片已添加');
|
|
} catch (e) {
|
|
Log.e('拍照失败', e);
|
|
}
|
|
}
|
|
|
|
/// 选择视频
|
|
Future<void> pickVideo() async {
|
|
try {
|
|
final result = await FilePicker.pickFiles(type: FileType.video);
|
|
if (result == null) return;
|
|
final platformFile = result.files.first;
|
|
if (platformFile.path == null) return;
|
|
final file = File(platformFile.path!);
|
|
final size = await file.length();
|
|
final savedPath = await ChatFileService.saveFile(
|
|
conversationId: conversationId,
|
|
sourceFile: file,
|
|
fileName: platformFile.name,
|
|
);
|
|
state = state.copyWith(
|
|
pendingAttachments: [
|
|
...state.pendingAttachments,
|
|
PendingAttachment(
|
|
fileName: platformFile.name,
|
|
fileSize: size,
|
|
fileType: 'video/${_getExtension(platformFile.name)}',
|
|
filePath: savedPath,
|
|
),
|
|
],
|
|
);
|
|
Log.d('视频已选择: ${platformFile.name}');
|
|
} catch (e) {
|
|
Log.e('选择视频失败', e);
|
|
}
|
|
}
|
|
|
|
/// 选择文件
|
|
Future<void> pickFile() async {
|
|
try {
|
|
final result = await FilePicker.pickFiles();
|
|
if (result == null) return;
|
|
for (final platformFile in result.files) {
|
|
if (platformFile.path == null) continue;
|
|
final file = File(platformFile.path!);
|
|
final size = await file.length();
|
|
final savedPath = await ChatFileService.saveFile(
|
|
conversationId: conversationId,
|
|
sourceFile: file,
|
|
fileName: platformFile.name,
|
|
);
|
|
state = state.copyWith(
|
|
pendingAttachments: [
|
|
...state.pendingAttachments,
|
|
PendingAttachment(
|
|
fileName: platformFile.name,
|
|
fileSize: size,
|
|
fileType: _getMimeType(platformFile.name),
|
|
filePath: savedPath,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
Log.d('已选择${result.files.length}个文件');
|
|
} catch (e) {
|
|
Log.e('选择文件失败', e);
|
|
}
|
|
}
|
|
|
|
/// 移除待发送附件
|
|
void removePendingAttachment(int index) {
|
|
final updated = [...state.pendingAttachments];
|
|
if (index >= 0 && index < updated.length) {
|
|
final removed = updated.removeAt(index);
|
|
if (removed.filePath != null) {
|
|
ChatFileService.deleteFile(removed.filePath!);
|
|
}
|
|
state = state.copyWith(pendingAttachments: updated);
|
|
}
|
|
}
|
|
|
|
/// 清空待发送附件
|
|
void clearPendingAttachments() {
|
|
for (final att in state.pendingAttachments) {
|
|
if (att.filePath != null) {
|
|
ChatFileService.deleteFile(att.filePath!);
|
|
}
|
|
}
|
|
state = state.copyWith(pendingAttachments: []);
|
|
}
|
|
|
|
/// 确认发送附件(创建消息+附件记录)
|
|
Future<void> commitAttachments(String messageId) async {
|
|
state = state.copyWith(isSending: true);
|
|
try {
|
|
for (final pending in state.pendingAttachments) {
|
|
if (pending.isImage) {
|
|
await ChatAttachmentService.saveImageAttachment(
|
|
messageId: messageId,
|
|
conversationId: conversationId,
|
|
fileName: pending.fileName,
|
|
filePath: pending.filePath!,
|
|
fileSize: pending.fileSize,
|
|
thumbnailPath: pending.thumbnailPath,
|
|
width: pending.width,
|
|
height: pending.height,
|
|
);
|
|
} else {
|
|
await ChatAttachmentService.saveFileAttachment(
|
|
messageId: messageId,
|
|
conversationId: conversationId,
|
|
fileName: pending.fileName,
|
|
filePath: pending.filePath!,
|
|
fileSize: pending.fileSize,
|
|
mimeType: pending.fileType,
|
|
);
|
|
}
|
|
}
|
|
state = state.copyWith(pendingAttachments: [], isSending: false);
|
|
await loadAttachments();
|
|
Log.d('附件发送完成: ${state.pendingAttachments.length}个');
|
|
} catch (e) {
|
|
Log.e('附件发送失败', e);
|
|
state = state.copyWith(isSending: false);
|
|
}
|
|
}
|
|
|
|
/// 发送所有待发送附件(自动创建消息)
|
|
Future<void> sendPendingAttachments() async {
|
|
if (state.pendingAttachments.isEmpty) return;
|
|
state = state.copyWith(isSending: true);
|
|
try {
|
|
for (final pending in state.pendingAttachments) {
|
|
if (pending.isImage) {
|
|
final record = await ChatMessageService.sendImage(
|
|
conversationId: conversationId,
|
|
content: pending.fileName,
|
|
meta: {
|
|
'fileName': pending.fileName,
|
|
'fileSize': pending.fileSize,
|
|
'filePath': pending.filePath,
|
|
'thumbnailPath': pending.thumbnailPath,
|
|
'width': pending.width,
|
|
'height': pending.height,
|
|
},
|
|
);
|
|
await ChatAttachmentService.saveImageAttachment(
|
|
messageId: record.id,
|
|
conversationId: conversationId,
|
|
fileName: pending.fileName,
|
|
filePath: pending.filePath!,
|
|
fileSize: pending.fileSize,
|
|
thumbnailPath: pending.thumbnailPath,
|
|
width: pending.width,
|
|
height: pending.height,
|
|
);
|
|
} else {
|
|
final record = await ChatMessageService.sendFile(
|
|
conversationId: conversationId,
|
|
content: pending.fileName,
|
|
meta: {
|
|
'fileName': pending.fileName,
|
|
'fileSize': pending.fileSize,
|
|
'filePath': pending.filePath,
|
|
'mimeType': pending.fileType,
|
|
},
|
|
);
|
|
await ChatAttachmentService.saveFileAttachment(
|
|
messageId: record.id,
|
|
conversationId: conversationId,
|
|
fileName: pending.fileName,
|
|
filePath: pending.filePath!,
|
|
fileSize: pending.fileSize,
|
|
mimeType: pending.fileType,
|
|
);
|
|
}
|
|
}
|
|
state = state.copyWith(pendingAttachments: [], isSending: false);
|
|
await loadAttachments();
|
|
Log.d('所有待发送附件已发送');
|
|
} catch (e) {
|
|
Log.e('发送待发送附件失败', e);
|
|
state = state.copyWith(isSending: false);
|
|
}
|
|
}
|
|
|
|
/// 删除附件
|
|
Future<void> deleteAttachment(String messageId) async {
|
|
await ChatAttachmentService.deleteByMessage(messageId);
|
|
await loadAttachments();
|
|
}
|
|
|
|
/// 删除会话所有附件
|
|
Future<void> deleteAllAttachments() async {
|
|
await ChatAttachmentService.deleteByConversation(conversationId);
|
|
await loadAttachments();
|
|
}
|
|
|
|
String _getExtension(String fileName) {
|
|
final dotIndex = fileName.lastIndexOf('.');
|
|
if (dotIndex >= 0 && dotIndex < fileName.length - 1) {
|
|
return fileName.substring(dotIndex + 1).toLowerCase();
|
|
}
|
|
return 'jpg';
|
|
}
|
|
|
|
String _getMimeType(String fileName) {
|
|
final ext = _getExtension(fileName);
|
|
const mimeMap = {
|
|
'pdf': 'application/pdf',
|
|
'doc': 'application/msword',
|
|
'docx':
|
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
|
'xls': 'application/vnd.ms-excel',
|
|
'xlsx':
|
|
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
|
'ppt': 'application/vnd.ms-powerpoint',
|
|
'pptx':
|
|
'application/vnd.openxmlformats-officedocument.presentationml.presentation',
|
|
'txt': 'text/plain',
|
|
'zip': 'application/zip',
|
|
'rar': 'application/x-rar-compressed',
|
|
'mp3': 'audio/mpeg',
|
|
'mp4': 'video/mp4',
|
|
'wav': 'audio/wav',
|
|
'avi': 'video/x-msvideo',
|
|
'mov': 'video/quicktime',
|
|
};
|
|
return mimeMap[ext] ?? 'application/octet-stream';
|
|
}
|
|
}
|
|
|
|
final chatAttachmentProvider =
|
|
NotifierProvider.family<
|
|
ChatAttachmentNotifier,
|
|
ChatAttachmentState,
|
|
String
|
|
>((conversationId) => ChatAttachmentNotifier(conversationId));
|