feat: 新增文件传输助手功能及相关组件

新增文件传输助手功能,包含设备发现、配对、传输等核心模块。主要变更包括:
1. 新增局域网、蓝牙、NFC等多种设备发现方式
2. 实现基于WebRTC、TCP、USB等多种传输协议
3. 添加相关权限管理及状态监控
4. 完善UI界面及交互流程
5. 更新依赖库及版本号至4.19.0

同时优化部分现有功能:
1. 聊天会话增加隐藏功能
2. 完善本地通知权限处理
3. 修复部分已知问题
This commit is contained in:
Developer
2026-05-10 02:48:52 +08:00
parent 41a60b0288
commit 72f64f9ca9
458 changed files with 56803 additions and 72785 deletions

View File

@@ -1,9 +1,9 @@
/// ============================================================
/// 闲言APP — Drift 数据库定义
/// 创建时间: 2026-04-20
/// 更新时间: 2026-04-28
/// 更新时间: 2026-05-08
/// 作用: 本地 SQLite 数据库表结构定义 (Drift)
/// 上次更新: 新增 HanziCache 汉字查询缓存表 (v4)
/// 上次更新: 新增传输表TransferDevices/TransferRecords/PairingRecords/TransferMessages (v12)
/// ============================================================
import 'package:drift/drift.dart';
@@ -203,6 +203,211 @@ class LearningRecords extends Table {
DateTimeColumn get completedAt => dateTime()();
}
// ============================================================
// 聊天会话表
// ============================================================
class ChatConversations extends Table {
TextColumn get id => text()();
TextColumn get emoji => text().withDefault(const Constant('💬'))();
TextColumn get name => text()();
TextColumn get description => text().nullable()();
TextColumn get bgImagePath => text().nullable()();
TextColumn get categoriesJson => text().withDefault(const Constant('[]'))();
TextColumn get settingsJson => text().withDefault(const Constant('{}'))();
BoolColumn get isPinned => boolean().withDefault(const Constant(false))();
BoolColumn get isHidden => boolean().withDefault(const Constant(false))();
BoolColumn get isMuted => boolean().withDefault(const Constant(false))();
TextColumn get lastMessageText => text().withDefault(const Constant(''))();
DateTimeColumn get lastMessageAt => dateTime().nullable()();
IntColumn get unreadCount => integer().withDefault(const Constant(0))();
TextColumn get syncMode => text().withDefault(const Constant('local'))();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
// ============================================================
// 聊天消息记录表
// ============================================================
class ChatMsgRecords extends Table {
TextColumn get id => text()();
TextColumn get conversationId => text()();
TextColumn get type => text()();
TextColumn get role => text()();
TextColumn get content => text()();
TextColumn get author => text().nullable()();
TextColumn get source => text().nullable()();
TextColumn get category => text().nullable()();
BoolColumn get isRead => boolean().withDefault(const Constant(false))();
IntColumn get readCount => integer().withDefault(const Constant(0))();
TextColumn get metaJson => text().withDefault(const Constant('{}'))();
TextColumn get extJson => text().withDefault(const Constant('{}'))();
TextColumn get replyToId => text().nullable()();
TextColumn get richContent => text().withDefault(const Constant(''))();
TextColumn get ipText => text().withDefault(const Constant(''))();
TextColumn get ipDetailJson => text().withDefault(const Constant(''))();
BoolColumn get isDeleted => boolean().withDefault(const Constant(false))();
DateTimeColumn get deletedAt => dateTime().nullable()();
DateTimeColumn get timestamp => dateTime()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
// ============================================================
// 聊天附件表
// ============================================================
class ChatAttachments extends Table {
TextColumn get id => text()();
TextColumn get messageId => text()();
TextColumn get conversationId => text()();
TextColumn get fileName => text()();
TextColumn get filePath => text()();
TextColumn get fileType => text()();
IntColumn get fileSize => integer()();
TextColumn get thumbnailPath => text().nullable()();
IntColumn get width => integer().nullable()();
IntColumn get height => integer().nullable()();
IntColumn get durationMs => integer().nullable()();
TextColumn get cloudUrl => text().nullable()();
DateTimeColumn get cloudSyncedAt => dateTime().nullable()();
DateTimeColumn get createdAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
// ============================================================
// IP地址缓存表
// ============================================================
class IpLocationCaches extends Table {
TextColumn get ip => text()();
TextColumn get city => text()();
TextColumn get province => text().nullable()();
TextColumn get fullText => text()();
DateTimeColumn get queriedAt => dateTime()();
@override
Set<Column> get primaryKey => {ip};
}
// ============================================================
// 传输设备表 — 文件传输助手
// ============================================================
class TransferDeviceRecords extends Table {
TextColumn get id => text()();
TextColumn get alias => text()();
TextColumn get deviceModel => text().nullable()();
TextColumn get deviceType => text().withDefault(const Constant('mobile'))();
TextColumn get ip => text().nullable()();
IntColumn get port => integer().withDefault(const Constant(53317))();
TextColumn get pairingMethod => text().withDefault(const Constant('lan'))();
TextColumn get preferredTransport =>
text().withDefault(const Constant('localsend_http'))();
BoolColumn get isOnline => boolean().withDefault(const Constant(false))();
BoolColumn get isVerified => boolean().withDefault(const Constant(false))();
TextColumn get publicKey => text().nullable()();
TextColumn get fingerprint => text().nullable()();
DateTimeColumn get lastSeen => dateTime()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
// ============================================================
// 传输记录表 — 文件传输助手
// ============================================================
class TransferRecords extends Table {
TextColumn get id => text()();
TextColumn get sessionId => text()();
TextColumn get peerId => text()();
TextColumn get peerAlias => text().withDefault(const Constant(''))();
TextColumn get transport =>
text().withDefault(const Constant('localsend_http'))();
TextColumn get direction => text().withDefault(const Constant('send'))();
TextColumn get status => text().withDefault(const Constant('waiting'))();
TextColumn get fileName => text()();
IntColumn get fileSize => integer().withDefault(const Constant(0))();
IntColumn get transferredBytes => integer().withDefault(const Constant(0))();
RealColumn get speed => real().withDefault(const Constant(0.0))();
TextColumn get mimeType => text().nullable()();
TextColumn get filePath => text().nullable()();
TextColumn get thumbnailPath => text().nullable()();
TextColumn get fileSha256 => text().nullable()();
BoolColumn get hashVerified => boolean().nullable()();
TextColumn get errorMessage => text().nullable()();
DateTimeColumn get startTime => dateTime()();
DateTimeColumn get endTime => dateTime().nullable()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
// ============================================================
// 配对记录表 — 文件传输助手
// ============================================================
class PairingRecords extends Table {
TextColumn get id => text()();
TextColumn get deviceId => text()();
TextColumn get alias => text()();
TextColumn get pairingMethod => text().withDefault(const Constant('lan'))();
BoolColumn get isTrusted => boolean().withDefault(const Constant(false))();
TextColumn get ip => text().nullable()();
IntColumn get port => integer().withDefault(const Constant(53317))();
TextColumn get fingerprint => text().nullable()();
TextColumn get publicKey => text().nullable()();
DateTimeColumn get pairedAt => dateTime()();
DateTimeColumn get lastConnectedAt => dateTime().nullable()();
DateTimeColumn get createdAt => dateTime()();
DateTimeColumn get updatedAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
// ============================================================
// 传输消息表 — 文件传输助手
// ============================================================
class TransferMsgRecords extends Table {
TextColumn get id => text()();
TextColumn get sessionId => text()();
TextColumn get type => text().withDefault(const Constant('text'))();
TextColumn get content => text()();
BoolColumn get isRemote => boolean().withDefault(const Constant(false))();
TextColumn get peerDeviceId => text().nullable()();
TextColumn get transferTaskId => text().nullable()();
TextColumn get fileName => text().nullable()();
IntColumn get fileSize => integer().nullable()();
TextColumn get mimeType => text().nullable()();
TextColumn get thumbnailPath => text().nullable()();
TextColumn get filePath => text().nullable()();
RealColumn get progress => real().nullable()();
TextColumn get transferStatus => text().nullable()();
TextColumn get deviceAlias => text().nullable()();
TextColumn get deviceEmoji => text().nullable()();
DateTimeColumn get timestamp => dateTime()();
DateTimeColumn get createdAt => dateTime()();
@override
Set<Column> get primaryKey => {id};
}
// ============================================================
// 数据库实例
// ============================================================
@@ -220,6 +425,14 @@ class LearningRecords extends Table {
ShareHistories,
LearningPlans,
LearningRecords,
ChatConversations,
ChatMsgRecords,
ChatAttachments,
IpLocationCaches,
TransferDeviceRecords,
TransferRecords,
PairingRecords,
TransferMsgRecords,
],
)
class AppDatabase extends _$AppDatabase {
@@ -229,7 +442,7 @@ class AppDatabase extends _$AppDatabase {
static AppDatabase get instance => _instance;
@override
int get schemaVersion => 8;
int get schemaVersion => 12;
@override
MigrationStrategy get migration => MigrationStrategy(
@@ -263,6 +476,49 @@ class AppDatabase extends _$AppDatabase {
'ALTER TABLE sentences ADD COLUMN isLiked INTEGER NOT NULL DEFAULT 0',
);
}
if (from < 9) {
await m.createTable(chatConversations);
await m.createTable(chatMsgRecords);
await m.createTable(chatAttachments);
await m.createTable(ipLocationCaches);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_chat_msg_records_conv ON chat_msg_records (conversation_id, timestamp DESC)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_chat_msg_records_conv_deleted ON chat_msg_records (conversation_id, is_deleted)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_chat_attachments_msg ON chat_attachments (message_id)',
);
await customStatement(
'CREATE INDEX IF NOT EXISTS idx_chat_attachments_conv ON chat_attachments (conversation_id)',
);
}
if (from < 10) {
await customStatement(
'ALTER TABLE chat_msg_records ADD COLUMN reply_to_id TEXT DEFAULT NULL',
);
await customStatement(
'ALTER TABLE chat_msg_records ADD COLUMN rich_content TEXT NOT NULL DEFAULT \'\'',
);
await customStatement(
'ALTER TABLE chat_msg_records ADD COLUMN ip_text TEXT NOT NULL DEFAULT \'\'',
);
await customStatement(
'ALTER TABLE chat_msg_records ADD COLUMN ip_detail_json TEXT NOT NULL DEFAULT \'\'',
);
}
if (from < 11) {
await customStatement(
'ALTER TABLE chat_conversations ADD COLUMN is_hidden INTEGER NOT NULL DEFAULT 0',
);
}
if (from < 12) {
await m.createTable(transferDeviceRecords);
await m.createTable(transferRecords);
await m.createTable(pairingRecords);
await m.createTable(transferMsgRecords);
}
},
);
@@ -922,6 +1178,276 @@ class AppDatabase extends _$AppDatabase {
learningRecords,
)..where((t) => t.planId.equals(planId))).go();
}
// ---- 聊天会话 CRUD ----
Future<void> insertChatConversation(ChatConversationsCompanion conv) {
return into(
chatConversations,
).insert(conv, mode: InsertMode.insertOrIgnore);
}
Future<List<ChatConversation>> getAllChatConversations() {
return (select(chatConversations)..orderBy([
(t) => OrderingTerm.desc(t.isPinned),
(t) => OrderingTerm.desc(t.lastMessageAt),
]))
.get();
}
Future<ChatConversation?> getChatConversation(String id) {
return (select(
chatConversations,
)..where((t) => t.id.equals(id))).getSingleOrNull();
}
Future<void> updateChatConversation(ChatConversationsCompanion conv) {
return (update(
chatConversations,
)..where((t) => t.id.equals(conv.id.value))).write(conv);
}
Future<void> deleteChatConversation(String id) {
return (delete(chatConversations)..where((t) => t.id.equals(id))).go();
}
Future<int> getChatConversationCount() async {
final rows = await (selectOnly(
chatConversations,
)..addColumns([chatConversations.id.count()])).getSingle();
return rows.read(chatConversations.id.count()) ?? 0;
}
// ---- 聊天消息记录 CRUD ----
Future<void> insertChatMsgRecord(ChatMsgRecordsCompanion msg) {
return into(chatMsgRecords).insert(msg);
}
Future<void> insertChatMsgRecordBatch(
List<ChatMsgRecordsCompanion> msgs,
) async {
await batch((b) {
for (final msg in msgs) {
b.insert(chatMsgRecords, msg);
}
});
}
Future<List<ChatMsgRecord>> getChatMsgRecords(
String conversationId, {
int limit = 50,
int offset = 0,
}) {
return (select(chatMsgRecords)
..where(
(t) =>
t.conversationId.equals(conversationId) &
t.isDeleted.equals(false),
)
..orderBy([(t) => OrderingTerm.desc(t.timestamp)])
..limit(limit, offset: offset))
.get();
}
Future<ChatMsgRecord?> getChatMsgRecord(String id) {
return (select(
chatMsgRecords,
)..where((t) => t.id.equals(id))).getSingleOrNull();
}
Future<void> updateChatMsgRecord(ChatMsgRecordsCompanion msg) {
return (update(
chatMsgRecords,
)..where((t) => t.id.equals(msg.id.value))).write(msg);
}
Future<void> softDeleteChatMsgRecord(String id) {
return (update(chatMsgRecords)..where((t) => t.id.equals(id))).write(
ChatMsgRecordsCompanion(
isDeleted: const Value(true),
deletedAt: Value(DateTime.now()),
updatedAt: Value(DateTime.now()),
),
);
}
Future<void> restoreChatMsgRecord(String id) {
return (update(chatMsgRecords)..where((t) => t.id.equals(id))).write(
ChatMsgRecordsCompanion(
isDeleted: const Value(false),
deletedAt: const Value(null),
updatedAt: Value(DateTime.now()),
),
);
}
Future<void> permanentlyDeleteMsgRecord(String id) {
return (delete(chatMsgRecords)..where((t) => t.id.equals(id))).go();
}
Future<List<ChatMsgRecord>> getDeletedChatMsgRecords(
String conversationId, {
int limit = 50,
}) {
return (select(chatMsgRecords)
..where(
(t) =>
t.conversationId.equals(conversationId) &
t.isDeleted.equals(true),
)
..orderBy([(t) => OrderingTerm.desc(t.deletedAt)])
..limit(limit))
.get();
}
Future<int> getChatMsgRecordCount(String conversationId) async {
final rows =
await (selectOnly(chatMsgRecords)
..addColumns([chatMsgRecords.id.count()])
..where(
chatMsgRecords.conversationId.equals(conversationId) &
chatMsgRecords.isDeleted.equals(false),
))
.getSingle();
return rows.read(chatMsgRecords.id.count()) ?? 0;
}
Future<int> getDeletedChatMsgRecordCount(String conversationId) async {
final rows =
await (selectOnly(chatMsgRecords)
..addColumns([chatMsgRecords.id.count()])
..where(
chatMsgRecords.conversationId.equals(conversationId) &
chatMsgRecords.isDeleted.equals(true),
))
.getSingle();
return rows.read(chatMsgRecords.id.count()) ?? 0;
}
Future<void> incrementMsgReadCount(String id) async {
final msg = await getChatMsgRecord(id);
if (msg == null) return;
await (update(chatMsgRecords)..where((t) => t.id.equals(id))).write(
ChatMsgRecordsCompanion(
readCount: Value(msg.readCount + 1),
updatedAt: Value(DateTime.now()),
),
);
}
Future<void> markChatMsgRecordRead(String id) {
return (update(chatMsgRecords)..where((t) => t.id.equals(id))).write(
ChatMsgRecordsCompanion(
isRead: const Value(true),
updatedAt: Value(DateTime.now()),
),
);
}
Future<void> markAllChatMsgRecordsRead(String conversationId) {
return (update(chatMsgRecords)..where(
(t) =>
t.conversationId.equals(conversationId) & t.isRead.equals(false),
))
.write(const ChatMsgRecordsCompanion(isRead: Value(true)));
}
Future<void> cleanExpiredDeletedMsgRecords({int days = 30}) {
final cutoff = DateTime.now().subtract(Duration(days: days));
return (delete(chatMsgRecords)..where(
(t) =>
t.isDeleted.equals(true) & t.deletedAt.isSmallerThanValue(cutoff),
))
.go();
}
// ---- 聊天附件 CRUD ----
Future<void> insertChatAttachment(ChatAttachmentsCompanion att) {
return into(chatAttachments).insert(att);
}
Future<void> insertChatAttachmentBatch(
List<ChatAttachmentsCompanion> atts,
) async {
await batch((b) {
for (final att in atts) {
b.insert(chatAttachments, att);
}
});
}
Future<List<ChatAttachment>> getChatAttachments(String messageId) {
return (select(
chatAttachments,
)..where((t) => t.messageId.equals(messageId))).get();
}
Future<List<ChatAttachment>> getChatAttachmentsByConversation(
String conversationId, {
int limit = 100,
}) {
return (select(chatAttachments)
..where((t) => t.conversationId.equals(conversationId))
..orderBy([(t) => OrderingTerm.desc(t.createdAt)])
..limit(limit))
.get();
}
Future<void> updateChatAttachment(ChatAttachmentsCompanion att) {
return (update(
chatAttachments,
)..where((t) => t.id.equals(att.id.value))).write(att);
}
Future<void> deleteChatAttachmentsByMessage(String messageId) {
return (delete(
chatAttachments,
)..where((t) => t.messageId.equals(messageId))).go();
}
Future<void> deleteChatAttachmentsByConversation(String conversationId) {
return (delete(
chatAttachments,
)..where((t) => t.conversationId.equals(conversationId))).go();
}
Future<int> getChatAttachmentCount(String conversationId) async {
final rows =
await (selectOnly(chatAttachments)
..addColumns([chatAttachments.id.count()])
..where(chatAttachments.conversationId.equals(conversationId)))
.getSingle();
return rows.read(chatAttachments.id.count()) ?? 0;
}
Future<int> getUnsyncedChatAttachmentCount() async {
final rows =
await (selectOnly(chatAttachments)
..addColumns([chatAttachments.id.count()])
..where(chatAttachments.cloudUrl.isNull()))
.getSingle();
return rows.read(chatAttachments.id.count()) ?? 0;
}
// ---- IP地址缓存 CRUD ----
Future<IpLocationCache?> getIpLocation(String ip) {
return (select(
ipLocationCaches,
)..where((t) => t.ip.equals(ip))).getSingleOrNull();
}
Future<void> saveIpLocation(IpLocationCachesCompanion entry) {
return into(
ipLocationCaches,
).insert(entry, mode: InsertMode.insertOrReplace);
}
Future<void> clearIpLocationCache() {
return delete(ipLocationCaches).go();
}
}
class HistorySentenceWithTime {

File diff suppressed because it is too large Load Diff