pagination work

This commit is contained in:
Christien Rioux 2024-06-03 21:20:00 -04:00
parent 4082d1dd76
commit 5473bd2ee4
11 changed files with 469 additions and 24 deletions

View File

@ -2,11 +2,13 @@ import 'dart:async';
import 'package:async_tools/async_tools.dart';
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:veilid_support/veilid_support.dart';
import '../../account_manager/account_manager.dart';
import '../../proto/proto.dart' as proto;
import '../../tools/tools.dart';
import '../models/models.dart';
import 'reconciliation/reconciliation.dart';
@ -42,7 +44,7 @@ class RenderStateElement {
bool sentOffline;
}
typedef SingleContactMessagesState = AsyncValue<IList<MessageState>>;
typedef SingleContactMessagesState = AsyncValue<MessagesState>;
// Cubit that processes single-contact chats
// Builds the reconciled chat record from the local and remote conversation keys
@ -60,6 +62,7 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
_localMessagesRecordKey = localMessagesRecordKey,
_remoteConversationRecordKey = remoteConversationRecordKey,
_remoteMessagesRecordKey = remoteMessagesRecordKey,
_commandController = StreamController(),
super(const AsyncValue.loading()) {
// Async Init
_initWait.add(_init);
@ -69,6 +72,8 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
Future<void> close() async {
await _initWait();
await _commandController.close();
await _commandRunnerFut;
await _unsentMessagesQueue.close();
await _sentSubscription?.cancel();
await _rcvdSubscription?.cancel();
@ -99,6 +104,9 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
// Remote messages key
await _initRcvdMessagesCubit();
// Command execution background process
_commandRunnerFut = Future.delayed(Duration.zero, _commandRunner);
}
// Make crypto
@ -191,6 +199,34 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
_sendMessage(message: message);
}
// Run a chat command
void runCommand(String command) {
final (cmd, rest) = command.splitOnce(' ');
if (kDebugMode) {
if (cmd == '/repeat' && rest != null) {
final (countStr, text) = rest.splitOnce(' ');
final count = int.tryParse(countStr);
if (count != null) {
runCommandRepeat(count, text ?? '');
}
}
}
}
// Run a repeat command
void runCommandRepeat(int count, String text) {
_commandController.sink.add(() async {
for (var i = 0; i < count; i++) {
final protoMessageText = proto.Message_Text()
..text = text.replaceAll(RegExp(r'\$n\b'), i.toString());
final message = proto.Message()..text = protoMessageText;
_sendMessage(message: message);
await Future<void>.delayed(const Duration(milliseconds: 50));
}
});
}
////////////////////////////////////////////////////////////////////////////
// Internal implementation
@ -220,9 +256,6 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
_reconciliation.reconcileMessages(
_remoteIdentityPublicKey, rcvdMessages, _rcvdMessagesCubit!);
// Update the view
_renderState();
}
// Called when the reconciled messages window gets a change
@ -296,7 +329,7 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
final renderedElements = <RenderStateElement>[];
for (final m in reconciledMessages.elements) {
for (final m in reconciledMessages.windowElements) {
final isLocal = m.content.author.toVeilid() ==
_activeAccountInfo.localAccount.identityMaster
.identityPublicTypedKey();
@ -316,7 +349,7 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
}
// Render the state
final renderedState = renderedElements
final messages = renderedElements
.map((x) => MessageState(
content: x.message,
sentTimestamp: Timestamp.fromInt64(x.message.timestamp),
@ -325,7 +358,12 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
.toIList();
// Emit the rendered state
emit(AsyncValue.data(renderedState));
emit(AsyncValue.data(MessagesState(
windowMessages: messages,
length: reconciledMessages.length,
windowTail: reconciledMessages.windowTail,
windowCount: reconciledMessages.windowCount,
follow: reconciledMessages.follow)));
}
void _sendMessage({required proto.Message message}) {
@ -344,6 +382,12 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
_renderState();
}
Future<void> _commandRunner() async {
await for (final command in _commandController.stream) {
await command();
}
}
/////////////////////////////////////////////////////////////////////////
// Static utility functions
@ -383,4 +427,6 @@ class SingleContactMessagesCubit extends Cubit<SingleContactMessagesState> {
StreamSubscription<DHTLogBusyState<proto.Message>>? _rcvdSubscription;
StreamSubscription<TableDBArrayProtobufBusyState<proto.ReconciledMessage>>?
_reconciledSubscription;
final StreamController<Future<void> Function()> _commandController;
late final Future<void> _commandRunnerFut;
}

View File

@ -0,0 +1,27 @@
import 'package:fast_immutable_collections/fast_immutable_collections.dart';
import 'package:flutter/foundation.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'message_state.dart';
part 'messages_state.freezed.dart';
part 'messages_state.g.dart';
@freezed
class MessagesState with _$MessagesState {
const factory MessagesState({
// List of messages in the window
required IList<MessageState> windowMessages,
// Total number of messages
required int length,
// One past the end of the last element
required int windowTail,
// The total number of elements to try to keep in 'messages'
required int windowCount,
// If we should have the tail following the array
required bool follow,
}) = _MessagesState;
factory MessagesState.fromJson(dynamic json) =>
_$MessagesStateFromJson(json as Map<String, dynamic>);
}

View File

@ -0,0 +1,268 @@
// coverage:ignore-file
// GENERATED CODE - DO NOT MODIFY BY HAND
// ignore_for_file: type=lint
// ignore_for_file: unused_element, deprecated_member_use, deprecated_member_use_from_same_package, use_function_type_syntax_for_parameters, unnecessary_const, avoid_init_to_null, invalid_override_different_default_values_named, prefer_expression_function_bodies, annotate_overrides, invalid_annotation_target, unnecessary_question_mark
part of 'messages_state.dart';
// **************************************************************************
// FreezedGenerator
// **************************************************************************
T _$identity<T>(T value) => value;
final _privateConstructorUsedError = UnsupportedError(
'It seems like you constructed your class using `MyClass._()`. This constructor is only meant to be used by freezed and you are not supposed to need it nor use it.\nPlease check the documentation here for more information: https://github.com/rrousselGit/freezed#adding-getters-and-methods-to-our-models');
MessagesState _$MessagesStateFromJson(Map<String, dynamic> json) {
return _MessagesState.fromJson(json);
}
/// @nodoc
mixin _$MessagesState {
// List of messages in the window
IList<MessageState> get windowMessages =>
throw _privateConstructorUsedError; // Total number of messages
int get length =>
throw _privateConstructorUsedError; // One past the end of the last element
int get windowTail =>
throw _privateConstructorUsedError; // The total number of elements to try to keep in 'messages'
int get windowCount =>
throw _privateConstructorUsedError; // If we should have the tail following the array
bool get follow => throw _privateConstructorUsedError;
Map<String, dynamic> toJson() => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$MessagesStateCopyWith<MessagesState> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $MessagesStateCopyWith<$Res> {
factory $MessagesStateCopyWith(
MessagesState value, $Res Function(MessagesState) then) =
_$MessagesStateCopyWithImpl<$Res, MessagesState>;
@useResult
$Res call(
{IList<MessageState> windowMessages,
int length,
int windowTail,
int windowCount,
bool follow});
}
/// @nodoc
class _$MessagesStateCopyWithImpl<$Res, $Val extends MessagesState>
implements $MessagesStateCopyWith<$Res> {
_$MessagesStateCopyWithImpl(this._value, this._then);
// ignore: unused_field
final $Val _value;
// ignore: unused_field
final $Res Function($Val) _then;
@pragma('vm:prefer-inline')
@override
$Res call({
Object? windowMessages = null,
Object? length = null,
Object? windowTail = null,
Object? windowCount = null,
Object? follow = null,
}) {
return _then(_value.copyWith(
windowMessages: null == windowMessages
? _value.windowMessages
: windowMessages // ignore: cast_nullable_to_non_nullable
as IList<MessageState>,
length: null == length
? _value.length
: length // ignore: cast_nullable_to_non_nullable
as int,
windowTail: null == windowTail
? _value.windowTail
: windowTail // ignore: cast_nullable_to_non_nullable
as int,
windowCount: null == windowCount
? _value.windowCount
: windowCount // ignore: cast_nullable_to_non_nullable
as int,
follow: null == follow
? _value.follow
: follow // ignore: cast_nullable_to_non_nullable
as bool,
) as $Val);
}
}
/// @nodoc
abstract class _$$MessagesStateImplCopyWith<$Res>
implements $MessagesStateCopyWith<$Res> {
factory _$$MessagesStateImplCopyWith(
_$MessagesStateImpl value, $Res Function(_$MessagesStateImpl) then) =
__$$MessagesStateImplCopyWithImpl<$Res>;
@override
@useResult
$Res call(
{IList<MessageState> windowMessages,
int length,
int windowTail,
int windowCount,
bool follow});
}
/// @nodoc
class __$$MessagesStateImplCopyWithImpl<$Res>
extends _$MessagesStateCopyWithImpl<$Res, _$MessagesStateImpl>
implements _$$MessagesStateImplCopyWith<$Res> {
__$$MessagesStateImplCopyWithImpl(
_$MessagesStateImpl _value, $Res Function(_$MessagesStateImpl) _then)
: super(_value, _then);
@pragma('vm:prefer-inline')
@override
$Res call({
Object? windowMessages = null,
Object? length = null,
Object? windowTail = null,
Object? windowCount = null,
Object? follow = null,
}) {
return _then(_$MessagesStateImpl(
windowMessages: null == windowMessages
? _value.windowMessages
: windowMessages // ignore: cast_nullable_to_non_nullable
as IList<MessageState>,
length: null == length
? _value.length
: length // ignore: cast_nullable_to_non_nullable
as int,
windowTail: null == windowTail
? _value.windowTail
: windowTail // ignore: cast_nullable_to_non_nullable
as int,
windowCount: null == windowCount
? _value.windowCount
: windowCount // ignore: cast_nullable_to_non_nullable
as int,
follow: null == follow
? _value.follow
: follow // ignore: cast_nullable_to_non_nullable
as bool,
));
}
}
/// @nodoc
@JsonSerializable()
class _$MessagesStateImpl
with DiagnosticableTreeMixin
implements _MessagesState {
const _$MessagesStateImpl(
{required this.windowMessages,
required this.length,
required this.windowTail,
required this.windowCount,
required this.follow});
factory _$MessagesStateImpl.fromJson(Map<String, dynamic> json) =>
_$$MessagesStateImplFromJson(json);
// List of messages in the window
@override
final IList<MessageState> windowMessages;
// Total number of messages
@override
final int length;
// One past the end of the last element
@override
final int windowTail;
// The total number of elements to try to keep in 'messages'
@override
final int windowCount;
// If we should have the tail following the array
@override
final bool follow;
@override
String toString({DiagnosticLevel minLevel = DiagnosticLevel.info}) {
return 'MessagesState(windowMessages: $windowMessages, length: $length, windowTail: $windowTail, windowCount: $windowCount, follow: $follow)';
}
@override
void debugFillProperties(DiagnosticPropertiesBuilder properties) {
super.debugFillProperties(properties);
properties
..add(DiagnosticsProperty('type', 'MessagesState'))
..add(DiagnosticsProperty('windowMessages', windowMessages))
..add(DiagnosticsProperty('length', length))
..add(DiagnosticsProperty('windowTail', windowTail))
..add(DiagnosticsProperty('windowCount', windowCount))
..add(DiagnosticsProperty('follow', follow));
}
@override
bool operator ==(Object other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _$MessagesStateImpl &&
const DeepCollectionEquality()
.equals(other.windowMessages, windowMessages) &&
(identical(other.length, length) || other.length == length) &&
(identical(other.windowTail, windowTail) ||
other.windowTail == windowTail) &&
(identical(other.windowCount, windowCount) ||
other.windowCount == windowCount) &&
(identical(other.follow, follow) || other.follow == follow));
}
@JsonKey(ignore: true)
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(windowMessages),
length,
windowTail,
windowCount,
follow);
@JsonKey(ignore: true)
@override
@pragma('vm:prefer-inline')
_$$MessagesStateImplCopyWith<_$MessagesStateImpl> get copyWith =>
__$$MessagesStateImplCopyWithImpl<_$MessagesStateImpl>(this, _$identity);
@override
Map<String, dynamic> toJson() {
return _$$MessagesStateImplToJson(
this,
);
}
}
abstract class _MessagesState implements MessagesState {
const factory _MessagesState(
{required final IList<MessageState> windowMessages,
required final int length,
required final int windowTail,
required final int windowCount,
required final bool follow}) = _$MessagesStateImpl;
factory _MessagesState.fromJson(Map<String, dynamic> json) =
_$MessagesStateImpl.fromJson;
@override // List of messages in the window
IList<MessageState> get windowMessages;
@override // Total number of messages
int get length;
@override // One past the end of the last element
int get windowTail;
@override // The total number of elements to try to keep in 'messages'
int get windowCount;
@override // If we should have the tail following the array
bool get follow;
@override
@JsonKey(ignore: true)
_$$MessagesStateImplCopyWith<_$MessagesStateImpl> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -0,0 +1,28 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'messages_state.dart';
// **************************************************************************
// JsonSerializableGenerator
// **************************************************************************
_$MessagesStateImpl _$$MessagesStateImplFromJson(Map<String, dynamic> json) =>
_$MessagesStateImpl(
windowMessages: IList<MessageState>.fromJson(
json['window_messages'], (value) => MessageState.fromJson(value)),
length: (json['length'] as num).toInt(),
windowTail: (json['window_tail'] as num).toInt(),
windowCount: (json['window_count'] as num).toInt(),
follow: json['follow'] as bool,
);
Map<String, dynamic> _$$MessagesStateImplToJson(_$MessagesStateImpl instance) =>
<String, dynamic>{
'window_messages': instance.windowMessages.toJson(
(value) => value.toJson(),
),
'length': instance.length,
'window_tail': instance.windowTail,
'window_count': instance.windowCount,
'follow': instance.follow,
};

View File

@ -1 +1,2 @@
export 'message_state.dart';
export 'messages_state.dart';

View File

@ -1,8 +1,9 @@
import 'dart:typed_data';
import 'dart:math';
import 'package:async_tools/async_tools.dart';
import 'package:awesome_extensions/awesome_extensions.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
@ -166,8 +167,9 @@ class ChatComponent extends StatelessWidget {
_messagesCubit.sendTextMessage(messageText: protoMessageText);
}
void _handleSendPressed(types.PartialText message) {
void _sendMessage(types.PartialText message) {
final text = message.text;
final replyId = (message.repliedMessage != null)
? base64UrlNoPadDecode(message.repliedMessage!.id)
: null;
@ -200,6 +202,17 @@ class ChatComponent extends StatelessWidget {
attachments: attachments ?? []);
}
void _handleSendPressed(types.PartialText message) {
final text = message.text;
if (text.startsWith('/')) {
_messagesCubit.runCommand(text);
return;
}
_sendMessage(message);
}
// void _handleAttachmentPressed() async {
// //
// }
@ -211,15 +224,15 @@ class ChatComponent extends StatelessWidget {
final textTheme = Theme.of(context).textTheme;
final chatTheme = makeChatTheme(scale, textTheme);
final messages = _messagesState.asData?.value;
if (messages == null) {
final messagesState = _messagesState.asData?.value;
if (messagesState == null) {
return _messagesState.buildNotData();
}
// Convert protobuf messages to chat messages
final chatMessages = <types.Message>[];
final tsSet = <String>{};
for (final message in messages) {
for (final message in messagesState.windowMessages) {
final chatMessage = messageStateToChatMessage(message);
if (chatMessage == null) {
continue;
@ -228,12 +241,17 @@ class ChatComponent extends StatelessWidget {
if (!tsSet.add(chatMessage.id)) {
// ignore: avoid_print
print('duplicate id found: ${chatMessage.id}:\n'
'Messages:\n$messages\n'
'Messages:\n${messagesState.windowMessages}\n'
'ChatMessages:\n$chatMessages');
assert(false, 'should not have duplicate id');
}
}
final isLastPage =
(messagesState.windowTail - messagesState.windowMessages.length) <= 0;
final follow = messagesState.windowTail == 0 ||
messagesState.windowTail == messagesState.length; xxx finish calculating pagination and get scroll position here somehow
return DefaultTextStyle(
style: textTheme.bodySmall!,
child: Align(
@ -272,9 +290,17 @@ class ChatComponent extends StatelessWidget {
decoration: const BoxDecoration(),
child: Chat(
theme: chatTheme,
// emojiEnlargementBehavior:
// EmojiEnlargementBehavior.multi,
messages: chatMessages,
onEndReached: () async {
final tail = await _messagesCubit.setWindow(
tail: max(
0,
(messagesState.windowTail -
(messagesState.windowCount ~/ 2))),
count: messagesState.windowCount,
follow: follow);
},
isLastPage: isLastPage,
//onAttachmentPressed: _handleAttachmentPressed,
//onMessageTap: _handleMessageTap,
//onPreviewDataFetched: _handlePreviewDataFetched,

View File

@ -609,6 +609,29 @@ ThemeData radixGenerator(Brightness brightness, RadixThemeColor themeColor) {
final themeData = ThemeData.from(
colorScheme: colorScheme, textTheme: textTheme, useMaterial3: true);
return themeData.copyWith(
scrollbarTheme: themeData.scrollbarTheme.copyWith(
thumbColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.pressed)) {
return scaleScheme.primaryScale.border;
} else if (states.contains(WidgetState.hovered)) {
return scaleScheme.primaryScale.hoverBorder;
}
return scaleScheme.primaryScale.subtleBorder;
}), trackColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.pressed)) {
return scaleScheme.primaryScale.activeElementBackground;
} else if (states.contains(WidgetState.hovered)) {
return scaleScheme.primaryScale.hoverElementBackground;
}
return scaleScheme.primaryScale.elementBackground;
}), trackBorderColor: WidgetStateProperty.resolveWith((states) {
if (states.contains(WidgetState.pressed)) {
return scaleScheme.primaryScale.subtleBorder;
} else if (states.contains(WidgetState.hovered)) {
return scaleScheme.primaryScale.subtleBorder;
}
return scaleScheme.primaryScale.subtleBorder;
})),
bottomSheetTheme: themeData.bottomSheetTheme.copyWith(
elevation: 0,
modalElevation: 0,

18
lib/tools/misc.dart Normal file
View File

@ -0,0 +1,18 @@
extension StringExt on String {
(String, String?) splitOnce(Pattern p) {
final pos = indexOf(p);
if (pos == -1) {
return (this, null);
}
final rest = substring(pos);
var offset = 0;
while (true) {
final match = p.matchAsPrefix(rest, offset);
if (match == null) {
break;
}
offset = match.end;
}
return (substring(0, pos), rest.substring(offset));
}
}

View File

@ -6,8 +6,9 @@ const Map<String, LogLevel> _blocChangeLogLevels = {
'ConnectionStateCubit': LogLevel.off,
'ActiveSingleContactChatBlocMapCubit': LogLevel.off,
'ActiveConversationsBlocMapCubit': LogLevel.off,
'DHTShortArrayCubit<Message>': LogLevel.off,
'PersistentQueueCubit<Message>': LogLevel.off,
'TableDBArrayProtobufCubit<ReconciledMessage>': LogLevel.off,
'DHTLogCubit<Message>': LogLevel.off,
'SingleContactMessagesCubit': LogLevel.off,
};
const Map<String, LogLevel> _blocCreateCloseLogLevels = {};

View File

@ -2,6 +2,7 @@ export 'animations.dart';
export 'enter_password.dart';
export 'enter_pin.dart';
export 'loggy.dart';
export 'misc.dart';
export 'phono_byte.dart';
export 'pop_control.dart';
export 'responsive.dart';

View File

@ -14,22 +14,25 @@ import '../../../veilid_support.dart';
class TableDBArrayProtobufStateData<T extends GeneratedMessage>
extends Equatable {
const TableDBArrayProtobufStateData(
{required this.elements,
required this.tail,
required this.count,
{required this.windowElements,
required this.length,
required this.windowTail,
required this.windowCount,
required this.follow});
// The view of the elements in the dhtlog
// Span is from [tail-length, tail)
final IList<T> elements;
final IList<T> windowElements;
// The length of the entire array
final int length;
// One past the end of the last element
final int tail;
final int windowTail;
// The total number of elements to try to keep in 'elements'
final int count;
final int windowCount;
// If we should have the tail following the array
final bool follow;
@override
List<Object?> get props => [elements, tail, count, follow];
List<Object?> get props => [windowElements, windowTail, windowCount, follow];
}
typedef TableDBArrayProtobufState<T extends GeneratedMessage>
@ -99,7 +102,10 @@ class TableDBArrayProtobufCubit<T extends GeneratedMessage>
}
final elements = avElements.asData!.value;
emit(AsyncValue.data(TableDBArrayProtobufStateData(
elements: elements, tail: _tail, count: _count, follow: _follow)));
windowElements: elements,
windowTail: _tail,
windowCount: _count,
follow: _follow)));
}
Future<AsyncValue<IList<T>>> _loadElements(