Chat message uploading file progress

This commit is contained in:
LittleSheep 2025-05-18 02:21:14 +08:00
parent 81d5083908
commit 3737de6936
6 changed files with 323 additions and 111 deletions

View File

@ -236,5 +236,6 @@
},
"levelingProgress": "Leveling Progress",
"levelingProgressExperience": "{} EXP",
"levelingProgressLevel": "Level {}"
"levelingProgressLevel": "Level {}",
"fileUploadingProgress": "Uploading file #{}: {}%"
}

View File

@ -1,5 +1,6 @@
import 'package:drift/drift.dart';
import 'package:island/models/chat.dart';
import 'package:island/models/file.dart';
class ChatMessages extends Table {
TextColumn get id => text()();
@ -23,6 +24,7 @@ class LocalChatMessage {
final DateTime createdAt;
MessageStatus status;
final String? nonce;
List<UniversalFile>? localAttachments;
LocalChatMessage({
required this.id,
@ -30,8 +32,9 @@ class LocalChatMessage {
required this.senderId,
required this.data,
required this.createdAt,
required this.nonce,
required this.status,
this.nonce,
this.localAttachments,
});
SnChatMessage toRemoteMessage() {

View File

@ -14,6 +14,7 @@ class MessageRepository {
final AppDatabase _database;
final Map<String, LocalChatMessage> pendingMessages = {};
final Map<String, Map<int, double>> fileUploadProgress = {};
MessageRepository(this.room, this.identity, this._apiClient, this._database);
@ -181,6 +182,7 @@ class MessageRepository {
SnChatMessage? forwardingTo,
SnChatMessage? editingTo,
Function(LocalChatMessage)? onPending,
Function(String, Map<int, double>)? onProgress,
}) async {
// Generate a unique nonce for this message
final nonce = const Uuid().v4();
@ -204,6 +206,7 @@ class MessageRepository {
// Store in memory and database
pendingMessages[localMessage.id] = localMessage;
fileUploadProgress[localMessage.id] = {};
await _database.saveMessage(_database.messageToCompanion(localMessage));
onPending?.call(localMessage);
@ -225,6 +228,13 @@ class MessageRepository {
UniversalFileType.audio => 'audio/unknown',
UniversalFileType.file => 'application/octet-stream',
},
onProgress: (progress, _) {
fileUploadProgress[localMessage.id]?[idx] = progress;
onProgress?.call(
localMessage.id,
fileUploadProgress[localMessage.id] ?? {},
);
},
).future;
if (cloudFile == null) {
throw ArgumentError('Failed to upload the file...');

View File

@ -1,3 +1,4 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
@ -23,6 +24,10 @@ import 'package:styled_widget/styled_widget.dart';
import 'package:super_context_menu/super_context_menu.dart';
import 'package:uuid/uuid.dart';
import 'chat.dart';
import 'package:material_symbols_icons/symbols.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
part 'room.g.dart';
final messageRepositoryProvider =
FutureProvider.family<MessageRepository, String>((ref, roomId) async {
@ -33,29 +38,22 @@ final messageRepositoryProvider =
return MessageRepository(room!, identity!, apiClient, database);
});
// Provider for messages with pagination
final messagesProvider = StateNotifierProvider.family<
MessagesNotifier,
AsyncValue<List<LocalChatMessage>>,
String
>((ref, roomId) => MessagesNotifier(ref, roomId));
class MessagesNotifier
extends StateNotifier<AsyncValue<List<LocalChatMessage>>> {
final Ref _ref;
final String _roomId;
@riverpod
class MessagesNotifier extends _$MessagesNotifier {
late final String _roomId;
int _currentPage = 0;
static const int _pageSize = 20;
bool _hasMore = true;
MessagesNotifier(this._ref, this._roomId)
: super(const AsyncValue.loading()) {
loadInitial();
@override
FutureOr<List<LocalChatMessage>> build(String roomId) async {
_roomId = roomId;
return await loadInitial();
}
Future<void> loadInitial() async {
Future<List<LocalChatMessage>> loadInitial() async {
try {
final repository = await _ref.read(
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
final synced = await repository.syncMessages();
@ -64,11 +62,11 @@ class MessagesNotifier
take: _pageSize,
synced: synced,
);
state = AsyncValue.data(messages);
_currentPage = 0;
_hasMore = messages.length == _pageSize;
} catch (e, stack) {
state = AsyncValue.error(e, stack);
return messages;
} catch (_) {
rethrow;
}
}
@ -78,7 +76,7 @@ class MessagesNotifier
try {
final currentMessages = state.value ?? [];
_currentPage++;
final repository = await _ref.read(
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
final newMessages = await repository.listMessages(
@ -100,61 +98,49 @@ class MessagesNotifier
Future<void> sendMessage(
String content,
List<UniversalFile> attachments, {
SnChatMessage? replyingTo,
SnChatMessage? forwardingTo,
SnChatMessage? editingTo,
SnChatMessage? forwardingTo,
SnChatMessage? replyingTo,
Function(String, Map<int, double>)? onProgress,
}) async {
try {
final repository = await _ref.read(
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
final nonce = const Uuid().v4();
final baseUrl = _ref.read(serverUrlProvider);
final baseUrl = ref.read(serverUrlProvider);
final atk = await getFreshAtk(
_ref.watch(tokenPairProvider),
ref.watch(tokenPairProvider),
baseUrl,
onRefreshed: (atk, rtk) {
setTokenPair(_ref.watch(sharedPreferencesProvider), atk, rtk);
_ref.invalidate(tokenPairProvider);
setTokenPair(ref.watch(sharedPreferencesProvider), atk, rtk);
ref.invalidate(tokenPairProvider);
},
);
if (atk == null) throw Exception("Unauthorized");
if (atk == null) throw ArgumentError('Access token is null');
LocalChatMessage? pendingMessage;
final messageTask = repository.sendMessage(
final currentMessages = state.value ?? [];
await repository.sendMessage(
atk,
baseUrl,
_roomId,
content,
nonce,
const Uuid().v4(),
attachments: attachments,
replyingTo: replyingTo,
forwardingTo: forwardingTo,
editingTo: editingTo,
forwardingTo: forwardingTo,
replyingTo: replyingTo,
onPending: (pending) {
pendingMessage = pending;
final currentMessages = state.value ?? [];
state = AsyncValue.data([pending, ...currentMessages]);
},
onProgress: onProgress,
);
final message = await messageTask;
final updatedMessages = state.value ?? [];
if (pendingMessage != null) {
final index = updatedMessages.indexWhere(
(m) => m.id == pendingMessage!.id,
// Refresh messages
final messages = await repository.listMessages(
offset: 0,
take: _pageSize,
);
if (index >= 0) {
final newList = [...updatedMessages];
newList[index] = message;
state = AsyncValue.data(newList);
}
} else {
state = AsyncValue.data([message, ...updatedMessages]);
}
state = AsyncValue.data(messages);
} catch (err) {
showErrorAlert(err);
}
@ -162,7 +148,7 @@ class MessagesNotifier
Future<void> retryMessage(String pendingMessageId) async {
try {
final repository = await _ref.read(
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
final updatedMessage = await repository.retryMessage(pendingMessageId);
@ -182,7 +168,7 @@ class MessagesNotifier
Future<void> receiveMessage(SnChatMessage remoteMessage) async {
try {
final repository = await _ref.read(
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
@ -217,7 +203,7 @@ class MessagesNotifier
Future<void> receiveMessageUpdate(SnChatMessage remoteMessage) async {
try {
final repository = await _ref.read(
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
@ -246,7 +232,7 @@ class MessagesNotifier
Future<void> receiveMessageDeletion(String messageId) async {
try {
final repository = await _ref.read(
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
@ -265,41 +251,9 @@ class MessagesNotifier
}
}
Future<void> updateMessage(
String messageId,
String content, {
List<SnCloudFile>? attachments,
Map<String, dynamic>? meta,
}) async {
try {
final repository = await _ref.read(
messageRepositoryProvider(_roomId).future,
);
final updatedMessage = await repository.updateMessage(
messageId,
content,
attachments: attachments,
meta: meta,
);
// Update the message in the list
final currentMessages = state.value ?? [];
final index = currentMessages.indexWhere((m) => m.id == messageId);
if (index >= 0) {
final newList = [...currentMessages];
newList[index] = updatedMessage;
state = AsyncValue.data(newList);
}
} catch (err) {
showErrorAlert(err);
}
}
Future<void> deleteMessage(String messageId) async {
try {
final repository = await _ref.read(
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
@ -320,7 +274,7 @@ class MessagesNotifier
Future<LocalChatMessage?> fetchMessageById(String messageId) async {
try {
final repository = await _ref.read(
final repository = await ref.read(
messageRepositoryProvider(_roomId).future,
);
return await repository.getMessageById(messageId);
@ -340,8 +294,8 @@ class ChatRoomScreen extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final chatRoom = ref.watch(chatroomProvider(id));
final chatIdentity = ref.watch(chatroomIdentityProvider(id));
final messages = ref.watch(messagesProvider(id));
final messagesNotifier = ref.read(messagesProvider(id).notifier);
final messages = ref.watch(messagesNotifierProvider(id));
final messagesNotifier = ref.read(messagesNotifierProvider(id).notifier);
final ws = ref.watch(websocketProvider);
final messageController = useTextEditingController();
@ -350,6 +304,8 @@ class ChatRoomScreen extends HookConsumerWidget {
final messageReplyingTo = useState<SnChatMessage?>(null);
final messageForwardingTo = useState<SnChatMessage?>(null);
final messageEditingTo = useState<SnChatMessage?>(null);
final attachments = useState<List<UniversalFile>>([]);
final attachmentProgress = useState<Map<String, Map<int, double>>>({});
// Add scroll listener for pagination
useEffect(() {
@ -385,8 +341,6 @@ class ChatRoomScreen extends HookConsumerWidget {
return () => subscription.cancel();
}, [ws, chatRoom]);
final attachments = useState<List<UniversalFile>>([]);
Future<void> pickPhotoMedia() async {
final result = await ref
.watch(imagePickerProvider)
@ -420,6 +374,12 @@ class ChatRoomScreen extends HookConsumerWidget {
editingTo: messageEditingTo.value,
forwardingTo: messageForwardingTo.value,
replyingTo: messageReplyingTo.value,
onProgress: (messageId, progress) {
attachmentProgress.value = {
...attachmentProgress.value,
messageId: progress,
};
},
);
messageController.clear();
messageEditingTo.value = null;
@ -542,12 +502,15 @@ class ChatRoomScreen extends HookConsumerWidget {
message.toRemoteMessage();
}
},
progress:
attachmentProgress.value[message.id],
),
loading:
() => _MessageBubble(
message: message,
isCurrentUser: false,
onAction: null,
progress: null,
),
error: (_, __) => const SizedBox.shrink(),
);
@ -804,11 +767,13 @@ class _MessageBubble extends HookConsumerWidget {
final LocalChatMessage message;
final bool isCurrentUser;
final Function(String action)? onAction;
final Map<int, double>? progress;
const _MessageBubble({
required this.message,
required this.isCurrentUser,
required this.onAction,
required this.progress,
});
@override
@ -914,9 +879,58 @@ class _MessageBubble extends HookConsumerWidget {
style: TextStyle(color: textColor),
),
if (message.toRemoteMessage().attachments.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
CloudFileList(
files: message.toRemoteMessage().attachments,
maxWidth: MediaQuery.of(context).size.width * 0.8,
),
],
).padding(top: 4),
if (progress != null && progress!.isNotEmpty)
Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: 8,
children: [
if ((message
.toRemoteMessage()
.content
?.isNotEmpty ??
false))
const Gap(0),
for (var entry in progress!.entries)
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'fileUploadingProgress'.tr(
args: [
(entry.key + 1).toString(),
entry.value.toStringAsFixed(1),
],
),
style: TextStyle(
fontSize: 12,
color: textColor.withOpacity(0.8),
),
),
const Gap(4),
LinearProgressIndicator(
value: entry.value / 100,
backgroundColor:
Theme.of(
context,
).colorScheme.surfaceVariant,
valueColor: AlwaysStoppedAnimation<Color>(
Theme.of(context).colorScheme.primary,
),
),
],
),
const Gap(0),
],
),
const Gap(4),
Row(
spacing: 4,
@ -978,7 +992,7 @@ class _MessageBubble extends HookConsumerWidget {
(context, ref, _) => GestureDetector(
onTap: () {
ref
.read(messagesProvider(message.roomId).notifier)
.read(messagesNotifierProvider(message.roomId).notifier)
.retryMessage(message.id);
},
child: const Icon(
@ -1006,7 +1020,7 @@ class _MessageQuoteWidget extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final messagesNotifier = ref.watch(
messagesProvider(message.roomId).notifier,
messagesNotifierProvider(message.roomId).notifier,
);
return FutureBuilder<LocalChatMessage?>(

View File

@ -0,0 +1,179 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'room.dart';
// **************************************************************************
// RiverpodGenerator
// **************************************************************************
String _$messagesNotifierHash() => r'71a9fc1c6d024f6203f06225384c19335b9b6f2c';
/// Copied from Dart SDK
class _SystemHash {
_SystemHash._();
static int combine(int hash, int value) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + value);
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x0007ffff & hash) << 10));
return hash ^ (hash >> 6);
}
static int finish(int hash) {
// ignore: parameter_assignments
hash = 0x1fffffff & (hash + ((0x03ffffff & hash) << 3));
// ignore: parameter_assignments
hash = hash ^ (hash >> 11);
return 0x1fffffff & (hash + ((0x00003fff & hash) << 15));
}
}
abstract class _$MessagesNotifier
extends BuildlessAutoDisposeAsyncNotifier<List<LocalChatMessage>> {
late final String roomId;
FutureOr<List<LocalChatMessage>> build(String roomId);
}
/// See also [MessagesNotifier].
@ProviderFor(MessagesNotifier)
const messagesNotifierProvider = MessagesNotifierFamily();
/// See also [MessagesNotifier].
class MessagesNotifierFamily
extends Family<AsyncValue<List<LocalChatMessage>>> {
/// See also [MessagesNotifier].
const MessagesNotifierFamily();
/// See also [MessagesNotifier].
MessagesNotifierProvider call(String roomId) {
return MessagesNotifierProvider(roomId);
}
@override
MessagesNotifierProvider getProviderOverride(
covariant MessagesNotifierProvider provider,
) {
return call(provider.roomId);
}
static const Iterable<ProviderOrFamily>? _dependencies = null;
@override
Iterable<ProviderOrFamily>? get dependencies => _dependencies;
static const Iterable<ProviderOrFamily>? _allTransitiveDependencies = null;
@override
Iterable<ProviderOrFamily>? get allTransitiveDependencies =>
_allTransitiveDependencies;
@override
String? get name => r'messagesNotifierProvider';
}
/// See also [MessagesNotifier].
class MessagesNotifierProvider
extends
AutoDisposeAsyncNotifierProviderImpl<
MessagesNotifier,
List<LocalChatMessage>
> {
/// See also [MessagesNotifier].
MessagesNotifierProvider(String roomId)
: this._internal(
() => MessagesNotifier()..roomId = roomId,
from: messagesNotifierProvider,
name: r'messagesNotifierProvider',
debugGetCreateSourceHash:
const bool.fromEnvironment('dart.vm.product')
? null
: _$messagesNotifierHash,
dependencies: MessagesNotifierFamily._dependencies,
allTransitiveDependencies:
MessagesNotifierFamily._allTransitiveDependencies,
roomId: roomId,
);
MessagesNotifierProvider._internal(
super._createNotifier, {
required super.name,
required super.dependencies,
required super.allTransitiveDependencies,
required super.debugGetCreateSourceHash,
required super.from,
required this.roomId,
}) : super.internal();
final String roomId;
@override
FutureOr<List<LocalChatMessage>> runNotifierBuild(
covariant MessagesNotifier notifier,
) {
return notifier.build(roomId);
}
@override
Override overrideWith(MessagesNotifier Function() create) {
return ProviderOverride(
origin: this,
override: MessagesNotifierProvider._internal(
() => create()..roomId = roomId,
from: from,
name: null,
dependencies: null,
allTransitiveDependencies: null,
debugGetCreateSourceHash: null,
roomId: roomId,
),
);
}
@override
AutoDisposeAsyncNotifierProviderElement<
MessagesNotifier,
List<LocalChatMessage>
>
createElement() {
return _MessagesNotifierProviderElement(this);
}
@override
bool operator ==(Object other) {
return other is MessagesNotifierProvider && other.roomId == roomId;
}
@override
int get hashCode {
var hash = _SystemHash.combine(0, runtimeType.hashCode);
hash = _SystemHash.combine(hash, roomId.hashCode);
return _SystemHash.finish(hash);
}
}
@Deprecated('Will be removed in 3.0. Use Ref instead')
// ignore: unused_element
mixin MessagesNotifierRef
on AutoDisposeAsyncNotifierProviderRef<List<LocalChatMessage>> {
/// The parameter `roomId` of this provider.
String get roomId;
}
class _MessagesNotifierProviderElement
extends
AutoDisposeAsyncNotifierProviderElement<
MessagesNotifier,
List<LocalChatMessage>
>
with MessagesNotifierRef {
_MessagesNotifierProviderElement(super.provider);
@override
String get roomId => (origin as MessagesNotifierProvider).roomId;
}
// ignore_for_file: type=lint
// ignore_for_file: subtype_of_sealed_class, invalid_use_of_internal_member, invalid_use_of_visible_for_testing_member, deprecated_member_use_from_same_package

View File

@ -1,3 +1,5 @@
import 'dart:math' as math;
import 'package:flutter/material.dart';
import 'package:gap/gap.dart';
import 'package:island/models/file.dart';
@ -7,7 +9,13 @@ import 'package:styled_widget/styled_widget.dart';
class CloudFileList extends StatelessWidget {
final List<SnCloudFile> files;
final double maxHeight;
const CloudFileList({super.key, required this.files, this.maxHeight = 360});
final double maxWidth;
const CloudFileList({
super.key,
required this.files,
this.maxHeight = 360,
this.maxWidth = double.infinity,
});
double calculateAspectRatio() {
double total = 0;
@ -44,14 +52,14 @@ class CloudFileList extends StatelessWidget {
if (allImages) {
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: maxHeight,
minWidth: double.infinity,
),
constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: CarouselView(
itemExtent: MediaQuery.of(context).size.width * 0.85,
itemExtent: math.min(
MediaQuery.of(context).size.width * 0.85,
maxWidth * 0.85,
),
itemSnapping: true,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(16)),
@ -63,10 +71,7 @@ class CloudFileList extends StatelessWidget {
}
return ConstrainedBox(
constraints: BoxConstraints(
maxHeight: maxHeight,
minWidth: double.infinity,
),
constraints: BoxConstraints(maxHeight: maxHeight, minWidth: maxWidth),
child: AspectRatio(
aspectRatio: calculateAspectRatio(),
child: ListView.separated(