diff --git a/analysis_options.yaml b/analysis_options.yaml index 550c27c6ce..c4c025004f 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -1,4 +1,6 @@ include: package:very_good_analysis/analysis_options.yaml +plugins: + riverpod_lint: ^3.1.3 analyzer: language: diff --git a/lib/account/account_server.dart b/lib/account/account_server.dart index 8f3ba5d895..cf5349163d 100644 --- a/lib/account/account_server.dart +++ b/lib/account/account_server.dart @@ -1,15 +1,12 @@ import 'dart:async'; import 'dart:convert'; import 'dart:io'; -import 'dart:isolate'; import 'package:cross_file/cross_file.dart'; import 'package:dio/dio.dart'; import 'package:drift/drift.dart'; -import 'package:flutter/services.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:rxdart/rxdart.dart'; -import 'package:stream_channel/isolate_channel.dart'; import 'package:uuid/uuid.dart'; import '../blaze/blaze.dart'; @@ -20,14 +17,20 @@ import '../crypto/signal/signal_database.dart'; import '../crypto/signal/signal_key_util.dart'; import '../crypto/uuid/uuid.dart'; import '../db/dao/asset_dao.dart'; +import '../db/dao/circle_dao.dart'; import '../db/dao/sticker_album_dao.dart'; import '../db/dao/sticker_dao.dart'; import '../db/database.dart'; import '../db/extension/job.dart'; import '../db/mixin_database.dart' as db; import '../enum/encrypt_category.dart'; +import '../enum/media_status.dart'; import '../enum/message_category.dart'; -import '../ui/provider/account_server_provider.dart'; +import '../runtime/app_runtime_hub.dart'; +import '../runtime/db_write/method.dart'; +import '../runtime/db_write/payload.dart'; +import '../runtime/isolate/protocol.dart'; +import '../runtime/session/app_runtime_session_host.dart'; import '../ui/provider/multi_auth_provider.dart'; import '../ui/provider/setting_provider.dart'; import '../utils/app_lifecycle.dart'; @@ -42,20 +45,50 @@ import '../utils/web_view/web_view_interface.dart'; import '../widgets/message/item/action_card/action_card_data.dart'; import '../workers/injector.dart'; import '../workers/isolate_event.dart'; -import '../workers/message_worker_isolate.dart'; import 'account_key_value.dart'; import 'send_message_helper.dart'; import 'show_pin_message_key_value.dart'; class AccountServer { - AccountServer({ + AccountServer._({ required this.multiAuthNotifier, required this.settingChangeNotifier, required this.database, required this.currentConversationId, - this.userAgent, - this.deviceId, - }); + required this.userId, + required this.sessionId, + required this.identityNumber, + required this.privateKey, + required AppRuntimeSessionHost runtimeSession, + }) : _runtimeSession = runtimeSession; + + factory AccountServer.create({ + required MultiAuthStateNotifier multiAuthNotifier, + required SettingChangeNotifier settingChangeNotifier, + required Database database, + required GetCurrentConversationId currentConversationId, + required String userId, + required String sessionId, + required String identityNumber, + required String privateKey, + required AppRuntimeSessionHost runtimeSession, + }) { + sid = sessionId; + final accountServer = AccountServer._( + multiAuthNotifier: multiAuthNotifier, + settingChangeNotifier: settingChangeNotifier, + database: database, + currentConversationId: currentConversationId, + userId: userId, + sessionId: sessionId, + identityNumber: identityNumber, + privateKey: privateKey, + runtimeSession: runtimeSession, + ); + return accountServer + .._initClient() + .._bindRuntimeSession(); + } static String? sid; @@ -66,31 +99,15 @@ class AccountServer { final SettingChangeNotifier settingChangeNotifier; final Database database; final GetCurrentConversationId currentConversationId; + final AppRuntimeSessionHost _runtimeSession; Timer? checkSignalKeyTimer; - bool get _loginByPhoneNumber => - AccountKeyValue.instance.primarySessionId == null; - String? userAgent; - String? deviceId; - - Future initServer( - String userId, - String sessionId, - String identityNumber, - String privateKey, - ) async { - if (sid == sessionId) return; - sid = sessionId; - - this.userId = userId; - this.sessionId = sessionId; - this.identityNumber = identityNumber; - this.privateKey = privateKey; - - await initKeyValues(identityNumber); - - await _initClient(); + final String userId; + final String sessionId; + final String identityNumber; + final String privateKey; + Future activate() async { checkSignalKeyTimer = Timer.periodic(const Duration(days: 1), (timer) { i('refreshSignalKeys periodic'); checkSignalKey(client); @@ -111,8 +128,6 @@ class AccountServer { rethrow; } - unawaited(_start()); - DownloadKeyValue.instance.messageIds.forEach((messageId) { attachmentUtil.downloadAttachment(messageId: messageId); }); @@ -154,12 +169,12 @@ class AccountServer { markRead(id); } - Future _initClient() async { + void _initClient() { client = createClient( userId: userId, sessionId: sessionId, privateKey: privateKey, - loginByPhoneNumber: _loginByPhoneNumber, + loginByPhoneNumber: AccountKeyValue.instance.primarySessionId == null, interceptors: [ InterceptorsWrapper( onError: (e, handler) async { @@ -175,103 +190,38 @@ class AccountServer { database, attachmentUtil, addSendingJob, + _requestDbWrite, ); - _injector = Injector(userId, database, client); + _injector = Injector( + userId, + database, + client, + requestDbWrite: _requestDbWrite, + ); } - late String userId; - late String sessionId; - late String identityNumber; - late String privateKey; - late Client client; late Injector _injector; late SendMessageHelper _sendMessageHelper; late AttachmentUtil attachmentUtil; - IsolateChannel? _isolateChannel; - - final BehaviorSubject _connectedStateBehaviorSubject = - BehaviorSubject(); - ValueStream get connectedStateStream => - _connectedStateBehaviorSubject; + _runtimeSession.connectedStateStream; Future reconnectBlaze() async { - _sendEventToWorkerIsolate(MainIsolateEventType.reconnectBlaze); + _runtimeSession.sendSyncCommand(const ReconnectBlazeCommand()); } void _notifyBlazeWaitSyncTime() { - _sendEventToWorkerIsolate(MainIsolateEventType.disconnectBlazeWithTime); + _runtimeSession.sendSyncCommand(const DisconnectBlazeWithTimeCommand()); } - final jobSubscribers = {}; - - Future _start() async { - final receivePort = ReceivePort(); - _isolateChannel = IsolateChannel.connectReceive(receivePort); - final exitReceivePort = ReceivePort(); - final errorReceivePort = ReceivePort(); - await Isolate.spawn( - startMessageProcessIsolate, - IsolateInitParams( - sendPort: receivePort.sendPort, - identityNumber: identityNumber, - userId: userId, - sessionId: sessionId, - privateKey: privateKey, - mixinDocumentDirectory: mixinDocumentsDirectory.path, - primarySessionId: AccountKeyValue.instance.primarySessionId, - loginByPhoneNumber: _loginByPhoneNumber, - rootIsolateToken: ServicesBinding.rootIsolateToken!, - ), - errorsAreFatal: false, - onExit: exitReceivePort.sendPort, - onError: errorReceivePort.sendPort, - ); - jobSubscribers - ..add( - exitReceivePort.listen((message) { - w('worker isolate service exited. $message'); - _connectedStateBehaviorSubject.add(ConnectedState.disconnected); - }), - ) - ..add( - errorReceivePort.listen((error) { - e('work isolate RemoteError: $error'); - }), - ) - ..add( - _isolateChannel!.stream.listen((event) { - if (event is! WorkerIsolateEvent) { - e('unexpected event from worker isolate: $event'); - return; - } - try { - _handleWorkIsolateEvent(event); - } catch (error, stacktrace) { - e('handle worker isolate event failed: $error, $stacktrace'); - } - }), - ); - } - - void _handleWorkIsolateEvent(WorkerIsolateEvent event) { - switch (event.type) { - case WorkerIsolateEventType.onIsolateReady: - d('message process service ready'); - case WorkerIsolateEventType.onBlazeConnectStateChanged: - _connectedStateBehaviorSubject.add(event.argument as ConnectedState); - case WorkerIsolateEventType.onApiRequestedError: - _onClientRequestError(event.argument as DioException); - case WorkerIsolateEventType.requestDownloadAttachment: - final request = event.argument as AttachmentRequest; - _onAttachmentDownloadRequest(request); - case WorkerIsolateEventType.showPinMessage: - final conversationId = event.argument as String; - unawaited(ShowPinMessageKeyValue.instance.show(conversationId)); - } + void _bindRuntimeSession() { + _runtimeSession.onApiRequestedError = _onClientRequestError; + _runtimeSession.onAttachmentRequest = _onAttachmentDownloadRequest; + _runtimeSession.onShowPinMessage = (conversationId) => + ShowPinMessageKeyValue.instance.show(conversationId); } // Call when worker isolate process message need download attachment. @@ -321,15 +271,12 @@ class AccountServer { } Future signOutAndClear() async { - _sendEventToWorkerIsolate(MainIsolateEventType.exit); + await _runtimeSession.dispose(); try { await client.accountApi.logout(LogoutRequest(sessionId)); } catch (error, stacktrace) { e('logout api error: $error, $stacktrace'); } - await Future.wait(jobSubscribers.map((s) => s.cancel())); - jobSubscribers.clear(); - await clearKeyValues(); try { @@ -339,8 +286,10 @@ class AccountServer { } try { - await database.participantSessionDao.deleteBySessionId(sessionId); - await database.participantSessionDao.updateSentToServer(); + await _requestDbWrite( + DbWriteMethod.cleanupParticipantSession, + payload: DbWriteCleanupParticipantSessionPayload(sessionId: sessionId), + ); } catch (_) { // ignore closed database error } @@ -672,20 +621,19 @@ class AccountServer { ); void selectConversation(String? conversationId) { - _sendEventToWorkerIsolate( - MainIsolateEventType.updateSelectedConversation, - conversationId, + _runtimeSession.sendSyncCommand( + UpdateSelectedConversationCommand(conversationId: conversationId), ); } void addAckJob(List jobs) { assert(jobs.every((job) => job.action == kAcknowledgeMessageReceipts)); - _sendEventToWorkerIsolate(MainIsolateEventType.addAckJobs, jobs); + _runtimeSession.sendSyncCommand(AddAckJobsCommand(jobs: jobs)); } void addSessionAckJob(List jobs) { assert(jobs.every((job) => job.action == kCreateMessage)); - _sendEventToWorkerIsolate(MainIsolateEventType.addSessionAckJobs, jobs); + _runtimeSession.sendSyncCommand(AddSessionAckJobsCommand(jobs: jobs)); } void addSendingJob(db.Job job) { @@ -694,28 +642,29 @@ class AccountServer { job.action == kPinMessage || job.action == kRecallMessage, ); - _sendEventToWorkerIsolate(MainIsolateEventType.addSendingJob, job); + _runtimeSession.sendSyncCommand(AddSendingJobCommand(job: job)); } void addUpdateAssetJob(db.Job job) { assert(job.action == kUpdateAsset); - _sendEventToWorkerIsolate(MainIsolateEventType.addUpdateAssetJob, job); + _runtimeSession.sendSyncCommand(AddUpdateAssetJobCommand(job: job)); } void addUpdateTokenJob(db.Job job) { assert(job.action == kUpdateToken); - _sendEventToWorkerIsolate(MainIsolateEventType.addUpdateTokenJob, job); + _runtimeSession.sendSyncCommand(AddUpdateTokenJobCommand(job: job)); } void addUpdateStickerJob(db.Job job) { assert(job.action == kUpdateSticker); - _sendEventToWorkerIsolate(MainIsolateEventType.addUpdateStickerJob, job); + _runtimeSession.sendSyncCommand(AddUpdateStickerJobCommand(job: job)); } void addSyncInscriptionMessageJob(String messageId) { - _sendEventToWorkerIsolate( - MainIsolateEventType.addSyncInscriptionMessageJob, - createSyncInscriptionMessageJob(messageId), + _runtimeSession.sendSyncCommand( + AddSyncInscriptionMessageJobCommand( + job: createSyncInscriptionMessageJob(messageId), + ), ); } @@ -774,7 +723,7 @@ class AccountServer { Future stop() async { appActiveListener.removeListener(onActive); checkSignalKeyTimer?.cancel(); - _sendEventToWorkerIsolate(MainIsolateEventType.exit); + await _runtimeSession.dispose(); } void release() { @@ -783,30 +732,32 @@ class AccountServer { Future refreshSelf() async { final me = (await client.accountApi.getMe()).data; - await database.userDao.insert( - db.User( - userId: me.userId, - identityNumber: me.identityNumber, - relationship: me.relationship, - fullName: me.fullName, - avatarUrl: me.avatarUrl, - phone: me.phone, - isVerified: me.isVerified, - createdAt: me.createdAt, - muteUntil: DateTime.tryParse(me.muteUntil), - biography: me.biography, - isScam: me.isScam ? 1 : 0, - codeId: me.codeId, - codeUrl: me.codeUrl, - membership: me.membership, - ), + final user = db.User( + userId: me.userId, + identityNumber: me.identityNumber, + relationship: me.relationship, + fullName: me.fullName, + avatarUrl: me.avatarUrl, + phone: me.phone, + isVerified: me.isVerified, + createdAt: me.createdAt, + muteUntil: DateTime.tryParse(me.muteUntil), + biography: me.biography, + isScam: me.isScam ? 1 : 0, + codeId: me.codeId, + codeUrl: me.codeUrl, + membership: me.membership, + ); + await _requestDbWrite( + DbWriteMethod.upsertUser, + payload: user, ); multiAuthNotifier.updateAccount(me); } Future refreshFriends() async { final friends = (await client.accountApi.getFriends()).data; - await _injector.insertUpdateUsers(friends); + await _insertUpdateUsers(friends); } Future checkSignalKeys() async { @@ -849,8 +800,9 @@ class AccountServer { if (localAlbum == null) { maxOrder++; } - await database.stickerAlbumDao.insert( - a.asStickerAlbumsCompanion.copyWith( + await _requestDbWrite( + DbWriteMethod.upsertStickerAlbum, + payload: a.asStickerAlbumsCompanion.copyWith( orderedAt: Value(localAlbum?.orderedAt ?? maxOrder), added: Value(localAlbum?.added ?? a.banner?.isNotEmpty == true), ), @@ -882,7 +834,7 @@ class AccountServer { refreshUserIdSet.clear(); final res = await client.circleApi.getCircles(); await Future.forEach(res.data, (circle) async { - await database.circleDao.insertUpdate( + await _upsertCircle( db.Circle( circleId: circle.circleId, name: circle.name, @@ -900,7 +852,9 @@ class AccountServer { if (clean) { return; } - await database.jobDao.insert(createCleanupQuoteContentJob()); + await _requestDbWrite( + DbWriteMethod.insertCleanupQuoteContentJob, + ); AccountKeyValue.instance.alreadyCleanupQuoteContent = true; } @@ -913,13 +867,13 @@ class AccountServer { circle.circleId, )).data; for (final cc in ccList) { - await database.circleConversationDao.insert( + await _upsertCircleConversations([ db.CircleConversation( conversationId: cc.conversationId, circleId: cc.circleId, createdAt: cc.createdAt, ), - ); + ]); await _injector.syncConversion(cc.conversationId); if (cc.userId != null && !refreshUserIdSet.contains(cc.userId)) { final u = await database.userDao.userById(cc.userId!).getSingleOrNull(); @@ -950,10 +904,13 @@ class AccountServer { ); }); - await database.mixinDatabase.transaction(() async { - await database.stickerRelationshipDao.insertAll(relationships); - await database.stickerDao.insertAll(stickers); - }); + await _requestDbWrite( + DbWriteMethod.replaceStickersByAlbum, + payload: DbWriteReplaceStickersByAlbumPayload( + relationships: relationships, + stickers: stickers, + ), + ); } catch (e, s) { w('Update sticker albums error: $e, stack: $s'); } @@ -1015,7 +972,7 @@ class AccountServer { Future _relationship(RelationshipRequest request) async { try { final response = await client.userApi.relationships(request); - await database.userDao.insertSdkUser(response.data); + await _upsertSdkUser(response.data); } catch (e) { w('_relationship error $e'); } @@ -1044,7 +1001,7 @@ class AccountServer { randomId: randomId, ), ); - await database.conversationDao.updateConversation(response.data, userId); + await _updateConversationFromResponse(response.data); await addParticipant(conversationId, userIds); } @@ -1055,7 +1012,7 @@ class AccountServer { Future joinGroup(String code) async { final response = await client.conversationApi.join(code); - await database.conversationDao.updateConversation(response.data, userId); + await _updateConversationFromResponse(response.data); } Future addParticipant( @@ -1069,7 +1026,7 @@ class AccountServer { userIds.map((e) => ParticipantRequest(userId: e)).toList(), ); - await database.conversationDao.updateConversation(response.data, userId); + await _updateConversationFromResponse(response.data); } catch (e) { w('addParticipant error $e'); // throw error?? @@ -1110,7 +1067,7 @@ class AccountServer { CircleName(name: name), ); - await database.circleDao.insertUpdate( + await _upsertCircle( db.Circle( circleId: response.data.circleId, name: response.data.name, @@ -1126,7 +1083,7 @@ class AccountServer { circleId, CircleName(name: name), ); - await database.circleDao.insertUpdate( + await _upsertCircle( db.Circle( circleId: response.data.circleId, name: response.data.name, @@ -1148,26 +1105,12 @@ class AccountServer { .conversationById(conversationId) .getSingleOrNull(); if (conversation == null) { - await database.conversationDao.insert( - db.Conversation( - conversationId: conversationId, - category: ConversationCategory.contact, - createdAt: DateTime.now(), - ownerId: recipientId, - status: ConversationStatus.start, - ), - ); - await database.participantDao.insert( - db.Participant( + await _requestDbWrite( + DbWriteMethod.upsertContactConversation, + payload: DbWriteUpsertContactConversationPayload( conversationId: conversationId, - userId: userId, - createdAt: DateTime.now(), - ), - ); - await database.participantDao.insert( - db.Participant( - conversationId: conversationId, - userId: recipientId, + currentUserId: userId, + recipientId: recipientId, createdAt: DateTime.now(), ), ); @@ -1199,7 +1142,13 @@ class AccountServer { userId: userId, ), ]); - await database.circleConversationDao.deleteById(conversationId, circleId); + await _requestDbWrite( + DbWriteMethod.deleteCircleConversationById, + payload: DbWriteDeleteCircleConversationPayload( + conversationId: conversationId, + circleId: circleId, + ), + ); } Future editCircleConversation( @@ -1211,27 +1160,23 @@ class AccountServer { circleId, list, ); - await database.transaction( - () => Future.wait( - response.data.map((cc) async { - await database.circleConversationDao.insert( - db.CircleConversation( - conversationId: cc.conversationId, - circleId: cc.circleId, - createdAt: cc.createdAt, - ), - ); - if (cc.userId != null && !refreshUserIdSet.contains(cc.userId)) { - final u = await database.userDao - .userById(cc.userId!) - .getSingleOrNull(); - if (u == null) { - refreshUserIdSet.add(cc.userId); - } - } - }), - ), - ); + final items = []; + for (final cc in response.data) { + items.add( + db.CircleConversation( + conversationId: cc.conversationId, + circleId: cc.circleId, + createdAt: cc.createdAt, + ), + ); + if (cc.userId != null && !refreshUserIdSet.contains(cc.userId)) { + final u = await database.userDao.userById(cc.userId!).getSingleOrNull(); + if (u == null) { + refreshUserIdSet.add(cc.userId); + } + } + } + await _upsertCircleConversations(items); } Future deleteCircle(String circleId) async { @@ -1243,17 +1188,17 @@ class AccountServer { } } - await database.transaction(() async { - await database.circleDao.deleteCircleById(circleId); - await database.circleConversationDao.deleteByCircleId(circleId); - }); + await _requestDbWrite( + DbWriteMethod.deleteCircleAndConversations, + payload: circleId, + ); } Future report(String userId) async { final response = await client.userApi.report( RelationshipRequest(userId: userId, action: RelationshipAction.block), ); - await database.userDao.insertSdkUser(response.data); + await _upsertSdkUser(response.data); } Future unMuteConversation({ @@ -1303,13 +1248,22 @@ class AccountServer { final cr = response.data; if (cr.category == ConversationCategory.contact) { if (userId != null) { - await database.userDao.updateMuteUntil(userId, cr.muteUntil); + await _requestDbWrite( + DbWriteMethod.updateUserMuteUntil, + payload: DbWriteUpdateUserMuteUntilPayload( + userId: userId, + muteUntil: cr.muteUntil, + ), + ); } } else { if (conversationId != null) { - await database.conversationDao.updateMuteUntil( - conversationId, - cr.muteUntil, + await _requestDbWrite( + DbWriteMethod.updateConversationMuteUntil, + payload: DbWriteUpdateConversationMuteUntilPayload( + conversationId: conversationId, + muteUntil: cr.muteUntil, + ), ); } } @@ -1329,22 +1283,55 @@ class AccountServer { ), ); - await database.conversationDao.updateConversation(response.data, userId); + await _updateConversationFromResponse(response.data); } Future rotate(String conversationId) async { final response = await client.conversationApi.rotate(conversationId); - await database.conversationDao.updateCodeUrl( - conversationId, - response.data.codeUrl, + await _requestDbWrite( + DbWriteMethod.updateConversationCodeUrl, + payload: DbWriteUpdateConversationCodeUrlPayload( + conversationId: conversationId, + codeUrl: response.data.codeUrl, + ), ); } - Future unpin(String conversationId) => - database.conversationDao.unpin(conversationId); + Future unpin(String conversationId) => _requestDbWrite( + DbWriteMethod.unpinConversation, + payload: conversationId, + ); - Future pin(String conversationId) => - database.conversationDao.pin(conversationId); + Future pin(String conversationId) => _requestDbWrite( + DbWriteMethod.pinConversation, + payload: conversationId, + ); + + Future deleteConversation(String conversationId) => _requestDbWrite( + DbWriteMethod.deleteConversation, + payload: conversationId, + ); + + Future updateConversationDraft(String conversationId, String draft) => + _requestDbWrite( + DbWriteMethod.updateConversationDraft, + payload: DbWriteUpdateConversationDraftPayload( + conversationId: conversationId, + draft: draft, + ), + ); + + Future updateCircleOrders(List items) { + if (items.isEmpty) return Future.value(); + return _requestDbWrite( + DbWriteMethod.updateCircleOrders, + payload: DbWriteUpdateCircleOrdersPayload(items: items), + ); + } + + Future updateConversationFromResponse( + ConversationResponse conversation, + ) => _updateConversationFromResponse(conversation); Future getConversationMediaSize(String conversationId) async => (await getTotalSizeOfFile(attachmentUtil.getImagesPath(conversationId))) + @@ -1368,7 +1355,7 @@ class AccountServer { Future markMentionRead(String messageId, String conversationId) => Future.wait([ - database.messageMentionDao.markMentionRead(messageId), + _requestDbWrite(DbWriteMethod.markMentionRead, payload: messageId), (() async => addSessionAckJob([ await createMentionReadAckJob(conversationId, messageId), ]))(), @@ -1392,6 +1379,89 @@ class AccountServer { multiAuthNotifier.updateAccount(user.data); } + Future upsertSdkUser(User user) => _upsertSdkUser(user); + + Future upsertDbUser(db.User user) => + _requestDbWrite(DbWriteMethod.upsertUser, payload: user); + + Future upsertSticker(db.StickersCompanion sticker) => _requestDbWrite( + DbWriteMethod.insertSticker, + payload: sticker, + ); + + Future insertStickerAndRelationship( + db.StickersCompanion sticker, + db.StickerRelationship relationship, + ) => _requestDbWrite( + DbWriteMethod.insertStickerAndRelationship, + payload: DbWriteInsertStickerAndRelationshipPayload( + sticker: sticker, + relationship: relationship, + ), + ); + + Future deletePersonalSticker(String stickerId) => _requestDbWrite( + DbWriteMethod.deletePersonalSticker, + payload: stickerId, + ); + + Future updateStickerUsedAt( + String? albumId, + String stickerId, + DateTime usedAt, + ) => _requestDbWrite( + DbWriteMethod.updateStickerUsedAt, + payload: DbWriteUpdateStickerUsedAtPayload( + albumId: albumId, + stickerId: stickerId, + usedAt: usedAt, + ), + ); + + Future updateStickerAlbumAdded(String albumId, bool added) => + _requestDbWrite( + DbWriteMethod.updateStickerAlbumAdded, + payload: DbWriteUpdateStickerAlbumAddedPayload( + albumId: albumId, + added: added, + ), + ); + + Future updateStickerAlbumOrders(List albums) { + if (albums.isEmpty) return Future.value(); + return _requestDbWrite( + DbWriteMethod.updateStickerAlbumOrders, + payload: DbWriteUpdateStickerAlbumOrdersPayload(albums: albums), + ); + } + + Future upsertStickerAlbum(db.StickerAlbumsCompanion stickerAlbum) => + _requestDbWrite(DbWriteMethod.upsertStickerAlbum, payload: stickerAlbum); + + Future upsertSafeSnapshot(SafeSnapshot snapshot) => + _requestDbWrite(DbWriteMethod.upsertSafeSnapshot, payload: snapshot); + + Future upsertDbSafeSnapshot(db.SafeSnapshot snapshot) => + _requestDbWrite(DbWriteMethod.upsertSafeSnapshot, payload: snapshot); + + Future updateSafeSnapshotMessage(String messageId, String snapshotId) => + _requestDbWrite( + DbWriteMethod.updateSafeSnapshotMessage, + payload: DbWriteUpdateSafeSnapshotMessagePayload( + messageId: messageId, + snapshotId: snapshotId, + ), + ); + + Future updateMessageMediaStatus(String messageId, MediaStatus status) => + _requestDbWrite( + DbWriteMethod.updateMessageMediaStatus, + payload: DbWriteUpdateMessageMediaStatusPayload( + messageId: messageId, + status: status, + ), + ); + Future cancelProgressAttachmentJob(String messageId) => attachmentUtil.cancelProgressAttachmentJob(messageId); @@ -1450,8 +1520,16 @@ class AccountServer { final message = await database.messageDao.findMessageByMessageId(messageId); if (message == null) return; await attachmentUtil.cancelProgressAttachmentJob(messageId); - await database.messageDao.deleteMessage(message.conversationId, messageId); - unawaited(database.ftsDatabase.deleteByMessageId(messageId)); + await _requestDbWrite( + DbWriteMethod.deleteMessage, + payload: DbWriteDeleteMessagePayload( + conversationId: message.conversationId, + messageId: messageId, + ), + ); + unawaited( + _requestDbWrite(DbWriteMethod.deleteFtsByMessageId, payload: messageId), + ); unawaited(_deleteMessageAttachment(message)); } @@ -1467,11 +1545,18 @@ class AccountServer { attachmentUtil.cancelProgressAttachmentJob(message.messageId), ); - await database.messageDao.deleteMessagesByConversationId(conversationId); - await database.messageMentionDao.clearMessageMentionByConversationId( - conversationId, + await _requestDbWrite( + DbWriteMethod.deleteMessagesByConversation, + payload: DbWriteDeleteMessagesByConversationPayload( + conversationId: conversationId, + ), + ); + unawaited( + _requestDbWrite( + DbWriteMethod.deleteFtsByConversationId, + payload: conversationId, + ), ); - unawaited(database.ftsDatabase.deleteByConversationId(conversationId)); unawaited(_deleteMessageAttachmentByConversationId(conversationId)); } @@ -1525,18 +1610,18 @@ class AccountServer { Future updateSnapshotById({required String snapshotId}) async { final data = await client.snapshotApi.getSnapshotById(snapshotId); - await database.snapshotDao.insertSdkSnapshot(data.data); + await _requestDbWrite(DbWriteMethod.upsertSnapshot, payload: data.data); } Future updateSafeSnapshotById({required String snapshotId}) async { final data = await client.tokenApi.getSnapshotById(snapshotId); - await database.safeSnapshotDao.insertSdkSnapshot(data.data); + await _requestDbWrite(DbWriteMethod.upsertSafeSnapshot, payload: data.data); } Future updateSnapshotByTraceId({required String traceId}) async { final data = await client.snapshotApi.getSnapshotByTraceId(traceId); final snapshot = data.data; - await database.snapshotDao.insertSdkSnapshot(snapshot); + await _requestDbWrite(DbWriteMethod.upsertSnapshot, payload: snapshot); return snapshot; } @@ -1556,10 +1641,10 @@ class AccountServer { final a = (await client.assetApi.getAssetById(assetId)).data; final chain = (await client.assetApi.getChain(a.chainId)).data; - await Future.wait([ - database.assetDao.insertSdkAsset(a), - database.chainDao.insertSdkChain(chain), - ]); + await _requestDbWrite( + DbWriteMethod.upsertAssetAndChain, + payload: DbWriteUpsertAssetAndChainPayload(asset: a, chain: chain), + ); } catch (error, stacktrace) { e('checkAsset: $error $stacktrace'); } @@ -1576,7 +1661,7 @@ class AccountServer { Future updateFiats() async { final data = await client.accountApi.getFiats(); - await database.fiatDao.insertAllSdkFiat(data.data); + await _requestDbWrite(DbWriteMethod.replaceFiats, payload: data.data); } Future findOrSyncApp(String id) async => @@ -1595,7 +1680,7 @@ class AccountServer { try { final user = await client.userApi.getUserById(id); - await _injector.insertUpdateUsers([user.data]); + await _insertUpdateUsers([user.data]); return database.appDao.findAppById(id); } catch (error, stackTrace) { d('get app and check user error: $error, $stackTrace'); @@ -1606,7 +1691,10 @@ class AccountServer { Future loadFavoriteApps(String userId) async { final result = await client.userApi.getUserFavoriteApps(userId); final apps = result.data; - await database.favoriteAppDao.insertFavoriteApps(userId, apps); + await _requestDbWrite( + DbWriteMethod.insertFavoriteApps, + payload: DbWriteFavoriteAppsPayload(userId: userId, apps: apps), + ); // refresh app not exist. final appIds = apps.map((e) => e.appId).toList(); @@ -1624,18 +1712,52 @@ class AccountServer { return; } final usersResponse = await client.userApi.getUsers(notExits); - await _injector.insertUpdateUsers(usersResponse.data); + await _insertUpdateUsers(usersResponse.data); } - void _sendEventToWorkerIsolate(MainIsolateEventType type, [dynamic args]) { - try { - if (_isolateChannel == null) { - d('_sendEventToWorkerIsolate: _isolateChannel is null $type'); - assert(type == MainIsolateEventType.exit); - } - _isolateChannel?.sink.add(type.toEvent(args)); - } catch (error, s) { - e('_sendEventToWorkerIsolate: $error, $s'); - } + Future _insertUpdateUsers(List users) async { + if (users.isEmpty) return; + await _requestDbWrite( + DbWriteMethod.insertUpdateUsers, + payload: users, + ); + } + + Future _upsertSdkUser(User user) async { + await _requestDbWrite(DbWriteMethod.upsertSdkUser, payload: user); } + + Future _updateConversationFromResponse( + ConversationResponse conversation, + ) async { + await _requestDbWrite( + DbWriteMethod.updateConversationFromResponse, + payload: DbWriteUpdateConversationPayload( + conversation: conversation, + currentUserId: userId, + ), + ); + } + + Future _upsertCircle(db.Circle circle) async { + await _requestDbWrite( + DbWriteMethod.upsertCircle, + payload: DbWriteCirclePayload(circle: circle), + ); + } + + Future _upsertCircleConversations( + List items, + ) async { + if (items.isEmpty) return; + await _requestDbWrite( + DbWriteMethod.upsertCircleConversations, + payload: DbWriteCircleConversationsPayload(items: items), + ); + } + + Future _requestDbWrite( + DbWriteMethod method, { + Object? payload, + }) => _runtimeSession.requestDbWrite(method, payload: payload); } diff --git a/lib/account/notification_service.dart b/lib/account/notification_service.dart index 272e4ef5ec..b048c85b08 100644 --- a/lib/account/notification_service.dart +++ b/lib/account/notification_service.dart @@ -4,15 +4,16 @@ import 'package:flutter/widgets.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:stream_transform/stream_transform.dart'; +import '../account/account_server.dart'; import '../blaze/vo/pin_message_minimal.dart'; +import '../db/database.dart'; import '../db/database_event_bus.dart'; import '../db/extension/conversation.dart'; import '../enum/message_category.dart'; -import '../generated/l10n.dart'; import '../ui/provider/conversation_provider.dart'; import '../ui/provider/mention_cache_provider.dart'; -import '../ui/provider/slide_category_provider.dart'; +import '../ui/provider/setting_provider.dart'; import '../utils/app_lifecycle.dart'; import '../utils/extension/extension.dart'; import '../utils/load_balancer_utils.dart'; @@ -26,7 +27,22 @@ import '../widgets/message/item/system_message.dart'; const _keyConversationId = 'conversationId'; class NotificationService { - NotificationService({required BuildContext context}) { + NotificationService({ + required BuildContext context, + required AccountServer accountServer, + required Database database, + required SettingChangeNotifier settings, + required MentionCache mentionCache, + required Account? Function() readAccount, + required ConversationState? Function() readConversationState, + required void Function() switchToChatsIfSettings, + required Future Function( + BuildContext context, + String conversationId, { + String? initIndexMessageId, + }) + selectConversation, + }) { streamSubscriptions ..add( DataBaseEventBus.instance.notificationMessageStream @@ -42,7 +58,7 @@ class NotificationService { ..add( DataBaseEventBus.instance.notificationMessageStream .where((event) => event.type != MessageCategory.messageRecall) - .where((event) => event.senderId != context.accountServer.userId) + .where((event) => event.senderId != accountServer.userId) .where( (event) => event.createdAt != null && @@ -52,9 +68,7 @@ class NotificationService { ) .where((event) { if (isAppActive) { - final conversationState = context.providerContainer.read( - conversationProvider, - ); + final conversationState = readConversationState(); return event.conversationId != (conversationState?.conversationId ?? conversationState?.conversation?.conversationId); @@ -62,7 +76,7 @@ class NotificationService { return true; }) .asyncMapBuffer( - (event) => context.database.messageDao + (event) => database.messageDao .notificationMessage( event.map((e) => e.messageId).toList(), ) @@ -70,7 +84,8 @@ class NotificationService { ) .expand((event) => event) .asyncWhere((event) async { - final account = context.account!; + final account = readAccount(); + if (account == null) return false; bool mentionedCurrentUser() => mentionNumberRegExp .allMatchesAndSort(event.content ?? '') @@ -107,17 +122,13 @@ class NotificationService { ); String? body; - if (context.settingChangeNotifier.messagePreview) { - final mentionCache = context.providerContainer.read( - mentionCacheProvider, - ); - + if (settings.messagePreview) { if (event.type == MessageCategory.systemConversation) { body = generateSystemText( actionName: event.actionName, participantUserId: event.participantUserId, senderId: event.senderId, - currentUserId: context.accountServer.userId, + currentUserId: accountServer.userId, participantFullName: event.participantFullName, senderFullName: event.senderFullName, expireIn: int.tryParse(event.content ?? '0'), @@ -192,13 +203,11 @@ class NotificationService { ) { i('select notification $event'); - context.providerContainer - .read(slideCategoryStateProvider.notifier) - .switchToChatsIfSettings(); + switchToChatsIfSettings(); final conversationId = event.queryParameters[_keyConversationId] ?? event.host; - ConversationStateNotifier.selectConversation( + selectConversation( context, conversationId, initIndexMessageId: event.path, diff --git a/lib/account/send_message_helper.dart b/lib/account/send_message_helper.dart index 627266e177..d0badc8aa3 100644 --- a/lib/account/send_message_helper.dart +++ b/lib/account/send_message_helper.dart @@ -14,9 +14,7 @@ import '../blaze/vo/pin_message_payload.dart'; import '../blaze/vo/transcript_minimal.dart'; import '../db/dao/job_dao.dart'; import '../db/dao/message_dao.dart'; -import '../db/dao/message_mention_dao.dart'; import '../db/dao/participant_dao.dart'; -import '../db/dao/pin_message_dao.dart'; import '../db/dao/transcript_message_dao.dart'; import '../db/database.dart'; import '../db/extension/job.dart'; @@ -24,6 +22,8 @@ import '../db/mixin_database.dart'; import '../enum/encrypt_category.dart'; import '../enum/media_status.dart'; import '../enum/message_category.dart'; +import '../runtime/db_write/method.dart'; +import '../runtime/db_write/payload.dart'; import '../utils/attachment/attachment_util.dart'; import '../utils/extension/extension.dart'; import '../utils/image.dart'; @@ -39,19 +39,24 @@ const jpegMimeType = 'image/jpeg'; const gifMimeType = 'image/gif'; class SendMessageHelper { - SendMessageHelper(this._database, this._attachmentUtil, this._addSendingJob); + SendMessageHelper( + this._database, + this._attachmentUtil, + this._addSendingJob, + this._requestDbWrite, + ); final Database _database; late final MessageDao _messageDao = _database.messageDao; - late final MessageMentionDao _messageMentionDao = _database.messageMentionDao; late final ParticipantDao _participantDao = _database.participantDao; late final JobDao _jobDao = _database.jobDao; - late final PinMessageDao _pinMessageDao = _database.pinMessageDao; late final TranscriptMessageDao _transcriptMessageDao = _database.transcriptMessageDao; final AttachmentUtil _attachmentUtil; final Function(Job) _addSendingJob; + final Future Function(DbWriteMethod method, {Object? payload}) + _requestDbWrite; Future _insertSendMessageToDb( Message message, { @@ -64,15 +69,44 @@ class SendMessageHelper { .getSingleOrNull(); assert(conversation != null, 'no conversation'); final expireIn = conversation?.expireIn ?? 0; - await _messageDao.insert( - message, - message.userId, - expireIn: expireIn, - cleanDraft: cleanDraft, + await _requestDbWrite( + DbWriteMethod.insertSendMessage, + payload: DbWriteInsertSendMessagePayload( + message: message, + currentUserId: message.userId, + expireIn: expireIn, + cleanDraft: cleanDraft, + ftsContent: ftsContent, + ), ); - unawaited(_database.ftsDatabase.insertFts(message, ftsContent)); } + Future _updateAttachmentMessageContentAndStatus( + String messageId, + String content, + String? key, + String? digest, + ) => _requestDbWrite( + DbWriteMethod.updateAttachmentMessageContentAndStatus, + payload: DbWriteUpdateAttachmentMessageContentAndStatusPayload( + messageId: messageId, + content: content, + key: key, + digest: digest, + ), + ); + + Future _updateMessageMediaStatus( + String messageId, + MediaStatus status, + ) => _requestDbWrite( + DbWriteMethod.updateMessageMediaStatus, + payload: DbWriteUpdateMessageMediaStatusPayload( + messageId: messageId, + status: status, + ), + ); + Future sendTextMessage( String conversationId, String senderId, @@ -244,7 +278,7 @@ class SendMessageHelper { ); final encoded = await jsonBase64EncodeWithIsolate(attachmentMessage); - await _messageDao.updateAttachmentMessageContentAndStatus( + await _updateAttachmentMessageContentAndStatus( messageId, encoded, attachmentResult.keys, @@ -329,7 +363,7 @@ class SendMessageHelper { ); final encoded = await jsonBase64EncodeWithIsolate(attachmentMessage); - await _messageDao.updateAttachmentMessageContentAndStatus( + await _updateAttachmentMessageContentAndStatus( messageId, encoded, attachmentResult.keys, @@ -439,7 +473,7 @@ class SendMessageHelper { ); final encoded = await jsonBase64EncodeWithIsolate(attachmentMessage); - await _messageDao.updateAttachmentMessageContentAndStatus( + await _updateAttachmentMessageContentAndStatus( messageId, encoded, attachmentResult.keys, @@ -558,7 +592,7 @@ class SendMessageHelper { ); final encoded = await jsonBase64EncodeWithIsolate(attachmentMessage); - await _messageDao.updateAttachmentMessageContentAndStatus( + await _updateAttachmentMessageContentAndStatus( messageId, encoded, attachmentResult.keys, @@ -705,9 +739,20 @@ class SendMessageHelper { messageIds.map((messageId) async { final message = await _messageDao.findMessageByMessageId(messageId); - await _messageDao.recallMessage(conversationId, messageId); + await _requestDbWrite( + DbWriteMethod.recallMessage, + payload: DbWriteRecallMessagePayload( + conversationId: conversationId, + messageId: messageId, + ), + ); - unawaited(_database.ftsDatabase.deleteByMessageId(messageId)); + unawaited( + _requestDbWrite( + DbWriteMethod.deleteFtsByMessageId, + payload: messageId, + ), + ); await Future.wait([ (() async { @@ -719,10 +764,13 @@ class SendMessageHelper { } } })(), - _messageMentionDao.deleteMessageMention( - MessageMention( - messageId: messageId, - conversationId: conversationId, + _requestDbWrite( + DbWriteMethod.deleteMessageMention, + payload: DbWriteDeleteMessageMentionPayload( + messageMention: MessageMention( + messageId: messageId, + conversationId: conversationId, + ), ), ), (() async => _addSendingJob( @@ -735,10 +783,13 @@ class SendMessageHelper { ); if (quoteMessage != null) { - await _messageDao.updateQuoteContentByQuoteId( - conversationId, - messageId, - quoteMessage.toJson(), + await _requestDbWrite( + DbWriteMethod.updateQuoteContentByQuoteId, + payload: DbWriteUpdateQuoteContentByQuoteIdPayload( + conversationId: conversationId, + quoteMessageId: messageId, + content: quoteMessage.toJson(), + ), ); } })(), @@ -1017,7 +1068,12 @@ class SendMessageHelper { ); await Future.wait([ - _transcriptMessageDao.insertAll(transcriptMessages), + _requestDbWrite( + DbWriteMethod.insertTranscriptMessages, + payload: DbWriteInsertTranscriptMessagesPayload( + transcripts: transcriptMessages, + ), + ), _insertSendMessageToDb( message, ftsContent: await _transcriptMessageDao @@ -1113,37 +1169,40 @@ class SendMessageHelper { ); attachmentId = attachmentResult!.attachmentId; - await _transcriptMessageDao.updateTranscript( - transcriptId: transcriptMessage.transcriptId, - messageId: transcriptMessage.messageId, - attachmentId: attachmentId, - category: newCategory, - key: attachmentResult.keys, - digest: attachmentResult.digest, - mediaStatus: MediaStatus.done, - mediaCreatedAt: - DateTime.tryParse(attachmentResult.createdAt ?? '') ?? - DateTime.now(), + await _requestDbWrite( + DbWriteMethod.updateTranscript, + payload: DbWriteUpdateTranscriptPayload( + transcriptId: transcriptMessage.transcriptId, + messageId: transcriptMessage.messageId, + attachmentId: attachmentId, + category: newCategory, + key: attachmentResult.keys, + digest: attachmentResult.digest, + mediaStatus: MediaStatus.done, + mediaCreatedAt: + DateTime.tryParse(attachmentResult.createdAt ?? '') ?? + DateTime.now(), + ), ); } else { - await _transcriptMessageDao.updateTranscript( - transcriptId: transcriptMessage.transcriptId, - messageId: transcriptMessage.messageId, - attachmentId: attachmentId, - category: transcriptMessage.category, - key: transcriptMessage.mediaKey, - digest: transcriptMessage.mediaDigest, - mediaStatus: MediaStatus.done, - mediaCreatedAt: transcriptMessage.mediaCreatedAt, + await _requestDbWrite( + DbWriteMethod.updateTranscript, + payload: DbWriteUpdateTranscriptPayload( + transcriptId: transcriptMessage.transcriptId, + messageId: transcriptMessage.messageId, + attachmentId: attachmentId, + category: transcriptMessage.category, + key: transcriptMessage.mediaKey, + digest: transcriptMessage.mediaDigest, + mediaStatus: MediaStatus.done, + mediaCreatedAt: transcriptMessage.mediaCreatedAt, + ), ); } } try { - await _messageDao.updateMediaStatus( - message.messageId, - MediaStatus.pending, - ); + await _updateMessageMediaStatus(message.messageId, MediaStatus.pending); final attachmentTranscripts = transcripts.where( (element) => element.category.isAttachment, ); @@ -1151,7 +1210,7 @@ class SendMessageHelper { await Future.wait(attachmentTranscripts.map(uploadAttachment)); } - await _messageDao.updateMediaStatus(message.messageId, MediaStatus.done); + await _updateMessageMediaStatus(message.messageId, MediaStatus.done); _addSendingJob( await _jobDao.createSendingJob( message.messageId, @@ -1160,10 +1219,7 @@ class SendMessageHelper { ); } catch (error, stacktrace) { e('reUploadTranscriptAttachment error: $error, stacktrace: $stacktrace'); - await _messageDao.updateMediaStatus( - message.messageId, - MediaStatus.canceled, - ); + await _updateMessageMediaStatus(message.messageId, MediaStatus.canceled); } } @@ -1209,7 +1265,7 @@ class SendMessageHelper { attachmentResult.createdAt, ); final encoded = await jsonBase64EncodeWithIsolate(attachmentMessage); - await _messageDao.updateAttachmentMessageContentAndStatus( + await _updateAttachmentMessageContentAndStatus( messageId, encoded, attachmentResult.keys, @@ -1291,18 +1347,17 @@ class SendMessageHelper { ); final encoded = await jsonEncodeWithIsolate(pinMessagePayload); if (pin) { - await Future.forEach(pinMessageMinimals, ( - pinMessageMinimal, - ) async { - await _pinMessageDao.insert( + final pinMessages = []; + final systemMessages = []; + for (final pinMessageMinimal in pinMessageMinimals) { + pinMessages.add( PinMessage( messageId: pinMessageMinimal.messageId, conversationId: conversationId, createdAt: DateTime.now(), ), ); - - await _messageDao.insert( + systemMessages.add( Message( messageId: const Uuid().v4(), conversationId: conversationId, @@ -1313,14 +1368,23 @@ class SendMessageHelper { category: MessageCategory.messagePin, quoteMessageId: pinMessageMinimal.messageId, ), - senderId, - cleanDraft: false, ); - }); + } + await _requestDbWrite( + DbWriteMethod.pinAndInsertPinMessages, + payload: DbWritePinAndInsertPinMessagesPayload( + pinMessages: pinMessages, + systemMessages: systemMessages, + currentUserId: senderId, + ), + ); unawaited(ShowPinMessageKeyValue.instance.show(conversationId)); } else { - await _pinMessageDao.deleteByIds( - pinMessageMinimals.map((e) => e.messageId).toList(), + await _requestDbWrite( + DbWriteMethod.deletePinMessagesByIds, + payload: DbWriteDeletePinMessagesPayload( + messageIds: pinMessageMinimals.map((e) => e.messageId).toList(), + ), ); } @@ -1380,7 +1444,7 @@ class SendMessageHelper { } if (result == null) { e('failed to get send image bytes. $url'); - await _messageDao.updateMediaStatus(messageId, MediaStatus.canceled); + await _updateMessageMediaStatus(messageId, MediaStatus.canceled); return; } @@ -1399,11 +1463,14 @@ class SendMessageHelper { await attachment.writeAsBytes(data); final thumbImage = await attachment.encodeBlurHash(); final mediaSize = await attachment.length(); - await _messageDao.updateGiphyMessage( - messageId, - attachment.pathBasename, - mediaSize, - thumbImage, + await _requestDbWrite( + DbWriteMethod.updateGiphyMessage, + payload: DbWriteUpdateGiphyMessagePayload( + messageId: messageId, + mediaUrl: attachment.pathBasename, + mediaSize: mediaSize, + thumbImage: thumbImage, + ), ); if (await _attachmentUtil.isNotPending(messageId)) return; @@ -1431,7 +1498,7 @@ class SendMessageHelper { attachmentResult.createdAt, ); final encoded = await jsonBase64EncodeWithIsolate(attachmentMessage); - await _messageDao.updateAttachmentMessageContentAndStatus( + await _updateAttachmentMessageContentAndStatus( messageId, encoded, attachmentResult.keys, @@ -1441,7 +1508,7 @@ class SendMessageHelper { } Future reUploadGiphyGif(MessageItem message) async { - await _messageDao.updateMediaStatus(message.messageId, MediaStatus.pending); + await _updateMessageMediaStatus(message.messageId, MediaStatus.pending); final attachment = _attachmentUtil.getAttachmentFile( message.type, message.conversationId, @@ -1467,7 +1534,7 @@ class SendMessageHelper { e( 'reUploadGiphyGif: failed to get send image bytes. ${message.mediaUrl}', ); - await _messageDao.updateMediaStatus( + await _updateMessageMediaStatus( message.messageId, MediaStatus.canceled, ); @@ -1478,11 +1545,14 @@ class SendMessageHelper { thumbImage = await attachment.encodeBlurHash(); mediaSize = await attachment.length(); - await _messageDao.updateGiphyMessage( - message.messageId, - attachment.pathBasename, - mediaSize, - thumbImage, + await _requestDbWrite( + DbWriteMethod.updateGiphyMessage, + payload: DbWriteUpdateGiphyMessagePayload( + messageId: message.messageId, + mediaUrl: attachment.pathBasename, + mediaSize: mediaSize, + thumbImage: thumbImage, + ), ); } final attachmentResult = await _attachmentUtil.uploadAttachment( @@ -1508,7 +1578,7 @@ class SendMessageHelper { attachmentResult.createdAt, ); final encoded = await jsonBase64EncodeWithIsolate(attachmentMessage); - await _messageDao.updateAttachmentMessageContentAndStatus( + await _updateAttachmentMessageContentAndStatus( message.messageId, encoded, attachmentResult.keys, diff --git a/lib/app.dart b/lib/app.dart index d06c1e0845..7909710692 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -3,32 +3,24 @@ import 'dart:io'; import 'package:drift/isolate.dart'; import 'package:drift/native.dart'; import 'package:flutter/material.dart' hide AnimatedTheme; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' - hide Consumer, FutureProvider, Provider; -import 'package:provider/provider.dart'; + hide FutureProvider, Provider; -import 'account/account_key_value.dart'; -import 'account/notification_service.dart'; import 'constants/brightness_theme_data.dart'; import 'constants/resources.dart'; import 'generated/l10n.dart'; -import 'ui/home/bloc/conversation_list_bloc.dart'; import 'ui/home/conversation/conversation_page.dart'; import 'ui/home/home.dart'; +import 'ui/home/providers/home_scope_providers.dart'; import 'ui/landing/landing.dart'; import 'ui/landing/landing_failed.dart'; import 'ui/provider/account_server_provider.dart'; import 'ui/provider/database_provider.dart'; -import 'ui/provider/mention_cache_provider.dart'; import 'ui/provider/multi_auth_provider.dart'; import 'ui/provider/setting_provider.dart'; -import 'ui/provider/slide_category_provider.dart'; +import 'ui/provider/ui_context_providers.dart'; import 'utils/extension/extension.dart'; -import 'utils/logger.dart'; -import 'utils/platform.dart'; import 'utils/system/system_fonts.dart'; import 'utils/system/text_input.dart'; import 'utils/system/tray.dart'; @@ -86,12 +78,17 @@ class _LoginApp extends HookConsumerWidget { return _App(home: DatabaseOpenFailedPage(error: error)); } else { return _App( - home: LandingFailedPage( - title: context.l10n.unknowError, - message: error.toString(), - actions: [ - ElevatedButton(onPressed: () {}, child: Text(context.l10n.exit)), - ], + home: Consumer( + builder: (context, ref, child) { + final l10n = ref.watch(localizationProvider); + return LandingFailedPage( + title: l10n.unknowError, + message: error.toString(), + actions: [ + ElevatedButton(onPressed: () {}, child: Text(l10n.exit)), + ], + ); + }, ), ); } @@ -101,7 +98,7 @@ class _LoginApp extends HookConsumerWidget { } } -class _Providers extends HookConsumerWidget { +class _Providers extends ConsumerWidget { const _Providers({required this.app}); final Widget app; @@ -110,25 +107,7 @@ class _Providers extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final asyncAccountServer = ref.watch(accountServerProvider); if (!asyncAccountServer.hasValue) return app; - final accountServer = asyncAccountServer.requireValue; - - return MultiBlocProvider( - providers: [ - BlocProvider( - create: (context) => ConversationListBloc( - ref.read(slideCategoryStateProvider.notifier), - accountServer.database, - ref.read(mentionCacheProvider), - ), - ), - ], - child: Provider( - create: (context) => NotificationService(context: context), - lazy: false, - dispose: (_, notificationService) => notificationService.close(), - child: PortalProviders(child: app), - ), - ); + return PortalProviders(child: app); } } @@ -170,30 +149,36 @@ class _App extends HookConsumerWidget { useMaterial3: true, ).withFallbackFonts(), themeMode: ref.watch(settingProvider).themeMode, - builder: (context, child) { - try { - context.accountServer.language = Localizations.localeOf( - context, - ).languageCode; - } catch (_) {} - final mediaQueryData = MediaQuery.of(context); - return BrightnessObserver( - lightThemeData: lightBrightnessThemeData, - darkThemeData: darkBrightnessThemeData, - forceBrightness: ref.watch(settingProvider).brightness, - child: MediaQuery( - data: mediaQueryData.copyWith( - // Different linux distro change the value, e.g. 1.2 - textScaler: Platform.isLinux - ? TextScaler.noScaling - : mediaQueryData.textScaler, - ), - child: SystemTrayWidget( - child: TextInputActionHandler(child: AuthGuard(child: child!)), - ), + builder: (context, child) => BrightnessObserver( + lightThemeData: lightBrightnessThemeData, + darkThemeData: darkBrightnessThemeData, + forceBrightness: ref.watch(settingProvider).brightness, + child: UiContextScope( + child: Consumer( + builder: (context, ref, _) { + try { + ref.read(accountServerProvider).value?.language = ref + .read(localeProvider) + .languageCode; + } catch (_) {} + final mediaQueryData = ref.watch(mediaQueryDataProvider); + return MediaQuery( + data: mediaQueryData.copyWith( + // Different linux distro change the value, e.g. 1.2 + textScaler: Platform.isLinux + ? TextScaler.noScaling + : mediaQueryData.textScaler, + ), + child: SystemTrayWidget( + child: TextInputActionHandler( + child: AuthGuard(child: child!), + ), + ), + ); + }, ), - ); - }, + ), + ), home: MixinAppActions(child: MacosMenuBar(child: home)), ), ), @@ -206,51 +191,20 @@ class _Home extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final accountServer = ref.watch( - accountServerProvider.select((value) => value.valueOrNull), + accountServerProvider.select((value) => value.value), ); - useEffect(() { - accountServer?.refreshSelf(); - accountServer?.refreshFriends(); - accountServer?.refreshSticker(); - accountServer?.initCircles(); - accountServer?.checkMigration(); - }, [accountServer]); - - useEffect(() { - Future effect() async { - if (accountServer == null) return; - - try { - final currentDeviceId = await getDeviceId(); - if (currentDeviceId == 'unknown') return; - - final deviceId = AccountKeyValue.instance.deviceId; - - if (deviceId == null) { - await AccountKeyValue.instance.setDeviceId(currentDeviceId); - return; - } - - if (deviceId.toLowerCase() != currentDeviceId.toLowerCase()) { - final multiAuthCubit = context.multiAuthChangeNotifier; - await accountServer.signOutAndClear(); - multiAuthCubit.signOut(); - } - } catch (e) { - w('checkDeviceId error: $e'); - } - } - - effect(); - }, [accountServer]); - if (accountServer != null) { - BlocProvider.of(context) - ..limit = - MediaQuery.sizeOf(context).height ~/ - (ConversationPage.conversationItemHeight / 1.75) - ..init(); + final mediaQueryData = ref.watch(mediaQueryDataProvider); + final notifier = ref.read(conversationListControllerProvider.notifier); + // Defer state modifications to after widget build completes. + WidgetsBinding.instance.addPostFrameCallback((_) { + notifier + ..limit = + mediaQueryData.size.height ~/ + (ConversationPage.conversationItemHeight / 1.75) + ..init(); + }); return const PortalProviders(child: HomePage()); } return const LandingPage(); diff --git a/lib/blaze/blaze.dart b/lib/blaze/blaze.dart index ceb28fddef..23aeef342f 100644 --- a/lib/blaze/blaze.dart +++ b/lib/blaze/blaze.dart @@ -12,12 +12,14 @@ import '../constants/constants.dart'; import '../db/database.dart'; import '../db/extension/job.dart'; import '../db/mixin_database.dart'; +import '../runtime/db_write/method.dart'; +import '../runtime/db_write/payload.dart'; import '../utils/extension/extension.dart'; import '../utils/logger.dart'; import '../utils/proxy.dart'; import '../workers/job/ack_job.dart'; import '../workers/job/flood_job.dart'; -import '../workers/message_worker_isolate.dart'; +import '../workers/sync_worker_isolate.dart'; import 'blaze_message.dart'; import 'blaze_message_param_session.dart'; @@ -52,6 +54,7 @@ class Blaze { this.userAgent, this.ackJob, this.floodJob, + this.requestDbWrite, ) { database.settingProperties.addListener(_onProxySettingChanged); proxyConfig = database.settingProperties.activatedProxy; @@ -69,6 +72,8 @@ class Blaze { final Client client; final AckJob ackJob; final FloodJob floodJob; + final Future Function(DbWriteMethod method, {Object? payload}) + requestDbWrite; final String? userAgent; @@ -331,9 +336,7 @@ class Blaze { final timestamp = data.updatedAt.toIso8601String(); if (offset == null || offset != timestamp) { - await database.offsetDao.insert( - Offset(key: statusOffset, timestamp: timestamp), - ); + await _upsertStatusOffset(timestamp); } } else if (blazeMessage.action == kCreateMessage) { if (data.userId == userId && @@ -377,7 +380,7 @@ class Blaze { .getSingleOrNull(); if (currentStatus != null && status.index > currentStatus.index) { - await database.messageDao.updateMessageStatusById(messageId, status); + await _updateMessageStatusById(messageId, status); return; } @@ -421,9 +424,7 @@ class Blaze { // Use unified status handling logic in makeMessageStatus await makeMessageStatus(m.messageId, m.status); - await database.offsetDao.insert( - Offset(key: statusOffset, timestamp: m.updatedAt.toIso8601String()), - ); + await _upsertStatusOffset(m.updatedAt.toIso8601String()); }); final lastUpdateAt = blazeMessages.last.updatedAt.epochNano; if (lastUpdateAt == status) { @@ -439,6 +440,26 @@ class Blaze { ); } + Future _upsertStatusOffset(String timestamp) async { + await requestDbWrite( + DbWriteMethod.upsertOffset, + payload: Offset(key: statusOffset, timestamp: timestamp), + ); + } + + Future _updateMessageStatusById( + String messageId, + MessageStatus status, + ) async { + await requestDbWrite( + DbWriteMethod.updateMessageStatusById, + payload: DbWriteUpdateMessageStatusPayload( + messageId: messageId, + status: status, + ), + ); + } + void _disconnect([bool resetConnectedState = true]) { i('ws _disconnect'); diff --git a/lib/bloc/bloc_converter.dart b/lib/bloc/bloc_converter.dart deleted file mode 100644 index 4fbf69b519..0000000000 --- a/lib/bloc/bloc_converter.dart +++ /dev/null @@ -1,106 +0,0 @@ -import 'dart:async'; - -import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; - -class BlocConverter, S, T> extends StatefulWidget { - const BlocConverter({ - required this.converter, - super.key, - this.cubit, - this.when, - this.builder, - this.child, - this.listener, - this.immediatelyCallListener = false, - }) : assert(builder != null || child != null), - assert(!(builder != null && child != null)); - - final C? cubit; - final BlocBuilderCondition? when; - final BlocConverterCondition converter; - - final Widget? child; - final BlocWidgetBuilder? builder; - final BlocWidgetListener? listener; - - final bool immediatelyCallListener; - - @override - State createState() => _BlocConverterState(); -} - -typedef BlocConverterCondition = T Function(S state); - -class _BlocConverterState, S, T> - extends State> { - StreamSubscription? _subscription; - late T _previousState; - late T _state; - late C _cubit; - - T _converter(S state) => widget.converter(state); - - @override - void initState() { - super.initState(); - _cubit = widget.cubit ?? context.read(); - _previousState = _converter(_cubit.state); - _state = _previousState; - if (widget.immediatelyCallListener) widget.listener?.call(context, _state); - _subscribe(); - } - - @override - void didUpdateWidget(BlocConverter oldWidget) { - super.didUpdateWidget(oldWidget); - final oldCubit = oldWidget.cubit ?? context.read(); - final currentBloc = widget.cubit ?? oldCubit; - if (oldCubit != currentBloc) { - if (_subscription != null) { - _unsubscribe(); - _cubit = widget.cubit ?? context.read(); - _previousState = _converter(_cubit.state); - _state = _previousState; - } - _subscribe(); - } else { - final state = _converter(_cubit.state); - if (_previousState != state) { - _previousState = _state; - _state = state; - } - } - } - - @override - Widget build(BuildContext context) => - widget.builder?.call(context, _state) ?? widget.child!; - - @override - void dispose() { - _unsubscribe(); - super.dispose(); - } - - void _subscribe() { - _subscription = _cubit.stream.map(_converter).listen((state) { - if (_previousState == state) return; - - if (widget.when?.call(_previousState, state) ?? true) { - widget.listener?.call(context, state); - _state = state; - - if (widget.builder != null) setState(() {}); - } - _previousState = state; - }); - } - - void _unsubscribe() { - if (_subscription != null) { - _subscription?.cancel(); - _subscription = null; - } - } -} diff --git a/lib/bloc/custom_bloc_observer.dart b/lib/bloc/custom_bloc_observer.dart deleted file mode 100644 index cc9609cd7b..0000000000 --- a/lib/bloc/custom_bloc_observer.dart +++ /dev/null @@ -1,10 +0,0 @@ -import 'package:bloc/bloc.dart'; -import '../utils/logger.dart'; - -class CustomBlocObserver extends BlocObserver { - @override - void onError(BlocBase bloc, Object error, StackTrace stackTrace) { - super.onError(bloc, error, stackTrace); - w('Bloc Error: bloc: $bloc, error: $error, stackTrace: $stackTrace'); - } -} diff --git a/lib/bloc/paging/load_more_paging_state.dart b/lib/bloc/paging/load_more_paging_state.dart deleted file mode 100644 index f59adf55ae..0000000000 --- a/lib/bloc/paging/load_more_paging_state.dart +++ /dev/null @@ -1,77 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; - -class _LoadMorePagingEvent extends Equatable { - @override - List get props => []; -} - -class _LoadMorePagingInitEvent extends _LoadMorePagingEvent {} - -class _LoadMorePagingLoadMoreEvent extends _LoadMorePagingEvent {} - -class _LoadMorePagingInsertOrReplaceEvent extends _LoadMorePagingEvent { - _LoadMorePagingInsertOrReplaceEvent(this.item); - - final T item; - - @override - List get props => [item]; -} - -class LoadMorePagingState extends Equatable { - const LoadMorePagingState({this.list = const []}); - - final List list; - - @override - List get props => [list]; - - LoadMorePagingState copyWith({List? list}) => - LoadMorePagingState(list: list ?? this.list); -} - -class LoadMorePagingBloc - extends Bloc<_LoadMorePagingEvent, LoadMorePagingState> { - LoadMorePagingBloc({ - required this.reloadData, - required this.loadMoreData, - required this.isSameKey, - }) : super(LoadMorePagingState()) { - on<_LoadMorePagingEvent>(_onEvent); - add(_LoadMorePagingInitEvent()); - } - - final Future> Function() reloadData; - final Future> Function(List) loadMoreData; - final bool Function(T, T) isSameKey; - - Future _onEvent( - _LoadMorePagingEvent event, - Emitter> emit, - ) async { - if (event is _LoadMorePagingInitEvent) { - emit(LoadMorePagingState(list: await reloadData())); - } else if (event is _LoadMorePagingLoadMoreEvent) { - emit(state.copyWith(list: await loadMoreData(state.list))); - } else if (event is _LoadMorePagingInsertOrReplaceEvent) { - if (state.list.isEmpty) return; - final index = state.list.indexWhere( - (element) => isSameKey(element, event.item), - ); - List list; - if (index == -1) { - list = [event.item, ...state.list]; - } else { - list = state.list.toList(); - list[index] = event.item; - } - emit(state.copyWith(list: list)); - } - } - - void loadMore() => add(_LoadMorePagingLoadMoreEvent()); - - void insertOrReplace(T item) => - add(_LoadMorePagingInsertOrReplaceEvent(item)); -} diff --git a/lib/bloc/paging/paging_bloc.dart b/lib/bloc/paging/paging_bloc.dart deleted file mode 100644 index d0473646cf..0000000000 --- a/lib/bloc/paging/paging_bloc.dart +++ /dev/null @@ -1,273 +0,0 @@ -import 'dart:async'; -import 'dart:math'; - -import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; -import 'package:equatable/equatable.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; - -import '../subscribe_mixin.dart'; - -class PagingState extends Equatable { - const PagingState({ - this.map = const {}, - this.count = 0, - this.initialized = false, - this.index = 0, - this.alignment = 0, - this.hasData = false, - }); - - final Map map; - final int count; - final bool initialized; - final int index; - final double alignment; - final bool hasData; - - @override - List get props => [ - map, - count, - initialized, - index, - alignment, - hasData, - ]; - - PagingState copyWith({ - Map? map, - int? count, - bool? initialized, - int? index, - double? alignment, - bool? hasData, - }) => PagingState( - map: map ?? this.map, - count: count ?? this.count, - initialized: initialized ?? this.initialized, - index: index ?? this.index, - alignment: alignment ?? this.alignment, - hasData: hasData ?? this.hasData, - ); - - @override - bool get stringify => true; -} - -abstract class PagingEvent extends Equatable {} - -class PagingInitEvent extends PagingEvent { - PagingInitEvent({this.offset = 0, this.index = 0, this.alignment = 0}); - - final int offset; - final int index; - final double alignment; - - @override - List get props => [offset, index, alignment]; -} - -class PagingItemPositionEvent extends PagingEvent { - PagingItemPositionEvent(this.itemPositions); - - final List itemPositions; - - @override - List get props => [itemPositions]; -} - -class PagingUpdateEvent extends PagingEvent { - @override - List get props => []; -} - -abstract class PagingBloc extends Bloc> - with SubscribeMixin { - PagingBloc({ - required this.itemPositionsListener, - required this.limit, - required PagingState initState, - this.jumpTo, - int offset = 0, - int index = 0, - double alignment = 0, - }) : super(initState) { - on((event, emit) async { - await _onEvent(event, emit); - }, transformer: restartable()); - on((event, emit) async { - await _onEvent(event, emit); - }, transformer: restartable()); - on((event, emit) async { - await _onEvent(event, emit); - }, transformer: restartable()); - - itemPositionsListener.itemPositions.addListener(onItemPositions); - add(PagingInitEvent(offset: offset, index: index, alignment: alignment)); - } - - final ItemPositionsListener itemPositionsListener; - final void Function({int index, double alignment})? jumpTo; - - int limit; - List lastItemPositions = []; - - void onItemPositions() { - final list = itemPositionsListener.itemPositions.value - .map((e) => e.index) - .toList(); - if (list.isEmpty) return; - - add(PagingItemPositionEvent(list)); - } - - @override - Future close() async { - itemPositionsListener.itemPositions.removeListener(onItemPositions); - await super.close(); - } - - Future _onEvent(PagingEvent event, Emitter> emit) async { - final prefetchDistance = limit ~/ 2; - - if (event is PagingUpdateEvent) { - final count = await queryCount(); - emit( - state.copyWith( - count: count, - hasData: count != 0, - map: count == 0 ? {} : state.map, - ), - ); - - if (count != 0) { - emit( - state.copyWith( - map: await queryMap( - max(state.map.length, limit), - state.map.isNotEmpty ? state.map.keys.reduce(min) : 0, - ), - initialized: true, - ), - ); - } - } else if (event is PagingItemPositionEvent) { - lastItemPositions = event.itemPositions; - if (!state.initialized || - state.map.isEmpty || - expectMinListRange( - event.itemPositions.first - prefetchDistance, - event.itemPositions.last + prefetchDistance, - )) { - return; - } - - final expectStart = event.itemPositions.first - limit; - final expectEnd = event.itemPositions.last + limit; - - final result = differenceMaxExpectRange(expectStart, expectEnd); - - var map = state.map; - // If the diff range has only one direction and is greater than the limit - if (result.length > 1 && (result.first.$2 - result.first.$1) > limit) { - map = await queryMap( - event.itemPositions.length + limit, - event.itemPositions.first - prefetchDistance, - ); - } else { - for (final (start, end) in result) { - map = {...map, ...await queryMap(end - start, start)}; - } - - if (map.length > expectEnd - expectStart) { - map.removeWhere((key, value) => key < expectStart || key > expectEnd); - } - } - - emit(state.copyWith(map: map, initialized: true)); - } else if (event is PagingInitEvent) { - emit(state.copyWith(hasData: await queryHasData())); - - final offset = event.offset; - - final count = await queryCount(); - - emit( - state.copyWith( - map: await queryMap(limit, offset), - count: count, - initialized: true, - index: event.index, - alignment: event.alignment, - ), - ); - - jumpTo?.call(index: event.index, alignment: event.alignment); - } - } - - Future> queryMap(int limit, int _offset) async { - final offset = max(_offset, 0); - final list = await queryRange(limit, max(offset, 0)); - return Map.fromIterables( - List.generate(min(limit, list.length), (index) => offset + index), - list, - ); - } - - bool expectMinListRange(int _start, int _end) { - final start = max(_start, 0); - final end = max(min(_end, state.count - 1), 0); - return state.map[start] != null && state.map[end] != null; - } - - List<(int, int)> differenceMaxExpectRange(int _start, int _end) { - final start = max(_start, 0); - final end = min(_end, state.count); - final loadedIndexes = state.map.keys.toList(); - - (int, int)? before; - (int, int)? after; - - if (start < loadedIndexes.first) { - before = (start, min(loadedIndexes.first, end)); - } - - if (loadedIndexes.last < end) { - after = (max(loadedIndexes.last, start), end); - } - - return [?before, ?after]; - } - - Future queryCount(); - - Future> queryRange(int limit, int offset); - - Future queryHasData(); -} - -class AnonymousPagingBloc extends PagingBloc { - AnonymousPagingBloc({ - required super.limit, - required super.initState, - required Future Function() queryCount, - required Future> Function(int limit, int offset) queryRange, - }) : _queryCount = queryCount, - _queryRange = queryRange, - super(itemPositionsListener: ItemPositionsListener.create()); - - final Future Function() _queryCount; - final Future> Function(int limit, int offset) _queryRange; - - @override - Future queryCount() => _queryCount(); - - @override - Future> queryRange(int limit, int offset) => - _queryRange(limit, offset); - - @override - Future queryHasData() async => false; -} diff --git a/lib/bloc/simple_cubit.dart b/lib/bloc/simple_cubit.dart deleted file mode 100644 index f0ef645345..0000000000 --- a/lib/bloc/simple_cubit.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:flutter_bloc/flutter_bloc.dart'; - -class SimpleCubit extends Cubit { - SimpleCubit(super.state); - - @override - void emit(State state) => super.emit(state); -} diff --git a/lib/bloc/stream_cubit.dart b/lib/bloc/stream_cubit.dart deleted file mode 100644 index 25debcbb52..0000000000 --- a/lib/bloc/stream_cubit.dart +++ /dev/null @@ -1,8 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'subscribe_mixin.dart'; - -class StreamCubit extends Cubit with SubscribeMixin { - StreamCubit(super.state, Stream stream) { - addSubscription(stream.distinct().listen(emit)); - } -} diff --git a/lib/bloc/subscribe_mixin.dart b/lib/bloc/subscribe_mixin.dart deleted file mode 100644 index b7f33720bc..0000000000 --- a/lib/bloc/subscribe_mixin.dart +++ /dev/null @@ -1,33 +0,0 @@ -import 'dart:async'; - -import 'package:bloc/bloc.dart'; - -// mixin SubscribeCubitMixin on Cubit { -// List subscriptions = []; -// -// void addSubscription(StreamSubscription streamSubscription) => -// subscriptions.add(streamSubscription); -// -// @override -// Future close() async { -// await Future.wait(subscriptions.map((e) => e?.cancel())); -// subscriptions.clear(); -// await super.close(); -// } -// } -// -mixin SubscribeMixin on BlocBase { - List subscriptions = []; - - void addSubscription(StreamSubscription? streamSubscription) => - subscriptions.add(streamSubscription); - - @override - Future close() async { - await Future.wait( - subscriptions.where((element) => element != null).map((e) => e!.cancel()), - ); - subscriptions.clear(); - await super.close(); - } -} diff --git a/lib/db/dao/snapshot_dao.dart b/lib/db/dao/snapshot_dao.dart index a64e140ebd..80f56468ac 100644 --- a/lib/db/dao/snapshot_dao.dart +++ b/lib/db/dao/snapshot_dao.dart @@ -1,8 +1,7 @@ import 'package:drift/drift.dart'; -import 'package:flutter/widgets.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' as sdk; -import '../../utils/extension/extension.dart'; +import '../../generated/l10n.dart'; import '../database_event_bus.dart'; import '../extension/db.dart'; import '../mixin_database.dart'; @@ -28,14 +27,14 @@ extension SnapshotConverter on sdk.Snapshot { } extension SnapshotItemExtension on SnapshotItem { - String l10nType(BuildContext context) { - if (type == sdk.SnapshotType.transfer) return context.l10n.transfer; - if (type == sdk.SnapshotType.deposit) return context.l10n.deposit; - if (type == sdk.SnapshotType.withdrawal) return context.l10n.withdrawal; - if (type == sdk.SnapshotType.fee) return context.l10n.fee; - if (type == sdk.SnapshotType.rebate) return context.l10n.rebate; - if (type == sdk.SnapshotType.raw) return context.l10n.raw; - return context.l10n.na; + String l10nType(Localization l10n) { + if (type == sdk.SnapshotType.transfer) return l10n.transfer; + if (type == sdk.SnapshotType.deposit) return l10n.deposit; + if (type == sdk.SnapshotType.withdrawal) return l10n.withdrawal; + if (type == sdk.SnapshotType.fee) return l10n.fee; + if (type == sdk.SnapshotType.rebate) return l10n.rebate; + if (type == sdk.SnapshotType.raw) return l10n.raw; + return l10n.na; } } diff --git a/lib/db/database_event_bus.dart b/lib/db/database_event_bus.dart index 301272cd51..3ded5aaba6 100644 --- a/lib/db/database_event_bus.dart +++ b/lib/db/database_event_bus.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:rxdart/rxdart.dart'; +import '../runtime/sync/patch.dart'; import '../utils/event_bus.dart'; import '../utils/logger.dart'; import 'dao/job_dao.dart'; @@ -47,6 +48,78 @@ class DataBaseEventBus { static DataBaseEventBus instance = DataBaseEventBus._(); + final StreamController _patchController = + StreamController.broadcast(); + bool _legacyEventDispatchEnabled = true; + bool _suppressPatchEmission = false; + + Stream get patchStream => _patchController.stream; + + set legacyEventBridgeEnabled(bool enabled) => + _legacyEventDispatchEnabled = enabled; + + void applyPatches(Iterable patches) { + final previous = _suppressPatchEmission; + _suppressPatchEmission = true; + try { + patches.forEach(applyPatch); + } finally { + _suppressPatchEmission = previous; + } + } + + void applyPatch(SyncPatch patch) { + switch (patch.type) { + case SyncPatchType.notification: + notificationMessage(patch.payload! as MiniNotificationMessage); + case SyncPatchType.insertOrReplaceMessage: + insertOrReplaceMessages( + (patch.payload! as List).cast(), + ); + case SyncPatchType.deleteMessage: + final messageIds = (patch.payload! as List).cast(); + messageIds.forEach(deleteMessage); + case SyncPatchType.updateExpiredMessage: + updateExpiredMessageTable(); + case SyncPatchType.updateConversation: + final conversationIds = (patch.payload! as List).cast(); + conversationIds.forEach(updateConversation); + case SyncPatchType.updateFavoriteApp: + updateFavoriteApp((patch.payload! as List).cast()); + case SyncPatchType.updateUser: + updateUsers((patch.payload! as List).cast()); + case SyncPatchType.updateParticipant: + updateParticipant((patch.payload! as List).cast()); + case SyncPatchType.updateSticker: + updateSticker((patch.payload! as List).cast()); + case SyncPatchType.updateSnapshot: + updateSnapshot((patch.payload! as List).cast()); + case SyncPatchType.updateMessageMention: + updateMessageMention((patch.payload! as List).cast()); + case SyncPatchType.updateCircle: + updateCircle(); + case SyncPatchType.updateCircleConversation: + updateCircleConversation(); + case SyncPatchType.updatePinMessage: + updatePinMessage((patch.payload! as List).cast()); + case SyncPatchType.updateTranscriptMessage: + updateTranscriptMessage( + (patch.payload! as List).cast(), + ); + case SyncPatchType.updateAsset: + updateAsset((patch.payload! as List).cast()); + case SyncPatchType.updateToken: + updateToken((patch.payload! as List).cast()); + case SyncPatchType.addJob: + addJob(patch.payload! as MiniJobItem); + } + } + + void _emitPatch(SyncPatch patch) { + if (_suppressPatchEmission) return; + _patchController.add(patch); + } + Stream _watch(_DatabaseEvent event) => EventBus.instance.on .whereType<_DatabaseEventWrapper>() .where((e) => event == e.type) @@ -66,7 +139,9 @@ class DataBaseEventBus { if (kDebugMode && T.toString().startsWith('Iterable')) { w('DatabaseEvent: send iterable is not safe: $T'); } - EventBus.instance.fire(_DatabaseEventWrapper(event, value)); + if (_legacyEventDispatchEnabled) { + EventBus.instance.fire(_DatabaseEventWrapper(event, value)); + } } Stream<_DatabaseEvent> _watchEvent(_DatabaseEvent event) => EventBus @@ -76,8 +151,11 @@ class DataBaseEventBus { .map((e) => e.type) .where((e) => e == event); - void _sendEvent(_DatabaseEvent event) => + void _sendEvent(_DatabaseEvent event) { + if (_legacyEventDispatchEnabled) { EventBus.instance.fire(_DatabaseEventWrapper(event, null)); + } + } // user late Stream> updateUserIdsStream = _watch>( @@ -92,14 +170,15 @@ class DataBaseEventBus { if (id.trim().isNotEmpty) return true; i('DatabaseEvent: insertOrReplaceUsers userId is empty: $id'); return false; - }); + }).toList(); if (newUserIds.isEmpty) { w('DatabaseEvent: insertOrReplaceUsers userIds is empty'); return; } - _send(_DatabaseEvent.updateUser, newUserIds.toList()); + _emitPatch(SyncPatch.updateUser(newUserIds)); + _send(_DatabaseEvent.updateUser, newUserIds); } // circle @@ -107,15 +186,20 @@ class DataBaseEventBus { _DatabaseEvent.updateCircle, ); - void updateCircle() => _sendEvent(_DatabaseEvent.updateCircle); + void updateCircle() { + _emitPatch(SyncPatch.updateCircle()); + _sendEvent(_DatabaseEvent.updateCircle); + } // circleConversation late Stream updateCircleConversationStream = _watch( _DatabaseEvent.updateCircleConversation, ); - void updateCircleConversation() => - _sendEvent(_DatabaseEvent.updateCircleConversation); + void updateCircleConversation() { + _emitPatch(SyncPatch.updateCircleConversation()); + _sendEvent(_DatabaseEvent.updateCircleConversation); + } // conversation late final Stream> updateConversationIdStream = @@ -132,7 +216,9 @@ class DataBaseEventBus { w('DatabaseEvent: insertOrReplaceConversation conversationId is empty'); return; } - _send(_DatabaseEvent.updateConversation, [conversationId]); + final payload = [conversationId]; + _emitPatch(SyncPatch.updateConversation(payload)); + _send(_DatabaseEvent.updateConversation, payload); } // participant @@ -164,13 +250,14 @@ class DataBaseEventBus { } i('DatabaseEvent: updateParticipant participantId is empty'); return false; - }); + }).toList(); if (newParticipants.isEmpty) { w('DatabaseEvent: updateParticipant participantIds is empty'); return; } - _send(_DatabaseEvent.updateParticipant, newParticipants.toList()); + _emitPatch(SyncPatch.updateParticipant(newParticipants)); + _send(_DatabaseEvent.updateParticipant, newParticipants); } // message @@ -204,13 +291,14 @@ class DataBaseEventBus { 'DatabaseEvent: insertOrReplaceMessages messageId or conversationId is empty: $event', ); return false; - }); + }).toList(); if (newMessageEvents.isEmpty) { i('DatabaseEvent: insertOrReplaceMessages messageIds is empty'); return; } - _send(_DatabaseEvent.insertOrReplaceMessage, newMessageEvents.toList()); + _emitPatch(SyncPatch.insertOrReplaceMessage(newMessageEvents)); + _send(_DatabaseEvent.insertOrReplaceMessage, newMessageEvents); } late Stream> deleteMessageIdStream = _watch>( @@ -222,7 +310,9 @@ class DataBaseEventBus { w('DatabaseEvent: deleteMessage messageId is empty'); return; } - _send(_DatabaseEvent.deleteMessage, [messageId]); + final payload = [messageId]; + _emitPatch(SyncPatch.deleteMessage(payload)); + _send(_DatabaseEvent.deleteMessage, payload); } late Stream notificationMessageStream = @@ -234,6 +324,7 @@ class DataBaseEventBus { w('DatabaseEvent: notificationMessage messageId is empty'); return; } + _emitPatch(SyncPatch.notification(miniNotificationMessage)); _send(_DatabaseEvent.notification, miniNotificationMessage); } @@ -267,13 +358,14 @@ class DataBaseEventBus { 'DatabaseEvent: insertOrReplaceMessages messageId or conversationId is empty: $event', ); return false; - }); + }).toList(); if (newMessageEvents.isEmpty) { i('DatabaseEvent: insertOrReplaceMessages messageIds is empty'); return; } - _send(_DatabaseEvent.updateMessageMention, newMessageEvents.toList()); + _emitPatch(SyncPatch.updateMessageMention(newMessageEvents)); + _send(_DatabaseEvent.updateMessageMention, newMessageEvents); } // pinMessage @@ -307,13 +399,14 @@ class DataBaseEventBus { 'DatabaseEvent: updatePinMessage messageId or conversationId is empty: $event', ); return false; - }); + }).toList(); if (newMessageEvents.isEmpty) { i('DatabaseEvent: updatePinMessage messageIds is empty'); return; } - _send(_DatabaseEvent.updatePinMessage, newMessageEvents.toList()); + _emitPatch(SyncPatch.updatePinMessage(newMessageEvents)); + _send(_DatabaseEvent.updatePinMessage, newMessageEvents); } // transcriptMessage @@ -344,13 +437,14 @@ class DataBaseEventBus { if (event.transcriptId.trim().isNotEmpty) return true; i('DatabaseEvent: updateTranscriptMessage transcriptId is empty: $event'); return false; - }); + }).toList(); if (newMessageEvents.isEmpty) { i('DatabaseEvent: updateTranscriptMessage is empty'); return; } - _send(_DatabaseEvent.updateTranscriptMessage, newMessageEvents.toList()); + _emitPatch(SyncPatch.updateTranscriptMessage(newMessageEvents)); + _send(_DatabaseEvent.updateTranscriptMessage, newMessageEvents); } // expiredMessage @@ -358,8 +452,10 @@ class DataBaseEventBus { _DatabaseEvent.updateExpiredMessage, ); - void updateExpiredMessageTable() => - _sendEvent(_DatabaseEvent.updateExpiredMessage); + void updateExpiredMessageTable() { + _emitPatch(SyncPatch.updateExpiredMessage()); + _sendEvent(_DatabaseEvent.updateExpiredMessage); + } // sticker @@ -383,16 +479,19 @@ class DataBaseEventBus { ); void updateSticker(Iterable miniStickers) { - final newMiniStickers = miniStickers.where( - (element) => - (element.stickerId?.trim().isNotEmpty ?? false) || - (element.albumId?.trim().isNotEmpty ?? false), - ); + final newMiniStickers = miniStickers + .where( + (element) => + (element.stickerId?.trim().isNotEmpty ?? false) || + (element.albumId?.trim().isNotEmpty ?? false), + ) + .toList(); if (newMiniStickers.isEmpty) { w('DatabaseEvent: updateSticker miniStickers is empty'); return; } - _send(_DatabaseEvent.updateSticker, newMiniStickers.toList()); + _emitPatch(SyncPatch.updateSticker(newMiniStickers)); + _send(_DatabaseEvent.updateSticker, newMiniStickers); } // app @@ -401,12 +500,15 @@ class DataBaseEventBus { ); void updateFavoriteApp(Iterable appIds) { - final newAppIds = appIds.where((element) => element.trim().isNotEmpty); + final newAppIds = appIds + .where((element) => element.trim().isNotEmpty) + .toList(); if (newAppIds.isEmpty) { w('DatabaseEvent: insertOrReplaceFavoriteApp appIds is empty'); return; } - _send(_DatabaseEvent.updateFavoriteApp, newAppIds.toList()); + _emitPatch(SyncPatch.updateFavoriteApp(newAppIds)); + _send(_DatabaseEvent.updateFavoriteApp, newAppIds); } // Snapshot @@ -415,14 +517,17 @@ class DataBaseEventBus { ); void updateSnapshot(Iterable snapshotIds) { - final newSnapshotIds = snapshotIds.where( - (element) => element.trim().isNotEmpty, - ); + final newSnapshotIds = snapshotIds + .where( + (element) => element.trim().isNotEmpty, + ) + .toList(); if (newSnapshotIds.isEmpty) { w('DatabaseEvent: updateSnapshot snapshotIds is empty'); return; } - _send(_DatabaseEvent.updateSnapshot, newSnapshotIds.toList()); + _emitPatch(SyncPatch.updateSnapshot(newSnapshotIds)); + _send(_DatabaseEvent.updateSnapshot, newSnapshotIds); } // Safe Snapshot @@ -431,14 +536,17 @@ class DataBaseEventBus { ); void updateSafeSnapshot(Iterable snapshotIds) { - final newSnapshotIds = snapshotIds.where( - (element) => element.trim().isNotEmpty, - ); + final newSnapshotIds = snapshotIds + .where( + (element) => element.trim().isNotEmpty, + ) + .toList(); if (newSnapshotIds.isEmpty) { w('DatabaseEvent: updateSafeSnapshot snapshotIds is empty'); return; } - _send(_DatabaseEvent.updateSnapshot, newSnapshotIds.toList()); + _emitPatch(SyncPatch.updateSnapshot(newSnapshotIds)); + _send(_DatabaseEvent.updateSnapshot, newSnapshotIds); } // Asset @@ -447,12 +555,15 @@ class DataBaseEventBus { ); void updateAsset(Iterable assetIds) { - final newAssetIds = assetIds.where((element) => element.trim().isNotEmpty); + final newAssetIds = assetIds + .where((element) => element.trim().isNotEmpty) + .toList(); if (newAssetIds.isEmpty) { w('DatabaseEvent: updateAsset assetIds is empty'); return; } - _send(_DatabaseEvent.updateAsset, newAssetIds.toList()); + _emitPatch(SyncPatch.updateAsset(newAssetIds)); + _send(_DatabaseEvent.updateAsset, newAssetIds); } // Token @@ -461,12 +572,15 @@ class DataBaseEventBus { ); void updateToken(Iterable tokenIds) { - final newTokenIds = tokenIds.where((element) => element.trim().isNotEmpty); + final newTokenIds = tokenIds + .where((element) => element.trim().isNotEmpty) + .toList(); if (newTokenIds.isEmpty) { w('DatabaseEvent: updateToken tokenIds is empty'); return; } - _send(_DatabaseEvent.updateToken, newTokenIds.toList()); + _emitPatch(SyncPatch.updateToken(newTokenIds)); + _send(_DatabaseEvent.updateToken, newTokenIds); } // Job @@ -475,6 +589,7 @@ class DataBaseEventBus { ); void addJob(MiniJobItem job) { + _emitPatch(SyncPatch.addJob(job)); _send(_DatabaseEvent.addJob, job); } } diff --git a/lib/db/mixin_database.dart b/lib/db/mixin_database.dart index c1ce880652..790bc568f9 100644 --- a/lib/db/mixin_database.dart +++ b/lib/db/mixin_database.dart @@ -320,7 +320,7 @@ class MixinDatabase extends _$MixinDatabase { /// Connect to the database. Future connectToDatabase( String identityNumber, { - int readCount = 8, + int readCount = 3, bool fromMainIsolate = false, }) async { final queryExecutor = await openQueryExecutor( diff --git a/lib/db/util/open_database.dart b/lib/db/util/open_database.dart index b244cd3ebb..32869c4602 100644 --- a/lib/db/util/open_database.dart +++ b/lib/db/util/open_database.dart @@ -37,7 +37,7 @@ Future openQueryExecutor({ required String identityNumber, required String dbName, required bool fromMainIsolate, - int readCount = 8, + int readCount = 3, }) async { final backgroundPortName = 'one_mixin_drift_background_${identityNumber}_$dbName'; diff --git a/lib/main.dart b/lib/main.dart index 2afca90416..df3adf439e 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,15 +1,14 @@ import 'dart:async'; import 'dart:io'; import 'dart:math' as math; +import 'dart:ui'; import 'package:ansicolor/ansicolor.dart'; import 'package:drift/drift.dart'; import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:hive/hive.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:isolate/isolate.dart'; import 'package:overlay_support/overlay_support.dart'; import 'package:protocol_handler/protocol_handler.dart'; @@ -18,13 +17,13 @@ import 'package:window_manager/window_manager.dart'; import 'package:window_size/window_size.dart'; import 'app.dart'; -import 'bloc/custom_bloc_observer.dart'; import 'constants/env.dart'; import 'ui/home/home.dart'; import 'ui/setting/log_page.dart'; import 'utils/app_lifecycle.dart'; import 'utils/event_bus.dart'; import 'utils/file.dart'; +import 'utils/hydration_storage.dart'; import 'utils/load_balancer_utils.dart'; import 'utils/local_notification_center.dart'; import 'utils/logger.dart'; @@ -95,13 +94,9 @@ Future _runApp(List args) async { Hive.init(mixinDocumentsDirectory.path); - HydratedBloc.storage = await HydratedStorage.build( - storageDirectory: HydratedStorageDirectory(mixinDocumentsDirectory.path), + HydrationStorageRegistry.storage = await HydrationStorage.build( + storageDirectory: HydrationStorageDirectory(mixinDocumentsDirectory.path), ); - if (kDebugMode) { - Bloc.observer = CustomBlocObserver(); - } - runApp(const ProviderScope(child: OverlaySupport.global(child: App()))); if (kPlatformIsDesktop) { diff --git a/lib/paging/load_more_paging_controller.dart b/lib/paging/load_more_paging_controller.dart new file mode 100644 index 0000000000..72ba095ff5 --- /dev/null +++ b/lib/paging/load_more_paging_controller.dart @@ -0,0 +1,70 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; + +class LoadMorePagingState extends Equatable { + const LoadMorePagingState({this.list = const []}); + + final List list; + + @override + List get props => [list]; + + LoadMorePagingState copyWith({List? list}) => + LoadMorePagingState(list: list ?? this.list); +} + +class LoadMorePagingController { + LoadMorePagingController({ + required this.reloadData, + required this.loadMoreData, + required this.isSameKey, + }) : _state = LoadMorePagingState() { + unawaited(reload()); + } + + final Future> Function() reloadData; + final Future> Function(List) loadMoreData; + final bool Function(T, T) isSameKey; + final StreamController> _stateController = + StreamController>.broadcast(); + LoadMorePagingState _state; + + LoadMorePagingState get state => _state; + + Stream> get stream => _stateController.stream; + + set state(LoadMorePagingState value) { + if (_state == value) return; + _state = value; + if (!_stateController.isClosed) { + _stateController.add(value); + } + } + + Future reload() async { + state = LoadMorePagingState(list: await reloadData()); + } + + Future loadMore() async { + state = state.copyWith(list: await loadMoreData(state.list)); + } + + void insertOrReplace(T item) { + if (state.list.isEmpty) return; + + final index = state.list.indexWhere((element) => isSameKey(element, item)); + List list; + if (index == -1) { + list = [item, ...state.list]; + } else { + list = state.list.toList(); + list[index] = item; + } + state = state.copyWith(list: list); + } + + void dispose() { + unawaited(_stateController.close()); + } +} diff --git a/lib/paging/paging_controller.dart b/lib/paging/paging_controller.dart new file mode 100644 index 0000000000..89c43b0c97 --- /dev/null +++ b/lib/paging/paging_controller.dart @@ -0,0 +1,261 @@ +import 'dart:async'; +import 'dart:math'; + +import 'package:equatable/equatable.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +class PagingState extends Equatable { + const PagingState({ + this.map = const {}, + this.count = 0, + this.initialized = false, + this.index = 0, + this.alignment = 0, + this.hasData = false, + }); + + final Map map; + final int count; + final bool initialized; + final int index; + final double alignment; + final bool hasData; + + @override + List get props => [ + map, + count, + initialized, + index, + alignment, + hasData, + ]; + + PagingState copyWith({ + Map? map, + int? count, + bool? initialized, + int? index, + double? alignment, + bool? hasData, + }) => PagingState( + map: map ?? this.map, + count: count ?? this.count, + initialized: initialized ?? this.initialized, + index: index ?? this.index, + alignment: alignment ?? this.alignment, + hasData: hasData ?? this.hasData, + ); + + @override + bool get stringify => true; +} + +abstract class PagingController { + PagingController({ + required this.itemPositionsListener, + required this.limit, + required PagingState initState, + this.jumpTo, + int offset = 0, + int index = 0, + double alignment = 0, + }) : _state = initState { + itemPositionsListener.itemPositions.addListener(_onItemPositions); + unawaited( + initialize(offset: offset, index: index, alignment: alignment), + ); + } + + final ItemPositionsListener itemPositionsListener; + final void Function({int index, double alignment})? jumpTo; + final StreamController> _stateController = + StreamController>.broadcast(); + final List> _subscriptions = []; + PagingState _state; + + int limit; + List lastItemPositions = []; + + PagingState get state => _state; + + Stream> get stream => _stateController.stream; + + set state(PagingState value) { + if (_state == value) return; + _state = value; + if (!_stateController.isClosed) { + _stateController.add(value); + } + } + + void addSubscription(StreamSubscription subscription) { + _subscriptions.add(subscription); + } + + void _onItemPositions() { + final list = itemPositionsListener.itemPositions.value + .map((e) => e.index) + .toList(); + if (list.isEmpty) return; + + unawaited(handleItemPositions(list)); + } + + Future initialize({ + int offset = 0, + int index = 0, + double alignment = 0, + }) async { + state = state.copyWith(hasData: await queryHasData()); + + final count = await queryCount(); + + state = state.copyWith( + map: await queryMap(limit, offset), + count: count, + initialized: true, + index: index, + alignment: alignment, + ); + + jumpTo?.call(index: index, alignment: alignment); + } + + Future refresh() async { + final count = await queryCount(); + state = state.copyWith( + count: count, + hasData: count != 0, + map: count == 0 ? {} : state.map, + ); + + if (count != 0) { + state = state.copyWith( + map: await queryMap( + max(state.map.length, limit), + state.map.isNotEmpty ? state.map.keys.reduce(min) : 0, + ), + initialized: true, + ); + } + } + + Future handleItemPositions(List itemPositions) async { + lastItemPositions = itemPositions; + final prefetchDistance = limit ~/ 2; + + if (!state.initialized || + state.map.isEmpty || + expectMinListRange( + itemPositions.first - prefetchDistance, + itemPositions.last + prefetchDistance, + )) { + return; + } + + final expectStart = itemPositions.first - limit; + final expectEnd = itemPositions.last + limit; + final result = differenceMaxExpectRange(expectStart, expectEnd); + + var map = state.map; + if (result.length > 1 && (result.first.$2 - result.first.$1) > limit) { + map = await queryMap( + itemPositions.length + limit, + itemPositions.first - prefetchDistance, + ); + } else { + for (final (start, end) in result) { + map = {...map, ...await queryMap(end - start, start)}; + } + + if (map.length > expectEnd - expectStart) { + map.removeWhere((key, value) => key < expectStart || key > expectEnd); + } + } + + state = state.copyWith(map: map, initialized: true); + } + + Future> queryMap(int limit, int _offset) async { + final offset = max(_offset, 0); + final list = await queryRange(limit, offset); + return Map.fromIterables( + List.generate(min(limit, list.length), (index) => offset + index), + list, + ); + } + + bool expectMinListRange(int _start, int _end) { + final start = max(_start, 0); + final end = max(min(_end, state.count - 1), 0); + return state.map[start] != null && state.map[end] != null; + } + + List<(int, int)> differenceMaxExpectRange(int _start, int _end) { + final start = max(_start, 0); + final end = min(_end, state.count); + final loadedIndexes = state.map.keys.toList(); + + (int, int)? before; + (int, int)? after; + + if (start < loadedIndexes.first) { + before = (start, min(loadedIndexes.first, end)); + } + + if (loadedIndexes.last < end) { + after = (max(loadedIndexes.last, start), end); + } + + return [before, after].nonNulls.toList(); + } + + Future queryCount(); + + Future> queryRange(int limit, int offset); + + Future queryHasData(); + + void dispose() { + itemPositionsListener.itemPositions.removeListener(_onItemPositions); + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + _subscriptions.clear(); + unawaited(_stateController.close()); + } +} + +class AnonymousPagingController extends PagingController { + AnonymousPagingController({ + required super.itemPositionsListener, + required super.limit, + required super.jumpTo, + required Future Function() queryCount, + required Future> Function(int limit, int offset) queryRange, + required Future Function() queryHasData, + super.offset, + super.index, + super.alignment, + }) : _queryCount = queryCount, + _queryRange = queryRange, + _queryHasData = queryHasData, + super( + initState: PagingState(), + ); + + final Future Function() _queryCount; + final Future> Function(int limit, int offset) _queryRange; + final Future Function() _queryHasData; + + @override + Future queryCount() => _queryCount(); + + @override + Future> queryRange(int limit, int offset) => + _queryRange(limit, offset); + + @override + Future queryHasData() => _queryHasData(); +} diff --git a/lib/runtime/app_runtime_hub.dart b/lib/runtime/app_runtime_hub.dart new file mode 100644 index 0000000000..fe4e0d1e31 --- /dev/null +++ b/lib/runtime/app_runtime_hub.dart @@ -0,0 +1,428 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../account/account_key_value.dart'; +import '../account/account_server.dart'; +import '../blaze/blaze.dart'; +import '../db/database.dart'; +import '../runtime/session/app_runtime_session_host.dart'; +import '../ui/provider/conversation_provider.dart'; +import '../ui/provider/database_provider.dart'; +import '../ui/provider/multi_auth_provider.dart'; +import '../ui/provider/setting_provider.dart'; +import '../utils/hive_key_values.dart'; +import '../utils/logger.dart'; +import '../utils/platform.dart'; + +typedef GetCurrentConversationId = String? Function(); + +final Provider _currentConversationIdProvider = + Provider( + (ref) => + () => ref.read(currentConversationIdProvider), + ); + +final _runtimeArgsProvider = Provider.autoDispose((ref) { + final database = ref.watch( + databaseProvider.select((value) => value.value), + ); + final authState = ref.watch(authProvider); + final multiAuthNotifier = ref.read(multiAuthNotifierProvider.notifier); + final settingChangeNotifier = ref.read(settingProvider.notifier); + final currentConversationId = ref.read(_currentConversationIdProvider); + + return AppRuntimeArgs( + database: database, + userId: authState?.account.userId, + sessionId: authState?.account.sessionId, + identityNumber: authState?.account.identityNumber, + privateKey: authState?.privateKey, + multiAuthNotifier: multiAuthNotifier, + settingChangeNotifier: settingChangeNotifier, + currentConversationId: currentConversationId, + ); +}); + +enum AppRuntimePhase { + idle, + initializing, + ready, + failed, +} + +class AppRuntimeState extends Equatable { + const AppRuntimeState({ + required this.phase, + required this.accountServer, + this.connectedState, + this.sessionKey, + this.error, + this.stackTrace, + }); + + const AppRuntimeState.idle() + : this( + phase: AppRuntimePhase.idle, + accountServer: const AsyncValue.loading(), + ); + + final AppRuntimePhase phase; + final AsyncValue accountServer; + final ConnectedState? connectedState; + final String? sessionKey; + final Object? error; + final StackTrace? stackTrace; + + AppRuntimeState copyWith({ + AppRuntimePhase? phase, + AsyncValue? accountServer, + ConnectedState? connectedState, + String? sessionKey, + Object? error = _unset, + Object? stackTrace = _unset, + }) => AppRuntimeState( + phase: phase ?? this.phase, + accountServer: accountServer ?? this.accountServer, + connectedState: connectedState ?? this.connectedState, + sessionKey: sessionKey ?? this.sessionKey, + error: error == _unset ? this.error : error, + stackTrace: stackTrace == _unset + ? this.stackTrace + : stackTrace as StackTrace?, + ); + + @override + List get props => [ + phase, + accountServer, + connectedState, + sessionKey, + error, + stackTrace, + ]; +} + +const _unset = Object(); + +class AppRuntimeArgs extends Equatable { + const AppRuntimeArgs({ + required this.database, + required this.userId, + required this.sessionId, + required this.identityNumber, + required this.privateKey, + required this.multiAuthNotifier, + required this.settingChangeNotifier, + required this.currentConversationId, + }); + + final Database? database; + final String? userId; + final String? sessionId; + final String? identityNumber; + final String? privateKey; + final MultiAuthStateNotifier multiAuthNotifier; + final SettingChangeNotifier settingChangeNotifier; + final GetCurrentConversationId currentConversationId; + + @override + List get props => [ + database, + userId, + sessionId, + identityNumber, + privateKey, + multiAuthNotifier, + settingChangeNotifier, + currentConversationId, + ]; +} + +class AppRuntimeHub extends Notifier { + final _authCoordinator = _AuthRuntimeCoordinator(); + final _connectionCoordinator = _ConnectionRuntimeCoordinator(); + final _syncCoordinator = _SyncRuntimeCoordinator(); + + StreamSubscription? _connectionSubscription; + String? _activeSessionKey; + int _epoch = 0; + bool _disposed = false; + + @override + AppRuntimeState build() { + i('[RuntimeHub] build() called, hashCode=$hashCode'); + ref.keepAlive(); + ref.listen(_runtimeArgsProvider, (previous, next) { + i('[RuntimeHub] listener fired, about to call updateArgs'); + unawaited(updateArgs(next)); + }); + // Schedule the initial updateArgs after build() returns, + // so that `state` is initialized and accessible. + Future.microtask(() { + final args = ref.read(_runtimeArgsProvider); + i('[RuntimeHub] initial updateArgs via microtask'); + unawaited(updateArgs(args)); + }); + ref.onDispose(() { + i('[RuntimeHub] onDispose called, hashCode=$hashCode'); + unawaited(_disposeAsync()); + }); + return const AppRuntimeState.idle(); + } + + Future updateArgs(AppRuntimeArgs args) async { + if (_disposed) { + i('[RuntimeHub] updateArgs SKIPPED: _disposed=true, hashCode=$hashCode'); + return; + } + + final snapshot = _authCoordinator.resolve(args); + i( + '[RuntimeHub] updateArgs called: ' + 'database=${args.database != null}, ' + 'isReady=${snapshot.isReady}, ' + 'epoch=$_epoch, ' + 'activeSessionKey=$_activeSessionKey, ' + 'hasValue=${state.accountServer.hasValue}, ' + 'phase=${state.phase}', + ); + if (!snapshot.isReady) { + i('[RuntimeHub] snapshot not ready, going idle'); + await _teardownCurrentSession(); + state = const AppRuntimeState.idle(); + return; + } + + if (args.database == null) { + i('[RuntimeHub] database not ready yet, waiting'); + if (_activeSessionKey != null && + _activeSessionKey != snapshot.sessionKey) { + await _teardownCurrentSession(); + } + state = state.copyWith( + phase: AppRuntimePhase.initializing, + accountServer: const AsyncValue.loading(), + sessionKey: snapshot.sessionKey, + connectedState: ConnectedState.disconnected, + error: null, + stackTrace: null, + ); + return; + } + + if (_activeSessionKey == snapshot.sessionKey && + state.accountServer.hasValue) { + i('[RuntimeHub] session already active, skipping'); + return; + } + + final localEpoch = ++_epoch; + state = state.copyWith( + phase: AppRuntimePhase.initializing, + accountServer: const AsyncValue.loading(), + sessionKey: snapshot.sessionKey, + connectedState: ConnectedState.disconnected, + error: null, + stackTrace: null, + ); + + try { + await _teardownCurrentSession(); + + final accountServer = await _connectionCoordinator.connect( + snapshot: snapshot, + args: args, + ); + + if (_disposed || localEpoch != _epoch) { + await accountServer.stop(); + return; + } + + _activeSessionKey = snapshot.sessionKey; + await _connectionSubscription?.cancel(); + _connectionSubscription = accountServer.connectedStateStream.listen(( + event, + ) { + state = state.copyWith(connectedState: event); + }); + + state = state.copyWith( + phase: AppRuntimePhase.ready, + accountServer: AsyncValue.data(accountServer), + ); + + unawaited( + _syncCoordinator.onReady( + accountServer: accountServer, + snapshot: snapshot, + args: args, + ), + ); + } catch (error, stackTrace) { + if (_disposed || localEpoch != _epoch) return; + state = state.copyWith( + phase: AppRuntimePhase.failed, + accountServer: AsyncValue.error(error, stackTrace), + connectedState: ConnectedState.disconnected, + error: error, + stackTrace: stackTrace, + ); + } + } + + Future _teardownCurrentSession() async { + await _connectionSubscription?.cancel(); + _connectionSubscription = null; + _syncCoordinator.reset(); + + final current = state.accountServer.value; + if (current != null) { + await _connectionCoordinator.disconnect(current); + } + _activeSessionKey = null; + } + + Future _disposeAsync() async { + i('[RuntimeHub] _disposeAsync called, hashCode=$hashCode'); + _disposed = true; + _epoch += 1; + await _teardownCurrentSession(); + _syncCoordinator.dispose(); + } +} + +class _AuthRuntimeSnapshot { + const _AuthRuntimeSnapshot({ + required this.userId, + required this.sessionId, + required this.identityNumber, + required this.privateKey, + }); + + final String? userId; + final String? sessionId; + final String? identityNumber; + final String? privateKey; + + bool get isReady => + userId != null && + sessionId != null && + identityNumber != null && + privateKey != null; + + String get sessionKey => + '${userId ?? ''}:${sessionId ?? ''}:${identityNumber ?? ''}'; +} + +class _AuthRuntimeCoordinator { + _AuthRuntimeSnapshot resolve(AppRuntimeArgs args) => _AuthRuntimeSnapshot( + userId: args.userId, + sessionId: args.sessionId, + identityNumber: args.identityNumber, + privateKey: args.privateKey, + ); +} + +class _ConnectionRuntimeCoordinator { + Future connect({ + required _AuthRuntimeSnapshot snapshot, + required AppRuntimeArgs args, + }) async { + final database = args.database; + i( + '[RuntimeHub] connect: database=${database != null}, isReady=${snapshot.isReady}', + ); + if (!snapshot.isReady || database == null) { + i( + '[RuntimeHub] connect FAILED: isReady=${snapshot.isReady}, database=${database != null}', + ); + throw StateError('runtime args are not ready'); + } + await initKeyValues(snapshot.identityNumber!); + + final runtimeSession = AppRuntimeSessionHost( + identityNumber: snapshot.identityNumber!, + userId: snapshot.userId!, + sessionId: snapshot.sessionId!, + privateKey: snapshot.privateKey!, + loginByPhoneNumber: AccountKeyValue.instance.primarySessionId == null, + primarySessionId: AccountKeyValue.instance.primarySessionId, + ); + await runtimeSession.start(); + + final accountServer = AccountServer.create( + multiAuthNotifier: args.multiAuthNotifier, + settingChangeNotifier: args.settingChangeNotifier, + database: database, + currentConversationId: args.currentConversationId, + userId: snapshot.userId!, + sessionId: snapshot.sessionId!, + identityNumber: snapshot.identityNumber!, + privateKey: snapshot.privateKey!, + runtimeSession: runtimeSession, + ); + await accountServer.activate(); + return accountServer; + } + + Future disconnect(AccountServer accountServer) async { + await accountServer.stop(); + } +} + +class _SyncRuntimeCoordinator { + String? _lastSyncedSessionKey; + bool _disposed = false; + + Future onReady({ + required AccountServer accountServer, + required _AuthRuntimeSnapshot snapshot, + required AppRuntimeArgs args, + }) async { + if (_disposed) return; + if (_lastSyncedSessionKey == snapshot.sessionKey) return; + _lastSyncedSessionKey = snapshot.sessionKey; + + try { + await accountServer.refreshSelf(); + await accountServer.refreshFriends(); + await accountServer.refreshSticker(); + await accountServer.initCircles(); + await accountServer.checkMigration(); + await _checkDeviceConsistency(accountServer, args.multiAuthNotifier); + } catch (error, stackTrace) { + w('runtime sync bootstrap failed: $error, $stackTrace'); + } + } + + Future _checkDeviceConsistency( + AccountServer accountServer, + MultiAuthStateNotifier multiAuthNotifier, + ) async { + final currentDeviceId = await getDeviceId(); + if (currentDeviceId == 'unknown') return; + + final savedDeviceId = AccountKeyValue.instance.deviceId; + if (savedDeviceId == null) { + await AccountKeyValue.instance.setDeviceId(currentDeviceId); + return; + } + + if (savedDeviceId.toLowerCase() != currentDeviceId.toLowerCase()) { + await accountServer.signOutAndClear(); + multiAuthNotifier.signOut(); + } + } + + void reset() { + _lastSyncedSessionKey = null; + } + + void dispose() { + _disposed = true; + _lastSyncedSessionKey = null; + } +} diff --git a/lib/runtime/db_write/method.dart b/lib/runtime/db_write/method.dart new file mode 100644 index 0000000000..6fb2075488 --- /dev/null +++ b/lib/runtime/db_write/method.dart @@ -0,0 +1,96 @@ +enum DbWriteMethod { + insertUpdateUsers, + insertJob, + insertJobs, + deleteJobById, + deleteJobs, + deleteJobByAction, + updateJobRunState, + upsertConversation, + replaceParticipants, + replaceParticipantSessions, + insertParticipantSessions, + updateConversationStatus, + removeParticipantAndResetSessions, + upsertApp, + upsertUser, + upsertParticipant, + upsertSdkUser, + updateConversationFromResponse, + updateConversationExpireIn, + updateParticipantRole, + upsertCircle, + upsertCircleConversations, + deleteCircleConversationById, + deleteCircleAndConversations, + upsertContactConversation, + updateUserMuteUntil, + updateConversationMuteUntil, + updateConversationCodeUrl, + cleanupParticipantSession, + pinConversation, + unpinConversation, + markMentionRead, + parseMentionData, + deleteFloodMessage, + upsertOffset, + updateMessageStatusById, + deleteExpiredMessageByMessageId, + insertExpiredMessage, + updateExpiredMessageExpireAt, + markMessagesRead, + markExpiredMessagesRead, + takeConversationUnseen, + insertSendMessage, + insertMessageHistory, + insertMessageHistoryBatch, + insertResendSessionMessage, + updateAttachmentMessageContentAndStatus, + updateMessageContentAndStatus, + updateMessageContent, + updateMessageCategoryById, + deleteResendSessionMessageById, + updateAttachmentMessage, + updateStickerMessage, + updateContactMessage, + updateLiveMessage, + updateTranscriptMessage, + deletePendingSafeSnapshotByHash, + recallMessage, + deleteMessageMention, + updateQuoteContentByQuoteId, + insertTranscriptMessages, + updateTranscript, + updateMessageMediaStatus, + pinAndInsertPinMessages, + deletePinMessagesByIds, + updateGiphyMessage, + insertFts, + deleteFtsByMessageId, + deleteFtsByConversationId, + deleteConversation, + updateConversationDraft, + updateCircleOrders, + insertSticker, + insertStickerAndRelationship, + deletePersonalSticker, + updateStickerUsedAt, + updateStickerAlbumAdded, + updateStickerAlbumOrders, + updateSafeSnapshotMessage, + deleteMessage, + deleteMessagesByConversation, + upsertAssetAndChain, + upsertTokenAndChain, + upsertSnapshot, + upsertSafeSnapshot, + replaceFiats, + insertFavoriteApps, + upsertStickerAlbum, + replaceStickersByAlbum, + insertFloodMessage, + deleteLegacyFtsChunk, + migrateFtsInsertBatch, + replaceMigrateFtsJob, + insertCleanupQuoteContentJob, +} diff --git a/lib/runtime/db_write/payload.dart b/lib/runtime/db_write/payload.dart new file mode 100644 index 0000000000..e3cc973088 --- /dev/null +++ b/lib/runtime/db_write/payload.dart @@ -0,0 +1,693 @@ +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' as sdk; + +import '../../db/dao/circle_dao.dart'; +import '../../db/mixin_database.dart' as db; +import '../../enum/media_status.dart'; + +class DbWriteUpdateConversationPayload { + const DbWriteUpdateConversationPayload({ + required this.conversation, + required this.currentUserId, + }); + + final sdk.ConversationResponse conversation; + final String currentUserId; +} + +class DbWriteDeleteJobsPayload { + const DbWriteDeleteJobsPayload({required this.jobIds}); + + final List jobIds; +} + +class DbWriteUpdateJobRunStatePayload { + const DbWriteUpdateJobRunStatePayload({ + required this.jobId, + required this.createdAt, + required this.runCount, + }); + + final String jobId; + final DateTime createdAt; + final int runCount; +} + +class DbWriteCirclePayload { + const DbWriteCirclePayload({required this.circle}); + + final db.Circle circle; +} + +class DbWriteCircleConversationsPayload { + const DbWriteCircleConversationsPayload({required this.items}); + + final List items; +} + +class DbWriteReplaceParticipantsPayload { + const DbWriteReplaceParticipantsPayload({ + required this.conversationId, + required this.participants, + }); + + final String conversationId; + final List participants; +} + +class DbWriteReplaceParticipantSessionsPayload { + const DbWriteReplaceParticipantSessionsPayload({ + required this.conversationId, + required this.sessions, + }); + + final String conversationId; + final List sessions; +} + +class DbWriteInsertParticipantSessionsPayload { + const DbWriteInsertParticipantSessionsPayload({ + required this.sessions, + }); + + final List sessions; +} + +class DbWriteDeleteCircleConversationPayload { + const DbWriteDeleteCircleConversationPayload({ + required this.conversationId, + required this.circleId, + }); + + final String conversationId; + final String circleId; +} + +class DbWriteUpsertContactConversationPayload { + const DbWriteUpsertContactConversationPayload({ + required this.conversationId, + required this.currentUserId, + required this.recipientId, + required this.createdAt, + }); + + final String conversationId; + final String currentUserId; + final String recipientId; + final DateTime createdAt; +} + +class DbWriteUpdateUserMuteUntilPayload { + const DbWriteUpdateUserMuteUntilPayload({ + required this.userId, + required this.muteUntil, + }); + + final String userId; + final String muteUntil; +} + +class DbWriteUpdateConversationMuteUntilPayload { + const DbWriteUpdateConversationMuteUntilPayload({ + required this.conversationId, + required this.muteUntil, + }); + + final String conversationId; + final String muteUntil; +} + +class DbWriteUpdateConversationCodeUrlPayload { + const DbWriteUpdateConversationCodeUrlPayload({ + required this.conversationId, + required this.codeUrl, + }); + + final String conversationId; + final String codeUrl; +} + +class DbWriteUpdateConversationStatusPayload { + const DbWriteUpdateConversationStatusPayload({ + required this.conversationId, + required this.status, + }); + + final String conversationId; + final sdk.ConversationStatus status; +} + +class DbWriteUpdateConversationExpireInPayload { + const DbWriteUpdateConversationExpireInPayload({ + required this.conversationId, + required this.expireIn, + }); + + final String conversationId; + final int expireIn; +} + +class DbWriteUpdateParticipantRolePayload { + const DbWriteUpdateParticipantRolePayload({ + required this.conversationId, + required this.participantId, + required this.role, + }); + + final String conversationId; + final String participantId; + final sdk.ParticipantRole? role; +} + +class DbWriteRemoveParticipantAndResetSessionsPayload { + const DbWriteRemoveParticipantAndResetSessionsPayload({ + required this.conversationId, + required this.participantId, + }); + + final String conversationId; + final String participantId; +} + +class DbWriteCleanupParticipantSessionPayload { + const DbWriteCleanupParticipantSessionPayload({required this.sessionId}); + + final String sessionId; +} + +class DbWriteUpdateMessageStatusPayload { + const DbWriteUpdateMessageStatusPayload({ + required this.messageId, + required this.status, + }); + + final String messageId; + final sdk.MessageStatus status; +} + +class DbWriteParseMentionDataPayload { + const DbWriteParseMentionDataPayload({ + required this.content, + required this.messageId, + required this.conversationId, + required this.senderId, + required this.quoteContentJson, + required this.currentUserId, + required this.currentUserIdentityNumber, + }); + + final String? content; + final String messageId; + final String conversationId; + final String senderId; + final String? quoteContentJson; + final String currentUserId; + final String currentUserIdentityNumber; +} + +class DbWriteUpdateExpiredMessageExpireAtPayload { + const DbWriteUpdateExpiredMessageExpireAtPayload({ + required this.messageId, + required this.expireAt, + }); + + final String messageId; + final int? expireAt; +} + +class DbWriteInsertExpiredMessagePayload { + const DbWriteInsertExpiredMessagePayload({ + required this.messageId, + required this.expireIn, + required this.expireAt, + }); + + final String messageId; + final int expireIn; + final int expireAt; +} + +class DbWriteTakeConversationUnseenPayload { + const DbWriteTakeConversationUnseenPayload({ + required this.currentUserId, + required this.conversationId, + }); + + final String currentUserId; + final String conversationId; +} + +class DbWriteMiniMessagePayload { + const DbWriteMiniMessagePayload({ + required this.messageId, + required this.conversationId, + }); + + final String messageId; + final String conversationId; +} + +class DbWriteMarkMessagesReadPayload { + const DbWriteMarkMessagesReadPayload({ + required this.items, + }); + + final List items; +} + +class DbWriteDeleteMessagePayload { + const DbWriteDeleteMessagePayload({ + required this.conversationId, + required this.messageId, + }); + + final String conversationId; + final String messageId; +} + +class DbWriteDeleteMessagesByConversationPayload { + const DbWriteDeleteMessagesByConversationPayload({ + required this.conversationId, + }); + + final String conversationId; +} + +class DbWriteUpdateConversationDraftPayload { + const DbWriteUpdateConversationDraftPayload({ + required this.conversationId, + required this.draft, + }); + + final String conversationId; + final String draft; +} + +class DbWriteUpdateCircleOrdersPayload { + const DbWriteUpdateCircleOrdersPayload({required this.items}); + + final List items; +} + +class DbWriteFavoriteAppsPayload { + const DbWriteFavoriteAppsPayload({ + required this.userId, + required this.apps, + }); + + final String userId; + final List apps; +} + +class DbWriteReplaceStickersByAlbumPayload { + const DbWriteReplaceStickersByAlbumPayload({ + required this.relationships, + required this.stickers, + }); + + final List relationships; + final List stickers; +} + +class DbWriteInsertStickerAndRelationshipPayload { + const DbWriteInsertStickerAndRelationshipPayload({ + required this.sticker, + required this.relationship, + }); + + final db.StickersCompanion sticker; + final db.StickerRelationship relationship; +} + +class DbWriteUpdateStickerUsedAtPayload { + const DbWriteUpdateStickerUsedAtPayload({ + required this.albumId, + required this.stickerId, + required this.usedAt, + }); + + final String? albumId; + final String stickerId; + final DateTime usedAt; +} + +class DbWriteUpdateStickerAlbumAddedPayload { + const DbWriteUpdateStickerAlbumAddedPayload({ + required this.albumId, + required this.added, + }); + + final String albumId; + final bool added; +} + +class DbWriteUpdateStickerAlbumOrdersPayload { + const DbWriteUpdateStickerAlbumOrdersPayload({required this.albums}); + + final List albums; +} + +class DbWriteUpdateSafeSnapshotMessagePayload { + const DbWriteUpdateSafeSnapshotMessagePayload({ + required this.messageId, + required this.snapshotId, + }); + + final String messageId; + final String snapshotId; +} + +class DbWriteUpsertAssetAndChainPayload { + const DbWriteUpsertAssetAndChainPayload({ + required this.asset, + required this.chain, + }); + + final sdk.Asset asset; + final sdk.Chain chain; +} + +class DbWriteUpsertTokenAndChainPayload { + const DbWriteUpsertTokenAndChainPayload({ + required this.token, + required this.chain, + }); + + final sdk.Token token; + final sdk.Chain chain; +} + +class DbWriteInsertSendMessagePayload { + const DbWriteInsertSendMessagePayload({ + required this.message, + required this.currentUserId, + required this.expireIn, + required this.cleanDraft, + this.silent = false, + this.ftsContent, + }); + + final db.Message message; + final String currentUserId; + final int expireIn; + final bool cleanDraft; + final bool silent; + final String? ftsContent; +} + +class DbWriteInsertMessageHistoryPayload { + const DbWriteInsertMessageHistoryPayload({required this.messageId}); + + final String messageId; +} + +class DbWriteInsertMessageHistoryBatchPayload { + const DbWriteInsertMessageHistoryBatchPayload({required this.messageIds}); + + final List messageIds; +} + +class DbWriteInsertResendSessionMessagePayload { + const DbWriteInsertResendSessionMessagePayload({ + required this.message, + }); + + final db.ResendSessionMessage message; +} + +class DbWriteUpdateAttachmentMessageContentAndStatusPayload { + const DbWriteUpdateAttachmentMessageContentAndStatusPayload({ + required this.messageId, + required this.content, + this.key, + this.digest, + }); + + final String messageId; + final String content; + final String? key; + final String? digest; +} + +class DbWriteUpdateMessageContentAndStatusPayload { + const DbWriteUpdateMessageContentAndStatusPayload({ + required this.messageId, + required this.content, + required this.status, + }); + + final String messageId; + final String? content; + final sdk.MessageStatus status; +} + +class DbWriteUpdateMessageContentPayload { + const DbWriteUpdateMessageContentPayload({ + required this.messageId, + required this.content, + }); + + final String messageId; + final String content; +} + +class DbWriteUpdateMessageCategoryPayload { + const DbWriteUpdateMessageCategoryPayload({ + required this.messageId, + required this.category, + }); + + final String messageId; + final String category; +} + +class DbWriteDeleteResendSessionMessagePayload { + const DbWriteDeleteResendSessionMessagePayload({required this.messageId}); + + final String messageId; +} + +class DbWriteUpdateAttachmentMessagePayload { + const DbWriteUpdateAttachmentMessagePayload({ + required this.messageId, + required this.status, + required this.content, + required this.mediaMimeType, + required this.mediaSize, + required this.mediaStatus, + required this.mediaWidth, + required this.mediaHeight, + required this.mediaDigest, + required this.mediaKey, + required this.mediaWaveform, + required this.caption, + required this.name, + required this.thumbImage, + required this.mediaDuration, + }); + + final String messageId; + final sdk.MessageStatus status; + final String content; + final String? mediaMimeType; + final int? mediaSize; + final MediaStatus mediaStatus; + final int? mediaWidth; + final int? mediaHeight; + final String? mediaDigest; + final String? mediaKey; + final String? mediaWaveform; + final String? caption; + final String? name; + final String? thumbImage; + final String? mediaDuration; +} + +class DbWriteUpdateStickerMessagePayload { + const DbWriteUpdateStickerMessagePayload({ + required this.messageId, + required this.status, + required this.stickerId, + }); + + final String messageId; + final sdk.MessageStatus status; + final String stickerId; +} + +class DbWriteUpdateContactMessagePayload { + const DbWriteUpdateContactMessagePayload({ + required this.messageId, + required this.status, + required this.sharedUserId, + }); + + final String messageId; + final sdk.MessageStatus status; + final String sharedUserId; +} + +class DbWriteUpdateLiveMessagePayload { + const DbWriteUpdateLiveMessagePayload({ + required this.messageId, + required this.width, + required this.height, + required this.url, + required this.thumbUrl, + required this.status, + }); + + final String messageId; + final int width; + final int height; + final String url; + final String thumbUrl; + final sdk.MessageStatus status; +} + +class DbWriteUpdateTranscriptMessagePayload { + const DbWriteUpdateTranscriptMessagePayload({ + required this.messageId, + required this.content, + required this.mediaSize, + required this.mediaStatus, + required this.status, + }); + + final String messageId; + final String? content; + final int? mediaSize; + final MediaStatus? mediaStatus; + final sdk.MessageStatus status; +} + +class DbWriteDeletePendingSafeSnapshotByHashPayload { + const DbWriteDeletePendingSafeSnapshotByHashPayload({ + required this.depositHash, + }); + + final String depositHash; +} + +class DbWriteRecallMessagePayload { + const DbWriteRecallMessagePayload({ + required this.conversationId, + required this.messageId, + }); + + final String conversationId; + final String messageId; +} + +class DbWriteDeleteMessageMentionPayload { + const DbWriteDeleteMessageMentionPayload({required this.messageMention}); + + final db.MessageMention messageMention; +} + +class DbWriteUpdateQuoteContentByQuoteIdPayload { + const DbWriteUpdateQuoteContentByQuoteIdPayload({ + required this.conversationId, + required this.quoteMessageId, + required this.content, + }); + + final String conversationId; + final String quoteMessageId; + final String? content; +} + +class DbWriteInsertTranscriptMessagesPayload { + const DbWriteInsertTranscriptMessagesPayload({required this.transcripts}); + + final List transcripts; +} + +class DbWriteUpdateTranscriptPayload { + const DbWriteUpdateTranscriptPayload({ + required this.transcriptId, + required this.messageId, + required this.attachmentId, + required this.key, + required this.digest, + required this.mediaStatus, + required this.mediaCreatedAt, + required this.category, + }); + + final String transcriptId; + final String messageId; + final String attachmentId; + final String? key; + final String? digest; + final MediaStatus mediaStatus; + final DateTime? mediaCreatedAt; + final String category; +} + +class DbWriteUpdateMessageMediaStatusPayload { + const DbWriteUpdateMessageMediaStatusPayload({ + required this.messageId, + required this.status, + }); + + final String messageId; + final MediaStatus status; +} + +class DbWritePinAndInsertPinMessagesPayload { + const DbWritePinAndInsertPinMessagesPayload({ + required this.pinMessages, + required this.systemMessages, + required this.currentUserId, + }); + + final List pinMessages; + final List systemMessages; + final String currentUserId; +} + +class DbWriteDeletePinMessagesPayload { + const DbWriteDeletePinMessagesPayload({required this.messageIds}); + + final List messageIds; +} + +class DbWriteUpdateGiphyMessagePayload { + const DbWriteUpdateGiphyMessagePayload({ + required this.messageId, + required this.mediaUrl, + required this.mediaSize, + required this.thumbImage, + }); + + final String messageId; + final String mediaUrl; + final int mediaSize; + final String? thumbImage; +} + +class DbWriteInsertFtsPayload { + const DbWriteInsertFtsPayload({ + required this.message, + this.content, + }); + + final db.Message message; + final String? content; +} + +class DbWriteMigrateFtsInsertBatchPayload { + const DbWriteMigrateFtsInsertBatchPayload({ + required this.messages, + required this.transcriptContentMap, + }); + + final List messages; + final Map transcriptContentMap; +} + +class DbWriteReplaceMigrateFtsJobPayload { + const DbWriteReplaceMigrateFtsJobPayload({required this.messageRowId}); + + final int? messageRowId; +} diff --git a/lib/runtime/isolate/protocol.dart b/lib/runtime/isolate/protocol.dart new file mode 100644 index 0000000000..9d949b80da --- /dev/null +++ b/lib/runtime/isolate/protocol.dart @@ -0,0 +1,337 @@ +import 'package:dio/dio.dart'; + +import '../../blaze/blaze.dart'; +import '../../db/mixin_database.dart'; +import '../../workers/isolate_event.dart'; +import '../sync/patch.dart'; + +sealed class WorkerCommand { + const WorkerCommand(); + + factory WorkerCommand.fromLegacy(MainIsolateEvent event) { + switch (event.type) { + case MainIsolateEventType.reconnectBlaze: + return const ReconnectBlazeCommand(); + case MainIsolateEventType.disconnectBlazeWithTime: + return const DisconnectBlazeWithTimeCommand(); + case MainIsolateEventType.updateSelectedConversation: + return UpdateSelectedConversationCommand( + conversationId: event.argument as String?, + ); + case MainIsolateEventType.addAckJobs: + return AddAckJobsCommand(jobs: event.argument as List); + case MainIsolateEventType.addSessionAckJobs: + return AddSessionAckJobsCommand(jobs: event.argument as List); + case MainIsolateEventType.addSendingJob: + return AddSendingJobCommand(job: event.argument as Job); + case MainIsolateEventType.addUpdateAssetJob: + return AddUpdateAssetJobCommand(job: event.argument as Job); + case MainIsolateEventType.addUpdateStickerJob: + return AddUpdateStickerJobCommand(job: event.argument as Job); + case MainIsolateEventType.addUpdateTokenJob: + return AddUpdateTokenJobCommand(job: event.argument as Job); + case MainIsolateEventType.addSyncInscriptionMessageJob: + return AddSyncInscriptionMessageJobCommand(job: event.argument as Job); + case MainIsolateEventType.exit: + return const ExitWorkerCommand(); + } + } + + MainIsolateEvent toLegacy(); +} + +final class ReconnectBlazeCommand extends WorkerCommand { + const ReconnectBlazeCommand(); + + @override + MainIsolateEvent toLegacy() => MainIsolateEventType.reconnectBlaze.toEvent(); +} + +final class DisconnectBlazeWithTimeCommand extends WorkerCommand { + const DisconnectBlazeWithTimeCommand(); + + @override + MainIsolateEvent toLegacy() => + MainIsolateEventType.disconnectBlazeWithTime.toEvent(); +} + +final class UpdateSelectedConversationCommand extends WorkerCommand { + const UpdateSelectedConversationCommand({required this.conversationId}); + + final String? conversationId; + + @override + MainIsolateEvent toLegacy() => + MainIsolateEventType.updateSelectedConversation.toEvent(conversationId); +} + +final class AddAckJobsCommand extends WorkerCommand { + const AddAckJobsCommand({required this.jobs}); + + final List jobs; + + @override + MainIsolateEvent toLegacy() => MainIsolateEventType.addAckJobs.toEvent(jobs); +} + +final class AddSessionAckJobsCommand extends WorkerCommand { + const AddSessionAckJobsCommand({required this.jobs}); + + final List jobs; + + @override + MainIsolateEvent toLegacy() => + MainIsolateEventType.addSessionAckJobs.toEvent(jobs); +} + +final class AddSendingJobCommand extends WorkerCommand { + const AddSendingJobCommand({required this.job}); + + final Job job; + + @override + MainIsolateEvent toLegacy() => + MainIsolateEventType.addSendingJob.toEvent(job); +} + +final class AddUpdateAssetJobCommand extends WorkerCommand { + const AddUpdateAssetJobCommand({required this.job}); + + final Job job; + + @override + MainIsolateEvent toLegacy() => + MainIsolateEventType.addUpdateAssetJob.toEvent(job); +} + +final class AddUpdateStickerJobCommand extends WorkerCommand { + const AddUpdateStickerJobCommand({required this.job}); + + final Job job; + + @override + MainIsolateEvent toLegacy() => + MainIsolateEventType.addUpdateStickerJob.toEvent(job); +} + +final class AddUpdateTokenJobCommand extends WorkerCommand { + const AddUpdateTokenJobCommand({required this.job}); + + final Job job; + + @override + MainIsolateEvent toLegacy() => + MainIsolateEventType.addUpdateTokenJob.toEvent(job); +} + +final class AddSyncInscriptionMessageJobCommand extends WorkerCommand { + const AddSyncInscriptionMessageJobCommand({required this.job}); + + final Job job; + + @override + MainIsolateEvent toLegacy() => + MainIsolateEventType.addSyncInscriptionMessageJob.toEvent(job); +} + +final class ExitWorkerCommand extends WorkerCommand { + const ExitWorkerCommand(); + + @override + MainIsolateEvent toLegacy() => MainIsolateEventType.exit.toEvent(); +} + +sealed class WorkerEvent { + const WorkerEvent(); + + factory WorkerEvent.fromLegacy(WorkerIsolateEvent event) { + switch (event.type) { + case WorkerIsolateEventType.onIsolateReady: + return const WorkerIsolateReadyEvent(); + case WorkerIsolateEventType.onBlazeConnectStateChanged: + return WorkerBlazeConnectStateChangedEvent( + state: event.argument as ConnectedState, + ); + case WorkerIsolateEventType.onApiRequestedError: + return WorkerApiRequestedErrorEvent( + error: event.argument as DioException, + ); + case WorkerIsolateEventType.requestDownloadAttachment: + return WorkerRequestDownloadAttachmentEvent( + request: event.argument as AttachmentRequest, + ); + case WorkerIsolateEventType.showPinMessage: + return WorkerShowPinMessageEvent( + conversationId: event.argument as String, + ); + case WorkerIsolateEventType.syncPatches: + return WorkerSyncPatchesEvent( + patches: (event.argument as List).cast(), + ); + } + } + + WorkerIsolateEvent toLegacy(); +} + +final class WorkerIsolateReadyEvent extends WorkerEvent { + const WorkerIsolateReadyEvent(); + + @override + WorkerIsolateEvent toLegacy() => + WorkerIsolateEventType.onIsolateReady.toEvent(); +} + +final class WorkerBlazeConnectStateChangedEvent extends WorkerEvent { + const WorkerBlazeConnectStateChangedEvent({required this.state}); + + final ConnectedState state; + + @override + WorkerIsolateEvent toLegacy() => + WorkerIsolateEventType.onBlazeConnectStateChanged.toEvent(state); +} + +final class WorkerApiRequestedErrorEvent extends WorkerEvent { + const WorkerApiRequestedErrorEvent({required this.error}); + + final DioException error; + + @override + WorkerIsolateEvent toLegacy() => + WorkerIsolateEventType.onApiRequestedError.toEvent(error); +} + +final class WorkerRequestDownloadAttachmentEvent extends WorkerEvent { + const WorkerRequestDownloadAttachmentEvent({required this.request}); + + final AttachmentRequest request; + + @override + WorkerIsolateEvent toLegacy() => + WorkerIsolateEventType.requestDownloadAttachment.toEvent(request); +} + +final class WorkerShowPinMessageEvent extends WorkerEvent { + const WorkerShowPinMessageEvent({required this.conversationId}); + + final String conversationId; + + @override + WorkerIsolateEvent toLegacy() => + WorkerIsolateEventType.showPinMessage.toEvent(conversationId); +} + +final class WorkerSyncPatchesEvent extends WorkerEvent { + const WorkerSyncPatchesEvent({required this.patches}); + + final List patches; + + @override + WorkerIsolateEvent toLegacy() => + WorkerIsolateEventType.syncPatches.toEvent(patches); +} + +final class RpcRequest { + const RpcRequest({ + required this.requestId, + required this.method, + this.payload, + }); + + final String requestId; + final String method; + final Object? payload; +} + +sealed class RpcResponse { + const RpcResponse({required this.requestId}); + + final String requestId; +} + +final class RpcSuccessResponse extends RpcResponse { + const RpcSuccessResponse({required super.requestId, this.result}); + + final Object? result; +} + +final class RpcErrorResponse extends RpcResponse { + const RpcErrorResponse({ + required super.requestId, + required this.code, + required this.message, + this.details, + }); + + final String code; + final String message; + final Object? details; +} + +final class RpcCanceledResponse extends RpcResponse { + const RpcCanceledResponse({required super.requestId}); +} + +final class RpcCancelRequest { + const RpcCancelRequest({required this.requestId}); + + final String requestId; +} + +enum IsolateControlSignal { + ping, + pong, + ready, + stopping, +} + +final class IsolateControlMessage { + const IsolateControlMessage({ + required this.signal, + required this.timestampMs, + }); + + final IsolateControlSignal signal; + final int timestampMs; +} + +sealed class IsolateWireMessage { + const IsolateWireMessage(); +} + +final class IsolateCommandMessage extends IsolateWireMessage { + const IsolateCommandMessage(this.command); + + final WorkerCommand command; +} + +final class IsolateEventMessage extends IsolateWireMessage { + const IsolateEventMessage(this.event); + + final WorkerEvent event; +} + +final class IsolateRpcRequestMessage extends IsolateWireMessage { + const IsolateRpcRequestMessage(this.request); + + final RpcRequest request; +} + +final class IsolateRpcResponseMessage extends IsolateWireMessage { + const IsolateRpcResponseMessage(this.response); + + final RpcResponse response; +} + +final class IsolateRpcCancelMessage extends IsolateWireMessage { + const IsolateRpcCancelMessage(this.cancelRequest); + + final RpcCancelRequest cancelRequest; +} + +final class IsolateControlWireMessage extends IsolateWireMessage { + const IsolateControlWireMessage(this.control); + + final IsolateControlMessage control; +} diff --git a/lib/runtime/isolate/router.dart b/lib/runtime/isolate/router.dart new file mode 100644 index 0000000000..4455be0e41 --- /dev/null +++ b/lib/runtime/isolate/router.dart @@ -0,0 +1,153 @@ +import 'dart:async'; + +import '../../utils/logger.dart'; +import '../../workers/isolate_event.dart'; +import 'protocol.dart'; + +enum IsolateRouterSide { main, worker } + +class IsolateRouter { + IsolateRouter._({ + required IsolateRouterSide side, + required Stream inbound, + required void Function(Object message) sendMessage, + }) : _side = side, + _sendMessage = sendMessage { + _inboundSubscription = inbound.listen( + _handleInbound, + onError: _errorsController.add, + ); + } + + factory IsolateRouter.main({ + required Stream inbound, + required void Function(Object message) sendMessage, + }) => IsolateRouter._( + side: IsolateRouterSide.main, + inbound: inbound, + sendMessage: sendMessage, + ); + + factory IsolateRouter.worker({ + required Stream inbound, + required void Function(Object message) sendMessage, + }) => IsolateRouter._( + side: IsolateRouterSide.worker, + inbound: inbound, + sendMessage: sendMessage, + ); + + final IsolateRouterSide _side; + final void Function(Object message) _sendMessage; + late final StreamSubscription _inboundSubscription; + + final _commandsController = StreamController.broadcast(); + final _eventsController = StreamController.broadcast(); + final _rpcRequestsController = StreamController.broadcast(); + final _rpcResponsesController = StreamController.broadcast(); + final _rpcCancelsController = StreamController.broadcast(); + final _controlsController = + StreamController.broadcast(); + final _unknownMessagesController = StreamController.broadcast(); + final _errorsController = StreamController.broadcast(); + + Stream get commands => _commandsController.stream; + Stream get events => _eventsController.stream; + Stream get rpcRequests => _rpcRequestsController.stream; + Stream get rpcResponses => _rpcResponsesController.stream; + Stream get rpcCancels => _rpcCancelsController.stream; + Stream get controls => _controlsController.stream; + Stream get unknownMessages => _unknownMessagesController.stream; + Stream get errors => _errorsController.stream; + + void sendCommand(WorkerCommand command) { + _sendMessage(IsolateCommandMessage(command)); + } + + void sendEvent(WorkerEvent event) { + _sendMessage(IsolateEventMessage(event)); + } + + void sendRpcRequest(RpcRequest request) { + _sendMessage(IsolateRpcRequestMessage(request)); + } + + void sendRpcResponse(RpcResponse response) { + _sendMessage(IsolateRpcResponseMessage(response)); + } + + void sendRpcCancel(RpcCancelRequest request) { + _sendMessage(IsolateRpcCancelMessage(request)); + } + + void sendControl(IsolateControlSignal signal) { + _sendMessage( + IsolateControlWireMessage( + IsolateControlMessage( + signal: signal, + timestampMs: DateTime.now().millisecondsSinceEpoch, + ), + ), + ); + } + + void sendReady() => sendControl(IsolateControlSignal.ready); + + void _handleInbound(dynamic message) { + if (message is IsolateCommandMessage) { + _commandsController.add(message.command); + return; + } + if (message is IsolateEventMessage) { + _eventsController.add(message.event); + return; + } + if (message is IsolateRpcRequestMessage) { + _rpcRequestsController.add(message.request); + return; + } + if (message is IsolateRpcResponseMessage) { + _rpcResponsesController.add(message.response); + return; + } + if (message is IsolateRpcCancelMessage) { + _rpcCancelsController.add(message.cancelRequest); + return; + } + if (message is IsolateControlWireMessage) { + final control = message.control; + _controlsController.add(control); + if (control.signal == IsolateControlSignal.ping) { + sendControl(IsolateControlSignal.pong); + } + return; + } + + // Compatibility bridge while legacy isolate events still exist. + if (_side == IsolateRouterSide.worker && + message is IsolateEvent) { + _commandsController.add(WorkerCommand.fromLegacy(message)); + return; + } + if (_side == IsolateRouterSide.main && + message is IsolateEvent) { + _eventsController.add(WorkerEvent.fromLegacy(message)); + return; + } + + _unknownMessagesController.add(message); + w('unknown isolate message(${_side.name}): $message'); + } + + Future dispose() async { + await _inboundSubscription.cancel(); + await _commandsController.close(); + await _eventsController.close(); + await _rpcRequestsController.close(); + await _rpcResponsesController.close(); + await _rpcCancelsController.close(); + await _controlsController.close(); + await _unknownMessagesController.close(); + await _errorsController.close(); + } +} diff --git a/lib/runtime/isolate/rpc_client.dart b/lib/runtime/isolate/rpc_client.dart new file mode 100644 index 0000000000..0641d89b42 --- /dev/null +++ b/lib/runtime/isolate/rpc_client.dart @@ -0,0 +1,148 @@ +import 'dart:async'; + +import 'protocol.dart'; +import 'router.dart'; + +class IsolateRpcException implements Exception { + IsolateRpcException({ + required this.code, + required this.message, + this.details, + }); + + final String code; + final String message; + final Object? details; + + @override + String toString() => 'IsolateRpcException($code): $message'; +} + +class IsolateRpcTimeoutException implements Exception { + IsolateRpcTimeoutException(this.requestId, this.method); + + final String requestId; + final String method; + + @override + String toString() => + 'IsolateRpcTimeoutException: $method($requestId) timed out'; +} + +class IsolateRpcClient { + IsolateRpcClient( + this._router, { + this.defaultTimeout = const Duration(seconds: 10), + }) { + _responseSubscription = _router.rpcResponses.listen(_handleResponse); + } + + final IsolateRouter _router; + final Duration defaultTimeout; + + late final StreamSubscription _responseSubscription; + int _requestSeq = 0; + final Map _pending = {}; + + Future request( + String method, { + Object? payload, + Duration? timeout, + }) { + final requestId = + '${DateTime.now().microsecondsSinceEpoch}_${_requestSeq++}'; + final completer = Completer(); + final timer = Timer(timeout ?? defaultTimeout, () { + final pending = _pending.remove(requestId); + if (pending == null || pending.completer.isCompleted) return; + pending.completer.completeError( + IsolateRpcTimeoutException(requestId, method), + ); + _router.sendRpcCancel(RpcCancelRequest(requestId: requestId)); + }); + + _pending[requestId] = _PendingRequest( + completer: completer, + timer: timer, + ); + + try { + _router.sendRpcRequest( + RpcRequest(requestId: requestId, method: method, payload: payload), + ); + } catch (error) { + final pending = _pending.remove(requestId); + pending?.timer.cancel(); + if (pending != null && !pending.completer.isCompleted) { + pending.completer.completeError(error); + } + } + + return completer.future; + } + + void cancel(String requestId) { + final pending = _pending.remove(requestId); + if (pending == null) return; + pending.timer.cancel(); + if (!pending.completer.isCompleted) { + pending.completer.completeError( + IsolateRpcException( + code: 'rpc_canceled', + message: 'Request canceled by caller', + ), + ); + } + _router.sendRpcCancel(RpcCancelRequest(requestId: requestId)); + } + + void _handleResponse(RpcResponse response) { + final pending = _pending.remove(response.requestId); + if (pending == null) return; + pending.timer.cancel(); + + if (pending.completer.isCompleted) return; + + switch (response) { + case RpcSuccessResponse(:final result): + pending.completer.complete(result); + case RpcErrorResponse(:final code, :final message, :final details): + pending.completer.completeError( + IsolateRpcException(code: code, message: message, details: details), + ); + case RpcCanceledResponse(): + pending.completer.completeError( + IsolateRpcException( + code: 'rpc_canceled', + message: 'Request canceled by remote', + ), + ); + } + } + + Future dispose() async { + await _responseSubscription.cancel(); + for (final pending in _pending.values) { + pending.timer.cancel(); + if (!pending.completer.isCompleted) { + pending.completer.completeError( + IsolateRpcException( + code: 'rpc_disposed', + message: 'RPC client disposed before receiving response', + ), + ); + } + } + _pending.clear(); + } +} + +class _PendingRequest { + _PendingRequest({ + required this.completer, + required this.timer, + }); + + final Completer completer; + final Timer timer; +} diff --git a/lib/runtime/isolate/worker_supervisor.dart b/lib/runtime/isolate/worker_supervisor.dart new file mode 100644 index 0000000000..8bb873bab9 --- /dev/null +++ b/lib/runtime/isolate/worker_supervisor.dart @@ -0,0 +1,319 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:stream_channel/isolate_channel.dart'; + +import '../../utils/logger.dart'; +import 'protocol.dart'; + +typedef WorkerEntryPoint = FutureOr Function(T params); +typedef WorkerInitParamsFactory = T Function(SendPort sendPort); + +sealed class WorkerSupervisorEvent { + const WorkerSupervisorEvent(); +} + +final class WorkerStartingEvent extends WorkerSupervisorEvent { + const WorkerStartingEvent({required this.restartCount}); + + final int restartCount; +} + +final class WorkerStartedEvent extends WorkerSupervisorEvent { + const WorkerStartedEvent({required this.restartCount}); + + final int restartCount; +} + +final class WorkerExitedEvent extends WorkerSupervisorEvent { + const WorkerExitedEvent({required this.payload}); + + final dynamic payload; +} + +final class WorkerErrorEvent extends WorkerSupervisorEvent { + const WorkerErrorEvent({required this.payload}); + + final dynamic payload; +} + +final class WorkerRestartScheduledEvent extends WorkerSupervisorEvent { + const WorkerRestartScheduledEvent({ + required this.restartCount, + required this.delay, + this.reason, + }); + + final int restartCount; + final Duration delay; + final String? reason; +} + +final class WorkerStoppedEvent extends WorkerSupervisorEvent { + const WorkerStoppedEvent(); +} + +class WorkerSupervisor { + WorkerSupervisor({ + required this.entryPoint, + required this.initParamsFactory, + this.restartDelay = const Duration(seconds: 1), + this.maxRestarts = 20, + this.heartbeatInterval = const Duration(seconds: 5), + this.heartbeatTimeout = const Duration(seconds: 15), + this.debugName, + }); + + final WorkerEntryPoint entryPoint; + final WorkerInitParamsFactory initParamsFactory; + final Duration restartDelay; + final int maxRestarts; + final Duration heartbeatInterval; + final Duration heartbeatTimeout; + final String? debugName; + + final _messagesController = StreamController.broadcast(); + final _eventsController = StreamController.broadcast(); + + Stream get messages => _messagesController.stream; + Stream get events => _eventsController.stream; + + Isolate? _isolate; + IsolateChannel? _channel; + ReceivePort? _receivePort; + ReceivePort? _exitPort; + ReceivePort? _errorPort; + + StreamSubscription? _messageSubscription; + StreamSubscription? _exitSubscription; + StreamSubscription? _errorSubscription; + + Timer? _heartbeatTimer; + DateTime _lastHeartbeatAt = DateTime.now(); + bool _running = false; + bool _ready = false; + bool _disposed = false; + bool _starting = false; + bool _shouldRun = false; + int _restartCount = 0; + Completer? _readyCompleter; + + bool get isRunning => _running; + bool get isReady => _running && _ready; + + Future start() async { + if (_disposed || _running || _starting) return; + _shouldRun = true; + _restartCount = 0; + await _startWorker(); + } + + Future stop() async { + _shouldRun = false; + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + await _teardownWorker(); + _eventsController.add(const WorkerStoppedEvent()); + } + + Future waitUntilReady({Duration? timeout}) { + if (_disposed) { + return Future.error(StateError('worker supervisor already disposed')); + } + if (isReady) return Future.value(); + + final completer = _readyCompleter ??= Completer(); + final future = completer.future; + final guardedFuture = future.then((_) { + if (!isReady) { + throw StateError('worker stopped before becoming ready'); + } + }); + if (timeout == null) return guardedFuture; + return guardedFuture.timeout( + timeout, + onTimeout: () => throw TimeoutException( + 'Worker did not become ready within ${timeout.inMilliseconds}ms', + ), + ); + } + + void send(Object message) { + final channel = _channel; + if (channel == null) { + throw StateError('worker channel is null'); + } + try { + channel.sink.add(message); + } catch (error, stacktrace) { + e('worker supervisor send failed: $error, $stacktrace'); + } + } + + Future _startWorker() async { + if (_disposed || _starting) return; + _starting = true; + _eventsController.add(WorkerStartingEvent(restartCount: _restartCount)); + try { + _receivePort = ReceivePort(); + _exitPort = ReceivePort(); + _errorPort = ReceivePort(); + + final isolate = await Isolate.spawn( + entryPoint, + initParamsFactory(_receivePort!.sendPort), + errorsAreFatal: false, + onExit: _exitPort!.sendPort, + onError: _errorPort!.sendPort, + debugName: debugName, + ); + _isolate = isolate; + _channel = IsolateChannel.connectReceive(_receivePort!); + _ready = false; + _readyCompleter = Completer(); + + _messageSubscription = _channel!.stream.listen( + (message) { + if (message is IsolateControlWireMessage && + message.control.signal == IsolateControlSignal.pong) { + _lastHeartbeatAt = DateTime.now(); + } else if (message is IsolateControlWireMessage && + message.control.signal == IsolateControlSignal.ready) { + _markReady(); + } + _messagesController.add(message); + }, + onError: (error, stacktrace) { + _eventsController.add(WorkerErrorEvent(payload: error)); + e('worker supervisor message stream error: $error, $stacktrace'); + }, + ); + + _exitSubscription = _exitPort!.listen(_onWorkerExit); + _errorSubscription = _errorPort!.listen(_onWorkerError); + + _running = true; + _lastHeartbeatAt = DateTime.now(); + _startHeartbeat(); + _eventsController.add(WorkerStartedEvent(restartCount: _restartCount)); + } finally { + _starting = false; + } + } + + void _startHeartbeat() { + _heartbeatTimer?.cancel(); + _heartbeatTimer = Timer.periodic(heartbeatInterval, (_) { + if (!_running || !_shouldRun || _channel == null) return; + + send( + IsolateControlWireMessage( + IsolateControlMessage( + signal: IsolateControlSignal.ping, + timestampMs: DateTime.now().millisecondsSinceEpoch, + ), + ), + ); + + final elapsed = DateTime.now().difference(_lastHeartbeatAt); + if (elapsed > heartbeatTimeout) { + w('worker heartbeat timeout: ${elapsed.inMilliseconds}ms'); + unawaited(_restart(reason: 'heartbeat_timeout')); + } + }); + } + + void _onWorkerExit(dynamic payload) { + _running = false; + _eventsController.add(WorkerExitedEvent(payload: payload)); + if (_shouldRun) { + unawaited(_restart(reason: 'worker_exit')); + } + } + + void _onWorkerError(dynamic payload) { + _eventsController.add(WorkerErrorEvent(payload: payload)); + if (_shouldRun && _running) { + unawaited(_restart(reason: 'worker_error')); + } + } + + Future _restart({String? reason}) async { + if (_disposed || !_shouldRun) return; + if (_restartCount >= maxRestarts) { + e('worker supervisor max restart count reached'); + _shouldRun = false; + await stop(); + return; + } + + _restartCount += 1; + _eventsController.add( + WorkerRestartScheduledEvent( + restartCount: _restartCount, + delay: restartDelay, + reason: reason, + ), + ); + await _teardownWorker(); + if (!_shouldRun || _disposed) return; + await Future.delayed(restartDelay); + if (!_shouldRun || _disposed) return; + await _startWorker(); + } + + Future _teardownWorker() async { + _running = false; + _resetReadyState(); + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + + try { + _isolate?.kill(priority: Isolate.immediate); + } catch (error, stacktrace) { + e('worker supervisor kill isolate failed: $error, $stacktrace'); + } + _isolate = null; + + await _messageSubscription?.cancel(); + await _exitSubscription?.cancel(); + await _errorSubscription?.cancel(); + _messageSubscription = null; + _exitSubscription = null; + _errorSubscription = null; + + _channel = null; + + _receivePort?.close(); + _exitPort?.close(); + _errorPort?.close(); + _receivePort = null; + _exitPort = null; + _errorPort = null; + } + + Future dispose() async { + if (_disposed) return; + _disposed = true; + await stop(); + await _messagesController.close(); + await _eventsController.close(); + } + + void _markReady() { + _ready = true; + final completer = _readyCompleter; + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + } + + void _resetReadyState() { + _ready = false; + final completer = _readyCompleter; + if (completer != null && !completer.isCompleted) { + completer.complete(); + } + _readyCompleter = null; + } +} diff --git a/lib/runtime/session/app_runtime_session_host.dart b/lib/runtime/session/app_runtime_session_host.dart new file mode 100644 index 0000000000..86ccfa3904 --- /dev/null +++ b/lib/runtime/session/app_runtime_session_host.dart @@ -0,0 +1,369 @@ +import 'dart:async'; + +import 'package:dio/dio.dart'; +import 'package:flutter/services.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../blaze/blaze.dart'; +import '../../db/database_event_bus.dart'; +import '../../runtime/db_write/method.dart'; +import '../../runtime/isolate/protocol.dart'; +import '../../runtime/isolate/router.dart'; +import '../../runtime/isolate/rpc_client.dart'; +import '../../runtime/isolate/worker_supervisor.dart'; +import '../../utils/file.dart'; +import '../../utils/logger.dart'; +import '../../workers/db_write_worker_isolate.dart'; +import '../../workers/isolate_event.dart'; +import '../../workers/sync_worker_isolate.dart'; + +const _kWorkerDbWriteRpcPrefix = 'db_write:'; + +typedef RuntimeAttachmentRequestHandler = + Future Function( + AttachmentRequest request, + ); +typedef RuntimeApiErrorHandler = Future Function(DioException error); +typedef RuntimeShowPinHandler = Future Function(String conversationId); + +class AppRuntimeSessionHost { + AppRuntimeSessionHost({ + required this.identityNumber, + required this.userId, + required this.sessionId, + required this.privateKey, + required this.loginByPhoneNumber, + required this.primarySessionId, + }); + + final String identityNumber; + final String userId; + final String sessionId; + final String privateKey; + final bool loginByPhoneNumber; + final String? primarySessionId; + + RuntimeAttachmentRequestHandler? onAttachmentRequest; + RuntimeApiErrorHandler? onApiRequestedError; + RuntimeShowPinHandler? onShowPinMessage; + + WorkerSupervisor? _syncWorkerSupervisor; + IsolateRouter? _syncWorkerRouter; + IsolateRpcClient? _syncWorkerRpcClient; + + WorkerSupervisor? _dbWriteWorkerSupervisor; + IsolateRouter? _dbWriteWorkerRouter; + IsolateRpcClient? _dbWriteRpcClient; + + final _connectedStateBehaviorSubject = BehaviorSubject.seeded( + ConnectedState.disconnected, + ); + final _subscriptions = >[]; + bool _stopped = false; + bool _disposed = false; + + ValueStream get connectedStateStream => + _connectedStateBehaviorSubject.stream; + + Future start() async { + if (_disposed) { + throw StateError('runtime session host is already disposed'); + } + _stopped = false; + await _startDbWriteWorker(); + await _startSyncWorker(); + } + + Future stop({bool graceful = true}) async { + if (_stopped) return; + _stopped = true; + if (graceful) { + sendSyncCommand(const ExitWorkerCommand()); + _sendCommandToDbWriteWorker(const ExitWorkerCommand()); + } + + await Future.wait( + _subscriptions.map((subscription) => subscription.cancel()), + ); + _subscriptions.clear(); + + await _dbWriteRpcClient?.dispose(); + _dbWriteRpcClient = null; + await _dbWriteWorkerRouter?.dispose(); + _dbWriteWorkerRouter = null; + await _dbWriteWorkerSupervisor?.stop(); + await _dbWriteWorkerSupervisor?.dispose(); + _dbWriteWorkerSupervisor = null; + + await _syncWorkerRpcClient?.dispose(); + _syncWorkerRpcClient = null; + await _syncWorkerRouter?.dispose(); + _syncWorkerRouter = null; + await _syncWorkerSupervisor?.stop(); + await _syncWorkerSupervisor?.dispose(); + _syncWorkerSupervisor = null; + + _connectedStateBehaviorSubject.add(ConnectedState.disconnected); + } + + Future dispose() async { + if (_disposed) return; + _disposed = true; + await stop(); + await _connectedStateBehaviorSubject.close(); + } + + void sendSyncCommand(WorkerCommand command) { + try { + final router = _syncWorkerRouter; + if (router == null) { + d('sendSyncCommand dropped because router is null: $command'); + assert(command is ExitWorkerCommand); + return; + } + router.sendCommand(command); + } catch (error, stackTrace) { + e('sendSyncCommand failed: $error, $stackTrace'); + } + } + + Future requestDbWrite( + DbWriteMethod method, { + Object? payload, + }) async { + await _waitForDbWriteWorkerReady(method.name); + final rpcClient = _dbWriteRpcClient; + if (rpcClient == null) { + throw StateError('db write rpc client not ready: ${method.name}'); + } + + await rpcClient.request( + method.name, + payload: payload, + timeout: const Duration(seconds: 20), + ); + } + + Future _startSyncWorker() async { + await _syncWorkerSupervisor?.dispose(); + _syncWorkerSupervisor = WorkerSupervisor( + entryPoint: startSyncWorkerIsolate, + initParamsFactory: (sendPort) => SyncWorkerInitParams( + sendPort: sendPort, + identityNumber: identityNumber, + userId: userId, + sessionId: sessionId, + privateKey: privateKey, + mixinDocumentDirectory: mixinDocumentsDirectory.path, + primarySessionId: primarySessionId, + loginByPhoneNumber: loginByPhoneNumber, + rootIsolateToken: ServicesBinding.rootIsolateToken!, + ), + debugName: 'mixin_sync_worker_supervisor', + ); + + _syncWorkerRouter = IsolateRouter.main( + inbound: _syncWorkerSupervisor!.messages, + sendMessage: _syncWorkerSupervisor!.send, + ); + await _syncWorkerRpcClient?.dispose(); + _syncWorkerRpcClient = IsolateRpcClient(_syncWorkerRouter!); + + _subscriptions + ..add( + _syncWorkerSupervisor!.events.listen((event) { + switch (event) { + case WorkerExitedEvent(:final payload): + w('sync worker exited. $payload'); + _connectedStateBehaviorSubject.add(ConnectedState.disconnected); + case WorkerErrorEvent(:final payload): + e('sync worker isolate remote error: $payload'); + case WorkerRestartScheduledEvent(:final reason, :final delay): + w( + 'sync worker restart scheduled: $reason after ${delay.inMilliseconds}ms', + ); + case WorkerStartingEvent(): + case WorkerStartedEvent(): + case WorkerStoppedEvent(): + break; + } + }), + ) + ..add( + _syncWorkerRouter!.events.listen((event) { + try { + _handleSyncWorkerEvent(event); + } catch (error, stackTrace) { + e('handle sync worker event failed: $error, $stackTrace'); + } + }), + ) + ..add( + _syncWorkerRouter!.rpcRequests.listen((request) async { + final router = _syncWorkerRouter; + if (router == null) return; + + final method = request.method; + if (!method.startsWith(_kWorkerDbWriteRpcPrefix)) { + router.sendRpcResponse( + RpcErrorResponse( + requestId: request.requestId, + code: 'unsupported_worker_rpc', + message: 'Unsupported worker rpc method: $method', + ), + ); + return; + } + + final dbWriteMethodName = method.substring( + _kWorkerDbWriteRpcPrefix.length, + ); + try { + await _waitForDbWriteWorkerReady(dbWriteMethodName); + final dbRpcClient = _dbWriteRpcClient; + if (dbRpcClient == null) { + throw StateError('db write rpc client not ready'); + } + final result = await dbRpcClient.request( + dbWriteMethodName, + payload: request.payload, + timeout: const Duration(seconds: 20), + ); + router.sendRpcResponse( + RpcSuccessResponse( + requestId: request.requestId, + result: result, + ), + ); + } catch (error, stackTrace) { + e( + 'sync worker -> db write rpc forwarding failed: method=$dbWriteMethodName ' + 'error=$error, $stackTrace', + ); + router.sendRpcResponse( + RpcErrorResponse( + requestId: request.requestId, + code: 'db_write_forward_failed', + message: error.toString(), + ), + ); + } + }), + ); + + await _syncWorkerSupervisor!.start(); + } + + Future _startDbWriteWorker() async { + await _dbWriteWorkerSupervisor?.dispose(); + _dbWriteWorkerSupervisor = WorkerSupervisor( + entryPoint: startDbWriteWorkerIsolate, + initParamsFactory: (sendPort) => DbWriteWorkerInitParams( + sendPort: sendPort, + identityNumber: identityNumber, + ), + debugName: 'mixin_db_write_worker_supervisor', + ); + + _dbWriteWorkerRouter = IsolateRouter.main( + inbound: _dbWriteWorkerSupervisor!.messages, + sendMessage: _dbWriteWorkerSupervisor!.send, + ); + await _dbWriteRpcClient?.dispose(); + _dbWriteRpcClient = IsolateRpcClient(_dbWriteWorkerRouter!); + + _subscriptions + ..add( + _dbWriteWorkerSupervisor!.events.listen((event) { + switch (event) { + case WorkerExitedEvent(:final payload): + w('db write worker exited. $payload'); + case WorkerErrorEvent(:final payload): + e('db write worker error: $payload'); + case WorkerRestartScheduledEvent(:final reason, :final delay): + w( + 'db write worker restart scheduled: $reason after ${delay.inMilliseconds}ms', + ); + case WorkerStartingEvent(): + case WorkerStartedEvent(): + case WorkerStoppedEvent(): + break; + } + }), + ) + ..add( + _dbWriteWorkerRouter!.events.listen((event) { + try { + _handleDbWriteWorkerEvent(event); + } catch (error, stackTrace) { + e('handle db write isolate event failed: $error, $stackTrace'); + } + }), + ); + await _dbWriteWorkerSupervisor!.start(); + await _waitForDbWriteWorkerReady('startup'); + } + + void _handleSyncWorkerEvent(WorkerEvent event) { + switch (event) { + case WorkerIsolateReadyEvent(): + d('sync worker ready'); + case WorkerBlazeConnectStateChangedEvent(:final state): + _connectedStateBehaviorSubject.add(state); + case WorkerApiRequestedErrorEvent(:final error): + final handler = onApiRequestedError; + if (handler != null) { + unawaited(handler(error)); + } + case WorkerRequestDownloadAttachmentEvent(:final request): + final handler = onAttachmentRequest; + if (handler != null) { + unawaited(handler(request)); + } + case WorkerShowPinMessageEvent(:final conversationId): + final handler = onShowPinMessage; + if (handler != null) { + unawaited(handler(conversationId)); + } + case WorkerSyncPatchesEvent(:final patches): + DataBaseEventBus.instance.applyPatches(patches); + } + } + + void _handleDbWriteWorkerEvent(WorkerEvent event) { + switch (event) { + case WorkerSyncPatchesEvent(:final patches): + DataBaseEventBus.instance.applyPatches(patches); + case WorkerIsolateReadyEvent(): + case WorkerBlazeConnectStateChangedEvent(): + case WorkerApiRequestedErrorEvent(): + case WorkerRequestDownloadAttachmentEvent(): + case WorkerShowPinMessageEvent(): + break; + } + } + + void _sendCommandToDbWriteWorker(WorkerCommand command) { + try { + final router = _dbWriteWorkerRouter; + if (router == null) { + d( + 'sendCommandToDbWriteWorker dropped because router is null: $command', + ); + assert(command is ExitWorkerCommand); + return; + } + router.sendCommand(command); + } catch (error, stackTrace) { + e('sendCommandToDbWriteWorker failed: $error, $stackTrace'); + } + } + + Future _waitForDbWriteWorkerReady(String reason) async { + final supervisor = _dbWriteWorkerSupervisor; + if (supervisor == null) { + throw StateError('db write worker supervisor not ready: $reason'); + } + await supervisor.waitUntilReady(timeout: const Duration(seconds: 20)); + } +} diff --git a/lib/runtime/sync/patch.dart b/lib/runtime/sync/patch.dart new file mode 100644 index 0000000000..b3296bfa56 --- /dev/null +++ b/lib/runtime/sync/patch.dart @@ -0,0 +1,87 @@ +import '../../db/dao/job_dao.dart'; +import '../../db/dao/message_dao.dart'; +import '../../db/dao/participant_dao.dart'; +import '../../db/event.dart'; + +enum SyncPatchType { + notification, + insertOrReplaceMessage, + deleteMessage, + updateExpiredMessage, + updateConversation, + updateFavoriteApp, + updateUser, + updateParticipant, + updateSticker, + updateSnapshot, + updateMessageMention, + updateCircle, + updateCircleConversation, + updatePinMessage, + updateTranscriptMessage, + updateAsset, + updateToken, + addJob, +} + +class SyncPatch { + const SyncPatch(this.type, [this.payload]); + + factory SyncPatch.notification(MiniNotificationMessage message) => + SyncPatch(SyncPatchType.notification, message); + + factory SyncPatch.insertOrReplaceMessage(List events) => + SyncPatch(SyncPatchType.insertOrReplaceMessage, events); + + factory SyncPatch.deleteMessage(List messageIds) => + SyncPatch(SyncPatchType.deleteMessage, messageIds); + + factory SyncPatch.updateExpiredMessage() => + const SyncPatch(SyncPatchType.updateExpiredMessage); + + factory SyncPatch.updateConversation(List conversationIds) => + SyncPatch(SyncPatchType.updateConversation, conversationIds); + + factory SyncPatch.updateFavoriteApp(List appIds) => + SyncPatch(SyncPatchType.updateFavoriteApp, appIds); + + factory SyncPatch.updateUser(List userIds) => + SyncPatch(SyncPatchType.updateUser, userIds); + + factory SyncPatch.updateParticipant(List participants) => + SyncPatch(SyncPatchType.updateParticipant, participants); + + factory SyncPatch.updateSticker(List stickers) => + SyncPatch(SyncPatchType.updateSticker, stickers); + + factory SyncPatch.updateSnapshot(List snapshotIds) => + SyncPatch(SyncPatchType.updateSnapshot, snapshotIds); + + factory SyncPatch.updateMessageMention(List events) => + SyncPatch(SyncPatchType.updateMessageMention, events); + + factory SyncPatch.updateCircle() => + const SyncPatch(SyncPatchType.updateCircle); + + factory SyncPatch.updateCircleConversation() => + const SyncPatch(SyncPatchType.updateCircleConversation); + + factory SyncPatch.updatePinMessage(List events) => + SyncPatch(SyncPatchType.updatePinMessage, events); + + factory SyncPatch.updateTranscriptMessage( + List events, + ) => SyncPatch(SyncPatchType.updateTranscriptMessage, events); + + factory SyncPatch.updateAsset(List assetIds) => + SyncPatch(SyncPatchType.updateAsset, assetIds); + + factory SyncPatch.updateToken(List tokenIds) => + SyncPatch(SyncPatchType.updateToken, tokenIds); + + factory SyncPatch.addJob(MiniJobItem job) => + SyncPatch(SyncPatchType.addJob, job); + + final SyncPatchType type; + final Object? payload; +} diff --git a/lib/runtime/sync/tick_patch_batcher.dart b/lib/runtime/sync/tick_patch_batcher.dart new file mode 100644 index 0000000000..a6a6e39f99 --- /dev/null +++ b/lib/runtime/sync/tick_patch_batcher.dart @@ -0,0 +1,39 @@ +import 'dart:async'; + +import 'patch.dart'; + +class TickPatchBatcher { + TickPatchBatcher({ + required this.onFlush, + this.flushDelay = const Duration(milliseconds: 16), + }); + + final void Function(List patches) onFlush; + final Duration flushDelay; + + final List _pending = []; + Timer? _timer; + bool _disposed = false; + + void add(SyncPatch patch) { + if (_disposed) return; + _pending.add(patch); + _timer ??= Timer(flushDelay, _flush); + } + + void dispose() { + if (_disposed) return; + _disposed = true; + _timer?.cancel(); + _timer = null; + _flush(); + } + + void _flush() { + if (_pending.isEmpty) return; + final pending = List.unmodifiable(_pending.toList()); + _pending.clear(); + _timer = null; + onFlush(pending); + } +} diff --git a/lib/ui/home/bloc/blink_cubit.dart b/lib/ui/home/bloc/blink_cubit.dart deleted file mode 100644 index 284655bc1d..0000000000 --- a/lib/ui/home/bloc/blink_cubit.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:flutter/material.dart'; - -import '../../../bloc/subscribe_mixin.dart'; - -class BlinkState extends Equatable { - const BlinkState({this.color = Colors.transparent, this.messageId}); - - final Color color; - final String? messageId; - - @override - List get props => [color, messageId]; - - BlinkState copyWith({Color? color, String? messageId}) => BlinkState( - color: color ?? this.color, - messageId: messageId ?? this.messageId, - ); -} - -class BlinkCubit extends Cubit with SubscribeMixin { - BlinkCubit(this.tickerProvider, this.blinkColor) : super(const BlinkState()) { - animationController - ..addListener(_onUpdate) - ..addStatusListener(_onComplete); - } - - final TickerProvider tickerProvider; - final Color blinkColor; - - late final AnimationController animationController = AnimationController( - duration: const Duration(milliseconds: 600), - vsync: tickerProvider, - ); - - late final colorTween = ColorTween( - begin: Colors.transparent, - end: blinkColor, - ); - - void _onUpdate() { - emit(state.copyWith(color: colorTween.evaluate(animationController))); - } - - void _onComplete(AnimationStatus status) { - if (status != AnimationStatus.completed) return; - animationController.reverse(); - } - - void blinkByMessageId(String messageId) { - emit(BlinkState(messageId: messageId)); - animationController - ..reset() - ..forward(); - } - - @override - Future close() { - animationController - ..removeListener(_onUpdate) - ..removeStatusListener(_onComplete); - return super.close(); - } -} diff --git a/lib/ui/home/bloc/search_message_cubit.dart b/lib/ui/home/bloc/search_message_cubit.dart deleted file mode 100644 index cb6a9e6a04..0000000000 --- a/lib/ui/home/bloc/search_message_cubit.dart +++ /dev/null @@ -1,191 +0,0 @@ -import 'package:bloc/bloc.dart'; -import 'package:equatable/equatable.dart'; -import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; - -import '../../../db/dao/message_dao.dart'; -import '../../../db/database.dart'; -import '../../../utils/extension/extension.dart'; -import '../../../utils/logger.dart'; -import '../../provider/slide_category_provider.dart'; - -class SearchMessageState with EquatableMixin { - const SearchMessageState(this.items, this.loading); - - final List items; - final bool loading; - - bool get initializing => items.isEmpty && loading; - - @override - List get props => [items, loading]; -} - -abstract class SearchMessageCubit extends Cubit { - SearchMessageCubit({ - required this.database, - required this.keyword, - required this.limit, - }) : super(const SearchMessageState([], false)) { - _load(); - itemPositionsListener.itemPositions.addListener(_onItemPosition); - } - - factory SearchMessageCubit.conversation({ - required Database database, - required String keyword, - required String conversationId, - required String? userId, - required List? categories, - required int limit, - }) => _ConversationSearchMessageCubit( - database: database, - keyword: keyword, - conversationId: conversationId, - userId: userId, - categories: categories, - limit: limit, - ); - - factory SearchMessageCubit.slideCategory({ - required Database database, - required String keyword, - required SlideCategoryState category, - required int limit, - }) => _SlideCategorySearchMessageCubit( - database: database, - keyword: keyword, - category: category, - limit: limit, - ); - - final ItemPositionsListener itemPositionsListener = - ItemPositionsListener.create(); - - final Database database; - - final String keyword; - - final int limit; - - var _hasMore = true; - - @override - void emit(SearchMessageState state) { - if (isClosed) { - i('search message cubit: closed, ignore'); - return; - } - super.emit(state); - } - - Future _load() async { - if (state.loading) { - w('search message cubit: loading, ignore'); - return; - } - if (!_hasMore) { - return; - } - final lastMessageId = state.items.lastOrNull?.messageId; - emit(SearchMessageState(state.items, true)); - try { - final items = await _doFuzzySearch(lastMessageId); - if (items.isEmpty) { - d('search message cubit: no more data $lastMessageId'); - _hasMore = false; - } - emit(SearchMessageState([...state.items, ...items], false)); - } catch (error, stacktrace) { - e('search message cubit: load error', error, stacktrace); - emit(SearchMessageState(state.items, false)); - _hasMore = false; - } - } - - Future> _doFuzzySearch(String? anchorMessageId); - - void _onItemPosition() { - final itemPositionValue = itemPositionsListener.itemPositions.value; - if (itemPositionValue.isEmpty) { - return; - } - final lastIndex = itemPositionValue.last.index; - if (lastIndex >= state.items.length - 4) { - _load(); - } - } - - @override - Future close() { - itemPositionsListener.itemPositions.removeListener(_onItemPosition); - return super.close(); - } -} - -class _ConversationSearchMessageCubit extends SearchMessageCubit { - _ConversationSearchMessageCubit({ - required this.conversationId, - required super.database, - required super.keyword, - required super.limit, - this.userId, - this.categories, - }); - - final String? userId; - final List? categories; - final String conversationId; - - @override - Future> _doFuzzySearch( - String? anchorMessageId, - ) { - if (keyword.isEmpty) { - return _loadUserMessages(anchorMessageId); - } - return database.fuzzySearchMessage( - query: keyword, - limit: limit, - conversationIds: [conversationId], - userId: userId, - categories: categories, - anchorMessageId: anchorMessageId, - ); - } - - Future> _loadUserMessages( - String? anchorMessageId, - ) { - if (userId == null) { - return Future.value([]); - } - return database.messageDao.messageByConversationAndUser( - userId: userId!, - limit: limit, - anchorMessageId: anchorMessageId, - conversationId: conversationId, - categories: categories, - ); - } -} - -class _SlideCategorySearchMessageCubit extends SearchMessageCubit { - _SlideCategorySearchMessageCubit({ - required this.category, - required super.database, - required super.keyword, - required super.limit, - }); - - final SlideCategoryState category; - - @override - Future> _doFuzzySearch( - String? anchorMessageId, - ) => database.fuzzySearchMessageByCategory( - keyword, - category: category, - limit: limit, - anchorMessageId: anchorMessageId, - ); -} diff --git a/lib/ui/home/bloc/subscriber_mixin.dart b/lib/ui/home/bloc/subscriber_mixin.dart deleted file mode 100644 index c013f15e27..0000000000 --- a/lib/ui/home/bloc/subscriber_mixin.dart +++ /dev/null @@ -1,18 +0,0 @@ -import 'dart:async'; - -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -mixin SubscriberMixin on StateNotifier { - List subscriptions = []; - - void addSubscription(StreamSubscription? streamSubscription) => - subscriptions.add(streamSubscription); - - @override - void dispose() { - subscriptions - ..forEach((element) => element?.cancel()) - ..clear(); - super.dispose(); - } -} diff --git a/lib/ui/home/chat/chat_bar.dart b/lib/ui/home/chat/chat_bar.dart index 6e516f8960..a119d2237b 100644 --- a/lib/ui/home/chat/chat_bar.dart +++ b/lib/ui/home/chat/chat_bar.dart @@ -5,7 +5,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../constants/resources.dart'; import '../../../db/database_event_bus.dart'; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../utils/logger.dart'; import '../../../utils/web_view/web_view_interface.dart'; import '../../../widgets/action_button.dart'; @@ -15,10 +14,12 @@ import '../../../widgets/conversation/badges_widget.dart'; import '../../../widgets/high_light_text.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../../widgets/window/move_window.dart'; -import '../../provider/abstract_responsive_navigator.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/database_provider.dart'; import '../../provider/message_selection_provider.dart'; import '../../provider/responsive_navigator_provider.dart'; +import '../../provider/ui_context_providers.dart'; +import '../providers/home_scope_providers.dart'; import 'chat_page.dart'; class ChatBar extends HookConsumerWidget { @@ -26,14 +27,13 @@ class ChatBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final actionColor = context.theme.icon; - final chatSideCubit = context.read(); - - final chatSideRouteMode = - useBlocStateConverter( - bloc: chatSideCubit, - converter: (state) => state.routeMode, - ); + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + final actionColor = theme.icon; + final chatSideCubit = ref.read(chatSideControllerProvider.notifier); + final chatSideRouteMode = ref.watch( + chatSideControllerProvider.select((state) => state.routeMode), + ); final routeMode = ref.watch(navigatorRouteModeProvider); @@ -132,8 +132,8 @@ class ChatBar extends HookConsumerWidget { MoveWindowBarrier( child: TextButton( onPressed: () => - ref.read(messageSelectionProvider).clearSelection(), - child: Text(context.l10n.cancel), + ref.read(messageSelectionProvider.notifier).clearSelection(), + child: Text(l10n.cancel), ), ) else ...[ @@ -142,12 +142,12 @@ class ChatBar extends HookConsumerWidget { name: Resources.assetsImagesIcSearchSvg, color: actionColor, onTap: () { - final cubit = context.read(); + final cubit = ref.read(chatSideControllerProvider.notifier); if (cubit.state.pages.lastOrNull?.name == - ChatSideCubit.searchMessageHistory) { + ChatSideController.searchMessageHistory) { return cubit.pop(); } - cubit.replace(ChatSideCubit.searchMessageHistory); + cubit.replace(ChatSideController.searchMessageHistory); }, ), ), @@ -183,11 +183,14 @@ class ConversationIDOrCount extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final isGroup = conversationState?.isGroup ?? false; + final database = ref.read(databaseProvider).requireValue; final countStream = useMemoized(() { if (isGroup) { - return context.database.participantDao + return database.participantDao .conversationParticipantsCount(conversationState!.conversationId) .watchSingleWithStream( eventStreams: [ @@ -200,10 +203,10 @@ class ConversationIDOrCount extends HookConsumerWidget { } return const Stream.empty(); - }, [conversationState?.conversationId, isGroup]); + }, [conversationState?.conversationId, isGroup, database]); final textStyle = TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: fontSize, height: 1, ); @@ -220,7 +223,7 @@ class ConversationIDOrCount extends HookConsumerWidget { builder: (context, snapshot) { final count = snapshot.data; return CustomSelectableText( - count != null ? context.l10n.participantsCount(count) : '', + count != null ? l10n.participantsCount(count) : '', style: textStyle, ); }, @@ -228,7 +231,7 @@ class ConversationIDOrCount extends HookConsumerWidget { } } -class ConversationName extends StatelessWidget { +class ConversationName extends ConsumerWidget { const ConversationName({ required this.conversationState, super.key, @@ -239,31 +242,34 @@ class ConversationName extends StatelessWidget { final ConversationState conversationState; @override - Widget build(BuildContext context) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: CustomSelectableArea( - child: CustomText( - conversationState.name?.overflow ?? '', - style: TextStyle( - color: context.theme.text, - fontSize: fontSize, - height: 1, - overflow: TextOverflow.ellipsis, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: CustomSelectableArea( + child: CustomText( + conversationState.name?.overflow ?? '', + style: TextStyle( + color: theme.text, + fontSize: fontSize, + height: 1, + overflow: TextOverflow.ellipsis, + ), + textAlign: TextAlign.center, + maxLines: 1, ), - textAlign: TextAlign.center, - maxLines: 1, ), ), - ), - BadgesWidget( - verified: conversationState.isVerified, - isBot: conversationState.isBot, - membership: conversationState.membership, - ), - ], - ); + BadgesWidget( + verified: conversationState.isVerified, + isBot: conversationState.isBot, + membership: conversationState.membership, + ), + ], + ); + } } class ConversationAvatar extends StatelessWidget { @@ -310,16 +316,18 @@ class _BotIcon extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); if (!conversation.isBot) { return const SizedBox(); } return ActionButton( name: Resources.assetsImagesBotSvg, - color: context.theme.icon, + color: theme.icon, onTap: () { MixinWebView.instance.openBotWebViewWindow( context, + ref.container, conversation.app!, conversationId: conversation.conversationId, ); diff --git a/lib/ui/home/chat/chat_page.dart b/lib/ui/home/chat/chat_page.dart index 8d453bd22e..8d0b35901e 100644 --- a/lib/ui/home/chat/chat_page.dart +++ b/lib/ui/home/chat/chat_page.dart @@ -1,24 +1,21 @@ +import 'dart:async'; import 'dart:io'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; -import 'package:provider/provider.dart'; import 'package:super_context_menu/super_context_menu.dart'; +import '../../../account/account_server.dart'; import '../../../account/scam_warning_key_value.dart'; import '../../../account/show_pin_message_key_value.dart'; -import '../../../bloc/simple_cubit.dart'; -import '../../../bloc/subscribe_mixin.dart'; import '../../../constants/resources.dart'; import '../../../db/database_event_bus.dart'; import '../../../db/mixin_database.dart' hide Offset; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../widgets/action_button.dart'; import '../../../widgets/actions/actions.dart'; import '../../../widgets/animated_visibility.dart'; @@ -36,12 +33,12 @@ import '../../../widgets/pin_bubble.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/window/menus.dart'; import '../../provider/abstract_responsive_navigator.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; -import '../../provider/mention_cache_provider.dart'; +import '../../provider/database_provider.dart'; import '../../provider/message_selection_provider.dart'; import '../../provider/pending_jump_message_provider.dart'; -import '../bloc/blink_cubit.dart'; -import '../bloc/message_bloc.dart'; +import '../../provider/ui_context_providers.dart'; import '../chat_slide_page/chat_info_page.dart'; import '../chat_slide_page/circle_manager_page.dart'; import '../chat_slide_page/disappear_message_page.dart'; @@ -51,17 +48,46 @@ import '../chat_slide_page/pin_messages_page.dart'; import '../chat_slide_page/search_message_page.dart'; import '../chat_slide_page/shared_apps_page.dart'; import '../chat_slide_page/shared_media_page.dart'; +import '../controllers/message_controller.dart'; import '../home.dart'; -import '../hook/pin_message.dart'; -import '../route/responsive_navigator.dart'; +import '../providers/home_scope_providers.dart'; import 'chat_bar.dart'; import 'files_preview.dart'; import 'input_container.dart'; import 'selection_bottom_bar.dart'; -class ChatSideCubit extends AbstractResponsiveNavigatorCubit { - ChatSideCubit() : super(const ResponsiveNavigatorState()); +final _showScamWarningProvider = StreamProvider.autoDispose((ref) { + final (userId, isScam) = ref.watch( + conversationProvider.select( + (value) => (value?.userId, (value?.user?.isScam ?? 0) > 0), + ), + ); + if (userId == null || !isScam) { + return Stream.value(false); + } + return ScamWarningKeyValue.instance.watch(userId); +}); + +final _unreadMentionsProvider = + StreamProvider.autoDispose>((ref) { + final conversationId = ref.watch(currentConversationIdProvider); + final database = ref.watch(databaseProvider).value; + if (conversationId == null || database == null) { + return Stream.value(const []); + } + return database.messageMentionDao + .unreadMentionMessageByConversationId(conversationId) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchUpdateMessageMention( + conversationIds: [conversationId], + ), + ], + duration: kSlowThrottleDuration, + ); + }); +class ChatSideController extends AbstractResponsiveNavigatorStateNotifier { static const infoPage = 'infoPage'; static const circles = 'circles'; static const searchMessageHistory = 'searchMessageHistory'; @@ -136,36 +162,58 @@ class ChatSideCubit extends AbstractResponsiveNavigatorCubit { void toggleInfoPage() { if (state.pages.isEmpty) { - return emit(state.copyWith(pages: [route(ChatSideCubit.infoPage, null)])); + state = state.copyWith(pages: [route(ChatSideController.infoPage, null)]); + return; } - return clear(); + clear(); } } -class SearchConversationKeywordCubit extends SimpleCubit<(String?, String)> - with SubscribeMixin { - SearchConversationKeywordCubit({required ChatSideCubit chatSideCubit}) - : super(const (null, '')) { - addSubscription( - chatSideCubit.stream - .map( - (event) => event.pages.any( - (element) => element.name == ChatSideCubit.searchMessageHistory, - ), - ) - .distinct() - .listen((event) => emit(const (null, ''))), - ); +class SearchConversationKeywordController extends Notifier<(String?, String)> { + SearchConversationKeywordController({ + ChatSideController? chatSideController, + }) : _chatSideControllerOverride = chatSideController; + + final ChatSideController? _chatSideControllerOverride; + late final StreamController<(String?, String)> _stateController = + StreamController<(String?, String)>.broadcast(); + + Stream<(String?, String)> get stream => _stateController.stream; + + @override + (String?, String) build() { + final chatSideController = + _chatSideControllerOverride ?? + ref.read(chatSideControllerProvider.notifier); + final subscription = chatSideController?.stream + .map( + (event) => event.pages.any( + (element) => + element.name == ChatSideController.searchMessageHistory, + ), + ) + .distinct() + .listen((event) => _updateState(const (null, ''))); + ref.onDispose(() async { + await subscription?.cancel(); + await _stateController.close(); + }); + return const (null, ''); } - static void updateKeyword(BuildContext context, String keyword) { - final cubit = context.read(); - cubit.emit((cubit.state.$1, keyword)); + void _updateState((String?, String) value) { + state = value; + if (!_stateController.isClosed) { + _stateController.add(value); + } } - static void updateSelectedUser(BuildContext context, String? userId) { - final cubit = context.read(); - cubit.emit((userId, cubit.state.$2)); + void setKeyword(String keyword) { + _updateState((state.$1, keyword)); + } + + void setSelectedUser(String? userId) { + _updateState((userId, state.$2)); } } @@ -201,113 +249,101 @@ class ChatPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final chatContainerPageKey = useMemoized(GlobalKey.new); + final theme = ref.watch(brightnessThemeDataProvider); final (conversationId, initialSidePage) = ref.watch( conversationProvider.select( (value) => (value?.conversationId, value?.initialSidePage), ), ); - final chatSideCubit = useBloc(ChatSideCubit.new, keys: [conversationId]); - - final searchConversationKeywordCubit = useBloc( - () => SearchConversationKeywordCubit(chatSideCubit: chatSideCubit), - keys: [conversationId], + return KeyedSubtree( + key: ValueKey(conversationId), + child: TickerMode( + enabled: ModalRoute.of(context)?.isCurrent ?? true, + child: _ChatPageScope( + conversationId: conversationId, + initialSidePage: initialSidePage, + ), + ), ); + } +} - useEffect(() { - if (initialSidePage != null) { - chatSideCubit.pushPage(initialSidePage); - } - }, [initialSidePage, chatSideCubit]); +class _ChatPageScope extends HookConsumerWidget { + const _ChatPageScope({ + required this.conversationId, + required this.initialSidePage, + }); - final navigatorState = - useBlocState( - bloc: chatSideCubit, - ); + final String? conversationId; + final String? initialSidePage; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final chatContainerPageKey = useMemoized(GlobalKey.new); + final chatSideController = ref.read(chatSideControllerProvider.notifier); + final navigatorState = ref.watch(chatSideControllerProvider); ref.listen(hasSelectedMessageProvider, (previous, hasSelectedMessage) { if (!hasSelectedMessage) return; - chatSideCubit.clear(); + chatSideController.clear(); }); + useEffect(() { + final sidePage = initialSidePage; + if (sidePage == null) return null; + WidgetsBinding.instance.addPostFrameCallback((_) { + chatSideController.pushPage(sidePage); + }); + return null; + }, [initialSidePage, chatSideController, conversationId]); + final chatContainerPage = MaterialPage( key: const ValueKey('chatContainer'), name: 'chatContainer', child: ChatContainer(key: chatContainerPageKey), ); - final windowHeight = MediaQuery.sizeOf(context).height; - - final tickerProvider = useSingleTickerProvider(); - final blinkCubit = useBloc( - () => BlinkCubit( - tickerProvider, - context.theme.accent.withValues(alpha: 0.5), - ), - ); - final pinMessageState = usePinMessageState(conversationId); - - return MultiProvider( - providers: [ - BlocProvider.value(value: blinkCubit), - BlocProvider.value(value: chatSideCubit), - BlocProvider.value(value: searchConversationKeywordCubit), - BlocProvider( - create: (context) => MessageBloc( - accountServer: context.accountServer, - database: context.database, - conversationNotifier: ref.read(conversationProvider.notifier), - mentionCache: context.providerContainer.read( - mentionCacheProvider, - ), - limit: windowHeight ~/ 20, - ), - ), - Provider.value(value: pinMessageState), - ], - child: DecoratedBox( - decoration: BoxDecoration(color: context.theme.primary), - child: LayoutBuilder( - builder: (context, boxConstraints) { - final routeMode = - boxConstraints.maxWidth < - (kResponsiveNavigationMinWidth + kChatSidePageWidth); - chatSideCubit.updateRouteMode(routeMode); - - return _ChatMenuHandler( - child: Row( - children: [ - if (!routeMode) Expanded(child: chatContainerPage.child), - if (!routeMode) - Container(width: 1, color: context.theme.divider), - FocusableActionDetector( - shortcuts: const { - SingleActivator(LogicalKeyboardKey.escape): - EscapeIntent(), - }, - actions: { - EscapeIntent: CallbackAction( - onInvoke: (intent) => chatSideCubit.pop(), - ), - }, - child: _SideRouter( - chatSideCubit: chatSideCubit, - constraints: boxConstraints, - onDidRemovePage: (page) { - chatSideCubit.onPopPage(); - }, - pages: [ - if (routeMode) chatContainerPage, - ...navigatorState.pages, - ], + return DecoratedBox( + decoration: BoxDecoration(color: theme.primary), + child: LayoutBuilder( + builder: (context, boxConstraints) { + final routeMode = + boxConstraints.maxWidth < + (kResponsiveNavigationMinWidth + kChatSidePageWidth); + chatSideController.updateRouteMode(routeMode); + + return _ChatMenuHandler( + child: Row( + children: [ + if (!routeMode) Expanded(child: chatContainerPage.child), + if (!routeMode) Container(width: 1, color: theme.divider), + FocusableActionDetector( + shortcuts: const { + SingleActivator(LogicalKeyboardKey.escape): EscapeIntent(), + }, + actions: { + EscapeIntent: CallbackAction( + onInvoke: (intent) => chatSideController.pop(), ), + }, + child: _SideRouter( + chatSideController: chatSideController, + constraints: boxConstraints, + onDidRemovePage: (page) { + chatSideController.onPopPage(); + }, + pages: [ + if (routeMode) chatContainerPage, + ...navigatorState.pages, + ], ), - ], - ), - ); - }, - ), + ), + ], + ), + ); + }, ), ); } @@ -315,13 +351,13 @@ class ChatPage extends HookConsumerWidget { class _SideRouter extends StatelessWidget { const _SideRouter({ - required this.chatSideCubit, + required this.chatSideController, required this.pages, required this.constraints, this.onDidRemovePage, }); - final ChatSideCubit chatSideCubit; + final ChatSideController chatSideController; final List> pages; @@ -331,7 +367,7 @@ class _SideRouter extends StatelessWidget { @override Widget build(BuildContext context) { - final routeMode = chatSideCubit.state.routeMode; + final routeMode = chatSideController.state.routeMode; return routeMode ? SizedBox( width: constraints.maxWidth, @@ -407,7 +443,9 @@ class ChatContainer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - BlocProvider.of(context).limit = + final theme = ref.watch(brightnessThemeDataProvider); + final brightnessValue = ref.watch(brightnessValueProvider); + ref.read(messageControllerProvider.notifier).limit = MediaQuery.sizeOf(context).height ~/ 20; final inMultiSelectMode = ref.watch(hasSelectedMessageProvider); @@ -423,7 +461,7 @@ class ChatContainer extends HookConsumerWidget { actions: { EscapeIntent: CallbackAction( onInvoke: (intent) { - ref.read(messageSelectionProvider).clearSelection(); + ref.read(messageSelectionProvider.notifier).clearSelection(); }, ), }, @@ -432,23 +470,21 @@ class ChatContainer extends HookConsumerWidget { Container( height: 64, decoration: BoxDecoration( - border: Border( - bottom: BorderSide(color: context.theme.divider), - ), + border: Border(bottom: BorderSide(color: theme.divider)), ), child: const ChatBar(), ), Expanded( child: DecoratedBox( decoration: BoxDecoration( - color: context.theme.chatBackground, + color: theme.chatBackground, image: DecorationImage( image: const ExactAssetImage( Resources.assetsImagesChatBackgroundPng, ), fit: BoxFit.cover, colorFilter: ColorFilter.mode( - context.brightnessValue == 1.0 + brightnessValue == 1.0 ? Colors.white.withValues(alpha: 0.02) : Colors.black.withValues(alpha: 0.03), BlendMode.srcIn, @@ -467,9 +503,7 @@ class ChatContainer extends HookConsumerWidget { child: DecoratedBox( decoration: BoxDecoration( border: Border( - bottom: BorderSide( - color: context.theme.divider, - ), + bottom: BorderSide(color: theme.divider), ), ), child: const Stack( @@ -524,26 +558,27 @@ class ChatContainer extends HookConsumerWidget { } } -class _NotificationListener extends StatelessWidget { +class _NotificationListener extends ConsumerWidget { const _NotificationListener({required this.child}); final Widget child; @override - Widget build(BuildContext context) => + Widget build(BuildContext context, WidgetRef ref) => NotificationListener( onNotification: (notification) { final dimension = notification.metrics.viewportDimension / 2; if (notification is ScrollUpdateNotification) { if (notification.scrollDelta == null) return false; + final notifier = ref.read(messageControllerProvider.notifier); if (notification.scrollDelta! > 0) { // down if (notification.metrics.maxScrollExtent - notification.metrics.pixels < dimension) { - BlocProvider.of(context).after(); + notifier.after(); } } else if (notification.scrollDelta! < 0) { // up @@ -551,7 +586,7 @@ class _NotificationListener extends StatelessWidget { notification.metrics.pixels) .abs() < dimension) { - BlocProvider.of(context).before(); + notifier.before(); } } } @@ -565,10 +600,18 @@ class _NotificationListener extends StatelessWidget { class _List extends HookConsumerWidget { const _List(); + static const _alignmentEpsilon = 1.0; + @override Widget build(BuildContext context, WidgetRef ref) { - final state = useBlocState( - when: (state) => state.conversationId != null, + final messageController = ref.read(messageControllerProvider.notifier); + final state = ref.watch( + messageControllerProvider.select((state) { + if (state.conversationId == null) { + return MessageState(); + } + return state; + }), ); final key = ValueKey((state.conversationId, state.refreshKey)); @@ -576,14 +619,18 @@ class _List extends HookConsumerWidget { final center = state.center; final bottom = state.bottom; - final ref = useRef>({}); + final keyRef = useRef>({}); + final bodyKeyRef = useRef>({}); final ids = state.list.map((e) => e.messageId); useMemoized(() { - ref.value.removeWhere((key, value) => !ids.contains(key)); + keyRef.value.removeWhere((key, value) => !ids.contains(key)); + bodyKeyRef.value.removeWhere((key, value) => !ids.contains(key)); ids.forEach((id) { - ref.value[id] = ref.value[id] ?? GlobalKey(debugLabel: id); + keyRef.value[id] = keyRef.value[id] ?? GlobalKey(debugLabel: id); + bodyKeyRef.value[id] = + bodyKeyRef.value[id] ?? GlobalKey(debugLabel: 'body_$id'); }); }, [ids]); @@ -592,9 +639,47 @@ class _List extends HookConsumerWidget { () => GlobalKey(debugLabel: 'chat list bottom'), ); - final scrollController = BlocProvider.of( - context, - ).scrollController; + final scrollController = messageController.scrollController; + final centerItemKey = center == null + ? null + : keyRef.value[center.messageId] as GlobalKey?; + final centerBodyKey = center == null + ? null + : bodyKeyRef.value[center.messageId] as GlobalKey?; + + Future adjustCenterBodyAlignment() async { + if (centerItemKey == null || centerBodyKey == null) return; + if (!scrollController.hasClients) return; + + final itemContext = centerItemKey.currentContext; + final bodyContext = centerBodyKey.currentContext; + if (itemContext == null || bodyContext == null) return; + + final itemRender = itemContext.findRenderObject() as RenderBox?; + final bodyRender = bodyContext.findRenderObject() as RenderBox?; + if (itemRender == null || bodyRender == null) return; + + final delta = + bodyRender.localToGlobal(Offset.zero).dy - + itemRender.localToGlobal(Offset.zero).dy; + if (delta.abs() <= _alignmentEpsilon) return; + + final position = scrollController.position; + final target = (position.pixels + delta).clamp( + position.minScrollExtent, + position.maxScrollExtent, + ); + if ((target - position.pixels).abs() <= _alignmentEpsilon) return; + scrollController.jumpTo(target); + } + + useEffect(() { + if (center == null) return null; + WidgetsBinding.instance.addPostFrameCallback((_) { + unawaited(adjustCenterBodyAlignment()); + }); + return null; + }, [state.refreshKey, center?.messageId]); return MessageDayTimeViewportWidget.chatPage( key: key, @@ -602,9 +687,7 @@ class _List extends HookConsumerWidget { center: center, topKey: topKey, scrollController: scrollController, - centerKey: center == null - ? null - : ref.value[center.messageId] as GlobalKey?, + centerKey: centerItemKey, child: ClampingCustomScrollView( key: key, center: key, @@ -621,7 +704,8 @@ class _List extends HookConsumerWidget { final actualIndex = top.length - index - 1; final messageItem = top[actualIndex]; return MessageItemWidget( - key: ref.value[messageItem.messageId], + key: keyRef.value[messageItem.messageId], + bodyKey: bodyKeyRef.value[messageItem.messageId], prev: top.getOrNull(actualIndex - 1), message: messageItem, next: @@ -638,7 +722,8 @@ class _List extends HookConsumerWidget { builder: (context) { if (center == null) return const SizedBox(); return MessageItemWidget( - key: ref.value[center.messageId], + key: keyRef.value[center.messageId], + bodyKey: bodyKeyRef.value[center.messageId], prev: top.lastOrNull, message: center, next: bottom.firstOrNull, @@ -655,7 +740,8 @@ class _List extends HookConsumerWidget { ) { final messageItem = bottom[index]; return MessageItemWidget( - key: ref.value[messageItem.messageId], + key: keyRef.value[messageItem.messageId], + bodyKey: bodyKeyRef.value[messageItem.messageId], prev: bottom.getOrNull(index - 1) ?? center ?? top.lastOrNull, message: messageItem, next: bottom.getOrNull(index + 1), @@ -675,11 +761,11 @@ class _JumpCurrentButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final messageBloc = context.read(); + final messageController = ref.read(messageControllerProvider.notifier); final conversationId = ref.watch(currentConversationIdProvider); - - final state = useBlocState(); - final scrollController = useListenable(messageBloc.scrollController); + final state = ref.watch(messageControllerProvider); + final scrollController = messageController.scrollController; + useListenable(scrollController); final listPositionIsLatest = useState(false); @@ -704,9 +790,10 @@ class _JumpCurrentButton extends HookConsumerWidget { final pendingJumpMessageController = ref.read( pendingJumpMessageProvider.notifier, ); + final theme = ref.watch(brightnessThemeDataProvider); if (!enable) { - Future(() => pendingJumpMessageController.state = null); + Future(pendingJumpMessageController.clear); return const SizedBox(); } @@ -716,12 +803,16 @@ class _JumpCurrentButton extends HookConsumerWidget { onTap: () { final messageId = pendingJumpMessageController.state; if (messageId != null) { - messageBloc.scrollTo(messageId); - context.read().blinkByMessageId(messageId); - pendingJumpMessageController.state = null; + messageController.scrollTo(messageId); + ref + .read(blinkControllerProvider.notifier) + .blinkByMessageId( + messageId, + ); + pendingJumpMessageController.clear(); return; } - messageBloc.jumpToCurrent(); + messageController.jumpToCurrent(); }, child: Container( height: 40, @@ -740,7 +831,10 @@ class _JumpCurrentButton extends HookConsumerWidget { alignment: Alignment.center, child: SvgPicture.asset( Resources.assetsImagesJumpCurrentArrowSvg, - colorFilter: ColorFilter.mode(context.theme.text, BlendMode.srcIn), + colorFilter: ColorFilter.mode( + theme.text, + BlendMode.srcIn, + ), ), ), ), @@ -753,22 +847,12 @@ class _BottomBanner extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final (userId, isScam) = ref.watch( - conversationProvider.select( - (value) => (value?.userId, (value?.user?.isScam ?? 0) > 0), - ), + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + final userId = ref.watch( + conversationProvider.select((value) => value?.userId), ); - - final showScamWarning = - useMemoizedStream( - () { - if (userId == null || !isScam) return Stream.value(false); - return ScamWarningKeyValue.instance.watch(userId); - }, - initialData: false, - keys: [userId], - ).data ?? - false; + final showScamWarning = ref.watch(_showScamWarningProvider).value ?? false; return AnimatedVisibility( visible: showScamWarning, @@ -797,7 +881,7 @@ class _BottomBanner extends HookConsumerWidget { child: SvgPicture.asset( Resources.assetsImagesTriangleWarningSvg, colorFilter: ColorFilter.mode( - context.theme.red, + theme.red, BlendMode.srcIn, ), width: 26, @@ -806,13 +890,13 @@ class _BottomBanner extends HookConsumerWidget { ), Expanded( child: Text( - context.l10n.scamWarning, - style: TextStyle(color: context.theme.text, fontSize: 14), + l10n.scamWarning, + style: TextStyle(color: theme.text, fontSize: 14), ), ), ActionButton( name: Resources.assetsImagesIcCloseSvg, - color: context.theme.icon, + color: theme.icon, size: 20, onTap: () { if (userId == null) return; @@ -831,10 +915,13 @@ class _PinMessagesBanner extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentPinMessageIds = context.watchCurrentPinMessageIds; - final lastMessage = context.lastMessage; + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + final pinMessageState = ref.watch(pinMessageStateProvider); + final currentPinMessageIds = pinMessageState.messageIds; + final lastMessage = pinMessageState.lastMessage; - final showLastPinMessage = lastMessage?.isNotEmpty ?? false; + final showLastPinMessage = lastMessage != null; return Positioned( top: 12, @@ -853,7 +940,7 @@ class _PinMessagesBanner extends HookConsumerWidget { children: [ ActionButton( name: Resources.assetsImagesIcCloseSvg, - color: context.theme.icon, + color: theme.icon, size: 20, onTap: () { final conversationId = ref.read( @@ -868,7 +955,12 @@ class _PinMessagesBanner extends HookConsumerWidget { const SizedBox(width: 4), Expanded( child: CustomText( - (lastMessage ?? '').overflow, + l10n + .chatPinMessage( + lastMessage?.senderFullName ?? '', + lastMessage?.preview ?? '', + ) + .overflow, maxLines: 2, overflow: TextOverflow.ellipsis, ), @@ -883,13 +975,15 @@ class _PinMessagesBanner extends HookConsumerWidget { visible: currentPinMessageIds.isNotEmpty, child: InteractiveDecoratedBox( onTap: () { - final cubit = context.read(); - if (cubit.state.pages.lastOrNull?.name == - ChatSideCubit.pinMessages) { - return cubit.pop(); + final controller = ref.read( + chatSideControllerProvider.notifier, + ); + if (controller.state.pages.lastOrNull?.name == + ChatSideController.pinMessages) { + return controller.pop(); } - cubit.replace(ChatSideCubit.pinMessages); + controller.replace(ChatSideController.pinMessages); }, child: Container( height: 34, @@ -911,7 +1005,7 @@ class _PinMessagesBanner extends HookConsumerWidget { width: 34, height: 34, colorFilter: ColorFilter.mode( - context.theme.text, + theme.text, BlendMode.srcIn, ), ), @@ -930,22 +1024,10 @@ class _JumpMentionButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final conversationId = ref.watch(currentConversationIdProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final messageMentions = - useMemoizedStream(() { - if (conversationId == null) return Stream.value([]); - return context.database.messageMentionDao - .unreadMentionMessageByConversationId(conversationId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchUpdateMessageMention( - conversationIds: [conversationId], - ), - ], - duration: kSlowThrottleDuration, - ); - }, keys: [conversationId]).data ?? - []; + ref.watch(_unreadMentionsProvider).value ?? const []; if (messageMentions.isEmpty) return const SizedBox(); @@ -955,10 +1037,13 @@ class _JumpMentionButton extends HookConsumerWidget { menuProvider: (request) => Menu( children: [ MenuAction( - title: context.l10n.clear, + title: l10n.clear, callback: () { + final accountServer = ref + .read(accountServerProvider) + .requireValue; for (final mention in messageMentions) { - context.accountServer.markMentionRead( + accountServer.markMentionRead( mention.messageId, mention.conversationId, ); @@ -972,8 +1057,11 @@ class _JumpMentionButton extends HookConsumerWidget { if (messageMentions.isEmpty) return; final mention = messageMentions.first; - context.read().scrollTo(mention.messageId); - context.accountServer.markMentionRead( + final accountServer = ref.read(accountServerProvider).requireValue; + ref + .read(messageControllerProvider.notifier) + .scrollTo(mention.messageId); + accountServer.markMentionRead( mention.messageId, mention.conversationId, ); @@ -1005,7 +1093,7 @@ class _JumpMentionButton extends HookConsumerWidget { style: TextStyle( fontSize: 17, height: 1, - color: context.theme.text, + color: theme.text, ), ), ), @@ -1021,7 +1109,7 @@ class _JumpMentionButton extends HookConsumerWidget { minHeight: 20, ), decoration: BoxDecoration( - color: context.theme.accent, + color: theme.accent, borderRadius: const BorderRadius.all(Radius.circular(10)), ), padding: const EdgeInsets.symmetric(horizontal: 4), @@ -1092,30 +1180,39 @@ class _ChatDropOverlay extends HookConsumerWidget { } } -class _ChatDragIndicator extends StatelessWidget { +class _ChatDragIndicator extends ConsumerWidget { const _ChatDragIndicator(); @override - Widget build(BuildContext context) => DecoratedBox( - decoration: BoxDecoration(color: context.theme.popUp), - child: Container( - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: context.theme.listSelected, - borderRadius: const BorderRadius.all(Radius.circular(8)), - border: DashPathBorder.all( - borderSide: BorderSide(color: context.theme.divider), - dashArray: CircularIntervalList([4, 4]), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + return DecoratedBox( + decoration: BoxDecoration(color: theme.popUp), + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.listSelected, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: DashPathBorder.all( + borderSide: BorderSide( + color: theme.divider, + ), + dashArray: CircularIntervalList([4, 4]), + ), ), - ), - child: Center( - child: Text( - context.l10n.dragAndDropFileHere, - style: TextStyle(fontSize: 14, color: context.theme.text), + child: Center( + child: Text( + l10n.dragAndDropFileHere, + style: TextStyle( + fontSize: 14, + color: theme.text, + ), + ), ), ), - ), - ); + ); + } } class _ChatMenuHandler extends HookConsumerWidget { @@ -1131,7 +1228,12 @@ class _ChatMenuHandler extends HookConsumerWidget { final cubit = ref.read(macMenuBarProvider.notifier); if (conversationId == null) return null; - final handle = _ConversationHandle(context, conversationId); + final handle = _ConversationHandle( + context: context, + conversationId: conversationId, + container: ref.container, + accountServer: ref.read(accountServerProvider).requireValue, + ); Future(() => cubit.attach(handle)); return () => Future(() => cubit.unAttach(handle)); }, [conversationId]); @@ -1141,40 +1243,44 @@ class _ChatMenuHandler extends HookConsumerWidget { } class _ConversationHandle extends ConversationMenuHandle { - _ConversationHandle(this.context, this.conversationId); + _ConversationHandle({ + required this.context, + required this.conversationId, + required this.container, + required this.accountServer, + }); final BuildContext context; final String conversationId; + final ProviderContainer container; + final AccountServer accountServer; @override Future delete() async { - final name = - context.providerContainer.read(currentConversationNameProvider) ?? ''; + final name = container.read(currentConversationNameProvider) ?? ''; + final l10n = container.read(localizationProvider); assert(name.isNotEmpty, 'name is empty'); final ret = await showConfirmMixinDialog( context, - context.l10n.conversationDeleteTitle(name), - description: context.l10n.deleteChatDescription, + l10n.conversationDeleteTitle(name), + description: l10n.deleteChatDescription, ); if (ret == null) return; - await context.accountServer.deleteMessagesByConversationId(conversationId); - await context.database.conversationDao.deleteConversation(conversationId); - if (context.providerContainer.read(currentConversationIdProvider) == - conversationId) { - context.providerContainer - .read(conversationProvider.notifier) - .unselected(); + await accountServer.deleteMessagesByConversationId(conversationId); + await accountServer.deleteConversation(conversationId); + if (container.read(currentConversationIdProvider) == conversationId) { + container.read(conversationProvider.notifier).unselected(); } } @override - Stream get isMuted => context.providerContainer + Stream get isMuted => container .read(conversationProvider.notifier) .stream .map((event) => event?.conversation?.isMute == true); @override - Stream get isPinned => context.providerContainer + Stream get isPinned => container .read(conversationProvider.notifier) .stream .map((event) => event?.conversation?.pinTime != null); @@ -1186,14 +1292,12 @@ class _ConversationHandle extends ConversationMenuHandle { child: const MuteDialog(), ); if (result == null) return; - final conversationState = context.providerContainer.read( - conversationProvider, - ); + final conversationState = container.read(conversationProvider); if (conversationState == null) return; final isGroupConversation = conversationState.isGroup == true; await runFutureWithToast( - context.accountServer.muteConversation( + accountServer.muteConversation( result, conversationId: isGroupConversation ? conversationId : null, userId: isGroupConversation @@ -1205,40 +1309,39 @@ class _ConversationHandle extends ConversationMenuHandle { @override void pin() { - runFutureWithToast(context.accountServer.pin(conversationId)); + runFutureWithToast(accountServer.pin(conversationId)); } @override void showSearch() { - final cubit = context.read(); - if (cubit.state.pages.lastOrNull?.name == - ChatSideCubit.searchMessageHistory) { - return cubit.pop(); + final controller = container.read(chatSideControllerProvider.notifier); + final state = container.read(chatSideControllerProvider); + if (state.pages.lastOrNull?.name == + ChatSideController.searchMessageHistory) { + return controller.pop(); } - cubit.replace(ChatSideCubit.searchMessageHistory); + controller.replace(ChatSideController.searchMessageHistory); } @override void toggleSideBar() { - context.read().toggleInfoPage(); + container.read(chatSideControllerProvider.notifier).toggleInfoPage(); } @override void unPin() { - runFutureWithToast(context.accountServer.unpin(conversationId)); + runFutureWithToast(accountServer.unpin(conversationId)); } @override void unmute() { - final conversationState = context.providerContainer.read( - conversationProvider, - ); + final conversationState = container.read(conversationProvider); if (conversationState == null) return; final isGroup = conversationState.isGroup == true; runFutureWithToast( - context.accountServer.unMuteConversation( + accountServer.unMuteConversation( conversationId: isGroup ? conversationId : null, userId: isGroup ? null : conversationState.conversation?.ownerId, ), diff --git a/lib/ui/home/chat/files_preview.dart b/lib/ui/home/chat/files_preview.dart index 09073080af..36bca9cefa 100644 --- a/lib/ui/home/chat/files_preview.dart +++ b/lib/ui/home/chat/files_preview.dart @@ -44,8 +44,10 @@ import '../../../widgets/image.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../../widgets/menu.dart'; import '../../../widgets/mixin_image.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/quote_message_provider.dart'; +import '../../provider/ui_context_providers.dart'; import 'image_caption_input.dart'; import 'image_editor.dart'; @@ -189,6 +191,7 @@ class _FilesPreviewDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); final files = useState(initialFiles); final quoteMessageCubit = ref.watch(quoteMessageProvider.notifier); @@ -243,13 +246,14 @@ class _FilesPreviewDialog extends HookConsumerWidget { }, [isOneImage]); Future send(bool silent) async { + final quoteMessageId = ref.read(quoteMessageIdProvider); if (currentTab.value != _TabType.zip) { for (final file in files.value) { unawaited( _sendFile( - context, + ref, file, - quoteMessageCubit.state?.messageId, + quoteMessageId, silent: silent, compress: currentTab.value == _TabType.image, imageCaption: isOneImage @@ -258,7 +262,7 @@ class _FilesPreviewDialog extends HookConsumerWidget { ), ); } - quoteMessageCubit.state = null; + quoteMessageCubit.clear(); Navigator.pop(context); } else { final zipFilePath = await runLoadBalancer(_archiveFiles, ( @@ -268,14 +272,14 @@ class _FilesPreviewDialog extends HookConsumerWidget { )); unawaited( _sendFile( - context, + ref, _File.normal(zipFilePath), - quoteMessageCubit.state?.messageId, + quoteMessageId, silent: silent, compress: false, ), ); - quoteMessageCubit.state = null; + quoteMessageCubit.clear(); Navigator.pop(context); } } @@ -317,20 +321,20 @@ class _FilesPreviewDialog extends HookConsumerWidget { children: [ _Tab( assetName: Resources.assetsImagesFilePreviewImagesSvg, - tooltip: context.l10n.sendQuickly, + tooltip: l10n.sendQuickly, onTap: () => currentTab.value = _TabType.image, selected: currentTab.value == _TabType.image, show: hasMedia, ), _Tab( assetName: Resources.assetsImagesFilePreviewFilesSvg, - tooltip: context.l10n.sendWithoutCompression, + tooltip: l10n.sendWithoutCompression, onTap: () => currentTab.value = _TabType.files, selected: currentTab.value == _TabType.files, ), _Tab( assetName: Resources.assetsImagesFilePreviewZipSvg, - tooltip: context.l10n.sendArchived, + tooltip: l10n.sendArchived, onTap: () => currentTab.value = _TabType.zip, selected: currentTab.value == _TabType.zip, show: showZipTab, @@ -398,7 +402,7 @@ class _FilesPreviewDialog extends HookConsumerWidget { } } -class _BottomActionWidget extends StatelessWidget { +class _BottomActionWidget extends ConsumerWidget { const _BottomActionWidget({ required this.send, required this.imageCaptionController, @@ -410,56 +414,60 @@ class _BottomActionWidget extends StatelessWidget { final bool showImageCaption; @override - Widget build(BuildContext context) => ConstrainedBox( - constraints: const BoxConstraints(minWidth: double.infinity), - child: Column( - children: [ - const SizedBox(height: 16), - if (showImageCaption) - Padding( - padding: const EdgeInsets.symmetric(horizontal: 30), - child: ImageCaptionInputWidget( - textEditingController: imageCaptionController, + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return ConstrainedBox( + constraints: const BoxConstraints(minWidth: double.infinity), + child: Column( + children: [ + const SizedBox(height: 16), + if (showImageCaption) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: ImageCaptionInputWidget( + textEditingController: imageCaptionController, + ), ), - ), - const SizedBox(height: 16), - CustomContextMenuWidget( - desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), - menuProvider: (_) => Menu( - children: [ - MenuAction( - image: MenuImage.icon(IconFonts.mute), - title: context.l10n.sendWithoutSound, - callback: () => send(true), + const SizedBox(height: 16), + CustomContextMenuWidget( + desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), + menuProvider: (_) => Menu( + children: [ + MenuAction( + image: MenuImage.icon(IconFonts.mute), + title: l10n.sendWithoutSound, + callback: () => send(true), + ), + ], + ), + child: ElevatedButton( + onPressed: () => send(false), + style: ElevatedButton.styleFrom( + padding: const EdgeInsets.only( + left: 32, + top: 18, + bottom: 18, + right: 32, + ), + backgroundColor: theme.accent, ), - ], - ), - child: ElevatedButton( - onPressed: () => send(false), - style: ElevatedButton.styleFrom( - padding: const EdgeInsets.only( - left: 32, - top: 18, - bottom: 18, - right: 32, + child: Text( + l10n.send.toUpperCase(), + style: const TextStyle(color: Colors.white), ), - backgroundColor: context.theme.accent, - ), - child: Text( - context.l10n.send.toUpperCase(), - style: const TextStyle(color: Colors.white), ), ), - ), - const SizedBox(height: 24), - Text( - context.l10n.enterToSend, - style: const TextStyle(fontSize: 12, color: Colors.grey), - ), - const SizedBox(height: 24), - ], - ), - ); + const SizedBox(height: 24), + Text( + l10n.enterToSend, + style: const TextStyle(fontSize: 12, color: Colors.grey), + ), + const SizedBox(height: 24), + ], + ), + ); + } } class _FileListViewportProvider extends StatelessWidget { @@ -503,16 +511,16 @@ Future _archiveFiles( } Future _sendFile( - BuildContext context, + WidgetRef ref, _File file, String? quoteMessageId, { required bool silent, required bool compress, String? imageCaption, }) async { - final conversationItem = context.providerContainer.read(conversationProvider); + final conversationItem = ref.read(conversationProvider); if (conversationItem == null) return; - final accountServer = context.accountServer; + final accountServer = ref.read(accountServerProvider).requireValue; final xFile = file.file; switch (file) { case _ImageFile(): @@ -638,7 +646,7 @@ class _AnimatedFileTile extends HookConsumerWidget { } } -class _Tab extends StatelessWidget { +class _Tab extends ConsumerWidget { const _Tab({ required this.assetName, required this.tooltip, @@ -658,94 +666,103 @@ class _Tab extends StatelessWidget { final bool show; @override - Widget build(BuildContext context) => AnimatedSize( - duration: const Duration(milliseconds: 300), - curve: Curves.easeInOut, - child: show - ? GestureDetector( - onTap: onTap, - child: Padding( - padding: const EdgeInsets.all(15), - child: Tooltip( - message: tooltip, - textStyle: const TextStyle(color: Colors.white), - child: SvgPicture.asset( - assetName, - colorFilter: ColorFilter.mode( - selected ? context.theme.accent : context.theme.icon, - BlendMode.srcIn, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return AnimatedSize( + duration: const Duration(milliseconds: 300), + curve: Curves.easeInOut, + child: show + ? GestureDetector( + onTap: onTap, + child: Padding( + padding: const EdgeInsets.all(15), + child: Tooltip( + message: tooltip, + textStyle: const TextStyle(color: Colors.white), + child: SvgPicture.asset( + assetName, + colorFilter: ColorFilter.mode( + selected ? theme.accent : theme.icon, + BlendMode.srcIn, + ), + width: 24, + height: 24, ), - width: 24, - height: 24, ), ), - ), - ) - : const SizedBox(), - ); + ) + : const SizedBox(), + ); + } } -class _PageZip extends StatelessWidget { +class _PageZip extends ConsumerWidget { const _PageZip(this.zipPasswordController); final TextEditingController zipPasswordController; @override - Widget build(BuildContext context) => Column( - children: [ - Row( - children: [ - const SizedBox(width: 30), - const _FileIcon(extension: 'ZIP'), - const SizedBox(width: 16), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - _kDefaultArchiveName, - style: TextStyle( - color: context.theme.text, - fontSize: 16, - height: 1.5, + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return Column( + children: [ + Row( + children: [ + const SizedBox(width: 30), + const _FileIcon(extension: 'ZIP'), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + _kDefaultArchiveName, + style: TextStyle( + color: theme.text, + fontSize: 16, + height: 1.5, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - context.l10n.archivedFolder, - style: TextStyle( - color: context.theme.secondaryText, - fontSize: 14, + const SizedBox(height: 4), + Text( + l10n.archivedFolder, + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), ), - ), - ], + ], + ), ), + const SizedBox(width: 30), + ], + ), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: SizedBox( + width: 300, + child: _ZipPasswordInputEditText(controller: zipPasswordController), ), - const SizedBox(width: 30), - ], - ), - const Spacer(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 30), - child: SizedBox( - width: 300, - child: _ZipPasswordInputEditText(controller: zipPasswordController), ), - ), - ], - ); + ], + ); + } } -class _ZipPasswordInputEditText extends HookWidget { +class _ZipPasswordInputEditText extends HookConsumerWidget { const _ZipPasswordInputEditText({required this.controller}); final TextEditingController controller; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final focusNode = useFocusNode(); final hasText = useListenable(controller).text.isNotEmpty; @@ -754,9 +771,11 @@ class _ZipPasswordInputEditText extends HookWidget { return InteractiveDecoratedBox( decoration: ShapeDecoration( - color: context.dynamicColor( - const Color.fromRGBO(245, 247, 250, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + color: ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(245, 247, 250, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + )), ), shape: const StadiumBorder(), ), @@ -773,7 +792,7 @@ class _ZipPasswordInputEditText extends HookWidget { width: 18, height: 18, colorFilter: ColorFilter.mode( - hasText ? context.theme.text : context.theme.secondaryText, + hasText ? theme.text : theme.secondaryText, BlendMode.srcIn, ), ), @@ -783,7 +802,10 @@ class _ZipPasswordInputEditText extends HookWidget { focusNode: focusNode, autofocus: true, controller: controller, - style: TextStyle(color: context.theme.text, fontSize: 14), + style: TextStyle( + color: theme.text, + fontSize: 14, + ), obscureText: obscureText.value, scrollPadding: EdgeInsets.zero, decoration: InputDecoration( @@ -794,9 +816,9 @@ class _ZipPasswordInputEditText extends HookWidget { hoverColor: Colors.transparent, focusColor: Colors.transparent, contentPadding: EdgeInsets.zero, - hintText: context.l10n.encryptZipFileWithPassword, + hintText: l10n.encryptZipFileWithPassword, hintStyle: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), @@ -820,9 +842,7 @@ class _ZipPasswordInputEditText extends HookWidget { ? Icons.visibility_off_outlined : Icons.visibility_outlined, size: 20, - color: hasText - ? context.theme.text - : context.theme.secondaryText, + color: hasText ? theme.text : theme.secondaryText, ), ), ), @@ -888,13 +908,37 @@ class _AnimatedListBuilder extends HookConsumerWidget { } } -final _videoControllerProvider = ChangeNotifierProvider.autoDispose - .family( - (ref, file) => VideoPlayerController.file(File(file.path)) - ..initialize() - ..setVolume(0) - ..setLooping(true), - ); +final _videoControllerProvider = Provider.autoDispose + .family((ref, file) { + final controller = VideoPlayerController.file(File(file.path)); + unawaited(controller.initialize()); + unawaited(controller.setVolume(0)); + unawaited(controller.setLooping(true)); + ref.onDispose(controller.dispose); + return controller; + }); + +final _videoValueProvider = StreamProvider.autoDispose + .family((ref, file) { + final controller = ref.watch(_videoControllerProvider(file)); + final streamController = StreamController(); + + void listener() { + if (!streamController.isClosed) { + streamController.add(controller.value); + } + } + + controller.addListener(listener); + streamController.add(controller.value); + + ref.onDispose(() { + controller.removeListener(listener); + unawaited(streamController.close()); + }); + + return streamController.stream; + }); final _videoBlurHashProvider = FutureProvider.autoDispose .family((ref, file) => file._blurHashCompleter.future); @@ -907,10 +951,10 @@ class _TileBigVideo extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final controller = ref.watch( - _videoControllerProvider(file).select((value) => value), - ); - final blurHash = ref.watch(_videoBlurHashProvider(file)).valueOrNull; + final controller = ref.watch(_videoControllerProvider(file)); + final blurHash = ref.watch(_videoBlurHashProvider(file)).value; + final videoValue = + ref.watch(_videoValueProvider(file)).value ?? controller.value; useEffect(() { Future(controller.play); return () => Future(controller.pause); @@ -924,9 +968,9 @@ class _TileBigVideo extends HookConsumerWidget { } }); - final aspectRatio = ref.watch( - _videoControllerProvider(file).select((value) => value.value.aspectRatio), - ); + final aspectRatio = videoValue.aspectRatio == 0 + ? 1.0 + : videoValue.aspectRatio; return SizedBox( height: 200, child: Padding( @@ -992,12 +1036,11 @@ class _VideoPositionText extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final duration = ref.watch( - _videoControllerProvider(file).select((value) => value.value.duration), - ); - final position = ref.watch( - _videoControllerProvider(file).select((value) => value.value.position), - ); + final controller = ref.watch(_videoControllerProvider(file)); + final value = + ref.watch(_videoValueProvider(file)).value ?? controller.value; + final duration = value.duration; + final position = value.position; final left = duration - position; return DecoratedBox( decoration: const BoxDecoration( @@ -1100,30 +1143,33 @@ class _TileBigImage extends HookConsumerWidget { } } -class _FileIcon extends StatelessWidget { +class _FileIcon extends ConsumerWidget { const _FileIcon({required this.extension}); final String extension; @override - Widget build(BuildContext context) => Container( - height: 50, - width: 50, - decoration: BoxDecoration( - color: context.theme.statusBackground, - shape: BoxShape.circle, - ), - child: Center( - child: Text( - extension, - style: TextStyle( - fontSize: 16, - // force light style - color: lightBrightnessThemeData.secondaryText, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Container( + height: 50, + width: 50, + decoration: BoxDecoration( + color: theme.statusBackground, + shape: BoxShape.circle, + ), + child: Center( + child: Text( + extension, + style: TextStyle( + fontSize: 16, + // force light style + color: lightBrightnessThemeData.secondaryText, + ), ), ), - ), - ); + ); + } } final _fileSizeProvider = FutureProvider.autoDispose.family( @@ -1148,49 +1194,52 @@ class _TileNormalFile extends HookConsumerWidget { } @override - Widget build(BuildContext context, WidgetRef ref) => Row( - children: [ - const SizedBox(width: 30), - _FileIcon(extension: _getFileExtension(file)), - const SizedBox(width: 16), - Expanded( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - path.basename(file.path).overflow, - style: TextStyle( - color: context.theme.text, - fontSize: 16, - height: 1.5, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - const SizedBox(height: 4), - Text( - filesize( - ref.watch(_fileSizeProvider(file.file)).valueOrNull ?? 0, - 0, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Row( + children: [ + const SizedBox(width: 30), + _FileIcon(extension: _getFileExtension(file)), + const SizedBox(width: 16), + Expanded( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + path.basename(file.path).overflow, + style: TextStyle( + color: theme.text, + fontSize: 16, + height: 1.5, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - style: TextStyle( - color: context.theme.secondaryText, - fontSize: 14, + const SizedBox(height: 4), + Text( + filesize( + ref.watch(_fileSizeProvider(file.file)).value ?? 0, + 0, + ), + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), ), - ), - ], + ], + ), ), - ), - ActionButton( - color: context.theme.secondaryText, - name: Resources.assetsImagesDeleteSvg, - padding: const EdgeInsets.all(10), - onTap: onDelete, - ), - const SizedBox(width: 10), - ], - ); + ActionButton( + color: theme.secondaryText, + name: Resources.assetsImagesDeleteSvg, + padding: const EdgeInsets.all(10), + onTap: onDelete, + ), + const SizedBox(width: 10), + ], + ); + } } class _Actions extends HookWidget { @@ -1276,30 +1325,37 @@ class _SendFilesIntent extends Intent { const _SendFilesIntent(); } -class _ChatDragIndicator extends StatelessWidget { +class _ChatDragIndicator extends ConsumerWidget { const _ChatDragIndicator(); @override - Widget build(BuildContext context) => DecoratedBox( - decoration: BoxDecoration(color: context.theme.popUp), - child: Container( - margin: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: context.theme.listSelected, - borderRadius: const BorderRadius.all(Radius.circular(8)), - border: DashPathBorder.all( - borderSide: BorderSide(color: context.theme.accent), - dashArray: CircularIntervalList([4, 4]), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + return DecoratedBox( + decoration: BoxDecoration(color: theme.popUp), + child: Container( + margin: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: theme.listSelected, + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: DashPathBorder.all( + borderSide: BorderSide(color: theme.accent), + dashArray: CircularIntervalList([4, 4]), + ), ), - ), - child: Center( - child: Text( - context.l10n.addFile, - style: TextStyle(fontSize: 14, color: context.theme.text), + child: Center( + child: Text( + l10n.addFile, + style: TextStyle( + fontSize: 14, + color: theme.text, + ), + ), ), ), - ), - ); + ); + } } class _PasteContextAction extends Action { diff --git a/lib/ui/home/chat/image_caption_input.dart b/lib/ui/home/chat/image_caption_input.dart index 6104736060..f24aef0d93 100644 --- a/lib/ui/home/chat/image_caption_input.dart +++ b/lib/ui/home/chat/image_caption_input.dart @@ -2,13 +2,13 @@ import 'dart:ui' as ui; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../constants/constants.dart'; -import '../../../utils/extension/extension.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../widgets/high_light_text.dart'; -class ImageCaptionInputWidget extends HookWidget { +class ImageCaptionInputWidget extends HookConsumerWidget { const ImageCaptionInputWidget({ required this.textEditingController, super.key, @@ -17,34 +17,47 @@ class ImageCaptionInputWidget extends HookWidget { final TextEditingController textEditingController; @override - Widget build(BuildContext context) => Container( - constraints: const BoxConstraints(minHeight: 40), - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(4)), - color: context.dynamicColor( - const Color.fromRGBO(245, 247, 250, 1), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final backgroundColor = ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(245, 247, 250, 1), darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + )), + ); + return Container( + constraints: const BoxConstraints(minHeight: 40), + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(4)), + color: backgroundColor, ), - ), - alignment: Alignment.center, - child: TextField( - maxLines: 3, - minLines: 1, - controller: textEditingController, - style: TextStyle(color: context.theme.text, fontSize: 14), - inputFormatters: [LengthLimitingTextInputFormatter(kMaxTextLength)], - textAlignVertical: TextAlignVertical.center, - decoration: InputDecoration( - isDense: true, - hintText: context.l10n.addACaption, - enabledBorder: InputBorder.none, - focusedBorder: InputBorder.none, - hintStyle: TextStyle(color: context.theme.secondaryText, fontSize: 14), - contentPadding: const EdgeInsets.only(left: 8, top: 8, bottom: 8), + alignment: Alignment.center, + child: TextField( + maxLines: 3, + minLines: 1, + controller: textEditingController, + style: TextStyle( + color: theme.text, + fontSize: 14, + ), + inputFormatters: [LengthLimitingTextInputFormatter(kMaxTextLength)], + textAlignVertical: TextAlignVertical.center, + decoration: InputDecoration( + isDense: true, + hintText: l10n.addACaption, + enabledBorder: InputBorder.none, + focusedBorder: InputBorder.none, + hintStyle: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), + contentPadding: const EdgeInsets.only(left: 8, top: 8, bottom: 8), + ), + selectionHeightStyle: ui.BoxHeightStyle.includeLineSpacingMiddle, + contextMenuBuilder: (context, state) => + MixinAdaptiveSelectionToolbar(editableTextState: state), ), - selectionHeightStyle: ui.BoxHeightStyle.includeLineSpacingMiddle, - contextMenuBuilder: (context, state) => - MixinAdaptiveSelectionToolbar(editableTextState: state), - ), - ); + ); + } } diff --git a/lib/ui/home/chat/image_editor.dart b/lib/ui/home/chat/image_editor.dart index 2cd63da545..7020676eeb 100644 --- a/lib/ui/home/chat/image_editor.dart +++ b/lib/ui/home/chat/image_editor.dart @@ -5,13 +5,12 @@ import 'dart:ui' as ui; import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:image/image.dart' as img; -import '../../../bloc/subscribe_mixin.dart'; import '../../../constants/resources.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/file.dart'; import '../../../utils/hook.dart'; @@ -39,6 +38,8 @@ class _ImageEditorDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final boundaryKey = useMemoized(GlobalKey.new); final image = useMemoizedFuture( () async { @@ -60,13 +61,20 @@ class _ImageEditorDialog extends HookConsumerWidget { assert(false, 'image is null'); return const SizedBox(); } - return BlocProvider<_ImageEditorBloc>( - create: (context) => - _ImageEditorBloc(path: path, image: uiImage, snapshot: snapshot), + final controller = _ImageEditorController( + path: path, + image: uiImage, + snapshot: snapshot, + ); + useEffect(() => controller.dispose, [controller]); + return _ImageEditorRuntimeScope( + theme: theme, + l10n: l10n, + controller: controller, child: BackdropFilter( filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10), child: ColoredBox( - color: context.theme.background.withValues(alpha: 0.8), + color: theme.background.withValues(alpha: 0.8), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20), child: Column( @@ -94,6 +102,42 @@ class _ImageEditorDialog extends HookConsumerWidget { } } +class _ImageEditorRuntimeScope extends InheritedWidget { + const _ImageEditorRuntimeScope({ + required this.theme, + required this.l10n, + required this.controller, + required super.child, + }); + + final BrightnessThemeData theme; + final Localization l10n; + final _ImageEditorController controller; + + static _ImageEditorRuntimeScope of(BuildContext context) { + final inherited = context + .dependOnInheritedWidgetOfExactType<_ImageEditorRuntimeScope>(); + assert(inherited != null, '_ImageEditorRuntimeScope is missing'); + return inherited!; + } + + @override + bool updateShouldNotify(_ImageEditorRuntimeScope oldWidget) => + theme != oldWidget.theme || + l10n != oldWidget.l10n || + controller != oldWidget.controller; +} + +BrightnessThemeData _imageEditorTheme(BuildContext context) => + _ImageEditorRuntimeScope.of(context).theme; + +Localization _imageEditorL10n(BuildContext context) => + _ImageEditorRuntimeScope.of(context).l10n; + +_ImageEditorController? _imageEditorController(BuildContext context) => context + .dependOnInheritedWidgetOfExactType<_ImageEditorRuntimeScope>() + ?.controller; + class CustomDrawLine extends Equatable { const CustomDrawLine(this.path, this.color, this.width, this.eraser); @@ -194,8 +238,8 @@ class _ImageEditorState extends Equatable with EquatableMixin { ); } -class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { - _ImageEditorBloc({ +class _ImageEditorController extends ValueNotifier<_ImageEditorState> { + _ImageEditorController({ required this.path, required this.image, ImageEditorSnapshot? snapshot, @@ -240,6 +284,14 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { final double _drawStrokeWidth = 11; + _ImageEditorState get state => value; + + set state(_ImageEditorState nextState) => value = nextState; + + void emit(_ImageEditorState nextState) { + state = nextState; + } + void rotate() { ImageRotate next() { switch (state.rotate) { @@ -520,6 +572,14 @@ class _ImageEditorBloc extends Cubit<_ImageEditorState> with SubscribeMixin { } } +T _useImageEditorStateValue( + _ImageEditorController controller, + T Function(_ImageEditorState state) selector, +) { + final state = useValueListenable(controller); + return selector(state); +} + class _Preview extends HookConsumerWidget { const _Preview({ required this.path, @@ -538,20 +598,22 @@ class _Preview extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isFlip = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.flip, - ); - - final rotate = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, ImageRotate>( - converter: (state) => state.rotate, - ); - - final drawMode = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, DrawMode>( - converter: (state) => state.drawMode, - ); + final editorController = _imageEditorController(context); + if (editorController == null) { + return const SizedBox(); + } + final isFlip = _useImageEditorStateValue( + editorController, + (state) => state.flip, + ); + final rotate = _useImageEditorStateValue( + editorController, + (state) => state.rotate, + ); + final drawMode = _useImageEditorStateValue( + editorController, + (state) => state.drawMode, + ); final transformedViewPortSize = rotate.apply(viewPortSize); final scale = math.min( @@ -683,10 +745,14 @@ class _CropRectWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final cropRect = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, Rect>( - converter: (state) => state.cropRect, - ); + final editorController = _imageEditorController(context); + if (editorController == null) { + return const SizedBox(); + } + final cropRect = _useImageEditorStateValue( + editorController, + (state) => state.cropRect, + ); final transformedRect = useMemoized(() { if (cropRect.isEmpty || cropRect.isInfinite) { @@ -788,7 +854,7 @@ class _CropRectWidget extends HookConsumerWidget { imageRect, rotate.radius, ).scaled(1 / scale); - context.read<_ImageEditorBloc>().setCropRect(rect); + _imageEditorController(context)?.setCropRect(rect); }, onPanEnd: (details) { trackingRectCorner.value = null; @@ -945,14 +1011,14 @@ class _CustomDrawingWidget extends HookConsumerWidget { final scaledImageSize = Size(image.width * scale, image.height * scale); - final editorBloc = context.read<_ImageEditorBloc>(); - - final lines = - useBlocStateConverter< - _ImageEditorBloc, - _ImageEditorState, - List - >(bloc: editorBloc, converter: (state) => state.drawLines); + final editorBloc = _imageEditorController(context); + if (editorBloc == null) { + return const SizedBox(); + } + final lines = _useImageEditorStateValue( + editorBloc, + (state) => state.drawLines, + ); Offset screenToImage(Offset position) { final center = viewPortSize.center(Offset.zero); @@ -1117,7 +1183,7 @@ class _DrawColorSelector extends HookConsumerWidget { return SizedBox( height: 38, child: Material( - color: context.theme.chatBackground, + color: _imageEditorTheme(context).chatBackground, borderRadius: const BorderRadius.all(Radius.circular(62)), child: Row( mainAxisSize: MainAxisSize.min, @@ -1159,16 +1225,20 @@ class _NormalColorTile extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentColor = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, Color>( - converter: (state) => state.drawColor, - ); + final editorController = _imageEditorController(context); + if (editorController == null) { + return const SizedBox(); + } + final currentColor = _useImageEditorStateValue( + editorController, + (state) => state.drawColor, + ); return Padding( padding: const EdgeInsets.symmetric(horizontal: 2), child: InkResponse( radius: 18, onTap: () { - context.read<_ImageEditorBloc>().setCustomDrawColor(color); + _imageEditorController(context)?.setCustomDrawColor(color); }, child: SizedBox( width: 28, @@ -1180,7 +1250,10 @@ class _NormalColorTile extends HookConsumerWidget { Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( - border: Border.all(color: context.theme.accent, width: 2), + border: Border.all( + color: _imageEditorTheme(context).accent, + width: 2, + ), shape: BoxShape.circle, ), ), @@ -1227,7 +1300,10 @@ class _CustomColorTile extends StatelessWidget { Positioned.fill( child: DecoratedBox( decoration: BoxDecoration( - border: Border.all(color: context.theme.accent, width: 2), + border: Border.all( + color: _imageEditorTheme(context).accent, + width: 2, + ), shape: BoxShape.circle, ), ), @@ -1274,10 +1350,14 @@ class _CustomColorBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final editorController = _imageEditorController(context); + if (editorController == null) { + return const SizedBox(); + } final initialHue = useMemoized(() { - final color = context.read<_ImageEditorBloc>().state.drawColor; + final color = editorController.state.drawColor; return HSLColor.fromColor(color).hue; - }); + }, [editorController]); const maxSlideOffset = 256.0 - 12 - 9.0; final sliderOffset = useState(initialHue / 360 * maxSlideOffset); @@ -1322,7 +1402,9 @@ class _CustomColorBar extends HookConsumerWidget { } final color = sliderOffsetToColor(sliderOffset.value); onColorSelected(color); - context.read<_ImageEditorBloc>().setCustomDrawColor(color); + _imageEditorController(context)?.setCustomDrawColor( + color, + ); }, child: Material( color: Colors.white, @@ -1344,17 +1426,21 @@ class _ResetButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final canReset = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.canReset, - ); + final editorController = _imageEditorController(context); + if (editorController == null) { + return const SizedBox(); + } + final canReset = _useImageEditorStateValue( + editorController, + (state) => state.canReset, + ); return SizedBox( height: 38, child: canReset ? TextButton( - child: Text(context.l10n.reset), + child: Text(_imageEditorL10n(context).reset), onPressed: () { - context.read<_ImageEditorBloc>().reset(); + _imageEditorController(context)?.reset(); }, ) : null, @@ -1367,10 +1453,14 @@ class _OperationButtons extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final drawMode = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, DrawMode>( - converter: (state) => state.drawMode, - ); + final editorController = _imageEditorController(context); + if (editorController == null) { + return const SizedBox(); + } + final drawMode = _useImageEditorStateValue( + editorController, + (state) => state.drawMode, + ); return Column( children: [ if (drawMode != DrawMode.none) @@ -1392,32 +1482,34 @@ class _NormalOperationBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final imageEditorBloc = context.read<_ImageEditorBloc>(); - - final rotated = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.rotate != ImageRotate.none, - ); - final flipped = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.flip, - ); - final hasCustomDraw = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.drawLines.isNotEmpty, - ); - final hasCrop = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) { - final width = imageEditorBloc.image.width; - final height = imageEditorBloc.image.height; - return state.cropRect.width.round() != width || - state.cropRect.height.round() != height; - }, - ); + final imageEditorBloc = _imageEditorController(context); + if (imageEditorBloc == null) { + return const SizedBox(); + } + final rotated = _useImageEditorStateValue( + imageEditorBloc, + (state) => state.rotate != ImageRotate.none, + ); + final flipped = _useImageEditorStateValue( + imageEditorBloc, + (state) => state.flip, + ); + final hasCustomDraw = _useImageEditorStateValue( + imageEditorBloc, + (state) => state.drawLines.isNotEmpty, + ); + final hasCrop = _useImageEditorStateValue( + imageEditorBloc, + (state) { + final width = imageEditorBloc.image.width; + final height = imageEditorBloc.image.height; + return state.cropRect.width.round() != width || + state.cropRect.height.round() != height; + }, + ); return Material( borderRadius: const BorderRadius.all(Radius.circular(8)), - color: context.theme.stickerPlaceholderColor, + color: _imageEditorTheme(context).stickerPlaceholderColor, child: SizedBox( height: 40, child: Row( @@ -1428,25 +1520,29 @@ class _NormalOperationBar extends HookConsumerWidget { if (imageEditorBloc.state.canReset) { final result = await showConfirmMixinDialog( context, - context.l10n.editImageClearWarning, + _imageEditorL10n(context).editImageClearWarning, ); if (result == null) return; } await Navigator.maybePop(context); }, child: Text( - context.l10n.cancel, - style: TextStyle(color: context.theme.text), + _imageEditorL10n(context).cancel, + style: TextStyle(color: _imageEditorTheme(context).text), ), ), ActionButton( - color: rotated ? context.theme.accent : context.theme.icon, + color: rotated + ? _imageEditorTheme(context).accent + : _imageEditorTheme(context).icon, name: Resources.assetsImagesEditImageRotateSvg, onTap: imageEditorBloc.rotate, ), const SizedBox(width: 4), ActionButton( - color: flipped ? context.theme.accent : context.theme.icon, + color: flipped + ? _imageEditorTheme(context).accent + : _imageEditorTheme(context).icon, name: Resources.assetsImagesEditImageFlipSvg, onTap: imageEditorBloc.flip, ), @@ -1455,7 +1551,7 @@ class _NormalOperationBar extends HookConsumerWidget { alignment: Alignment.topCenter, itemBuilder: (context) => [ CustomPopupMenuItem( - title: context.l10n.originalImage, + title: _imageEditorL10n(context).originalImage, value: 0, ), CustomPopupMenuItem(title: '1:1', value: 1), @@ -1473,23 +1569,29 @@ class _NormalOperationBar extends HookConsumerWidget { imageEditorBloc.setCropRatio(value); } }, - color: hasCrop ? context.theme.accent : context.theme.icon, + color: hasCrop + ? _imageEditorTheme(context).accent + : _imageEditorTheme(context).icon, icon: Resources.assetsImagesEditImageClipSvg, ), const SizedBox(width: 4), ActionButton( - color: hasCustomDraw ? context.theme.accent : context.theme.icon, + color: hasCustomDraw + ? _imageEditorTheme(context).accent + : _imageEditorTheme(context).icon, name: Resources.assetsImagesEditImageDrawSvg, onTap: () { - context.read<_ImageEditorBloc>().enterDrawMode(DrawMode.brush); + _imageEditorController(context)?.enterDrawMode( + DrawMode.brush, + ); }, ), TextButton( onPressed: () async { showToastLoading(); - final snapshot = await context - .read<_ImageEditorBloc>() - .takeSnapshot(); + final snapshot = await _imageEditorController( + context, + )?.takeSnapshot(); if (snapshot == null) { showToastFailed(null); return; @@ -1497,7 +1599,7 @@ class _NormalOperationBar extends HookConsumerWidget { Toast.dismiss(); await Navigator.maybePop(context, snapshot); }, - child: Text(context.l10n.done), + child: Text(_imageEditorL10n(context).done), ), ], ), @@ -1511,21 +1613,25 @@ class _DrawOperationBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final drawMode = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, DrawMode>( - converter: (state) => state.drawMode, - ); - final canRedo = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.canRedo, - ); - final canUndo = - useBlocStateConverter<_ImageEditorBloc, _ImageEditorState, bool>( - converter: (state) => state.drawLines.isNotEmpty, - ); + final editorController = _imageEditorController(context); + if (editorController == null) { + return const SizedBox(); + } + final drawMode = _useImageEditorStateValue( + editorController, + (state) => state.drawMode, + ); + final canRedo = _useImageEditorStateValue( + editorController, + (state) => state.canRedo, + ); + final canUndo = _useImageEditorStateValue( + editorController, + (state) => state.drawLines.isNotEmpty, + ); return Material( borderRadius: const BorderRadius.all(Radius.circular(8)), - color: context.theme.stickerPlaceholderColor, + color: _imageEditorTheme(context).stickerPlaceholderColor, child: SizedBox( height: 40, child: Row( @@ -1533,58 +1639,62 @@ class _DrawOperationBar extends HookConsumerWidget { children: [ TextButton( onPressed: () { - context.read<_ImageEditorBloc>().exitDrawingMode(); + _imageEditorController(context)?.exitDrawingMode(); }, child: Text( - context.l10n.cancel, - style: TextStyle(color: context.theme.text), + _imageEditorL10n(context).cancel, + style: TextStyle(color: _imageEditorTheme(context).text), ), ), ActionButton( color: canUndo - ? context.theme.icon - : context.theme.icon.withValues(alpha: 0.2), + ? _imageEditorTheme(context).icon + : _imageEditorTheme(context).icon.withValues(alpha: 0.2), name: Resources.assetsImagesEditImageUndoSvg, onTap: () { - context.read<_ImageEditorBloc>().undoDraw(); + _imageEditorController(context)?.undoDraw(); }, ), const SizedBox(width: 4), ActionButton( color: canRedo - ? context.theme.icon - : context.theme.icon.withValues(alpha: 0.2), + ? _imageEditorTheme(context).icon + : _imageEditorTheme(context).icon.withValues(alpha: 0.2), name: Resources.assetsImagesEditImageRedoSvg, onTap: () { - context.read<_ImageEditorBloc>().redoDraw(); + _imageEditorController(context)?.redoDraw(); }, ), const SizedBox(width: 4), ActionButton( color: drawMode == DrawMode.brush - ? context.theme.accent - : context.theme.icon, + ? _imageEditorTheme(context).accent + : _imageEditorTheme(context).icon, name: Resources.assetsImagesEditImageDrawSvg, onTap: () { - context.read<_ImageEditorBloc>().enterDrawMode(DrawMode.brush); + _imageEditorController(context)?.enterDrawMode( + DrawMode.brush, + ); }, ), ActionButton( color: drawMode == DrawMode.eraser - ? context.theme.accent - : context.theme.icon, + ? _imageEditorTheme(context).accent + : _imageEditorTheme(context).icon, name: Resources.assetsImagesEditImageEraseSvg, onTap: () { - context.read<_ImageEditorBloc>().enterDrawMode(DrawMode.eraser); + _imageEditorController(context)?.enterDrawMode( + DrawMode.eraser, + ); }, ), TextButton( onPressed: () { - context.read<_ImageEditorBloc>().exitDrawingMode( + _imageEditorController(context)?.exitDrawingMode( applyTempDraw: true, ); }, - child: Text(context.l10n.done), + child: Text(_imageEditorL10n(context).done), ), ], ), diff --git a/lib/ui/home/chat/input_container.dart b/lib/ui/home/chat/input_container.dart index 4dde511bc4..ac5a6a493e 100644 --- a/lib/ui/home/chat/input_container.dart +++ b/lib/ui/home/chat/input_container.dart @@ -8,26 +8,22 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_animate/flutter_animate.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider; +import 'package:hooks_riverpod/hooks_riverpod.dart' + hide ChangeNotifierProvider, Provider; import 'package:image_picker/image_picker.dart'; -import 'package:provider/provider.dart' hide Consumer; -import 'package:rxdart/rxdart.dart'; import 'package:simple_animations/simple_animations.dart'; import 'package:super_context_menu/super_context_menu.dart'; import '../../../constants/constants.dart'; import '../../../constants/icon_fonts.dart'; import '../../../constants/resources.dart'; -import '../../../db/database_event_bus.dart'; import '../../../db/mixin_database.dart' hide Offset; import '../../../enum/encrypt_category.dart'; import '../../../utils/app_lifecycle.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/file.dart'; -import '../../../utils/hook.dart'; import '../../../utils/platform.dart'; import '../../../utils/reg_exp_utils.dart'; import '../../../utils/system/clipboard.dart'; @@ -38,17 +34,18 @@ import '../../../widgets/hover_overlay.dart'; import '../../../widgets/mention_panel.dart'; import '../../../widgets/menu.dart'; import '../../../widgets/message/item/quote_message.dart'; -import '../../../widgets/sticker_page/bloc/cubit/sticker_albums_cubit.dart'; import '../../../widgets/sticker_page/sticker_page.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/user_selector/conversation_selector.dart'; -import '../../provider/abstract_responsive_navigator.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/database_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/mention_provider.dart'; import '../../provider/quote_message_provider.dart'; import '../../provider/recall_message_reedit_provider.dart'; -import 'chat_page.dart'; +import '../../provider/ui_context_providers.dart'; +import '../providers/home_scope_providers.dart'; import 'files_preview.dart'; import 'voice_recorder_bottom_bar.dart'; @@ -58,43 +55,48 @@ class InputContainer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final conversationId = ref.watch(currentConversationIdProvider); - + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final hasParticipant = ref.watch(currentConversationHasParticipantProvider); - final voiceRecorderCubit = useBloc( - () => VoiceRecorderCubit(context.audioMessageService), - keys: [conversationId], - ); - if (!hasParticipant) { return Container( - decoration: BoxDecoration(color: context.theme.primary), + decoration: BoxDecoration(color: theme.primary), height: 56, alignment: Alignment.center, child: Text( - context.l10n.groupCantSend, - style: TextStyle(color: context.theme.secondaryText), + l10n.groupCantSend, + style: TextStyle(color: theme.secondaryText), ), ); } - return BlocProvider.value( - value: voiceRecorderCubit, - child: LayoutBuilder( - builder: (context, constraints) => VoiceRecorderBarOverlayComposition( - layoutWidth: constraints.maxWidth, - child: const _InputContainer(), - ), + return LayoutBuilder( + key: ValueKey(conversationId), + builder: (context, constraints) => VoiceRecorderBarOverlayComposition( + layoutWidth: constraints.maxWidth, + child: const _InputContainer(), ), ); } } +class _ChatInputRuntime { + const _ChatInputRuntime({ + required this.textEditingController, + required this.focusNode, + }); + + final TextEditingController textEditingController; + final FocusNode focusNode; +} + class _InputContainer extends HookConsumerWidget { const _InputContainer(); @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final (conversationId, originalDraft) = ref.watch( conversationProvider.select( (value) => (value?.conversationId, value?.conversation?.draft), @@ -109,7 +111,7 @@ class _InputContainer extends HookConsumerWidget { ); return _HighlightTextEditingController( initialText: draft, - highlightTextStyle: TextStyle(color: context.theme.accent), + highlightTextStyle: TextStyle(color: theme.accent), mentionCache: ref.read(mentionCacheProvider), ) ..selection = TextSelection.fromPosition( @@ -117,21 +119,21 @@ class _InputContainer extends HookConsumerWidget { ); }, [conversationId]); - final textEditingValueStream = useValueNotifierConvertSteam( - textEditingController, - ); - - final mentionProviderInstance = mentionProvider(textEditingValueStream); - - useEffect(() { - final updateDraft = context.database.conversationDao.updateDraft; - return () { + useEffect( + () => () { if (conversationId == null) return; if (textEditingController.text == originalDraft) return; - updateDraft(conversationId, textEditingController.text); - }; - }, [conversationId, originalDraft]); + ref + .read(accountServerProvider) + .requireValue + .updateConversationDraft( + conversationId, + textEditingController.text, + ); + }, + [conversationId, originalDraft], + ); final focusNode = useFocusNode( onKeyEvent: (_, _) => KeyEventResult.ignored, @@ -174,11 +176,9 @@ class _InputContainer extends HookConsumerWidget { }; }, []); - final chatSidePageSize = - useBlocStateConverter( - bloc: context.read(), - converter: (state) => state.pages.length, - ); + final chatSidePageSize = ref.watch( + chatSideControllerProvider.select((state) => state.pages.length), + ); useEffect(() { if (!context.textFieldAutoGainFocus) { // Make sure on iPad/Android Pad the text field not get focused when the chat side @@ -187,10 +187,20 @@ class _InputContainer extends HookConsumerWidget { } }, [chatSidePageSize]); + final runtime = useMemoized( + () => _ChatInputRuntime( + textEditingController: textEditingController, + focusNode: focusNode, + ), + [textEditingController, focusNode], + ); + return LayoutBuilder( builder: (context, constraints) => MentionPanelPortalEntry( textEditingController: textEditingController, - mentionProviderInstance: mentionProviderInstance, + mentionProviderInstance: mentionProvider( + ref.watch(chatInputTextValueStreamProvider(textEditingController)), + ), constraints: constraints, child: Column( mainAxisSize: MainAxisSize.min, @@ -200,7 +210,9 @@ class _InputContainer extends HookConsumerWidget { ConstrainedBox( constraints: const BoxConstraints(minHeight: 56), child: Container( - decoration: BoxDecoration(color: context.theme.primary), + decoration: BoxDecoration( + color: theme.primary, + ), padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 8, @@ -210,22 +222,11 @@ class _InputContainer extends HookConsumerWidget { children: [ const _SendActionTypeButton(), const SizedBox(width: 6), - _StickerButton( - textEditingController: textEditingController, - ), + _StickerButton(runtime: runtime), const SizedBox(width: 16), - Expanded( - child: _SendTextField( - focusNode: focusNode, - textEditingController: textEditingController, - mentionProviderInstance: mentionProviderInstance, - ), - ), + Expanded(child: _SendTextField(runtime: runtime)), const SizedBox(width: 16), - _AnimatedSendOrVoiceButton( - textEditingController: textEditingController, - textEditingValueStream: textEditingValueStream, - ), + _AnimatedSendOrVoiceButton(runtime: runtime), ], ), ), @@ -238,25 +239,22 @@ class _InputContainer extends HookConsumerWidget { } class _AnimatedSendOrVoiceButton extends HookConsumerWidget { - const _AnimatedSendOrVoiceButton({ - required this.textEditingValueStream, - required this.textEditingController, - }); + const _AnimatedSendOrVoiceButton({required this.runtime}); - final Stream textEditingValueStream; - final TextEditingController textEditingController; + final _ChatInputRuntime runtime; @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + final textEditingController = runtime.textEditingController; final hasInputText = - useMemoizedStream( - () => textEditingValueStream - .map((event) => event.text.isNotEmpty) - .distinct(), - keys: [textEditingValueStream], - initialData: textEditingController.text.isNotEmpty, - ).data ?? - false; + ref + .watch(chatInputTextValueProvider(textEditingController)) + .value + ?.text + .isNotEmpty ?? + textEditingController.text.isNotEmpty; // start -> show voice button // end -> show send button @@ -306,9 +304,9 @@ class _AnimatedSendOrVoiceButton extends HookConsumerWidget { children: [ MenuAction( image: MenuImage.icon(IconFonts.mute), - title: context.l10n.sendWithoutSound, + title: l10n.sendWithoutSound, callback: () => _sendMessage( - context, + ref, textEditingController, silent: true, ), @@ -317,8 +315,8 @@ class _AnimatedSendOrVoiceButton extends HookConsumerWidget { ), child: ActionButton( name: Resources.assetsImagesIcSendSvg, - color: context.theme.icon, - onTap: () => _sendMessage(context, textEditingController), + color: theme.icon, + onTap: () => _sendMessage(ref, textEditingController), ), ), ), @@ -327,8 +325,10 @@ class _AnimatedSendOrVoiceButton extends HookConsumerWidget { scale: voiceScale, child: ActionButton( name: Resources.assetsImagesMicrophoneSvg, - color: context.theme.icon, - onTap: () => context.read().startRecording(), + color: theme.icon, + onTap: () => ref + .read(voiceRecorderControllerProvider.notifier) + .startRecording(), ), ), ], @@ -336,94 +336,92 @@ class _AnimatedSendOrVoiceButton extends HookConsumerWidget { } } -void showMaxLengthReachedToast(BuildContext context) => - showToastFailed(ToastError(context.l10n.contentTooLong)); +void showMaxLengthReachedToast(WidgetRef ref) => + showToastFailed(ToastError(ref.read(localizationProvider).contentTooLong)); void _sendPostMessage( - BuildContext context, + WidgetRef ref, TextEditingController textEditingController, ) { final text = textEditingController.value.text.trim(); if (text.isEmpty) return; - final conversationItem = context.providerContainer.read(conversationProvider); + final conversationItem = ref.read(conversationProvider); if (conversationItem == null) return; if (text.length > kMaxTextLength) { - showMaxLengthReachedToast(context); + showMaxLengthReachedToast(ref); return; } - context.accountServer.sendPostMessage( - text, - conversationItem.encryptCategory, - conversationId: conversationItem.conversationId, - recipientId: conversationItem.userId, - ); + ref + .read(accountServerProvider) + .requireValue + .sendPostMessage( + text, + conversationItem.encryptCategory, + conversationId: conversationItem.conversationId, + recipientId: conversationItem.userId, + ); textEditingController.text = ''; - context.providerContainer.read(quoteMessageProvider.notifier).state = null; + ref.read(quoteMessageProvider.notifier).clear(); } void _sendMessage( - BuildContext context, + WidgetRef ref, TextEditingController textEditingController, { bool silent = false, }) { final text = textEditingController.value.text.trim(); if (text.isEmpty) return; - final conversationItem = context.providerContainer.read(conversationProvider); + final conversationItem = ref.read(conversationProvider); if (conversationItem == null) return; if (text.length > kMaxTextLength) { - showMaxLengthReachedToast(context); + showMaxLengthReachedToast(ref); return; } - context.accountServer.sendTextMessage( - text, - conversationItem.encryptCategory, - conversationId: conversationItem.conversationId, - recipientId: conversationItem.userId, - quoteMessageId: context.providerContainer.read(quoteMessageIdProvider), - silent: silent, - ); + ref + .read(accountServerProvider) + .requireValue + .sendTextMessage( + text, + conversationItem.encryptCategory, + conversationId: conversationItem.conversationId, + recipientId: conversationItem.userId, + quoteMessageId: ref.read(quoteMessageIdProvider), + silent: silent, + ); textEditingController.text = ''; - context.providerContainer.read(quoteMessageProvider.notifier).state = null; + ref.read(quoteMessageProvider.notifier).clear(); } class _SendTextField extends HookConsumerWidget { - const _SendTextField({ - required this.focusNode, - required this.textEditingController, - required this.mentionProviderInstance, - }); + const _SendTextField({required this.runtime}); - final FocusNode focusNode; - final TextEditingController textEditingController; - final AutoDisposeStateNotifierProvider - mentionProviderInstance; + final _ChatInputRuntime runtime; @override Widget build(BuildContext context, WidgetRef ref) { - final textEditingValueStream = useValueNotifierConvertSteam( - textEditingController, + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + final textEditingController = runtime.textEditingController; + final focusNode = runtime.focusNode; + final textEditingValueStream = ref.watch( + chatInputTextValueStreamProvider(textEditingController), ); - final mentionStream = ref.watch(mentionProviderInstance.notifier).stream; + final mentionProviderInstance = mentionProvider(textEditingValueStream); + final textEditingValue = + ref.watch(chatInputTextValueProvider(textEditingController)).value ?? + textEditingController.value; + final mentionState = ref.watch(mentionProviderInstance); final sendable = - useMemoizedStream( - () => Rx.combineLatest2( - textEditingValueStream, - mentionStream.startWith(ref.read(mentionProviderInstance)), - (textEditingValue, mentionState) => - (textEditingValue.text.trim().isNotEmpty) && - (textEditingValue.composing.composed) && - mentionState.users.isEmpty, - ).distinct(), - keys: [textEditingValueStream, mentionStream], - ).data ?? - true; + textEditingValue.text.trim().isNotEmpty && + textEditingValue.composing.composed && + mentionState.users.isEmpty; useEffect( () => ref @@ -442,23 +440,17 @@ class _SendTextField extends HookConsumerWidget { ), ); - final hasInputText = - useMemoizedStream( - () => textEditingValueStream - .map((event) => event.text.isNotEmpty) - .distinct(), - keys: [textEditingValueStream], - initialData: textEditingController.text.isNotEmpty, - ).data ?? - false; + final hasInputText = textEditingValue.text.isNotEmpty; return Container( constraints: const BoxConstraints(minHeight: 40), decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(4)), - color: context.dynamicColor( - const Color.fromRGBO(245, 247, 250, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + color: ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(245, 247, 250, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + )), ), ), alignment: Alignment.center, @@ -479,15 +471,14 @@ class _SendTextField extends HookConsumerWidget { }, actions: { _SendMessageIntent: CallbackAction( - onInvoke: (intent) => _sendMessage(context, textEditingController), + onInvoke: (intent) => _sendMessage(ref, textEditingController), ), PasteTextIntent: _PasteContextAction(context), _SendPostMessageIntent: CallbackAction( - onInvoke: (_) => _sendPostMessage(context, textEditingController), + onInvoke: (_) => _sendPostMessage(ref, textEditingController), ), EscapeIntent: CallbackAction( - onInvoke: (_) => - ref.read(quoteMessageProvider.notifier).state = null, + onInvoke: (_) => ref.read(quoteMessageProvider.notifier).clear(), ), }, child: Stack( @@ -497,7 +488,7 @@ class _SendTextField extends HookConsumerWidget { minLines: 1, focusNode: focusNode, controller: textEditingController, - style: TextStyle(color: context.theme.text, fontSize: 14), + style: TextStyle(color: theme.text, fontSize: 14), inputFormatters: [ LengthLimitingTextInputFormatter(kMaxTextLength), ], @@ -520,10 +511,10 @@ class _SendTextField extends HookConsumerWidget { child: IgnorePointer( child: Text( isEncryptConversation - ? context.l10n.chatHintE2e - : context.l10n.typeMessage, + ? l10n.chatHintE2e + : l10n.typeMessage, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), maxLines: 1, @@ -544,6 +535,7 @@ class _QuoteMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final quoting = ref.watch( quoteMessageProvider.select((value) => value != null), ); @@ -565,7 +557,7 @@ class _QuoteMessage extends HookConsumerWidget { if (message == null) return const SizedBox(); return DecoratedBox( - decoration: BoxDecoration(color: context.theme.popUp), + decoration: BoxDecoration(color: theme.popUp), child: Row( children: [ Expanded( @@ -575,8 +567,7 @@ class _QuoteMessage extends HookConsumerWidget { ), ), GestureDetector( - onTap: () => - ref.read(quoteMessageProvider.notifier).state = null, + onTap: () => ref.read(quoteMessageProvider.notifier).clear(), behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.all(14), @@ -601,14 +592,19 @@ class _SendActionTypeButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); final isDesktop = Platform.isMacOS || Platform.isLinux || Platform.isWindows; final menuDisplayed = useState(false); + final menuController = useMemoized(MenuPortalController.new); + + useEffect(() => menuController.dispose, [menuController]); return Padding( padding: const EdgeInsets.only(left: 6), child: ContextMenuPortalEntry( + controller: menuController, interactive: false, showedMenu: (value) => Future(() { menuDisplayed.value = value; @@ -616,11 +612,9 @@ class _SendActionTypeButton extends HookConsumerWidget { buildMenus: () => [ ContextMenu( icon: Resources.assetsImagesContactSvg, - title: context.l10n.contact, + title: l10n.contact, onTap: () async { - final conversationState = context.providerContainer.read( - conversationProvider, - ); + final conversationState = ref.read(conversationProvider); if (conversationState == null) return; @@ -628,7 +622,7 @@ class _SendActionTypeButton extends HookConsumerWidget { context: context, singleSelect: true, onlyContact: true, - title: context.l10n.select, + title: l10n.select, filteredIds: [conversationState.userId].nonNulls, ); @@ -637,7 +631,11 @@ class _SendActionTypeButton extends HookConsumerWidget { if (userId == null || userId.isEmpty) return; await runWithToast(() async { - final user = await context.database.userDao + final database = ref.read(databaseProvider).requireValue; + final accountServer = ref + .read(accountServerProvider) + .requireValue; + final user = await database.userDao .userById(userId) .getSingleOrNull(); if (user == null) throw Exception('User not found'); @@ -646,7 +644,7 @@ class _SendActionTypeButton extends HookConsumerWidget { quoteMessageProvider.notifier, ); - await context.accountServer.sendContactMessage( + await accountServer.sendContactMessage( userId, user.fullName, conversationState.encryptCategory, @@ -660,7 +658,7 @@ class _SendActionTypeButton extends HookConsumerWidget { ), ContextMenu( icon: Resources.assetsImagesFileSvg, - title: context.l10n.files, + title: l10n.files, onTap: () async { final files = await selectFiles(); if (files.isNotEmpty) { @@ -671,7 +669,7 @@ class _SendActionTypeButton extends HookConsumerWidget { if (isDesktop) ContextMenu( icon: Resources.assetsImagesFilePreviewImagesSvg, - title: context.l10n.picturesAndVideos, + title: l10n.picturesAndVideos, onTap: () async { final files = await selectFiles(); if (files.isNotEmpty) { @@ -682,7 +680,7 @@ class _SendActionTypeButton extends HookConsumerWidget { if (!isDesktop) ContextMenu( icon: Resources.assetsImagesImageSvg, - title: context.l10n.image, + title: l10n.image, onTap: () async { final image = await ImagePicker().pickImage( source: ImageSource.gallery, @@ -696,7 +694,7 @@ class _SendActionTypeButton extends HookConsumerWidget { if (!isDesktop) ContextMenu( icon: Resources.assetsImagesVideoSvg, - title: context.l10n.video, + title: l10n.video, onTap: () async { final video = await ImagePicker().pickVideo( source: ImageSource.gallery, @@ -708,19 +706,27 @@ class _SendActionTypeButton extends HookConsumerWidget { }, ), ], - child: _AnimatedSendTypeButton(menuDisplayed: menuDisplayed.value), + child: _AnimatedSendTypeButton( + menuDisplayed: menuDisplayed.value, + menuController: menuController, + ), ), ); } } -class _AnimatedSendTypeButton extends StatelessWidget { - const _AnimatedSendTypeButton({required this.menuDisplayed}); +class _AnimatedSendTypeButton extends ConsumerWidget { + const _AnimatedSendTypeButton({ + required this.menuDisplayed, + required this.menuController, + }); final bool menuDisplayed; + final MenuPortalController menuController; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final tween = MovieTween() ..scene( duration: 200.milliseconds, @@ -751,12 +757,12 @@ class _AnimatedSendTypeButton extends StatelessWidget { final position = renderBox.localToGlobal( renderBox.paintBounds.topCenter, ); - context.sendMenuPosition(position); + menuController.show(position); } else { - context.sendMenuPosition(details.globalPosition); + menuController.show(details.globalPosition); } }, - color: context.theme.icon, + color: theme.icon, ), builder: (context, value, child) => Transform.scale( scale: value.get('scale'), @@ -767,23 +773,15 @@ class _AnimatedSendTypeButton extends StatelessWidget { } class _StickerButton extends HookConsumerWidget { - const _StickerButton({required this.textEditingController}); + const _StickerButton({required this.runtime}); - final TextEditingController textEditingController; + final _ChatInputRuntime runtime; @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final key = useMemoized(GlobalKey.new); - final stickerAlbumsCubit = useBloc( - () => StickerAlbumsCubit( - context.database.stickerAlbumDao.systemAddedAlbums().watchWithStream( - eventStreams: [DataBaseEventBus.instance.updateStickerStream], - duration: kVerySlowThrottleDuration, - ), - ), - ); - final presetStickerGroups = useMemoized( () => [ PresetStickerGroup.store, @@ -794,66 +792,58 @@ class _StickerButton extends HookConsumerWidget { ], ); - final tabLength = - useBlocStateConverter, int>( - bloc: stickerAlbumsCubit, - converter: (state) => state.length + presetStickerGroups.length, - keys: [presetStickerGroups], - ); + final stickerAlbums = + ref.watch(stickerAlbumsProvider).value ?? const []; + final tabLength = stickerAlbums.length + presetStickerGroups.length; + + return DefaultTabController( + length: tabLength, + initialIndex: 1, + child: HoverOverlay( + key: key, + delayDuration: const Duration(milliseconds: 50), + duration: const Duration(milliseconds: 200), + closeDuration: const Duration(milliseconds: 200), + closeWaitDuration: const Duration(milliseconds: 300), + inCurve: Curves.easeOut, + outCurve: Curves.easeOut, + portalBuilder: (context, progress, _, child) { + ref.read(accountServerProvider).requireValue.refreshSticker(); + + final renderBox = + key.currentContext?.findRenderObject() as RenderBox?; + final offset = renderBox?.localToGlobal(Offset.zero); + + if (renderBox == null || offset == null || !renderBox.hasSize) { + return const SizedBox(); + } - return MultiProvider( - providers: [ - BlocProvider.value(value: stickerAlbumsCubit), - ChangeNotifierProvider.value(value: textEditingController), - ], - child: DefaultTabController( - length: tabLength, - initialIndex: 1, - child: HoverOverlay( - key: key, - delayDuration: const Duration(milliseconds: 50), - duration: const Duration(milliseconds: 200), - closeDuration: const Duration(milliseconds: 200), - closeWaitDuration: const Duration(milliseconds: 300), - inCurve: Curves.easeOut, - outCurve: Curves.easeOut, - portalBuilder: (context, progress, _, child) { - context.accountServer.refreshSticker(); - - final renderBox = - key.currentContext?.findRenderObject() as RenderBox?; - final offset = renderBox?.localToGlobal(Offset.zero); - - if (renderBox == null || offset == null || !renderBox.hasSize) { - return const SizedBox(); - } - - final size = renderBox.size; - return Opacity( - opacity: progress, - child: CustomSingleChildLayout( - delegate: _StickerPagePositionedLayoutDelegate( - position: offset + Offset(size.width / 2, 0), - ), - child: child, - ), - ); - }, - portal: Padding( - padding: const EdgeInsets.all(8), - child: Builder( - builder: (context) => StickerPage( - tabController: DefaultTabController.of(context), - tabLength: tabLength, - presetStickerGroups: presetStickerGroups, + final size = renderBox.size; + return Opacity( + opacity: progress, + child: CustomSingleChildLayout( + delegate: _StickerPagePositionedLayoutDelegate( + position: offset + Offset(size.width / 2, 0), ), + child: child, + ), + ); + }, + portal: Padding( + padding: const EdgeInsets.all(8), + child: Builder( + builder: (context) => StickerPage( + tabController: DefaultTabController.of(context), + tabLength: tabLength, + presetStickerGroups: presetStickerGroups, + textController: runtime.textEditingController, ), ), - child: ActionButton( - name: Resources.assetsImagesIcStickerSvg, - color: context.theme.icon, - interactive: false, - ), + ), + child: ActionButton( + name: Resources.assetsImagesIcStickerSvg, + color: theme.icon, + interactive: false, ), ), ); diff --git a/lib/ui/home/chat/selection_bottom_bar.dart b/lib/ui/home/chat/selection_bottom_bar.dart index dd711a2a02..f2d1516773 100644 --- a/lib/ui/home/chat/selection_bottom_bar.dart +++ b/lib/ui/home/chat/selection_bottom_bar.dart @@ -12,14 +12,20 @@ import '../../../widgets/dialog.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/user_selector/conversation_selector.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/database_provider.dart'; import '../../provider/message_selection_provider.dart'; +import '../../provider/ui_context_providers.dart'; class SelectionBottomBar extends HookConsumerWidget { const SelectionBottomBar({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final accountServer = ref.read(accountServerProvider).requireValue; + final database = ref.read(databaseProvider).requireValue; final canForward = ref.watch( messageSelectionProvider.select((value) => value.canForward), ); @@ -34,51 +40,51 @@ class SelectionBottomBar extends HookConsumerWidget { mainAxisAlignment: MainAxisAlignment.spaceAround, children: [ _Button( - label: context.l10n.combineAndForward, + label: l10n.combineAndForward, iconAssetName: Resources.assetsImagesMessageTranscriptForwardSvg, enable: canCombineForward, onTap: () async { final result = await showConversationSelector( context: context, singleSelect: true, - title: context.l10n.forward, + title: l10n.forward, onlyContact: false, ); if (result == null || result.isEmpty) return; - final cubit = ref.read(messageSelectionProvider); - final messageIds = cubit.selectedMessageIds; + final selection = ref.read(messageSelectionProvider); + final messageIds = selection.selectedMessageIds; await runWithLoading( - () => context.accountServer.sendTranscriptMessage( + () => accountServer.sendTranscriptMessage( messageIds.toList(), result.first.encryptCategory!, conversationId: result.first.conversationId, recipientId: result.first.userId, ), ); - cubit.clearSelection(); + ref.read(messageSelectionProvider.notifier).clearSelection(); }, ), _Button( - label: context.l10n.oneByOneForward, + label: l10n.oneByOneForward, iconAssetName: Resources.assetsImagesContextMenuForwardSvg, enable: canForward, onTap: () async { final result = await showConversationSelector( context: context, singleSelect: true, - title: context.l10n.forward, + title: l10n.forward, onlyContact: false, ); if (result == null || result.isEmpty) return; - final cubit = ref.read(messageSelectionProvider); - final messageIds = cubit.selectedMessageIds; + final selection = ref.read(messageSelectionProvider); + final messageIds = selection.selectedMessageIds; await runWithLoading(() async { for (final id in messageIds) { - await context.accountServer.forwardMessage( + await accountServer.forwardMessage( id, result.first.encryptCategory!, conversationId: result.first.conversationId, @@ -86,21 +92,18 @@ class SelectionBottomBar extends HookConsumerWidget { ); } }); - cubit.clearSelection(); + ref.read(messageSelectionProvider.notifier).clearSelection(); }, ), _Button( - label: context.l10n.copy, + label: l10n.copy, iconAssetName: Resources.assetsImagesCopySvg, onTap: () async => runFutureWithToast( (() async { - final messageSelectionNotifier = ref.read( - messageSelectionProvider, - ); + final messageSelection = ref.read(messageSelectionProvider); - final selectedMessageIds = - messageSelectionNotifier.selectedMessageIds; - final messages = await context.database.messageDao + final selectedMessageIds = messageSelection.selectedMessageIds; + final messages = await database.messageDao .messageItemByMessageIds( selectedMessageIds.toList(), ) @@ -123,12 +126,12 @@ class SelectionBottomBar extends HookConsumerWidget { await Clipboard.setData(ClipboardData(text: text)); - messageSelectionNotifier.clearSelection(); + ref.read(messageSelectionProvider.notifier).clearSelection(); })(), ), ), _Button( - label: context.l10n.delete, + label: l10n.delete, iconAssetName: Resources.assetsImagesContextMenuDeleteSvg, onTap: () async { final selection = ref.read(messageSelectionProvider); @@ -138,31 +141,31 @@ class SelectionBottomBar extends HookConsumerWidget { final confirm = await showConfirmMixinDialog( context, - context.l10n.chatDeleteMessage( + l10n.chatDeleteMessage( messagesToDelete.length, messagesToDelete.length, ), - positiveText: context.l10n.delete, - neutralText: canRecall ? context.l10n.deleteForEveryone : null, + positiveText: l10n.delete, + neutralText: canRecall ? l10n.deleteForEveryone : null, ); if (confirm == null) return; d('messagesToDelete: $messagesToDelete'); await runWithLoading(() async { if (confirm == DialogEvent.positive) { for (final id in messagesToDelete) { - await context.accountServer.deleteMessage(id); + await accountServer.deleteMessage(id); } return; } if (confirm == DialogEvent.neutral) { - await context.accountServer.sendRecallMessage( + await accountServer.sendRecallMessage( messagesToDelete.toList(), conversationId: ref.read(currentConversationIdProvider), ); } }); - selection.clearSelection(); + ref.read(messageSelectionProvider.notifier).clearSelection(); }, ), ], @@ -171,7 +174,7 @@ class SelectionBottomBar extends HookConsumerWidget { } } -class _Button extends StatelessWidget { +class _Button extends ConsumerWidget { const _Button({ required this.label, required this.iconAssetName, @@ -185,7 +188,16 @@ class _Button extends StatelessWidget { final bool enable; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final hoverColor = ref.watch( + dynamicColorProvider( + ( + color: const Color.fromRGBO(0, 0, 0, 0.03), + darkColor: const Color.fromRGBO(255, 255, 255, 0.2), + ), + ), + ); Widget child = Center( child: Padding( padding: const EdgeInsets.all(8), @@ -197,14 +209,17 @@ class _Button extends StatelessWidget { width: 24, height: 24, colorFilter: ColorFilter.mode( - context.theme.icon, + theme.icon, BlendMode.srcIn, ), ), const SizedBox(height: 8), Text( label, - style: TextStyle(color: context.theme.text, fontSize: 14), + style: TextStyle( + color: theme.text, + fontSize: 14, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), @@ -215,10 +230,7 @@ class _Button extends StatelessWidget { if (enable) { child = InteractiveDecoratedBox.color( decoration: const BoxDecoration(), - hoveringColor: context.dynamicColor( - const Color.fromRGBO(0, 0, 0, 0.03), - darkColor: const Color.fromRGBO(255, 255, 255, 0.2), - ), + hoveringColor: hoverColor, onTap: onTap, child: child, ); diff --git a/lib/ui/home/chat/voice_recorder_bottom_bar.dart b/lib/ui/home/chat/voice_recorder_bottom_bar.dart index cc87f4a983..8757ca4c6b 100644 --- a/lib/ui/home/chat/voice_recorder_bottom_bar.dart +++ b/lib/ui/home/chat/voice_recorder_bottom_bar.dart @@ -3,16 +3,14 @@ import 'dart:io'; import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; import 'package:ogg_opus_player/ogg_opus_player.dart'; import '../../../constants/resources.dart'; import '../../../utils/audio_message_player/audio_message_service.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/file.dart'; -import '../../../utils/hook.dart'; import '../../../utils/load_balancer_utils.dart'; import '../../../utils/logger.dart'; import '../../../utils/system/audio_session.dart'; @@ -20,13 +18,16 @@ import '../../../widgets/action_button.dart'; import '../../../widgets/dialog.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/waveform_widget.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/quote_message_provider.dart'; +import '../../provider/ui_context_providers.dart'; +import '../providers/home_scope_providers.dart'; enum RecorderState { idle, recording, recordingStopped } -class VoiceRecorderCubitState with EquatableMixin { - const VoiceRecorderCubitState({ +class VoiceRecorderState with EquatableMixin { + const VoiceRecorderState({ required this.state, this.startTime, this.recodedData, @@ -53,14 +54,28 @@ class RecordedData with EquatableMixin { List get props => [waveform, duration, path]; } -class VoiceRecorderCubit extends Cubit { - VoiceRecorderCubit(this.audioMessagePlayService) - : super(const VoiceRecorderCubitState(state: RecorderState.idle)); +class VoiceRecorderController extends Notifier { + @override + VoiceRecorderState build() { + ref.onDispose(() { + if (state.state == RecorderState.recording || + state.state == RecorderState.recordingStopped) { + unawaited(cancelAndExitRecordeMode()); + } else if (_startingCompleter != null) { + unawaited( + _startingCompleter?.future.then((_) => cancelAndExitRecordeMode()), + ); + } + _timer?.cancel(); + }); + return const VoiceRecorderState(state: RecorderState.idle); + } OggOpusRecorder? _recorder; String? _recorderFilePath; - final AudioMessagePlayService audioMessagePlayService; + AudioMessagePlayService get audioMessagePlayService => + ref.watch(audioMessagePlayServiceProvider); Completer? _startingCompleter; @@ -87,11 +102,9 @@ class VoiceRecorderCubit extends Cubit { _recorder = OggOpusRecorder(path); _recorder?.start(); _timer = Timer(const Duration(seconds: 60), stopRecording); - emit( - VoiceRecorderCubitState( - startTime: DateTime.now(), - state: RecorderState.recording, - ), + state = VoiceRecorderState( + startTime: DateTime.now(), + state: RecorderState.recording, ); _startingCompleter!.complete(); } @@ -131,11 +144,9 @@ class VoiceRecorderCubit extends Cubit { path!, ); - emit( - VoiceRecorderCubitState( - state: RecorderState.recordingStopped, - recodedData: recodeData, - ), + state = VoiceRecorderState( + state: RecorderState.recordingStopped, + recodedData: recodeData, ); return recodeData; } @@ -145,30 +156,17 @@ class VoiceRecorderCubit extends Cubit { return; } if (state.state == RecorderState.recordingStopped) { - emit(const VoiceRecorderCubitState(state: RecorderState.idle)); + state = const VoiceRecorderState(state: RecorderState.idle); return; } final result = await stopRecording(isCanceled: true); - emit(const VoiceRecorderCubitState(state: RecorderState.idle)); + state = const VoiceRecorderState(state: RecorderState.idle); try { await File(result.path).delete(); } catch (error, stacktrace) { e('cancelRecording: failed to delete file. $error $stacktrace'); } } - - @override - Future close() async { - if (state.state == RecorderState.recording || - state.state == RecorderState.recordingStopped) { - await cancelAndExitRecordeMode(); - } else if (_startingCompleter != null) { - await _startingCompleter?.future; - await cancelAndExitRecordeMode(); - } - _timer?.cancel(); - await super.close(); - } } class VoiceRecorderBarOverlayComposition extends HookConsumerWidget { @@ -184,20 +182,17 @@ class VoiceRecorderBarOverlayComposition extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isRecorderMode = - useBlocStateConverter< - VoiceRecorderCubit, - VoiceRecorderCubitState, - bool - >(converter: (state) => state.state != RecorderState.idle); + final isRecorderMode = ref.watch( + voiceRecorderControllerProvider.select( + (state) => state.state != RecorderState.idle, + ), + ); final link = useMemoized(LayerLink.new); final overlay = Navigator.of(context).overlay ?? Overlay.of(context); final recorderBottomBarEntry = useRef(null); - final voiceRecorderCubit = context.read(); - useEffect(() { recorderBottomBarEntry.value?.remove(); recorderBottomBarEntry.value = null; @@ -205,23 +200,16 @@ class VoiceRecorderBarOverlayComposition extends HookConsumerWidget { return; } final entry = OverlayEntry( - builder: (context) => MultiBlocProvider( - providers: [ - BlocProvider.value( - value: voiceRecorderCubit, - ), - ], - child: _RecordingInterceptor( - child: UnconstrainedBox( - child: CompositedTransformFollower( - link: link, - showWhenUnlinked: false, - targetAnchor: Alignment.bottomCenter, - followerAnchor: Alignment.bottomCenter, - child: SizedBox( - width: layoutWidth, - child: const Material(child: VoiceRecorderBottomBar()), - ), + builder: (context) => _RecordingInterceptor( + child: UnconstrainedBox( + child: CompositedTransformFollower( + link: link, + showWhenUnlinked: false, + targetAnchor: Alignment.bottomCenter, + followerAnchor: Alignment.bottomCenter, + child: SizedBox( + width: layoutWidth, + child: const Material(child: VoiceRecorderBottomBar()), ), ), ), @@ -244,12 +232,11 @@ class _RecordingInterceptor extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isRecording = - useBlocStateConverter< - VoiceRecorderCubit, - VoiceRecorderCubitState, - bool - >(converter: (state) => state.state == RecorderState.recording); + final isRecording = ref.watch( + voiceRecorderControllerProvider.select( + (state) => state.state == RecorderState.recording, + ), + ); return Stack( fit: StackFit.expand, children: [ @@ -260,8 +247,12 @@ class _RecordingInterceptor extends HookConsumerWidget { onTap: () async { _showDiscardRecordingWarningAlertOverlay( context, + theme: ref.read(brightnessThemeDataProvider), + l10n: ref.read(localizationProvider), onDiscard: () { - context.read().cancelAndExitRecordeMode(); + ref + .read(voiceRecorderControllerProvider.notifier) + .cancelAndExitRecordeMode(); }, ); }, @@ -274,6 +265,8 @@ class _RecordingInterceptor extends HookConsumerWidget { void _showDiscardRecordingWarningAlertOverlay( BuildContext context, { + required BrightnessThemeData theme, + required Localization l10n, required VoidCallback onDiscard, }) { final overlay = Overlay.of(context, rootOverlay: true); @@ -300,7 +293,7 @@ void _showDiscardRecordingWarningAlertOverlay( width: 400, child: Material( borderRadius: const BorderRadius.all(Radius.circular(11)), - color: context.theme.popUp, + color: theme.popUp, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 30), child: Column( @@ -308,11 +301,11 @@ void _showDiscardRecordingWarningAlertOverlay( children: [ const SizedBox(height: 40), Text( - context.l10n.discardRecordingWarning, + l10n.discardRecordingWarning, style: TextStyle( fontSize: 16, height: 2, - color: context.theme.text, + color: theme.text, fontWeight: FontWeight.w600, ), ), @@ -323,14 +316,14 @@ void _showDiscardRecordingWarningAlertOverlay( MixinButton( backgroundTransparent: true, onTap: dimiss, - child: Text(context.l10n.cancel), + child: Text(l10n.cancel), ), MixinButton( onTap: () { dimiss(); onDiscard(); }, - child: Text(context.l10n.discard), + child: Text(l10n.discard), ), ], ), @@ -352,24 +345,21 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final startTime = - useBlocStateConverter< - VoiceRecorderCubit, - VoiceRecorderCubitState, - DateTime? - >(converter: (state) => state.startTime); - final isRecording = - useBlocStateConverter< - VoiceRecorderCubit, - VoiceRecorderCubitState, - bool - >(converter: (state) => state.state == RecorderState.recording); - final recordedResult = - useBlocStateConverter< - VoiceRecorderCubit, - VoiceRecorderCubitState, - RecordedData? - >(converter: (state) => state.recodedData); + final theme = ref.watch(brightnessThemeDataProvider); + final voiceRecorderController = ref.read( + voiceRecorderControllerProvider.notifier, + ); + final startTime = ref.watch( + voiceRecorderControllerProvider.select((state) => state.startTime), + ); + final isRecording = ref.watch( + voiceRecorderControllerProvider.select( + (state) => state.state == RecorderState.recording, + ), + ); + final recordedResult = ref.watch( + voiceRecorderControllerProvider.select((state) => state.recodedData), + ); useEffect(() { if (recordedResult == null) { @@ -395,17 +385,15 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { return Container( height: 56, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - color: context.theme.primary, + color: theme.primary, child: Row( children: [ ActionButton( name: Resources.assetsImagesCloseOvalRecordSvg, - color: context.theme.icon, + color: theme.icon, onTap: () async { final path = recordedResult?.path; - await context - .read() - .cancelAndExitRecordeMode(); + await voiceRecorderController.cancelAndExitRecordeMode(); if (path != null) { try { await File(path).delete(); @@ -430,19 +418,16 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { if (isRecording) ActionButton( name: Resources.assetsImagesRecordStopSvg, - color: context.theme.accent, - onTap: () async { - final recorderCubit = context.read(); - await recorderCubit.stopRecording(); - }, + color: theme.accent, + onTap: () async => voiceRecorderController.stopRecording(), ) else ActionButton( name: Resources.assetsImagesRecordRetrySvg, - color: context.theme.icon, + color: theme.icon, onTap: () async { final path = recordedResult?.path; - await context.read().startRecording(); + await voiceRecorderController.startRecording(); if (path != null) { try { await File(path).delete(); @@ -454,25 +439,26 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { ), ActionButton( name: Resources.assetsImagesIcSendSvg, - color: context.theme.icon, + color: theme.icon, onTap: () async { final conversationItem = ref.read(conversationProvider); - final accountServer = context.accountServer; - - final recorderCubit = context.read(); + final accountServer = ref + .read(accountServerProvider) + .requireValue; final RecordedData result; - if (recorderCubit.state.state == RecorderState.recording) { - result = await recorderCubit.stopRecording(); + if (voiceRecorderController.state.state == + RecorderState.recording) { + result = await voiceRecorderController.stopRecording(); } else { if (recordedResult == null) { - e('result is null. ${recorderCubit.state}'); + e('result is null. ${voiceRecorderController.state}'); return; } result = recordedResult; } - await recorderCubit.cancelAndExitRecordeMode(); + await voiceRecorderController.cancelAndExitRecordeMode(); final audioFile = File(result.path); if (!audioFile.existsSync()) { e('audio file does not exist.'); @@ -485,7 +471,7 @@ class VoiceRecorderBottomBar extends HookConsumerWidget { if (conversationItem == null) return; final quoteMessageId = ref.read(quoteMessageIdProvider); - ref.read(quoteMessageProvider.notifier).state = null; + ref.read(quoteMessageProvider.notifier).clear(); await accountServer.sendAudioMessage( audioFile.xFile, result.duration, @@ -548,13 +534,14 @@ class _RecordedResultPreviewLayout extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final player = useMemoized(() => _Player(result.path)); useEffect(() => player.dispose, []); final isPlaying = useValueListenable(player.isPlaying); return SizedBox( height: 32, child: Material( - color: context.theme.listSelected, + color: theme.listSelected, borderRadius: const BorderRadius.all(Radius.circular(15)), child: Row( mainAxisAlignment: MainAxisAlignment.center, @@ -583,8 +570,8 @@ class _RecordedResultPreviewLayout extends HookConsumerWidget { result.duration.inMilliseconds) .clamp(0.0, 1.0), waveform: result.waveform, - backgroundColor: context.theme.waveformBackground, - foregroundColor: context.theme.waveformForeground, + backgroundColor: theme.waveformBackground, + foregroundColor: theme.waveformForeground, maxBarCount: null, alignment: WaveBarAlignment.center, ), @@ -594,7 +581,10 @@ class _RecordedResultPreviewLayout extends HookConsumerWidget { const SizedBox(width: 10), Text( result.duration.asMinutesSecondsWithDas, - style: TextStyle(color: context.theme.text, fontSize: 14), + style: TextStyle( + color: theme.text, + fontSize: 14, + ), ), const SizedBox(width: 12), ], @@ -654,29 +644,35 @@ class _RecordingLayout extends StatelessWidget { ); } -class _RecorderDurationText extends StatelessWidget { +class _RecorderDurationText extends ConsumerWidget { const _RecorderDurationText({required this.duration}); final Duration duration; @override - Widget build(BuildContext context) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox.square( - dimension: 8, - child: DecoratedBox( - decoration: BoxDecoration( - color: Color(0xFFE57874), - borderRadius: BorderRadius.all(Radius.circular(8)), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox.square( + dimension: 8, + child: DecoratedBox( + decoration: BoxDecoration( + color: Color(0xFFE57874), + borderRadius: BorderRadius.all(Radius.circular(8)), + ), ), ), - ), - const SizedBox(width: 4), - Text( - duration.asMinutesSecondsWithDas, - style: TextStyle(color: context.theme.text, fontSize: 14), - ), - ], - ); + const SizedBox(width: 4), + Text( + duration.asMinutesSecondsWithDas, + style: TextStyle( + color: theme.text, + fontSize: 14, + ), + ), + ], + ); + } } diff --git a/lib/ui/home/chat_slide_page/chat_info_page.dart b/lib/ui/home/chat_slide_page/chat_info_page.dart index dcfabadc36..6b674251fa 100644 --- a/lib/ui/home/chat_slide_page/chat_info_page.dart +++ b/lib/ui/home/chat_slide_page/chat_info_page.dart @@ -8,7 +8,6 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import '../../../constants/resources.dart'; import '../../../db/database_event_bus.dart'; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../utils/logger.dart'; import '../../../widgets/action_button.dart'; import '../../../widgets/app_bar.dart'; @@ -20,12 +19,33 @@ import '../../../widgets/more_extended_text.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/user/user_dialog.dart'; import '../../../widgets/user_selector/conversation_selector.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; -import '../bloc/message_bloc.dart'; +import '../../provider/database_provider.dart'; +import '../../provider/ui_context_providers.dart'; import '../chat/chat_bar.dart'; import '../chat/chat_page.dart'; +import '../providers/home_scope_providers.dart'; import 'shared_apps_page.dart'; +final _conversationAnnouncementProvider = StreamProvider.autoDispose + .family((ref, conversationId) { + final database = ref.watch(databaseProvider).value; + if (database == null) { + return Stream.value(null); + } + return database.conversationDao + .announcement(conversationId) + .watchSingleWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchUpdateConversationStream([ + conversationId, + ]), + ], + duration: kVerySlowThrottleDuration, + ); + }); + class ChatInfoPage extends HookConsumerWidget { const ChatInfoPage(this.conversationState, {super.key}); @@ -35,6 +55,8 @@ class ChatInfoPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final createdAt = useMemoized(() { final item = conversationState.conversation; if (item == null) return null; @@ -43,7 +65,8 @@ class ChatInfoPage extends HookConsumerWidget { return item.createdAt; }); - final accountServer = context.accountServer; + final accountServer = ref.read(accountServerProvider).requireValue; + final database = ref.read(databaseProvider).requireValue; final userParticipant = conversationState.participant; @@ -60,19 +83,9 @@ class ChatInfoPage extends HookConsumerWidget { accountServer.refreshUsers([userId], force: true); }, [userId]); - final announcement = useMemoizedStream( - () => context.database.conversationDao - .announcement(conversationId) - .watchSingleWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchUpdateConversationStream([ - conversationId, - ]), - ], - duration: kVerySlowThrottleDuration, - ), - keys: [conversationId], - ).data; + final announcement = ref + .watch(_conversationAnnouncementProvider(conversationId)) + .value; if (!conversationState.isLoaded) return const SizedBox(); final isGroupConversation = conversationState.isGroup ?? false; @@ -94,13 +107,14 @@ class ChatInfoPage extends HookConsumerWidget { if (ModalRoute.of(context)?.canPop != true) ActionButton( name: Resources.assetsImagesIcCloseSvg, - color: context.theme.icon, - onTap: () => context.read().onPopPage(), + color: theme.icon, + onTap: () => + ref.read(chatSideControllerProvider.notifier).onPopPage(), ), ], - backgroundColor: context.theme.popUp, + backgroundColor: theme.popUp, ), - backgroundColor: context.theme.popUp, + backgroundColor: theme.popUp, body: SingleChildScrollView( child: Column( children: [ @@ -149,30 +163,32 @@ class ChatInfoPage extends HookConsumerWidget { if (isGroupConversation && !isExited) CellGroup( child: CellItem( - title: Text(context.l10n.groupParticipants), - onTap: () => context.read().pushPage( - ChatSideCubit.participants, - ), + title: Text(l10n.groupParticipants), + onTap: () => ref + .read(chatSideControllerProvider.notifier) + .pushPage( + ChatSideController.participants, + ), ), ), if (!isGroupConversation) CellGroup( child: CellItem( - title: Text(context.l10n.shareContact), + title: Text(l10n.shareContact), onTap: () async { final result = await showConversationSelector( context: context, singleSelect: true, - title: context.l10n.shareContact, + title: l10n.shareContact, onlyContact: false, action: CustomPopupMenuButton( alignment: Alignment.bottomCenter, - color: context.theme.icon, + color: theme.icon, icon: Resources.assetsImagesInviteShareSvg, itemBuilder: (context) => [ CustomPopupMenuItem( icon: Resources.assetsImagesContextMenuCopySvg, - title: context.l10n.copyLink, + title: l10n.copyLink, value: null, ), ], @@ -185,7 +201,7 @@ class ChatInfoPage extends HookConsumerWidget { return; } - final user = await context.database.userDao + final user = await database.userDao .userById(userId) .getSingleOrNull(); @@ -226,18 +242,25 @@ class ChatInfoPage extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ CellItem( - title: Text(context.l10n.sharedMedia), - onTap: () => context.read().pushPage( - ChatSideCubit.sharedMedia, - ), + title: Text(l10n.sharedMedia), + onTap: () => ref + .read(chatSideControllerProvider.notifier) + .pushPage( + ChatSideController.sharedMedia, + ), ), if (conversationState.userId != null) _SharedApps(userId: conversationState.userId!), CellItem( - title: Text(context.l10n.searchConversation, maxLines: 1), - onTap: () => context.read().pushPage( - ChatSideCubit.searchMessageHistory, + title: Text( + l10n.searchConversation, + maxLines: 1, ), + onTap: () => ref + .read(chatSideControllerProvider.notifier) + .pushPage( + ChatSideController.searchMessageHistory, + ), ), ], ), @@ -245,22 +268,24 @@ class ChatInfoPage extends HookConsumerWidget { if (!(isGroupConversation && isExited)) CellGroup( child: CellItem( - title: Text(context.l10n.disappearingMessage), + title: Text(l10n.disappearingMessage), description: Text( expireIn.formatAsConversationExpireIn( - localization: context.l10n, + localization: l10n, ), style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), trailing: canModifyExpireIn ? const Arrow() : null, onTap: !canModifyExpireIn ? null - : () => context.read().pushPage( - ChatSideCubit.disappearMessages, - ), + : () => ref + .read(chatSideControllerProvider.notifier) + .pushPage( + ChatSideController.disappearMessages, + ), ), ), if (isGroupConversation && isOwnerOrAdmin) @@ -271,8 +296,8 @@ class ChatInfoPage extends HookConsumerWidget { Builder( builder: (context) { final announcementTitle = announcement?.isEmpty ?? true - ? context.l10n.addGroupDescription - : context.l10n.editGroupDescription; + ? l10n.addGroupDescription + : l10n.editGroupDescription; return CellItem( title: Text(announcementTitle), onTap: () async { @@ -288,7 +313,7 @@ class ChatInfoPage extends HookConsumerWidget { if (result == null) return; await runFutureWithToast( - context.accountServer.editGroup( + accountServer.editGroup( conversationId, announcement: result, ), @@ -306,7 +331,7 @@ class ChatInfoPage extends HookConsumerWidget { if (!(isGroupConversation && isExited)) CellItem( title: Text( - muting ? context.l10n.unmute : context.l10n.mute, + muting ? l10n.unmute : l10n.mute, ), description: muting ? Text( @@ -315,7 +340,7 @@ class ChatInfoPage extends HookConsumerWidget { .toLocal(), ), style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ) @@ -324,7 +349,7 @@ class ChatInfoPage extends HookConsumerWidget { onTap: () async { if (muting) { await runFutureWithToast( - context.accountServer.unMuteConversation( + accountServer.unMuteConversation( conversationId: isGroupConversation ? conversationId : null, @@ -343,7 +368,7 @@ class ChatInfoPage extends HookConsumerWidget { if (result == null) return; await runFutureWithToast( - context.accountServer.muteConversation( + accountServer.muteConversation( result, conversationId: isGroupConversation ? conversationId @@ -358,15 +383,15 @@ class ChatInfoPage extends HookConsumerWidget { if (!isGroupConversation || (isGroupConversation && isOwnerOrAdmin)) CellItem( - title: Text(context.l10n.editName), + title: Text(l10n.editName), trailing: null, onTap: () async { final name = await showMixinDialog( context: context, child: EditDialog( editText: conversationState.name ?? '', - title: Text(context.l10n.editName), - positiveAction: context.l10n.change, + title: Text(l10n.editName), + positiveAction: l10n.change, maxLength: 40, ), ); @@ -391,29 +416,34 @@ class ChatInfoPage extends HookConsumerWidget { if (!isGroupConversation) CellGroup( child: CellItem( - title: Text(context.l10n.groupsInCommon), - onTap: () => context.read().pushPage( - ChatSideCubit.groupsInCommon, - ), + title: Text(l10n.groupsInCommon), + onTap: () => ref + .read(chatSideControllerProvider.notifier) + .pushPage( + ChatSideController.groupsInCommon, + ), ), ), if (conversationState.app?.creatorId != null) CellGroup( child: CellItem( - title: Text(context.l10n.developer), + title: Text(l10n.developer), trailing: null, onTap: () => showUserDialog( context, + ref.container, conversationState.app?.creatorId, ), ), ), CellGroup( child: CellItem( - title: Text(context.l10n.editConversations), - onTap: () => context.read().pushPage( - ChatSideCubit.circles, - ), + title: Text(l10n.editConversations), + onTap: () => ref + .read(chatSideControllerProvider.notifier) + .pushPage( + ChatSideController.circles, + ), ), ), CellGroup( @@ -423,13 +453,13 @@ class ChatInfoPage extends HookConsumerWidget { if (conversationState.relationship == UserRelationship.blocking) CellItem( - title: Text(context.l10n.unblock), - color: context.theme.red, + title: Text(l10n.unblock), + color: theme.red, trailing: null, onTap: () async { final result = await showConfirmMixinDialog( context, - context.l10n.unblock, + l10n.unblock, ); if (result == null) return; await runFutureWithToast( @@ -441,11 +471,11 @@ class ChatInfoPage extends HookConsumerWidget { Builder( builder: (context) { final title = conversationState.isBot - ? context.l10n.removeBot - : context.l10n.removeContact; + ? l10n.removeBot + : l10n.removeContact; return CellItem( title: Text(title), - color: context.theme.red, + color: theme.red, trailing: null, onTap: () async { final result = await showConfirmMixinDialog( @@ -464,13 +494,13 @@ class ChatInfoPage extends HookConsumerWidget { ), if (conversationState.isStranger!) CellItem( - title: Text(context.l10n.block), - color: context.theme.red, + title: Text(l10n.block), + color: theme.red, trailing: null, onTap: () async { final result = await showConfirmMixinDialog( context, - context.l10n.block, + l10n.block, ); if (result == null) return; await runFutureWithToast( @@ -479,31 +509,31 @@ class ChatInfoPage extends HookConsumerWidget { }, ), CellItem( - title: Text(context.l10n.clearChat), - color: context.theme.red, + title: Text(l10n.clearChat), + color: theme.red, trailing: null, onTap: () async { final result = await showConfirmMixinDialog( context, - context.l10n.clearChat, + l10n.clearChat, ); if (result == null) return; await accountServer.deleteMessagesByConversationId( conversationId, ); - context.read().reload(); + ref.read(messageControllerProvider.notifier).reload(); }, ), if (isGroupConversation) if (!isExited) CellItem( - title: Text(context.l10n.exitGroup), - color: context.theme.red, + title: Text(l10n.exitGroup), + color: theme.red, trailing: null, onTap: () async { final result = await showConfirmMixinDialog( context, - context.l10n.exitGroup, + l10n.exitGroup, ); if (result == null) return; await runFutureWithToast( @@ -511,6 +541,7 @@ class ChatInfoPage extends HookConsumerWidget { ); await ConversationStateNotifier.selectConversation( + ref.container, context, conversationId, ); @@ -518,20 +549,21 @@ class ChatInfoPage extends HookConsumerWidget { ) else CellItem( - title: Text(context.l10n.deleteGroup), - color: context.theme.red, + title: Text(l10n.deleteGroup), + color: theme.red, trailing: null, onTap: () async { final result = await showConfirmMixinDialog( context, - context.l10n.deleteGroup, + l10n.deleteGroup, ); if (result == null) return; await accountServer.deleteMessagesByConversationId( conversationId, ); - await context.database.conversationDao - .deleteConversation(conversationId); + await accountServer.deleteConversation( + conversationId, + ); ref.read(conversationProvider.notifier).unselected(); }, ), @@ -541,13 +573,13 @@ class ChatInfoPage extends HookConsumerWidget { if (!isGroupConversation) CellGroup( child: CellItem( - title: Text(context.l10n.report), - color: context.theme.red, + title: Text(l10n.report), + color: theme.red, trailing: null, onTap: () async { final result = await showConfirmMixinDialog( context, - context.l10n.reportAndBlock, + l10n.reportAndBlock, ); if (result == null) return; final userId = conversationState.userId; @@ -561,9 +593,9 @@ class ChatInfoPage extends HookConsumerWidget { Padding( padding: const EdgeInsets.symmetric(vertical: 16), child: Text( - context.l10n.createdAt(DateFormat.yMMMd().format(createdAt)), + l10n.createdAt(DateFormat.yMMMd().format(createdAt)), style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 12, ), ), @@ -591,8 +623,9 @@ class ConversationBio extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final textStream = useMemoized(() { - final database = context.database; + final database = ref.read(databaseProvider).requireValue; if (isGroup) { return database.conversationDao .announcement(conversationId) @@ -620,7 +653,10 @@ class ConversationBio extends HookConsumerWidget { return MoreExtendedText( text, - style: TextStyle(color: context.theme.text, fontSize: fontSize), + style: TextStyle( + color: theme.text, + fontSize: fontSize, + ), ); } } @@ -628,57 +664,65 @@ class ConversationBio extends HookConsumerWidget { /// Button to add strange to contacts. /// /// if conversation is not stranger, show nothing. -class _AddToContactsButton extends StatelessWidget { +class _AddToContactsButton extends ConsumerWidget { _AddToContactsButton(this.conversation) : assert(conversation.isLoaded); final ConversationState conversation; @override - Widget build(BuildContext context) => AnimatedSize( - duration: const Duration(milliseconds: 200), - curve: Curves.easeInOut, - child: conversation.isStranger! - ? Padding( - padding: const EdgeInsets.only(top: 12), - child: TextButton( - style: TextButton.styleFrom( - backgroundColor: context.theme.statusBackground, - padding: const EdgeInsets.symmetric( - horizontal: 15, - vertical: 7, - ), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(15)), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + final accountServer = ref.read(accountServerProvider).requireValue; + return AnimatedSize( + duration: const Duration(milliseconds: 200), + curve: Curves.easeInOut, + child: conversation.isStranger! + ? Padding( + padding: const EdgeInsets.only(top: 12), + child: TextButton( + style: TextButton.styleFrom( + backgroundColor: theme.statusBackground, + padding: const EdgeInsets.symmetric( + horizontal: 15, + vertical: 7, + ), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(15)), + ), ), - ), - onPressed: () { - final username = - conversation.user?.fullName ?? - conversation.conversation?.validName; - assert( - username != null, - 'ContactsAdd: username should not be null.', - ); - assert( - conversation.isGroup != true, - 'ContactsAdd conversation should not be a group.', - ); - runFutureWithToast( - context.accountServer.addUser( - conversation.userId!, - username, + onPressed: () { + final username = + conversation.user?.fullName ?? + conversation.conversation?.validName; + assert( + username != null, + 'ContactsAdd: username should not be null.', + ); + assert( + conversation.isGroup != true, + 'ContactsAdd conversation should not be a group.', + ); + runFutureWithToast( + accountServer.addUser( + conversation.userId!, + username, + ), + ); + }, + child: Text( + conversation.isBot + ? l10n.addBotWithPlus + : l10n.addContactWithPlus, + style: TextStyle( + fontSize: 12, + color: theme.accent, ), - ); - }, - child: Text( - conversation.isBot - ? context.l10n.addBotWithPlus - : context.l10n.addContactWithPlus, - style: TextStyle(fontSize: 12, color: context.theme.accent), + ), ), - ), - ) - : const SizedBox(height: 0), - ); + ) + : const SizedBox(height: 0), + ); + } } class _SharedApps extends HookConsumerWidget { @@ -688,32 +732,24 @@ class _SharedApps extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); useMemoized(() { - context.accountServer.loadFavoriteApps(userId); + ref.read(accountServerProvider).requireValue.loadFavoriteApps(userId); }, [userId]); - - final apps = useMemoizedStream( - () => context.database.favoriteAppDao - .getFavoriteAppsByUserId(userId) - .watchWithStream( - eventStreams: [DataBaseEventBus.instance.updateAppIdStream], - duration: kVerySlowThrottleDuration, - ), - keys: [userId], - ); - - final data = apps.data ?? const []; + final data = ref.watch(sharedAppsProvider(userId)).value ?? const []; return AnimatedSize( duration: const Duration(milliseconds: 200), curve: Curves.easeInOut, child: data.isEmpty ? const SizedBox() : CellItem( - title: Text(context.l10n.shareApps), + title: Text(l10n.shareApps), trailing: OverlappedAppIcons(apps: data), - onTap: () => context.read().pushPage( - ChatSideCubit.sharedApps, - ), + onTap: () => ref + .read(chatSideControllerProvider.notifier) + .pushPage( + ChatSideController.sharedApps, + ), ), ); } diff --git a/lib/ui/home/chat_slide_page/circle_manager_page.dart b/lib/ui/home/chat_slide_page/circle_manager_page.dart index afc5c50445..754ca606ea 100644 --- a/lib/ui/home/chat_slide_page/circle_manager_page.dart +++ b/lib/ui/home/chat_slide_page/circle_manager_page.dart @@ -13,7 +13,10 @@ import '../../../widgets/action_button.dart'; import '../../../widgets/app_bar.dart'; import '../../../widgets/dialog.dart'; import '../../../widgets/toast.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/database_provider.dart'; +import '../../provider/ui_context_providers.dart'; class CircleManagerPage extends HookConsumerWidget { const CircleManagerPage(this.conversationState, {super.key}); @@ -24,9 +27,13 @@ class CircleManagerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final database = ref.watch(databaseProvider).requireValue; + final accountServer = ref.read(accountServerProvider).requireValue; final circles = useMemoizedStream>( - () => context.database.circleDao + () => database.circleDao .circleByConversationId(conversationId) .watchWithStream( eventStreams: [ @@ -40,7 +47,7 @@ class CircleManagerPage extends HookConsumerWidget { []; final otherCircles = useMemoizedStream>( - () => context.database.circleDao + () => database.circleDao .otherCircleByConversationId(conversationId) .watchWithStream( eventStreams: [ @@ -54,9 +61,9 @@ class CircleManagerPage extends HookConsumerWidget { []; return Scaffold( - backgroundColor: context.theme.background, + backgroundColor: theme.background, appBar: MixinAppBar( - title: Text(context.l10n.circles), + title: Text(l10n.circles), actions: [ ActionButton( name: Resources.assetsImagesIcAddSvg, @@ -72,13 +79,13 @@ class CircleManagerPage extends HookConsumerWidget { final name = await showMixinDialog( context: context, child: EditDialog( - title: Text(context.l10n.circles), - hintText: context.l10n.editCircleName, + title: Text(l10n.circles), + hintText: l10n.editCircleName, ), ); await runFutureWithToast( - context.accountServer.createCircle(name!, [ + accountServer.createCircle(name!, [ CircleConversationRequest( action: CircleConversationAction.add, conversationId: conversationId, @@ -132,94 +139,101 @@ class _CircleManagerItem extends HookConsumerWidget { final bool selected; @override - Widget build(BuildContext context, WidgetRef ref) => Container( - height: 80, - color: context.theme.primary, - child: Row( - children: [ - GestureDetector( - onTap: () async { - final (conversationId, userId) = ref.read( - conversationProvider.select( - (value) => (value?.conversationId, value?.userId), - ), - ); + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; + return Container( + height: 80, + color: theme.primary, + child: Row( + children: [ + GestureDetector( + onTap: () async { + final (conversationId, userId) = ref.read( + conversationProvider.select( + (value) => (value?.conversationId, value?.userId), + ), + ); - if (conversationId == null || userId == null) return; + if (conversationId == null || userId == null) return; + + if (selected) { + await runFutureWithToast( + accountServer.circleRemoveConversation( + circleId, + conversationId, + ), + ); + return; + } - if (selected) { await runFutureWithToast( - context.accountServer.circleRemoveConversation( - circleId, - conversationId, - ), + accountServer.editCircleConversation(circleId, [ + CircleConversationRequest( + action: CircleConversationAction.add, + conversationId: conversationId, + userId: userId, + ), + ]), ); - return; - } - - await runFutureWithToast( - context.accountServer.editCircleConversation(circleId, [ - CircleConversationRequest( - action: CircleConversationAction.add, - conversationId: conversationId, - userId: userId, - ), - ]), - ); - }, - child: Container( - height: 80, - padding: const EdgeInsets.symmetric(horizontal: 16), - child: SvgPicture.asset( - selected - ? Resources.assetsImagesCircleRemoveSvg - : Resources.assetsImagesCircleAddSvg, - height: 16, - width: 16, + }, + child: Container( + height: 80, + padding: const EdgeInsets.symmetric(horizontal: 16), + child: SvgPicture.asset( + selected + ? Resources.assetsImagesCircleRemoveSvg + : Resources.assetsImagesCircleAddSvg, + height: 16, + width: 16, + ), ), ), - ), - const SizedBox(width: 4), - ClipOval( - child: Container( - color: context.dynamicColor( - const Color.fromRGBO(246, 247, 250, 1), - darkColor: const Color.fromRGBO(245, 247, 250, 1), - ), - height: 50, - width: 50, - alignment: Alignment.center, - child: SvgPicture.asset( - Resources.assetsImagesCircleSvg, - width: 24, - height: 24, - colorFilter: ColorFilter.mode( - getCircleColorById(circleId), - BlendMode.srcIn, + const SizedBox(width: 4), + ClipOval( + child: Container( + color: ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(246, 247, 250, 1), + darkColor: const Color.fromRGBO(245, 247, 250, 1), + )), + ), + height: 50, + width: 50, + alignment: Alignment.center, + child: SvgPicture.asset( + Resources.assetsImagesCircleSvg, + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + getCircleColorById(circleId), + BlendMode.srcIn, + ), ), ), ), - ), - const SizedBox(width: 8), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - name, - style: TextStyle(color: context.theme.text, fontSize: 16), - ), - const SizedBox(height: 6), - Text( - context.l10n.circleSubtitle(count, count), - style: TextStyle( - color: context.theme.secondaryText, - fontSize: 14, + const SizedBox(width: 8), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + name, + style: TextStyle(color: theme.text, fontSize: 16), ), - ), - ], - ), - ], - ), - ); + const SizedBox(height: 6), + Text( + l10n.circleSubtitle(count, count), + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), + ), + ], + ), + ], + ), + ); + } } diff --git a/lib/ui/home/chat_slide_page/disappear_message_page.dart b/lib/ui/home/chat_slide_page/disappear_message_page.dart index ecf87eff2d..f0cceba42d 100644 --- a/lib/ui/home/chat_slide_page/disappear_message_page.dart +++ b/lib/ui/home/chat_slide_page/disappear_message_page.dart @@ -5,6 +5,7 @@ import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; +import '../../../account/account_server.dart'; import '../../../constants/constants.dart'; import '../../../constants/resources.dart'; import '../../../utils/extension/extension.dart'; @@ -17,125 +18,141 @@ import '../../../widgets/dialog.dart'; import '../../../widgets/high_light_text.dart'; import '../../../widgets/menu.dart'; import '../../../widgets/toast.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/ui_context_providers.dart'; -class DisappearMessagePage extends StatelessWidget { +class DisappearMessagePage extends ConsumerWidget { const DisappearMessagePage(this.conversationState, {super.key}); final ConversationState conversationState; @override - Widget build(BuildContext context) => Scaffold( - backgroundColor: context.theme.primary, - appBar: MixinAppBar(title: Text(context.l10n.disappearingMessage)), - body: SingleChildScrollView( - child: Column( - children: [ - const SizedBox(height: 30), - SvgPicture.asset( - Resources.assetsImagesDisappearingMessageSvg, - width: 70, - height: 70, - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: HighlightStarLinkText( - context.l10n.disappearingMessageHint, - style: TextStyle( - color: context.theme.secondaryText, - height: 1.5, - fontSize: 14, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + return Scaffold( + backgroundColor: theme.primary, + appBar: MixinAppBar( + title: Text(l10n.disappearingMessage), + ), + body: SingleChildScrollView( + child: Column( + children: [ + const SizedBox(height: 30), + SvgPicture.asset( + Resources.assetsImagesDisappearingMessageSvg, + width: 70, + height: 70, + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: HighlightStarLinkText( + l10n.disappearingMessageHint, + style: TextStyle( + color: theme.secondaryText, + height: 1.5, + fontSize: 14, + ), + highlightStyle: TextStyle( + color: theme.accent, + ), + links: const [mixinDisappearingMessageHelpUrl], + onLinkClick: (url) => + openUri(context, url, container: ref.container), ), - highlightStyle: TextStyle(color: context.theme.accent), - links: const [mixinDisappearingMessageHelpUrl], - onLinkClick: (url) => openUri(context, url), ), - ), - const SizedBox(height: 40), - _Options( - conversationId: conversationState.conversationId, - expireDuration: - conversationState.conversation?.expireDuration ?? Duration.zero, - ), - ], + const SizedBox(height: 40), + _Options( + conversationId: conversationState.conversationId, + expireDuration: + conversationState.conversation?.expireDuration ?? + Duration.zero, + ), + ], + ), ), - ), - ); + ); + } } -class _Options extends StatelessWidget { +class _Options extends ConsumerWidget { const _Options({required this.conversationId, required this.expireDuration}); final String conversationId; final Duration expireDuration; @override - Widget build(BuildContext context) => CellGroup( - child: Column( - children: [ - CellItem( - title: Text(context.l10n.close), - trailing: expireDuration < const Duration(seconds: 1) - ? SvgPicture.asset( - Resources.assetsImagesCheckedSvg, - width: 24, - height: 24, - ) - : null, - onTap: () { - if (expireDuration < const Duration(seconds: 1)) return; + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + return CellGroup( + child: Column( + children: [ + CellItem( + title: Text(l10n.close), + trailing: expireDuration < const Duration(seconds: 1) + ? SvgPicture.asset( + Resources.assetsImagesCheckedSvg, + width: 24, + height: 24, + ) + : null, + onTap: () { + if (expireDuration < const Duration(seconds: 1)) return; - _updateConversationExpireDuration( - context, - duration: Duration.zero, - conversationId: conversationId, - ); - }, - ), - _DurationOptionItem( - conversationId: conversationId, - label: '30 ${context.l10n.unitSecond(30)}', - duration: const Duration(seconds: 30), - current: expireDuration, - ), - _DurationOptionItem( - conversationId: conversationId, - label: '10 ${context.l10n.unitMinute(10)}', - duration: const Duration(minutes: 10), - current: expireDuration, - ), - _DurationOptionItem( - conversationId: conversationId, - label: '2 ${context.l10n.unitHour(2)}', - duration: const Duration(hours: 2), - current: expireDuration, - ), - _DurationOptionItem( - conversationId: conversationId, - label: '1 ${context.l10n.unitDay(1)}', - duration: const Duration(days: 1), - current: expireDuration, - ), - _DurationOptionItem( - conversationId: conversationId, - label: '1 ${context.l10n.unitWeek(1)}', - duration: const Duration(days: 7), - current: expireDuration, - ), - CellItem( - title: Text(context.l10n.customTime), - onTap: () => showMixinDialog( - context: context, - child: _CustomExpireTimeDialog(conversationId: conversationId), + _updateConversationExpireDuration( + context, + duration: Duration.zero, + conversationId: conversationId, + accountServer: ref.read(accountServerProvider).requireValue, + ); + }, ), - ), - ], - ), - ); + _DurationOptionItem( + conversationId: conversationId, + label: '30 ${l10n.unitSecond(30)}', + duration: const Duration(seconds: 30), + current: expireDuration, + ), + _DurationOptionItem( + conversationId: conversationId, + label: '10 ${l10n.unitMinute(10)}', + duration: const Duration(minutes: 10), + current: expireDuration, + ), + _DurationOptionItem( + conversationId: conversationId, + label: '2 ${l10n.unitHour(2)}', + duration: const Duration(hours: 2), + current: expireDuration, + ), + _DurationOptionItem( + conversationId: conversationId, + label: '1 ${l10n.unitDay(1)}', + duration: const Duration(days: 1), + current: expireDuration, + ), + _DurationOptionItem( + conversationId: conversationId, + label: '1 ${l10n.unitWeek(1)}', + duration: const Duration(days: 7), + current: expireDuration, + ), + CellItem( + title: Text(l10n.customTime), + onTap: () => showMixinDialog( + context: context, + child: _CustomExpireTimeDialog(conversationId: conversationId), + ), + ), + ], + ), + ); + } } -class _DurationOptionItem extends StatelessWidget { +class _DurationOptionItem extends ConsumerWidget { const _DurationOptionItem({ required this.duration, required this.current, @@ -149,7 +166,7 @@ class _DurationOptionItem extends StatelessWidget { final String conversationId; @override - Widget build(BuildContext context) => CellItem( + Widget build(BuildContext context, WidgetRef ref) => CellItem( title: Text(label), trailing: duration == current ? SvgPicture.asset( @@ -166,6 +183,7 @@ class _DurationOptionItem extends StatelessWidget { context, duration: duration, conversationId: conversationId, + accountServer: ref.read(accountServerProvider).requireValue, ); }, ); @@ -176,17 +194,15 @@ Future _updateConversationExpireDuration( BuildContext context, { required Duration duration, required String conversationId, + required AccountServer accountServer, }) async { - final api = context.accountServer.client.conversationApi; + final api = accountServer.client.conversationApi; try { final response = await api.disappear( conversationId, DisappearRequest(duration: duration.inSeconds), ); - await context.database.conversationDao.updateConversation( - response.data, - context.accountServer.userId, - ); + await accountServer.updateConversationFromResponse(response.data); } catch (error, stackTrace) { e('update conversation expire duration failed $error $stackTrace'); showToastFailed(error); @@ -234,6 +250,8 @@ class _CustomExpireTimeDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final inputController = useTextEditingController(); final unit = useState(_CustomExpireTimeUnit.second); return SizedBox( @@ -243,7 +261,7 @@ class _CustomExpireTimeDialog extends HookConsumerWidget { children: [ MixinAppBar( leading: const SizedBox(), - title: Text(context.l10n.customTime), + title: Text(l10n.customTime), actions: const [MixinCloseButton()], ), const SizedBox(height: 16), @@ -258,7 +276,10 @@ class _CustomExpireTimeDialog extends HookConsumerWidget { inputFormatters: [FilteringTextInputFormatter.digitsOnly], textAlign: TextAlign.center, maxLength: 2, - style: TextStyle(color: context.theme.text, fontSize: 16), + style: TextStyle( + color: theme.text, + fontSize: 16, + ), buildCounter: ( context, { @@ -267,11 +288,11 @@ class _CustomExpireTimeDialog extends HookConsumerWidget { required isFocused, }) => null, decoration: InputDecoration( - fillColor: context.theme.sidebarSelected, + fillColor: theme.sidebarSelected, filled: true, hintStyle: TextStyle( fontSize: 16, - color: context.theme.secondaryText, + color: theme.secondaryText, ), border: const OutlineInputBorder( borderRadius: BorderRadius.all(Radius.circular(8)), @@ -296,7 +317,7 @@ class _CustomExpireTimeDialog extends HookConsumerWidget { ), const SizedBox(height: 28), MixinButton( - child: Text(context.l10n.set), + child: Text(l10n.set), onTap: () async { if (inputController.text.isEmpty) { return; @@ -312,11 +333,11 @@ class _CustomExpireTimeDialog extends HookConsumerWidget { if (value > unit.value.maxValue) { showToastFailed( ToastError( - context.l10n.disappearingCustomTimeMaxWarning( + l10n.disappearingCustomTimeMaxWarning( unit.value .toDuration(unit.value.maxValue) .formatAsConversationExpireIn( - localization: context.l10n, + localization: l10n, ), ), ), @@ -328,6 +349,7 @@ class _CustomExpireTimeDialog extends HookConsumerWidget { context, duration: duration, conversationId: conversationId, + accountServer: ref.read(accountServerProvider).requireValue, ); Toast.dismiss(); await Navigator.of(context).maybePop(); @@ -340,53 +362,55 @@ class _CustomExpireTimeDialog extends HookConsumerWidget { } } -class _CustomExpireUnitSelection extends StatelessWidget { +class _CustomExpireUnitSelection extends ConsumerWidget { const _CustomExpireUnitSelection({required this.unit}); final ValueNotifier<_CustomExpireTimeUnit> unit; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final String text; switch (unit.value) { case _CustomExpireTimeUnit.second: - text = context.l10n.unitSecond(1); + text = l10n.unitSecond(1); case _CustomExpireTimeUnit.minute: - text = context.l10n.unitMinute(1); + text = l10n.unitMinute(1); case _CustomExpireTimeUnit.hour: - text = context.l10n.unitHour(1); + text = l10n.unitHour(1); case _CustomExpireTimeUnit.day: - text = context.l10n.unitDay(1); + text = l10n.unitDay(1); case _CustomExpireTimeUnit.week: - text = context.l10n.unitWeek(1); + text = l10n.unitWeek(1); } return CustomPopupMenuButton( itemBuilder: (context) => [ CustomPopupMenuItem( - title: context.l10n.unitSecond(1), + title: l10n.unitSecond(1), value: _CustomExpireTimeUnit.second, ), CustomPopupMenuItem( - title: context.l10n.unitMinute(1), + title: l10n.unitMinute(1), value: _CustomExpireTimeUnit.minute, ), CustomPopupMenuItem( - title: context.l10n.unitHour(1), + title: l10n.unitHour(1), value: _CustomExpireTimeUnit.hour, ), CustomPopupMenuItem( - title: context.l10n.unitDay(1), + title: l10n.unitDay(1), value: _CustomExpireTimeUnit.day, ), CustomPopupMenuItem( - title: context.l10n.unitWeek(1), + title: l10n.unitWeek(1), value: _CustomExpireTimeUnit.week, ), ], onSelected: (value) => unit.value = value, child: Builder( builder: (context) => Material( - color: context.theme.sidebarSelected, + color: theme.sidebarSelected, borderRadius: const BorderRadius.all(Radius.circular(8)), child: SizedBox( height: 46, @@ -397,7 +421,7 @@ class _CustomExpireUnitSelection extends StatelessWidget { text, style: TextStyle( fontSize: 16, - color: context.theme.text, + color: theme.text, ), textAlign: TextAlign.center, ), @@ -409,7 +433,7 @@ class _CustomExpireUnitSelection extends StatelessWidget { width: 30, height: 30, colorFilter: ColorFilter.mode( - context.theme.secondaryText, + theme.secondaryText, BlendMode.srcIn, ), ), diff --git a/lib/ui/home/chat_slide_page/group_invite/group_invite_dialog.dart b/lib/ui/home/chat_slide_page/group_invite/group_invite_dialog.dart index 240f468dbd..9b44118664 100644 --- a/lib/ui/home/chat_slide_page/group_invite/group_invite_dialog.dart +++ b/lib/ui/home/chat_slide_page/group_invite/group_invite_dialog.dart @@ -7,7 +7,6 @@ import '../../../../constants/resources.dart'; import '../../../../db/database_event_bus.dart'; import '../../../../db/mixin_database.dart'; import '../../../../utils/extension/extension.dart'; -import '../../../../utils/hook.dart'; import '../../../../widgets/avatar_view/avatar_view.dart'; import '../../../../widgets/buttons.dart'; import '../../../../widgets/dialog.dart'; @@ -15,6 +14,31 @@ import '../../../../widgets/high_light_text.dart'; import '../../../../widgets/interactive_decorated_box.dart'; import '../../../../widgets/toast.dart'; import '../../../../widgets/user_selector/conversation_selector.dart'; +import '../../../provider/account_server_provider.dart'; +import '../../../provider/database_provider.dart'; +import '../../../provider/ui_context_providers.dart'; + +final _groupInviteConversationProvider = StreamProvider.autoDispose + .family((ref, conversationId) { + final database = ref.watch(databaseProvider).value; + if (database == null) { + return Stream.value(null); + } + ref + .read(accountServerProvider) + .value + ?.refreshConversation(conversationId); + return database.conversationDao + .conversationById(conversationId) + .watchSingleOrNullWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchUpdateConversationStream([ + conversationId, + ]), + ], + duration: kDefaultThrottleDuration, + ); + }); Future showGroupInviteByLinkDialog( BuildContext context, { @@ -33,21 +57,13 @@ class _GroupInviteByLinkDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final conversation = useMemoizedStream(() { - context.accountServer.refreshConversation(conversationId); - return context.database.conversationDao - .conversationById(conversationId) - .watchSingleOrNullWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchUpdateConversationStream([ - conversationId, - ]), - ], - duration: kDefaultThrottleDuration, - ); - }, keys: [conversationId]).data; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final conversation = ref + .watch(_groupInviteConversationProvider(conversationId)) + .value; return Material( - color: context.theme.popUp, + color: theme.popUp, child: SizedBox( width: 480, height: 600, @@ -61,10 +77,10 @@ class _GroupInviteByLinkDialog extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.only(top: 30), child: Text( - context.l10n.inviteToGroupViaLink, + l10n.inviteToGroupViaLink, style: TextStyle( fontSize: 18, - color: context.theme.text, + color: theme.text, fontWeight: FontWeight.w600, ), ), @@ -84,121 +100,129 @@ class _GroupInviteByLinkDialog extends HookConsumerWidget { } } -class _GroupInviteBody extends StatelessWidget { +class _GroupInviteBody extends ConsumerWidget { const _GroupInviteBody({required this.conversation}); final Conversation conversation; @override - Widget build(BuildContext context) => Column( - children: [ - const SizedBox(height: 120), - ConversationAvatarWidget( - size: 90, - conversationId: conversation.conversationId, - fullName: conversation.name, - groupIconUrl: conversation.iconUrl, - category: conversation.category, - userId: conversation.ownerId, - ), - const SizedBox(height: 16), - Text( - conversation.name ?? '', - style: TextStyle( - fontSize: 18, - color: context.theme.text, - fontWeight: FontWeight.w600, - height: 1.5, + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return Column( + children: [ + const SizedBox(height: 120), + ConversationAvatarWidget( + size: 90, + conversationId: conversation.conversationId, + fullName: conversation.name, + groupIconUrl: conversation.iconUrl, + category: conversation.category, + userId: conversation.ownerId, ), - ), - const SizedBox(height: 12), - SizedBox( - width: 320, - child: CustomSelectableText( - conversation.codeUrl ?? '', + const SizedBox(height: 16), + Text( + conversation.name ?? '', style: TextStyle( - fontSize: 14, - color: context.theme.text, - fontWeight: FontWeight.w400, + fontSize: 18, + color: theme.text, + fontWeight: FontWeight.w600, height: 1.5, ), - textAlign: TextAlign.center, ), - ), - const SizedBox(height: 8), - SizedBox( - width: 338, - child: Text( - context.l10n.inviteInfo, - style: TextStyle( - fontSize: 12, - color: context.theme.secondaryText, - height: 1.5, + const SizedBox(height: 12), + SizedBox( + width: 320, + child: CustomSelectableText( + conversation.codeUrl ?? '', + style: TextStyle( + fontSize: 14, + color: theme.text, + fontWeight: FontWeight.w400, + height: 1.5, + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), - ), - const SizedBox(height: 61), - _ActionButtons(conversation: conversation), - ], - ); + const SizedBox(height: 8), + SizedBox( + width: 338, + child: Text( + l10n.inviteInfo, + style: TextStyle( + fontSize: 12, + color: theme.secondaryText, + height: 1.5, + ), + textAlign: TextAlign.center, + ), + ), + const SizedBox(height: 61), + _ActionButtons(conversation: conversation), + ], + ); + } } -class _ActionButtons extends StatelessWidget { +class _ActionButtons extends ConsumerWidget { const _ActionButtons({required this.conversation}); final Conversation conversation; @override - Widget build(BuildContext context) => Row( - mainAxisAlignment: MainAxisAlignment.spaceEvenly, - children: [ - _IconButton( - label: context.l10n.shareLink, - iconAssetName: Resources.assetsImagesInviteShareSvg, - onTap: () async { - assert(conversation.codeUrl != null); - final result = await showConversationSelector( - context: context, - singleSelect: true, - title: context.l10n.forward, - onlyContact: false, - ); - if (result == null || result.isEmpty) return; - await runFutureWithToast( - context.accountServer.sendTextMessage( - conversation.codeUrl!, - result.first.encryptCategory!, - conversationId: result.first.conversationId, - recipientId: result.first.userId, - ), - ); - }, - ), - if (conversation.codeUrl != null) + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final accountServer = ref.read(accountServerProvider).requireValue; + return Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ _IconButton( - label: context.l10n.copyInvite, - iconAssetName: Resources.assetsImagesInviteCopySvg, + label: l10n.shareLink, + iconAssetName: Resources.assetsImagesInviteShareSvg, onTap: () async { - final codeUrl = conversation.codeUrl; - await Clipboard.setData(ClipboardData(text: codeUrl!)); - showToastSuccessful(); + assert(conversation.codeUrl != null); + final result = await showConversationSelector( + context: context, + singleSelect: true, + title: l10n.forward, + onlyContact: false, + ); + if (result == null || result.isEmpty) return; + await runFutureWithToast( + accountServer.sendTextMessage( + conversation.codeUrl!, + result.first.encryptCategory!, + conversationId: result.first.conversationId, + recipientId: result.first.userId, + ), + ); }, ), - _IconButton( - label: context.l10n.resetLink, - iconAssetName: Resources.assetsImagesInviteRefreshSvg, - onTap: () { - runFutureWithToast( - context.accountServer.rotate(conversation.conversationId), - ); - }, - ), - ], - ); + if (conversation.codeUrl != null) + _IconButton( + label: l10n.copyInvite, + iconAssetName: Resources.assetsImagesInviteCopySvg, + onTap: () async { + final codeUrl = conversation.codeUrl; + await Clipboard.setData(ClipboardData(text: codeUrl!)); + showToastSuccessful(); + }, + ), + _IconButton( + label: l10n.resetLink, + iconAssetName: Resources.assetsImagesInviteRefreshSvg, + onTap: () { + runFutureWithToast( + accountServer.rotate(conversation.conversationId), + ); + }, + ), + ], + ); + } } -class _IconButton extends StatelessWidget { +class _IconButton extends ConsumerWidget { const _IconButton({ required this.label, required this.iconAssetName, @@ -210,31 +234,33 @@ class _IconButton extends StatelessWidget { final VoidCallback onTap; @override - Widget build(BuildContext context) => InteractiveDecoratedBox.color( - onTap: onTap, - decoration: const BoxDecoration(), - hoveringColor: context.dynamicColor( - const Color.fromRGBO(0, 0, 0, 0.03), - darkColor: const Color.fromRGBO(255, 255, 255, 0.2), - ), - child: Padding( - padding: const EdgeInsets.all(8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SvgPicture.asset( - iconAssetName, - width: 24, - height: 24, - colorFilter: ColorFilter.mode(context.theme.icon, BlendMode.srcIn), - ), - const SizedBox(height: 15), - Text( - label, - style: TextStyle(color: context.theme.text, fontSize: 14), - ), - ], + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return InteractiveDecoratedBox.color( + onTap: onTap, + decoration: const BoxDecoration(), + hoveringColor: ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(0, 0, 0, 0.03), + darkColor: const Color.fromRGBO(255, 255, 255, 0.2), + )), ), - ), - ); + child: Padding( + padding: const EdgeInsets.all(8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + iconAssetName, + width: 24, + height: 24, + colorFilter: ColorFilter.mode(theme.icon, BlendMode.srcIn), + ), + const SizedBox(height: 15), + Text(label, style: TextStyle(color: theme.text, fontSize: 14)), + ], + ), + ), + ); + } } diff --git a/lib/ui/home/chat_slide_page/group_participants_page.dart b/lib/ui/home/chat_slide_page/group_participants_page.dart index cfd35d9e79..cdfa197d1a 100644 --- a/lib/ui/home/chat_slide_page/group_participants_page.dart +++ b/lib/ui/home/chat_slide_page/group_participants_page.dart @@ -11,7 +11,6 @@ import '../../../constants/resources.dart'; import '../../../db/dao/participant_dao.dart'; import '../../../db/database_event_bus.dart'; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../widgets/app_bar.dart'; import '../../../widgets/avatar_view/avatar_view.dart'; import '../../../widgets/conversation/badges_widget.dart'; @@ -21,10 +20,31 @@ import '../../../widgets/search_text_field.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/user/user_dialog.dart'; import '../../../widgets/user_selector/conversation_selector.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/database_provider.dart'; +import '../../provider/ui_context_providers.dart'; import 'group_invite/group_invite_dialog.dart'; /// The participants of group. +final _groupParticipantsProvider = StreamProvider.autoDispose + .family, String>((ref, conversationId) { + final database = ref.watch(databaseProvider).value; + if (database == null) { + return Stream.value(const []); + } + return database.participantDao + .groupParticipantsByConversationId(conversationId) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchUpdateParticipantStream( + conversationIds: [conversationId], + ), + ], + duration: kDefaultThrottleDuration, + ); + }); + class GroupParticipantsPage extends HookConsumerWidget { const GroupParticipantsPage(this.conversationState, {super.key}); @@ -32,29 +52,19 @@ class GroupParticipantsPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; final conversationId = conversationState.conversationId; - final participants = - useMemoizedStream(() { - final dao = context.database.participantDao; - return dao - .groupParticipantsByConversationId(conversationId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchUpdateParticipantStream( - conversationIds: [conversationId], - ), - ], - duration: kDefaultThrottleDuration, - ); - }, keys: [conversationId]).data ?? + ref.watch(_groupParticipantsProvider(conversationId)).value ?? const []; // Find current user info to check if we have group manage permission. // Could be null if has been removed from group. final currentUser = useMemoized( () => participants.firstWhereOrNull( - (e) => e.userId == context.accountServer.userId, + (e) => e.userId == accountServer.userId, ), [participants], ); @@ -63,9 +73,9 @@ class GroupParticipantsPage extends HookConsumerWidget { final hasParticipant = ref.watch(currentConversationHasParticipantProvider); return Scaffold( - backgroundColor: context.theme.primary, + backgroundColor: theme.primary, appBar: MixinAppBar( - title: Text(context.l10n.groupParticipants), + title: Text(l10n.groupParticipants), actions: [ if (currentUser?.role != null) _ActionAddParticipants(participants: participants), @@ -76,7 +86,7 @@ class GroupParticipantsPage extends HookConsumerWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 16), child: SearchTextField( - hintText: context.l10n.settingAuthSearchHint, + hintText: l10n.settingAuthSearchHint, autofocus: context.textFieldAutoGainFocus, controller: controller, ), @@ -141,7 +151,7 @@ class _ParticipantList extends HookConsumerWidget { } } -class _ParticipantTile extends HookWidget { +class _ParticipantTile extends HookConsumerWidget { const _ParticipantTile({ required this.participant, required this.currentUser, @@ -155,77 +165,82 @@ class _ParticipantTile extends HookWidget { final String keyword; @override - Widget build(BuildContext context) => _ParticipantMenuEntry( - participant: participant, - currentUser: currentUser, - child: Material( - color: context.theme.primary, - child: InkWell( - onTap: () { - showUserDialog(context, participant.userId); - }, - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), - child: Row( - children: [ - AvatarWidget( - size: 50, - avatarUrl: participant.avatarUrl, - userId: participant.userId, - name: participant.fullName, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Flexible( - child: CustomText( - participant.fullName ?? '?', - style: TextStyle( - color: context.theme.text, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - textMatchers: [ - EmojiTextMatcher(), - KeyWordTextMatcher( - keyword, - style: TextStyle(color: context.theme.accent), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return _ParticipantMenuEntry( + participant: participant, + currentUser: currentUser, + child: Material( + color: theme.primary, + child: InkWell( + onTap: () { + showUserDialog(context, ref.container, participant.userId); + }, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + child: Row( + children: [ + AvatarWidget( + size: 50, + avatarUrl: participant.avatarUrl, + userId: participant.userId, + name: participant.fullName, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Flexible( + child: CustomText( + participant.fullName ?? '?', + style: TextStyle( + color: theme.text, + fontSize: 16, ), - ], + maxLines: 1, + overflow: TextOverflow.ellipsis, + textMatchers: [ + EmojiTextMatcher(), + KeyWordTextMatcher( + keyword, + style: TextStyle( + color: theme.accent, + ), + ), + ], + ), ), + BadgesWidget( + isBot: participant.appId != null, + verified: participant.isVerified, + membership: participant.membership, + ), + ], + ), + const SizedBox(height: 4), + Text( + participant.identityNumber, + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, ), - BadgesWidget( - isBot: participant.appId != null, - verified: participant.isVerified, - membership: participant.membership, - ), - ], - ), - const SizedBox(height: 4), - Text( - participant.identityNumber, - style: TextStyle( - color: context.theme.secondaryText, - fontSize: 14, + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + ], + ), ), - ), - _RoleWidget(role: participant.role), - ], + _RoleWidget(role: participant.role), + ], + ), ), ), ), - ), - ); + ); + } } class _ParticipantMenuEntry extends HookConsumerWidget { @@ -241,7 +256,9 @@ class _ParticipantMenuEntry extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final self = participant.userId == context.accountServer.userId; + final l10n = ref.watch(localizationProvider); + final accountServer = ref.read(accountServerProvider).requireValue; + final self = participant.userId == accountServer.userId; if (self) { return child; } @@ -253,11 +270,12 @@ class _ParticipantMenuEntry extends HookConsumerWidget { [ MenuAction( image: MenuImage.icon(IconFonts.chat), - title: context.l10n.groupPopMenuMessage( + title: l10n.groupPopMenuMessage( participant.fullName ?? '?', ), callback: () { ConversationStateNotifier.selectUser( + ref.container, context, participant.userId, ); @@ -269,7 +287,7 @@ class _ParticipantMenuEntry extends HookConsumerWidget { if (participant.role != ParticipantRole.admin) MenuAction( image: MenuImage.icon(IconFonts.manageUser), - title: context.l10n.makeGroupAdmin, + title: l10n.makeGroupAdmin, callback: () { final conversationId = ref.read( currentConversationIdProvider, @@ -277,7 +295,7 @@ class _ParticipantMenuEntry extends HookConsumerWidget { if (conversationId == null) return; runFutureWithToast( - context.accountServer.updateParticipantRole( + accountServer.updateParticipantRole( conversationId, participant.userId, ParticipantRole.admin, @@ -288,7 +306,7 @@ class _ParticipantMenuEntry extends HookConsumerWidget { else MenuAction( image: MenuImage.icon(IconFonts.stop), - title: context.l10n.dismissAsAdmin, + title: l10n.dismissAsAdmin, callback: () { final conversationId = ref.read( currentConversationIdProvider, @@ -296,7 +314,7 @@ class _ParticipantMenuEntry extends HookConsumerWidget { if (conversationId == null) return; runFutureWithToast( - context.accountServer.updateParticipantRole( + accountServer.updateParticipantRole( conversationId, participant.userId, null, @@ -310,7 +328,7 @@ class _ParticipantMenuEntry extends HookConsumerWidget { currentUser?.role == ParticipantRole.owner) MenuAction( image: MenuImage.icon(IconFonts.delete), - title: context.l10n.groupPopMenuRemove( + title: l10n.groupPopMenuRemove( participant.fullName ?? '?', ), callback: () { @@ -320,7 +338,7 @@ class _ParticipantMenuEntry extends HookConsumerWidget { if (conversationId == null) return; runFutureWithToast( - context.accountServer.removeParticipant( + accountServer.removeParticipant( conversationId, participant.userId, ), @@ -335,34 +353,41 @@ class _ParticipantMenuEntry extends HookConsumerWidget { } } -class _RoleWidget extends StatelessWidget { +class _RoleWidget extends ConsumerWidget { const _RoleWidget({required this.role}); final ParticipantRole? role; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); switch (role) { case ParticipantRole.owner: - return _RoleLabel(context.l10n.owner); + return _RoleLabel(l10n.owner); case ParticipantRole.admin: - return _RoleLabel(context.l10n.admin); + return _RoleLabel(l10n.admin); case null: return Container(width: 0); } } } -class _RoleLabel extends StatelessWidget { +class _RoleLabel extends ConsumerWidget { const _RoleLabel(this.label); final String label; @override - Widget build(BuildContext context) => Text( - label, - style: TextStyle(color: context.theme.secondaryText, fontSize: 14), - ); + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Text( + label, + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), + ); + } } enum _ActionType { addParticipants, inviteByLink } @@ -373,54 +398,60 @@ class _ActionAddParticipants extends HookConsumerWidget { final List participants; @override - Widget build(BuildContext context, WidgetRef ref) => CustomPopupMenuButton( - itemBuilder: (context) => [ - CustomPopupMenuItem( - icon: Resources.assetsImagesContextMenuSearchUserSvg, - title: context.l10n.addParticipants, - value: _ActionType.addParticipants, - ), - CustomPopupMenuItem( - icon: Resources.assetsImagesContextMenuLinkSvg, - title: context.l10n.inviteToGroupViaLink, - value: _ActionType.inviteByLink, - ), - ], - onSelected: (action) async { - switch (action) { - case _ActionType.addParticipants: - { - final result = await showConversationSelector( - context: context, - singleSelect: false, - title: context.l10n.addParticipants, - onlyContact: true, - maxSelect: kMaxGroupParticipants - participants.length, - ); - if (result == null || result.isEmpty) return; - - final userIds = result.map((e) => e.userId).nonNulls.toList(); - final conversationId = ref.read(currentConversationIdProvider); - if (conversationId == null) return; - - await runFutureWithToast( - context.accountServer.addParticipant(conversationId, userIds), - ); - break; - } - case _ActionType.inviteByLink: - { - final conversationId = ref.read(currentConversationIdProvider); - if (conversationId == null) return; - - await showGroupInviteByLinkDialog( - context, - conversationId: conversationId, - ); - break; - } - } - }, - icon: Resources.assetsImagesIcAddSvg, - ); + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + return CustomPopupMenuButton( + itemBuilder: (context) => [ + CustomPopupMenuItem( + icon: Resources.assetsImagesContextMenuSearchUserSvg, + title: l10n.addParticipants, + value: _ActionType.addParticipants, + ), + CustomPopupMenuItem( + icon: Resources.assetsImagesContextMenuLinkSvg, + title: l10n.inviteToGroupViaLink, + value: _ActionType.inviteByLink, + ), + ], + onSelected: (action) async { + switch (action) { + case _ActionType.addParticipants: + { + final result = await showConversationSelector( + context: context, + singleSelect: false, + title: l10n.addParticipants, + onlyContact: true, + maxSelect: kMaxGroupParticipants - participants.length, + ); + if (result == null || result.isEmpty) return; + + final userIds = result.map((e) => e.userId).nonNulls.toList(); + final conversationId = ref.read(currentConversationIdProvider); + if (conversationId == null) return; + + await runFutureWithToast( + ref + .read(accountServerProvider) + .requireValue + .addParticipant(conversationId, userIds), + ); + break; + } + case _ActionType.inviteByLink: + { + final conversationId = ref.read(currentConversationIdProvider); + if (conversationId == null) return; + + await showGroupInviteByLinkDialog( + context, + conversationId: conversationId, + ); + break; + } + } + }, + icon: Resources.assetsImagesIcAddSvg, + ); + } } diff --git a/lib/ui/home/chat_slide_page/groups_in_common_page.dart b/lib/ui/home/chat_slide_page/groups_in_common_page.dart index 0e85937f4f..be20ffa72f 100644 --- a/lib/ui/home/chat_slide_page/groups_in_common_page.dart +++ b/lib/ui/home/chat_slide_page/groups_in_common_page.dart @@ -5,12 +5,13 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import '../../../constants/resources.dart'; import '../../../db/dao/conversation_dao.dart'; -import '../../../utils/extension/extension.dart'; import '../../../utils/hook.dart'; import '../../../widgets/app_bar.dart'; import '../../../widgets/avatar_view/avatar_view.dart'; import '../../../widgets/interactive_decorated_box.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/ui_context_providers.dart'; import '../conversation/conversation_page.dart'; class GroupsInCommonPage extends HookConsumerWidget { @@ -22,10 +23,12 @@ class GroupsInCommonPage extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final userId = conversationState.userId; if (userId == null) return const SizedBox(); + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); return Scaffold( - backgroundColor: context.theme.primary, - appBar: MixinAppBar(title: Text(context.l10n.groupsInCommon)), + backgroundColor: theme.primary, + appBar: MixinAppBar(title: Text(l10n.groupsInCommon)), body: _ConversationList(userId: userId), ); } @@ -38,10 +41,13 @@ class _ConversationList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final conversationList = useMemoizedFuture( () { - final selfId = context.accountServer.userId; - return context.accountServer.database.conversationDao + final accountServer = ref.read(accountServerProvider).requireValue; + final selfId = accountServer.userId; + return accountServer.database.conversationDao .findSameConversations(selfId, userId) .get(); }, @@ -67,14 +73,16 @@ class _ConversationList extends HookConsumerWidget { height: 80, width: 80, colorFilter: ColorFilter.mode( - context.theme.secondaryText, + theme.secondaryText, BlendMode.srcIn, ), ), const SizedBox(height: 20), Text( - context.l10n.noResults, - style: TextStyle(color: context.theme.secondaryText), + l10n.noResults, + style: TextStyle( + color: theme.secondaryText, + ), ), ], ), @@ -90,70 +98,75 @@ class _ConversationList extends HookConsumerWidget { } } -class _GroupConversationItemWidget extends StatelessWidget { +class _GroupConversationItemWidget extends ConsumerWidget { const _GroupConversationItemWidget({required this.group}); final GroupMinimal group; @override - Widget build(BuildContext context) => SizedBox( - height: ConversationPage.conversationItemHeight, - child: InteractiveDecoratedBox( - onTap: () { - ConversationStateNotifier.selectConversation( - context, - group.conversationId, - ); - }, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return SizedBox( + height: ConversationPage.conversationItemHeight, + child: InteractiveDecoratedBox( + onTap: () { + ConversationStateNotifier.selectConversation( + ref.container, + context, + group.conversationId, + ); + }, child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Row( - children: [ - ConversationAvatarWidget( - size: ConversationPage.conversationItemAvatarSize, - conversationId: group.conversationId, - category: ConversationCategory.group, - groupIconUrl: group.groupIconUrl, - ), - const SizedBox(width: 12), - Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 16), - child: Column( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - group.groupName ?? '', - style: TextStyle( - color: context.theme.text, - fontSize: 16, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - SizedBox( - height: 20, - child: Text( - context.l10n.participantsCount(group.memberCount), + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + ConversationAvatarWidget( + size: ConversationPage.conversationItemAvatarSize, + conversationId: group.conversationId, + category: ConversationCategory.group, + groupIconUrl: group.groupIconUrl, + ), + const SizedBox(width: 12), + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 16), + child: Column( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + group.groupName ?? '', style: TextStyle( - color: context.theme.secondaryText, - fontSize: 14, + color: theme.text, + fontSize: 16, ), - overflow: TextOverflow.ellipsis, maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + SizedBox( + height: 20, + child: Text( + l10n.participantsCount(group.memberCount), + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + maxLines: 1, + ), ), - ), - ], + ], + ), ), ), - ), - ], + ], + ), ), ), ), - ), - ); + ); + } } diff --git a/lib/ui/home/chat_slide_page/pin_messages_page.dart b/lib/ui/home/chat_slide_page/pin_messages_page.dart index 5d1aa7993f..b951d32b87 100644 --- a/lib/ui/home/chat_slide_page/pin_messages_page.dart +++ b/lib/ui/home/chat_slide_page/pin_messages_page.dart @@ -1,26 +1,22 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; -import 'package:provider/provider.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../blaze/vo/pin_message_minimal.dart'; import '../../../constants/resources.dart'; -import '../../../db/database_event_bus.dart'; -import '../../../db/mixin_database.dart'; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../widgets/action_button.dart'; import '../../../widgets/app_bar.dart'; import '../../../widgets/dialog.dart'; import '../../../widgets/interactive_decorated_box.dart'; -import '../../../widgets/message/item/audio_message.dart'; import '../../../widgets/message/message.dart'; import '../../../widgets/message/message_day_time.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; -import '../chat/chat_page.dart'; +import '../../provider/ui_context_providers.dart'; +import '../providers/home_scope_providers.dart'; class PinMessagesPage extends HookConsumerWidget { const PinMessagesPage(this.conversationState, {super.key}); @@ -29,28 +25,10 @@ class PinMessagesPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final conversationId = conversationState.conversationId; - - final rawList = useMemoizedStream>( - () => context.database.pinMessageDao - .messageItems(conversationId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchPinMessageStream( - conversationIds: [conversationId], - ), - DataBaseEventBus.instance.updateAssetStream, - DataBaseEventBus.instance.updateStickerStream, - ], - duration: kSlowThrottleDuration, - ), - keys: [conversationId], - ).data; - - final chatSideCubit = useBloc(ChatSideCubit.new); - final searchConversationKeywordCubit = useBloc( - () => SearchConversationKeywordCubit(chatSideCubit: chatSideCubit), - ); + final rawList = ref.watch(pinnedMessagesProvider(conversationId)).value; useEffect(() { if (rawList?.isNotEmpty ?? true) return; @@ -58,115 +36,103 @@ class PinMessagesPage extends HookConsumerWidget { }, [rawList?.isNotEmpty]); final list = (rawList ?? []).reversed.toList(); - final scrollController = useMemoized(ScrollController.new); final listKey = useMemoized(() => GlobalKey(debugLabel: 'PinMessagesPage')); - return MultiProvider( - providers: [ - BlocProvider.value(value: searchConversationKeywordCubit), - Provider( - create: (_) => AudioMessagesPlayAgent( - list, - (m) => context.accountServer.convertMessageAbsolutePath(m, true), - ), - ), - ], - child: Scaffold( - backgroundColor: context.theme.popUp, - appBar: MixinAppBar( - title: Text( - context.l10n.pinnedMessageTitle(list.length, list.length), - ), - backgroundColor: context.theme.popUp, - actions: [ - if (!Navigator.of(context).canPop()) - ActionButton( - name: Resources.assetsImagesIcCloseSvg, - color: context.theme.icon, - onTap: () => context.read().onPopPage(), - ), - ], - ), - body: Column( - children: [ - Expanded( - child: MessageDayTimeViewportWidget.singleList( - scrollController: scrollController, - listKey: listKey, - reTraversalKey: list, + return Scaffold( + backgroundColor: theme.popUp, + appBar: MixinAppBar( + title: Text(l10n.pinnedMessageTitle(list.length, list.length)), + backgroundColor: theme.popUp, + actions: [ + if (!Navigator.of(context).canPop()) + ActionButton( + name: Resources.assetsImagesIcCloseSvg, + color: theme.icon, + onTap: () => + ref.read(chatSideControllerProvider.notifier).onPopPage(), + ), + ], + ), + body: Column( + children: [ + Expanded( + child: MessageDayTimeViewportWidget.singleList( + scrollController: scrollController, + listKey: listKey, + reTraversalKey: list, + reverse: true, + child: ListView.builder( + key: listKey, reverse: true, - child: ListView.builder( - key: listKey, - reverse: true, - padding: const EdgeInsets.only(bottom: 16), - controller: scrollController, - itemBuilder: (context, index) { - final messageItem = list[index]; - return MessageItemWidget( - key: ValueKey(messageItem.messageId), - prev: list.getOrNull(index + 1), - message: messageItem, - next: list.getOrNull(index - 1), - blink: false, - isPinnedPage: true, - ); - }, - itemCount: list.length, - ), + padding: const EdgeInsets.only(bottom: 16), + controller: scrollController, + itemBuilder: (context, index) { + final messageItem = list[index]; + return MessageItemWidget( + key: ValueKey(messageItem.messageId), + prev: list.getOrNull(index + 1), + message: messageItem, + next: list.getOrNull(index - 1), + blink: false, + isPinnedPage: true, + ); + }, + itemCount: list.length, ), ), - InteractiveDecoratedBox( - cursor: SystemMouseCursors.click, - onTap: () async { - await showMixinDialog( - context: context, - child: Builder( - builder: (context) => AlertDialogLayout( - title: Text( - context.l10n.unpinAllMessagesConfirmation, + ), + InteractiveDecoratedBox( + cursor: SystemMouseCursors.click, + onTap: () async { + await showMixinDialog( + context: context, + child: Builder( + builder: (context) => AlertDialogLayout( + title: Text(l10n.unpinAllMessagesConfirmation), + content: const SizedBox(), + actions: [ + MixinButton( + backgroundTransparent: true, + onTap: () => Navigator.pop(context), + child: Text(l10n.cancel), + ), + MixinButton( + onTap: () { + Navigator.pop(context); + ref + .read(accountServerProvider) + .requireValue + .unpinMessage( + conversationId: conversationId, + pinMessageMinimals: list + .map( + (e) => PinMessageMinimal( + type: e.type, + messageId: e.messageId, + content: e.content, + ), + ) + .toList(), + ); + }, + child: Text(l10n.confirm), ), - content: const SizedBox(), - actions: [ - MixinButton( - backgroundTransparent: true, - onTap: () => Navigator.pop(context), - child: Text(context.l10n.cancel), - ), - MixinButton( - onTap: () { - Navigator.pop(context); - context.accountServer.unpinMessage( - conversationId: conversationId, - pinMessageMinimals: list - .map( - (e) => PinMessageMinimal( - type: e.type, - messageId: e.messageId, - content: e.content, - ), - ) - .toList(), - ); - }, - child: Text(context.l10n.confirm), - ), - ], - ), + ], ), - ); - }, - child: Container( - height: 56, - alignment: Alignment.center, - child: Text( - context.l10n.unpinAllMessages, - style: TextStyle(fontSize: 16, color: context.theme.accent), ), + ); + }, + child: Container( + height: 56, + alignment: Alignment.center, + child: Text( + l10n.unpinAllMessages, + style: TextStyle(fontSize: 16, color: theme.accent), ), ), - ], - ), + ), + ], ), ); } diff --git a/lib/ui/home/chat_slide_page/search_message_page.dart b/lib/ui/home/chat_slide_page/search_message_page.dart index 135b1505d3..9d73828163 100644 --- a/lib/ui/home/chat_slide_page/search_message_page.dart +++ b/lib/ui/home/chat_slide_page/search_message_page.dart @@ -8,11 +8,11 @@ import 'package:rxdart/rxdart.dart' hide ThrottleExtensions; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../../constants/resources.dart'; +import '../../../db/database.dart'; import '../../../db/database_event_bus.dart'; import '../../../db/mixin_database.dart'; import '../../../enum/message_category.dart'; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../utils/system/text_input.dart'; import '../../../widgets/action_button.dart'; import '../../../widgets/app_bar.dart'; @@ -22,11 +22,12 @@ import '../../../widgets/high_light_text.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../../widgets/search_text_field.dart'; import '../../provider/conversation_provider.dart'; -import '../bloc/blink_cubit.dart'; -import '../bloc/conversation_list_bloc.dart'; -import '../bloc/search_message_cubit.dart'; -import '../chat/chat_page.dart'; +import '../../provider/database_provider.dart'; +import '../../provider/multi_auth_provider.dart'; +import '../../provider/ui_context_providers.dart'; +import '../controllers/search_message_controller.dart'; import '../conversation/search_list.dart'; +import '../providers/home_scope_providers.dart'; class SearchMessagePage extends HookConsumerWidget { const SearchMessagePage(this.conversationState, {super.key}); @@ -35,14 +36,16 @@ class SearchMessagePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final categories = useMemoized( () => [ - _Category(context.l10n.text, const [ + _Category(l10n.text, const [ MessageCategory.plainText, MessageCategory.signalText, MessageCategory.encryptedText, ]), - _Category(context.l10n.post, const [ + _Category(l10n.post, const [ MessageCategory.plainPost, MessageCategory.signalPost, MessageCategory.encryptedPost, @@ -56,13 +59,8 @@ class SearchMessagePage extends HookConsumerWidget { final editingController = useMemoized(EmojiTextEditingController.new); final userMode = useState(false); final selectedUser = useState(null); - - final keywordStream = useValueNotifierConvertSteam(editingController); - final keywordIsEmpty = - useMemoizedStream( - () => keywordStream.map((event) => event.text.isEmpty).distinct(), - ).data ?? - editingController.text.isEmpty; + final editingValue = useValueListenable(editingController); + final keywordIsEmpty = editingValue.text.isEmpty; useEffect(() { if (keywordIsEmpty) selectedCategories.value = null; @@ -77,22 +75,23 @@ class SearchMessagePage extends HookConsumerWidget { }, [userMode.value, selectedUser.value]); useEffect(() { - SearchConversationKeywordCubit.updateSelectedUser( - context, - selectedUser.value?.userId, - ); + ref + .read(searchConversationKeywordControllerProvider.notifier) + .setSelectedUser(selectedUser.value?.userId); + return null; }, [selectedUser.value]); return Scaffold( - backgroundColor: context.theme.primary, + backgroundColor: theme.primary, appBar: MixinAppBar( - title: Text(context.l10n.searchConversation), + title: Text(l10n.searchConversation), actions: [ if (!Navigator.of(context).canPop()) ActionButton( name: Resources.assetsImagesIcCloseSvg, - color: context.theme.icon, - onTap: () => context.read().onPopPage(), + color: theme.icon, + onTap: () => + ref.read(chatSideControllerProvider.notifier).onPopPage(), ), ], ), @@ -125,17 +124,17 @@ class SearchMessagePage extends HookConsumerWidget { fontSize: 16, focusNode: focusNode, controller: editingController, - hintText: context.l10n.search, + hintText: l10n.search, showClear: userMode.value, leading: userMode.value ? Row( mainAxisSize: MainAxisSize.min, children: [ Text( - context.l10n.fromWithColon, + l10n.fromWithColon, style: TextStyle( fontSize: 16, - color: context.theme.text, + color: theme.text, ), ), if (selectedUser.value != null) @@ -159,10 +158,12 @@ class SearchMessagePage extends HookConsumerWidget { if (userMode.value && selectedUser.value == null) { return; } - SearchConversationKeywordCubit.updateKeyword( - context, - keyword, - ); + ref + .read( + searchConversationKeywordControllerProvider + .notifier, + ) + .setKeyword(keyword); }, ), ), @@ -177,7 +178,7 @@ class SearchMessagePage extends HookConsumerWidget { height: 36, child: ActionButton( name: Resources.assetsImagesUserSearchSvg, - color: context.theme.icon, + color: theme.icon, onTap: () { editingController.text = ''; userMode.value = true; @@ -268,37 +269,24 @@ class _SearchMessageList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final (_, initKeyword) = useMemoized( - () => context.read().state, - ); final keyword = - useMemoizedStream( - () => context - .read() - .stream - .map((event) => event.$2.trim()) - .debounceTime(const Duration(milliseconds: 150)), - ).data ?? - initKeyword.trim(); - - final searchMessageCubit = useBloc( - () => SearchMessageCubit.conversation( - database: context.database, - keyword: keyword, - limit: context.read().limit, - categories: categories, - userId: selectedUserId, - conversationId: conversationId, - ), - keys: [keyword, categories, selectedUserId, conversationId], + ref.watch(searchConversationKeywordDebouncedProvider).value ?? ''; + final database = ref.watch(databaseProvider).requireValue; + final args = ConversationSearchMessageArgs( + database: database, + keyword: keyword, + limit: ref.read(conversationListControllerProvider.notifier).limit, + categories: categories, + userId: selectedUserId, + conversationId: conversationId, ); - - final pageState = useBlocState( - bloc: searchMessageCubit, + final searchMessageNotifier = ref.watch( + conversationSearchMessageStateProvider(args).notifier, ); + final pageState = ref.watch(conversationSearchMessageStateProvider(args)); return ScrollablePositionedList.builder( - itemPositionsListener: searchMessageCubit.itemPositionsListener, + itemPositionsListener: searchMessageNotifier.itemPositionsListener, itemCount: pageState.items.length, itemBuilder: (context, index) { final message = pageState.items[index]; @@ -308,12 +296,15 @@ class _SearchMessageList extends HookConsumerWidget { showSender: true, onTap: () { ConversationStateNotifier.selectConversation( + ref.container, context, message.conversationId, initIndexMessageId: message.messageId, ); - context.read().blinkByMessageId(message.messageId); - final chatSideCubit = context.read(); + ref + .read(blinkControllerProvider.notifier) + .blinkByMessageId(message.messageId); + final chatSideCubit = ref.read(chatSideControllerProvider.notifier); if (chatSideCubit.state.routeMode) { chatSideCubit.clear(); } @@ -339,77 +330,22 @@ class _SearchParticipantList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final keywordStream = useValueNotifierConvertSteam(editingController); - + final theme = ref.watch(brightnessThemeDataProvider); + final editingValue = useValueListenable(editingController); + final database = ref.watch(databaseProvider).requireValue; + final currentUserId = + ref.watch(authAccountProvider)?.userId ?? + ref.watch(multiAuthNotifierProvider).current?.userId ?? + ''; + final args = _SearchParticipantsArgs( + database: database, + conversationId: conversationId, + currentUserId: currentUserId, + isBot: isBot, + keyword: editingValue.text, + ); final filteredUser = - useMemoizedStream( - () => keywordStream - .throttleTime(const Duration(milliseconds: 100)) - .map((event) => event.text) - .switchMap((value) { - final userDao = context.database.userDao; - - if (isBot) { - return value.isEmpty - ? userDao.friends().watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.updateUserIdsStream, - ], - duration: kSlowThrottleDuration, - ) - : userDao - .fuzzySearchBotGroupUser( - currentUserId: - context - .multiAuthChangeNotifier - .current - ?.userId ?? - '', - conversationId: conversationId, - keyword: value, - ) - .watchWithStream( - eventStreams: [ - DataBaseEventBus - .instance - .insertOrReplaceMessageIdsStream, - DataBaseEventBus.instance.deleteMessageIdStream, - DataBaseEventBus.instance.updateUserIdsStream, - ], - duration: kVerySlowThrottleDuration, - ); - } - - if (value.isEmpty) { - return userDao - .groupParticipants(conversationId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance - .watchUpdateParticipantStream( - conversationIds: [conversationId], - ), - ], - duration: kSlowThrottleDuration, - ); - } - return userDao - .fuzzySearchGroupUser( - context.account?.userId ?? '', - conversationId, - value, - ) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchUpdateParticipantStream( - conversationIds: [conversationId], - ), - ], - duration: kSlowThrottleDuration, - ); - }), - ).data ?? - []; + ref.watch(_searchParticipantsProvider(args)).value ?? []; return ListView.builder( itemCount: filteredUser.length, @@ -436,7 +372,7 @@ class _SearchParticipantList extends HookConsumerWidget { user.fullName ?? '', style: TextStyle( fontSize: 16, - color: context.theme.text, + color: theme.text, ), maxLines: 1, overflow: TextOverflow.ellipsis, @@ -459,7 +395,95 @@ class _SearchParticipantList extends HookConsumerWidget { } } -class _CategoryItem extends StatelessWidget { +class _SearchParticipantsArgs with EquatableMixin { + const _SearchParticipantsArgs({ + required this.database, + required this.conversationId, + required this.currentUserId, + required this.isBot, + required this.keyword, + }); + + final Database database; + final String conversationId; + final String currentUserId; + final bool isBot; + final String keyword; + + @override + List get props => [ + database, + conversationId, + currentUserId, + isBot, + keyword, + ]; +} + +final _searchParticipantsProvider = StreamProvider.autoDispose + .family, _SearchParticipantsArgs>((ref, args) async* { + final userDao = args.database.userDao; + final keyword = args.keyword.trim(); + + Stream> buildStream() { + if (args.isBot) { + return keyword.isEmpty + ? userDao.friends().watchWithStream( + eventStreams: [DataBaseEventBus.instance.updateUserIdsStream], + duration: kSlowThrottleDuration, + ) + : userDao + .fuzzySearchBotGroupUser( + currentUserId: args.currentUserId, + conversationId: args.conversationId, + keyword: keyword, + ) + .watchWithStream( + eventStreams: [ + DataBaseEventBus + .instance + .insertOrReplaceMessageIdsStream, + DataBaseEventBus.instance.deleteMessageIdStream, + DataBaseEventBus.instance.updateUserIdsStream, + ], + duration: kVerySlowThrottleDuration, + ); + } + + if (keyword.isEmpty) { + return userDao + .groupParticipants(args.conversationId) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchUpdateParticipantStream( + conversationIds: [args.conversationId], + ), + ], + duration: kSlowThrottleDuration, + ); + } + return userDao + .fuzzySearchGroupUser( + args.currentUserId, + args.conversationId, + keyword, + ) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchUpdateParticipantStream( + conversationIds: [args.conversationId], + ), + ], + duration: kSlowThrottleDuration, + ); + } + + yield* buildStream() + .debounceTime(const Duration(milliseconds: 100)) + .distinct(listEquals); + }); + +class _CategoryItem extends ConsumerWidget { const _CategoryItem({ required this.name, required this.categories, @@ -473,7 +497,8 @@ class _CategoryItem extends StatelessWidget { final ValueChanged> onSelected; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final selected = listEquals(categories, selectedCategories); return InteractiveDecoratedBox( onTap: () { @@ -482,7 +507,7 @@ class _CategoryItem extends StatelessWidget { child: AnimatedContainer( duration: const Duration(milliseconds: 200), decoration: ShapeDecoration( - color: selected ? context.theme.accent : context.theme.listSelected, + color: selected ? theme.accent : theme.listSelected, shape: const StadiumBorder(), ), padding: const EdgeInsets.symmetric(horizontal: 12), @@ -491,7 +516,7 @@ class _CategoryItem extends StatelessWidget { child: Text( name, style: TextStyle( - color: selected ? Colors.white : context.theme.text, + color: selected ? Colors.white : theme.text, fontSize: 16, height: 1, ), diff --git a/lib/ui/home/chat_slide_page/share_media/file_page.dart b/lib/ui/home/chat_slide_page/share_media/file_page.dart index fed3c16b56..3271e67d7d 100644 --- a/lib/ui/home/chat_slide_page/share_media/file_page.dart +++ b/lib/ui/home/chat_slide_page/share_media/file_page.dart @@ -7,52 +7,43 @@ import 'package:intl/intl.dart'; import 'package:rxdart/rxdart.dart'; import 'package:sliver_tools/sliver_tools.dart'; -import '../../../../bloc/paging/load_more_paging_state.dart'; import '../../../../constants/resources.dart'; +import '../../../../db/database.dart'; import '../../../../db/mixin_database.dart'; import '../../../../enum/message_category.dart'; -import '../../../../utils/extension/extension.dart'; -import '../../../../utils/hook.dart'; +import '../../../../paging/load_more_paging_controller.dart'; import '../../../../widgets/message/item/file_message.dart'; import '../../../../widgets/message/message.dart'; +import '../../../provider/database_provider.dart'; +import '../../../provider/ui_context_providers.dart'; import '../shared_media_page.dart'; -class FilePage extends HookConsumerWidget { - const FilePage({ - required this.maxHeight, - required this.conversationId, - super.key, - }); - - final double maxHeight; - final String conversationId; +typedef _FilePageArgs = ({ + Database database, + String conversationId, + int size, +}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final size = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); - final messageDao = context.database.messageDao; - - final mediaCubit = useBloc( - () => LoadMorePagingBloc( +final _filePagingControllerProvider = Provider.autoDispose + .family, _FilePageArgs>((ref, args) { + final messageDao = args.database.messageDao; + final controller = LoadMorePagingController( reloadData: () => - messageDao.fileMessages(conversationId, size, 0).get(), + messageDao.fileMessages(args.conversationId, args.size, 0).get(), loadMoreData: (list) async { if (list.isEmpty) return []; final last = list.last; final info = await messageDao.messageOrderInfo(last.messageId); if (info == null) return []; final items = await messageDao - .fileMessagesBefore(info, conversationId, size) + .fileMessagesBefore(info, args.conversationId, args.size) .get(); return [...list, ...items]; }, isSameKey: (a, b) => a.messageId == b.messageId, - ), - keys: [conversationId], - ); - useEffect( - () => messageDao - .watchInsertOrReplaceMessageStream(conversationId) + ); + final subscription = messageDao + .watchInsertOrReplaceMessageStream(args.conversationId) .switchMap((value) async* { for (final item in value) { yield item; @@ -64,23 +55,52 @@ class FilePage extends HookConsumerWidget { MessageCategory.signalData, ].contains(event.type), ) - .listen(mediaCubit.insertOrReplace) - .cancel, - [conversationId], + .listen(controller.insertOrReplace); + ref.onDispose(subscription.cancel); + ref.onDispose(controller.dispose); + return controller; + }); + +final _fileGroupedItemsProvider = StreamProvider.autoDispose + .family>, _FilePageArgs>((ref, args) { + final controller = ref.watch(_filePagingControllerProvider(args)); + return (() async* { + Map> group(List list) => + groupBy(list, (messageItem) { + final local = messageItem.createdAt.toLocal(); + return DateTime(local.year, local.month, local.day); + }); + + yield group(controller.state.list); + yield* controller.stream.map((state) => group(state.list)).distinct(); + })(); + }); + +class FilePage extends HookConsumerWidget { + const FilePage({ + required this.maxHeight, + required this.conversationId, + super.key, + }); + + final double maxHeight; + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final size = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); + final database = ref.read(databaseProvider).requireValue; + final args = ( + database: database, + conversationId: conversationId, + size: size, ); + final fileController = ref.watch(_filePagingControllerProvider(args)); final map = - useBlocStateConverter< - LoadMorePagingBloc, - LoadMorePagingState, - Map> - >( - bloc: mediaCubit, - converter: (state) => - groupBy(state.list, (messageItem) { - final local = messageItem.createdAt.toLocal(); - return DateTime(local.year, local.month, local.day); - }), - ); + ref.watch(_fileGroupedItemsProvider(args)).value ?? + const >{}; final scrollController = useScrollController(); @@ -92,17 +112,14 @@ class FilePage extends HookConsumerWidget { SvgPicture.asset( Resources.assetsImagesEmptyFileSvg, colorFilter: ColorFilter.mode( - context.theme.secondaryText.withValues(alpha: 0.4), + theme.secondaryText.withValues(alpha: 0.4), BlendMode.srcIn, ), ), const SizedBox(height: 24), Text( - context.l10n.noFiles, - style: TextStyle( - fontSize: 12, - color: context.theme.secondaryText, - ), + l10n.noFiles, + style: TextStyle(fontSize: 12, color: theme.secondaryText), ), ], ), @@ -119,7 +136,7 @@ class FilePage extends HookConsumerWidget { if (notification.metrics.maxScrollExtent - notification.metrics.pixels < dimension) { - mediaCubit.loadMore(); + fileController.loadMore(); } return false; @@ -133,13 +150,13 @@ class FilePage extends HookConsumerWidget { children: [ SliverPinnedHeader( child: Container( - color: context.theme.primary, + color: theme.primary, padding: const EdgeInsets.all(10), child: Text( DateFormat.yMMMd().format(e.key.toLocal()), style: TextStyle( fontSize: 14, - color: context.theme.secondaryText, + color: theme.secondaryText, ), ), ), diff --git a/lib/ui/home/chat_slide_page/share_media/media_page.dart b/lib/ui/home/chat_slide_page/share_media/media_page.dart index 00d20ff656..3f475bdf98 100644 --- a/lib/ui/home/chat_slide_page/share_media/media_page.dart +++ b/lib/ui/home/chat_slide_page/share_media/media_page.dart @@ -7,57 +7,46 @@ import 'package:intl/intl.dart'; import 'package:rxdart/rxdart.dart'; import 'package:sliver_tools/sliver_tools.dart'; -import '../../../../bloc/paging/load_more_paging_state.dart'; import '../../../../constants/resources.dart'; +import '../../../../db/database.dart'; import '../../../../db/mixin_database.dart'; import '../../../../enum/message_category.dart'; +import '../../../../paging/load_more_paging_controller.dart'; import '../../../../utils/extension/extension.dart'; -import '../../../../utils/hook.dart'; import '../../../../widgets/message/item/image/image_message.dart'; import '../../../../widgets/message/item/video/video_message.dart'; import '../../../../widgets/message/message.dart'; -import '../../chat/chat_page.dart'; +import '../../../provider/database_provider.dart'; +import '../../../provider/ui_context_providers.dart'; +import '../../providers/home_scope_providers.dart'; import '../shared_media_page.dart'; -class MediaPage extends HookConsumerWidget { - const MediaPage({ - required this.maxHeight, - required this.conversationId, - super.key, - }); +typedef _MediaPageArgs = ({ + Database database, + String conversationId, + int size, +}); - final double maxHeight; - final String conversationId; - - @override - Widget build(BuildContext context, WidgetRef ref) { - final column = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); - final routeMode = context.read().state.routeMode; - final size = column * (routeMode ? 4 : 3); - - final messageDao = context.database.messageDao; - - final mediaCubit = useBloc( - () => LoadMorePagingBloc( +final _mediaPagingControllerProvider = Provider.autoDispose + .family, _MediaPageArgs>((ref, args) { + final messageDao = args.database.messageDao; + final controller = LoadMorePagingController( reloadData: () => - messageDao.mediaMessages(conversationId, size, 0).get(), + messageDao.mediaMessages(args.conversationId, args.size, 0).get(), loadMoreData: (list) async { if (list.isEmpty) return []; final last = list.last; final info = await messageDao.messageOrderInfo(last.messageId); if (info == null) return []; final items = await messageDao - .mediaMessagesBefore(info, conversationId, size) + .mediaMessagesBefore(info, args.conversationId, args.size) .get(); return [...list, ...items]; }, isSameKey: (a, b) => a.messageId == b.messageId, - ), - keys: [conversationId], - ); - useEffect( - () => messageDao - .watchInsertOrReplaceMessageStream(conversationId) + ); + final subscription = messageDao + .watchInsertOrReplaceMessageStream(args.conversationId) .switchMap((value) async* { for (final item in value) { yield item; @@ -71,23 +60,56 @@ class MediaPage extends HookConsumerWidget { MessageCategory.signalVideo, ].contains(event.type), ) - .listen(mediaCubit.insertOrReplace) - .cancel, - [conversationId], + .listen(controller.insertOrReplace); + ref.onDispose(subscription.cancel); + ref.onDispose(controller.dispose); + return controller; + }); + +final _mediaGroupedItemsProvider = StreamProvider.autoDispose + .family>, _MediaPageArgs>((ref, args) { + final controller = ref.watch(_mediaPagingControllerProvider(args)); + return (() async* { + Map> group(List list) => + groupBy(list, (messageItem) { + final local = messageItem.createdAt.toLocal(); + return DateTime(local.year, local.month, local.day); + }); + + yield group(controller.state.list); + yield* controller.stream.map((state) => group(state.list)).distinct(); + })(); + }); + +class MediaPage extends HookConsumerWidget { + const MediaPage({ + required this.maxHeight, + required this.conversationId, + super.key, + }); + + final double maxHeight; + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final column = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); + final routeMode = ref.watch( + chatSideControllerProvider.select((value) => value.routeMode), + ); + final database = ref.read(databaseProvider).requireValue; + final size = column * (routeMode ? 4 : 3); + final args = ( + database: database, + conversationId: conversationId, + size: size, ); + final mediaController = ref.watch(_mediaPagingControllerProvider(args)); final map = - useBlocStateConverter< - LoadMorePagingBloc, - LoadMorePagingState, - Map> - >( - bloc: mediaCubit, - converter: (state) => - groupBy(state.list, (messageItem) { - final local = messageItem.createdAt.toLocal(); - return DateTime(local.year, local.month, local.day); - }), - ); + ref.watch(_mediaGroupedItemsProvider(args)).value ?? + const >{}; final scrollController = useScrollController(); @@ -99,17 +121,14 @@ class MediaPage extends HookConsumerWidget { SvgPicture.asset( Resources.assetsImagesEmptyImageSvg, colorFilter: ColorFilter.mode( - context.theme.secondaryText.withValues(alpha: 0.4), + theme.secondaryText.withValues(alpha: 0.4), BlendMode.srcIn, ), ), const SizedBox(height: 24), Text( - context.l10n.noMedia, - style: TextStyle( - fontSize: 12, - color: context.theme.secondaryText, - ), + l10n.noMedia, + style: TextStyle(fontSize: 12, color: theme.secondaryText), ), ], ), @@ -126,7 +145,7 @@ class MediaPage extends HookConsumerWidget { if (notification.metrics.maxScrollExtent - notification.metrics.pixels < dimension) { - mediaCubit.loadMore(); + mediaController.loadMore(); } return false; @@ -140,13 +159,13 @@ class MediaPage extends HookConsumerWidget { children: [ SliverPinnedHeader( child: Container( - color: context.theme.primary, + color: theme.primary, padding: const EdgeInsets.all(10), child: Text( DateFormat.yMMMd().format(e.key.toLocal()), style: TextStyle( fontSize: 14, - color: context.theme.secondaryText, + color: theme.secondaryText, ), ), ), diff --git a/lib/ui/home/chat_slide_page/share_media/post_page.dart b/lib/ui/home/chat_slide_page/share_media/post_page.dart index 6f9f967d6e..88bc0e3f6e 100644 --- a/lib/ui/home/chat_slide_page/share_media/post_page.dart +++ b/lib/ui/home/chat_slide_page/share_media/post_page.dart @@ -7,53 +7,43 @@ import 'package:intl/intl.dart'; import 'package:rxdart/rxdart.dart'; import 'package:sliver_tools/sliver_tools.dart'; -import '../../../../bloc/paging/load_more_paging_state.dart'; import '../../../../constants/resources.dart'; +import '../../../../db/database.dart'; import '../../../../db/mixin_database.dart'; import '../../../../enum/message_category.dart'; -import '../../../../utils/extension/extension.dart'; -import '../../../../utils/hook.dart'; +import '../../../../paging/load_more_paging_controller.dart'; import '../../../../widgets/message/item/post_message.dart'; import '../../../../widgets/message/message.dart'; +import '../../../provider/database_provider.dart'; +import '../../../provider/ui_context_providers.dart'; import '../shared_media_page.dart'; -class PostPage extends HookConsumerWidget { - const PostPage({ - required this.maxHeight, - required this.conversationId, - super.key, - }); - - final double maxHeight; - final String conversationId; +typedef _PostPageArgs = ({ + Database database, + String conversationId, + int size, +}); - @override - Widget build(BuildContext context, WidgetRef ref) { - final size = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); - - final messageDao = context.database.messageDao; - - final mediaCubit = useBloc( - () => LoadMorePagingBloc( +final _postPagingControllerProvider = Provider.autoDispose + .family, _PostPageArgs>((ref, args) { + final messageDao = args.database.messageDao; + final controller = LoadMorePagingController( reloadData: () => - messageDao.postMessages(conversationId, size, 0).get(), + messageDao.postMessages(args.conversationId, args.size, 0).get(), loadMoreData: (list) async { if (list.isEmpty) return []; final last = list.last; final info = await messageDao.messageOrderInfo(last.messageId); if (info == null) return []; final items = await messageDao - .postMessagesBefore(info, conversationId, size) + .postMessagesBefore(info, args.conversationId, args.size) .get(); return [...list, ...items]; }, isSameKey: (a, b) => a.messageId == b.messageId, - ), - keys: [conversationId], - ); - useEffect( - () => messageDao - .watchInsertOrReplaceMessageStream(conversationId) + ); + final subscription = messageDao + .watchInsertOrReplaceMessageStream(args.conversationId) .switchMap((value) async* { for (final item in value) { yield item; @@ -65,23 +55,52 @@ class PostPage extends HookConsumerWidget { MessageCategory.signalPost, ].contains(event.type), ) - .listen(mediaCubit.insertOrReplace) - .cancel, - [conversationId], + .listen(controller.insertOrReplace); + ref.onDispose(subscription.cancel); + ref.onDispose(controller.dispose); + return controller; + }); + +final _postGroupedItemsProvider = StreamProvider.autoDispose + .family>, _PostPageArgs>((ref, args) { + final controller = ref.watch(_postPagingControllerProvider(args)); + return (() async* { + Map> group(List list) => + groupBy(list, (messageItem) { + final local = messageItem.createdAt.toLocal(); + return DateTime(local.year, local.month, local.day); + }); + + yield group(controller.state.list); + yield* controller.stream.map((state) => group(state.list)).distinct(); + })(); + }); + +class PostPage extends HookConsumerWidget { + const PostPage({ + required this.maxHeight, + required this.conversationId, + super.key, + }); + + final double maxHeight; + final String conversationId; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final size = useMemoized(() => maxHeight / 90 * 2, [maxHeight]).toInt(); + final database = ref.read(databaseProvider).requireValue; + final args = ( + database: database, + conversationId: conversationId, + size: size, ); + final postController = ref.watch(_postPagingControllerProvider(args)); final map = - useBlocStateConverter< - LoadMorePagingBloc, - LoadMorePagingState, - Map> - >( - bloc: mediaCubit, - converter: (state) => - groupBy(state.list, (messageItem) { - final local = messageItem.createdAt.toLocal(); - return DateTime(local.year, local.month, local.day); - }), - ); + ref.watch(_postGroupedItemsProvider(args)).value ?? + const >{}; final scrollController = useScrollController(); @@ -93,17 +112,14 @@ class PostPage extends HookConsumerWidget { SvgPicture.asset( Resources.assetsImagesEmptyFileSvg, colorFilter: ColorFilter.mode( - context.theme.secondaryText.withValues(alpha: 0.4), + theme.secondaryText.withValues(alpha: 0.4), BlendMode.srcIn, ), ), const SizedBox(height: 24), Text( - context.l10n.noPosts, - style: TextStyle( - fontSize: 12, - color: context.theme.secondaryText, - ), + l10n.noPosts, + style: TextStyle(fontSize: 12, color: theme.secondaryText), ), ], ), @@ -120,7 +136,7 @@ class PostPage extends HookConsumerWidget { if (notification.metrics.maxScrollExtent - notification.metrics.pixels < dimension) { - mediaCubit.loadMore(); + postController.loadMore(); } return false; @@ -134,13 +150,13 @@ class PostPage extends HookConsumerWidget { children: [ SliverPinnedHeader( child: Container( - color: context.theme.primary, + color: theme.primary, padding: const EdgeInsets.all(10), child: Text( DateFormat.yMMMd().format(e.key.toLocal()), style: TextStyle( fontSize: 14, - color: context.theme.secondaryText, + color: theme.secondaryText, ), ), ), @@ -163,13 +179,13 @@ class PostPage extends HookConsumerWidget { } } -class _Item extends StatelessWidget { +class _Item extends ConsumerWidget { const _Item({required this.message}); final MessageItem message; @override - Widget build(BuildContext context) => Padding( + Widget build(BuildContext context, WidgetRef ref) => Padding( padding: const EdgeInsets.only(left: 10, right: 10, bottom: 10), child: ShareMediaItemMenuWrapper( messageId: message.messageId, @@ -179,7 +195,11 @@ class _Item extends StatelessWidget { content: message.content ?? '', padding: const EdgeInsets.all(8), decoration: BoxDecoration( - color: context.theme.sidebarSelected, + color: ref.watch( + brightnessThemeDataProvider.select( + (value) => value.sidebarSelected, + ), + ), borderRadius: const BorderRadius.all(Radius.circular(8)), ), showStatus: false, diff --git a/lib/ui/home/chat_slide_page/shared_apps_page.dart b/lib/ui/home/chat_slide_page/shared_apps_page.dart index 764a50d733..7f1a832bfe 100644 --- a/lib/ui/home/chat_slide_page/shared_apps_page.dart +++ b/lib/ui/home/chat_slide_page/shared_apps_page.dart @@ -4,13 +4,31 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../db/database_event_bus.dart'; import '../../../db/mixin_database.dart'; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../widgets/app_bar.dart'; import '../../../widgets/mixin_image.dart'; import '../../../widgets/user/user_dialog.dart'; import '../../provider/conversation_provider.dart'; - -class SharedAppsPage extends HookConsumerWidget { +import '../../provider/database_provider.dart'; +import '../../provider/ui_context_providers.dart'; + +final sharedAppsProvider = StreamProvider.autoDispose + .family, String?>((ref, userId) { + if (userId == null) { + return Stream.value(const []); + } + final database = ref.watch(databaseProvider).value; + if (database == null) { + return Stream.value(const []); + } + return database.favoriteAppDao + .getFavoriteAppsByUserId(userId) + .watchWithStream( + eventStreams: [DataBaseEventBus.instance.updateAppIdStream], + duration: kVerySlowThrottleDuration, + ); + }); + +class SharedAppsPage extends ConsumerWidget { const SharedAppsPage(this.conversationState, {super.key}); final ConversationState conversationState; @@ -18,22 +36,13 @@ class SharedAppsPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final userId = conversationState.userId; - - final apps = - useMemoizedStream(() { - if (userId == null) return Stream.value([]); - return context.database.favoriteAppDao - .getFavoriteAppsByUserId(userId) - .watchWithStream( - eventStreams: [DataBaseEventBus.instance.updateAppIdStream], - duration: kVerySlowThrottleDuration, - ); - }, keys: [userId]).data ?? - const []; + final apps = ref.watch(sharedAppsProvider(userId)).value ?? const []; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); return Scaffold( - backgroundColor: context.theme.primary, - appBar: MixinAppBar(title: Text(context.l10n.shareApps)), + backgroundColor: theme.primary, + appBar: MixinAppBar(title: Text(l10n.shareApps)), body: Column( children: [ const SizedBox(height: 6), @@ -44,74 +53,83 @@ class SharedAppsPage extends HookConsumerWidget { } } -class _AppTile extends StatelessWidget { +class _AppTile extends ConsumerWidget { const _AppTile({required this.app}); final App app; @override - Widget build(BuildContext context) => InkWell( - onTap: () => showUserDialog(context, app.appId), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), - child: Row( - children: [ - _AppIcon(app: app, size: 50), - const SizedBox(width: 10), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - app.name, - style: TextStyle(color: context.theme.text, fontSize: 16), - ), - const SizedBox(height: 3), - Text( - app.description, - maxLines: 1, - style: TextStyle( - color: context.theme.secondaryText, - fontSize: 14, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return InkWell( + onTap: () => showUserDialog(context, ref.container, app.appId), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 16), + child: Row( + children: [ + _AppIcon(app: app, size: 50), + const SizedBox(width: 10), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + app.name, + style: TextStyle( + color: theme.text, + fontSize: 16, + ), ), - overflow: TextOverflow.ellipsis, - ), - ], + const SizedBox(height: 3), + Text( + app.description, + maxLines: 1, + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), + overflow: TextOverflow.ellipsis, + ), + ], + ), ), - ), - ], + ], + ), ), - ), - ); + ); + } } -class OverlappedAppIcons extends StatelessWidget { +class OverlappedAppIcons extends ConsumerWidget { OverlappedAppIcons({required this.apps, super.key}) : assert(apps.isNotEmpty); final List apps; @override - Widget build(BuildContext context) => Stack( - children: [ - for (var index = 0; index < apps.length; index++) - Padding( - padding: EdgeInsets.fromLTRB(index.toDouble() * 14, 0, 0, 0), - child: ClipOval( - child: Container( - color: Color.alphaBlend( - context.theme.listSelected, - context.theme.popUp, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Stack( + children: [ + for (var index = 0; index < apps.length; index++) + Padding( + padding: EdgeInsets.fromLTRB(index.toDouble() * 14, 0, 0, 0), + child: ClipOval( + child: Container( + color: Color.alphaBlend( + theme.listSelected, + theme.popUp, + ), + padding: const EdgeInsets.all(2), + child: _AppIcon(size: 24, app: apps[index]), ), - padding: const EdgeInsets.all(2), - child: _AppIcon(size: 24, app: apps[index]), ), ), - ), - ].reversed.toList(), - ); + ].reversed.toList(), + ); + } } -class _AppIcon extends StatelessWidget { +class _AppIcon extends ConsumerWidget { const _AppIcon({required this.app, required this.size}); final App app; @@ -119,15 +137,18 @@ class _AppIcon extends StatelessWidget { final double size; @override - Widget build(BuildContext context) => ClipOval( - child: MixinImage.network( - app.iconUrl, - width: size, - height: size, - placeholder: () => SizedBox.fromSize( - size: Size.square(size), - child: ColoredBox(color: context.theme.listSelected), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return ClipOval( + child: MixinImage.network( + app.iconUrl, + width: size, + height: size, + placeholder: () => SizedBox.fromSize( + size: Size.square(size), + child: ColoredBox(color: theme.listSelected), + ), ), - ), - ); + ); + } } diff --git a/lib/ui/home/chat_slide_page/shared_media_page.dart b/lib/ui/home/chat_slide_page/shared_media_page.dart index a5800f443f..1be2ebdebd 100644 --- a/lib/ui/home/chat_slide_page/shared_media_page.dart +++ b/lib/ui/home/chat_slide_page/shared_media_page.dart @@ -3,13 +3,11 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:super_context_menu/super_context_menu.dart'; import '../../../constants/icon_fonts.dart'; -import '../../../utils/extension/extension.dart'; import '../../../widgets/app_bar.dart'; import '../../../widgets/menu.dart'; import '../../provider/conversation_provider.dart'; -import '../bloc/blink_cubit.dart'; - -import '../bloc/message_bloc.dart'; +import '../../provider/ui_context_providers.dart'; +import '../providers/home_scope_providers.dart'; import 'share_media/file_page.dart'; import 'share_media/media_page.dart'; import 'share_media/post_page.dart'; @@ -22,11 +20,13 @@ class SharedMediaPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final conversationId = conversationState.conversationId; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final selectedIndex = useState(0); return Scaffold( - backgroundColor: context.theme.primary, - appBar: MixinAppBar(title: Text(context.l10n.sharedMedia)), + backgroundColor: theme.primary, + appBar: MixinAppBar(title: Text(l10n.sharedMedia)), body: Column( children: [ Expanded( @@ -51,31 +51,36 @@ class SharedMediaPage extends HookConsumerWidget { ), ), Row( - children: [context.l10n.media, context.l10n.post, context.l10n.file] - .asMap() - .entries - .map( - (e) => Expanded( - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => selectedIndex.value = e.key, - child: Container( - height: 56, - alignment: Alignment.center, - child: Text( - e.value, - style: TextStyle( - fontSize: 14, - color: e.key == selectedIndex.value - ? context.theme.accent - : context.theme.secondaryText, + children: + [ + l10n.media, + l10n.post, + l10n.file, + ] + .asMap() + .entries + .map( + (e) => Expanded( + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => selectedIndex.value = e.key, + child: Container( + height: 56, + alignment: Alignment.center, + child: Text( + e.value, + style: TextStyle( + fontSize: 14, + color: e.key == selectedIndex.value + ? theme.accent + : theme.secondaryText, + ), + ), ), ), ), - ), - ), - ) - .toList(), + ) + .toList(), ), ], ), @@ -83,7 +88,7 @@ class SharedMediaPage extends HookConsumerWidget { } } -class ShareMediaItemMenuWrapper extends StatelessWidget { +class ShareMediaItemMenuWrapper extends ConsumerWidget { const ShareMediaItemMenuWrapper({ required this.child, required this.messageId, @@ -94,20 +99,25 @@ class ShareMediaItemMenuWrapper extends StatelessWidget { final String messageId; @override - Widget build(BuildContext context) => CustomContextMenuWidget( - desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), - menuProvider: (request) => Menu( - children: [ - MenuAction( - image: MenuImage.icon(IconFonts.positionToChat), - title: context.l10n.locateToChat, - callback: () { - context.read().blinkByMessageId(messageId); - context.read().scrollTo(messageId); - }, - ), - ], - ), - child: child, - ); + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + return CustomContextMenuWidget( + desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), + menuProvider: (request) => Menu( + children: [ + MenuAction( + image: MenuImage.icon(IconFonts.positionToChat), + title: l10n.locateToChat, + callback: () { + ref + .read(blinkControllerProvider.notifier) + .blinkByMessageId(messageId); + ref.read(messageControllerProvider.notifier).scrollTo(messageId); + }, + ), + ], + ), + child: child, + ); + } } diff --git a/lib/ui/home/controllers/blink_controller.dart b/lib/ui/home/controllers/blink_controller.dart new file mode 100644 index 0000000000..26b9374de7 --- /dev/null +++ b/lib/ui/home/controllers/blink_controller.dart @@ -0,0 +1,114 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../../constants/brightness_theme_data.dart'; +import '../../provider/ui_context_providers.dart'; + +class BlinkState extends Equatable { + const BlinkState({this.color = Colors.transparent, this.messageId}); + + final Color color; + final String? messageId; + + @override + List get props => [color, messageId]; + + BlinkState copyWith({Color? color, String? messageId}) => BlinkState( + color: color ?? this.color, + messageId: messageId ?? this.messageId, + ); +} + +final blinkColorProvider = Provider( + (ref) { + try { + return ref + .watch(brightnessThemeDataProvider) + .accent + .withValues(alpha: 0.5); + } catch (_) { + return lightBrightnessThemeData.accent.withValues(alpha: 0.5); + } + }, + dependencies: [brightnessThemeDataProvider], +); + +final blinkControllerProvider = + NotifierProvider.autoDispose<_BlinkNotifier, BlinkState>( + _BlinkNotifier.new, + dependencies: [blinkColorProvider], + ); + +class _BlinkNotifier extends Notifier { + final StreamController _streamController = + StreamController.broadcast(); + Timer? _timer; + Color? _blinkColor; + static const _blinkDuration = Duration(milliseconds: 1200); + static const _tickInterval = Duration(milliseconds: 16); + + Stream get stream => _streamController.stream; + + void _emit(BlinkState nextState) { + if (state == nextState) return; + state = nextState; + if (!_streamController.isClosed) { + _streamController.add(nextState); + } + } + + void _stopBlinking() { + _timer?.cancel(); + _timer = null; + } + + @override + BlinkState build() { + ref.onDispose(() { + _stopBlinking(); + unawaited(_streamController.close()); + }); + + final blinkColor = ref.watch(blinkColorProvider); + if (_blinkColor != blinkColor) { + _blinkColor = blinkColor; + } + return stateOrNull ?? const BlinkState(); + } + + void blinkByMessageId(String messageId) { + final blinkColor = _blinkColor ?? ref.read(blinkColorProvider); + _stopBlinking(); + final startedAt = DateTime.now(); + + void emitForProgress(double progress) { + final normalized = progress.clamp(0.0, 1.0); + final triangle = normalized <= 0.5 + ? normalized * 2 + : (1 - normalized) * 2; + final eased = Curves.easeInOut.transform(triangle); + _emit( + BlinkState( + messageId: messageId, + color: Color.lerp(Colors.transparent, blinkColor, eased)!, + ), + ); + } + + emitForProgress(0); + _timer = Timer.periodic(_tickInterval, (timer) { + final elapsed = DateTime.now().difference(startedAt); + final progress = elapsed.inMicroseconds / _blinkDuration.inMicroseconds; + if (progress >= 1) { + timer.cancel(); + _timer = null; + _emit(const BlinkState()); + return; + } + emitForProgress(progress); + }); + } +} diff --git a/lib/ui/home/bloc/conversation_list_bloc.dart b/lib/ui/home/controllers/conversation_list_controller.dart similarity index 63% rename from lib/ui/home/bloc/conversation_list_bloc.dart rename to lib/ui/home/controllers/conversation_list_controller.dart index fc9378d5a7..c379d26e7c 100644 --- a/lib/ui/home/bloc/conversation_list_bloc.dart +++ b/lib/ui/home/controllers/conversation_list_controller.dart @@ -1,48 +1,41 @@ import 'dart:async'; import 'package:flutter_app_icon_badge/flutter_app_icon_badge.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:rxdart/rxdart.dart' hide ThrottleExtensions; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; -import '../../../bloc/paging/paging_bloc.dart'; -import '../../../bloc/subscribe_mixin.dart'; import '../../../db/dao/conversation_dao.dart'; import '../../../db/database.dart'; import '../../../db/database_event_bus.dart'; +import '../../../paging/paging_controller.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/logger.dart'; import '../../../utils/platform.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/slide_category_provider.dart'; const kDefaultLimit = 15; -class ConversationListBloc extends Cubit> - with SubscribeMixin { - ConversationListBloc( - this.slideCategoryStateNotifier, - this.database, - this.mentionCache, - ) : super(const PagingState()) { - addSubscription( - slideCategoryStateNotifier.stream.distinct().listen( - (event) => _switchBloc(event, _limit), - ), - ); - _initBadge(); - } +class ConversationListController + extends Notifier> { + SlideCategoryStateNotifier get slideCategoryStateNotifier => + ref.read(slideCategoryProvider.notifier); + Database get database => + ref.read(accountServerProvider).requireValue.database; + MentionCache get mentionCache => ref.read(mentionCacheProvider); - final SlideCategoryStateNotifier slideCategoryStateNotifier; - final Database database; - final MentionCache mentionCache; - final Map _map = {}; + final Map _map = {}; + StreamSubscription? _slideCategorySubscription; + StreamSubscription>? _streamSubscription; + bool _badgeInitialized = false; int? _limit; int get limit { if (_limit == null) { - w('conversation list bloc: limit is null'); + w('conversation list controller: limit is null'); return kDefaultLimit; } return _limit!; @@ -50,19 +43,41 @@ class ConversationListBloc extends Cubit> set limit(int limit) { if (limit <= 0) { - w('conversation list bloc: ignore limit <= 0'); + w('conversation list controller: ignore limit <= 0'); return; } _limit = limit; - _map.values.forEach((element) { - element.limit = limit; - }); + for (final controller in _map.values) { + controller.limit = limit; + } } - StreamSubscription? streamSubscription; SlideCategoryState? _activeState; + @override + PagingState build() { + _slideCategorySubscription ??= slideCategoryStateNotifier.stream + .distinct() + .listen((event) => _switchController(event, _limit)); + if (!_badgeInitialized) { + _badgeInitialized = true; + unawaited(_initBadge()); + } + + ref.onDispose(() async { + await _slideCategorySubscription?.cancel(); + await _streamSubscription?.cancel(); + _map[_activeState]?.deactivate(); + for (final controller in _map.values) { + controller.dispose(); + } + _map.clear(); + }); + + return stateOrNull ?? const PagingState(); + } + ItemPositionsListener? itemPositionsListener( SlideCategoryState slideCategoryState, ) => _map[slideCategoryState]?.itemPositionsListener; @@ -71,16 +86,16 @@ class ConversationListBloc extends Cubit> SlideCategoryState slideCategoryState, ) => _map[slideCategoryState]?.itemScrollController; - void init() => _switchBloc(slideCategoryStateNotifier.state, _limit); + void init() => _switchController(slideCategoryStateNotifier.state, _limit); - late Stream updateEvent = Rx.merge([ + late final Stream updateEvent = Rx.merge([ DataBaseEventBus.instance.updateConversationIdStream, DataBaseEventBus.instance.updateUserIdsStream, DataBaseEventBus.instance.insertOrReplaceMessageIdsStream, DataBaseEventBus.instance.updateMessageMentionStream, ]).throttleTime(kDefaultThrottleDuration).asBroadcastStream(); - late Stream circleUpdateEvent = Rx.merge([ + late final Stream circleUpdateEvent = Rx.merge([ DataBaseEventBus.instance.updateConversationIdStream, DataBaseEventBus.instance.updateUserIdsStream, DataBaseEventBus.instance.insertOrReplaceMessageIdsStream, @@ -89,7 +104,7 @@ class ConversationListBloc extends Cubit> DataBaseEventBus.instance.updateCircleConversationStream, ]).throttleTime(kDefaultThrottleDuration).asBroadcastStream(); - void _switchBloc(SlideCategoryState state, int? limit) { + void _switchController(SlideCategoryState state, int? limit) { final dao = database.conversationDao; switch (state.type) { @@ -98,7 +113,7 @@ class ConversationListBloc extends Cubit> case SlideCategoryType.groups: case SlideCategoryType.bots: case SlideCategoryType.strangers: - _map[state] ??= _ConversationListBloc( + _map[state] ??= _ConversationListController( limit ?? kDefaultLimit, () => dao.conversationCountByCategory(state.type), (limit, offset) => @@ -108,7 +123,7 @@ class ConversationListBloc extends Cubit> () => dao.conversationHasDataByCategory(state.type), ); case SlideCategoryType.circle: - _map[state] ??= _ConversationListBloc( + _map[state] ??= _ConversationListController( limit ?? kDefaultLimit, () => database.conversationDao .conversationsCountByCircleId(state.id!) @@ -124,31 +139,25 @@ class ConversationListBloc extends Cubit> case SlideCategoryType.setting: return; } + final previous = _activeState; if (previous != null && previous != state) { _map[previous]?.deactivate(); } - final bloc = _map[state]!..activate(); + final controller = _map[state]!..activate(); _activeState = state; - emit(bloc.state); - streamSubscription?.cancel(); - streamSubscription = bloc.stream.listen(emit); - } - - @override - Future close() async { - await streamSubscription?.cancel(); - _map[_activeState]?.deactivate(); - await Future.wait(_map.values.map((e) => e.close())); - await super.close(); + this.state = controller.state; + unawaited(_streamSubscription?.cancel()); + _streamSubscription = controller.stream.listen( + (value) => this.state = value, + ); } Future _initBadge() async { Future updateBadge(int count) async { if (!kPlatformIsDarwin) { - // not work on other platform. return; } if (count == 0) { @@ -158,21 +167,26 @@ class ConversationListBloc extends Cubit> await FlutterAppIconBadge.updateBadge(count); } - final count = await database.conversationDao + if (!ref.mounted) return; + final db = database; + final count = await db.conversationDao .allUnseenIgnoreMuteMessageCount() .getSingle(); + if (!ref.mounted) return; await updateBadge(count); - addSubscription( - database.conversationDao.allUnseenIgnoreMuteMessageCountEvent - .distinct() - .asyncBufferMap((event) => updateBadge(event.last)) - .listen((_) {}), - ); + if (!ref.mounted) return; + final subscription = db + .conversationDao + .allUnseenIgnoreMuteMessageCountEvent + .distinct() + .asyncBufferMap((event) => updateBadge(event.last)) + .listen((_) {}); + ref.onDispose(subscription.cancel); } } -class _ConversationListBloc extends PagingBloc { - _ConversationListBloc( +class _ConversationListController extends PagingController { + _ConversationListController( int limit, Future Function() queryCount, Future> Function(int limit, int offset) queryRange, @@ -201,23 +215,17 @@ class _ConversationListBloc extends PagingBloc { final ItemScrollController itemScrollController = ItemScrollController(); void activate() { - _updateSubscription ??= _updateEvent.listen( - (_) => add(PagingUpdateEvent()), - ); - add(PagingUpdateEvent()); + _updateSubscription ??= _updateEvent.listen((_) { + unawaited(refresh()); + }); + unawaited(refresh()); } void deactivate() { - _updateSubscription?.cancel(); + unawaited(_updateSubscription?.cancel()); _updateSubscription = null; } - @override - Future close() async { - deactivate(); - await super.close(); - } - @override Future queryCount() => _queryCount(); @@ -225,10 +233,15 @@ class _ConversationListBloc extends PagingBloc { Future> queryRange(int limit, int offset) async { final list = await _queryRange(limit, offset); await mentionCache.checkMentionCache(list.map((e) => e.content).toSet()); - return list; } @override Future queryHasData() => _queryHasData(); + + @override + void dispose() { + deactivate(); + super.dispose(); + } } diff --git a/lib/ui/home/bloc/message_bloc.dart b/lib/ui/home/controllers/message_controller.dart similarity index 62% rename from lib/ui/home/bloc/message_bloc.dart rename to lib/ui/home/controllers/message_controller.dart index e606123881..598745cae6 100644 --- a/lib/ui/home/bloc/message_bloc.dart +++ b/lib/ui/home/controllers/message_controller.dart @@ -1,15 +1,13 @@ import 'dart:async'; -import 'package:bloc/bloc.dart'; -import 'package:bloc_concurrency/bloc_concurrency.dart'; import 'package:equatable/equatable.dart'; import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:mixin_logger/mixin_logger.dart'; import 'package:rxdart/rxdart.dart'; import '../../../account/account_server.dart'; -import '../../../bloc/subscribe_mixin.dart'; import '../../../db/dao/message_dao.dart'; import '../../../db/database.dart'; import '../../../db/database_event_bus.dart'; @@ -17,61 +15,11 @@ import '../../../db/mixin_database.dart'; import '../../../enum/message_category.dart'; import '../../../utils/app_lifecycle.dart'; import '../../../utils/extension/extension.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/database_provider.dart'; import '../../provider/mention_cache_provider.dart'; - -abstract class _MessageEvent extends Equatable { - @override - List get props => []; -} - -class _MessageJumpCurrentEvent extends _MessageEvent {} - -class _MessageInitEvent extends _MessageEvent { - _MessageInitEvent({this.centerMessageId, this.lastReadMessageId}); - - final String? centerMessageId; - final String? lastReadMessageId; - - @override - List get props => [centerMessageId, lastReadMessageId]; - - @override - final stringify = true; -} - -class _MessageScrollEvent extends _MessageEvent { - _MessageScrollEvent({required this.messageId}); - - final String messageId; - - @override - List get props => [messageId]; -} - -class _MessageLoadMoreEvent extends _MessageEvent {} - -class _MessageLoadAfterEvent extends _MessageLoadMoreEvent {} - -class _MessageLoadBeforeEvent extends _MessageLoadMoreEvent {} - -class _MessageInsertOrReplaceEvent extends _MessageEvent { - _MessageInsertOrReplaceEvent(this.data); - - final List data; - - @override - List get props => [data]; -} - -class _MessageDeleteEvent extends _MessageEvent { - _MessageDeleteEvent(this.messageId); - - final String messageId; - - @override - List get props => [messageId]; -} +import '../providers/home_scope_providers.dart'; class MessageState extends Equatable { MessageState({ @@ -84,7 +32,6 @@ class MessageState extends Equatable { this.lastReadMessageId, this.refreshKey, }) { - // check top, center, bottom didn't has same messageId assert(() { final ids = {}; for (final item in list) { @@ -126,11 +73,7 @@ class MessageState extends Equatable { bool get isEmpty => top.isEmpty && center == null && bottom.isEmpty; - List get list => [ - ...top, - ?center, - ...bottom, - ]; + List get list => [...top, ?center, ...bottom]; MessageState copyWith({ String? conversationId, @@ -152,7 +95,7 @@ class MessageState extends Equatable { refreshKey: refreshKey ?? this.refreshKey, ); - MessageState _copyWithJumpCurrentState() => MessageState( + MessageState copyWithJumpCurrentState() => MessageState( top: list.toList(), refreshKey: Object(), conversationId: conversationId, @@ -189,51 +132,74 @@ class MessageState extends Equatable { } } -class MessageBloc extends Bloc<_MessageEvent, MessageState> - with SubscribeMixin { - MessageBloc({ - required this.conversationNotifier, - required this.limit, - required this.database, - required this.mentionCache, - required this.accountServer, - }) : super(MessageState()) { - on<_MessageInitEvent>( - _onEvent, - transformer: restartable(), - ); - on<_MessageLoadAfterEvent>( - _onEvent, - transformer: droppable(), - ); - on<_MessageLoadBeforeEvent>( - _onEvent, - transformer: droppable(), - ); - on<_MessageInsertOrReplaceEvent>( - _onEvent, - transformer: restartable(), - ); - on<_MessageDeleteEvent>( - _onEvent, - transformer: sequential(), - ); - on<_MessageScrollEvent>( - _onEvent, - transformer: restartable(), - ); - on<_MessageJumpCurrentEvent>( - _onEvent, - transformer: droppable(), - ); +class MessageController extends Notifier { + final ScrollController scrollController = ScrollController(); + final List> _subscriptions = []; + ConversationStateNotifier? _conversationNotifier; + Database? _database; + MentionCache? _mentionCache; + AccountServer? _accountServer; + int _limit = 0; + bool _initialized = false; + + int _initToken = 0; + bool _loadingBefore = false; + bool _loadingAfter = false; + int? _lastBuiltLimit; + + ConversationStateNotifier get conversationNotifier => _conversationNotifier!; + Database get database => _database!; + MentionCache get mentionCache => _mentionCache!; + AccountServer get accountServer => _accountServer!; + int get limit => _limit; + set limit(int value) => _limit = value; - add( - _MessageInitEvent( - centerMessageId: conversationNotifier.state?.initIndexMessageId, - lastReadMessageId: conversationNotifier.state?.lastReadMessageId, - ), - ); - addSubscription( + @override + MessageState build() { + final accountServer = ref.watch(accountServerProvider).value; + final database = ref.watch(databaseProvider).value; + if (accountServer == null || database == null) { + throw StateError('MessageControllerDeps'); + } + + _accountServer = accountServer; + _database = database; + _mentionCache = ref.watch(mentionCacheProvider); + _conversationNotifier = ref.read(conversationProvider.notifier); + final nextLimit = + ref.watch(messagePageLimitProvider) ?? (_limit > 0 ? _limit : 30); + final limitChanged = + _lastBuiltLimit != null && _lastBuiltLimit != nextLimit; + _limit = nextLimit; + _lastBuiltLimit = nextLimit; + + _resetSubscriptions(); + _bindConversation(); + _bindUpdates(); + if (!_initialized) { + _initialized = true; + unawaited( + initialize( + centerMessageId: conversationNotifier.state?.initIndexMessageId, + lastReadMessageId: conversationNotifier.state?.lastReadMessageId, + ), + ); + } else if (limitChanged) { + reload(); + } + + ref.onDispose(() { + scrollController.dispose(); + _resetSubscriptions(); + }); + + return stateOrNull ?? MessageState(); + } + + MessageDao get messageDao => database.messageDao; + + void _bindConversation() { + _subscriptions.add( conversationNotifier.stream .where((event) => event?.conversationId != null) .map( @@ -245,16 +211,19 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> ), ) .distinct() - .asyncMap( - (event) async => _MessageInitEvent( - centerMessageId: event.$2, - lastReadMessageId: event.$3, - ), - ) - .listen(add), + .listen((event) { + unawaited( + initialize( + centerMessageId: event.$2, + lastReadMessageId: event.$3, + ), + ); + }), ); + } - addSubscription( + void _bindUpdates() { + _subscriptions.add( conversationNotifier.stream .startWith(conversationNotifier.state) .map((event) => event?.conversationId) @@ -265,101 +234,151 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> } return messageDao.watchInsertOrReplaceMessageStream(conversationId); }) - .listen((state) => add(_MessageInsertOrReplaceEvent(state))), + .listen((items) { + unawaited(insertOrReplace(items)); + }), ); - addSubscription( + _subscriptions.add( DataBaseEventBus.instance.deleteMessageIdStream.listen((messageIds) { - messageIds.forEach((messageId) { - add(_MessageDeleteEvent(messageId)); - }); + for (final messageId in messageIds) { + deleteMessage(messageId); + } }), ); } - final ScrollController scrollController = ScrollController(); - final ConversationStateNotifier conversationNotifier; - final Database database; - final MentionCache mentionCache; - final AccountServer accountServer; - int limit; + void _resetSubscriptions() { + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + _subscriptions.clear(); + } - MessageDao get messageDao => database.messageDao; + Future initialize({ + String? centerMessageId, + String? lastReadMessageId, + }) async { + final conversationId = conversationNotifier.state?.conversationId; + if (conversationId == null) return; - Future _preCacheMention(MessageState state) async { - final set = {...state.top, state.center, ...state.bottom}; - await mentionCache.checkMentionCache( - { - ...set.map((e) => e?.content), - ...set.map((e) => e?.quoteContent), - }.nonNulls.toSet(), + final token = ++_initToken; + final messageState = await _resetMessageList( + conversationId, + limit, + centerMessageId, + ); + if (token != _initToken) return; + + await _preCacheMention(messageState); + if (token != _initToken) return; + + state = _pretreatment( + messageState.copyWith( + refreshKey: Object(), + lastReadMessageId: lastReadMessageId, + ), ); } - Future _onEvent(_MessageEvent event, Emitter emit) async { - // Avoid value change - final finalLimit = limit; + Future after() async { + if (_loadingAfter || state.isLatest) return; final conversationId = conversationNotifier.state?.conversationId; - if (conversationId == null) return; - // If the conversationId has changed, then events other than init are ignored - if (event is! _MessageInitEvent && state.conversationId != conversationId) { + if (conversationId == null || state.conversationId != conversationId) { return; } - if (event is _MessageInitEvent) { - final messageState = await _resetMessageList( - conversationId, - finalLimit, - event.centerMessageId, - ); + _loadingAfter = true; + try { + final messageState = await _after(conversationId); await _preCacheMention(messageState); - emit( - _pretreatment( - messageState.copyWith( - refreshKey: Object(), - lastReadMessageId: event.lastReadMessageId, - ), - ), - ); - } else if (event is _MessageDeleteEvent) { - final messageState = state.removeMessage(event.messageId); - emit(_pretreatment(messageState)); - } else { - if (event is _MessageLoadMoreEvent) { - if (event is _MessageLoadAfterEvent) { - if (state.isLatest) return; - final messageState = await _after(conversationId); - await _preCacheMention(messageState); - emit(_pretreatment(messageState)); - } else if (event is _MessageLoadBeforeEvent) { - if (state.isOldest) return; - final messageState = await _before(conversationId); - await _preCacheMention(messageState); - emit(_pretreatment(messageState)); - } - } else if (event is _MessageInsertOrReplaceEvent) { - final result = _insertOrReplace(conversationId, event.data); - if (result != null) { - await _preCacheMention(result); - emit(_pretreatment(result)); - } - } else if (event is _MessageScrollEvent) { - add( - _MessageInitEvent( - centerMessageId: event.messageId, - lastReadMessageId: state.lastReadMessageId, - ), - ); - } else if (event is _MessageJumpCurrentEvent) { - emit(_pretreatment(state._copyWithJumpCurrentState())); + if (state.conversationId == conversationId) { + state = _pretreatment(messageState); } + } finally { + _loadingAfter = false; } } - void after() => add(_MessageLoadAfterEvent()); + Future before() async { + if (_loadingBefore || state.isOldest) return; - void before() => add(_MessageLoadBeforeEvent()); + final conversationId = conversationNotifier.state?.conversationId; + if (conversationId == null || state.conversationId != conversationId) { + return; + } + + _loadingBefore = true; + try { + final messageState = await _before(conversationId); + await _preCacheMention(messageState); + if (state.conversationId == conversationId) { + state = _pretreatment(messageState); + } + } finally { + _loadingBefore = false; + } + } + + Future insertOrReplace(List items) async { + final conversationId = conversationNotifier.state?.conversationId; + if (conversationId == null || state.conversationId != conversationId) { + return; + } + + final result = _insertOrReplace(conversationId, items); + if (result == null) return; + + await _preCacheMention(result); + if (state.conversationId == conversationId) { + state = _pretreatment(result); + } + } + + void deleteMessage(String messageId) { + final conversationId = conversationNotifier.state?.conversationId; + if (conversationId == null || state.conversationId != conversationId) { + return; + } + state = _pretreatment(state.removeMessage(messageId)); + } + + void scrollTo(String messageId) { + unawaited( + initialize( + centerMessageId: messageId, + lastReadMessageId: state.lastReadMessageId, + ), + ); + } + + void reload() { + unawaited( + initialize( + centerMessageId: conversationNotifier.state?.initIndexMessageId, + lastReadMessageId: conversationNotifier.state?.lastReadMessageId, + ), + ); + } + + void jumpToCurrent() { + if (scrollController.hasClients && state.isLatest) { + state = _pretreatment(state.copyWithJumpCurrentState()); + return; + } + reload(); + } + + Future _preCacheMention(MessageState state) async { + final set = {...state.top, state.center, ...state.bottom}; + await mentionCache.checkMentionCache( + { + ...set.map((e) => e?.content), + ...set.map((e) => e?.quoteContent), + }.nonNulls.toSet(), + ); + } Future _before(String conversationId) async { final topMessageId = state.topMessage?.messageId; @@ -397,23 +416,23 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> String? centerMessageId, ]) async { final conversation = conversationNotifier.state?.conversation; - final _centerMessageId = + final resolvedCenterMessageId = centerMessageId ?? ((conversation?.unseenMessageCount ?? 0) > 0 ? conversation?.lastReadMessageId : null); - final state = await _messagesByConversationId( + final nextState = await _messagesByConversationId( conversationId, limit, - centerMessageId: _centerMessageId, + centerMessageId: resolvedCenterMessageId, ); - return state.copyWith( + return nextState.copyWith( conversationId: conversationId, - center: state.center, - bottom: state.bottom, - top: state.top, + center: nextState.center, + bottom: nextState.bottom, + top: nextState.top, ); } @@ -441,19 +460,19 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> return recentMessages(); } - final _limit = limit ~/ 2; + final halfLimit = limit ~/ 2; final bottomList = await messageDao - .afterMessagesByConversationId(info, conversationId, _limit) + .afterMessagesByConversationId(info, conversationId, halfLimit) .get(); var topList = (await messageDao - .beforeMessagesByConversationId(info, conversationId, _limit) + .beforeMessagesByConversationId(info, conversationId, halfLimit) .get()) .reversed .toList(); - final isLatest = bottomList.length < _limit; - final isOldest = topList.length < _limit; + final isLatest = bottomList.length < halfLimit; + final isOldest = topList.length < halfLimit; var center = await messageDao .messageItemByMessageId(centerMessageId) @@ -506,7 +525,6 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> continue; } - // if don't have messages or older message after then valid item if (state.topMessage?.type != MessageCategory.secret && (state.topMessage?.createdAt.isAfter(item.createdAt) ?? false)) { continue; @@ -522,14 +540,16 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> top = [item, ...top] ..sort((a, b) => b.createdAt.compareTo(a.createdAt)); } - final position = scrollController.position; - jumpToBottom = - currentUserSent || - (position.hasContentDimensions && - position.pixels == position.maxScrollExtent); + if (scrollController.hasClients) { + final position = scrollController.position; + jumpToBottom = + currentUserSent || + (position.hasContentDimensions && + position.pixels == position.maxScrollExtent); + } } else { if (currentUserSent && item.status == MessageStatus.sending) { - add(_MessageInitEvent()); + reload(); return null; } } @@ -538,28 +558,13 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> final result = state.copyWith(top: top, center: center, bottom: bottom); if (scrollController.hasClients && jumpToBottom) { - return result._copyWithJumpCurrentState(); + return result.copyWithJumpCurrentState(); } return result; } - void scrollTo(String messageId) => - add(_MessageScrollEvent(messageId: messageId)); - - void reload() { - add(_MessageInitEvent()); - } - - void jumpToCurrent() { - if (scrollController.hasClients && state.isLatest) { - return add(_MessageJumpCurrentEvent()); - } - return add(_MessageInitEvent()); - } - MessageState _pretreatment(MessageState messageState) { List? top; - // check secretMessage if (messageState.isOldest && conversationNotifier.state?.isBot == false) { if (messageState.top.firstOrNull?.type == MessageCategory.secret) { messageState.top.remove(messageState.top.first); @@ -583,10 +588,10 @@ class MessageBloc extends Bloc<_MessageEvent, MessageState> ...messageState.top, ]; } - final _messageState = messageState.copyWith(top: top); + final nextState = messageState.copyWith(top: top); if (isAppActive) { accountServer.markRead(conversationNotifier.state!.conversationId); } - return _messageState; + return nextState; } } diff --git a/lib/ui/home/controllers/search_message_controller.dart b/lib/ui/home/controllers/search_message_controller.dart new file mode 100644 index 0000000000..809f9922d5 --- /dev/null +++ b/lib/ui/home/controllers/search_message_controller.dart @@ -0,0 +1,223 @@ +import 'dart:async'; + +import 'package:equatable/equatable.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; + +import '../../../db/dao/message_dao.dart'; +import '../../../db/database.dart'; +import '../../../utils/extension/extension.dart'; +import '../../../utils/logger.dart'; +import '../../provider/slide_category_provider.dart'; + +class SearchMessageState with EquatableMixin { + const SearchMessageState(this.items, this.loading); + + final List items; + final bool loading; + + bool get initializing => items.isEmpty && loading; + + @override + List get props => [items, loading]; +} + +class ConversationSearchMessageArgs with EquatableMixin { + const ConversationSearchMessageArgs({ + required this.database, + required this.keyword, + required this.conversationId, + required this.limit, + this.userId, + this.categories, + }); + + final Database database; + final String keyword; + final String conversationId; + final String? userId; + final List? categories; + final int limit; + + @override + List get props => [ + database, + keyword, + conversationId, + userId, + categories, + limit, + ]; +} + +class SlideCategorySearchMessageArgs with EquatableMixin { + const SlideCategorySearchMessageArgs({ + required this.database, + required this.keyword, + required this.category, + required this.limit, + }); + + final Database database; + final String keyword; + final SlideCategoryState category; + final int limit; + + @override + List get props => [database, keyword, category, limit]; +} + +class ConversationSearchMessageNotifier extends Notifier { + ConversationSearchMessageNotifier(this._args); + + final itemPositionsListener = ItemPositionsListener.create(); + var _hasMore = true; + final ConversationSearchMessageArgs _args; + + @override + SearchMessageState build() { + _hasMore = true; + itemPositionsListener.itemPositions.addListener(_onItemPosition); + ref.onDispose( + () => itemPositionsListener.itemPositions.removeListener(_onItemPosition), + ); + Future.microtask(_load); + return const SearchMessageState([], false); + } + + Future _load() async { + if (state.loading) { + w('search message notifier: loading, ignore'); + return; + } + if (!_hasMore) return; + + final lastMessageId = state.items.lastOrNull?.messageId; + state = SearchMessageState(state.items, true); + try { + final items = await _doFuzzySearch(lastMessageId); + if (items.isEmpty) { + d('search message notifier: no more data $lastMessageId'); + _hasMore = false; + } + state = SearchMessageState([...state.items, ...items], false); + } catch (error, stacktrace) { + e('search message notifier: load error', error, stacktrace); + state = SearchMessageState(state.items, false); + _hasMore = false; + } + } + + Future> _doFuzzySearch( + String? anchorMessageId, + ) { + if (_args.keyword.isEmpty) { + return _loadUserMessages(anchorMessageId); + } + return _args.database.fuzzySearchMessage( + query: _args.keyword, + limit: _args.limit, + conversationIds: [_args.conversationId], + userId: _args.userId, + categories: _args.categories, + anchorMessageId: anchorMessageId, + ); + } + + Future> _loadUserMessages( + String? anchorMessageId, + ) { + final userId = _args.userId; + if (userId == null) { + return Future.value([]); + } + return _args.database.messageDao.messageByConversationAndUser( + userId: userId, + limit: _args.limit, + anchorMessageId: anchorMessageId, + conversationId: _args.conversationId, + categories: _args.categories, + ); + } + + void _onItemPosition() { + final itemPositionValue = itemPositionsListener.itemPositions.value; + if (itemPositionValue.isEmpty) return; + + final lastIndex = itemPositionValue.last.index; + if (lastIndex >= state.items.length - 4) { + unawaited(_load()); + } + } +} + +class SlideCategorySearchMessageNotifier extends Notifier { + SlideCategorySearchMessageNotifier(this._args); + + final itemPositionsListener = ItemPositionsListener.create(); + var _hasMore = true; + final SlideCategorySearchMessageArgs _args; + + @override + SearchMessageState build() { + _hasMore = true; + itemPositionsListener.itemPositions.addListener(_onItemPosition); + ref.onDispose( + () => itemPositionsListener.itemPositions.removeListener(_onItemPosition), + ); + Future.microtask(_load); + return const SearchMessageState([], false); + } + + Future _load() async { + if (state.loading) { + w('search message notifier: loading, ignore'); + return; + } + if (!_hasMore) return; + + final lastMessageId = state.items.lastOrNull?.messageId; + state = SearchMessageState(state.items, true); + try { + final items = await _args.database.fuzzySearchMessageByCategory( + _args.keyword, + category: _args.category, + limit: _args.limit, + anchorMessageId: lastMessageId, + ); + if (items.isEmpty) { + d('search message notifier: no more data $lastMessageId'); + _hasMore = false; + } + state = SearchMessageState([...state.items, ...items], false); + } catch (error, stacktrace) { + e('search message notifier: load error', error, stacktrace); + state = SearchMessageState(state.items, false); + _hasMore = false; + } + } + + void _onItemPosition() { + final itemPositionValue = itemPositionsListener.itemPositions.value; + if (itemPositionValue.isEmpty) return; + + final lastIndex = itemPositionsListener.itemPositions.value.last.index; + if (lastIndex >= state.items.length - 4) { + unawaited(_load()); + } + } +} + +final conversationSearchMessageStateProvider = NotifierProvider.autoDispose + .family< + ConversationSearchMessageNotifier, + SearchMessageState, + ConversationSearchMessageArgs + >(ConversationSearchMessageNotifier.new); + +final slideCategorySearchMessageStateProvider = NotifierProvider.autoDispose + .family< + SlideCategorySearchMessageNotifier, + SearchMessageState, + SlideCategorySearchMessageArgs + >(SlideCategorySearchMessageNotifier.new); diff --git a/lib/ui/home/conversation/audio_player_bar.dart b/lib/ui/home/conversation/audio_player_bar.dart index 314bc0c626..a02f42149a 100644 --- a/lib/ui/home/conversation/audio_player_bar.dart +++ b/lib/ui/home/conversation/audio_player_bar.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -7,40 +6,53 @@ import '../../../constants/resources.dart'; import '../../../db/dao/conversation_dao.dart'; import '../../../db/database_event_bus.dart'; import '../../../db/mixin_database.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/audio_message_player/audio_message_service.dart'; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../widgets/action_button.dart'; import '../../../widgets/avatar_view/avatar_view.dart'; import '../../../widgets/interactive_decorated_box.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/database_provider.dart'; +import '../providers/home_scope_providers.dart'; -class AudioPlayerBar extends HookConsumerWidget { - const AudioPlayerBar({super.key}); - - @override - Widget build(BuildContext context, WidgetRef ref) { - final message = useCurrentPlayingMessage(); - - final state = useAudioMessagePlayerState(); - - final conversationItem = useMemoizedStream(() { - if (message == null) { +final _audioPlayerConversationProvider = StreamProvider.autoDispose + .family((ref, conversationId) { + final database = ref.watch(databaseProvider).value; + if (database == null) { return Stream.value(null); } - return context.database.conversationDao - .conversationItem(message.conversationId) + return database.conversationDao + .conversationItem(conversationId) .watchSingleOrNullWithStream( eventStreams: [ DataBaseEventBus.instance.watchUpdateConversationStream([ - message.conversationId, + conversationId, ]), ], duration: kSlowThrottleDuration, ); - }, keys: [message?.conversationId]).data; + }); + +class AudioPlayerBar extends ConsumerWidget { + const AudioPlayerBar({super.key}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final message = ref.watch(currentPlayingAudioMessageProvider).value; + final state = + ref.watch(audioPlayerPlaybackStateProvider).value ?? PlaybackState.idle; + final conversationItem = message == null + ? null + : ref + .watch( + _audioPlayerConversationProvider(message.conversationId), + ) + .value; final selectedConversationId = ref.watch(currentConversationIdProvider); + final audioService = ref.read(audioMessagePlayServiceProvider); if (state == PlaybackState.idle || state == PlaybackState.completed || @@ -54,6 +66,7 @@ class AudioPlayerBar extends HookConsumerWidget { return; } ConversationStateNotifier.selectConversation( + ref.container, context, conversationItem.conversationId, conversation: conversationItem, @@ -71,12 +84,12 @@ class AudioPlayerBar extends HookConsumerWidget { name: state.isPlaying ? Resources.assetsImagesPlayerPauseSvg : Resources.assetsImagesPlayerPlaySvg, - color: context.theme.icon, + color: theme.icon, onTap: () { if (state.isPlaying) { - context.audioMessageService.pause(); + audioService.pause(); } else { - context.audioMessageService.resume(); + audioService.resume(); } }, ), @@ -93,7 +106,7 @@ class AudioPlayerBar extends HookConsumerWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: context.theme.text, + color: theme.text, fontSize: 14, ), ), @@ -104,10 +117,8 @@ class AudioPlayerBar extends HookConsumerWidget { const SizedBox(width: 8), ActionButton( name: Resources.assetsImagesIcCloseSvg, - color: context.theme.icon, - onTap: () { - context.audioMessageService.stop(); - }, + color: theme.icon, + onTap: audioService.stop, ), ], ), @@ -119,20 +130,20 @@ class AudioPlayerBar extends HookConsumerWidget { } } -class _PlaybackSpeedButton extends HookConsumerWidget { +class _PlaybackSpeedButton extends ConsumerWidget { const _PlaybackSpeedButton(); @override Widget build(BuildContext context, WidgetRef ref) { - final speed = useAudioPlayerSpeed(); + final theme = ref.watch(brightnessThemeDataProvider); + final speed = ref.watch(audioPlayerSpeedProvider).value ?? 1; + final audioService = ref.read(audioMessagePlayServiceProvider); return ActionButton( child: Center( child: Text( '2X', style: TextStyle( - color: speed == 2 - ? context.theme.accent - : context.theme.secondaryText, + color: speed == 2 ? theme.accent : theme.secondaryText, fontSize: 16, fontWeight: FontWeight.w600, ), @@ -140,65 +151,68 @@ class _PlaybackSpeedButton extends HookConsumerWidget { ), onTap: () { if (speed == 1) { - context.audioMessageService.setPlaySpeed(2); + audioService.setPlaySpeed(2); } else { - context.audioMessageService.setPlaySpeed(1); + audioService.setPlaySpeed(1); } }, ); } } -class _Icon extends StatelessWidget { +class _Icon extends ConsumerWidget { const _Icon({required this.conversation}); final ConversationItem? conversation; @override - Widget build(BuildContext context) => SizedBox( - height: 32, - width: 40, - child: Stack( - fit: StackFit.expand, - children: [ - Align( - alignment: Alignment.centerLeft, - child: conversation == null - ? const SizedBox.square(dimension: 32) - : ConversationAvatarWidget( - conversation: conversation, - size: 32, - ), - ), - Align( - alignment: Alignment.bottomRight, - child: SvgPicture.asset( - Resources.assetsImagesAudioSvg, - colorFilter: ColorFilter.mode(context.theme.icon, BlendMode.srcIn), - width: 16, - height: 16, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return SizedBox( + height: 32, + width: 40, + child: Stack( + fit: StackFit.expand, + children: [ + Align( + alignment: Alignment.centerLeft, + child: conversation == null + ? const SizedBox.square(dimension: 32) + : ConversationAvatarWidget( + conversation: conversation, + size: 32, + ), ), - ), - ], - ), - ); + Align( + alignment: Alignment.bottomRight, + child: SvgPicture.asset( + Resources.assetsImagesAudioSvg, + colorFilter: ColorFilter.mode( + theme.icon, + BlendMode.srcIn, + ), + width: 16, + height: 16, + ), + ), + ], + ), + ); + } } -class _ProgressBar extends HookConsumerWidget { +class _ProgressBar extends ConsumerWidget { const _ProgressBar({required this.message}); final MessageItem? message; @override Widget build(BuildContext context, WidgetRef ref) { - final position = useAudioPlayerPosition(); - - final duration = useMemoized(() { - if (message == null) { - return 0; - } - return int.tryParse(message!.mediaDuration ?? '') ?? 0; - }, [message]); + final theme = ref.watch(brightnessThemeDataProvider); + final position = ref.watch(audioPlayerPositionProvider).value ?? 0; + final duration = message == null + ? 0 + : int.tryParse(message!.mediaDuration ?? '') ?? 0; final progress = (position / duration).clamp(0.0, 1.0); return SizedBox( @@ -206,7 +220,9 @@ class _ProgressBar extends HookConsumerWidget { child: LinearProgressIndicator( value: progress, backgroundColor: Colors.transparent, - valueColor: AlwaysStoppedAnimation(context.theme.accent), + valueColor: AlwaysStoppedAnimation( + theme.accent, + ), ), ); } diff --git a/lib/ui/home/conversation/conversation_hotkey.dart b/lib/ui/home/conversation/conversation_hotkey.dart index 1a988a60f9..e7090d31e8 100644 --- a/lib/ui/home/conversation/conversation_hotkey.dart +++ b/lib/ui/home/conversation/conversation_hotkey.dart @@ -1,22 +1,21 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../../utils/extension/extension.dart'; import '../../../utils/platform.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/conversation_unseen_filter_enabled.dart'; import '../../provider/slide_category_provider.dart'; - import '../../provider/unseen_conversations_provider.dart'; -import '../bloc/conversation_list_bloc.dart'; +import '../providers/home_scope_providers.dart'; -class ConversationHotKey extends StatelessWidget { +class ConversationHotKey extends ConsumerWidget { const ConversationHotKey({required this.child, super.key}); final Widget child; @override - Widget build(BuildContext context) => FocusableActionDetector( + Widget build(BuildContext context, WidgetRef ref) => FocusableActionDetector( shortcuts: { SingleActivator( LogicalKeyboardKey.arrowDown, @@ -31,10 +30,10 @@ class ConversationHotKey extends StatelessWidget { }, actions: { NextConversationIntent: CallbackAction( - onInvoke: (_) => _navigationConversation(context, forward: true), + onInvoke: (_) => _navigationConversation(context, ref, forward: true), ), PreviousConversationIntent: CallbackAction( - onInvoke: (_) => _navigationConversation(context, forward: false), + onInvoke: (_) => _navigationConversation(context, ref, forward: false), ), }, child: child, @@ -49,25 +48,34 @@ class PreviousConversationIntent extends Intent { const PreviousConversationIntent(); } -void _navigationConversation(BuildContext context, {required bool forward}) { - final category = context.providerContainer.read(slideCategoryStateProvider); - final conversationListBloc = context.read(); +void _navigationConversation( + BuildContext context, + WidgetRef ref, { + required bool forward, +}) { + final category = ref.read(slideCategoryProvider); + final conversationListBloc = ref.read( + conversationListControllerProvider.notifier, + ); + final pagingState = ref.read( + conversationListControllerProvider, + ); if (category.type == SlideCategoryType.setting) return; - final currentConversationId = context.providerContainer.read( + final currentConversationId = ref.read( currentConversationIdProvider, ); if (currentConversationId == null) return; - final conversationUnseenFilterEnabled = context.providerContainer.read( + final conversationUnseenFilterEnabled = ref.read( conversationUnseenFilterEnabledProvider, ); String nextConversationId; int? nextConversationIndex; if (conversationUnseenFilterEnabled) { - final unseenConversations = context.providerContainer.read( + final unseenConversations = ref.read( unseenConversationsProvider, ); final index = unseenConversations?.indexWhere( @@ -83,7 +91,7 @@ void _navigationConversation(BuildContext context, {required bool forward}) { nextConversationId = unseenConversations[nextIndex].conversationId; } else { var currentConversationIndex = -1; - conversationListBloc.state.map.forEach((key, value) { + pagingState.map.forEach((key, value) { if (value.conversationId == currentConversationId) { currentConversationIndex = key; } @@ -95,13 +103,16 @@ void _navigationConversation(BuildContext context, {required bool forward}) { ? currentConversationIndex + 1 : currentConversationIndex - 1; - final nextConversation = - conversationListBloc.state.map[nextConversationIndex]; + final nextConversation = pagingState.map[nextConversationIndex]; if (nextConversation == null) return; nextConversationId = nextConversation.conversationId; } - ConversationStateNotifier.selectConversation(context, nextConversationId); + ConversationStateNotifier.selectConversation( + ref.container, + context, + nextConversationId, + ); if (nextConversationIndex == null) return; diff --git a/lib/ui/home/conversation/conversation_list.dart b/lib/ui/home/conversation/conversation_list.dart index 2b0d3404c1..d6f5ab67fa 100644 --- a/lib/ui/home/conversation/conversation_list.dart +++ b/lib/ui/home/conversation/conversation_list.dart @@ -1,17 +1,14 @@ import 'package:flutter/material.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide Key, User; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../../blaze/vo/pin_message_minimal.dart'; -import '../../../bloc/paging/paging_bloc.dart'; import '../../../constants/resources.dart'; import '../../../db/dao/conversation_dao.dart'; import '../../../enum/message_category.dart'; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../utils/message_optimize.dart'; import '../../../widgets/avatar_view/avatar_view.dart'; import '../../../widgets/conversation/badges_widget.dart'; @@ -21,30 +18,91 @@ import '../../../widgets/message/item/pin_message.dart'; import '../../../widgets/message/item/system_message.dart'; import '../../../widgets/message_status_icon.dart'; import '../../../widgets/unread_text.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/minute_timer_provider.dart'; +import '../../provider/multi_auth_provider.dart'; import '../../provider/responsive_navigator_provider.dart'; import '../../provider/slide_category_provider.dart'; -import '../bloc/conversation_list_bloc.dart'; +import '../../provider/ui_context_providers.dart'; +import '../providers/home_scope_providers.dart'; import 'audio_player_bar.dart'; import 'conversation_page.dart'; import 'menu_wrapper.dart'; import 'network_status.dart'; -class ConversationList extends HookConsumerWidget { +final _conversationPreviewTextProvider = FutureProvider.autoDispose + .family((ref, conversation) async { + final hasDraft = + conversation.status != ConversationStatus.quit && + (conversation.draft?.isNotEmpty ?? false); + if (hasDraft) return conversation.draft; + + final isGroup = + conversation.category == ConversationCategory.group || + conversation.senderId != conversation.ownerId; + + final mentionCache = ref.read(mentionCacheProvider); + + if (conversation.contentType == MessageCategory.systemConversation) { + return generateSystemText( + actionName: conversation.actionName, + participantUserId: conversation.participantUserId, + senderId: conversation.senderId, + currentUserId: ref.read(accountServerProvider).value?.userId ?? '', + participantFullName: conversation.participantFullName, + senderFullName: conversation.senderFullName, + expireIn: int.tryParse(conversation.content ?? '0'), + ); + } + if (conversation.contentType.isPin) { + final pinMessageMinimal = PinMessageMinimal.fromJsonString( + conversation.content ?? '', + ); + final localization = ref.read(localizationProvider); + if (pinMessageMinimal == null) { + return localization.chatPinMessage( + conversation.senderFullName ?? '', + localization.aMessage, + ); + } + final preview = await generatePinPreviewText( + pinMessageMinimal: pinMessageMinimal, + mentionCache: mentionCache, + ); + return localization.chatPinMessage( + conversation.senderFullName ?? '', + preview, + ); + } + + return messagePreviewOptimize( + conversation.messageStatus, + conversation.contentType, + mentionCache.replaceMention( + conversation.content, + await mentionCache.checkMentionCache({conversation.content}), + ), + conversation.senderId == ref.read(accountServerProvider).value?.userId, + isGroup, + conversation.senderFullName, + ); + }); + +class ConversationList extends ConsumerWidget { const ConversationList({required Key key}) : super(key: key); @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final slideCategoryState = (key! as PageStorageKey).value; - final conversationListBloc = context.read(); - final pagingState = - useBlocState>( - bloc: conversationListBloc, - ); + final conversationListBloc = ref.read( + conversationListControllerProvider.notifier, + ); + final pagingState = ref.watch(conversationListControllerProvider); final conversationId = ref.watch(currentConversationIdProvider); final routeMode = ref.watch(navigatorRouteModeProvider); @@ -67,7 +125,7 @@ class ConversationList extends HookConsumerWidget { ? Center( child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(context.theme.accent), + valueColor: AlwaysStoppedAnimation(theme.accent), ), ) : const _Empty() @@ -89,6 +147,7 @@ class ConversationList extends HookConsumerWidget { conversation: conversation, onTap: () { ConversationStateNotifier.selectConversation( + ref.container, context, conversation.conversationId, conversation: conversation, @@ -112,14 +171,18 @@ class ConversationList extends HookConsumerWidget { } } -class _Empty extends StatelessWidget { +class _Empty extends ConsumerWidget { const _Empty(); @override - Widget build(BuildContext context) { - final dynamicColor = context.dynamicColor( - const Color.fromRGBO(229, 233, 240, 1), + Widget build(BuildContext context, WidgetRef ref) { + final dynamicColor = ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(229, 233, 240, 1), + darkColor: null, + )), ); + final l10n = ref.watch(localizationProvider); return Center( child: Column( mainAxisSize: MainAxisSize.min, @@ -132,7 +195,7 @@ class _Empty extends StatelessWidget { ), const SizedBox(height: 24), Text( - context.l10n.noData, + l10n.noData, style: TextStyle(color: dynamicColor, fontSize: 14), ), ], @@ -141,7 +204,7 @@ class _Empty extends StatelessWidget { } } -class ConversationItemWidget extends StatelessWidget { +class ConversationItemWidget extends ConsumerWidget { const ConversationItemWidget({ required this.conversation, required this.onTap, @@ -154,20 +217,22 @@ class ConversationItemWidget extends StatelessWidget { final VoidCallback onTap; @override - Widget build(BuildContext context) { - final messageColor = context.theme.secondaryText; + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + final messageColor = theme.secondaryText; return SizedBox( height: ConversationPage.conversationItemHeight, child: InteractiveDecoratedBox( onTap: onTap, - decoration: BoxDecoration(color: context.theme.primary), + decoration: BoxDecoration(color: theme.primary), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 8), child: DecoratedBox( decoration: selected ? BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(8)), - color: context.theme.listSelected, + color: theme.listSelected, ) : const BoxDecoration(), child: Padding( @@ -194,7 +259,7 @@ class ConversationItemWidget extends StatelessWidget { child: CustomText( conversation.validName, style: TextStyle( - color: context.theme.text, + color: theme.text, fontSize: 16, ), maxLines: 1, @@ -243,46 +308,49 @@ class ConversationItemWidget extends StatelessWidget { } } -class _ItemConversationSubtitle extends StatelessWidget { +class _ItemConversationSubtitle extends ConsumerWidget { const _ItemConversationSubtitle({required this.conversation}); final ConversationItem conversation; @override - Widget build(BuildContext context) => SizedBox( - height: 20, - child: Row( - children: [ - Expanded( - child: _MessagePreview( - messageColor: context.theme.secondaryText, - conversation: conversation, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return SizedBox( + height: 20, + child: Row( + children: [ + Expanded( + child: _MessagePreview( + messageColor: theme.secondaryText, + conversation: conversation, + ), ), - ), - Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (conversation.mentionCount > 0) - UnreadText( - data: '@', - textColor: const Color.fromRGBO(255, 255, 255, 1), - backgroundColor: context.theme.accent, - ), - if ((conversation.unseenMessageCount ?? 0) > 0) - UnreadText( - data: '${conversation.unseenMessageCount}', - textColor: const Color.fromRGBO(255, 255, 255, 1), - backgroundColor: conversation.isMute - ? context.theme.secondaryText - : context.theme.accent, - ), - if ((conversation.unseenMessageCount ?? 0) <= 0) - _StatusRow(conversation: conversation), - ].joinList(const SizedBox(width: 8)), - ), - ], - ), - ); + Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (conversation.mentionCount > 0) + UnreadText( + data: '@', + textColor: const Color.fromRGBO(255, 255, 255, 1), + backgroundColor: theme.accent, + ), + if ((conversation.unseenMessageCount ?? 0) > 0) + UnreadText( + data: '${conversation.unseenMessageCount}', + textColor: const Color.fromRGBO(255, 255, 255, 1), + backgroundColor: conversation.isMute + ? theme.secondaryText + : theme.accent, + ), + if ((conversation.unseenMessageCount ?? 0) <= 0) + _StatusRow(conversation: conversation), + ].joinList(const SizedBox(width: 8)), + ), + ], + ), + ); + } } class _MessagePreview extends StatelessWidget { @@ -314,7 +382,7 @@ class _MessagePreview extends StatelessWidget { } } -class _MessageContent extends HookConsumerWidget { +class _MessageContent extends ConsumerWidget { const _MessageContent({required this.conversation, required this.hasDraft}); final ConversationItem conversation; @@ -322,85 +390,20 @@ class _MessageContent extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final text = useMemoizedFuture( - () async { - if (hasDraft) return conversation.draft; - final isGroup = - conversation.category == ConversationCategory.group || - conversation.senderId != conversation.ownerId; - - final mentionCache = ref.read(mentionCacheProvider); - - if (conversation.contentType == MessageCategory.systemConversation) { - return generateSystemText( - actionName: conversation.actionName, - participantUserId: conversation.participantUserId, - senderId: conversation.senderId, - currentUserId: context.accountServer.userId, - participantFullName: conversation.participantFullName, - senderFullName: conversation.senderFullName, - expireIn: int.tryParse(conversation.content ?? '0'), - ); - } else if (conversation.contentType.isPin) { - final pinMessageMinimal = PinMessageMinimal.fromJsonString( - conversation.content ?? '', - ); - if (pinMessageMinimal == null) { - return context.l10n.chatPinMessage( - conversation.senderFullName ?? '', - context.l10n.aMessage, - ); - } - final preview = await generatePinPreviewText( - pinMessageMinimal: pinMessageMinimal, - mentionCache: mentionCache, - ); - return context.l10n.chatPinMessage( - conversation.senderFullName ?? '', - preview, - ); - } - - return messagePreviewOptimize( - conversation.messageStatus, - conversation.contentType, - mentionCache.replaceMention( - conversation.content, - await mentionCache.checkMentionCache({conversation.content}), - ), - conversation.senderId == context.accountServer.userId, - isGroup, - conversation.senderFullName, - ); - }, - null, - keys: [ - conversation.actionName, - conversation.messageStatus, - conversation.contentType, - conversation.content, - conversation.senderId, - conversation.ownerId, - conversation.relationship, - conversation.participantFullName, - conversation.senderFullName, - conversation.groupName, - conversation.draft, - hasDraft, - ], - ).data; - - final icon = useMemoized( - () => messagePreviewIcon( - conversation.messageStatus, - conversation.contentType, - ), - [conversation.messageStatus, conversation.contentType], + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + final text = ref + .watch(_conversationPreviewTextProvider(conversation)) + .value; + + final icon = messagePreviewIcon( + conversation.messageStatus, + conversation.contentType, ); if (conversation.contentType == null && !hasDraft) return const SizedBox(); - final dynamicColor = context.theme.secondaryText; + final dynamicColor = theme.secondaryText; return Row( children: [ @@ -411,8 +414,8 @@ class _MessageContent extends HookConsumerWidget { ), if (hasDraft) Text( - '${context.l10n.draft}:', - style: TextStyle(color: context.theme.red, fontSize: 14), + '${l10n.draft}:', + style: TextStyle(color: theme.red, fontSize: 14), ), if (text != null) Expanded( @@ -428,14 +431,15 @@ class _MessageContent extends HookConsumerWidget { } } -class _MessageStatusIcon extends StatelessWidget { +class _MessageStatusIcon extends ConsumerWidget { const _MessageStatusIcon({required this.conversation}); final ConversationItem conversation; @override - Widget build(BuildContext context) { - if (context.account?.userId == conversation.senderId && + Widget build(BuildContext context, WidgetRef ref) { + final selfUserId = ref.watch(authAccountProvider)?.userId; + if (selfUserId == conversation.senderId && conversation.contentType != MessageCategory.systemConversation && conversation.contentType != MessageCategory.systemAccountSnapshot && !conversation.contentType.isCallMessage && @@ -448,31 +452,28 @@ class _MessageStatusIcon extends StatelessWidget { } } -class _StatusRow extends StatelessWidget { +class _StatusRow extends ConsumerWidget { const _StatusRow({required this.conversation}); final ConversationItem conversation; @override - Widget build(BuildContext context) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - if (conversation.isMute) - SvgPicture.asset( - Resources.assetsImagesMuteSvg, - colorFilter: ColorFilter.mode( - context.theme.secondaryText, - BlendMode.srcIn, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + if (conversation.isMute) + SvgPicture.asset( + Resources.assetsImagesMuteSvg, + colorFilter: ColorFilter.mode(theme.secondaryText, BlendMode.srcIn), ), - ), - if (conversation.pinTime != null) - SvgPicture.asset( - Resources.assetsImagesPinSvg, - colorFilter: ColorFilter.mode( - context.theme.secondaryText, - BlendMode.srcIn, + if (conversation.pinTime != null) + SvgPicture.asset( + Resources.assetsImagesPinSvg, + colorFilter: ColorFilter.mode(theme.secondaryText, BlendMode.srcIn), ), - ), - ].joinList(const SizedBox(width: 4)), - ); + ].joinList(const SizedBox(width: 4)), + ); + } } diff --git a/lib/ui/home/conversation/conversation_page.dart b/lib/ui/home/conversation/conversation_page.dart index 95842d17e8..108bd09e83 100644 --- a/lib/ui/home/conversation/conversation_page.dart +++ b/lib/ui/home/conversation/conversation_page.dart @@ -1,18 +1,23 @@ import 'package:flutter/material.dart' hide SearchBar; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider; -import 'package:provider/provider.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../utils/system/text_input.dart'; import '../../../widgets/search_bar.dart'; import '../../provider/conversation_unseen_filter_enabled.dart'; import '../../provider/keyword_provider.dart'; import '../../provider/slide_category_provider.dart'; +import '../providers/home_scope_providers.dart'; import 'conversation_list.dart'; import 'search_list.dart'; class ConversationPage extends HookConsumerWidget { - const ConversationPage({super.key}); + const ConversationPage({ + required this.hasDrawer, + super.key, + }); + + final bool hasDrawer; static const conversationItemHeight = 78.0; static const conversationItemAvatarSize = 50.0; @@ -24,28 +29,30 @@ class ConversationPage extends HookConsumerWidget { final textEditingController = useMemoized(EmojiTextEditingController.new); final focusNode = useFocusNode(); - final slideCategoryState = ref.watch(slideCategoryStateProvider); + final slideCategoryState = ref.watch(slideCategoryProvider); final filterUnseen = ref.watch(conversationUnseenFilterEnabledProvider); - return MultiProvider( - providers: [ - ChangeNotifierProvider.value( - value: textEditingController, + return Column( + children: [ + SearchBar( + textEditingController: textEditingController, + focusNode: focusNode, + hasDrawer: hasDrawer, ), - ChangeNotifierProvider.value(value: focusNode), - ], - child: Column( - children: [ - const SearchBar(), - if (!filterUnseen && !hasKeyword) - Expanded( - child: ConversationList(key: PageStorageKey(slideCategoryState)), + if (!filterUnseen && !hasKeyword) + Expanded( + child: ConversationList(key: PageStorageKey(slideCategoryState)), + ), + if (filterUnseen || hasKeyword) + Expanded( + child: SearchList( + filterUnseen: filterUnseen, + textEditingController: textEditingController, + focusNode: focusNode, ), - if (filterUnseen || hasKeyword) - Expanded(child: SearchList(filterUnseen: filterUnseen)), - ], - ), + ), + ], ); } } diff --git a/lib/ui/home/conversation/menu_wrapper.dart b/lib/ui/home/conversation/menu_wrapper.dart index c0e516aef8..1b5c1d2b20 100644 --- a/lib/ui/home/conversation/menu_wrapper.dart +++ b/lib/ui/home/conversation/menu_wrapper.dart @@ -6,13 +6,15 @@ import 'package:super_context_menu/super_context_menu.dart'; import '../../../constants/icon_fonts.dart'; import '../../../db/dao/conversation_dao.dart'; import '../../../db/extension/conversation.dart'; -import '../../../utils/extension/extension.dart'; import '../../../widgets/conversation/mute_dialog.dart'; import '../../../widgets/dialog.dart'; import '../../../widgets/menu.dart'; import '../../../widgets/toast.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; +import '../../provider/database_provider.dart'; import '../../provider/slide_category_provider.dart'; +import '../../provider/ui_context_providers.dart'; class ConversationMenuWrapper extends HookConsumerWidget { const ConversationMenuWrapper({ @@ -41,17 +43,21 @@ class ConversationMenuWrapper extends HookConsumerWidget { conversation?.isGroupConversation ?? searchConversation!.isGroupConversation; + final accountServer = ref.read(accountServerProvider).requireValue; + final database = ref.read(databaseProvider).requireValue; + final l10n = ref.watch(localizationProvider); + return CustomContextMenuWidget( desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), menuProvider: (request) async { final circleId = ref.read( - slideCategoryStateProvider.select((value) { + slideCategoryProvider.select((value) { if (value.type != SlideCategoryType.circle) return null; return value.id; }), ); - final circles = await context.database.circleDao + final circles = await database.circleDao .otherCircleByConversationId(conversationId) .get(); @@ -61,25 +67,23 @@ class ConversationMenuWrapper extends HookConsumerWidget { if (pinTime != null) MenuAction( image: MenuImage.icon(IconFonts.unPin), - title: context.l10n.unpin, - callback: () => runFutureWithToast( - context.accountServer.unpin(conversationId), - ), + title: l10n.unpin, + callback: () => + runFutureWithToast(accountServer.unpin(conversationId)), ) else MenuAction( image: MenuImage.icon(IconFonts.pin), - title: context.l10n.pinTitle, - callback: () => runFutureWithToast( - context.accountServer.pin(conversationId), - ), + title: l10n.pinTitle, + callback: () => + runFutureWithToast(accountServer.pin(conversationId)), ), if (isMute) MenuAction( image: MenuImage.icon(IconFonts.unMute), - title: context.l10n.unmute, + title: l10n.unmute, callback: () => runFutureWithToast( - context.accountServer.unMuteConversation( + accountServer.unMuteConversation( conversationId: isGroupConversation ? conversationId : null, @@ -90,7 +94,7 @@ class ConversationMenuWrapper extends HookConsumerWidget { else MenuAction( image: MenuImage.icon(IconFonts.mute), - title: context.l10n.mute, + title: l10n.mute, callback: () async { final result = await showMixinDialog( context: context, @@ -98,7 +102,7 @@ class ConversationMenuWrapper extends HookConsumerWidget { ); if (result == null) return; await runFutureWithToast( - context.accountServer.muteConversation( + accountServer.muteConversation( result, conversationId: isGroupConversation ? conversationId @@ -114,23 +118,25 @@ class ConversationMenuWrapper extends HookConsumerWidget { if (circles.isNotEmpty) Menu( image: MenuImage.icon(IconFonts.circle), - title: context.l10n.addToCircle, + title: l10n.addToCircle, children: circles .map( (e) => MenuAction( title: e.name, callback: () async { await runFutureWithToast(() async { - await context.accountServer - .editCircleConversation(e.circleId, [ - CircleConversationRequest( - action: CircleConversationAction.add, - conversationId: conversationId, - userId: isGroupConversation - ? null - : ownerId, - ), - ]); + await accountServer.editCircleConversation( + e.circleId, + [ + CircleConversationRequest( + action: CircleConversationAction.add, + conversationId: conversationId, + userId: isGroupConversation + ? null + : ownerId, + ), + ], + ); }()); }, ), @@ -141,22 +147,20 @@ class ConversationMenuWrapper extends HookConsumerWidget { [ MenuAction( image: MenuImage.icon(IconFonts.delete), - title: context.l10n.deleteChat, + title: l10n.deleteChat, callback: () async { final name = conversation?.validName ?? searchConversation!.validName; final ret = await showConfirmMixinDialog( context, - context.l10n.conversationDeleteTitle(name), - description: context.l10n.deleteChatDescription, + l10n.conversationDeleteTitle(name), + description: l10n.deleteChatDescription, ); if (ret == null) return; - await context.accountServer.deleteMessagesByConversationId( - conversationId, - ); - await context.database.conversationDao.deleteConversation( + await accountServer.deleteMessagesByConversationId( conversationId, ); + await accountServer.deleteConversation(conversationId); if (ref.read(conversationProvider)?.conversationId == conversationId) { ref.read(conversationProvider.notifier).unselected(); @@ -168,9 +172,9 @@ class ConversationMenuWrapper extends HookConsumerWidget { circleId.isNotEmpty) MenuAction( image: MenuImage.icon(IconFonts.delete), - title: context.l10n.removeChatFromCircle, + title: l10n.removeChatFromCircle, callback: () => runFutureWithToast( - context.accountServer.editCircleConversation(circleId, [ + accountServer.editCircleConversation(circleId, [ CircleConversationRequest( action: CircleConversationAction.remove, conversationId: conversationId, diff --git a/lib/ui/home/conversation/network_status.dart b/lib/ui/home/conversation/network_status.dart index 8c70e6a6c6..fe900c27f6 100644 --- a/lib/ui/home/conversation/network_status.dart +++ b/lib/ui/home/conversation/network_status.dart @@ -7,9 +7,9 @@ import 'package:super_context_menu/super_context_menu.dart'; import '../../../blaze/blaze.dart'; import '../../../constants/resources.dart'; -import '../../../utils/extension/extension.dart'; +import '../../../ui/provider/account_server_provider.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/file.dart'; -import '../../../utils/hook.dart'; import '../../../utils/uri_utils.dart'; import '../../../widgets/menu.dart'; @@ -18,10 +18,13 @@ class NetworkStatus extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final connectedState = useMemoizedStream( - () => context.accountServer.connectedStateStream.distinct(), - initialData: ConnectedState.connecting, - ).requireData; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final connectedState = ref.watch( + appRuntimeHubProvider.select( + (value) => value.connectedState ?? ConnectedState.connecting, + ), + ); final hasDisconnectedBefore = useRef(false); @@ -38,9 +41,12 @@ class NetworkStatus extends HookConsumerWidget { menuProvider: (request) => Menu( children: [ MenuAction( - title: context.l10n.openLogDirectory, - callback: () => - openUri(context, mixinLogDirectory.uri.toString()), + title: l10n.openLogDirectory, + callback: () => openUri( + context, + mixinLogDirectory.uri.toString(), + container: ref.container, + ), ), ], ), @@ -57,7 +63,7 @@ class NetworkStatus extends HookConsumerWidget { connectedState == ConnectedState.reconnecting ? LinearProgressIndicator( backgroundColor: Colors.transparent, - color: context.theme.accent, + color: theme.accent, minHeight: 2, ) : const SizedBox(), @@ -67,23 +73,25 @@ class NetworkStatus extends HookConsumerWidget { } } -class _NetworkNotConnect extends StatelessWidget { +class _NetworkNotConnect extends ConsumerWidget { const _NetworkNotConnect({required this.visible}); final bool visible; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); Widget child; child = visible ? Container( padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 22), - color: context.theme.warning.withValues(alpha: 0.2), + color: theme.warning.withValues(alpha: 0.2), child: Row( children: [ ClipOval( child: Container( - color: context.theme.warning, + color: theme.warning, width: 20, height: 20, alignment: Alignment.center, @@ -105,21 +113,29 @@ class _NetworkNotConnect extends StatelessWidget { const SizedBox(width: 12), Expanded( child: DefaultTextStyle.merge( - style: TextStyle(color: context.theme.text, fontSize: 14), + style: TextStyle( + color: theme.text, + fontSize: 14, + ), child: Row( children: [ - Text(context.l10n.networkConnectionFailed), + Text(l10n.networkConnectionFailed), const Spacer(), MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( onTap: () { i('ui: click reconnect'); - context.accountServer.reconnectBlaze(); + ref + .read(accountServerProvider) + .requireValue + .reconnectBlaze(); }, child: Text( - context.l10n.retry, - style: TextStyle(color: context.theme.accent), + l10n.retry, + style: TextStyle( + color: theme.accent, + ), ), ), ), diff --git a/lib/ui/home/conversation/search_list.dart b/lib/ui/home/conversation/search_list.dart index c6a0a62277..bf83cc98d3 100644 --- a/lib/ui/home/conversation/search_list.dart +++ b/lib/ui/home/conversation/search_list.dart @@ -1,24 +1,24 @@ import 'dart:async'; import 'dart:math'; +import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide User; -import 'package:rxdart/rxdart.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../../../blaze/vo/pin_message_minimal.dart'; import '../../../constants/resources.dart'; import '../../../db/dao/conversation_dao.dart'; import '../../../db/dao/message_dao.dart'; +import '../../../db/database.dart'; import '../../../db/database_event_bus.dart'; import '../../../db/extension/conversation.dart'; import '../../../db/mixin_database.dart'; import '../../../enum/message_category.dart'; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../utils/logger.dart'; import '../../../utils/message_optimize.dart'; import '../../../utils/reg_exp_utils.dart'; @@ -33,110 +33,310 @@ import '../../../widgets/message/item/system_message.dart'; import '../../../widgets/mixin_image.dart'; import '../../../widgets/toast.dart'; import '../../../widgets/user/user_dialog.dart'; +import '../../provider/account_server_provider.dart'; import '../../provider/conversation_provider.dart'; import '../../provider/conversation_unseen_filter_enabled.dart'; +import '../../provider/database_provider.dart'; import '../../provider/keyword_provider.dart'; import '../../provider/mention_cache_provider.dart'; import '../../provider/minute_timer_provider.dart'; import '../../provider/search_mao_user_provider.dart'; import '../../provider/slide_category_provider.dart'; -import '../bloc/conversation_list_bloc.dart'; -import '../bloc/search_message_cubit.dart'; +import '../../provider/ui_context_providers.dart'; +import '../controllers/search_message_controller.dart'; +import '../providers/home_scope_providers.dart'; import 'conversation_page.dart'; import 'menu_wrapper.dart'; import 'unseen_conversation_list.dart'; const _defaultLimit = 3; -void _clear(BuildContext context) { - context.providerContainer.read(keywordProvider.notifier).state = ''; - context.read().text = ''; - context.read().unfocus(); +class _SearchUsersArgs with EquatableMixin { + const _SearchUsersArgs({ + required this.database, + required this.currentUserId, + required this.keyword, + required this.filterUnseen, + required this.slideCategoryState, + }); + + final Database database; + final String currentUserId; + final String keyword; + final bool filterUnseen; + final SlideCategoryState slideCategoryState; + + @override + List get props => [ + database, + currentUserId, + keyword, + filterUnseen, + slideCategoryState, + ]; +} + +class _SearchConversationsArgs with EquatableMixin { + const _SearchConversationsArgs({ + required this.database, + required this.keyword, + required this.filterUnseen, + required this.slideCategoryState, + }); + + final Database database; + final String keyword; + final bool filterUnseen; + final SlideCategoryState slideCategoryState; + + @override + List get props => [ + database, + keyword, + filterUnseen, + slideCategoryState, + ]; +} + +class _SearchMessagesArgs with EquatableMixin { + const _SearchMessagesArgs({ + required this.database, + required this.keyword, + required this.filterUnseen, + required this.slideCategoryState, + }); + + final Database database; + final String keyword; + final bool filterUnseen; + final SlideCategoryState slideCategoryState; + + @override + List get props => [ + database, + keyword, + filterUnseen, + slideCategoryState, + ]; +} + +class _SearchConversationDescriptionArgs with EquatableMixin { + const _SearchConversationDescriptionArgs({ + required this.conversation, + required this.currentUserId, + }); + + final SearchConversationItem conversation; + final String currentUserId; + + @override + List get props => [conversation, currentUserId]; +} + +class _SearchMessageDescriptionArgs with EquatableMixin { + const _SearchMessageDescriptionArgs({ + required this.message, + required this.currentUserId, + }); + + final SearchMessageDetailItem message; + final String currentUserId; + + @override + List get props => [message, currentUserId]; +} + +final _searchUsersProvider = StreamProvider.autoDispose + .family, _SearchUsersArgs>(( + ref, + args, + ) { + if (args.keyword.trim().isEmpty || args.filterUnseen) { + return Stream.value(const []); + } + return args.database.userDao + .fuzzySearchUser( + id: args.currentUserId, + username: args.keyword, + identityNumber: args.keyword, + category: args.slideCategoryState, + isIncludeConversation: true, + ) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.updateConversationIdStream, + DataBaseEventBus.instance.updateUserIdsStream, + ], + duration: kSlowThrottleDuration, + ); + }); + +final _searchConversationsProvider = StreamProvider.autoDispose + .family, _SearchConversationsArgs>(( + ref, + args, + ) { + if (args.keyword.trim().isEmpty) { + return Stream.value(const []); + } + return args.database.conversationDao + .fuzzySearchConversation( + args.keyword, + 32, + filterUnseen: args.filterUnseen, + category: args.slideCategoryState, + ) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.updateConversationIdStream, + DataBaseEventBus.instance.updateUserIdsStream, + ], + duration: kSlowThrottleDuration, + ); + }); + +final _searchMessagesProvider = FutureProvider.autoDispose + .family, _SearchMessagesArgs>(( + ref, + args, + ) async { + if (args.keyword.isEmpty) { + return const []; + } + return args.database.fuzzySearchMessageByCategory( + args.keyword, + limit: 4, + unseenConversationOnly: args.filterUnseen, + category: args.slideCategoryState, + ); + }); + +final _searchConversationDescriptionProvider = FutureProvider.autoDispose + .family((ref, args) async { + final conversation = args.conversation; + final l10n = ref.watch(localizationProvider); + final mentionCache = ref.watch(mentionCacheProvider); + + if (conversation.contentType == MessageCategory.systemConversation) { + return generateSystemText( + actionName: conversation.actionName, + participantUserId: conversation.participantUserId, + senderId: conversation.senderId, + currentUserId: args.currentUserId, + participantFullName: conversation.participantFullName, + senderFullName: conversation.senderFullName, + expireIn: int.tryParse(conversation.content ?? '0'), + ); + } + + if (conversation.contentType.isPin) { + final pinMessageMinimal = PinMessageMinimal.fromJsonString( + conversation.content ?? '', + ); + if (pinMessageMinimal == null) { + return l10n.chatPinMessage( + conversation.senderFullName ?? '', + l10n.aMessage ?? '', + ); + } + final preview = await generatePinPreviewText( + pinMessageMinimal: pinMessageMinimal, + mentionCache: mentionCache, + ); + return l10n.chatPinMessage(conversation.senderFullName ?? '', preview); + } + + return messagePreviewOptimize( + conversation.messageStatus, + conversation.contentType, + mentionCache.replaceMention( + conversation.content, + await mentionCache.checkMentionCache({conversation.content}), + ), + conversation.senderId == args.currentUserId, + conversation.isGroupConversation, + conversation.senderFullName, + ); + }); + +final _searchMessageDescriptionProvider = FutureProvider.autoDispose + .family((ref, args) async { + final mentionCache = ref.watch(mentionCacheProvider); + final message = args.message; + final isGroup = + message.category == ConversationCategory.group || + message.senderId != message.ownerId; + + return messagePreviewOptimize( + message.status, + message.type, + mentionCache.replaceMention( + message.content, + await mentionCache.checkMentionCache({message.content}), + ), + message.senderId == args.currentUserId, + isGroup, + message.senderFullName, + ); + }); + +void _clear( + WidgetRef ref, + TextEditingController textEditingController, + FocusNode focusNode, +) { + ref.read(keywordProvider.notifier).clear(); + textEditingController.text = ''; + focusNode.unfocus(); } class SearchList extends HookConsumerWidget { - const SearchList({super.key, this.filterUnseen = false}); + const SearchList({ + required this.textEditingController, + required this.focusNode, + super.key, + this.filterUnseen = false, + }); final bool filterUnseen; + final TextEditingController textEditingController; + final FocusNode focusNode; @override Widget build(BuildContext context, WidgetRef ref) { - final keyword = - useMemoizedStream(() { - final keywordCubit = ref.watch(keywordProvider.notifier); - return keywordCubit.stream - .startWith(keywordCubit.state) - .map((event) => event.trim()) - .distinct() - .debounceTime(const Duration(milliseconds: 150)); - }).data ?? - ''; - - final accountServer = context.accountServer; - - final slideCategoryState = ref.watch(slideCategoryStateProvider); - - final maoUser = ref.watch(searchMaoUserProvider(keyword)).valueOrNull; - - final users = - useMemoizedStream(() { - if (keyword.trim().isEmpty || filterUnseen) { - return Stream.value([]); - } - return accountServer.database.userDao - .fuzzySearchUser( - id: accountServer.userId, - username: keyword, - identityNumber: keyword, - category: slideCategoryState, - isIncludeConversation: true, - ) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.updateConversationIdStream, - DataBaseEventBus.instance.updateUserIdsStream, - ], - duration: kSlowThrottleDuration, - ); - }, keys: [keyword, filterUnseen, slideCategoryState]).data ?? - []; + final l10n = ref.watch(localizationProvider); + final keyword = ref.watch(debouncedKeywordProvider).value ?? ''; + final slideCategoryState = ref.watch(slideCategoryProvider); + final database = ref.read(databaseProvider).requireValue; + final accountServer = ref.read(accountServerProvider).requireValue; - final conversations = - useMemoizedStream(() { - if (keyword.trim().isEmpty) { - return Stream.value([]); - } - return accountServer.database.conversationDao - .fuzzySearchConversation( - keyword, - 32, - filterUnseen: filterUnseen, - category: slideCategoryState, - ) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.updateConversationIdStream, - DataBaseEventBus.instance.updateUserIdsStream, - ], - duration: kSlowThrottleDuration, - ); - }, keys: [keyword, filterUnseen, slideCategoryState]).data ?? - []; + final maoUser = ref.watch(searchMaoUserProvider(keyword)).value; + final usersArgs = _SearchUsersArgs( + database: database, + currentUserId: accountServer.userId, + keyword: keyword, + filterUnseen: filterUnseen, + slideCategoryState: slideCategoryState, + ); + final conversationsArgs = _SearchConversationsArgs( + database: database, + keyword: keyword, + filterUnseen: filterUnseen, + slideCategoryState: slideCategoryState, + ); + final messagesArgs = _SearchMessagesArgs( + database: database, + keyword: keyword, + filterUnseen: filterUnseen, + slideCategoryState: slideCategoryState, + ); + final users = ref.watch(_searchUsersProvider(usersArgs)).value ?? const []; + final conversations = + ref.watch(_searchConversationsProvider(conversationsArgs)).value ?? + const []; final messages = - useMemoizedFuture>( - () => keyword.isEmpty - ? Future.value([]) - : accountServer.database.fuzzySearchMessageByCategory( - keyword, - limit: 4, - unseenConversationOnly: filterUnseen, - category: slideCategoryState, - ), - [], - keys: [keyword, filterUnseen, slideCategoryState], - ).data ?? - []; + ref.watch(_searchMessagesProvider(messagesArgs)).value ?? const []; final isMixinNumber = useMemoized(() => numberRegExp.hasMatch(keyword), [ keyword, @@ -159,7 +359,9 @@ class SearchList extends HookConsumerWidget { } if (resultIsEmpty && (!isMixinNumber && !isUrl)) { - return const SearchEmptyWidget(); + return SearchEmptyWidget( + onClear: () => _clear(ref, textEditingController, focusNode), + ); } if (type.value == _ShowMoreType.message) { @@ -168,27 +370,38 @@ class SearchList extends HookConsumerWidget { onTap: () => type.value = null, filterUnseen: filterUnseen, categoryState: slideCategoryState, + textEditingController: textEditingController, + focusNode: focusNode, ); } return CustomScrollView( slivers: [ if (maoUser != null) SliverToBoxAdapter( - child: _SearchMaoUserWidget(maoUser: maoUser, keyword: keyword), + child: _SearchMaoUserWidget( + maoUser: maoUser, + keyword: keyword, + textEditingController: textEditingController, + focusNode: focusNode, + ), ), if (users.isEmpty && isUrl) SliverToBoxAdapter( child: SearchItemWidget( - name: context.l10n.openLink(keyword), + name: l10n.openLink(keyword), keyword: keyword, maxLines: true, - onTap: () => openUriWithWebView(context, keyword), + onTap: () => openUriWithWebView( + context, + keyword, + container: ref.container, + ), ), ), if (users.isEmpty && isMixinNumber) SliverToBoxAdapter( child: SearchItemWidget( - name: context.l10n.searchPlaceholderNumber + keyword, + name: l10n.searchPlaceholderNumber + keyword, keyword: keyword, maxLines: true, onTap: () async { @@ -197,29 +410,26 @@ class SearchList extends HookConsumerWidget { String? userId; try { - final mixinResponse = await context - .accountServer - .client - .userApi + final mixinResponse = await accountServer.client.userApi .search(keyword); - await context.database.userDao.insertSdkUser( - mixinResponse.data, - ); + await accountServer.upsertSdkUser(mixinResponse.data); userId = mixinResponse.data.userId; } catch (error) { - showToastFailed(ToastError(context.l10n.userNotFound)); + showToastFailed(ToastError(l10n.userNotFound)); } Toast.dismiss(); - if (userId != null) await showUserDialog(context, userId); + if (userId != null) { + await showUserDialog(context, ref.container, userId); + } }, ), ), if (users.isNotEmpty) SliverToBoxAdapter( child: _SearchHeader( - title: context.l10n.contact, + title: l10n.contact, showMore: users.length > _defaultLimit, more: type.value != _ShowMoreType.contact, onTap: () { @@ -242,7 +452,7 @@ class SearchList extends HookConsumerWidget { avatarUrl: user.avatarUrl, ), name: user.fullName ?? '?', - description: context.l10n.contactMixinId(user.identityNumber), + description: l10n.contactMixinId(user.identityNumber), trailing: BadgesWidget( verified: user.isVerified ?? false, isBot: user.appId != null, @@ -251,11 +461,12 @@ class SearchList extends HookConsumerWidget { keyword: keyword, onTap: () async { await ConversationStateNotifier.selectUser( + ref.container, context, user.userId, user: user, ); - _clear(context); + _clear(ref, textEditingController, focusNode); }, ); }, @@ -269,7 +480,7 @@ class SearchList extends HookConsumerWidget { if (conversations.isNotEmpty) SliverToBoxAdapter( child: _SearchHeader( - title: context.l10n.conversation, + title: l10n.conversation, showMore: conversations.length > _defaultLimit, more: type.value != _ShowMoreType.conversation, onTap: () { @@ -284,109 +495,11 @@ class SearchList extends HookConsumerWidget { delegate: SliverChildBuilderDelegate( (context, index) { final conversation = conversations[index]; - return HookConsumer( - builder: (context, ref, _) { - final description = useMemoizedFuture( - () async { - final mentionCache = ref.read(mentionCacheProvider); - - if (conversation.contentType == - MessageCategory.systemConversation) { - return generateSystemText( - actionName: conversation.actionName, - participantUserId: conversation.participantUserId, - senderId: conversation.senderId, - currentUserId: context.accountServer.userId, - participantFullName: - conversation.participantFullName, - senderFullName: conversation.senderFullName, - expireIn: int.tryParse( - conversation.content ?? '0', - ), - ); - } - - if (conversation.contentType.isPin) { - final pinMessageMinimal = - PinMessageMinimal.fromJsonString( - conversation.content ?? '', - ); - if (pinMessageMinimal == null) { - return context.l10n.chatPinMessage( - conversation.senderFullName ?? '', - context.l10n.aMessage, - ); - } - final preview = await generatePinPreviewText( - pinMessageMinimal: pinMessageMinimal, - mentionCache: ref.read(mentionCacheProvider), - ); - return context.l10n.chatPinMessage( - conversation.senderFullName ?? '', - preview, - ); - } - - return messagePreviewOptimize( - conversation.messageStatus, - conversation.contentType, - mentionCache.replaceMention( - conversation.content, - await mentionCache.checkMentionCache({ - conversation.content, - }), - ), - conversation.senderId == context.accountServer.userId, - conversation.isGroupConversation, - conversation.senderFullName, - ); - }, - null, - keys: [ - conversation.actionName, - conversation.participantUserId, - context.accountServer.userId, - conversation.participantFullName, - conversation.messageStatus, - conversation.contentType, - conversation.content, - conversation.senderId, - conversation.isGroupConversation, - conversation.senderFullName, - ], - ).data; - - return ConversationMenuWrapper( - searchConversation: conversation, - child: SearchItemWidget( - avatar: ConversationAvatarWidget( - conversationId: conversation.conversationId, - fullName: conversation.validName, - groupIconUrl: conversation.groupIconUrl, - avatarUrl: conversation.avatarUrl, - category: conversation.category, - size: ConversationPage.conversationItemAvatarSize, - userId: conversation.ownerId, - ), - name: conversation.validName, - description: description, - trailing: BadgesWidget( - verified: conversation.isVerified, - isBot: conversation.appId != null, - membership: conversation.membership, - ), - keyword: keyword, - onTap: () async { - await ConversationStateNotifier.selectConversation( - context, - conversation.conversationId, - ); - - _clear(context); - }, - ), - ); - }, + return _SearchConversationItem( + conversation: conversation, + keyword: keyword, + textEditingController: textEditingController, + focusNode: focusNode, ); }, childCount: type.value == _ShowMoreType.conversation @@ -399,7 +512,7 @@ class SearchList extends HookConsumerWidget { if (messages.isNotEmpty) SliverToBoxAdapter( child: _SearchHeader( - title: context.l10n.messages, + title: l10n.messages, showMore: messages.length > _defaultLimit, more: type.value != _ShowMoreType.message, onTap: () { @@ -417,7 +530,13 @@ class SearchList extends HookConsumerWidget { return SearchMessageItem( message: message, keyword: keyword, - onTap: _searchMessageItemOnTap(context, message), + onTap: _searchMessageItemOnTap( + ref, + context, + message, + textEditingController, + focusNode, + ), ); }, childCount: type.value == _ShowMoreType.message @@ -435,14 +554,24 @@ class SearchList extends HookConsumerWidget { const _maoIcon = 'https://kernel.mixin.dev/objects/fe75a8e48aeffb486df622c91bebfe4056ada7009f3151fb49e2a18340bbd615/icon'; -class _SearchMaoUserWidget extends StatelessWidget { - const _SearchMaoUserWidget({required this.maoUser, required this.keyword}); +class _SearchMaoUserWidget extends ConsumerWidget { + const _SearchMaoUserWidget({ + required this.maoUser, + required this.keyword, + required this.textEditingController, + required this.focusNode, + }); final MaoUser maoUser; final String keyword; + final TextEditingController textEditingController; + final FocusNode focusNode; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; final defaultInscriptionImage = SvgPicture.asset( Resources.assetsImagesInscriptionPlaceholderSvg, width: 14, @@ -452,24 +581,26 @@ class _SearchMaoUserWidget extends StatelessWidget { if (maoUser.user.isBot) { openButton = ElevatedButton( style: ElevatedButton.styleFrom( - backgroundColor: context.theme.accent, + backgroundColor: theme.accent, foregroundColor: Colors.white, textStyle: const TextStyle(fontSize: 12), minimumSize: Size.zero, padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), ), onPressed: () async { - final app = await context.accountServer.findOrSyncApp( - maoUser.user.appId!, - ); + final app = await accountServer.findOrSyncApp(maoUser.user.appId!); if (app == null) { e('app not found: ${maoUser.user.appId}'); showToastFailed(null); return; } - await MixinWebView.instance.openBotWebViewWindow(context, app); + await MixinWebView.instance.openBotWebViewWindow( + context, + ref.container, + app, + ); }, - child: Text(context.l10n.open), + child: Text(l10n.open), ); } return SearchItemWidget( @@ -501,23 +632,24 @@ class _SearchMaoUserWidget extends StatelessWidget { ), keyword: keyword, onTap: () async { - if (maoUser.user.userId == context.accountServer.userId) { - _clear(context); - await showUserDialog(context, maoUser.user.userId); + if (maoUser.user.userId == accountServer.userId) { + _clear(ref, textEditingController, focusNode); + await showUserDialog(context, ref.container, maoUser.user.userId); return; } await ConversationStateNotifier.selectUser( + ref.container, context, maoUser.user.userId, user: maoUser.user, ); - _clear(context); + _clear(ref, textEditingController, focusNode); }, ); } } -class SearchItemWidget extends StatelessWidget { +class SearchItemWidget extends ConsumerWidget { const SearchItemWidget({ required this.name, required this.keyword, @@ -556,10 +688,11 @@ class SearchItemWidget extends StatelessWidget { final Widget? contentTrailing; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final selectedDecoration = BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(8)), - color: context.theme.listSelected, + color: theme.listSelected, ); return Padding( padding: margin, @@ -599,7 +732,7 @@ class SearchItemWidget extends StatelessWidget { ? null : TextOverflow.ellipsis, style: TextStyle( - color: context.theme.text, + color: theme.text, fontSize: nameFontSize, ), textMatchers: [ @@ -608,7 +741,7 @@ class SearchItemWidget extends StatelessWidget { MultiKeyWordTextMatcher.createKeywordMatcher( keyword: keyword, style: TextStyle( - color: context.theme.accent, + color: theme.accent, ), caseSensitive: false, ), @@ -624,7 +757,7 @@ class SearchItemWidget extends StatelessWidget { builder: (context, ref, _) => Text( ref.watch(formattedDateTimeProvider(date!)), style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 12, ), ), @@ -645,7 +778,7 @@ class SearchItemWidget extends StatelessWidget { child: SvgPicture.asset( descriptionIcon!, colorFilter: ColorFilter.mode( - context.theme.secondaryText, + theme.secondaryText, BlendMode.srcIn, ), ), @@ -656,7 +789,7 @@ class SearchItemWidget extends StatelessWidget { maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), textMatchers: [ @@ -665,7 +798,7 @@ class SearchItemWidget extends StatelessWidget { MultiKeyWordTextMatcher.createKeywordMatcher( keyword: keyword, style: TextStyle( - color: context.theme.accent, + color: theme.accent, ), caseSensitive: false, ), @@ -696,47 +829,58 @@ class _SearchMessageList extends HookConsumerWidget { required this.onTap, required this.filterUnseen, required this.categoryState, + required this.textEditingController, + required this.focusNode, }); final String keyword; final VoidCallback onTap; final bool filterUnseen; final SlideCategoryState categoryState; + final TextEditingController textEditingController; + final FocusNode focusNode; @override Widget build(BuildContext context, WidgetRef ref) { - final searchMessageCubit = useBloc( - () => SearchMessageCubit.slideCategory( - database: context.database, - category: categoryState, - keyword: keyword, - limit: context.read().limit, - ), - keys: [keyword, categoryState], + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final args = SlideCategorySearchMessageArgs( + database: ref.read(databaseProvider).requireValue, + category: categoryState, + keyword: keyword, + limit: ref.read(conversationListControllerProvider.notifier).limit, ); - - final pageState = useBlocState( - bloc: searchMessageCubit, + final searchMessageNotifier = ref.watch( + slideCategorySearchMessageStateProvider(args).notifier, ); + final pageState = ref.watch(slideCategorySearchMessageStateProvider(args)); final child = pageState.initializing ? Center( child: CircularProgressIndicator( strokeWidth: 2, - valueColor: AlwaysStoppedAnimation(context.theme.accent), + valueColor: AlwaysStoppedAnimation(theme.accent), ), ) : pageState.items.isEmpty - ? const SearchEmptyWidget() + ? SearchEmptyWidget( + onClear: () => _clear(ref, textEditingController, focusNode), + ) : ScrollablePositionedList.builder( - itemPositionsListener: searchMessageCubit.itemPositionsListener, + itemPositionsListener: searchMessageNotifier.itemPositionsListener, itemCount: pageState.items.length, itemBuilder: (context, index) { final message = pageState.items[index]; return SearchMessageItem( message: message, keyword: keyword, - onTap: _searchMessageItemOnTap(context, message), + onTap: _searchMessageItemOnTap( + ref, + context, + message, + textEditingController, + focusNode, + ), ); }, ); @@ -744,7 +888,7 @@ class _SearchMessageList extends HookConsumerWidget { return Column( children: [ _SearchHeader( - title: context.l10n.messages, + title: l10n.messages, showMore: true, more: false, onTap: onTap, @@ -755,7 +899,7 @@ class _SearchMessageList extends HookConsumerWidget { } } -class _SearchHeader extends StatelessWidget { +class _SearchHeader extends ConsumerWidget { const _SearchHeader({ required this.title, required this.showMore, @@ -769,39 +913,56 @@ class _SearchHeader extends StatelessWidget { final bool more; @override - Widget build(BuildContext context) => Container( - padding: const EdgeInsets.only(top: 16, bottom: 10, right: 20, left: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(title, style: TextStyle(fontSize: 14, color: context.theme.text)), - if (showMore) - GestureDetector( - onTap: onTap, - child: Text( - more ? context.l10n.more : context.l10n.less, - style: TextStyle(fontSize: 14, color: context.theme.accent), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return Container( + padding: const EdgeInsets.only(top: 16, bottom: 10, right: 20, left: 12), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + title, + style: TextStyle( + fontSize: 14, + color: theme.text, ), ), - ], - ), - ); + if (showMore) + GestureDetector( + onTap: onTap, + child: Text( + more ? l10n.more : l10n.less, + style: TextStyle( + fontSize: 14, + color: theme.accent, + ), + ), + ), + ], + ), + ); + } } enum _ShowMoreType { contact, conversation, message } Future Function() _searchMessageItemOnTap( + WidgetRef ref, BuildContext context, SearchMessageDetailItem message, + TextEditingController textEditingController, + FocusNode focusNode, ) => () async { await ConversationStateNotifier.selectConversation( + ref.container, context, message.conversationId, initIndexMessageId: message.messageId, - keyword: context.providerContainer.read(trimmedKeywordProvider), + keyword: ref.read(trimmedKeywordProvider), ); - _clear(context); + _clear(ref, textEditingController, focusNode); }; class SearchMessageItem extends HookConsumerWidget { @@ -820,44 +981,20 @@ class SearchMessageItem extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final isGroup = useMemoized( - () => - message.category == ConversationCategory.group || - message.senderId != message.ownerId, - [message.category, message.senderId, message.ownerId], - ); - - final icon = useMemoized( - () => messagePreviewIcon(message.status, message.type), - [message.status, message.type], - ); - - final description = useMemoizedFuture( - () async { - final mentionCache = ref.read(mentionCacheProvider); - - return messagePreviewOptimize( - message.status, - message.type, - mentionCache.replaceMention( - message.content, - await mentionCache.checkMentionCache({message.content}), + final icon = messagePreviewIcon(message.status, message.type); + final description = ref + .watch( + _searchMessageDescriptionProvider( + _SearchMessageDescriptionArgs( + message: message, + currentUserId: ref + .read(accountServerProvider) + .requireValue + .userId, + ), ), - message.senderId == context.accountServer.userId, - isGroup, - message.senderFullName, - ); - }, - null, - keys: [ - message.status, - message.type, - message.content, - isGroup, - message.senderId, - message.senderFullName, - ], - ).data; + ) + .value; final avatar = showSender ? AvatarWidget( @@ -900,32 +1037,111 @@ class SearchMessageItem extends HookConsumerWidget { } } -class SearchEmptyWidget extends HookConsumerWidget { - const SearchEmptyWidget({super.key}); +class _SearchConversationItem extends ConsumerWidget { + const _SearchConversationItem({ + required this.conversation, + required this.keyword, + required this.textEditingController, + required this.focusNode, + }); + + final SearchConversationItem conversation; + final String keyword; + final TextEditingController textEditingController; + final FocusNode focusNode; @override - Widget build(BuildContext context, WidgetRef ref) => Container( - padding: const EdgeInsets.symmetric(horizontal: 43, vertical: 86), - width: double.infinity, - alignment: Alignment.topCenter, - child: Column( - children: [ - Text( - context.l10n.searchEmpty, - style: TextStyle(fontSize: 14, color: context.theme.secondaryText), - ), - const SizedBox(height: 4), - TextButton( - child: Text( - context.l10n.clearFilter, - style: TextStyle(fontSize: 14, color: context.theme.accent), + Widget build(BuildContext context, WidgetRef ref) { + final description = ref + .watch( + _searchConversationDescriptionProvider( + _SearchConversationDescriptionArgs( + conversation: conversation, + currentUserId: ref + .read(accountServerProvider) + .requireValue + .userId, + ), ), - onPressed: () { - _clear(context); - ref.read(conversationUnseenFilterEnabledProvider.notifier).reset(); - }, + ) + .value; + + return ConversationMenuWrapper( + searchConversation: conversation, + child: SearchItemWidget( + avatar: ConversationAvatarWidget( + conversationId: conversation.conversationId, + fullName: conversation.validName, + groupIconUrl: conversation.groupIconUrl, + avatarUrl: conversation.avatarUrl, + category: conversation.category, + size: ConversationPage.conversationItemAvatarSize, + userId: conversation.ownerId, ), - ], - ), - ); + name: conversation.validName, + description: description, + trailing: BadgesWidget( + verified: conversation.isVerified, + isBot: conversation.appId != null, + membership: conversation.membership, + ), + keyword: keyword, + onTap: () async { + await ConversationStateNotifier.selectConversation( + ref.container, + context, + conversation.conversationId, + ); + _clear(ref, textEditingController, focusNode); + }, + ), + ); + } +} + +class SearchEmptyWidget extends HookConsumerWidget { + const SearchEmptyWidget({ + required this.onClear, + super.key, + }); + + final VoidCallback onClear; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return Container( + padding: const EdgeInsets.symmetric(horizontal: 43, vertical: 86), + width: double.infinity, + alignment: Alignment.topCenter, + child: Column( + children: [ + Text( + l10n.searchEmpty, + style: TextStyle( + fontSize: 14, + color: theme.secondaryText, + ), + ), + const SizedBox(height: 4), + TextButton( + child: Text( + l10n.clearFilter, + style: TextStyle( + fontSize: 14, + color: theme.accent, + ), + ), + onPressed: () { + onClear(); + ref + .read(conversationUnseenFilterEnabledProvider.notifier) + .reset(); + }, + ), + ], + ), + ); + } } diff --git a/lib/ui/home/conversation/unseen_conversation_list.dart b/lib/ui/home/conversation/unseen_conversation_list.dart index b054666940..c4f62f0bbf 100644 --- a/lib/ui/home/conversation/unseen_conversation_list.dart +++ b/lib/ui/home/conversation/unseen_conversation_list.dart @@ -24,7 +24,7 @@ class UnseenConversationList extends HookConsumerWidget { return const SizedBox(); } if (unseenConversations.isEmpty) { - return const SearchEmptyWidget(); + return const SearchEmptyWidget(onClear: _noop); } return ScrollablePositionedList.builder( itemBuilder: (context, index) { @@ -38,6 +38,7 @@ class UnseenConversationList extends HookConsumerWidget { conversation.conversationId == currentConversationId && !routeMode, onTap: () => ConversationStateNotifier.selectConversation( + ref.container, context, conversation.conversationId, conversation: conversation, @@ -49,3 +50,5 @@ class UnseenConversationList extends HookConsumerWidget { ); } } + +void _noop() {} diff --git a/lib/ui/home/home.dart b/lib/ui/home/home.dart index a054d07b43..ffe2c9952d 100644 --- a/lib/ui/home/home.dart +++ b/lib/ui/home/home.dart @@ -3,14 +3,11 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' hide ChangeNotifierProvider, Provider; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; -import 'package:provider/provider.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '../../blaze/blaze.dart'; -import '../../utils/audio_message_player/audio_message_service.dart'; import '../../utils/device_transfer/device_transfer_widget.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../../utils/platform.dart'; import '../../utils/system/package_info.dart'; import '../../utils/system/text_input.dart'; @@ -20,16 +17,19 @@ import '../../widgets/empty.dart'; import '../../widgets/protocol_handler.dart'; import '../../widgets/toast.dart'; import '../landing/landing.dart'; +import '../provider/account_server_provider.dart'; import '../provider/conversation_provider.dart'; import '../provider/multi_auth_provider.dart'; import '../provider/responsive_navigator_provider.dart'; import '../provider/setting_provider.dart'; import '../provider/slide_category_provider.dart'; +import '../provider/ui_context_providers.dart'; import '../setting/setting_page.dart'; import 'command_palette_wrapper.dart'; import 'conversation/conversation_hotkey.dart'; import 'conversation/conversation_page.dart'; +import 'providers/home_scope_providers.dart'; import 'route/responsive_navigator.dart'; import 'slide_page.dart'; @@ -45,31 +45,34 @@ const kConversationListWidth = 300.0; const kChatSidePageWidth = 300.0; final _conversationPageKey = GlobalKey(); +final _updateRequiredProvider = StreamProvider.autoDispose((ref) { + final accountServer = ref.watch(accountServerProvider).value; + if (accountServer == null) { + return Stream.value(false); + } + return accountServer.isUpdateRequired; +}); + +final _packageInfoProvider = FutureProvider.autoDispose( + (ref) => getPackageInfo(), +); class HomePage extends HookConsumerWidget { const HomePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final localTimeError = - useMemoizedStream( - () => context.accountServer.connectedStateStream - .map((event) => event == ConnectedState.hasLocalTimeError) - .distinct(), - keys: [context.accountServer], - ).data ?? - false; + final localTimeError = ref.watch( + appRuntimeHubProvider.select( + (value) => value.connectedState == ConnectedState.hasLocalTimeError, + ), + ); final isEmptyUserName = ref.watch( authAccountProvider.select((value) => value?.fullName?.isEmpty ?? true), ); - final updateRequired = - useMemoizedStream( - () => context.accountServer.isUpdateRequired, - keys: [context.accountServer], - ).data ?? - false; + final updateRequired = ref.watch(_updateRequiredProvider).value ?? false; return DeviceTransferHandlerWidget( child: CommandPaletteWrapper( @@ -92,14 +95,16 @@ class HomePage extends HookConsumerWidget { } } -class _RequiredUpdateWidget extends HookWidget { +class _RequiredUpdateWidget extends HookConsumerWidget { const _RequiredUpdateWidget(); @override - Widget build(BuildContext context) { - final info = useMemoizedFuture(getPackageInfo, null).data; + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final info = ref.watch(_packageInfoProvider).value; return Material( - color: context.theme.background, + color: theme.background, child: Stack( children: [ Center( @@ -107,21 +112,21 @@ class _RequiredUpdateWidget extends HookWidget { mainAxisSize: MainAxisSize.min, children: [ Text( - context.l10n.updateMixin, - style: TextStyle(color: context.theme.text, fontSize: 16), + l10n.updateMixin, + style: TextStyle(color: theme.text, fontSize: 16), ), const SizedBox(height: 10), Text( - context.l10n.updateMixinDescription(info?.version ?? ''), + l10n.updateMixinDescription(info?.version ?? ''), textAlign: TextAlign.center, - style: TextStyle(color: context.theme.text, fontSize: 14), + style: TextStyle(color: theme.text, fontSize: 14), ), const SizedBox(height: 32), MixinButton( onTap: () async { await launchUrlString('https://mixin.one/messenger'); }, - child: Text(context.l10n.upgrade), + child: Text(l10n.upgrade), ), ], ), @@ -133,44 +138,46 @@ class _RequiredUpdateWidget extends HookWidget { } } -class _LocalTimeError extends StatelessWidget { +class _LocalTimeError extends HookConsumerWidget { const _LocalTimeError(); @override - Widget build(BuildContext context) => HookBuilder( - builder: (context) { - final loading = useState(false); - return Material( - color: context.theme.background, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Text( - context.l10n.loadingTime, - style: TextStyle(color: context.theme.text, fontSize: 16), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final loading = useState(false); + return Material( + color: theme.background, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + l10n.loadingTime, + style: TextStyle(color: theme.text, fontSize: 16), + ), + const SizedBox(height: 24), + if (loading.value) CircularProgressIndicator(color: theme.accent), + if (!loading.value) + MixinButton( + onTap: () async { + loading.value = true; + try { + await ref + .read(accountServerProvider) + .requireValue + .reconnectBlaze(); + } catch (_) {} + + loading.value = false; + }, + child: Text(l10n.continueText), ), - const SizedBox(height: 24), - if (loading.value) - CircularProgressIndicator(color: context.theme.accent), - if (!loading.value) - MixinButton( - onTap: () async { - loading.value = true; - try { - await context.accountServer.reconnectBlaze(); - } catch (_) {} - - loading.value = false; - }, - child: Text(context.l10n.continueText), - ), - ], - ), + ], ), - ); - }, - ); + ), + ); + } } class _SetupNameWidget extends HookConsumerWidget { @@ -178,16 +185,18 @@ class _SetupNameWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final textEditingController = useMemoized(EmojiTextEditingController.new); final textEditingValue = useValueListenable(textEditingController); return Scaffold( - backgroundColor: context.theme.background, + backgroundColor: theme.background, body: Center( child: AlertDialogLayout( - title: Text(context.l10n.whatsYourName), + title: Text(l10n.whatsYourName), content: DialogTextField( textEditingController: textEditingController, - hintText: context.l10n.name, + hintText: l10n.name, maxLength: 40, ), actions: [ @@ -196,9 +205,12 @@ class _SetupNameWidget extends HookConsumerWidget { onTap: () async { showToastLoading(); try { - await context.accountServer.updateAccount( - fullName: textEditingController.text.trim(), - ); + await ref + .read(accountServerProvider) + .requireValue + .updateAccount( + fullName: textEditingController.text.trim(), + ); } on MixinApiError catch (error) { final mixinError = error.error! as MixinError; showToastFailed( @@ -211,7 +223,7 @@ class _SetupNameWidget extends HookConsumerWidget { } showToastSuccessful(); }, - child: Text(context.l10n.confirm), + child: Text(l10n.confirm), ), ], ), @@ -220,10 +232,6 @@ class _SetupNameWidget extends HookConsumerWidget { } } -class HasDrawerValueNotifier extends ValueNotifier { - HasDrawerValueNotifier(super.value); -} - class _HomePage extends HookConsumerWidget { const _HomePage({required this.constraints}); @@ -231,6 +239,8 @@ class _HomePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final maxWidth = constraints.maxWidth; final clampSlideWidth = (maxWidth - kResponsiveNavigationMinWidth).clamp( kSlidePageMinWidth, @@ -249,78 +259,64 @@ class _HomePage extends HookConsumerWidget { targetWidth = 0; } - final hasDrawerValueNotifier = useMemoized( - () => HasDrawerValueNotifier(targetWidth == 0), - ); - - final hasDrawer = useListenable(hasDrawerValueNotifier); + final hasDrawer = targetWidth == 0; - return MultiProvider( - providers: [ - ChangeNotifierProvider.value(value: hasDrawerValueNotifier), - Provider( - create: (context) => AudioMessagePlayService(context.accountServer), - dispose: (context, service) => service.dispose(), - ), - ], - child: Scaffold( - backgroundColor: context.theme.primary, - drawerEnableOpenDragGesture: false, - drawer: hasDrawer.value && targetWidth == 0 - ? Drawer( - child: Container( - width: kSlidePageMaxWidth, - color: context.theme.primary, - child: const SlidePage(showCollapse: false), + return Scaffold( + backgroundColor: theme.primary, + drawerEnableOpenDragGesture: false, + drawer: hasDrawer + ? Drawer( + child: Container( + width: kSlidePageMaxWidth, + color: theme.primary, + child: const SlidePage(showCollapse: false), + ), + ) + : null, + body: SafeArea( + child: AppProtocolHandler( + child: Row( + children: [ + TweenAnimationBuilder( + tween: Tween(end: targetWidth), + duration: const Duration(milliseconds: 200), + builder: (context, value, child) => SizedBox( + width: value, + child: value == 0 ? null : child, ), - ) - : null, - body: SafeArea( - child: AppProtocolHandler( - child: Row( - children: [ - TweenAnimationBuilder( - tween: Tween(end: targetWidth), - duration: const Duration(milliseconds: 200), - onEnd: () => hasDrawerValueNotifier.value = targetWidth == 0, - builder: (context, value, child) => SizedBox( - width: value, - child: value == 0 ? null : child, - ), - child: OverflowBox( - alignment: Alignment.centerLeft, - minWidth: kSlidePageMinWidth, - maxWidth: collapse ? kSlidePageMinWidth : clampSlideWidth, - child: SlidePage(showCollapse: !autoCollapse), - ), + child: OverflowBox( + alignment: Alignment.centerLeft, + minWidth: kSlidePageMinWidth, + maxWidth: collapse ? kSlidePageMinWidth : clampSlideWidth, + child: SlidePage(showCollapse: !autoCollapse), ), - Expanded( - child: ResponsiveNavigator( - switchWidth: - kResponsiveNavigationMinWidth + kConversationListWidth, - leftPage: MaterialPage( - key: const ValueKey('center'), - name: 'center', - child: SizedBox( - key: _conversationPageKey, - width: kConversationListWidth, - child: const _CenterPage(), - ), + ), + Expanded( + child: ResponsiveNavigator( + switchWidth: + kResponsiveNavigationMinWidth + kConversationListWidth, + leftPage: MaterialPage( + key: const ValueKey('center'), + name: 'center', + child: SizedBox( + key: _conversationPageKey, + width: kConversationListWidth, + child: _CenterPage(hasDrawer: hasDrawer), ), - rightEmptyPage: MaterialPage( - key: const ValueKey('empty'), - name: 'empty', - child: DecoratedBox( - decoration: BoxDecoration( - color: context.theme.chatBackground, - ), - child: Empty(text: context.l10n.pickAConversation), + ), + rightEmptyPage: MaterialPage( + key: const ValueKey('empty'), + name: 'empty', + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.chatBackground, ), + child: Empty(text: l10n.pickAConversation), ), ), ), - ], - ), + ), + ], ), ), ), @@ -329,20 +325,25 @@ class _HomePage extends HookConsumerWidget { } class _CenterPage extends HookConsumerWidget { - const _CenterPage(); + const _CenterPage({ + required this.hasDrawer, + }); + + final bool hasDrawer; @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final isSetting = ref.watch( - slideCategoryStateProvider.select( + slideCategoryProvider.select( (value) => value.type == SlideCategoryType.setting, ), ); - ref.listen(slideCategoryStateProvider, (previous, next) { + ref.listen(slideCategoryProvider, (previous, next) { final isSetting = next.type == SlideCategoryType.setting; - final responsiveNavigatorNotifier = context.providerContainer.read( + final responsiveNavigatorNotifier = ref.read( responsiveNavigatorProvider.notifier, ); @@ -365,15 +366,19 @@ class _CenterPage extends HookConsumerWidget { return RepaintBoundary( child: DecoratedBox( decoration: BoxDecoration( - color: context.theme.primary, - border: Border(right: BorderSide(color: context.theme.divider)), + color: theme.primary, + border: Border(right: BorderSide(color: theme.divider)), ), child: IndexedStack( index: isSetting ? 1 : 0, sizing: StackFit.expand, - children: const [ - AutomaticKeepAliveClientWidget(child: ConversationPage()), - AutomaticKeepAliveClientWidget(child: SettingPage()), + children: [ + AutomaticKeepAliveClientWidget( + child: ConversationPage(hasDrawer: hasDrawer), + ), + AutomaticKeepAliveClientWidget( + child: SettingPage(hasDrawer: hasDrawer), + ), ], ), ), diff --git a/lib/ui/home/hook/pin_message.dart b/lib/ui/home/hook/pin_message.dart index 733a9bc352..94bfe232f7 100644 --- a/lib/ui/home/hook/pin_message.dart +++ b/lib/ui/home/hook/pin_message.dart @@ -1,113 +1,24 @@ import 'package:equatable/equatable.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; -import '../../../account/show_pin_message_key_value.dart'; -import '../../../blaze/vo/pin_message_minimal.dart'; -import '../../../db/database_event_bus.dart'; -import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; -import '../../../widgets/message/item/pin_message.dart'; -import '../../provider/mention_cache_provider.dart'; +class PinMessagePreview extends Equatable { + const PinMessagePreview({ + required this.senderFullName, + required this.preview, + }); + + final String senderFullName; + final String preview; + + @override + List get props => [senderFullName, preview]; +} class PinMessageState extends Equatable { const PinMessageState({required this.messageIds, this.lastMessage}); final List messageIds; - final String? lastMessage; + final PinMessagePreview? lastMessage; @override List get props => [messageIds, lastMessage]; } - -extension PinMessageCubitExtension on BuildContext { - List get currentPinMessageIds => read().messageIds; - - List get watchCurrentPinMessageIds => - watch().messageIds; - - String? get lastMessage => read().lastMessage; -} - -PinMessageState usePinMessageState(String? conversationId) { - final context = useContext(); - - final pinMessageIds = useMemoizedStream>( - () { - if (conversationId == null) return Stream.value([]); - return context.database.pinMessageDao - .pinMessageIds(conversationId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchPinMessageStream( - conversationIds: [conversationId], - ), - ], - duration: kSlowThrottleDuration, - ) - .map((event) => event.nonNulls.toList()); - }, - initialData: [], - keys: [conversationId], - ).requireData; - - final showLastPinMessage = useMemoizedStream( - () { - if (conversationId == null) return Stream.value(false); - return ShowPinMessageKeyValue.instance.watch(conversationId); - }, - initialData: false, - keys: [conversationId], - ).requireData; - - final previewContent = useMemoizedStream( - () { - if (!showLastPinMessage || - conversationId == null || - pinMessageIds.firstOrNull == null) { - return Stream.value(null); - } - final messageId = pinMessageIds.first; - - return context.database.pinMessageDao - .pinMessageItem(messageId, conversationId) - .watchSingleOrNullWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchInsertOrReplaceMessageIdsStream( - messageIds: [messageId], - ), - DataBaseEventBus.instance.deleteMessageIdStream.where( - (event) => event.any((element) => element.contains(messageId)), - ), - ], - duration: kSlowThrottleDuration, - ) - .asyncMap((message) async { - if (message == null) return null; - - final pinMessageMinimal = PinMessageMinimal.fromJsonString( - message.content ?? '', - ); - if (pinMessageMinimal == null) return null; - final preview = await generatePinPreviewText( - pinMessageMinimal: pinMessageMinimal, - mentionCache: context.providerContainer.read( - mentionCacheProvider, - ), - ); - - return context.l10n.chatPinMessage( - message.userFullName ?? '', - preview, - ); - }); - }, - keys: [showLastPinMessage, conversationId, pinMessageIds.firstOrNull], - ).data; - - return useMemoized( - () => - PinMessageState(messageIds: pinMessageIds, lastMessage: previewContent), - [previewContent, pinMessageIds], - ); -} diff --git a/lib/ui/home/providers/home_scope_providers.dart b/lib/ui/home/providers/home_scope_providers.dart new file mode 100644 index 0000000000..4d6b8e8280 --- /dev/null +++ b/lib/ui/home/providers/home_scope_providers.dart @@ -0,0 +1,348 @@ +import 'dart:async'; + +import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:rxdart/rxdart.dart'; + +import '../../../account/notification_service.dart'; +import '../../../account/show_pin_message_key_value.dart'; +import '../../../blaze/vo/pin_message_minimal.dart'; +import '../../../db/dao/conversation_dao.dart'; +import '../../../db/database_event_bus.dart'; +import '../../../db/mixin_database.dart'; +import '../../../paging/paging_controller.dart'; +import '../../../ui/provider/abstract_responsive_navigator.dart'; +import '../../../ui/provider/account_server_provider.dart'; +import '../../../ui/provider/conversation_provider.dart'; +import '../../../ui/provider/database_provider.dart'; +import '../../../ui/provider/mention_cache_provider.dart'; +import '../../../ui/provider/multi_auth_provider.dart'; +import '../../../ui/provider/setting_provider.dart'; +import '../../../ui/provider/slide_category_provider.dart'; +import '../../../ui/provider/ui_context_providers.dart'; +import '../../../utils/audio_message_player/audio_message_service.dart'; +import '../../../utils/extension/extension.dart'; +import '../../../widgets/message/item/audio_message.dart'; +import '../../../widgets/message/item/pin_message.dart'; +import '../../home/chat/chat_page.dart'; +import '../../home/controllers/conversation_list_controller.dart'; +import '../../home/controllers/message_controller.dart'; +import '../../home/hook/pin_message.dart'; +import '../chat/voice_recorder_bottom_bar.dart'; +import '../home.dart'; + +export '../controllers/blink_controller.dart' + show blinkColorProvider, blinkControllerProvider; + +StateError _missingScopeProvider(String name) => + StateError('$name is not available in the current ProviderScope'); + +final conversationListControllerProvider = + NotifierProvider.autoDispose< + ConversationListController, + PagingState + >(ConversationListController.new); + +final notificationServiceProvider = Provider.autoDispose(( + ref, +) { + // depends on buildContextProvider via ref.watch + final accountServer = ref.watch(accountServerProvider).value; + final database = ref.watch(databaseProvider).value; + final context = ref.watch(buildContextProvider); + if (accountServer == null || database == null) { + throw _missingScopeProvider('NotificationService'); + } + final service = NotificationService( + context: context, + accountServer: accountServer, + database: database, + settings: ref.watch(settingProvider.notifier), + mentionCache: ref.watch(mentionCacheProvider), + readAccount: () => ref.read(authAccountProvider), + readConversationState: () => ref.read(conversationProvider), + switchToChatsIfSettings: () => + ref.read(slideCategoryProvider.notifier).switchToChatsIfSettings(), + selectConversation: (context, conversationId, {initIndexMessageId}) => + ConversationStateNotifier.selectConversation( + ref.container, + context, + conversationId, + initIndexMessageId: initIndexMessageId, + ), + ); + ref.onDispose(service.close); + return service; +}, dependencies: [buildContextProvider]); + +final audioMessagePlayServiceProvider = + Provider.autoDispose(( + ref, + ) { + final accountServer = ref.watch(accountServerProvider).value; + if (accountServer == null) { + throw _missingScopeProvider('AudioMessagePlayService'); + } + + final service = AudioMessagePlayService(accountServer); + ref.onDispose(service.dispose); + return service; + }); + +final currentPlayingAudioMessageProvider = + StreamProvider.autoDispose((ref) { + final service = ref.watch(audioMessagePlayServiceProvider); + return service.currentMessageStream; + }); + +final audioPlayerPlaybackStateProvider = + StreamProvider.autoDispose((ref) { + final service = ref.watch(audioMessagePlayServiceProvider); + return service.playbackStateStream; + }); + +final audioPlayerSpeedProvider = StreamProvider.autoDispose((ref) { + final service = ref.watch(audioMessagePlayServiceProvider); + return service.playbackSpeedStream; +}); + +final audioPlayerPositionProvider = StreamProvider.autoDispose((ref) { + final service = ref.watch(audioMessagePlayServiceProvider); + return service.positionStream; +}); + +final audioMessagePlayingProvider = Provider.autoDispose + .family((ref, args) { + final playbackState = + ref.watch(audioPlayerPlaybackStateProvider).value ?? + PlaybackState.idle; + final currentMessage = ref + .watch(currentPlayingAudioMessageProvider) + .value; + if (!playbackState.isPlaying) { + return false; + } + return currentMessage?.messageId == args.messageId && + serviceIsMediaList(ref) == args.isMediaList; + }); + +bool serviceIsMediaList(Ref ref) => + ref.watch(audioMessagePlayServiceProvider).isMediaList; + +final chatInputTextValueStreamProvider = Provider.autoDispose + .family, TextEditingController>((ref, controller) { + late final StreamController streamController; + streamController = StreamController.broadcast( + onListen: () { + if (!streamController.isClosed) { + streamController.add(controller.value); + } + }, + ); + + void listener() { + if (!streamController.isClosed) { + streamController.add(controller.value); + } + } + + controller.addListener(listener); + ref.onDispose(() { + controller.removeListener(listener); + unawaited(streamController.close()); + }); + return streamController.stream.startWith(controller.value); + }); + +final chatInputTextValueProvider = StreamProvider.autoDispose + .family( + (ref, controller) => + ref.watch(chatInputTextValueStreamProvider(controller)), + ); + +final chatSideControllerProvider = + NotifierProvider.autoDispose( + ChatSideController.new, + ); + +final searchConversationKeywordControllerProvider = + NotifierProvider.autoDispose< + SearchConversationKeywordController, + (String?, String) + >(SearchConversationKeywordController.new); + +final searchConversationKeywordForUserProvider = Provider.autoDispose + .family((ref, userId) { + final state = ref.watch(searchConversationKeywordControllerProvider); + if (state.$1 == null || state.$1 == userId) { + return state.$2; + } + return ''; + }); + +final searchConversationKeywordDebouncedProvider = + StreamProvider.autoDispose((ref) { + final controller = ref.read( + searchConversationKeywordControllerProvider.notifier, + ); + return controller.stream + .map((event) => event.$2.trim()) + .startWith( + ref.read(searchConversationKeywordControllerProvider).$2.trim(), + ) + .debounceTime(const Duration(milliseconds: 150)); + }); +final messagePageLimitProvider = Provider( + (ref) => (ref.watch(mediaQueryDataProvider).size.height ~/ 20).clamp(1, 1000), + dependencies: [mediaQueryDataProvider], +); + +final pinMessageIdsProvider = StreamProvider.autoDispose + .family, String>((ref, conversationId) { + final database = ref.watch(databaseProvider).value; + if (database == null) { + return Stream.value(const []); + } + + return database.pinMessageDao + .pinMessageIds(conversationId) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchPinMessageStream( + conversationIds: [conversationId], + ), + ], + duration: kSlowThrottleDuration, + ) + .map((event) => event.nonNulls.toList()); + }); + +final showLastPinMessageProvider = StreamProvider.autoDispose + .family( + (ref, conversationId) => + ShowPinMessageKeyValue.instance.watch(conversationId), + ); + +final pinMessagePreviewProvider = StreamProvider.autoDispose + .family((ref, conversationId) { + final database = ref.watch(databaseProvider).value; + if (database == null) { + return Stream.value(null); + } + + final showLastPinMessage = + ref.watch(showLastPinMessageProvider(conversationId)).value ?? false; + final messageId = ref + .watch(pinMessageIdsProvider(conversationId)) + .value + ?.firstOrNull; + if (!showLastPinMessage || messageId == null) { + return Stream.value(null); + } + + return database.pinMessageDao + .pinMessageItem(messageId, conversationId) + .watchSingleOrNullWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchInsertOrReplaceMessageIdsStream( + messageIds: [messageId], + ), + DataBaseEventBus.instance.deleteMessageIdStream.where( + (event) => event.any((element) => element.contains(messageId)), + ), + ], + duration: kSlowThrottleDuration, + ) + .asyncMap((message) async { + if (message == null) return null; + + final pinMessageMinimal = PinMessageMinimal.fromJsonString( + message.content ?? '', + ); + if (pinMessageMinimal == null) return null; + final preview = await generatePinPreviewText( + pinMessageMinimal: pinMessageMinimal, + mentionCache: ref.watch(mentionCacheProvider), + ); + return PinMessagePreview( + senderFullName: message.userFullName ?? '', + preview: preview, + ); + }); + }); + +final pinnedMessagesProvider = StreamProvider.autoDispose + .family, String>((ref, conversationId) { + final database = ref.watch(databaseProvider).value; + if (database == null) { + return Stream.value(const []); + } + + return database.pinMessageDao + .messageItems(conversationId) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchPinMessageStream( + conversationIds: [conversationId], + ), + DataBaseEventBus.instance.updateAssetStream, + DataBaseEventBus.instance.updateStickerStream, + ], + duration: kSlowThrottleDuration, + ); + }); + +final pinnedAudioMessagesPlayAgentProvider = Provider.autoDispose + .family((ref, conversationId) { + final accountServer = ref.watch(accountServerProvider).value; + if (accountServer == null) { + return null; + } + + final messages = + ref.watch(pinnedMessagesProvider(conversationId)).value ?? + const []; + return AudioMessagesPlayAgent( + messages.reversed.toList(), + (message) => accountServer.convertMessageAbsolutePath(message, true), + ); + }); + +final messageControllerProvider = + NotifierProvider.autoDispose( + MessageController.new, + dependencies: [messagePageLimitProvider], + ); + +final pinMessageStateProvider = Provider.autoDispose((ref) { + final conversationId = ref.watch(currentConversationIdProvider); + if (conversationId == null) { + return const PinMessageState(messageIds: []); + } + + return PinMessageState( + messageIds: + ref.watch(pinMessageIdsProvider(conversationId)).value ?? + const [], + lastMessage: ref.watch(pinMessagePreviewProvider(conversationId)).value, + ); +}); + +final voiceRecorderControllerProvider = + NotifierProvider.autoDispose( + VoiceRecorderController.new, + ); + +final stickerAlbumsProvider = StreamProvider.autoDispose>(( + ref, +) { + final database = ref.watch(databaseProvider).value; + if (database == null) { + return Stream.value(const []); + } + + return database.stickerAlbumDao.systemAddedAlbums().watchWithStream( + eventStreams: [DataBaseEventBus.instance.updateStickerStream], + duration: kVerySlowThrottleDuration, + ); +}); diff --git a/lib/ui/home/route/responsive_navigator.dart b/lib/ui/home/route/responsive_navigator.dart index f084fe9f38..773f2f0682 100644 --- a/lib/ui/home/route/responsive_navigator.dart +++ b/lib/ui/home/route/responsive_navigator.dart @@ -1,69 +1,9 @@ -import 'dart:math'; - import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../provider/abstract_responsive_navigator.dart'; import '../../provider/responsive_navigator_provider.dart'; -abstract class AbstractResponsiveNavigatorCubit - extends Cubit { - AbstractResponsiveNavigatorCubit(super.initialState); - - void updateRouteMode(bool routeMode) => - emit(state.copyWith(routeMode: routeMode)); - - void onPopPage() { - final bool = state.pages.isNotEmpty; - if (bool) { - emit(state.copyWith(pages: state.pages.toList()..removeLast())); - } - } - - MaterialPage route(String name, Object? arguments); - - void pushPage(String name, {Object? arguments}) { - final page = route(name, arguments); - var index = -1; - index = state.pages.indexWhere( - (element) => - page.child.key != null && element.child.key == page.child.key, - ); - if (state.pages.isNotEmpty && index == state.pages.length - 1) return; - if (index != -1) state.pages.removeRange(max(index, 0), state.pages.length); - emit(state.copyWith(pages: state.pages.toList()..add(page))); - } - - void popUntil(bool Function(MaterialPage page) test) { - final index = state.pages.indexWhere(test); - if (index == -1) return; - - List? list; - list = index == 0 ? [] : state.pages.toList() - ..sublist(0, index); - emit(state.copyWith(pages: list)); - } - - void popWhere(bool Function(MaterialPage page) test) => - emit(state.copyWith(pages: state.pages.toList()..removeWhere(test))); - - void pop() => emit( - state.copyWith( - pages: state.pages.sublist(0, max(state.pages.length - 1, 0)).toList(), - ), - ); - - Future replace(String name, {Object? arguments}) async { - popWhere((page) => page.name == name); - await Future.delayed(Duration.zero); - pushPage(name, arguments: arguments); - } - - void clear() => emit(state.copyWith(pages: [])); -} - class ResponsiveNavigator extends HookConsumerWidget { const ResponsiveNavigator({ required this.leftPage, diff --git a/lib/ui/home/slide_page.dart b/lib/ui/home/slide_page.dart index 675fb1a056..b43a4a95a2 100644 --- a/lib/ui/home/slide_page.dart +++ b/lib/ui/home/slide_page.dart @@ -12,10 +12,8 @@ import '../../constants/resources.dart'; import '../../db/dao/circle_dao.dart'; import '../../db/dao/conversation_dao.dart'; import '../../db/database_event_bus.dart'; -import '../../generated/l10n.dart'; import '../../utils/color_utils.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../../widgets/animated_visibility.dart'; import '../../widgets/avatar_view/avatar_view.dart'; import '../../widgets/dialog.dart'; @@ -24,112 +22,160 @@ import '../../widgets/select_item.dart'; import '../../widgets/toast.dart'; import '../../widgets/user_selector/conversation_selector.dart'; import '../../widgets/window/move_window.dart'; +import '../provider/account_server_provider.dart'; +import '../provider/database_provider.dart'; import '../provider/multi_auth_provider.dart'; import '../provider/setting_provider.dart'; import '../provider/slide_category_provider.dart'; +import '../provider/ui_context_providers.dart'; -class SlidePage extends StatelessWidget { +final _allCirclesProvider = + StreamProvider.autoDispose>((ref) { + final database = ref.watch(databaseProvider).value; + if (database == null) return const Stream.empty(); + return database.circleDao.allCircles().watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.updateCircleStream, + DataBaseEventBus.instance.updateCircleConversationStream, + DataBaseEventBus.instance.updateUserIdsStream, + DataBaseEventBus.instance.updateConversationIdStream, + ], + duration: kDefaultThrottleDuration, + ); + }); + +final _slideCategoryUnseenCountProvider = StreamProvider.autoDispose + .family((ref, type) { + if (type == SlideCategoryType.chats || + type == SlideCategoryType.circle || + type == SlideCategoryType.setting) { + return const Stream.empty(); + } + final database = ref.watch(databaseProvider).value; + if (database == null) { + return const Stream.empty(); + } + return database.conversationDao + .unseenConversationCountByCategory(type) + .watchSingleWithStream( + eventStreams: [ + DataBaseEventBus.instance.updateConversationIdStream, + ], + duration: kDefaultThrottleDuration, + ); + }); + +class SlidePage extends ConsumerWidget { const SlidePage({required this.showCollapse, super.key}); final bool showCollapse; @override - Widget build(BuildContext context) => SafeArea( - child: RepaintBoundary( - child: DecoratedBox( - decoration: BoxDecoration( - color: context.brightnessValue == 1.0 - ? Colors.black.withValues(alpha: 0.03) - : Colors.white.withValues(alpha: 0.01), - border: Border(right: BorderSide(color: context.theme.divider)), - ), - child: MoveWindow( - behavior: HitTestBehavior.opaque, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - SizedBox( - height: (defaultTargetPlatform == TargetPlatform.macOS) - ? 64.0 - : 16.0, - ), - const _CurrentUser(), - const SizedBox(height: 24), - _Item( - asset: Resources.assetsImagesChatSvg, - title: context.l10n.allChats, - type: SlideCategoryType.chats, - ), - const SizedBox(height: 12), - const _Divider(), - const SizedBox(height: 12), - _CategoryList( - children: [ - _Item( - asset: Resources.assetsImagesSlideContactsSvg, - title: context.l10n.contactTitle, - type: SlideCategoryType.contacts, - ), - _Item( - asset: Resources.assetsImagesGroupSvg, - title: context.l10n.groups, - type: SlideCategoryType.groups, - ), - _Item( - asset: Resources.assetsImagesBotSvg, - title: Localization.current.botsTitle, - type: SlideCategoryType.bots, - ), - _Item( - asset: Resources.assetsImagesStrangersSvg, - title: context.l10n.strangers, - type: SlideCategoryType.strangers, - ), - ], - ), - const SizedBox(height: 16), - const Expanded(child: _CircleList()), - AnimatedVisibility( - alignment: Alignment.bottomCenter, - visible: showCollapse, - child: Consumer( - builder: (context, ref, child) { - final collapse = ref.watch( - settingProvider.select( - (value) => value.collapsedSidebar, - ), - ); - - return SelectItem( - icon: SvgPicture.asset( - collapse - ? Resources.assetsImagesExpandedSvg - : Resources.assetsImagesCollapseSvg, - width: 24, - height: 24, - colorFilter: ColorFilter.mode( - context.theme.text, - BlendMode.srcIn, + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final brightness = ref.watch(brightnessValueProvider); + return SafeArea( + child: RepaintBoundary( + child: DecoratedBox( + decoration: BoxDecoration( + color: brightness == 1.0 + ? Colors.black.withValues(alpha: 0.03) + : Colors.white.withValues(alpha: 0.01), + border: Border( + right: BorderSide(color: theme.divider), + ), + ), + child: MoveWindow( + behavior: HitTestBehavior.opaque, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + SizedBox( + height: (defaultTargetPlatform == TargetPlatform.macOS) + ? 64.0 + : 16.0, + ), + const _CurrentUser(), + const SizedBox(height: 24), + _Item( + asset: Resources.assetsImagesChatSvg, + title: l10n.allChats, + type: SlideCategoryType.chats, + ), + const SizedBox(height: 12), + const _Divider(), + const SizedBox(height: 12), + _CategoryList( + children: [ + _Item( + asset: Resources.assetsImagesSlideContactsSvg, + title: l10n.contactTitle, + type: SlideCategoryType.contacts, + ), + _Item( + asset: Resources.assetsImagesGroupSvg, + title: l10n.groups, + type: SlideCategoryType.groups, + ), + _Item( + asset: Resources.assetsImagesBotSvg, + title: Localization.current.botsTitle, + type: SlideCategoryType.bots, + ), + _Item( + asset: Resources.assetsImagesStrangersSvg, + title: l10n.strangers, + type: SlideCategoryType.strangers, + ), + ], + ), + const SizedBox(height: 16), + const Expanded(child: _CircleList()), + AnimatedVisibility( + alignment: Alignment.bottomCenter, + visible: showCollapse, + child: Consumer( + builder: (context, ref, child) { + final collapse = ref.watch( + settingProvider.select( + (value) => value.collapsedSidebar, ), - ), - title: Text(context.l10n.collapse), - onTap: () => - context.settingChangeNotifier.collapsedSidebar = - !collapse, - ); - }, + ); + + return SelectItem( + icon: SvgPicture.asset( + collapse + ? Resources.assetsImagesExpandedSvg + : Resources.assetsImagesCollapseSvg, + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + theme.text, + BlendMode.srcIn, + ), + ), + title: Text(l10n.collapse), + onTap: () => + ref + .read(settingProvider.notifier) + .collapsedSidebar = + !collapse, + ); + }, + ), ), - ), - const SizedBox(height: 4), - ], + const SizedBox(height: 4), + ], + ), ), ), ), ), - ), - ); + ); + } } class _CurrentUser extends HookConsumerWidget { @@ -137,9 +183,10 @@ class _CurrentUser extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final account = ref.watch(authAccountProvider); final selected = ref.watch( - slideCategoryStateProvider.select( + slideCategoryProvider.select( (value) => value.type == SlideCategoryType.setting, ), ); @@ -164,7 +211,7 @@ class _CurrentUser extends HookConsumerWidget { Text( '${account?.identityNumber}', style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 12, ), ), @@ -173,7 +220,7 @@ class _CurrentUser extends HookConsumerWidget { selected: selected, onTap: () { ref - .read(slideCategoryStateProvider.notifier) + .read(slideCategoryProvider.notifier) .select(SlideCategoryType.setting); if (ModalRoute.of(context)?.canPop == true) { @@ -190,24 +237,17 @@ class _CircleList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final circles = useMemoizedStream>( - () => context.database.circleDao.allCircles().watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.updateCircleStream, - DataBaseEventBus.instance.updateCircleConversationStream, - DataBaseEventBus.instance.updateUserIdsStream, - DataBaseEventBus.instance.updateConversationIdStream, - ], - duration: kDefaultThrottleDuration, - ), - initialData: [], - ); + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final circles = ref.watch(_allCirclesProvider).value ?? const []; final controller = useScrollController(); - final list = useState(circles.data ?? []); + final list = useState(circles); + final accountServer = ref.read(accountServerProvider).requireValue; + final database = ref.read(databaseProvider).requireValue; useEffect(() { - list.value = circles.data ?? []; - }, [circles.data]); - if (circles.data?.isEmpty ?? true) return const SizedBox(); + list.value = circles; + }, [circles]); + if (circles.isEmpty) return const SizedBox(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -224,7 +264,7 @@ class _CircleList extends HookConsumerWidget { newList.insert(_newIndex, oldItem); list.value = newList; - context.database.circleDao.updateOrders(list.value); + accountServer.updateCircleOrders(list.value); }, itemCount: list.value.length, itemBuilder: (context, index) { @@ -233,7 +273,7 @@ class _CircleList extends HookConsumerWidget { key: Key(circle.circleId), builder: (context, ref, _) { final selected = ref.watch( - slideCategoryStateProvider.select((value) { + slideCategoryProvider.select((value) { final conversationCircleItem = list.value[index]; return value.type == SlideCategoryType.circle && value.id == conversationCircleItem.circleId; @@ -266,22 +306,22 @@ class _CircleList extends HookConsumerWidget { [ MenuAction( image: MenuImage.icon(IconFonts.edit), - title: context.l10n.editCircleName, + title: l10n.editCircleName, callback: () async { final name = await showMixinDialog( context: context, child: EditDialog( editText: circle.name, - title: Text(context.l10n.circles), - hintText: context.l10n.editCircleName, - positiveAction: context.l10n.edit, + title: Text(l10n.circles), + hintText: l10n.editCircleName, + positiveAction: l10n.edit, maxLength: 64, ), ); if (name?.isEmpty ?? true) return; await runFutureWithToast( - context.accountServer.updateCircle( + accountServer.updateCircle( circle.circleId, name!, ), @@ -292,12 +332,10 @@ class _CircleList extends HookConsumerWidget { image: MenuImage.icon( IconFonts.manageCircle, ), - title: context.l10n.editConversations, + title: l10n.editConversations, callback: () async { final initSelected = - (await context - .database - .circleConversationDao + (await database.circleConversationDao .allCircleConversations( circle.circleId, ) @@ -317,7 +355,7 @@ class _CircleList extends HookConsumerWidget { onlyContact: false, initSelected: initSelected, allowEmpty: true, - confirmedText: context.l10n.done, + confirmedText: l10n.done, ); if (result == null || result.isEmpty) { @@ -357,11 +395,10 @@ class _CircleList extends HookConsumerWidget { ), ), ]; - await context.accountServer - .editCircleConversation( - circle.circleId, - requests, - ); + await accountServer.editCircleConversation( + circle.circleId, + requests, + ); }()); }, ), @@ -369,22 +406,20 @@ class _CircleList extends HookConsumerWidget { [ MenuAction( image: MenuImage.icon(IconFonts.delete), - title: context.l10n.deleteCircle, + title: l10n.deleteCircle, callback: () async { final result = await showConfirmMixinDialog( context, - context.l10n.deleteTheCircle( - circle.name, - ), + l10n.deleteTheCircle(circle.name), ); if (result == null) return; await runFutureWithToast(() async { - await context.accountServer.deleteCircle( + await accountServer.deleteCircle( circle.circleId, ); ref .read( - slideCategoryStateProvider.notifier, + slideCategoryProvider.notifier, ) .select(SlideCategoryType.chats); }()); @@ -394,7 +429,7 @@ class _CircleList extends HookConsumerWidget { ], ), child: Material( - color: context.theme.primary, + color: theme.primary, child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: SelectItem( @@ -410,7 +445,7 @@ class _CircleList extends HookConsumerWidget { title: Text(circle.name), onTap: () { ref - .read(slideCategoryStateProvider.notifier) + .read(slideCategoryProvider.notifier) .select( SlideCategoryType.circle, circle.circleId, @@ -467,31 +502,12 @@ class _Item extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final selected = ref.watch( - slideCategoryStateProvider.select((value) => value.type == type), + slideCategoryProvider.select((value) => value.type == type), ); - final result = useMemoizedStream(() { - final dao = context.database.conversationDao; - switch (type) { - case SlideCategoryType.contacts: - case SlideCategoryType.groups: - case SlideCategoryType.bots: - case SlideCategoryType.strangers: - return dao - .unseenConversationCountByCategory(type) - .watchSingleWithStream( - eventStreams: [ - DataBaseEventBus.instance.updateConversationIdStream, - ], - duration: kDefaultThrottleDuration, - ); - case SlideCategoryType.chats: - case SlideCategoryType.circle: - case SlideCategoryType.setting: - return const Stream.empty(); - } - }, keys: [type]).data; + final result = ref.watch(_slideCategoryUnseenCountProvider(type)).value; return MoveWindowBarrier( child: SelectItem( @@ -499,11 +515,14 @@ class _Item extends HookConsumerWidget { asset, width: 24, height: 24, - colorFilter: ColorFilter.mode(context.theme.text, BlendMode.srcIn), + colorFilter: ColorFilter.mode( + theme.text, + BlendMode.srcIn, + ), ), title: Text(title), onTap: () { - ref.read(slideCategoryStateProvider.notifier).select(type, title); + ref.read(slideCategoryProvider.notifier).select(type, title); if (ModalRoute.of(context)?.canPop == true) { Navigator.pop(context); @@ -517,17 +536,19 @@ class _Item extends HookConsumerWidget { } } -class _Divider extends StatelessWidget { +class _Divider extends ConsumerWidget { const _Divider(); @override - Widget build(BuildContext context) => Container( - height: 1.5, - // width: 32, - margin: const EdgeInsets.symmetric(horizontal: 8), - decoration: ShapeDecoration( - color: context.theme.listSelected, - shape: const StadiumBorder(), - ), - ); + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Container( + height: 1.5, + margin: const EdgeInsets.symmetric(horizontal: 8), + decoration: ShapeDecoration( + color: theme.listSelected, + shape: const StadiumBorder(), + ), + ); + } } diff --git a/lib/ui/landing/bloc/landing_cubit.dart b/lib/ui/landing/controllers/landing_controller.dart similarity index 52% rename from lib/ui/landing/bloc/landing_cubit.dart rename to lib/ui/landing/controllers/landing_controller.dart index 3a09711bb7..37dcf33317 100644 --- a/lib/ui/landing/bloc/landing_cubit.dart +++ b/lib/ui/landing/controllers/landing_controller.dart @@ -3,10 +3,9 @@ import 'dart:convert'; import 'dart:io'; import 'dart:ui'; -import 'package:bloc/bloc.dart'; import 'package:dio/dio.dart'; import 'package:ed25519_edwards/ed25519_edwards.dart' as ed; -import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:libsignal_protocol_dart/libsignal_protocol_dart.dart' as signal; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; @@ -14,65 +13,53 @@ import '../../../account/account_key_value.dart'; import '../../../crypto/crypto_key_value.dart'; import '../../../crypto/signal/signal_protocol.dart'; import '../../../generated/l10n.dart'; -import '../../../utils/extension/extension.dart'; import '../../../utils/logger.dart'; import '../../../utils/platform.dart'; import '../../../utils/system/package_info.dart'; import '../../provider/multi_auth_provider.dart'; import 'landing_state.dart'; -class LandingCubit extends Cubit { - LandingCubit( - this.multiAuthChangeNotifier, - Locale locale, - T initialState, { - String? userAgent, - String? deviceId, - }) : client = Client( - dioOptions: BaseOptions( - headers: { - 'Accept-Language': locale.languageCode, - 'User-Agent': ?userAgent, - 'Mixin-Device-Id': ?deviceId, - }, - ), - ), - super(initialState); - final Client client; - final MultiAuthStateNotifier multiAuthChangeNotifier; -} - -class LandingQrCodeCubit extends LandingCubit { - LandingQrCodeCubit( - MultiAuthStateNotifier multiAuthChangeNotifier, - Locale locale, - ) : super( - multiAuthChangeNotifier, - locale, - LandingState( - status: multiAuthChangeNotifier.current != null - ? LandingStatus.provisioning - : LandingStatus.init, - ), - ) { - if (multiAuthChangeNotifier.current != null) return; - requestAuthUrl(); - } - - final StreamController<(int, String, signal.ECKeyPair)> - periodicStreamController = - StreamController<(int, String, signal.ECKeyPair)>(); - - StreamSubscription? _periodicSubscription; +Client buildLandingClient( + Locale locale, { + String? userAgent, + String? deviceId, +}) => Client( + dioOptions: BaseOptions( + headers: { + 'Accept-Language': locale.languageCode, + 'User-Agent': ?userAgent, + 'Mixin-Device-Id': ?deviceId, + }, + ), +); + +class LandingQrCodeNotifier extends Notifier { + StreamSubscription? _periodicSubscription; + + late final Client client = buildLandingClient( + PlatformDispatcher.instance.locale, + ); - void _cancelPeriodicSubscription() { - final periodicSubscription = _periodicSubscription; - _periodicSubscription = null; - unawaited(periodicSubscription?.cancel()); + @override + LandingState build() { + ref.onDispose(() { + unawaited(_periodicSubscription?.cancel()); + }); + + final multiAuth = ref.watch(multiAuthNotifierProvider.notifier); + final initialState = LandingState( + status: multiAuth.current != null + ? LandingStatus.provisioning + : LandingStatus.init, + ); + if (multiAuth.current == null) { + Future.microtask(requestAuthUrl); + } + return initialState; } Future requestAuthUrl() async { - _cancelPeriodicSubscription(); + await _cancelPeriodicSubscription(); try { final rsp = await client.provisioningApi.getProvisioningId( Platform.operatingSystem, @@ -82,30 +69,29 @@ class LandingQrCodeCubit extends LandingCubit { base64Encode(keyPair.publicKey.serialize()), ); - emit( - state.copyWith( - authUrl: - 'mixin://device/auth?id=${rsp.data.deviceId}&pub_key=$pubKey', - status: LandingStatus.ready, - ), + state = state.copyWith( + authUrl: 'mixin://device/auth?id=${rsp.data.deviceId}&pub_key=$pubKey', + status: LandingStatus.ready, ); _periodicSubscription = - Stream.periodic( - const Duration(seconds: 1), - (i) => i, - ) - .asyncBufferMap( - (event) => - _checkLanding(event.last, rsp.data.deviceId, keyPair), + Stream.periodic(const Duration(seconds: 1), (i) => i) + .asyncMap( + (event) => _checkLanding(event, rsp.data.deviceId, keyPair), ) .listen((event) {}); } catch (error, stack) { e('requestAuthUrl failed: $error $stack'); - emit(state.needReload('Failed to request auth: $error')); + state = state.needReload('Failed to request auth: $error'); } } + Future _cancelPeriodicSubscription() async { + final subscription = _periodicSubscription; + _periodicSubscription = null; + await subscription?.cancel(); + } + Future _checkLanding( int count, String deviceId, @@ -114,8 +100,8 @@ class LandingQrCodeCubit extends LandingCubit { if (_periodicSubscription == null) return; if (count > 60) { - _cancelPeriodicSubscription(); - emit(state.needReload(Localization.current.qrCodeExpiredDesc)); + await _cancelPeriodicSubscription(); + state = state.needReload(Localization.current.qrCodeExpiredDesc); return; } @@ -124,21 +110,23 @@ class LandingQrCodeCubit extends LandingCubit { secret = (await client.provisioningApi.getProvisioning( deviceId, )).data.secret; - } catch (e) { + } catch (_) { return; } if (secret.isEmpty) return; - _cancelPeriodicSubscription(); - emit(state.copyWith(status: LandingStatus.provisioning)); + await _cancelPeriodicSubscription(); + state = state.copyWith(status: LandingStatus.provisioning); try { - final (acount, privateKey) = await _verify(secret, keyPair); - multiAuthChangeNotifier.signIn( - AuthState(account: acount, privateKey: privateKey), - ); + final (account, privateKey) = await _verify(secret, keyPair); + ref + .read(multiAuthNotifierProvider.notifier) + .signIn( + AuthState(account: account, privateKey: privateKey), + ); } catch (error, stack) { - emit(state.needReload('Failed to verify: $error')); + state = state.needReload('Failed to verify: $error'); e('_verify: $error $stack'); } } @@ -185,26 +173,4 @@ class LandingQrCodeCubit extends LandingCubit { return (rsp.data, privateKey); } - - @override - Future close() async { - await _periodicSubscription?.cancel(); - await periodicStreamController.close(); - await super.close(); - } -} - -class LandingMobileCubit extends LandingCubit { - LandingMobileCubit( - MultiAuthStateNotifier multiAuthChangeNotifier, - Locale locale, { - required String deviceId, - required String userAgent, - }) : super( - multiAuthChangeNotifier, - locale, - null, - deviceId: deviceId, - userAgent: userAgent, - ); } diff --git a/lib/ui/landing/bloc/landing_state.dart b/lib/ui/landing/controllers/landing_state.dart similarity index 100% rename from lib/ui/landing/bloc/landing_state.dart rename to lib/ui/landing/controllers/landing_state.dart diff --git a/lib/ui/landing/landing.dart b/lib/ui/landing/landing.dart index da41d9bdd2..5b2435563f 100644 --- a/lib/ui/landing/landing.dart +++ b/lib/ui/landing/landing.dart @@ -1,7 +1,7 @@ +import 'dart:ui'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; @@ -13,21 +13,59 @@ import '../../utils/extension/extension.dart'; import '../../utils/hive_key_values.dart'; import '../../utils/hook.dart'; import '../../utils/mixin_api_client.dart'; +import '../../utils/platform.dart'; import '../../utils/system/package_info.dart'; import '../../widgets/buttons.dart'; import '../../widgets/dialog.dart'; import '../../widgets/toast.dart'; import '../provider/account_server_provider.dart'; +import '../provider/multi_auth_provider.dart'; +import '../provider/ui_context_providers.dart'; import '../setting/log_page.dart'; +import 'controllers/landing_controller.dart'; +import 'controllers/landing_state.dart'; import 'landing_mobile.dart'; import 'landing_qrcode.dart'; enum LandingMode { qrcode, mobile } -class LandingModeCubit extends Cubit { - LandingModeCubit() : super(LandingMode.qrcode); +final landingModeControllerProvider = + NotifierProvider.autoDispose( + LandingModeController.new, + ); + +final landingQrCodeNotifierProvider = + NotifierProvider.autoDispose( + LandingQrCodeNotifier.new, + ); + +typedef LandingMobilePlatformInfo = ({String userAgent, String deviceId}); + +final landingMobilePlatformInfoProvider = + FutureProvider.autoDispose((ref) async { + final userAgent = await generateUserAgent(); + final deviceId = await getDeviceId(); + return (userAgent: userAgent ?? '', deviceId: deviceId); + }); + +final landingMobileClientProvider = FutureProvider.autoDispose(( + ref, +) async { + final platformInfo = await ref.watch( + landingMobilePlatformInfoProvider.future, + ); + return buildLandingClient( + PlatformDispatcher.instance.locale, + userAgent: platformInfo.userAgent, + deviceId: platformInfo.deviceId, + ); +}); + +class LandingModeController extends Notifier { + @override + LandingMode build() => LandingMode.qrcode; - void changeMode(LandingMode mode) => emit(mode); + void changeMode(LandingMode mode) => state = mode; } class LandingPage extends HookConsumerWidget { @@ -35,12 +73,22 @@ class LandingPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + ref.listen( + accountServerProvider, + (previous, next) { + if (!next.hasError) return; + e( + 'accountServerProvider error changed: ' + 'previousError=${previous?.error} ' + 'nextError=${next.error} ' + 'nextStackTrace=${next.stackTrace}', + ); + }, + ); final accountServerHasError = ref.watch( accountServerProvider.select((value) => value.hasError), ); - - final modeCubit = useBloc(LandingModeCubit.new); - final mode = useBlocState(bloc: modeCubit); + final mode = ref.watch(landingModeControllerProvider); Widget child; switch (mode) { @@ -52,10 +100,7 @@ class LandingPage extends HookConsumerWidget { if (accountServerHasError) { child = const _LoginFailed(); } - return BlocProvider.value( - value: modeCubit, - child: LandingScaffold(child: child), - ); + return LandingScaffold(child: child); } } @@ -64,6 +109,8 @@ class _LoginFailed extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final accountServerError = ref.watch(accountServerProvider); final errorText = 'Error: ${accountServerError.error}'; @@ -74,10 +121,10 @@ class _LoginFailed extends HookConsumerWidget { child: Column( children: [ Text( - context.l10n.unknowError, + l10n.unknowError, textAlign: TextAlign.center, style: TextStyle( - color: context.theme.red, + color: theme.red, fontSize: 18, fontWeight: FontWeight.w600, ), @@ -86,14 +133,14 @@ class _LoginFailed extends HookConsumerWidget { Expanded( child: DefaultTextStyle( style: TextStyle( - color: context.theme.text, + color: theme.text, fontSize: 16, fontWeight: FontWeight.w400, ), child: SelectionArea( child: DecoratedBox( decoration: BoxDecoration( - color: context.theme.sidebarSelected, + color: theme.sidebarSelected, borderRadius: const BorderRadius.all(Radius.circular(8)), ), child: SingleChildScrollView( @@ -131,7 +178,7 @@ class _LoginFailed extends HookConsumerWidget { ); showToastSuccessful(); }, - child: Text(context.l10n.copy), + child: Text(l10n.copy), ), MixinButton( padding: const EdgeInsets.symmetric( @@ -139,7 +186,7 @@ class _LoginFailed extends HookConsumerWidget { vertical: 14, ), onTap: () async { - final authState = context.auth; + final authState = ref.read(authProvider); if (authState == null) return; try { @@ -157,9 +204,9 @@ class _LoginFailed extends HookConsumerWidget { } await clearKeyValues(); await SignalDatabase.get.clear(); - context.multiAuthChangeNotifier.signOut(); + ref.read(multiAuthNotifierProvider.notifier).signOut(); }, - child: Text(context.l10n.retry), + child: Text(l10n.retry), ), ], ), @@ -175,49 +222,63 @@ class LandingScaffold extends HookConsumerWidget { final Widget child; @override - Widget build(BuildContext context, WidgetRef ref) => Portal( - child: Scaffold( - backgroundColor: context.dynamicColor( - const Color(0xFFE5E5E5), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final backgroundColor = ref.watch( + dynamicColorProvider(( + color: const Color(0xFFE5E5E5), darkColor: const Color.fromRGBO(35, 39, 43, 1), - ), - resizeToAvoidBottomInset: false, - body: Stack( - children: [ - Center( - child: SizedBox( - width: 520, - height: 418, - child: Material( - color: context.theme.popUp, - borderRadius: const BorderRadius.all(Radius.circular(13)), - elevation: 10, - child: ClipRRect( + )), + ); + return Portal( + child: Scaffold( + backgroundColor: backgroundColor, + resizeToAvoidBottomInset: false, + body: Stack( + children: [ + Center( + child: SizedBox( + width: 520, + height: 418, + child: Material( + color: theme.popUp, borderRadius: const BorderRadius.all(Radius.circular(13)), - child: child, + elevation: 10, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(13)), + child: child, + ), ), ), ), - ), - const Positioned(bottom: 16, right: 16, child: VersionInfoWidget()), - ], + const Positioned( + bottom: 16, + right: 16, + child: VersionInfoWidget(), + ), + ], + ), ), - ), - ); + ); + } } -class VersionInfoWidget extends HookWidget { +class VersionInfoWidget extends HookConsumerWidget { const VersionInfoWidget({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final info = useMemoizedFuture(getPackageInfo, null).data; return NTapGestureDetector( n: 5, onTap: () => showLogPage(context), child: Text( info?.versionAndBuildNumber ?? '', - style: TextStyle(fontSize: 14, color: context.theme.secondaryText), + style: TextStyle( + fontSize: 14, + color: theme.secondaryText, + ), ), ); } @@ -228,22 +289,27 @@ class LandingModeSwitchButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final mode = useBlocState(); + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final mode = ref.watch(landingModeControllerProvider); final String buttonText; switch (mode) { case LandingMode.qrcode: - buttonText = context.l10n.signWithMobileNumber; + buttonText = l10n.signWithMobileNumber; case LandingMode.mobile: - buttonText = context.l10n.signWithQrcode; + buttonText = l10n.signWithQrcode; } return TextButton( onPressed: () { - final modeCubit = context.read(); switch (mode) { case LandingMode.qrcode: - modeCubit.changeMode(LandingMode.mobile); + ref + .read(landingModeControllerProvider.notifier) + .changeMode(LandingMode.mobile); case LandingMode.mobile: - modeCubit.changeMode(LandingMode.qrcode); + ref + .read(landingModeControllerProvider.notifier) + .changeMode(LandingMode.qrcode); } }, child: Text( @@ -251,7 +317,7 @@ class LandingModeSwitchButton extends HookConsumerWidget { style: TextStyle( fontSize: 14, fontWeight: FontWeight.normal, - color: context.theme.accent, + color: theme.accent, ), ), ); diff --git a/lib/ui/landing/landing_failed.dart b/lib/ui/landing/landing_failed.dart index 5444684181..03e14356e4 100644 --- a/lib/ui/landing/landing_failed.dart +++ b/lib/ui/landing/landing_failed.dart @@ -6,10 +6,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path/path.dart' as p; import '../../constants/constants.dart'; -import '../../utils/extension/extension.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/file.dart'; import '../../widgets/dialog.dart'; import '../provider/database_provider.dart'; +import '../provider/multi_auth_provider.dart'; import 'landing.dart'; // https://sqlite.org/rescode.html @@ -18,21 +19,22 @@ const _kSqliteLocked = 6; const _kSqliteNotADb = 26; const _kSqliteIOErr = 10; -class DatabaseOpenFailedPage extends StatelessWidget { +class DatabaseOpenFailedPage extends ConsumerWidget { const DatabaseOpenFailedPage({required this.error, super.key}); final SqliteException error; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); final String message; switch (error.resultCode) { case _kSqliteCorrupt: - message = context.l10n.databaseCorruptedTips; + message = l10n.databaseCorruptedTips; case _kSqliteLocked: - message = context.l10n.databaseLockedTips; + message = l10n.databaseLockedTips; case _kSqliteNotADb: - message = context.l10n.databaseNotADbTips; + message = l10n.databaseNotADbTips; default: message = '${error.explanation}'; } @@ -43,7 +45,7 @@ class DatabaseOpenFailedPage extends StatelessWidget { }.contains(error.resultCode); return LandingFailedPage( - title: context.l10n.failedToOpenDatabase, + title: l10n.failedToOpenDatabase, message: message, actions: [ if (canDeleteDatabase) @@ -55,7 +57,7 @@ class DatabaseOpenFailedPage extends StatelessWidget { onTap: () { exit(1); }, - text: context.l10n.exit, + text: l10n.exit, ), ], ); @@ -66,72 +68,85 @@ class _RecreateDatabaseButton extends HookConsumerWidget { const _RecreateDatabaseButton(); @override - Widget build(BuildContext context, WidgetRef ref) => TextButton( - onPressed: () async { - final identityNumber = context.account?.identityNumber; - if (identityNumber == null) return; - - final result = await showConfirmMixinDialog( - context, - context.l10n.databaseRecreateTips, - positiveText: context.l10n.create, - ); - if (result != DialogEvent.positive) { - return; - } - await ref.read(databaseProvider.notifier).close(); - // Rename the old database file to a new name with timestamp. - final now = DateTime.now(); - renameFileWithTime( - p.join(mixinDocumentsDirectory.path, identityNumber, '$kDbFileName.db'), - now, - ); - await Future.forEach( - [ - File( - p.join( - mixinDocumentsDirectory.path, - identityNumber, - '$kDbFileName.db-shm', - ), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final identityNumber = ref.watch( + authAccountProvider.select((value) => value?.identityNumber), + ); + return TextButton( + onPressed: () async { + if (identityNumber == null) return; + + final result = await showConfirmMixinDialog( + context, + l10n.databaseRecreateTips, + positiveText: l10n.create, + ); + if (result != DialogEvent.positive) { + return; + } + await ref.read(databaseProvider.notifier).close(); + // Rename the old database file to a new name with timestamp. + final now = DateTime.now(); + renameFileWithTime( + p.join( + mixinDocumentsDirectory.path, + identityNumber, + '$kDbFileName.db', ), - File( - p.join( - mixinDocumentsDirectory.path, - identityNumber, - '$kDbFileName.db-wal', + now, + ); + await Future.forEach( + [ + File( + p.join( + mixinDocumentsDirectory.path, + identityNumber, + '$kDbFileName.db-shm', + ), ), - ), - ].where((e) => e.existsSync()), - (element) => element.delete(), - ); - await ref.read(databaseProvider.notifier).open(); - }, - child: Text( - context.l10n.continueText, - style: TextStyle(color: context.theme.red), - ), - ); + File( + p.join( + mixinDocumentsDirectory.path, + identityNumber, + '$kDbFileName.db-wal', + ), + ), + ].where((e) => e.existsSync()), + (element) => element.delete(), + ); + await ref.read(databaseProvider.notifier).open(); + }, + child: Text( + l10n.continueText, + style: TextStyle(color: theme.red), + ), + ); + } } -class _Button extends StatelessWidget { +class _Button extends ConsumerWidget { const _Button({required this.text, required this.onTap}); final String text; final VoidCallback onTap; @override - Widget build(BuildContext context) => ElevatedButton( - style: ElevatedButton.styleFrom( - backgroundColor: context.theme.accent, - foregroundColor: Colors.white, - ), - onPressed: onTap, - child: Text(text), - ); + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: theme.accent, + foregroundColor: Colors.white, + ), + onPressed: onTap, + child: Text(text), + ); + } } -class LandingFailedPage extends StatelessWidget { +class LandingFailedPage extends ConsumerWidget { const LandingFailedPage({ required this.message, required this.actions, @@ -144,34 +159,40 @@ class LandingFailedPage extends StatelessWidget { final List actions; @override - Widget build(BuildContext context) => LandingScaffold( - child: Column( - children: [ - const SizedBox(height: 32), - const Spacer(), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - title, - style: TextStyle( - color: context.theme.text, - fontSize: 20, - fontWeight: FontWeight.bold, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return LandingScaffold( + child: Column( + children: [ + const SizedBox(height: 32), + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + title, + style: TextStyle( + color: theme.text, + fontSize: 20, + fontWeight: FontWeight.bold, + ), ), ), - ), - const SizedBox(height: 32), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 30), - child: Text( - message, - style: TextStyle(color: context.theme.text, fontSize: 14), + const SizedBox(height: 32), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: Text( + message, + style: TextStyle( + color: theme.text, + fontSize: 14, + ), + ), ), - ), - const Spacer(), - ...actions, - const SizedBox(height: 32), - ], - ), - ); + const Spacer(), + ...actions, + const SizedBox(height: 32), + ], + ), + ); + } } diff --git a/lib/ui/landing/landing_mobile.dart b/lib/ui/landing/landing_mobile.dart index 4487949347..465afe8520 100644 --- a/lib/ui/landing/landing_mobile.dart +++ b/lib/ui/landing/landing_mobile.dart @@ -4,7 +4,6 @@ import 'dart:convert'; import 'package:ed25519_edwards/ed25519_edwards.dart' as ed; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:intl/intl.dart'; @@ -16,7 +15,6 @@ import '../../constants/resources.dart'; import '../../crypto/crypto_key_value.dart'; import '../../crypto/signal/signal_protocol.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../../utils/logger.dart'; import '../../utils/platform.dart'; import '../../utils/system/package_info.dart'; @@ -27,106 +25,107 @@ import '../../widgets/user/captcha_web_view_dialog.dart'; import '../../widgets/user/phone_number_input.dart'; import '../../widgets/user/verification_dialog.dart'; import '../provider/multi_auth_provider.dart'; -import 'bloc/landing_cubit.dart'; +import '../provider/ui_context_providers.dart'; import 'landing.dart'; -class LoginWithMobileWidget extends HookConsumerWidget { +class LoginWithMobileWidget extends ConsumerWidget { const LoginWithMobileWidget({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { - final locale = useMemoized(() => Localizations.localeOf(context)); - final userAgent = useMemoizedFuture(generateUserAgent, null).data; - final deviceId = useMemoizedFuture(getDeviceId, null).data; + final landingMobileClient = ref.watch(landingMobileClientProvider); - if (userAgent == null || deviceId == null) { + if (landingMobileClient.isLoading) { return const Center(child: CircularProgressIndicator()); } - return BlocProvider( - create: (_) => LandingMobileCubit( - context.multiAuthChangeNotifier, - locale, - userAgent: userAgent, - deviceId: deviceId, - ), - child: Navigator( - onDidRemovePage: (page) {}, - pages: const [MaterialPage(child: _PhoneNumberInputScene())], - ), + if (landingMobileClient.hasError) { + return const Center(child: CircularProgressIndicator()); + } + return Navigator( + onDidRemovePage: (page) {}, + pages: const [MaterialPage(child: _PhoneNumberInputScene())], ); } } -class _PhoneNumberInputScene extends StatelessWidget { +class _PhoneNumberInputScene extends ConsumerWidget { const _PhoneNumberInputScene(); @override - Widget build(BuildContext context) => Column( - children: [ - const SizedBox(height: 56), - Expanded( - child: PhoneNumberInputLayout( - onNextStep: (phoneNumber) async { - final ret = await showConfirmMixinDialog( - context, - context.l10n.landingInvitationDialogContent(phoneNumber), - maxWidth: 440, - ); - if (ret == null) return; - showToastLoading(); - try { - final response = await _requestVerificationCode( - phone: phoneNumber, - context: context, + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final client = ref.watch(landingMobileClientProvider).value; + return Column( + children: [ + const SizedBox(height: 56), + Expanded( + child: PhoneNumberInputLayout( + onNextStep: (phoneNumber) async { + final ret = await showConfirmMixinDialog( + context, + l10n.landingInvitationDialogContent(phoneNumber), + maxWidth: 440, ); - Toast.dismiss(); - if (response.deactivationEffectiveAt != null) { - final date = response.deactivationEffectiveAt!.toLocal(); - final requestedAt = response.deactivationRequestedAt!.toLocal(); - final continueLogin = await showConfirmMixinDialog( + if (ret == null) return; + showToastLoading(); + try { + final response = await _requestVerificationCode( + phone: phoneNumber, + context: context, + accountApi: client!.accountApi, + ); + Toast.dismiss(); + if (response.deactivationEffectiveAt != null) { + final date = response.deactivationEffectiveAt!.toLocal(); + final requestedAt = response.deactivationRequestedAt! + .toLocal(); + final continueLogin = await showConfirmMixinDialog( + context, + l10n.loginAndAbortAccountDeletion, + description: l10n.landingDeleteContent( + DateFormat().format(requestedAt), + DateFormat().format(date), + ), + maxWidth: 440, + positiveText: l10n.continueText, + negativeText: l10n.cancel, + barrierDismissible: false, + ); + if (continueLogin == null) { + i('User canceled login and deactivatedAt is not empty'); + return; + } + } + await Navigator.push( context, - context.l10n.loginAndAbortAccountDeletion, - description: context.l10n.landingDeleteContent( - DateFormat().format(requestedAt), - DateFormat().format(date), + MaterialPageRoute( + builder: (context) => _CodeInputScene( + phoneNumber: phoneNumber, + initialVerificationResponse: response, + ), ), - maxWidth: 440, - positiveText: context.l10n.continueText, - negativeText: context.l10n.cancel, - barrierDismissible: false, ); - if (continueLogin == null) { - i('User canceled login and deactivatedAt is not empty'); - return; - } + } on MixinApiError catch (error) { + e('Error requesting verification code: $error'); + final mixinError = error.error! as MixinError; + showToastFailed( + ToastError(mixinError.toDisplayString(context)), + ); + return; + } catch (error) { + e('Error requesting verification code: $error'); + showToastFailed(null); + return; } - await Navigator.push( - context, - MaterialPageRoute( - builder: (context) => _CodeInputScene( - phoneNumber: phoneNumber, - initialVerificationResponse: response, - ), - ), - ); - } on MixinApiError catch (error) { - e('Error requesting verification code: $error'); - final mixinError = error.error! as MixinError; - showToastFailed(ToastError(mixinError.toDisplayString(context))); - return; - } catch (error) { - e('Error requesting verification code: $error'); - showToastFailed(null); - return; - } - }, + }, + ), ), - ), - const SizedBox(height: 30), - const LandingModeSwitchButton(), - const SizedBox(height: 40), - ], - ); + const SizedBox(height: 30), + const LandingModeSwitchButton(), + const SizedBox(height: 40), + ], + ); + } } class _CodeInputScene extends HookConsumerWidget { @@ -140,6 +139,8 @@ class _CodeInputScene extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final codeInputController = useTextEditingController(); final verification = useRef( @@ -169,7 +170,7 @@ class _CodeInputScene extends HookConsumerWidget { sessionSecret: sessionSecret, pin: '', ); - final client = context.read().client; + final client = await ref.read(landingMobileClientProvider.future); final response = await client.accountApi.create( verification.value.id, accountRequest, @@ -184,9 +185,11 @@ class _CodeInputScene extends HookConsumerWidget { SessionKeyValue.instance.pinToken = base64Encode( decryptPinToken(response.data.pinToken, sessionKey.privateKey), ); - context.multiAuthChangeNotifier.signIn( - AuthState(account: response.data, privateKey: privateKey), - ); + ref + .read(multiAuthNotifierProvider.notifier) + .signIn( + AuthState(account: response.data, privateKey: privateKey), + ); Toast.dismiss(); } catch (error) { e('login account error: $error'); @@ -202,7 +205,7 @@ class _CodeInputScene extends HookConsumerWidget { useListenable(codeInputController); return Material( - color: context.theme.popUp, + color: theme.popUp, child: Column( children: [ SizedBox( @@ -212,7 +215,7 @@ class _CodeInputScene extends HookConsumerWidget { const SizedBox(width: 12), ActionButton( name: Resources.assetsImagesIcBackSvg, - color: context.theme.icon, + color: theme.icon, onTap: () => Navigator.maybePop(context), ), const Spacer(), @@ -222,12 +225,12 @@ class _CodeInputScene extends HookConsumerWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 125), child: Text( - context.l10n.landingValidationTitle(phoneNumber), + l10n.landingValidationTitle(phoneNumber), textAlign: TextAlign.center, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, - color: context.theme.text, + color: theme.text, ), ), ), @@ -245,12 +248,12 @@ class _CodeInputScene extends HookConsumerWidget { onCompleted: performLogin, useHapticFeedback: true, pinTheme: PinTheme( - activeColor: context.theme.accent, - inactiveColor: context.theme.secondaryText, + activeColor: theme.accent, + inactiveColor: theme.secondaryText, fieldWidth: 15, borderWidth: 2, ), - textStyle: TextStyle(fontSize: 18, color: context.theme.text), + textStyle: TextStyle(fontSize: 18, color: theme.text), onChanged: (value) {}, ), ), @@ -262,6 +265,10 @@ class _CodeInputScene extends HookConsumerWidget { final response = await _requestVerificationCode( phone: phoneNumber, context: context, + accountApi: ref + .read(landingMobileClientProvider) + .requireValue + .accountApi, ); Toast.dismiss(); verification.value = response; @@ -285,7 +292,7 @@ class _CodeInputScene extends HookConsumerWidget { disable: codeInputController.text.length < 4, padding: const EdgeInsets.symmetric(horizontal: 60, vertical: 14), onTap: () => performLogin(codeInputController.text), - child: Text(context.l10n.signIn), + child: Text(l10n.signIn), ), const SizedBox(height: 90), ], @@ -297,6 +304,7 @@ class _CodeInputScene extends HookConsumerWidget { Future _requestVerificationCode({ required String phone, required BuildContext context, + required AccountApi accountApi, (CaptchaType, String)? captcha, }) async { final request = VerificationRequest( @@ -309,8 +317,7 @@ Future _requestVerificationCode({ hCaptchaResponse: captcha?.$1 == CaptchaType.hCaptcha ? captcha?.$2 : null, ); try { - final cubit = context.read(); - final response = await cubit.client.accountApi.verification(request); + final response = await accountApi.verification(request); return response.data; } on MixinApiError catch (error) { final mixinError = error.error! as MixinError; @@ -325,6 +332,7 @@ Future _requestVerificationCode({ return _requestVerificationCode( phone: phone, context: context, + accountApi: accountApi, captcha: (type, token), ); } diff --git a/lib/ui/landing/landing_qrcode.dart b/lib/ui/landing/landing_qrcode.dart index c30dcd4096..6f500bbbbe 100644 --- a/lib/ui/landing/landing_qrcode.dart +++ b/lib/ui/landing/landing_qrcode.dart @@ -1,16 +1,12 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../constants/resources.dart'; -import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../../utils/platform.dart'; import '../../widgets/qr_code.dart'; -import 'bloc/landing_cubit.dart'; -import 'bloc/landing_state.dart'; +import '../provider/ui_context_providers.dart'; +import 'controllers/landing_state.dart'; import 'landing.dart'; class LandingQrCodeWidget extends HookConsumerWidget { @@ -18,31 +14,24 @@ class LandingQrCodeWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final locale = useMemoized(() => Localizations.localeOf(context)); - - final landingCubit = useBloc( - () => LandingQrCodeCubit(context.multiAuthChangeNotifier, locale), + final l10n = ref.watch(localizationProvider); + final status = ref.watch( + landingQrCodeNotifierProvider.select((value) => value.status), ); - final status = - useBlocStateConverter( - bloc: landingCubit, - converter: (state) => state.status, - ); - final Widget child; if (status == LandingStatus.init) { child = Center( child: _Loading( - title: context.l10n.initializing, - message: context.l10n.chatHintE2e, + title: l10n.initializing, + message: l10n.chatHintE2e, ), ); } else if (status == LandingStatus.provisioning) { child = Center( child: _Loading( - title: context.l10n.loading, - message: context.l10n.chatHintE2e, + title: l10n.loading, + message: l10n.chatHintE2e, ), ); } else { @@ -61,7 +50,7 @@ class LandingQrCodeWidget extends HookConsumerWidget { ], ); } - return BlocProvider.value(value: landingCubit, child: child); + return child; } } @@ -70,20 +59,12 @@ class _QrCode extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final url = - useBlocStateConverter( - converter: (state) => state.authUrl, - ); - - final visible = - useBlocStateConverter( - converter: (state) => state.status == LandingStatus.needReload, - ); - - final errorMessage = - useBlocStateConverter( - converter: (state) => state.errorMessage, - ); + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final landingState = ref.watch(landingQrCodeNotifierProvider); + final url = landingState.authUrl; + final visible = landingState.status == LandingStatus.needReload; + final errorMessage = landingState.errorMessage; Widget? qrCode; @@ -110,8 +91,9 @@ class _QrCode extends HookConsumerWidget { visible: visible, child: _Retry( errorMessage: errorMessage, - onTap: () => - context.read().requestAuthUrl(), + onTap: () => ref + .read(landingQrCodeNotifierProvider.notifier) + .requestAuthUrl(), ), ), ], @@ -120,8 +102,8 @@ class _QrCode extends HookConsumerWidget { ), const SizedBox(height: 16), Text( - context.l10n.loginByQrcode, - style: TextStyle(fontSize: 16, color: context.theme.text), + l10n.loginByQrcode, + style: TextStyle(fontSize: 16, color: theme.text), ), const SizedBox(height: 16), Padding( @@ -129,18 +111,20 @@ class _QrCode extends HookConsumerWidget { child: DefaultTextStyle.merge( style: TextStyle( fontSize: 14, - color: context.dynamicColor( - const Color.fromRGBO(187, 190, 195, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.4), + color: ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(187, 190, 195, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.4), + )), ), ), textAlign: TextAlign.left, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Text('1. ${context.l10n.loginByQrcodeTips1}'), + Text('1. ${l10n.loginByQrcodeTips1}'), const SizedBox(height: 4), - Text('2. ${context.l10n.loginByQrcodeTips2}'), + Text('2. ${l10n.loginByQrcodeTips2}'), ], ), ), @@ -150,15 +134,22 @@ class _QrCode extends HookConsumerWidget { } } -class _Loading extends StatelessWidget { +class _Loading extends ConsumerWidget { const _Loading({required this.title, required this.message}); final String title; final String message; @override - Widget build(BuildContext context) { - final primaryColor = context.theme.text; + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final secondaryColor = ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(188, 190, 195, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.4), + )), + ); + final primaryColor = theme.text; return SizedBox( width: 375, child: Column( @@ -181,10 +172,7 @@ class _Loading extends StatelessWidget { message, textAlign: TextAlign.center, style: TextStyle( - color: context.dynamicColor( - const Color.fromRGBO(188, 190, 195, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.4), - ), + color: secondaryColor, fontSize: 16, ), ), @@ -194,7 +182,7 @@ class _Loading extends StatelessWidget { } } -class _Retry extends StatelessWidget { +class _Retry extends ConsumerWidget { const _Retry({required this.onTap, this.errorMessage}); final VoidCallback onTap; @@ -202,39 +190,42 @@ class _Retry extends StatelessWidget { final String? errorMessage; @override - Widget build(BuildContext context) => DecoratedBox( - decoration: const BoxDecoration(color: Color.fromRGBO(0, 0, 0, 0.86)), - child: GestureDetector( - onTap: onTap, - behavior: HitTestBehavior.opaque, - child: Tooltip( - message: errorMessage ?? '', - excludeFromSemantics: true, - child: Center( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - SvgPicture.asset( - Resources.assetsImagesIcRetrySvg, - width: 40, - height: 40, - ), - const SizedBox(height: 14), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Text( - context.l10n.clickToReloadQrcode, - textAlign: TextAlign.center, - style: const TextStyle( - color: Color.fromRGBO(255, 255, 255, 0.9), - fontSize: 14, + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + return DecoratedBox( + decoration: const BoxDecoration(color: Color.fromRGBO(0, 0, 0, 0.86)), + child: GestureDetector( + onTap: onTap, + behavior: HitTestBehavior.opaque, + child: Tooltip( + message: errorMessage ?? '', + excludeFromSemantics: true, + child: Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + SvgPicture.asset( + Resources.assetsImagesIcRetrySvg, + width: 40, + height: 40, + ), + const SizedBox(height: 14), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Text( + l10n.clickToReloadQrcode, + textAlign: TextAlign.center, + style: const TextStyle( + color: Color.fromRGBO(255, 255, 255, 0.9), + fontSize: 14, + ), ), ), - ), - ], + ], + ), ), ), ), - ), - ); + ); + } } diff --git a/lib/ui/provider/abstract_responsive_navigator.dart b/lib/ui/provider/abstract_responsive_navigator.dart index 9b0aa8f494..700c0b6195 100644 --- a/lib/ui/provider/abstract_responsive_navigator.dart +++ b/lib/ui/provider/abstract_responsive_navigator.dart @@ -1,9 +1,9 @@ +import 'dart:async'; import 'dart:math'; import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; - -import '../../utils/rivepod.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; class ResponsiveNavigatorState extends Equatable { const ResponsiveNavigatorState({ @@ -30,16 +30,34 @@ class ResponsiveNavigatorState extends Equatable { } abstract class AbstractResponsiveNavigatorStateNotifier - extends DistinctStateNotifier { - AbstractResponsiveNavigatorStateNotifier(super.initialState); + extends Notifier { + late final StreamController _streamController = + StreamController.broadcast(); + + Stream get stream => _streamController.stream; + + @override + ResponsiveNavigatorState build() { + ref.onDispose(() async { + await _streamController.close(); + }); + return const ResponsiveNavigatorState(); + } + + void updateState(ResponsiveNavigatorState next) { + state = next; + if (!_streamController.isClosed) { + _streamController.add(next); + } + } void updateRouteMode(bool routeMode) => - Future(() => state = state.copyWith(routeMode: routeMode)); + Future(() => updateState(state.copyWith(routeMode: routeMode))); void onPopPage() { final bool = state.pages.isNotEmpty; if (bool) { - state = state.copyWith(pages: state.pages.toList()..removeLast()); + updateState(state.copyWith(pages: state.pages.toList()..removeLast())); } } @@ -54,7 +72,7 @@ abstract class AbstractResponsiveNavigatorStateNotifier ); if (state.pages.isNotEmpty && index == state.pages.length - 1) return; if (index != -1) state.pages.removeRange(max(index, 0), state.pages.length); - state = state.copyWith(pages: state.pages.toList()..add(page)); + updateState(state.copyWith(pages: state.pages.toList()..add(page))); } void popUntil(bool Function(MaterialPage page) test) { @@ -64,14 +82,17 @@ abstract class AbstractResponsiveNavigatorStateNotifier List? list; list = index == 0 ? [] : state.pages.toList() ..sublist(0, index); - state = state.copyWith(pages: list); + updateState(state.copyWith(pages: list)); } - void popWhere(bool Function(MaterialPage page) test) => - state = state.copyWith(pages: state.pages.toList()..removeWhere(test)); + void popWhere(bool Function(MaterialPage page) test) => updateState( + state.copyWith(pages: state.pages.toList()..removeWhere(test)), + ); - void pop() => state = state.copyWith( - pages: state.pages.sublist(0, max(state.pages.length - 1, 0)).toList(), + void pop() => updateState( + state.copyWith( + pages: state.pages.sublist(0, max(state.pages.length - 1, 0)).toList(), + ), ); Future replace(String name, {Object? arguments}) async { @@ -80,5 +101,5 @@ abstract class AbstractResponsiveNavigatorStateNotifier pushPage(name, arguments: arguments); } - void clear() => state = state.copyWith(pages: []); + void clear() => updateState(state.copyWith(pages: [])); } diff --git a/lib/ui/provider/account_server_provider.dart b/lib/ui/provider/account_server_provider.dart index 00c58ead64..a52c46a548 100644 --- a/lib/ui/provider/account_server_provider.dart +++ b/lib/ui/provider/account_server_provider.dart @@ -1,166 +1,14 @@ -import 'package:equatable/equatable.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../account/account_server.dart'; -import '../../db/database.dart'; -import '../../utils/logger.dart'; -import '../../utils/rivepod.dart'; -import 'conversation_provider.dart'; -import 'database_provider.dart'; -import 'multi_auth_provider.dart'; -import 'setting_provider.dart'; +import '../../runtime/app_runtime_hub.dart'; -typedef GetCurrentConversationId = String? Function(); - -class AccountServerOpener - extends DistinctStateNotifier> { - AccountServerOpener() : super(const AsyncValue.loading()); - - AccountServerOpener.open({ - required this.multiAuthChangeNotifier, - required this.settingChangeNotifier, - required this.database, - required this.userId, - required this.sessionId, - required this.identityNumber, - required this.privateKey, - required this.currentConversationId, - }) : super(const AsyncValue.loading()) { - _init(); - } - - late final MultiAuthStateNotifier multiAuthChangeNotifier; - late final SettingChangeNotifier settingChangeNotifier; - late final Database database; - - late final String userId; - late final String sessionId; - late final String identityNumber; - late final String privateKey; - late final GetCurrentConversationId currentConversationId; - - Future _init() async { - final accountServer = AccountServer( - multiAuthNotifier: multiAuthChangeNotifier, - settingChangeNotifier: settingChangeNotifier, - database: database, - currentConversationId: currentConversationId, - ); - - await accountServer.initServer( - userId, - sessionId, - identityNumber, - privateKey, - ); - - state = AsyncValue.data(accountServer); - } - - @override - Future dispose() async { - await state.valueOrNull?.stop(); - super.dispose(); - } -} - -// create _Args for equatable -class _Args extends Equatable { - const _Args({ - required this.database, - required this.userId, - required this.sessionId, - required this.identityNumber, - required this.privateKey, - required this.multiAuthChangeNotifier, - required this.settingChangeNotifier, - required this.currentConversationId, - }); - - final Database? database; - final String? userId; - final String? sessionId; - final String? identityNumber; - final String? privateKey; - final MultiAuthStateNotifier multiAuthChangeNotifier; - final SettingChangeNotifier settingChangeNotifier; - final GetCurrentConversationId currentConversationId; - - @override - List get props => [ - database, - userId, - sessionId, - identityNumber, - privateKey, - multiAuthChangeNotifier, - settingChangeNotifier, - currentConversationId, - ]; -} - -final Provider _currentConversationIdProvider = - Provider( - (ref) => - () => ref.read(currentConversationIdProvider), +final appRuntimeHubProvider = + NotifierProvider.autoDispose( + AppRuntimeHub.new, ); -final _argsProvider = Provider.autoDispose((ref) { - final database = ref.watch( - databaseProvider.select((value) => value.valueOrNull), - ); - final (userId, sessionId, identityNumber, privateKey) = ref.watch( - authProvider.select( - (value) => ( - value?.account.userId, - value?.account.sessionId, - value?.account.identityNumber, - value?.privateKey, - ), - ), - ); - final multiAuthChangeNotifier = ref.watch( - multiAuthStateNotifierProvider.notifier, - ); - final settingChangeNotifier = ref.watch(settingProvider); - final currentConversationId = ref.read(_currentConversationIdProvider); - - return _Args( - database: database, - userId: userId, - sessionId: sessionId, - identityNumber: identityNumber, - privateKey: privateKey, - multiAuthChangeNotifier: multiAuthChangeNotifier, - settingChangeNotifier: settingChangeNotifier, - currentConversationId: currentConversationId, - ); -}); - -final accountServerProvider = - StateNotifierProvider.autoDispose< - AccountServerOpener, - AsyncValue - >((ref) { - final args = ref.watch(_argsProvider); - - if (args.database == null) return AccountServerOpener(); - if (args.userId == null || - args.sessionId == null || - args.identityNumber == null || - args.privateKey == null) { - w('[accountServerProvider] Account not ready'); - return AccountServerOpener(); - } - - return AccountServerOpener.open( - multiAuthChangeNotifier: args.multiAuthChangeNotifier, - settingChangeNotifier: args.settingChangeNotifier, - database: args.database!, - userId: args.userId!, - sessionId: args.sessionId!, - identityNumber: args.identityNumber!, - privateKey: args.privateKey!, - currentConversationId: args.currentConversationId, - ); - }); +final accountServerProvider = Provider.autoDispose>( + (ref) => + ref.watch(appRuntimeHubProvider.select((value) => value.accountServer)), +); diff --git a/lib/ui/provider/conversation_provider.dart b/lib/ui/provider/conversation_provider.dart index 25d3f71b40..58ec522d65 100644 --- a/lib/ui/provider/conversation_provider.dart +++ b/lib/ui/provider/conversation_provider.dart @@ -16,14 +16,14 @@ import '../../enum/encrypt_category.dart'; import '../../utils/app_lifecycle.dart'; import '../../utils/extension/extension.dart'; import '../../utils/local_notification_center.dart'; -import '../../utils/rivepod.dart'; import '../../widgets/toast.dart'; -import '../home/bloc/conversation_list_bloc.dart'; -import '../home/bloc/subscriber_mixin.dart'; +import '../home/providers/home_scope_providers.dart'; import 'account_server_provider.dart'; +import 'database_provider.dart'; import 'is_bot_group_provider.dart'; import 'recent_conversation_provider.dart'; import 'responsive_navigator_provider.dart'; +import 'ui_context_providers.dart'; class ConversationState extends Equatable { const ConversationState({ @@ -135,22 +135,53 @@ EncryptCategory _getEncryptCategory(App? app) { return EncryptCategory.signal; } -class ConversationStateNotifier - extends DistinctStateNotifier - with SubscriberMixin { - ConversationStateNotifier({ - required AccountServer accountServer, - required ResponsiveNavigatorStateNotifier responsiveNavigatorStateNotifier, - }) : _responsiveNavigatorStateNotifier = responsiveNavigatorStateNotifier, - _accountServer = accountServer, - super(null) { - addSubscription( +class ConversationStateNotifier extends Notifier { + final List> _subscriptions = []; + late final StreamController _stateController = + StreamController.broadcast(); + AccountServer? _accountServer; + ResponsiveNavigatorStateNotifier? _responsiveNavigatorStateNotifier; + + Stream get stream => _stateController.stream; + + @override + ConversationState? build() { + final accountServerAsync = ref.watch(accountServerProvider); + if (!accountServerAsync.hasValue) { + throw Exception('accountServer is not ready'); + } + + _accountServer = accountServerAsync.requireValue; + _responsiveNavigatorStateNotifier = ref.watch( + responsiveNavigatorProvider.notifier, + ); + + _resetSubscriptions(); + _bind(); + + ref.onDispose(() { + appActiveListener.removeListener(onListen); + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + _subscriptions.clear(); + unawaited(_stateController.close()); + }); + + return stateOrNull; + } + + Database get _database => _accountServer!.database; + String get _currentUserId => _accountServer!.userId; + + void _bind() { + _subscriptions.add( stream .map((event) => event?.conversationId) .distinct() - .listen(_accountServer.selectConversation), + .listen(_accountServer!.selectConversation), ); - addSubscription( + _subscriptions.add( stream .map((event) => (event?.conversationId, event?.userId)) .where((event) => event.$1 != null) @@ -176,10 +207,10 @@ class ConversationStateNotifier if (event != null && !event.isGroupConversation) { userId = event.ownerId; } - state = state?.copyWith(conversation: event, userId: userId); + _updateState(state?.copyWith(conversation: event, userId: userId)); }), ); - addSubscription( + _subscriptions.add( stream .map((event) => event?.userId) .distinct() @@ -195,9 +226,9 @@ class ConversationStateNotifier prepend: false, ); }) - .listen((event) => state = state?.copyWith(user: event)), + .listen((event) => _updateState(state?.copyWith(user: event))), ); - addSubscription( + _subscriptions.add( stream .map((event) => event?.conversationId) .where((event) => event != null) @@ -218,22 +249,30 @@ class ConversationStateNotifier ), ) .listen((event) { - state = state?.copyWith(participant: event); + _updateState(state?.copyWith(participant: event)); }), ); appActiveListener.addListener(onListen); } - final AccountServer _accountServer; - final ResponsiveNavigatorStateNotifier _responsiveNavigatorStateNotifier; - late final Database _database = _accountServer.database; - late final String _currentUserId = _accountServer.userId; - - @override - void dispose() { + void _resetSubscriptions() { appActiveListener.removeListener(onListen); - super.dispose(); + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + _subscriptions.clear(); + } + + void _updateState(ConversationState? next) { + state = next; + if (!_stateController.isClosed) { + _stateController.add(next); + } + } + + void replaceState(ConversationState? next) { + _updateState(next); } void onListen() { @@ -244,11 +283,12 @@ class ConversationStateNotifier } void unselected() { - state = null; - _responsiveNavigatorStateNotifier.clear(); + _updateState(null); + _responsiveNavigatorStateNotifier!.clear(); } static Future selectConversation( + ProviderContainer container, BuildContext context, String conversationId, { ConversationItem? conversation, @@ -258,11 +298,11 @@ class ConversationStateNotifier bool sync = false, bool checkCurrentUserExist = false, }) async { - context.providerContainer.read(isBotGroupProvider(conversationId)); + container.read(isBotGroupProvider(conversationId)); - final accountServer = context.accountServer; - final database = context.database; - final conversationNotifier = context.providerContainer.read( + final accountServer = container.read(accountServerProvider).requireValue; + final database = container.read(databaseProvider).requireValue; + final conversationNotifier = container.read( conversationProvider.notifier, ); final state = conversationNotifier.state; @@ -282,15 +322,15 @@ class ConversationStateNotifier _conversation = conversation ?? _conversation ?? - await _conversationItem(context, conversationId); + await _conversationItem(container, conversationId); if (_conversation == null && sync) { showToastLoading(); - await context.accountServer.refreshConversation( + await accountServer.refreshConversation( conversationId, checkCurrentUserExist: checkCurrentUserExist, ); - _conversation = await _conversationItem(context, conversationId); + _conversation = await _conversationItem(container, conversationId); } hasUnreadMessage ??= (_conversation?.unseenMessageCount ?? 0) > 0; @@ -331,37 +371,37 @@ class ConversationStateNotifier Toast.dismiss(); - conversationNotifier.state = conversationState; + conversationNotifier.replaceState(conversationState); - conversationNotifier._responsiveNavigatorStateNotifier.pushPage( + conversationNotifier._responsiveNavigatorStateNotifier!.pushPage( ResponsiveNavigatorStateNotifier.chatPage, ); unawaited(dismissByConversationId(conversationId)); - context.providerContainer - .read(recentConversationIDsProvider.notifier) - .add(conversationId); + container.read(recentConversationIDsProvider.notifier).add(conversationId); } static Future selectUser( + ProviderContainer container, BuildContext context, String userId, { User? user, String? initialChatSidePage, }) async { - final accountServer = context.accountServer; - final database = context.database; - final conversationNotifier = context.providerContainer.read( + final accountServer = container.read(accountServerProvider).requireValue; + final database = container.read(databaseProvider).requireValue; + final conversationNotifier = container.read( conversationProvider.notifier, ); final conversationId = generateConversationId(userId, accountServer.userId); - context.providerContainer.read(isBotGroupProvider(conversationId)); + container.read(isBotGroupProvider(conversationId)); - final conversation = await _conversationItem(context, conversationId); + final conversation = await _conversationItem(container, conversationId); if (conversation != null) { return selectConversation( + container, context, conversationId, conversation: conversation, @@ -373,21 +413,25 @@ class ConversationStateNotifier user ?? await database.userDao.userById(userId).getSingleOrNull(); if (_user == null) { - return showToastFailed(ToastError(context.l10n.userNotFound)); + return showToastFailed( + ToastError(container.read(localizationProvider).userNotFound), + ); } final app = await database.appDao.findAppById(userId); - conversationNotifier.state = ConversationState( - conversationId: conversationId, - userId: userId, - user: _user, - app: app, - initialSidePage: initialChatSidePage, - refreshKey: Object(), + conversationNotifier.replaceState( + ConversationState( + conversationId: conversationId, + userId: userId, + user: _user, + app: app, + initialSidePage: initialChatSidePage, + refreshKey: Object(), + ), ); - conversationNotifier._responsiveNavigatorStateNotifier.pushPage( + conversationNotifier._responsiveNavigatorStateNotifier!.pushPage( ResponsiveNavigatorStateNotifier.chatPage, ); @@ -395,11 +439,11 @@ class ConversationStateNotifier } static Future _conversationItem( - BuildContext context, + ProviderContainer container, String conversationId, ) async { - final conversations = context - .read() + final conversations = container + .read(conversationListControllerProvider.notifier) .state .map .values @@ -410,77 +454,48 @@ class ConversationStateNotifier (element) => element?.conversationId == conversationId, orElse: () => null, ) ?? - await context.database.conversationDao + await container + .read(databaseProvider) + .requireValue + .conversationDao .conversationItem(conversationId) .getSingleOrNull(); } } -class _LastConversationNotifier - extends DistinctStateNotifier { - _LastConversationNotifier(super.state); +class _LastConversationNotifier extends Notifier { + _LastConversationNotifier([this._filter]); + + final bool Function(ConversationState?)? _filter; - set _state(ConversationState? value) => super.state = value; + @override + ConversationState? build() { + final conversation = ref.read(conversationProvider); + ref.listen(conversationProvider, (previous, next) { + if (next == null) return; + if (_filter != null && !_filter(next)) return; + state = next; + }); + return conversation; + } } final conversationProvider = - StateNotifierProvider.autoDispose< - ConversationStateNotifier, - ConversationState? - >((ref) { - final keepAlive = ref.keepAlive(); - - final accountServerAsync = ref.watch(accountServerProvider); - - if (!accountServerAsync.hasValue) { - throw Exception('accountServer is not ready'); - } - - final responsiveNavigatorNotifier = ref.watch( - responsiveNavigatorProvider.notifier, - ); - - ref - ..listen(accountServerProvider, (previous, next) => keepAlive.close()) - ..listen( - responsiveNavigatorProvider.notifier, - (previous, next) => keepAlive.close(), - ); - - return ConversationStateNotifier( - accountServer: accountServerAsync.requireValue, - responsiveNavigatorStateNotifier: responsiveNavigatorNotifier, - ); - }); + NotifierProvider.autoDispose( + ConversationStateNotifier.new, + ); final _lastConversationProvider = - StateNotifierProvider.autoDispose< - _LastConversationNotifier, - ConversationState? - >((ref) { - final conversation = ref.read(conversationProvider); - final lastConversationNotifier = _LastConversationNotifier(conversation); - ref.listen(conversationProvider, (previous, next) { - if (next == null) return; - lastConversationNotifier._state = next; - }); - return lastConversationNotifier; - }); + NotifierProvider.autoDispose<_LastConversationNotifier, ConversationState?>( + _LastConversationNotifier.new, + ); -final filterLastConversationProvider = StateNotifierProvider.autoDispose +final filterLastConversationProvider = NotifierProvider.autoDispose .family< _LastConversationNotifier, ConversationState?, bool Function(ConversationState?) - >((ref, filter) { - final conversation = ref.read(conversationProvider); - final lastConversationNotifier = _LastConversationNotifier(conversation); - ref.listen(conversationProvider, (previous, next) { - if (!filter(next)) return; - lastConversationNotifier._state = next; - }); - return lastConversationNotifier; - }); + >(_LastConversationNotifier.new); final lastConversationProvider = _lastConversationProvider.select( (value) => value, diff --git a/lib/ui/provider/conversation_unseen_filter_enabled.dart b/lib/ui/provider/conversation_unseen_filter_enabled.dart index 0d3e01f05b..d1dbdf998b 100644 --- a/lib/ui/provider/conversation_unseen_filter_enabled.dart +++ b/lib/ui/provider/conversation_unseen_filter_enabled.dart @@ -1,9 +1,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../utils/rivepod.dart'; -class ConversationUnseenFilterEnabledNotifier - extends DistinctStateNotifier { - ConversationUnseenFilterEnabledNotifier() : super(false); +class ConversationUnseenFilterEnabledNotifier extends Notifier { + @override + bool build() => false; void toggle() => state = !state; @@ -11,6 +10,6 @@ class ConversationUnseenFilterEnabledNotifier } final conversationUnseenFilterEnabledProvider = - StateNotifierProvider( - (ref) => ConversationUnseenFilterEnabledNotifier(), + NotifierProvider( + ConversationUnseenFilterEnabledNotifier.new, ); diff --git a/lib/ui/provider/database_provider.dart b/lib/ui/provider/database_provider.dart index 09fd1b2346..3472fa7824 100644 --- a/lib/ui/provider/database_provider.dart +++ b/lib/ui/provider/database_provider.dart @@ -1,44 +1,70 @@ +import 'dart:async'; + import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_logger/mixin_logger.dart'; import '../../db/database.dart'; import '../../db/fts_database.dart'; import '../../db/mixin_database.dart'; -import '../../utils/rivepod.dart'; import '../../utils/synchronized.dart'; import 'multi_auth_provider.dart'; import 'slide_category_provider.dart'; final databaseProvider = - StateNotifierProvider.autoDispose>(( - ref, - ) { - final identityNumber = ref.watch( - authAccountProvider.select((value) => value?.identityNumber), - ); - - if (identityNumber == null) return DatabaseOpener(); - - return DatabaseOpener.open(identityNumber); - }); + NotifierProvider.autoDispose>( + DatabaseOpener.new, + ); extension _DatabaseExt on MixinDatabase { Future doInitVerify() => conversationDao.conversationCountByCategory(SlideCategoryType.chats); } -class DatabaseOpener extends DistinctStateNotifier> { - DatabaseOpener() : super(const AsyncValue.loading()); +class DatabaseOpener extends Notifier> { + String? _identityNumber; + + final Lock _lock = Lock(); + + @override + AsyncValue build() { + ref.keepAlive(); + + final identityNumber = ref.watch( + authAccountProvider.select((value) => value?.identityNumber), + ); + + ref.onDispose(() { + unawaited(_disposeAsync()); + }); + + Future(() => _syncIdentity(identityNumber)); - DatabaseOpener.open(this.identityNumber) : super(const AsyncValue.loading()) { - open(); + return stateOrNull ?? const AsyncValue.loading(); } - late final String identityNumber; + Future _syncIdentity(String? identityNumber) async { + if (_identityNumber == identityNumber) return; - final Lock _lock = Lock(); + if (_identityNumber != null || stateOrNull?.hasValue == true) { + await close(); + } + + _identityNumber = identityNumber; + if (identityNumber == null) { + state = const AsyncValue.loading(); + return; + } + + await open(); + } Future open() => _lock.synchronized(() async { + final identityNumber = _identityNumber; + if (identityNumber == null) { + state = const AsyncValue.loading(); + return; + } + i('connect to database: $identityNumber'); if (state.hasValue) { e('database already opened'); @@ -62,14 +88,15 @@ class DatabaseOpener extends DistinctStateNotifier> { } }); - @override - Future dispose() async { - await close(); - super.dispose(); + Future _disposeAsync() async { + _identityNumber = null; + // Only dispose the database resource, don't modify state + // since this is called from onDispose where state modification is forbidden. + await state.value?.dispose(); } Future close() async { - await state.valueOrNull?.dispose(); + await state.value?.dispose(); state = const AsyncValue.loading(); } } diff --git a/lib/ui/provider/is_bot_group_provider.dart b/lib/ui/provider/is_bot_group_provider.dart index 622768f30d..90f31b9006 100644 --- a/lib/ui/provider/is_bot_group_provider.dart +++ b/lib/ui/provider/is_bot_group_provider.dart @@ -1,33 +1,35 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../db/dao/conversation_dao.dart'; -import '../../utils/rivepod.dart'; import 'database_provider.dart'; -class _IsBotGroupState extends DistinctStateNotifier { - _IsBotGroupState(String conversationId, ConversationDao? conversationDao) - : super(false) { - if (conversationDao == null) return; +class _IsBotGroupNotifier extends Notifier { + _IsBotGroupNotifier(this.conversationId); - conversationDao - .isBotGroup(conversationId) - .getSingle() - .then((value) => state = value); + final String conversationId; + + ConversationDao? get _conversationDao => ref.watch( + databaseProvider.select((value) => value.value?.conversationDao), + ); + + @override + bool build() { + final keepAlive = ref.keepAlive(); + ref.onDispose( + () => Future.delayed(const Duration(minutes: 10), keepAlive.close), + ); + + final conversationDao = _conversationDao; + if (conversationDao != null) { + Future(() async { + state = await conversationDao.isBotGroup(conversationId).getSingle(); + }); + } + return false; } } -final isBotGroupProvider = StateNotifierProvider.autoDispose - .family<_IsBotGroupState, bool, String>((ref, conversationId) { - // Minimize frequent calls to isBotGroup by keeping it alive for 10 minutes - final keepAlive = ref.keepAlive(); - ref.onDispose( - () => Future.delayed(const Duration(minutes: 10), keepAlive.close), - ); - - return _IsBotGroupState( - conversationId, - ref.watch( - databaseProvider.select((value) => value.value?.conversationDao), - ), - ); - }); +final isBotGroupProvider = NotifierProvider.autoDispose + .family<_IsBotGroupNotifier, bool, String>( + _IsBotGroupNotifier.new, + ); diff --git a/lib/ui/provider/keyword_provider.dart b/lib/ui/provider/keyword_provider.dart index cc35c12dd6..cbcd7634ac 100644 --- a/lib/ui/provider/keyword_provider.dart +++ b/lib/ui/provider/keyword_provider.dart @@ -1,7 +1,36 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:rxdart/rxdart.dart'; + +class KeywordNotifier extends Notifier { + @override + String build() => ''; + + void set(String value) => state = value; + + void clear() => state = ''; +} + +final keywordProvider = NotifierProvider( + KeywordNotifier.new, +); -final keywordProvider = StateProvider((ref) => ''); final trimmedKeywordProvider = keywordProvider.select((value) => value.trim()); + final hasKeywordProvider = trimmedKeywordProvider.select( (value) => value.isNotEmpty, ); + +final debouncedKeywordProvider = StreamProvider.autoDispose((ref) { + final controller = BehaviorSubject.seeded( + ref.read(trimmedKeywordProvider), + ); + + ref.listen(trimmedKeywordProvider, (previous, next) { + controller.add(next); + }); + + ref.onDispose(controller.close); + return controller.stream.distinct().debounceTime( + const Duration(milliseconds: 150), + ); +}); diff --git a/lib/ui/provider/mention_cache_provider.dart b/lib/ui/provider/mention_cache_provider.dart index 45fc167cca..992151125f 100644 --- a/lib/ui/provider/mention_cache_provider.dart +++ b/lib/ui/provider/mention_cache_provider.dart @@ -152,6 +152,6 @@ class MentionCache { final mentionCacheProvider = Provider.autoDispose( (ref) => MentionCache( - ref.watch(databaseProvider.select((value) => value.valueOrNull?.userDao)), + ref.watch(databaseProvider.select((value) => value.value?.userDao)), ), ); diff --git a/lib/ui/provider/mention_provider.dart b/lib/ui/provider/mention_provider.dart index 2deab6bd1d..19b145bee9 100644 --- a/lib/ui/provider/mention_provider.dart +++ b/lib/ui/provider/mention_provider.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:math'; import 'package:equatable/equatable.dart'; @@ -6,14 +7,11 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:rxdart/rxdart.dart'; -import '../../db/dao/user_dao.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; import '../../utils/extension/extension.dart'; import '../../utils/reg_exp_utils.dart'; -import '../../utils/rivepod.dart'; import '../../widgets/mention_panel.dart'; -import '../home/bloc/subscriber_mixin.dart'; import 'conversation_provider.dart'; import 'database_provider.dart'; import 'multi_auth_provider.dart'; @@ -36,16 +34,50 @@ class MentionState extends Equatable { ); } -class MentionStateNotifier extends DistinctStateNotifier - with SubscriberMixin { - MentionStateNotifier({ - required this.userDao, - required this.multiAuthChangeNotifier, - required this.textEditingValueStream, - required String conversationId, - required bool? isGroup, - required bool isBot, - }) : super(const MentionState()) { +class MentionStateNotifier extends Notifier { + MentionStateNotifier(this.textEditingValueStream); + + final Stream textEditingValueStream; + final scrollController = ScrollController(); + final _subscriptions = >[]; + late final StreamController _stateController = + StreamController.broadcast(); + + Stream get stream => _stateController.stream; + + @override + MentionState build() { + final userDao = ref.watch( + databaseProvider.select((value) => value.requireValue.userDao), + ); + final multiAuthChangeNotifier = ref.watch( + multiAuthNotifierProvider.notifier, + ); + final (conversationId, isGroup, isBot) = ref.watch( + conversationProvider.select( + (value) => + (value?.conversationId, value?.isGroup, value?.isBot ?? false), + ), + ); + + ref.onDispose(() async { + for (final subscription in _subscriptions) { + await subscription.cancel(); + } + _subscriptions.clear(); + scrollController.dispose(); + await _stateController.close(); + }); + + for (final subscription in _subscriptions) { + unawaited(subscription.cancel()); + } + _subscriptions.clear(); + + if (conversationId == null) { + return stateOrNull ?? const MentionState(); + } + final mentionTextStream = textEditingValueStream.map((event) { final text = event.text.substring( 0, @@ -54,14 +86,14 @@ class MentionStateNotifier extends DistinctStateNotifier return mentionRegExp.firstMatch(text)?[1]; }).asBroadcastStream(); - addSubscription( + _subscriptions.add( mentionTextStream.distinct().listen((index) { if (!scrollController.hasClients) return; scrollController.jumpTo(0); }), ); - addSubscription( + _subscriptions.add( mentionTextStream .switchMap((keyword) { if (keyword == null) { @@ -140,15 +172,11 @@ class MentionStateNotifier extends DistinctStateNotifier } return Stream.value(MentionState(text: keyword)); }) - .listen((value) => state = value), + .listen(_setState), ); - } - MentionStateNotifier.idle({ - required this.userDao, - required this.multiAuthChangeNotifier, - required this.textEditingValueStream, - }) : super(const MentionState()); + return stateOrNull ?? const MentionState(); + } MentionState _resultToMentionState(String? keyword, List users) => MentionState( @@ -202,16 +230,11 @@ class MentionStateNotifier extends DistinctStateNotifier } } - final UserDao userDao; - final MultiAuthStateNotifier multiAuthChangeNotifier; - final Stream textEditingValueStream; - - final scrollController = ScrollController(); - - @override - Future dispose() async { - scrollController.dispose(); - super.dispose(); + void _setState(MentionState value) { + state = value; + if (!_stateController.isClosed) { + _stateController.add(value); + } } void next() { @@ -227,38 +250,7 @@ class MentionStateNotifier extends DistinctStateNotifier } } -final mentionProvider = StateNotifierProvider.autoDispose - .family>(( - ref, - stream, - ) { - final userDao = ref.watch( - databaseProvider.select((value) => value.requireValue.userDao), - ); - final authStateNotifier = ref.watch( - multiAuthStateNotifierProvider.notifier, - ); - final (conversationId, isGroup, isBot) = ref.watch( - conversationProvider.select( - (value) => - (value?.conversationId, value?.isGroup, value?.isBot ?? false), - ), - ); - - if (conversationId == null) { - return MentionStateNotifier.idle( - userDao: userDao, - multiAuthChangeNotifier: authStateNotifier, - textEditingValueStream: stream, - ); - } - - return MentionStateNotifier( - userDao: userDao, - multiAuthChangeNotifier: authStateNotifier, - textEditingValueStream: stream, - conversationId: conversationId, - isGroup: isGroup, - isBot: isBot, - ); - }); +final mentionProvider = NotifierProvider.autoDispose + .family>( + MentionStateNotifier.new, + ); diff --git a/lib/ui/provider/message_selection_provider.dart b/lib/ui/provider/message_selection_provider.dart index 5317e536d7..4724dbd6b2 100644 --- a/lib/ui/provider/message_selection_provider.dart +++ b/lib/ui/provider/message_selection_provider.dart @@ -1,4 +1,4 @@ -import 'package:flutter/foundation.dart'; +import 'package:equatable/equatable.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../db/extension/message.dart'; @@ -6,82 +6,119 @@ import '../../db/extension/message_category.dart'; import '../../db/mixin_database.dart'; import 'conversation_provider.dart'; -class MessageSelectionNotifier extends ChangeNotifier { - MessageSelectionNotifier(); +class MessageSelectionState extends Equatable { + const MessageSelectionState({ + this.selectedMessageIds = const {}, + this.messageCannotForward = const {}, + this.messageCannotRecall = const {}, + this.messageCannotCombineForward = const {}, + }); - final Set _selectedMessageIds = {}; - final Set _messageCannotForward = {}; - final Set _messageCannotRecall = {}; - final Set _messageCannotCombineForward = {}; + final Set selectedMessageIds; + final Set messageCannotForward; + final Set messageCannotRecall; + final Set messageCannotCombineForward; - bool get hasSelectedMessage => _selectedMessageIds.isNotEmpty; - - Set get selectedMessageIds => _selectedMessageIds.toSet(); + bool get hasSelectedMessage => selectedMessageIds.isNotEmpty; bool get canForward => - _messageCannotForward.isEmpty && _selectedMessageIds.length < 100; + messageCannotForward.isEmpty && selectedMessageIds.length < 100; bool get canCombineForward => - _messageCannotCombineForward.isEmpty && - _selectedMessageIds.length >= 2 && - _selectedMessageIds.length < 100; + messageCannotCombineForward.isEmpty && + selectedMessageIds.length >= 2 && + selectedMessageIds.length < 100; bool get canRecall => - _messageCannotRecall.isEmpty && _selectedMessageIds.length < 100; + messageCannotRecall.isEmpty && selectedMessageIds.length < 100; + + MessageSelectionState copyWith({ + Set? selectedMessageIds, + Set? messageCannotForward, + Set? messageCannotRecall, + Set? messageCannotCombineForward, + }) => MessageSelectionState( + selectedMessageIds: selectedMessageIds ?? this.selectedMessageIds, + messageCannotForward: messageCannotForward ?? this.messageCannotForward, + messageCannotRecall: messageCannotRecall ?? this.messageCannotRecall, + messageCannotCombineForward: + messageCannotCombineForward ?? this.messageCannotCombineForward, + ); + + @override + List get props => [ + selectedMessageIds, + messageCannotForward, + messageCannotRecall, + messageCannotCombineForward, + ]; +} + +class MessageSelectionNotifier extends Notifier { + @override + MessageSelectionState build() { + ref.watch(currentConversationIdProvider); + return const MessageSelectionState(); + } void selectMessage(MessageItem message) { - _selectedMessageIds.add(message.messageId); - if (!message.canForward) { - _messageCannotForward.add(message.messageId); - _messageCannotCombineForward.add(message.messageId); - } - if (!message.canRecall) { - _messageCannotRecall.add(message.messageId); - } - if (message.type.isTranscript) { - _messageCannotCombineForward.add(message.messageId); - } - notifyListeners(); + state = _addMessage(message); } void toggleSelection(MessageItem message) { final messageId = message.messageId; - - if (_selectedMessageIds.remove(messageId)) { - _messageCannotForward.remove(messageId); - _messageCannotRecall.remove(messageId); - _messageCannotCombineForward.remove(messageId); - } else { - _selectedMessageIds.add(messageId); - if (!message.canForward) { - _messageCannotForward.add(message.messageId); - _messageCannotCombineForward.add(messageId); - } - if (message.type.isTranscript) { - _messageCannotCombineForward.add(messageId); - } - if (!message.canRecall) { - _messageCannotRecall.add(message.messageId); - } + if (state.selectedMessageIds.contains(messageId)) { + state = state.copyWith( + selectedMessageIds: {...state.selectedMessageIds}..remove(messageId), + messageCannotForward: {...state.messageCannotForward} + ..remove(messageId), + messageCannotRecall: {...state.messageCannotRecall}..remove(messageId), + messageCannotCombineForward: {...state.messageCannotCombineForward} + ..remove(messageId), + ); + return; } - - notifyListeners(); + state = _addMessage(message); } void clearSelection() { - _selectedMessageIds.clear(); - _messageCannotForward.clear(); - _messageCannotRecall.clear(); - _messageCannotCombineForward.clear(); + state = const MessageSelectionState(); + } + + MessageSelectionState _addMessage(MessageItem message) { + final selectedMessageIds = {...state.selectedMessageIds} + ..add(message.messageId); + final messageCannotForward = {...state.messageCannotForward}; + final messageCannotRecall = {...state.messageCannotRecall}; + final messageCannotCombineForward = {...state.messageCannotCombineForward}; + + if (!message.canForward) { + messageCannotForward.add(message.messageId); + messageCannotCombineForward.add(message.messageId); + } + if (!message.canRecall) { + messageCannotRecall.add(message.messageId); + } + if (message.type.isTranscript) { + messageCannotCombineForward.add(message.messageId); + } - notifyListeners(); + return state.copyWith( + selectedMessageIds: selectedMessageIds, + messageCannotForward: messageCannotForward, + messageCannotRecall: messageCannotRecall, + messageCannotCombineForward: messageCannotCombineForward, + ); } } -final messageSelectionProvider = ChangeNotifierProvider.autoDispose((ref) { - ref.watch(currentConversationIdProvider); - return MessageSelectionNotifier(); -}); +final messageSelectionProvider = + NotifierProvider.autoDispose< + MessageSelectionNotifier, + MessageSelectionState + >( + MessageSelectionNotifier.new, + ); final hasSelectedMessageProvider = messageSelectionProvider.select( (value) => value.hasSelectedMessage, diff --git a/lib/ui/provider/minute_timer_provider.dart b/lib/ui/provider/minute_timer_provider.dart index 07df28c964..7ec24ddd76 100644 --- a/lib/ui/provider/minute_timer_provider.dart +++ b/lib/ui/provider/minute_timer_provider.dart @@ -3,40 +3,24 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/rivepod.dart'; -class MinuteTimerNotifier extends DistinctStateNotifier { - MinuteTimerNotifier() : super(DateTime.now()) { - timer = Timer.periodic(const Duration(minutes: 1), (_) => handleTimeout()); - } - - late Timer timer; - - void handleTimeout() => state = DateTime.now(); - - @override - void dispose() { - timer.cancel(); - super.dispose(); - } -} - -final minuteTimerProvider = - StateNotifierProvider( - (ref) => MinuteTimerNotifier(), - ); +final minuteTimerProvider = StreamProvider((ref) async* { + yield DateTime.now(); + yield* Stream.periodic( + const Duration(minutes: 1), + (_) => DateTime.now(), + ); +}); final formattedDateTimeProvider = Provider.family(( ref, dateTime, ) { - // for update this provider ref.watch(minuteTimerProvider); return dateTime.format; }); final formattedDayProvider = Provider.family((ref, dateTime) { - // for update this provider ref.watch(minuteTimerProvider); return dateTime.formatOfDay; }); diff --git a/lib/ui/provider/multi_auth_provider.dart b/lib/ui/provider/multi_auth_provider.dart index 7066e6dba9..636d61777a 100644 --- a/lib/ui/provider/multi_auth_provider.dart +++ b/lib/ui/provider/multi_auth_provider.dart @@ -3,14 +3,12 @@ import 'dart:convert'; import 'package:equatable/equatable.dart'; -import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:mixin_logger/mixin_logger.dart'; -import '../../utils/hydrated_bloc.dart'; -import '../../utils/rivepod.dart'; +import '../../utils/hydration_codec.dart'; +import '../../utils/hydration_storage.dart'; class AuthState extends Equatable { const AuthState({required this.account, required this.privateKey}); @@ -78,19 +76,39 @@ class MultiAuthState extends Equatable { String toJson() => json.encode(toMap()); } -class MultiAuthStateNotifier extends DistinctStateNotifier { - MultiAuthStateNotifier(super.state); +class MultiAuthStateNotifier extends Notifier { + @override + MultiAuthState build() { + ref.keepAlive(); + + final oldJson = HydrationStorageRegistry.storage.read(_kMultiAuthCubitKey); + if (oldJson != null) { + final multiAuthState = fromHydratedJson(oldJson, MultiAuthState.fromMap); + if (multiAuthState != null) { + return multiAuthState; + } + } + return const MultiAuthState(); + } AuthState? get current => state.current; + void _update(MultiAuthState value) { + final hydratedJson = toHydratedJson(value.toMap()); + HydrationStorageRegistry.storage.write(_kMultiAuthCubitKey, hydratedJson); + state = value; + } + void signIn(AuthState authState) { - state = MultiAuthState( - auths: { - ...state._auths.where( - (element) => element.account.userId != authState.account.userId, - ), - authState, - }, + _update( + MultiAuthState( + auths: { + ...state._auths.where( + (element) => element.account.userId != authState.account.userId, + ), + authState, + }, + ), ); } @@ -103,58 +121,37 @@ class MultiAuthStateNotifier extends DistinctStateNotifier { return; } authState = AuthState(account: account, privateKey: authState.privateKey); - state = MultiAuthState( - auths: { - ...state._auths.where( - (element) => element.account.userId != authState?.account.userId, - ), - authState, - }, + _update( + MultiAuthState( + auths: { + ...state._auths.where( + (element) => element.account.userId != authState?.account.userId, + ), + authState, + }, + ), ); } void signOut() { if (state._auths.isEmpty) return; - state = MultiAuthState( - auths: state._auths.toSet()..remove(state._auths.last), + _update( + MultiAuthState( + auths: state._auths.toSet()..remove(state._auths.last), + ), ); } - - @override - @protected - set state(MultiAuthState value) { - final hydratedJson = toHydratedJson(state.toMap()); - HydratedBloc.storage.write(_kMultiAuthCubitKey, hydratedJson); - super.state = value; - } } @Deprecated('Use multiAuthNotifierProvider instead') const _kMultiAuthCubitKey = 'MultiAuthCubit'; -final multiAuthStateNotifierProvider = - StateNotifierProvider.autoDispose(( - ref, - ) { - ref.keepAlive(); - - final oldJson = HydratedBloc.storage.read(_kMultiAuthCubitKey); - if (oldJson != null) { - final multiAuthState = fromHydratedJson( - oldJson, - MultiAuthState.fromMap, - ); - if (multiAuthState == null) { - return MultiAuthStateNotifier(const MultiAuthState()); - } - - return MultiAuthStateNotifier(multiAuthState); - } - - return MultiAuthStateNotifier(const MultiAuthState()); - }); +final multiAuthNotifierProvider = + NotifierProvider.autoDispose( + MultiAuthStateNotifier.new, + ); -final authProvider = multiAuthStateNotifierProvider.select( +final authProvider = multiAuthNotifierProvider.select( (value) => value.current, ); diff --git a/lib/ui/provider/pending_jump_message_provider.dart b/lib/ui/provider/pending_jump_message_provider.dart index 7a575a1804..29dc142f40 100644 --- a/lib/ui/provider/pending_jump_message_provider.dart +++ b/lib/ui/provider/pending_jump_message_provider.dart @@ -1,14 +1,35 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart'; import 'conversation_provider.dart'; -final pendingJumpMessageProvider = StateProvider.autoDispose((ref) { - final keepAlive = ref.keepAlive(); +class PendingJumpMessageNotifier extends Notifier { + KeepAliveLink? _keepAlive; - ref.listen(currentConversationIdProvider, (previous, next) { - keepAlive.close(); - ref.invalidateSelf(); - }); + @override + String? build() { + ref.listen(currentConversationIdProvider, (previous, next) { + _keepAlive?.close(); + _keepAlive = null; + state = null; + }); + return null; + } - return null; -}); + void set(String? value) { + if (value == null) { + _keepAlive?.close(); + _keepAlive = null; + } else { + _keepAlive ??= ref.keepAlive(); + } + state = value; + } + + void clear() => set(null); +} + +final pendingJumpMessageProvider = + NotifierProvider.autoDispose( + PendingJumpMessageNotifier.new, + ); diff --git a/lib/ui/provider/quote_message_provider.dart b/lib/ui/provider/quote_message_provider.dart index 4643ebc0fa..8a4a1e63bc 100644 --- a/lib/ui/provider/quote_message_provider.dart +++ b/lib/ui/provider/quote_message_provider.dart @@ -1,44 +1,57 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/misc.dart'; import '../../db/mixin_database.dart'; -import '../../utils/rivepod.dart'; import 'conversation_provider.dart'; -final quoteMessageProvider = StateProvider.autoDispose((ref) { - final keepAlive = ref.keepAlive(); +class QuoteMessageNotifier extends Notifier { + KeepAliveLink? _keepAlive; - ref.listen(currentConversationIdProvider, (previous, next) { - keepAlive.close(); - ref.invalidateSelf(); - }); + @override + MessageItem? build() { + ref.listen(currentConversationIdProvider, (previous, next) { + _keepAlive?.close(); + _keepAlive = null; + state = null; + }); + return null; + } + + void set(MessageItem? value) { + if (value == null) { + _keepAlive?.close(); + _keepAlive = null; + } else { + _keepAlive ??= ref.keepAlive(); + } + state = value; + } + + void clear() => set(null); +} - return null; -}); +final quoteMessageProvider = + NotifierProvider.autoDispose( + QuoteMessageNotifier.new, + ); final quoteMessageIdProvider = quoteMessageProvider.select( (message) => message?.messageId, ); -class LastQuoteMessageStateNotifier - extends DistinctStateNotifier { - LastQuoteMessageStateNotifier(super.state); - - set _state(MessageItem? value) => super.state = value; +class LastQuoteMessageNotifier extends Notifier { + @override + MessageItem? build() { + ref.listen(quoteMessageProvider, (previous, next) { + if (next != null) { + state = next; + } + }); + return ref.read(quoteMessageProvider); + } } final lastQuoteMessageProvider = - StateNotifierProvider.autoDispose< - LastQuoteMessageStateNotifier, - MessageItem? - >((ref) { - final lastQuoteMessageStateNotifier = LastQuoteMessageStateNotifier( - ref.read(quoteMessageProvider), - ); - - ref.listen(quoteMessageProvider, (previous, next) { - if (next == null) return; - lastQuoteMessageStateNotifier._state = next; - }); - - return lastQuoteMessageStateNotifier; - }); + NotifierProvider.autoDispose( + LastQuoteMessageNotifier.new, + ); diff --git a/lib/ui/provider/recall_message_reedit_provider.dart b/lib/ui/provider/recall_message_reedit_provider.dart index ef1ea7a99c..3c43cf6312 100644 --- a/lib/ui/provider/recall_message_reedit_provider.dart +++ b/lib/ui/provider/recall_message_reedit_provider.dart @@ -1,18 +1,24 @@ import 'dart:async'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../utils/rivepod.dart'; // Duration to keep reedit available after message recalled. const _kReeditOutdatedDuration = Duration(minutes: 6); -class RecallMessageNotifier extends DistinctStateNotifier> { - RecallMessageNotifier(this._onReEditStreamController) : super({}); - - final StreamController _onReEditStreamController; - +class RecallMessageNotifier extends Notifier> { final Set _timers = {}; + @override + Map build() { + ref.onDispose(() { + for (final timer in _timers) { + timer.cancel(); + } + _timers.clear(); + }); + return const {}; + } + void _updateMessage(String messageId, String? content) { if (content == null) { state = {...state}..remove(messageId); @@ -31,15 +37,8 @@ class RecallMessageNotifier extends DistinctStateNotifier> { _timers.add(timer); } - void onReedit(String content) => _onReEditStreamController.add(content); - - @override - void dispose() { - for (final timer in _timers) { - timer.cancel(); - } - super.dispose(); - } + void onReedit(String content) => + ref.read(_onReEditStreamControllerProvider).add(content); } final _onReEditStreamControllerProvider = Provider( @@ -51,9 +50,8 @@ final onReEditStreamProvider = _onReEditStreamControllerProvider.select( ); final _recallMessageProvider = - StateNotifierProvider>( - (ref) => - RecallMessageNotifier(ref.watch(_onReEditStreamControllerProvider)), + NotifierProvider>( + RecallMessageNotifier.new, ); final recalledTextProvider = Provider.family( diff --git a/lib/ui/provider/recent_conversation_provider.dart b/lib/ui/provider/recent_conversation_provider.dart index db061994cd..d1f4ebea24 100644 --- a/lib/ui/provider/recent_conversation_provider.dart +++ b/lib/ui/provider/recent_conversation_provider.dart @@ -1,11 +1,13 @@ import 'dart:math'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../utils/rivepod.dart'; -class RecentConversationIDsNotifier - extends DistinctStateNotifier> { - RecentConversationIDsNotifier() : super([]); +class RecentConversationIDsNotifier extends Notifier> { + @override + List build() { + ref.keepAlive(); + return const []; + } void add(String conversationId) { final newList = ([...state] @@ -20,10 +22,6 @@ class RecentConversationIDsNotifier } final recentConversationIDsProvider = - StateNotifierProvider.autoDispose< - RecentConversationIDsNotifier, - List - >((ref) { - ref.keepAlive(); - return RecentConversationIDsNotifier(); - }); + NotifierProvider.autoDispose>( + RecentConversationIDsNotifier.new, + ); diff --git a/lib/ui/provider/responsive_navigator_provider.dart b/lib/ui/provider/responsive_navigator_provider.dart index 94e1c0c156..0cfc9d8501 100644 --- a/lib/ui/provider/responsive_navigator_provider.dart +++ b/lib/ui/provider/responsive_navigator_provider.dart @@ -18,8 +18,6 @@ import 'abstract_responsive_navigator.dart'; class ResponsiveNavigatorStateNotifier extends AbstractResponsiveNavigatorStateNotifier { - ResponsiveNavigatorStateNotifier() : super(const ResponsiveNavigatorState()); - final _chatPageKey = GlobalKey(); static const chatPage = 'chatPage'; @@ -148,10 +146,10 @@ class ResponsiveNavigatorStateNotifier } final responsiveNavigatorProvider = - StateNotifierProvider.autoDispose< + NotifierProvider.autoDispose< ResponsiveNavigatorStateNotifier, ResponsiveNavigatorState - >((ref) => ResponsiveNavigatorStateNotifier()); + >(ResponsiveNavigatorStateNotifier.new); final navigatorRouteModeProvider = responsiveNavigatorProvider.select( (value) => value.routeMode, diff --git a/lib/ui/provider/search_mao_user_provider.dart b/lib/ui/provider/search_mao_user_provider.dart index c5f9310752..5d4fab9cbb 100644 --- a/lib/ui/provider/search_mao_user_provider.dart +++ b/lib/ui/provider/search_mao_user_provider.dart @@ -5,7 +5,6 @@ import 'package:mixin_logger/mixin_logger.dart'; import '../../db/dao/user_dao.dart'; import '../../db/mixin_database.dart'; import 'account_server_provider.dart'; -import 'database_provider.dart'; class MaoUser with EquatableMixin { MaoUser({required this.user, required this.mao}); @@ -19,7 +18,7 @@ class MaoUser with EquatableMixin { final searchMaoUserProvider = FutureProvider.autoDispose .family((ref, keyword) async { - final client = ref.read(accountServerProvider).valueOrNull?.client; + final client = ref.read(accountServerProvider).value?.client; if (client == null) { return null; } @@ -28,7 +27,7 @@ final searchMaoUserProvider = FutureProvider.autoDispose } try { final user = (await client.userApi.search(keyword)).data.asDbUser; - await ref.read(databaseProvider).valueOrNull?.userDao.insert(user); + await ref.read(accountServerProvider).value?.upsertDbUser(user); return MaoUser(user: user, mao: keyword.completeMao()); } catch (error, stackTrace) { e('searchMaoUserProvider error: $error, $stackTrace'); diff --git a/lib/ui/provider/setting_provider.dart b/lib/ui/provider/setting_provider.dart index 7d971b2f5d..64f682ee73 100644 --- a/lib/ui/provider/setting_provider.dart +++ b/lib/ui/provider/setting_provider.dart @@ -1,18 +1,16 @@ -// ignore_for_file: deprecated_consistency -// ignore_for_file: deprecated_member_use_from_same_package - import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import 'package:hydrated_bloc/hydrated_bloc.dart'; -import '../../utils/hydrated_bloc.dart'; +import '../../utils/hydration_codec.dart'; +import '../../utils/hydration_storage.dart'; import '../../utils/logger.dart'; -@Deprecated('Use settingProvider instead') -class _SettingState extends Equatable { - const _SettingState({ - int? brightness, +const _kSettingStorageKey = 'SettingCubit'; + +class SettingState extends Equatable { + const SettingState({ + int? brightnessValue, bool? messageShowAvatar, bool? messagePreview, bool? photoAutoDownload, @@ -21,7 +19,7 @@ class _SettingState extends Equatable { bool? collapsedSidebar, double? chatFontSizeDelta, bool? messageShowIdentityNumber, - }) : _brightness = brightness, + }) : _brightnessValue = brightnessValue, _messageShowAvatar = messageShowAvatar, _messagePreview = messagePreview, _photoAutoDownload = photoAutoDownload, @@ -31,8 +29,8 @@ class _SettingState extends Equatable { _chatFontSizeDelta = chatFontSizeDelta, _messageShowIdentityNumber = messageShowIdentityNumber; - factory _SettingState.fromMap(Map map) => _SettingState( - brightness: map['brightness'] as int?, + factory SettingState.fromMap(Map map) => SettingState( + brightnessValue: map['brightness'] as int?, messageShowAvatar: map['messageShowAvatar'] as bool?, messagePreview: map['messagePreview'] as bool?, photoAutoDownload: map['photoAutoDownload'] as bool?, @@ -43,7 +41,7 @@ class _SettingState extends Equatable { messageShowIdentityNumber: map['messageShowIdentityNumber'] as bool?, ); - final int? _brightness; + final int? _brightnessValue; final bool? _messageShowAvatar; final bool? _messagePreview; final bool? _photoAutoDownload; @@ -53,7 +51,33 @@ class _SettingState extends Equatable { final double? _chatFontSizeDelta; final bool? _messageShowIdentityNumber; - int get brightness => _brightness ?? 0; + Brightness? get brightness { + switch (_brightnessValue) { + case 0: + case null: + return null; + case 1: + return Brightness.dark; + case 2: + return Brightness.light; + default: + w('invalid value for brightness. $_brightnessValue'); + return null; + } + } + + ThemeMode get themeMode { + switch (brightness) { + case Brightness.dark: + return ThemeMode.dark; + case Brightness.light: + return ThemeMode.light; + case null: + return ThemeMode.system; + } + } + + int? get brightnessValue => _brightnessValue; bool get messageShowAvatar => _messageShowAvatar ?? true; @@ -71,21 +95,8 @@ class _SettingState extends Equatable { bool get messageShowIdentityNumber => _messageShowIdentityNumber ?? false; - @override - List get props => [ - _brightness, - _messageShowAvatar, - _messagePreview, - _photoAutoDownload, - _videoAutoDownload, - _fileAutoDownload, - _collapsedSidebar, - _chatFontSizeDelta, - _messageShowIdentityNumber, - ]; - Map toMap() => { - 'brightness': _brightness, + 'brightness': brightnessValue, 'messageShowAvatar': _messageShowAvatar, 'messagePreview': _messagePreview, 'photoAutoDownload': _photoAutoDownload, @@ -96,8 +107,8 @@ class _SettingState extends Equatable { 'messageShowIdentityNumber': _messageShowIdentityNumber, }; - _SettingState copyWith({ - int? brightness, + SettingState copyWith({ + int? brightnessValue, bool? messageShowAvatar, bool? messagePreview, bool? photoAutoDownload, @@ -106,8 +117,8 @@ class _SettingState extends Equatable { bool? collapsedSidebar, double? chatFontSizeDelta, bool? messageShowIdentityNumber, - }) => _SettingState( - brightness: brightness ?? _brightness, + }) => SettingState( + brightnessValue: brightnessValue ?? _brightnessValue, messageShowAvatar: messageShowAvatar ?? _messageShowAvatar, messagePreview: messagePreview ?? _messagePreview, photoAutoDownload: photoAutoDownload ?? _photoAutoDownload, @@ -118,204 +129,110 @@ class _SettingState extends Equatable { messageShowIdentityNumber: messageShowIdentityNumber ?? _messageShowIdentityNumber, ); -} -class SettingChangeNotifier extends ChangeNotifier { - SettingChangeNotifier({ - int? brightness, - bool? messageShowAvatar, - bool? messagePreview, - bool? photoAutoDownload, - bool? videoAutoDownload, - bool? fileAutoDownload, - bool? collapsedSidebar, - double? chatFontSizeDelta, - bool? messageShowIdentityNumber, - }) : _brightness = brightness, - _messageShowAvatar = messageShowAvatar, - _messagePreview = messagePreview, - _photoAutoDownload = photoAutoDownload, - _videoAutoDownload = videoAutoDownload, - _fileAutoDownload = fileAutoDownload, - _collapsedSidebar = collapsedSidebar, - _chatFontSizeDelta = chatFontSizeDelta, - _messageShowIdentityNumber = messageShowIdentityNumber; + @override + List get props => [ + brightnessValue, + _messageShowAvatar, + _messagePreview, + _photoAutoDownload, + _videoAutoDownload, + _fileAutoDownload, + _collapsedSidebar, + _chatFontSizeDelta, + _messageShowIdentityNumber, + ]; +} - /// The brightness of theme. - /// 0 : follow system - /// 1 : dark - /// 2 : light - /// - /// The reason [int] instead of [Brightness] enum is that Hive has limited - /// support for custom data class. - /// https://docs.hivedb.dev/#/custom-objects/type_adapters?id=register-adapter - /// https://github.com/hivedb/hive/issues/525 - /// https://github.com/hivedb/hive/issues/518 - int? _brightness; - bool? _messageShowAvatar; - bool? _messageShowIdentityNumber; - bool? _messagePreview; - bool? _photoAutoDownload; - bool? _videoAutoDownload; - bool? _fileAutoDownload; - bool? _collapsedSidebar; - double? _chatFontSizeDelta; - - /// [brightness] null to follow system. - set brightness(Brightness? value) { - switch (value) { - case Brightness.dark: - _brightness = 1; - case Brightness.light: - _brightness = 2; - case null: - _brightness = 0; +class SettingChangeNotifier extends Notifier { + @override + SettingState build() { + ref.keepAlive(); + final oldJson = HydrationStorageRegistry.storage.read(_kSettingStorageKey); + if (oldJson == null) { + return const SettingState(); } - notifyListeners(); - } - Brightness? get brightness { - switch (_brightness) { - case 0: - case null: - return null; - case 1: - return Brightness.dark; - case 2: - return Brightness.light; - default: - w('invalid value for brightness. $_brightness'); - return null; - } + return fromHydratedJson(oldJson, SettingState.fromMap) ?? + const SettingState(); } - ThemeMode get themeMode { - switch (brightness) { - case Brightness.dark: - return ThemeMode.dark; - case Brightness.light: - return ThemeMode.light; - case null: - return ThemeMode.system; - } + set brightness(Brightness? value) { + final brightnessValue = switch (value) { + Brightness.dark => 1, + Brightness.light => 2, + null => 0, + }; + _update(state.copyWith(brightnessValue: brightnessValue)); } - set messageShowAvatar(bool value) { - if (_messageShowAvatar == value) return; + Brightness? get brightness => state.brightness; - _messageShowAvatar = value; - notifyListeners(); + ThemeMode get themeMode => state.themeMode; + + set messageShowAvatar(bool value) { + if (state.messageShowAvatar == value) return; + _update(state.copyWith(messageShowAvatar: value)); } - bool get messageShowAvatar => _messageShowAvatar ?? true; + bool get messageShowAvatar => state.messageShowAvatar; set messageShowIdentityNumber(bool value) { - if (_messageShowIdentityNumber == value) return; - - _messageShowIdentityNumber = value; - notifyListeners(); + if (state.messageShowIdentityNumber == value) return; + _update(state.copyWith(messageShowIdentityNumber: value)); } - bool get messageShowIdentityNumber => _messageShowIdentityNumber ?? false; + bool get messageShowIdentityNumber => state.messageShowIdentityNumber; set messagePreview(bool value) { - if (_messagePreview == value) return; - - _messagePreview = value; - notifyListeners(); + if (state.messagePreview == value) return; + _update(state.copyWith(messagePreview: value)); } - bool get messagePreview => _messagePreview ?? true; + bool get messagePreview => state.messagePreview; set photoAutoDownload(bool value) { - if (_photoAutoDownload == value) return; - - _photoAutoDownload = value; - notifyListeners(); + if (state.photoAutoDownload == value) return; + _update(state.copyWith(photoAutoDownload: value)); } - bool get photoAutoDownload => _photoAutoDownload ?? true; + bool get photoAutoDownload => state.photoAutoDownload; set videoAutoDownload(bool value) { - if (_videoAutoDownload == value) return; - - _videoAutoDownload = value; - notifyListeners(); + if (state.videoAutoDownload == value) return; + _update(state.copyWith(videoAutoDownload: value)); } - bool get videoAutoDownload => _videoAutoDownload ?? true; + bool get videoAutoDownload => state.videoAutoDownload; set fileAutoDownload(bool value) { - if (_fileAutoDownload == value) return; - - _fileAutoDownload = value; - notifyListeners(); + if (state.fileAutoDownload == value) return; + _update(state.copyWith(fileAutoDownload: value)); } - bool get fileAutoDownload => _fileAutoDownload ?? true; + bool get fileAutoDownload => state.fileAutoDownload; set collapsedSidebar(bool value) { - if (_collapsedSidebar == value) return; - - _collapsedSidebar = value; - notifyListeners(); + if (state.collapsedSidebar == value) return; + _update(state.copyWith(collapsedSidebar: value)); } - bool get collapsedSidebar => _collapsedSidebar ?? false; + bool get collapsedSidebar => state.collapsedSidebar; set chatFontSizeDelta(double value) { - if (_chatFontSizeDelta == value) return; - - _chatFontSizeDelta = value; - notifyListeners(); + if (state.chatFontSizeDelta == value) return; + _update(state.copyWith(chatFontSizeDelta: value)); } - double get chatFontSizeDelta => _chatFontSizeDelta ?? 0; + double get chatFontSizeDelta => state.chatFontSizeDelta; - @override - void notifyListeners() { - HydratedBloc.storage.write( - _kSettingCubitKey, - _SettingState( - brightness: _brightness, - messageShowAvatar: _messageShowAvatar, - messagePreview: _messagePreview, - photoAutoDownload: _photoAutoDownload, - videoAutoDownload: _videoAutoDownload, - fileAutoDownload: _fileAutoDownload, - collapsedSidebar: _collapsedSidebar, - chatFontSizeDelta: _chatFontSizeDelta, - messageShowIdentityNumber: _messageShowIdentityNumber, - ).toMap(), - ); - super.notifyListeners(); + void _update(SettingState next) { + HydrationStorageRegistry.storage.write(_kSettingStorageKey, next.toMap()); + state = next; } } -@Deprecated('Use SettingChangeNotifier instead') -const _kSettingCubitKey = 'SettingCubit'; - final settingProvider = - ChangeNotifierProvider.autoDispose((ref) { - ref.keepAlive(); - - //migrate - final oldJson = HydratedBloc.storage.read(_kSettingCubitKey); - if (oldJson != null) { - final settingState = fromHydratedJson(oldJson, _SettingState.fromMap); - if (settingState == null) return SettingChangeNotifier(); - return SettingChangeNotifier( - brightness: settingState.brightness, - messageShowAvatar: settingState.messageShowAvatar, - messagePreview: settingState.messagePreview, - photoAutoDownload: settingState.photoAutoDownload, - videoAutoDownload: settingState.videoAutoDownload, - fileAutoDownload: settingState.fileAutoDownload, - collapsedSidebar: settingState.collapsedSidebar, - chatFontSizeDelta: settingState.chatFontSizeDelta, - messageShowIdentityNumber: settingState.messageShowIdentityNumber, - ); - } - - return SettingChangeNotifier(); - }); + NotifierProvider.autoDispose( + SettingChangeNotifier.new, + ); diff --git a/lib/ui/provider/slide_category_provider.dart b/lib/ui/provider/slide_category_provider.dart index 95073ca1ee..60d3b2cab9 100644 --- a/lib/ui/provider/slide_category_provider.dart +++ b/lib/ui/provider/slide_category_provider.dart @@ -1,8 +1,8 @@ +import 'dart:async'; + import 'package:equatable/equatable.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../utils/rivepod.dart'; - enum SlideCategoryType { chats, contacts, @@ -25,13 +25,22 @@ class SlideCategoryState extends Equatable { List get props => [type, id]; } -class SlideCategoryStateNotifier - extends DistinctStateNotifier { - SlideCategoryStateNotifier() - : super(const SlideCategoryState(type: SlideCategoryType.chats)); +class SlideCategoryStateNotifier extends Notifier { + late final StreamController _streamController = + StreamController.broadcast(); - void select(SlideCategoryType type, [String? id]) => - state = SlideCategoryState(type: type, id: id); + Stream get stream => _streamController.stream; + + @override + SlideCategoryState build() { + ref.onDispose(_streamController.close); + return const SlideCategoryState(type: SlideCategoryType.chats); + } + + void select(SlideCategoryType type, [String? id]) { + state = SlideCategoryState(type: type, id: id); + _streamController.add(state); + } void switchToChatsIfSettings() { if (state.type != SlideCategoryType.setting) return; @@ -39,7 +48,7 @@ class SlideCategoryStateNotifier } } -final slideCategoryStateProvider = - StateNotifierProvider( - (ref) => SlideCategoryStateNotifier(), +final slideCategoryProvider = + NotifierProvider( + SlideCategoryStateNotifier.new, ); diff --git a/lib/ui/provider/ui_context_providers.dart b/lib/ui/provider/ui_context_providers.dart new file mode 100644 index 0000000000..9dcc283301 --- /dev/null +++ b/lib/ui/provider/ui_context_providers.dart @@ -0,0 +1,170 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +import '../../constants/brightness_theme_data.dart'; +import '../../generated/l10n.dart'; +import '../../widgets/brightness_observer.dart'; +import 'setting_provider.dart'; + +class _UiContextSnapshot { + const _UiContextSnapshot({ + required this.context, + required this.locale, + required this.localization, + required this.materialLocalizations, + required this.mediaQueryData, + required this.materialTheme, + required this.brightnessValue, + }); + + final BuildContext context; + final Locale locale; + final Localization localization; + final MaterialLocalizations materialLocalizations; + final MediaQueryData mediaQueryData; + final ThemeData materialTheme; + final double brightnessValue; +} + +class _UiContextRevisionNotifier extends Notifier { + @override + int build() => 0; + + void bump() => state++; +} + +_UiContextSnapshot? _uiContextSnapshot; + +final _uiContextRevisionProvider = + NotifierProvider<_UiContextRevisionNotifier, int>( + _UiContextRevisionNotifier.new, + ); + +_UiContextSnapshot _requireUiContextSnapshot(Ref ref) { + ref.watch(_uiContextRevisionProvider); + final snapshot = _uiContextSnapshot; + if (snapshot == null) { + throw StateError('UiContextScope is not ready'); + } + return snapshot; +} + +final buildContextProvider = Provider( + (ref) => _requireUiContextSnapshot(ref).context, + dependencies: [_uiContextRevisionProvider], +); + +final localeProvider = Provider( + (ref) => _requireUiContextSnapshot(ref).locale, + dependencies: [_uiContextRevisionProvider], +); + +final localizationProvider = Provider( + (ref) => _requireUiContextSnapshot(ref).localization, + dependencies: [_uiContextRevisionProvider], +); + +final materialLocalizationsProvider = Provider( + (ref) => _requireUiContextSnapshot(ref).materialLocalizations, + dependencies: [_uiContextRevisionProvider], +); + +final mediaQueryDataProvider = Provider( + (ref) => _requireUiContextSnapshot(ref).mediaQueryData, + dependencies: [_uiContextRevisionProvider], +); + +final materialThemeProvider = Provider( + (ref) => _requireUiContextSnapshot(ref).materialTheme, + dependencies: [_uiContextRevisionProvider], +); + +final brightnessValueProvider = Provider( + (ref) => _requireUiContextSnapshot(ref).brightnessValue, + dependencies: [_uiContextRevisionProvider], +); + +final brightnessThemeDataProvider = Provider( + (ref) => BrightnessThemeData.lerp( + lightBrightnessThemeData, + darkBrightnessThemeData, + ref.watch(brightnessValueProvider), + ), + dependencies: [brightnessValueProvider], +); + +final platformBrightnessProvider = Provider( + (ref) => ref.watch(materialThemeProvider.select((value) => value.brightness)), + dependencies: [materialThemeProvider], +); + +final effectiveBrightnessProvider = Provider( + (ref) { + final forcedBrightness = ref.watch( + settingProvider.select((value) => value.brightness), + ); + return forcedBrightness ?? ref.watch(platformBrightnessProvider); + }, + dependencies: [platformBrightnessProvider], +); + +typedef DynamicColorArgs = ({Color color, Color? darkColor}); + +final dynamicColorProvider = Provider.family( + (ref, args) { + if (args.darkColor == null) return args.color; + return Color.lerp( + args.color, + args.darkColor, + ref.watch(brightnessValueProvider), + )!; + }, + dependencies: [brightnessValueProvider], +); + +class UiContextScope extends HookConsumerWidget { + const UiContextScope({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final locale = Localizations.localeOf(context); + final localization = Localization.of(context); + final materialLocalizations = MaterialLocalizations.of(context); + final mediaQueryData = MediaQuery.of(context); + final materialTheme = Theme.of(context); + final brightnessValue = BrightnessData.of(context); + + _uiContextSnapshot = _UiContextSnapshot( + context: context, + locale: locale, + localization: localization, + materialLocalizations: materialLocalizations, + mediaQueryData: mediaQueryData, + materialTheme: materialTheme, + brightnessValue: brightnessValue, + ); + + useEffect( + () { + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!context.mounted) return; + ref.read(_uiContextRevisionProvider.notifier).bump(); + }); + return null; + }, + [ + locale, + mediaQueryData.size, + mediaQueryData.devicePixelRatio, + mediaQueryData.textScaler, + materialTheme.brightness, + brightnessValue, + ], + ); + + return child; + } +} diff --git a/lib/ui/provider/unseen_conversations_provider.dart b/lib/ui/provider/unseen_conversations_provider.dart index e21a65a30d..27dc2d7a49 100644 --- a/lib/ui/provider/unseen_conversations_provider.dart +++ b/lib/ui/provider/unseen_conversations_provider.dart @@ -4,7 +4,6 @@ import 'package:rxdart/rxdart.dart'; import '../../db/dao/conversation_dao.dart'; import '../../db/database_event_bus.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/rivepod.dart'; import 'conversation_provider.dart'; import 'database_provider.dart'; import 'slide_category_provider.dart'; @@ -39,73 +38,69 @@ extension _ConversationItemSort on List { } class UnseenConversationsStateNotifier - extends DistinctStateNotifier?> { - UnseenConversationsStateNotifier(super.state); + extends Notifier?> { + @override + List? build() { + final database = ref.read(databaseProvider).requireValue; + final slideCategoryState = ref.watch(slideCategoryProvider); - set _state(List? state) => super.state = state; -} - -final unseenConversationsProvider = - StateNotifierProvider.autoDispose< - UnseenConversationsStateNotifier, - List? - >((ref) { - final database = ref.read(databaseProvider).requireValue; - final slideCategoryState = ref.watch(slideCategoryStateProvider); - final unseenConversationsStateNotifier = UnseenConversationsStateNotifier( - null, - ); - - final updateEvent = Rx.merge([ - DataBaseEventBus.instance.updateConversationIdStream, - DataBaseEventBus.instance.insertOrReplaceMessageIdsStream, - ]); + final updateEvent = Rx.merge([ + DataBaseEventBus.instance.updateConversationIdStream, + DataBaseEventBus.instance.insertOrReplaceMessageIdsStream, + ]); - final Stream> unseenConversations; + final Stream> unseenConversations; - switch (slideCategoryState.type) { - case SlideCategoryType.chats: - case SlideCategoryType.contacts: - case SlideCategoryType.groups: - case SlideCategoryType.bots: - case SlideCategoryType.strangers: - unseenConversations = database.conversationDao - .unseenConversationByCategory(slideCategoryState.type) - .watchWithStream( - eventStreams: [updateEvent], - duration: kSlowThrottleDuration, - ); - case SlideCategoryType.circle: - unseenConversations = database.conversationDao - .unseenConversationsByCircleId(slideCategoryState.id!) - .watchWithStream( - eventStreams: [updateEvent], - duration: kSlowThrottleDuration, - ); - case SlideCategoryType.setting: - unseenConversations = const Stream.empty(); - } + switch (slideCategoryState.type) { + case SlideCategoryType.chats: + case SlideCategoryType.contacts: + case SlideCategoryType.groups: + case SlideCategoryType.bots: + case SlideCategoryType.strangers: + unseenConversations = database.conversationDao + .unseenConversationByCategory(slideCategoryState.type) + .watchWithStream( + eventStreams: [updateEvent], + duration: kSlowThrottleDuration, + ); + case SlideCategoryType.circle: + unseenConversations = database.conversationDao + .unseenConversationsByCircleId(slideCategoryState.id!) + .watchWithStream( + eventStreams: [updateEvent], + duration: kSlowThrottleDuration, + ); + case SlideCategoryType.setting: + unseenConversations = const Stream.empty(); + } - final subscription = unseenConversations.asyncListen((items) async { - final newItems = List.of(items); + final subscription = unseenConversations.asyncListen((items) async { + final newItems = List.of(items); - final selectedConversationId = ref.read(currentConversationIdProvider); + final selectedConversationId = ref.read(currentConversationIdProvider); - if (selectedConversationId != null && - !newItems.any( - (item) => item.conversationId == selectedConversationId, - )) { - final selectedConversationItem = await database.conversationDao - .conversationItem(selectedConversationId) - .getSingleOrNull(); - if (selectedConversationItem != null) { - newItems.add(selectedConversationItem); - } + if (selectedConversationId != null && + !newItems.any( + (item) => item.conversationId == selectedConversationId, + )) { + final selectedConversationItem = await database.conversationDao + .conversationItem(selectedConversationId) + .getSingleOrNull(); + if (selectedConversationItem != null) { + newItems.add(selectedConversationItem); } - unseenConversationsStateNotifier._state = newItems..sortConversation(); - }); + } + state = newItems..sortConversation(); + }); - ref.onDispose(subscription.cancel); + ref.onDispose(subscription.cancel); - return unseenConversationsStateNotifier; - }); + return null; + } +} + +final unseenConversationsProvider = + NotifierProvider.autoDispose< + UnseenConversationsStateNotifier, + List? + >(UnseenConversationsStateNotifier.new); diff --git a/lib/ui/provider/user_cache_provider.dart b/lib/ui/provider/user_cache_provider.dart index 430f060918..3f587e5f92 100644 --- a/lib/ui/provider/user_cache_provider.dart +++ b/lib/ui/provider/user_cache_provider.dart @@ -1,29 +1,34 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../db/dao/user_dao.dart'; import '../../db/mixin_database.dart'; -import '../../utils/rivepod.dart'; import 'database_provider.dart'; -class _UserCacheState extends DistinctStateNotifier { - _UserCacheState(String userId, UserDao? userDao) : super(null) { - if (userDao == null) return; +class _UserCacheState extends Notifier { + _UserCacheState(this.userId); - userDao.userById(userId).getSingle().then((value) => state = value); + final String userId; + + @override + User? build() { + // Minimize frequent calls to userById by keeping it alive for 10 minutes. + final keepAlive = ref.keepAlive(); + ref.onDispose( + () => Future.delayed(const Duration(minutes: 10), keepAlive.close), + ); + + final userDao = ref.watch( + databaseProvider.select((value) => value.value?.userDao), + ); + if (userDao != null) { + Future(() async { + state = await userDao.userById(userId).getSingle(); + }); + } + return null; } } -// !!! Only query once in 10 minutes !!! -final userCacheProvider = StateNotifierProvider.autoDispose - .family<_UserCacheState, User?, String>((ref, userId) { - // Minimize frequent calls to isBotGroup by keeping it alive for 10 minutes - final keepAlive = ref.keepAlive(); - ref.onDispose( - () => Future.delayed(const Duration(minutes: 10), keepAlive.close), - ); - - return _UserCacheState( - userId, - ref.watch(databaseProvider.select((value) => value.value?.userDao)), - ); - }); +final userCacheProvider = NotifierProvider.autoDispose + .family<_UserCacheState, User?, String>( + _UserCacheState.new, + ); diff --git a/lib/ui/setting/about_page.dart b/lib/ui/setting/about_page.dart index 48a82c371a..1fbbb0788c 100644 --- a/lib/ui/setting/about_page.dart +++ b/lib/ui/setting/about_page.dart @@ -16,6 +16,7 @@ import '../../widgets/app_bar.dart'; import '../../widgets/buttons.dart'; import '../../widgets/cell.dart'; import '../../widgets/high_light_text.dart'; +import '../provider/ui_context_providers.dart'; import 'log_page.dart'; class AboutPage extends HookConsumerWidget { @@ -23,7 +24,9 @@ class AboutPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final ref = useRef<(DateTime, int)?>(null); + final tapRef = useRef<(DateTime, int)?>(null); + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final debugMode = useState(false); @@ -32,27 +35,27 @@ class AboutPage extends HookConsumerWidget { void onTap() { if (debugMode.value) return; - final value = ref.value; + final value = tapRef.value; if (value == null) { - ref.value = (DateTime.now(), 1); + tapRef.value = (DateTime.now(), 1); } else { final now = DateTime.now(); final (last, count) = value; if (now.difference(last) < 1.seconds) { - ref.value = (now, count + 1); + tapRef.value = (now, count + 1); } else { - ref.value = (now, 1); + tapRef.value = (now, 1); } } - if ((ref.value?.$2 ?? 0) > 6) { + if ((tapRef.value?.$2 ?? 0) > 6) { debugMode.value = true; } } return Scaffold( - backgroundColor: context.theme.background, - appBar: MixinAppBar(title: Text(context.l10n.about)), + backgroundColor: theme.background, + appBar: MixinAppBar(title: Text(l10n.about)), body: SingleChildScrollView( child: Container( alignment: Alignment.topCenter, @@ -77,8 +80,8 @@ class AboutPage extends HookConsumerWidget { ScaleEffect(duration: 1000.ms), ], child: Text( - context.l10n.mixinMessengerDesktop, - style: TextStyle(color: context.theme.text, fontSize: 18), + l10n.mixinMessengerDesktop, + style: TextStyle(color: theme.text, fontSize: 18), ), ), ), @@ -88,47 +91,60 @@ class AboutPage extends HookConsumerWidget { CustomSelectableText( info?.versionAndBuildNumber ?? '', style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 16, ), ), const SizedBox(height: 50), CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: Column( mainAxisSize: MainAxisSize.min, children: [ CellItem( - title: Text(context.l10n.followUsOnX), - onTap: () => - openUri(context, 'https://x.com/MixinMessenger'), + title: Text(l10n.followUsOnX), + onTap: () => openUri( + context, + 'https://x.com/MixinMessenger', + container: ref.container, + ), ), CellItem( - title: Text(context.l10n.followUsOnFacebook), - onTap: () => - openUri(context, 'https://fb.com/MixinMessenger'), + title: Text(l10n.followUsOnFacebook), + onTap: () => openUri( + context, + 'https://fb.com/MixinMessenger', + container: ref.container, + ), ), CellItem( - title: Text(context.l10n.helpCenter), - onTap: () => - openUri(context, 'https://support.mixin.one'), + title: Text(l10n.helpCenter), + onTap: () => openUri( + context, + 'https://support.mixin.one', + container: ref.container, + ), ), CellItem( - title: Text(context.l10n.termsOfService), - onTap: () => - openUri(context, 'https://mixin.one/pages/terms'), + title: Text(l10n.termsOfService), + onTap: () => openUri( + context, + 'https://mixin.one/pages/terms', + container: ref.container, + ), ), CellItem( - title: Text(context.l10n.privacyPolicy), + title: Text(l10n.privacyPolicy), onTap: () => openUri( context, 'https://mixin.one/pages/privacy', + container: ref.container, ), ), if (!Platform.isMacOS) CellItem( - title: Text(context.l10n.checkNewVersion), - onTap: () => openCheckUpdate(context), + title: Text(l10n.checkNewVersion), + onTap: () => openCheckUpdate(context, ref), ), ], ), @@ -136,9 +152,12 @@ class AboutPage extends HookConsumerWidget { if (debugMode.value) CellGroup( child: CellItem( - title: Text(context.l10n.openLogDirectory), - onTap: () => - openUri(context, mixinLogDirectory.uri.toString()), + title: Text(l10n.openLogDirectory), + onTap: () => openUri( + context, + mixinLogDirectory.uri.toString(), + container: ref.container, + ), ), ), ], @@ -148,19 +167,21 @@ class AboutPage extends HookConsumerWidget { ); } - void openCheckUpdate(BuildContext context) { + void openCheckUpdate(BuildContext context, WidgetRef ref) { if (defaultTargetPlatform == TargetPlatform.linux) { - openUri(context, 'https://mixin.one/messenger'); + openUri(context, 'https://mixin.one/messenger', container: ref.container); } else if (defaultTargetPlatform == TargetPlatform.macOS || defaultTargetPlatform == TargetPlatform.iOS) { openUri( context, 'https://apps.apple.com/app/mixin-messenger/id1571128582', + container: ref.container, ); } else if (defaultTargetPlatform == TargetPlatform.windows) { openUri( context, 'https://apps.microsoft.com/store/detail/mixin-desktop/9NQ6HF99B8NJ', + container: ref.container, ); } } diff --git a/lib/ui/setting/account_delete_page.dart b/lib/ui/setting/account_delete_page.dart index bab8ff2b35..763d629f88 100644 --- a/lib/ui/setting/account_delete_page.dart +++ b/lib/ui/setting/account_delete_page.dart @@ -2,12 +2,12 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide encryptPin; import '../../account/session_key_value.dart'; import '../../constants/resources.dart'; -import '../../utils/extension/extension.dart'; import '../../utils/logger.dart'; import '../../utils/uri_utils.dart'; import '../../widgets/app_bar.dart'; @@ -19,153 +19,167 @@ import '../../widgets/toast.dart'; import '../../widgets/user/change_number_dialog.dart'; import '../../widgets/user/pin_verification_dialog.dart'; import '../../widgets/user/verification_dialog.dart'; +import '../provider/account_server_provider.dart'; +import '../provider/multi_auth_provider.dart'; +import '../provider/ui_context_providers.dart'; -class AccountDeletePage extends StatelessWidget { +class AccountDeletePage extends ConsumerWidget { const AccountDeletePage({super.key}); @override - Widget build(BuildContext context) => Scaffold( - backgroundColor: context.theme.background, - appBar: MixinAppBar(title: Text(context.l10n.deleteMyAccount)), - body: SingleChildScrollView( - child: Container( - alignment: Alignment.topCenter, - padding: const EdgeInsets.only(top: 50), - child: Column( - children: [ - const _DeleteWarningWidget(), - const SizedBox(height: 30), - CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, - child: CellItem( - title: Text(context.l10n.deleteMyAccount), - color: context.theme.red, - onTap: () async { - if (!SessionKeyValue.instance.checkPinToken()) { - showToastFailed(ToastError(context.l10n.errorNoPinToken)); - return; - } - - final user = context.account; - assert(user != null, 'user is null'); - if (user == null) { - return; - } - if (user.hasPin) { - final pin = await showPinVerificationDialog( - context, - title: context.l10n.enterYourPinToContinue, - ); - if (pin == null) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return Scaffold( + backgroundColor: theme.background, + appBar: MixinAppBar(title: Text(l10n.deleteMyAccount)), + body: SingleChildScrollView( + child: Container( + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(top: 50), + child: Column( + children: [ + const _DeleteWarningWidget(), + const SizedBox(height: 30), + CellGroup( + cellBackgroundColor: theme.settingCellBackgroundColor, + child: CellItem( + title: Text(l10n.deleteMyAccount), + color: theme.red, + onTap: () async { + if (!SessionKeyValue.instance.checkPinToken()) { + showToastFailed(ToastError(l10n.errorNoPinToken)); return; } - final confirmed = await showConfirmMixinDialog( - context, - context.l10n.landingInvitationDialogContent(user.phone), - maxWidth: 440, - positiveText: context.l10n.continueText, - ); - if (confirmed == null) return; - showToastLoading(); - VerificationResponse? verificationResponse; - try { - verificationResponse = await requestVerificationCode( - phone: user.phone, - context: context, - purpose: VerificationPurpose.deactivated, - ); - Toast.dismiss(); - } catch (error, stacktrace) { - e('_requestVerificationCode $error, $stacktrace'); - showToastFailed(error); + final accountServer = ref + .read(accountServerProvider) + .requireValue; + final user = ref.read(authAccountProvider); + assert(user != null, 'user is null'); + if (user == null) { return; } - final verificationId = await showVerificationDialog( - context, - phoneNumber: user.phone, - verificationResponse: verificationResponse, - reRequestVerification: () => requestVerificationCode( - phone: user.phone, - context: context, - purpose: VerificationPurpose.deactivated, - ), - onVerification: (code, response) async { - final result = await context - .accountServer - .client - .accountApi - .deactivateVerification(response.id, code); - return result.data.id; - }, - ); - if (verificationId == null || verificationId.isEmpty) { - return; - } - final deleted = await _showDeleteAccountPinDialog( - context, - verificationId: verificationId, - ); - if (deleted) { - w('account deleted'); - await context.accountServer.signOutAndClear(); - context.multiAuthChangeNotifier.signOut(); + if (user.hasPin) { + final pin = await showPinVerificationDialog( + context, + title: l10n.enterYourPinToContinue, + ); + if (pin == null) { + return; + } + final confirmed = await showConfirmMixinDialog( + context, + l10n.landingInvitationDialogContent(user.phone), + maxWidth: 440, + positiveText: l10n.continueText, + ); + if (confirmed == null) return; + showToastLoading(); + VerificationResponse? verificationResponse; + + try { + verificationResponse = await requestVerificationCode( + phone: user.phone, + context: context, + purpose: VerificationPurpose.deactivated, + accountApi: accountServer.client.accountApi, + ); + Toast.dismiss(); + } catch (error, stacktrace) { + e('_requestVerificationCode $error, $stacktrace'); + showToastFailed(error); + return; + } + final verificationId = await showVerificationDialog( + context, + phoneNumber: user.phone, + verificationResponse: verificationResponse, + reRequestVerification: () => requestVerificationCode( + phone: user.phone, + context: context, + purpose: VerificationPurpose.deactivated, + accountApi: accountServer.client.accountApi, + ), + onVerification: (code, response) async { + final result = await accountServer.client.accountApi + .deactivateVerification(response.id, code); + return result.data.id; + }, + ); + if (verificationId == null || verificationId.isEmpty) { + return; + } + final deleted = await _showDeleteAccountPinDialog( + context, + verificationId: verificationId, + ); + if (deleted) { + w('account deleted'); + await accountServer.signOutAndClear(); + ref.read(multiAuthNotifierProvider.notifier).signOut(); + } + } else { + e('delete account no pin'); } - } else { - e('delete account no pin'); - } - }, + }, + ), ), - ), - CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, - child: CellItem( - title: Text(context.l10n.changeNumberInstead), - onTap: () => showChangeNumberDialog(context), + CellGroup( + cellBackgroundColor: theme.settingCellBackgroundColor, + child: CellItem( + title: Text(l10n.changeNumberInstead), + onTap: () => showChangeNumberDialog(context, ref), + ), ), - ), - ], + ], + ), ), ), - ), - ); + ); + } } -class _DeleteWarningWidget extends StatelessWidget { +class _DeleteWarningWidget extends ConsumerWidget { const _DeleteWarningWidget(); @override - Widget build(BuildContext context) => Column( - children: [ - SvgPicture.asset( - Resources.assetsImagesDeleteAccountSvg, - width: 70, - height: 72, - ), - const SizedBox(height: 20), - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 380), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _WarningItem(title: context.l10n.deleteAccountHint), - _WarningItem(title: context.l10n.deleteAccountDetailHint), - _WarningItem(title: context.l10n.transactionsCannotBeDeleted), - ], + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + return Column( + children: [ + SvgPicture.asset( + Resources.assetsImagesDeleteAccountSvg, + width: 70, + height: 72, ), - ), - ], - ); + const SizedBox(height: 20), + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 380), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + _WarningItem(title: l10n.deleteAccountHint), + _WarningItem(title: l10n.deleteAccountDetailHint), + _WarningItem(title: l10n.transactionsCannotBeDeleted), + ], + ), + ), + ], + ); + } } -class _WarningItem extends StatelessWidget { +class _WarningItem extends ConsumerWidget { const _WarningItem({required this.title}); final String title; @override - Widget build(BuildContext context) { - final color = context.theme.text; + Widget build(BuildContext context, WidgetRef ref) { + final color = ref.watch( + brightnessThemeDataProvider.select((value) => value.text), + ); return SizedBox( width: 380, child: Row( @@ -198,35 +212,41 @@ Future _showDeleteAccountPinDialog( return confirmed == true; } -class _DeleteAccountPinDialog extends StatelessWidget { +class _DeleteAccountPinDialog extends HookConsumerWidget { const _DeleteAccountPinDialog({required this.verificationId}); final String verificationId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; final textSpan = useMemoized(() { - final content = context.l10n.settingDeleteAccountPinContent( + final content = l10n.settingDeleteAccountPinContent( DateFormat.yMMMd().format(DateTime.now().add(const Duration(days: 30))), ); - final index = content.indexOf(context.l10n.learnMore); + final index = content.indexOf(l10n.learnMore); if (index == -1) return TextSpan(text: content); return TextSpan( text: content.substring(0, index), children: [ TextSpan( - text: context.l10n.learnMore, - style: TextStyle(color: context.theme.accent), + text: l10n.learnMore, + style: TextStyle(color: theme.accent), recognizer: TapGestureRecognizer() ..onTap = () => openUri( context, - context.l10n.settingDeleteAccountUrl, + l10n.settingDeleteAccountUrl, + container: ref.container, ), ), TextSpan( - text: content.substring(index + context.l10n.learnMore.length), + text: content.substring( + index + l10n.learnMore.length, + ), ), ], ); @@ -243,13 +263,13 @@ class _DeleteAccountPinDialog extends StatelessWidget { children: [ const SizedBox(height: 40), Text( - context.l10n.enterPinToDeleteAccount, - style: TextStyle(color: context.theme.red, fontSize: 18), + l10n.enterPinToDeleteAccount, + style: TextStyle(color: theme.red, fontSize: 18), ), const SizedBox(height: 29), PinInputLayout( doVerify: (pin) async { - await context.accountServer.client.accountApi.deactivate( + await accountServer.client.accountApi.deactivate( DeactivateRequest(encryptPin(pin)!, verificationId), ); Navigator.pop(context, true); @@ -260,7 +280,7 @@ class _DeleteAccountPinDialog extends StatelessWidget { textSpan, style: TextStyle( fontSize: 16, - color: context.theme.text, + color: theme.text, height: 1.5, ), textAlign: TextAlign.center, diff --git a/lib/ui/setting/account_page.dart b/lib/ui/setting/account_page.dart index c0cde327a8..c7861bec6c 100644 --- a/lib/ui/setting/account_page.dart +++ b/lib/ui/setting/account_page.dart @@ -1,46 +1,50 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../utils/extension/extension.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/cell.dart'; import '../../widgets/user/change_number_dialog.dart'; import '../provider/responsive_navigator_provider.dart'; +import '../provider/ui_context_providers.dart'; class AccountPage extends HookConsumerWidget { const AccountPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) => Scaffold( - backgroundColor: context.theme.background, - appBar: MixinAppBar(title: Text(context.l10n.account)), - body: SingleChildScrollView( - child: Container( - alignment: Alignment.topCenter, - padding: const EdgeInsets.only(top: 40), - child: Column( - children: [ - CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, - child: CellItem( - title: Text(context.l10n.changeNumber), - onTap: () => showChangeNumberDialog(context), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return Scaffold( + backgroundColor: theme.background, + appBar: MixinAppBar(title: Text(l10n.account)), + body: SingleChildScrollView( + child: Container( + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(top: 40), + child: Column( + children: [ + CellGroup( + cellBackgroundColor: theme.settingCellBackgroundColor, + child: CellItem( + title: Text(l10n.changeNumber), + onTap: () => showChangeNumberDialog(context, ref), + ), ), - ), - CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, - child: CellItem( - title: Text(context.l10n.deleteMyAccount), - onTap: () => ref - .read(responsiveNavigatorProvider.notifier) - .pushPage( - ResponsiveNavigatorStateNotifier.accountDeletePage, - ), + CellGroup( + cellBackgroundColor: theme.settingCellBackgroundColor, + child: CellItem( + title: Text(l10n.deleteMyAccount), + onTap: () => ref + .read(responsiveNavigatorProvider.notifier) + .pushPage( + ResponsiveNavigatorStateNotifier.accountDeletePage, + ), + ), ), - ), - ], + ], + ), ), ), - ), - ); + ); + } } diff --git a/lib/ui/setting/appearance_page.dart b/lib/ui/setting/appearance_page.dart index 670adb75ad..2b19e85ebf 100644 --- a/lib/ui/setting/appearance_page.dart +++ b/lib/ui/setting/appearance_page.dart @@ -1,101 +1,105 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:intl/intl.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; -import 'package:provider/provider.dart'; import 'package:uuid/uuid.dart'; import '../../constants/resources.dart'; import '../../db/mixin_database.dart'; -import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/cell.dart'; import '../../widgets/message/item/text/text_message.dart'; import '../../widgets/message/message.dart'; -import '../../widgets/message/message_day_time.dart'; import '../../widgets/radio.dart'; -import '../home/bloc/blink_cubit.dart'; -import '../home/chat/chat_page.dart'; +import '../home/controllers/blink_controller.dart'; import '../provider/setting_provider.dart'; +import '../provider/ui_context_providers.dart'; -class AppearancePage extends StatelessWidget { +class AppearancePage extends ConsumerWidget { const AppearancePage({super.key}); @override - Widget build(BuildContext context) => Scaffold( - backgroundColor: context.theme.background, - appBar: MixinAppBar(title: Text(context.l10n.appearance)), - body: const Align(alignment: Alignment.topCenter, child: _Body()), - ); + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + return Scaffold( + backgroundColor: theme.background, + appBar: MixinAppBar(title: Text(l10n.appearance)), + body: const Align(alignment: Alignment.topCenter, child: _Body()), + ); + } } class _Body extends HookConsumerWidget { const _Body(); @override - Widget build(BuildContext context, WidgetRef ref) => SingleChildScrollView( - child: Container( - padding: const EdgeInsets.only(top: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Padding( - padding: const EdgeInsets.only(left: 10, bottom: 14), - child: Text( - context.l10n.theme, - style: TextStyle( - color: context.theme.secondaryText, - fontSize: 14, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + return SingleChildScrollView( + child: Container( + padding: const EdgeInsets.only(top: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 10, bottom: 14), + child: Text( + l10n.theme, + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), ), ), - ), - CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CellItem( - title: RadioItem( - title: Text(context.l10n.followSystem), - groupValue: ref.watch(settingProvider).brightness, - onChanged: (value) => - context.settingChangeNotifier.brightness = value, - value: null, + CellGroup( + cellBackgroundColor: theme.settingCellBackgroundColor, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CellItem( + title: RadioItem( + title: Text(l10n.followSystem), + groupValue: ref.watch(settingProvider).brightness, + onChanged: (value) => + ref.read(settingProvider.notifier).brightness = value, + value: null, + ), + trailing: null, ), - trailing: null, - ), - CellItem( - title: RadioItem( - title: Text(context.l10n.light), - groupValue: ref.watch(settingProvider).brightness, - onChanged: (value) => - context.settingChangeNotifier.brightness = value, - value: Brightness.light, + CellItem( + title: RadioItem( + title: Text(l10n.light), + groupValue: ref.watch(settingProvider).brightness, + onChanged: (value) => + ref.read(settingProvider.notifier).brightness = value, + value: Brightness.light, + ), + trailing: null, ), - trailing: null, - ), - CellItem( - title: RadioItem( - title: Text(context.l10n.dark), - groupValue: ref.watch(settingProvider).brightness, - onChanged: (value) => - context.settingChangeNotifier.brightness = value, - value: Brightness.dark, + CellItem( + title: RadioItem( + title: Text(l10n.dark), + groupValue: ref.watch(settingProvider).brightness, + onChanged: (value) => + ref.read(settingProvider.notifier).brightness = value, + value: Brightness.dark, + ), + trailing: null, ), - trailing: null, - ), - ], + ], + ), ), - ), - const _MessageAvatarSetting(), - const _ChatTextSizeSetting(), - ], + const _MessageAvatarSetting(), + const _ChatTextSizeSetting(), + ], + ), ), - ), - ); + ); + } } class _MessageAvatarSetting extends HookConsumerWidget { @@ -103,6 +107,8 @@ class _MessageAvatarSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final showAvatar = ref.watch( settingProvider.select((value) => value.messageShowAvatar), ); @@ -116,36 +122,40 @@ class _MessageAvatarSetting extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(left: 10, bottom: 14, top: 22), child: Text( - context.l10n.chats, - style: TextStyle(color: context.theme.secondaryText, fontSize: 14), + l10n.chats, + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), ), ), CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: Column( children: [ CellItem( - title: Text(context.l10n.showAvatar), + title: Text(l10n.showAvatar), trailing: Transform.scale( scale: 0.7, child: CupertinoSwitch( - activeTrackColor: context.theme.accent, + activeTrackColor: theme.accent, value: showAvatar, onChanged: (value) => - context.settingChangeNotifier.messageShowAvatar = value, + ref.read(settingProvider.notifier).messageShowAvatar = + value, ), ), ), CellItem( - title: Text(context.l10n.showIdentityNumber), + title: Text(l10n.showIdentityNumber), trailing: Transform.scale( scale: 0.7, child: CupertinoSwitch( - activeTrackColor: context.theme.accent, + activeTrackColor: theme.accent, value: showIdentityNumber, onChanged: (value) => - context - .settingChangeNotifier + ref + .read(settingProvider.notifier) .messageShowIdentityNumber = value, ), @@ -164,6 +174,8 @@ class _ChatTextSizeSetting extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final fontSize = ref.watch( settingProvider.select((value) => value.chatFontSizeDelta), ); @@ -177,9 +189,9 @@ class _ChatTextSizeSetting extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(left: 10, bottom: 14, top: 22), child: Text( - context.l10n.chatTextSize, + l10n.chatTextSize, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), @@ -191,7 +203,10 @@ class _ChatTextSizeSetting extends HookConsumerWidget { const SizedBox(width: 10), Text( 'A', - style: TextStyle(fontSize: 12, color: context.theme.text), + style: TextStyle( + fontSize: 12, + color: theme.text, + ), ), const SizedBox(width: 10), Expanded( @@ -208,7 +223,8 @@ class _ChatTextSizeSetting extends HookConsumerWidget { max: 4, onChanged: (value) { debugPrint('fontSize: $value'); - context.settingChangeNotifier.chatFontSizeDelta = value; + ref.read(settingProvider.notifier).chatFontSizeDelta = + value; }, ), ), @@ -216,7 +232,10 @@ class _ChatTextSizeSetting extends HookConsumerWidget { const SizedBox(width: 10), Text( 'A', - style: TextStyle(fontSize: 24, color: context.theme.text), + style: TextStyle( + fontSize: 24, + color: theme.text, + ), ), const SizedBox(width: 10), ], @@ -250,31 +269,20 @@ class _ChatTextSizePreview extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final tickerProvider = useSingleTickerProvider(); - final blinkCubit = useMemoized( - () => BlinkCubit( - tickerProvider, - context.theme.accent.withValues(alpha: 0.5), - ), - ); - final chatSideCubit = useBloc(ChatSideCubit.new); - final searchConversationKeywordCubit = useBloc( - () => SearchConversationKeywordCubit(chatSideCubit: chatSideCubit), - ); - + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final brightnessValue = ref.watch(brightnessValueProvider); final messageHi = useMemoized( - () => _buildFakeTextMessage(context.l10n.sayHi), + () => _buildFakeTextMessage(l10n.sayHi), + [l10n], ); final messageAnswer = useMemoized( - () => _buildFakeTextMessage(context.l10n.iAmGood), + () => _buildFakeTextMessage(l10n.iAmGood), + [l10n], ); - return MultiProvider( - providers: [ - BlocProvider.value(value: searchConversationKeywordCubit), - Provider.value(value: blinkCubit), - BlocProvider(create: (_) => HiddenMessageDayTimeBloc()), - ], + return TickerMode( + enabled: ModalRoute.of(context)?.isCurrent ?? true, child: IgnorePointer( child: Container( margin: const EdgeInsets.symmetric(horizontal: 10), @@ -285,7 +293,7 @@ class _ChatTextSizePreview extends HookConsumerWidget { bottom: 20, ), decoration: BoxDecoration( - color: context.theme.chatBackground, + color: theme.chatBackground, borderRadius: const BorderRadius.all(Radius.circular(8)), image: DecorationImage( image: const ExactAssetImage( @@ -293,7 +301,7 @@ class _ChatTextSizePreview extends HookConsumerWidget { ), fit: BoxFit.none, colorFilter: ColorFilter.mode( - context.brightnessValue == 1.0 + brightnessValue == 1.0 ? Colors.white.withValues(alpha: 0.02) : Colors.black.withValues(alpha: 0.03), BlendMode.srcIn, @@ -303,7 +311,7 @@ class _ChatTextSizePreview extends HookConsumerWidget { child: Column( mainAxisSize: MainAxisSize.min, children: [ - MessageDayTime(dateTime: DateTime(2023)), + _PreviewMessageDayTime(dateTime: DateTime(2023)), MessageContext( message: messageHi, isTranscriptPage: false, @@ -327,3 +335,37 @@ class _ChatTextSizePreview extends HookConsumerWidget { ); } } + +class _PreviewMessageDayTime extends ConsumerWidget { + const _PreviewMessageDayTime({required this.dateTime}); + + final DateTime dateTime; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Center( + child: Padding( + padding: const EdgeInsets.only(top: 16, bottom: 10), + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(10)), + color: theme.dateTime, + ), + padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 10), + child: ConstrainedBox( + constraints: const BoxConstraints(minWidth: 64), + child: Text( + DateFormat.yMMMd().format(dateTime.toLocal()), + textAlign: TextAlign.center, + style: const TextStyle( + fontSize: 12, + color: Colors.black, + ), + ), + ), + ), + ), + ); + } +} diff --git a/lib/ui/setting/backup_page.dart b/lib/ui/setting/backup_page.dart index 9cd99404aa..d2647831e7 100644 --- a/lib/ui/setting/backup_page.dart +++ b/lib/ui/setting/backup_page.dart @@ -4,90 +4,91 @@ import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../constants/resources.dart'; -import '../../utils/extension/extension.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/cell.dart'; +import '../provider/ui_context_providers.dart'; class BackupPage extends HookConsumerWidget { const BackupPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) => Scaffold( - backgroundColor: context.theme.background, - appBar: MixinAppBar(title: Text(context.l10n.chatBackup)), - body: Container( - alignment: Alignment.topCenter, - padding: const EdgeInsets.only(top: 40), - child: Column( - children: [ - SvgPicture.asset( - Resources.assetsImagesChatBackupSvg, - width: 88, - height: 58, - colorFilter: ColorFilter.mode( - context.theme.secondaryText.withValues(alpha: 0.4), - BlendMode.srcIn, + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return Scaffold( + backgroundColor: theme.background, + appBar: MixinAppBar(title: Text(l10n.chatBackup)), + body: Container( + alignment: Alignment.topCenter, + padding: const EdgeInsets.only(top: 40), + child: Column( + children: [ + SvgPicture.asset( + Resources.assetsImagesChatBackupSvg, + width: 88, + height: 58, + colorFilter: ColorFilter.mode( + theme.secondaryText.withValues(alpha: 0.4), + BlendMode.srcIn, + ), ), - ), - const SizedBox(height: 20), - SizedBox( - width: 500, - child: Text( - context.l10n.settingBackupTips, - textAlign: TextAlign.center, - style: TextStyle( - fontSize: 16, - color: context.theme.secondaryText, + const SizedBox(height: 20), + SizedBox( + width: 500, + child: Text( + l10n.settingBackupTips, + textAlign: TextAlign.center, + style: TextStyle(fontSize: 16, color: theme.secondaryText), ), ), - ), - const SizedBox(height: 30), - CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, - child: CellItem(title: Text(context.l10n.backup)), - ), - CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, - child: Column( - children: [ - CellItem( - title: Text(context.l10n.autoBackup), - trailing: Transform.scale( - scale: 0.7, - child: CupertinoSwitch( - activeTrackColor: context.theme.accent, - value: true, - onChanged: (value) {}, + const SizedBox(height: 30), + CellGroup( + cellBackgroundColor: theme.settingCellBackgroundColor, + child: CellItem(title: Text(l10n.backup)), + ), + CellGroup( + cellBackgroundColor: theme.settingCellBackgroundColor, + child: Column( + children: [ + CellItem( + title: Text(l10n.autoBackup), + trailing: Transform.scale( + scale: 0.7, + child: CupertinoSwitch( + activeTrackColor: theme.accent, + value: true, + onChanged: (value) {}, + ), ), ), - ), - CellItem( - title: Text(context.l10n.includeFiles), - trailing: Transform.scale( - scale: 0.7, - child: CupertinoSwitch( - activeTrackColor: context.theme.accent, - value: true, - onChanged: (value) {}, + CellItem( + title: Text(l10n.includeFiles), + trailing: Transform.scale( + scale: 0.7, + child: CupertinoSwitch( + activeTrackColor: theme.accent, + value: true, + onChanged: (value) {}, + ), ), ), - ), - CellItem( - title: Text(context.l10n.includeVideos), - trailing: Transform.scale( - scale: 0.7, - child: CupertinoSwitch( - activeTrackColor: context.theme.accent, - value: true, - onChanged: (value) {}, + CellItem( + title: Text(l10n.includeVideos), + trailing: Transform.scale( + scale: 0.7, + child: CupertinoSwitch( + activeTrackColor: theme.accent, + value: true, + onChanged: (value) {}, + ), ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), - ), - ); + ); + } } diff --git a/lib/ui/setting/edit_profile_page.dart b/lib/ui/setting/edit_profile_page.dart index 36c654f1ce..b73fdabcb4 100644 --- a/lib/ui/setting/edit_profile_page.dart +++ b/lib/ui/setting/edit_profile_page.dart @@ -5,19 +5,23 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:intl/intl.dart'; -import '../../utils/extension/extension.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/avatar_view/avatar_view.dart'; import '../../widgets/dialog.dart'; import '../../widgets/high_light_text.dart'; import '../../widgets/toast.dart'; +import '../provider/account_server_provider.dart'; import '../provider/multi_auth_provider.dart'; +import '../provider/ui_context_providers.dart'; class EditProfilePage extends HookConsumerWidget { const EditProfilePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; final (fullName, biography, identityNumber, phone, createdAt) = ref.watch( authAccountProvider.select( (value) => ( @@ -34,7 +38,8 @@ class EditProfilePage extends HookConsumerWidget { final bioTextEditingController = useTextEditingController(text: biography); useEffect(() { - context.accountServer.refreshSelf(); + accountServer.refreshSelf(); + return null; }, []); ref.listen(authAccountProvider, (previous, next) { @@ -44,21 +49,21 @@ class EditProfilePage extends HookConsumerWidget { }); return Scaffold( - backgroundColor: context.theme.background, + backgroundColor: theme.background, appBar: MixinAppBar( - title: Text(context.l10n.editProfile), + title: Text(l10n.editProfile), actions: [ MixinButton( onTap: () { runFutureWithToast( - context.accountServer.updateAccount( + accountServer.updateAccount( fullName: nameTextEditingController.text.trim(), biography: bioTextEditingController.text.trim(), ), ); }, backgroundTransparent: true, - child: Center(child: Text(context.l10n.save)), + child: Center(child: Text(l10n.save)), ), ], ), @@ -68,7 +73,7 @@ class EditProfilePage extends HookConsumerWidget { const SizedBox(height: 40), Builder( builder: (context) { - final account = context.account!; + final account = ref.watch(authAccountProvider)!; return AvatarWidget( userId: account.userId, name: account.fullName, @@ -82,40 +87,42 @@ class EditProfilePage extends HookConsumerWidget { 'Mixin ID: $identityNumber', style: TextStyle( fontSize: 14, - color: context.dynamicColor( - const Color.fromRGBO(188, 190, 195, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.4), + color: ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(188, 190, 195, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.4), + )), ), ), ), const SizedBox(height: 32), _Item( - title: context.l10n.name, + title: l10n.name, controller: nameTextEditingController, maxLength: 40, ), const SizedBox(height: 32), _Item( - title: context.l10n.biography, + title: l10n.biography, controller: bioTextEditingController, maxLength: 140, ), const SizedBox(height: 32), _Item( - title: context.l10n.phoneNumber, + title: l10n.phoneNumber, controller: TextEditingController(text: phone), readOnly: true, ), const SizedBox(height: 70), Text( createdAt != null - ? context.l10n.joinedIn( + ? l10n.joinedIn( DateFormat.yMMMd().format(createdAt.toLocal()), ) : '', style: TextStyle( fontSize: 14, - color: context.theme.secondaryText, + color: theme.secondaryText, ), ), const SizedBox(height: 48), @@ -126,7 +133,7 @@ class EditProfilePage extends HookConsumerWidget { } } -class _Item extends StatelessWidget { +class _Item extends ConsumerWidget { const _Item({ required this.title, required this.controller, @@ -140,21 +147,26 @@ class _Item extends StatelessWidget { final int? maxLength; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { const outlineInputBorder = OutlineInputBorder( borderSide: BorderSide(color: Colors.transparent), borderRadius: BorderRadius.all(Radius.circular(8)), gapPadding: 0, ); + final theme = ref.watch(brightnessThemeDataProvider); final backgroundColor = readOnly - ? context.dynamicColor( - const Color.fromRGBO(236, 238, 242, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.04), + ? ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(236, 238, 242, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.04), + )), ) - : context.dynamicColor( - const Color.fromRGBO(255, 255, 255, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + : ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(255, 255, 255, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + )), ); return _DynamicHorizontalPadding( @@ -167,7 +179,7 @@ class _Item extends StatelessWidget { style: TextStyle( fontSize: 14, fontWeight: FontWeight.w500, - color: context.theme.secondaryText, + color: theme.secondaryText, ), ), const SizedBox(height: 16), @@ -176,9 +188,7 @@ class _Item extends StatelessWidget { controller: controller, style: TextStyle( fontSize: 16, - color: readOnly - ? context.theme.secondaryText - : context.theme.text, + color: readOnly ? theme.secondaryText : theme.text, ), minLines: 1, maxLines: 10, @@ -198,7 +208,7 @@ class _Item extends StatelessWidget { ), counterStyle: TextStyle( fontSize: 14, - color: context.theme.secondaryText, + color: theme.secondaryText, ), ), contextMenuBuilder: (context, state) => diff --git a/lib/ui/setting/log_page.dart b/lib/ui/setting/log_page.dart index 5c30ec997a..64890315e7 100644 --- a/lib/ui/setting/log_page.dart +++ b/lib/ui/setting/log_page.dart @@ -2,9 +2,11 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_logger/mixin_logger.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../../utils/file.dart'; import '../../widgets/action_button.dart'; @@ -16,7 +18,7 @@ Future showLogPage(BuildContext context) => showGeneralDialog( context: context, barrierColor: Colors.transparent, barrierDismissible: true, - barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierLabel: Localization.current.close, pageBuilder: ( buildContext, @@ -28,21 +30,22 @@ Future showLogPage(BuildContext context) => showGeneralDialog( ).wrap(const _LogPage()), ); -class _LogPage extends HookWidget { +class _LogPage extends HookConsumerWidget { const _LogPage(); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); useListenable(_logChangeNotifier); return Material( - color: context.theme.background, + color: theme.background, child: Column( children: [ MixinAppBar( leading: const SizedBox(), actions: [ ActionButton( - color: context.theme.icon, + color: theme.icon, onTap: () { scheduleMicrotask(() { i( diff --git a/lib/ui/setting/notification_page.dart b/lib/ui/setting/notification_page.dart index c87059bcd4..6822753272 100644 --- a/lib/ui/setting/notification_page.dart +++ b/lib/ui/setting/notification_page.dart @@ -6,7 +6,6 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../utils/app_lifecycle.dart'; -import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../../utils/local_notification_center.dart'; import '../../utils/uri_utils.dart'; @@ -14,12 +13,15 @@ import '../../widgets/animated_visibility.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/cell.dart'; import '../provider/setting_provider.dart'; +import '../provider/ui_context_providers.dart'; class NotificationPage extends HookConsumerWidget { const NotificationPage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final currentMessagePreview = ref.watch( settingProvider.select((value) => value.messagePreview), ); @@ -32,8 +34,8 @@ class NotificationPage extends HookConsumerWidget { ).data; return Scaffold( - backgroundColor: context.theme.background, - appBar: MixinAppBar(title: Text(context.l10n.notifications)), + backgroundColor: theme.background, + appBar: MixinAppBar(title: Text(l10n.notifications)), body: Container( alignment: Alignment.topCenter, padding: const EdgeInsets.only(top: 40), @@ -42,16 +44,17 @@ class NotificationPage extends HookConsumerWidget { children: [ CellGroup( padding: const EdgeInsets.only(right: 10, left: 10), - cellBackgroundColor: context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: CellItem( - title: Text(context.l10n.messagePreview), + title: Text(l10n.messagePreview), trailing: Transform.scale( scale: 0.7, child: CupertinoSwitch( - activeTrackColor: context.theme.accent, + activeTrackColor: theme.accent, value: currentMessagePreview, onChanged: (value) => - context.settingChangeNotifier.messagePreview = value, + ref.read(settingProvider.notifier).messagePreview = + value, ), ), ), @@ -59,9 +62,9 @@ class NotificationPage extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(left: 20, bottom: 14, top: 10), child: Text( - context.l10n.messagePreviewDescription, + l10n.messagePreviewDescription, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), @@ -75,13 +78,13 @@ class NotificationPage extends HookConsumerWidget { children: [ CellGroup( padding: const EdgeInsets.only(right: 10, left: 10), - cellBackgroundColor: - context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: CellItem( - title: Text(context.l10n.enablePushNotification), + title: Text(l10n.enablePushNotification), onTap: () => openUri( context, 'x-apple.systempreferences:com.apple.preference.notifications', + container: ref.container, ), ), ), @@ -92,9 +95,9 @@ class NotificationPage extends HookConsumerWidget { top: 10, ), child: Text( - context.l10n.notificationContent, + l10n.notificationContent, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), @@ -108,9 +111,9 @@ class NotificationPage extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.only(left: 20, bottom: 14), child: Text( - '${context.l10n.notificationPermissionManually}${context.l10n.notificationContent}', + '${l10n.notificationPermissionManually}${l10n.notificationContent}', style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), diff --git a/lib/ui/setting/proxy_page.dart b/lib/ui/setting/proxy_page.dart index 429dc38247..f0eabb1b19 100644 --- a/lib/ui/setting/proxy_page.dart +++ b/lib/ui/setting/proxy_page.dart @@ -17,21 +17,29 @@ import '../../widgets/app_bar.dart'; import '../../widgets/cell.dart'; import '../../widgets/dialog.dart'; import '../../widgets/high_light_text.dart'; +import '../provider/database_provider.dart'; +import '../provider/ui_context_providers.dart'; -class ProxyPage extends StatelessWidget { +class ProxyPage extends ConsumerWidget { const ProxyPage({super.key}); @override - Widget build(BuildContext context) => Scaffold( - backgroundColor: context.theme.background, - appBar: MixinAppBar(title: Text(context.l10n.proxy)), - body: ConstrainedBox( - constraints: const BoxConstraints.expand(), - child: const SingleChildScrollView( - child: Column(children: [SizedBox(height: 40), _ProxySettingWidget()]), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return Scaffold( + backgroundColor: theme.background, + appBar: MixinAppBar(title: Text(l10n.proxy)), + body: ConstrainedBox( + constraints: const BoxConstraints.expand(), + child: const SingleChildScrollView( + child: Column( + children: [SizedBox(height: 40), _ProxySettingWidget()], + ), + ), ), - ), - ); + ); + } } class _ProxySettingWidget extends HookConsumerWidget { @@ -39,15 +47,21 @@ class _ProxySettingWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final settingProperties = ref + .read(databaseProvider) + .requireValue + .settingProperties; final enableProxy = useListenableConverter( - context.database.settingProperties, + settingProperties, converter: (settingProperties) => settingProperties.enableProxy, ).data ?? false; final hasProxyConfig = useListenableConverter( - context.database.settingProperties, + settingProperties, converter: (settingProperties) => settingProperties.proxyList.isNotEmpty, ).data ?? @@ -56,30 +70,28 @@ class _ProxySettingWidget extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: CellItem( - title: Text(context.l10n.proxy), + title: Text(l10n.proxy), trailing: Transform.scale( scale: 0.7, child: CupertinoSwitch( - activeTrackColor: context.theme.accent, + activeTrackColor: theme.accent, value: hasProxyConfig && enableProxy, onChanged: !hasProxyConfig ? null - : (value) => - context.database.settingProperties.enableProxy = - value, + : (value) => settingProperties.enableProxy = value, ), ), ), ), CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: Column( children: [ CellItem( - title: Text(context.l10n.addProxy), - leading: Icon(Icons.add, color: context.theme.icon), + title: Text(l10n.addProxy), + leading: Icon(Icons.add, color: theme.icon), trailing: null, onTap: () { showMixinDialog( @@ -88,7 +100,11 @@ class _ProxySettingWidget extends HookConsumerWidget { ); }, ), - Divider(height: 0.5, indent: 56, color: context.theme.divider), + Divider( + height: 0.5, + indent: 56, + color: theme.divider, + ), const _ProxyItemList(), ], ), @@ -103,15 +119,19 @@ class _ProxyItemList extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final settingProperties = ref + .read(databaseProvider) + .requireValue + .settingProperties; final proxyList = useListenableConverter( - context.database.settingProperties, + settingProperties, converter: (settingProperties) => settingProperties.proxyList, ).data ?? const []; final selectedProxyId = useListenableConverter( - context.database.settingProperties, + settingProperties, converter: (settingProperties) => settingProperties.selectedProxyId, ).data ?? proxyList.firstOrNull?.id; @@ -128,7 +148,7 @@ class _ProxyItemList extends HookConsumerWidget { } } -class _ProxyItemWidget extends StatelessWidget { +class _ProxyItemWidget extends ConsumerWidget { const _ProxyItemWidget({required this.proxy, required this.selected}); final ProxyConfig proxy; @@ -136,44 +156,55 @@ class _ProxyItemWidget extends StatelessWidget { final bool selected; @override - Widget build(BuildContext context) => Material( - color: context.theme.settingCellBackgroundColor, - child: ListTile( - leading: SizedBox( - height: double.infinity, - width: 20, - child: selected - ? Icon(Icons.check, color: context.theme.icon, size: 20) - : null, - ), - minLeadingWidth: 0, - title: Text( - '${proxy.host}:${proxy.port}', - style: TextStyle(fontSize: 16, color: context.theme.text), - ), - subtitle: Text( - proxy.type.name, - style: TextStyle(fontSize: 14, color: context.theme.secondaryText), - ), - trailing: ActionButton( - name: Resources.assetsImagesDeleteSvg, - color: context.theme.icon, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final settingProperties = ref + .read(databaseProvider) + .requireValue + .settingProperties; + return Material( + color: theme.settingCellBackgroundColor, + child: ListTile( + leading: SizedBox( + height: double.infinity, + width: 20, + child: selected + ? Icon( + Icons.check, + color: theme.icon, + size: 20, + ) + : null, + ), + minLeadingWidth: 0, + title: Text( + '${proxy.host}:${proxy.port}', + style: TextStyle(fontSize: 16, color: theme.text), + ), + subtitle: Text( + proxy.type.name, + style: TextStyle(fontSize: 14, color: theme.secondaryText), + ), + trailing: ActionButton( + name: Resources.assetsImagesDeleteSvg, + color: theme.icon, + onTap: () { + settingProperties.removeProxy(proxy.id); + if (selected) { + settingProperties.selectedProxyId = null; + settingProperties.enableProxy = false; + } + }, + ), onTap: () { - context.database.settingProperties.removeProxy(proxy.id); if (selected) { - context.database.settingProperties.selectedProxyId = null; - context.database.settingProperties.enableProxy = false; + return; } + settingProperties.selectedProxyId = proxy.id; }, ), - onTap: () { - if (selected) { - return; - } - context.database.settingProperties.selectedProxyId = proxy.id; - }, - ), - ); + ); + } } class _ProxyAddDialog extends HookConsumerWidget { @@ -181,27 +212,33 @@ class _ProxyAddDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final settingProperties = ref + .read(databaseProvider) + .requireValue + .settingProperties; final proxyType = useState(ProxyType.http); final proxyHostController = useTextEditingController(); final proxyPortController = useTextEditingController(); final proxyUsernameController = useTextEditingController(); final proxyPasswordController = useTextEditingController(); return AlertDialogLayout( - title: Text(context.l10n.addProxy), + title: Text(l10n.addProxy), titleMarginBottom: 24, content: DefaultTextStyle.merge( style: TextStyle( fontWeight: FontWeight.normal, fontSize: 14, - color: context.theme.text, + color: theme.text, ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( - context.l10n.proxyType, + l10n.proxyType, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), @@ -209,9 +246,9 @@ class _ProxyAddDialog extends HookConsumerWidget { _ProxyTypeWidget(proxyType: proxyType), const SizedBox(height: 16), Text( - context.l10n.proxyConnection, + l10n.proxyConnection, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), @@ -219,14 +256,14 @@ class _ProxyAddDialog extends HookConsumerWidget { _ProxyInputWidget( firstController: proxyHostController, secondController: proxyPortController, - firstHintText: context.l10n.host, - secondHintText: context.l10n.port, + firstHintText: l10n.host, + secondHintText: l10n.port, ), const SizedBox(height: 16), Text( - context.l10n.proxyAuth, + l10n.proxyAuth, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), @@ -234,8 +271,8 @@ class _ProxyAddDialog extends HookConsumerWidget { _ProxyInputWidget( firstController: proxyUsernameController, secondController: proxyPasswordController, - firstHintText: context.l10n.username, - secondHintText: context.l10n.password, + firstHintText: l10n.username, + secondHintText: l10n.password, ), ], ), @@ -244,7 +281,7 @@ class _ProxyAddDialog extends HookConsumerWidget { MixinButton( backgroundTransparent: true, onTap: () => Navigator.pop(context), - child: Text(context.l10n.cancel), + child: Text(l10n.cancel), ), MixinButton( onTap: () { @@ -262,47 +299,50 @@ class _ProxyAddDialog extends HookConsumerWidget { id: id, ); i('add proxy config: ${config.type} ${config.host}:${config.port}'); - context.database.settingProperties.addProxy(config); + settingProperties.addProxy(config); Navigator.pop(context); }, - child: Text(context.l10n.add), + child: Text(l10n.add), ), ], ); } } -class _ProxyTypeWidget extends StatelessWidget { +class _ProxyTypeWidget extends ConsumerWidget { const _ProxyTypeWidget({required this.proxyType}); final ValueNotifier proxyType; @override - Widget build(BuildContext context) => Material( - color: context.theme.listSelected, - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: ListTileTheme( - data: ListTileThemeData(dense: true, textColor: context.theme.text), - child: Column( - children: [ - ListTile( - title: const Text('HTTP'), - trailing: proxyType.value == ProxyType.http - ? SvgPicture.asset( - Resources.assetsImagesCheckedSvg, - width: 24, - height: 24, - ) - : null, - onTap: () => proxyType.value = ProxyType.http, - ), - ], + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Material( + color: theme.listSelected, + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: ListTileTheme( + data: ListTileThemeData(dense: true, textColor: theme.text), + child: Column( + children: [ + ListTile( + title: const Text('HTTP'), + trailing: proxyType.value == ProxyType.http + ? SvgPicture.asset( + Resources.assetsImagesCheckedSvg, + width: 24, + height: 24, + ) + : null, + onTap: () => proxyType.value = ProxyType.http, + ), + ], + ), ), - ), - ); + ); + } } -class _ProxyInputWidget extends StatelessWidget { +class _ProxyInputWidget extends ConsumerWidget { const _ProxyInputWidget({ required this.firstController, required this.secondController, @@ -317,64 +357,61 @@ class _ProxyInputWidget extends StatelessWidget { final String secondHintText; @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextField( - controller: firstController, - style: TextStyle(fontSize: 14, color: context.theme.text), - inputFormatters: [ - LengthLimitingTextInputFormatter(kDefaultTextInputLimit), - ], - decoration: InputDecoration( - isDense: true, - hintText: firstHintText, - hintStyle: TextStyle( - color: context.theme.secondaryText, - fontSize: 14, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 18, - ), - border: const OutlineInputBorder( - borderRadius: BorderRadius.vertical(top: Radius.circular(8)), - borderSide: BorderSide.none, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + TextField( + controller: firstController, + style: TextStyle(fontSize: 14, color: theme.text), + inputFormatters: [ + LengthLimitingTextInputFormatter(kDefaultTextInputLimit), + ], + decoration: InputDecoration( + isDense: true, + hintText: firstHintText, + hintStyle: TextStyle(color: theme.secondaryText, fontSize: 14), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 18, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(8)), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: theme.listSelected, ), - filled: true, - fillColor: context.theme.listSelected, + contextMenuBuilder: (context, state) => + MixinAdaptiveSelectionToolbar(editableTextState: state), ), - contextMenuBuilder: (context, state) => - MixinAdaptiveSelectionToolbar(editableTextState: state), - ), - Divider(height: 1, color: context.theme.divider), - TextField( - controller: secondController, - style: TextStyle(fontSize: 14, color: context.theme.text), - inputFormatters: [ - LengthLimitingTextInputFormatter(kDefaultTextInputLimit), - ], - decoration: InputDecoration( - isDense: true, - hintText: secondHintText, - hintStyle: TextStyle( - color: context.theme.secondaryText, - fontSize: 14, - ), - contentPadding: const EdgeInsets.symmetric( - horizontal: 16, - vertical: 18, - ), - border: const OutlineInputBorder( - borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)), - borderSide: BorderSide.none, + Divider(height: 1, color: theme.divider), + TextField( + controller: secondController, + style: TextStyle(fontSize: 14, color: theme.text), + inputFormatters: [ + LengthLimitingTextInputFormatter(kDefaultTextInputLimit), + ], + decoration: InputDecoration( + isDense: true, + hintText: secondHintText, + hintStyle: TextStyle(color: theme.secondaryText, fontSize: 14), + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 18, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.vertical(bottom: Radius.circular(8)), + borderSide: BorderSide.none, + ), + filled: true, + fillColor: theme.listSelected, ), - filled: true, - fillColor: context.theme.listSelected, + contextMenuBuilder: (context, state) => + MixinAdaptiveSelectionToolbar(editableTextState: state), ), - contextMenuBuilder: (context, state) => - MixinAdaptiveSelectionToolbar(editableTextState: state), - ), - ], - ); + ], + ); + } } diff --git a/lib/ui/setting/security_page.dart b/lib/ui/setting/security_page.dart index 55dd4dbdaa..4fd89b0005 100644 --- a/lib/ui/setting/security_page.dart +++ b/lib/ui/setting/security_page.dart @@ -7,28 +7,32 @@ import 'package:pin_code_fields/pin_code_fields.dart'; import '../../account/security_key_value.dart'; import '../../utils/authentication.dart'; -import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/buttons.dart'; import '../../widgets/cell.dart'; import '../../widgets/dialog.dart'; import '../../widgets/toast.dart'; +import '../provider/ui_context_providers.dart'; -class SecurityPage extends StatelessWidget { +class SecurityPage extends ConsumerWidget { const SecurityPage({super.key}); @override - Widget build(BuildContext context) => Scaffold( - backgroundColor: context.theme.background, - appBar: MixinAppBar(title: Text(context.l10n.security)), - body: ConstrainedBox( - constraints: const BoxConstraints.expand(), - child: const SingleChildScrollView( - child: Column(children: [SizedBox(height: 40), _Passcode()]), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return Scaffold( + backgroundColor: theme.background, + appBar: MixinAppBar(title: Text(l10n.security)), + body: ConstrainedBox( + constraints: const BoxConstraints.expand(), + child: const SingleChildScrollView( + child: Column(children: [SizedBox(height: 40), _Passcode()]), + ), ), - ), - ); + ); + } } class _Passcode extends HookConsumerWidget { @@ -36,6 +40,8 @@ class _Passcode extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final globalKey = useMemoized( GlobalKey>.new, [], @@ -59,16 +65,16 @@ class _Passcode extends HookConsumerWidget { mainAxisSize: MainAxisSize.min, children: [ CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: Column( mainAxisSize: MainAxisSize.min, children: [ CellItem( - title: Text(context.l10n.screenPasscode), + title: Text(l10n.screenPasscode), trailing: Transform.scale( scale: 0.7, child: CupertinoSwitch( - activeTrackColor: context.theme.accent, + activeTrackColor: theme.accent, value: hasPasscode, onChanged: (value) { if (!value) { @@ -88,30 +94,30 @@ class _Passcode extends HookConsumerWidget { description: PopupMenuButton( key: globalKey, color: Color.alphaBlend( - context.theme.listSelected, - context.theme.background, + theme.listSelected, + theme.background, ), itemBuilder: (context) => [ PopupMenuItem( value: Duration.zero, - child: Text(context.l10n.disabled), + child: Text(l10n.disabled), ), PopupMenuItem( value: const Duration(minutes: 1), - child: Text(context.l10n.minute(1, 1)), + child: Text(l10n.minute(1, 1)), ), PopupMenuItem( value: const Duration(minutes: 5), - child: Text(context.l10n.minute(5, 5)), + child: Text(l10n.minute(5, 5)), ), PopupMenuItem( value: const Duration(hours: 1), - child: Text(context.l10n.hour(1, 1)), + child: Text(l10n.hour(1, 1)), ), PopupMenuItem( value: const Duration(hours: 5), - child: Text(context.l10n.hour(5, 5)), + child: Text(l10n.hour(5, 5)), ), ] .map( @@ -119,9 +125,7 @@ class _Passcode extends HookConsumerWidget { value: e.value, child: DefaultTextStyle.merge( child: e.child ?? const SizedBox(), - style: TextStyle( - color: context.theme.text, - ), + style: TextStyle(color: theme.text), ), ), ) @@ -130,13 +134,13 @@ class _Passcode extends HookConsumerWidget { SecurityKeyValue.instance.lockDuration = value, child: Text( (minutes == null || minutes == 0) - ? context.l10n.disabled + ? l10n.disabled : minutes < 60 - ? context.l10n.minute(minutes, minutes) - : context.l10n.hour(minutes ~/ 60, minutes ~/ 60), + ? l10n.minute(minutes, minutes) + : l10n.hour(minutes ~/ 60, minutes ~/ 60), ), ), - title: Text(context.l10n.autoLock), + title: Text(l10n.autoLock), onTap: () => globalKey.currentState?.showButtonMenu(), ), ], @@ -144,17 +148,17 @@ class _Passcode extends HookConsumerWidget { ), if (hasPasscode) CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: CellItem( - title: Text(context.l10n.biometric), + title: Text(l10n.biometric), trailing: Transform.scale( scale: 0.7, child: CupertinoSwitch( - activeTrackColor: context.theme.accent, + activeTrackColor: theme.accent, value: enableBiometric, onChanged: (value) async { if (!await checkAuthenticateAvailable()) { - showToastFailed(context.l10n.notSupportBiometric); + showToastFailed(l10n.notSupportBiometric); return; } @@ -174,6 +178,8 @@ class _InputPasscode extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final focusNode = useFocusNode(); useEffect(() { focusNode.requestFocus(); @@ -199,7 +205,7 @@ class _InputPasscode extends HookConsumerWidget { if (passcode.value != confirmPasscode.value) { WidgetsBinding.instance.addPostFrameCallback((_) { - showToastFailed(context.l10n.passcodeIncorrect); + showToastFailed(l10n.passcodeIncorrect); }); passcode.value = null; @@ -228,11 +234,11 @@ class _InputPasscode extends HookConsumerWidget { ), Text( passcode.value != null - ? context.l10n.confirmPasscodeDesc - : context.l10n.setPasscodeDesc, + ? l10n.confirmPasscodeDesc + : l10n.setPasscodeDesc, textAlign: TextAlign.center, style: TextStyle( - color: context.theme.text, + color: theme.text, fontSize: 18, fontWeight: FontWeight.w600, ), @@ -248,13 +254,13 @@ class _InputPasscode extends HookConsumerWidget { keyboardType: TextInputType.number, useHapticFeedback: true, pinTheme: PinTheme( - activeColor: context.theme.text, - inactiveColor: context.theme.text, - selectedColor: context.theme.text, + activeColor: theme.text, + inactiveColor: theme.text, + selectedColor: theme.text, fieldWidth: 15, borderWidth: 2, ), - textStyle: TextStyle(fontSize: 18, color: context.theme.text), + textStyle: TextStyle(fontSize: 18, color: theme.text), autoDisposeControllers: false, focusNode: focusNode, controller: textEditingController, diff --git a/lib/ui/setting/setting_page.dart b/lib/ui/setting/setting_page.dart index 3e66506620..33ae22467f 100644 --- a/lib/ui/setting/setting_page.dart +++ b/lib/ui/setting/setting_page.dart @@ -9,7 +9,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../account/account_key_value.dart'; import '../../constants/resources.dart'; import '../../utils/app_lifecycle.dart'; -import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../../utils/local_notification_center.dart'; import '../../widgets/action_button.dart'; @@ -19,22 +18,32 @@ import '../../widgets/cell.dart'; import '../../widgets/conversation/badges_widget.dart'; import '../../widgets/high_light_text.dart'; import '../../widgets/toast.dart'; -import '../home/home.dart'; +import '../provider/account_server_provider.dart'; import '../provider/multi_auth_provider.dart'; import '../provider/responsive_navigator_provider.dart'; +import '../provider/ui_context_providers.dart'; class SettingPage extends HookConsumerWidget { - const SettingPage({super.key}); + const SettingPage({ + required this.hasDrawer, + super.key, + }); + + final bool hasDrawer; @override Widget build(BuildContext context, WidgetRef ref) { - final hasDrawer = context.watch(); - + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); Widget? leading; - if (hasDrawer.value) { + if (hasDrawer) { leading = ActionButton( onTapUp: (event) => Scaffold.of(context).openDrawer(), - child: Icon(Icons.menu, size: 20, color: context.theme.icon), + child: Icon( + Icons.menu, + size: 20, + color: theme.icon, + ), ); } @@ -72,7 +81,7 @@ class SettingPage extends HookConsumerWidget { leadingAssetName: Resources.assetsImagesIcProfileSvg, pageName: ResponsiveNavigatorStateNotifier.editProfilePage, - title: context.l10n.editProfile, + title: l10n.editProfile, ), ), CellGroup( @@ -86,21 +95,21 @@ class SettingPage extends HookConsumerWidget { Resources.assetsImagesAccountSvg, pageName: ResponsiveNavigatorStateNotifier.accountPage, - title: context.l10n.account, + title: l10n.account, ), _Item( leadingAssetName: Resources.assetsImagesIcNotificationSvg, pageName: ResponsiveNavigatorStateNotifier .notificationPage, - title: context.l10n.notifications, + title: l10n.notifications, trailing: hasNotificationPermission == false ? Padding( padding: const EdgeInsets.all(4), child: SvgPicture.asset( Resources.assetsImagesTriangleWarningSvg, colorFilter: ColorFilter.mode( - context.theme.red, + theme.red, BlendMode.srcIn, ), width: 22, @@ -109,40 +118,40 @@ class SettingPage extends HookConsumerWidget { ) : const Arrow(), color: hasNotificationPermission == false - ? context.theme.red - : context.theme.text, + ? theme.red + : theme.text, ), _Item( leadingAssetName: Resources.assetsImagesIcStorageUsageSvg, pageName: ResponsiveNavigatorStateNotifier .dataAndStorageUsagePage, - title: context.l10n.dataAndStorageUsage, + title: l10n.dataAndStorageUsage, ), _Item( leadingAssetName: Resources.assetsImagesShieldSvg, pageName: ResponsiveNavigatorStateNotifier.securityPage, - title: context.l10n.security, + title: l10n.security, ), _Item( leadingAssetName: Resources.assetsImagesProxySvg, pageName: ResponsiveNavigatorStateNotifier.proxyPage, - title: context.l10n.proxy, + title: l10n.proxy, ), _Item( leadingAssetName: Resources.assetsImagesIcAppearanceSvg, pageName: ResponsiveNavigatorStateNotifier.appearancePage, - title: context.l10n.appearance, + title: l10n.appearance, ), _Item( leadingAssetName: Resources.assetsImagesIcAboutSvg, pageName: ResponsiveNavigatorStateNotifier.aboutPage, - title: context.l10n.about, + title: l10n.about, ), ], ), @@ -152,15 +161,18 @@ class SettingPage extends HookConsumerWidget { CellGroup( child: _Item( leadingAssetName: Resources.assetsImagesIcSignOutSvg, - title: context.l10n.signOut, + title: l10n.signOut, onTap: () async { + final accountServer = ref + .read(accountServerProvider) + .requireValue; final succeed = await runFutureWithToast( - context.accountServer.signOutAndClear(), + accountServer.signOutAndClear(), ); if (!succeed) return; - context.multiAuthChangeNotifier.signOut(); + ref.read(multiAuthNotifierProvider.notifier).signOut(); }, - color: context.theme.red, + color: theme.red, trailing: const SizedBox(), ), ), @@ -192,6 +204,7 @@ class _Item extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final selected = ref.watch( responsiveNavigatorProvider.select( (value) => @@ -207,17 +220,17 @@ class _Item extends HookConsumerWidget { width: 24, height: 24, colorFilter: ColorFilter.mode( - color ?? context.theme.text, + color ?? theme.text, BlendMode.srcIn, ), ) : null), title: AutoSizeText(title, maxLines: 1), - color: color ?? context.theme.text, + color: color ?? theme.text, selected: selected, onTap: () { if (onTap == null && pageName != null) { - context.providerContainer.read(responsiveNavigatorProvider.notifier) + ref.read(responsiveNavigatorProvider.notifier) ..popWhere( (page) => ResponsiveNavigatorStateNotifier.settingPageNameSet .contains(page.name), @@ -238,6 +251,7 @@ class _UserProfile extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final (fullName, identityNumber, membership, isVerified) = ref.watch( authAccountProvider.select( (value) => ( @@ -252,16 +266,15 @@ class _UserProfile extends HookConsumerWidget { return Column( mainAxisSize: MainAxisSize.min, children: [ - Builder( - builder: (context) { - final account = context.account!; - return AvatarWidget( - userId: account.userId, - name: account.fullName, - avatarUrl: account.avatarUrl, - size: 90, - ); - }, + AvatarWidget( + userId: ref.watch( + authAccountProvider.select((value) => value?.userId), + ), + name: fullName, + avatarUrl: ref.watch( + authAccountProvider.select((value) => value?.avatarUrl), + ), + size: 90, ), const SizedBox(height: 10), Padding( @@ -276,7 +289,7 @@ class _UserProfile extends HookConsumerWidget { style: TextStyle( fontWeight: FontWeight.w600, fontSize: 18, - color: context.theme.text, + color: theme.text, ), ), BadgesWidget( @@ -292,9 +305,11 @@ class _UserProfile extends HookConsumerWidget { 'Mixin ID: $identityNumber', style: TextStyle( fontSize: 14, - color: context.dynamicColor( - const Color.fromRGBO(188, 190, 195, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.4), + color: ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(188, 190, 195, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.4), + )), ), ), ), diff --git a/lib/ui/setting/storage_page.dart b/lib/ui/setting/storage_page.dart index 3006339476..91c00ee181 100644 --- a/lib/ui/setting/storage_page.dart +++ b/lib/ui/setting/storage_page.dart @@ -2,17 +2,19 @@ import 'package:flutter/cupertino.dart'; import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../utils/extension/extension.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/cell.dart'; import '../provider/responsive_navigator_provider.dart'; import '../provider/setting_provider.dart'; +import '../provider/ui_context_providers.dart'; class StoragePage extends HookConsumerWidget { const StoragePage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final (photoAutoDownload, videoAutoDownload, fileAutoDownload) = ref.watch( settingProvider.select( (value) => ( @@ -24,8 +26,8 @@ class StoragePage extends HookConsumerWidget { ); return Scaffold( - backgroundColor: context.theme.background, - appBar: MixinAppBar(title: Text(context.l10n.dataAndStorageUsage)), + backgroundColor: theme.background, + appBar: MixinAppBar(title: Text(l10n.dataAndStorageUsage)), body: SingleChildScrollView( child: Container( alignment: Alignment.topCenter, @@ -34,45 +36,51 @@ class StoragePage extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: Column( mainAxisSize: MainAxisSize.min, children: [ CellItem( - title: Text(context.l10n.photos), + title: Text(l10n.photos), trailing: Transform.scale( scale: 0.7, child: CupertinoSwitch( - activeTrackColor: context.theme.accent, + activeTrackColor: theme.accent, value: photoAutoDownload, onChanged: (value) => - context.settingChangeNotifier.photoAutoDownload = + ref + .read(settingProvider.notifier) + .photoAutoDownload = value, ), ), ), CellItem( - title: Text(context.l10n.videos), + title: Text(l10n.videos), trailing: Transform.scale( scale: 0.7, child: CupertinoSwitch( - activeTrackColor: context.theme.accent, + activeTrackColor: theme.accent, value: videoAutoDownload, onChanged: (value) => - context.settingChangeNotifier.videoAutoDownload = + ref + .read(settingProvider.notifier) + .videoAutoDownload = value, ), ), ), CellItem( - title: Text(context.l10n.files), + title: Text(l10n.files), trailing: Transform.scale( scale: 0.7, child: CupertinoSwitch( - activeTrackColor: context.theme.accent, + activeTrackColor: theme.accent, value: fileAutoDownload, onChanged: (value) => - context.settingChangeNotifier.fileAutoDownload = + ref + .read(settingProvider.notifier) + .fileAutoDownload = value, ), ), @@ -83,17 +91,17 @@ class StoragePage extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(left: 20, bottom: 14, top: 10), child: Text( - context.l10n.storageAutoDownloadDescription, + l10n.storageAutoDownloadDescription, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), ), CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: CellItem( - title: Text(context.l10n.storageUsage), + title: Text(l10n.storageUsage), onTap: () => ref .read(responsiveNavigatorProvider.notifier) .pushPage( diff --git a/lib/ui/setting/storage_usage_detail_page.dart b/lib/ui/setting/storage_usage_detail_page.dart index 38c85983a3..e929035b96 100644 --- a/lib/ui/setting/storage_usage_detail_page.dart +++ b/lib/ui/setting/storage_usage_detail_page.dart @@ -4,7 +4,7 @@ import 'package:filesize/filesize.dart'; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../utils/extension/extension.dart'; + import '../../utils/file.dart'; import '../../utils/hook.dart'; import '../../widgets/app_bar.dart'; @@ -13,6 +13,8 @@ import '../../widgets/dialog.dart'; import '../../widgets/disable.dart'; import '../../widgets/radio.dart'; import '../../widgets/toast.dart'; +import '../provider/account_server_provider.dart'; +import '../provider/ui_context_providers.dart'; class StorageUsageDetailPage extends HookConsumerWidget { const StorageUsageDetailPage({ @@ -26,16 +28,19 @@ class StorageUsageDetailPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; final watchEvent = useMemoizedStream( () => File( - context.accountServer.getMediaFilePath(), + accountServer.getMediaFilePath(), ).watch(recursive: true), ).data; final photosSize = useMemoizedFuture( () async => filesize( await getTotalSizeOfFile( - context.accountServer.getImagesPath(conversationId), + accountServer.getImagesPath(conversationId), ), ), '0 B', @@ -44,7 +49,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { final videosSize = useMemoizedFuture( () async => filesize( await getTotalSizeOfFile( - context.accountServer.getVideosPath(conversationId), + accountServer.getVideosPath(conversationId), ), ), '0 B', @@ -53,7 +58,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { final audiosSize = useMemoizedFuture( () async => filesize( await getTotalSizeOfFile( - context.accountServer.getAudiosPath(conversationId), + accountServer.getAudiosPath(conversationId), ), ), '0 B', @@ -62,7 +67,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { final filesSize = useMemoizedFuture( () async => filesize( await getTotalSizeOfFile( - context.accountServer.getFilesPath(conversationId), + accountServer.getFilesPath(conversationId), ), ), '0 B', @@ -77,7 +82,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { )); return Scaffold( - backgroundColor: context.theme.background, + backgroundColor: theme.background, appBar: MixinAppBar( title: Text(name), actions: [ @@ -91,7 +96,6 @@ class StorageUsageDetailPage extends HookConsumerWidget { child: MixinButton( backgroundTransparent: true, onTap: () => runFutureWithToast(() async { - final accountServer = context.accountServer; if (selected.value.$1) { await _clear(accountServer.getImagesPath(conversationId)); } @@ -105,7 +109,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { await _clear(accountServer.getFilesPath(conversationId)); } }()), - child: Center(child: Text(context.l10n.clear)), + child: Center(child: Text(l10n.clear)), ), ), ], @@ -117,14 +121,14 @@ class StorageUsageDetailPage extends HookConsumerWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ CellGroup( - cellBackgroundColor: context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: Column( children: [ CellItem( title: RadioItem( groupValue: true, value: selected.value.$1, - title: Text(context.l10n.photos), + title: Text(l10n.photos), onChanged: (value) { final (_, item2, item3, item4) = selected.value; @@ -134,7 +138,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { description: Text( photosSize, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), @@ -143,7 +147,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { title: RadioItem( groupValue: true, value: selected.value.$2, - title: Text(context.l10n.videos), + title: Text(l10n.videos), onChanged: (value) { final (item1, _, item3, item4) = selected.value; @@ -153,7 +157,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { description: Text( videosSize, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), @@ -162,7 +166,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { title: RadioItem( groupValue: true, value: selected.value.$3, - title: Text(context.l10n.audio), + title: Text(l10n.audio), onChanged: (value) { final (item1, item2, _, item4) = selected.value; @@ -172,7 +176,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { description: Text( audiosSize, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), @@ -181,7 +185,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { title: RadioItem( groupValue: true, value: selected.value.$4, - title: Text(context.l10n.files), + title: Text(l10n.files), onChanged: (value) { final (item1, item2, item3, _) = selected.value; @@ -191,7 +195,7 @@ class StorageUsageDetailPage extends HookConsumerWidget { description: Text( filesSize, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), diff --git a/lib/ui/setting/storage_usage_list_page.dart b/lib/ui/setting/storage_usage_list_page.dart index 4730c0ec76..bb9f4145e1 100644 --- a/lib/ui/setting/storage_usage_list_page.dart +++ b/lib/ui/setting/storage_usage_list_page.dart @@ -6,6 +6,8 @@ import 'package:watcher/watcher.dart'; import '../../db/dao/conversation_dao.dart'; import '../../db/extension/conversation.dart'; +import '../../ui/provider/account_server_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../../widgets/app_bar.dart'; @@ -17,11 +19,15 @@ class StorageUsageListPage extends HookConsumerWidget { const StorageUsageListPage({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) => Scaffold( - backgroundColor: context.theme.background, - appBar: MixinAppBar(title: Text(context.l10n.storageUsage)), - body: const _Content(), - ); + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return Scaffold( + backgroundColor: theme.background, + appBar: MixinAppBar(title: Text(l10n.storageUsage)), + body: const _Content(), + ); + } } class _Content extends HookConsumerWidget { @@ -29,16 +35,17 @@ class _Content extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; final watchEvent = useMemoizedStream( () => DirectoryWatcher( - context.accountServer.getMediaFilePath(), + accountServer.getMediaFilePath(), ).events.throttleTime(const Duration(milliseconds: 400)), ).data; final list = useMemoizedFuture?>( () async { try { - final accountServer = context.accountServer; final list = await accountServer.database.conversationDao .conversationStorageUsage() .get(); @@ -65,7 +72,7 @@ class _Content extends HookConsumerWidget { if (list == null) { return Center( child: CircularProgressIndicator( - valueColor: AlwaysStoppedAnimation(context.theme.accent), + valueColor: AlwaysStoppedAnimation(theme.accent), ), ); } @@ -90,11 +97,12 @@ class _Item extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final sizeString = useMemoized(() => filesize(size), [item, size]); return Align( child: CellGroup( padding: EdgeInsets.zero, - cellBackgroundColor: context.theme.settingCellBackgroundColor, + cellBackgroundColor: theme.settingCellBackgroundColor, child: CellItem( leading: ConversationAvatarWidget( conversationId: item.conversationId, @@ -113,7 +121,7 @@ class _Item extends HookConsumerWidget { Text( sizeString, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), diff --git a/lib/utils/action_utils.dart b/lib/utils/action_utils.dart index cae42a9147..480fbd959b 100644 --- a/lib/utils/action_utils.dart +++ b/lib/utils/action_utils.dart @@ -1,15 +1,17 @@ import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../ui/provider/account_server_provider.dart'; import '../ui/provider/conversation_provider.dart'; -import 'extension/extension.dart'; extension OpenUriExtension on BuildContext { - bool openAction(String actionText) { + bool openAction(WidgetRef ref, String actionText) { if (actionText.startsWith('input:')) { final content = actionText.substring(6).trim(); - final conversationItem = providerContainer.read(conversationProvider); + final conversationItem = ref.read(conversationProvider); + final accountServer = ref.read(accountServerProvider).value; if (content.isNotEmpty && conversationItem != null) { - accountServer.sendTextMessage( + accountServer?.sendTextMessage( content, conversationItem.encryptCategory, conversationId: conversationItem.conversationId, diff --git a/lib/utils/attachment/attachment_util.dart b/lib/utils/attachment/attachment_util.dart index 52cc058a65..8a1b8d6b77 100644 --- a/lib/utils/attachment/attachment_util.dart +++ b/lib/utils/attachment/attachment_util.dart @@ -424,7 +424,7 @@ class AttachmentUtil extends AttachmentUtilBase with ChangeNotifier { w('upload failed error: $error, $s'); showToastFailed( ToastError.builder( - (context) => context.l10n.errorUploadAttachmentFailed, + (context) => Localization.current.errorUploadAttachmentFailed, ), ); await _messageDao.updateMediaStatus(messageId, MediaStatus.canceled); diff --git a/lib/utils/audio_message_player/audio_message_service.dart b/lib/utils/audio_message_player/audio_message_service.dart index 82c17cc215..fa00bcd568 100644 --- a/lib/utils/audio_message_player/audio_message_service.dart +++ b/lib/utils/audio_message_player/audio_message_service.dart @@ -1,14 +1,11 @@ import 'dart:async'; import 'dart:io'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:rxdart/rxdart.dart'; import '../../account/account_server.dart'; import '../../db/mixin_database.dart'; import '../../enum/media_status.dart'; -import '../extension/extension.dart'; -import '../hook.dart'; import '../system/audio_session.dart'; import 'audio_message_player.dart'; @@ -26,8 +23,27 @@ class AudioMessagePlayService { bool _isMediaList = false; bool get playing => _player.playbackState.isPlaying; + bool get isMediaList => _isMediaList; Duration get currentPosition => _player.currentPosition(); + Stream get playbackStateStream => + _player.playbackStream.distinct(); + Stream get currentMessageStream => + _player.currentStream.map((e) => e?.messageItem).distinct(); + Stream get playbackSpeedStream => + _player.playbackSpeedStream.distinct(); + Stream get positionStream => playbackStateStream.switchMap((event) { + if (event == PlaybackState.idle || event == PlaybackState.completed) { + return Stream.value(0); + } + if (event == PlaybackState.paused) { + return Stream.value(currentPosition.inMilliseconds.toDouble()); + } + return Stream.periodic( + const Duration(milliseconds: 200), + (_) => currentPosition.inMilliseconds.toDouble(), + ).startWith(currentPosition.inMilliseconds.toDouble()); + }).distinct(); final _subscriptions = []; @@ -82,7 +98,7 @@ class AudioMessagePlayService { if (message.mediaStatus == MediaStatus.done) { unawaited( - _accountServer.database.messageDao.updateMediaStatus( + _accountServer.updateMessageMediaStatus( message.messageId, MediaStatus.read, ), @@ -137,99 +153,3 @@ class AudioMessagePlayService { _player.setPlaybackSpeed(speed); } } - -bool useAudioMessagePlaying(String messageId, {bool isMediaList = false}) { - final context = useContext(); - - final result = useMemoizedStream(() { - final ams = context.audioMessageService; - - return CombineLatestStream.combine2< - MessageMedia?, - bool, - (MessageMedia?, bool) - >( - ams._player.currentStream, - ams._player.playbackStream.map((e) => e.isPlaying).distinct(), - (a, b) => (a, b), - ) - .map((event) { - if (!event.$2) return false; - - final message = event.$1?.messageItem; - - return message?.messageId == messageId && - isMediaList == ams._isMediaList; - }) - .distinct(); - }, keys: [messageId, isMediaList, context]); - return result.data ?? false; -} - -PlaybackState useAudioMessagePlayerState() { - final context = useContext(); - - final result = useMemoizedStream(() { - final ams = context.audioMessageService; - return ams._player.playbackStream.distinct(); - }, keys: [context]); - return result.data ?? PlaybackState.idle; -} - -MessageItem? useCurrentPlayingMessage() { - final context = useContext(); - - final result = useMemoizedStream(() { - final ams = context.audioMessageService; - return ams._player.currentStream.map((e) => e?.messageItem).distinct(); - }, keys: [context]); - return result.data; -} - -double useAudioPlayerPosition() { - final context = useContext(); - final ams = context.audioMessageService; - final position = useState(0); - final isImagePlay = useValueListenable(useImagePlaying(context)); - useEffect(() { - Timer? timer; - final subscription = ams._player.playbackStream.listen((event) { - timer?.cancel(); - if (event == PlaybackState.idle || event == PlaybackState.completed) { - position.value = 0; - return; - } - if (event == PlaybackState.paused) { - position.value = ams.currentPosition.inMilliseconds.toDouble(); - return; - } - // Avoid update too often. since there is performance issue in Flutter. - // https://github.com/flutter/flutter/issues/85781 - timer = Timer.periodic(Duration(milliseconds: isImagePlay ? 40 : 200), ( - timer, - ) { - position.value = ams.currentPosition.inMilliseconds.toDouble(); - }); - }); - return () { - subscription.cancel(); - timer?.cancel(); - }; - }, [ams, isImagePlay]); - - return position.value; -} - -double useAudioPlayerSpeed() { - final context = useContext(); - final ams = context.audioMessageService; - final speed = useState(1); - useEffect(() { - final subscription = ams._player.playbackSpeedStream.listen((event) { - speed.value = event; - }); - return subscription.cancel; - }, [ams]); - - return speed.value; -} diff --git a/lib/utils/device_transfer/device_transfer_dialog.dart b/lib/utils/device_transfer/device_transfer_dialog.dart index 42ed3f83ae..bad456fd7a 100644 --- a/lib/utils/device_transfer/device_transfer_dialog.dart +++ b/lib/utils/device_transfer/device_transfer_dialog.dart @@ -1,17 +1,16 @@ import 'package:equatable/equatable.dart'; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; import '../../constants/resources.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../widgets/app_bar.dart'; import '../../widgets/buttons.dart'; import '../../widgets/cell.dart'; import '../../widgets/dialog.dart'; import '../event_bus.dart'; import '../extension/extension.dart'; -import '../hook.dart'; import '../logger.dart'; import 'device_transfer_widget.dart'; @@ -57,81 +56,91 @@ class _NavigatorState with EquatableMixin { List get props => [pages]; } -class _NavigatorCubit extends Cubit<_NavigatorState> { - _NavigatorCubit() - : super(_NavigatorState([_DeviceTransferPageType.deviceTransfer])); +class _NavigatorNotifier extends Notifier<_NavigatorState> { + @override + _NavigatorState build() => + _NavigatorState([_DeviceTransferPageType.deviceTransfer]); void push(_DeviceTransferPageType page) { - emit(_NavigatorState([...state.pages, page])); + state = _NavigatorState([...state.pages, page]); } void pop() { - emit(_NavigatorState([...state.pages]..removeLast())); + state = _NavigatorState([...state.pages]..removeLast()); } } +final _navigatorProvider = + NotifierProvider.autoDispose<_NavigatorNotifier, _NavigatorState>( + _NavigatorNotifier.new, + ); + class _Navigator extends HookConsumerWidget { const _Navigator(); @override Widget build(BuildContext context, WidgetRef ref) { - final cubit = useBloc(_NavigatorCubit.new); - final pages = useBlocState<_NavigatorCubit, _NavigatorState>(bloc: cubit); - return BlocProvider.value( - value: cubit, - child: AnimatedSize( - duration: const Duration(milliseconds: 150), - alignment: Alignment.topCenter, - child: pages.pages.lastOrNull?.build() ?? const SizedBox.shrink(), - ), + final pages = ref.watch(_navigatorProvider); + return AnimatedSize( + duration: const Duration(milliseconds: 150), + alignment: Alignment.topCenter, + child: pages.pages.lastOrNull?.build() ?? const SizedBox.shrink(), ); } } -class _DeviceTransferPage extends StatelessWidget { +class _DeviceTransferPage extends ConsumerWidget { const _DeviceTransferPage(); @override - Widget build(BuildContext context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - MixinAppBar( - backgroundColor: Colors.transparent, - title: const Text('Chat Backup and Restore'), - leading: const SizedBox.shrink(), - actions: [ - MixinCloseButton( - onTap: () => Navigator.maybeOf(context, rootNavigator: true)?.pop(), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + MixinAppBar( + backgroundColor: Colors.transparent, + title: const Text('Chat Backup and Restore'), + leading: const SizedBox.shrink(), + actions: [ + MixinCloseButton( + onTap: () => + Navigator.maybeOf(context, rootNavigator: true)?.pop(), + ), + ], + ), + const SizedBox(height: 20), + CellGroup( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: CellItem( + title: const Text('sync from other device'), + onTap: () { + ref + .read(_navigatorProvider.notifier) + .push( + _DeviceTransferPageType.restore, + ); + }, ), - ], - ), - const SizedBox(height: 20), - CellGroup( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: CellItem( - title: const Text('sync from other device'), - onTap: () { - context.read<_NavigatorCubit>().push( - _DeviceTransferPageType.restore, - ); - }, ), - ), - const SizedBox(height: 16), - CellGroup( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: CellItem( - title: const Text('sync to other device'), - onTap: () { - context.read<_NavigatorCubit>().push( - _DeviceTransferPageType.backup, - ); - }, + const SizedBox(height: 16), + CellGroup( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: CellItem( + title: const Text('sync to other device'), + onTap: () { + ref + .read(_navigatorProvider.notifier) + .push( + _DeviceTransferPageType.backup, + ); + }, + ), ), - ), - const SizedBox(height: 32), - ], - ); + const SizedBox(height: 32), + ], + ); + } } class _DialogBackButton extends HookConsumerWidget { @@ -141,69 +150,77 @@ class _DialogBackButton extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final canPopup = - useBlocStateConverter<_NavigatorCubit, _NavigatorState, bool>( - converter: (state) => state.pages.length > 1, - ); + final canPopup = ref.watch( + _navigatorProvider.select((value) => value.pages.length > 1), + ); return !canPopup ? const SizedBox.shrink() : Center( child: MixinBackButton( onTap: () { onTapped?.call(); - context.read<_NavigatorCubit>().pop(); + ref.read(_navigatorProvider.notifier).pop(); }, ), ); } } -class _RestorePage extends StatelessWidget { +class _RestorePage extends ConsumerWidget { const _RestorePage(); @override - Widget build(BuildContext context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - MixinAppBar( - backgroundColor: Colors.transparent, - title: const Text('sync from other device'), - leading: const _DialogBackButton(), - actions: [ - MixinCloseButton( - onTap: () => Navigator.maybeOf(context, rootNavigator: true)?.pop(), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + MixinAppBar( + backgroundColor: Colors.transparent, + title: const Text('sync from other device'), + leading: const _DialogBackButton(), + actions: [ + MixinCloseButton( + onTap: () => + Navigator.maybeOf(context, rootNavigator: true)?.pop(), + ), + ], + ), + const SizedBox(height: 32), + SvgPicture.asset(Resources.assetsImagesDeviceTransferSvg), + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: Text( + 'restore chat tip', + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), + textAlign: TextAlign.center, ), - ], - ), - const SizedBox(height: 32), - SvgPicture.asset(Resources.assetsImagesDeviceTransferSvg), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 36), - child: Text( - 'restore chat tip', - style: TextStyle(color: context.theme.secondaryText, fontSize: 14), - textAlign: TextAlign.center, ), - ), - const SizedBox(height: 40), - CellGroup( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: CellItem( - title: const Text('restore chat'), - color: context.theme.accent, - trailing: null, - onTap: () { - EventBus.instance.fire(DeviceTransferCommand.pullToRemote); - context.read<_NavigatorCubit>().push( - _DeviceTransferPageType.restoreWaitingConnect, - ); - }, + const SizedBox(height: 40), + CellGroup( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: CellItem( + title: const Text('restore chat'), + color: theme.accent, + trailing: null, + onTap: () { + EventBus.instance.fire(DeviceTransferCommand.pullToRemote); + ref + .read(_navigatorProvider.notifier) + .push( + _DeviceTransferPageType.restoreWaitingConnect, + ); + }, + ), ), - ), - const SizedBox(height: 40), - ], - ); + const SizedBox(height: 40), + ], + ); + } } class _RestoreWaitingConnectPage extends HookConsumerWidget { @@ -211,6 +228,8 @@ class _RestoreWaitingConnectPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); useOnTransferEventType(DeviceTransferCallbackType.onRestoreStart, () { i('_RestoreWaitingConnectPage: onRestoreStart, closing dialog'); Navigator.maybeOf(context, rootNavigator: true)?.pop(); @@ -242,7 +261,10 @@ class _RestoreWaitingConnectPage extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 36), child: Text( 'waiting other device connection', - style: TextStyle(color: context.theme.secondaryText, fontSize: 14), + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), textAlign: TextAlign.center, ), ), @@ -252,7 +274,7 @@ class _RestoreWaitingConnectPage extends HookConsumerWidget { Navigator.maybeOf(context, rootNavigator: true)?.pop(); EventBus.instance.fire(DeviceTransferCommand.cancelRestore); }, - child: Text(context.l10n.cancel), + child: Text(l10n.cancel), ), const SizedBox(height: 40), ], @@ -260,58 +282,67 @@ class _RestoreWaitingConnectPage extends HookConsumerWidget { } } -class _BackupPage extends StatelessWidget { +class _BackupPage extends ConsumerWidget { const _BackupPage(); @override - Widget build(BuildContext context) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - MixinAppBar( - backgroundColor: Colors.transparent, - title: const Text('backup to other device'), - leading: const _DialogBackButton(), - actions: [ - MixinCloseButton( - onTap: () => Navigator.maybeOf(context, rootNavigator: true)?.pop(), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + MixinAppBar( + backgroundColor: Colors.transparent, + title: const Text('backup to other device'), + leading: const _DialogBackButton(), + actions: [ + MixinCloseButton( + onTap: () => + Navigator.maybeOf(context, rootNavigator: true)?.pop(), + ), + ], + ), + const SizedBox(height: 32), + SvgPicture.asset( + Resources.assetsImagesDeviceTransferSvg, + colorFilter: ColorFilter.mode( + theme.secondaryText, + BlendMode.srcIn, ), - ], - ), - const SizedBox(height: 32), - SvgPicture.asset( - Resources.assetsImagesDeviceTransferSvg, - colorFilter: ColorFilter.mode( - context.theme.secondaryText, - BlendMode.srcIn, ), - ), - const SizedBox(height: 20), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 36), - child: Text( - 'tips for backup to other device', - style: TextStyle(color: context.theme.secondaryText, fontSize: 14), - textAlign: TextAlign.center, + const SizedBox(height: 20), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: Text( + 'tips for backup to other device', + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), + textAlign: TextAlign.center, + ), ), - ), - const SizedBox(height: 40), - CellGroup( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: CellItem( - title: const Text('backup chat'), - color: context.theme.accent, - trailing: null, - onTap: () { - EventBus.instance.fire(DeviceTransferCommand.pushToRemote); - context.read<_NavigatorCubit>().push( - _DeviceTransferPageType.backupWaitingConnect, - ); - }, + const SizedBox(height: 40), + CellGroup( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: CellItem( + title: const Text('backup chat'), + color: theme.accent, + trailing: null, + onTap: () { + EventBus.instance.fire(DeviceTransferCommand.pushToRemote); + ref + .read(_navigatorProvider.notifier) + .push( + _DeviceTransferPageType.backupWaitingConnect, + ); + }, + ), ), - ), - const SizedBox(height: 40), - ], - ); + const SizedBox(height: 40), + ], + ); + } } class _BackupWaitingConnectPage extends HookConsumerWidget { @@ -319,6 +350,8 @@ class _BackupWaitingConnectPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); useOnTransferEventType(DeviceTransferCallbackType.onBackupStart, () { i('_BackupWaitingConnectPage: onBackupStart, closing dialog'); Navigator.maybeOf(context, rootNavigator: true)?.pop(); @@ -350,7 +383,10 @@ class _BackupWaitingConnectPage extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 36), child: Text( 'restore waiting other device', - style: TextStyle(color: context.theme.secondaryText, fontSize: 14), + style: TextStyle( + color: theme.secondaryText, + fontSize: 14, + ), textAlign: TextAlign.center, ), ), @@ -360,7 +396,7 @@ class _BackupWaitingConnectPage extends HookConsumerWidget { Navigator.maybeOf(context, rootNavigator: true)?.pop(); EventBus.instance.fire(DeviceTransferCommand.cancelBackup); }, - child: Text(context.l10n.cancel), + child: Text(l10n.cancel), ), const SizedBox(height: 40), ], diff --git a/lib/utils/device_transfer/device_transfer_widget.dart b/lib/utils/device_transfer/device_transfer_widget.dart index 9f681f7ed4..3509996633 100644 --- a/lib/utils/device_transfer/device_transfer_widget.dart +++ b/lib/utils/device_transfer/device_transfer_widget.dart @@ -8,9 +8,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:rxdart/rxdart.dart'; import '../../constants/resources.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../widgets/dialog.dart'; import '../event_bus.dart'; -import '../extension/extension.dart'; import '../logger.dart'; enum DeviceTransferCommand { @@ -225,21 +225,24 @@ class DeviceTransferHandlerWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); _useTransferStatus( () => _restoreBehavior.stream, progressBuilder: (context) => const _RestoreProcessingDialog(), succeedBuilder: (context) => - _ConfirmDialog(message: context.l10n.transferCompleted), - failedBuilder: (context) => - _ConfirmDialog(message: context.l10n.deviceTransferFailed), + _ConfirmDialog(message: l10n.transferCompleted), + failedBuilder: (context) => _ConfirmDialog( + message: l10n.deviceTransferFailed, + ), ); _useTransferStatus( () => _backupBehavior.stream, progressBuilder: (context) => const _BackupProcessingDialog(), succeedBuilder: (context) => - _ConfirmDialog(message: context.l10n.transferCompleted), - failedBuilder: (context) => - _ConfirmDialog(message: context.l10n.deviceTransferFailed), + _ConfirmDialog(message: l10n.transferCompleted), + failedBuilder: (context) => _ConfirmDialog( + message: l10n.deviceTransferFailed, + ), ); useOnTransferEventType( DeviceTransferCallbackType.onBackupRequestReceived, @@ -247,7 +250,7 @@ class DeviceTransferHandlerWidget extends HookConsumerWidget { final approved = await showMixinDialog( context: context, child: _ApproveDialog( - message: context.l10n.confirmSyncChatsFromPhone, + message: l10n.confirmSyncChatsFromPhone, ), ); if (approved == true) { @@ -262,7 +265,9 @@ class DeviceTransferHandlerWidget extends HookConsumerWidget { () async { final approved = await showMixinDialog( context: context, - child: _ApproveDialog(message: context.l10n.confirmSyncChatsToPhone), + child: _ApproveDialog( + message: l10n.confirmSyncChatsToPhone, + ), ); if (approved == true) { EventBus.instance.fire(DeviceTransferCommand.confirmBackup); @@ -278,9 +283,9 @@ class DeviceTransferHandlerWidget extends HookConsumerWidget { final String message; switch (reason) { case ConnectionFailedReason.versionNotMatched: - message = context.l10n.transferProtocolVersionNotMatched; + message = l10n.transferProtocolVersionNotMatched; case ConnectionFailedReason.unknown: - message = context.l10n.deviceTransferFailed; + message = l10n.deviceTransferFailed; } showMixinDialog( context: context, @@ -293,57 +298,66 @@ class DeviceTransferHandlerWidget extends HookConsumerWidget { } } -class _ConfirmDialog extends StatelessWidget { +class _ConfirmDialog extends ConsumerWidget { const _ConfirmDialog({required this.message}); final String message; @override - Widget build(BuildContext context) => AlertDialogLayout( - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Text(message), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text(context.l10n.confirm), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + return AlertDialogLayout( + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Text(message), ), - ], - ); + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text(l10n.confirm), + ), + ], + ); + } } -class _ApproveDialog extends StatelessWidget { +class _ApproveDialog extends ConsumerWidget { const _ApproveDialog({required this.message}); final String message; @override - Widget build(BuildContext context) => AlertDialogLayout( - content: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 420), - child: Text(message), - ), - actions: [ - TextButton( - onPressed: () { - Navigator.of(context).pop(false); - }, - child: Text( - context.l10n.cancel, - style: TextStyle(color: context.theme.secondaryText), - ), - ), - MixinButton( - onTap: () { - Navigator.of(context).pop(true); - }, - child: Text(context.l10n.confirm), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return AlertDialogLayout( + content: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 420), + child: Text(message), ), - ], - ); + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(false); + }, + child: Text( + l10n.cancel, + style: TextStyle( + color: theme.secondaryText, + ), + ), + ), + MixinButton( + onTap: () { + Navigator.of(context).pop(true); + }, + child: Text(l10n.confirm), + ), + ], + ); + } } class _RestoreProcessingDialog extends StatelessWidget { @@ -392,6 +406,8 @@ class _TransferProcessDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final progress = useStream(progressBehavior, initialData: 0); useEffect(() { DesktopKeepScreenOn.setPreventSleep(true); @@ -416,17 +432,20 @@ class _TransferProcessDialog extends HookConsumerWidget { width: 72, height: 72, colorFilter: ColorFilter.mode( - context.theme.secondaryText, + theme.secondaryText, BlendMode.srcIn, ), ), const SizedBox(height: 38), DefaultTextStyle.merge( - style: TextStyle(color: context.theme.text, fontSize: 18), + style: TextStyle( + color: theme.text, + fontSize: 18, + ), child: Row( mainAxisSize: MainAxisSize.min, children: [ - Text(context.l10n.transferringChats), + Text(l10n.transferringChats), const SizedBox(width: 2), if (progress.data != null && progress.data! > 0) Text('(${progress.data!.toStringAsFixed(2)}%)'), @@ -436,17 +455,17 @@ class _TransferProcessDialog extends HookConsumerWidget { const SizedBox(height: 16), DefaultTextStyle.merge( style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), textAlign: TextAlign.center, - child: Text(context.l10n.transferringChatsTips), + child: Text(l10n.transferringChatsTips), ), const SizedBox(height: 18), Text( _formatNetworkSpeed(networkSpeed.data ?? 0), style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), ), @@ -454,11 +473,11 @@ class _TransferProcessDialog extends HookConsumerWidget { TextButton( onPressed: onCancelTapped, child: Text( - context.l10n.cancel, + l10n.cancel, style: TextStyle( fontSize: 14, fontWeight: FontWeight.normal, - color: context.theme.accent, + color: theme.accent, ), ), ), diff --git a/lib/utils/extension/extension.dart b/lib/utils/extension/extension.dart index d4a4d859b2..3ac935c2ab 100644 --- a/lib/utils/extension/extension.dart +++ b/lib/utils/extension/extension.dart @@ -9,27 +9,16 @@ import 'package:decimal/intl.dart'; import 'package:drift/drift.dart'; import 'package:flutter/services.dart'; import 'package:flutter/widgets.dart' hide Table; -import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:intl/intl.dart'; import 'package:markdown/markdown.dart'; -import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:package_info_plus/package_info_plus.dart'; -import 'package:provider/provider.dart'; import 'package:rxdart/rxdart.dart' hide ThrottleExtensions; import 'package:string_tokenizer/string_tokenizer.dart' as string_tokenizer; import 'package:ulid/ulid.dart'; import 'package:uuid/uuid.dart'; -import '../../account/account_server.dart'; import '../../db/dao/snapshot_dao.dart'; -import '../../db/database.dart'; import '../../generated/l10n.dart'; -import '../../ui/provider/account_server_provider.dart'; -import '../../ui/provider/database_provider.dart'; -import '../../ui/provider/multi_auth_provider.dart'; -import '../../ui/provider/setting_provider.dart'; -import '../../widgets/brightness_observer.dart'; -import '../audio_message_player/audio_message_service.dart'; import '../platform.dart'; import '../synchronized.dart'; @@ -45,6 +34,9 @@ export '../../db/extension/message.dart' show MessageItemExtension, QuoteMessageItemExtension; export '../../db/extension/message_category.dart' show MessageCategoryExtension; export '../../db/extension/user.dart' show UserExtension; +export '../../generated/l10n.dart' show Localization; +export '../../widgets/brightness_observer.dart' + show BrightnessData, BrightnessThemeData; export '../action_utils.dart' show OpenUriExtension; export '../datetime_format_utils.dart' show DateTimeExtension, StringEpochNanoExtension; @@ -60,7 +52,6 @@ part 'src/iterable.dart'; part 'src/key_event.dart'; part 'src/markdown.dart'; part 'src/number.dart'; -part 'src/provider.dart'; part 'src/regexp.dart'; part 'src/stream.dart'; part 'src/string.dart'; diff --git a/lib/utils/extension/src/errors.dart b/lib/utils/extension/src/errors.dart index f7121223b4..ca8029fb83 100644 --- a/lib/utils/extension/src/errors.dart +++ b/lib/utils/extension/src/errors.dart @@ -5,6 +5,7 @@ import '../extension.dart'; extension GetErrorStringByCode on BuildContext { String getMixinErrorStringByCode(int code, String message) { + final l10n = Localization.of(this); switch (code) { case transaction: return '$code TRANSACTION'; diff --git a/lib/utils/extension/src/number.dart b/lib/utils/extension/src/number.dart index 7ffb8a699f..2470ef1938 100644 --- a/lib/utils/extension/src/number.dart +++ b/lib/utils/extension/src/number.dart @@ -1,18 +1,22 @@ part of '../extension.dart'; -extension CurrencyExtension on BuildContext { - String currencyFormat(dynamic value) => - currentCurrencyNumberFormat.format(num.tryParse('$value')); - - String currencyFormatWithoutSymbol(dynamic value) => currencyFormat( - value, - ).replaceAll(currentCurrencyNumberFormat.currencySymbol, ''); - - String get currencyFormatCoin => NumberFormat().format(num.tryParse('$this')); - - NumberFormat get currentCurrencyNumberFormat => - NumberFormat.simpleCurrency(name: account?.fiatCurrency); -} +NumberFormat currentCurrencyNumberFormat([String? fiatCurrency]) => + NumberFormat.simpleCurrency(name: fiatCurrency); + +String currencyFormat( + dynamic value, { + String? fiatCurrency, +}) => currentCurrencyNumberFormat( + fiatCurrency, +).format(num.tryParse('$value')); + +String currencyFormatWithoutSymbol( + dynamic value, { + String? fiatCurrency, +}) => currencyFormat( + value, + fiatCurrency: fiatCurrency, +).replaceAll(currentCurrencyNumberFormat(fiatCurrency).currencySymbol, ''); extension StringCurrencyExtension on String { bool get isZero => (double.tryParse(this) ?? 0.0) == 0; @@ -21,6 +25,8 @@ extension StringCurrencyExtension on String { Decimal get asDecimalOrZero => Decimal.tryParse(this) ?? Decimal.zero; + String get currencyFormatCoin => NumberFormat().format(num.tryParse(this)); + String numberFormat() { if (isEmpty) return this; try { @@ -64,50 +70,9 @@ extension DoubleCurrencyExtension on num { Decimal get asDecimal => Decimal.parse('$this'); } -// extension AssetResultExtension on AssetResult { -// Decimal get amountOfUsd => balance.asDecimal * priceUsd.asDecimal; -// -// Decimal get amountOfBtc => balance.asDecimal * priceBtc.asDecimal; -// -// Decimal get amountOfCurrentCurrency => -// balance.asDecimal * priceUsd.asDecimal * fiatRate.asDecimal; -// -// Decimal get usdUnitPrice => priceUsd.asDecimal * fiatRate.asDecimal; -// -// bool get needShowMemo => tag?.isNotEmpty == true; -// -// bool get needShowReserve => (int.tryParse(reserve ?? '0') ?? 0) > 0; -// -// String getTip(BuildContext context) { -// switch (chainId) { -// case bitcoin: -// return context.l10n.depositTipBtc; -// case ethereum: -// return context.l10n.depositTipEth; -// case eos: -// return context.l10n.depositTipEos; -// case tron: -// return context.l10n.depositTipTron; -// default: -// return context.l10n.depositTip(symbol); -// } -// } -// } -// extension SnapshotItemExtension on SnapshotItem { Decimal amountOfCurrentCurrency() => amount.asDecimal * priceUsd!.asDecimal * fiatRate!.asDecimal; bool get isPositive => (double.tryParse(amount) ?? 0) > 0; } - -// -// extension AddressExtension on Addresse { -// String displayAddress() { -// if (tag == null || tag?.isEmpty == true) { -// return '$destination$tag'; -// } else { -// return destination; -// } -// } -// } diff --git a/lib/utils/extension/src/provider.dart b/lib/utils/extension/src/provider.dart deleted file mode 100644 index f7a977e696..0000000000 --- a/lib/utils/extension/src/provider.dart +++ /dev/null @@ -1,45 +0,0 @@ -part of '../extension.dart'; - -extension ProviderExtension on BuildContext { - MultiAuthStateNotifier get multiAuthChangeNotifier => - providerContainer.read(multiAuthStateNotifierProvider.notifier); - - AuthState? get auth => providerContainer.read(authProvider); - - Account? get account => providerContainer.read(authAccountProvider); - - SettingChangeNotifier get settingChangeNotifier => - providerContainer.read(settingProvider); - - AccountServer get accountServer => providerContainer.read( - accountServerProvider.select((value) { - if (!value.hasValue) throw Exception('AccountServerProvider not ready'); - return value.requireValue; - }), - ); - - AudioMessagePlayService get audioMessageService => - read(); - - Database get database => providerContainer.read( - databaseProvider.select((value) { - if (!value.hasValue) throw Exception('DatabaseProvider not ready'); - return value.requireValue; - }), - ); - - Localization get l10n => Localization.maybeOf(this) ?? Localization.current; - - BrightnessThemeData get theme => BrightnessData.themeOf(this); - - double get brightnessValue => BrightnessData.of(this); - - Brightness get brightness => - settingChangeNotifier.brightness ?? MediaQuery.platformBrightnessOf(this); - - Color dynamicColor(Color color, {Color? darkColor}) => - BrightnessData.dynamicColor(this, color, darkColor: darkColor); - - ProviderContainer get providerContainer => - ProviderScope.containerOf(this, listen: false); -} diff --git a/lib/utils/file.dart b/lib/utils/file.dart index 17227f0dc3..f2341f6314 100644 --- a/lib/utils/file.dart +++ b/lib/utils/file.dart @@ -4,7 +4,6 @@ import 'dart:typed_data'; import 'package:cross_file/cross_file.dart'; import 'package:file_picker/file_picker.dart'; import 'package:file_selector/file_selector.dart' as file_selector; -import 'package:flutter/cupertino.dart'; import 'package:mime/mime.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; @@ -34,8 +33,8 @@ Future> selectFiles() async { /// Copy [file] to user file system. /// TODO: support Android/iOS. Future saveFileToSystem( - BuildContext context, String file, { + required String confirmButtonText, String? suggestName, }) async { final targetName = (suggestName ?? file).pathBasename; @@ -46,7 +45,7 @@ Future saveFileToSystem( final extension = p.extension(targetName); var path = (await file_selector.getSaveLocation( - confirmButtonText: context.l10n.save, + confirmButtonText: confirmButtonText, suggestedName: targetName, acceptedTypeGroups: [ if (Platform.isWindows) diff --git a/lib/utils/hook.dart b/lib/utils/hook.dart index 46f545fdfa..aa21b1255d 100644 --- a/lib/utils/hook.dart +++ b/lib/utils/hook.dart @@ -1,10 +1,8 @@ import 'dart:async'; -import 'package:bloc/bloc.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:protocol_handler/protocol_handler.dart'; -import 'package:provider/provider.dart'; import 'package:rxdart/rxdart.dart'; import '../app.dart'; @@ -42,56 +40,6 @@ AsyncSnapshot _logSnapshotError(AsyncSnapshot snapshot) { return snapshot; } -T useBloc( - T Function() valueBuilder, { - List keys = const [], -}) { - final sink = useMemoized(valueBuilder, keys); - useEffect(() => sink.close, keys); - return sink; -} - -S useBlocState, S>({ - B? bloc, - List keys = const [], - bool preserveState = false, - bool Function(S state)? when, -}) { - final (stream, initialData) = useMemoized(() { - final b = bloc ?? useContext().read(); - var stream = b.stream; - if (when != null) stream = stream.where(when); - return (stream.distinct(), b.state); - }, [bloc ?? useContext().read(), ...keys]); - return useStream( - stream, - initialData: initialData, - preserveState: preserveState, - ).data - as S; -} - -T useBlocStateConverter, S, T>({ - required T Function(S) converter, - B? bloc, - List keys = const [], - bool preserveState = false, - bool Function(T)? when, -}) { - final (stream, initialData) = useMemoized(() { - final b = bloc ?? useContext().read(); - var stream = b.stream.map(converter); - if (when != null) stream = stream.where(when); - return (stream.distinct(), converter(b.state)); - }, [bloc ?? useContext().read(), ...keys]); - return useStream( - stream, - initialData: initialData, - preserveState: preserveState, - ).data - as T; -} - Stream useValueNotifierConvertSteam(ValueNotifier valueNotifier) { final streamController = useStreamController(keys: [valueNotifier]); useEffect(() { diff --git a/lib/utils/hydrated_bloc.dart b/lib/utils/hydration_codec.dart similarity index 98% rename from lib/utils/hydrated_bloc.dart rename to lib/utils/hydration_codec.dart index 3a3402d2fa..ed42ffa676 100644 --- a/lib/utils/hydrated_bloc.dart +++ b/lib/utils/hydration_codec.dart @@ -1,6 +1,6 @@ // ignore_for_file: avoid_catching_errors -import 'package:hydrated_bloc/hydrated_bloc.dart'; +import 'hydration_storage.dart'; T? fromHydratedJson( dynamic json, diff --git a/lib/utils/hydration_storage.dart b/lib/utils/hydration_storage.dart new file mode 100644 index 0000000000..4dcfc39082 --- /dev/null +++ b/lib/utils/hydration_storage.dart @@ -0,0 +1,103 @@ +import 'dart:convert'; +import 'dart:io'; + +abstract class KeyValueStorage { + dynamic read(String key); + + Future write(String key, dynamic value); + + Future delete(String key); + + Future clear(); +} + +class HydrationStorageRegistry { + static late KeyValueStorage storage; +} + +class HydrationStorageDirectory { + const HydrationStorageDirectory(this.path); + + final String path; +} + +class HydrationStorage implements KeyValueStorage { + HydrationStorage._(this._directory, this._cache); + + final Directory _directory; + final Map _cache; + + static Future build({ + required HydrationStorageDirectory storageDirectory, + }) async { + final directory = Directory(storageDirectory.path); + if (!await directory.exists()) { + await directory.create(recursive: true); + } + + final cache = {}; + await for (final entity in directory.list()) { + if (entity is! File || !entity.path.endsWith('.json')) { + continue; + } + final key = entity.uri.pathSegments.last.replaceAll('.json', ''); + try { + final content = await entity.readAsString(); + cache[key] = jsonDecode(content); + } catch (_) {} + } + + return HydrationStorage._(directory, cache); + } + + @override + dynamic read(String key) => _cache[key]; + + @override + Future write(String key, dynamic value) async { + _cache[key] = value; + final file = File(_filePathFor(key)); + await file.writeAsString(jsonEncode(value)); + } + + @override + Future delete(String key) async { + _cache.remove(key); + final file = File(_filePathFor(key)); + if (await file.exists()) { + await file.delete(); + } + } + + @override + Future clear() async { + _cache.clear(); + await for (final entity in _directory.list()) { + if (entity is File && entity.path.endsWith('.json')) { + await entity.delete(); + } + } + } + + String _filePathFor(String key) => + '${_directory.path}${Platform.pathSeparator}$key.json'; +} + +class HydratedUnsupportedError extends Error { + HydratedUnsupportedError(this.value, {this.cause}); + + final Object? value; + final Object? cause; + + @override + String toString() => 'HydratedUnsupportedError(value: $value, cause: $cause)'; +} + +class HydratedCyclicError extends Error { + HydratedCyclicError([this.cause]); + + final Object? cause; + + @override + String toString() => 'HydratedCyclicError(cause: $cause)'; +} diff --git a/lib/utils/message_optimize.dart b/lib/utils/message_optimize.dart index 8836b11688..9871b261a4 100644 --- a/lib/utils/message_optimize.dart +++ b/lib/utils/message_optimize.dart @@ -4,7 +4,6 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import '../constants/resources.dart'; import '../enum/message_category.dart'; -import '../generated/l10n.dart'; import '../widgets/message/item/action/action_data.dart'; import '../widgets/message/item/action_card/action_card_data.dart'; import 'extension/extension.dart'; diff --git a/lib/utils/rivepod.dart b/lib/utils/rivepod.dart deleted file mode 100644 index 6a78a698b5..0000000000 --- a/lib/utils/rivepod.dart +++ /dev/null @@ -1,11 +0,0 @@ -import 'package:hooks_riverpod/hooks_riverpod.dart'; - -abstract class DistinctStateNotifier extends StateNotifier { - DistinctStateNotifier(super.state); - - @override - T get state => super.state; - - @override - bool updateShouldNotify(T old, T current) => old != current; -} diff --git a/lib/utils/system/tray.dart b/lib/utils/system/tray.dart index bb1abfe9cc..5238effe62 100644 --- a/lib/utils/system/tray.dart +++ b/lib/utils/system/tray.dart @@ -9,7 +9,7 @@ import 'package:system_tray/system_tray.dart'; import 'package:window_manager/window_manager.dart'; import '../../constants/resources.dart'; -import '../extension/extension.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../logger.dart'; final SystemTray _systemTray = SystemTray(); @@ -23,8 +23,9 @@ class SystemTrayWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final show = context.l10n.show; - final exitStr = context.l10n.exit; + final l10n = ref.watch(localizationProvider); + final show = l10n.show; + final exitStr = l10n.exit; useMemoized(() { if (!_enableTray) { diff --git a/lib/utils/uri_utils.dart b/lib/utils/uri_utils.dart index 11cf5a8300..5382dd4915 100644 --- a/lib/utils/uri_utils.dart +++ b/lib/utils/uri_utils.dart @@ -1,12 +1,19 @@ import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:url_launcher/url_launcher.dart'; +import '../account/account_server.dart'; import '../constants/constants.dart'; import '../crypto/uuid/uuid.dart'; +import '../db/database.dart'; import '../db/mixin_database.dart' hide User; import '../enum/encrypt_category.dart'; +import '../ui/provider/account_server_provider.dart'; import '../ui/provider/conversation_provider.dart'; +import '../ui/provider/database_provider.dart'; +import '../ui/provider/multi_auth_provider.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../widgets/conversation/conversation_dialog.dart'; import '../widgets/message/item/action_card/action_card_data.dart'; import '../widgets/message/item/transfer/transfer_page.dart'; @@ -24,17 +31,21 @@ import 'web_view/web_view_interface.dart'; Future openUriWithWebView( BuildContext context, String text, { + ProviderContainer? container, String? title, String? conversationId, AppCardData? appCardData, }) async => openUri( context, text, + container: container, fallbackHandler: (uri) async { - if (await MixinWebView.instance.isWebViewRuntimeAvailable()) { + if (container != null && + await MixinWebView.instance.isWebViewRuntimeAvailable()) { await MixinWebView.instance.openWebViewWindowWithUrl( context, uri.toString(), + container: container, conversationId: conversationId, title: title, appCardData: appCardData, @@ -48,27 +59,49 @@ Future openUriWithWebView( Future openUri( BuildContext context, String text, { + ProviderContainer? container, Future Function(Uri uri) fallbackHandler = launchUrl, App? app, }) async { + final accountServer = container?.read(accountServerProvider).value; + final database = container?.read(databaseProvider).value; + final l10n = container?.read(localizationProvider) ?? Localization.current; + final account = container?.read(authAccountProvider); final uri = Uri.parse(text); if (uri.scheme.isEmpty) return Future.value(false); if (uri.isMixin) { + if (container == null) { + return fallbackHandler(uri); + } final userId = uri.userId; if (userId != null && userId.trim().isNotEmpty) { - await showUserDialog(context, userId); + await showUserDialog(context, container, userId); return true; } final code = uri.code; if (code != null && code.trim().isNotEmpty) { - return _showCodeDialog(context, code, uri); + return _showCodeDialog( + context, + code, + uri, + container: container, + accountServer: accountServer, + database: database, + account: account, + l10n: l10n, + ); } final snapshotTraceId = uri.snapshotTraceId; if (snapshotTraceId != null && snapshotTraceId.trim().isNotEmpty) { - return _showTransferDialog(context, snapshotTraceId); + return _showTransferDialog( + context, + snapshotTraceId, + accountServer: accountServer, + database: database, + ); } final conversationId = uri.conversationId; @@ -76,7 +109,11 @@ Future openUri( if (conversationId != null && conversationId.trim().isNotEmpty) { if (startText?.trim().isNotEmpty == true) { try { - final conversation = await context.database.conversationDao + if (database == null || accountServer == null) { + showToastFailed(null); + return false; + } + final conversation = await database.conversationDao .conversationItem(conversationId) .getSingleOrNull(); @@ -86,12 +123,13 @@ Future openUri( } await ConversationStateNotifier.selectConversation( + container, context, conversation.conversationId, conversation: conversation, ); - await context.accountServer.sendTextMessage( + await accountServer.sendTextMessage( startText ?? '', EncryptCategory.plain, conversationId: conversationId, @@ -104,7 +142,13 @@ Future openUri( } } - return _selectConversation(uri, context, conversationId); + return _selectConversation( + uri, + context, + conversationId, + container: container, + accountServer: accountServer, + ); } if (uri.isSend) { @@ -115,6 +159,7 @@ Future openUri( uri.dataOfSend, app, uri.userOfSend, + container, ); } @@ -134,8 +179,12 @@ Future openUri( try { showToastLoading(); - await context.accountServer.refreshUsers([uri.appId!]); - app = await context.database.appDao.findAppById(uri.appId!); + if (database == null || accountServer == null) { + showToastFailed(null); + return false; + } + await accountServer.refreshUsers([uri.appId!]); + app = await database.appDao.findAppById(uri.appId!); } finally { Toast.dismiss(); } @@ -143,7 +192,7 @@ Future openUri( var homeUri = Uri.tryParse(app?.homeUri ?? ''); if (app == null || homeUri == null) { - showToastFailed(ToastError(context.l10n.botNotFound)); + showToastFailed(ToastError(l10n.botNotFound)); return true; } @@ -155,13 +204,14 @@ Future openUri( await MixinWebView.instance.openWebViewWindowWithUrl( context, homeUri.toString(), + container: container, conversationId: conversationId, ); return true; } return fallbackHandler(homeUri); } else { - await showUserDialog(context, uri.appId); + await showUserDialog(context, container, uri.appId); return true; } } @@ -176,24 +226,46 @@ Future openUri( return fallbackHandler(uri); } -Future _showCodeDialog(BuildContext context, String code, Uri uri) async { +Future _showCodeDialog( + BuildContext context, + String code, + Uri uri, { + required ProviderContainer container, + required AccountServer? accountServer, + required Database? database, + required Account? account, + required Localization l10n, +}) async { + if (accountServer == null) { + showToastFailed(null); + return false; + } showToastLoading(); try { - final mixinResponse = await context.accountServer.client.accountApi.code( - code, - ); + final mixinResponse = await accountServer.client.accountApi.code(code); final data = mixinResponse.data; Toast.dismiss(); if (data is User) { - await showUserDialog(context, data.userId); + await showUserDialog(context, container, data.userId); return true; } else if (data is ConversationResponse) { - await showConversationDialog(context, data, code); + if (database == null) { + showToastFailed(ToastError(l10n.groupAlreadyIn)); + return false; + } + await showConversationDialog( + context, + container, + data, + code, + database: database, + accountServer: accountServer, + account: account, + l10n: l10n, + ); return true; } else if (data is PaymentCodeResponse) { - final asset = await context.accountServer.checkAsset( - assetId: data.assetId, - ); + final asset = await accountServer.checkAsset(assetId: data.assetId); if (asset == null) { await showUnknownMixinUrlDialog(context, uri); return false; @@ -201,7 +273,7 @@ Future _showCodeDialog(BuildContext context, String code, Uri uri) async { await showMultisigsPaymentDialog( context, item: MultisigsPaymentItem( - senders: [context.accountServer.userId], + senders: [accountServer.userId], receivers: data.receivers, threshold: data.threshold, asset: asset, @@ -213,9 +285,7 @@ Future _showCodeDialog(BuildContext context, String code, Uri uri) async { return true; } else if (data is MultisigsResponse) { debugPrint('PaymentCodeResponse: ${data.toJson()}'); - final asset = await context.accountServer.checkAsset( - assetId: data.assetId, - ); + final asset = await accountServer.checkAsset(assetId: data.assetId); if (asset == null) { await showUnknownMixinUrlDialog(context, uri); return false; @@ -246,12 +316,19 @@ Future _showCodeDialog(BuildContext context, String code, Uri uri) async { Future _showTransferDialog( BuildContext context, - String snapshotTraceId, -) async { + String snapshotTraceId, { + required AccountServer? accountServer, + required Database? database, +}) async { try { showToastLoading(); - final snapshotId = await context.database.snapshotDao + if (database == null || accountServer == null) { + showToastFailed(null); + return false; + } + + final snapshotId = await database.snapshotDao .snapshotIdByTraceId(snapshotTraceId) .getSingleOrNull(); @@ -261,7 +338,7 @@ Future _showTransferDialog( return true; } - final snapshot = await context.accountServer.updateSnapshotByTraceId( + final snapshot = await accountServer.updateSnapshotByTraceId( traceId: snapshotTraceId, ); @@ -278,25 +355,32 @@ Future _showTransferDialog( Future _selectConversation( Uri uri, BuildContext context, - String conversationId, -) async { + String conversationId, { + required ProviderContainer container, + required AccountServer? accountServer, +}) async { final userId = uri.queryParameters['user']; if (userId != null && userId.trim().isNotEmpty) { + if (accountServer == null) { + showToastFailed(null); + return false; + } showToastLoading(); - await context.accountServer.refreshUsers([userId]); + await accountServer.refreshUsers([userId]); Toast.dismiss(); if (conversationId != - generateConversationId(context.accountServer.userId, userId)) { + generateConversationId(accountServer.userId, userId)) { showToastFailed(null); return false; } else { - await ConversationStateNotifier.selectUser(context, userId); + await ConversationStateNotifier.selectUser(container, context, userId); return true; } } await ConversationStateNotifier.selectConversation( + container, context, conversationId, sync: true, diff --git a/lib/utils/web_view/web_view_desktop.dart b/lib/utils/web_view/web_view_desktop.dart index 4c3dcb368d..c3e9efa0b9 100644 --- a/lib/utils/web_view/web_view_desktop.dart +++ b/lib/utils/web_view/web_view_desktop.dart @@ -4,11 +4,14 @@ import 'dart:io'; import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:path/path.dart' as p; import '../../constants/brightness_theme_data.dart'; import '../../db/mixin_database.dart'; -import '../../widgets/brightness_observer.dart'; +import '../../ui/provider/multi_auth_provider.dart'; +import '../../ui/provider/setting_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../widgets/dialog.dart'; import '../../widgets/high_light_text.dart'; import '../../widgets/message/item/action_card/action_card_data.dart'; @@ -37,14 +40,15 @@ class DesktopMixinWebView extends MixinWebView { !Platform.isWindows || await WebviewWindow.isWebviewAvailable(); Future> _mixinContext( - BuildContext context, + ProviderContainer container, String? conversationId, ) async { - assert(context.auth != null); + final auth = container.read(authProvider); + final settings = container.read(settingProvider.notifier); + assert(auth != null); final mode = - context.settingChangeNotifier.brightness ?? - MediaQuery.platformBrightnessOf(context); + settings.brightness ?? container.read(platformBrightnessProvider); final info = await getPackageInfo(); debugPrint( 'info: appName: ${info.appName} packageName: ${info.packageName} version: ${info.version} buildNumber: ${info.buildNumber} buildSignature: ${info.buildSignature} ', @@ -54,9 +58,9 @@ class DesktopMixinWebView extends MixinWebView { 'immersive': false, 'appearance': mode == Brightness.light ? 'light' : 'dark', 'platform': 'Desktop', - 'locale': Localizations.localeOf(context).toLanguageTag(), + 'locale': container.read(localeProvider).toLanguageTag(), 'conversation_id': conversationId ?? '', - 'currency': context.account?.fiatCurrency, + 'currency': auth?.account.fiatCurrency, }; } @@ -73,12 +77,13 @@ class DesktopMixinWebView extends MixinWebView { Future openWebViewWindowWithUrl( BuildContext context, String url, { + required ProviderContainer container, String? conversationId, String? title, App? app, AppCardData? appCardData, }) async { - final brightness = context.settingChangeNotifier.brightness; + final brightness = container.read(settingProvider.notifier).brightness; final packageInfo = await getPackageInfo(); final webView = await WebviewWindow.create( configuration: CreateConfiguration( @@ -90,7 +95,7 @@ class DesktopMixinWebView extends MixinWebView { ), ); final mixinContext = jsonEncode( - await _mixinContext(context, conversationId), + await _mixinContext(container, conversationId), ); webView ..setBrightness(brightness) @@ -121,34 +126,38 @@ bool runWebViewNavigationBar(List args) => runWebViewTitleBarWidget( backgroundColor: const Color(0xFFF0E7EA), ); -class _BotWebViewRuntimeInstallDialog extends StatelessWidget { +class _BotWebViewRuntimeInstallDialog extends ConsumerWidget { const _BotWebViewRuntimeInstallDialog(); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); const runtimeDownloadLink = 'https://go.microsoft.com/fwlink/p/?LinkId=2124703'; return SizedBox( width: 400, child: AlertDialogLayout( - title: Text(context.l10n.webviewRuntimeUnavailable), + title: Text(l10n.webviewRuntimeUnavailable), content: DefaultTextStyle.merge( style: TextStyle( fontSize: 14, fontWeight: FontWeight.normal, - color: context.theme.text, + color: theme.text, ), child: Column( children: [ - Text(context.l10n.webview2RuntimeInstallDescription), + Text(l10n.webview2RuntimeInstallDescription), const SizedBox(height: 10), CustomSelectableText.rich( TextSpan( children: [ - TextSpan(text: context.l10n.downloadLink), + TextSpan(text: l10n.downloadLink), TextSpan( text: runtimeDownloadLink.overflow, - style: TextStyle(color: context.theme.accent), + style: TextStyle( + color: theme.accent, + ), recognizer: TapGestureRecognizer() ..onTap = () => openUri(context, runtimeDownloadLink), ), @@ -162,7 +171,7 @@ class _BotWebViewRuntimeInstallDialog extends StatelessWidget { actions: [ MixinButton( onTap: () => Navigator.pop(context, true), - child: Text(context.l10n.confirm), + child: Text(l10n.confirm), ), ], ), diff --git a/lib/utils/web_view/web_view_interface.dart b/lib/utils/web_view/web_view_interface.dart index beb5dee1a6..be4ba12044 100644 --- a/lib/utils/web_view/web_view_interface.dart +++ b/lib/utils/web_view/web_view_interface.dart @@ -1,4 +1,5 @@ import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../db/mixin_database.dart'; import '../../widgets/message/item/action_card/action_card_data.dart'; @@ -25,6 +26,7 @@ abstract class MixinWebView { Future openWebViewWindowWithUrl( BuildContext context, String url, { + required ProviderContainer container, String? conversationId, String? title, App? app, @@ -33,6 +35,7 @@ abstract class MixinWebView { Future openBotWebViewWindow( BuildContext context, + ProviderContainer container, App app, { String? conversationId, }) async { @@ -43,6 +46,7 @@ abstract class MixinWebView { return openWebViewWindowWithUrl( context, app.homeUri, + container: container, conversationId: conversationId, title: app.name, app: app, diff --git a/lib/utils/web_view/web_view_mobile.dart b/lib/utils/web_view/web_view_mobile.dart index 38c88af301..ab0ac00b3b 100644 --- a/lib/utils/web_view/web_view_mobile.dart +++ b/lib/utils/web_view/web_view_mobile.dart @@ -7,13 +7,14 @@ import 'package:webview_flutter/webview_flutter.dart'; import '../../constants/resources.dart'; import '../../db/extension/app.dart'; import '../../db/mixin_database.dart'; +import '../../ui/provider/account_server_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../widgets/action_button.dart'; import '../../widgets/cell.dart'; import '../../widgets/dialog.dart'; import '../../widgets/message/item/action_card/action_card_data.dart'; import '../../widgets/toast.dart'; import '../../widgets/user_selector/conversation_selector.dart'; -import '../extension/extension.dart'; import '../logger.dart'; import 'web_view_interface.dart'; @@ -33,6 +34,7 @@ class MobileMixinWebView extends MixinWebView { Future openWebViewWindowWithUrl( BuildContext context, String url, { + required ProviderContainer container, String? conversationId, String? title, App? app, @@ -70,13 +72,14 @@ class _FullWindowInAppWebViewPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final webViewController = useMemoized( () => WebViewController() ..setJavaScriptMode(JavaScriptMode.unrestricted) ..loadRequest(Uri.parse(initialUrl)), ); return Material( - color: context.theme.background, + color: theme.background, child: SafeArea( child: Stack( fit: StackFit.expand, @@ -100,7 +103,7 @@ class _FullWindowInAppWebViewPage extends HookConsumerWidget { } } -class _WebControl extends StatelessWidget { +class _WebControl extends ConsumerWidget { const _WebControl({ required this.webViewController, required this.app, @@ -112,48 +115,65 @@ class _WebControl extends StatelessWidget { final AppCardData? appCardData; @override - Widget build(BuildContext context) => DecoratedBox( - decoration: BoxDecoration( - color: context.theme.background, - border: Border.all(color: context.theme.sidebarSelected), - borderRadius: const BorderRadius.all(Radius.circular(32)), - ), - child: Row( - children: [ - Expanded( - child: InkWell( - child: Icon(Icons.more_horiz, size: 24, color: context.theme.icon), - onTap: () { - final controller = webViewController; - if (controller == null) { - return; - } - showMixinDialog( - context: context, - child: _WebViewActionDialog( - webViewController: controller, - app: app, - appCardData: appCardData, - ), - ); - }, - ), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return DecoratedBox( + decoration: BoxDecoration( + color: theme.background, + border: Border.all( + color: theme.sidebarSelected, ), - Container(width: 1, height: 20, color: context.theme.sidebarSelected), - Expanded( - child: InkWell( - child: Icon(Icons.close, size: 24, color: context.theme.icon), - onTap: () { - Navigator.maybePop(context); - }, + borderRadius: const BorderRadius.all(Radius.circular(32)), + ), + child: Row( + children: [ + Expanded( + child: InkWell( + child: Icon( + Icons.more_horiz, + size: 24, + color: theme.icon, + ), + onTap: () { + final controller = webViewController; + if (controller == null) { + return; + } + showMixinDialog( + context: context, + child: _WebViewActionDialog( + webViewController: controller, + app: app, + appCardData: appCardData, + ), + ); + }, + ), ), - ), - ], - ), - ); + Container( + width: 1, + height: 20, + color: theme.sidebarSelected, + ), + Expanded( + child: InkWell( + child: Icon( + Icons.close, + size: 24, + color: theme.icon, + ), + onTap: () { + Navigator.maybePop(context); + }, + ), + ), + ], + ), + ); + } } -class _WebViewActionDialog extends StatelessWidget { +class _WebViewActionDialog extends ConsumerWidget { const _WebViewActionDialog({ required this.webViewController, required this.app, @@ -167,61 +187,65 @@ class _WebViewActionDialog extends StatelessWidget { final AppCardData? appCardData; @override - Widget build(BuildContext context) => SizedBox( - width: 480, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 16), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - const SizedBox(width: 16), - const Spacer(), - ActionButton( - name: Resources.assetsImagesIcCloseSvg, - color: context.theme.icon, - onTap: () => Navigator.pop(context), - ), - const SizedBox(width: 16), - ], - ), - const SizedBox(height: 16), - CellGroup( - child: Column( + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + return SizedBox( + width: 480, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 16), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - _ShareMenuItem( - appCardData: appCardData, - app: app, - webViewController: webViewController, + const SizedBox(width: 16), + const Spacer(), + ActionButton( + name: Resources.assetsImagesIcCloseSvg, + color: theme.icon, + onTap: () => Navigator.pop(context), ), - CellItem( - title: Text(context.l10n.refresh), - leading: SvgPicture.asset( - Resources.assetsImagesInviteRefreshSvg, - width: 24, - height: 24, - colorFilter: ColorFilter.mode( - context.theme.text, - BlendMode.srcIn, + const SizedBox(width: 16), + ], + ), + const SizedBox(height: 16), + CellGroup( + child: Column( + children: [ + _ShareMenuItem( + appCardData: appCardData, + app: app, + webViewController: webViewController, + ), + CellItem( + title: Text(l10n.refresh), + leading: SvgPicture.asset( + Resources.assetsImagesInviteRefreshSvg, + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + theme.text, + BlendMode.srcIn, + ), ), + trailing: null, + onTap: () { + webViewController.reload(); + Navigator.pop(context); + }, ), - trailing: null, - onTap: () { - webViewController.reload(); - Navigator.pop(context); - }, - ), - ], + ], + ), ), - ), - const SizedBox(height: 16), - ], - ), - ); + const SizedBox(height: 16), + ], + ), + ); + } } -class _ShareMenuItem extends StatelessWidget { +class _ShareMenuItem extends ConsumerWidget { const _ShareMenuItem({ required this.appCardData, required this.app, @@ -233,81 +257,91 @@ class _ShareMenuItem extends StatelessWidget { final WebViewController webViewController; @override - Widget build(BuildContext context) => CellItem( - title: Text(context.l10n.share), - leading: SvgPicture.asset( - Resources.assetsImagesShareSvg, - width: 24, - height: 24, - colorFilter: ColorFilter.mode(context.theme.text, BlendMode.srcIn), - ), - trailing: null, - onTap: () async { - // can not share app - if (appCardData?.shareable == false) { - showToastFailed(ToastError(context.l10n.appCardShareDisallow)); - } else { - var title = await webViewController.getTitle(); - final url = await webViewController.currentUrl(); - - final selectedConversation = await showConversationSelector( - context: context, - singleSelect: true, - title: context.l10n.forward, - onlyContact: false, - ); - if (selectedConversation == null || selectedConversation.isEmpty) { - return; - } + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; + return CellItem( + title: Text(l10n.share), + leading: SvgPicture.asset( + Resources.assetsImagesShareSvg, + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + theme.text, + BlendMode.srcIn, + ), + ), + trailing: null, + onTap: () async { + // can not share app + if (appCardData?.shareable == false) { + showToastFailed( + ToastError(l10n.appCardShareDisallow), + ); + } else { + var title = await webViewController.getTitle(); + final url = await webViewController.currentUrl(); - var app = this.app; - if (appCardData?.appId != null) { - app = await context.accountServer.getAppAndCheckUser( - appCardData!.appId!, - DateTime.tryParse(appCardData!.updatedAt ?? ''), + final selectedConversation = await showConversationSelector( + context: context, + singleSelect: true, + title: l10n.forward, + onlyContact: false, ); - } + if (selectedConversation == null || selectedConversation.isEmpty) { + return; + } - if (app != null && url != null && _matchResourcePattern(url, app)) { - if (title?.trim().isNotEmpty != true) { - title = app.name; + var app = this.app; + if (appCardData?.appId != null) { + app = await accountServer.getAppAndCheckUser( + appCardData!.appId!, + DateTime.tryParse(appCardData!.updatedAt ?? ''), + ); } - final appCardData = AppCardData( - app.appId, - app.iconUrl, - app.name, - title ?? app.name, - url, - app.updatedAt?.toIso8601String() ?? '', - true, - [], - '', - null, - ); - await context.accountServer.sendAppCardMessage( - conversationId: selectedConversation.first.conversationId, - recipientId: selectedConversation.first.userId, - data: appCardData, - ); - } else { - final category = selectedConversation.first.encryptCategory; - assert(category != null, 'category must not be null'); - if (category == null) { - e('selected conversation encrypt category is null'); - return; + if (app != null && url != null && _matchResourcePattern(url, app)) { + if (title?.trim().isNotEmpty != true) { + title = app.name; + } + final appCardData = AppCardData( + app.appId, + app.iconUrl, + app.name, + title ?? app.name, + url, + app.updatedAt?.toIso8601String() ?? '', + true, + [], + '', + null, + ); + + await accountServer.sendAppCardMessage( + conversationId: selectedConversation.first.conversationId, + recipientId: selectedConversation.first.userId, + data: appCardData, + ); + } else { + final category = selectedConversation.first.encryptCategory; + assert(category != null, 'category must not be null'); + if (category == null) { + e('selected conversation encrypt category is null'); + return; + } + await accountServer.sendTextMessage( + url ?? '', + category, + conversationId: selectedConversation.first.conversationId, + recipientId: selectedConversation.first.userId, + ); } - await context.accountServer.sendTextMessage( - url ?? '', - category, - conversationId: selectedConversation.first.conversationId, - recipientId: selectedConversation.first.userId, - ); + Navigator.pop(context); } - Navigator.pop(context); - } - }, - ); + }, + ); + } } bool _matchResourcePattern(String url, App app) { diff --git a/lib/widgets/action_button.dart b/lib/widgets/action_button.dart index f0d87c52e5..587c9ae087 100644 --- a/lib/widgets/action_button.dart +++ b/lib/widgets/action_button.dart @@ -64,7 +64,8 @@ class ActionButton extends StatelessWidget { onExit: onExit, onHover: onHover, decoration: const BoxDecoration(shape: BoxShape.circle), - hoveringColor: context.dynamicColor( + hoveringColor: BrightnessData.dynamicColor( + context, const Color.fromRGBO(0, 0, 0, 0.03), darkColor: const Color.fromRGBO(255, 255, 255, 0.2), ), diff --git a/lib/widgets/actions/actions.dart b/lib/widgets/actions/actions.dart index 6c5975f4af..022d2bf484 100644 --- a/lib/widgets/actions/actions.dart +++ b/lib/widgets/actions/actions.dart @@ -1,5 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../ui/provider/account_server_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import 'command_palette_action.dart'; import 'create_circle_action.dart'; import 'create_conversation_action.dart'; @@ -25,19 +28,41 @@ class ToggleCommandPaletteIntent extends Intent { const ToggleCommandPaletteIntent(); } -class MixinAppActions extends StatelessWidget { +class MixinAppActions extends ConsumerWidget { const MixinAppActions({required this.child, super.key}); final Widget child; @override - Widget build(BuildContext context) => Actions( - actions: { - CreateConversationIntent: CreateConversationAction(context), - CreateGroupConversationIntent: CreateGroupConversationAction(context), - CreateCircleIntent: CreateCircleAction(context), - ToggleCommandPaletteIntent: CommandPaletteAction(context), - }, - child: child, - ); + Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).value; + final l10n = ref.watch(localizationProvider); + if (accountServer == null) { + return child; + } + return Actions( + actions: { + CreateConversationIntent: CreateConversationAction( + context: context, + container: ref.container, + l10n: l10n, + ), + CreateGroupConversationIntent: CreateGroupConversationAction( + context: context, + accountServer: accountServer, + l10n: l10n, + ), + CreateCircleIntent: CreateCircleAction( + context: context, + accountServer: accountServer, + l10n: l10n, + ), + ToggleCommandPaletteIntent: CommandPaletteAction( + context, + ref.container, + ), + }, + child: child, + ); + } } diff --git a/lib/widgets/actions/command_palette_action.dart b/lib/widgets/actions/command_palette_action.dart index 1762b0bf83..caf6ea1a47 100644 --- a/lib/widgets/actions/command_palette_action.dart +++ b/lib/widgets/actions/command_palette_action.dart @@ -8,12 +8,16 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import '../../constants/resources.dart'; +import '../../db/database.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; import '../../ui/home/conversation/search_list.dart'; import '../../ui/home/intent.dart'; +import '../../ui/provider/account_server_provider.dart'; import '../../ui/provider/conversation_provider.dart'; +import '../../ui/provider/database_provider.dart'; import '../../ui/provider/recent_conversation_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../../utils/platform.dart'; @@ -74,17 +78,18 @@ class _SearchState { } _SearchState _useSearchState({ - required BuildContext context, required WidgetRef ref, required String keyword, + required Database database, + required String selfUserId, }) { final users = useMemoizedStream>(() { if (keyword.trim().isEmpty) { return Stream.value([]); } - return context.database.userDao - .fuzzySearchUserItem(keyword, context.accountServer.userId) + return database.userDao + .fuzzySearchUserItem(keyword, selfUserId) .watchWithStream( eventStreams: [DataBaseEventBus.instance.updateUserIdsStream], duration: kVerySlowThrottleDuration, @@ -101,7 +106,7 @@ _SearchState _useSearchState({ return Stream.value([]); } - return context.database.conversationDao + return database.conversationDao .fuzzySearchConversationItemByIds(recentConversationIds) .watchWithStream( eventStreams: [ @@ -112,7 +117,7 @@ _SearchState _useSearchState({ duration: kSlowThrottleDuration, ); } - return context.database.conversationDao + return database.conversationDao .fuzzySearchConversationItem(keyword) .watchWithStream( eventStreams: [ @@ -149,6 +154,7 @@ _NavigationState _useNavigationState({ required ScrollController scrollController, required List items, required BuildContext context, + required ProviderContainer container, }) { final selectedIndex = useState(0); @@ -179,9 +185,9 @@ _NavigationState _useNavigationState({ final item = items[selectedIndex.value]; if (item.type == 'GROUP' || item.type == 'CONTACT') { - ConversationStateNotifier.selectConversation(context, item.id); + ConversationStateNotifier.selectConversation(container, context, item.id); } else { - ConversationStateNotifier.selectUser(context, item.id); + ConversationStateNotifier.selectUser(container, context, item.id); } Navigator.pop(context); }, [items]); @@ -195,9 +201,10 @@ _NavigationState _useNavigationState({ } class CommandPaletteAction extends Action { - CommandPaletteAction(this.context); + CommandPaletteAction(this.context, this.container); final BuildContext context; + final ProviderContainer container; static bool _commandPaletteShowing = false; @override @@ -220,6 +227,10 @@ class CommandPalettePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final database = ref.read(databaseProvider).requireValue; + final selfUserId = ref.read(accountServerProvider).requireValue.userId; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final scrollController = useScrollController(); final textEditingController = useTextEditingController(); final stream = useValueNotifierConvertSteam(textEditingController); @@ -233,15 +244,17 @@ class CommandPalettePage extends HookConsumerWidget { ''; final searchState = _useSearchState( - context: context, ref: ref, keyword: keyword, + database: database, + selfUserId: selfUserId, ); final navigationState = _useNavigationState( scrollController: scrollController, items: searchState.items, context: context, + container: ref.container, ); return FocusableActionDetector( @@ -314,14 +327,16 @@ class CommandPalettePage extends HookConsumerWidget { height: 80, width: 80, colorFilter: ColorFilter.mode( - context.theme.secondaryText, + theme.secondaryText, BlendMode.srcIn, ), ), const SizedBox(height: 20), Text( - context.l10n.noResults, - style: TextStyle(color: context.theme.secondaryText), + l10n.noResults, + style: TextStyle( + color: theme.secondaryText, + ), ), ], ), diff --git a/lib/widgets/actions/create_circle_action.dart b/lib/widgets/actions/create_circle_action.dart index ff895681cc..c6070f421c 100644 --- a/lib/widgets/actions/create_circle_action.dart +++ b/lib/widgets/actions/create_circle_action.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; +import '../../account/account_server.dart'; import '../../utils/extension/extension.dart'; import '../dialog.dart'; import '../toast.dart'; @@ -8,17 +9,23 @@ import '../user_selector/conversation_selector.dart'; import 'actions.dart'; class CreateCircleAction extends Action { - CreateCircleAction(this.context); + CreateCircleAction({ + required this.context, + required this.accountServer, + required this.l10n, + }); final BuildContext context; + final AccountServer accountServer; + final Localization l10n; @override Future invoke(CreateCircleIntent intent) async { final name = await showMixinDialog( context: context, child: EditDialog( - title: Text(context.l10n.circles), - hintText: context.l10n.editCircleName, + title: Text(l10n.circles), + hintText: l10n.editCircleName, maxLength: 64, ), ); @@ -28,7 +35,7 @@ class CreateCircleAction extends Action { final list = await showConversationSelector( context: context, singleSelect: false, - title: context.l10n.createCircle, + title: l10n.createCircle, onlyContact: false, allowEmpty: true, ); @@ -36,7 +43,7 @@ class CreateCircleAction extends Action { if (list == null) return; await runFutureWithToast( - context.accountServer.createCircle( + accountServer.createCircle( name!, list .map( diff --git a/lib/widgets/actions/create_conversation_action.dart b/lib/widgets/actions/create_conversation_action.dart index abb8c67db0..7945d0b815 100644 --- a/lib/widgets/actions/create_conversation_action.dart +++ b/lib/widgets/actions/create_conversation_action.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../ui/provider/conversation_provider.dart'; import '../../utils/extension/extension.dart'; @@ -6,16 +7,22 @@ import '../user_selector/conversation_selector.dart'; import 'actions.dart'; class CreateConversationAction extends Action { - CreateConversationAction(this.context); + CreateConversationAction({ + required this.context, + required this.container, + required this.l10n, + }); final BuildContext context; + final ProviderContainer container; + final Localization l10n; @override Object? invoke(CreateConversationIntent intent) async { final list = await showConversationSelector( context: context, singleSelect: true, - title: context.l10n.createConversation, + title: l10n.createConversation, onlyContact: true, ); if (list == null || list.isEmpty || (list.first.userId?.isEmpty ?? true)) { @@ -23,6 +30,6 @@ class CreateConversationAction extends Action { } final userId = list.first.userId!; - await ConversationStateNotifier.selectUser(context, userId); + await ConversationStateNotifier.selectUser(container, context, userId); } } diff --git a/lib/widgets/actions/create_group_conversation_action.dart b/lib/widgets/actions/create_group_conversation_action.dart index a9fdd3992d..bfa343a72d 100644 --- a/lib/widgets/actions/create_group_conversation_action.dart +++ b/lib/widgets/actions/create_group_conversation_action.dart @@ -4,7 +4,10 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../../account/account_server.dart'; import '../../db/mixin_database.dart'; +import '../../ui/provider/database_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../avatar_view/avatar_view.dart'; @@ -15,16 +18,22 @@ import 'actions.dart'; class CreateGroupConversationAction extends Action { - CreateGroupConversationAction(this.context); + CreateGroupConversationAction({ + required this.context, + required this.accountServer, + required this.l10n, + }); final BuildContext context; + final AccountServer accountServer; + final Localization l10n; @override Future invoke(CreateGroupConversationIntent intent) async { final result = await showConversationSelector( context: context, singleSelect: false, - title: context.l10n.createGroup, + title: l10n.createGroup, onlyContact: true, ); if (result == null || result.isEmpty) return; @@ -36,14 +45,14 @@ class CreateGroupConversationAction final name = await showMixinDialog( context: context, child: _NewConversationConfirm([ - context.accountServer.userId, + accountServer.userId, ...userIds, ]), ); if (name?.isEmpty ?? true) return; await runFutureWithToast( - context.accountServer.createGroupConversation(name!, userIds), + accountServer.createGroupConversation(name!, userIds), ); } } @@ -55,8 +64,11 @@ class _NewConversationConfirm extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + final database = ref.read(databaseProvider).requireValue; final users = useMemoizedFuture( - () => context.database.userDao + () => database.userDao .usersByIn(userIds.sublist(0, math.min(4, userIds.length))) .get(), [], @@ -65,7 +77,7 @@ class _NewConversationConfirm extends HookConsumerWidget { final textEditingController = useTextEditingController(); final textEditingValue = useValueListenable(textEditingController); return AlertDialogLayout( - title: Text(context.l10n.groups), + title: Text(l10n.groups), titleMarginBottom: 24, content: Column( mainAxisSize: MainAxisSize.min, @@ -79,13 +91,16 @@ class _NewConversationConfirm extends HookConsumerWidget { ), const SizedBox(height: 8), Text( - context.l10n.participantsCount(userIds.length), - style: TextStyle(fontSize: 14, color: context.theme.secondaryText), + l10n.participantsCount(userIds.length), + style: TextStyle( + fontSize: 14, + color: brightnessTheme.secondaryText, + ), ), const SizedBox(height: 48), DialogTextField( textEditingController: textEditingController, - hintText: context.l10n.groupName, + hintText: l10n.groupName, maxLength: 40, ), ], @@ -94,12 +109,12 @@ class _NewConversationConfirm extends HookConsumerWidget { MixinButton( backgroundTransparent: true, onTap: () => Navigator.pop(context), - child: Text(context.l10n.cancel), + child: Text(l10n.cancel), ), MixinButton( disable: textEditingValue.text.isEmpty, onTap: () => Navigator.pop(context, textEditingController.text), - child: Text(context.l10n.create), + child: Text(l10n.create), ), ], ); diff --git a/lib/widgets/app_bar.dart b/lib/widgets/app_bar.dart index 0445c15285..621a7195b5 100644 --- a/lib/widgets/app_bar.dart +++ b/lib/widgets/app_bar.dart @@ -1,10 +1,11 @@ import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/ui_context_providers.dart'; import 'buttons.dart'; import 'window/move_window.dart'; -class MixinAppBar extends StatelessWidget implements PreferredSizeWidget { +class MixinAppBar extends ConsumerWidget implements PreferredSizeWidget { const MixinAppBar({ super.key, this.title, @@ -19,11 +20,12 @@ class MixinAppBar extends StatelessWidget implements PreferredSizeWidget { final Widget? leading; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final actionTextStyle = TextStyle( fontSize: 16, fontWeight: FontWeight.w500, - color: context.theme.accent, + color: theme.accent, ); return MoveWindow( child: AppBar( @@ -34,7 +36,7 @@ class MixinAppBar extends StatelessWidget implements PreferredSizeWidget { style: TextStyle( fontSize: 16, fontWeight: FontWeight.w600, - color: context.theme.text, + color: theme.text, ), child: title!, ), @@ -48,7 +50,7 @@ class MixinAppBar extends StatelessWidget implements PreferredSizeWidget { ], elevation: 0, centerTitle: true, - backgroundColor: backgroundColor ?? context.theme.primary, + backgroundColor: backgroundColor ?? theme.primary, leading: MoveWindowBarrier( child: Builder( builder: (context) => diff --git a/lib/widgets/auth.dart b/lib/widgets/auth.dart index 4f0d54fdde..a3b05edc4b 100644 --- a/lib/widgets/auth.dart +++ b/lib/widgets/auth.dart @@ -12,17 +12,24 @@ import 'package:rxdart/rxdart.dart'; import '../account/security_key_value.dart'; import '../constants/resources.dart'; import '../ui/provider/account_server_provider.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../utils/app_lifecycle.dart'; import '../utils/authentication.dart'; import '../utils/event_bus.dart'; -import '../utils/extension/extension.dart'; -import '../utils/hook.dart'; import 'dialog.dart'; const _lockDuration = Duration(minutes: 1); enum LockEvent { lock, unlock } +final _hasPasscodeProvider = StreamProvider.autoDispose( + (ref) => SecurityKeyValue.instance.watchHasPasscode(), +); + +final _biometricEnabledProvider = StreamProvider.autoDispose( + (ref) => SecurityKeyValue.instance.watchBiometric(), +); + class AuthGuard extends HookConsumerWidget { const AuthGuard({required this.child, super.key}); @@ -47,15 +54,17 @@ class _AuthGuard extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); final focusNode = useFocusNode(); final textEditingController = useTextEditingController(); final hasPasscode = - useMemoizedStream(SecurityKeyValue.instance.watchHasPasscode).data ?? + ref.watch(_hasPasscodeProvider).value ?? SecurityKeyValue.instance.hasPasscode; final enableBiometric = - useMemoizedStream(SecurityKeyValue.instance.watchBiometric).data ?? + ref.watch(_biometricEnabledProvider).value ?? SecurityKeyValue.instance.biometric; final hasError = useState(false); @@ -154,16 +163,16 @@ class _AuthGuard extends HookConsumerWidget { width: 68, height: 68, colorFilter: ColorFilter.mode( - context.theme.icon, + brightnessTheme.icon, BlendMode.srcIn, ), ), const SizedBox(height: 24), Text( - context.l10n.unlockWithWasscode, + l10n.unlockWithWasscode, textAlign: TextAlign.center, style: TextStyle( - color: context.theme.text, + color: brightnessTheme.text, fontSize: 20, fontWeight: FontWeight.w600, ), @@ -180,9 +189,9 @@ class _AuthGuard extends HookConsumerWidget { FilteringTextInputFormatter.digitsOnly, ], pinTheme: PinTheme( - activeColor: context.theme.text, - inactiveColor: context.theme.text, - selectedColor: context.theme.text, + activeColor: brightnessTheme.text, + inactiveColor: brightnessTheme.text, + selectedColor: brightnessTheme.text, fieldWidth: 15, borderWidth: 1, shape: PinCodeFieldShape.circle, @@ -191,7 +200,7 @@ class _AuthGuard extends HookConsumerWidget { autoDisposeControllers: false, obscuringWidget: Container( decoration: BoxDecoration( - color: context.theme.text, + color: brightnessTheme.text, shape: BoxShape.circle, ), ), @@ -218,10 +227,10 @@ class _AuthGuard extends HookConsumerWidget { maintainAnimation: true, maintainState: true, child: Text( - context.l10n.passcodeIncorrect, + l10n.passcodeIncorrect, textAlign: TextAlign.center, style: TextStyle( - color: context.theme.red, + color: brightnessTheme.red, fontSize: 16, fontWeight: FontWeight.w400, ), @@ -240,8 +249,10 @@ class _AuthGuard extends HookConsumerWidget { } }, child: Text( - context.l10n.useBiometric, - style: TextStyle(color: context.theme.accent), + l10n.useBiometric, + style: TextStyle( + color: brightnessTheme.accent, + ), ), ), ), diff --git a/lib/widgets/avatar_view/avatar_view.dart b/lib/widgets/avatar_view/avatar_view.dart index e65163e94b..8e5e04c14c 100644 --- a/lib/widgets/avatar_view/avatar_view.dart +++ b/lib/widgets/avatar_view/avatar_view.dart @@ -7,6 +7,8 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide User; import '../../db/dao/conversation_dao.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; +import '../../ui/provider/database_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/color_utils.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; @@ -36,6 +38,7 @@ class ConversationAvatarWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final database = ref.read(databaseProvider).requireValue; final _conversationId = conversation?.conversationId ?? conversationId; assert(_conversationId != null); final _name = conversation?.name ?? fullName; @@ -50,7 +53,7 @@ class ConversationAvatarWidget extends HookConsumerWidget { useMemoizedStream( () { if (_category == ConversationCategory.group) { - return context.database.participantDao + return database.participantDao .participantsAvatar(_conversationId!) .watchWithStream( eventStreams: [ @@ -63,7 +66,7 @@ class ConversationAvatarWidget extends HookConsumerWidget { } return const Stream>.empty(); }, - keys: [_conversationId, _category], + keys: [_conversationId, _category, database], initialData: [], ).data ?? []; @@ -149,7 +152,7 @@ class AvatarPuzzlesWidget extends HookConsumerWidget { ); } -class AvatarWidget extends StatelessWidget { +class AvatarWidget extends ConsumerWidget { const AvatarWidget({ required this.size, required this.avatarUrl, @@ -166,14 +169,15 @@ class AvatarWidget extends StatelessWidget { final bool clipOval; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final placeholder = SizedBox.fromSize( size: Size.square(size), child: DecoratedBox( decoration: BoxDecoration( color: userId != null ? getAvatarColorById(userId!) - : context.theme.listSelected, + : theme.listSelected, ), child: Center( child: Text( diff --git a/lib/widgets/az_selection.dart b/lib/widgets/az_selection.dart index bbc086196f..b165c0e120 100644 --- a/lib/widgets/az_selection.dart +++ b/lib/widgets/az_selection.dart @@ -16,7 +16,7 @@ class AZSelection extends SingleChildRenderObjectWidget { @override RenderObject createRenderObject(BuildContext context) => AZRender() ..onSelection = onSelection - ..textStyle = textStyle ?? Theme.of(context).textTheme.bodyLarge; + ..textStyle = textStyle; @override void updateRenderObject( @@ -25,7 +25,7 @@ class AZSelection extends SingleChildRenderObjectWidget { ) { renderObject ..onSelection = onSelection - ..textStyle = textStyle ?? Theme.of(context).textTheme.bodyLarge; + ..textStyle = textStyle; } } diff --git a/lib/widgets/brightness_observer.dart b/lib/widgets/brightness_observer.dart index dba48f033b..bb56face87 100644 --- a/lib/widgets/brightness_observer.dart +++ b/lib/widgets/brightness_observer.dart @@ -25,7 +25,8 @@ class BrightnessObserver extends HookConsumerWidget { final Brightness? forceBrightness; Brightness _getBrightness(BuildContext context) => - forceBrightness ?? MediaQuery.platformBrightnessOf(context); + forceBrightness ?? + WidgetsBinding.instance.platformDispatcher.platformBrightness; @override Widget build(BuildContext context, WidgetRef ref) { diff --git a/lib/widgets/buttons.dart b/lib/widgets/buttons.dart index 9f38be5d75..d7da0ebe07 100644 --- a/lib/widgets/buttons.dart +++ b/lib/widgets/buttons.dart @@ -1,42 +1,49 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../constants/resources.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/ui_context_providers.dart'; import 'action_button.dart'; -class MixinBackButton extends StatelessWidget { +class MixinBackButton extends ConsumerWidget { const MixinBackButton({super.key, this.color, this.onTap}); final Color? color; final VoidCallback? onTap; @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.only(right: 8), - child: ActionButton( - name: Resources.assetsImagesIcBackSvg, - color: color ?? context.theme.icon, - onTap: () { - if (onTap != null) return onTap?.call(); - Navigator.pop(context); - }, - ), - ); + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Padding( + padding: const EdgeInsets.only(right: 8), + child: ActionButton( + name: Resources.assetsImagesIcBackSvg, + color: color ?? theme.icon, + onTap: () { + if (onTap != null) return onTap?.call(); + Navigator.pop(context); + }, + ), + ); + } } -class MixinCloseButton extends StatelessWidget { +class MixinCloseButton extends ConsumerWidget { const MixinCloseButton({super.key, this.onTap, this.color}); final VoidCallback? onTap; final Color? color; @override - Widget build(BuildContext context) => ActionButton( - name: Resources.assetsImagesIcCloseSvg, - color: color ?? context.theme.icon, - onTap: onTap ?? () => Navigator.pop(context), - ); + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return ActionButton( + name: Resources.assetsImagesIcCloseSvg, + color: color ?? theme.icon, + onTap: onTap ?? () => Navigator.pop(context), + ); + } } class NTapGestureDetector extends HookWidget { diff --git a/lib/widgets/cell.dart b/lib/widgets/cell.dart index 93aedce980..2e3e6f8921 100644 --- a/lib/widgets/cell.dart +++ b/lib/widgets/cell.dart @@ -4,10 +4,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../constants/resources.dart'; import '../ui/provider/responsive_navigator_provider.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/ui_context_providers.dart'; import 'interactive_decorated_box.dart'; -class CellGroup extends StatelessWidget { +class CellGroup extends ConsumerWidget { const CellGroup({ required this.child, super.key, @@ -23,19 +23,22 @@ class CellGroup extends StatelessWidget { final Color? cellBackgroundColor; @override - Widget build(BuildContext context) => ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: Padding( - padding: padding, - child: ClipRRect( - borderRadius: borderRadius, - child: _CellItemStyle( - backgroundColor: cellBackgroundColor ?? context.theme.listSelected, - child: child, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Padding( + padding: padding, + child: ClipRRect( + borderRadius: borderRadius, + child: _CellItemStyle( + backgroundColor: cellBackgroundColor ?? theme.listSelected, + child: child, + ), ), ), - ), - ); + ); + } } class _CellItemStyle extends InheritedWidget { @@ -76,14 +79,17 @@ class CellItem extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final dynamicColor = color ?? context.theme.text; + final theme = ref.watch(brightnessThemeDataProvider); + final dynamicColor = color ?? theme.text; final backgroundColor = _CellItemStyle.of(context).backgroundColor; var selectedBackgroundColor = backgroundColor; if (selected && !ref.watch(navigatorRouteModeProvider)) { selectedBackgroundColor = Color.alphaBlend( - context.dynamicColor( - const Color.fromRGBO(0, 0, 0, 0.05), - darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(0, 0, 0, 0.05), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + )), ), backgroundColor, ); @@ -112,7 +118,7 @@ class CellItem extends HookConsumerWidget { if (description != null) DefaultTextStyle.merge( style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 14, ), child: description!, @@ -126,14 +132,20 @@ class CellItem extends HookConsumerWidget { } } -class Arrow extends StatelessWidget { +class Arrow extends ConsumerWidget { const Arrow({super.key}); @override - Widget build(BuildContext context) => SvgPicture.asset( - Resources.assetsImagesIcArrowRightSvg, - colorFilter: ColorFilter.mode(context.theme.secondaryText, BlendMode.srcIn), - width: 30, - height: 30, - ); + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return SvgPicture.asset( + Resources.assetsImagesIcArrowRightSvg, + colorFilter: ColorFilter.mode( + theme.secondaryText, + BlendMode.srcIn, + ), + width: 30, + height: 30, + ); + } } diff --git a/lib/widgets/conversation/conversation_dialog.dart b/lib/widgets/conversation/conversation_dialog.dart index 79bd9d9a12..d567092c02 100644 --- a/lib/widgets/conversation/conversation_dialog.dart +++ b/lib/widgets/conversation/conversation_dialog.dart @@ -4,8 +4,12 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide User; +import '../../account/account_server.dart'; +import '../../db/database.dart'; import '../../db/mixin_database.dart'; +import '../../ui/provider/account_server_provider.dart'; import '../../ui/provider/conversation_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../avatar_view/avatar_view.dart'; import '../buttons.dart'; @@ -15,24 +19,29 @@ import '../toast.dart'; Future showConversationDialog( BuildContext context, + ProviderContainer container, ConversationResponse conversationResponse, - String code, -) async { - final localExisted = await context.database.conversationDao.hasConversation( + String code, { + required Database database, + required AccountServer accountServer, + required Account? account, + required Localization l10n, +}) async { + final localExisted = await database.conversationDao.hasConversation( conversationResponse.conversationId, ); if (!localExisted) { - await context.accountServer.refreshConversation( + await accountServer.refreshConversation( conversationResponse.conversationId, ); } - final existed = conversationResponse.participants.any( - (element) => element.userId == context.account?.userId, + (element) => element.userId == account?.userId, ); if (existed) { - showToast(context.l10n.groupAlreadyIn); + showToast(l10n.groupAlreadyIn); await ConversationStateNotifier.selectConversation( + container, context, conversationResponse.conversationId, ); @@ -43,7 +52,7 @@ Future showConversationDialog( .sublist(0, min(conversationResponse.participants.length, 4)) .map((e) => e.userId) .toList(); - final users = await context.accountServer.refreshUsers(userIds); + final users = await accountServer.refreshUsers(userIds); Toast.dismiss(); await showMixinDialog( @@ -108,41 +117,51 @@ class _ConversationInfo extends HookConsumerWidget { final String code; @override - Widget build(BuildContext context, WidgetRef ref) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - ClipOval( - child: SizedBox.square( - dimension: 90, - child: AvatarPuzzlesWidget(users, 90), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + ClipOval( + child: SizedBox.square( + dimension: 90, + child: AvatarPuzzlesWidget(users, 90), + ), ), - ), - const SizedBox(height: 8), - CustomSelectableText( - conversationResponse.name, - style: TextStyle(color: context.theme.text, fontSize: 16, height: 1), - textAlign: TextAlign.center, - ), - const SizedBox(height: 4), - CustomSelectableText( - context.l10n.participantsCount( - conversationResponse.participants.length, + const SizedBox(height: 8), + CustomSelectableText( + conversationResponse.name, + style: TextStyle( + color: brightnessTheme.text, + fontSize: 16, + height: 1, + ), + textAlign: TextAlign.center, ), - style: TextStyle(color: context.theme.secondaryText, fontSize: 12), - ), - const SizedBox(height: 8), - DialogAddOrJoinButton( - onTap: () => runFutureWithToast(() async { - await context.accountServer.joinGroup(code); - await ConversationStateNotifier.selectConversation( - context, - conversationResponse.conversationId, - ); - Navigator.pop(context); - }()), - title: Text(context.l10n.joinGroupWithPlus), - ), - const SizedBox(height: 56), - ], - ); + const SizedBox(height: 4), + CustomSelectableText( + l10n.participantsCount(conversationResponse.participants.length), + style: TextStyle( + color: brightnessTheme.secondaryText, + fontSize: 12, + ), + ), + const SizedBox(height: 8), + DialogAddOrJoinButton( + onTap: () => runFutureWithToast(() async { + await ref.read(accountServerProvider).requireValue.joinGroup(code); + await ConversationStateNotifier.selectConversation( + ref.container, + context, + conversationResponse.conversationId, + ); + Navigator.pop(context); + }()), + title: Text(l10n.joinGroupWithPlus), + ), + const SizedBox(height: 56), + ], + ); + } } diff --git a/lib/widgets/conversation/mute_dialog.dart b/lib/widgets/conversation/mute_dialog.dart index b0293be552..653de3a997 100644 --- a/lib/widgets/conversation/mute_dialog.dart +++ b/lib/widgets/conversation/mute_dialog.dart @@ -2,7 +2,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../utils/extension/extension.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../dialog.dart'; import '../radio.dart'; @@ -11,18 +11,19 @@ class MuteDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); final result = useState(null); return AlertDialogLayout( - title: Text(context.l10n.contactMuteTitle), + title: Text(l10n.contactMuteTitle), content: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, children: [ - (context.l10n.oneHour, 1 * 60 * 60), - (context.l10n.hour(8, 8), 8 * 60 * 60), - (context.l10n.oneWeek, 7 * 24 * 60 * 60), - (context.l10n.oneYear, 365 * 24 * 60 * 60), + (l10n.oneHour, 1 * 60 * 60), + (l10n.hour(8, 8), 8 * 60 * 60), + (l10n.oneWeek, 7 * 24 * 60 * 60), + (l10n.oneYear, 365 * 24 * 60 * 60), ] .map( (e) => RadioItem( @@ -39,11 +40,11 @@ class MuteDialog extends HookConsumerWidget { MixinButton( backgroundTransparent: true, onTap: () => Navigator.pop(context), - child: Text(context.l10n.cancel), + child: Text(l10n.cancel), ), MixinButton( onTap: () => Navigator.pop(context, result.value), - child: Text(context.l10n.confirm), + child: Text(l10n.confirm), ), ], ); diff --git a/lib/widgets/dialog.dart b/lib/widgets/dialog.dart index 863444ce9c..986acaa7bb 100644 --- a/lib/widgets/dialog.dart +++ b/lib/widgets/dialog.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../utils/extension/extension.dart'; import '../utils/hook.dart'; import '../utils/system/text_input.dart'; @@ -36,7 +37,7 @@ Future _showDialog({ ), ), barrierDismissible: barrierDismissible, - barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierLabel: Localization.current.close, barrierColor: barrierColor, transitionDuration: const Duration(milliseconds: 80), useRootNavigator: useRootNavigator, @@ -76,7 +77,7 @@ Future showMixinDialog({ ), ); -class AlertDialogLayout extends StatelessWidget { +class AlertDialogLayout extends ConsumerWidget { const AlertDialogLayout({ required this.content, super.key, @@ -99,50 +100,56 @@ class AlertDialogLayout extends StatelessWidget { final double? maxWidth; @override - Widget build(BuildContext context) => Material( - color: Colors.transparent, - child: ConstrainedBox( - constraints: BoxConstraints( - minWidth: minWidth, - minHeight: minHeight, - maxWidth: maxWidth ?? double.infinity, - ), - child: Padding( - padding: padding, - child: IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - if (title != null) + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Material( + color: Colors.transparent, + child: ConstrainedBox( + constraints: BoxConstraints( + minWidth: minWidth, + minHeight: minHeight, + maxWidth: maxWidth ?? double.infinity, + ), + child: Padding( + padding: padding, + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + if (title != null) + DefaultTextStyle.merge( + style: TextStyle( + fontSize: 16, + color: theme.text, + ), + child: title!, + ), + if (title != null) SizedBox(height: titleMarginBottom), DefaultTextStyle.merge( - style: TextStyle(fontSize: 16, color: context.theme.text), - child: title!, + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w600, + color: theme.text, + ), + child: content, ), - if (title != null) SizedBox(height: titleMarginBottom), - DefaultTextStyle.merge( - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w600, - color: context.theme.text, + const SizedBox(height: 30), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: actions.joinList(const SizedBox(width: 4)), ), - child: content, - ), - const SizedBox(height: 30), - Row( - mainAxisAlignment: MainAxisAlignment.end, - children: actions.joinList(const SizedBox(width: 4)), - ), - ], + ], + ), ), ), ), - ), - ); + ); + } } -class _DialogPage extends StatelessWidget { +class _DialogPage extends ConsumerWidget { const _DialogPage({ required this.child, this.padding, @@ -156,7 +163,9 @@ class _DialogPage extends StatelessWidget { final Color? backgroundColor; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final brightness = ref.watch(brightnessValueProvider); final effectivePadding = MediaQuery.viewInsetsOf(context) + (padding ?? EdgeInsets.zero); return Padding( @@ -175,12 +184,12 @@ class _DialogPage extends StatelessWidget { BoxShadow( color: const Color.fromRGBO(0, 0, 0, 0.07), offset: const Offset(0, 4), - blurRadius: lerpDouble(16, 6, context.brightnessValue)!, + blurRadius: lerpDouble(16, 6, brightness)!, ), ], ), child: Material( - color: backgroundColor ?? context.theme.popUp, + color: backgroundColor ?? theme.popUp, borderRadius: const BorderRadius.all(Radius.circular(11)), child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(11)), @@ -203,11 +212,11 @@ abstract class DialogInteracterEntry extends StatelessWidget { } /// default onTap is Navigator.pop -class MixinButton extends DialogInteracterEntry { +class MixinButton extends ConsumerWidget { const MixinButton({ required this.child, super.key, - super.value, + this.value, this.backgroundTransparent = false, this.onTap, this.padding = const EdgeInsets.symmetric(vertical: 8, horizontal: 16), @@ -221,23 +230,30 @@ class MixinButton extends DialogInteracterEntry { final EdgeInsetsGeometry padding; final bool disable; final Color? backgroundColor; + final T? value; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final dynamicWhite = ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(255, 255, 255, 1), + darkColor: null, + )), + ); final boxDecoration = backgroundTransparent ? const BoxDecoration() : BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(5)), - color: backgroundColor ?? context.theme.accent, + color: backgroundColor ?? theme.accent, ); - final textColor = backgroundTransparent - ? context.theme.accent - : context.dynamicColor(const Color.fromRGBO(255, 255, 255, 1)); + final textColor = backgroundTransparent ? theme.accent : dynamicWhite; return Disable( disable: disable, child: InteractiveDecoratedBox.color( decoration: boxDecoration, - onTap: () => onTap != null ? onTap?.call() : handleTap(context), + onTap: () => + onTap != null ? onTap?.call() : Navigator.pop(context, value), child: DefaultTextStyle.merge( style: TextStyle( fontWeight: FontWeight.w500, @@ -270,6 +286,7 @@ class DialogTextField extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final textStream = useValueNotifierConvertSteam(textEditingController); final hasText = useMemoizedStream( @@ -280,7 +297,7 @@ class DialogTextField extends HookConsumerWidget { constraints: const BoxConstraints(minHeight: 48), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), decoration: BoxDecoration( - color: context.theme.background, + color: theme.background, borderRadius: const BorderRadius.all(Radius.circular(5)), ), alignment: Alignment.center, @@ -289,7 +306,7 @@ class DialogTextField extends HookConsumerWidget { TextField( autofocus: true, controller: textEditingController, - style: TextStyle(color: context.theme.text), + style: TextStyle(color: theme.text), maxLines: maxLines ?? 1, minLines: 1, maxLength: maxLength, @@ -301,7 +318,7 @@ class DialogTextField extends HookConsumerWidget { enabledBorder: InputBorder.none, counterStyle: TextStyle( fontSize: 14, - color: context.theme.secondaryText, + color: theme.secondaryText, ), ), inputFormatters: inputFormatters, @@ -341,47 +358,51 @@ Future showConfirmMixinDialog( }) => showMixinDialog( context: context, barrierDismissible: barrierDismissible, - child: Builder( - builder: (context) => AlertDialogLayout( - maxWidth: maxWidth, - content: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(content), - if (description != null) - Padding( - padding: const EdgeInsets.only(top: 20), - child: Text( - description, - style: TextStyle( - color: context.theme.text, - fontSize: 14, - fontWeight: FontWeight.normal, + child: Consumer( + builder: (context, ref, child) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return AlertDialogLayout( + maxWidth: maxWidth, + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(content), + if (description != null) + Padding( + padding: const EdgeInsets.only(top: 20), + child: Text( + description, + style: TextStyle( + color: theme.text, + fontSize: 14, + fontWeight: FontWeight.normal, + ), ), ), + ], + ), + actions: [ + if (neutralText != null) ...[ + MixinButton( + onTap: () => Navigator.pop(context, DialogEvent.neutral), + child: Text(neutralText), ), - ], - ), - actions: [ - if (neutralText != null) ...[ + const Spacer(), + ], + MixinButton( + backgroundTransparent: true, + onTap: () => Navigator.pop(context), + child: Text(negativeText ?? l10n.cancel), + ), MixinButton( - onTap: () => Navigator.pop(context, DialogEvent.neutral), - child: Text(neutralText), + onTap: () => Navigator.pop(context, DialogEvent.positive), + child: Text(positiveText ?? l10n.confirm), ), - const Spacer(), ], - MixinButton( - backgroundTransparent: true, - onTap: () => Navigator.pop(context), - child: Text(negativeText ?? context.l10n.cancel), - ), - MixinButton( - onTap: () => Navigator.pop(context, DialogEvent.positive), - child: Text(positiveText ?? context.l10n.confirm), - ), - ], - ), + ); + }, ), ); @@ -409,6 +430,7 @@ class EditDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); final textEditingController = useMemoized( () => EmojiTextEditingController(text: editText), ); @@ -425,19 +447,19 @@ class EditDialog extends HookConsumerWidget { MixinButton( backgroundTransparent: true, onTap: () => Navigator.pop(context), - child: Text(context.l10n.cancel), + child: Text(l10n.cancel), ), MixinButton( disable: textEditingValue.text.isEmpty, onTap: () => Navigator.pop(context, textEditingController.text), - child: Text(positiveAction ?? context.l10n.create), + child: Text(positiveAction ?? l10n.create), ), ], ); } } -class DialogAddOrJoinButton extends StatelessWidget { +class DialogAddOrJoinButton extends ConsumerWidget { const DialogAddOrJoinButton({ required this.onTap, required this.title, @@ -448,18 +470,24 @@ class DialogAddOrJoinButton extends StatelessWidget { final Widget title; @override - Widget build(BuildContext context) => TextButton( - style: TextButton.styleFrom( - backgroundColor: context.theme.statusBackground, - padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 7), - shape: const RoundedRectangleBorder( - borderRadius: BorderRadius.all(Radius.circular(15)), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return TextButton( + style: TextButton.styleFrom( + backgroundColor: theme.statusBackground, + padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 7), + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(15)), + ), ), - ), - onPressed: onTap, - child: DefaultTextStyle.merge( - style: TextStyle(fontSize: 12, color: context.theme.accent), - child: title, - ), - ); + onPressed: onTap, + child: DefaultTextStyle.merge( + style: TextStyle( + fontSize: 12, + color: theme.accent, + ), + child: title, + ), + ); + } } diff --git a/lib/widgets/empty.dart b/lib/widgets/empty.dart index 2fc8b0a184..97930a7041 100644 --- a/lib/widgets/empty.dart +++ b/lib/widgets/empty.dart @@ -1,14 +1,21 @@ import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/ui_context_providers.dart'; -class Empty extends StatelessWidget { +class Empty extends ConsumerWidget { const Empty({required this.text, super.key}); final String text; @override - Widget build(BuildContext context) => Center( - child: Text(text, style: TextStyle(color: context.theme.secondaryText)), - ); + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Center( + child: Text( + text, + style: TextStyle(color: theme.secondaryText), + ), + ); + } } diff --git a/lib/widgets/full_screen_portal.dart b/lib/widgets/full_screen_portal.dart index 1b3cb2d521..97a85aae25 100644 --- a/lib/widgets/full_screen_portal.dart +++ b/lib/widgets/full_screen_portal.dart @@ -1,16 +1,10 @@ import 'package:flutter/widgets.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../bloc/simple_cubit.dart'; -import '../utils/hook.dart'; import 'menu.dart'; -class FullScreenVisibleCubit extends SimpleCubit { - FullScreenVisibleCubit(super.state); -} - class FullScreenPortal extends HookConsumerWidget { const FullScreenPortal({ required this.builder, @@ -28,31 +22,25 @@ class FullScreenPortal extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final visibleBloc = useBloc( - () => FullScreenVisibleCubit(false), - ); - final visible = useBlocState( - bloc: visibleBloc, - ); - return BlocProvider.value( - value: visibleBloc, - child: Barrier( - duration: duration, - visible: visible, - onClose: () => visibleBloc.emit(false), - child: PortalTarget( - closeDuration: duration, - visible: visible, - portalFollower: TweenAnimationBuilder( - duration: duration, - tween: Tween(begin: 0, end: visible ? 1 : 0), - curve: curve, - builder: (context, progress, child) => - Opacity(opacity: progress, child: child), - child: visible ? Builder(builder: portalBuilder) : const SizedBox(), - ), - child: Builder(builder: builder), + final visible = useState(false); + return Barrier( + duration: duration, + visible: visible.value, + onClose: () => visible.value = false, + child: PortalTarget( + closeDuration: duration, + visible: visible.value, + portalFollower: TweenAnimationBuilder( + duration: duration, + tween: Tween(begin: 0, end: visible.value ? 1 : 0), + curve: curve, + builder: (context, progress, child) => + Opacity(opacity: progress, child: child), + child: visible.value + ? Builder(builder: portalBuilder) + : const SizedBox(), ), + child: Builder(builder: builder), ), ); } diff --git a/lib/widgets/high_light_text.dart b/lib/widgets/high_light_text.dart index dc76caeea5..214cf82664 100644 --- a/lib/widgets/high_light_text.dart +++ b/lib/widgets/high_light_text.dart @@ -13,9 +13,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:url_launcher/url_launcher_string.dart'; import '../db/dao/user_dao.dart'; -import '../ui/provider/conversation_provider.dart'; +import '../db/mixin_database.dart' show App; +import '../ui/provider/ui_context_providers.dart'; import '../utils/emoji.dart'; -import '../utils/extension/extension.dart'; import '../utils/platform.dart'; import '../utils/reg_exp_utils.dart'; import '../utils/uri_utils.dart'; @@ -514,32 +514,39 @@ class _MatchedSpans { } class UrlTextMatcher extends TextMatcher implements EquatableMixin { - UrlTextMatcher(BuildContext context) - : super.textRangesFromText( - textRangesFromText: (text) { - if (kPlatformIsDarwin) { - final dataDetector = DataDetector( - NSTextCheckingType.NSTextCheckingTypeLink, - ); - return dataDetector.matchesInString(text).map((e) => e.range); - } else { - return TextMatcher._textRangesFromText(text, uriRegExp); - } - }, - matchBuilder: (span, displayString, linkString) => TextSpan( - text: displayString, - style: TextStyle(color: context.theme.accent), - mouseCursor: SystemMouseCursors.click, - recognizer: TapGestureRecognizer() - ..onTap = () => openUri( - context, - linkString, - app: context.providerContainer.read( - conversationProvider.select((value) => value?.app), - ), - ), - ), - ); + UrlTextMatcher( + BuildContext context, { + required this.container, + required this.accent, + required this.app, + }) : super.textRangesFromText( + textRangesFromText: (text) { + if (kPlatformIsDarwin) { + final dataDetector = DataDetector( + NSTextCheckingType.NSTextCheckingTypeLink, + ); + return dataDetector.matchesInString(text).map((e) => e.range); + } else { + return TextMatcher._textRangesFromText(text, uriRegExp); + } + }, + matchBuilder: (span, displayString, linkString) => TextSpan( + text: displayString, + style: TextStyle(color: accent), + mouseCursor: SystemMouseCursors.click, + recognizer: TapGestureRecognizer() + ..onTap = () => openUri( + context, + linkString, + container: container, + app: app, + ), + ), + ); + + final ProviderContainer container; + final Color accent; + final App? app; @override List get props => []; @@ -549,18 +556,20 @@ class UrlTextMatcher extends TextMatcher implements EquatableMixin { } class MailTextMatcher extends TextMatcher implements EquatableMixin { - MailTextMatcher(BuildContext context) + MailTextMatcher({required this.accent}) : super.regExp( regExp: mailRegExp, matchBuilder: (span, displayString, linkString) => TextSpan( text: displayString, - style: TextStyle(color: context.theme.accent), + style: TextStyle(color: accent), mouseCursor: SystemMouseCursors.click, recognizer: TapGestureRecognizer() ..onTap = () => launchUrlString(linkString), ), ); + final Color accent; + @override List get props => []; @@ -700,17 +709,28 @@ class MultiKeyWordTextMatcher extends TextMatcher implements EquatableMixin { } class BotNumberTextMatcher extends TextMatcher implements EquatableMixin { - BotNumberTextMatcher(BuildContext context) - : super.regExp( - regExp: botNumberRegExp, - matchBuilder: (span, displayString, linkString) => TextSpan( - text: displayString, - style: TextStyle(color: context.theme.accent), - mouseCursor: SystemMouseCursors.click, - recognizer: TapGestureRecognizer() - ..onTap = () => showUserDialog(context, null, linkString), - ), - ); + BotNumberTextMatcher( + BuildContext context, { + required this.container, + required this.accent, + }) : super.regExp( + regExp: botNumberRegExp, + matchBuilder: (span, displayString, linkString) => TextSpan( + text: displayString, + style: TextStyle(color: accent), + mouseCursor: SystemMouseCursors.click, + recognizer: TapGestureRecognizer() + ..onTap = () => showUserDialog( + context, + container, + null, + linkString, + ), + ), + ); + + final ProviderContainer container; + final Color accent; @override List get props => []; @@ -720,30 +740,37 @@ class BotNumberTextMatcher extends TextMatcher implements EquatableMixin { } class MentionTextMatcher extends TextMatcher implements EquatableMixin { - MentionTextMatcher(BuildContext context, this.map) - : super.regExp( - regExp: mentionNumberRegExp, - matchBuilder: (span, displayString, linkString) { - final mentionUser = map[linkString.substring(1)]; - if (displayString != linkString || mentionUser == null) { - return TextSpan(text: linkString); - } - - return TextSpan( - text: '@${mentionUser.fullName ?? mentionUser.identityNumber}', - style: TextStyle(color: context.theme.accent), - mouseCursor: SystemMouseCursors.click, - recognizer: TapGestureRecognizer() - ..onTap = () => showUserDialog( - context, - null, - mentionUser.identityNumber, - ), - ); - }, - ); + MentionTextMatcher( + BuildContext context, + this.map, { + required this.container, + required this.accent, + }) : super.regExp( + regExp: mentionNumberRegExp, + matchBuilder: (span, displayString, linkString) { + final mentionUser = map[linkString.substring(1)]; + if (displayString != linkString || mentionUser == null) { + return TextSpan(text: linkString); + } + + return TextSpan( + text: '@${mentionUser.fullName ?? mentionUser.identityNumber}', + style: TextStyle(color: accent), + mouseCursor: SystemMouseCursors.click, + recognizer: TapGestureRecognizer() + ..onTap = () => showUserDialog( + context, + container, + null, + mentionUser.identityNumber, + ), + ); + }, + ); final Map map; + final ProviderContainer container; + final Color accent; @override List get props => [map]; @@ -839,13 +866,14 @@ class CustomSelectableArea extends StatelessWidget { ); } -class _SelectionAreaToolbar extends StatelessWidget { +class _SelectionAreaToolbar extends ConsumerWidget { const _SelectionAreaToolbar({required this.state}); final SelectableRegionState state; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final materialL10n = ref.watch(materialLocalizationsProvider); if (!kPlatformIsDesktop) { return AdaptiveTextSelectionToolbar.selectableRegion( selectableRegionState: state, @@ -855,7 +883,7 @@ class _SelectionAreaToolbar extends StatelessWidget { menus: [ if (state.copyEnabled) ContextMenu( - title: MaterialLocalizations.of(context).copyButtonLabel, + title: materialL10n.copyButtonLabel, onTap: () { // ignore: deprecated_member_use state.copySelection(SelectionChangedCause.toolbar); @@ -863,7 +891,7 @@ class _SelectionAreaToolbar extends StatelessWidget { ), if (state.selectAllEnabled) ContextMenu( - title: MaterialLocalizations.of(context).selectAllButtonLabel, + title: materialL10n.selectAllButtonLabel, onTap: state.selectAll, ), ], @@ -872,7 +900,7 @@ class _SelectionAreaToolbar extends StatelessWidget { } } -class MixinAdaptiveSelectionToolbar extends StatelessWidget { +class MixinAdaptiveSelectionToolbar extends ConsumerWidget { const MixinAdaptiveSelectionToolbar({ required this.editableTextState, super.key, @@ -881,34 +909,35 @@ class MixinAdaptiveSelectionToolbar extends StatelessWidget { final EditableTextState editableTextState; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final materialL10n = ref.watch(materialLocalizationsProvider); if (kPlatformIsDesktop) { return _SelectionToolbar( menus: [ if (editableTextState.copyEnabled) ContextMenu( - title: MaterialLocalizations.of(context).copyButtonLabel, + title: materialL10n.copyButtonLabel, onTap: () { editableTextState.copySelection(SelectionChangedCause.toolbar); }, ), if (editableTextState.cutEnabled) ContextMenu( - title: MaterialLocalizations.of(context).cutButtonLabel, + title: materialL10n.cutButtonLabel, onTap: () { editableTextState.cutSelection(SelectionChangedCause.toolbar); }, ), if (editableTextState.selectAllEnabled) ContextMenu( - title: MaterialLocalizations.of(context).selectAllButtonLabel, + title: materialL10n.selectAllButtonLabel, onTap: () { editableTextState.selectAll(SelectionChangedCause.toolbar); }, ), if (editableTextState.pasteEnabled) ContextMenu( - title: MaterialLocalizations.of(context).pasteButtonLabel, + title: materialL10n.pasteButtonLabel, onTap: () { editableTextState.pasteText(SelectionChangedCause.toolbar); }, diff --git a/lib/widgets/markdown.dart b/lib/widgets/markdown.dart index 1e2ff2dc2d..9e8317ea26 100644 --- a/lib/widgets/markdown.dart +++ b/lib/widgets/markdown.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:html/dom.dart' as h; import 'package:html/dom_parsing.dart'; import 'package:html/parser.dart'; @@ -6,18 +7,20 @@ import 'package:markdown/markdown.dart' as m; import 'package:markdown_widget/markdown_widget.dart'; import 'package:mixin_logger/mixin_logger.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../utils/uri_utils.dart'; import 'high_light_text.dart'; import 'mixin_image.dart'; -class MarkdownColumn extends StatelessWidget { +class MarkdownColumn extends ConsumerWidget { const MarkdownColumn({required this.data, super.key}); final String data; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final darkMode = ref.watch(platformBrightnessProvider) == Brightness.dark; final widgets = MarkdownGenerator( textGenerator: (node, config, visitor) => @@ -28,12 +31,14 @@ class MarkdownColumn extends StatelessWidget { data, config: _createMarkdownConfig( context: context, - darkMode: context.brightness == Brightness.dark, + darkMode: darkMode, + textColor: theme.text, + accentColor: theme.accent, ), ); return ClipRect( child: DefaultTextStyle.merge( - style: TextStyle(color: context.theme.text), + style: TextStyle(color: theme.text), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: widgets, @@ -43,7 +48,7 @@ class MarkdownColumn extends StatelessWidget { } } -class Markdown extends StatelessWidget { +class Markdown extends ConsumerWidget { const Markdown({ required this.data, super.key, @@ -56,29 +61,37 @@ class Markdown extends StatelessWidget { final ScrollPhysics? physics; @override - Widget build(BuildContext context) => DefaultTextStyle.merge( - style: TextStyle(color: context.theme.text), - child: MarkdownWidget( - data: data, - padding: padding, - physics: physics, - config: _createMarkdownConfig( - context: context, - darkMode: context.brightness == Brightness.dark, - ), - markdownGenerator: MarkdownGenerator( - textGenerator: (node, config, visitor) => - CustomTextNode(node.textContent, config, visitor), - generators: _kMixinGenerators, - richTextBuilder: CustomText.rich, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final darkMode = ref.watch(platformBrightnessProvider) == Brightness.dark; + return DefaultTextStyle.merge( + style: TextStyle(color: theme.text), + child: MarkdownWidget( + data: data, + padding: padding, + physics: physics, + config: _createMarkdownConfig( + context: context, + darkMode: darkMode, + textColor: theme.text, + accentColor: theme.accent, + ), + markdownGenerator: MarkdownGenerator( + textGenerator: (node, config, visitor) => + CustomTextNode(node.textContent, config, visitor), + generators: _kMixinGenerators, + richTextBuilder: CustomText.rich, + ), ), - ), - ); + ); + } } MarkdownConfig _createMarkdownConfig({ required BuildContext context, required bool darkMode, + required Color textColor, + required Color accentColor, }) => MarkdownConfig( configs: [ if (darkMode) ...[ @@ -111,7 +124,7 @@ MarkdownConfig _createMarkdownConfig({ }, ), LinkConfig( - style: TextStyle(color: context.theme.accent), + style: TextStyle(color: accentColor), onTap: (href) { if (href.isEmpty) return; openUri(context, href); @@ -124,7 +137,7 @@ MarkdownConfig _createMarkdownConfig({ return getDefaultMarker( isOrdered, depth, - context.theme.text, + textColor, index, height / 2 + 1, MarkdownConfig(), diff --git a/lib/widgets/mention_panel.dart b/lib/widgets/mention_panel.dart index ca8f131ee7..3234096cf3 100644 --- a/lib/widgets/mention_panel.dart +++ b/lib/widgets/mention_panel.dart @@ -11,6 +11,7 @@ import '../db/mixin_database.dart' hide Offset; import '../ui/home/intent.dart'; import '../ui/provider/conversation_provider.dart'; import '../ui/provider/mention_provider.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../utils/extension/extension.dart'; import '../utils/platform.dart'; import '../utils/reg_exp_utils.dart'; @@ -32,7 +33,7 @@ class MentionPanelPortalEntry extends HookConsumerWidget { final BoxConstraints constraints; final TextEditingController textEditingController; final Widget child; - final AutoDisposeStateNotifierProvider + final NotifierProvider mentionProviderInstance; @override @@ -142,7 +143,7 @@ class MentionPanelPortalEntry extends HookConsumerWidget { } } -class _MentionPanel extends StatelessWidget { +class _MentionPanel extends ConsumerWidget { const _MentionPanel({ required this.mentionState, required this.onSelect, @@ -154,23 +155,26 @@ class _MentionPanel extends StatelessWidget { final ScrollController scrollController; @override - Widget build(BuildContext context) => DecoratedBox( - decoration: BoxDecoration(color: context.theme.popUp), - child: ListView.builder( - controller: scrollController, - itemCount: mentionState.users.length, - shrinkWrap: true, - itemBuilder: (context, index) => _MentionItem( - user: mentionState.users[index], - keyword: mentionState.text, - selected: mentionState.index == index, - onSelect: onSelect, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return DecoratedBox( + decoration: BoxDecoration(color: theme.popUp), + child: ListView.builder( + controller: scrollController, + itemCount: mentionState.users.length, + shrinkWrap: true, + itemBuilder: (context, index) => _MentionItem( + user: mentionState.users[index], + keyword: mentionState.text, + selected: mentionState.index == index, + onSelect: onSelect, + ), ), - ), - ); + ); + } } -class _MentionItem extends StatelessWidget { +class _MentionItem extends ConsumerWidget { const _MentionItem({ required this.user, this.keyword, @@ -184,67 +188,72 @@ class _MentionItem extends StatelessWidget { final Function(User user)? onSelect; @override - Widget build(BuildContext context) => InteractiveDecoratedBox.color( - decoration: selected - ? BoxDecoration(color: context.theme.listSelected) - : null, - onTap: () => onSelect?.call(user), - child: Container( - height: kMentionItemHeight, - padding: const EdgeInsets.all(8), - child: Row( - children: [ - AvatarWidget( - userId: user.userId, - name: user.fullName, - avatarUrl: user.avatarUrl, - size: 32, - ), - const SizedBox(width: 6), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - CustomText( - user.fullName ?? '', - style: TextStyle( - fontSize: 14, - color: context.theme.text, - height: 1, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return InteractiveDecoratedBox.color( + decoration: selected ? BoxDecoration(color: theme.listSelected) : null, + onTap: () => onSelect?.call(user), + child: Container( + height: kMentionItemHeight, + padding: const EdgeInsets.all(8), + child: Row( + children: [ + AvatarWidget( + userId: user.userId, + name: user.fullName, + avatarUrl: user.avatarUrl, + size: 32, + ), + const SizedBox(width: 6), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + CustomText( + user.fullName ?? '', + style: TextStyle( + fontSize: 14, + color: theme.text, + height: 1, + ), + textMatchers: [ + EmojiTextMatcher(), + if (keyword != null && keyword!.trim().isNotEmpty) + MultiKeyWordTextMatcher.createKeywordMatcher( + keyword: keyword!, + style: TextStyle( + color: theme.accent, + ), + caseSensitive: false, + ), + ], + maxLines: 1, ), - textMatchers: [ - EmojiTextMatcher(), - if (keyword != null && keyword!.trim().isNotEmpty) - MultiKeyWordTextMatcher.createKeywordMatcher( - keyword: keyword!, - style: TextStyle(color: context.theme.accent), - caseSensitive: false, - ), - ], - maxLines: 1, - ), - const SizedBox(height: 2), - CustomText( - user.identityNumber, - style: TextStyle( - fontSize: 12, - color: context.theme.secondaryText, + const SizedBox(height: 2), + CustomText( + user.identityNumber, + style: TextStyle( + fontSize: 12, + color: theme.secondaryText, + ), + textMatchers: [ + EmojiTextMatcher(), + if (keyword != null && keyword!.trim().isNotEmpty) + MultiKeyWordTextMatcher.createKeywordMatcher( + keyword: keyword!, + style: TextStyle( + color: theme.accent, + ), + caseSensitive: false, + ), + ], + maxLines: 1, ), - textMatchers: [ - EmojiTextMatcher(), - if (keyword != null && keyword!.trim().isNotEmpty) - MultiKeyWordTextMatcher.createKeywordMatcher( - keyword: keyword!, - style: TextStyle(color: context.theme.accent), - caseSensitive: false, - ), - ], - maxLines: 1, - ), - ], - ), - ], + ], + ), + ], + ), ), - ), - ); + ); + } } diff --git a/lib/widgets/menu.dart b/lib/widgets/menu.dart index a26e4a6b69..a293ab79a2 100644 --- a/lib/widgets/menu.dart +++ b/lib/widgets/menu.dart @@ -5,13 +5,12 @@ import 'dart:ui'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_logger/mixin_logger.dart'; -import 'package:provider/provider.dart'; import 'package:super_context_menu/src/default_builder/group_intrinsic_width.dart'; import 'package:super_context_menu/src/desktop.dart'; import 'package:super_context_menu/src/mobile.dart'; @@ -19,10 +18,9 @@ import 'package:super_context_menu/src/scaffold/desktop/menu_widget_builder.dart import 'package:super_context_menu/src/scaffold/mobile/menu_widget_builder.dart'; import 'package:super_context_menu/super_context_menu.dart'; -import '../bloc/simple_cubit.dart'; import '../constants/resources.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../utils/extension/extension.dart'; -import '../utils/hook.dart'; import '../utils/platform.dart'; import 'action_button.dart'; import 'hover_overlay.dart'; @@ -43,14 +41,18 @@ class MenusWithSeparator extends Menu { ); } -class _OffsetCubit extends SimpleCubit { - _OffsetCubit(super.state); -} +class MenuPortalController { + MenuPortalController(); + + final ValueNotifier offset = ValueNotifier(null); + + bool get visible => offset.value != null; -extension ContextMenuPortalEntrySender on BuildContext { - void sendMenuPosition(Offset offset) => read<_OffsetCubit>().emit(offset); + void show(Offset value) => offset.value = value; - void closeMenu() => read<_OffsetCubit?>()?.emit(null); + void close() => offset.value = null; + + void dispose() => offset.dispose(); } typedef CustomPopupMenuItemBuilder = @@ -75,41 +77,47 @@ class CustomPopupMenuButton extends HookConsumerWidget { final Alignment? alignment; @override - Widget build(BuildContext context, WidgetRef ref) => ContextMenuPortalEntry( - interactive: false, - buildMenus: () => itemBuilder(context) - .map( - (e) => ContextMenu( - title: e.title, - onTap: () => onSelected?.call(e.value), - isDestructiveAction: e.isDestructiveAction, - icon: e.icon, - ), - ) - .toList(), - child: Builder( - builder: (context) => ActionButton( - name: icon, - color: color ?? context.theme.icon, - onTapUp: (details) { - d('onTapUp: $alignment'); - if (alignment == null) { - context.sendMenuPosition(details.globalPosition); - return; - } - final renderBox = context.findRenderObject() as RenderBox?; - if (renderBox != null) { - var position = alignment!.withinRect(renderBox.paintBounds); - position = renderBox.localToGlobal(position); - context.sendMenuPosition(position); - } else { - context.sendMenuPosition(details.globalPosition); - } - }, - child: child, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final controller = useMemoized(MenuPortalController.new); + useEffect(() => controller.dispose, [controller]); + return ContextMenuPortalEntry( + controller: controller, + interactive: false, + buildMenus: () => itemBuilder(context) + .map( + (e) => ContextMenu( + title: e.title, + onTap: () => onSelected?.call(e.value), + isDestructiveAction: e.isDestructiveAction, + icon: e.icon, + ), + ) + .toList(), + child: Builder( + builder: (context) => ActionButton( + name: icon, + color: color ?? theme.icon, + onTapUp: (details) { + d('onTapUp: $alignment'); + if (alignment == null) { + controller.show(details.globalPosition); + return; + } + final renderBox = context.findRenderObject() as RenderBox?; + if (renderBox != null) { + var position = alignment!.withinRect(renderBox.paintBounds); + position = renderBox.localToGlobal(position); + controller.show(position); + } else { + controller.show(details.globalPosition); + } + }, + child: child, + ), ), - ), - ); + ); + } } class CustomPopupMenuItem { @@ -131,6 +139,7 @@ class ContextMenuPortalEntry extends HookConsumerWidget { required this.child, required this.buildMenus, super.key, + this.controller, this.showedMenu, this.enable = true, this.onTap, @@ -139,6 +148,7 @@ class ContextMenuPortalEntry extends HookConsumerWidget { final Widget child; final List Function() buildMenus; + final MenuPortalController? controller; final ValueChanged? showedMenu; final bool enable; final VoidCallback? onTap; @@ -148,75 +158,73 @@ class ContextMenuPortalEntry extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final offsetCubit = useBloc(() => _OffsetCubit(null)); - final offset = useBlocState<_OffsetCubit, Offset?>( - bloc: offsetCubit, - when: (state) => state != null, - ); - final visible = useBlocStateConverter<_OffsetCubit, Offset?, bool>( - bloc: offsetCubit, - converter: (state) => state != null, - ); + final localController = useMemoized(MenuPortalController.new); + final effectiveController = controller ?? localController; + final ownsController = controller == null; + final offset = useValueListenable(effectiveController.offset); + final visible = offset != null; useEffect(() { showedMenu?.call(visible); - }, [visible]); + return null; + }, [visible, showedMenu]); useEffect(() { - if (!enable) { - offsetCubit.emit(null); - } - }, [enable]); + if (!ownsController) return null; + return localController.dispose; + }, [ownsController, localController]); if (!enable) { + Future.microtask(effectiveController.close); return child; } - return Provider.value( - value: offsetCubit, - child: Barrier( + + return Barrier( + visible: visible, + onClose: effectiveController.close, + child: PortalTarget( visible: visible, - onClose: () => offsetCubit.emit(null), - child: PortalTarget( - visible: visible, - portalFollower: Builder( - builder: (context) { - final show = offset != null && visible; - if (show) { - return CustomSingleChildLayout( - delegate: PositionedLayoutDelegate(position: offset), + portalFollower: Builder( + builder: (context) { + final show = offset != null && visible; + if (show) { + return CustomSingleChildLayout( + delegate: PositionedLayoutDelegate(position: offset), + child: _MenuPortalControllerScope( + controller: effectiveController, child: ContextMenuPage(menus: buildMenus()), - ); - } - return const SizedBox(); - }, - ), - child: InteractiveDecoratedBox( - onRightClick: (event) { - if (!interactive) { - return; - } - offsetCubit.emit(event.globalPosition); - }, - onLongPress: (details) { - if (!interactive) { - return; - } - if (Platform.isAndroid || Platform.isIOS) { - offsetCubit.emit(details.globalPosition); + ), + ); + } + return const SizedBox(); + }, + ), + child: InteractiveDecoratedBox( + onRightClick: (event) { + if (!interactive) { + return; + } + effectiveController.show(event.globalPosition); + }, + onLongPress: (details) { + if (!interactive) { + return; + } + if (Platform.isAndroid || Platform.isIOS) { + effectiveController.show(details.globalPosition); + } + }, + onTap: onTap, + child: Focus( + onKeyEvent: (node, key) { + final show = offset != null && visible; + if (show && key.logicalKey == LogicalKeyboardKey.escape) { + effectiveController.close(); + return KeyEventResult.handled; } + return KeyEventResult.ignored; }, - onTap: onTap, - child: Focus( - onKeyEvent: (node, key) { - final show = offset != null && visible; - if (show && key.logicalKey == LogicalKeyboardKey.escape) { - offsetCubit.emit(null); - return KeyEventResult.handled; - } - return KeyEventResult.ignored; - }, - child: child, - ), + child: child, ), ), ), @@ -224,6 +232,26 @@ class ContextMenuPortalEntry extends HookConsumerWidget { } } +class _MenuPortalControllerScope extends InheritedWidget { + const _MenuPortalControllerScope({ + required this.controller, + required super.child, + }); + + final MenuPortalController controller; + + static MenuPortalController of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType<_MenuPortalControllerScope>(); + assert(scope != null, '_MenuPortalControllerScope is missing'); + return scope!.controller; + } + + @override + bool updateShouldNotify(_MenuPortalControllerScope oldWidget) => + controller != oldWidget.controller; +} + class Barrier extends StatelessWidget { const Barrier({ required this.onClose, @@ -297,14 +325,22 @@ class ContextMenuPage extends StatelessWidget { ); } -class _ContextMenuContainerLayout extends StatelessWidget { +class _ContextMenuContainerLayout extends ConsumerWidget { const _ContextMenuContainerLayout({required this.child}); final Widget child; @override - Widget build(BuildContext context) { - final brightnessData = context.brightnessValue; + Widget build(BuildContext context, WidgetRef ref) { + final brightnessData = ref.watch(brightnessValueProvider); + final backgroundColor = ref.watch( + dynamicColorProvider( + const ( + color: Color.fromRGBO(255, 255, 255, 1), + darkColor: Color.fromRGBO(62, 65, 72, 1), + ), + ), + ); return DecoratedBox( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(11)), @@ -327,10 +363,7 @@ class _ContextMenuContainerLayout extends StatelessWidget { blurRadius: lerpDouble(6, 12, brightnessData)!, ), ], - color: context.dynamicColor( - const Color.fromRGBO(255, 255, 255, 1), - darkColor: const Color.fromRGBO(62, 65, 72, 1), - ), + color: backgroundColor, ), child: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(11)), @@ -343,7 +376,7 @@ class _ContextMenuContainerLayout extends StatelessWidget { } } -class ContextMenu extends StatelessWidget { +class ContextMenu extends ConsumerWidget { const ContextMenu({ required this.title, super.key, @@ -366,28 +399,39 @@ class ContextMenu extends StatelessWidget { final VoidCallback? onTap; @override - Widget build(BuildContext context) { - final backgroundColor = context.dynamicColor( - const Color.fromRGBO(255, 255, 255, 1), - darkColor: const Color.fromRGBO(62, 65, 72, 1), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final backgroundColor = ref.watch( + dynamicColorProvider( + const ( + color: Color.fromRGBO(255, 255, 255, 1), + darkColor: Color.fromRGBO(62, 65, 72, 1), + ), + ), ); final color = isDestructiveAction - ? context.theme.red - : context.dynamicColor( - const Color.fromRGBO(0, 0, 0, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.9), + ? theme.red + : ref.watch( + dynamicColorProvider( + const ( + color: Color.fromRGBO(0, 0, 0, 1), + darkColor: Color.fromRGBO(255, 255, 255, 0.9), + ), + ), ); return ConstrainedBox( constraints: const BoxConstraints(minWidth: 160), child: InteractiveDecoratedBox.color( decoration: BoxDecoration(color: backgroundColor), tapDowningColor: Color.alphaBlend( - context.theme.listSelected, + theme.listSelected, backgroundColor, ), onTap: () { onTap?.call(); - if (!_subMenuMode) context.closeMenu(); + if (!_subMenuMode) { + _MenuPortalControllerScope.of(context).close(); + } }, onTapUp: (details) { if (_subMenuMode && details.kind == PointerDeviceKind.touch) { @@ -420,7 +464,7 @@ class ContextMenu extends StatelessWidget { width: 20, height: 20, colorFilter: ColorFilter.mode( - context.theme.secondaryText, + theme.secondaryText, BlendMode.srcIn, ), ), @@ -472,48 +516,49 @@ class SubMenuClickedByTouchNotification extends Notification { class CustomDesktopMenuWidgetBuilder extends DefaultDesktopMenuWidgetBuilder { CustomDesktopMenuWidgetBuilder(); - static DefaultDesktopMenuTheme _themeForContext(BuildContext context) => - DefaultDesktopMenuTheme.themeForBrightness(context.brightness); - @override Widget buildMenuContainer( BuildContext context, DesktopMenuInfo menuInfo, Widget child, - ) { - final pixelRatio = MediaQuery.of(context).devicePixelRatio; - final theme = _themeForContext(context); - return Container( - decoration: theme.decorationOuter.copyWith( - borderRadius: BorderRadius.circular(6.0 + 1.0 / pixelRatio), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular(6), - child: Padding( - padding: EdgeInsets.all(1.0 / pixelRatio), - child: Container( - decoration: theme.decorationInner, - padding: const EdgeInsets.symmetric(vertical: 6), - child: DefaultTextStyle.merge( - style: TextStyle( - color: Colors.black, - fontSize: 14, - decoration: TextDecoration.none, - fontWeight: FontWeight.w500, - fontFamilyFallback: Platform.isWindows - ? ['Microsoft Yahei'] - : null, - ), - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: maxWidth), - child: GroupIntrinsicWidthContainer(child: child), + ) => Consumer( + builder: (context, ref, _) { + final pixelRatio = ref.watch(mediaQueryDataProvider).devicePixelRatio; + final theme = DefaultDesktopMenuTheme.themeForBrightness( + ref.watch(platformBrightnessProvider), + ); + return Container( + decoration: theme.decorationOuter.copyWith( + borderRadius: BorderRadius.circular(6.0 + 1.0 / pixelRatio), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular(6), + child: Padding( + padding: EdgeInsets.all(1.0 / pixelRatio), + child: Container( + decoration: theme.decorationInner, + padding: const EdgeInsets.symmetric(vertical: 6), + child: DefaultTextStyle.merge( + style: TextStyle( + color: Colors.black, + fontSize: 14, + decoration: TextDecoration.none, + fontWeight: FontWeight.w500, + fontFamilyFallback: Platform.isWindows + ? ['Microsoft Yahei'] + : null, + ), + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: maxWidth), + child: GroupIntrinsicWidthContainer(child: child), + ), ), ), ), ), - ), - ); - } + ); + }, + ); @override Widget buildSeparator( @@ -521,7 +566,9 @@ class CustomDesktopMenuWidgetBuilder extends DefaultDesktopMenuWidgetBuilder { DesktopMenuInfo menuInfo, MenuSeparator separator, ) { - final theme = _themeForContext(context); + final theme = DefaultDesktopMenuTheme.themeForBrightness( + MediaQuery.platformBrightnessOf(context), + ); final paddingLeft = 10.0 + (menuInfo.hasAnyCheckedItems ? (16 + 6) : 0); const paddingRight = 10.0; return Container( @@ -561,7 +608,9 @@ class CustomDesktopMenuWidgetBuilder extends DefaultDesktopMenuWidgetBuilder { DesktopMenuButtonState state, MenuElement element, ) { - final theme = _themeForContext(context); + final theme = DefaultDesktopMenuTheme.themeForBrightness( + MediaQuery.platformBrightnessOf(context), + ); final itemInfo = DesktopMenuItemInfo( destructive: element is MenuAction && element.attributes.destructive, disabled: element is MenuAction && element.attributes.disabled, diff --git a/lib/widgets/message/item/action/action_message.dart b/lib/widgets/message/item/action/action_message.dart index c7e334b12a..ecd14657bf 100644 --- a/lib/widgets/message/item/action/action_message.dart +++ b/lib/widgets/message/item/action/action_message.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../../constants/resources.dart'; import '../../../../ui/provider/conversation_provider.dart'; +import '../../../../ui/provider/ui_context_providers.dart'; import '../../../../utils/color_utils.dart'; import '../../../../utils/extension/extension.dart'; import '../../../../utils/logger.dart'; @@ -62,6 +63,7 @@ class ActionMessageButton extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final bubbleClipper = BubbleClipper( currentUser: false, showNip: false, @@ -70,17 +72,18 @@ class ActionMessageButton extends ConsumerWidget { return InteractiveDecoratedBox.color( cursor: SystemMouseCursors.click, onTap: () { - if (context.openAction(action.action)) return; + if (context.openAction(ref, action.action)) return; openUriWithWebView( context, action.action, + container: ref.container, title: action.label, conversationId: ref.read(currentConversationIdProvider), ); }, child: CustomPaint( painter: BubblePainter( - color: context.theme.primary, + color: theme.primary, clipper: bubbleClipper, ), child: IntrinsicWidth( diff --git a/lib/widgets/message/item/action_card/action_message.dart b/lib/widgets/message/item/action_card/action_message.dart index 62467988b6..0081e22ff5 100644 --- a/lib/widgets/message/item/action_card/action_message.dart +++ b/lib/widgets/message/item/action_card/action_message.dart @@ -6,6 +6,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../../ui/provider/conversation_provider.dart'; +import '../../../../ui/provider/ui_context_providers.dart'; import '../../../../utils/extension/extension.dart'; import '../../../../utils/logger.dart'; import '../../../../utils/uri_utils.dart'; @@ -46,10 +47,11 @@ class ActionCardMessage extends HookConsumerWidget { outerTimeAndStatusWidget: const MessageDatetimeAndStatus(), child: InteractiveDecoratedBox( onTap: () { - if (context.openAction(appCardData.action)) return; + if (context.openAction(ref, appCardData.action)) return; openUriWithWebView( context, appCardData.action, + container: ref.container, title: appCardData.title, appCardData: appCardData, conversationId: ref.read(currentConversationIdProvider), @@ -68,6 +70,7 @@ class AppCardItem extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final description = useMemoized( () => const LineSplitter().convert(data.description).firstOrNull ?? '', [data.description], @@ -88,7 +91,7 @@ class AppCardItem extends HookConsumerWidget { Text( data.title, style: TextStyle( - color: context.theme.text, + color: theme.text, fontSize: context.messageStyle.secondaryFontSize, ), maxLines: 1, @@ -98,7 +101,7 @@ class AppCardItem extends HookConsumerWidget { description, maxLines: 1, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: context.messageStyle.tertiaryFontSize, ), ), diff --git a/lib/widgets/message/item/action_card/actions_card.dart b/lib/widgets/message/item/action_card/actions_card.dart index 894fe394cb..1cf8051927 100644 --- a/lib/widgets/message/item/action_card/actions_card.dart +++ b/lib/widgets/message/item/action_card/actions_card.dart @@ -2,8 +2,9 @@ import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../../../utils/extension/extension.dart'; +import '../../../../ui/provider/ui_context_providers.dart'; import '../../../high_light_text.dart'; import '../../../mixin_image.dart'; import '../../message.dart'; @@ -14,56 +15,59 @@ import '../action/action_message.dart'; import '../text/text_message.dart'; import 'action_card_data.dart'; -class ActionsCardMessage extends StatelessWidget { +class ActionsCardMessage extends ConsumerWidget { ActionsCardMessage({required this.data, super.key}) : assert(data.isActionsCard); final AppCardData data; @override - Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) { - final width = (constraints.maxWidth * 0.5).clamp(320.0, 375.0); - return MessageBubble( - showBubble: false, - padding: EdgeInsets.zero, - includeNip: true, - child: Column( - children: [ - _Bubble( - child: ConstrainedBox( - constraints: BoxConstraints(maxWidth: width, minWidth: width), - child: MessageSelectionArea( - child: ActionsCardBody( - data: data, - description: MessageTextWidget( - enableSelection: false, - color: context.theme.text, - fontSize: context.messageStyle.primaryFontSize, - content: data.description, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return LayoutBuilder( + builder: (context, constraints) { + final width = (constraints.maxWidth * 0.5).clamp(320.0, 375.0); + return MessageBubble( + showBubble: false, + padding: EdgeInsets.zero, + includeNip: true, + child: Column( + children: [ + _Bubble( + child: ConstrainedBox( + constraints: BoxConstraints(maxWidth: width, minWidth: width), + child: MessageSelectionArea( + child: ActionsCardBody( + data: data, + description: MessageTextWidget( + enableSelection: false, + color: theme.text, + fontSize: context.messageStyle.primaryFontSize, + content: data.description, + ), ), ), ), ), - ), - const SizedBox(height: 8), - HookBuilder( - builder: (context) => MessageBubbleNipPadding( - currentUser: useIsCurrentUser(), - child: ConstrainedBox( - constraints: BoxConstraints( - maxWidth: width, - minWidth: width, + const SizedBox(height: 8), + HookBuilder( + builder: (context) => MessageBubbleNipPadding( + currentUser: useIsCurrentUser(), + child: ConstrainedBox( + constraints: BoxConstraints( + maxWidth: width, + minWidth: width, + ), + child: _Actions(actions: data.actions), ), - child: _Actions(actions: data.actions), ), ), - ), - ], - ), - ); - }, - ); + ], + ), + ); + }, + ); + } } class _Bubble extends HookWidget { @@ -89,7 +93,7 @@ class _Bubble extends HookWidget { } } -class ActionsCardBody extends StatelessWidget { +class ActionsCardBody extends ConsumerWidget { const ActionsCardBody({ required this.description, required this.data, @@ -100,33 +104,36 @@ class ActionsCardBody extends StatelessWidget { final Widget description; @override - Widget build(BuildContext context) => Column( - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - if (data.coverUrl.isNotEmpty) - AspectRatio(aspectRatio: 1, child: MixinImage.network(data.coverUrl)) - else if (data.cover != null) - _CoverWidget(cover: data.cover!), - const SizedBox(height: 10), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: CustomText( - data.title, - style: TextStyle( - color: context.theme.text, - fontSize: context.messageStyle.primaryFontSize, - fontWeight: FontWeight.bold, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + if (data.coverUrl.isNotEmpty) + AspectRatio(aspectRatio: 1, child: MixinImage.network(data.coverUrl)) + else if (data.cover != null) + _CoverWidget(cover: data.cover!), + const SizedBox(height: 10), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: CustomText( + data.title, + style: TextStyle( + color: theme.text, + fontSize: context.messageStyle.primaryFontSize, + fontWeight: FontWeight.bold, + ), ), ), - ), - const SizedBox(height: 8), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 12), - child: description, - ), - const SizedBox(height: 10), - ], - ); + const SizedBox(height: 8), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12), + child: description, + ), + const SizedBox(height: 10), + ], + ); + } } class _CoverWidget extends StatelessWidget { diff --git a/lib/widgets/message/item/audio_message.dart b/lib/widgets/message/item/audio_message.dart index 2bf88f2e5c..70d1d86da9 100644 --- a/lib/widgets/message/item/audio_message.dart +++ b/lib/widgets/message/item/audio_message.dart @@ -8,7 +8,10 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import '../../../db/mixin_database.dart' hide Message, Offset; import '../../../enum/media_status.dart'; -import '../../../utils/audio_message_player/audio_message_service.dart'; +import '../../../ui/home/providers/home_scope_providers.dart'; +import '../../../ui/provider/account_server_provider.dart'; +import '../../../ui/provider/conversation_provider.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/extension/extension.dart'; import '../../interactive_decorated_box.dart'; import '../../status.dart'; @@ -19,12 +22,14 @@ import '../message_datetime_and_status.dart'; import '../message_style.dart'; import 'transcript_message.dart'; -class AudioMessage extends HookConsumerWidget { +class AudioMessage extends ConsumerWidget { const AudioMessage({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final isTranscriptPage = useIsTranscriptPage(); + final isPinnedPage = useIsPinnedPage(); final messageId = useMessageConverter( converter: (state) => state.messageId, ); @@ -36,9 +41,11 @@ class AudioMessage extends HookConsumerWidget { ); final mediaUrl = useMessageConverter(converter: (state) => state.mediaUrl); - final playing = useAudioMessagePlaying( - messageId, - isMediaList: isTranscriptPage, + final playing = ref.watch( + audioMessagePlayingProvider(( + messageId: messageId, + isMediaList: isTranscriptPage, + )), ); final duration = useMessageConverter( @@ -51,6 +58,23 @@ class AudioMessage extends HookConsumerWidget { (isTranscriptPage && TranscriptPage.of(context)?.relationship == UserRelationship.me) || (!isTranscriptPage && relationship == UserRelationship.me); + final audioService = ref.read(audioMessagePlayServiceProvider); + AudioMessagesPlayAgent? audioMessagesPlayAgent; + if (isTranscriptPage) { + final transcriptMessageId = TranscriptPage.of(context)?.messageId; + if (transcriptMessageId != null) { + audioMessagesPlayAgent = ref.read( + transcriptAudioMessagesPlayAgentProvider(transcriptMessageId), + ); + } + } else if (isPinnedPage) { + final conversationId = ref.read(currentConversationIdProvider); + if (conversationId != null) { + audioMessagesPlayAgent = ref.read( + pinnedAudioMessagesPlayAgentProvider(conversationId), + ); + } + } return MessageBubble( outerTimeAndStatusWidget: const MessageDatetimeAndStatus(), @@ -62,39 +86,46 @@ class AudioMessage extends HookConsumerWidget { case MediaStatus.read: case MediaStatus.done: if (playing) { - context.audioMessageService.stop(); + audioService.stop(); return; } - if (context.audioMessagesPlayAgent != null) { - context.audioMessageService.playMessages( - context.audioMessagesPlayAgent!.getMessages( + if (audioMessagesPlayAgent != null) { + audioService.playMessages( + audioMessagesPlayAgent.getMessages( message.messageId, ), - context.audioMessagesPlayAgent!.convertMessageAbsolutePath, + audioMessagesPlayAgent.convertMessageAbsolutePath, ); return; } - context.audioMessageService.playAudioMessage(message); + audioService.playAudioMessage(message); case MediaStatus.canceled: if (isMessageSentOut && message.mediaUrl?.isNotEmpty == true) { + final accountServer = ref + .read(accountServerProvider) + .requireValue; if (isTranscriptPage) { final transcriptMessageId = TranscriptPage.of( context, )?.messageId; - context.accountServer.reUploadTranscriptAttachment( + accountServer.reUploadTranscriptAttachment( transcriptMessageId!, ); } else { - context.accountServer.reUploadAttachment(message); + accountServer.reUploadAttachment(message); } } else { - context.accountServer.downloadAttachment(message.messageId); + ref + .read(accountServerProvider) + .requireValue + .downloadAttachment(message.messageId); } case MediaStatus.pending: - context.accountServer.cancelProgressAttachmentJob( - message.messageId, - ); + ref + .read(accountServerProvider) + .requireValue + .cancelProgressAttachmentJob(message.messageId); case MediaStatus.expired: case null: break; @@ -138,7 +169,7 @@ class AudioMessage extends HookConsumerWidget { duration.asMinutesSeconds, style: TextStyle( fontSize: context.messageStyle.tertiaryFontSize, - color: context.theme.secondaryText, + color: theme.secondaryText, ), ), ], @@ -158,6 +189,7 @@ class _AnimatedWave extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final mediaWaveform = useMessageConverter( converter: (state) => state.mediaWaveform ?? '', ); @@ -168,37 +200,36 @@ class _AnimatedWave extends HookConsumerWidget { converter: (state) => state.messageId, ); - final waveform = useMemoized(() => base64Decode(mediaWaveform), [ - mediaWaveform, - ]); + final waveform = useMemoized( + () => base64Decode(mediaWaveform), + [mediaWaveform], + ); final read = mediaStatus == MediaStatus.read; final isTranscriptPage = useIsTranscriptPage(); - final playing = useAudioMessagePlaying( - messageId, - isMediaList: isTranscriptPage, + final playing = ref.watch( + audioMessagePlayingProvider(( + messageId: messageId, + isMediaList: isTranscriptPage, + )), ); final isMe = useMessageConverter( converter: (state) => state.relationship == UserRelationship.me, ); - final position = (useAudioPlayerPosition() / duration.inMilliseconds).clamp( - 0.0, - 1.0, - ); + final position = + ((ref.watch(audioPlayerPositionProvider).value ?? 0) / + duration.inMilliseconds) + .clamp(0.0, 1.0); return SizedBox( height: 12, child: WaveformWidget( value: playing ? position : 0, waveform: waveform, - backgroundColor: isMe || read - ? context.theme.waveformBackground - : context.theme.accent, - foregroundColor: isMe || read - ? context.theme.waveformForeground - : context.theme.accent, + backgroundColor: isMe || read ? theme.waveformBackground : theme.accent, + foregroundColor: isMe || read ? theme.waveformForeground : theme.accent, ), ); } @@ -220,12 +251,20 @@ class AudioMessagesPlayAgent { } } -extension _AudioMessagesPlayAgentExtension on BuildContext { - AudioMessagesPlayAgent? get audioMessagesPlayAgent { - try { - return read(); - } catch (e) { - return null; - } - } -} +final transcriptAudioMessagesPlayAgentProvider = Provider.autoDispose + .family(( + ref, + transcriptMessageId, + ) { + final list = + ref.watch(transcriptMessagesProvider(transcriptMessageId)).value ?? + const []; + final accountServer = ref.watch(accountServerProvider).value; + if (accountServer == null) { + return null; + } + return AudioMessagesPlayAgent( + list, + (message) => accountServer.convertMessageAbsolutePath(message, true), + ); + }); diff --git a/lib/widgets/message/item/contact_message_widget.dart b/lib/widgets/message/item/contact_message_widget.dart index cd1d9e9bd2..1b33419ff8 100644 --- a/lib/widgets/message/item/contact_message_widget.dart +++ b/lib/widgets/message/item/contact_message_widget.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/extension/extension.dart'; import '../../avatar_view/avatar_view.dart'; import '../../conversation/badges_widget.dart'; @@ -43,7 +44,7 @@ class ContactMessageWidget extends HookConsumerWidget { return MessageBubble( outerTimeAndStatusWidget: const MessageDatetimeAndStatus(), child: InteractiveDecoratedBox( - onTap: () => showUserDialog(context, sharedUserId), + onTap: () => showUserDialog(context, ref.container, sharedUserId), child: ContactItem( avatarUrl: sharedUserAvatarUrl, userId: sharedUserId, @@ -58,7 +59,7 @@ class ContactMessageWidget extends HookConsumerWidget { } } -class ContactItem extends StatelessWidget { +class ContactItem extends ConsumerWidget { const ContactItem({ required this.avatarUrl, required this.userId, @@ -79,52 +80,55 @@ class ContactItem extends StatelessWidget { final Membership? membership; @override - Widget build(BuildContext context) => Row( - mainAxisSize: MainAxisSize.min, - children: [ - AvatarWidget( - size: 40, - avatarUrl: avatarUrl, - userId: userId, - name: fullName, - ), - const SizedBox(width: 8), - Flexible( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisSize: MainAxisSize.min, - children: [ - Flexible( - child: Text( - fullName?.overflow ?? '', - style: TextStyle( - color: context.theme.text, - fontSize: context.messageStyle.primaryFontSize, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Row( + mainAxisSize: MainAxisSize.min, + children: [ + AvatarWidget( + size: 40, + avatarUrl: avatarUrl, + userId: userId, + name: fullName, + ), + const SizedBox(width: 8), + Flexible( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + mainAxisSize: MainAxisSize.min, + children: [ + Flexible( + child: Text( + fullName?.overflow ?? '', + style: TextStyle( + color: theme.text, + fontSize: context.messageStyle.primaryFontSize, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, ), + BadgesWidget( + verified: isVerified, + isBot: appId != null, + membership: membership, + ), + ], + ), + Text( + identityNumber, + style: TextStyle( + color: theme.secondaryText, + fontSize: context.messageStyle.secondaryFontSize, ), - BadgesWidget( - verified: isVerified, - isBot: appId != null, - membership: membership, - ), - ], - ), - Text( - identityNumber, - style: TextStyle( - color: context.theme.secondaryText, - fontSize: context.messageStyle.secondaryFontSize, ), - ), - ], + ], + ), ), - ), - ], - ); + ], + ); + } } diff --git a/lib/widgets/message/item/file_message.dart b/lib/widgets/message/item/file_message.dart index 95cd299928..11707f4b3e 100644 --- a/lib/widgets/message/item/file_message.dart +++ b/lib/widgets/message/item/file_message.dart @@ -9,6 +9,8 @@ import 'package:path/path.dart' as p; import '../../../constants/brightness_theme_data.dart'; import '../../../enum/media_status.dart'; +import '../../../ui/provider/account_server_provider.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/logger.dart'; import '../../interactive_decorated_box.dart'; @@ -36,6 +38,9 @@ class MessageFile extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isTranscriptPage = useIsTranscriptPage(); + final accountServer = ref.read(accountServerProvider).requireValue; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final mediaStatus = useMessageConverter( converter: (state) => state.mediaStatus, ); @@ -78,20 +83,20 @@ class MessageFile extends HookConsumerWidget { if (isMessageSentOut && message.mediaUrl?.isNotEmpty == true) { if (isTranscriptPage) { final transcriptMessageId = TranscriptPage.of(context)?.messageId; - await context.accountServer.reUploadTranscriptAttachment( + await accountServer.reUploadTranscriptAttachment( transcriptMessageId!, ); } else { - await context.accountServer.reUploadAttachment(message); + await accountServer.reUploadAttachment(message); } } else { - await context.accountServer.downloadAttachment(message.messageId); + await accountServer.downloadAttachment(message.messageId); } } else if (message.mediaStatus == MediaStatus.done && message.mediaUrl != null) { if (message.mediaUrl?.isEmpty ?? true) return; if (_shouldOpenDirectly(mediaName)) { - final path = context.accountServer.convertMessageAbsolutePath( + final path = accountServer.convertMessageAbsolutePath( message, isTranscriptPage, ); @@ -101,21 +106,22 @@ class MessageFile extends HookConsumerWidget { 'open file result: $mediaName ${openResult.type} ${openResult.message}', ); showToastFailed( - ToastError(context.l10n.unableToOpenFile(mediaName)), + ToastError( + l10n.unableToOpenFile(mediaName), + ), ); } } else { await saveAs( context, - context.accountServer, + accountServer, message, isTranscriptPage, + confirmButtonText: l10n.save, ); } } else if (message.mediaStatus == MediaStatus.pending) { - await context.accountServer.cancelProgressAttachmentJob( - message.messageId, - ); + await accountServer.cancelProgressAttachmentJob(message.messageId); } }, child: Row( @@ -142,7 +148,7 @@ class MessageFile extends HookConsumerWidget { height: 38, width: 38, decoration: BoxDecoration( - color: context.theme.statusBackground, + color: theme.statusBackground, shape: BoxShape.circle, ), alignment: Alignment.center, @@ -167,7 +173,7 @@ class MessageFile extends HookConsumerWidget { mediaName.overflow, style: TextStyle( fontSize: context.messageStyle.secondaryFontSize, - color: context.theme.text, + color: theme.text, ), overflow: TextOverflow.ellipsis, maxLines: 1, @@ -176,7 +182,7 @@ class MessageFile extends HookConsumerWidget { mediaSizeText, style: TextStyle( fontSize: context.messageStyle.tertiaryFontSize, - color: context.theme.secondaryText, + color: theme.secondaryText, ), maxLines: 1, ), diff --git a/lib/widgets/message/item/image/image_message.dart b/lib/widgets/message/item/image/image_message.dart index 0bb33f6b05..409b46de65 100644 --- a/lib/widgets/message/item/image/image_message.dart +++ b/lib/widgets/message/item/image/image_message.dart @@ -7,7 +7,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import '../../../../enum/media_status.dart'; -import '../../../../utils/extension/extension.dart'; +import '../../../../ui/provider/account_server_provider.dart'; +import '../../../../ui/provider/ui_context_providers.dart'; import '../../../image.dart'; import '../../../interactive_decorated_box.dart'; import '../../../mixin_image.dart'; @@ -27,6 +28,7 @@ class ImageMessageWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final mediaWidth = useMessageConverter( converter: (state) => state.mediaWidth, ); @@ -74,6 +76,7 @@ class MessageImage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final isTranscriptPage = useIsTranscriptPage(); final type = useMessageConverter(converter: (state) => state.type); final conversationId = useMessageConverter( @@ -96,7 +99,7 @@ class MessageImage extends HookConsumerWidget { // un-downloaded giphy gif image. thumbWidget = MixinImage.network( thumbImage, - placeholder: () => ColoredBox(color: context.theme.secondaryText), + placeholder: () => ColoredBox(color: theme.secondaryText), ); } else { thumbWidget = ImageByBlurHashOrBase64(imageData: thumbImage); @@ -110,6 +113,7 @@ class MessageImage extends HookConsumerWidget { (isTranscriptPage && TranscriptPage.of(context)?.relationship == UserRelationship.me) || (!isTranscriptPage && relationship == UserRelationship.me); + final accountServer = ref.read(accountServerProvider).requireValue; final mediaSize = useMessageConverter( converter: (state) => (state.mediaWidth, state.mediaHeight), @@ -140,6 +144,10 @@ class MessageImage extends HookConsumerWidget { context, conversationId: message.conversationId, messageId: message.messageId, + container: ref.container, + barrierLabel: ref + .watch(materialLocalizationsProvider) + .modalBarrierDismissLabel, isTranscriptPage: isTranscriptPage, ); case MediaStatus.canceled: @@ -153,22 +161,20 @@ class MessageImage extends HookConsumerWidget { 'transcriptMessageId is null', ); if (transcriptMessageId != null) { - context.accountServer.reUploadTranscriptAttachment( + accountServer.reUploadTranscriptAttachment( transcriptMessageId, ); } } else if (isUnDownloadGiphyGif) { - context.accountServer.reUploadGiphyGif(message); + accountServer.reUploadGiphyGif(message); } else { - context.accountServer.reUploadAttachment(message); + accountServer.reUploadAttachment(message); } } else { - context.accountServer.downloadAttachment(message.messageId); + accountServer.downloadAttachment(message.messageId); } case MediaStatus.pending: - context.accountServer.cancelProgressAttachmentJob( - message.messageId, - ); + accountServer.cancelProgressAttachmentJob(message.messageId); case null: case MediaStatus.expired: case MediaStatus.read: @@ -182,7 +188,7 @@ class MessageImage extends HookConsumerWidget { children: [ MixinImage.file( File( - context.accountServer.convertAbsolutePath( + accountServer.convertAbsolutePath( type, conversationId, mediaUrl, @@ -289,16 +295,16 @@ class ImageMessageLayout extends StatelessWidget { ); } -class ImageCaption extends StatelessWidget { +class ImageCaption extends ConsumerWidget { const ImageCaption({required this.caption, super.key}); final String caption; @override - Widget build(BuildContext context) => Padding( + Widget build(BuildContext context, WidgetRef ref) => Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), child: MessageTextWidget( - color: context.theme.text, + color: ref.watch(brightnessThemeDataProvider).text, fontSize: context.messageStyle.primaryFontSize, content: caption, ), diff --git a/lib/widgets/message/item/image/image_preview_page.dart b/lib/widgets/message/item/image/image_preview_page.dart index a948ec59eb..b1794076bb 100644 --- a/lib/widgets/message/item/image/image_preview_page.dart +++ b/lib/widgets/message/item/image/image_preview_page.dart @@ -6,12 +6,15 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; -import 'package:provider/provider.dart'; import 'package:rxdart/rxdart.dart'; +import '../../../../account/account_server.dart'; import '../../../../constants/resources.dart'; import '../../../../db/mixin_database.dart' hide Offset; import '../../../../enum/message_category.dart'; +import '../../../../ui/provider/account_server_provider.dart'; +import '../../../../ui/provider/database_provider.dart'; +import '../../../../ui/provider/ui_context_providers.dart'; import '../../../../utils/extension/extension.dart'; import '../../../../utils/platform.dart'; import '../../../../utils/system/clipboard.dart'; @@ -41,12 +44,14 @@ class ImagePreviewPage extends HookConsumerWidget { BuildContext context, { required String conversationId, required String messageId, + required ProviderContainer container, + required String barrierLabel, bool isTranscriptPage = false, }) => showGeneralDialog( context: context, barrierColor: Colors.transparent, barrierDismissible: true, - barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierLabel: barrierLabel, pageBuilder: ( buildContext, @@ -58,20 +63,15 @@ class ImagePreviewPage extends HookConsumerWidget { messageId: messageId, isTranscriptPage: isTranscriptPage, ); - - try { - return Provider.value( - value: context.read(), - child: child, - ); - } catch (_) {} - return child; }, ); @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; + final database = ref.read(databaseProvider).requireValue; final _messageId = useState(messageId); final current = useState(null); final prev = useState(null); @@ -81,13 +81,12 @@ class ImagePreviewPage extends HookConsumerWidget { current.value?.messageId, ]); - final transcriptMessagesWatcher = useMemoized(() { - try { - return context.read(); - } catch (_) { - return null; - } - }); + final transcriptMessageId = isTranscriptPage + ? TranscriptPage.of(context)?.messageId + : null; + final transcriptMessagesWatcher = transcriptMessageId == null + ? null + : ref.watch(transcriptMessagesWatcherProvider(transcriptMessageId)); useEffect(() { if (transcriptMessagesWatcher != null) return; @@ -97,7 +96,7 @@ class ImagePreviewPage extends HookConsumerWidget { } else if (next.value?.messageId == _messageId.value) { current.value = next.value; } else { - context.database.messageDao + database.messageDao .messageItemByMessageId(_messageId.value) .getSingleOrNull() .then((value) => current.value = value); @@ -107,7 +106,7 @@ class ImagePreviewPage extends HookConsumerWidget { useEffect(() { if (transcriptMessagesWatcher != null) return; - final messageDao = context.database.messageDao; + final messageDao = database.messageDao; () async { final info = await messageDao.messageOrderInfo(_messageId.value); if (info == null) return; @@ -146,7 +145,7 @@ class ImagePreviewPage extends HookConsumerWidget { }, [_messageId.value]); useEffect( - () => context.database.messageDao + () => database.messageDao .watchInsertOrReplaceMessageStream(conversationId) .switchMap((value) async* { for (final item in value) { @@ -208,7 +207,7 @@ class ImagePreviewPage extends HookConsumerWidget { actions: { _CopyIntent: CallbackAction( onInvoke: (intent) => copyFile( - context.accountServer.convertMessageAbsolutePath( + accountServer.convertMessageAbsolutePath( current.value, isTranscriptPage, ), @@ -250,7 +249,9 @@ class ImagePreviewPage extends HookConsumerWidget { children: [ Container( height: 70, - decoration: BoxDecoration(color: context.theme.primary), + decoration: BoxDecoration( + color: theme.primary, + ), child: Builder( builder: (context) { if (current.value == null) return const SizedBox(); @@ -323,7 +324,7 @@ class ImagePreviewPage extends HookConsumerWidget { } } -class _Bar extends StatelessWidget { +class _Bar extends ConsumerWidget { const _Bar({ required this.message, required this.controller, @@ -335,71 +336,78 @@ class _Bar extends StatelessWidget { final bool isTranscriptPage; @override - Widget build(BuildContext context) => Row( - children: [ - AvatarWidget( - name: message.userFullName, - size: 36, - avatarUrl: message.avatarUrl, - userId: message.userId, - ), - const SizedBox(width: 10), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - message.userFullName!, - style: TextStyle( - fontSize: MessageItemWidget.primaryFontSize, - color: context.theme.text, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Row( + children: [ + AvatarWidget( + name: message.userFullName, + size: 36, + avatarUrl: message.avatarUrl, + userId: message.userId, + ), + const SizedBox(width: 10), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + message.userFullName!, + style: TextStyle( + fontSize: MessageItemWidget.primaryFontSize, + color: theme.text, + ), ), - ), - Text( - message.userIdentityNumber, - style: TextStyle( - fontSize: MessageItemWidget.secondaryFontSize, - color: context.theme.secondaryText, + Text( + message.userIdentityNumber, + style: TextStyle( + fontSize: MessageItemWidget.secondaryFontSize, + color: theme.secondaryText, + ), ), - ), - ], - ), - const SizedBox(width: 14), - _Action( - controller: controller, - isTranscriptPage: isTranscriptPage, - message: message, - ), - const SizedBox(width: 24), - ], - ); + ], + ), + const SizedBox(width: 14), + _Action( + controller: controller, + isTranscriptPage: isTranscriptPage, + message: message, + accountServer: ref.read(accountServerProvider).requireValue, + ), + const SizedBox(width: 24), + ], + ); + } } enum _ActionType { share, copy, download } -class _Action extends StatelessWidget { +class _Action extends ConsumerWidget { const _Action({ required this.controller, required this.isTranscriptPage, required this.message, + required this.accountServer, }); final TransformImageController controller; final bool isTranscriptPage; final MessageItem message; + final AccountServer accountServer; static const _dividerWidth = 14.0; static const _divider = SizedBox(width: _dividerWidth); static const _width = 36; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); Future share() async { - final accountServer = context.accountServer; final result = await showConversationSelector( context: context, singleSelect: true, - title: context.l10n.forward, + title: l10n.forward, onlyContact: false, ); if (result == null || result.isEmpty) return; @@ -412,7 +420,7 @@ class _Action extends StatelessWidget { } Future copy() => copyFile( - context.accountServer.convertMessageAbsolutePath( + accountServer.convertMessageAbsolutePath( message, isTranscriptPage, ), @@ -420,7 +428,13 @@ class _Action extends StatelessWidget { Future download() async { if (message.mediaUrl?.isEmpty ?? true) return; - await saveAs(context, context.accountServer, message, isTranscriptPage); + await saveAs( + context, + accountServer, + message, + isTranscriptPage, + confirmButtonText: l10n.save, + ); } final collapsible = [ @@ -428,18 +442,18 @@ class _Action extends StatelessWidget { ActionButton( name: Resources.assetsImagesShareSvg, size: 20, - color: context.theme.icon, + color: theme.icon, onTap: share, ), ActionButton( name: Resources.assetsImagesCopySvg, - color: context.theme.icon, + color: theme.icon, size: 20, onTap: copy, ), ActionButton( name: Resources.assetsImagesAttachmentDownloadSvg, - color: context.theme.icon, + color: theme.icon, size: 20, onTap: download, ), @@ -447,7 +461,7 @@ class _Action extends StatelessWidget { final close = ActionButton( name: Resources.assetsImagesIcCloseBigSvg, - color: context.theme.icon, + color: theme.icon, size: 20, onTap: () => Navigator.pop(context), ); @@ -455,19 +469,19 @@ class _Action extends StatelessWidget { final common = [ ActionButton( name: Resources.assetsImagesZoomInSvg, - color: context.theme.icon, + color: theme.icon, size: 20, onTap: controller.zoomIn, ), ActionButton( name: Resources.assetsImagesZoomOutSvg, size: 20, - color: context.theme.icon, + color: theme.icon, onTap: controller.zoomOut, ), ActionButton( name: Resources.assetsImagesRotatoSvg, - color: context.theme.icon, + color: theme.icon, size: 20, onTap: controller.rotate, ), @@ -477,17 +491,17 @@ class _Action extends StatelessWidget { itemBuilder: (context) => [ CustomPopupMenuItem( icon: Resources.assetsImagesShareSvg, - title: context.l10n.forward, + title: l10n.forward, value: _ActionType.share, ), CustomPopupMenuItem( icon: Resources.assetsImagesCopySvg, - title: context.l10n.copy, + title: l10n.copy, value: _ActionType.copy, ), CustomPopupMenuItem( icon: Resources.assetsImagesAttachmentDownloadSvg, - title: context.l10n.download, + title: l10n.download, value: _ActionType.download, ), ], @@ -545,6 +559,7 @@ class _Item extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).requireValue; // scale image to fit viewport on first show. final initialScale = useMemoized(() { final imageSize = Size( @@ -581,7 +596,7 @@ class _Item extends HookConsumerWidget { }, image: Image.file( File( - context.accountServer.convertMessageAbsolutePath( + accountServer.convertMessageAbsolutePath( message, isTranscriptPage, ), diff --git a/lib/widgets/message/item/location/location_message_widget.dart b/lib/widgets/message/item/location/location_message_widget.dart index 313f956f90..552f077744 100644 --- a/lib/widgets/message/item/location/location_message_widget.dart +++ b/lib/widgets/message/item/location/location_message_widget.dart @@ -47,7 +47,7 @@ class LocationMessageWidget extends HookConsumerWidget { url = 'https://www.google.com/maps/search/${Uri.encodeComponent(location.address!)}/@${location.latitude},${location.longitude},17z?hl=zh-CN'; } - openUri(context, url); + openUri(context, url, container: ref.container); }, child: Stack( children: [ diff --git a/lib/widgets/message/item/pin_message.dart b/lib/widgets/message/item/pin_message.dart index b0976a3a72..a83da9ac65 100644 --- a/lib/widgets/message/item/pin_message.dart +++ b/lib/widgets/message/item/pin_message.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../blaze/vo/pin_message_minimal.dart'; import '../../../ui/provider/mention_cache_provider.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/hook.dart'; import '../../../utils/message_optimize.dart'; @@ -20,6 +21,7 @@ class PinMessageWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); final mentionCache = ref.read(mentionCacheProvider); final content = useMessageConverter( @@ -36,7 +38,7 @@ class PinMessageWidget extends HookConsumerWidget { final cachePreview = useMemoized(() { if (pinMessageMinimal == null) { - return context.l10n.chatPinMessage(userFullName, context.l10n.aMessage); + return l10n.chatPinMessage(userFullName, l10n.aMessage); } final preview = cachePinPreviewText( pinMessageMinimal: pinMessageMinimal, @@ -48,8 +50,8 @@ class PinMessageWidget extends HookConsumerWidget { ? '${lines.first}...' : lines.firstOrNull ?? ''; - return context.l10n.chatPinMessage(userFullName, singleLinePreview); - }, [userFullName, content, mentionCache]); + return l10n.chatPinMessage(userFullName, singleLinePreview); + }, [userFullName, content, mentionCache, l10n]); final text = useMemoizedFuture( () async { @@ -64,12 +66,10 @@ class PinMessageWidget extends HookConsumerWidget { final singleLinePreview = lines.length > 1 ? '${lines.first}...' : lines.firstOrNull ?? ''; - return context.l10n - .chatPinMessage(userFullName, singleLinePreview) - .overflow; + return l10n.chatPinMessage(userFullName, singleLinePreview).overflow; }, cachePreview, - keys: [userFullName, content, mentionCache], + keys: [userFullName, content, mentionCache, l10n], ).requireData; return Center( @@ -79,8 +79,11 @@ class PinMessageWidget extends HookConsumerWidget { constraints: const BoxConstraints(maxWidth: 400), child: DecoratedBox( decoration: BoxDecoration( - color: context.dynamicColor( - const Color.fromRGBO(202, 234, 201, 1), + color: ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(202, 234, 201, 1), + darkColor: null, + )), ), borderRadius: const BorderRadius.all(Radius.circular(10)), ), @@ -90,7 +93,12 @@ class PinMessageWidget extends HookConsumerWidget { text, style: TextStyle( fontSize: context.messageStyle.secondaryFontSize, - color: context.dynamicColor(const Color.fromRGBO(0, 0, 0, 1)), + color: ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(0, 0, 0, 1), + darkColor: null, + )), + ), ), overflow: TextOverflow.ellipsis, maxLines: 1, diff --git a/lib/widgets/message/item/post_message.dart b/lib/widgets/message/item/post_message.dart index 6f5d3970dc..77ac0497ce 100644 --- a/lib/widgets/message/item/post_message.dart +++ b/lib/widgets/message/item/post_message.dart @@ -5,6 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../constants/resources.dart'; import '../../../db/mixin_database.dart' hide Message, Offset; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/extension/extension.dart'; import '../../app_bar.dart'; import '../../buttons.dart'; @@ -124,7 +125,7 @@ class PostDetailIcon extends StatelessWidget { ); } -class PostPreview extends StatelessWidget { +class PostPreview extends ConsumerWidget { const PostPreview({required this.message, super.key}); static Future push( @@ -134,7 +135,7 @@ class PostPreview extends StatelessWidget { context: context, barrierColor: Colors.transparent, barrierDismissible: true, - barrierLabel: MaterialLocalizations.of(context).modalBarrierDismissLabel, + barrierLabel: Localization.current.close, pageBuilder: ( buildContext, @@ -149,24 +150,30 @@ class PostPreview extends StatelessWidget { final MessageItem message; @override - Widget build(BuildContext context) => Material( - color: context.theme.background, - child: Column( - children: [ - MixinAppBar( - leading: const SizedBox(), - actions: [MixinCloseButton(onTap: () => Navigator.pop(context))], - ), - Expanded( - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 600), - child: Markdown( - data: message.content ?? '', - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 32), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Material( + color: theme.background, + child: Column( + children: [ + MixinAppBar( + leading: const SizedBox(), + actions: [MixinCloseButton(onTap: () => Navigator.pop(context))], + ), + Expanded( + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 600), + child: Markdown( + data: message.content ?? '', + padding: const EdgeInsets.symmetric( + vertical: 8, + horizontal: 32, + ), + ), ), ), - ), - ], - ), - ); + ], + ), + ); + } } diff --git a/lib/widgets/message/item/quote_message.dart b/lib/widgets/message/item/quote_message.dart index 75ddfeb55f..e573c812dc 100644 --- a/lib/widgets/message/item/quote_message.dart +++ b/lib/widgets/message/item/quote_message.dart @@ -12,11 +12,13 @@ import '../../../db/dao/message_dao.dart'; import '../../../db/extension/message.dart'; import '../../../db/mixin_database.dart'; import '../../../enum/message_category.dart'; -import '../../../ui/home/bloc/blink_cubit.dart'; -import '../../../ui/home/bloc/message_bloc.dart'; +import '../../../ui/home/providers/home_scope_providers.dart'; +import '../../../ui/provider/account_server_provider.dart'; import '../../../ui/provider/conversation_provider.dart'; +import '../../../ui/provider/database_provider.dart'; import '../../../ui/provider/mention_cache_provider.dart'; import '../../../ui/provider/pending_jump_message_provider.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../ui/provider/user_cache_provider.dart'; import '../../../utils/color_utils.dart'; import '../../../utils/extension/extension.dart'; @@ -53,6 +55,8 @@ class QuoteMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final decodeMap = useMemoized(() { if (quoteContent == null) return null; return jsonDecode(quoteContent!); @@ -61,7 +65,7 @@ class QuoteMessage extends HookConsumerWidget { if (quoteMessageId?.isEmpty ?? true) return const SizedBox(); var inputMode = false; - final iconColor = context.theme.secondaryText; + final iconColor = theme.secondaryText; try { dynamic quote; @@ -123,7 +127,11 @@ class QuoteMessage extends HookConsumerWidget { return const Stream.empty(); } - return watchStickerById(context.database, stickerId); + final database = ref.read(databaseProvider).value; + if (database == null) { + return const Stream.empty(); + } + return watchStickerById(database, stickerId); }, keys: [type, stickerId], ).data; @@ -134,7 +142,7 @@ class QuoteMessage extends HookConsumerWidget { messageId: messageId, quoteMessageId: quoteMessageId!, userId: null, - description: context.l10n.messageNotSupport, + description: l10n.messageNotSupport, icon: SvgPicture.asset( Resources.assetsImagesRecallSvg, colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), @@ -187,7 +195,7 @@ class QuoteMessage extends HookConsumerWidget { Resources.assetsImagesImageSvg, colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), ), - description: context.l10n.image, + description: l10n.image, inputMode: inputMode, ); } @@ -202,7 +210,7 @@ class QuoteMessage extends HookConsumerWidget { Resources.assetsImagesVideoSvg, colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), ), - description: context.l10n.video, + description: l10n.video, inputMode: inputMode, ); } @@ -225,7 +233,7 @@ class QuoteMessage extends HookConsumerWidget { Resources.assetsImagesLiveSvg, colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), ), - description: context.l10n.live, + description: l10n.live, inputMode: inputMode, ); } @@ -239,7 +247,7 @@ class QuoteMessage extends HookConsumerWidget { Resources.assetsImagesFileSvg, colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), ), - description: mediaName ?? context.l10n.file, + description: mediaName ?? l10n.file, inputMode: inputMode, ); } @@ -253,7 +261,7 @@ class QuoteMessage extends HookConsumerWidget { Resources.assetsImagesFileSvg, colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), ), - description: context.l10n.transcript, + description: l10n.transcript, inputMode: inputMode, ); } @@ -281,7 +289,7 @@ class QuoteMessage extends HookConsumerWidget { Resources.assetsImagesLocationSvg, colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), ), - description: context.l10n.location, + description: l10n.location, inputMode: inputMode, ); } @@ -295,7 +303,7 @@ class QuoteMessage extends HookConsumerWidget { Resources.assetsImagesAudioSvg, colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), ), - description: context.l10n.audio, + description: l10n.audio, inputMode: inputMode, ); } @@ -316,7 +324,7 @@ class QuoteMessage extends HookConsumerWidget { Resources.assetsImagesStickerSvg, colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), ), - description: context.l10n.sticker, + description: l10n.sticker, inputMode: inputMode, ); } @@ -370,7 +378,7 @@ class QuoteMessage extends HookConsumerWidget { Resources.assetsImagesAppButtonSvg, colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), ), - description: description ?? context.l10n.bots, + description: description ?? l10n.bots, inputMode: inputMode, ); } @@ -382,7 +390,7 @@ class QuoteMessage extends HookConsumerWidget { messageId: messageId, quoteMessageId: quoteMessageId!, userId: null, - description: context.l10n.messageNotFound, + description: l10n.messageNotFound, icon: SvgPicture.asset( Resources.assetsImagesRecallSvg, colorFilter: ColorFilter.mode(iconColor, BlendMode.srcIn), @@ -393,7 +401,7 @@ class QuoteMessage extends HookConsumerWidget { } } -class _QuoteImage extends HookWidget { +class _QuoteImage extends HookConsumerWidget { const _QuoteImage({ required this.quote, required this.type, @@ -409,7 +417,7 @@ class _QuoteImage extends HookWidget { final String? messageId; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { final thumbImage = quote?.thumbImage as String?; final mediaUrl = quote?.mediaUrl as String?; @@ -420,7 +428,8 @@ class _QuoteImage extends HookWidget { } if (mediaUrl == null) { scheduleMicrotask(() async { - final messageDao = context.database.messageDao; + final database = ref.read(databaseProvider).requireValue; + final messageDao = database.messageDao; final messageItem = await messageDao.findMessageItemByMessageId( quoteMessageId, ); @@ -437,12 +446,15 @@ class _QuoteImage extends HookWidget { return MixinImage.file( File( - context.accountServer.convertAbsolutePath( - type, - quote.conversationId as String, - mediaUrl, - isTranscriptPage, - ), + ref + .read(accountServerProvider) + .requireValue + .convertAbsolutePath( + type, + quote.conversationId as String, + mediaUrl, + isTranscriptPage, + ), ), errorBuilder: (_, _, _) => ImageByBlurHashOrBase64(imageData: thumbImage!), @@ -475,12 +487,13 @@ class _QuoteMessageBase extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final iterator = LineSplitter.split(description).iterator; final _description = '${iterator.moveNext() ? iterator.current : ''}${iterator.moveNext() ? '...' : ''}'; final color = userId?.isNotEmpty == true ? getNameColorById(userId!) - : context.theme.accent; + : theme.accent; final user = userId != null ? ref.watch(userCacheProvider(userId!)) : null; @@ -494,11 +507,14 @@ class _QuoteMessageBase extends HookConsumerWidget { onTap!(); return; } - context.read().blinkByMessageId(quoteMessageId); + ref + .read(blinkControllerProvider.notifier) + .blinkByMessageId(quoteMessageId); try { if (context.isPinnedPage) { ConversationStateNotifier.selectConversation( + ref.container, context, context.message.conversationId, initIndexMessageId: quoteMessageId, @@ -507,11 +523,8 @@ class _QuoteMessageBase extends HookConsumerWidget { } } catch (_) {} - context.providerContainer - .read(pendingJumpMessageProvider.notifier) - .state = - messageId; - context.read().scrollTo(quoteMessageId); + ref.read(pendingJumpMessageProvider.notifier).set(messageId); + ref.read(messageControllerProvider.notifier).scrollTo(quoteMessageId); }, behavior: HitTestBehavior.opaque, child: Container( @@ -577,7 +590,7 @@ class _QuoteMessageBase extends HookConsumerWidget { style: TextStyle( fontSize: context.messageStyle.tertiaryFontSize, - color: context.theme.secondaryText, + color: theme.secondaryText, ), maxLines: 1, overflow: TextOverflow.ellipsis, diff --git a/lib/widgets/message/item/recall_message.dart b/lib/widgets/message/item/recall_message.dart index afff801e56..ccf0d0c273 100644 --- a/lib/widgets/message/item/recall_message.dart +++ b/lib/widgets/message/item/recall_message.dart @@ -6,7 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../constants/resources.dart'; import '../../../ui/provider/recall_message_reedit_provider.dart'; -import '../../../utils/extension/extension.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../message.dart'; import '../message_bubble.dart'; import '../message_style.dart'; @@ -16,6 +16,8 @@ class RecallMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final isCurrentUser = useIsCurrentUser(); final messageId = useMessageConverter( converter: (state) => state.messageId, @@ -28,10 +30,7 @@ class RecallMessage extends HookConsumerWidget { children: [ SvgPicture.asset( Resources.assetsImagesRecallSvg, - colorFilter: ColorFilter.mode( - context.theme.secondaryText, - BlendMode.srcIn, - ), + colorFilter: ColorFilter.mode(theme.secondaryText, BlendMode.srcIn), width: 16, height: 16, ), @@ -42,13 +41,13 @@ class RecallMessage extends HookConsumerWidget { children: [ TextSpan( text: isCurrentUser - ? context.l10n.youDeletedThisMessage - : context.l10n.thisMessageWasDeleted, + ? l10n.youDeletedThisMessage + : l10n.thisMessageWasDeleted, ), if (recalledText != null) TextSpan( - text: ' ${context.l10n.reedit}', - style: TextStyle(color: context.theme.accent), + text: ' ${l10n.reedit}', + style: TextStyle(color: theme.accent), recognizer: TapGestureRecognizer() ..onTap = () => ref .read(recallMessageNotifierProvider) @@ -58,7 +57,7 @@ class RecallMessage extends HookConsumerWidget { ), style: TextStyle( fontSize: context.messageStyle.primaryFontSize, - color: context.theme.text, + color: theme.text, ), ), ), diff --git a/lib/widgets/message/item/secret_message.dart b/lib/widgets/message/item/secret_message.dart index 8fc8a720ab..374188d4d7 100644 --- a/lib/widgets/message/item/secret_message.dart +++ b/lib/widgets/message/item/secret_message.dart @@ -1,39 +1,53 @@ import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../../utils/extension/extension.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/uri_utils.dart'; import '../message_style.dart'; -class SecretMessage extends StatelessWidget { +class SecretMessage extends ConsumerWidget { const SecretMessage({super.key}); @override - Widget build(BuildContext context) => Center( - child: Padding( - padding: const EdgeInsets.only(left: 8, right: 8, bottom: 4), - child: MouseRegion( - cursor: SystemMouseCursors.click, - child: GestureDetector( - onTap: () => openUri(context, context.l10n.secretUrl), - child: DecoratedBox( - decoration: BoxDecoration( - color: context.theme.encrypt, - borderRadius: const BorderRadius.all(Radius.circular(10)), - ), - child: Padding( - padding: const EdgeInsets.all(10), - child: Text( - context.l10n.messageE2ee, - style: TextStyle( - fontSize: context.messageStyle.secondaryFontSize, - color: context.dynamicColor(const Color.fromRGBO(0, 0, 0, 1)), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final textColor = ref.watch( + dynamicColorProvider( + ( + color: const Color.fromRGBO(0, 0, 0, 1), + darkColor: null, + ), + ), + ); + return Center( + child: Padding( + padding: const EdgeInsets.only(left: 8, right: 8, bottom: 4), + child: MouseRegion( + cursor: SystemMouseCursors.click, + child: GestureDetector( + onTap: () => + openUri(context, l10n.secretUrl, container: ref.container), + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.encrypt, + borderRadius: const BorderRadius.all(Radius.circular(10)), + ), + child: Padding( + padding: const EdgeInsets.all(10), + child: Text( + l10n.messageE2ee, + style: TextStyle( + fontSize: context.messageStyle.secondaryFontSize, + color: textColor, + ), ), ), ), ), ), ), - ), - ); + ); + } } diff --git a/lib/widgets/message/item/sticker_message.dart b/lib/widgets/message/item/sticker_message.dart index 105bdebd9a..6da5da95ce 100644 --- a/lib/widgets/message/item/sticker_message.dart +++ b/lib/widgets/message/item/sticker_message.dart @@ -5,6 +5,9 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_logger/mixin_logger.dart'; +import '../../../ui/provider/account_server_provider.dart'; +import '../../../ui/provider/database_provider.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/hook.dart'; import '../../../utils/sticker_watch.dart'; @@ -37,6 +40,9 @@ class StickerMessageWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final mediaQuery = ref.watch(mediaQueryDataProvider); + final database = ref.read(databaseProvider).requireValue; final stickerId = useMessageConverter( converter: (state) => state.stickerId, ); @@ -77,7 +83,7 @@ class StickerMessageWidget extends HookConsumerWidget { d('stickerData watch: $stickerId, ${context.message.messageId}'); yield* watchStickerById( - context.database, + database, stickerId, ).whereNotNull().map( (event) => _StickerData( @@ -95,7 +101,7 @@ class StickerMessageWidget extends HookConsumerWidget { final assetType = stickerData?.assetType; final stickerSize = _calculateSize( - context, + mediaQuery, stickerData?.assetWidth?.toDouble(), stickerData?.assetHeight?.toDouble(), assetType, @@ -104,7 +110,7 @@ class StickerMessageWidget extends HookConsumerWidget { final errorWidget = Container( width: stickerSize.width, height: stickerSize.height, - color: context.theme.stickerPlaceholderColor, + color: theme.stickerPlaceholderColor, ); return MessageBubble( showBubble: false, @@ -119,7 +125,12 @@ class StickerMessageWidget extends HookConsumerWidget { onTap: () { if (stickerId == null) return; - showStickerPageDialog(context, stickerId); + showStickerPageDialog( + context, + stickerId, + database: ref.read(databaseProvider).requireValue, + accountServer: ref.read(accountServerProvider).requireValue, + ); }, child: StickerItem( stickerId: stickerId, @@ -139,12 +150,12 @@ class StickerMessageWidget extends HookConsumerWidget { const kMaxWidth = 140.0; Size _calculateSize( - BuildContext context, + MediaQueryData mediaQuery, double? assetWidth, double? assetHeight, String? assetType, ) { - final devicePixelRatio = MediaQuery.of(context).devicePixelRatio; + final devicePixelRatio = mediaQuery.devicePixelRatio; double width; double height; @@ -197,9 +208,7 @@ Size _calculateSize( var size = Size(width, height) / devicePixelRatio; final isJson = assetType == 'json'; - if (!isJson && - scale <= 0.5 && - MediaQuery.of(context).devicePixelRatio <= 1.5) { + if (!isJson && scale <= 0.5 && mediaQuery.devicePixelRatio <= 1.5) { d('scale: $scale, devicePixelRatio: $devicePixelRatio'); if (size.longestSide >= kMaxWidth) { // scale up max to 200px for less than 1.5x device. eg. Windows 1920 * 1080 diff --git a/lib/widgets/message/item/stranger_message.dart b/lib/widgets/message/item/stranger_message.dart index f9f89f2426..5b443da1c2 100644 --- a/lib/widgets/message/item/stranger_message.dart +++ b/lib/widgets/message/item/stranger_message.dart @@ -1,19 +1,26 @@ import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../enum/encrypt_category.dart'; -import '../../../utils/extension/extension.dart'; +import '../../../ui/provider/account_server_provider.dart'; +import '../../../ui/provider/database_provider.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/web_view/web_view_interface.dart'; import '../../interactive_decorated_box.dart'; import '../../toast.dart'; import '../message.dart'; import '../message_style.dart'; -class StrangerMessage extends StatelessWidget { +class StrangerMessage extends HookConsumerWidget { const StrangerMessage({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; + final database = ref.read(databaseProvider).requireValue; final isBotConversation = useMessageConverter( converter: (state) => state.appId != null, ); @@ -21,12 +28,10 @@ class StrangerMessage extends StatelessWidget { return Column( children: [ Text( - isBotConversation - ? context.l10n.chatBotReceptionTitle - : context.l10n.strangerHint, + isBotConversation ? l10n.chatBotReceptionTitle : l10n.strangerHint, style: TextStyle( fontSize: context.messageStyle.primaryFontSize, - color: context.theme.text, + color: theme.text, ), ), const SizedBox(height: 10), @@ -34,41 +39,40 @@ class StrangerMessage extends StatelessWidget { mainAxisAlignment: MainAxisAlignment.center, children: [ _StrangerButton( - isBotConversation - ? context.l10n.openHomePage - : context.l10n.block, + isBotConversation ? l10n.openHomePage : l10n.block, onTap: () async { final message = context.message; if (isBotConversation) { - final app = await context.database.appDao.findAppById( + final app = await database.appDao.findAppById( message.appId!, ); if (app == null) return; await MixinWebView.instance.openBotWebViewWindow( context, + ref.container, app, conversationId: message.conversationId, ); } else { await runFutureWithToast( - context.accountServer.blockUser(message.userId), + accountServer.blockUser(message.userId), ); } }, ), const SizedBox(width: 16), _StrangerButton( - isBotConversation ? context.l10n.sayHi : context.l10n.addContact, + isBotConversation ? l10n.sayHi : l10n.addContact, onTap: () { final message = context.message; if (isBotConversation) { - context.accountServer.sendTextMessage( + accountServer.sendTextMessage( 'Hi', EncryptCategory.plain, conversationId: message.conversationId, ); } else { - context.accountServer.addUser( + accountServer.addUser( message.userId, message.userFullName, ); @@ -82,37 +86,40 @@ class StrangerMessage extends StatelessWidget { } } -class _StrangerButton extends StatelessWidget { +class _StrangerButton extends ConsumerWidget { const _StrangerButton(this.text, {this.onTap}); final String text; final VoidCallback? onTap; @override - Widget build(BuildContext context) => InteractiveDecoratedBox.color( - onTap: onTap, - decoration: BoxDecoration( - color: context.theme.primary, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: ConstrainedBox( - constraints: const BoxConstraints( - minWidth: 162, - minHeight: 36, - maxHeight: 36, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return InteractiveDecoratedBox.color( + onTap: onTap, + decoration: BoxDecoration( + color: theme.primary, + borderRadius: const BorderRadius.all(Radius.circular(8)), ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Center( - child: Text( - text, - style: TextStyle( - fontSize: context.messageStyle.primaryFontSize, - color: context.theme.accent, + child: ConstrainedBox( + constraints: const BoxConstraints( + minWidth: 162, + minHeight: 36, + maxHeight: 36, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Center( + child: Text( + text, + style: TextStyle( + fontSize: context.messageStyle.primaryFontSize, + color: theme.accent, + ), ), ), ), ), - ), - ); + ); + } } diff --git a/lib/widgets/message/item/system_message.dart b/lib/widgets/message/item/system_message.dart index f1c9684bd3..2c622bf924 100644 --- a/lib/widgets/message/item/system_message.dart +++ b/lib/widgets/message/item/system_message.dart @@ -2,7 +2,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../enum/message_action.dart'; -import '../../../generated/l10n.dart'; +import '../../../ui/provider/account_server_provider.dart'; import '../../../utils/extension/extension.dart'; import '../../high_light_text.dart'; import '../message.dart'; @@ -13,6 +13,7 @@ class SystemMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).requireValue; final actionName = useMessageConverter( converter: (state) => state.actionName, ); @@ -35,7 +36,8 @@ class SystemMessage extends HookConsumerWidget { cursor: SystemMouseCursors.click, child: DecoratedBox( decoration: BoxDecoration( - color: context.dynamicColor( + color: BrightnessData.dynamicColor( + context, const Color.fromRGBO(202, 234, 201, 1), ), borderRadius: const BorderRadius.all(Radius.circular(10)), @@ -47,14 +49,17 @@ class SystemMessage extends HookConsumerWidget { actionName: actionName, participantUserId: participantUserId, senderId: senderId, - currentUserId: context.accountServer.userId, + currentUserId: accountServer.userId, participantFullName: participantFullName, senderFullName: userFullName, expireIn: int.tryParse(content ?? '0'), ), style: TextStyle( fontSize: context.messageStyle.secondaryFontSize, - color: context.dynamicColor(const Color.fromRGBO(0, 0, 0, 1)), + color: BrightnessData.dynamicColor( + context, + const Color.fromRGBO(0, 0, 0, 1), + ), ), ), ), diff --git a/lib/widgets/message/item/text/text_message.dart b/lib/widgets/message/item/text/text_message.dart index ae590957f9..1baabdc0f2 100644 --- a/lib/widgets/message/item/text/text_message.dart +++ b/lib/widgets/message/item/text/text_message.dart @@ -2,11 +2,11 @@ import 'package:flutter/material.dart' hide SelectableRegion; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../../../ui/home/chat/chat_page.dart'; +import '../../../../ui/home/providers/home_scope_providers.dart'; import '../../../../ui/provider/conversation_provider.dart'; import '../../../../ui/provider/keyword_provider.dart'; import '../../../../ui/provider/mention_cache_provider.dart'; -import '../../../../utils/extension/extension.dart'; +import '../../../../ui/provider/ui_context_providers.dart'; import '../../../../utils/hook.dart'; import '../../../../utils/platform.dart'; import '../../../high_light_text.dart'; @@ -22,13 +22,14 @@ class TextMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final content = useMessageConverter( converter: (state) => state.content ?? '', ); return MessageBubble( child: MessageTextWidget( fontSize: context.messageStyle.primaryFontSize, - color: context.theme.text, + color: theme.text, content: content, ), ); @@ -51,20 +52,9 @@ class MessageTextWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final userId = useMessageConverter(converter: (state) => state.userId); - - var keyword = - useBlocStateConverter< - SearchConversationKeywordCubit, - (String?, String), - String - >( - converter: (state) { - if (state.$1 == null || state.$1 == userId) return state.$2; - return ''; - }, - keys: [userId], - ); + var keyword = ref.watch(searchConversationKeywordForUserProvider(userId)); final globalKeyword = ref.watch(trimmedKeywordProvider); final conversationKeyword = ref.watch( @@ -78,6 +68,7 @@ class MessageTextWidget extends HookConsumerWidget { } final mentionCache = ref.read(mentionCacheProvider); + final app = ref.watch(conversationProvider.select((value) => value?.app)); final mentionMap = useMemoizedFuture( () => mentionCache.checkMentionCache({content}), @@ -93,16 +84,30 @@ class MessageTextWidget extends HookConsumerWidget { content, style: TextStyle(fontSize: fontSize, color: color), textMatchers: [ - UrlTextMatcher(context), - MailTextMatcher(context), - MentionTextMatcher(context, mentionMap), - BotNumberTextMatcher(context), + UrlTextMatcher( + context, + container: ref.container, + accent: theme.accent, + app: app, + ), + MailTextMatcher(accent: theme.accent), + MentionTextMatcher( + context, + mentionMap, + container: ref.container, + accent: theme.accent, + ), + BotNumberTextMatcher( + context, + container: ref.container, + accent: theme.accent, + ), EmojiTextMatcher(), KeyWordTextMatcher( keyword, style: TextStyle( - backgroundColor: context.theme.highlight, - color: context.theme.text, + backgroundColor: theme.highlight, + color: theme.text, ), ), ], diff --git a/lib/widgets/message/item/transcript_message.dart b/lib/widgets/message/item/transcript_message.dart index e6a8c0c821..639756e52c 100644 --- a/lib/widgets/message/item/transcript_message.dart +++ b/lib/widgets/message/item/transcript_message.dart @@ -2,21 +2,19 @@ import 'dart:convert'; import 'dart:math' as math; import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; -import 'package:provider/provider.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../blaze/vo/transcript_minimal.dart'; import '../../../constants/resources.dart'; +import '../../../db/database.dart'; import '../../../db/database_event_bus.dart'; import '../../../db/mixin_database.dart'; -import '../../../ui/home/bloc/blink_cubit.dart'; -import '../../../ui/home/chat/chat_page.dart'; -import '../../../utils/audio_message_player/audio_message_service.dart'; +import '../../../ui/home/providers/home_scope_providers.dart'; +import '../../../ui/provider/database_provider.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/extension/extension.dart'; -import '../../../utils/hook.dart'; import '../../../utils/logger.dart'; import '../../../utils/message_optimize.dart'; import '../../action_button.dart'; @@ -27,7 +25,6 @@ import '../message_bubble.dart'; import '../message_datetime_and_status.dart'; import '../message_day_time.dart'; import '../message_style.dart'; -import 'audio_message.dart'; import 'unknown_message.dart'; class TranscriptMessagesWatcher { @@ -36,11 +33,64 @@ class TranscriptMessagesWatcher { final Stream> Function() watchMessages; } +final transcriptMessagesProvider = StreamProvider.autoDispose + .family, String>(( + ref, + transcriptMessageId, + ) { + final database = ref.watch(databaseProvider).value; + if (database == null) { + return Stream.value(const []); + } + return _watchTranscriptMessages(database, transcriptMessageId); + }); + +final transcriptMessagesWatcherProvider = Provider.autoDispose + .family(( + ref, + transcriptMessageId, + ) { + final database = ref.watch(databaseProvider).value; + if (database == null) { + return TranscriptMessagesWatcher( + () => const Stream>.empty(), + ); + } + return TranscriptMessagesWatcher( + () => _watchTranscriptMessages(database, transcriptMessageId), + ); + }); + +Stream> _watchTranscriptMessages( + Database database, + String transcriptMessageId, +) => database.transcriptMessageDao + .transactionMessageItem(transcriptMessageId) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchUpdateTranscriptMessageStream( + transcriptIds: [transcriptMessageId], + ), + DataBaseEventBus.instance.updateAssetStream, + DataBaseEventBus.instance.updateStickerStream, + ], + duration: kDefaultThrottleDuration, + ) + .map( + (list) => list + .map( + (transcriptMessageItem) => transcriptMessageItem.messageItem, + ) + .toList(), + ); + class TranscriptMessageWidget extends HookConsumerWidget { const TranscriptMessageWidget({super.key}); @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final content = useMessageConverter( converter: (state) => state.content ?? '', ); @@ -105,19 +155,19 @@ class TranscriptMessageWidget extends HookConsumerWidget { padding: const EdgeInsets.only(top: 4, bottom: 2, right: 2, left: 2), child: InteractiveDecoratedBox( onTap: () async { + final audioService = ref.read(audioMessagePlayServiceProvider); final message = context.message; await showMixinDialog( context: context, padding: const EdgeInsets.symmetric(vertical: 80), - backgroundColor: context.theme.chatBackground, + backgroundColor: theme.chatBackground, child: TranscriptPage( transcriptMessage: message, - vlcService: context.audioMessageService, ), ); - if (context.audioMessageService.playing) { - context.audioMessageService.stop(); + if (audioService.playing) { + audioService.stop(); } }, child: SizedBox( @@ -134,9 +184,9 @@ class TranscriptMessageWidget extends HookConsumerWidget { children: [ const SizedBox(width: 4), Text( - context.l10n.transcript, + l10n.transcript, style: TextStyle( - color: context.theme.text, + color: theme.text, fontSize: context.messageStyle.primaryFontSize, ), ), @@ -176,7 +226,7 @@ class TranscriptMessageWidget extends HookConsumerWidget { (text) => Text( text, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: context.messageStyle.tertiaryFontSize, ), @@ -217,12 +267,10 @@ class TranscriptMessageWidget extends HookConsumerWidget { class TranscriptPage extends HookConsumerWidget { const TranscriptPage({ - required this.vlcService, required this.transcriptMessage, super.key, }); - final AudioMessagePlayService vlcService; final MessageItem transcriptMessage; static MessageItem? of(BuildContext context) => context @@ -231,47 +279,40 @@ class TranscriptPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - Stream> watchMessages() => context - .database - .transcriptMessageDao - .transactionMessageItem(transcriptMessage.messageId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.watchUpdateTranscriptMessageStream( - transcriptIds: [transcriptMessage.messageId], - ), - DataBaseEventBus.instance.updateAssetStream, - DataBaseEventBus.instance.updateStickerStream, - ], - duration: kDefaultThrottleDuration, - ) - .map( - (list) => list - .map( - (transcriptMessageItem) => transcriptMessageItem.messageItem, - ) - .toList(), - ); - - final list = useMemoizedStream(watchMessages).data ?? []; - - final chatSideCubit = useBloc(ChatSideCubit.new); - final searchConversationKeywordCubit = useBloc( - () => SearchConversationKeywordCubit(chatSideCubit: chatSideCubit), + final scrollController = useMemoized(ScrollController.new); + final listKey = useMemoized( + () => GlobalKey(debugLabel: 'transcript_list_key'), ); - final tickerProvider = useSingleTickerProvider(); - final blinkCubit = useBloc( - () => BlinkCubit( - tickerProvider, - context.theme.accent.withValues(alpha: 0.5), + return TickerMode( + enabled: ModalRoute.of(context)?.isCurrent ?? true, + child: _TranscriptPageScope( + transcriptMessageId: transcriptMessage.messageId, + listKey: listKey, + scrollController: scrollController, ), ); + } +} - final scrollController = useMemoized(ScrollController.new); - final listKey = useMemoized( - () => GlobalKey(debugLabel: 'transcript_list_key'), - ); +class _TranscriptPageScope extends HookConsumerWidget { + const _TranscriptPageScope({ + required this.transcriptMessageId, + required this.listKey, + required this.scrollController, + }); + + final String transcriptMessageId; + final GlobalKey listKey; + final ScrollController scrollController; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final list = + ref.watch(transcriptMessagesProvider(transcriptMessageId)).value ?? + []; return ConstrainedBox( constraints: const BoxConstraints( @@ -279,71 +320,57 @@ class TranscriptPage extends HookConsumerWidget { minWidth: 600, minHeight: 800, ), - child: MultiProvider( - providers: [ - BlocProvider.value(value: searchConversationKeywordCubit), - BlocProvider.value(value: blinkCubit), - Provider.value(value: vlcService), - Provider( - create: (_) => AudioMessagesPlayAgent( - list, - (m) => context.accountServer.convertMessageAbsolutePath(m, true), - ), - ), - Provider.value(value: TranscriptMessagesWatcher(watchMessages)), - ], - child: Column( - children: [ - Padding( - padding: const EdgeInsets.only(right: 16, left: 16, top: 16), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Align( - alignment: Alignment.centerLeft, - child: ActionButton( - name: Resources.assetsImagesIcCloseSvg, - color: context.theme.icon, - onTap: () => Navigator.pop(context), - ), + child: Column( + children: [ + Padding( + padding: const EdgeInsets.only(right: 16, left: 16, top: 16), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Align( + alignment: Alignment.centerLeft, + child: ActionButton( + name: Resources.assetsImagesIcCloseSvg, + color: theme.icon, + onTap: () => Navigator.pop(context), ), ), - Expanded( - child: Align( - child: Text( - context.l10n.transcript, - style: TextStyle( - color: context.theme.text, - fontSize: 16, - ), + ), + Expanded( + child: Align( + child: Text( + l10n.transcript, + style: TextStyle( + color: theme.text, + fontSize: 16, ), ), ), - const Expanded(child: SizedBox()), - ], - ), + ), + const Expanded(child: SizedBox()), + ], ), - Expanded( - child: MessageDayTimeViewportWidget.singleList( - listKey: listKey, - scrollController: scrollController, - child: ListView.builder( - controller: scrollController, - key: listKey, - padding: const EdgeInsets.only(bottom: 16), - itemBuilder: (context, index) => MessageItemWidget( - prev: list.getOrNull(index - 1), - message: list[index], - next: list.getOrNull(index + 1), - isTranscriptPage: true, - ), - itemCount: list.length, + ), + Expanded( + child: MessageDayTimeViewportWidget.singleList( + listKey: listKey, + scrollController: scrollController, + child: ListView.builder( + controller: scrollController, + key: listKey, + padding: const EdgeInsets.only(bottom: 16), + itemBuilder: (context, index) => MessageItemWidget( + prev: list.getOrNull(index - 1), + message: list[index], + next: list.getOrNull(index + 1), + isTranscriptPage: true, ), + itemCount: list.length, ), ), - ], - ), + ), + ], ), ); } diff --git a/lib/widgets/message/item/transfer/inscription_message/inscription_content.dart b/lib/widgets/message/item/transfer/inscription_message/inscription_content.dart index 0b96ca0aef..e861602f81 100644 --- a/lib/widgets/message/item/transfer/inscription_message/inscription_content.dart +++ b/lib/widgets/message/item/transfer/inscription_message/inscription_content.dart @@ -5,11 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:hexagon/hexagon.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../../../constants/resources.dart'; import '../../../../../db/vo/inscription.dart'; +import '../../../../../ui/provider/database_provider.dart'; +import '../../../../../ui/provider/ui_context_providers.dart'; import '../../../../../utils/cache_client.dart'; -import '../../../../../utils/extension/extension.dart'; import '../../../../../utils/hook.dart'; import '../../../../mixin_image.dart'; import 'inscription_message.dart'; @@ -64,7 +66,7 @@ String inscriptionDisplayContent(String content) => content.replaceAll( 'â– ', ); -class _TextInscriptionContent extends HookWidget { +class _TextInscriptionContent extends HookConsumerWidget { const _TextInscriptionContent({ required this.contentUrl, required this.iconUrl, @@ -76,17 +78,18 @@ class _TextInscriptionContent extends HookWidget { final InscriptionContentMode mode; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final database = ref.read(databaseProvider).requireValue; final defaultCollectionImage = SvgPicture.asset( Resources.assetsImagesCollectionPlaceholderSvg, ); final client = useMemoized( () => CacheClient( - context.database.settingProperties.activatedProxy, + database.settingProperties.activatedProxy, cacheInscriptionTextFolderName, ), - [], + [database.settingProperties.activatedProxy], ); final text = useMemoizedFuture( @@ -161,7 +164,7 @@ class _TextInscriptionContent extends HookWidget { } } -class _MinLinesWrapper extends HookWidget { +class _MinLinesWrapper extends HookConsumerWidget { const _MinLinesWrapper({ required this.text, required this.style, @@ -175,16 +178,17 @@ class _MinLinesWrapper extends HookWidget { final int minLines; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final mediaQuery = ref.watch(mediaQueryDataProvider); final minHeight = useMemoized(() { final textPainter = TextPainter( text: TextSpan(text: text, style: style), textDirection: TextDirection.ltr, maxLines: minLines, - )..layout(maxWidth: MediaQuery.of(context).size.width); + )..layout(maxWidth: mediaQuery.size.width); return textPainter.preferredLineHeight * minLines; - }, [text, style, minLines]); + }, [text, style, minLines, mediaQuery.size.width]); return ConstrainedBox( constraints: BoxConstraints(minHeight: minHeight), diff --git a/lib/widgets/message/item/transfer/inscription_message/inscription_dialog.dart b/lib/widgets/message/item/transfer/inscription_message/inscription_dialog.dart index f9f79a7fe2..d8dd02304e 100644 --- a/lib/widgets/message/item/transfer/inscription_message/inscription_dialog.dart +++ b/lib/widgets/message/item/transfer/inscription_message/inscription_dialog.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../../../constants/brightness_theme_data.dart'; import '../../../../../db/vo/inscription.dart'; import '../../../../../ui/provider/database_provider.dart'; +import '../../../../../ui/provider/ui_context_providers.dart'; import '../../../../../utils/extension/extension.dart'; import '../../../../buttons.dart'; import '../../../../dialog.dart'; @@ -39,10 +40,9 @@ class _InscriptionDialog extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final inscription = ref - .watch(_inscriptionProvider(inscriptionHash)) - .valueOrNull; - const theme = darkBrightnessThemeData; + final inscription = ref.watch(_inscriptionProvider(inscriptionHash)).value; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); return SizedBox( width: 400, child: Stack( @@ -67,14 +67,17 @@ class _InscriptionDialog extends ConsumerWidget { height: 200, child: Center( child: CircularProgressIndicator( - color: context.theme.secondaryText, + color: theme.secondaryText, ), ), ) else Flexible( child: SingleChildScrollView( - child: _InscriptionDetailLayout(inscription: inscription), + child: _InscriptionDetailLayout( + inscription: inscription, + l10n: l10n, + ), ), ), ], @@ -109,9 +112,13 @@ class _BlurBackground extends StatelessWidget { } class _InscriptionDetailLayout extends StatelessWidget { - const _InscriptionDetailLayout({required this.inscription}); + const _InscriptionDetailLayout({ + required this.inscription, + required this.l10n, + }); final Inscription inscription; + final Localization l10n; @override Widget build(BuildContext context) => Padding( @@ -129,7 +136,7 @@ class _InscriptionDetailLayout extends StatelessWidget { ), const SizedBox(height: 20), _ItemInfoTile( - title: Text(context.l10n.hash), + title: Text(l10n.hash), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ @@ -148,12 +155,12 @@ class _InscriptionDetailLayout extends StatelessWidget { ), const SizedBox(height: 20), _ItemInfoTile( - title: Text(context.l10n.id), + title: Text(l10n.id), subtitle: Text('#${inscription.sequence}'), ), const SizedBox(height: 20), _ItemInfoTile( - title: Text(context.l10n.collection), + title: Text(l10n.collection), subtitle: Text(inscription.name ?? ''), ), const SizedBox(height: 20), diff --git a/lib/widgets/message/item/transfer/inscription_message/inscription_message.dart b/lib/widgets/message/item/transfer/inscription_message/inscription_message.dart index 2a07675775..d7e42c3140 100644 --- a/lib/widgets/message/item/transfer/inscription_message/inscription_message.dart +++ b/lib/widgets/message/item/transfer/inscription_message/inscription_message.dart @@ -5,10 +5,13 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:hexagon/hexagon.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_logger/mixin_logger.dart'; import '../../../../../constants/resources.dart'; import '../../../../../db/vo/inscription.dart'; +import '../../../../../ui/provider/account_server_provider.dart'; +import '../../../../../ui/provider/ui_context_providers.dart'; import '../../../../../utils/extension/extension.dart'; import '../../../../interactive_decorated_box.dart'; import '../../../../mixin_image.dart'; @@ -20,11 +23,14 @@ import 'colored_hash_widget.dart'; import 'inscription_content.dart'; import 'inscription_dialog.dart'; -class InscriptionMessage extends HookWidget { +class InscriptionMessage extends HookConsumerWidget { const InscriptionMessage({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).requireValue; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final content = useMessageConverter(converter: (m) => m.content); final inscription = useMemoized(() { if (content == null) { @@ -38,7 +44,7 @@ class InscriptionMessage extends HookWidget { e('InscriptionMessage: errored to parse content: $content', error); try { hex.decode(content); - context.accountServer.addSyncInscriptionMessageJob( + accountServer.addSyncInscriptionMessageJob( context.message.messageId, ); } catch (_) {} @@ -57,21 +63,22 @@ class InscriptionMessage extends HookWidget { child: InteractiveDecoratedBox( onTap: () { if (inscription == null) { - showToastFailed(context.l10n.dataLoading); + showToastFailed(l10n.dataLoading); return; } showInscriptionDialog(context, inscription.inscriptionHash); }, - child: _InscriptionLayout(inscription: inscription), + child: _InscriptionLayout(inscription: inscription, theme: theme), ), ); } } class _InscriptionLayout extends StatelessWidget { - const _InscriptionLayout({required this.inscription}); + const _InscriptionLayout({required this.inscription, required this.theme}); final Inscription? inscription; + final BrightnessThemeData theme; @override Widget build(BuildContext context) { @@ -95,14 +102,17 @@ class _InscriptionLayout extends StatelessWidget { children: [ Text( inscription?.name ?? '', - style: TextStyle(color: context.theme.text, fontSize: 14), + style: TextStyle( + color: theme.text, + fontSize: 14, + ), ), const SizedBox(height: 4), Text( inscription == null ? '' : '#${inscription!.sequence}', style: TextStyle( fontSize: 12, - color: context.theme.secondaryText, + color: theme.secondaryText, ), ), const Spacer(), diff --git a/lib/widgets/message/item/transfer/safe_transfer_dialog.dart b/lib/widgets/message/item/transfer/safe_transfer_dialog.dart index bde10e52f5..fb8aa86a04 100644 --- a/lib/widgets/message/item/transfer/safe_transfer_dialog.dart +++ b/lib/widgets/message/item/transfer/safe_transfer_dialog.dart @@ -10,6 +10,7 @@ import '../../../../db/mixin_database.dart' hide Offset; import '../../../../ui/provider/account_server_provider.dart'; import '../../../../ui/provider/multi_auth_provider.dart'; import '../../../../ui/provider/transfer_provider.dart'; +import '../../../../ui/provider/ui_context_providers.dart'; import '../../../../utils/extension/extension.dart'; import '../../../buttons.dart'; import '../../../dialog.dart'; @@ -65,29 +66,34 @@ class _SafeTransferDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; useEffect(() { - context.accountServer.updateFiats(); + accountServer.updateFiats(); + return null; }, []); final snapshot = ref.watch(safeSnapshotProvider(snapshotId)); useEffect(() { - context.accountServer.updateSafeSnapshotById(snapshotId: snapshotId); + accountServer.updateSafeSnapshotById(snapshotId: snapshotId); + return null; }, [snapshotId]); var token = const AsyncValue.loading(); - final tokenId = snapshot.valueOrNull?.assetId; + final tokenId = snapshot.value?.assetId; if (tokenId != null) { token = ref.watch(tokenProvider(tokenId)); } useEffect(() { if (tokenId == null) { - return; + return null; } - context.accountServer.updateTokenById(assetId: tokenId); + accountServer.updateTokenById(assetId: tokenId); + return null; }, [tokenId]); - final snapshotValue = snapshot.valueOrNull; - final tokenValue = token.valueOrNull; + final snapshotValue = snapshot.value; + final tokenValue = token.value; final chain = ref.watch(assetChainProvider(tokenValue?.chainId)); if (snapshotValue == null) { @@ -97,7 +103,7 @@ class _SafeTransferDialog extends HookConsumerWidget { final isPositive = (double.tryParse(snapshotValue.amount) ?? 0) > 0; final type = ref.watch(_snapshotTypeProvider(snapshotValue)); - final fiatRate = ref.watch(_fiatRateProvider).unwrapPrevious().valueOrNull; + final fiatRate = ref.watch(_fiatRateProvider).unwrapPrevious().value; return SizedBox( width: 400, @@ -120,7 +126,7 @@ class _SafeTransferDialog extends HookConsumerWidget { children: [ SnapshotDetailHeader( symbolIconUrl: tokenValue?.iconUrl ?? '', - chainIconUrl: chain.valueOrNull?.iconUrl ?? '', + chainIconUrl: chain.value?.iconUrl ?? '', amount: snapshotValue.amount, symbol: tokenValue?.symbol ?? '', snapshotType: type, @@ -142,7 +148,10 @@ class _SafeTransferDialog extends HookConsumerWidget { ), ), const SizedBox(height: 24), - Container(color: context.theme.divider, height: 10), + Container( + color: theme.divider, + height: 10, + ), _SafeTransactionDetailInfo( snapshot: snapshotValue, token: tokenValue, @@ -160,7 +169,7 @@ class _SafeTransferDialog extends HookConsumerWidget { } final _fiatRateProvider = StreamProvider.autoDispose((ref) { - final accountServer = ref.watch(accountServerProvider).valueOrNull; + final accountServer = ref.watch(accountServerProvider).value; if (accountServer == null) { return const Stream.empty(); } @@ -183,7 +192,7 @@ final _tickerProvider = FutureProvider.family ref, snapshot, ) async { - final accountServer = ref.watch(accountServerProvider).valueOrNull; + final accountServer = ref.watch(accountServerProvider).value; if (accountServer == null) { return null; } @@ -208,9 +217,12 @@ class _ValuesDescription extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final fiatCurrency = ref.watch(authAccountProvider)?.fiatCurrency; final ticker = ref .watch(_tickerProvider((snapshot.assetId, snapshot.createdAt))) - .valueOrNull; + .value; final String? thatTimeValue; @@ -219,34 +231,36 @@ class _ValuesDescription extends HookConsumerWidget { token.priceUsd.asDecimal * fiatRate.asDecimal) .abs(); - final unitValue = context.currencyFormat( + final unitValue = currencyFormat( (current / snapshot.amount.asDecimal.abs()).toDouble(), + fiatCurrency: fiatCurrency, ); final symbol = token.symbol.overflow; final currentValue = - '${context.l10n.valueNow(context.currencyFormat(current))}($unitValue/$symbol)'; + '${l10n.valueNow(currencyFormat(current, fiatCurrency: fiatCurrency))}($unitValue/$symbol)'; if (ticker == null) { thatTimeValue = null; } else if (ticker.priceUsd == '0') { - thatTimeValue = context.l10n.valueThen(context.l10n.na); + thatTimeValue = l10n.valueThen(l10n.na); } else { final past = (snapshot.amount.asDecimal * ticker.priceUsd.asDecimal * fiatRate.asDecimal) .abs(); - final unitValue = context.currencyFormat( + final unitValue = currencyFormat( (past / snapshot.amount.asDecimal.abs()).toDouble(), + fiatCurrency: fiatCurrency, ); thatTimeValue = - '${context.l10n.valueThen(context.currencyFormat(past))}($unitValue/$symbol)'; + '${l10n.valueThen(currencyFormat(past, fiatCurrency: fiatCurrency))}($unitValue/$symbol)'; } return DefaultTextStyle.merge( style: TextStyle( fontSize: 14, fontWeight: FontWeight.w400, - color: context.theme.secondaryText, + color: theme.secondaryText, ), child: Column( mainAxisSize: MainAxisSize.min, @@ -281,6 +295,8 @@ class _SafeTransactionDetailInfo extends ConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final createdAt = DateTime.parse(snapshot.createdAt).toLocal(); final memo = parseSafeSnapshotMemo(snapshot.memo); return Padding( @@ -291,9 +307,9 @@ class _SafeTransactionDetailInfo extends ConsumerWidget { children: [ if (type == SnapshotType.pending) ...[ TransactionInfoTile( - title: Text(context.l10n.status), + title: Text(l10n.status), subtitle: CustomSelectableText( - context.l10n.pendingConfirmation( + l10n.pendingConfirmation( snapshot.confirmations ?? 0, snapshot.confirmations ?? 0, token?.confirmations ?? '', @@ -302,73 +318,75 @@ class _SafeTransactionDetailInfo extends ConsumerWidget { ), if (snapshot.deposit != null) TransactionInfoTile( - title: Text(context.l10n.depositHash), + title: Text(l10n.depositHash), subtitle: CustomSelectableText(snapshot.deposit!.depositHash), ), ] else ...[ TransactionInfoTile( - title: Text(context.l10n.transactionId), + title: Text(l10n.transactionId), subtitle: CustomSelectableText(snapshot.snapshotId), ), TransactionInfoTile( - title: Text(context.l10n.transactionHash), + title: Text(l10n.transactionHash), subtitle: CustomSelectableText(snapshot.transactionHash), ), ], if (type == SnapshotType.transfer) ...[ TransactionInfoTile( - title: Text(isPositive ? context.l10n.from : context.l10n.to), + title: Text( + isPositive ? l10n.from : l10n.to, + ), subtitle: CustomSelectableText( snapshot.opponentId.isNullOrBlank() ? 'N/A' : (ref .watch(_userProvider(snapshot.opponentId)) - .valueOrNull + .value ?.fullName ?? ''), ), ), if (!memo.isNullOrBlank()) TransactionInfoTile( - title: Text(context.l10n.memo), + title: Text(l10n.memo), subtitle: CustomSelectableText(memo), ), ] else if (type == SnapshotType.deposit && snapshot.deposit != null) ...[ TransactionInfoTile( - title: Text(context.l10n.depositHash), + title: Text(l10n.depositHash), subtitle: CustomSelectableText( snapshot.deposit?.depositHash ?? '', ), ), ] else if (type == SnapshotType.withdrawal) ...[ TransactionInfoTile( - title: Text(context.l10n.to), + title: Text(l10n.to), subtitle: CustomSelectableText( snapshot.withdrawal?.receiver ?? '', ), ), if ((snapshot.withdrawal?.withdrawalHash).isNullOrBlank()) TransactionInfoTile( - title: Text(context.l10n.withdrawalHash), + title: Text(l10n.withdrawalHash), subtitle: SizedBox.square( dimension: 20, child: CircularProgressIndicator( strokeWidth: 2, - color: context.theme.secondaryText, + color: theme.secondaryText, ), ), ) else TransactionInfoTile( - title: Text(context.l10n.withdrawalHash), + title: Text(l10n.withdrawalHash), subtitle: CustomSelectableText( snapshot.withdrawal?.withdrawalHash ?? '', ), ), ], TransactionInfoTile( - title: Text(context.l10n.time), + title: Text(l10n.time), subtitle: CustomSelectableText( '${DateFormat.yMMMMd().format(createdAt)} ' '${DateFormat.Hms().format(createdAt)}', diff --git a/lib/widgets/message/item/transfer/safe_transfer_message.dart b/lib/widgets/message/item/transfer/safe_transfer_message.dart index e40d9b9d8e..3dea4c193a 100644 --- a/lib/widgets/message/item/transfer/safe_transfer_message.dart +++ b/lib/widgets/message/item/transfer/safe_transfer_message.dart @@ -12,7 +12,9 @@ import 'package:mixin_logger/mixin_logger.dart'; import '../../../../constants/resources.dart'; import '../../../../db/extension/job.dart'; import '../../../../db/mixin_database.dart'; +import '../../../../ui/provider/account_server_provider.dart'; import '../../../../ui/provider/transfer_provider.dart'; +import '../../../../ui/provider/ui_context_providers.dart'; import '../../../../utils/extension/extension.dart'; import '../../../high_light_text.dart'; import '../../../interactive_decorated_box.dart'; @@ -41,6 +43,8 @@ class SafeTransferMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).requireValue; + final theme = ref.watch(brightnessThemeDataProvider); final assetId = useMessageConverter(converter: (state) => state.assetId); var assetIcon = useMessageConverter(converter: (state) => state.assetIcon); @@ -57,8 +61,8 @@ class SafeTransferMessage extends HookConsumerWidget { final token = ref.watch(tokenProvider(assetId)); - assetIcon = assetIcon ?? token.valueOrNull?.iconUrl; - assetSymbol = assetSymbol ?? token.valueOrNull?.symbol; + assetIcon = assetIcon ?? token.value?.iconUrl; + assetSymbol = assetSymbol ?? token.value?.symbol; useEffect(() { if (assetId == null) { @@ -67,7 +71,7 @@ class SafeTransferMessage extends HookConsumerWidget { } if (token.hasValue && token.value == null) { i('${context.message.snapshotId}: token is null'); - context.accountServer.updateTokenById(assetId: assetId); + accountServer.updateTokenById(assetId: assetId); } }, [token]); @@ -83,7 +87,6 @@ class SafeTransferMessage extends HookConsumerWidget { if (content == null) { return; } - final database = context.database; final messageId = context.message.messageId; scheduleMicrotask(() async { try { @@ -91,11 +94,11 @@ class SafeTransferMessage extends HookConsumerWidget { jsonDecode(utf8.decode(base64Decode(content), allowMalformed: true)) as Map, ); - context.accountServer.addUpdateTokenJob( + accountServer.addUpdateTokenJob( createUpdateTokenJob(snapshot.assetId), ); - await database.safeSnapshotDao.insert(snapshot); - await database.messageDao.updateSafeSnapshotMessage( + await accountServer.upsertDbSafeSnapshot(snapshot); + await accountServer.updateSafeSnapshotMessage( messageId, snapshot.snapshotId, ); @@ -133,6 +136,7 @@ class SafeTransferMessage extends HookConsumerWidget { assetIcon: assetIcon, snapshotAmount: snapshotAmount, memo: memo, + theme: theme, ), ), ), @@ -144,6 +148,7 @@ class _SnapshotLayout extends StatelessWidget { const _SnapshotLayout({ required this.assetSymbol, required this.memo, + required this.theme, this.assetIcon, this.snapshotAmount, }); @@ -152,6 +157,7 @@ class _SnapshotLayout extends StatelessWidget { final String? snapshotAmount; final String assetSymbol; final String memo; + final BrightnessThemeData theme; @override Widget build(BuildContext context) => Stack( @@ -180,7 +186,10 @@ class _SnapshotLayout extends StatelessWidget { const SizedBox(width: 4), Text( assetSymbol, - style: TextStyle(color: context.theme.text, fontSize: 13), + style: TextStyle( + color: theme.text, + fontSize: 13, + ), ), ], ), @@ -190,7 +199,7 @@ class _SnapshotLayout extends StatelessWidget { maxFontSize: 36, minFontSize: 24, style: TextStyle( - color: context.theme.text, + color: theme.text, fontFamily: 'MixinCondensed', fontSize: 36, height: 1, @@ -203,7 +212,7 @@ class _SnapshotLayout extends StatelessWidget { CustomText( memo, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 12, ), maxLines: 1, diff --git a/lib/widgets/message/item/transfer/transfer_message.dart b/lib/widgets/message/item/transfer/transfer_message.dart index aafe2aaff3..2adcecc276 100644 --- a/lib/widgets/message/item/transfer/transfer_message.dart +++ b/lib/widgets/message/item/transfer/transfer_message.dart @@ -7,6 +7,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_logger/mixin_logger.dart'; import '../../../../db/database_event_bus.dart'; +import '../../../../ui/provider/account_server_provider.dart'; +import '../../../../ui/provider/database_provider.dart'; +import '../../../../ui/provider/ui_context_providers.dart'; import '../../../../utils/extension/extension.dart'; import '../../../../utils/hook.dart'; import '../../../interactive_decorated_box.dart'; @@ -21,6 +24,9 @@ class TransferMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).requireValue; + final database = ref.read(databaseProvider).requireValue; + final theme = ref.watch(brightnessThemeDataProvider); final assetId = useMessageConverter(converter: (state) => state.assetId); var assetIcon = useMessageConverter(converter: (state) => state.assetIcon); @@ -36,7 +42,7 @@ class TransferMessage extends HookConsumerWidget { if (assetId == null || assetIcon != null) { return Stream.value(null); } - return context.database.assetDao + return database.assetDao .assetItem(assetId) .watchSingleOrNullWithStream( eventStreams: [ @@ -57,7 +63,7 @@ class TransferMessage extends HookConsumerWidget { e('${context.message.snapshotId}: assetId is null'); return; } - context.accountServer.updateAssetById(assetId: assetId); + accountServer.updateAssetById(assetId: assetId); }, [chainIcon, assetId]); return MessageBubble( @@ -99,7 +105,7 @@ class TransferMessage extends HookConsumerWidget { return Text( snapshotAmount!.numberFormat(), style: TextStyle( - color: context.theme.text, + color: theme.text, fontSize: context.messageStyle.secondaryFontSize, ), ); @@ -111,7 +117,7 @@ class TransferMessage extends HookConsumerWidget { Text( assetSymbol, style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: context.messageStyle.tertiaryFontSize, ), ), diff --git a/lib/widgets/message/item/transfer/transfer_page.dart b/lib/widgets/message/item/transfer/transfer_page.dart index 9900ac0586..917ee44959 100644 --- a/lib/widgets/message/item/transfer/transfer_page.dart +++ b/lib/widgets/message/item/transfer/transfer_page.dart @@ -9,6 +9,10 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' import '../../../../db/dao/snapshot_dao.dart'; import '../../../../db/database_event_bus.dart'; import '../../../../db/mixin_database.dart' hide Offset; +import '../../../../ui/provider/account_server_provider.dart'; +import '../../../../ui/provider/database_provider.dart'; +import '../../../../ui/provider/multi_auth_provider.dart'; +import '../../../../ui/provider/ui_context_providers.dart'; import '../../../../utils/extension/extension.dart'; import '../../../../utils/hook.dart'; import '../../../buttons.dart'; @@ -26,13 +30,18 @@ class _TransferPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; + final database = ref.read(databaseProvider).requireValue; + final account = ref.watch(authAccountProvider); useEffect(() { - context.accountServer.updateFiats(); + accountServer.updateFiats(); + return null; }, []); final snapshotItem = useMemoizedStream( - () => context.database.snapshotDao - .snapshotItemById(snapshotId, context.account!.fiatCurrency) + () => database.snapshotDao + .snapshotItemById(snapshotId, account!.fiatCurrency) .watchSingleOrNullWithStream( eventStreams: [ DataBaseEventBus.instance.updateSnapshotStream.where( @@ -47,7 +56,7 @@ class _TransferPage extends HookConsumerWidget { final opponentFullName = useMemoizedStream(() { final opponentId = snapshotItem?.opponentId; if (opponentId != null && opponentId.trim().isNotEmpty) { - final stream = context.database.userDao + final stream = database.userDao .userById(opponentId) .watchSingleOrNullWithStream( eventStreams: [ @@ -59,7 +68,7 @@ class _TransferPage extends HookConsumerWidget { ); return stream.map((event) { if (event == null) { - context.accountServer.refreshUsers([opponentId]); + accountServer.refreshUsers([opponentId]); } return event; }); @@ -68,13 +77,15 @@ class _TransferPage extends HookConsumerWidget { }, keys: [snapshotItem?.opponentId]).data?.fullName; useEffect(() { - context.accountServer.updateSnapshotById(snapshotId: snapshotId); + accountServer.updateSnapshotById(snapshotId: snapshotId); + return null; }, [snapshotId]); useEffect(() { final assetId = snapshotItem?.assetId; - if (assetId == null) return; - context.accountServer.updateAssetById(assetId: assetId); + if (assetId == null) return null; + accountServer.updateAssetById(assetId: assetId); + return null; }, [snapshotItem?.assetId]); if (snapshotItem == null) return const SizedBox(); @@ -118,10 +129,14 @@ class _TransferPage extends HookConsumerWidget { ), ), const SizedBox(height: 24), - Container(color: context.theme.divider, height: 10), + Container( + color: theme.divider, + height: 10, + ), TransactionDetailInfo( snapshot: snapshotItem, opponentFullName: opponentFullName, + currentUserFullName: account?.fullName, ), ], ), @@ -152,48 +167,54 @@ class SnapshotDetailHeader extends HookConsumerWidget { final String snapshotType; @override - Widget build(BuildContext context, WidgetRef ref) => Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 20), - SymbolIconWithBorder( - symbolUrl: symbolIconUrl, - chainUrl: chainIconUrl, - size: 58, - chainSize: 16, - ), - const SizedBox(height: 16), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 30), - child: CustomSelectableText.rich( - TextSpan( - children: [ - TextSpan( - text: amount.numberFormat(), - style: TextStyle( - fontSize: 32, - fontWeight: FontWeight.w700, - fontFamily: 'MixinCondensed', - color: snapshotType == SnapshotType.pending - ? context.theme.text - : _isPositive(amount) - ? context.theme.green - : context.theme.red, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 20), + SymbolIconWithBorder( + symbolUrl: symbolIconUrl, + chainUrl: chainIconUrl, + size: 58, + chainSize: 16, + ), + const SizedBox(height: 16), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 30), + child: CustomSelectableText.rich( + TextSpan( + children: [ + TextSpan( + text: amount.numberFormat(), + style: TextStyle( + fontSize: 32, + fontWeight: FontWeight.w700, + fontFamily: 'MixinCondensed', + color: snapshotType == SnapshotType.pending + ? theme.text + : _isPositive(amount) + ? theme.green + : theme.red, + ), ), - ), - const TextSpan(text: ' '), - TextSpan( - text: symbol.overflow, - style: TextStyle(fontSize: 14, color: context.theme.text), - ), - ], + const TextSpan(text: ' '), + TextSpan( + text: symbol.overflow, + style: TextStyle( + fontSize: 14, + color: theme.text, + ), + ), + ], + ), + textAlign: TextAlign.center, ), - textAlign: TextAlign.center, ), - ), - const SizedBox(height: 4), - ], - ); + const SizedBox(height: 4), + ], + ); + } } class _ValuesDescription extends HookConsumerWidget { @@ -203,8 +224,12 @@ class _ValuesDescription extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final fiatCurrency = ref.watch(authAccountProvider)?.fiatCurrency; + final accountServer = ref.read(accountServerProvider).requireValue; final ticker = useMemoizedFuture( - () => context.accountServer.client.snapshotApi.getTicker( + () => accountServer.client.snapshotApi.getTicker( snapshot.assetId, offset: snapshot.createdAt.toIso8601String(), ), @@ -215,34 +240,36 @@ class _ValuesDescription extends HookConsumerWidget { final String? thatTimeValue; final current = snapshot.amountOfCurrentCurrency().abs(); - final unitValue = context.currencyFormat( + final unitValue = currencyFormat( (current / snapshot.amount.asDecimal.abs()).toDouble(), + fiatCurrency: fiatCurrency, ); final symbol = snapshot.symbol?.overflow ?? ''; final currentValue = - '${context.l10n.valueNow(context.currencyFormat(current))}($unitValue/$symbol)'; + '${l10n.valueNow(currencyFormat(current, fiatCurrency: fiatCurrency))}($unitValue/$symbol)'; if (ticker == null) { thatTimeValue = null; } else if (ticker.priceUsd == '0') { - thatTimeValue = context.l10n.valueThen(context.l10n.na); + thatTimeValue = l10n.valueThen(l10n.na); } else { final past = (snapshot.amount.asDecimal * ticker.priceUsd.asDecimal * snapshot.fiatRate!.asDecimal) .abs(); - final unitValue = context.currencyFormat( + final unitValue = currencyFormat( (past / snapshot.amount.asDecimal.abs()).toDouble(), + fiatCurrency: fiatCurrency, ); thatTimeValue = - '${context.l10n.valueThen(context.currencyFormat(past))}($unitValue/$symbol)'; + '${l10n.valueThen(currencyFormat(past, fiatCurrency: fiatCurrency))}($unitValue/$symbol)'; } return DefaultTextStyle.merge( style: TextStyle( fontSize: 14, fontWeight: FontWeight.w400, - color: context.theme.secondaryText, + color: theme.secondaryText, ), child: Column( mainAxisSize: MainAxisSize.min, @@ -262,18 +289,21 @@ class _ValuesDescription extends HookConsumerWidget { } } -class TransactionDetailInfo extends StatelessWidget { +class TransactionDetailInfo extends ConsumerWidget { const TransactionDetailInfo({ required this.snapshot, required this.opponentFullName, + required this.currentUserFullName, super.key, }); final SnapshotItem snapshot; final String? opponentFullName; + final String? currentUserFullName; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); final createdAt = snapshot.createdAt.toLocal(); return Padding( padding: const EdgeInsets.only(left: 24, right: 24, top: 20), @@ -282,36 +312,36 @@ class TransactionDetailInfo extends StatelessWidget { crossAxisAlignment: CrossAxisAlignment.start, children: [ TransactionInfoTile( - title: Text(context.l10n.transactionId), + title: Text(l10n.transactionId), subtitle: CustomSelectableText(snapshot.snapshotId), ), if (snapshot.snapshotHash?.isNotEmpty ?? false) TransactionInfoTile( - title: Text(context.l10n.snapshotHash), + title: Text(l10n.snapshotHash), subtitle: CustomSelectableText(snapshot.snapshotHash!), ), TransactionInfoTile( - title: Text(context.l10n.assetType), + title: Text(l10n.assetType), subtitle: CustomSelectableText(snapshot.symbolName ?? ''), ), TransactionInfoTile( - title: Text(context.l10n.transactionType), - subtitle: CustomSelectableText(snapshot.l10nType(context)), + title: Text(l10n.transactionType), + subtitle: CustomSelectableText(snapshot.l10nType(l10n)), ), if (snapshot.type == SnapshotType.deposit) ...[ TransactionInfoTile( - title: Text(context.l10n.from), + title: Text(l10n.from), subtitle: CustomSelectableText(snapshot.sender ?? ''), ), TransactionInfoTile( - title: Text(context.l10n.transactionHash), + title: Text(l10n.transactionHash), subtitle: CustomSelectableText(snapshot.transactionHash ?? ''), ), ] else if (snapshot.type == SnapshotType.pending) ...[ TransactionInfoTile( - title: Text(context.l10n.status), + title: Text(l10n.status), subtitle: CustomSelectableText( - context.l10n.pendingConfirmation( + l10n.pendingConfirmation( snapshot.confirmations ?? 0, snapshot.confirmations ?? 0, snapshot.assetConfirmations ?? 0, @@ -319,60 +349,60 @@ class TransactionDetailInfo extends StatelessWidget { ), ), TransactionInfoTile( - title: Text(context.l10n.from), + title: Text(l10n.from), subtitle: CustomSelectableText(snapshot.sender ?? ''), ), TransactionInfoTile( - title: Text(context.l10n.transactionHash), + title: Text(l10n.transactionHash), subtitle: CustomSelectableText(snapshot.transactionHash ?? ''), ), ] else if (snapshot.type == SnapshotType.transfer) ...[ TransactionInfoTile( - title: Text(context.l10n.from), + title: Text(l10n.from), subtitle: CustomSelectableText( (snapshot.isPositive ? opponentFullName - : context.account?.fullName) ?? + : currentUserFullName) ?? '', ), ), TransactionInfoTile( - title: Text(context.l10n.receiver), + title: Text(l10n.receiver), subtitle: CustomSelectableText( (!snapshot.isPositive ? opponentFullName - : context.account?.fullName) ?? + : currentUserFullName) ?? '', ), ), ] else if (snapshot.tag?.isNotEmpty ?? false) ...[ TransactionInfoTile( - title: Text(context.l10n.transactionHash), + title: Text(l10n.transactionHash), subtitle: CustomSelectableText(snapshot.transactionHash ?? ''), ), TransactionInfoTile( - title: Text(context.l10n.address), + title: Text(l10n.address), subtitle: CustomSelectableText(snapshot.receiver ?? ''), ), ] else ...[ TransactionInfoTile( - title: Text(context.l10n.transactionHash), + title: Text(l10n.transactionHash), subtitle: CustomSelectableText(snapshot.transactionHash ?? ''), ), TransactionInfoTile( - title: Text(context.l10n.receiver), + title: Text(l10n.receiver), subtitle: CustomSelectableText(snapshot.receiver ?? ''), ), ], if (snapshot.memo?.isNotEmpty ?? false) TransactionInfoTile( - title: Text(context.l10n.memo), + title: Text(l10n.memo), subtitle: CustomSelectableText(snapshot.memo!), ), if ((snapshot.openingBalance?.isNotEmpty ?? false) && (snapshot.symbol?.isNotEmpty ?? false)) TransactionInfoTile( - title: Text(context.l10n.openingBalance), + title: Text(l10n.openingBalance), subtitle: CustomSelectableText( '${snapshot.openingBalance!} ${snapshot.symbol!}', ), @@ -380,13 +410,13 @@ class TransactionDetailInfo extends StatelessWidget { if ((snapshot.closingBalance?.isNotEmpty ?? false) && (snapshot.symbol?.isNotEmpty ?? false)) TransactionInfoTile( - title: Text(context.l10n.closingBalance), + title: Text(l10n.closingBalance), subtitle: CustomSelectableText( '${snapshot.closingBalance ?? ''} ${snapshot.symbol ?? ''}', ), ), TransactionInfoTile( - title: Text(context.l10n.time), + title: Text(l10n.time), subtitle: CustomSelectableText( '${DateFormat.yMMMMd().format(createdAt)}' '${DateFormat.Hms().format(createdAt)}', @@ -396,7 +426,7 @@ class TransactionDetailInfo extends StatelessWidget { snapshot.traceId != null && snapshot.traceId!.isNotEmpty) TransactionInfoTile( - title: Text(context.l10n.trace), + title: Text(l10n.trace), subtitle: CustomSelectableText(snapshot.traceId ?? ''), ), ], @@ -405,7 +435,7 @@ class TransactionDetailInfo extends StatelessWidget { } } -class TransactionInfoTile extends StatelessWidget { +class TransactionInfoTile extends ConsumerWidget { const TransactionInfoTile({ required this.title, required this.subtitle, @@ -418,31 +448,34 @@ class TransactionInfoTile extends StatelessWidget { final Color? subtitleColor; @override - Widget build(BuildContext context) => Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 12), - DefaultTextStyle.merge( - style: TextStyle( - fontSize: 16, - height: 1, - color: context.theme.secondaryText, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 12), + DefaultTextStyle.merge( + style: TextStyle( + fontSize: 16, + height: 1, + color: theme.secondaryText, + ), + child: title, ), - child: title, - ), - const SizedBox(height: 8), - DefaultTextStyle.merge( - style: TextStyle( - fontSize: 16, - height: 1, - color: subtitleColor ?? context.theme.text, + const SizedBox(height: 8), + DefaultTextStyle.merge( + style: TextStyle( + fontSize: 16, + height: 1, + color: subtitleColor ?? theme.text, + ), + child: subtitle, ), - child: subtitle, - ), - const SizedBox(height: 12), - ], - ); + const SizedBox(height: 12), + ], + ); + } } class SymbolIconWithBorder extends StatelessWidget { diff --git a/lib/widgets/message/item/unknown_message.dart b/lib/widgets/message/item/unknown_message.dart index 17084e95bd..4666f45599 100644 --- a/lib/widgets/message/item/unknown_message.dart +++ b/lib/widgets/message/item/unknown_message.dart @@ -1,37 +1,44 @@ import 'package:flutter/gestures.dart'; import 'package:flutter/material.dart'; import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../../utils/extension/extension.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/uri_utils.dart'; import '../message_bubble.dart'; import '../message_datetime_and_status.dart'; import '../message_layout.dart'; import '../message_style.dart'; -class UnknownMessage extends StatelessWidget { +class UnknownMessage extends ConsumerWidget { const UnknownMessage({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final content = RichText( text: TextSpan( - text: context.l10n.messageNotSupport, + text: l10n.messageNotSupport, style: TextStyle( fontSize: context.messageStyle.primaryFontSize, - color: context.theme.text, + color: theme.text, ), children: [ const TextSpan(text: ' '), TextSpan( mouseCursor: SystemMouseCursors.click, - text: context.l10n.learnMore, + text: l10n.learnMore, style: TextStyle( fontSize: context.messageStyle.primaryFontSize, - color: context.theme.accent, + color: theme.accent, ), recognizer: TapGestureRecognizer() - ..onTap = () => openUri(context, context.l10n.chatNotSupportUrl), + ..onTap = () => openUri( + context, + l10n.chatNotSupportUrl, + container: ref.container, + ), ), ], ), diff --git a/lib/widgets/message/item/video/video_message.dart b/lib/widgets/message/item/video/video_message.dart index 5ab1dc5541..6f5d9d9e64 100644 --- a/lib/widgets/message/item/video/video_message.dart +++ b/lib/widgets/message/item/video/video_message.dart @@ -11,6 +11,7 @@ import 'package:url_launcher/url_launcher_string.dart'; import '../../../../constants/resources.dart'; import '../../../../enum/media_status.dart'; +import '../../../../ui/provider/account_server_provider.dart'; import '../../../../utils/extension/extension.dart'; import '../../../../utils/platform.dart'; import '../../../../utils/uri_utils.dart'; @@ -89,6 +90,7 @@ class MessageVideo extends HookConsumerWidget { (isTranscriptPage && TranscriptPage.of(context)?.relationship == UserRelationship.me) || (!isTranscriptPage && relationship == UserRelationship.me); + final accountServer = ref.read(accountServerProvider).requireValue; return InteractiveDecoratedBox( onTap: () { @@ -97,18 +99,16 @@ class MessageVideo extends HookConsumerWidget { if (isMessageSentOut && message.mediaUrl?.isNotEmpty == true) { if (isTranscriptPage) { final transcriptMessageId = TranscriptPage.of(context)!.messageId; - context.accountServer.reUploadTranscriptAttachment( - transcriptMessageId, - ); + accountServer.reUploadTranscriptAttachment(transcriptMessageId); } else { - context.accountServer.reUploadAttachment(message); + accountServer.reUploadAttachment(message); } } else { - context.accountServer.downloadAttachment(message.messageId); + accountServer.downloadAttachment(message.messageId); } } else if (message.mediaStatus == MediaStatus.done && message.mediaUrl != null) { - final path = context.accountServer.convertMessageAbsolutePath( + final path = accountServer.convertMessageAbsolutePath( message, isTranscriptPage, ); @@ -122,10 +122,14 @@ class MessageVideo extends HookConsumerWidget { } else if (Platform.isIOS || Platform.isAndroid) { OpenFile.open(path); } else { - openUri(context, Uri.file(path).toString()); + openUri( + context, + Uri.file(path).toString(), + container: ref.container, + ); } } else if (message.mediaStatus == MediaStatus.pending) { - context.accountServer.cancelProgressAttachmentJob(message.messageId); + accountServer.cancelProgressAttachmentJob(message.messageId); } else if (message.type.isLive && message.mediaUrl != null) { launchUrlString(message.mediaUrl!); } diff --git a/lib/widgets/message/item/video/video_preview_page.dart b/lib/widgets/message/item/video/video_preview_page.dart index e4aa9f0411..4d071e0486 100644 --- a/lib/widgets/message/item/video/video_preview_page.dart +++ b/lib/widgets/message/item/video/video_preview_page.dart @@ -13,6 +13,8 @@ import 'package:video_player/video_player.dart'; import '../../../../constants/resources.dart'; import '../../../../db/mixin_database.dart'; +import '../../../../ui/provider/account_server_provider.dart'; +import '../../../../ui/provider/ui_context_providers.dart'; import '../../../../utils/extension/extension.dart'; import '../../../../utils/system/clipboard.dart'; import '../../../action_button.dart'; @@ -37,11 +39,19 @@ Future showVideoPreviewPage( barrierDismissible: false, ); -final videoPlayerProvider = ChangeNotifierProvider(( - ref, -) { - throw UnimplementedError(); -}); +class _VideoPlayerScope extends InheritedNotifier { + const _VideoPlayerScope({ + required VideoPlayerController controller, + required super.child, + }) : super(notifier: controller); + + static VideoPlayerController of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType<_VideoPlayerScope>(); + assert(scope?.notifier != null, '_VideoPlayerScope is missing'); + return scope!.notifier!; + } +} class _CupertinoVideoPlayerStyle extends InheritedWidget { const _CupertinoVideoPlayerStyle({ @@ -68,20 +78,21 @@ class _CupertinoVideoPlayerStyle extends InheritedWidget { extension on BuildContext { _CupertinoVideoPlayerStyle get playerStyle => _CupertinoVideoPlayerStyle.of(this); -} -final videoPlayerValueProvider = videoPlayerProvider.select( - (value) => value.value, -); + VideoPlayerController get videoPlayerController => _VideoPlayerScope.of(this); +} final shouldShowVideoControlProvider = - StateNotifierProvider.autoDispose<_VideoControlShowHideNotifier, bool>( - (ref) => _VideoControlShowHideNotifier(true), + NotifierProvider.autoDispose<_VideoControlShowHideNotifier, bool>( + _VideoControlShowHideNotifier.new, ); -class _VideoControlShowHideNotifier extends StateNotifier { - _VideoControlShowHideNotifier(super.state) { +class _VideoControlShowHideNotifier extends Notifier { + @override + bool build() { _autoHide(); + ref.onDispose(() => _timer?.cancel()); + return true; } Timer? _timer; @@ -101,12 +112,6 @@ class _VideoControlShowHideNotifier extends StateNotifier { _timer = null; _autoHide(); } - - @override - void dispose() { - _timer?.cancel(); - super.dispose(); - } } class _VideoPreviewPage extends HookConsumerWidget { @@ -122,6 +127,7 @@ class _VideoPreviewPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final controller = useMemoized(() { final controller = VideoPlayerController.file(File(path)); return controller @@ -131,8 +137,8 @@ class _VideoPreviewPage extends HookConsumerWidget { useEffect(() => controller.dispose, [controller]); - return ProviderScope( - overrides: [videoPlayerProvider.overrideWith((ref) => controller)], + return _VideoPlayerScope( + controller: controller, child: _CupertinoVideoPlayerStyle( background: const Color.fromRGBO(41, 41, 41, 0.7), foreground: const Color.fromARGB(255, 200, 200, 200), @@ -145,7 +151,7 @@ class _VideoPreviewPage extends HookConsumerWidget { child: Stack( children: [ ColoredBox( - color: context.theme.background, + color: theme.background, child: const SizedBox.expand(), ), const VideoFrame(), @@ -206,24 +212,24 @@ class _PlayerShortcuts extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final node = useFocusScopeNode(); final volumeBeforeMuted = useRef(null); + final controller = context.videoPlayerController; + final videoValue = useValueListenable(controller); return FocusableActionDetector( actions: { _MuteOrUnMuteIntent: CallbackAction<_MuteOrUnMuteIntent>( onInvoke: (intent) { - final controller = ref.read(videoPlayerProvider); - if (controller.value.volume == 0) { + if (videoValue.volume == 0) { final target = volumeBeforeMuted.value ?? 0.5; controller.setVolume(target); } else { - volumeBeforeMuted.value = controller.value.volume; + volumeBeforeMuted.value = videoValue.volume; controller.setVolume(0); } }, ), _PlayPauseIntent: CallbackAction<_PlayPauseIntent>( onInvoke: (intent) { - final controller = ref.read(videoPlayerProvider); - if (controller.value.isPlaying) { + if (videoValue.isPlaying) { controller.pause(); } else { controller.play(); @@ -233,15 +239,12 @@ class _PlayerShortcuts extends HookConsumerWidget { ), _ForwardIntent: CallbackAction<_ForwardIntent>( onInvoke: (intent) { - final position = ref - .read(videoPlayerValueProvider) - .position - .inMilliseconds; + final position = videoValue.position.inMilliseconds; final target = math.min( - ref.read(videoPlayerValueProvider).duration.inMilliseconds, + videoValue.duration.inMilliseconds, position + 15 * 1000, ); - ref.read(videoPlayerProvider).seekTo(target.milliseconds); + controller.seekTo(target.milliseconds); }, ), _CopyIntent: CallbackAction<_CopyIntent>( @@ -249,33 +252,26 @@ class _PlayerShortcuts extends HookConsumerWidget { ), _BackwardIntent: CallbackAction<_BackwardIntent>( onInvoke: (intent) { - final position = ref - .read(videoPlayerValueProvider) - .position - .inMilliseconds; + final position = videoValue.position.inMilliseconds; final target = math.max(0, position - 15 * 1000); - ref.read(videoPlayerProvider).seekTo(target.milliseconds); + controller.seekTo(target.milliseconds); }, ), _UpVolumeIntent: CallbackAction<_UpVolumeIntent>( onInvoke: (intent) { - final controller = ref.read(videoPlayerProvider); - final volume = controller.value.volume.clamp(0.0, 1.0); + final volume = videoValue.volume.clamp(0.0, 1.0); controller.setVolume(volume + 0.1); }, ), _DownVolumeIntent: CallbackAction<_DownVolumeIntent>( onInvoke: (intent) { - final controller = ref.read(videoPlayerProvider); - final volume = controller.value.volume.clamp(0.0, 1.0); + final volume = videoValue.volume.clamp(0.0, 1.0); controller.setVolume(volume - 0.1); }, ), _CloseIntent: CallbackAction<_CloseIntent>( onInvoke: (intent) { - ref.read(videoPlayerProvider) - ..pause() - ..dispose(); + controller.pause(); Navigator.pop(context); }, ), @@ -303,118 +299,127 @@ class _Bar extends ConsumerWidget { final bool isTranscriptPage; @override - Widget build(BuildContext context, WidgetRef ref) => Container( - color: context.theme.primary, - height: 70, - child: Row( - children: [ - const SizedBox(width: 100), - AvatarWidget( - name: message.userFullName, - size: 36, - avatarUrl: message.avatarUrl, - userId: message.userId, - ), - const SizedBox(width: 10), - Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 200), - child: Text( - message.userFullName!, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + return Container( + color: theme.primary, + height: 70, + child: Row( + children: [ + const SizedBox(width: 100), + AvatarWidget( + name: message.userFullName, + size: 36, + avatarUrl: message.avatarUrl, + userId: message.userId, + ), + const SizedBox(width: 10), + Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 200), + child: Text( + message.userFullName!, + style: TextStyle( + fontSize: MessageItemWidget.primaryFontSize, + color: theme.text, + overflow: TextOverflow.ellipsis, + ), + ), + ), + Text( + message.userIdentityNumber, style: TextStyle( - fontSize: MessageItemWidget.primaryFontSize, - color: context.theme.text, - overflow: TextOverflow.ellipsis, + fontSize: MessageItemWidget.secondaryFontSize, + color: theme.secondaryText, ), ), + ], + ), + const SizedBox(width: 14), + const Spacer(), + if (!isTranscriptPage) + ActionButton( + name: Resources.assetsImagesShareSvg, + size: 20, + color: theme.icon, + onTap: () async { + final accountServer = ref + .read(accountServerProvider) + .requireValue; + final result = await showConversationSelector( + context: context, + singleSelect: true, + title: l10n.forward, + onlyContact: false, + ); + if (result == null || result.isEmpty) return; + await accountServer.forwardMessage( + message.messageId, + result.first.encryptCategory!, + conversationId: result.first.conversationId, + recipientId: result.first.userId, + ); + }, ), - Text( - message.userIdentityNumber, - style: TextStyle( - fontSize: MessageItemWidget.secondaryFontSize, - color: context.theme.secondaryText, - ), + const SizedBox(width: 14), + ActionButton( + name: Resources.assetsImagesCopySvg, + color: theme.icon, + size: 20, + onTap: () => copyFile( + ref + .read(accountServerProvider) + .requireValue + .convertMessageAbsolutePath( + message, + isTranscriptPage, + ), ), - ], - ), - const SizedBox(width: 14), - const Spacer(), - if (!isTranscriptPage) + ), + const SizedBox(width: 14), ActionButton( - name: Resources.assetsImagesShareSvg, + name: Resources.assetsImagesAttachmentDownloadSvg, + color: theme.icon, size: 20, - color: context.theme.icon, onTap: () async { - final accountServer = context.accountServer; - final result = await showConversationSelector( - context: context, - singleSelect: true, - title: context.l10n.forward, - onlyContact: false, - ); - if (result == null || result.isEmpty) return; - await accountServer.forwardMessage( - message.messageId, - result.first.encryptCategory!, - conversationId: result.first.conversationId, - recipientId: result.first.userId, + if (message.mediaUrl?.isEmpty ?? true) return; + await saveAs( + context, + ref.read(accountServerProvider).requireValue, + message, + isTranscriptPage, + confirmButtonText: l10n.save, ); }, ), - const SizedBox(width: 14), - ActionButton( - name: Resources.assetsImagesCopySvg, - color: context.theme.icon, - size: 20, - onTap: () => copyFile( - context.accountServer.convertMessageAbsolutePath( - message, - isTranscriptPage, - ), + const SizedBox(width: 14), + ActionButton( + name: Resources.assetsImagesIcCloseBigSvg, + color: theme.icon, + size: 20, + onTap: () { + Actions.invoke(context, const _CloseIntent()); + }, ), - ), - const SizedBox(width: 14), - ActionButton( - name: Resources.assetsImagesAttachmentDownloadSvg, - color: context.theme.icon, - size: 20, - onTap: () async { - if (message.mediaUrl?.isEmpty ?? true) return; - await saveAs( - context, - context.accountServer, - message, - isTranscriptPage, - ); - }, - ), - const SizedBox(width: 14), - ActionButton( - name: Resources.assetsImagesIcCloseBigSvg, - color: context.theme.icon, - size: 20, - onTap: () { - Actions.invoke(context, const _CloseIntent()); - }, - ), - const SizedBox(width: 24), - ], - ), - ); + const SizedBox(width: 24), + ], + ), + ); + } } -class VideoFrame extends ConsumerWidget { +class VideoFrame extends HookWidget { const VideoFrame({super.key}); @override - Widget build(BuildContext context, WidgetRef ref) { - final aspect = ref.watch( - videoPlayerProvider.select((value) => value.value.aspectRatio), - ); - final controller = ref.watch(videoPlayerProvider.select((value) => value)); + Widget build(BuildContext context) { + final controller = context.videoPlayerController; + final value = useValueListenable(controller); + final aspect = value.aspectRatio == 0 ? 1.0 : value.aspectRatio; return Center( child: AspectRatio(aspectRatio: aspect, child: VideoPlayer(controller)), ); @@ -455,83 +460,75 @@ class _Controls extends ConsumerWidget { } } -class _OperationBar extends ConsumerWidget { +class _OperationBar extends HookWidget { const _OperationBar({required this.message, required this.isTranscriptPage}); final MessageItem message; final bool isTranscriptPage; @override - Widget build(BuildContext context, WidgetRef ref) => ClipRRect( - borderRadius: BorderRadius.circular(10), - child: Material( - color: context.playerStyle.background, - child: BackdropFilter( - filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10), - child: ConstrainedBox( - constraints: const BoxConstraints(maxWidth: 500, minWidth: 300), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - Row( - children: [ - const Expanded(child: _PlayerVolumeBar()), - ActionButton( - child: Icon( - CupertinoIcons.gobackward_15, - color: context.playerStyle.foreground, + Widget build(BuildContext context) { + final controller = context.videoPlayerController; + final value = useValueListenable(controller); + return ClipRRect( + borderRadius: BorderRadius.circular(10), + child: Material( + color: context.playerStyle.background, + child: BackdropFilter( + filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: ConstrainedBox( + constraints: const BoxConstraints(maxWidth: 500, minWidth: 300), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Row( + children: [ + const Expanded(child: _PlayerVolumeBar()), + ActionButton( + child: Icon( + CupertinoIcons.gobackward_15, + color: context.playerStyle.foreground, + ), + onTap: () { + final target = math.max( + 0, + value.position.inMilliseconds - 15 * 1000, + ); + controller.seekTo(target.milliseconds); + }, ), - onTap: () { - final position = ref - .read(videoPlayerValueProvider) - .position - .inMilliseconds; - final target = math.max(0, position - 15 * 1000); - ref - .read(videoPlayerProvider) - .seekTo(target.milliseconds); - }, - ), - const SizedBox(width: 8), - const _PlayPause(), - const SizedBox(width: 8), - ActionButton( - child: Icon( - CupertinoIcons.goforward_15, - color: context.playerStyle.foreground, + const SizedBox(width: 8), + const _PlayPause(), + const SizedBox(width: 8), + ActionButton( + child: Icon( + CupertinoIcons.goforward_15, + color: context.playerStyle.foreground, + ), + onTap: () { + final target = math.min( + value.duration.inMilliseconds, + value.position.inMilliseconds + 15 * 1000, + ); + controller.seekTo(target.milliseconds); + }, ), - onTap: () { - final position = ref - .read(videoPlayerValueProvider) - .position - .inMilliseconds; - final target = math.min( - ref - .read(videoPlayerValueProvider) - .duration - .inMilliseconds, - position + 15 * 1000, - ); - ref - .read(videoPlayerProvider) - .seekTo(target.milliseconds); - }, - ), - const Spacer(), - ], - ), - const SizedBox(height: 8), - const _PlayerProgressBar(), - const SizedBox(height: 8), - ], + const Spacer(), + ], + ), + const SizedBox(height: 8), + const _PlayerProgressBar(), + const SizedBox(height: 8), + ], + ), ), ), ), ), - ), - ); + ); + } } class _PlayerVolumeBar extends HookConsumerWidget { @@ -539,9 +536,9 @@ class _PlayerVolumeBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final volume = ref.watch( - videoPlayerValueProvider.select((value) => value.volume.clamp(0.0, 1.0)), - ); + final theme = ref.watch(brightnessThemeDataProvider); + final controller = context.videoPlayerController; + final volume = useValueListenable(controller).volume.clamp(0.0, 1.0); return Row( children: [ ActionButton( @@ -574,14 +571,12 @@ class _PlayerVolumeBar extends HookConsumerWidget { child: Slider( value: volume, allowedInteraction: SliderInteraction.tapAndSlide, - activeColor: context.theme.accent, + activeColor: theme.accent, inactiveColor: context.playerStyle.foreground.withValues( alpha: 0.6, ), thumbColor: context.playerStyle.foreground, - onChanged: (value) { - ref.read(videoPlayerProvider).setVolume(value); - }, + onChanged: controller.setVolume, ), ), ), @@ -595,16 +590,10 @@ class _PlayerProgressBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final durationText = ref.watch( - videoPlayerValueProvider.select( - (value) => value.duration.asMinutesSeconds, - ), - ); - final positionText = ref.watch( - videoPlayerValueProvider.select( - (value) => value.position.asMinutesSeconds, - ), - ); + final controller = context.videoPlayerController; + final value = useValueListenable(controller); + final durationText = value.duration.asMinutesSeconds; + final positionText = value.position.asMinutesSeconds; return Row( mainAxisSize: MainAxisSize.min, children: [ @@ -613,11 +602,7 @@ class _PlayerProgressBar extends HookConsumerWidget { style: TextStyle(fontSize: 12, color: context.playerStyle.foreground), ), const SizedBox(width: 12), - Expanded( - child: CupertinoVideoProgressBar( - ref.watch(videoPlayerProvider.select((value) => value)), - ), - ), + Expanded(child: CupertinoVideoProgressBar(controller)), const SizedBox(width: 12), Text( durationText, @@ -628,14 +613,12 @@ class _PlayerProgressBar extends HookConsumerWidget { } } -class _PlayPause extends ConsumerWidget { +class _PlayPause extends HookWidget { const _PlayPause(); @override - Widget build(BuildContext context, WidgetRef ref) { - final playing = ref.watch( - videoPlayerValueProvider.select((value) => value.isPlaying), - ); + Widget build(BuildContext context) { + final playing = useValueListenable(context.videoPlayerController).isPlaying; return ActionButton( size: 32, color: context.playerStyle.foreground, diff --git a/lib/widgets/message/item/waiting_message.dart b/lib/widgets/message/item/waiting_message.dart index 0b5de8cd5a..9297e45383 100644 --- a/lib/widgets/message/item/waiting_message.dart +++ b/lib/widgets/message/item/waiting_message.dart @@ -4,7 +4,7 @@ import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; -import '../../../utils/extension/extension.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/uri_utils.dart'; import '../message.dart'; import '../message_bubble.dart'; @@ -17,6 +17,8 @@ class WaitingMessage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final relationship = useMessageConverter( converter: (state) => state.relationship, ); @@ -26,25 +28,29 @@ class WaitingMessage extends HookConsumerWidget { final content = RichText( text: TextSpan( - text: context.l10n.chatDecryptionFailedHint( + text: l10n.chatDecryptionFailedHint( relationship == UserRelationship.me - ? context.l10n.linkedDevice + ? l10n.linkedDevice : userFullName!, ), style: TextStyle( fontSize: context.messageStyle.primaryFontSize, - color: context.theme.text, + color: theme.text, ), children: [ TextSpan( mouseCursor: SystemMouseCursors.click, - text: context.l10n.learnMore, + text: l10n.learnMore, style: TextStyle( fontSize: context.messageStyle.primaryFontSize, - color: context.theme.accent, + color: theme.accent, ), recognizer: TapGestureRecognizer() - ..onTap = () => openUri(context, context.l10n.chatNotSupportUrl), + ..onTap = () => openUri( + context, + l10n.chatNotSupportUrl, + container: ref.container, + ), ), ], ), diff --git a/lib/widgets/message/message.dart b/lib/widgets/message/message.dart index b2a89a44e3..ed583f17ea 100644 --- a/lib/widgets/message/message.dart +++ b/lib/widgets/message/message.dart @@ -11,30 +11,31 @@ import 'package:flutter/services.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:gal/gal.dart'; -import 'package:hooks_riverpod/hooks_riverpod.dart' hide Provider; -import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide Key; import 'package:open_file/open_file.dart'; -import 'package:provider/provider.dart'; import 'package:rxdart/rxdart.dart'; import 'package:super_context_menu/super_context_menu.dart'; import 'package:visibility_detector/visibility_detector.dart'; import '../../account/account_server.dart'; import '../../blaze/vo/pin_message_minimal.dart'; -import '../../bloc/simple_cubit.dart'; import '../../constants/icon_fonts.dart'; import '../../constants/resources.dart'; import '../../db/dao/sticker_dao.dart'; import '../../db/mixin_database.dart' hide Message, Offset; import '../../enum/media_status.dart'; import '../../enum/message_category.dart'; -import '../../ui/home/bloc/blink_cubit.dart'; +import '../../ui/home/controllers/blink_controller.dart'; +import '../../ui/provider/account_server_provider.dart'; import '../../ui/provider/conversation_provider.dart'; +import '../../ui/provider/database_provider.dart'; import '../../ui/provider/is_bot_group_provider.dart'; import '../../ui/provider/message_selection_provider.dart'; import '../../ui/provider/quote_message_provider.dart'; import '../../ui/provider/recall_message_reedit_provider.dart'; import '../../ui/provider/setting_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/datetime_format_utils.dart'; import '../../utils/double_tap_util.dart'; import '../../utils/extension/extension.dart'; @@ -79,10 +80,6 @@ import 'message_day_time.dart'; import 'message_name.dart'; import 'message_style.dart'; -class _MessageContextCubit extends SimpleCubit<_MessageContext> { - _MessageContextCubit(super.initialState); -} - class _MessageContext with EquatableMixin { _MessageContext({ required this.isTranscriptPage, @@ -108,54 +105,61 @@ class _MessageContext with EquatableMixin { ]; } +class _MessageContextScope extends InheritedWidget { + const _MessageContextScope({ + required this.value, + required super.child, + }); + + final _MessageContext value; + + static _MessageContext of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType<_MessageContextScope>(); + assert(scope != null, '_MessageContextScope is missing'); + return scope!.value; + } + + @override + bool updateShouldNotify(_MessageContextScope oldWidget) => + oldWidget.value != value; +} + bool useIsTranscriptPage() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.isTranscriptPage, - ); + _MessageContextScope.of(useContext()).isTranscriptPage; -bool useIsPinnedPage() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.isPinnedPage, - ); +bool useIsPinnedPage() => _MessageContextScope.of(useContext()).isPinnedPage; -bool useShowNip() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.showNip, - ); +bool useShowNip() => _MessageContextScope.of(useContext()).showNip; -bool useIsCurrentUser() => - useBlocStateConverter<_MessageContextCubit, _MessageContext, bool>( - converter: (state) => state.isCurrentUser, - ); +bool useIsCurrentUser() => _MessageContextScope.of(useContext()).isCurrentUser; MessageItem useMessage() => useMessageConverter(converter: (state) => state); T useMessageConverter({required T Function(MessageItem) converter}) => - useBlocStateConverter<_MessageContextCubit, _MessageContext, T>( - converter: (state) => converter(state.message), - ); + converter(_MessageContextScope.of(useContext()).message); extension MessageContextExtension on BuildContext { - MessageItem get message => read<_MessageContextCubit>().state.message; + MessageItem get message => _MessageContextScope.of(this).message; - bool get isPinnedPage => read<_MessageContextCubit>().state.isPinnedPage; + bool get isPinnedPage => _MessageContextScope.of(this).isPinnedPage; - bool get isTranscriptPage => - read<_MessageContextCubit>().state.isTranscriptPage; + bool get isTranscriptPage => _MessageContextScope.of(this).isTranscriptPage; } const _pinArrowWidth = 32.0; -void _quickReply(BuildContext context) { +void _quickReply(BuildContext context, WidgetRef ref) { if (context.isPinnedPage) return; if (context.isTranscriptPage) return; if (!context.message.type.canReply) return; doubleTap('_quickReply', const Duration(milliseconds: 300), () { - context.read().blinkByMessageId(context.message.messageId); - context.providerContainer.read(quoteMessageProvider.notifier).state = - context.message; + ref + .read(blinkControllerProvider.notifier) + .blinkByMessageId(context.message.messageId); + ref.read(quoteMessageProvider.notifier).set(context.message); }); } @@ -196,6 +200,7 @@ class MessageItemWidget extends HookConsumerWidget { super.key, this.prev, this.next, + this.bodyKey, this.lastReadMessageId, this.isTranscriptPage = false, this.blink = true, @@ -205,6 +210,7 @@ class MessageItemWidget extends HookConsumerWidget { final MessageItem message; final MessageItem? prev; final MessageItem? next; + final Key? bodyKey; final String? lastReadMessageId; final bool isTranscriptPage; final bool blink; @@ -218,6 +224,8 @@ class MessageItemWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final isCurrentUser = message.relationship == UserRelationship.me; final sameDayPrev = isSameDay(prev?.createdAt, message.createdAt); @@ -254,481 +262,495 @@ class MessageItemWidget extends HookConsumerWidget { userAvatarUrl = message.avatarUrl; } - final showedMenuCubit = useBloc(() => SimpleCubit(false)); + final showedMenuController = useState(false); final focusNode = useFocusScopeNode( debugLabel: 'message_item_${message.messageId}', ); - final blinkCubit = context.read(); - - final blinkColor = - useMemoizedStream( - () => Rx.combineLatest2( - blinkCubit.stream.startWith(blinkCubit.state), - showedMenuCubit.stream.startWith(showedMenuCubit.state), - (blinkState, showedMenu) { - if (showedMenu) return context.theme.listSelected; - if (blinkState.messageId == message.messageId && blink) { - return blinkState.color; - } - return Colors.transparent; - }, - ), - keys: [message.messageId], - ).data ?? - Colors.transparent; + final showedMenu = useValueListenable(showedMenuController); + final currentBlinkColor = ref.watch( + blinkControllerProvider.select((blinkState) { + if (!blink || blinkState.messageId != message.messageId) { + return Colors.transparent; + } + return blinkState.color; + }), + ); + final blinkColor = showedMenu ? theme.listSelected : currentBlinkColor; Widget child = Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ if (datetime != null) MessageDayTime(dateTime: datetime), - ColoredBox( - color: blinkColor, - child: Builder( - builder: (context) { - if (message.type == MessageCategory.systemConversation) { - return const SystemMessage(); - } - - if (message.type.isPin) { - return const PinMessageWidget(); - } - - if (message.type == MessageCategory.secret) { - return const SecretMessage(); - } - - if (message.type == MessageCategory.stranger) { - return const StrangerMessage(); - } - - return _MessageSelectionWrapper( - message: message, - child: _MessageBubbleMargin( - userName: userName, - userId: userId, - userAvatarUrl: userAvatarUrl, - showAvatar: showAvatar, - isCurrentUser: isCurrentUser, - pinArrowWidth: isPinnedPage ? _pinArrowWidth : 0, - isBot: message.isBot, - isVerified: message.isVerified, - buildMenus: (request) { - request.onShowMenu.addListener(() { - showedMenuCubit.emit(true); - focusNode.requestFocus(); - }); - request.onHideMenu.addListener(() { - showedMenuCubit.emit(false); - }); - - final enable = !ref.read(hasSelectedMessageProvider); - - if (!enable) return null; - - final role = ref.read( - conversationProvider.select((value) => value?.role), - ); - - final pinEnabled = - !isTranscriptPage && - message.type.canReply && - const [ - MessageStatus.delivered, - MessageStatus.read, - MessageStatus.sent, - ].contains(message.status) && - role != null; - final enableReply = - !isTranscriptPage && - message.type.canReply && - !isPinnedPage; - final enableForward = - !isTranscriptPage && message.canForward; - final enableSelect = !isTranscriptPage; - final enableSaveMobile = - kPlatformIsMobile && - (message.type.isImage || message.type.isVideo); - final enableSaveDesktop = - kPlatformIsDesktop && - message.mediaStatus == MediaStatus.done && - message.mediaUrl?.isNotEmpty == true && - (message.type.isData || - message.type.isImage || - message.type.isVideo || - message.type.isAudio); - final enableRecall = !isTranscriptPage && message.canRecall; - - final enableDelete = !isTranscriptPage && !isPinnedPage; - - final addStickerMenuAction = [ - if (message.type.isSticker) - MenuAction( - image: MenuImage.icon(IconFonts.sticker), - title: context.l10n.addSticker, - callback: () => _onAddSticker(context), - ), - if (message.type.isImage && message.canForward) - MenuAction( - image: MenuImage.icon(IconFonts.sticker), - title: context.l10n.addSticker, - callback: () => _onAddImageAsSticker(context), - ), - ]; - - final replayAction = [ - if (enableReply) - MenuAction( - image: MenuImage.icon(IconFonts.reply), - title: context.l10n.reply, - callback: () => - context.providerContainer - .read(quoteMessageProvider.notifier) - .state = + KeyedSubtree( + key: bodyKey, + child: ColoredBox( + color: blinkColor, + child: Builder( + builder: (context) { + if (message.type == MessageCategory.systemConversation) { + return const SystemMessage(); + } + + if (message.type.isPin) { + return const PinMessageWidget(); + } + + if (message.type == MessageCategory.secret) { + return const SecretMessage(); + } + + if (message.type == MessageCategory.stranger) { + return const StrangerMessage(); + } + + return _MessageSelectionWrapper( + message: message, + child: _MessageBubbleMargin( + userName: userName, + userId: userId, + userAvatarUrl: userAvatarUrl, + showAvatar: showAvatar, + isCurrentUser: isCurrentUser, + pinArrowWidth: isPinnedPage ? _pinArrowWidth : 0, + isBot: message.isBot, + isVerified: message.isVerified, + buildMenus: (request) { + request.onShowMenu.addListener(() { + showedMenuController.value = true; + focusNode.requestFocus(); + }); + request.onHideMenu.addListener(() { + showedMenuController.value = false; + }); + + final enable = !ref.read(hasSelectedMessageProvider); + + if (!enable) return null; + + final role = ref.read( + conversationProvider.select((value) => value?.role), + ); + + final pinEnabled = + !isTranscriptPage && + message.type.canReply && + const [ + MessageStatus.delivered, + MessageStatus.read, + MessageStatus.sent, + ].contains(message.status) && + role != null; + final enableReply = + !isTranscriptPage && + message.type.canReply && + !isPinnedPage; + final enableForward = + !isTranscriptPage && message.canForward; + final enableSelect = !isTranscriptPage; + final enableSaveMobile = + kPlatformIsMobile && + (message.type.isImage || message.type.isVideo); + final enableSaveDesktop = + kPlatformIsDesktop && + message.mediaStatus == MediaStatus.done && + message.mediaUrl?.isNotEmpty == true && + (message.type.isData || + message.type.isImage || + message.type.isVideo || + message.type.isAudio); + final enableRecall = + !isTranscriptPage && message.canRecall; + + final enableDelete = !isTranscriptPage && !isPinnedPage; + + final addStickerMenuAction = [ + if (message.type.isSticker) + MenuAction( + image: MenuImage.icon(IconFonts.sticker), + title: l10n.addSticker, + callback: () => _onAddSticker(context, ref), + ), + if (message.type.isImage && message.canForward) + MenuAction( + image: MenuImage.icon(IconFonts.sticker), + title: l10n.addSticker, + callback: () => _onAddImageAsSticker(ref), + ), + ]; + + final replayAction = [ + if (enableReply) + MenuAction( + image: MenuImage.icon(IconFonts.reply), + title: l10n.reply, + callback: () => ref + .read(quoteMessageProvider.notifier) + .set( message, - ), - ]; - - final messageActions = [ - if (enableForward) - MenuAction( - image: MenuImage.icon(IconFonts.forward), - title: context.l10n.forward, - callback: () async { - final result = await showConversationSelector( - context: context, - singleSelect: true, - title: context.l10n.forward, - onlyContact: false, - ); - if (result == null || result.isEmpty) return; - await context.accountServer.forwardMessage( - message.messageId, - result.first.encryptCategory!, - conversationId: result.first.conversationId, - recipientId: result.first.userId, - ); - }, - ), - if (enableSelect) - MenuAction( - image: MenuImage.icon(IconFonts.select), - title: context.l10n.select, - callback: () => ref - .read(messageSelectionProvider) - .selectMessage(message), - ), - if (pinEnabled) - MenuAction( - image: MenuImage.icon( - message.pinned ? IconFonts.unPin : IconFonts.pin, + ), ), - title: message.pinned - ? context.l10n.unpin - : context.l10n.pinTitle, - callback: () async { - final pinMessageMinimal = PinMessageMinimal( - messageId: message.messageId, - type: message.type, - content: message.type.isText - ? message.content - : null, - ); - if (message.pinned) { - await context.accountServer.unpinMessage( - conversationId: message.conversationId, - pinMessageMinimals: [pinMessageMinimal], + ]; + + final messageActions = [ + if (enableForward) + MenuAction( + image: MenuImage.icon(IconFonts.forward), + title: l10n.forward, + callback: () async { + final result = await showConversationSelector( + context: context, + singleSelect: true, + title: l10n.forward, + onlyContact: false, ); - return; - } - await context.accountServer.pinMessage( - conversationId: message.conversationId, - pinMessageMinimals: [pinMessageMinimal], - ); - }, - ), - ]; - - final copyActions = []; - if (message.type.isPost) { - copyActions.add( - MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: context.l10n.copy, - callback: () { - Clipboard.setData( - ClipboardData(text: message.content ?? ''), + if (result == null || result.isEmpty) return; + final accountServer = ref + .read(accountServerProvider) + .requireValue; + await accountServer.forwardMessage( + message.messageId, + result.first.encryptCategory!, + conversationId: result.first.conversationId, + recipientId: result.first.userId, + ); + }, + ), + if (enableSelect) + MenuAction( + image: MenuImage.icon(IconFonts.select), + title: l10n.select, + callback: () => ref + .read(messageSelectionProvider.notifier) + .selectMessage(message), + ), + if (pinEnabled) + MenuAction( + image: MenuImage.icon( + message.pinned ? IconFonts.unPin : IconFonts.pin, + ), + title: message.pinned ? l10n.unpin : l10n.pinTitle, + callback: () async { + final pinMessageMinimal = PinMessageMinimal( + messageId: message.messageId, + type: message.type, + content: message.type.isText + ? message.content + : null, + ); + if (message.pinned) { + await ref + .read(accountServerProvider) + .requireValue + .unpinMessage( + conversationId: message.conversationId, + pinMessageMinimals: [pinMessageMinimal], + ); + return; + } + await ref + .read(accountServerProvider) + .requireValue + .pinMessage( + conversationId: message.conversationId, + pinMessageMinimals: [pinMessageMinimal], + ); + }, + ), + ]; + + final copyActions = []; + if (message.type.isPost) { + copyActions.add( + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: l10n.copy, + callback: () { + Clipboard.setData( + ClipboardData(text: message.content ?? ''), + ); + }, + ), + ); + } else if (message.type.isImage) { + copyActions.add( + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: l10n.copyImage, + callback: () { + final accountServer = ref + .read(accountServerProvider) + .requireValue; + copyFile( + accountServer.convertMessageAbsolutePath( + message, + isTranscriptPage, + ), + ); + }, + ), + ); + if (!message.caption.isNullOrBlank()) { + final selectedContent = _findSelectedContent(context); + if (selectedContent != null) { + copyActions.add( + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: l10n.copySelectedText, + callback: () { + Clipboard.setData( + ClipboardData( + text: selectedContent.plainText, + ), + ); + }, + ), ); - }, - ), - ); - } else if (message.type.isImage) { - copyActions.add( - MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: context.l10n.copyImage, - callback: () { - copyFile( - context.accountServer.convertMessageAbsolutePath( - message, - isTranscriptPage, + } else { + copyActions.add( + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: l10n.copyText, + callback: () { + Clipboard.setData( + ClipboardData(text: message.caption ?? ''), + ); + }, ), ); - }, - ), - ); - if (!message.caption.isNullOrBlank()) { + } + } + } else if (message.type.isText) { final selectedContent = _findSelectedContent(context); - if (selectedContent != null) { - copyActions.add( + copyActions + ..add( MenuAction( image: MenuImage.icon(IconFonts.copy), - title: context.l10n.copySelectedText, + title: selectedContent == null + ? l10n.copy + : l10n.copySelectedText, callback: () { - Clipboard.setData( - ClipboardData( - text: selectedContent.plainText, - ), - ); + if (selectedContent != null) { + Clipboard.setData( + ClipboardData( + text: selectedContent.plainText, + ), + ); + } else { + Clipboard.setData( + ClipboardData(text: message.content ?? ''), + ); + } + }, + ), + ) + ..add( + MenuAction( + image: MenuImage.icon(Icons.qr_code), + title: l10n.generateQrcode, + callback: () { + final content = + selectedContent?.plainText ?? + message.content ?? + ''; + showQrCodeDialog(context, content); }, ), ); - } else { + } else if (message.type.isAppCard) { + final selectedContent = _findSelectedContent(context); + if (selectedContent != null) { copyActions.add( MenuAction( image: MenuImage.icon(IconFonts.copy), - title: context.l10n.copyText, + title: l10n.copySelectedText, callback: () { - Clipboard.setData( - ClipboardData(text: message.caption ?? ''), - ); + String text; + try { + final data = AppCardData.fromJson( + jsonDecode(message.content!) + as Map, + ); + text = data.generateCopyTextWithBreakLine( + selectedContent.plainText, + ); + } catch (error) { + e('ActionCard decode error: $error'); + text = selectedContent.plainText; + } + Clipboard.setData(ClipboardData(text: text)); }, ), ); } } - } else if (message.type.isText) { - final selectedContent = _findSelectedContent(context); - copyActions - ..add( + + final saveActions = [ + if (enableSaveMobile) MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: selectedContent == null - ? context.l10n.copy - : context.l10n.copySelectedText, - callback: () { - if (selectedContent != null) { - Clipboard.setData( - ClipboardData( - text: selectedContent.plainText, - ), - ); - } else { - Clipboard.setData( - ClipboardData(text: message.content ?? ''), - ); - } - }, + image: MenuImage.icon(IconFonts.download), + title: l10n.saveToCameraRoll, + callback: () => saveAs( + context, + ref.read(accountServerProvider).requireValue, + message, + isTranscriptPage, + confirmButtonText: l10n.save, + ), ), - ) - ..add( + if (enableSaveDesktop) MenuAction( - image: MenuImage.icon(Icons.qr_code), - title: context.l10n.generateQrcode, - callback: () { - final content = - selectedContent?.plainText ?? - message.content ?? - ''; - showQrCodeDialog(context, content); - }, + image: MenuImage.icon(IconFonts.download), + title: l10n.saveAs, + callback: () => saveAs( + context, + ref.read(accountServerProvider).requireValue, + message, + isTranscriptPage, + confirmButtonText: l10n.save, + ), ), - ); - } else if (message.type.isAppCard) { - final selectedContent = _findSelectedContent(context); - if (selectedContent != null) { - copyActions.add( + ]; + final deleteActions = [ + if (enableRecall) MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: context.l10n.copySelectedText, - callback: () { - String text; - try { - final data = AppCardData.fromJson( - jsonDecode(message.content!) - as Map, - ); - text = data.generateCopyTextWithBreakLine( - selectedContent.plainText, - ); - } catch (error) { - e('ActionCard decode error: $error'); - text = selectedContent.plainText; + image: MenuImage.icon(IconFonts.recall), + title: l10n.deleteForEveryone, + callback: () async { + String? content; + if (message.type.isText) { + content = message.content; + } + await ref + .read(accountServerProvider) + .requireValue + .sendRecallMessage([ + message.messageId, + ], conversationId: message.conversationId); + if (content != null) { + ref + .read(recallMessageNotifierProvider) + .onRecalled(message.messageId, content); } - Clipboard.setData(ClipboardData(text: text)); }, ), - ); - } - } - - final saveActions = [ - if (enableSaveMobile) - MenuAction( - image: MenuImage.icon(IconFonts.download), - title: context.l10n.saveToCameraRoll, - callback: () => saveAs( - context, - context.accountServer, - message, - isTranscriptPage, - ), - ), - if (enableSaveDesktop) - MenuAction( - image: MenuImage.icon(IconFonts.download), - title: context.l10n.saveAs, - callback: () => saveAs( - context, - context.accountServer, - message, - isTranscriptPage, - ), - ), - ]; - final deleteActions = [ - if (enableRecall) - MenuAction( - image: MenuImage.icon(IconFonts.recall), - title: context.l10n.deleteForEveryone, - callback: () async { - String? content; - if (message.type.isText) { - content = message.content; - } - await context.accountServer.sendRecallMessage([ - message.messageId, - ], conversationId: message.conversationId); - if (content != null) { - context.providerContainer - .read(recallMessageNotifierProvider) - .onRecalled(message.messageId, content); - } - }, - ), - if (enableDelete) - MenuAction( - image: MenuImage.icon(IconFonts.delete), - title: context.l10n.deleteForMe, - callback: () => context.accountServer.deleteMessage( - message.messageId, + if (enableDelete) + MenuAction( + image: MenuImage.icon(IconFonts.delete), + title: l10n.deleteForMe, + callback: () => ref + .read(accountServerProvider) + .requireValue + .deleteMessage(message.messageId), ), - ), - ]; - - final devActions = [ - if (!kReleaseMode) - MenuAction( - image: MenuImage.icon(IconFonts.copy), - title: 'Copy message', - callback: () => Clipboard.setData( - ClipboardData(text: message.toString()), + ]; + + final devActions = [ + if (!kReleaseMode) + MenuAction( + image: MenuImage.icon(IconFonts.copy), + title: 'Copy message', + callback: () => Clipboard.setData( + ClipboardData(text: message.toString()), + ), ), - ), - ]; - - return MenusWithSeparator( - childrens: [ - replayAction, - copyActions, - messageActions, - saveActions, - addStickerMenuAction, - deleteActions, - devActions, - ], - ); - }, - builder: (context) { - if (message.type.isIllegalMessageCategory || - message.status == MessageStatus.unknown) { - return const UnknownMessage(); - } + ]; + + return MenusWithSeparator( + childrens: [ + replayAction, + copyActions, + messageActions, + saveActions, + addStickerMenuAction, + deleteActions, + devActions, + ], + ); + }, + builder: (context) { + if (message.type.isIllegalMessageCategory || + message.status == MessageStatus.unknown) { + return const UnknownMessage(); + } - if (message.status == MessageStatus.failed) { - return const WaitingMessage(); - } + if (message.status == MessageStatus.failed) { + return const WaitingMessage(); + } - if (message.type.isTranscript) { - return const TranscriptMessageWidget(); - } + if (message.type.isTranscript) { + return const TranscriptMessageWidget(); + } - if (message.type.isLocation) { - return const LocationMessageWidget(); - } + if (message.type.isLocation) { + return const LocationMessageWidget(); + } - if (message.type.isPost) { - return const PostMessage(); - } + if (message.type.isPost) { + return const PostMessage(); + } - if (message.type == MessageCategory.systemAccountSnapshot) { - return const TransferMessage(); - } + if (message.type == + MessageCategory.systemAccountSnapshot) { + return const TransferMessage(); + } - if (message.type == MessageCategory.systemSafeSnapshot) { - return const SafeTransferMessage(); - } + if (message.type == MessageCategory.systemSafeSnapshot) { + return const SafeTransferMessage(); + } - if (message.type.isContact) { - return const ContactMessageWidget(); - } + if (message.type.isContact) { + return const ContactMessageWidget(); + } - if (message.type == MessageCategory.appButtonGroup) { - return const ActionMessage(); - } + if (message.type == MessageCategory.appButtonGroup) { + return const ActionMessage(); + } - if (message.type == MessageCategory.appCard) { - return const ActionCardMessage(); - } + if (message.type == MessageCategory.appCard) { + return const ActionCardMessage(); + } - if (message.type.isData) { - return const FileMessage(); - } + if (message.type.isData) { + return const FileMessage(); + } - if (message.type.isText) { - return const TextMessage(); - } + if (message.type.isText) { + return const TextMessage(); + } - if (message.type.isSticker) { - return const StickerMessageWidget(); - } + if (message.type.isSticker) { + return const StickerMessageWidget(); + } - if (message.type.isImage) { - return const ImageMessageWidget(); - } + if (message.type.isImage) { + return const ImageMessageWidget(); + } - if (message.type.isVideo || message.type.isLive) { - return const VideoMessageWidget(); - } + if (message.type.isVideo || message.type.isLive) { + return const VideoMessageWidget(); + } - if (message.type.isAudio) { - return const AudioMessage(); - } + if (message.type.isAudio) { + return const AudioMessage(); + } - if (message.type.isRecall) { - return const RecallMessage(); - } + if (message.type.isRecall) { + return const RecallMessage(); + } - if (message.type == MessageCategory.systemSafeInscription) { - return const InscriptionMessage(); - } + if (message.type == + MessageCategory.systemSafeInscription) { + return const InscriptionMessage(); + } - return const UnknownMessage(); - }, - ), - ); - }, + return const UnknownMessage(); + }, + ), + ); + }, + ), ), ), if (message.messageId == lastReadMessageId && next != null) @@ -740,32 +762,37 @@ class MessageItemWidget extends HookConsumerWidget { child = VisibilityDetector( onVisibilityChanged: (info) { if (info.visibleFraction < 1) return; - context.accountServer.markMentionRead( - message.messageId, - message.conversationId, - ); + ref + .read(accountServerProvider) + .requireValue + .markMentionRead( + message.messageId, + message.conversationId, + ); }, key: ValueKey(message.messageId), child: child, ); } - return FocusScope( - node: focusNode, - child: MessageContext( - isTranscriptPage: isTranscriptPage, - isPinnedPage: isPinnedPage, - showNip: showNip, - isCurrentUser: isCurrentUser, - message: message, - child: Builder( - builder: (context) => GestureDetector( - onTap: () => _quickReply(context), - child: Padding( - padding: sameUserPrev - ? EdgeInsets.zero - : const EdgeInsets.only(top: 8), - child: child, + return MessageStyleScope( + child: FocusScope( + node: focusNode, + child: MessageContext( + isTranscriptPage: isTranscriptPage, + isPinnedPage: isPinnedPage, + showNip: showNip, + isCurrentUser: isCurrentUser, + message: message, + child: Builder( + builder: (context) => GestureDetector( + onTap: () => _quickReply(context, ref), + child: Padding( + padding: sameUserPrev + ? EdgeInsets.zero + : const EdgeInsets.only(top: 8), + child: child, + ), ), ), ), @@ -773,26 +800,26 @@ class MessageItemWidget extends HookConsumerWidget { ); } - Future _onAddImageAsSticker(BuildContext context) async { + Future _onAddImageAsSticker(WidgetRef ref) async { await showAddStickerDialog( - context, - filepath: context.accountServer.convertMessageAbsolutePath( - message, - isTranscriptPage, - ), + ref.context, + filepath: ref + .read(accountServerProvider) + .requireValue + .convertMessageAbsolutePath(message, isTranscriptPage), ); } - Future _onAddSticker(BuildContext context) async { + Future _onAddSticker(BuildContext context, WidgetRef ref) async { showToastLoading(); try { - final accountServer = context.accountServer; + final accountServer = ref.read(accountServerProvider).requireValue; final mixinResponse = await accountServer.client.accountApi.addSticker( StickerRequest(stickerId: message.stickerId), ); - final database = context.database; + final database = ref.read(databaseProvider).requireValue; final personalAlbum = await database.stickerAlbumDao .personalAlbum() @@ -801,19 +828,18 @@ class MessageItemWidget extends HookConsumerWidget { unawaited(accountServer.refreshSticker(force: true)); } else { final data = mixinResponse.data; - await database.mixinDatabase.transaction(() async { - await database.stickerDao.insert(data.asStickersCompanion); - await database.stickerRelationshipDao.insert( - StickerRelationship( - albumId: personalAlbum.albumId, - stickerId: data.stickerId, - ), - ); - }); + await accountServer.insertStickerAndRelationship( + data.asStickersCompanion, + StickerRelationship( + albumId: personalAlbum.albumId, + stickerId: data.stickerId, + ), + ); } showToastSuccessful(); } catch (_) { - showToastFailed(ToastError(context.l10n.addStickerFailed)); + final l10n = ref.read(localizationProvider); + showToastFailed(ToastError(l10n.addStickerFailed)); } } } @@ -847,23 +873,14 @@ class MessageContext extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - _MessageContext newMessageContext() => _MessageContext( + final messageContext = _MessageContext( isTranscriptPage: isTranscriptPage, isPinnedPage: isPinnedPage, showNip: showNip, isCurrentUser: isCurrentUser, message: message, ); - - final messageContextCubit = useBloc( - () => _MessageContextCubit(newMessageContext()), - ); - - useEffect(() { - messageContextCubit.emit(newMessageContext()); - }, [isTranscriptPage, isPinnedPage, showNip, isCurrentUser, message]); - - return Provider.value(value: messageContextCubit, child: child); + return _MessageContextScope(value: messageContext, child: child); } } @@ -871,8 +888,9 @@ Future saveAs( BuildContext context, AccountServer accountServer, MessageItem message, - bool isTranscriptPage, -) async { + bool isTranscriptPage, { + required String confirmButtonText, +}) async { final path = accountServer.convertMessageAbsolutePath( message, isTranscriptPage, @@ -896,9 +914,9 @@ Future saveAs( } else { try { final result = await saveFileToSystem( - context, path, suggestName: message.mediaName, + confirmButtonText: confirmButtonText, ); if (result) return showToastSuccessful(); } catch (error, s) { @@ -965,7 +983,7 @@ class _MessageBubbleMargin extends HookConsumerWidget { menuProvider: buildMenus, desktopMenuWidgetBuilder: CustomDesktopMenuWidgetBuilder(), child: GestureDetector( - onTap: () => _quickReply(context), + onTap: () => _quickReply(context, ref), child: Builder(builder: builder), ), ), @@ -990,7 +1008,7 @@ class _MessageBubbleMargin extends HookConsumerWidget { children: [ const SizedBox(width: 8), InteractiveDecoratedBox( - onTap: () => showUserDialog(context, userId), + onTap: () => showUserDialog(context, ref.container, userId), cursor: SystemMouseCursors.click, child: AvatarWidget( userId: userId, @@ -1011,23 +1029,27 @@ class _MessageBubbleMargin extends HookConsumerWidget { } } -class _UnreadMessageBar extends StatelessWidget { +class _UnreadMessageBar extends ConsumerWidget { const _UnreadMessageBar(); @override - Widget build(BuildContext context) => Container( - color: context.theme.background, - padding: const EdgeInsets.symmetric(vertical: 4), - margin: const EdgeInsets.symmetric(vertical: 6), - alignment: Alignment.center, - child: Text( - context.l10n.unreadMessages, - style: TextStyle( - color: context.theme.secondaryText, - fontSize: context.messageStyle.secondaryFontSize, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + return Container( + color: theme.background, + padding: const EdgeInsets.symmetric(vertical: 4), + margin: const EdgeInsets.symmetric(vertical: 6), + alignment: Alignment.center, + child: Text( + l10n.unreadMessages, + style: TextStyle( + color: theme.secondaryText, + fontSize: context.messageStyle.secondaryFontSize, + ), ), - ), - ); + ); + } } class _MessageSelectionWrapper extends HookConsumerWidget { @@ -1050,7 +1072,9 @@ class _MessageSelectionWrapper extends HookConsumerWidget { return GestureDetector( behavior: HitTestBehavior.translucent, onTap: inMultiSelectMode - ? () => ref.read(messageSelectionProvider).toggleSelection(message) + ? () => ref + .read(messageSelectionProvider.notifier) + .toggleSelection(message) : null, child: Row( children: [ @@ -1079,6 +1103,7 @@ class _AnimatedSelectionIcon extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final animationController = useAnimationController( duration: const Duration(milliseconds: 300), ); @@ -1112,9 +1137,7 @@ class _AnimatedSelectionIcon extends HookConsumerWidget { child: Center( child: ClipOval( child: Container( - color: selected - ? context.theme.accent - : context.theme.secondaryText, + color: selected ? theme.accent : theme.secondaryText, height: 16, width: 16, alignment: const Alignment(0, -0.2), diff --git a/lib/widgets/message/message_bubble.dart b/lib/widgets/message/message_bubble.dart index 8199e39632..a45611fc1c 100644 --- a/lib/widgets/message/message_bubble.dart +++ b/lib/widgets/message/message_bubble.dart @@ -6,8 +6,10 @@ import 'package:flutter_svg/flutter_svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../constants/resources.dart'; -import '../../ui/home/bloc/blink_cubit.dart'; +import '../../ui/home/providers/home_scope_providers.dart'; +import '../../ui/provider/account_server_provider.dart'; import '../../ui/provider/conversation_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../action_button.dart'; import '../toast.dart'; @@ -22,8 +24,16 @@ const darkOtherBubble = Color.fromRGBO(52, 59, 67, 1); extension BubbleColor on BuildContext { Color messageBubbleColor(bool isCurrentUser) => isCurrentUser - ? dynamicColor(_lightCurrentBubble, darkColor: _darkCurrentBubble) - : dynamicColor(lightOtherBubble, darkColor: darkOtherBubble); + ? BrightnessData.dynamicColor( + this, + _lightCurrentBubble, + darkColor: _darkCurrentBubble, + ) + : BrightnessData.dynamicColor( + this, + lightOtherBubble, + darkColor: darkOtherBubble, + ); } class MessageBubble extends HookConsumerWidget { @@ -138,8 +148,11 @@ class MessageBubble extends HookConsumerWidget { name: Resources.assetsImagesPinArrowSvg, onTap: () { final message = context.message; - context.read().blinkByMessageId(message.messageId); + ref + .read(blinkControllerProvider.notifier) + .blinkByMessageId(message.messageId); ConversationStateNotifier.selectConversation( + ref.container, context, message.conversationId, initIndexMessageId: message.messageId, @@ -162,10 +175,11 @@ class MessageBubble extends HookConsumerWidget { } if (isDisappearingMessage) { + final brightness = ref.watch(platformBrightnessProvider); Widget icon = Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: SvgPicture.asset( - context.brightness == Brightness.dark + brightness == Brightness.dark ? Resources.assetsImagesExpiringDarkSvg : Resources.assetsImagesExpiringSvg, width: 16, @@ -174,14 +188,12 @@ class MessageBubble extends HookConsumerWidget { ); if (!kReleaseMode) { + final accountServer = ref.read(accountServerProvider).requireValue; icon = GestureDetector( child: icon, onTap: () async { final message = context.message; - final expireAt = await context - .accountServer - .database - .expiredMessageDao + final expireAt = await accountServer.database.expiredMessageDao .getMessageExpireAt([message.messageId]); final time = (expireAt[message.messageId] ?? 0) - diff --git a/lib/widgets/message/message_datetime_and_status.dart b/lib/widgets/message/message_datetime_and_status.dart index ee72401e5b..6b220e2172 100644 --- a/lib/widgets/message/message_datetime_and_status.dart +++ b/lib/widgets/message/message_datetime_and_status.dart @@ -7,6 +7,7 @@ import 'package:intl/intl.dart'; import '../../constants/resources.dart'; import '../../db/mixin_database.dart'; +import '../../ui/provider/account_server_provider.dart'; import '../../ui/provider/conversation_provider.dart'; import '../../utils/extension/extension.dart'; import '../message_status_icon.dart'; @@ -35,6 +36,7 @@ class MessageDatetimeAndStatus extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).requireValue; final isTranscriptPage = useIsTranscriptPage(); final isPinnedPage = useIsPinnedPage(); final isCurrentUser = useIsCurrentUser(); @@ -44,7 +46,7 @@ class MessageDatetimeAndStatus extends HookConsumerWidget { converter: (state) => _isRepresentative( state, ref.read(conversationProvider), - context.accountServer.userId, + accountServer.userId, ), ); final createdAt = useMessageConverter( @@ -117,7 +119,8 @@ class _ChatIcon extends StatelessWidget { height: 8, colorFilter: ColorFilter.mode( color ?? - context.dynamicColor( + BrightnessData.dynamicColor( + context, const Color.fromRGBO(131, 145, 158, 1), darkColor: const Color.fromRGBO(128, 131, 134, 1), ), @@ -144,7 +147,8 @@ class _MessageDatetime extends HookConsumerWidget { fontSize: context.messageStyle.statusFontSize, color: color ?? - context.dynamicColor( + BrightnessData.dynamicColor( + context, const Color.fromRGBO(131, 145, 158, 1), darkColor: const Color.fromRGBO(128, 131, 134, 1), ), diff --git a/lib/widgets/message/message_day_time.dart b/lib/widgets/message/message_day_time.dart index c7727e55dc..a52ea7e4a9 100644 --- a/lib/widgets/message/message_day_time.dart +++ b/lib/widgets/message/message_day_time.dart @@ -1,17 +1,31 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../db/mixin_database.dart' as db; import '../../ui/provider/minute_timer_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/datetime_format_utils.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../../utils/logger.dart'; import 'message.dart'; import 'message_style.dart'; +class _HiddenMessageDayTimeScope + extends InheritedNotifier { + const _HiddenMessageDayTimeScope({ + required HiddenMessageDayTimeController controller, + required super.child, + }) : super(notifier: controller); + + static HiddenMessageDayTimeController of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType<_HiddenMessageDayTimeScope>(); + assert(scope?.notifier != null, '_HiddenMessageDayTimeScope is missing'); + return scope!.notifier!; + } +} + class MessageDayTime extends HookConsumerWidget { const MessageDayTime({required this.dateTime, super.key}); @@ -19,11 +33,9 @@ class MessageDayTime extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final hide = - useBlocStateConverter( - converter: (state) => isSameDay(state, dateTime), - keys: [dateTime], - ); + final controller = _HiddenMessageDayTimeScope.of(context); + final hiddenDateTime = useValueListenable(controller); + final hide = isSameDay(hiddenDateTime, dateTime); return Center( child: Opacity( opacity: hide ? 0 : 1, @@ -40,6 +52,7 @@ class _MessageDayTimeWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final dateTimeString = ref.watch(formattedDayProvider(dateTime)); return Padding( @@ -47,7 +60,7 @@ class _MessageDayTimeWidget extends HookConsumerWidget { child: Container( decoration: BoxDecoration( borderRadius: const BorderRadius.all(Radius.circular(10)), - color: context.theme.dateTime, + color: theme.dateTime, ), padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 10), child: ConstrainedBox( @@ -66,10 +79,10 @@ class _MessageDayTimeWidget extends HookConsumerWidget { } } -class HiddenMessageDayTimeBloc extends Cubit { - HiddenMessageDayTimeBloc() : super(null); +class HiddenMessageDayTimeController extends ValueNotifier { + HiddenMessageDayTimeController() : super(null); - void update(DateTime? dateTime) => emit(dateTime); + void update(DateTime? dateTime) => value = dateTime; } class _CurrentShowingMessages { @@ -180,9 +193,7 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { final dateTime = useState(null); final dateTimeTopOffset = useState(0); - final bloc = useBloc( - HiddenMessageDayTimeBloc.new, - ); + final controller = useMemoized(HiddenMessageDayTimeController.new); void doTraversal() { final result = _traversalCurrentShowingMessageElements(); @@ -262,7 +273,7 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { if (offset.dy < render.size.height / 2) { // up firstInScreenIndex = closestToTopDayTimeIndex; - bloc.update(items[closestToTopDayTimeIndex].createdAt); + controller.update(items[closestToTopDayTimeIndex].createdAt); dateTimeTopOffset.value = 0; } else { // down @@ -282,16 +293,16 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { } return true; }()); - bloc.update(null); + controller.update(null); dateTimeTopOffset.value = offset.dy - render.size.height * 1.5; } else { firstInScreenIndex = -1; dateTimeTopOffset.value = 0; - bloc.update(null); + controller.update(null); } } } else { - bloc.update(null); + controller.update(null); dateTimeTopOffset.value = 0; } @@ -311,6 +322,8 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { }); }, [reTraversalKey]); + useEffect(() => controller.dispose, [controller]); + return LayoutBuilder( builder: (context, constraints) => HookBuilder( builder: (context) { @@ -319,8 +332,8 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { doTraversal(); }); }, [constraints]); - return BlocProvider.value( - value: bloc, + return _HiddenMessageDayTimeScope( + controller: controller, child: ClipRect( child: Stack( fit: StackFit.expand, @@ -334,8 +347,10 @@ class MessageDayTimeViewportWidget extends HookConsumerWidget { ), child: Align( alignment: Alignment.topCenter, - child: _MessageDayTimeWidget( - dateTime: dateTime.value!, + child: MessageStyleScope( + child: _MessageDayTimeWidget( + dateTime: dateTime.value!, + ), ), ), ), diff --git a/lib/widgets/message/message_name.dart b/lib/widgets/message/message_name.dart index aac3a6fad8..59fd1ecf37 100644 --- a/lib/widgets/message/message_name.dart +++ b/lib/widgets/message/message_name.dart @@ -3,8 +3,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import '../../ui/provider/setting_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/color_utils.dart'; -import '../../utils/extension/extension.dart'; import '../conversation/badges_widget.dart'; import '../high_light_text.dart'; import '../interactive_decorated_box.dart'; @@ -34,6 +34,7 @@ class MessageName extends ConsumerWidget { final showIdentityNumber = ref.watch( settingProvider.select((value) => value.messageShowIdentityNumber), ); + final theme = ref.watch(brightnessThemeDataProvider); final children = [ CustomText( userName, @@ -54,7 +55,7 @@ class MessageName extends ConsumerWidget { '@$userIdentityNumber', style: TextStyle( fontSize: context.messageStyle.statusFontSize, - color: context.theme.text.withValues(alpha: 0.5), + color: theme.text.withValues(alpha: 0.5), ), ), ), @@ -75,7 +76,7 @@ class MessageName extends ConsumerWidget { return Align( alignment: Alignment.centerLeft, child: InteractiveDecoratedBox( - onTap: () => showUserDialog(context, userId), + onTap: () => showUserDialog(context, ref.container, userId), cursor: SystemMouseCursors.click, child: Padding( padding: const EdgeInsets.only(left: 10, bottom: 2), diff --git a/lib/widgets/message/message_style.dart b/lib/widgets/message/message_style.dart index 817f44ef77..cee9bd7e3b 100644 --- a/lib/widgets/message/message_style.dart +++ b/lib/widgets/message/message_style.dart @@ -1,10 +1,48 @@ import 'package:flutter/cupertino.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../../utils/extension/extension.dart'; +import '../../ui/provider/setting_provider.dart'; + +final messageStyleProvider = Provider( + (ref) => + MessageStyle.defaultStyle + + ref.watch(settingProvider.notifier).chatFontSizeDelta, +); + +class MessageStyleScope extends ConsumerWidget { + const MessageStyleScope({required this.child, super.key}); + + final Widget child; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final style = ref.watch(messageStyleProvider); + return _MessageStyleInherited(style: style, child: child); + } +} + +class _MessageStyleInherited extends InheritedWidget { + const _MessageStyleInherited({ + required this.style, + required super.child, + }); + + final MessageStyle style; + + static MessageStyle of(BuildContext context) { + final inherited = context + .dependOnInheritedWidgetOfExactType<_MessageStyleInherited>(); + assert(inherited != null, 'MessageStyleScope is missing'); + return inherited!.style; + } + + @override + bool updateShouldNotify(_MessageStyleInherited oldWidget) => + style != oldWidget.style; +} extension MessageStyleExt on BuildContext { - MessageStyle get messageStyle => - MessageStyle.defaultStyle + settingChangeNotifier.chatFontSizeDelta; + MessageStyle get messageStyle => _MessageStyleInherited.of(this); } class MessageStyle { diff --git a/lib/widgets/message/send_message_dialog/send_message_dialog.dart b/lib/widgets/message/send_message_dialog/send_message_dialog.dart index 67f1039c1f..23ddef11c2 100644 --- a/lib/widgets/message/send_message_dialog/send_message_dialog.dart +++ b/lib/widgets/message/send_message_dialog/send_message_dialog.dart @@ -9,7 +9,10 @@ import '../../../crypto/uuid/uuid.dart'; import '../../../db/dao/sticker_dao.dart'; import '../../../db/mixin_database.dart'; import '../../../enum/encrypt_category.dart'; +import '../../../ui/provider/account_server_provider.dart'; import '../../../ui/provider/conversation_provider.dart'; +import '../../../ui/provider/database_provider.dart'; +import '../../../ui/provider/ui_context_providers.dart'; import '../../../utils/extension/extension.dart'; import '../../../utils/hook.dart'; import '../../../utils/load_balancer_utils.dart'; @@ -52,6 +55,7 @@ Future showSendDialog( String? data, App? app, String? user, + ProviderContainer container, ) async { final _category = category?.category; if (_category == null || data == null || data.isEmpty) return false; @@ -100,15 +104,13 @@ Future showSendDialog( } if (user != null) { - return _sendMessageToUserId(context, user, _category, result); + return _sendMessageToUserId(context, user, _category, result, container); } if (conversationId == null) { - final currentConversation = context.providerContainer.read( - conversationProvider, - ); + final currentConversation = container.read(conversationProvider); if (currentConversation != null) { await _sendMessage( - context, + container, currentConversation.conversationId, currentConversation.encryptCategory, _category, @@ -131,14 +133,16 @@ Future _sendMessageToUserId( String userId, _Category category, dynamic data, + ProviderContainer container, ) async { + final accountServer = container.read(accountServerProvider).requireValue; if (!Uuid.isValidUUID(fromString: userId)) { return false; } showToastLoading(); try { - final users = await context.accountServer.refreshUsers([userId]); + final users = await accountServer.refreshUsers([userId]); if (users == null || users.isEmpty) { return false; } @@ -147,16 +151,16 @@ Future _sendMessageToUserId( } final conversationId = generateConversationId( - context.accountServer.userId, + accountServer.userId, userId, ); - await ConversationStateNotifier.selectUser(context, userId); - final conversation = context.providerContainer.read(conversationProvider); + await ConversationStateNotifier.selectUser(container, context, userId); + final conversation = container.read(conversationProvider); if (conversation == null) { return false; } await _sendMessage( - context, + container, conversationId, conversation.encryptCategory, category, @@ -176,30 +180,41 @@ class _SendPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final database = ref.read(databaseProvider).requireValue; + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + final dynamicSurface = ref.watch( + dynamicColorProvider( + const ( + color: Color.fromRGBO(245, 247, 250, 1), + darkColor: Color.fromRGBO(255, 255, 255, 0.08), + ), + ), + ); final title = useMemoized(() { String _category; switch (category) { case _Category.text: - _category = context.l10n.text; + _category = l10n.text; case _Category.image: - _category = context.l10n.image; + _category = l10n.image; case _Category.sticker: - _category = context.l10n.sticker; + _category = l10n.sticker; case _Category.contact: - _category = context.l10n.contact; + _category = l10n.contact; case _Category.post: - _category = context.l10n.post; + _category = l10n.post; case _Category.app_card: - _category = context.l10n.card; + _category = l10n.card; } if (app != null) { - return context.l10n.shareMessageDescription( + return l10n.shareMessageDescription( '${app?.name}(${app?.appNumber})', _category, ); } - return context.l10n.shareMessageDescriptionEmpty(_category); - }, [category, app]); + return l10n.shareMessageDescriptionEmpty(_category); + }, [category, app, l10n]); final child = useMemoized(() { if (category == _Category.text) return _Text(data as String); @@ -219,27 +234,29 @@ class _SendPage extends HookConsumerWidget { final result = await showConversationSelector( context: context, singleSelect: true, - title: context.l10n.forward, + title: l10n.forward, onlyContact: false, ); _conversationId = result?.firstOrNull?.conversationId; encryptCategory = result?.firstOrNull?.encryptCategory; } if (encryptCategory == null && _conversationId != null) { - final conversation = await context.database.conversationDao + final conversation = await database.conversationDao .conversationItem(_conversationId) .getSingleOrNull(); final ownerId = conversation?.ownerId; final isBotConversation = conversation?.isBotConversation; if (ownerId == null || isBotConversation == null) return; - encryptCategory = await context.database.conversationDao - .getEncryptCategory(ownerId, isBotConversation); + encryptCategory = await database.conversationDao.getEncryptCategory( + ownerId, + isBotConversation, + ); } if (_conversationId == null || encryptCategory == null) return; await _sendMessage( - context, + ref.container, _conversationId, encryptCategory, category, @@ -248,60 +265,58 @@ class _SendPage extends HookConsumerWidget { Navigator.pop(context); } - return SizedBox( - width: 480, - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - MixinAppBar( - title: Text(title), - actions: const [MixinCloseButton()], - leading: const SizedBox(), - backgroundColor: context.theme.popUp, - ), - const SizedBox(height: 12), - Container( - width: 340, - height: 340, - decoration: BoxDecoration( - color: context.dynamicColor( - const Color.fromRGBO(245, 247, 250, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + return MessageStyleScope( + child: SizedBox( + width: 480, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + MixinAppBar( + title: Text(title), + actions: const [MixinCloseButton()], + leading: const SizedBox(), + backgroundColor: theme.popUp, + ), + const SizedBox(height: 12), + Container( + width: 340, + height: 340, + decoration: BoxDecoration( + color: dynamicSurface, + borderRadius: const BorderRadius.all(Radius.circular(8)), ), - borderRadius: const BorderRadius.all(Radius.circular(8)), + alignment: Alignment.center, + padding: category == _Category.app_card + ? null + : const EdgeInsets.all(34), + child: child, ), - alignment: Alignment.center, - padding: category == _Category.app_card - ? null - : const EdgeInsets.all(34), - child: child, - ), - const SizedBox(height: 54), - MixinButton( - onTap: sendMessage, - child: Text( - (conversationId != null) - ? context.l10n.send - : context.l10n.forward, + const SizedBox(height: 54), + MixinButton( + onTap: sendMessage, + child: Text( + (conversationId != null) ? l10n.send : l10n.forward, + ), ), - ), - const SizedBox(height: 56), - ], + const SizedBox(height: 56), + ], + ), ), ); } } Future _sendMessage( - BuildContext context, + ProviderContainer container, String conversationId, EncryptCategory encryptCategory, _Category category, dynamic data, { String? recipientId, }) async { + final accountServer = container.read(accountServerProvider).requireValue; if (category == _Category.text) { - return context.accountServer.sendTextMessage( + return accountServer.sendTextMessage( data as String, encryptCategory, conversationId: conversationId, @@ -309,7 +324,7 @@ Future _sendMessage( ); } if (category == _Category.post) { - return context.accountServer.sendPostMessage( + return accountServer.sendPostMessage( data as String, encryptCategory, conversationId: conversationId, @@ -317,7 +332,7 @@ Future _sendMessage( ); } if (category == _Category.sticker) { - return context.accountServer.sendStickerMessage( + return accountServer.sendStickerMessage( data as String, null, encryptCategory, @@ -326,7 +341,7 @@ Future _sendMessage( ); } if (category == _Category.contact) { - return context.accountServer.sendContactMessage( + return accountServer.sendContactMessage( data as String, null, encryptCategory, @@ -335,7 +350,7 @@ Future _sendMessage( ); } if (category == _Category.app_card) { - return context.accountServer.sendAppCardMessage( + return accountServer.sendAppCardMessage( data: data as AppCardData, conversationId: conversationId, recipientId: recipientId, @@ -344,7 +359,7 @@ Future _sendMessage( if (category == _Category.image) { final sendImageData = data as SendImageData; await runWithLoading( - () => context.accountServer.sendImageMessageByUrl( + () => accountServer.sendImageMessageByUrl( encryptCategory, sendImageData.url, sendImageData.url, @@ -362,7 +377,7 @@ final _bubbleClipper = BubbleClipper( nipPadding: false, ); -class _MessageBubble extends StatelessWidget { +class _MessageBubble extends ConsumerWidget { const _MessageBubble({ required this.child, this.padding = const EdgeInsets.all(8), @@ -374,7 +389,7 @@ class _MessageBubble extends StatelessWidget { final bool clip; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { Widget child = Padding(padding: padding, child: this.child); if (clip) { child = ClipPath( @@ -384,9 +399,11 @@ class _MessageBubble extends StatelessWidget { } return CustomPaint( painter: BubblePainter( - color: context.dynamicColor( - lightOtherBubble, - darkColor: darkOtherBubble, + color: ref.watch( + dynamicColorProvider(( + color: lightOtherBubble, + darkColor: darkOtherBubble, + )), ), clipper: _bubbleClipper, ), @@ -395,18 +412,18 @@ class _MessageBubble extends StatelessWidget { } } -class _Text extends StatelessWidget { +class _Text extends ConsumerWidget { const _Text(this.text); final String text; @override - Widget build(BuildContext context) => _MessageBubble( + Widget build(BuildContext context, WidgetRef ref) => _MessageBubble( child: Text( text, style: TextStyle( fontSize: MessageItemWidget.primaryFontSize, - color: context.theme.text, + color: ref.watch(brightnessThemeDataProvider).text, ), ), ); @@ -418,10 +435,13 @@ class _Image extends HookConsumerWidget { final SendImageData image; @override - Widget build(BuildContext context, WidgetRef ref) => MixinImage.network( - image.url, - placeholder: () => ColoredBox(color: context.theme.secondaryText), - ); + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return MixinImage.network( + image.url, + placeholder: () => ColoredBox(color: theme.secondaryText), + ); + } } class _Sticker extends HookConsumerWidget { @@ -431,22 +451,22 @@ class _Sticker extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).requireValue; + final database = ref.read(databaseProvider).requireValue; final sticker = useMemoizedFuture( () async { - final sticker = await context.database.stickerDao + final sticker = await database.stickerDao .sticker(stickerId) .getSingleOrNull(); if (sticker != null) return sticker; - final s = await context.accountServer.client.accountApi.getStickerById( + final s = await accountServer.client.accountApi.getStickerById( stickerId, ); - await context.database.stickerDao.insert( - s.data.asStickersCompanion, - ); + await accountServer.upsertSticker(s.data.asStickersCompanion); - return context.database.stickerDao.sticker(stickerId).getSingle(); + return database.stickerDao.sticker(stickerId).getSingle(); }, null, keys: [stickerId], @@ -472,9 +492,10 @@ class _Contact extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).requireValue; final user = useMemoizedFuture( () async { - final list = await context.accountServer.refreshUsers([userId]); + final list = await accountServer.refreshUsers([userId]); return (list != null && list.isNotEmpty) ? list.first : null; }, null, @@ -513,13 +534,13 @@ class _Post extends StatelessWidget { ); } -class _AppCard extends StatelessWidget { +class _AppCard extends ConsumerWidget { const _AppCard(this.data); final AppCardData data; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { if (!data.isActionsCard) { return _MessageBubble( child: Padding( @@ -538,7 +559,7 @@ class _AppCard extends StatelessWidget { description: Text( data.description, style: TextStyle( - color: context.theme.text, + color: ref.watch(brightnessThemeDataProvider).text, fontSize: context.messageStyle.primaryFontSize, ), ), diff --git a/lib/widgets/message_status_icon.dart b/lib/widgets/message_status_icon.dart index 2e872856e7..01a4c3134e 100644 --- a/lib/widgets/message_status_icon.dart +++ b/lib/widgets/message_status_icon.dart @@ -9,10 +9,10 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:visibility_detector/visibility_detector.dart'; import '../constants/resources.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../utils/hook.dart'; -class MessageStatusIcon extends StatelessWidget { +class MessageStatusIcon extends ConsumerWidget { const MessageStatusIcon({required this.status, super.key, this.color}); final MessageStatus? status; @@ -20,8 +20,9 @@ class MessageStatusIcon extends StatelessWidget { final Color? color; @override - Widget build(BuildContext context) { - var color = this.color ?? context.theme.secondaryText; + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + var color = this.color ?? theme.secondaryText; String icon; switch (status) { case MessageStatus.sent: @@ -30,7 +31,7 @@ class MessageStatusIcon extends StatelessWidget { icon = Resources.assetsImagesDeliveredSvg; case MessageStatus.read: icon = Resources.assetsImagesReadSvg; - color = context.theme.accent; + color = theme.accent; case MessageStatus.sending: case MessageStatus.failed: case MessageStatus.unknown: diff --git a/lib/widgets/mixin_image.dart b/lib/widgets/mixin_image.dart index 130aa49de3..8bdd738ca8 100644 --- a/lib/widgets/mixin_image.dart +++ b/lib/widgets/mixin_image.dart @@ -8,11 +8,12 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:http_client_helper/http_client_helper.dart'; import 'package:path/path.dart'; import 'package:path_provider/path_provider.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/database_provider.dart'; import '../utils/hook.dart'; import '../utils/logger.dart'; import '../utils/proxy.dart'; @@ -168,7 +169,7 @@ Future downloadImage(String url) async => /// get md5 from key String keyToMd5(String key) => md5.convert(utf8.encode(key)).toString(); -class MixinImage extends HookWidget { +class MixinImage extends HookConsumerWidget { const MixinImage({ required this.image, super.key, @@ -245,8 +246,12 @@ class MixinImage extends HookWidget { final ValueNotifier? controller; @override - Widget build(BuildContext context) { - final proxyUrl = context.database.settingProperties.activatedProxy; + Widget build(BuildContext context, WidgetRef ref) { + final proxyUrl = ref + .watch(databaseProvider) + .requireValue + .settingProperties + .activatedProxy; final codecAsync = useMemoizedFuture( () async { diff --git a/lib/widgets/more_extended_text.dart b/lib/widgets/more_extended_text.dart index 60c7cbf9a9..8dd2f1e330 100644 --- a/lib/widgets/more_extended_text.dart +++ b/lib/widgets/more_extended_text.dart @@ -3,7 +3,8 @@ import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/conversation_provider.dart'; +import '../ui/provider/ui_context_providers.dart'; import 'high_light_text.dart'; class MoreExtendedText extends HookConsumerWidget { @@ -29,13 +30,18 @@ class _MoreExtendedText extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final expand = useState(false); + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final app = ref.watch(conversationProvider.select((value) => value?.app)); final style = useMemoized( () => this.style?.merge(const TextStyle(height: 1)), ); final overflowTextSpan = TextSpan( - text: '...${context.l10n.more}', - style: style?.merge(TextStyle(color: context.theme.accent)), + text: '...${l10n.more}', + style: style?.merge( + TextStyle(color: theme.accent), + ), recognizer: TapGestureRecognizer() ..onTap = () { expand.value = true; @@ -85,7 +91,15 @@ class _MoreExtendedText extends HookConsumerWidget { return CustomSelectableText.rich( TextSpan(children: [textSpan, if (endIndex != -1) overflowTextSpan]), - textMatchers: [UrlTextMatcher(context), EmojiTextMatcher()], + textMatchers: [ + UrlTextMatcher( + context, + container: ref.container, + accent: theme.accent, + app: app, + ), + EmojiTextMatcher(), + ], textAlign: TextAlign.center, ); } diff --git a/lib/widgets/payment/multisigs_payment_dialog.dart b/lib/widgets/payment/multisigs_payment_dialog.dart index bb177a6f47..85f0ea5f0b 100644 --- a/lib/widgets/payment/multisigs_payment_dialog.dart +++ b/lib/widgets/payment/multisigs_payment_dialog.dart @@ -5,6 +5,8 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../constants/resources.dart'; import '../../db/dao/asset_dao.dart'; import '../../db/database_event_bus.dart'; +import '../../ui/provider/account_server_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../avatar_view/avatar_view.dart'; @@ -64,13 +66,13 @@ extension _PaymentCodeResponseExt on MultisigsPaymentItem { bool get isDone => {'signed', 'unlocked', 'paid'}.contains(state); } -class _PaymentDialog extends StatelessWidget { +class _PaymentDialog extends ConsumerWidget { const _PaymentDialog({required this.item}); final MultisigsPaymentItem item; @override - Widget build(BuildContext context) => Column( + Widget build(BuildContext context, WidgetRef ref) => Column( mainAxisSize: MainAxisSize.min, children: [ SizedBox( @@ -102,6 +104,8 @@ class _MultisigsPaymentBody extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); final asset = item.asset; return Column( mainAxisSize: MainAxisSize.min, @@ -109,9 +113,12 @@ class _MultisigsPaymentBody extends HookConsumerWidget { Text( (item is Multi2MultiItem && (item as Multi2MultiItem).action == 'unlock') - ? context.l10n.revokeMultisigTransaction - : context.l10n.multisigTransaction, - style: TextStyle(fontSize: 18, color: context.theme.text), + ? l10n.revokeMultisigTransaction + : l10n.multisigTransaction, + style: TextStyle( + fontSize: 18, + color: theme.text, + ), ), const SizedBox(height: 24), _UsersLayout(senders: item.senders, receivers: item.receivers), @@ -125,7 +132,10 @@ class _MultisigsPaymentBody extends HookConsumerWidget { const SizedBox(height: 10), Text( '${item.amount.numberFormat()} ${asset.symbol}', - style: TextStyle(fontSize: 16, color: context.theme.text), + style: TextStyle( + fontSize: 16, + color: theme.text, + ), ), const SizedBox(height: 8), if (item.isDone) const _DoneLayout() else _QrCodeLayout(uri: item.uri), @@ -135,89 +145,105 @@ class _MultisigsPaymentBody extends HookConsumerWidget { } } -class _UsersLayout extends StatelessWidget { +class _UsersLayout extends ConsumerWidget { const _UsersLayout({required this.senders, required this.receivers}); final List senders; final List receivers; @override - Widget build(BuildContext context) => Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - _OverlappedUserAvatars( - children: [ - if (senders.length <= 3) - for (final sender in senders) _UserIcon(userId: sender), - if (senders.length > 3) - for (final sender in senders.take(2)) _UserIcon(userId: sender), - if (senders.length > 3) _UserCountIcon(count: senders.length - 2), - ], - ), - SizedBox.square( - dimension: 24, - child: SvgPicture.asset( - Resources.assetsImagesIcArrowRightSvg, - colorFilter: ColorFilter.mode(context.theme.green, BlendMode.srcIn), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + _OverlappedUserAvatars( + children: [ + if (senders.length <= 3) + for (final sender in senders) _UserIcon(userId: sender), + if (senders.length > 3) + for (final sender in senders.take(2)) _UserIcon(userId: sender), + if (senders.length > 3) _UserCountIcon(count: senders.length - 2), + ], ), - ), - _OverlappedUserAvatars( - children: [ - if (receivers.length <= 3) - for (final receiver in receivers) _UserIcon(userId: receiver), - if (receivers.length > 3) - for (final receiver in receivers.take(2)) - _UserIcon(userId: receiver), - if (receivers.length > 3) _UserCountIcon(count: receivers.length - 2), - ], - ), - ], - ); + SizedBox.square( + dimension: 24, + child: SvgPicture.asset( + Resources.assetsImagesIcArrowRightSvg, + colorFilter: ColorFilter.mode( + theme.green, + BlendMode.srcIn, + ), + ), + ), + _OverlappedUserAvatars( + children: [ + if (receivers.length <= 3) + for (final receiver in receivers) _UserIcon(userId: receiver), + if (receivers.length > 3) + for (final receiver in receivers.take(2)) + _UserIcon(userId: receiver), + if (receivers.length > 3) + _UserCountIcon(count: receivers.length - 2), + ], + ), + ], + ); + } } -class _UserCountIcon extends StatelessWidget { +class _UserCountIcon extends ConsumerWidget { const _UserCountIcon({required this.count}); final int count; @override - Widget build(BuildContext context) => Container( - decoration: BoxDecoration( - color: context.theme.listSelected, - shape: BoxShape.circle, - ), - width: 24, - height: 24, - child: Center( - child: Text( - '+$count', - style: TextStyle(fontSize: 12, color: context.theme.secondaryText), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Container( + decoration: BoxDecoration( + color: theme.listSelected, + shape: BoxShape.circle, ), - ), - ); + width: 24, + height: 24, + child: Center( + child: Text( + '+$count', + style: TextStyle( + fontSize: 12, + color: theme.secondaryText, + ), + ), + ), + ); + } } -class _OverlappedUserAvatars extends StatelessWidget { +class _OverlappedUserAvatars extends ConsumerWidget { const _OverlappedUserAvatars({required this.children}); final List children; @override - Widget build(BuildContext context) => Stack( - children: [ - for (var index = 0; index < children.length; index++) - Padding( - padding: EdgeInsets.fromLTRB(index.toDouble() * 20, 0, 0, 0), - child: ClipOval( - child: Container( - color: context.theme.popUp, - padding: const EdgeInsets.all(2), - child: children[index], + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Stack( + children: [ + for (var index = 0; index < children.length; index++) + Padding( + padding: EdgeInsets.fromLTRB(index.toDouble() * 20, 0, 0, 0), + child: ClipOval( + child: Container( + color: theme.popUp, + padding: const EdgeInsets.all(2), + child: children[index], + ), ), ), - ), - ].reversed.toList(), - ); + ].reversed.toList(), + ); + } } class _UserIcon extends HookConsumerWidget { @@ -227,8 +253,10 @@ class _UserIcon extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final accountServer = ref.read(accountServerProvider).requireValue; final user = useMemoizedStream( - () => context.accountServer.database.userDao + () => accountServer.database.userDao .userById(userId) .watchSingleOrNullWithStream( eventStreams: [ @@ -236,6 +264,7 @@ class _UserIcon extends HookConsumerWidget { ], duration: kDefaultThrottleDuration, ), + keys: [accountServer, userId], ).data; final Widget child; @@ -246,8 +275,8 @@ class _UserIcon extends HookConsumerWidget { height: 24, decoration: BoxDecoration( color: Color.alphaBlend( - context.theme.listSelected, - context.theme.popUp, + theme.listSelected, + theme.popUp, ), shape: BoxShape.circle, ), @@ -282,39 +311,46 @@ class _QrCodeLayout extends StatelessWidget { ); } -class _DoneLayout extends StatelessWidget { +class _DoneLayout extends ConsumerWidget { const _DoneLayout(); @override - Widget build(BuildContext context) => Column( - children: [ - const SizedBox(height: 40), - Container( - width: 80, - height: 80, - decoration: BoxDecoration( - color: context.theme.green.withValues(alpha: 0.2), - shape: BoxShape.circle, - ), - child: Center( - child: SizedBox.square( - dimension: 60, - child: SvgPicture.asset( - Resources.assetsImagesCheckedSvg, - colorFilter: ColorFilter.mode( - context.theme.green, - BlendMode.srcIn, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); + return Column( + children: [ + const SizedBox(height: 40), + Container( + width: 80, + height: 80, + decoration: BoxDecoration( + color: theme.green.withValues(alpha: 0.2), + shape: BoxShape.circle, + ), + child: Center( + child: SizedBox.square( + dimension: 60, + child: SvgPicture.asset( + Resources.assetsImagesCheckedSvg, + colorFilter: ColorFilter.mode( + theme.green, + BlendMode.srcIn, + ), ), ), ), ), - ), - const SizedBox(height: 10), - Text( - context.l10n.done, - style: TextStyle(fontSize: 14, color: context.theme.secondaryText), - ), - const SizedBox(height: 40), - ], - ); + const SizedBox(height: 10), + Text( + l10n.done, + style: TextStyle( + fontSize: 14, + color: theme.secondaryText, + ), + ), + const SizedBox(height: 40), + ], + ); + } } diff --git a/lib/widgets/pin_bubble.dart b/lib/widgets/pin_bubble.dart index 5692a5fa0d..93b2c578e1 100644 --- a/lib/widgets/pin_bubble.dart +++ b/lib/widgets/pin_bubble.dart @@ -1,11 +1,12 @@ import 'package:flutter/cupertino.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/ui_context_providers.dart'; import 'message/message_bubble.dart'; const _nipWidth = 7.0; -class PinMessageBubble extends StatelessWidget { +class PinMessageBubble extends ConsumerWidget { const PinMessageBubble({ required this.child, super.key, @@ -16,7 +17,8 @@ class PinMessageBubble extends StatelessWidget { final EdgeInsetsGeometry padding; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); const clipper = _PinBubbleClipper(); return CustomPaint( painter: BubblePainter( @@ -27,7 +29,7 @@ class PinMessageBubble extends StatelessWidget { padding: padding.add(const EdgeInsets.only(right: _nipWidth)), child: SizedBox.expand( child: DefaultTextStyle.merge( - style: TextStyle(color: context.theme.text), + style: TextStyle(color: theme.text), child: child, ), ), diff --git a/lib/widgets/protocol_handler.dart b/lib/widgets/protocol_handler.dart index 28ec657e13..e3f84dbd31 100644 --- a/lib/widgets/protocol_handler.dart +++ b/lib/widgets/protocol_handler.dart @@ -37,7 +37,7 @@ class AppProtocolHandler extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { useEffect(() { if (_initialUrl != null) { - openUri(context, _initialUrl!); + openUri(context, _initialUrl!, container: ref.container); } }, [_initialUrl]); if (Platform.isLinux) { @@ -62,7 +62,7 @@ class _LinuxAppProtocolHandler extends HookConsumerWidget { windowManager.show(); bringWindowToFront(); if (url != null) { - openUri(context, url); + openUri(context, url, container: ref.container); } }, ); @@ -118,7 +118,7 @@ class _ProtocolHandler extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { useProtocol((url) { windowManager.show(); - openUri(context, url); + openUri(context, url, container: ref.container); }); return child; } diff --git a/lib/widgets/qr_code.dart b/lib/widgets/qr_code.dart index 89fc90c2d1..a7321ef6d1 100644 --- a/lib/widgets/qr_code.dart +++ b/lib/widgets/qr_code.dart @@ -1,7 +1,8 @@ import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:pretty_qr_code/pretty_qr_code.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/ui_context_providers.dart'; import 'dialog.dart'; class QrCode extends StatelessWidget { @@ -33,32 +34,35 @@ Future showQrCodeDialog(BuildContext context, String data) async => child: _QrcodeDialog(data: data), ); -class _QrcodeDialog extends StatelessWidget { +class _QrcodeDialog extends ConsumerWidget { const _QrcodeDialog({required this.data}); final String data; @override - Widget build(BuildContext context) => Material( - color: Colors.transparent, - child: Container( - constraints: const BoxConstraints(minWidth: 320, minHeight: 210), - padding: const EdgeInsets.symmetric(horizontal: 20), - child: IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 36), - QrCode(data: data, dimension: 240), - const SizedBox(height: 20), - MixinButton( - onTap: () => Navigator.pop(context, true), - child: Text(context.l10n.confirm), - ), - const SizedBox(height: 20), - ], + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + return Material( + color: Colors.transparent, + child: Container( + constraints: const BoxConstraints(minWidth: 320, minHeight: 210), + padding: const EdgeInsets.symmetric(horizontal: 20), + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 36), + QrCode(data: data, dimension: 240), + const SizedBox(height: 20), + MixinButton( + onTap: () => Navigator.pop(context, true), + child: Text(l10n.confirm), + ), + const SizedBox(height: 20), + ], + ), ), ), - ), - ); + ); + } } diff --git a/lib/widgets/radio.dart b/lib/widgets/radio.dart index 9c02201cdf..055ec6b855 100644 --- a/lib/widgets/radio.dart +++ b/lib/widgets/radio.dart @@ -1,10 +1,11 @@ import 'package:flutter/widgets.dart'; import 'package:flutter_svg/svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../constants/resources.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/ui_context_providers.dart'; -class RadioItem extends StatelessWidget { +class RadioItem extends ConsumerWidget { const RadioItem({ required this.title, required this.value, @@ -19,35 +20,39 @@ class RadioItem extends StatelessWidget { final ValueChanged onChanged; @override - Widget build(BuildContext context) => GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: () => onChanged(value), - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - children: [ - ClipOval( - child: Container( - color: groupValue == value - ? context.theme.accent - : context.theme.secondaryText, - height: 16, - width: 16, - alignment: const Alignment(0, -0.2), - child: SvgPicture.asset( - Resources.assetsImagesSelectedSvg, - height: 10, - width: 10, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: () => onChanged(value), + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 10), + child: Row( + children: [ + ClipOval( + child: Container( + color: groupValue == value ? theme.accent : theme.secondaryText, + height: 16, + width: 16, + alignment: const Alignment(0, -0.2), + child: SvgPicture.asset( + Resources.assetsImagesSelectedSvg, + height: 10, + width: 10, + ), ), ), - ), - const SizedBox(width: 30), - DefaultTextStyle.merge( - style: TextStyle(color: context.theme.text, fontSize: 16), - child: title, - ), - ], + const SizedBox(width: 30), + DefaultTextStyle.merge( + style: TextStyle( + color: theme.text, + fontSize: 16, + ), + child: title, + ), + ], + ), ), - ), - ); + ); + } } diff --git a/lib/widgets/search_bar.dart b/lib/widgets/search_bar.dart index c162d0d5bb..46b8718f72 100644 --- a/lib/widgets/search_bar.dart +++ b/lib/widgets/search_bar.dart @@ -8,9 +8,11 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../constants/constants.dart'; import '../constants/resources.dart'; -import '../ui/home/home.dart'; +import '../ui/provider/account_server_provider.dart'; import '../ui/provider/conversation_unseen_filter_enabled.dart'; import '../ui/provider/keyword_provider.dart'; +import '../ui/provider/multi_auth_provider.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../utils/extension/extension.dart'; import '../utils/hook.dart'; import 'action_button.dart'; @@ -30,17 +32,30 @@ enum _ActionType { } class SearchBar extends HookConsumerWidget { - const SearchBar({super.key}); + const SearchBar({ + required this.textEditingController, + required this.focusNode, + required this.hasDrawer, + super.key, + }); + + final TextEditingController textEditingController; + final FocusNode focusNode; + final bool hasDrawer; @override Widget build(BuildContext context, WidgetRef ref) { - final hasDrawer = context.watch(); - + final theme = ref.watch(brightnessThemeDataProvider); + final l10n = ref.watch(localizationProvider); Widget? leading; - if (hasDrawer.value) { + if (hasDrawer) { leading = ActionButton( onTapUp: (event) => Scaffold.of(context).openDrawer(), - child: Icon(Icons.menu, size: 20, color: context.theme.icon), + child: Icon( + Icons.menu, + size: 20, + color: theme.icon, + ), ); } @@ -67,23 +82,21 @@ class SearchBar extends HookConsumerWidget { actions: { EscapeIntent: CallbackAction( onInvoke: (intent) { - ref.read(keywordProvider.notifier).state = ''; - context.read().text = ''; - context.read().unfocus(); + ref.read(keywordProvider.notifier).clear(); + textEditingController.text = ''; + focusNode.unfocus(); }, ), }, - child: Builder( - builder: (context) => SearchTextField( - focusNode: context.read(), - controller: context.read(), - onChanged: (keyword) => - ref.read(keywordProvider.notifier).state = keyword, - hintText: filterUnseen - ? context.l10n.searchUnread - // ignore: avoid-non-ascii-symbols - : '${context.l10n.search} (${Platform.isMacOS || Platform.isIOS ? '⌘' : 'Ctrl '}K)', - ), + child: SearchTextField( + focusNode: focusNode, + controller: textEditingController, + onChanged: (keyword) => + ref.read(keywordProvider.notifier).set(keyword), + hintText: filterUnseen + ? l10n.searchUnread + // ignore: avoid-non-ascii-symbols + : '${l10n.search} (${Platform.isMacOS || Platform.isIOS ? '⌘' : 'Ctrl '}K)', ), ), ), @@ -96,29 +109,29 @@ class SearchBar extends HookConsumerWidget { conversationUnseenFilterEnabledProvider.notifier, ) .toggle(), - color: filterUnseen ? context.theme.accent : context.theme.icon, + color: filterUnseen ? theme.accent : theme.icon, ), const SizedBox(width: 4), CustomPopupMenuButton( itemBuilder: (context) => [ CustomPopupMenuItem( icon: Resources.assetsImagesContextMenuSearchUserSvg, - title: context.l10n.searchContact, + title: l10n.searchContact, value: _ActionType.searchContact, ), CustomPopupMenuItem( icon: Resources.assetsImagesContextMenuCreateConversationSvg, - title: context.l10n.createConversation, + title: l10n.createConversation, value: _ActionType.createConversation, ), CustomPopupMenuItem( icon: Resources.assetsImagesContextMenuCreateGroupSvg, - title: context.l10n.createGroup, + title: l10n.createGroup, value: _ActionType.createGroup, ), CustomPopupMenuItem( icon: Resources.assetsImagesCircleSvg, - title: context.l10n.createCircle, + title: l10n.createCircle, value: _ActionType.createCircle, ), ], @@ -162,7 +175,11 @@ class _SearchUserDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final currentIdentityNumber = context.account?.identityNumber; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final currentIdentityNumber = ref.watch( + authAccountProvider.select((value) => value?.identityNumber), + ); final textEditingController = useTextEditingController(); final textEditingValueStream = useValueNotifierConvertSteam( @@ -184,13 +201,14 @@ class _SearchUserDialog extends HookConsumerWidget { loading.value = true; try { - final mixinResponse = await context.accountServer.client.userApi.search( + final accountServer = ref.read(accountServerProvider).requireValue; + final mixinResponse = await accountServer.client.userApi.search( textEditingController.text, ); - await context.database.userDao.insertSdkUser(mixinResponse.data); + await accountServer.upsertSdkUser(mixinResponse.data); resultUserId.value = mixinResponse.data.userId; } catch (e) { - showToastFailed(ToastError(context.l10n.userNotFound)); + showToastFailed(ToastError(l10n.userNotFound)); } loading.value = false; @@ -212,7 +230,7 @@ class _SearchUserDialog extends HookConsumerWidget { maintainAnimation: true, maintainState: true, child: AlertDialogLayout( - title: Text(context.l10n.addContact), + title: Text(l10n.addContact), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, @@ -230,7 +248,7 @@ class _SearchUserDialog extends HookConsumerWidget { }, child: DialogTextField( textEditingController: textEditingController, - hintText: context.l10n.addPeopleSearchHint, + hintText: l10n.addPeopleSearchHint, inputFormatters: [ FilteringTextInputFormatter.allow(RegExp('[0-9+]')), LengthLimitingTextInputFormatter( @@ -243,9 +261,9 @@ class _SearchUserDialog extends HookConsumerWidget { Padding( padding: const EdgeInsets.only(top: 8), child: Text( - context.l10n.myMixinId(currentIdentityNumber!), + l10n.myMixinId(currentIdentityNumber!), style: TextStyle( - color: context.theme.secondaryText, + color: theme.secondaryText, fontSize: 12, ), ), @@ -256,12 +274,12 @@ class _SearchUserDialog extends HookConsumerWidget { MixinButton( backgroundTransparent: true, onTap: () => Navigator.pop(context), - child: Text(context.l10n.cancel), + child: Text(l10n.cancel), ), MixinButton( disable: !searchable, onTap: search, - child: Text(context.l10n.search), + child: Text(l10n.search), ), ], ), @@ -274,7 +292,7 @@ class _SearchUserDialog extends HookConsumerWidget { bottom: 0, child: Align( child: CircularProgressIndicator( - color: context.theme.accent, + color: theme.accent, ), ), ), diff --git a/lib/widgets/search_text_field.dart b/lib/widgets/search_text_field.dart index 9816f13a4c..e24de21fbb 100644 --- a/lib/widgets/search_text_field.dart +++ b/lib/widgets/search_text_field.dart @@ -6,6 +6,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../constants/constants.dart'; import '../constants/resources.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../utils/extension/extension.dart'; import '../utils/hook.dart'; import 'high_light_text.dart'; @@ -41,11 +42,16 @@ class SearchTextField extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final _focusNode = useMemoized(() => focusNode ?? FocusNode()); - final backgroundColor = context.dynamicColor( - const Color.fromRGBO(245, 247, 250, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + final backgroundColor = ref.watch( + dynamicColorProvider( + ( + color: const Color.fromRGBO(245, 247, 250, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.08), + ), + ), ); - final hintColor = context.theme.secondaryText; + final theme = ref.watch(brightnessThemeDataProvider); + final hintColor = theme.secondaryText; useEffect(() { void notifyChanged() { @@ -98,7 +104,7 @@ class SearchTextField extends HookConsumerWidget { autofocus: autofocus, controller: controller, style: TextStyle( - color: context.theme.text, + color: theme.text, fontSize: fontSize, ), scrollPadding: EdgeInsets.zero, @@ -162,12 +168,19 @@ class _SearchClearIcon extends HookConsumerWidget { final VoidCallback onTap; @override - Widget build(BuildContext context, WidgetRef ref) => InteractiveDecoratedBox( - cursor: SystemMouseCursors.basic, - onTap: onTap, - child: Padding( - padding: const EdgeInsets.only(right: 16, left: 8), - child: Icon(Icons.close, color: context.theme.secondaryText, size: 16), - ), - ); + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return InteractiveDecoratedBox( + cursor: SystemMouseCursors.basic, + onTap: onTap, + child: Padding( + padding: const EdgeInsets.only(right: 16, left: 8), + child: Icon( + Icons.close, + color: theme.secondaryText, + size: 16, + ), + ), + ); + } } diff --git a/lib/widgets/select_item.dart b/lib/widgets/select_item.dart index 7eea531e3e..3abcea9b99 100644 --- a/lib/widgets/select_item.dart +++ b/lib/widgets/select_item.dart @@ -3,6 +3,7 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_portal/flutter_portal.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../utils/extension/extension.dart'; import 'interactive_decorated_box.dart'; import 'unread_text.dart'; @@ -31,6 +32,15 @@ class SelectItem extends HookConsumerWidget { Widget build(BuildContext context, WidgetRef ref) { final showed = useState(false); final showedTooltip = useState(false); + final theme = ref.watch(brightnessThemeDataProvider); + final dynamicColor = ref.watch( + dynamicColorProvider( + ( + color: const Color.fromRGBO(51, 51, 51, 0.16), + darkColor: const Color.fromRGBO(255, 255, 255, 0.4), + ), + ), + ); const boxDecoration = BoxDecoration( borderRadius: BorderRadius.all(Radius.circular(8)), @@ -40,10 +50,12 @@ class SelectItem extends HookConsumerWidget { onExit: (_) => showed.value = false, onTap: onTap, decoration: selected - ? boxDecoration.copyWith(color: context.theme.sidebarSelected) + ? boxDecoration.copyWith( + color: theme.sidebarSelected, + ) : boxDecoration, - hoveringColor: context.theme.sidebarSelected.withValues( - alpha: context.theme.sidebarSelected.a / 2, + hoveringColor: theme.sidebarSelected.withValues( + alpha: theme.sidebarSelected.a / 2, ), child: LayoutBuilder( builder: (context, boxConstraints) { @@ -51,17 +63,16 @@ class SelectItem extends HookConsumerWidget { final hideUnreadText = boxConstraints.maxWidth < 100; final titleWidget = DefaultTextStyle.merge( overflow: TextOverflow.ellipsis, - style: TextStyle(color: context.theme.text, fontSize: 14), + style: TextStyle( + color: theme.text, + fontSize: 14, + ), child: title, ); - final dynamicColor = context.dynamicColor( - const Color.fromRGBO(51, 51, 51, 0.16), - darkColor: const Color.fromRGBO(255, 255, 255, 0.4), - ); final unreadTextWidget = UnreadText( data: '$count', backgroundColor: dynamicColor, - textColor: context.theme.text, + textColor: theme.text, ); return PortalTarget( visible: @@ -85,7 +96,7 @@ class SelectItem extends HookConsumerWidget { vertical: 14, ), decoration: boxDecoration.copyWith( - color: context.theme.background, + color: theme.background, ), child: Row( mainAxisSize: MainAxisSize.min, @@ -121,7 +132,7 @@ class SelectItem extends HookConsumerWidget { color: count > 0 && hideUnreadText ? count == mutedCount ? dynamicColor - : context.theme.red + : theme.red : Colors.transparent, ), duration: const Duration(milliseconds: 100), diff --git a/lib/widgets/status.dart b/lib/widgets/status.dart index 1dcb988b29..f562d9bdbb 100644 --- a/lib/widgets/status.dart +++ b/lib/widgets/status.dart @@ -3,7 +3,8 @@ import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../constants/resources.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/account_server_provider.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../utils/hook.dart'; import 'message/message.dart'; @@ -15,9 +16,14 @@ class StatusPending extends HookConsumerWidget { final messageId = useMessageConverter( converter: (state) => state.messageId, ); + final attachmentUtil = ref.watch( + accountServerProvider.select( + (value) => value.requireValue.attachmentUtil, + ), + ); final value = useListenableConverter( - context.accountServer.attachmentUtil, + attachmentUtil, converter: (attachmentUtil) => attachmentUtil.getAttachmentProgress(messageId), keys: [messageId], @@ -27,120 +33,158 @@ class StatusPending extends HookConsumerWidget { } } -class _StatusPending extends StatelessWidget { +class _StatusPending extends ConsumerWidget { const _StatusPending({required this.value}); final double value; @override - Widget build(BuildContext context) => _StatusLayout( - child: Stack( - fit: StackFit.expand, - children: [ - Center( - child: SizedBox.fromSize( - size: const Size.square(10), - child: DecoratedBox( - decoration: BoxDecoration(color: context.theme.accent), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return _StatusLayout( + child: Stack( + fit: StackFit.expand, + children: [ + Center( + child: SizedBox.fromSize( + size: const Size.square(10), + child: DecoratedBox( + decoration: BoxDecoration( + color: theme.accent, + ), + ), ), ), - ), - TweenAnimationBuilder( - tween: Tween(end: value), - duration: const Duration(milliseconds: 100), - builder: (context, value, _) => CircularProgressIndicator( - value: value, - valueColor: AlwaysStoppedAnimation(context.theme.accent), + TweenAnimationBuilder( + tween: Tween(end: value), + duration: const Duration(milliseconds: 100), + builder: (context, value, _) => CircularProgressIndicator( + value: value, + valueColor: AlwaysStoppedAnimation(theme.accent), + ), ), - ), - ], - ), - ); + ], + ), + ); + } } -class StatusWarning extends StatelessWidget { +class StatusWarning extends ConsumerWidget { const StatusWarning({super.key}); @override - Widget build(BuildContext context) => _StatusLayout( - child: Center( - child: SvgPicture.asset( - Resources.assetsImagesWarningSvg, - colorFilter: ColorFilter.mode(context.theme.text, BlendMode.srcIn), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return _StatusLayout( + child: Center( + child: SvgPicture.asset( + Resources.assetsImagesWarningSvg, + colorFilter: ColorFilter.mode( + theme.text, + BlendMode.srcIn, + ), + ), ), - ), - ); + ); + } } -class StatusDownload extends StatelessWidget { +class StatusDownload extends ConsumerWidget { const StatusDownload({super.key}); @override - Widget build(BuildContext context) => _StatusLayout( - child: Center( - child: SvgPicture.asset( - Resources.assetsImagesDownloadSvg, - colorFilter: ColorFilter.mode(context.theme.accent, BlendMode.srcIn), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return _StatusLayout( + child: Center( + child: SvgPicture.asset( + Resources.assetsImagesDownloadSvg, + colorFilter: ColorFilter.mode( + theme.accent, + BlendMode.srcIn, + ), + ), ), - ), - ); + ); + } } -class StatusUpload extends StatelessWidget { +class StatusUpload extends ConsumerWidget { const StatusUpload({super.key}); @override - Widget build(BuildContext context) => _StatusLayout( - child: Center( - child: SvgPicture.asset( - Resources.assetsImagesUploadSvg, - colorFilter: ColorFilter.mode(context.theme.accent, BlendMode.srcIn), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return _StatusLayout( + child: Center( + child: SvgPicture.asset( + Resources.assetsImagesUploadSvg, + colorFilter: ColorFilter.mode( + theme.accent, + BlendMode.srcIn, + ), + ), ), - ), - ); + ); + } } -class StatusAudioPlay extends StatelessWidget { +class StatusAudioPlay extends ConsumerWidget { const StatusAudioPlay({super.key}); @override - Widget build(BuildContext context) => _StatusLayout( - child: Center( - child: SvgPicture.asset( - Resources.assetsImagesAudioPlaySvg, - colorFilter: ColorFilter.mode(context.theme.accent, BlendMode.srcIn), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return _StatusLayout( + child: Center( + child: SvgPicture.asset( + Resources.assetsImagesAudioPlaySvg, + colorFilter: ColorFilter.mode( + theme.accent, + BlendMode.srcIn, + ), + ), ), - ), - ); + ); + } } -class StatusAudioStop extends StatelessWidget { +class StatusAudioStop extends ConsumerWidget { const StatusAudioStop({super.key}); @override - Widget build(BuildContext context) => _StatusLayout( - child: Center( - child: SvgPicture.asset( - Resources.assetsImagesAudioStopSvg, - colorFilter: ColorFilter.mode(context.theme.accent, BlendMode.srcIn), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return _StatusLayout( + child: Center( + child: SvgPicture.asset( + Resources.assetsImagesAudioStopSvg, + colorFilter: ColorFilter.mode( + theme.accent, + BlendMode.srcIn, + ), + ), ), - ), - ); + ); + } } -class _StatusLayout extends StatelessWidget { +class _StatusLayout extends ConsumerWidget { const _StatusLayout({required this.child}); final Widget child; @override - Widget build(BuildContext context) => Container( - height: 38, - width: 38, - decoration: BoxDecoration( - color: context.theme.statusBackground, - shape: BoxShape.circle, - ), - child: child, - ); + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return Container( + height: 38, + width: 38, + decoration: BoxDecoration( + color: theme.statusBackground, + shape: BoxShape.circle, + ), + child: child, + ); + } } diff --git a/lib/widgets/sticker_page/add_sticker_dialog.dart b/lib/widgets/sticker_page/add_sticker_dialog.dart index bba4675d09..8d87764c52 100644 --- a/lib/widgets/sticker_page/add_sticker_dialog.dart +++ b/lib/widgets/sticker_page/add_sticker_dialog.dart @@ -5,11 +5,15 @@ import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:mixin_logger/mixin_logger.dart'; import '../../db/dao/sticker_dao.dart'; import '../../db/mixin_database.dart'; +import '../../ui/provider/account_server_provider.dart'; +import '../../ui/provider/database_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../app_bar.dart'; import '../buttons.dart'; @@ -30,103 +34,103 @@ Future showAddStickerDialog( ); } -class _AddStickerDialog extends StatelessWidget { +class _AddStickerDialog extends ConsumerWidget { const _AddStickerDialog({required this.filepath}); final String filepath; @override - Widget build(BuildContext context) => Scaffold( - appBar: MixinAppBar( - backgroundColor: Colors.transparent, - title: Text(context.l10n.addSticker), - leading: const SizedBox(), - actions: [ - MixinCloseButton( - onTap: () => Navigator.maybeOf(context, rootNavigator: true)?.pop(), - ), - ], - ), - body: ConstrainedBox( - constraints: const BoxConstraints(minWidth: double.infinity), - child: Column( - children: [ - const Spacer(), - SizedBox.square( - dimension: 400, - child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(8)), - border: DashPathBorder.all( - borderSide: BorderSide( - color: context.theme.divider, - width: 2, + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + return Scaffold( + appBar: MixinAppBar( + backgroundColor: Colors.transparent, + title: Text(l10n.addSticker), + leading: const SizedBox(), + actions: [ + MixinCloseButton( + onTap: () => Navigator.maybeOf(context, rootNavigator: true)?.pop(), + ), + ], + ), + body: ConstrainedBox( + constraints: const BoxConstraints(minWidth: double.infinity), + child: Column( + children: [ + const Spacer(), + SizedBox.square( + dimension: 400, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(8)), + border: DashPathBorder.all( + borderSide: BorderSide( + color: brightnessTheme.divider, + width: 2, + ), + dashArray: CircularIntervalList([8, 2]), ), - dashArray: CircularIntervalList([8, 2]), ), - ), - child: Padding( - padding: const EdgeInsets.all(30), - child: Image.file(File(filepath), fit: BoxFit.scaleDown), + child: Padding( + padding: const EdgeInsets.all(30), + child: Image.file(File(filepath), fit: BoxFit.scaleDown), + ), ), ), - ), - const Spacer(), - Padding( - padding: const EdgeInsets.symmetric(vertical: 30), - child: MixinButton( - child: Text(context.l10n.save), - onTap: () async { - final accountServer = context.accountServer; - final database = context.database; - - try { - showToastLoading(); - final bytes = await _imageToSticker(context, filepath); - if (bytes == null) { - return; - } - - final response = await accountServer.client.accountApi - .addSticker( - StickerRequest(dataBase64: bytes.base64Encode()), - ); - final sticker = response.data; - - final personalAlbum = await database.stickerAlbumDao - .personalAlbum() - .getSingleOrNull(); - if (personalAlbum == null) { - unawaited( - context.accountServer.refreshSticker(force: true), - ); - } else { - await database.mixinDatabase.transaction(() async { - await database.stickerDao.insert( + const Spacer(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 30), + child: MixinButton( + child: Text(l10n.save), + onTap: () async { + final accountServer = ref + .read(accountServerProvider) + .requireValue; + final database = ref.read(databaseProvider).requireValue; + + try { + showToastLoading(); + final bytes = await _imageToSticker(l10n, filepath); + if (bytes == null) { + return; + } + + final response = await accountServer.client.accountApi + .addSticker( + StickerRequest(dataBase64: bytes.base64Encode()), + ); + final sticker = response.data; + + final personalAlbum = await database.stickerAlbumDao + .personalAlbum() + .getSingleOrNull(); + if (personalAlbum == null) { + unawaited(accountServer.refreshSticker(force: true)); + } else { + await accountServer.insertStickerAndRelationship( sticker.asStickersCompanion, - ); - await database.stickerRelationshipDao.insert( StickerRelationship( albumId: personalAlbum.albumId, stickerId: sticker.stickerId, ), ); - }); + } + showToastSuccessful(); + Navigator.pop(context); + } catch (error, stacktrace) { + e('($filepath) error: $error\n$stacktrace'); + showToastFailed(error); + return; } - showToastSuccessful(); - Navigator.pop(context); - } catch (error, stacktrace) { - e('($filepath) error: $error\n$stacktrace'); - showToastFailed(error); - return; - } - }, + }, + ), ), - ), - ], + ], + ), ), - ), - ); + ); + } } const _kMinFileSize = 1024; @@ -136,18 +140,18 @@ const _kMinSize = 128; const _kMaxSize = 1024; Future _imageToSticker( - BuildContext context, + Localization l10n, String filepath, ) async { final file = File(filepath).xFile; if (!file.isStickerSupport) { - showToastFailed(context.l10n.invalidStickerFormat); + showToastFailed(l10n.invalidStickerFormat); return null; } final fileLength = await file.length(); if (fileLength < _kMinFileSize || fileLength > _kMaxFileSize) { - showToastFailed(context.l10n.stickerAddInvalidSize); + showToastFailed(l10n.stickerAddInvalidSize); return null; } @@ -158,7 +162,7 @@ Future _imageToSticker( if (math.min(descriptor.width, descriptor.height) < _kMinSize || math.max(descriptor.width, descriptor.height) > _kMaxSize) { - showToastFailed(context.l10n.stickerAddInvalidSize); + showToastFailed(l10n.stickerAddInvalidSize); return null; } return file.readAsBytes(); diff --git a/lib/widgets/sticker_page/bloc/cubit/sticker_albums_cubit.dart b/lib/widgets/sticker_page/bloc/cubit/sticker_albums_cubit.dart deleted file mode 100644 index 5c0bad6107..0000000000 --- a/lib/widgets/sticker_page/bloc/cubit/sticker_albums_cubit.dart +++ /dev/null @@ -1,6 +0,0 @@ -import '../../../../bloc/stream_cubit.dart'; -import '../../../../db/mixin_database.dart'; - -class StickerAlbumsCubit extends StreamCubit> { - StickerAlbumsCubit(Stream> stream) : super([], stream); -} diff --git a/lib/widgets/sticker_page/bloc/cubit/sticker_cubit.dart b/lib/widgets/sticker_page/bloc/cubit/sticker_cubit.dart deleted file mode 100644 index 3cd3004b7d..0000000000 --- a/lib/widgets/sticker_page/bloc/cubit/sticker_cubit.dart +++ /dev/null @@ -1,6 +0,0 @@ -import '../../../../bloc/stream_cubit.dart'; -import '../../../../db/mixin_database.dart'; - -class StickerCubit extends StreamCubit> { - StickerCubit(Stream> stream) : super([], stream); -} diff --git a/lib/widgets/sticker_page/emoji_page.dart b/lib/widgets/sticker_page/emoji_page.dart index d18e7c40e5..85bbc88d2c 100644 --- a/lib/widgets/sticker_page/emoji_page.dart +++ b/lib/widgets/sticker_page/emoji_page.dart @@ -8,11 +8,23 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../account/account_key_value.dart'; import '../../constants/resources.dart'; +import '../../ui/home/providers/home_scope_providers.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/emoji.dart'; import '../../utils/extension/extension.dart'; import '../interactive_decorated_box.dart'; -final _emojiScrollOffsetProvider = StateProvider((ref) => 0); +class _EmojiScrollOffsetNotifier extends Notifier { + @override + double build() => 0; + + void set(double value) => state = value; +} + +final _emojiScrollOffsetProvider = + NotifierProvider<_EmojiScrollOffsetNotifier, double>( + _EmojiScrollOffsetNotifier.new, + ); const emojiGroups = [ [EmojiGroup.smileysEmotion, EmojiGroup.peopleBody], @@ -26,22 +38,34 @@ const emojiGroups = [ ]; class EmojiPage extends StatelessWidget { - const EmojiPage({super.key}); + const EmojiPage({ + required this.textController, + super.key, + }); + + final TextEditingController? textController; @override Widget build(BuildContext context) => LayoutBuilder( - builder: (context, constraints) => - _EmojiPageBody(layoutWidth: constraints.maxWidth), + builder: (context, constraints) => _EmojiPageBody( + layoutWidth: constraints.maxWidth, + textController: textController, + ), ); } class _EmojiPageBody extends HookConsumerWidget { - const _EmojiPageBody({required this.layoutWidth}); + const _EmojiPageBody({ + required this.layoutWidth, + required this.textController, + }); final double layoutWidth; + final TextEditingController? textController; @override Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); const emojiGroupIcon = [ Resources.assetsImagesEmojiRecentSvg, Resources.assetsImagesEmojiFaceSvg, @@ -116,12 +140,13 @@ class _EmojiPageBody extends HookConsumerWidget { selectedIndex: selectedIndex, icons: emojiGroupIcon, onTap: (index) { - ref.read(_emojiScrollOffsetProvider.notifier).state = - groupOffset[index]; + ref + .read(_emojiScrollOffsetProvider.notifier) + .set(groupOffset[index]); emojiOffsetController.add(groupOffset[index]); }, ), - Divider(color: context.theme.divider, height: 1), + Divider(color: theme.divider, height: 1), const SizedBox(height: 8), Expanded( child: _AllEmojisPage( @@ -129,6 +154,7 @@ class _EmojiPageBody extends HookConsumerWidget { offsetStream: emojiOffsetStream, emojiLineStride: emojiLineStride.value, groupedEmojis: groupedEmojis, + textController: textController, ), ), ], @@ -183,7 +209,7 @@ class _EmojiGroupHeader extends HookConsumerWidget { } } -class _EmojiGroupIcon extends StatelessWidget { +class _EmojiGroupIcon extends ConsumerWidget { const _EmojiGroupIcon({ required this.index, required this.onTap, @@ -197,27 +223,28 @@ class _EmojiGroupIcon extends StatelessWidget { final int selectedIndex; @override - Widget build(BuildContext context) => InteractiveDecoratedBox( - onTap: onTap, - hoveringDecoration: BoxDecoration( - color: context.theme.sidebarSelected, - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Padding( - padding: const EdgeInsets.all(4), - child: SvgPicture.asset( - icon, - width: 24, - height: 24, - colorFilter: ColorFilter.mode( - selectedIndex == index - ? context.theme.accent - : context.theme.secondaryText, - BlendMode.srcIn, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return InteractiveDecoratedBox( + onTap: onTap, + hoveringDecoration: BoxDecoration( + color: theme.sidebarSelected, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: SvgPicture.asset( + icon, + width: 24, + height: 24, + colorFilter: ColorFilter.mode( + selectedIndex == index ? theme.accent : theme.secondaryText, + BlendMode.srcIn, + ), ), ), - ), - ); + ); + } } class _AllEmojisPage extends HookConsumerWidget { @@ -226,33 +253,36 @@ class _AllEmojisPage extends HookConsumerWidget { required this.offsetStream, required this.emojiLineStride, required this.groupedEmojis, + required this.textController, }); final double initialOffset; final Stream offsetStream; final int emojiLineStride; final List> groupedEmojis; + final TextEditingController? textController; @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); final controller = useMemoized( () => ScrollController(initialScrollOffset: initialOffset), ); final groupTitles = [ - context.l10n.smileysAndPeople, - context.l10n.animalsAndNature, - context.l10n.foodAndDrink, - context.l10n.travelAndPlaces, - context.l10n.activity, - context.l10n.objects, - context.l10n.symbols, - context.l10n.flags, + l10n.smileysAndPeople, + l10n.animalsAndNature, + l10n.foodAndDrink, + l10n.travelAndPlaces, + l10n.activity, + l10n.objects, + l10n.symbols, + l10n.flags, ]; useEffect(() { void onScroll() { - ref.read(_emojiScrollOffsetProvider.notifier).state = controller.offset; + ref.read(_emojiScrollOffsetProvider.notifier).set(controller.offset); } controller.addListener(onScroll); @@ -278,8 +308,10 @@ class _AllEmojisPage extends HookConsumerWidget { padding: const EdgeInsets.symmetric(horizontal: 14), sliver: SliverGrid( delegate: SliverChildBuilderDelegate( - (context, index) => - _EmojiItem(emoji: groupedEmojis[i][index]), + (context, index) => _EmojiItem( + emoji: groupedEmojis[i][index], + textController: textController, + ), childCount: groupedEmojis[i].length, ), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( @@ -293,72 +325,86 @@ class _AllEmojisPage extends HookConsumerWidget { } } -class _EmojiGroupTitle extends StatelessWidget { +class _EmojiGroupTitle extends ConsumerWidget { const _EmojiGroupTitle({required this.title}); final String title; @override - Widget build(BuildContext context) => SizedBox( - height: 40, - child: Padding( - padding: const EdgeInsets.only(left: 20, top: 12), - child: Text( - title, - style: TextStyle(fontSize: 14, color: context.theme.secondaryText), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return SizedBox( + height: 40, + child: Padding( + padding: const EdgeInsets.only(left: 20, top: 12), + child: Text( + title, + style: TextStyle( + fontSize: 14, + color: theme.secondaryText, + ), + ), ), - ), - ); + ); + } } class _EmojiItem extends StatelessWidget { - const _EmojiItem({required this.emoji}); + const _EmojiItem({ + required this.emoji, + required this.textController, + }); final String emoji; + final TextEditingController? textController; @override - Widget build(BuildContext context) => Padding( - padding: const EdgeInsets.all(2), - child: InteractiveDecoratedBox( - onTap: () { - final textController = context.read(); - final textEditingValue = textController.value; - final selection = textEditingValue.selection; - if (!selection.isValid) { - textController.text = '${textEditingValue.text}$emoji'; - } else { - final int lastSelectionIndex = math.max( - selection.baseOffset, - selection.extentOffset, - ); - final collapsedTextEditingValue = textEditingValue.copyWith( - selection: TextSelection.collapsed(offset: lastSelectionIndex), - ); - textController.value = collapsedTextEditingValue.replaced( - selection, - emoji, - ); - } - AccountKeyValue.instance.onEmojiUsed(emoji); - }, - hoveringDecoration: BoxDecoration( - color: context.dynamicColor( - const Color.fromRGBO(229, 231, 235, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + Widget build(BuildContext context) => Consumer( + builder: (context, ref, _) => Padding( + padding: const EdgeInsets.all(2), + child: InteractiveDecoratedBox( + onTap: () { + final controller = textController; + if (controller == null) return; + final textEditingValue = controller.value; + final selection = textEditingValue.selection; + if (!selection.isValid) { + controller.text = '${textEditingValue.text}$emoji'; + } else { + final int lastSelectionIndex = math.max( + selection.baseOffset, + selection.extentOffset, + ); + final collapsedTextEditingValue = textEditingValue.copyWith( + selection: TextSelection.collapsed(offset: lastSelectionIndex), + ); + controller.value = collapsedTextEditingValue.replaced( + selection, + emoji, + ); + } + AccountKeyValue.instance.onEmojiUsed(emoji); + }, + hoveringDecoration: BoxDecoration( + color: BrightnessData.dynamicColor( + context, + const Color.fromRGBO(229, 231, 235, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ), + borderRadius: const BorderRadius.all(Radius.circular(8)), ), - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - child: Center( - child: Text( - emoji, - style: TextStyle( - fontSize: 26, - height: 1, - fontFamily: kEmojiFontFamily, - inherit: false, + child: Center( + child: Text( + emoji, + style: TextStyle( + fontSize: 26, + height: 1, + fontFamily: kEmojiFontFamily, + inherit: false, + ), + strutStyle: const StrutStyle(height: 1), + textAlign: TextAlign.center, ), - strutStyle: const StrutStyle(height: 1), - textAlign: TextAlign.center, ), ), ), diff --git a/lib/widgets/sticker_page/giphy_page.dart b/lib/widgets/sticker_page/giphy_page.dart index babcfbcf65..1a33c8a6a9 100644 --- a/lib/widgets/sticker_page/giphy_page.dart +++ b/lib/widgets/sticker_page/giphy_page.dart @@ -7,8 +7,9 @@ import 'package:rxdart/rxdart.dart'; import '../../api/giphy_api.dart'; import '../../api/giphy_vo/giphy_gif.dart'; +import '../../ui/provider/account_server_provider.dart'; import '../../ui/provider/conversation_provider.dart'; -import '../../utils/extension/extension.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/hook.dart'; import '../../utils/logger.dart'; import '../interactive_decorated_box.dart'; @@ -20,6 +21,7 @@ class GiphyPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); final textEditingController = useTextEditingController(); final searchKeywordController = useStreamController(); @@ -44,7 +46,7 @@ class GiphyPage extends HookConsumerWidget { return Column( children: [ _SearchBar(controller: textEditingController), - Divider(color: context.theme.divider, height: 1), + Divider(color: brightnessTheme.divider, height: 1), const SizedBox(height: 12), Expanded(child: _GiphyGifsLoader(query: keyword ?? '')), ], @@ -52,22 +54,22 @@ class GiphyPage extends HookConsumerWidget { } } -class _SearchBar extends StatelessWidget { +class _SearchBar extends ConsumerWidget { const _SearchBar({required this.controller}); final TextEditingController controller; @override - Widget build(BuildContext context) => SizedBox( - height: 48, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), - child: SearchTextField( - controller: controller, - hintText: context.l10n.search, + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + return SizedBox( + height: 48, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 8), + child: SearchTextField(controller: controller, hintText: l10n.search), ), - ), - ); + ); + } } class _GiphyGifsLoader extends HookConsumerWidget { @@ -164,12 +166,13 @@ class _GifItem extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); final previewImage = gif.images.fixedWidthDownsampled; final sendImage = gif.images.fixedWidth; return InteractiveDecoratedBox( onTap: () async { - final accountServer = context.accountServer; + final accountServer = ref.read(accountServerProvider).requireValue; final conversationItem = ref.read(conversationProvider); if (conversationItem == null) return; @@ -185,7 +188,7 @@ class _GifItem extends HookConsumerWidget { }, child: MixinImage.network( previewImage.url, - placeholder: () => ColoredBox(color: context.theme.secondaryText), + placeholder: () => ColoredBox(color: brightnessTheme.secondaryText), ), ); } diff --git a/lib/widgets/sticker_page/sticker_album_page.dart b/lib/widgets/sticker_page/sticker_album_page.dart index 17025e154a..9bab9dd500 100644 --- a/lib/widgets/sticker_page/sticker_album_page.dart +++ b/lib/widgets/sticker_page/sticker_album_page.dart @@ -3,6 +3,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; +import '../../ui/provider/account_server_provider.dart'; +import '../../ui/provider/database_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../app_bar.dart'; @@ -25,9 +28,10 @@ class StickerAlbumPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final database = ref.read(databaseProvider).requireValue; final album = useMemoizedStream( - () => context.database.stickerAlbumDao + () => database.stickerAlbumDao .album(albumId) .watchSingleWithStream( eventStreams: [ @@ -61,26 +65,29 @@ class StickerAlbumPage extends HookConsumerWidget { } } -class _StickerAlbumHeader extends StatelessWidget { +class _StickerAlbumHeader extends ConsumerWidget { const _StickerAlbumHeader({this.album}); final StickerAlbum? album; @override - Widget build(BuildContext context) => MixinAppBar( - backgroundColor: Colors.transparent, - title: Text( - album == null ? context.l10n.stickerAlbumDetail : (album?.name ?? ''), - ), - leading: navigatorKey.currentState?.canPop() == true - ? null - : const SizedBox(), - actions: [ - MixinCloseButton( - onTap: () => Navigator.maybeOf(context, rootNavigator: true)?.pop(), + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + return MixinAppBar( + backgroundColor: Colors.transparent, + title: Text( + album == null ? l10n.stickerAlbumDetail : (album?.name ?? ''), ), - ], - ); + leading: navigatorKey.currentState?.canPop() == true + ? null + : const SizedBox(), + actions: [ + MixinCloseButton( + onTap: () => Navigator.maybeOf(context, rootNavigator: true)?.pop(), + ), + ], + ); + } } class _StickerAlbumDetail extends HookConsumerWidget { @@ -91,13 +98,15 @@ class _StickerAlbumDetail extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final database = ref.read(databaseProvider).requireValue; + final accountServer = ref.read(accountServerProvider).requireValue; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final stickers = useMemoizedFuture( () async { if (this.stickers != null) return this.stickers; - return context.database.stickerDao - .stickerByAlbumId(album.albumId) - .get(); + return database.stickerDao.stickerByAlbumId(album.albumId).get(); }, [], keys: [album.albumId], @@ -123,9 +132,9 @@ class _StickerAlbumDetail extends HookConsumerWidget { begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ - context.theme.popUp.withValues(alpha: 0), - context.theme.popUp.withValues(alpha: 0.36), - context.theme.popUp.withValues(alpha: 1), + theme.popUp.withValues(alpha: 0), + theme.popUp.withValues(alpha: 0.36), + theme.popUp.withValues(alpha: 1), ], ), ), @@ -134,14 +143,14 @@ class _StickerAlbumDetail extends HookConsumerWidget { const Spacer(), MixinButton( backgroundColor: album.added == true - ? context.theme.red - : context.theme.accent, + ? theme.red + : theme.accent, child: Text( album.added == true - ? context.l10n.removeStickers - : context.l10n.addStickers, + ? l10n.removeStickers + : l10n.addStickers, ), - onTap: () => context.database.stickerAlbumDao.updateAdded( + onTap: () => accountServer.updateStickerAlbumAdded( album.albumId, !(album.added == true), ), diff --git a/lib/widgets/sticker_page/sticker_item.dart b/lib/widgets/sticker_page/sticker_item.dart index 41ffc0ad8b..c1cf96ec59 100644 --- a/lib/widgets/sticker_page/sticker_item.dart +++ b/lib/widgets/sticker_page/sticker_item.dart @@ -7,20 +7,23 @@ import 'package:lottie/lottie.dart'; import '../../app.dart'; import '../../db/extension/job.dart'; +import '../../ui/provider/account_server_provider.dart'; +import '../../ui/provider/database_provider.dart'; import '../../utils/app_lifecycle.dart'; -import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../cache_lottie.dart'; import '../mixin_image.dart'; -void _triggerRefreshJob(BuildContext context, String? stickerId) { +void _triggerRefreshJob(WidgetRef ref, String? stickerId) { if (stickerId == null || stickerId.isEmpty) return; scheduleMicrotask(() { - if (!context.mounted) return; - context.accountServer.addUpdateStickerJob( - createUpdateStickerJob(stickerId), - ); + ref + .read(accountServerProvider) + .requireValue + .addUpdateStickerJob( + createUpdateStickerJob(stickerId), + ); }); } @@ -45,6 +48,7 @@ class StickerItem extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isJson = useMemoized(() => assetType == 'json', [assetType]); + final database = ref.read(databaseProvider).requireValue; final playing = useState(true); final controller = useAnimationController(); @@ -86,7 +90,7 @@ class StickerItem extends HookConsumerWidget { ? LottieBuilder( lottie: CachedNetworkLottie( assetUrl, - proxyConfig: context.database.settingProperties.activatedProxy, + proxyConfig: database.settingProperties.activatedProxy, ), controller: controller, height: height, @@ -97,7 +101,7 @@ class StickerItem extends HookConsumerWidget { listener(); }, errorBuilder: (context, error, stackTrace) { - _triggerRefreshJob(context, stickerId); + _triggerRefreshJob(ref, stickerId); return errorWidget ?? const SizedBox(); }, ) @@ -107,7 +111,7 @@ class StickerItem extends HookConsumerWidget { width: width, fit: BoxFit.contain, errorBuilder: (context, error, stackTrace) { - _triggerRefreshJob(context, stickerId); + _triggerRefreshJob(ref, stickerId); return errorWidget ?? const SizedBox(); }, ); diff --git a/lib/widgets/sticker_page/sticker_page.dart b/lib/widgets/sticker_page/sticker_page.dart index 2a8e3b59de..fc4c925114 100644 --- a/lib/widgets/sticker_page/sticker_page.dart +++ b/lib/widgets/sticker_page/sticker_page.dart @@ -1,5 +1,4 @@ import 'package:flutter/material.dart'; -import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_svg/svg.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -8,22 +7,22 @@ import 'package:mixin_logger/mixin_logger.dart'; import 'package:super_context_menu/super_context_menu.dart'; import '../../account/account_key_value.dart'; -import '../../bloc/bloc_converter.dart'; import '../../constants/icon_fonts.dart'; import '../../constants/resources.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; +import '../../ui/home/providers/home_scope_providers.dart'; +import '../../ui/provider/account_server_provider.dart'; import '../../ui/provider/conversation_provider.dart'; +import '../../ui/provider/database_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../automatic_keep_alive_client_widget.dart'; import '../hover_overlay.dart'; import '../interactive_decorated_box.dart'; import '../menu.dart'; import '../toast.dart'; import 'add_sticker_dialog.dart'; -import 'bloc/cubit/sticker_albums_cubit.dart'; -import 'bloc/cubit/sticker_cubit.dart'; import 'emoji_page.dart'; import 'giphy_page.dart'; import 'sticker_item.dart'; @@ -31,261 +30,317 @@ import 'sticker_store.dart'; enum PresetStickerGroup { store, emoji, recent, favorite, gif } -class StickerPage extends StatelessWidget { +enum _StickerCollectionKind { album, recent, favorite } + +class _StickerCollection { + const _StickerCollection._(this.kind, {this.albumId}); + + const _StickerCollection.album(this.albumId) + : kind = _StickerCollectionKind.album; + + const _StickerCollection.recent() : this._(_StickerCollectionKind.recent); + + const _StickerCollection.favorite() : this._(_StickerCollectionKind.favorite); + + final _StickerCollectionKind kind; + final String? albumId; + + @override + bool operator ==(Object other) => + identical(this, other) || + other is _StickerCollection && + other.kind == kind && + other.albumId == albumId; + + @override + int get hashCode => Object.hash(kind, albumId); +} + +final _stickerItemsProvider = StreamProvider.autoDispose + .family, _StickerCollection>((ref, collection) { + final database = ref.watch(databaseProvider).value; + if (database == null) { + return Stream.value(const []); + } + + switch (collection.kind) { + case _StickerCollectionKind.recent: + return database.stickerDao.recentUsedStickers().watchWithStream( + eventStreams: [DataBaseEventBus.instance.updateStickerStream], + duration: kVerySlowThrottleDuration, + ); + case _StickerCollectionKind.favorite: + return database.stickerDao.personalStickers().watchWithStream( + eventStreams: [DataBaseEventBus.instance.updateStickerStream], + duration: kVerySlowThrottleDuration, + ); + case _StickerCollectionKind.album: + final albumId = collection.albumId; + if (albumId == null) { + return Stream.value(const []); + } + return database.stickerDao + .stickerByAlbumId(albumId) + .watchWithStream( + eventStreams: [ + DataBaseEventBus.instance.watchUpdateStickerStream( + albumIds: [albumId], + ), + ], + duration: kVerySlowThrottleDuration, + ); + } + }); + +class StickerPage extends ConsumerWidget { const StickerPage({ required this.tabLength, required this.tabController, required this.presetStickerGroups, + required this.textController, super.key, }); final TabController tabController; final int tabLength; final List presetStickerGroups; + final TextEditingController? textController; @override - Widget build(BuildContext context) => Material( - color: Colors.transparent, - elevation: 5, - borderRadius: const BorderRadius.all(Radius.circular(11)), - child: Container( - width: 464, - height: 407, - decoration: BoxDecoration( - borderRadius: const BorderRadius.all(Radius.circular(11)), - color: context.dynamicColor( - const Color.fromRGBO(255, 255, 255, 1), - darkColor: const Color.fromRGBO(62, 65, 72, 1), + Widget build(BuildContext context, WidgetRef ref) { + final stickerAlbums = + ref.watch(stickerAlbumsProvider).value ?? const []; + final accountServer = ref.read(accountServerProvider).requireValue; + + return Material( + color: Colors.transparent, + elevation: 5, + borderRadius: const BorderRadius.all(Radius.circular(11)), + child: Container( + width: 464, + height: 407, + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Radius.circular(11)), + color: BrightnessData.dynamicColor( + context, + const Color.fromRGBO(255, 255, 255, 1), + darkColor: const Color.fromRGBO(62, 65, 72, 1), + ), ), - ), - child: ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(11)), - child: Column( - children: [ - Expanded( - child: TabBarView( - controller: tabController, - physics: const NeverScrollableScrollPhysics(), - children: List.generate(tabLength, (index) { - if (index < presetStickerGroups.length) { - final preset = presetStickerGroups[index]; - switch (preset) { - case PresetStickerGroup.store: - return _StickerStoreEmptyPage(); - case PresetStickerGroup.emoji: - return const AutomaticKeepAliveClientWidget( - child: EmojiPage(), - ); - case PresetStickerGroup.recent: - return _StickerAlbumPage( - getStickers: () => context.database.stickerDao - .recentUsedStickers() - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.updateStickerStream, - ], - duration: kVerySlowThrottleDuration, - ), - updateUsedAt: false, - ); - case PresetStickerGroup.favorite: - return _StickerAlbumPage( - getStickers: () => context.database.stickerDao - .personalStickers() - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance.updateStickerStream, - ], - duration: kVerySlowThrottleDuration, - ), - delete: (sticker) { - final ctx = Navigator.of(context).context; - showToastLoading(context: ctx); - try { - ctx.accountServer.client.accountApi.removeSticker( - [sticker.stickerId], - ); - ctx.database.stickerDao.deletePersonalSticker( - sticker.stickerId, - ); - showToastSuccessful(context: ctx); - } catch (error, stacktrace) { - e('removeSticker error: $error, $stacktrace'); - showToastFailed(error, context: ctx); - } - }, - canAddSticker: true, - ); - case PresetStickerGroup.gif: - return const AutomaticKeepAliveClientWidget( - child: GiphyPage(), - ); - } - } - return _StickerAlbumPage( - getStickers: () { - final albumId = BlocProvider.of( - context, - ).state[index - presetStickerGroups.length].albumId; - return context.database.stickerDao - .stickerByAlbumId(albumId) - .watchWithStream( - eventStreams: [ - DataBaseEventBus.instance - .watchUpdateStickerStream( - albumIds: [albumId], - ), - ], - duration: kVerySlowThrottleDuration, + child: ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(11)), + child: Column( + children: [ + Expanded( + child: TabBarView( + controller: tabController, + physics: const NeverScrollableScrollPhysics(), + children: List.generate(tabLength, (index) { + if (index < presetStickerGroups.length) { + final preset = presetStickerGroups[index]; + switch (preset) { + case PresetStickerGroup.store: + return _StickerStoreEmptyPage(); + case PresetStickerGroup.emoji: + return AutomaticKeepAliveClientWidget( + child: EmojiPage(textController: textController), ); - }, - ); - }), + case PresetStickerGroup.recent: + return const _StickerAlbumPage( + collection: _StickerCollection.recent(), + updateUsedAt: false, + ); + case PresetStickerGroup.favorite: + return _StickerAlbumPage( + collection: const _StickerCollection.favorite(), + delete: (sticker) { + final ctx = Navigator.of(context).context; + showToastLoading(context: ctx); + try { + accountServer.client.accountApi.removeSticker([ + sticker.stickerId, + ]); + accountServer.deletePersonalSticker( + sticker.stickerId, + ); + showToastSuccessful(context: ctx); + } catch (error, stacktrace) { + e('removeSticker error: $error, $stacktrace'); + showToastFailed(error, context: ctx); + } + }, + canAddSticker: true, + ); + case PresetStickerGroup.gif: + return const AutomaticKeepAliveClientWidget( + child: GiphyPage(), + ); + } + } + final album = + stickerAlbums[index - presetStickerGroups.length]; + return _StickerAlbumPage( + collection: _StickerCollection.album(album.albumId), + ); + }), + ), ), - ), - _StickerAlbumBar( - tabLength: tabLength, - tabController: tabController, - presetStickerGroups: presetStickerGroups, - ), - ], + _StickerAlbumBar( + tabLength: tabLength, + tabController: tabController, + presetStickerGroups: presetStickerGroups, + ), + ], + ), ), ), - ), - ); + ); + } } class _StickerAlbumPage extends HookConsumerWidget { const _StickerAlbumPage({ - required this.getStickers, + required this.collection, this.updateUsedAt = true, this.delete, this.canAddSticker = false, }); - final Stream> Function() getStickers; - + final _StickerCollection collection; final bool updateUsedAt; final void Function(Sticker)? delete; final bool canAddSticker; @override Widget build(BuildContext context, WidgetRef ref) { - final stickerCubit = useBloc(() => StickerCubit(getStickers())); - - final itemCount = useBlocStateConverter, int>( - bloc: stickerCubit, - converter: (state) => state.length, - ); + final stickers = + ref.watch(_stickerItemsProvider(collection)).value ?? const []; final controller = useMemoized(ScrollController.new); - return BlocProvider.value( - value: stickerCubit, - child: GridView.builder( - controller: controller, - padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20), - gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( - crossAxisCount: 4, - mainAxisSpacing: 8, - crossAxisSpacing: 8, - ), - itemCount: canAddSticker ? itemCount + 1 : itemCount, - itemBuilder: (context, index) { - if (canAddSticker && index == 0) { - return const _AddStickerWidget(); - } - return _StickerAlbumPageItem( - index: canAddSticker ? index - 1 : index, - updateUsedAt: updateUsedAt, - delete: delete, - ); - }, + return GridView.builder( + controller: controller, + padding: const EdgeInsets.symmetric(vertical: 8, horizontal: 20), + gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( + crossAxisCount: 4, + mainAxisSpacing: 8, + crossAxisSpacing: 8, ), + itemCount: canAddSticker ? stickers.length + 1 : stickers.length, + itemBuilder: (context, index) { + if (canAddSticker && index == 0) { + return const _AddStickerWidget(); + } + return _StickerAlbumPageItem( + sticker: stickers[canAddSticker ? index - 1 : index], + updateUsedAt: updateUsedAt, + delete: delete, + ); + }, ); } } -class _AddStickerWidget extends StatelessWidget { +class _AddStickerWidget extends ConsumerWidget { const _AddStickerWidget(); @override - Widget build(BuildContext context) => InteractiveDecoratedBox( - hoveringDecoration: BoxDecoration( - color: context.dynamicColor( - const Color.fromRGBO(229, 231, 235, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + final hoverColor = ref.watch( + dynamicColorProvider( + ( + color: const Color.fromRGBO(229, 231, 235, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ), ), - borderRadius: const BorderRadius.all(Radius.circular(8)), - ), - onTap: () async { - try { - final ctx = Navigator.of(context).context; - final image = await ImagePicker().pickImage( - source: ImageSource.gallery, - ); - if (image == null) { - return; + ); + return InteractiveDecoratedBox( + hoveringDecoration: BoxDecoration( + color: hoverColor, + borderRadius: const BorderRadius.all(Radius.circular(8)), + ), + onTap: () async { + try { + final ctx = Navigator.of(context).context; + final image = await ImagePicker().pickImage( + source: ImageSource.gallery, + ); + if (image == null) { + return; + } + await showAddStickerDialog(ctx, filepath: image.path); + } catch (error, stacktrace) { + e('pickFiles error: $error, $stacktrace'); + showToastFailed(error); } - await showAddStickerDialog(ctx, filepath: image.path); - } catch (error, stacktrace) { - e('pickFiles error: $error, $stacktrace'); - showToastFailed(error); - } - }, - child: Center( - child: SvgPicture.asset( - Resources.assetsImagesAddStickerSvg, - width: 78, - height: 78, - colorFilter: ColorFilter.mode( - context.theme.secondaryText, - BlendMode.srcIn, + }, + child: Center( + child: SvgPicture.asset( + Resources.assetsImagesAddStickerSvg, + width: 78, + height: 78, + colorFilter: ColorFilter.mode( + theme.secondaryText, + BlendMode.srcIn, + ), ), ), - ), - ); + ); + } } -class _StickerStoreEmptyPage extends StatelessWidget { +class _StickerStoreEmptyPage extends ConsumerWidget { @override - Widget build(BuildContext context) => Center( - child: Text( - context.l10n.stickerStore, - style: TextStyle(color: context.theme.secondaryText, fontSize: 18), - ), - ); + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return Center( + child: Text( + l10n.stickerStore, + style: TextStyle( + color: theme.secondaryText, + fontSize: 18, + ), + ), + ); + } } class _StickerAlbumPageItem extends HookConsumerWidget { const _StickerAlbumPageItem({ - required this.index, + required this.sticker, required this.updateUsedAt, this.delete, }); - final int index; + final Sticker sticker; final bool updateUsedAt; final void Function(Sticker)? delete; @override Widget build(BuildContext context, WidgetRef ref) { - final sticker = useBlocStateConverter, Sticker>( - converter: (state) => state[index], - keys: [index], + final accountServer = ref.read(accountServerProvider).requireValue; + final database = ref.read(databaseProvider).requireValue; + final l10n = ref.watch(localizationProvider); + final hoverColor = ref.watch( + dynamicColorProvider( + ( + color: const Color.fromRGBO(229, 231, 235, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ), + ), ); - Widget widget = InteractiveDecoratedBox( onTap: () async { - final accountServer = context.accountServer; final conversationItem = ref.read(conversationProvider); if (conversationItem == null) return; - final albumId = await accountServer.database.stickerRelationshipDao + final albumId = await database.stickerRelationshipDao .stickerSystemAlbumId(sticker.stickerId) .getSingleOrNull(); await Future.wait([ if (updateUsedAt) - accountServer.database.stickerDao.updateUsedAt( + accountServer.updateStickerUsedAt( sticker.albumId, sticker.stickerId, DateTime.now(), @@ -300,10 +355,7 @@ class _StickerAlbumPageItem extends HookConsumerWidget { ]); }, hoveringDecoration: BoxDecoration( - color: context.dynamicColor( - const Color.fromRGBO(229, 231, 235, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.06), - ), + color: hoverColor, borderRadius: const BorderRadius.all(Radius.circular(8)), ), child: Padding( @@ -324,7 +376,7 @@ class _StickerAlbumPageItem extends HookConsumerWidget { menuProvider: (request) => Menu( children: [ MenuAction( - title: context.l10n.delete, + title: l10n.delete, image: MenuImage.icon(IconFonts.delete), callback: () => delete?.call(sticker), ), @@ -352,6 +404,22 @@ class _StickerAlbumBar extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final validIndexRef = useRef(tabController.index); + final barColor = ref.watch( + dynamicColorProvider( + ( + color: const Color.fromRGBO(0, 0, 0, 0.05), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ), + ), + ); + final indicatorColor = ref.watch( + dynamicColorProvider( + ( + color: const Color.fromRGBO(229, 231, 235, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ), + ), + ); final setPreviousIndex = useCallback(() { final previousIndex = tabController.previousIndex; @@ -388,19 +456,13 @@ class _StickerAlbumBar extends HookConsumerWidget { return Container( width: double.infinity, height: 50, - color: context.dynamicColor( - const Color.fromRGBO(0, 0, 0, 0.05), - darkColor: const Color.fromRGBO(255, 255, 255, 0.06), - ), + color: barColor, child: TabBar( controller: tabController, isScrollable: true, tabAlignment: TabAlignment.start, indicator: BoxDecoration( - color: context.dynamicColor( - const Color.fromRGBO(229, 231, 235, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.06), - ), + color: indicatorColor, borderRadius: const BorderRadius.all(Radius.circular(8)), ), labelPadding: EdgeInsets.zero, @@ -418,7 +480,7 @@ class _StickerAlbumBar extends HookConsumerWidget { } } -class _StickerAlbumBarItem extends StatelessWidget { +class _StickerAlbumBarItem extends ConsumerWidget { const _StickerAlbumBarItem({ required this.index, required this.presetStickerGroups, @@ -428,59 +490,71 @@ class _StickerAlbumBarItem extends StatelessWidget { final List presetStickerGroups; @override - Widget build(BuildContext context) => SizedBox.fromSize( - size: const Size.square(48), - child: Padding( - padding: const EdgeInsets.all(4), - child: _StickerGroupIconHoverContainer( - child: Center( + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return SizedBox.fromSize( + size: const Size.square(48), + child: Padding( + padding: const EdgeInsets.all(4), + child: _StickerGroupIconHoverContainer( child: Center( - child: Builder( - builder: (context) { - final presetStickerAlbum = { - PresetStickerGroup.store: AccountKeyValue.instance.hasNewAlbum - ? Resources.assetsImagesStickerStoreRedDotSvg - : Resources.assetsImagesStickerStoreSvg, - PresetStickerGroup.emoji: - Resources.assetsImagesEmojiStickerSvg, - PresetStickerGroup.recent: - Resources.assetsImagesRecentStickerSvg, - PresetStickerGroup.favorite: - Resources.assetsImagesPersonalStickerSvg, - PresetStickerGroup.gif: Resources.assetsImagesGifStickerSvg, - }; - - if (index < presetStickerGroups.length) { - return SvgPicture.asset( - presetStickerAlbum[presetStickerGroups[index]]!, - colorFilter: index != 0 - ? ColorFilter.mode( - context.theme.secondaryText, - BlendMode.srcIn, - ) - : null, - width: 24, - height: 24, + child: Center( + child: Builder( + builder: (context) { + final presetStickerAlbum = { + PresetStickerGroup.store: + AccountKeyValue.instance.hasNewAlbum + ? Resources.assetsImagesStickerStoreRedDotSvg + : Resources.assetsImagesStickerStoreSvg, + PresetStickerGroup.emoji: + Resources.assetsImagesEmojiStickerSvg, + PresetStickerGroup.recent: + Resources.assetsImagesRecentStickerSvg, + PresetStickerGroup.favorite: + Resources.assetsImagesPersonalStickerSvg, + PresetStickerGroup.gif: Resources.assetsImagesGifStickerSvg, + }; + + if (index < presetStickerGroups.length) { + return SvgPicture.asset( + presetStickerAlbum[presetStickerGroups[index]]!, + colorFilter: index != 0 + ? ColorFilter.mode( + theme.secondaryText, + BlendMode.srcIn, + ) + : null, + width: 24, + height: 24, + ); + } + + return _StickerAlbumIcon( + albumIndex: index - presetStickerGroups.length, ); - } - - return BlocConverter< - StickerAlbumsCubit, - List, - String - >( - converter: (state) => - state[index - presetStickerGroups.length].iconUrl, - builder: (context, iconUrl) => - StickerGroupIcon(iconUrl: iconUrl, size: 28), - ); - }, + }, + ), ), ), ), ), - ), - ); + ); + } +} + +class _StickerAlbumIcon extends HookConsumerWidget { + const _StickerAlbumIcon({required this.albumIndex}); + + final int albumIndex; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final stickerAlbums = + ref.watch(stickerAlbumsProvider).value ?? const []; + final iconUrl = stickerAlbums[albumIndex].iconUrl; + + return StickerGroupIcon(iconUrl: iconUrl, size: 28); + } } class _StickerGroupIconHoverContainer extends HookConsumerWidget { @@ -491,6 +565,14 @@ class _StickerGroupIconHoverContainer extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final isHovering = useState(false); + final hoverColor = ref.watch( + dynamicColorProvider( + ( + color: const Color.fromRGBO(229, 231, 235, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ), + ), + ); return MouseRegion( onEnter: (event) { isHovering.value = true; @@ -500,12 +582,7 @@ class _StickerGroupIconHoverContainer extends HookConsumerWidget { }, child: DecoratedBox( decoration: BoxDecoration( - color: isHovering.value - ? context.dynamicColor( - const Color.fromRGBO(229, 231, 235, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.06), - ) - : null, + color: isHovering.value ? hoverColor : null, borderRadius: const BorderRadius.all(Radius.circular(8)), ), child: child, diff --git a/lib/widgets/sticker_page/sticker_store.dart b/lib/widgets/sticker_page/sticker_store.dart index 0eedea2279..780756c45a 100644 --- a/lib/widgets/sticker_page/sticker_store.dart +++ b/lib/widgets/sticker_page/sticker_store.dart @@ -4,10 +4,15 @@ import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:rxdart/rxdart.dart'; +import '../../account/account_server.dart'; import '../../constants/resources.dart'; import '../../db/dao/sticker_album_dao.dart'; +import '../../db/database.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; +import '../../ui/provider/account_server_provider.dart'; +import '../../ui/provider/database_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../action_button.dart'; @@ -53,9 +58,11 @@ Future showStickerStorePageDialog(BuildContext context) async { Future showStickerPageDialog( BuildContext context, - String stickerId, -) async { - final a = await context.database.stickerRelationshipDao + String stickerId, { + required Database database, + required AccountServer accountServer, +}) async { + final a = await database.stickerRelationshipDao .stickerSystemAlbum(stickerId) .getSingleOrNull(); @@ -72,7 +79,7 @@ Future showStickerPageDialog( builder: (context) { final album = useMemoizedStream( - () => context.database.stickerRelationshipDao + () => database.stickerRelationshipDao .stickerSystemAlbum(stickerId) .watchSingleOrNullWithStream( eventStreams: [ @@ -89,8 +96,6 @@ Future showStickerPageDialog( Future effect() async { var albumId = album?.albumId; - final accountServer = context.accountServer; - final database = context.database; final client = accountServer.client; albumId ??= (await client.accountApi.getStickerById( @@ -102,7 +107,7 @@ Future showStickerPageDialog( final stickerAlbum = (await client.accountApi.getStickerAlbum( albumId, )).data; - await database.stickerAlbumDao.insert( + await accountServer.upsertStickerAlbum( stickerAlbum.asStickerAlbumsCompanion, ); @@ -130,23 +135,26 @@ class _StickerStorePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); useEffect(() { - context.accountServer.refreshSticker(force: true); + ref.read(accountServerProvider).requireValue.refreshSticker(force: true); + return null; }, []); return Column( children: [ MixinAppBar( backgroundColor: Colors.transparent, - title: Text(context.l10n.stickerStore), + title: Text(l10n.stickerStore), leading: Center( child: ActionButton( name: Resources.assetsImagesSettingSvg, - color: context.theme.icon, + color: theme.icon, onTap: () => Navigator.push( context, MaterialPageRoute( builder: (_) => ColoredBox( - color: context.theme.popUp, + color: theme.popUp, child: const _StickerAlbumManagePage(), ), ), @@ -171,6 +179,7 @@ class _List extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final database = ref.read(databaseProvider).requireValue; final albums = useMemoizedStream( () => @@ -179,11 +188,11 @@ class _List extends HookConsumerWidget { List, List<(StickerAlbum, List)> >( - context.database.stickerAlbumDao.systemAlbums().watchWithStream( + database.stickerAlbumDao.systemAlbums().watchWithStream( eventStreams: [DataBaseEventBus.instance.updateStickerStream], duration: kSlowThrottleDuration, ), - context.database.stickerDao.systemStickers().watchWithStream( + database.stickerDao.systemStickers().watchWithStream( eventStreams: [DataBaseEventBus.instance.updateStickerStream], duration: kSlowThrottleDuration, ), @@ -218,76 +227,79 @@ class _Item extends HookConsumerWidget { final List stickers; @override - Widget build(BuildContext context, WidgetRef ref) => SizedBox( - height: 104, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - album.name, - style: TextStyle( - fontSize: 16, - fontWeight: FontWeight.w600, - color: context.theme.text, + Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).requireValue; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + return SizedBox( + height: 104, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + album.name, + style: TextStyle( + fontSize: 16, + fontWeight: FontWeight.w600, + color: theme.text, + ), ), - ), - Expanded( - child: Row( - children: [ - for (final sticker in stickers.take(4)) - Padding( - padding: const EdgeInsets.only(right: 8), - child: InteractiveDecoratedBox( - onTap: () => Navigator.push( - context, - MaterialPageRoute( - builder: (_) => ColoredBox( - color: context.theme.popUp, - child: StickerAlbumPage( - album: album, - stickers: stickers, - albumId: album.albumId, + Expanded( + child: Row( + children: [ + for (final sticker in stickers.take(4)) + Padding( + padding: const EdgeInsets.only(right: 8), + child: InteractiveDecoratedBox( + onTap: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => ColoredBox( + color: theme.popUp, + child: StickerAlbumPage( + album: album, + stickers: stickers, + albumId: album.albumId, + ), ), ), ), - ), - child: SizedBox( - width: 72, - height: 72, - child: StickerItem( - stickerId: sticker.stickerId, - assetUrl: sticker.assetUrl, - assetType: sticker.assetType, + child: SizedBox( + width: 72, + height: 72, + child: StickerItem( + stickerId: sticker.stickerId, + assetUrl: sticker.assetUrl, + assetType: sticker.assetType, + ), ), ), ), - ), - const Spacer(), - AnimatedOpacity( - opacity: album.added == true ? 0.4 : 1, - duration: const Duration(milliseconds: 200), - child: MixinButton( - onTap: () => context.database.stickerAlbumDao.updateAdded( - album.albumId, - !(album.added == true), - ), - child: Text( - album.added == true - ? context.l10n.added - : context.l10n.add, - style: const TextStyle(fontSize: 14), + const Spacer(), + AnimatedOpacity( + opacity: album.added == true ? 0.4 : 1, + duration: const Duration(milliseconds: 200), + child: MixinButton( + onTap: () => accountServer.updateStickerAlbumAdded( + album.albumId, + !(album.added == true), + ), + child: Text( + album.added == true ? l10n.added : l10n.add, + style: const TextStyle(fontSize: 14), + ), ), ), - ), - ], + ], + ), ), - ), - ], + ], + ), ), - ), - ); + ); + } } class _StickerAlbumManagePage extends HookConsumerWidget { @@ -296,13 +308,16 @@ class _StickerAlbumManagePage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { final controller = useScrollController(); + final database = ref.read(databaseProvider).requireValue; + final accountServer = ref.read(accountServerProvider).requireValue; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); final albums = useMemoizedStream( - () => - context.database.stickerAlbumDao.systemAddedAlbums().watchWithStream( - eventStreams: [DataBaseEventBus.instance.updateStickerStream], - duration: kSlowThrottleDuration, - ), + () => database.stickerAlbumDao.systemAddedAlbums().watchWithStream( + eventStreams: [DataBaseEventBus.instance.updateStickerStream], + duration: kSlowThrottleDuration, + ), ).data; final list = useState(albums ?? []); useEffect(() { @@ -314,7 +329,7 @@ class _StickerAlbumManagePage extends HookConsumerWidget { children: [ MixinAppBar( backgroundColor: Colors.transparent, - title: Text(context.l10n.myStickers), + title: Text(l10n.myStickers), actions: [ MixinCloseButton( onTap: () => @@ -334,7 +349,7 @@ class _StickerAlbumManagePage extends HookConsumerWidget { newList.insert(_newIndex, oldItem); list.value = newList; - context.database.stickerAlbumDao.updateOrders(list.value); + accountServer.updateStickerAlbumOrders(list.value); }, itemBuilder: (context, index) { final album = list.value[index]; @@ -370,7 +385,7 @@ class _StickerAlbumManagePage extends HookConsumerWidget { child: Text( album.name, style: TextStyle( - color: context.theme.text, + color: theme.text, fontSize: 16, fontWeight: FontWeight.w600, ), @@ -378,12 +393,11 @@ class _StickerAlbumManagePage extends HookConsumerWidget { ), ActionButton( name: Resources.assetsImagesDeleteSvg, - color: context.theme.secondaryText, - onTap: () => - context.database.stickerAlbumDao.updateAdded( - album.albumId, - false, - ), + color: theme.secondaryText, + onTap: () => accountServer.updateStickerAlbumAdded( + album.albumId, + false, + ), ), ], ), @@ -405,10 +419,22 @@ class _StickerPage extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final database = ref.read(databaseProvider).requireValue; + final accountServer = ref.read(accountServerProvider).requireValue; + final l10n = ref.watch(localizationProvider); + final theme = ref.watch(brightnessThemeDataProvider); + final tabIndicatorColor = ref.watch( + dynamicColorProvider( + ( + color: const Color.fromRGBO(229, 231, 235, 1), + darkColor: const Color.fromRGBO(255, 255, 255, 0.06), + ), + ), + ); final sticker = useState(null); useEffect(() { Future effect() async { - final s = await context.database.stickerDao + final s = await database.stickerDao .sticker(stickerId) .getSingleOrNull(); sticker.value = s; @@ -420,7 +446,7 @@ class _StickerPage extends HookConsumerWidget { final album = useMemoizedStream(() { if (albumId == null) return Stream.value(null); - return context.database.stickerAlbumDao + return database.stickerAlbumDao .album(albumId!) .watchSingleWithStream( eventStreams: [ @@ -435,7 +461,7 @@ class _StickerPage extends HookConsumerWidget { final stickers = useMemoizedStream(() { if (album == null) return Stream.value([]); - return context.database.stickerDao + return database.stickerDao .stickerByAlbumId(album.albumId) .watchWithStream( eventStreams: [ @@ -486,7 +512,7 @@ class _StickerPage extends HookConsumerWidget { aspectRatio: 1, child: Container( margin: const EdgeInsets.all(56).copyWith(top: 0), - color: context.theme.background, + color: theme.background, alignment: Alignment.center, child: SizedBox( height: 256, @@ -514,7 +540,7 @@ class _StickerPage extends HookConsumerWidget { child: Text( album.name, style: TextStyle( - color: context.theme.text, + color: theme.text, fontSize: 16, fontWeight: FontWeight.w600, ), @@ -522,17 +548,16 @@ class _StickerPage extends HookConsumerWidget { ), MixinButton( backgroundColor: album.added == true - ? context.theme.red - : context.theme.accent, - onTap: () => - context.database.stickerAlbumDao.updateAdded( - album.albumId, - !(album.added == true), - ), + ? theme.red + : theme.accent, + onTap: () => accountServer.updateStickerAlbumAdded( + album.albumId, + !(album.added == true), + ), child: Text( album.added == true - ? context.l10n.removeStickers - : context.l10n.addStickers, + ? l10n.removeStickers + : l10n.addStickers, ), ), ], @@ -543,10 +568,7 @@ class _StickerPage extends HookConsumerWidget { isScrollable: true, overlayColor: WidgetStateProperty.all(Colors.transparent), indicator: BoxDecoration( - color: context.dynamicColor( - const Color.fromRGBO(229, 231, 235, 1), - darkColor: const Color.fromRGBO(255, 255, 255, 0.06), - ), + color: tabIndicatorColor, borderRadius: const BorderRadius.all(Radius.circular(8)), ), labelPadding: EdgeInsets.zero, diff --git a/lib/widgets/toast.dart b/lib/widgets/toast.dart index f74678ecea..30efe1d032 100644 --- a/lib/widgets/toast.dart +++ b/lib/widgets/toast.dart @@ -2,10 +2,12 @@ import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_svg/flutter_svg.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:overlay_support/overlay_support.dart'; import '../constants/resources.dart'; +import '../ui/provider/ui_context_providers.dart'; import '../utils/extension/extension.dart'; import '../utils/logger.dart'; @@ -91,10 +93,12 @@ class ToastWidget extends StatelessWidget { void showToastSuccessful({BuildContext? context}) => Toast.createView( context: context, - builder: (context) => ToastWidget( - barrierColor: Colors.transparent, - icon: const _Successful(), - text: context.l10n.successful, + builder: (context) => Consumer( + builder: (context, ref, _) => ToastWidget( + barrierColor: Colors.transparent, + icon: const _Successful(), + text: ref.watch(localizationProvider).successful, + ), ), ); @@ -107,13 +111,14 @@ class ToastError extends Error { ToastError._internal({this.message, this.messageBuilder}); static String errorToString(BuildContext context, Object? error) { + final l10n = Localization.current; if (error is ToastError) { if (error.message != null) { return error.message!; } else if (error.messageBuilder != null) { return error.messageBuilder!(context); } else { - return context.l10n.failed; + return l10n.failed; } } else if (error is MixinApiError) { return (error.error! as MixinError).toDisplayString(context); @@ -122,7 +127,7 @@ class ToastError extends Error { } else if (error is String) { return error; } else { - return error?.toString() ?? context.l10n.failed; + return error?.toString() ?? l10n.failed; } } @@ -148,10 +153,12 @@ void showToast(String message, {BuildContext? context}) => Toast.createView( void showToastLoading({BuildContext? context}) => Toast.createView( context: context, - builder: (context) => ToastWidget( - icon: const _Loading(), - text: context.l10n.loading, - ignoring: false, + builder: (context) => Consumer( + builder: (context, ref, _) => ToastWidget( + icon: const _Loading(), + text: ref.watch(localizationProvider).loading, + ignoring: false, + ), ), duration: null, ); diff --git a/lib/widgets/unknown_mixin_url_dialog.dart b/lib/widgets/unknown_mixin_url_dialog.dart index 8cf4824f3f..b96b588080 100644 --- a/lib/widgets/unknown_mixin_url_dialog.dart +++ b/lib/widgets/unknown_mixin_url_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/ui_context_providers.dart'; import 'dialog.dart'; import 'qr_code.dart'; @@ -10,40 +11,44 @@ Future showUnknownMixinUrlDialog(BuildContext context, Uri uri) async => child: _UnknownMixinUri(uri: uri), ); -class _UnknownMixinUri extends StatelessWidget { +class _UnknownMixinUri extends ConsumerWidget { const _UnknownMixinUri({required this.uri}); final Uri uri; @override - Widget build(BuildContext context) => Material( - color: Colors.transparent, - child: Container( - constraints: const BoxConstraints(minWidth: 320, minHeight: 210), - padding: const EdgeInsets.symmetric(horizontal: 20), - child: IntrinsicWidth( - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(height: 36), - Text( - context.l10n.chatNotSupportUriOnPhone, - style: TextStyle(fontSize: 16, color: context.theme.text), - ), - const SizedBox(height: 36), - ClipRRect( - borderRadius: const BorderRadius.all(Radius.circular(8)), - child: QrCode(dimension: 240, data: uri.toString()), - ), - const SizedBox(height: 36), - MixinButton( - onTap: () => Navigator.pop(context, true), - child: Text(context.l10n.confirm), - ), - const SizedBox(height: 36), - ], + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + return Material( + color: Colors.transparent, + child: Container( + constraints: const BoxConstraints(minWidth: 320, minHeight: 210), + padding: const EdgeInsets.symmetric(horizontal: 20), + child: IntrinsicWidth( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(height: 36), + Text( + l10n.chatNotSupportUriOnPhone, + style: TextStyle(fontSize: 16, color: brightnessTheme.text), + ), + const SizedBox(height: 36), + ClipRRect( + borderRadius: const BorderRadius.all(Radius.circular(8)), + child: QrCode(dimension: 240, data: uri.toString()), + ), + const SizedBox(height: 36), + MixinButton( + onTap: () => Navigator.pop(context, true), + child: Text(l10n.confirm), + ), + const SizedBox(height: 36), + ], + ), ), ), - ), - ); + ); + } } diff --git a/lib/widgets/user/captcha_web_view_dialog.dart b/lib/widgets/user/captcha_web_view_dialog.dart index 51e3436007..f8d31c1a8c 100644 --- a/lib/widgets/user/captcha_web_view_dialog.dart +++ b/lib/widgets/user/captcha_web_view_dialog.dart @@ -8,7 +8,7 @@ import 'package:webview_flutter/webview_flutter.dart'; import '../../constants/constants.dart'; import '../../constants/resources.dart'; -import '../../utils/extension/extension.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/logger.dart'; import '../dialog.dart'; import '../toast.dart'; @@ -29,6 +29,7 @@ class CaptchaWebViewDialog extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); final timer = useRef(null); final captcha = useRef(CaptchaType.gCaptcha); @@ -49,7 +50,7 @@ class CaptchaWebViewDialog extends HookConsumerWidget { _loadCaptcha(controller, CaptchaType.hCaptcha); } else { controller.loadRequest(Uri.parse('about:blank')); - showToastFailed(ToastError(context.l10n.recaptchaTimeout)); + showToastFailed(ToastError(l10n.recaptchaTimeout)); Navigator.pop(context); } } diff --git a/lib/widgets/user/change_number_dialog.dart b/lib/widgets/user/change_number_dialog.dart index 323e6b0041..55b85ddd7d 100644 --- a/lib/widgets/user/change_number_dialog.dart +++ b/lib/widgets/user/change_number_dialog.dart @@ -1,8 +1,11 @@ import 'package:flutter/widgets.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' hide encryptPin; import '../../account/session_key_value.dart'; -import '../../utils/extension/extension.dart'; +import '../../ui/provider/account_server_provider.dart'; +import '../../ui/provider/multi_auth_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/logger.dart'; import '../../utils/platform.dart'; import '../../utils/system/package_info.dart'; @@ -12,10 +15,12 @@ import 'phone_number_input.dart'; import 'pin_verification_dialog.dart'; import 'verification_dialog.dart'; -Future showChangeNumberDialog(BuildContext context) async { +Future showChangeNumberDialog(BuildContext context, WidgetRef ref) async { + final accountServer = ref.read(accountServerProvider).requireValue; + final l10n = ref.read(localizationProvider); final pinCode = await showPinVerificationDialog( context, - title: context.l10n.verifyPin, + title: l10n.verifyPin, ); if (pinCode == null) { i('showChangeNumberDialog: Pin verification failed'); @@ -34,6 +39,7 @@ Future showChangeNumberDialog(BuildContext context) async { phone: phoneNumber, context: context, purpose: VerificationPurpose.phone, + accountApi: accountServer.client.accountApi, ); Toast.dismiss(); } catch (error, stacktrace) { @@ -50,11 +56,12 @@ Future showChangeNumberDialog(BuildContext context) async { phone: phoneNumber, context: context, purpose: VerificationPurpose.phone, + accountApi: accountServer.client.accountApi, ), onVerification: (code, response) async { final packageInfo = await getPackageInfo(); final platformVersion = await getPlatformVersion(); - final result = await context.accountServer.client.accountApi.create( + final result = await accountServer.client.accountApi.create( response.id, AccountRequest( purpose: VerificationPurpose.phone, @@ -73,7 +80,7 @@ Future showChangeNumberDialog(BuildContext context) async { i('showChangeNumberDialog: Verification failed'); return; } - context.multiAuthChangeNotifier.updateAccount(account); + ref.read(multiAuthNotifierProvider.notifier).updateAccount(account); showToastSuccessful(); } diff --git a/lib/widgets/user/phone_number_input.dart b/lib/widgets/user/phone_number_input.dart index 4a5ffe1d89..f92971f331 100644 --- a/lib/widgets/user/phone_number_input.dart +++ b/lib/widgets/user/phone_number_input.dart @@ -17,6 +17,7 @@ import 'package:intl_phone_number_input/src/utils/phone_number/phone_number_util import '../../constants/constants.dart'; import '../../constants/resources.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../../utils/logger.dart'; @@ -30,13 +31,16 @@ class PhoneNumberInputLayout extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); final countries = useMemoizedFuture( () => compute(_getCountries, null), null, ).data; if (countries == null || countries.isEmpty) { return Center( - child: CircularProgressIndicator(color: context.theme.accent), + child: CircularProgressIndicator( + color: brightnessTheme.accent, + ), ); } return _PhoneNumberInputScene(countries: countries, onNextStep: onNextStep); @@ -54,6 +58,9 @@ class _PhoneNumberInputScene extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + final locale = ref.watch(localeProvider); final phoneInputController = useTextEditingController(); final countryMap = useMemoized( () => Map.fromEntries(countries.map((e) => MapEntry(e.alpha2Code, e))), @@ -99,11 +106,11 @@ class _PhoneNumberInputScene extends HookConsumerWidget { child: Column( children: [ Text( - context.l10n.enterYourPhoneNumber, + l10n.enterYourPhoneNumber, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, - color: context.theme.text, + color: brightnessTheme.text, ), ), const SizedBox(height: 24), @@ -112,7 +119,7 @@ class _PhoneNumberInputScene extends HookConsumerWidget { portalFollower: ClipRRect( borderRadius: const BorderRadius.all(Radius.circular(10)), child: Material( - color: context.theme.chatBackground, + color: brightnessTheme.chatBackground, elevation: 2, borderRadius: const BorderRadius.all(Radius.circular(10)), child: SizedBox( @@ -122,6 +129,7 @@ class _PhoneNumberInputScene extends HookConsumerWidget { size: const Size(360, 400), child: _CountryPickPortal( countries: countries, + locale: locale, selected: selectedCountry.value, onSelected: (country) { selectedCountry.value = country; @@ -167,7 +175,7 @@ class _PhoneNumberInputScene extends HookConsumerWidget { onNextStep(phoneNumber); }, child: Text( - context.l10n.next, + l10n.next, style: const TextStyle(fontWeight: FontWeight.normal), ), ), @@ -192,57 +200,63 @@ class _MobileInput extends HookConsumerWidget { final bool countryPortalExpand; @override - Widget build(BuildContext context, WidgetRef ref) => TextField( - controller: controller, - style: TextStyle(fontSize: 16, color: context.theme.text), - textInputAction: TextInputAction.next, - inputFormatters: [ - FilteringTextInputFormatter.digitsOnly, - LengthLimitingTextInputFormatter(kDefaultTextInputLimit), - ], - autofillHints: const [AutofillHints.telephoneNumber], - keyboardType: TextInputType.phone, - decoration: InputDecoration( - fillColor: context.theme.sidebarSelected, - filled: true, - hintStyle: TextStyle(fontSize: 16, color: context.theme.secondaryText), - border: const OutlineInputBorder( - borderRadius: BorderRadius.all(Radius.circular(8)), - borderSide: BorderSide.none, - ), - prefixIcon: InkWell( - borderRadius: const BorderRadius.all(Radius.circular(8)), - onTap: onCountryDiaClick, - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - const SizedBox(width: 20), - Text( - country.dialCode ?? '', - style: TextStyle(fontSize: 16, color: context.theme.text), - ), - const SizedBox(width: 8), - AnimatedRotation( - turns: countryPortalExpand ? -0.25 : 0.25, - duration: const Duration(milliseconds: 200), - child: SvgPicture.asset( - Resources.assetsImagesIcArrowRightSvg, - width: 30, - height: 30, - colorFilter: ColorFilter.mode( - context.theme.secondaryText, - BlendMode.srcIn, + Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + return TextField( + controller: controller, + style: TextStyle(fontSize: 16, color: brightnessTheme.text), + textInputAction: TextInputAction.next, + inputFormatters: [ + FilteringTextInputFormatter.digitsOnly, + LengthLimitingTextInputFormatter(kDefaultTextInputLimit), + ], + autofillHints: const [AutofillHints.telephoneNumber], + keyboardType: TextInputType.phone, + decoration: InputDecoration( + fillColor: brightnessTheme.sidebarSelected, + filled: true, + hintStyle: TextStyle( + fontSize: 16, + color: brightnessTheme.secondaryText, + ), + border: const OutlineInputBorder( + borderRadius: BorderRadius.all(Radius.circular(8)), + borderSide: BorderSide.none, + ), + prefixIcon: InkWell( + borderRadius: const BorderRadius.all(Radius.circular(8)), + onTap: onCountryDiaClick, + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const SizedBox(width: 20), + Text( + country.dialCode ?? '', + style: TextStyle(fontSize: 16, color: brightnessTheme.text), + ), + const SizedBox(width: 8), + AnimatedRotation( + turns: countryPortalExpand ? -0.25 : 0.25, + duration: const Duration(milliseconds: 200), + child: SvgPicture.asset( + Resources.assetsImagesIcArrowRightSvg, + width: 30, + height: 30, + colorFilter: ColorFilter.mode( + brightnessTheme.secondaryText, + BlendMode.srcIn, + ), ), ), - ), - const SizedBox(width: 20), - ], + const SizedBox(width: 20), + ], + ), ), + isDense: true, + contentPadding: const EdgeInsets.symmetric(vertical: 20), ), - isDense: true, - contentPadding: const EdgeInsets.symmetric(vertical: 20), - ), - ); + ); + } } // for compute @@ -255,14 +269,17 @@ class _CountryPickPortal extends HookConsumerWidget { required this.onSelected, required this.countries, required this.selected, + required this.locale, }); final void Function(Country country) onSelected; final List countries; final Country selected; + final Locale locale; @override Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); final groupedCountries = useMemoized( () => countries.groupListsBy( (country) => country.alpha2Code?.substring(0, 1), @@ -316,6 +333,7 @@ class _CountryPickPortal extends HookConsumerWidget { if (item is Country) { return _CountryItem( country: item, + locale: locale, onTap: () => onSelected(item), isSelected: item == selected, ); @@ -336,7 +354,7 @@ class _CountryPickPortal extends HookConsumerWidget { child: AZSelection( textStyle: TextStyle( fontSize: 10, - color: context.theme.secondaryText, + color: brightnessTheme.secondaryText, ), onSelection: animatedTarget.add, ), @@ -346,62 +364,71 @@ class _CountryPickPortal extends HookConsumerWidget { } } -class _CharIndexItem extends StatelessWidget { +class _CharIndexItem extends ConsumerWidget { const _CharIndexItem({required this.char}); final String char; @override - Widget build(BuildContext context) => SizedBox( - height: 40, - child: Row( - children: [ - const SizedBox(width: 20), - Text( - char, - style: TextStyle(fontSize: 14, color: context.theme.secondaryText), - ), - ], - ), - ); + Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + return SizedBox( + height: 40, + child: Row( + children: [ + const SizedBox(width: 20), + Text( + char, + style: TextStyle( + fontSize: 14, + color: brightnessTheme.secondaryText, + ), + ), + ], + ), + ); + } } -class _CountryItem extends StatelessWidget { +class _CountryItem extends ConsumerWidget { const _CountryItem({ required this.country, required this.onTap, required this.isSelected, + required this.locale, }); final Country country; final VoidCallback onTap; final bool isSelected; + final Locale locale; @override - Widget build(BuildContext context) => InkWell( - onTap: onTap, - child: DefaultTextStyle.merge( - style: TextStyle( - fontSize: 14, - color: isSelected ? context.theme.accent : context.theme.text, - ), - child: SizedBox( - height: 40, - child: Row( - children: [ - const SizedBox(width: 20), - SizedBox(width: 80, child: Text(country.dialCode ?? '')), - Text( - country.nameTranslations?[Localizations.localeOf( - context, - ).languageCode] ?? - country.name ?? - '', - ), - const SizedBox(width: 20), - ], + Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + return InkWell( + onTap: onTap, + child: DefaultTextStyle.merge( + style: TextStyle( + fontSize: 14, + color: isSelected ? brightnessTheme.accent : brightnessTheme.text, + ), + child: SizedBox( + height: 40, + child: Row( + children: [ + const SizedBox(width: 20), + SizedBox(width: 80, child: Text(country.dialCode ?? '')), + Text( + country.nameTranslations?[locale.languageCode] ?? + country.name ?? + '', + ), + const SizedBox(width: 20), + ], + ), ), ), - ), - ); + ); + } } diff --git a/lib/widgets/user/pin_verification_dialog.dart b/lib/widgets/user/pin_verification_dialog.dart index eee374f684..036d194d00 100644 --- a/lib/widgets/user/pin_verification_dialog.dart +++ b/lib/widgets/user/pin_verification_dialog.dart @@ -1,7 +1,10 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../account/session_key_value.dart'; +import '../../ui/provider/account_server_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../../utils/logger.dart'; import '../buttons.dart'; @@ -18,60 +21,69 @@ Future showPinVerificationDialog( barrierDismissible: false, ); -class _PinVerificationDialog extends StatelessWidget { +class _PinVerificationDialog extends ConsumerWidget { const _PinVerificationDialog({required this.title}); final String title; @override - Widget build(BuildContext context) => SizedBox( - width: 400, - height: 210, - child: Stack( - fit: StackFit.expand, - children: [ - Column( - children: [ - const SizedBox(height: 40), - Text( - title, - style: TextStyle(color: context.theme.text, fontSize: 18), - ), - const SizedBox(height: 20), - PinInputLayout( - doVerify: (pin) async { - await context.accountServer.client.accountApi.verifyPin( - encryptPin(pin)!, - ); - Navigator.pop(context, pin); - }, + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); + return SizedBox( + width: 400, + height: 210, + child: Stack( + fit: StackFit.expand, + children: [ + Column( + children: [ + const SizedBox(height: 40), + Text( + title, + style: TextStyle( + color: theme.text, + fontSize: 18, + ), + ), + const SizedBox(height: 20), + PinInputLayout( + doVerify: (pin) async { + final accountServer = ref + .read(accountServerProvider) + .requireValue; + await accountServer.client.accountApi.verifyPin( + encryptPin(pin)!, + ); + Navigator.pop(context, pin); + }, + ), + ], + ), + const Align( + alignment: Alignment.topRight, + child: Padding( + padding: EdgeInsets.all(22), + child: MixinCloseButton(), ), - ], - ), - const Align( - alignment: Alignment.topRight, - child: Padding( - padding: EdgeInsets.all(22), - child: MixinCloseButton(), ), - ), - ], - ), - ); + ], + ), + ); + } } const _kPinCodeLength = 6; -class PinInputLayout extends StatefulWidget { +class PinInputLayout extends ConsumerStatefulWidget { const PinInputLayout({required this.doVerify, super.key}); final Future Function(String pinCode) doVerify; @override - State createState() => _PinInputLayoutState(); + ConsumerState createState() => _PinInputLayoutState(); } -class _PinInputLayoutState extends State +class _PinInputLayoutState extends ConsumerState implements TextInputClient { final focusNode = FocusNode(debugLabel: '_PinInputLayoutState'); @@ -142,6 +154,7 @@ class _PinInputLayoutState extends State void _openInputConnection() { if (!_hasInputConnection) { + final platformBrightness = ref.read(platformBrightnessProvider); _textInputConnection = TextInput.attach( this, @@ -152,7 +165,7 @@ class _PinInputLayoutState extends State smartDashesType: SmartDashesType.disabled, enableSuggestions: false, enableInteractiveSelection: false, - keyboardAppearance: MediaQuery.platformBrightnessOf(context), + keyboardAppearance: platformBrightness, enableIMEPersonalizedLearning: false, ), ) @@ -181,6 +194,7 @@ class _PinInputLayoutState extends State @override Widget build(BuildContext context) { + final theme = ref.watch(brightnessThemeDataProvider); final inputPinLength = _controller.text.length; return Focus( focusNode: focusNode, @@ -198,7 +212,7 @@ class _PinInputLayoutState extends State width: 10, height: 10, decoration: BoxDecoration( - color: context.theme.text, + color: theme.text, shape: BoxShape.circle, ), ), @@ -208,7 +222,9 @@ class _PinInputLayoutState extends State height: 10, decoration: BoxDecoration( shape: BoxShape.circle, - border: Border.all(color: context.theme.secondaryText), + border: Border.all( + color: theme.secondaryText, + ), ), ), ].joinList(const SizedBox(width: 20)), diff --git a/lib/widgets/user/user_dialog.dart b/lib/widgets/user/user_dialog.dart index ece49bd368..9a19b4db7e 100644 --- a/lib/widgets/user/user_dialog.dart +++ b/lib/widgets/user/user_dialog.dart @@ -7,7 +7,10 @@ import '../../constants/resources.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; import '../../ui/home/chat/chat_page.dart'; +import '../../ui/provider/account_server_provider.dart'; import '../../ui/provider/conversation_provider.dart'; +import '../../ui/provider/database_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../../utils/hook.dart'; import '../../utils/logger.dart'; @@ -24,21 +27,24 @@ import '../user_selector/conversation_selector.dart'; Future showUserDialog( BuildContext context, + ProviderContainer container, String? userId, [ String? identityNumber, ]) async { + final database = container.read(databaseProvider).requireValue; + final accountServer = container.read(accountServerProvider).requireValue; + final l10n = container.read(localizationProvider); var _userId = userId; assert(_userId != null || identityNumber != null); - final existed = await context.database.userDao.hasUser( + final existed = await database.userDao.hasUser( _userId ?? identityNumber!, ); if (existed) { Toast.dismiss(); - _userId ??= - (await context.database.userDao.findMultiUserIdsByIdentityNumbers([ - identityNumber!, - ])).first; + _userId ??= (await database.userDao.findMultiUserIdsByIdentityNumbers([ + identityNumber!, + ])).first; await showMixinDialog( context: context, child: UserDialog(userId: _userId), @@ -50,23 +56,23 @@ Future showUserDialog( User? user; if (_userId != null) { - user = (await context.accountServer.refreshUsers([ + user = (await accountServer.refreshUsers([ _userId, ], force: true))?.firstOrNull; } if (user == null && identityNumber != null) { - user = (await context.accountServer.updateUserByIdentityNumber( + user = (await accountServer.updateUserByIdentityNumber( identityNumber, ))?.firstOrNull; } if (user == null) { - showToastFailed(ToastError(context.l10n.userNotFound)); + showToastFailed(ToastError(l10n.userNotFound)); return; } - _userId ??= (await context.database.userDao.findMultiUserIdsByIdentityNumbers( + _userId ??= (await database.userDao.findMultiUserIdsByIdentityNumbers( [ identityNumber!, ], @@ -119,7 +125,7 @@ class _UserProfileLoader extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final accountServer = context.accountServer; + final accountServer = ref.read(accountServerProvider).requireValue; final user = useMemoizedStream( () => accountServer.database.userDao .userById(userId) @@ -143,13 +149,15 @@ class _UserProfileLoader extends HookConsumerWidget { } } -class _UserProfileBody extends StatelessWidget { +class _UserProfileBody extends ConsumerWidget { const _UserProfileBody({required this.user}); final User user; @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); final anonymous = user.identityNumber == '0'; final biographyIsNotEmpty = !(user.biography?.isEmpty ?? true); @@ -187,7 +195,7 @@ class _UserProfileBody extends StatelessWidget { child: CustomText( user.fullName ?? '', style: TextStyle( - color: context.theme.text, + color: brightnessTheme.text, fontSize: 16, height: 1, ), @@ -210,9 +218,9 @@ class _UserProfileBody extends StatelessWidget { Padding( padding: const EdgeInsets.only(top: 4), child: CustomSelectableText( - context.l10n.contactMixinId(user.identityNumber), + l10n.contactMixinId(user.identityNumber), style: TextStyle( - color: context.theme.secondaryText, + color: brightnessTheme.secondaryText, fontSize: 12, ), ), @@ -237,12 +245,14 @@ class _UserProfileBody extends StatelessWidget { alignment: Alignment.center, margin: const EdgeInsets.only(top: 20, right: 20, left: 20), decoration: BoxDecoration( - color: context.theme.listSelected, + color: brightnessTheme.listSelected, borderRadius: const BorderRadius.all(Radius.circular(8)), ), child: Text( - context.l10n.userDeleteHint, - style: TextStyle(color: context.theme.red), + l10n.userDeleteHint, + style: TextStyle( + color: brightnessTheme.red, + ), ), ), if (!anonymous) @@ -259,60 +269,70 @@ class _UserProfileBody extends StatelessWidget { } } -class _BioText extends StatelessWidget { +class _BioText extends ConsumerWidget { const _BioText({required this.biography}); final String biography; @override - Widget build(BuildContext context) => ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 120, minWidth: 160), - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 36), - child: MoreExtendedText( - biography, - style: TextStyle( - color: context.theme.text, - fontSize: 14, - height: 1.5, + Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + return ConstrainedBox( + constraints: const BoxConstraints(maxHeight: 120, minWidth: 160), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 36), + child: MoreExtendedText( + biography, + style: TextStyle( + color: brightnessTheme.text, + fontSize: 14, + height: 1.5, + ), ), ), ), - ), - ); + ); + } } -class _AddToContactsButton extends StatelessWidget { +class _AddToContactsButton extends ConsumerWidget { const _AddToContactsButton({required this.user}); final User user; @override - Widget build(BuildContext context) => DialogAddOrJoinButton( - onTap: () { - assert(user.fullName != null, ' username should not be null.'); - runFutureWithToast( - context.accountServer.addUser(user.userId, user.fullName), - ); - }, - title: Text( - user.isBot - ? context.l10n.addBotWithPlus - : context.l10n.addContactWithPlus, - style: TextStyle(fontSize: 12, color: context.theme.accent), - ), - ); + Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).requireValue; + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + return DialogAddOrJoinButton( + onTap: () { + assert(user.fullName != null, ' username should not be null.'); + runFutureWithToast(accountServer.addUser(user.userId, user.fullName)); + }, + title: Text( + user.isBot ? l10n.addBotWithPlus : l10n.addContactWithPlus, + style: TextStyle( + fontSize: 12, + color: brightnessTheme.accent, + ), + ), + ); + } } -class _UserProfileButtonBar extends StatelessWidget { +class _UserProfileButtonBar extends ConsumerWidget { const _UserProfileButtonBar({required this.user}); final User user; @override - Widget build(BuildContext context) { - final isSelf = user.userId == context.accountServer.userId; + Widget build(BuildContext context, WidgetRef ref) { + final accountServer = ref.read(accountServerProvider).requireValue; + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + final isSelf = user.userId == accountServer.userId; final children = [ ActionButton( name: Resources.assetsImagesInviteShareSvg, @@ -321,14 +341,14 @@ class _UserProfileButtonBar extends StatelessWidget { final result = await showConversationSelector( context: context, singleSelect: true, - title: context.l10n.shareContact, + title: l10n.shareContact, onlyContact: false, action: CustomPopupMenuButton( alignment: Alignment.bottomCenter, itemBuilder: (context) => [ CustomPopupMenuItem( icon: Resources.assetsImagesContextMenuCopySvg, - title: context.l10n.copyLink, + title: l10n.copyLink, value: null, ), ], @@ -341,7 +361,7 @@ class _UserProfileButtonBar extends StatelessWidget { i('share contact ${user.userId} $codeUrl'); await Clipboard.setData(ClipboardData(text: codeUrl)); }, - color: context.theme.icon, + color: brightnessTheme.icon, icon: Resources.assetsImagesInviteShareSvg, ), ); @@ -350,7 +370,7 @@ class _UserProfileButtonBar extends StatelessWidget { final conversationId = result.first.conversationId; await runFutureWithToast( - context.accountServer.sendContactMessage( + accountServer.sendContactMessage( user.userId, user.fullName, result.first.encryptCategory!, @@ -359,21 +379,25 @@ class _UserProfileButtonBar extends StatelessWidget { ), ); }, - color: context.theme.icon, + color: brightnessTheme.icon, ), if (!isSelf) ActionButton( name: Resources.assetsImagesChatSmallSvg, size: 30, onTap: () async { - if (user.userId == context.accountServer.userId) { + if (user.userId == accountServer.userId) { // skip self. return; } - await ConversationStateNotifier.selectUser(context, user.userId); + await ConversationStateNotifier.selectUser( + ref.container, + context, + user.userId, + ); Navigator.pop(context); }, - color: context.theme.icon, + color: brightnessTheme.icon, ), if (!isSelf) ActionButton( @@ -381,13 +405,14 @@ class _UserProfileButtonBar extends StatelessWidget { size: 30, onTap: () async { await ConversationStateNotifier.selectUser( + ref.container, context, user.userId, - initialChatSidePage: ChatSideCubit.infoPage, + initialChatSidePage: ChatSideController.infoPage, ); Navigator.pop(context); }, - color: context.theme.icon, + color: brightnessTheme.icon, ), ]; return Padding( diff --git a/lib/widgets/user/verification_dialog.dart b/lib/widgets/user/verification_dialog.dart index 6708d97a22..4fe72d19e9 100644 --- a/lib/widgets/user/verification_dialog.dart +++ b/lib/widgets/user/verification_dialog.dart @@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:pin_code_fields/pin_code_fields.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; import '../../utils/logger.dart'; import '../dialog.dart'; @@ -36,7 +37,7 @@ typedef RequestVerification = Future Function(); typedef VerifyPhoneCode = Future Function(String code, VerificationResponse response); -class _VerificationCodeDialog extends StatelessWidget { +class _VerificationCodeDialog extends ConsumerWidget { const _VerificationCodeDialog({ required this.phoneNumber, required this.initialVerificationResponse, @@ -51,44 +52,47 @@ class _VerificationCodeDialog extends StatelessWidget { final VerifyPhoneCode onVerification; @override - Widget build(BuildContext context) => SizedBox( - width: 520, - height: 326, - child: Column( - children: [ - const SizedBox(height: 56), - Material( - color: context.theme.popUp, - child: VerificationCodeInputLayout( - phoneNumber: phoneNumber, - initialVerificationResponse: initialVerificationResponse, - reRequestVerification: reRequestVerification, - onVerification: (code, response) async { - showToastLoading(); - try { - final result = await onVerification(code, response); - d('_VerificationCodeDialog: result: $result'); - Navigator.pop(context, result); - Toast.dismiss(); - } catch (error, stacktrace) { - e('_VerificationCodeDialog error: $error $stacktrace'); - showToastFailed(error); - } - }, + Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + return SizedBox( + width: 520, + height: 326, + child: Column( + children: [ + const SizedBox(height: 56), + Material( + color: brightnessTheme.popUp, + child: VerificationCodeInputLayout( + phoneNumber: phoneNumber, + initialVerificationResponse: initialVerificationResponse, + reRequestVerification: reRequestVerification, + onVerification: (code, response) async { + showToastLoading(); + try { + final result = await onVerification(code, response); + d('_VerificationCodeDialog: result: $result'); + Navigator.pop(context, result); + Toast.dismiss(); + } catch (error, stacktrace) { + e('_VerificationCodeDialog error: $error $stacktrace'); + showToastFailed(error); + } + }, + ), ), - ), - const SizedBox(height: 77), - ], - ), - ); + const SizedBox(height: 77), + ], + ), + ); + } } Future requestVerificationCode({ required String phone, required BuildContext context, required VerificationPurpose purpose, + required AccountApi accountApi, (CaptchaType, String)? captcha, - AccountApi? accountApi, }) async { final request = VerificationRequest( phone: phone, @@ -99,7 +103,7 @@ Future requestVerificationCode({ : null, hCaptchaResponse: captcha?.$1 == CaptchaType.hCaptcha ? captcha?.$2 : null, ); - final api = accountApi ?? context.accountServer.client.accountApi; + final api = accountApi; try { final response = await api.verification(request); return response.data; @@ -146,6 +150,8 @@ class VerificationCodeInputLayout extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); final codeInputController = useTextEditingController(); final verification = useRef( initialVerificationResponse, @@ -156,12 +162,12 @@ class VerificationCodeInputLayout extends HookConsumerWidget { Padding( padding: const EdgeInsets.symmetric(horizontal: 125), child: Text( - context.l10n.landingValidationTitle(phoneNumber), + l10n.landingValidationTitle(phoneNumber), textAlign: TextAlign.center, style: TextStyle( fontSize: 18, fontWeight: FontWeight.w600, - color: context.theme.text, + color: brightnessTheme.text, ), ), ), @@ -179,12 +185,15 @@ class VerificationCodeInputLayout extends HookConsumerWidget { onCompleted: (code) => onVerification(code, verification.value), useHapticFeedback: true, pinTheme: PinTheme( - activeColor: context.theme.accent, - inactiveColor: context.theme.secondaryText, + activeColor: brightnessTheme.accent, + inactiveColor: brightnessTheme.secondaryText, fieldWidth: 15, borderWidth: 2, ), - textStyle: TextStyle(fontSize: 18, color: context.theme.text), + textStyle: TextStyle( + fontSize: 18, + color: brightnessTheme.text, + ), onChanged: (value) {}, ), ), @@ -221,6 +230,8 @@ class ResendCodeWidget extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); final nextDuration = useState(60); useEffect(() { final timer = Timer.periodic(const Duration(seconds: 1), (timer) { @@ -235,10 +246,10 @@ class ResendCodeWidget extends HookConsumerWidget { ? Padding( padding: const EdgeInsets.all(8), child: Text( - context.l10n.resendCodeIn(nextDuration.value), + l10n.resendCodeIn(nextDuration.value), style: TextStyle( fontSize: 14, - color: context.theme.secondaryText, + color: brightnessTheme.secondaryText, ), ), ) @@ -251,8 +262,11 @@ class ResendCodeWidget extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.all(8), child: Text( - context.l10n.resendCode, - style: TextStyle(fontSize: 14, color: context.theme.accent), + l10n.resendCode, + style: TextStyle( + fontSize: 14, + color: brightnessTheme.accent, + ), ), ), ); diff --git a/lib/widgets/user_selector/bloc/conversation_filter_cubit.dart b/lib/widgets/user_selector/controllers/conversation_filter_controller.dart similarity index 62% rename from lib/widgets/user_selector/bloc/conversation_filter_cubit.dart rename to lib/widgets/user_selector/controllers/conversation_filter_controller.dart index ec9ec9b0ae..85498531b4 100644 --- a/lib/widgets/user_selector/bloc/conversation_filter_cubit.dart +++ b/lib/widgets/user_selector/controllers/conversation_filter_controller.dart @@ -1,5 +1,5 @@ -import 'package:bloc/bloc.dart'; import 'package:equatable/equatable.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../account/account_server.dart'; import '../../../db/dao/conversation_dao.dart'; @@ -9,32 +9,28 @@ import '../../../utils/sort.dart'; part 'conversation_filter_state.dart'; -class ConversationFilterCubit extends Cubit { - ConversationFilterCubit( - this.accountServer, - this.onlyContact, - this.filteredIds, - this.afterInit, - ) : super(const ConversationFilterState()) { - _init(); - } +class ConversationFilterNotifier extends Notifier { + ConversationFilterNotifier(this._args); - final AccountServer accountServer; - final bool onlyContact; - final Function(ConversationFilterState) afterInit; - final Iterable filteredIds; + final ConversationFilterArgs _args; late List conversations; late List friends; late List bots; + @override + ConversationFilterState build() { + Future.microtask(_init); + return const ConversationFilterState(); + } + Future _init() async { var contactConversationIds = {}; var botConversationIds = {}; - if (onlyContact) { + if (_args.onlyContact) { conversations = []; } else { - conversations = await accountServer.database.conversationDao + conversations = await _args.accountServer.database.conversationDao .conversationItems() .get(); contactConversationIds = conversations @@ -55,41 +51,42 @@ class ConversationFilterCubit extends Cubit { friends = []; bots = []; - final Iterable users = await accountServer.database.userDao + final Iterable users = await _args.accountServer.database.userDao .notInFriends([ ...contactConversationIds, ...botConversationIds, ]) .get(); - users.where((element) => !filteredIds.contains(element.userId)).forEach(( - e, - ) { - if (e.isBot) { - bots.add(e); - } else { - friends.add(e); - } - }); + users + .where((element) => !_args.filteredIds.contains(element.userId)) + .forEach(( + e, + ) { + if (e.isBot) { + bots.add(e); + } else { + friends.add(e); + } + }); _filterList(); - afterInit(state); } - set keyword(String keyword) { - emit(state.copyWith(keyword: keyword)); + void setKeyword(String keyword) { + state = state.copyWith(keyword: keyword); _filterList(); } void _filterList() { if (state.keyword?.isEmpty ?? true) { - return emit( - state.copyWith( - recentConversations: conversations, - friends: friends, - bots: bots, - keyword: state.keyword, - ), + state = state.copyWith( + recentConversations: conversations, + friends: friends, + bots: bots, + keyword: state.keyword, + initialized: true, ); + return; } final keyword = state.keyword!.toLowerCase(); @@ -129,13 +126,34 @@ class ConversationFilterCubit extends Cubit { final filterFriends = friends.where(where).toList()..sort(sort); final filterBots = bots.where(where).toList()..sort(sort); - emit( - state.copyWith( - recentConversations: recentConversations, - friends: filterFriends, - bots: filterBots, - keyword: state.keyword, - ), + state = state.copyWith( + recentConversations: recentConversations, + friends: filterFriends, + bots: filterBots, + keyword: state.keyword, + initialized: true, ); } } + +class ConversationFilterArgs with EquatableMixin { + const ConversationFilterArgs({ + required this.accountServer, + required this.onlyContact, + required this.filteredIds, + }); + + final AccountServer accountServer; + final bool onlyContact; + final List filteredIds; + + @override + List get props => [accountServer, onlyContact, filteredIds]; +} + +final conversationFilterStateProvider = NotifierProvider.autoDispose + .family< + ConversationFilterNotifier, + ConversationFilterState, + ConversationFilterArgs + >(ConversationFilterNotifier.new); diff --git a/lib/widgets/user_selector/bloc/conversation_filter_state.dart b/lib/widgets/user_selector/controllers/conversation_filter_state.dart similarity index 74% rename from lib/widgets/user_selector/bloc/conversation_filter_state.dart rename to lib/widgets/user_selector/controllers/conversation_filter_state.dart index 0f634d1ece..ae39e27c9b 100644 --- a/lib/widgets/user_selector/bloc/conversation_filter_state.dart +++ b/lib/widgets/user_selector/controllers/conversation_filter_state.dart @@ -1,4 +1,4 @@ -part of 'conversation_filter_cubit.dart'; +part of 'conversation_filter_controller.dart'; class ConversationFilterState extends Equatable { const ConversationFilterState({ @@ -6,12 +6,14 @@ class ConversationFilterState extends Equatable { this.friends = const [], this.bots = const [], this.keyword, + this.initialized = false, }); final List recentConversations; final List friends; final List bots; final String? keyword; + final bool initialized; Set get appIds => { ...recentConversations.map((e) => e.ownerId).nonNulls, @@ -19,17 +21,25 @@ class ConversationFilterState extends Equatable { }; @override - List get props => [friends, bots, keyword, recentConversations]; + List get props => [ + friends, + bots, + keyword, + recentConversations, + initialized, + ]; ConversationFilterState copyWith({ List? recentConversations, List? friends, List? bots, String? keyword, + bool? initialized, }) => ConversationFilterState( recentConversations: recentConversations ?? this.recentConversations, friends: friends ?? this.friends, bots: bots ?? this.bots, keyword: keyword ?? this.keyword, + initialized: initialized ?? this.initialized, ); } diff --git a/lib/widgets/user_selector/conversation_selector.dart b/lib/widgets/user_selector/conversation_selector.dart index 7cebfb588f..7ce018150e 100644 --- a/lib/widgets/user_selector/conversation_selector.dart +++ b/lib/widgets/user_selector/conversation_selector.dart @@ -8,7 +8,6 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' as sdk; import 'package:mixin_logger/mixin_logger.dart'; import 'package:sliver_tools/sliver_tools.dart'; -import '../../bloc/simple_cubit.dart'; import '../../constants/brightness_theme_data.dart'; import '../../constants/constants.dart'; import '../../constants/resources.dart'; @@ -16,15 +15,18 @@ import '../../crypto/uuid/uuid.dart'; import '../../db/dao/conversation_dao.dart'; import '../../db/mixin_database.dart'; import '../../enum/encrypt_category.dart'; +import '../../ui/provider/account_server_provider.dart'; +import '../../ui/provider/database_provider.dart'; +import '../../ui/provider/multi_auth_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; import '../action_button.dart'; import '../avatar_view/avatar_view.dart'; import '../conversation/badges_widget.dart'; import '../dialog.dart'; import '../high_light_text.dart'; import '../interactive_decorated_box.dart'; -import 'bloc/conversation_filter_cubit.dart'; +import 'controllers/conversation_filter_controller.dart'; String _getConversationName(dynamic item) { if (item is ConversationItem) return item.validName; @@ -32,10 +34,10 @@ String _getConversationName(dynamic item) { throw ArgumentError('must be ConversationItem or User'); } -String _getConversationId(dynamic item, BuildContext context) { +String _getConversationId(dynamic item, String selfUserId) { if (item is ConversationItem) return item.conversationId; if (item is User) { - return generateConversationId(item.userId, context.accountServer.userId); + return generateConversationId(item.userId, selfUserId); } throw ArgumentError('must be ConversationItem or User'); } @@ -131,15 +133,72 @@ class ConversationSelector with EquatableMixin { static ConversationSelector init( dynamic item, - BuildContext context, + String selfUserId, Map map, ) => ConversationSelector( - conversationId: _getConversationId(item, context), + conversationId: _getConversationId(item, selfUserId), userId: _getUserId(item), encryptCategory: _getEncryptedCategory(item, map), ); } +class _ConversationSelectorAppsArgs with EquatableMixin { + const _ConversationSelectorAppsArgs({ + required this.database, + required this.appIds, + }); + + final MixinDatabase database; + final List appIds; + + @override + List get props => [database, appIds]; +} + +class _SelectedItemsNotifier extends Notifier> { + @override + List build() => const []; + + void toggle(dynamic item, {int? maxSelect}) { + final list = [...state]; + if (list.contains(item)) { + list.remove(item); + } else { + if (maxSelect != null && list.length >= maxSelect) { + w('max select reached: $maxSelect'); + return; + } + list.add(item); + } + state = list; + } + + void replace(List items) => state = items; +} + +class _BootstrapSelectionNotifier extends Notifier { + @override + bool build() => false; + + void markReady() => state = true; +} + +final _selectedItemsProvider = + NotifierProvider.autoDispose<_SelectedItemsNotifier, List>( + _SelectedItemsNotifier.new, + ); + +final _bootstrapSelectionProvider = + NotifierProvider.autoDispose<_BootstrapSelectionNotifier, bool>( + _BootstrapSelectionNotifier.new, + ); + +final _conversationSelectorAppsProvider = FutureProvider.autoDispose + .family, _ConversationSelectorAppsArgs>((ref, args) async { + final list = await args.database.appDao.appInIds(args.appIds).get(); + return {for (final e in list) e.appId: e}; + }); + class _ConversationSelector extends HookConsumerWidget { const _ConversationSelector({ required this.singleSelect, @@ -165,79 +224,72 @@ class _ConversationSelector extends HookConsumerWidget { @override Widget build(BuildContext context, WidgetRef ref) { - final selector = useBloc(() => SimpleCubit>(const [])); - void selectItem(dynamic item) { - final list = [...selector.state]; - if (list.contains(item)) { - list.remove(item); - } else { - if (maxSelect != null && list.length >= maxSelect!) { - w('max select reached: $maxSelect'); - return; - } - list.add(item); - } - selector.emit(list); - } - - final conversationFilterCubit = useBloc( - () => ConversationFilterCubit( - useContext().accountServer, - onlyContact, - filteredIds, - (state) { - state.recentConversations.forEach((element) { - if (!initSelected - .map((e) => e.conversationId) - .contains(element.conversationId)) { - return; - } - selectItem(element); - }); - - final userIds = initSelected.map((e) => e.userId); - state.friends.forEach((element) { - if (!userIds.contains(element.userId)) return; - selectItem(element); - }); - state.bots.forEach((element) { - if (!userIds.contains(element.userId)) return; - selectItem(element); - }); - }, - ), - keys: [useContext().accountServer, onlyContact, filteredIds], + final accountServer = ref.read(accountServerProvider).requireValue; + final database = ref.read(databaseProvider).requireValue; + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + final selfUserId = + ref.read(authAccountProvider)?.userId ?? accountServer.userId; + final args = ConversationFilterArgs( + accountServer: accountServer, + onlyContact: onlyContact, + filteredIds: filteredIds.toList(growable: false), ); - - final conversationFilterState = - useBlocState( - bloc: conversationFilterCubit, - ); - - final appMap = useMemoizedFuture( - () async { - final list = await context.database.appDao - .appInIds(conversationFilterState.appIds) - .get(); - return {for (final e in list) e.appId: e}; + final conversationFilterState = ref.watch( + conversationFilterStateProvider(args), + ); + final selected = ref.watch(_selectedItemsProvider); + final selectedNotifier = ref.read(_selectedItemsProvider.notifier); + + ref.listen( + conversationFilterStateProvider(args), + (previous, next) { + if (ref.read(_bootstrapSelectionProvider)) return; + if (!next.initialized) return; + + final conversationIds = initSelected + .map((e) => e.conversationId) + .toSet(); + final userIds = initSelected.map((e) => e.userId).toSet(); + selectedNotifier.replace([ + ...next.recentConversations.where( + (element) => conversationIds.contains(element.conversationId), + ), + ...next.friends.where((element) => userIds.contains(element.userId)), + ...next.bots.where((element) => userIds.contains(element.userId)), + ]); + ref.read(_bootstrapSelectionProvider.notifier).markReady(); }, - {}, - keys: [conversationFilterState], - ).requireData; + ); + + final appMap = + ref + .watch( + _conversationSelectorAppsProvider( + _ConversationSelectorAppsArgs( + database: database.mixinDatabase, + appIds: conversationFilterState.appIds.toList( + growable: false, + ), + ), + ), + ) + .value ?? + const {}; useEffect( - () => selector.stream.listen((event) { + () => ref.listenManual>(_selectedItemsProvider, ( + previous, + event, + ) { if (event.isNotEmpty && singleSelect) { final item = event.first; Navigator.pop(context, [ - ConversationSelector.init(item, context, appMap), + ConversationSelector.init(item, selfUserId, appMap), ]); } - }).cancel, - [selector.stream], - ); - final selected = useBlocState>, List>( - bloc: selector, + }).close, + [singleSelect, appMap, selfUserId], ); const boxDecoration = BoxDecoration( @@ -262,7 +314,7 @@ class _ConversationSelector extends HookConsumerWidget { alignment: Alignment.centerLeft, child: ActionButton( name: Resources.assetsImagesIcCloseSvg, - color: context.theme.icon, + color: brightnessTheme.icon, onTap: () => Navigator.pop(context), ), ), @@ -275,7 +327,7 @@ class _ConversationSelector extends HookConsumerWidget { Text( title, style: TextStyle( - color: context.theme.text, + color: brightnessTheme.text, fontSize: 16, ), ), @@ -284,7 +336,7 @@ class _ConversationSelector extends HookConsumerWidget { '${selected.length} / ${maxSelect ?? conversationFilterState.recentConversations.length + conversationFilterState.friends.length + conversationFilterState.bots.length}', style: TextStyle( fontSize: 12, - color: context.theme.secondaryText, + color: brightnessTheme.secondaryText, ), ), ], @@ -306,14 +358,14 @@ class _ConversationSelector extends HookConsumerWidget { .map( (item) => ConversationSelector.init( item, - context, + selfUserId, appMap, ), ) .toList(), ), child: Text( - confirmedText ?? context.l10n.next, + confirmedText ?? l10n.next, ), ) : const SizedBox()), @@ -322,7 +374,9 @@ class _ConversationSelector extends HookConsumerWidget { ], ), ), - _FilterTextField(conversationFilterCubit: conversationFilterCubit), + _FilterTextField( + conversationFilterArgs: args, + ), AnimatedSize( alignment: Alignment.topCenter, duration: const Duration(milliseconds: 200), @@ -343,7 +397,10 @@ class _ConversationSelector extends HookConsumerWidget { children: [ _getAvatarWidget(selected[index]), _AvatarSmallCloseIcon( - onTap: () => selectItem(selected[index]), + onTap: () => selectedNotifier.toggle( + selected[index], + maxSelect: maxSelect, + ), ), ], ), @@ -352,7 +409,7 @@ class _ConversationSelector extends HookConsumerWidget { _getConversationName(selected[index]), style: TextStyle( fontSize: 14, - color: context.theme.text, + color: brightnessTheme.text, ), textAlign: TextAlign.center, overflow: TextOverflow.ellipsis, @@ -374,16 +431,20 @@ class _ConversationSelector extends HookConsumerWidget { slivers: [ if (conversationFilterState.recentConversations.isNotEmpty) _Section( - title: context.l10n.recentChats, + title: l10n.recentChats, count: conversationFilterState.recentConversations.length, + hoveringColor: brightnessTheme.listSelected, builder: (context, index) { final item = conversationFilterState .recentConversations[index]; return InteractiveDecoratedBox.color( decoration: boxDecoration, - hoveringColor: context.theme.listSelected, - onTap: () => selectItem(item), + hoveringColor: brightnessTheme.listSelected, + onTap: () => selectedNotifier.toggle( + item, + maxSelect: maxSelect, + ), child: _BaseItem( keyword: conversationFilterState.keyword, avatar: item.avatarWidget, @@ -393,8 +454,8 @@ class _ConversationSelector extends HookConsumerWidget { membership: item.membership, selected: selected.any( (element) => - _getConversationId(element, context) == - _getConversationId(item, context), + _getConversationId(element, selfUserId) == + _getConversationId(item, selfUserId), ), showSelector: !singleSelect, ), @@ -403,14 +464,18 @@ class _ConversationSelector extends HookConsumerWidget { ), if (conversationFilterState.friends.isNotEmpty) _Section( - title: context.l10n.contactTitle, + title: l10n.contactTitle, count: conversationFilterState.friends.length, + hoveringColor: brightnessTheme.listSelected, builder: (context, index) { final item = conversationFilterState.friends[index]; return InteractiveDecoratedBox.color( decoration: boxDecoration, - hoveringColor: context.theme.listSelected, - onTap: () => selectItem(item), + hoveringColor: brightnessTheme.listSelected, + onTap: () => selectedNotifier.toggle( + item, + maxSelect: maxSelect, + ), child: _BaseItem( keyword: conversationFilterState.keyword, avatar: item.avatarWidget, @@ -421,8 +486,8 @@ class _ConversationSelector extends HookConsumerWidget { showSelector: !singleSelect, selected: selected.any( (element) => - _getConversationId(element, context) == - _getConversationId(item, context), + _getConversationId(element, selfUserId) == + _getConversationId(item, selfUserId), ), ), ); @@ -430,14 +495,18 @@ class _ConversationSelector extends HookConsumerWidget { ), if (conversationFilterState.bots.isNotEmpty) _Section( - title: context.l10n.bots, + title: l10n.bots, count: conversationFilterState.bots.length, + hoveringColor: brightnessTheme.listSelected, builder: (context, index) { final item = conversationFilterState.bots[index]; return InteractiveDecoratedBox.color( decoration: boxDecoration, - hoveringColor: context.theme.listSelected, - onTap: () => selectItem(item), + hoveringColor: brightnessTheme.listSelected, + onTap: () => selectedNotifier.toggle( + item, + maxSelect: maxSelect, + ), child: _BaseItem( keyword: conversationFilterState.keyword, avatar: item.avatarWidget, @@ -448,8 +517,8 @@ class _ConversationSelector extends HookConsumerWidget { showSelector: !singleSelect, selected: selected.any( (element) => - _getConversationId(element, context) == - _getConversationId(item, context), + _getConversationId(element, selfUserId) == + _getConversationId(item, selfUserId), ), ), ); @@ -467,35 +536,41 @@ class _ConversationSelector extends HookConsumerWidget { } class _FilterTextField extends HookConsumerWidget { - const _FilterTextField({required this.conversationFilterCubit}); + const _FilterTextField({required this.conversationFilterArgs}); - final ConversationFilterCubit conversationFilterCubit; + final ConversationFilterArgs conversationFilterArgs; @override Widget build(BuildContext context, WidgetRef ref) { - final isTextEmpty = - useMemoizedStream( - () => conversationFilterCubit.stream - .map((event) => event.keyword?.isEmpty ?? true) - .distinct(), - keys: [conversationFilterCubit], - ).data ?? - conversationFilterCubit.state.keyword?.isEmpty ?? - true; + final l10n = ref.watch(localizationProvider); + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + final conversationFilterState = ref.watch( + conversationFilterStateProvider(conversationFilterArgs), + ); + final isTextEmpty = conversationFilterState.keyword?.isEmpty ?? true; return Container( height: 32, padding: const EdgeInsets.symmetric(horizontal: 16), margin: const EdgeInsets.only(top: 8, right: 24, left: 24), decoration: BoxDecoration( - color: context.theme.background, + color: brightnessTheme.background, borderRadius: const BorderRadius.all(Radius.circular(16)), ), alignment: Alignment.center, child: Stack( children: [ TextField( - onChanged: (string) => conversationFilterCubit.keyword = string, - style: TextStyle(color: context.theme.text, fontSize: 14), + onChanged: (string) => ref + .read( + conversationFilterStateProvider( + conversationFilterArgs, + ).notifier, + ) + .setKeyword(string), + style: TextStyle( + color: brightnessTheme.text, + fontSize: 14, + ), inputFormatters: [ LengthLimitingTextInputFormatter(kDefaultTextInputLimit), ], @@ -507,7 +582,7 @@ class _FilterTextField extends HookConsumerWidget { child: SvgPicture.asset( Resources.assetsImagesIcSearchSmallSvg, colorFilter: ColorFilter.mode( - context.theme.secondaryText, + brightnessTheme.secondaryText, BlendMode.srcIn, ), ), @@ -528,9 +603,9 @@ class _FilterTextField extends HookConsumerWidget { child: Padding( padding: const EdgeInsets.only(left: 24, top: 7), child: Text( - context.l10n.search, + l10n.search, style: TextStyle( - color: context.theme.secondaryText, + color: brightnessTheme.secondaryText, height: 1, ), maxLines: 1, @@ -544,87 +619,104 @@ class _FilterTextField extends HookConsumerWidget { } } -class _AvatarSmallCloseIcon extends StatelessWidget { +class _AvatarSmallCloseIcon extends ConsumerWidget { const _AvatarSmallCloseIcon({required this.onTap}); final VoidCallback onTap; @override - Widget build(BuildContext context) => Positioned( - top: 0, - right: 0, - child: GestureDetector( - onTap: onTap, - child: Container( - width: 22, - height: 22, - decoration: BoxDecoration( - color: context.theme.popUp, - shape: BoxShape.circle, - ), - child: Center( - child: Container( - height: 16, - width: 16, - decoration: BoxDecoration( - color: context.dynamicColor( - darkBrightnessThemeData.divider, - darkColor: const Color.fromRGBO(142, 141, 143, 1), + Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + final iconBackground = ref.watch( + dynamicColorProvider(( + color: darkBrightnessThemeData.divider, + darkColor: const Color.fromRGBO(142, 141, 143, 1), + )), + ); + return Positioned( + top: 0, + right: 0, + child: GestureDetector( + onTap: onTap, + child: Container( + width: 22, + height: 22, + decoration: BoxDecoration( + color: brightnessTheme.popUp, + shape: BoxShape.circle, + ), + child: Center( + child: Container( + height: 16, + width: 16, + decoration: BoxDecoration( + color: iconBackground, + shape: BoxShape.circle, ), - shape: BoxShape.circle, - ), - child: Center( - child: SvgPicture.asset( - Resources.assetsImagesSmallCloseSvg, - colorFilter: ColorFilter.mode( - Colors.white.withValues(alpha: 0.9), - BlendMode.srcIn, + child: Center( + child: SvgPicture.asset( + Resources.assetsImagesSmallCloseSvg, + colorFilter: ColorFilter.mode( + Colors.white.withValues(alpha: 0.9), + BlendMode.srcIn, + ), ), ), ), ), ), ), - ), - ); + ); + } } -class _Section extends StatelessWidget { +class _Section extends ConsumerWidget { const _Section({ required this.builder, required this.title, required this.count, + required this.hoveringColor, }); final IndexedWidgetBuilder builder; final String title; final int count; + final Color hoveringColor; @override - Widget build(BuildContext context) => MultiSliver( - pushPinnedChildren: true, - children: [ - SliverPinnedHeader( - child: Container( - color: context.dynamicColor( - const Color.fromRGBO(255, 255, 255, 1), - darkColor: const Color.fromRGBO(62, 65, 72, 1), - ), - padding: const EdgeInsets.only(top: 10, bottom: 10, left: 14), - child: Text( - title, - style: TextStyle(fontSize: 16, color: context.theme.text), + Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + final headerColor = ref.watch( + dynamicColorProvider(( + color: const Color.fromRGBO(255, 255, 255, 1), + darkColor: const Color.fromRGBO(62, 65, 72, 1), + )), + ); + return MultiSliver( + pushPinnedChildren: true, + children: [ + SliverPinnedHeader( + child: Container( + color: headerColor, + padding: const EdgeInsets.only(top: 10, bottom: 10, left: 14), + child: Text( + title, + style: TextStyle( + fontSize: 16, + color: brightnessTheme.text, + ), + ), ), ), - ), - SliverList( - delegate: SliverChildBuilderDelegate(builder, childCount: count), - ), - ], - ); + SliverList( + delegate: SliverChildBuilderDelegate(builder, childCount: count), + ), + ], + ); + } } -class _BaseItem extends StatelessWidget { +class _BaseItem extends ConsumerWidget { const _BaseItem({ required this.keyword, required this.title, @@ -647,59 +739,65 @@ class _BaseItem extends StatelessWidget { final sdk.Membership? membership; @override - Widget build(BuildContext context) => Container( - height: 70, - padding: const EdgeInsets.only(top: 10, bottom: 10, left: 14, right: 10), - child: Row( - children: [ - if (showSelector) - Padding( - padding: const EdgeInsets.only(right: 20), - child: ClipOval( - child: Container( - height: 16, - width: 16, - decoration: BoxDecoration( - color: selected - ? context.theme.accent - : context.theme.secondaryText, + Widget build(BuildContext context, WidgetRef ref) { + final brightnessTheme = ref.watch(brightnessThemeDataProvider); + return Container( + height: 70, + padding: const EdgeInsets.only(top: 10, bottom: 10, left: 14, right: 10), + child: Row( + children: [ + if (showSelector) + Padding( + padding: const EdgeInsets.only(right: 20), + child: ClipOval( + child: Container( + height: 16, + width: 16, + decoration: BoxDecoration( + color: selected + ? brightnessTheme.accent + : brightnessTheme.secondaryText, + ), + alignment: Alignment.center, + child: SvgPicture.asset(Resources.assetsImagesSelectedSvg), ), - alignment: Alignment.center, - child: SvgPicture.asset(Resources.assetsImagesSelectedSvg), ), ), - ), - avatar, - const SizedBox(width: 16), - Expanded( - child: Row( - children: [ - Flexible( - child: CustomText( - title.overflow, - maxLines: 1, - overflow: TextOverflow.ellipsis, - textMatchers: [ - EmojiTextMatcher(), - if (keyword != null && keyword!.trim().isNotEmpty) - MultiKeyWordTextMatcher.createKeywordMatcher( - keyword: keyword!.overflow, - style: TextStyle(color: context.theme.accent), - caseSensitive: false, - ), - ], - style: TextStyle(fontSize: 16, color: context.theme.text), + avatar, + const SizedBox(width: 16), + Expanded( + child: Row( + children: [ + Flexible( + child: CustomText( + title.overflow, + maxLines: 1, + overflow: TextOverflow.ellipsis, + textMatchers: [ + EmojiTextMatcher(), + if (keyword != null && keyword!.trim().isNotEmpty) + MultiKeyWordTextMatcher.createKeywordMatcher( + keyword: keyword!.overflow, + style: TextStyle(color: brightnessTheme.accent), + caseSensitive: false, + ), + ], + style: TextStyle( + fontSize: 16, + color: brightnessTheme.text, + ), + ), ), - ), - BadgesWidget( - verified: verified, - isBot: isBot, - membership: membership, - ), - ], + BadgesWidget( + verified: verified, + isBot: isBot, + membership: membership, + ), + ], + ), ), - ), - ], - ), - ); + ], + ), + ); + } } diff --git a/lib/widgets/web_view_navigation_bar.dart b/lib/widgets/web_view_navigation_bar.dart index 26e4d1eeaa..135347deff 100644 --- a/lib/widgets/web_view_navigation_bar.dart +++ b/lib/widgets/web_view_navigation_bar.dart @@ -1,15 +1,17 @@ import 'package:desktop_webview_window/desktop_webview_window.dart'; import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../constants/resources.dart'; -import '../utils/extension/extension.dart'; +import '../ui/provider/ui_context_providers.dart'; import 'action_button.dart'; -class WebViewNavigationBar extends StatelessWidget { +class WebViewNavigationBar extends ConsumerWidget { const WebViewNavigationBar({super.key}); @override - Widget build(BuildContext context) { + Widget build(BuildContext context, WidgetRef ref) { + final theme = ref.watch(brightnessThemeDataProvider); final state = TitleBarWebViewState.of(context); final controller = TitleBarWebViewController.of(context); return Row( @@ -18,8 +20,8 @@ class WebViewNavigationBar extends StatelessWidget { ActionButton( name: Resources.assetsImagesIcBackSvg, color: state.canGoBack - ? context.theme.icon - : context.theme.icon.withValues(alpha: 0.5), + ? theme.icon + : theme.icon.withValues(alpha: 0.5), onTap: controller.back, size: 16, padding: EdgeInsets.zero, @@ -28,8 +30,8 @@ class WebViewNavigationBar extends StatelessWidget { ActionButton( name: Resources.assetsImagesIcForwardSvg, color: state.canGoForward - ? context.theme.icon - : context.theme.icon.withValues(alpha: 0.5), + ? theme.icon + : theme.icon.withValues(alpha: 0.5), onTap: controller.forward, size: 16, padding: EdgeInsets.zero, @@ -37,7 +39,7 @@ class WebViewNavigationBar extends StatelessWidget { const SizedBox(width: 16), ActionButton( name: Resources.assetsImagesWebViewRefreshSvg, - color: context.theme.icon, + color: theme.icon, onTap: controller.reload, size: 16, padding: EdgeInsets.zero, diff --git a/lib/widgets/window/menus.dart b/lib/widgets/window/menus.dart index c552cbbe0b..0c18e9a7ba 100644 --- a/lib/widgets/window/menus.dart +++ b/lib/widgets/window/menus.dart @@ -3,7 +3,6 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; -import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:window_manager/window_manager.dart'; @@ -11,11 +10,9 @@ import '../../account/security_key_value.dart'; import '../../ui/home/conversation/conversation_hotkey.dart'; import '../../ui/provider/account_server_provider.dart'; import '../../ui/provider/slide_category_provider.dart'; +import '../../ui/provider/ui_context_providers.dart'; import '../../utils/device_transfer/device_transfer_dialog.dart'; import '../../utils/event_bus.dart'; -import '../../utils/extension/extension.dart'; -import '../../utils/hook.dart'; -import '../../utils/rivepod.dart'; import '../../utils/uri_utils.dart'; import '../actions/actions.dart'; import '../auth.dart'; @@ -40,9 +37,9 @@ abstract class ConversationMenuHandle { void delete(); } -class MacMenuBarStateNotifier - extends DistinctStateNotifier { - MacMenuBarStateNotifier(super.state); +class MacMenuBarStateNotifier extends Notifier { + @override + ConversationMenuHandle? build() => null; void attach(ConversationMenuHandle handle) { if (!Platform.isMacOS) return; @@ -57,11 +54,11 @@ class MacMenuBarStateNotifier } final macMenuBarProvider = - StateNotifierProvider( - (ref) => MacMenuBarStateNotifier(null), + NotifierProvider( + MacMenuBarStateNotifier.new, ); -class MacosMenuBar extends HookConsumerWidget { +class MacosMenuBar extends ConsumerWidget { const MacosMenuBar({required this.child, super.key}); final Widget child; @@ -75,68 +72,49 @@ class MacosMenuBar extends HookConsumerWidget { } } -class _Menus extends HookConsumerWidget { +class _Menus extends ConsumerWidget { const _Menus({required this.child}); final Widget child; @override Widget build(BuildContext context, WidgetRef ref) { + final l10n = ref.watch(localizationProvider); final signed = ref.watch( accountServerProvider.select((value) => value.hasValue), ); final handle = ref.watch(macMenuBarProvider); - final muted = - useMemoizedStream( - () => handle?.isMuted ?? const Stream.empty(), - keys: [handle], - ).data ?? - false; - - final pinned = - useMemoizedStream( - () => handle?.isPinned ?? const Stream.empty(), - keys: [handle], - ).data ?? - false; - + final muted = ref.watch(_macMenuMutedProvider).value ?? false; + final pinned = ref.watch(_macMenuPinnedProvider).value ?? false; final hasPasscode = - useMemoizedStream( - signed - ? SecurityKeyValue.instance.watchHasPasscode - : () => Stream.value(false), - ).data ?? - false; + ref.watch(_macMenuHasPasscodeProvider(signed)).value ?? false; PlatformMenu buildConversationMenu() => PlatformMenu( - label: context.l10n.conversation, + label: l10n.conversation, menus: [ if (muted) PlatformMenuItem( - label: context.l10n.unmute, + label: l10n.unmute, onSelected: handle?.unmute, ) else - PlatformMenuItem(label: context.l10n.mute, onSelected: handle?.mute), + PlatformMenuItem(label: l10n.mute, onSelected: handle?.mute), PlatformMenuItem( - label: context.l10n.search, + label: l10n.search, onSelected: handle?.showSearch, ), PlatformMenuItem( - label: context.l10n.deleteChat, + label: l10n.deleteChat, onSelected: handle?.delete, ), if (pinned) - PlatformMenuItem(label: context.l10n.unpin, onSelected: handle?.unPin) + PlatformMenuItem(label: l10n.unpin, onSelected: handle?.unPin) else - PlatformMenuItem( - label: context.l10n.pinTitle, - onSelected: handle?.pin, - ), + PlatformMenuItem(label: l10n.pinTitle, onSelected: handle?.pin), PlatformMenuItem( - label: context.l10n.toggleChatInfo, + label: l10n.toggleChatInfo, onSelected: handle?.toggleSideBar, ), ], @@ -151,7 +129,7 @@ class _Menus extends HookConsumerWidget { PlatformMenuItemGroup( members: [ PlatformMenuItem( - label: '${context.l10n.about} Mixin', + label: '${l10n.about} Mixin', onSelected: () => methodChannel.invokeMethod('showAbout'), ), ], @@ -159,7 +137,7 @@ class _Menus extends HookConsumerWidget { PlatformMenuItemGroup( members: [ PlatformMenuItem( - label: context.l10n.preferences, + label: l10n.preferences, shortcut: const SingleActivator( LogicalKeyboardKey.comma, meta: true, @@ -168,7 +146,7 @@ class _Menus extends HookConsumerWidget { ? () { windowManager.show(); ref - .read(slideCategoryStateProvider.notifier) + .read(slideCategoryProvider.notifier) .select(SlideCategoryType.setting); } : null, @@ -178,7 +156,7 @@ class _Menus extends HookConsumerWidget { PlatformMenuItemGroup( members: [ PlatformMenuItem( - label: context.l10n.lock, + label: l10n.lock, shortcut: const SingleActivator( LogicalKeyboardKey.keyL, meta: true, @@ -193,7 +171,7 @@ class _Menus extends HookConsumerWidget { PlatformMenuItemGroup( members: [ PlatformMenuItem( - label: context.l10n.quickSearch, + label: l10n.quickSearch, shortcut: const SingleActivator( LogicalKeyboardKey.keyK, meta: true, @@ -208,7 +186,7 @@ class _Menus extends HookConsumerWidget { : null, ), PlatformMenuItem( - label: context.l10n.hideMixin, + label: l10n.hideMixin, shortcut: const SingleActivator( LogicalKeyboardKey.keyH, meta: true, @@ -216,13 +194,13 @@ class _Menus extends HookConsumerWidget { onSelected: windowManager.hide, ), PlatformMenuItem( - label: context.l10n.showMixin, + label: l10n.showMixin, onSelected: windowManager.show, ), ], ), PlatformMenuItem( - label: context.l10n.quitMixin, + label: l10n.quitMixin, shortcut: const SingleActivator( LogicalKeyboardKey.keyQ, meta: true, @@ -232,12 +210,12 @@ class _Menus extends HookConsumerWidget { ], ), PlatformMenu( - label: context.l10n.file, + label: l10n.file, menus: [ PlatformMenuItemGroup( members: [ PlatformMenuItem( - label: context.l10n.createConversation, + label: l10n.createConversation, shortcut: const SingleActivator( LogicalKeyboardKey.keyN, meta: true, @@ -253,7 +231,7 @@ class _Menus extends HookConsumerWidget { : null, ), PlatformMenuItem( - label: context.l10n.createGroup, + label: l10n.createGroup, shortcut: const SingleActivator( LogicalKeyboardKey.keyN, shift: true, @@ -283,7 +261,7 @@ class _Menus extends HookConsumerWidget { ], ), PlatformMenuItem( - label: context.l10n.createCircle, + label: l10n.createCircle, onSelected: signed ? () { windowManager.show(); @@ -297,7 +275,7 @@ class _Menus extends HookConsumerWidget { PlatformMenuItemGroup( members: [ PlatformMenuItem( - label: context.l10n.closeWindow, + label: l10n.closeWindow, onSelected: windowManager.close, ), ], @@ -308,10 +286,10 @@ class _Menus extends HookConsumerWidget { ), buildConversationMenu(), PlatformMenu( - label: context.l10n.window, + label: l10n.window, menus: [ PlatformMenuItem( - label: context.l10n.minimize, + label: l10n.minimize, shortcut: const SingleActivator( LogicalKeyboardKey.keyM, meta: true, @@ -319,7 +297,7 @@ class _Menus extends HookConsumerWidget { onSelected: windowManager.minimize, ), PlatformMenuItem( - label: context.l10n.zoom, + label: l10n.zoom, onSelected: () async => !await windowManager.isMaximized() ? windowManager.maximize() : windowManager.restore(), @@ -327,7 +305,7 @@ class _Menus extends HookConsumerWidget { PlatformMenuItemGroup( members: [ PlatformMenuItem( - label: context.l10n.previousConversation, + label: l10n.previousConversation, shortcut: const SingleActivator( LogicalKeyboardKey.arrowUp, meta: true, @@ -342,7 +320,7 @@ class _Menus extends HookConsumerWidget { : null, ), PlatformMenuItem( - label: context.l10n.nextConversation, + label: l10n.nextConversation, shortcut: const SingleActivator( LogicalKeyboardKey.arrowDown, meta: true, @@ -373,7 +351,7 @@ class _Menus extends HookConsumerWidget { PlatformMenuItemGroup( members: [ PlatformMenuItem( - label: context.l10n.bringAllToFront, + label: l10n.bringAllToFront, onSelected: windowManager.show, ), ], @@ -382,29 +360,56 @@ class _Menus extends HookConsumerWidget { ], ), PlatformMenu( - label: context.l10n.help, + label: l10n.help, menus: [ PlatformMenuItem( - label: context.l10n.helpCenter, - onSelected: () => openUri(context, 'https://support.mixin.one/'), + label: l10n.helpCenter, + onSelected: () => openUri( + context, + 'https://support.mixin.one/', + container: ref.container, + ), ), PlatformMenuItem( - label: context.l10n.termsOfService, - onSelected: () => openUri(context, 'https://mixin.one/pages/terms'), + label: l10n.termsOfService, + onSelected: () => openUri( + context, + 'https://mixin.one/pages/terms', + container: ref.container, + ), ), PlatformMenuItem( - label: context.l10n.privacyPolicy, - onSelected: () => - openUri(context, 'https://mixin.one/pages/privacy'), + label: l10n.privacyPolicy, + onSelected: () => openUri( + context, + 'https://mixin.one/pages/privacy', + container: ref.container, + ), ), ], ), ]; - useEffect(() { - WidgetsBinding.instance.platformMenuDelegate.setMenus(menus); - }, [menus]); + WidgetsBinding.instance.platformMenuDelegate.setMenus(menus); return child; } } + +final _macMenuMutedProvider = StreamProvider.autoDispose((ref) { + final handle = ref.watch(macMenuBarProvider); + return handle?.isMuted ?? Stream.value(false); +}); + +final _macMenuPinnedProvider = StreamProvider.autoDispose((ref) { + final handle = ref.watch(macMenuBarProvider); + return handle?.isPinned ?? Stream.value(false); +}); + +final _macMenuHasPasscodeProvider = StreamProvider.autoDispose + .family((ref, signed) { + if (!signed) { + return Stream.value(false); + } + return SecurityKeyValue.instance.watchHasPasscode(); + }); diff --git a/lib/workers/db_write_worker_isolate.dart b/lib/workers/db_write_worker_isolate.dart new file mode 100644 index 0000000000..f69440d3a3 --- /dev/null +++ b/lib/workers/db_write_worker_isolate.dart @@ -0,0 +1,865 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:isolate'; + +import 'package:drift/drift.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart' as sdk; +import 'package:stream_channel/isolate_channel.dart'; + +import '../constants/constants.dart'; +import '../db/dao/message_dao.dart'; +import '../db/database.dart'; +import '../db/database_event_bus.dart'; +import '../db/extension/job.dart'; +import '../db/extension/message.dart'; +import '../db/fts_database.dart'; +import '../db/mixin_database.dart' as db; +import '../runtime/db_write/method.dart'; +import '../runtime/db_write/payload.dart'; +import '../runtime/isolate/protocol.dart'; +import '../runtime/isolate/router.dart'; +import '../runtime/sync/tick_patch_batcher.dart'; +import '../utils/logger.dart'; + +class DbWriteWorkerInitParams { + DbWriteWorkerInitParams({ + required this.sendPort, + required this.identityNumber, + }); + + final SendPort sendPort; + final String identityNumber; +} + +Future startDbWriteWorkerIsolate(DbWriteWorkerInitParams params) async { + final isolateChannel = IsolateChannel.connectSend(params.sendPort); + final router = IsolateRouter.worker( + inbound: isolateChannel.stream, + sendMessage: isolateChannel.sink.add, + ); + + final runner = _DbWriteRunner( + identityNumber: params.identityNumber, + emitEvent: router.sendEvent, + ); + await runner.init(); + router.commands.listen((command) async { + if (command is! ExitWorkerCommand) return; + await runner.dispose(); + Isolate.exit(); + }); + router.rpcRequests.listen((request) async { + try { + final result = await runner.handleRequest(request); + router.sendRpcResponse( + RpcSuccessResponse(requestId: request.requestId, result: result), + ); + } catch (error, stackTrace) { + e('db_write_worker rpc error: $error, $stackTrace'); + router.sendRpcResponse( + RpcErrorResponse( + requestId: request.requestId, + code: 'db_write_failed', + message: error.toString(), + ), + ); + } + }); + router.sendReady(); +} + +class _DbWriteRunner { + _DbWriteRunner({ + required this.identityNumber, + required this.emitEvent, + }); + + final String identityNumber; + final void Function(WorkerEvent event) emitEvent; + late final TickPatchBatcher _patchBatcher = TickPatchBatcher( + onFlush: (patches) => emitEvent(WorkerSyncPatchesEvent(patches: patches)), + ); + + late Database database; + final _subscriptions = >[]; + + Future init() async { + DataBaseEventBus.instance.legacyEventBridgeEnabled = false; + database = Database( + await db.connectToDatabase(identityNumber, readCount: 2), + await FtsDatabase.connect(identityNumber), + ); + _subscriptions.add( + DataBaseEventBus.instance.patchStream.listen(_patchBatcher.add), + ); + } + + Future handleRequest(RpcRequest request) async { + final method = DbWriteMethod.values.byName(request.method); + switch (method) { + case DbWriteMethod.insertUpdateUsers: + final users = (request.payload! as List).cast(); + await _insertUpdateUsers(users); + return null; + case DbWriteMethod.insertJob: + final job = request.payload! as db.Job; + await database.jobDao.insert(job); + return null; + case DbWriteMethod.insertJobs: + final jobs = (request.payload! as List).cast(); + await database.jobDao.insertAll(jobs); + return null; + case DbWriteMethod.deleteJobById: + final jobId = request.payload! as String; + await database.jobDao.deleteJobById(jobId); + return null; + case DbWriteMethod.deleteJobs: + final payload = request.payload! as DbWriteDeleteJobsPayload; + await database.jobDao.deleteJobs(payload.jobIds); + return null; + case DbWriteMethod.deleteJobByAction: + final action = request.payload! as String; + await database.jobDao.deleteJobByAction(action); + return null; + case DbWriteMethod.updateJobRunState: + final payload = request.payload! as DbWriteUpdateJobRunStatePayload; + await (database.mixinDatabase.update( + database.mixinDatabase.jobs, + )..where((tbl) => tbl.jobId.equals(payload.jobId))).write( + db.JobsCompanion( + createdAt: Value(payload.createdAt), + runCount: Value(payload.runCount), + ), + ); + return null; + case DbWriteMethod.upsertConversation: + final conversation = request.payload! as db.Conversation; + await database.conversationDao.insert(conversation); + return null; + case DbWriteMethod.replaceParticipants: + final payload = request.payload! as DbWriteReplaceParticipantsPayload; + await database.participantDao.replaceAll( + payload.conversationId, + payload.participants, + ); + return null; + case DbWriteMethod.replaceParticipantSessions: + final payload = + request.payload! as DbWriteReplaceParticipantSessionsPayload; + await database.participantSessionDao.replaceAll( + payload.conversationId, + payload.sessions, + ); + return null; + case DbWriteMethod.insertParticipantSessions: + final payload = + request.payload! as DbWriteInsertParticipantSessionsPayload; + await database.participantSessionDao.insertAll(payload.sessions); + return null; + case DbWriteMethod.updateConversationStatus: + final payload = + request.payload! as DbWriteUpdateConversationStatusPayload; + await database.conversationDao.updateConversationStatusById( + payload.conversationId, + payload.status, + ); + return null; + case DbWriteMethod.removeParticipantAndResetSessions: + final payload = + request.payload! as DbWriteRemoveParticipantAndResetSessionsPayload; + await database.transaction(() async { + await database.participantDao.deleteByCIdAndPId( + payload.conversationId, + payload.participantId, + ); + await database.participantSessionDao.deleteByCIdAndPId( + payload.conversationId, + payload.participantId, + ); + await database.participantSessionDao.emptyStatusByConversationId( + payload.conversationId, + ); + }); + return null; + case DbWriteMethod.upsertApp: + final app = request.payload! as db.App; + await database.appDao.insert(app); + return null; + case DbWriteMethod.upsertUser: + final user = request.payload! as db.User; + await database.userDao.insert(user); + return null; + case DbWriteMethod.upsertParticipant: + final participant = request.payload! as db.Participant; + await database.participantDao.insert(participant); + return null; + case DbWriteMethod.upsertSdkUser: + final user = request.payload! as sdk.User; + await database.userDao.insertSdkUser(user); + return null; + case DbWriteMethod.updateConversationFromResponse: + final payload = request.payload! as DbWriteUpdateConversationPayload; + await database.conversationDao.updateConversation( + payload.conversation, + payload.currentUserId, + ); + return null; + case DbWriteMethod.updateConversationExpireIn: + final payload = + request.payload! as DbWriteUpdateConversationExpireInPayload; + await database.conversationDao.updateConversationExpireIn( + payload.conversationId, + payload.expireIn, + ); + return null; + case DbWriteMethod.updateParticipantRole: + final payload = request.payload! as DbWriteUpdateParticipantRolePayload; + await database.participantDao.updateParticipantRole( + payload.conversationId, + payload.participantId, + payload.role, + ); + return null; + case DbWriteMethod.upsertCircle: + final payload = request.payload! as DbWriteCirclePayload; + await database.circleDao.insertUpdate(payload.circle); + return null; + case DbWriteMethod.upsertCircleConversations: + final payload = request.payload! as DbWriteCircleConversationsPayload; + for (final item in payload.items) { + await database.circleConversationDao.insert(item); + } + return null; + case DbWriteMethod.deleteCircleConversationById: + final payload = + request.payload! as DbWriteDeleteCircleConversationPayload; + await database.circleConversationDao.deleteById( + payload.conversationId, + payload.circleId, + ); + return null; + case DbWriteMethod.deleteCircleAndConversations: + final circleId = request.payload! as String; + await database.transaction(() async { + await database.circleDao.deleteCircleById(circleId); + await database.circleConversationDao.deleteByCircleId(circleId); + }); + return null; + case DbWriteMethod.upsertContactConversation: + final payload = + request.payload! as DbWriteUpsertContactConversationPayload; + await database.transaction(() async { + await database.conversationDao.insert( + db.Conversation( + conversationId: payload.conversationId, + category: sdk.ConversationCategory.contact, + createdAt: payload.createdAt, + ownerId: payload.recipientId, + status: sdk.ConversationStatus.start, + ), + ); + await database.participantDao.insert( + db.Participant( + conversationId: payload.conversationId, + userId: payload.currentUserId, + createdAt: payload.createdAt, + ), + ); + await database.participantDao.insert( + db.Participant( + conversationId: payload.conversationId, + userId: payload.recipientId, + createdAt: payload.createdAt, + ), + ); + }); + return null; + case DbWriteMethod.updateUserMuteUntil: + final payload = request.payload! as DbWriteUpdateUserMuteUntilPayload; + await database.userDao.updateMuteUntil( + payload.userId, + payload.muteUntil, + ); + return null; + case DbWriteMethod.updateConversationMuteUntil: + final payload = + request.payload! as DbWriteUpdateConversationMuteUntilPayload; + await database.conversationDao.updateMuteUntil( + payload.conversationId, + payload.muteUntil, + ); + return null; + case DbWriteMethod.updateConversationCodeUrl: + final payload = + request.payload! as DbWriteUpdateConversationCodeUrlPayload; + await database.conversationDao.updateCodeUrl( + payload.conversationId, + payload.codeUrl, + ); + return null; + case DbWriteMethod.cleanupParticipantSession: + final payload = + request.payload! as DbWriteCleanupParticipantSessionPayload; + await database.participantSessionDao.deleteBySessionId( + payload.sessionId, + ); + await database.participantSessionDao.updateSentToServer(); + return null; + case DbWriteMethod.pinConversation: + final conversationId = request.payload! as String; + await database.conversationDao.pin(conversationId); + return null; + case DbWriteMethod.unpinConversation: + final conversationId = request.payload! as String; + await database.conversationDao.unpin(conversationId); + return null; + case DbWriteMethod.markMentionRead: + final messageId = request.payload! as String; + await database.messageMentionDao.markMentionRead(messageId); + return null; + case DbWriteMethod.parseMentionData: + final payload = request.payload! as DbWriteParseMentionDataPayload; + await database.messageMentionDao.parseMentionData( + payload.content, + payload.messageId, + payload.conversationId, + payload.senderId, + payload.quoteContentJson == null + ? null + : mapToQuoteMessage( + jsonDecode(payload.quoteContentJson!) as Map, + ), + payload.currentUserId, + payload.currentUserIdentityNumber, + ); + return null; + case DbWriteMethod.deleteFloodMessage: + final floodMessage = request.payload! as db.FloodMessage; + await database.floodMessageDao.deleteFloodMessage(floodMessage); + return null; + case DbWriteMethod.upsertOffset: + final offset = request.payload! as db.Offset; + await database.offsetDao.insert(offset); + return null; + case DbWriteMethod.updateMessageStatusById: + final payload = request.payload! as DbWriteUpdateMessageStatusPayload; + await database.messageDao.updateMessageStatusById( + payload.messageId, + payload.status, + ); + return null; + case DbWriteMethod.deleteExpiredMessageByMessageId: + final messageId = request.payload! as String; + await database.expiredMessageDao.deleteByMessageId(messageId); + return null; + case DbWriteMethod.insertExpiredMessage: + final payload = request.payload! as DbWriteInsertExpiredMessagePayload; + await database.expiredMessageDao.insert( + messageId: payload.messageId, + expireIn: payload.expireIn, + expireAt: payload.expireAt, + ); + return null; + case DbWriteMethod.updateExpiredMessageExpireAt: + final payload = + request.payload! as DbWriteUpdateExpiredMessageExpireAtPayload; + await database.expiredMessageDao.updateMessageExpireAt( + payload.expireAt, + payload.messageId, + ); + return null; + case DbWriteMethod.markMessagesRead: + final payload = request.payload! as DbWriteMarkMessagesReadPayload; + await database.messageDao.markMessageRead( + payload.items + .map( + (item) => MiniMessageItem( + messageId: item.messageId, + conversationId: item.conversationId, + ), + ) + .toList(), + updateExpired: false, + ); + return null; + case DbWriteMethod.markExpiredMessagesRead: + final messageIds = (request.payload! as List).cast(); + await database.expiredMessageDao.onMessageRead(messageIds); + return null; + case DbWriteMethod.takeConversationUnseen: + final payload = + request.payload! as DbWriteTakeConversationUnseenPayload; + await database.messageDao.takeUnseen( + payload.currentUserId, + payload.conversationId, + ); + return null; + case DbWriteMethod.insertSendMessage: + final payload = request.payload! as DbWriteInsertSendMessagePayload; + await database.messageDao.insert( + payload.message, + payload.currentUserId, + silent: payload.silent, + expireIn: payload.expireIn, + cleanDraft: payload.cleanDraft, + ); + await database.ftsDatabase.insertFts( + payload.message, + payload.ftsContent, + ); + return null; + case DbWriteMethod.insertMessageHistory: + final payload = request.payload! as DbWriteInsertMessageHistoryPayload; + await database.messageHistoryDao.insert( + db.MessagesHistoryData(messageId: payload.messageId), + ); + return null; + case DbWriteMethod.insertMessageHistoryBatch: + final payload = + request.payload! as DbWriteInsertMessageHistoryBatchPayload; + if (payload.messageIds.isEmpty) return null; + await database.messageHistoryDao.insertList( + payload.messageIds.map( + (id) => db.MessagesHistoryData(messageId: id), + ), + ); + return null; + case DbWriteMethod.insertResendSessionMessage: + final payload = + request.payload! as DbWriteInsertResendSessionMessagePayload; + await database.resendSessionMessageDao.insert(payload.message); + return null; + case DbWriteMethod.updateAttachmentMessageContentAndStatus: + final payload = + request.payload! + as DbWriteUpdateAttachmentMessageContentAndStatusPayload; + await database.messageDao.updateAttachmentMessageContentAndStatus( + payload.messageId, + payload.content, + payload.key, + payload.digest, + ); + return null; + case DbWriteMethod.updateMessageContentAndStatus: + final payload = + request.payload! as DbWriteUpdateMessageContentAndStatusPayload; + await database.messageDao.updateMessageContentAndStatus( + payload.messageId, + payload.content, + payload.status, + ); + return null; + case DbWriteMethod.updateMessageContent: + final payload = request.payload! as DbWriteUpdateMessageContentPayload; + await database.messageDao.updateMessageContent( + payload.messageId, + payload.content, + ); + return null; + case DbWriteMethod.updateMessageCategoryById: + final payload = request.payload! as DbWriteUpdateMessageCategoryPayload; + await database.messageDao.updateCategoryById( + payload.messageId, + payload.category, + ); + return null; + case DbWriteMethod.deleteResendSessionMessageById: + final payload = + request.payload! as DbWriteDeleteResendSessionMessagePayload; + await database.resendSessionMessageDao.deleteResendSessionMessageById( + payload.messageId, + ); + return null; + case DbWriteMethod.updateAttachmentMessage: + final payload = + request.payload! as DbWriteUpdateAttachmentMessagePayload; + await database.messageDao.updateAttachmentMessage( + payload.messageId, + db.MessagesCompanion( + status: Value(payload.status), + content: Value(payload.content), + mediaMimeType: Value(payload.mediaMimeType), + mediaSize: Value(payload.mediaSize), + mediaStatus: Value(payload.mediaStatus), + mediaWidth: Value(payload.mediaWidth), + mediaHeight: Value(payload.mediaHeight), + mediaDigest: Value(payload.mediaDigest), + mediaKey: Value(payload.mediaKey), + mediaWaveform: Value(payload.mediaWaveform), + caption: Value(payload.caption), + name: Value(payload.name), + thumbImage: Value(payload.thumbImage), + mediaDuration: Value(payload.mediaDuration), + ), + ); + return null; + case DbWriteMethod.updateStickerMessage: + final payload = request.payload! as DbWriteUpdateStickerMessagePayload; + await database.messageDao.updateStickerMessage( + payload.messageId, + payload.status, + payload.stickerId, + ); + return null; + case DbWriteMethod.updateContactMessage: + final payload = request.payload! as DbWriteUpdateContactMessagePayload; + await database.messageDao.updateContactMessage( + payload.messageId, + payload.status, + payload.sharedUserId, + ); + return null; + case DbWriteMethod.updateLiveMessage: + final payload = request.payload! as DbWriteUpdateLiveMessagePayload; + await database.messageDao.updateLiveMessage( + payload.messageId, + payload.width, + payload.height, + payload.url, + payload.thumbUrl, + payload.status, + ); + return null; + case DbWriteMethod.updateTranscriptMessage: + final payload = + request.payload! as DbWriteUpdateTranscriptMessagePayload; + await database.messageDao.updateTranscriptMessage( + payload.content, + payload.mediaSize, + payload.mediaStatus, + payload.status, + payload.messageId, + ); + return null; + case DbWriteMethod.deletePendingSafeSnapshotByHash: + final payload = + request.payload! as DbWriteDeletePendingSafeSnapshotByHashPayload; + await database.safeSnapshotDao.deletePendingSnapshotByHash( + payload.depositHash, + ); + return null; + case DbWriteMethod.recallMessage: + final payload = request.payload! as DbWriteRecallMessagePayload; + await database.messageDao.recallMessage( + payload.conversationId, + payload.messageId, + ); + return null; + case DbWriteMethod.deleteMessageMention: + final payload = request.payload! as DbWriteDeleteMessageMentionPayload; + await database.messageMentionDao.deleteMessageMention( + payload.messageMention, + ); + return null; + case DbWriteMethod.updateQuoteContentByQuoteId: + final payload = + request.payload! as DbWriteUpdateQuoteContentByQuoteIdPayload; + await database.messageDao.updateQuoteContentByQuoteId( + payload.conversationId, + payload.quoteMessageId, + payload.content, + ); + return null; + case DbWriteMethod.insertTranscriptMessages: + final payload = + request.payload! as DbWriteInsertTranscriptMessagesPayload; + await database.transcriptMessageDao.insertAll(payload.transcripts); + return null; + case DbWriteMethod.updateTranscript: + final payload = request.payload! as DbWriteUpdateTranscriptPayload; + await database.transcriptMessageDao.updateTranscript( + transcriptId: payload.transcriptId, + messageId: payload.messageId, + attachmentId: payload.attachmentId, + key: payload.key, + digest: payload.digest, + mediaStatus: payload.mediaStatus, + mediaCreatedAt: payload.mediaCreatedAt, + category: payload.category, + ); + return null; + case DbWriteMethod.updateMessageMediaStatus: + final payload = + request.payload! as DbWriteUpdateMessageMediaStatusPayload; + await database.messageDao.updateMediaStatus( + payload.messageId, + payload.status, + ); + return null; + case DbWriteMethod.pinAndInsertPinMessages: + final payload = + request.payload! as DbWritePinAndInsertPinMessagesPayload; + for (final pinMessage in payload.pinMessages) { + await database.pinMessageDao.insert(pinMessage); + } + for (final message in payload.systemMessages) { + await database.messageDao.insert( + message, + payload.currentUserId, + cleanDraft: false, + ); + } + return null; + case DbWriteMethod.deletePinMessagesByIds: + final payload = request.payload! as DbWriteDeletePinMessagesPayload; + await database.pinMessageDao.deleteByIds(payload.messageIds); + return null; + case DbWriteMethod.updateGiphyMessage: + final payload = request.payload! as DbWriteUpdateGiphyMessagePayload; + await database.messageDao.updateGiphyMessage( + payload.messageId, + payload.mediaUrl, + payload.mediaSize, + payload.thumbImage, + ); + return null; + case DbWriteMethod.insertFts: + final payload = request.payload! as DbWriteInsertFtsPayload; + await database.ftsDatabase.insertFts( + payload.message, + payload.content, + ); + return null; + case DbWriteMethod.deleteFtsByMessageId: + final messageId = request.payload! as String; + await database.ftsDatabase.deleteByMessageId(messageId); + return null; + case DbWriteMethod.deleteFtsByConversationId: + final conversationId = request.payload! as String; + await database.ftsDatabase.deleteByConversationId(conversationId); + return null; + case DbWriteMethod.deleteConversation: + final conversationId = request.payload! as String; + await database.conversationDao.deleteConversation(conversationId); + return null; + case DbWriteMethod.updateConversationDraft: + final payload = + request.payload! as DbWriteUpdateConversationDraftPayload; + await database.conversationDao.updateDraft( + payload.conversationId, + payload.draft, + ); + return null; + case DbWriteMethod.updateCircleOrders: + final payload = request.payload! as DbWriteUpdateCircleOrdersPayload; + await database.circleDao.updateOrders(payload.items); + return null; + case DbWriteMethod.insertSticker: + final sticker = request.payload! as db.StickersCompanion; + await database.stickerDao.insert(sticker); + return null; + case DbWriteMethod.insertStickerAndRelationship: + final payload = + request.payload! as DbWriteInsertStickerAndRelationshipPayload; + await database.transaction(() async { + await database.stickerDao.insert(payload.sticker); + await database.stickerRelationshipDao.insert(payload.relationship); + }); + return null; + case DbWriteMethod.deletePersonalSticker: + final stickerId = request.payload! as String; + await database.stickerDao.deletePersonalSticker(stickerId); + return null; + case DbWriteMethod.updateStickerUsedAt: + final payload = request.payload! as DbWriteUpdateStickerUsedAtPayload; + await database.stickerDao.updateUsedAt( + payload.albumId, + payload.stickerId, + payload.usedAt, + ); + return null; + case DbWriteMethod.updateStickerAlbumAdded: + final payload = + request.payload! as DbWriteUpdateStickerAlbumAddedPayload; + await database.stickerAlbumDao.updateAdded( + payload.albumId, + payload.added, + ); + return null; + case DbWriteMethod.updateStickerAlbumOrders: + final payload = + request.payload! as DbWriteUpdateStickerAlbumOrdersPayload; + await database.stickerAlbumDao.updateOrders(payload.albums); + return null; + case DbWriteMethod.updateSafeSnapshotMessage: + final payload = + request.payload! as DbWriteUpdateSafeSnapshotMessagePayload; + await database.messageDao.updateSafeSnapshotMessage( + payload.messageId, + payload.snapshotId, + ); + return null; + case DbWriteMethod.deleteMessage: + final payload = request.payload! as DbWriteDeleteMessagePayload; + await database.messageDao.deleteMessage( + payload.conversationId, + payload.messageId, + ); + return null; + case DbWriteMethod.deleteMessagesByConversation: + final payload = + request.payload! as DbWriteDeleteMessagesByConversationPayload; + await database.messageDao.deleteMessagesByConversationId( + payload.conversationId, + ); + await database.messageMentionDao.clearMessageMentionByConversationId( + payload.conversationId, + ); + return null; + case DbWriteMethod.upsertAssetAndChain: + final payload = request.payload! as DbWriteUpsertAssetAndChainPayload; + await Future.wait([ + database.assetDao.insertSdkAsset(payload.asset), + database.chainDao.insertSdkChain(payload.chain), + ]); + return null; + case DbWriteMethod.upsertTokenAndChain: + final payload = request.payload! as DbWriteUpsertTokenAndChainPayload; + await Future.wait([ + database.tokenDao.insertSdkToken(payload.token), + database.chainDao.insertSdkChain(payload.chain), + ]); + return null; + case DbWriteMethod.upsertSnapshot: + final snapshot = request.payload!; + if (snapshot is sdk.Snapshot) { + await database.snapshotDao.insertSdkSnapshot(snapshot); + } else if (snapshot is db.Snapshot) { + await database.snapshotDao.insert(snapshot); + } else { + throw ArgumentError( + 'invalid snapshot payload type: ${snapshot.runtimeType}', + ); + } + return null; + case DbWriteMethod.upsertSafeSnapshot: + final snapshot = request.payload!; + if (snapshot is sdk.SafeSnapshot) { + await database.safeSnapshotDao.insertSdkSnapshot(snapshot); + } else if (snapshot is db.SafeSnapshot) { + await database.safeSnapshotDao.insert(snapshot); + } else { + throw ArgumentError( + 'invalid safe snapshot payload type: ${snapshot.runtimeType}', + ); + } + return null; + case DbWriteMethod.replaceFiats: + final fiats = (request.payload! as List).cast(); + await database.fiatDao.insertAllSdkFiat(fiats); + return null; + case DbWriteMethod.insertFavoriteApps: + final payload = request.payload! as DbWriteFavoriteAppsPayload; + await database.favoriteAppDao.insertFavoriteApps( + payload.userId, + payload.apps, + ); + return null; + case DbWriteMethod.upsertStickerAlbum: + final stickerAlbum = request.payload! as db.StickerAlbumsCompanion; + await database.stickerAlbumDao.insert(stickerAlbum); + return null; + case DbWriteMethod.replaceStickersByAlbum: + final payload = + request.payload! as DbWriteReplaceStickersByAlbumPayload; + await database.transaction(() async { + await database.stickerRelationshipDao.insertAll( + payload.relationships, + ); + await database.stickerDao.insertAll(payload.stickers); + }); + return null; + case DbWriteMethod.insertFloodMessage: + final floodMessage = request.payload! as db.FloodMessage; + await database.floodMessageDao.insert(floodMessage); + return null; + case DbWriteMethod.deleteLegacyFtsChunk: + await database.mixinDatabase.customStatement( + 'DELETE FROM messages_fts WHERE rowid IN (SELECT rowid FROM messages_fts LIMIT 1000)', + ); + return null; + case DbWriteMethod.migrateFtsInsertBatch: + final payload = request.payload! as DbWriteMigrateFtsInsertBatchPayload; + final messageMeta = {}; + for (final message in payload.messages) { + final exists = await database.ftsDatabase + .checkMessageMetaExists(message.messageId) + .getSingle(); + if (exists) { + continue; + } + final rowId = await database.ftsDatabase.insertFtsOnly( + message, + payload.transcriptContentMap[message.messageId], + ); + if (rowId != null) { + messageMeta[rowId] = message; + } + } + if (messageMeta.isNotEmpty) { + await database.ftsDatabase.batch((batch) { + batch.insertAll(database.ftsDatabase.messagesMetas, [ + for (final entry in messageMeta.entries) + MessagesMeta( + docId: entry.key, + messageId: entry.value.messageId, + conversationId: entry.value.conversationId, + category: entry.value.category, + userId: entry.value.userId, + createdAt: entry.value.createdAt, + ), + ]); + }); + } + return null; + case DbWriteMethod.replaceMigrateFtsJob: + final payload = request.payload! as DbWriteReplaceMigrateFtsJobPayload; + await database.jobDao.transaction(() async { + await database.jobDao.deleteJobByAction(kMigrateFts); + await database.jobDao.insert( + createMigrationFtsJob(payload.messageRowId), + ); + }); + return null; + case DbWriteMethod.insertCleanupQuoteContentJob: + await database.jobDao.insert(createCleanupQuoteContentJob()); + return null; + } + } + + Future _insertUpdateUsers(List users) async { + if (users.isEmpty) return; + await database.userDao.insertAllSdkUser(users); + + for (final user in users) { + final app = user.app; + if (app == null) continue; + await database.appDao.insert( + db.App( + appId: app.appId, + appNumber: app.appNumber, + homeUri: app.homeUri, + redirectUri: app.redirectUri, + name: app.name, + iconUrl: app.iconUrl, + category: app.category, + description: app.description, + appSecret: app.category, + capabilities: app.capabilites?.toString(), + creatorId: app.creatorId, + resourcePatterns: app.resourcePatterns?.toString(), + updatedAt: app.updatedAt, + ), + ); + } + } + + Future dispose() async { + _patchBatcher.dispose(); + await Future.wait( + _subscriptions.map((subscription) => subscription.cancel()), + ); + _subscriptions.clear(); + await database.dispose(); + } +} diff --git a/lib/workers/decrypt_message.dart b/lib/workers/decrypt_message.dart index 572915ea3d..8ae359017f 100644 --- a/lib/workers/decrypt_message.dart +++ b/lib/workers/decrypt_message.dart @@ -35,6 +35,9 @@ import '../enum/message_action.dart'; import '../enum/message_category.dart'; import '../enum/system_circle_action.dart'; import '../enum/system_user_action.dart'; +import '../runtime/db_write/method.dart'; +import '../runtime/db_write/payload.dart'; +import '../runtime/isolate/protocol.dart'; import '../utils/device_transfer/transfer_data_command.dart'; import '../utils/extension/extension.dart'; import '../utils/load_balancer_utils.dart'; @@ -49,8 +52,8 @@ import 'job/sync_inscription_message_job.dart'; import 'job/update_asset_job.dart'; import 'job/update_sticker_job.dart'; import 'job/update_token_job.dart'; -import 'message_worker_isolate.dart'; import 'sender.dart'; +import 'sync_worker_isolate.dart'; class DecryptMessage extends Injector { DecryptMessage( @@ -70,12 +73,19 @@ class DecryptMessage extends Injector { this._deviceTransfer, this._updateTokenJob, this._syncInscriptionJob, - ) : super(userId, database, client) { + Future Function(DbWriteMethod method, {Object? payload}) + requestDbWrite, + ) : _requestDbWrite = requestDbWrite, + super( + userId, + database, + client, + requestDbWrite: requestDbWrite, + ) { _encryptedProtocol = EncryptedProtocol(); } - final void Function(WorkerIsolateEventType event, [dynamic argument]) - _isolateEventSender; + final void Function(WorkerEvent event) _isolateEventSender; String? _conversationId; late final SignalProtocol _signalProtocol; @@ -92,6 +102,8 @@ class DecryptMessage extends Injector { final UpdateTokenJob _updateTokenJob; final SyncInscriptionMessageJob _syncInscriptionJob; final DeviceTransferIsolateController? _deviceTransfer; + final Future Function(DbWriteMethod method, {Object? payload}) + _requestDbWrite; final refreshKeyMap = {}; @@ -112,23 +124,281 @@ class DecryptMessage extends Injector { } Future _insertMessage(Message message, BlazeMessageData data) async { - await database.messageDao.insert( - message, - accountId, - silent: data.silent, - expireIn: data.expireIn, + await _requestDbWrite( + DbWriteMethod.insertSendMessage, + payload: DbWriteInsertSendMessagePayload( + message: message, + currentUserId: accountId, + expireIn: data.expireIn, + cleanDraft: false, + silent: data.silent ?? false, + ), ); - unawaited(database.ftsDatabase.insertFts(message)); if (data.expireIn > 0 && message.userId == accountId) { final expiredAt = data.createdAt.millisecondsSinceEpoch ~/ 1000 + data.expireIn; - await database.expiredMessageDao.updateMessageExpireAt( - expiredAt, - message.messageId, + await _requestDbWrite( + DbWriteMethod.updateExpiredMessageExpireAt, + payload: DbWriteUpdateExpiredMessageExpireAtPayload( + messageId: message.messageId, + expireAt: expiredAt, + ), ); } } + Future _insertMessageHistory(String messageId) => _requestDbWrite( + DbWriteMethod.insertMessageHistory, + payload: DbWriteInsertMessageHistoryPayload(messageId: messageId), + ); + + Future _insertResendSessionMessage(db.ResendSessionMessage message) => + _requestDbWrite( + DbWriteMethod.insertResendSessionMessage, + payload: DbWriteInsertResendSessionMessagePayload(message: message), + ); + + Future _parseMentionData({ + required String? content, + required String messageId, + required String conversationId, + required String senderId, + required QuoteMessageItem? quoteContent, + }) => _requestDbWrite( + DbWriteMethod.parseMentionData, + payload: DbWriteParseMentionDataPayload( + content: content, + messageId: messageId, + conversationId: conversationId, + senderId: senderId, + quoteContentJson: quoteContent?.toJson(), + currentUserId: accountId, + currentUserIdentityNumber: identityNumber, + ), + ); + + Future _deleteFloodMessage(db.FloodMessage floodMessage) => + _requestDbWrite( + DbWriteMethod.deleteFloodMessage, + payload: floodMessage, + ); + + Future _insertTranscriptMessages( + List transcripts, + ) => _requestDbWrite( + DbWriteMethod.insertTranscriptMessages, + payload: DbWriteInsertTranscriptMessagesPayload( + transcripts: transcripts, + ), + ); + + Future _insertFts(db.Message message, {String? content}) => + _requestDbWrite( + DbWriteMethod.insertFts, + payload: DbWriteInsertFtsPayload(message: message, content: content), + ); + + Future _upsertUser(db.User user) => _requestDbWrite( + DbWriteMethod.upsertUser, + payload: user, + ); + + Future _upsertParticipant(db.Participant participant) => + _requestDbWrite( + DbWriteMethod.upsertParticipant, + payload: participant, + ); + + Future _updateConversationStatus( + String conversationId, + ConversationStatus status, + ) => _requestDbWrite( + DbWriteMethod.updateConversationStatus, + payload: DbWriteUpdateConversationStatusPayload( + conversationId: conversationId, + status: status, + ), + ); + + Future _updateConversationExpireIn( + String conversationId, + int expireIn, + ) => _requestDbWrite( + DbWriteMethod.updateConversationExpireIn, + payload: DbWriteUpdateConversationExpireInPayload( + conversationId: conversationId, + expireIn: expireIn, + ), + ); + + Future _updateParticipantRole( + String conversationId, + String participantId, + ParticipantRole? role, + ) => _requestDbWrite( + DbWriteMethod.updateParticipantRole, + payload: DbWriteUpdateParticipantRolePayload( + conversationId: conversationId, + participantId: participantId, + role: role, + ), + ); + + Future _upsertCircleConversations(List items) => + _requestDbWrite( + DbWriteMethod.upsertCircleConversations, + payload: DbWriteCircleConversationsPayload(items: items), + ); + + Future _deleteCircleConversationById( + String conversationId, + String circleId, + ) => _requestDbWrite( + DbWriteMethod.deleteCircleConversationById, + payload: DbWriteDeleteCircleConversationPayload( + conversationId: conversationId, + circleId: circleId, + ), + ); + + Future _deleteCircleAndConversations(String circleId) => + _requestDbWrite( + DbWriteMethod.deleteCircleAndConversations, + payload: circleId, + ); + + Future _upsertSnapshot(db.Snapshot snapshot) => _requestDbWrite( + DbWriteMethod.upsertSnapshot, + payload: snapshot, + ); + + Future _upsertSafeSnapshot(db.SafeSnapshot snapshot) => _requestDbWrite( + DbWriteMethod.upsertSafeSnapshot, + payload: snapshot, + ); + + Future _deletePendingSafeSnapshotByHash(String depositHash) => + _requestDbWrite( + DbWriteMethod.deletePendingSafeSnapshotByHash, + payload: DbWriteDeletePendingSafeSnapshotByHashPayload( + depositHash: depositHash, + ), + ); + + Future _markMessagesRead(List items) => + _requestDbWrite( + DbWriteMethod.markMessagesRead, + payload: DbWriteMarkMessagesReadPayload( + items: items + .map( + (item) => DbWriteMiniMessagePayload( + messageId: item.messageId, + conversationId: item.conversationId, + ), + ) + .toList(), + ), + ); + + Future _updateMessageContentAndStatus({ + required String messageId, + required String? content, + required MessageStatus status, + }) => _requestDbWrite( + DbWriteMethod.updateMessageContentAndStatus, + payload: DbWriteUpdateMessageContentAndStatusPayload( + messageId: messageId, + content: content, + status: status, + ), + ); + + Future _updateAttachmentMessage({ + required String messageId, + required MessagesCompanion messagesCompanion, + }) => _requestDbWrite( + DbWriteMethod.updateAttachmentMessage, + payload: DbWriteUpdateAttachmentMessagePayload( + messageId: messageId, + status: messagesCompanion.status.value, + content: messagesCompanion.content.value!, + mediaMimeType: messagesCompanion.mediaMimeType.value, + mediaSize: messagesCompanion.mediaSize.value, + mediaStatus: messagesCompanion.mediaStatus.value ?? MediaStatus.canceled, + mediaWidth: messagesCompanion.mediaWidth.value, + mediaHeight: messagesCompanion.mediaHeight.value, + mediaDigest: messagesCompanion.mediaDigest.value, + mediaKey: messagesCompanion.mediaKey.value, + mediaWaveform: messagesCompanion.mediaWaveform.value, + caption: messagesCompanion.caption.value, + name: messagesCompanion.name.value, + thumbImage: messagesCompanion.thumbImage.value, + mediaDuration: messagesCompanion.mediaDuration.value, + ), + ); + + Future _updateStickerMessage({ + required String messageId, + required MessageStatus status, + required String stickerId, + }) => _requestDbWrite( + DbWriteMethod.updateStickerMessage, + payload: DbWriteUpdateStickerMessagePayload( + messageId: messageId, + status: status, + stickerId: stickerId, + ), + ); + + Future _updateContactMessage({ + required String messageId, + required MessageStatus status, + required String sharedUserId, + }) => _requestDbWrite( + DbWriteMethod.updateContactMessage, + payload: DbWriteUpdateContactMessagePayload( + messageId: messageId, + status: status, + sharedUserId: sharedUserId, + ), + ); + + Future _updateLiveMessage({ + required String messageId, + required int width, + required int height, + required String url, + required String thumbUrl, + required MessageStatus status, + }) => _requestDbWrite( + DbWriteMethod.updateLiveMessage, + payload: DbWriteUpdateLiveMessagePayload( + messageId: messageId, + width: width, + height: height, + url: url, + thumbUrl: thumbUrl, + status: status, + ), + ); + + Future _updateTranscriptMessage({ + required String messageId, + required String? content, + required int? mediaSize, + required MediaStatus? mediaStatus, + required MessageStatus status, + }) => _requestDbWrite( + DbWriteMethod.updateTranscriptMessage, + payload: DbWriteUpdateTranscriptMessagePayload( + messageId: messageId, + content: content, + mediaSize: mediaSize, + mediaStatus: mediaStatus, + status: status, + ), + ); + Future process(FloodMessage floodMessage) async { final data = BlazeMessageData.fromJson( jsonDecode(floodMessage.data) as Map, @@ -136,7 +406,7 @@ class DecryptMessage extends Injector { d('DecryptMessage process data: ${data.toJson()}'); if (await isExistMessage(data.messageId)) { await _updateRemoteMessageStatus(data.messageId, MessageStatus.delivered); - await database.floodMessageDao.deleteFloodMessage(floodMessage); + await _deleteFloodMessage(floodMessage); return; } try { @@ -159,9 +429,7 @@ class DecryptMessage extends Injector { d('DecryptMessage isSignal'); if (data.category == MessageCategory.signalKey) { _remoteStatus = MessageStatus.read; - await database.messageHistoryDao.insert( - MessagesHistoryData(messageId: data.messageId), - ); + await _insertMessageHistory(data.messageId); } await _processSignalMessage(data); } else if (category.isPlain) { @@ -204,7 +472,7 @@ class DecryptMessage extends Injector { await _updateRemoteMessageStatus(messageId, _remoteStatus); - await database.floodMessageDao.deleteFloodMessage(floodMessage); + await _deleteFloodMessage(floodMessage); } Future _processSignalMessage(BlazeMessageData data) async { @@ -227,9 +495,7 @@ class DecryptMessage extends Injector { composeMessageData.resendMessageId!, plain, ); - await database.messageHistoryDao.insert( - MessagesHistoryData(messageId: data.messageId), - ); + await _insertMessageHistory(data.messageId); } else { try { await _processDecryptSuccess(data, plain); @@ -314,9 +580,7 @@ class DecryptMessage extends Injector { i('on device transfer command: $command'); _deviceTransfer?.handleRemoteCommand(command); } - await database.messageHistoryDao.insert( - MessagesHistoryData(messageId: data.messageId), - ); + await _insertMessageHistory(data.messageId); } else if (data.category == MessageCategory.plainText || data.category == MessageCategory.plainImage || data.category == MessageCategory.plainVideo || @@ -379,7 +643,7 @@ class DecryptMessage extends Injector { .findMessageByMessageIdAndUserId(id, accountId); if (needResendMessage == null || needResendMessage.category == MessageCategory.messageRecall) { - await database.resendSessionMessageDao.insert( + await _insertResendSessionMessage( ResendSessionMessage( messageId: id, userId: data.userId, @@ -396,7 +660,7 @@ class DecryptMessage extends Injector { if (p.createdAt.isAfter(needResendMessage.createdAt)) { continue; } - await database.resendSessionMessageDao.insert( + await _insertResendSessionMessage( ResendSessionMessage( messageId: id, userId: data.userId, @@ -517,6 +781,8 @@ class DecryptMessage extends Injector { ); if (pinMessage.action == PinMessagePayloadAction.pin) { + final pinMessages = []; + final systemMessages = []; await futureForEachIndexed(pinMessage.messageIds, ( index, messageId, @@ -530,14 +796,14 @@ class DecryptMessage extends Injector { messageId: message.messageId, content: message.category.isText ? message.content : null, ); - await database.pinMessageDao.insert( + pinMessages.add( PinMessage( messageId: messageId, conversationId: message.conversationId, createdAt: data.createdAt, ), ); - await database.messageDao.insert( + systemMessages.add( Message( messageId: index == 0 ? data.messageId : const Uuid().v4(), conversationId: data.conversationId, @@ -548,20 +814,29 @@ class DecryptMessage extends Injector { createdAt: data.createdAt, category: MessageCategory.messagePin, ), - accountId, ); }); + await _requestDbWrite( + DbWriteMethod.pinAndInsertPinMessages, + payload: DbWritePinAndInsertPinMessagesPayload( + pinMessages: pinMessages, + systemMessages: systemMessages, + currentUserId: accountId, + ), + ); _isolateEventSender( - WorkerIsolateEventType.showPinMessage, - data.conversationId, + WorkerShowPinMessageEvent(conversationId: data.conversationId), ); } else if (pinMessage.action == PinMessagePayloadAction.unpin) { - await database.pinMessageDao.deleteByIds(pinMessage.messageIds); + await _requestDbWrite( + DbWriteMethod.deletePinMessagesByIds, + payload: DbWriteDeletePinMessagesPayload( + messageIds: pinMessage.messageIds, + ), + ); } - await database.messageHistoryDao.insert( - MessagesHistoryData(messageId: data.messageId), - ); + await _insertMessageHistory(data.messageId); } Future _processRecallMessage(BlazeMessageData data) async { @@ -572,19 +847,28 @@ class DecryptMessage extends Injector { recallMessage.messageId, ); - await database.messageDao.recallMessage( - data.conversationId, - recallMessage.messageId, + await _requestDbWrite( + DbWriteMethod.recallMessage, + payload: DbWriteRecallMessagePayload( + conversationId: data.conversationId, + messageId: recallMessage.messageId, + ), ); - unawaited(database.ftsDatabase.deleteByMessageId(recallMessage.messageId)); + unawaited( + _requestDbWrite( + DbWriteMethod.deleteFtsByMessageId, + payload: recallMessage.messageId, + ), + ); await Future.wait([ (() async { if (message != null && message.category.isAttachment) { _isolateEventSender( - WorkerIsolateEventType.requestDownloadAttachment, - AttachmentCancelRequest(messageId: message.messageId), + WorkerRequestDownloadAttachmentEvent( + request: AttachmentCancelRequest(messageId: message.messageId), + ), ); if (message.mediaUrl?.isNotEmpty ?? false) { final file = File(message.mediaUrl!); @@ -601,22 +885,26 @@ class DecryptMessage extends Injector { recallMessage.messageId, ); if (quoteMessage != null) { - await database.messageDao.updateQuoteContentByQuoteId( - data.conversationId, - recallMessage.messageId, - quoteMessage.toJson(), + await _requestDbWrite( + DbWriteMethod.updateQuoteContentByQuoteId, + payload: DbWriteUpdateQuoteContentByQuoteIdPayload( + conversationId: data.conversationId, + quoteMessageId: recallMessage.messageId, + content: quoteMessage.toJson(), + ), ); } })(), - database.messageMentionDao.deleteMessageMention( - MessageMention( - messageId: recallMessage.messageId, - conversationId: data.conversationId, + _requestDbWrite( + DbWriteMethod.deleteMessageMention, + payload: DbWriteDeleteMessageMentionPayload( + messageMention: MessageMention( + messageId: recallMessage.messageId, + conversationId: data.conversationId, + ), ), ), - database.messageHistoryDao.insert( - MessagesHistoryData(messageId: data.messageId), - ), + _insertMessageHistory(data.messageId), ]); } @@ -665,14 +953,12 @@ class DecryptMessage extends Injector { quoteContent: quoteContent?.toJson(), ); }); - await database.messageMentionDao.parseMentionData( - message.content, - message.messageId, - message.conversationId, - data.senderId, - _quoteContent, - accountId, - identityNumber, + await _parseMentionData( + content: message.content, + messageId: message.messageId, + conversationId: message.conversationId, + senderId: data.senderId, + quoteContent: _quoteContent, ); await _insertMessage(message, data); } else if (data.category.isImage) { @@ -712,8 +998,9 @@ class DecryptMessage extends Injector { ); await _insertMessage(message, data); _isolateEventSender( - WorkerIsolateEventType.requestDownloadAttachment, - AttachmentDownloadRequest(message), + WorkerRequestDownloadAttachmentEvent( + request: AttachmentDownloadRequest(message), + ), ); } else if (data.category.isVideo) { final plain = data.category.isEncrypted ? plainText : _decode(plainText); @@ -753,8 +1040,9 @@ class DecryptMessage extends Injector { ); await _insertMessage(message, data); _isolateEventSender( - WorkerIsolateEventType.requestDownloadAttachment, - AttachmentDownloadRequest(message), + WorkerRequestDownloadAttachmentEvent( + request: AttachmentDownloadRequest(message), + ), ); } else if (data.category.isData) { final plain = data.category.isEncrypted ? plainText : _decode(plainText); @@ -790,8 +1078,9 @@ class DecryptMessage extends Injector { ); await _insertMessage(message, data); _isolateEventSender( - WorkerIsolateEventType.requestDownloadAttachment, - AttachmentDownloadRequest(message), + WorkerRequestDownloadAttachmentEvent( + request: AttachmentDownloadRequest(message), + ), ); } else if (data.category.isAudio) { final plain = data.category.isEncrypted ? plainText : _decode(plainText); @@ -829,8 +1118,9 @@ class DecryptMessage extends Injector { ); await _insertMessage(message, data); _isolateEventSender( - WorkerIsolateEventType.requestDownloadAttachment, - AttachmentDownloadRequest(message), + WorkerRequestDownloadAttachmentEvent( + request: AttachmentDownloadRequest(message), + ), ); } else if (data.category.isSticker) { final plain = data.category.isEncrypted ? plainText : _decode(plainText); @@ -972,9 +1262,7 @@ class DecryptMessage extends Injector { final userId = systemMessage.userId ?? data.senderId; if (userId == systemUser && (await database.userDao.userById(userId).getSingleOrNull()) == null) { - await database.userDao.insert( - const db.User(userId: systemUser, identityNumber: '0'), - ); + await _upsertUser(const db.User(userId: systemUser, identityNumber: '0')); } var message = db.Message( messageId: data.messageId, @@ -989,7 +1277,7 @@ class DecryptMessage extends Injector { ); if (systemMessage.action == MessageAction.add || systemMessage.action == MessageAction.join) { - await database.participantDao.insert( + await _upsertParticipant( db.Participant( conversationId: data.conversationId, userId: systemMessage.participantId!, @@ -1020,7 +1308,7 @@ class DecryptMessage extends Injector { systemMessage.action == MessageAction.exit) { if (systemMessage.participantId == accountId) { unawaited( - database.conversationDao.updateConversationStatusById( + _updateConversationStatus( data.conversationId, ConversationStatus.quit, ), @@ -1046,7 +1334,7 @@ class DecryptMessage extends Injector { return; } else if (systemMessage.action == MessageAction.create) { } else if (systemMessage.action == MessageAction.role) { - await database.participantDao.updateParticipantRole( + await _updateParticipantRole( data.conversationId, systemMessage.participantId!, systemMessage.role, @@ -1059,7 +1347,7 @@ class DecryptMessage extends Injector { message = message.copyWith( content: Value(systemMessage.expireIn.toString()), ); - await database.conversationDao.updateConversationExpireIn( + await _updateConversationExpireIn( data.conversationId, systemMessage.expireIn, ); @@ -1093,7 +1381,7 @@ class DecryptMessage extends Injector { if (systemMessage.userId != null) { await refreshUsers([systemMessage.userId!]); } - await database.circleConversationDao.insert( + await _upsertCircleConversations([ db.CircleConversation( conversationId: conversationId ?? @@ -1102,20 +1390,17 @@ class DecryptMessage extends Injector { userId: systemMessage.userId, createdAt: data.createdAt, ), - ); + ]); } else if (systemMessage.action == SystemCircleAction.remove) { final conversationId = systemMessage.conversationId ?? generateConversationId(accountId, systemMessage.userId!); - await database.circleConversationDao.deleteById( + await _deleteCircleConversationById( conversationId, systemMessage.circleId, ); } else if (systemMessage.action == SystemCircleAction.delete) { - await database.circleDao.deleteCircleById(systemMessage.circleId); - await database.circleConversationDao.deleteByCircleId( - systemMessage.circleId, - ); + await _deleteCircleAndConversations(systemMessage.circleId); } } @@ -1134,7 +1419,7 @@ class DecryptMessage extends Injector { openingBalance: snapshotMessage.openingBalance, closingBalance: snapshotMessage.closingBalance, ); - await database.snapshotDao.insert(snapshot); + await _upsertSnapshot(snapshot); await _updateAssetJob.add(createUpdateAssetJob(snapshotMessage.assetId)); var status = data.status; if (_conversationId == data.conversationId && data.userId != accountId) { @@ -1159,14 +1444,14 @@ class DecryptMessage extends Injector { String? depositHash, ) async { if (depositHash != null && depositHash.isNotEmpty) { - await database.safeSnapshotDao.deletePendingSnapshotByHash(depositHash); - await database.safeSnapshotDao.insert( + await _deletePendingSafeSnapshotByHash(depositHash); + await _upsertSafeSnapshot( snapshot.copyWith( deposit: Value(SafeDeposit(depositHash: depositHash, sender: '')), ), ); } else { - await database.safeSnapshotDao.insert(snapshot); + await _upsertSafeSnapshot(snapshot); } final message = Message( @@ -1199,7 +1484,7 @@ class DecryptMessage extends Injector { createdAt: data.createdAt, action: snapshot.type, ); - await database.safeSnapshotDao.insert(snapshot); + await _upsertSafeSnapshot(snapshot); await _insertMessage(message, data); await _syncInscriptionJob.add( createSyncInscriptionMessageJob(data.messageId), @@ -1225,7 +1510,10 @@ class DecryptMessage extends Injector { .where((m) => m.status == 'READ' || m.status == 'MENTION_READ') .forEach((m) async { if (m.status == 'MENTION_READ') { - await database.messageMentionDao.markMentionRead(m.messageId); + await _requestDbWrite( + DbWriteMethod.markMentionRead, + payload: m.messageId, + ); } else if (m.status == 'READ') { messagesWithExpiredAt.add((m.messageId, m.expireAt)); } @@ -1235,13 +1523,16 @@ class DecryptMessage extends Injector { final messageIds = messagesWithExpiredAt.map((e) => e.$1).toList(); final list = await database.messageDao.miniMessageByIds(messageIds).get(); - await database.messageDao.markMessageRead(list, updateExpired: false); + await _markMessagesRead(list); for (final item in messagesWithExpiredAt) { final (messageId, expireAt) = item; if (expireAt != null && expireAt > 0) { - await database.expiredMessageDao.updateMessageExpireAt( - expireAt, - messageId, + await _requestDbWrite( + DbWriteMethod.updateExpiredMessageExpireAt, + payload: DbWriteUpdateExpiredMessageExpireAtPayload( + messageId: messageId, + expireAt: expireAt, + ), ); } else { final expiredMessage = await database.expiredMessageDao @@ -1249,13 +1540,22 @@ class DecryptMessage extends Injector { .getSingleOrNull(); if (expiredMessage != null) { w('expireAt is null or 0. messageId: $messageId'); - await database.expiredMessageDao.onMessageRead([messageId]); + await _requestDbWrite( + DbWriteMethod.markExpiredMessagesRead, + payload: [messageId], + ); } } } final set = list.map((e) => e.conversationId).toSet(); for (final cId in set) { - await database.messageDao.takeUnseen(accountId, cId); + await _requestDbWrite( + DbWriteMethod.takeConversationUnseen, + payload: DbWriteTakeConversationUnseenPayload( + currentUserId: accountId, + conversationId: cId, + ), + ); } } } @@ -1305,31 +1605,29 @@ class DecryptMessage extends Injector { String plaintext, ) async { if (data.category == MessageCategory.signalText) { - await database.messageMentionDao.parseMentionData( - plaintext, - messageId, - data.conversationId, - data.senderId, - null, - accountId, - identityNumber, + await _parseMentionData( + content: plaintext, + messageId: messageId, + conversationId: data.conversationId, + senderId: data.senderId, + quoteContent: null, ); - await database.messageDao.updateMessageContentAndStatus( - messageId, - plaintext, - data.status, + await _updateMessageContentAndStatus( + messageId: messageId, + content: plaintext, + status: data.status, ); } else if (data.category == MessageCategory.signalPost) { - await database.messageDao.updateMessageContentAndStatus( - messageId, - plaintext, - data.status, + await _updateMessageContentAndStatus( + messageId: messageId, + content: plaintext, + status: data.status, ); } else if (data.category == MessageCategory.signalLocation) { - await database.messageDao.updateMessageContentAndStatus( - messageId, - plaintext, - data.status, + await _updateMessageContentAndStatus( + messageId: messageId, + content: plaintext, + status: data.status, ); } else if (data.category == MessageCategory.signalImage || data.category == MessageCategory.signalVideo || @@ -1361,9 +1659,9 @@ class DecryptMessage extends Injector { thumbImage: Value(attachment.thumbnail), mediaDuration: Value(attachment.duration.toString()), ); - await database.messageDao.updateAttachmentMessage( - messageId, - messagesCompanion, + await _updateAttachmentMessage( + messageId: messageId, + messagesCompanion: messagesCompanion, ); final message = await database.messageDao.findMessageByMessageId( @@ -1372,8 +1670,9 @@ class DecryptMessage extends Injector { if (message != null) { _isolateEventSender( - WorkerIsolateEventType.requestDownloadAttachment, - AttachmentDownloadRequest(message), + WorkerRequestDownloadAttachmentEvent( + request: AttachmentDownloadRequest(message), + ), ); } } else if (data.category == MessageCategory.signalSticker) { @@ -1391,45 +1690,45 @@ class DecryptMessage extends Injector { createUpdateStickerJob(stickerMessage.stickerId), ); } - await database.messageDao.updateStickerMessage( - messageId, - data.status, - stickerMessage.stickerId, + await _updateStickerMessage( + messageId: messageId, + status: data.status, + stickerId: stickerMessage.stickerId, ); } else if (data.category == MessageCategory.signalContact) { final plain = _decode(plaintext); final contactMessage = ContactMessage.fromJson( jsonDecode(plain) as Map, ); - await database.messageDao.updateContactMessage( - messageId, - data.status, - contactMessage.userId, + await _updateContactMessage( + messageId: messageId, + status: data.status, + sharedUserId: contactMessage.userId, ); } else if (data.category == MessageCategory.signalLive) { final plain = _decode(plaintext); final liveMessage = LiveMessage.fromJson( jsonDecode(plain) as Map, ); - await database.messageDao.updateLiveMessage( - messageId, - liveMessage.width, - liveMessage.height, - liveMessage.url, - liveMessage.thumbUrl, - data.status, + await _updateLiveMessage( + messageId: messageId, + width: liveMessage.width, + height: liveMessage.height, + url: liveMessage.url, + thumbUrl: liveMessage.thumbUrl, + status: data.status, ); } else if (data.category == MessageCategory.signalTranscript) { final plain = _decode(plaintext); final list = jsonDecode(plain) as List; final message = await processTranscriptMessage(data, list); if (message != null) { - await database.messageDao.updateTranscriptMessage( - message.content, - message.mediaSize, - message.mediaStatus, - message.status, - messageId, + await _updateTranscriptMessage( + messageId: messageId, + content: message.content, + mediaSize: message.mediaSize, + mediaStatus: message.mediaStatus, + status: message.status, ); } } @@ -1443,10 +1742,13 @@ class DecryptMessage extends Injector { messageId, ); if (messageItem != null) { - await database.messageDao.updateQuoteContentByQuoteId( - data.conversationId, - messageId, - messageItem.toJson(), + await _requestDbWrite( + DbWriteMethod.updateQuoteContentByQuoteId, + payload: DbWriteUpdateQuoteContentByQuoteIdPayload( + conversationId: data.conversationId, + quoteMessageId: messageId, + content: messageItem.toJson(), + ), ); } } @@ -1616,17 +1918,18 @@ class DecryptMessage extends Injector { (transcript) => transcript.category.isAttachment, ); - final insertAllTranscriptMessageFuture = database.transcriptMessageDao - .insertAll( - transcripts.map((transcript) { - if (transcript.category.isAttachment) { - return transcript.copyWith( - mediaStatus: const Value(MediaStatus.canceled), - ); - } - return transcript; - }).toList(), + final transcriptsForInsert = transcripts.map((transcript) { + if (transcript.category.isAttachment) { + return transcript.copyWith( + mediaStatus: const Value(MediaStatus.canceled), ); + } + return transcript; + }).toList(); + + final insertAllTranscriptMessageFuture = _insertTranscriptMessages( + transcriptsForInsert, + ); await Future.wait([ _refreshSticker(), @@ -1659,8 +1962,9 @@ class DecryptMessage extends Injector { transcripts.forEach((message) { if (message.category.isAttachment && message.content != null) { _isolateEventSender( - WorkerIsolateEventType.requestDownloadAttachment, - TranscriptAttachmentDownloadRequest(message), + WorkerRequestDownloadAttachmentEvent( + request: TranscriptAttachmentDownloadRequest(message), + ), ); } }); @@ -1681,7 +1985,7 @@ class DecryptMessage extends Injector { Future.sync(() async { final content = await database.transcriptMessageDao .generateTranscriptMessageFts5Content(transcripts); - await database.ftsDatabase.insertFts(message, content); + await _insertFts(message, content: content); }), ); diff --git a/lib/workers/injector.dart b/lib/workers/injector.dart index b9ec31484c..50c6531758 100644 --- a/lib/workers/injector.dart +++ b/lib/workers/injector.dart @@ -7,15 +7,25 @@ import '../db/dao/user_dao.dart'; import '../db/database.dart'; import '../db/database_event_bus.dart'; import '../db/mixin_database.dart' as db; +import '../runtime/db_write/method.dart'; +import '../runtime/db_write/payload.dart'; import '../utils/extension/extension.dart'; import '../utils/logger.dart'; class Injector { - Injector(this.accountId, this.database, this.client); + Injector( + this.accountId, + this.database, + this.client, { + required Future Function(DbWriteMethod method, {Object? payload}) + requestDbWrite, + }) : _requestDbWrite = requestDbWrite; String accountId; Database database; Client client; + final Future Function(DbWriteMethod method, {Object? payload}) + _requestDbWrite; final refreshUserIdSet = {}; @@ -80,7 +90,7 @@ class Injector { ? ConversationStatus.success : ConversationStatus.quit; - await database.conversationDao.insert( + await _upsertConversation( db.Conversation( conversationId: response.data.conversationId, ownerId: ownerId, @@ -125,7 +135,7 @@ class Injector { List remote, List? userSessions, ) async { - await database.participantDao.replaceAll(conversationId, remote); + await _replaceParticipants(conversationId, remote); final participantSessions = []; userSessions?.forEach((u) { participantSessions.add( @@ -137,10 +147,7 @@ class Injector { ), ); }); - await database.participantSessionDao.replaceAll( - conversationId, - participantSessions, - ); + await _replaceParticipantSessions(conversationId, participantSessions); } Future?> refreshUsers( @@ -190,10 +197,10 @@ class Injector { final user = e.asDbUser; result.add(user); - await database.userDao.insert(user); + await _upsertUser(user); final app = e.app; if (app != null) { - await database.appDao.insert( + await _upsertApp( db.App( appId: app.appId, appNumber: app.appNumber, @@ -220,7 +227,7 @@ class Injector { if (circleId == null) { final res = await client.circleApi.getCircles(); res.data.forEach((circle) async { - await database.circleDao.insertUpdate( + await _upsertCircle( db.Circle( circleId: circle.circleId, name: circle.name, @@ -231,7 +238,7 @@ class Injector { }); } else { final circle = (await client.circleApi.getCircle(circleId)).data; - await database.circleDao.insertUpdate( + await _upsertCircle( db.Circle( circleId: circle.circleId, name: circle.name, @@ -252,13 +259,13 @@ class Injector { circle.circleId, )).data; for (final cc in ccList) { - await database.circleConversationDao.insert( + await _upsertCircleConversations([ db.CircleConversation( conversationId: cc.conversationId, circleId: cc.circleId, createdAt: cc.createdAt, ), - ); + ]); if (cc.userId != null && !refreshUserIdSet.contains(cc.userId)) { final u = await database.userDao.userById(cc.userId!).getSingleOrNull(); if (u == null) { @@ -271,6 +278,64 @@ class Injector { } } + Future _upsertConversation(db.Conversation conversation) async { + await _requestDbWrite( + DbWriteMethod.upsertConversation, + payload: conversation, + ); + } + + Future _replaceParticipants( + String conversationId, + List participants, + ) async { + await _requestDbWrite( + DbWriteMethod.replaceParticipants, + payload: DbWriteReplaceParticipantsPayload( + conversationId: conversationId, + participants: participants, + ), + ); + } + + Future _replaceParticipantSessions( + String conversationId, + List sessions, + ) async { + await _requestDbWrite( + DbWriteMethod.replaceParticipantSessions, + payload: DbWriteReplaceParticipantSessionsPayload( + conversationId: conversationId, + sessions: sessions, + ), + ); + } + + Future _upsertUser(db.User user) async { + await _requestDbWrite(DbWriteMethod.upsertUser, payload: user); + } + + Future _upsertApp(db.App app) async { + await _requestDbWrite(DbWriteMethod.upsertApp, payload: app); + } + + Future _upsertCircle(db.Circle circle) async { + await _requestDbWrite( + DbWriteMethod.upsertCircle, + payload: DbWriteCirclePayload(circle: circle), + ); + } + + Future _upsertCircleConversations( + List items, + ) async { + if (items.isEmpty) return; + await _requestDbWrite( + DbWriteMethod.upsertCircleConversations, + payload: DbWriteCircleConversationsPayload(items: items), + ); + } + Future?> updateUserByIdentityNumber( String identityNumber, ) async { diff --git a/lib/workers/isolate_event.dart b/lib/workers/isolate_event.dart index f3a13c2c0a..712b168020 100644 --- a/lib/workers/isolate_event.dart +++ b/lib/workers/isolate_event.dart @@ -33,6 +33,9 @@ enum WorkerIsolateEventType { /// args: [String] pin message conversationId. showPinMessage, + + /// args: [List] + syncPatches, } extension WorkerIsolateEventTypeExtension on WorkerIsolateEventType { diff --git a/lib/workers/job/ack_job.dart b/lib/workers/job/ack_job.dart index c1446a0fb4..607de1a692 100644 --- a/lib/workers/job/ack_job.dart +++ b/lib/workers/job/ack_job.dart @@ -4,10 +4,16 @@ import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; import 'package:mixin_logger/mixin_logger.dart'; import '../../db/mixin_database.dart'; +import '../../runtime/db_write/method.dart'; +import '../../runtime/db_write/payload.dart'; import '../job_queue.dart'; class AckJob extends JobQueue, List> { - AckJob({required super.database, required this.client}); + AckJob({ + required super.database, + required super.requestDbWrite, + required this.client, + }); final Client client; @@ -15,7 +21,8 @@ class AckJob extends JobQueue, List> { String get name => 'AckJob'; @override - Future insertJob(List jobs) => database.jobDao.insertAll(jobs); + Future insertJob(List jobs) => + requestDbWrite(DbWriteMethod.insertJobs, payload: jobs); @override Future> fetchJobs() => database.jobDao.ackJobs().get(); @@ -39,7 +46,10 @@ class AckJob extends JobQueue, List> { i( 'ack ids: ${ack.map((e) => e.messageId).toList()}, request id: ${rsp.headers['x-request-id']}', ); - await database.jobDao.deleteJobs(jobIds); + await requestDbWrite( + DbWriteMethod.deleteJobs, + payload: DbWriteDeleteJobsPayload(jobIds: jobIds), + ); } catch (e, s) { w('Send ack error: $e, stack: $s'); await Future.delayed(const Duration(seconds: 1)); diff --git a/lib/workers/job/base_migration_job.dart b/lib/workers/job/base_migration_job.dart index d2089d4af2..70366a691d 100644 --- a/lib/workers/job/base_migration_job.dart +++ b/lib/workers/job/base_migration_job.dart @@ -2,10 +2,16 @@ import 'package:mixin_logger/mixin_logger.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; +import '../../runtime/db_write/method.dart'; +import '../../runtime/db_write/payload.dart'; import '../job_queue.dart'; abstract class BaseMigrationJob extends JobQueue> { - BaseMigrationJob({required super.database, required this.action}) { + BaseMigrationJob({ + required super.database, + required super.requestDbWrite, + required this.action, + }) { DataBaseEventBus.instance.addJobStream .where((event) => event.action == action) .listen((event) => start()); @@ -27,7 +33,10 @@ abstract class BaseMigrationJob extends JobQueue> { final first = jobs.first; final invalidJobs = jobs.skip(1).map((e) => e.jobId).toList(); - await database.jobDao.deleteJobs(invalidJobs); + await requestDbWrite( + DbWriteMethod.deleteJobs, + payload: DbWriteDeleteJobsPayload(jobIds: invalidJobs), + ); return [first]; } @@ -68,7 +77,7 @@ abstract class BaseMigrationJob extends JobQueue> { Future migration(Job job); Future onMigrationSuccess(Job job) async { - await database.jobDao.deleteJobById(job.jobId); + await requestDbWrite(DbWriteMethod.deleteJobById, payload: job.jobId); } @override diff --git a/lib/workers/job/cleanup_quote_content_job.dart b/lib/workers/job/cleanup_quote_content_job.dart index 3dfd8abf3c..b4b567bc27 100644 --- a/lib/workers/job/cleanup_quote_content_job.dart +++ b/lib/workers/job/cleanup_quote_content_job.dart @@ -2,14 +2,18 @@ import 'package:mixin_logger/mixin_logger.dart'; import '../../constants/constants.dart'; import '../../db/mixin_database.dart'; +import '../../runtime/db_write/method.dart'; +import '../../runtime/db_write/payload.dart'; import '../../utils/extension/extension.dart'; import 'base_migration_job.dart'; const _kQueryMax = 100; class CleanupQuoteContentJob extends BaseMigrationJob { - CleanupQuoteContentJob({required super.database}) - : super(action: kCleanupQuoteContent); + CleanupQuoteContentJob({ + required super.database, + required super.requestDbWrite, + }) : super(action: kCleanupQuoteContent); @override Future migration(Job job) async { @@ -30,10 +34,13 @@ class CleanupQuoteContentJob extends BaseMigrationJob { message.conversationId, quoteMessageId, ); - await database.messageDao.updateQuoteContentByQuoteId( - message.conversationId, - quoteMessageId, - quote?.toJson(), + await requestDbWrite( + DbWriteMethod.updateQuoteContentByQuoteId, + payload: DbWriteUpdateQuoteContentByQuoteIdPayload( + conversationId: message.conversationId, + quoteMessageId: quoteMessageId, + content: quote?.toJson(), + ), ); } if (messages.length < _kQueryMax) { diff --git a/lib/workers/job/delete_old_fts_record_job.dart b/lib/workers/job/delete_old_fts_record_job.dart index 13b149ee6b..4c761997be 100644 --- a/lib/workers/job/delete_old_fts_record_job.dart +++ b/lib/workers/job/delete_old_fts_record_job.dart @@ -1,8 +1,12 @@ +import '../../runtime/db_write/method.dart'; import '../../utils/logger.dart'; import '../job_queue.dart'; class DeleteOldFtsRecordJob extends JobQueue> { - DeleteOldFtsRecordJob({required super.database}); + DeleteOldFtsRecordJob({ + required super.database, + required super.requestDbWrite, + }); @override Future> fetchJobs() async { @@ -55,8 +59,8 @@ class DeleteOldFtsRecordJob extends JobQueue> { return; } - await database.mixinDatabase.customStatement( - 'DELETE FROM messages_fts WHERE rowid IN (SELECT rowid FROM messages_fts LIMIT 1000)', + await requestDbWrite( + DbWriteMethod.deleteLegacyFtsChunk, ); deleted += 1000; diff --git a/lib/workers/job/flood_job.dart b/lib/workers/job/flood_job.dart index faefbe4b2d..6c5cdc5938 100644 --- a/lib/workers/job/flood_job.dart +++ b/lib/workers/job/flood_job.dart @@ -1,10 +1,15 @@ import 'package:mixin_logger/mixin_logger.dart'; import '../../db/mixin_database.dart'; +import '../../runtime/db_write/method.dart'; import '../job_queue.dart'; class FloodJob extends JobQueue> { - FloodJob({required super.database, required this.getProcessFloodJob}); + FloodJob({ + required super.database, + required super.requestDbWrite, + required this.getProcessFloodJob, + }); Function(FloodMessage floodMessage)? Function() getProcessFloodJob; @@ -14,7 +19,7 @@ class FloodJob extends JobQueue> { @override Future insertJob(FloodMessage job) => - database.floodMessageDao.insert(job); + requestDbWrite(DbWriteMethod.insertFloodMessage, payload: job); @override bool get enable => getProcessFloodJob() != null; diff --git a/lib/workers/job/migrate_fts_job.dart b/lib/workers/job/migrate_fts_job.dart index f832f20ae0..f3e473c500 100644 --- a/lib/workers/job/migrate_fts_job.dart +++ b/lib/workers/job/migrate_fts_job.dart @@ -3,9 +3,10 @@ import 'package:mixin_logger/mixin_logger.dart'; import '../../constants/constants.dart'; import '../../db/dao/message_dao.dart'; import '../../db/dao/transcript_message_dao.dart'; -import '../../db/extension/job.dart'; import '../../db/fts_database.dart'; import '../../db/mixin_database.dart'; +import '../../runtime/db_write/method.dart'; +import '../../runtime/db_write/payload.dart'; import '../../utils/extension/extension.dart'; import '../job_queue.dart'; @@ -15,10 +16,12 @@ import '../job_queue.dart'; /// The fist job is created by [MixinDatabase.migration]. /// class MigrateFtsJob extends JobQueue> { - MigrateFtsJob({required super.database}) - : messageDao = database.messageDao, - ftsDatabase = database.ftsDatabase, - transcriptMessageDao = database.transcriptMessageDao; + MigrateFtsJob({ + required super.database, + required super.requestDbWrite, + }) : messageDao = database.messageDao, + ftsDatabase = database.ftsDatabase, + transcriptMessageDao = database.transcriptMessageDao; final MessageDao messageDao; final FtsDatabase ftsDatabase; @@ -38,7 +41,10 @@ class MigrateFtsJob extends JobQueue> { final first = jobs.first; final invalidJobs = jobs.skip(1).map((e) => e.jobId).toList(); - await database.jobDao.deleteJobs(invalidJobs); + await requestDbWrite( + DbWriteMethod.deleteJobs, + payload: DbWriteDeleteJobsPayload(jobIds: invalidJobs), + ); return [first]; } @@ -69,7 +75,10 @@ class MigrateFtsJob extends JobQueue> { .where((e) => e.jobId != job.jobId) .map((e) => e.jobId) .toList(); - await database.jobDao.deleteJobs(invalidJobs); + await requestDbWrite( + DbWriteMethod.deleteJobs, + payload: DbWriteDeleteJobsPayload(jobIds: invalidJobs), + ); } else if (jobs.length == 1) { job = jobs.first; } else { @@ -87,7 +96,10 @@ class MigrateFtsJob extends JobQueue> { final messages = await messageDao.getMessages(lastMessageRowId, 1000); if (messages.isEmpty) { d('migrateFtsDatabase done'); - await database.jobDao.deleteJobByAction(kMigrateFts); + await requestDbWrite( + DbWriteMethod.deleteJobByAction, + payload: kMigrateFts, + ); break; } try { @@ -120,37 +132,20 @@ class MigrateFtsJob extends JobQueue> { transcriptMessageFtsContent[message.messageId] = content; } - // insert fts - final messageMeta = {}; - for (final message in messagesToMigrate) { - final rowId = await ftsDatabase.insertFtsOnly( - message, - transcriptMessageFtsContent[message.messageId], - ); - if (rowId != null) { - messageMeta[rowId] = message; - } - } + await requestDbWrite( + DbWriteMethod.migrateFtsInsertBatch, + payload: DbWriteMigrateFtsInsertBatchPayload( + messages: messagesToMigrate.toList(), + transcriptContentMap: transcriptMessageFtsContent, + ), + ); - // insert metas - await ftsDatabase.batch((batch) { - batch.insertAll(ftsDatabase.messagesMetas, [ - for (final MapEntry entry in messageMeta.entries) - MessagesMeta( - docId: entry.key, - messageId: entry.value.messageId, - conversationId: entry.value.conversationId, - category: entry.value.category, - userId: entry.value.userId, - createdAt: entry.value.createdAt, - ), - ]); - }); - - await database.jobDao.transaction(() async { - await database.jobDao.deleteJobByAction(kMigrateFts); - await database.jobDao.insert(createMigrationFtsJob(lastMessageRowId)); - }); + await requestDbWrite( + DbWriteMethod.replaceMigrateFtsJob, + payload: DbWriteReplaceMigrateFtsJobPayload( + messageRowId: lastMessageRowId, + ), + ); i( 'migrateFtsDatabase(${messages.length}) elapsed: ${stopwatch.elapsed} lastMessageRowId: $lastMessageRowId', diff --git a/lib/workers/job/sending_job.dart b/lib/workers/job/sending_job.dart index ae46a74d22..eeb0ccd640 100644 --- a/lib/workers/job/sending_job.dart +++ b/lib/workers/job/sending_job.dart @@ -21,6 +21,8 @@ import '../../db/dao/message_dao.dart'; import '../../db/extension/message.dart'; import '../../db/mixin_database.dart'; import '../../enum/message_category.dart'; +import '../../runtime/db_write/method.dart'; +import '../../runtime/db_write/payload.dart'; import '../../utils/extension/extension.dart'; import '../../utils/load_balancer_utils.dart'; import '../../utils/reg_exp_utils.dart'; @@ -32,6 +34,7 @@ import '../sender.dart'; class SendingJob extends JobQueue> { SendingJob({ required super.database, + required super.requestDbWrite, required this.sender, required this.userId, required this.sessionId, @@ -51,7 +54,8 @@ class SendingJob extends JobQueue> { String get name => 'SendingJob'; @override - Future insertJob(Job job) => database.jobDao.insert(job); + Future insertJob(Job job) => + requestDbWrite(DbWriteMethod.insertJob, payload: job); @override Future> fetchJobs() => database.jobDao.sendingJobs().get(); @@ -95,7 +99,7 @@ class SendingJob extends JobQueue> { try { final result = await sender.deliver(blazeMessage); if (result.success || result.errorCode == badData) { - await database.jobDao.deleteJobById(job.jobId); + await _deleteJobById(job.jobId); } } catch (e, s) { w('Send pin error: $e, stack: $s'); @@ -120,7 +124,7 @@ class SendingJob extends JobQueue> { try { final result = await sender.deliver(blazeMessage); if (result.success || result.errorCode == badData) { - await database.jobDao.deleteJobById(job.jobId); + await _deleteJobById(job.jobId); } } catch (e, s) { w('Send recall error: $e, stack: $s'); @@ -147,7 +151,7 @@ class SendingJob extends JobQueue> { .sendingMessage(messageId) .getSingleOrNull(); if (message == null) { - await database.jobDao.deleteJobById(job.jobId); + await _deleteJobById(job.jobId); return; } @@ -219,7 +223,7 @@ class SendingJob extends JobQueue> { // Maybe get a badData response when create conversation with // an invalid user(for example: network user). if (error is MixinError && error.code == badData) { - await database.jobDao.deleteJobById(job.jobId); + await _deleteJobById(job.jobId); return; } rethrow; @@ -248,9 +252,9 @@ class SendingJob extends JobQueue> { category: message.category.replaceAll('ENCRYPTED_', 'PLAIN_'), ); d('category: ${message.category}'); - await database.messageDao.updateCategoryById( - messageId, - message.category, + await _updateMessageCategoryById( + messageId: messageId, + category: message.category, ); result = await _sendPlainMessage( message, @@ -269,16 +273,16 @@ class SendingJob extends JobQueue> { if (result?.success ?? (result?.errorCode == badData)) { if (result?.errorCode == null) { - await database.messageDao.updateMessageContentAndStatus( - message.messageId, - sentContent, - MessageStatus.sent, + await _updateMessageContentAndStatus( + messageId: message.messageId, + content: sentContent, + status: MessageStatus.sent, ); } - await database.jobDao.deleteJobById(job.jobId); + await _deleteJobById(job.jobId); if (conversation.expireIn != null && conversation.expireIn! > 0) { - await database.expiredMessageDao.insert( + await _insertExpiredMessage( messageId: messageId, expireIn: conversation.expireIn!, expireAt: @@ -464,8 +468,7 @@ class SendingJob extends JobQueue> { ); result = await sender.deliver(encrypted); if (result.success || result.errorCode == badData) { - await database.resendSessionMessageDao - .deleteResendSessionMessageById(message.messageId); + await _deleteResendSessionMessageById(message.messageId); } } } @@ -486,6 +489,54 @@ class SendingJob extends JobQueue> { } return result; } + + Future _deleteJobById(String jobId) => + requestDbWrite(DbWriteMethod.deleteJobById, payload: jobId); + + Future _updateMessageCategoryById({ + required String messageId, + required String category, + }) => requestDbWrite( + DbWriteMethod.updateMessageCategoryById, + payload: DbWriteUpdateMessageCategoryPayload( + messageId: messageId, + category: category, + ), + ); + + Future _updateMessageContentAndStatus({ + required String messageId, + required String? content, + required MessageStatus status, + }) => requestDbWrite( + DbWriteMethod.updateMessageContentAndStatus, + payload: DbWriteUpdateMessageContentAndStatusPayload( + messageId: messageId, + content: content, + status: status, + ), + ); + + Future _insertExpiredMessage({ + required String messageId, + required int expireIn, + required int expireAt, + }) => requestDbWrite( + DbWriteMethod.insertExpiredMessage, + payload: DbWriteInsertExpiredMessagePayload( + messageId: messageId, + expireIn: expireIn, + expireAt: expireAt, + ), + ); + + Future _deleteResendSessionMessageById(String messageId) => + requestDbWrite( + DbWriteMethod.deleteResendSessionMessageById, + payload: DbWriteDeleteResendSessionMessagePayload( + messageId: messageId, + ), + ); } class _NoParticipantSessionKeyException implements Exception { diff --git a/lib/workers/job/session_ack_job.dart b/lib/workers/job/session_ack_job.dart index 96009190f0..18f7525ed9 100644 --- a/lib/workers/job/session_ack_job.dart +++ b/lib/workers/job/session_ack_job.dart @@ -8,6 +8,8 @@ import '../../blaze/vo/plain_json_message.dart'; import '../../constants/constants.dart'; import '../../crypto/uuid/uuid.dart'; import '../../db/mixin_database.dart'; +import '../../runtime/db_write/method.dart'; +import '../../runtime/db_write/payload.dart'; import '../../utils/load_balancer_utils.dart'; import '../job_queue.dart'; import '../sender.dart'; @@ -15,6 +17,7 @@ import '../sender.dart'; class SessionAckJob extends JobQueue, List> { SessionAckJob({ required super.database, + required super.requestDbWrite, required this.userId, required this.primarySessionId, required this.sender, @@ -31,7 +34,8 @@ class SessionAckJob extends JobQueue, List> { String get name => 'SessionAckJob'; @override - Future insertJob(List jobs) => database.jobDao.insertAll(jobs); + Future insertJob(List jobs) => + requestDbWrite(DbWriteMethod.insertJobs, payload: jobs); @override Future> fetchJobs() => database.jobDao.sessionAckJobs().get(); @@ -84,7 +88,10 @@ class SessionAckJob extends JobQueue, List> { 'session ack, ${stopwatch.elapsed} ids: ${ack.map((e) => e.messageId).toList()}, BlazeMessage.id: ${bm.id}, param.messageId: ${param.messageId}', ); if (result.success || result.errorCode == badData) { - await database.jobDao.deleteJobs(jobIds); + await requestDbWrite( + DbWriteMethod.deleteJobs, + payload: DbWriteDeleteJobsPayload(jobIds: jobIds), + ); } else { return jobs; } diff --git a/lib/workers/job/sync_inscription_message_job.dart b/lib/workers/job/sync_inscription_message_job.dart index d32bc5dc7a..9c19d89e6f 100644 --- a/lib/workers/job/sync_inscription_message_job.dart +++ b/lib/workers/job/sync_inscription_message_job.dart @@ -7,11 +7,17 @@ import '../../db/dao/inscription_collection_dao.dart'; import '../../db/dao/inscription_item_dao.dart'; import '../../db/mixin_database.dart'; import '../../db/vo/inscription.dart'; +import '../../runtime/db_write/method.dart'; +import '../../runtime/db_write/payload.dart'; import '../../utils/load_balancer_utils.dart'; import '../job_queue.dart'; class SyncInscriptionMessageJob extends JobQueue> { - SyncInscriptionMessageJob({required super.database, required this.client}); + SyncInscriptionMessageJob({ + required super.database, + required super.requestDbWrite, + required this.client, + }); final Client client; @@ -36,7 +42,7 @@ class SyncInscriptionMessageJob extends JobQueue> { if (exists) return; - await database.jobDao.insert(job); + await requestDbWrite(DbWriteMethod.insertJob, payload: job); } @override @@ -56,7 +62,7 @@ class SyncInscriptionMessageJob extends JobQueue> { continue; } await syncInscriptionMessageItem(messageId); - await database.jobDao.deleteJobById(job.jobId); + await requestDbWrite(DbWriteMethod.deleteJobById, payload: job.jobId); } } @@ -111,17 +117,20 @@ class SyncInscriptionMessageJob extends JobQueue> { } } - await database.messageDao.updateMessageContent( - messageId, - jsonEncode( - Inscription( - collectionHash: collection.collectionHash, - inscriptionHash: inscriptionHash, - sequence: inscription.sequence, - contentType: inscription.contentType, - contentUrl: inscription.contentUrl, - name: collection.name, - iconUrl: collection.iconUrl, + await requestDbWrite( + DbWriteMethod.updateMessageContent, + payload: DbWriteUpdateMessageContentPayload( + messageId: messageId, + content: jsonEncode( + Inscription( + collectionHash: collection.collectionHash, + inscriptionHash: inscriptionHash, + sequence: inscription.sequence, + contentType: inscription.contentType, + contentUrl: inscription.contentUrl, + name: collection.name, + iconUrl: collection.iconUrl, + ), ), ), ); diff --git a/lib/workers/job/update_asset_job.dart b/lib/workers/job/update_asset_job.dart index 6a2b852316..d0dedc614f 100644 --- a/lib/workers/job/update_asset_job.dart +++ b/lib/workers/job/update_asset_job.dart @@ -5,10 +5,16 @@ import 'package:mixin_logger/mixin_logger.dart'; import '../../constants/constants.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; +import '../../runtime/db_write/method.dart'; +import '../../runtime/db_write/payload.dart'; import '../job_queue.dart'; class UpdateAssetJob extends JobQueue> { - UpdateAssetJob({required super.database, required this.client}); + UpdateAssetJob({ + required super.database, + required super.requestDbWrite, + required this.client, + }); final Client client; @@ -35,7 +41,7 @@ class UpdateAssetJob extends JobQueue> { if (exists) return; - await database.jobDao.insert(job); + await requestDbWrite(DbWriteMethod.insertJob, payload: job); } @override @@ -49,11 +55,14 @@ class UpdateAssetJob extends JobQueue> { final chain = (await client.assetApi.getChain(asset.chainId)).data; - await Future.wait([ - database.assetDao.insertSdkAsset(asset), - database.chainDao.insertSdkChain(chain), - database.jobDao.deleteJobById(job.jobId), - ]); + await requestDbWrite( + DbWriteMethod.upsertAssetAndChain, + payload: DbWriteUpsertAssetAndChainPayload( + asset: asset, + chain: chain, + ), + ); + await requestDbWrite(DbWriteMethod.deleteJobById, payload: job.jobId); return asset.assetId; } catch (e, s) { w('Update asset job error: $e, stack: $s'); diff --git a/lib/workers/job/update_sticker_job.dart b/lib/workers/job/update_sticker_job.dart index 2f69b80a13..cbfbd68580 100644 --- a/lib/workers/job/update_sticker_job.dart +++ b/lib/workers/job/update_sticker_job.dart @@ -5,10 +5,16 @@ import 'package:mixin_logger/mixin_logger.dart'; import '../../constants/constants.dart'; import '../../db/dao/sticker_dao.dart'; import '../../db/mixin_database.dart'; +import '../../runtime/db_write/method.dart'; +import '../../runtime/db_write/payload.dart'; import '../job_queue.dart'; class UpdateStickerJob extends JobQueue> { - UpdateStickerJob({required super.database, required this.client}); + UpdateStickerJob({ + required super.database, + required super.requestDbWrite, + required this.client, + }); final Client client; @@ -35,7 +41,7 @@ class UpdateStickerJob extends JobQueue> { if (exists) return; - await database.jobDao.insert(job); + await requestDbWrite(DbWriteMethod.insertJob, payload: job); } @override @@ -64,9 +70,12 @@ class UpdateStickerJob extends JobQueue> { final sticker = (await client.accountApi.getStickerById( stickerId, )).data; - await database.stickerDao.insert(sticker.asStickersCompanion); + await requestDbWrite( + DbWriteMethod.insertSticker, + payload: sticker.asStickersCompanion, + ); } - await database.jobDao.deleteJobById(job.jobId); + await requestDbWrite(DbWriteMethod.deleteJobById, payload: job.jobId); } catch (e, s) { if (e is MixinApiError) { var code = e.response?.statusCode; @@ -76,7 +85,10 @@ class UpdateStickerJob extends JobQueue> { } if (code == 404) { i('Sticker not found: ${job.blazeMessage}'); - await database.jobDao.deleteJobById(job.jobId); + await requestDbWrite( + DbWriteMethod.deleteJobById, + payload: job.jobId, + ); continue; } } @@ -84,12 +96,12 @@ class UpdateStickerJob extends JobQueue> { w('Update sticker job error: $e, stack: $s'); final nextRunAt = DateTime.now().add(_backoffDuration(job.runCount)); - await (database.mixinDatabase.update( - database.mixinDatabase.jobs, - )..where((tbl) => tbl.jobId.equals(job.jobId))).write( - JobsCompanion( - createdAt: Value(nextRunAt), - runCount: Value(job.runCount + 1), + await requestDbWrite( + DbWriteMethod.updateJobRunState, + payload: DbWriteUpdateJobRunStatePayload( + jobId: job.jobId, + createdAt: nextRunAt, + runCount: job.runCount + 1, ), ); } diff --git a/lib/workers/job/update_token_job.dart b/lib/workers/job/update_token_job.dart index cc807d9798..710f08ade8 100644 --- a/lib/workers/job/update_token_job.dart +++ b/lib/workers/job/update_token_job.dart @@ -7,10 +7,16 @@ import 'package:mixin_logger/mixin_logger.dart'; import '../../constants/constants.dart'; import '../../db/database_event_bus.dart'; import '../../db/mixin_database.dart'; +import '../../runtime/db_write/method.dart'; +import '../../runtime/db_write/payload.dart'; import '../job_queue.dart'; class UpdateTokenJob extends JobQueue> { - UpdateTokenJob({required super.database, required this.client}); + UpdateTokenJob({ + required super.database, + required super.requestDbWrite, + required this.client, + }); final Client client; @@ -37,7 +43,7 @@ class UpdateTokenJob extends JobQueue> { if (exists) return; - await database.jobDao.insert(job); + await requestDbWrite(DbWriteMethod.insertJob, payload: job); } final _retryDelay = {}; @@ -53,11 +59,14 @@ class UpdateTokenJob extends JobQueue> { final chain = (await client.assetApi.getChain(token.chainId)).data; - await Future.wait([ - database.tokenDao.insertSdkToken(token), - database.chainDao.insertSdkChain(chain), - database.jobDao.deleteJobById(job.jobId), - ]); + await requestDbWrite( + DbWriteMethod.upsertTokenAndChain, + payload: DbWriteUpsertTokenAndChainPayload( + token: token, + chain: chain, + ), + ); + await requestDbWrite(DbWriteMethod.deleteJobById, payload: job.jobId); return token.assetId; } catch (e, s) { w('Update token job error: $e, stack: $s'); diff --git a/lib/workers/job_queue.dart b/lib/workers/job_queue.dart index 7c87fa96a4..f68eeda7ed 100644 --- a/lib/workers/job_queue.dart +++ b/lib/workers/job_queue.dart @@ -3,15 +3,22 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import '../db/database.dart'; +import '../runtime/db_write/method.dart'; import '../utils/logger.dart'; abstract class JobQueue { - JobQueue({required this.database}) { + JobQueue({ + required this.database, + required this.requestDbWrite, + }) { start(); } @protected final Database database; + @protected + final Future Function(DbWriteMethod method, {Object? payload}) + requestDbWrite; @protected bool isRunning = false; diff --git a/lib/workers/message_worker_isolate.dart b/lib/workers/message_worker_isolate.dart index 04dfebcd2e..4af4f94807 100644 --- a/lib/workers/message_worker_isolate.dart +++ b/lib/workers/message_worker_isolate.dart @@ -1,393 +1,3 @@ -import 'dart:async'; -import 'dart:convert'; -import 'dart:io'; -import 'dart:isolate'; -import 'dart:ui' as ui; - -import 'package:ansicolor/ansicolor.dart'; -import 'package:dio/dio.dart'; -import 'package:ed25519_edwards/ed25519_edwards.dart' as ed; -import 'package:equatable/equatable.dart'; -import 'package:flutter/services.dart'; -import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; -import 'package:rhttp/rhttp.dart'; -import 'package:rxdart/rxdart.dart'; -import 'package:stream_channel/isolate_channel.dart'; - -import '../blaze/blaze.dart'; -import '../crypto/signal/signal_protocol.dart'; -import '../db/database.dart'; -import '../db/database_event_bus.dart'; -import '../db/fts_database.dart'; -import '../db/mixin_database.dart' hide Chain; -import '../utils/extension/extension.dart'; -import '../utils/file.dart'; -import '../utils/logger.dart'; -import '../utils/mixin_api_client.dart'; -import '../utils/system/package_info.dart'; -import 'decrypt_message.dart'; -import 'device_transfer.dart'; -import 'isolate_event.dart'; -import 'job/ack_job.dart'; -import 'job/cleanup_quote_content_job.dart'; -import 'job/delete_old_fts_record_job.dart'; -import 'job/flood_job.dart'; -import 'job/migrate_fts_job.dart'; -import 'job/sending_job.dart'; -import 'job/session_ack_job.dart'; -import 'job/sync_inscription_message_job.dart'; -import 'job/update_asset_job.dart'; -import 'job/update_sticker_job.dart'; -import 'job/update_token_job.dart'; -import 'sender.dart'; - -class IsolateInitParams { - IsolateInitParams({ - required this.sendPort, - required this.identityNumber, - required this.userId, - required this.sessionId, - required this.privateKey, - required this.mixinDocumentDirectory, - required this.primarySessionId, - required this.loginByPhoneNumber, - required this.rootIsolateToken, - }); - - final SendPort sendPort; - final String identityNumber; - final String userId; - final String sessionId; - final String privateKey; - final String mixinDocumentDirectory; - final String? primarySessionId; - - final bool loginByPhoneNumber; - final ui.RootIsolateToken rootIsolateToken; -} - -Future startMessageProcessIsolate(IsolateInitParams params) async { - EquatableConfig.stringify = true; - ansiColorDisabled = Platform.isIOS; - mixinDocumentsDirectory = Directory(params.mixinDocumentDirectory); - await Rhttp.init(); - BackgroundIsolateBinaryMessenger.ensureInitialized(params.rootIsolateToken); - final isolateChannel = IsolateChannel.connectSend( - params.sendPort, - ); - final runner = _MessageProcessRunner( - identityNumber: params.identityNumber, - userId: params.userId, - sessionId: params.sessionId, - privateKeyStr: params.privateKey, - primarySessionId: params.primarySessionId, - eventSink: isolateChannel.sink, - sendPort: params.sendPort, - ); - isolateChannel.stream.listen((event) { - assert(event is MainIsolateEvent, 'event is not MainIsolateEvent'); - if (event is! MainIsolateEvent) { - return; - } - try { - runner.onEvent(event); - } catch (error, stacktrace) { - e('error: $error, stacktrace: $stacktrace'); - } - }); - await runner.init(params); - runner._start(); - isolateChannel.sink.add(WorkerIsolateEventType.onIsolateReady.toEvent()); -} - -final Map pendingMessageStatusMap = {}; - -class _MessageProcessRunner { - _MessageProcessRunner({ - required this.identityNumber, - required this.userId, - required this.sessionId, - required this.privateKeyStr, - required this.primarySessionId, - required this.eventSink, - required this.sendPort, - }) : privateKey = ed.PrivateKey(base64Decode(privateKeyStr)); - - final String identityNumber; - final String userId; - final String sessionId; - final String privateKeyStr; - final ed.PrivateKey privateKey; - final String? primarySessionId; - final SendPort sendPort; - - final Sink eventSink; - - DecryptMessage? _decryptMessage; - - late Client client; - late Database database; - late Blaze blaze; - late Sender _sender; - late SignalProtocol signalProtocol; - - late SendingJob _sendingJob; - late AckJob _ackJob; - late UpdateAssetJob _updateAssetJob; - late UpdateStickerJob _updateStickerJob; - late UpdateTokenJob _updateTokenJob; - late SessionAckJob _sessionAckJob; - late SyncInscriptionMessageJob _syncInscriptionMessageJob; - late FloodJob _floodJob; - DeviceTransferIsolateController? _deviceTransfer; - - final jobSubscribers = []; - - Timer? _nextExpiredMessageRunner; - - Future init(IsolateInitParams initParams) async { - database = Database( - await connectToDatabase(identityNumber, readCount: 4), - await FtsDatabase.connect(identityNumber), - ); - - client = createClient( - userId: userId, - sessionId: sessionId, - privateKey: privateKeyStr, - interceptors: [ - InterceptorsWrapper( - onError: (e, handler) async { - _sendEventToMainIsolate( - WorkerIsolateEventType.onApiRequestedError, - e, - ); - handler.next(e); - }, - ), - ], - loginByPhoneNumber: initParams.loginByPhoneNumber, - )..configProxySetting(database.settingProperties); - - _ackJob = AckJob(database: database, client: client); - - _floodJob = FloodJob( - database: database, - getProcessFloodJob: getProcessFloodJob, - ); - - blaze = Blaze( - userId, - sessionId, - privateKeyStr, - database, - client, - await generateUserAgent(), - _ackJob, - _floodJob, - ); - - blaze.connectedStateStream.listen((event) { - _sendEventToMainIsolate( - WorkerIsolateEventType.onBlazeConnectStateChanged, - event, - ); - }); - - signalProtocol = SignalProtocol(userId)..init(); - - _sender = Sender( - signalProtocol, - blaze, - client, - sessionId, - userId, - database, - ); - - _sendingJob = SendingJob( - database: database, - sender: _sender, - userId: userId, - sessionId: sessionId, - privateKey: privateKey, - signalProtocol: signalProtocol, - ); - - _sessionAckJob = SessionAckJob( - database: database, - userId: userId, - primarySessionId: primarySessionId, - sender: _sender, - ); - _updateAssetJob = UpdateAssetJob(database: database, client: client); - _updateTokenJob = UpdateTokenJob(database: database, client: client); - - _updateStickerJob = UpdateStickerJob(database: database, client: client); - _syncInscriptionMessageJob = SyncInscriptionMessageJob( - database: database, - client: client, - ); - - MigrateFtsJob(database: database); - DeleteOldFtsRecordJob(database: database); - CleanupQuoteContentJob(database: database); - - if (primarySessionId != null) { - _deviceTransfer = await startTransferIsolate( - userId: userId, - messageDeliver: (message) async { - d('device_transfer: send message: $message'); - final result = await _sender.deliver(message); - if (!result.success) { - w('device_transfer: send message failed: $result'); - } else { - d('device_transfer: send message success: $result'); - } - }, - primarySessionId: primarySessionId!, - identityNumber: identityNumber, - rootIsolateToken: initParams.rootIsolateToken, - mixinDocumentDirectory: initParams.mixinDocumentDirectory, - ); - } else { - e( - 'device_transfer: primarySessionId is null, device transfer is disabled', - ); - } - - _decryptMessage = DecryptMessage( - userId, - database, - signalProtocol, - _sender, - client, - sessionId, - privateKey, - _sendEventToMainIsolate, - identityNumber, - _ackJob, - _sendingJob, - _updateStickerJob, - _updateAssetJob, - _deviceTransfer, - _updateTokenJob, - _syncInscriptionMessageJob, - ); - _floodJob.start(); - } - - Function(FloodMessage floodMessage)? getProcessFloodJob() => - _decryptMessage?.process; - - void _start() { - blaze.connect(); - - jobSubscribers - ..add( - blaze.connectedStateStream - .where((state) => state == ConnectedState.connected) - .listen((event) { - _floodJob.start(); - }), - ) - ..add( - DataBaseEventBus.instance.updateExpiredMessageTableStream - .startWith(null) - .asyncBufferMap((event) => _scheduleExpiredJob()) - .listen((_) {}), - ); - } - - void _sendEventToMainIsolate( - WorkerIsolateEventType event, [ - dynamic argument, - ]) { - eventSink.add(event.toEvent(argument)); - } - - Future _scheduleExpiredJob() async { - d('_scheduleExpiredJob'); - final messages = await database.expiredMessageDao - .getCurrentExpiredMessages(); - if (messages.isEmpty) return; - - for (final em in messages) { - // cancel attachment download. - final message = await database.messageDao.findMessageByMessageId( - em.messageId, - ); - if (message == null) { - e('message is null, messageId: ${em.messageId} ${em.expireAt}'); - await database.expiredMessageDao.deleteByMessageId(em.messageId); - continue; - } - await database.messageDao.deleteMessage( - message.conversationId, - em.messageId, - ); - unawaited(database.ftsDatabase.deleteByMessageId(em.messageId)); - if (message.category.isAttachment || message.category.isTranscript) { - _sendEventToMainIsolate( - WorkerIsolateEventType.requestDownloadAttachment, - AttachmentDeleteRequest(message: message), - ); - } - } - - final firstExpiredMessage = await database.expiredMessageDao - .getFirstExpiredMessage() - .getSingleOrNull(); - if (firstExpiredMessage == null) { - _nextExpiredMessageRunner?.cancel(); - _nextExpiredMessageRunner = null; - return; - } - _nextExpiredMessageRunner?.cancel(); - _nextExpiredMessageRunner = Timer( - Duration( - seconds: - firstExpiredMessage.expireAt! - - DateTime.now().millisecondsSinceEpoch ~/ 1000, - ), - _scheduleExpiredJob, - ); - } - - void onEvent(MainIsolateEvent event) { - switch (event.type) { - case MainIsolateEventType.updateSelectedConversation: - final conversationId = event.argument as String?; - _decryptMessage?.conversationId = conversationId; - case MainIsolateEventType.disconnectBlazeWithTime: - blaze.waitSyncTime(); - case MainIsolateEventType.reconnectBlaze: - i('message worker isolate: reconnect blaze'); - blaze.reconnect(); - case MainIsolateEventType.addAckJobs: - _ackJob.add(event.argument as List); - case MainIsolateEventType.addSessionAckJobs: - _sessionAckJob.add(event.argument as List); - case MainIsolateEventType.addSendingJob: - _sendingJob.add(event.argument as Job); - case MainIsolateEventType.addUpdateAssetJob: - _updateAssetJob.add(event.argument as Job); - case MainIsolateEventType.addUpdateTokenJob: - _updateTokenJob.add(event.argument as Job); - case MainIsolateEventType.addUpdateStickerJob: - _updateStickerJob.add(event.argument as Job); - case MainIsolateEventType.addSyncInscriptionMessageJob: - _syncInscriptionMessageJob.add(event.argument as Job); - case MainIsolateEventType.exit: - dispose(); - Isolate.exit(); - } - } - - void dispose() { - blaze.dispose(); - database.dispose(); - jobSubscribers.forEach((subscription) => subscription.cancel()); - _deviceTransfer?.dispose(); - } -} +@Deprecated('Use sync_worker_isolate.dart instead.') +export 'sync_worker_isolate.dart' + show SyncWorkerInitParams, pendingMessageStatusMap, startSyncWorkerIsolate; diff --git a/lib/workers/sender.dart b/lib/workers/sender.dart index 559f919370..2e19fda9b5 100644 --- a/lib/workers/sender.dart +++ b/lib/workers/sender.dart @@ -18,6 +18,8 @@ import '../crypto/signal/signal_protocol.dart'; import '../db/database.dart'; import '../db/mixin_database.dart' as db; import '../enum/message_category.dart'; +import '../runtime/db_write/method.dart'; +import '../runtime/db_write/payload.dart'; import '../utils/extension/extension.dart'; import '../utils/logger.dart'; @@ -29,6 +31,7 @@ class Sender { this.sessionId, this.accountId, this.database, + this._requestDbWrite, ); final SignalProtocol signalProtocol; @@ -37,6 +40,8 @@ class Sender { final String sessionId; final String accountId; final Database database; + final Future Function(DbWriteMethod method, {Object? payload}) + _requestDbWrite; Future deliver(BlazeMessage blazeMessage) async { final params = blazeMessage.params as BlazeMessageParam; @@ -216,7 +221,7 @@ class Sender { ), ) .toList(); - await database.participantSessionDao.updateList(sentSenderKeys); + await _insertParticipantSessions(sentSenderKeys); } } } @@ -235,10 +240,9 @@ class Sender { return checkSessionSenderKey(conversationId); } if (result.success) { - final messageIds = signalKeyMessages.map( - (e) => db.MessagesHistoryData(messageId: e.messageId), + await _insertMessageHistoryBatch( + signalKeyMessages.map((e) => e.messageId).toList(), ); - await database.messageHistoryDao.insertList(messageIds); final sentSenderKeys = signalKeyMessages .map( @@ -250,7 +254,7 @@ class Sender { ), ) .toList(); - await database.participantSessionDao.updateList(sentSenderKeys); + await _insertParticipantSessions(sentSenderKeys); } } @@ -294,7 +298,7 @@ class Sender { ), ), ); - await database.participantDao.replaceAll(conversationId, participants); + await _replaceParticipants(conversationId, participants); if (conversation.participantSessions != null) { await _syncParticipantSession( conversationId, @@ -307,7 +311,6 @@ class Sender { String conversationId, List data, ) async { - await database.participantSessionDao.deleteByStatus(conversationId); final remote = []; for (final s in data) { remote.add( @@ -319,37 +322,7 @@ class Sender { ), ); } - if (remote.isEmpty) { - await database.participantSessionDao.deleteByConversationId( - conversationId, - ); - return; - } - final local = await database.participantSessionDao - .getParticipantSessionsByConversationId(conversationId); - if (local.isEmpty) { - await database.participantSessionDao.insertAll(remote); - return; - } - final common = remote.toSet().intersection(local.toSet()); - final remove = []; - for (final p in local) { - if (!common.contains(p)) { - remove.add(p); - } - } - final add = []; - for (final p in remote) { - if (!common.contains(p)) { - add.add(p); - } - } - if (remove.isNotEmpty) { - await database.participantSessionDao.deleteList(remove); - } - if (add.isNotEmpty) { - await database.participantSessionDao.insertAll(add); - } + await _replaceParticipantSessions(conversationId, remote); } Future checkConversationExists(db.Conversation conversation) async { @@ -369,7 +342,7 @@ class Sender { ), ); - await database.conversationDao.updateConversationStatusById( + await _updateConversationStatus( conversation.conversationId, ConversationStatus.success, ); @@ -388,7 +361,7 @@ class Sender { ); } if (newParticipantSessions.isNotEmpty) { - await database.participantSessionDao.replaceAll( + await _replaceParticipantSessions( conversation.conversationId, newParticipantSessions, ); @@ -420,13 +393,13 @@ class Sender { final preKeyBundle = keys.first.createPreKeyBundle(); await signalProtocol.processSession(recipientId, preKeyBundle); } else { - await database.participantSessionDao.insert( + await _insertParticipantSessions([ db.ParticipantSessionData( conversationId: conversationId, userId: recipientId, sessionId: sessionId, ), - ); + ]); return false; } final encryptedResult = await signalProtocol.encryptSenderKey( @@ -452,14 +425,14 @@ class Sender { return sendSenderKey(conversationId, recipientId, sessionId); } if (result.success) { - await database.participantSessionDao.insert( + await _insertParticipantSessions([ db.ParticipantSessionData( conversationId: conversationId, userId: recipientId, sessionId: sessionId, sentToServer: SenderKeyStatus.sent.index, ), - ); + ]); } return result.success; } @@ -509,19 +482,7 @@ class Sender { } } else if (action == ProcessSignalKeyAction.removeParticipant) { final pid = participantId!; - await database.transaction(() async { - await database.participantDao.deleteByCIdAndPId( - data.conversationId, - pid, - ); - await database.participantSessionDao.deleteByCIdAndPId( - data.conversationId, - pid, - ); - await database.participantSessionDao.emptyStatusByConversationId( - data.conversationId, - ); - }); + await _removeParticipantAndResetSessions(data.conversationId, pid); await signalProtocol.clearSenderKey(data.conversationId, accountId); } else if (action == ProcessSignalKeyAction.addParticipant) { final userIds = [participantId!]; @@ -546,9 +507,79 @@ class Sender { ); }); if (list.isNotEmpty) { - await database.participantSessionDao.insertAll(list); + await _insertParticipantSessions(list); } } + + Future _insertMessageHistoryBatch(List messageIds) async { + if (messageIds.isEmpty) return; + await _requestDbWrite( + DbWriteMethod.insertMessageHistoryBatch, + payload: DbWriteInsertMessageHistoryBatchPayload(messageIds: messageIds), + ); + } + + Future _replaceParticipants( + String conversationId, + List participants, + ) async { + await _requestDbWrite( + DbWriteMethod.replaceParticipants, + payload: DbWriteReplaceParticipantsPayload( + conversationId: conversationId, + participants: participants, + ), + ); + } + + Future _replaceParticipantSessions( + String conversationId, + List sessions, + ) async { + await _requestDbWrite( + DbWriteMethod.replaceParticipantSessions, + payload: DbWriteReplaceParticipantSessionsPayload( + conversationId: conversationId, + sessions: sessions, + ), + ); + } + + Future _insertParticipantSessions( + List sessions, + ) async { + if (sessions.isEmpty) return; + await _requestDbWrite( + DbWriteMethod.insertParticipantSessions, + payload: DbWriteInsertParticipantSessionsPayload(sessions: sessions), + ); + } + + Future _updateConversationStatus( + String conversationId, + ConversationStatus status, + ) async { + await _requestDbWrite( + DbWriteMethod.updateConversationStatus, + payload: DbWriteUpdateConversationStatusPayload( + conversationId: conversationId, + status: status, + ), + ); + } + + Future _removeParticipantAndResetSessions( + String conversationId, + String participantId, + ) async { + await _requestDbWrite( + DbWriteMethod.removeParticipantAndResetSessions, + payload: DbWriteRemoveParticipantAndResetSessionsPayload( + conversationId: conversationId, + participantId: participantId, + ), + ); + } } enum ProcessSignalKeyAction { addParticipant, removeParticipant, resendKey } diff --git a/lib/workers/sync_worker_isolate.dart b/lib/workers/sync_worker_isolate.dart new file mode 100644 index 0000000000..430c34c3f1 --- /dev/null +++ b/lib/workers/sync_worker_isolate.dart @@ -0,0 +1,456 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'dart:isolate'; +import 'dart:ui' as ui; + +import 'package:ansicolor/ansicolor.dart'; +import 'package:dio/dio.dart'; +import 'package:ed25519_edwards/ed25519_edwards.dart' as ed; +import 'package:equatable/equatable.dart'; +import 'package:flutter/services.dart'; +import 'package:mixin_bot_sdk_dart/mixin_bot_sdk_dart.dart'; +import 'package:rhttp/rhttp.dart'; +import 'package:rxdart/rxdart.dart'; +import 'package:stream_channel/isolate_channel.dart'; + +import '../blaze/blaze.dart'; +import '../crypto/signal/signal_protocol.dart'; +import '../db/database.dart'; +import '../db/database_event_bus.dart'; +import '../db/fts_database.dart'; +import '../db/mixin_database.dart' hide Chain; +import '../runtime/db_write/method.dart'; +import '../runtime/db_write/payload.dart'; +import '../runtime/isolate/protocol.dart'; +import '../runtime/isolate/router.dart'; +import '../runtime/isolate/rpc_client.dart'; +import '../runtime/sync/tick_patch_batcher.dart'; +import '../utils/extension/extension.dart'; +import '../utils/file.dart'; +import '../utils/logger.dart'; +import '../utils/mixin_api_client.dart'; +import '../utils/system/package_info.dart'; +import 'decrypt_message.dart'; +import 'device_transfer.dart'; +import 'isolate_event.dart'; +import 'job/ack_job.dart'; +import 'job/cleanup_quote_content_job.dart'; +import 'job/delete_old_fts_record_job.dart'; +import 'job/flood_job.dart'; +import 'job/migrate_fts_job.dart'; +import 'job/sending_job.dart'; +import 'job/session_ack_job.dart'; +import 'job/sync_inscription_message_job.dart'; +import 'job/update_asset_job.dart'; +import 'job/update_sticker_job.dart'; +import 'job/update_token_job.dart'; +import 'sender.dart'; + +const _kWorkerDbWriteRpcPrefix = 'db_write:'; + +class SyncWorkerInitParams { + SyncWorkerInitParams({ + required this.sendPort, + required this.identityNumber, + required this.userId, + required this.sessionId, + required this.privateKey, + required this.mixinDocumentDirectory, + required this.primarySessionId, + required this.loginByPhoneNumber, + required this.rootIsolateToken, + }); + + final SendPort sendPort; + final String identityNumber; + final String userId; + final String sessionId; + final String privateKey; + final String mixinDocumentDirectory; + final String? primarySessionId; + + final bool loginByPhoneNumber; + final ui.RootIsolateToken rootIsolateToken; +} + +Future startSyncWorkerIsolate(SyncWorkerInitParams params) async { + EquatableConfig.stringify = true; + ansiColorDisabled = Platform.isIOS; + mixinDocumentsDirectory = Directory(params.mixinDocumentDirectory); + await Rhttp.init(); + BackgroundIsolateBinaryMessenger.ensureInitialized(params.rootIsolateToken); + final isolateChannel = IsolateChannel.connectSend( + params.sendPort, + ); + final router = IsolateRouter.worker( + inbound: isolateChannel.stream, + sendMessage: isolateChannel.sink.add, + ); + final rpcClient = IsolateRpcClient(router); + final runner = _SyncWorkerRunner( + identityNumber: params.identityNumber, + userId: params.userId, + sessionId: params.sessionId, + privateKeyStr: params.privateKey, + primarySessionId: params.primarySessionId, + emitEvent: router.sendEvent, + sendPort: params.sendPort, + rpcClient: rpcClient, + ); + router.commands.listen((command) { + try { + runner.onCommand(command); + } catch (error, stacktrace) { + e('error: $error, stacktrace: $stacktrace'); + } + }); + await runner.init(params); + runner._start(); + router + ..sendReady() + ..sendEvent(const WorkerIsolateReadyEvent()); +} + +final Map pendingMessageStatusMap = {}; + +class _SyncWorkerRunner { + _SyncWorkerRunner({ + required this.identityNumber, + required this.userId, + required this.sessionId, + required this.privateKeyStr, + required this.primarySessionId, + required this.emitEvent, + required this.sendPort, + required this.rpcClient, + }) : privateKey = ed.PrivateKey(base64Decode(privateKeyStr)); + + final String identityNumber; + final String userId; + final String sessionId; + final String privateKeyStr; + final ed.PrivateKey privateKey; + final String? primarySessionId; + final SendPort sendPort; + final IsolateRpcClient rpcClient; + + final void Function(WorkerEvent event) emitEvent; + late final TickPatchBatcher _syncPatchBatcher = TickPatchBatcher( + onFlush: (patches) => + _sendEventToMainIsolate(WorkerSyncPatchesEvent(patches: patches)), + ); + + DecryptMessage? _decryptMessage; + + late Client client; + late Database database; + late Blaze blaze; + late Sender _sender; + late SignalProtocol signalProtocol; + + late SendingJob _sendingJob; + late AckJob _ackJob; + late UpdateAssetJob _updateAssetJob; + late UpdateStickerJob _updateStickerJob; + late UpdateTokenJob _updateTokenJob; + late SessionAckJob _sessionAckJob; + late SyncInscriptionMessageJob _syncInscriptionMessageJob; + late FloodJob _floodJob; + DeviceTransferIsolateController? _deviceTransfer; + + final jobSubscribers = []; + + Timer? _nextExpiredMessageRunner; + + Future init(SyncWorkerInitParams initParams) async { + DataBaseEventBus.instance.legacyEventBridgeEnabled = false; + database = Database( + await connectToDatabase(identityNumber), + await FtsDatabase.connect(identityNumber), + ); + + client = createClient( + userId: userId, + sessionId: sessionId, + privateKey: privateKeyStr, + interceptors: [ + InterceptorsWrapper( + onError: (e, handler) async { + _sendEventToMainIsolate(WorkerApiRequestedErrorEvent(error: e)); + handler.next(e); + }, + ), + ], + loginByPhoneNumber: initParams.loginByPhoneNumber, + )..configProxySetting(database.settingProperties); + + _ackJob = AckJob( + database: database, + requestDbWrite: _requestDbWrite, + client: client, + ); + + _floodJob = FloodJob( + database: database, + requestDbWrite: _requestDbWrite, + getProcessFloodJob: getProcessFloodJob, + ); + + blaze = Blaze( + userId, + sessionId, + privateKeyStr, + database, + client, + await generateUserAgent(), + _ackJob, + _floodJob, + _requestDbWrite, + ); + + blaze.connectedStateStream.listen((event) { + _sendEventToMainIsolate( + WorkerBlazeConnectStateChangedEvent(state: event), + ); + }); + + signalProtocol = SignalProtocol(userId)..init(); + + _sender = Sender( + signalProtocol, + blaze, + client, + sessionId, + userId, + database, + _requestDbWrite, + ); + + _sendingJob = SendingJob( + database: database, + requestDbWrite: _requestDbWrite, + sender: _sender, + userId: userId, + sessionId: sessionId, + privateKey: privateKey, + signalProtocol: signalProtocol, + ); + + _sessionAckJob = SessionAckJob( + database: database, + requestDbWrite: _requestDbWrite, + userId: userId, + primarySessionId: primarySessionId, + sender: _sender, + ); + _updateAssetJob = UpdateAssetJob( + database: database, + requestDbWrite: _requestDbWrite, + client: client, + ); + _updateTokenJob = UpdateTokenJob( + database: database, + requestDbWrite: _requestDbWrite, + client: client, + ); + + _updateStickerJob = UpdateStickerJob( + database: database, + requestDbWrite: _requestDbWrite, + client: client, + ); + _syncInscriptionMessageJob = SyncInscriptionMessageJob( + database: database, + requestDbWrite: _requestDbWrite, + client: client, + ); + + MigrateFtsJob(database: database, requestDbWrite: _requestDbWrite); + DeleteOldFtsRecordJob(database: database, requestDbWrite: _requestDbWrite); + CleanupQuoteContentJob(database: database, requestDbWrite: _requestDbWrite); + + if (primarySessionId != null) { + _deviceTransfer = await startTransferIsolate( + userId: userId, + messageDeliver: (message) async { + d('device_transfer: send message: $message'); + final result = await _sender.deliver(message); + if (!result.success) { + w('device_transfer: send message failed: $result'); + } else { + d('device_transfer: send message success: $result'); + } + }, + primarySessionId: primarySessionId!, + identityNumber: identityNumber, + rootIsolateToken: initParams.rootIsolateToken, + mixinDocumentDirectory: initParams.mixinDocumentDirectory, + ); + } else { + e( + 'device_transfer: primarySessionId is null, device transfer is disabled', + ); + } + + _decryptMessage = DecryptMessage( + userId, + database, + signalProtocol, + _sender, + client, + sessionId, + privateKey, + _sendEventToMainIsolate, + identityNumber, + _ackJob, + _sendingJob, + _updateStickerJob, + _updateAssetJob, + _deviceTransfer, + _updateTokenJob, + _syncInscriptionMessageJob, + _requestDbWrite, + ); + + jobSubscribers.add( + DataBaseEventBus.instance.patchStream.listen((event) { + _syncPatchBatcher.add(event); + }), + ); + _floodJob.start(); + } + + Function(FloodMessage floodMessage)? getProcessFloodJob() => + _decryptMessage?.process; + + void _start() { + blaze.connect(); + + jobSubscribers + ..add( + blaze.connectedStateStream + .where((state) => state == ConnectedState.connected) + .listen((event) { + _floodJob.start(); + }), + ) + ..add( + DataBaseEventBus.instance.updateExpiredMessageTableStream + .startWith(null) + .asyncBufferMap((event) => _scheduleExpiredJob()) + .listen((_) {}), + ); + } + + void _sendEventToMainIsolate(WorkerEvent event) => emitEvent(event); + + Future _requestDbWrite( + DbWriteMethod method, { + Object? payload, + }) async { + await rpcClient.request( + '$_kWorkerDbWriteRpcPrefix${method.name}', + payload: payload, + timeout: const Duration(seconds: 20), + ); + } + + Future _scheduleExpiredJob() async { + d('_scheduleExpiredJob'); + final messages = await database.expiredMessageDao + .getCurrentExpiredMessages(); + if (messages.isEmpty) return; + + for (final em in messages) { + // cancel attachment download. + final message = await database.messageDao.findMessageByMessageId( + em.messageId, + ); + if (message == null) { + e('message is null, messageId: ${em.messageId} ${em.expireAt}'); + await _requestDbWrite( + DbWriteMethod.deleteExpiredMessageByMessageId, + payload: em.messageId, + ); + continue; + } + await _requestDbWrite( + DbWriteMethod.deleteMessage, + payload: DbWriteDeleteMessagePayload( + conversationId: message.conversationId, + messageId: em.messageId, + ), + ); + unawaited( + _requestDbWrite( + DbWriteMethod.deleteFtsByMessageId, + payload: em.messageId, + ), + ); + if (message.category.isAttachment || message.category.isTranscript) { + _sendEventToMainIsolate( + WorkerRequestDownloadAttachmentEvent( + request: AttachmentDeleteRequest(message: message), + ), + ); + } + } + + final firstExpiredMessage = await database.expiredMessageDao + .getFirstExpiredMessage() + .getSingleOrNull(); + if (firstExpiredMessage == null) { + _nextExpiredMessageRunner?.cancel(); + _nextExpiredMessageRunner = null; + return; + } + _nextExpiredMessageRunner?.cancel(); + _nextExpiredMessageRunner = Timer( + Duration( + seconds: + firstExpiredMessage.expireAt! - + DateTime.now().millisecondsSinceEpoch ~/ 1000, + ), + _scheduleExpiredJob, + ); + } + + void onCommand(WorkerCommand command) { + switch (command) { + case UpdateSelectedConversationCommand(:final conversationId): + _decryptMessage?.conversationId = conversationId; + case DisconnectBlazeWithTimeCommand(): + blaze.waitSyncTime(); + case ReconnectBlazeCommand(): + i('sync worker isolate: reconnect blaze'); + blaze.reconnect(); + case AddAckJobsCommand(:final jobs): + _ackJob.add(jobs); + case AddSessionAckJobsCommand(:final jobs): + _sessionAckJob.add(jobs); + case AddSendingJobCommand(:final job): + _sendingJob.add(job); + case AddUpdateAssetJobCommand(:final job): + _updateAssetJob.add(job); + case AddUpdateTokenJobCommand(:final job): + _updateTokenJob.add(job); + case AddUpdateStickerJobCommand(:final job): + _updateStickerJob.add(job); + case AddSyncInscriptionMessageJobCommand(:final job): + _syncInscriptionMessageJob.add(job); + case ExitWorkerCommand(): + dispose(); + Isolate.exit(); + } + } + + void dispose() { + _syncPatchBatcher.dispose(); + blaze.dispose(); + database.dispose(); + unawaited(rpcClient.dispose()); + jobSubscribers.forEach((subscription) => subscription.cancel()); + _deviceTransfer?.dispose(); + } +} diff --git a/macos/Runner.xcodeproj/project.pbxproj b/macos/Runner.xcodeproj/project.pbxproj index ef954225eb..078af80835 100644 --- a/macos/Runner.xcodeproj/project.pbxproj +++ b/macos/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 54; objects = { /* Begin PBXAggregateTarget section */ diff --git a/pubspec.lock b/pubspec.lock index 5f112b0134..29e443f068 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -17,6 +17,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + analysis_server_plugin: + dependency: "direct main" + description: + name: analysis_server_plugin + sha256: "5f3920acbd5765764ec9ef6c5bbdd102015424281232ee4fb4f5431c87abb4eb" + url: "https://pub.dev" + source: hosted + version: "0.3.7" analyzer: dependency: transitive description: @@ -25,6 +33,14 @@ packages: url: "https://pub.dev" source: hosted version: "10.0.1" + analyzer_plugin: + dependency: transitive + description: + name: analyzer_plugin + sha256: "7df504f0c9d6891bacc9f73a5a8c5f6fe4fc49c90ec8e3379916372906ba0b32" + url: "https://pub.dev" + source: hosted + version: "0.14.1" android_id: dependency: "direct main" description: @@ -89,22 +105,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.1" - bloc: - dependency: "direct main" - description: - name: bloc - sha256: a48653a82055a900b88cd35f92429f068c5a8057ae9b136d197b3d56c57efb81 - url: "https://pub.dev" - source: hosted - version: "9.2.0" - bloc_concurrency: - dependency: "direct main" - description: - name: bloc_concurrency - sha256: "86b7b17a0a78f77fca0d7c030632b59b593b22acea2d96972588f40d4ef53a94" - url: "https://pub.dev" - source: hosted - version: "0.3.0" blurhash_dart: dependency: "direct main" description: @@ -218,6 +218,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.3" + cli_config: + dependency: transitive + description: + name: cli_config + sha256: ac20a183a07002b700f0c25e61b7ee46b23c309d76ab7b7640a028f18e4d99ec + url: "https://pub.dev" + source: hosted + version: "0.2.0" cli_util: dependency: transitive description: @@ -283,6 +291,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.2" + coverage: + dependency: transitive + description: + name: coverage + sha256: "5da775aa218eaf2151c721b16c01c7676fbfdd99cebba2bf64e8b807a28ff94d" + url: "https://pub.dev" + source: hosted + version: "1.15.0" cross_file: dependency: transitive description: @@ -659,14 +675,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" - flutter_bloc: - dependency: "direct main" - description: - name: flutter_bloc - sha256: cf51747952201a455a1c840f8171d273be009b932c75093020f9af64f2123e38 - url: "https://pub.dev" - source: hosted - version: "9.1.1" flutter_highlight: dependency: transitive description: @@ -748,10 +756,10 @@ packages: dependency: transitive description: name: flutter_riverpod - sha256: "9532ee6db4a943a1ed8383072a2e3eeda041db5657cdf6d2acecf3c21ecbe7e1" + sha256: e2026c72738a925a60db30258ff1f29974e40716749f3c9850aabf34ffc1a14c url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.2.1" flutter_rust_bridge: dependency: "direct main" description: @@ -794,6 +802,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" + frontend_server_client: + dependency: transitive + description: + name: frontend_server_client + sha256: f64a0333a82f30b0cca061bc3d143813a486dc086b574bfb233b7c1372427694 + url: "https://pub.dev" + source: hosted + version: "4.0.0" gal: dependency: "direct main" description: @@ -859,14 +875,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.3" - hive_ce: - dependency: transitive - description: - name: hive_ce - sha256: "708bb39050998707c5d422752159f91944d3c81ab42d80e1bd0ee37d8e130658" - url: "https://pub.dev" - source: hosted - version: "2.11.3" hive_flutter: dependency: "direct main" description: @@ -887,10 +895,10 @@ packages: dependency: "direct main" description: name: hooks_riverpod - sha256: "70bba33cfc5670c84b796e6929c54b8bc5be7d0fe15bb28c2560500b9ad06966" + sha256: "402a0969af107ebb42d0300dd68aa48df136671dacb70593d164f04b30af43f4" url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.2.1" html: dependency: "direct main" description: @@ -939,14 +947,6 @@ packages: url: "https://pub.dev" source: hosted version: "0.1.0" - hydrated_bloc: - dependency: "direct main" - description: - name: hydrated_bloc - sha256: f359a1135cc2eaddb53eeb21c8f915a149b9bdb80d01fda27c6f4e333db0cf2f - url: "https://pub.dev" - source: hosted - version: "10.1.1" image: dependency: "direct main" description: @@ -1075,14 +1075,6 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.1" - isolate_channel: - dependency: transitive - description: - name: isolate_channel - sha256: f3d36f783b301e6b312c3450eeb2656b0e7d1db81331af2a151d9083a3f6b18d - url: "https://pub.dev" - source: hosted - version: "0.2.2+1" js: dependency: transitive description: @@ -1332,6 +1324,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.5.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" objective_c: dependency: transitive description: @@ -1720,10 +1720,10 @@ packages: dependency: transitive description: name: riverpod - sha256: "59062512288d3056b2321804332a13ffdd1bf16df70dcc8e506e411280a72959" + sha256: "8c22216be8ad3ef2b44af3a329693558c98eca7b8bd4ef495c92db0bba279f83" url: "https://pub.dev" source: hosted - version: "2.6.1" + version: "3.2.1" rxdart: dependency: "direct main" description: @@ -1796,6 +1796,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.2" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: c87c3875f91262785dade62d135760c2c69cb217ac759485334c5857ad89f6e3 + url: "https://pub.dev" + source: hosted + version: "1.1.3" shelf_web_socket: dependency: transitive description: @@ -1841,6 +1857,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.10" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: c0713a43e323c3302c2abe2a1cc89aa057a387101ebd280371d6a6c9fa68516b + url: "https://pub.dev" + source: hosted + version: "2.1.2" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "190222579a448b03896e0ca6eca5998fa810fda630c1d65e2f78b3f638f54812" + url: "https://pub.dev" + source: hosted + version: "0.10.13" source_span: dependency: transitive description: @@ -1985,6 +2017,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.2" + test: + dependency: transitive + description: + name: test + sha256: "54c516bbb7cee2754d327ad4fca637f78abfc3cbcc5ace83b3eda117e42cd71a" + url: "https://pub.dev" + source: hosted + version: "1.29.0" test_api: dependency: transitive description: @@ -1993,6 +2033,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.7.9" + test_core: + dependency: transitive + description: + name: test_core + sha256: "394f07d21f0f2255ec9e3989f21e54d3c7dc0e6e9dbce160e5a9c1a6be0e2943" + url: "https://pub.dev" + source: hosted + version: "0.6.15" thirds: dependency: transitive description: @@ -2251,6 +2299,14 @@ packages: url: "https://github.com/boyan01/webcrypto.dart.git" source: git version: "0.5.4" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" webview_flutter: dependency: "direct main" description: @@ -2356,6 +2412,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.3" + yaml_edit: + dependency: transitive + description: + name: yaml_edit + sha256: "07c9e63ba42519745182b88ca12264a7ba2484d8239958778dfe4d44fe760488" + url: "https://pub.dev" + source: hosted + version: "2.2.4" sdks: dart: ">=3.11.0 <4.0.0" flutter: ">=3.38.0 <4.0.0" diff --git a/pubspec.yaml b/pubspec.yaml index 3f4c12a847..f0ab3d8e48 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -25,8 +25,6 @@ dependencies: async: ^2.13.0 audio_session: ^0.2.2 auto_size_text: ^3.0.0 - bloc: ^9.2.0 - bloc_concurrency: ^0.3.0 blurhash_dart: ^1.0.2 bring_window_to_front: git: @@ -68,7 +66,6 @@ dependencies: sdk: flutter flutter_animate: ^4.4.0 flutter_app_icon_badge: ^2.0.0 - flutter_bloc: ^9.1.1 flutter_hooks: ^0.21.3 flutter_local_notifications: ^20.1.0 flutter_localizations: @@ -82,10 +79,9 @@ dependencies: github: ^9.25.0 hive: ^2.2.3 hive_flutter: ^1.0.0 - hooks_riverpod: ^2.5.1 + hooks_riverpod: ^3.2.1 http: ^1.6.0 http_client_helper: ^3.0.0 - hydrated_bloc: ^10.1.1 image: ^4.8.0 image_picker: ^1.2.1 intl: ^0.20.2 @@ -166,6 +162,7 @@ dependencies: ref: 08c1ce40eb6abfad6049fb6aad8bd30312ec5319 path: packages/data_detector envied: ^1.3.3 + analysis_server_plugin: ^0.3.7 dev_dependencies: build_runner: ^2.11.1 diff --git a/test/runtime/app_runtime_hub_architecture_test.dart b/test/runtime/app_runtime_hub_architecture_test.dart new file mode 100644 index 0000000000..84fb9d192e --- /dev/null +++ b/test/runtime/app_runtime_hub_architecture_test.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'AppRuntimeHub waits for database instead of failing immediately', + () async { + final file = File( + '/Users/yeungkc/coding/work/mixin/flutter-app/lib/runtime/app_runtime_hub.dart', + ); + final content = await file.readAsString(); + + expect(content, contains("if (args.database == null)")); + expect(content, contains("[RuntimeHub] database not ready yet, waiting")); + }, + ); +} diff --git a/test/runtime/isolate_rpc_client_test.dart b/test/runtime/isolate_rpc_client_test.dart new file mode 100644 index 0000000000..463fc485ab --- /dev/null +++ b/test/runtime/isolate_rpc_client_test.dart @@ -0,0 +1,32 @@ +import 'dart:async'; + +import 'package:flutter_app/runtime/isolate/rpc_client.dart'; +import 'package:flutter_app/runtime/isolate/router.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('rpc client fails immediately when router send throws', () async { + final router = IsolateRouter.main( + inbound: const Stream.empty(), + sendMessage: (_) => throw StateError('worker channel is null'), + ); + addTearDown(router.dispose); + + final client = IsolateRpcClient( + router, + defaultTimeout: const Duration(milliseconds: 50), + ); + addTearDown(client.dispose); + + await expectLater( + client.request('upsertUser', payload: {'id': 'u1'}), + throwsA( + isA().having( + (error) => error.message, + 'message', + contains('worker channel is null'), + ), + ), + ); + }); +} diff --git a/test/runtime/sync/tick_patch_batcher_test.dart b/test/runtime/sync/tick_patch_batcher_test.dart new file mode 100644 index 0000000000..bafc95219a --- /dev/null +++ b/test/runtime/sync/tick_patch_batcher_test.dart @@ -0,0 +1,40 @@ +import 'package:flutter_app/runtime/sync/patch.dart'; +import 'package:flutter_app/runtime/sync/tick_patch_batcher.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test('flushes pending patches at tick end in a single batch', () async { + final flushed = >[]; + TickPatchBatcher( + onFlush: flushed.add, + flushDelay: const Duration(milliseconds: 20), + ) + ..add(SyncPatch.updateConversation(['c1'])) + ..add(SyncPatch.updateConversation(['c2'])) + ..add(SyncPatch.updateConversation(['c3'])); + + expect(flushed, isEmpty); + await Future.delayed(const Duration(milliseconds: 30)); + + expect(flushed, hasLength(1)); + expect(flushed.single, hasLength(3)); + expect( + flushed.single.every((p) => p.type == SyncPatchType.updateConversation), + isTrue, + ); + }); + + test('dispose flushes pending patches immediately', () { + final flushed = >[]; + TickPatchBatcher( + onFlush: flushed.add, + flushDelay: const Duration(seconds: 1), + ) + ..add(SyncPatch.updateUser(['u1'])) + ..dispose(); + + expect(flushed, hasLength(1)); + expect(flushed.single, hasLength(1)); + expect(flushed.single.first.type, SyncPatchType.updateUser); + }); +} diff --git a/test/runtime/worker_supervisor_test.dart b/test/runtime/worker_supervisor_test.dart new file mode 100644 index 0000000000..48973a9396 --- /dev/null +++ b/test/runtime/worker_supervisor_test.dart @@ -0,0 +1,69 @@ +import 'dart:async'; +import 'dart:isolate'; + +import 'package:flutter_app/runtime/isolate/router.dart'; +import 'package:flutter_app/runtime/isolate/worker_supervisor.dart'; +import 'package:flutter_app/runtime/isolate/protocol.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:stream_channel/isolate_channel.dart'; + +class _ReadyWorkerInitParams { + _ReadyWorkerInitParams({ + required this.sendPort, + required this.readyDelay, + }); + + final SendPort sendPort; + final Duration readyDelay; +} + +Future _readyWorkerMain(_ReadyWorkerInitParams params) async { + final isolateChannel = IsolateChannel.connectSend(params.sendPort); + final router = IsolateRouter.worker( + inbound: isolateChannel.stream, + sendMessage: isolateChannel.sink.add, + ); + + Future.delayed(params.readyDelay, router.sendReady); + + await router.commands.firstWhere((command) => command is ExitWorkerCommand); + Isolate.exit(); +} + +void main() { + test('worker supervisor waits until ready control arrives', () async { + final supervisor = WorkerSupervisor<_ReadyWorkerInitParams>( + entryPoint: _readyWorkerMain, + initParamsFactory: (sendPort) => _ReadyWorkerInitParams( + sendPort: sendPort, + readyDelay: const Duration(milliseconds: 80), + ), + heartbeatInterval: const Duration(seconds: 30), + heartbeatTimeout: const Duration(seconds: 30), + ); + addTearDown(supervisor.dispose); + + await supervisor.start(); + expect(supervisor.isRunning, isTrue); + expect(supervisor.isReady, isFalse); + + await supervisor.waitUntilReady(timeout: const Duration(seconds: 1)); + expect(supervisor.isReady, isTrue); + }); + + test('worker supervisor send fails fast when channel is unavailable', () { + final supervisor = WorkerSupervisor<_ReadyWorkerInitParams>( + entryPoint: _readyWorkerMain, + initParamsFactory: (sendPort) => _ReadyWorkerInitParams( + sendPort: sendPort, + readyDelay: Duration.zero, + ), + ); + addTearDown(supervisor.dispose); + + expect( + () => supervisor.send(const ExitWorkerCommand()), + throwsA(isA()), + ); + }); +} diff --git a/test/ui/conversation_provider_architecture_test.dart b/test/ui/conversation_provider_architecture_test.dart new file mode 100644 index 0000000000..dee8a9247d --- /dev/null +++ b/test/ui/conversation_provider_architecture_test.dart @@ -0,0 +1,18 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + test( + 'conversation provider uses replaceState instead of direct state write', + () async { + final file = File( + '/Users/yeungkc/coding/work/mixin/flutter-app/lib/ui/provider/conversation_provider.dart', + ); + final content = await file.readAsString(); + + expect(content, contains('void replaceState(ConversationState? next)')); + expect(content, isNot(contains('conversationNotifier.state ='))); + }, + ); +} diff --git a/test/ui/home/blink_controller_test.dart b/test/ui/home/blink_controller_test.dart new file mode 100644 index 0000000000..f7f7f4a1b5 --- /dev/null +++ b/test/ui/home/blink_controller_test.dart @@ -0,0 +1,29 @@ +import 'dart:async'; + +import 'package:flutter_app/ui/home/controllers/blink_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + test('blink controller updates state without ticker scope', () async { + final container = ProviderContainer(); + addTearDown(container.dispose); + + final notifier = container.read(blinkControllerProvider.notifier); + expect(container.read(blinkControllerProvider), const BlinkState()); + + final completer = Completer(); + final sub = notifier.stream.listen((state) { + if (!completer.isCompleted && state.messageId == 'message-1') { + completer.complete(state); + } + }); + addTearDown(sub.cancel); + + notifier.blinkByMessageId('message-1'); + + final state = await completer.future.timeout(const Duration(seconds: 1)); + expect(state.messageId, 'message-1'); + expect(state.color.alpha, greaterThanOrEqualTo(0)); + }); +} diff --git a/test/ui/landing/landing_mobile_provider_test.dart b/test/ui/landing/landing_mobile_provider_test.dart new file mode 100644 index 0000000000..bfcafaaf58 --- /dev/null +++ b/test/ui/landing/landing_mobile_provider_test.dart @@ -0,0 +1,29 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_app/ui/landing/landing.dart'; +import 'package:flutter_app/ui/provider/ui_context_providers.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + test( + 'landingMobileClientProvider derives headers from platform info', + () async { + final container = ProviderContainer( + overrides: [ + localeProvider.overrideWith((ref) => const Locale('en')), + landingMobilePlatformInfoProvider.overrideWith( + (ref) async => + (userAgent: 'test-user-agent', deviceId: 'device-123'), + ), + ], + ); + addTearDown(container.dispose); + + final client = await container.read(landingMobileClientProvider.future); + + expect(client.dio.options.headers['Accept-Language'], 'en'); + expect(client.dio.options.headers['User-Agent'], 'test-user-agent'); + expect(client.dio.options.headers['Mixin-Device-Id'], 'device-123'); + }, + ); +} diff --git a/test/ui/shared_ui_runtime_provider_architecture_test.dart b/test/ui/shared_ui_runtime_provider_architecture_test.dart new file mode 100644 index 0000000000..ed65606c63 --- /dev/null +++ b/test/ui/shared_ui_runtime_provider_architecture_test.dart @@ -0,0 +1,696 @@ +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const cases = <({String path, List forbiddenPatterns})>[ + ( + path: 'lib/widgets/toast.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/widgets/conversation/mute_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/widgets/conversation/conversation_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/user/user_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/actions/create_circle_action.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/widgets/actions/create_group_conversation_action.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/actions/create_conversation_action.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/widgets/unknown_mixin_url_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/qr_code.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/widgets/az_selection.dart', + forbiddenPatterns: [ + 'Theme.of(context).textTheme', + ], + ), + ( + path: 'lib/widgets/empty.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/radio.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/buttons.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/app_bar.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/pin_bubble.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message_status_icon.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/sticker_page/giphy_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/sticker_page/add_sticker_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/user/captcha_web_view_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/widgets/user/phone_number_input.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/user/verification_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/user/change_number_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/widgets/auth.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/user_selector/conversation_selector.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + 'BrightnessData.dynamicColor(context', + ], + ), + ( + path: 'lib/widgets/menu.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + 'BrightnessData.dynamicColor(context', + 'BrightnessData.of(context)', + 'Theme.of(context).brightness', + 'MediaQuery.of(context)', + ], + ), + ( + path: 'lib/app.dart', + forbiddenPatterns: [ + 'MediaQuery.of(context)', + 'MediaQuery.sizeOf(context)', + 'Localizations.localeOf(context)', + ], + ), + ( + path: 'lib/utils/system/tray.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/ui/setting/backup_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/about_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/account_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/notification_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/storage_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/security_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/proxy_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/storage_usage_detail_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/video/video_preview_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/image/image_preview_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + 'MaterialLocalizations.of(context)', + ], + ), + ( + path: 'lib/widgets/message/send_message_dialog/send_message_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + 'BrightnessData.dynamicColor(context', + ], + ), + ( + path: 'lib/widgets/message/item/transfer/transfer_message.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/transfer/safe_transfer_message.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: + 'lib/widgets/message/item/transfer/inscription_message/inscription_message.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: + 'lib/widgets/message/item/transfer/inscription_message/inscription_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/setting_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/account_delete_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/edit_profile_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + 'BrightnessData.dynamicColor(context', + ], + ), + ( + path: 'lib/ui/landing/landing_failed.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/storage_usage_list_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/setting/log_page.dart', + forbiddenPatterns: [ + 'MaterialLocalizations.of(context)', + ], + ), + ( + path: 'lib/ui/home/chat_slide_page/circle_manager_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + 'BrightnessData.dynamicColor(context', + ], + ), + ( + path: 'lib/ui/home/chat_slide_page/group_invite/group_invite_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + 'BrightnessData.dynamicColor(context', + ], + ), + ( + path: 'lib/ui/home/chat_slide_page/pin_messages_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/home/chat_slide_page/share_media/media_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/home/chat_slide_page/share_media/file_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/home/chat_slide_page/share_media/post_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/home/chat_slide_page/shared_media_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/home/chat_slide_page/shared_apps_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/home/chat_slide_page/groups_in_common_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/home/conversation/search_list.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/actions/command_palette_action.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/widgets/sticker_page/sticker_album_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/sticker_page/sticker_store.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + 'BrightnessData.dynamicColor(context', + ], + ), + ( + path: 'lib/widgets/sticker_page/sticker_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + 'BrightnessData.dynamicColor(context', + ], + ), + ( + path: 'lib/widgets/message/item/pin_message.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.dynamicColor(context', + ], + ), + ( + path: 'lib/widgets/message/item/stranger_message.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/recall_message.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/unknown_message.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/waiting_message.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/secret_message.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + 'BrightnessData.dynamicColor(context', + ], + ), + ( + path: 'lib/widgets/message/item/file_message.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/post_message.dart', + forbiddenPatterns: [ + 'MaterialLocalizations.of(context)', + ], + ), + ( + path: 'lib/widgets/message/item/transcript_message.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/transfer/transfer_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/transfer/safe_transfer_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/home/chat/selection_bottom_bar.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + 'BrightnessData.dynamicColor(context', + ], + ), + ( + path: 'lib/ui/home/conversation/audio_player_bar.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/home/chat_slide_page/group_participants_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/home/slide_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + 'BrightnessData.of(context)', + ], + ), + ( + path: 'lib/utils/device_transfer/device_transfer_dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/utils/device_transfer/device_transfer_widget.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/markdown.dart', + forbiddenPatterns: [ + 'Theme.of(context).brightness', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/dialog.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'MaterialLocalizations.of(context)', + 'BrightnessData.themeOf(context)', + 'BrightnessData.dynamicColor(context', + 'BrightnessData.of(context)', + ], + ), + ( + path: 'lib/widgets/high_light_text.dart', + forbiddenPatterns: [ + 'MaterialLocalizations.of(context)', + ], + ), + ( + path: 'lib/widgets/message/item/action_card/action_message.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/action_card/actions_card.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/contact_message_widget.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/text/text_message.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/item/action/action_message.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/message_day_time.dart', + forbiddenPatterns: [ + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/widgets/message/message_bubble.dart', + forbiddenPatterns: [ + 'Theme.of(context).brightness', + ], + ), + ( + path: + 'lib/widgets/message/item/transfer/inscription_message/inscription_content.dart', + forbiddenPatterns: [ + 'MediaQuery.of(context)', + ], + ), + ( + path: 'lib/widgets/brightness_observer.dart', + forbiddenPatterns: [ + 'MediaQuery.platformBrightnessOf(context)', + ], + ), + ( + path: 'lib/utils/uri_utils.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/utils/file.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/utils/attachment/attachment_util.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/ui/provider/conversation_provider.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/db/dao/snapshot_dao.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + ], + ), + ( + path: 'lib/ui/home/chat/chat_page.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ( + path: 'lib/ui/home/chat/image_editor.dart', + forbiddenPatterns: [ + 'Localization.of(context)', + 'BrightnessData.themeOf(context)', + ], + ), + ]; + + for (final entry in cases) { + test( + '${entry.path} uses ui runtime providers instead of shared .of(context)', + () { + final content = File(entry.path).readAsStringSync(); + for (final pattern in entry.forbiddenPatterns) { + expect( + content.contains(pattern), + isFalse, + reason: + 'Found forbidden shared runtime lookup: $pattern in ${entry.path}', + ); + } + }, + ); + } +} diff --git a/test/ui/ui_context_scope_test.dart b/test/ui/ui_context_scope_test.dart new file mode 100644 index 0000000000..e6b4a52a90 --- /dev/null +++ b/test/ui/ui_context_scope_test.dart @@ -0,0 +1,45 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_app/generated/l10n.dart'; +import 'package:flutter_app/ui/provider/ui_context_providers.dart'; +import 'package:flutter_localizations/flutter_localizations.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +void main() { + testWidgets( + 'UiContextScope does not modify providers during build', + (tester) async { + FlutterErrorDetails? capturedError; + final originalOnError = FlutterError.onError; + FlutterError.onError = (details) { + capturedError = details; + }; + addTearDown(() => FlutterError.onError = originalOnError); + + await tester.pumpWidget( + ProviderScope( + child: MaterialApp( + localizationsDelegates: const [ + Localization.delegate, + GlobalMaterialLocalizations.delegate, + GlobalWidgetsLocalizations.delegate, + GlobalCupertinoLocalizations.delegate, + ], + supportedLocales: Localization.delegate.supportedLocales, + home: const UiContextScope(child: SizedBox()), + ), + ), + ); + await tester.pump(); + + expect( + capturedError?.exceptionAsString(), + isNot( + contains( + 'Tried to modify a provider while the widget tree was building', + ), + ), + ); + }, + ); +} diff --git a/test/utils/transfer_protocol_test.dart b/test/utils/transfer_protocol_test.dart index 9d9c149002..20c47c2be2 100644 --- a/test/utils/transfer_protocol_test.dart +++ b/test/utils/transfer_protocol_test.dart @@ -269,12 +269,14 @@ class _BytesStreamSink implements IOSink { } @override - Future addStream(Stream> stream) { - throw UnimplementedError(); + Future addStream(Stream> stream) async { + await for (final chunk in stream) { + add(chunk); + } } @override - Future get done => throw UnimplementedError(); + Future get done async {} @override Future flush() async {}