parent
a54004736b
commit
0fcbc275cd
5 changed files with 326 additions and 1 deletions
41
lib/widgets/bouncing_dots.dart
Normal file
41
lib/widgets/bouncing_dots.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import "dart:math";
|
||||
|
||||
import "package:flutter_hooks/flutter_hooks.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
|
||||
/// Three dots that bounce one after another, in a loop.
|
||||
class const BouncingDots({required final Color color, super.key})
|
||||
extends HookWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final controller = useAnimationController(
|
||||
duration: const .new(milliseconds: 1200),
|
||||
);
|
||||
useEffect(() {
|
||||
controller.repeat();
|
||||
return null;
|
||||
}, []);
|
||||
final progress = useAnimation(controller);
|
||||
|
||||
return Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 3,
|
||||
children: [0, 1, 2].map((index) {
|
||||
final phase = (progress - index * 0.15) % 1;
|
||||
final bounce = phase < 0.4 ? sin(phase / 0.4 * pi) : 0.0;
|
||||
|
||||
return Transform.translate(
|
||||
offset: .new(0, -3 * bounce),
|
||||
child: Opacity(
|
||||
opacity: 0.4 + 0.6 * bounce,
|
||||
child: Container(
|
||||
width: 6,
|
||||
height: 6,
|
||||
decoration: BoxDecoration(color: color, shape: .circle),
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -11,6 +11,8 @@ import "package:flutter_hooks/flutter_hooks.dart";
|
|||
import "package:fluttertagger/fluttertagger.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:nexus/controllers/attachment.dart";
|
||||
import "package:nexus/controllers/client.dart";
|
||||
import "package:nexus/controllers/settings.dart";
|
||||
import "package:nexus/controllers/image_picker.dart";
|
||||
import "package:nexus/helpers/file_dialog_helper.dart";
|
||||
import "package:image_picker/image_picker.dart";
|
||||
|
|
@ -94,6 +96,44 @@ class const Composer(
|
|||
return null;
|
||||
}, [relationType, relatedEvent == null]);
|
||||
|
||||
final sinceTypingSent = useMemoized(Stopwatch.new);
|
||||
|
||||
useEffect(() {
|
||||
final client = ref.read(ClientController.provider.notifier);
|
||||
|
||||
void stopTyping() {
|
||||
if (!sinceTypingSent.isRunning) return;
|
||||
sinceTypingSent
|
||||
..stop()
|
||||
..reset();
|
||||
client.setTyping(roomId, .zero).ignore();
|
||||
}
|
||||
|
||||
void onChanged() {
|
||||
final shouldSend =
|
||||
ref.read(SettingsController.provider).value?.typingIndicators ==
|
||||
.showAndSend;
|
||||
|
||||
if (!shouldSend || controller.text.isEmpty) return stopTyping();
|
||||
|
||||
if (sinceTypingSent.isRunning &&
|
||||
sinceTypingSent.elapsed < const .new(seconds: 5)) {
|
||||
return;
|
||||
}
|
||||
|
||||
sinceTypingSent
|
||||
..reset()
|
||||
..start();
|
||||
client.setTyping(roomId, const .new(seconds: 10)).ignore();
|
||||
}
|
||||
|
||||
controller.addListener(onChanged);
|
||||
return () {
|
||||
controller.removeListener(onChanged);
|
||||
stopTyping();
|
||||
};
|
||||
}, [roomId]);
|
||||
|
||||
final attachment = ref.watch(AttachmentController.provider(roomId));
|
||||
|
||||
Future<void> attach(Future<XFile?> pick) async {
|
||||
|
|
|
|||
104
lib/widgets/composer/typing_indicator.dart
Normal file
104
lib/widgets/composer/typing_indicator.dart
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import "package:collection/collection.dart";
|
||||
import "package:hooks_riverpod/hooks_riverpod.dart";
|
||||
import "package:material_ui/material_ui.dart";
|
||||
import "package:nexus/controllers/room_typing.dart";
|
||||
import "package:nexus/controllers/user.dart";
|
||||
import "package:nexus/helpers/extensions/get_localpart.dart";
|
||||
import "package:nexus/widgets/bouncing_dots.dart";
|
||||
|
||||
class const TypingIndicator(final String roomId, {super.key})
|
||||
extends ConsumerWidget {
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final theme = Theme.of(context);
|
||||
final typing = ref.watch(RoomTypingController.provider(roomId));
|
||||
|
||||
// Past three people, only the first two are named
|
||||
final named = typing.length > 3 ? 2 : typing.length;
|
||||
final subjects = [
|
||||
...typing
|
||||
.take(named)
|
||||
.map(
|
||||
(typingUserId) => TextSpan(
|
||||
text:
|
||||
ref
|
||||
.watch(
|
||||
UserController.provider(
|
||||
.new(roomId: roomId, userId: typingUserId),
|
||||
),
|
||||
)
|
||||
.value
|
||||
?.displayName ??
|
||||
typingUserId.localpart,
|
||||
style: TextStyle(
|
||||
fontWeight: .bold,
|
||||
color: theme.colorScheme.onSurface,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (typing.length > named)
|
||||
TextSpan(text: "${typing.length - named} others"),
|
||||
];
|
||||
|
||||
final spans = [
|
||||
...subjects.expandIndexed(
|
||||
(index, subject) => [
|
||||
if (index > 0)
|
||||
TextSpan(
|
||||
text: index < subjects.length - 1
|
||||
? ", "
|
||||
: subjects.length > 2
|
||||
? ", and "
|
||||
: " and ",
|
||||
),
|
||||
subject,
|
||||
],
|
||||
),
|
||||
TextSpan(text: subjects.length == 1 ? " is typing..." : " are typing..."),
|
||||
];
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: Durations.medium2,
|
||||
switchInCurve: Curves.easeOutCubic,
|
||||
switchOutCurve: Curves.easeInCubic,
|
||||
transitionBuilder: (child, animation) => FadeTransition(
|
||||
opacity: animation,
|
||||
child: ScaleTransition(
|
||||
scale: animation,
|
||||
alignment: .bottomLeft,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
child: typing.isEmpty
|
||||
? const SizedBox.shrink()
|
||||
: Align(
|
||||
alignment: .bottomLeft,
|
||||
child: Material(
|
||||
color: theme.colorScheme.surfaceContainerHigh,
|
||||
elevation: 1,
|
||||
shape: const StadiumBorder(),
|
||||
child: Padding(
|
||||
padding: .symmetric(horizontal: 12, vertical: 4),
|
||||
child: Row(
|
||||
mainAxisSize: .min,
|
||||
spacing: 8,
|
||||
children: [
|
||||
BouncingDots(color: theme.colorScheme.onSurfaceVariant),
|
||||
Flexible(
|
||||
child: Text.rich(
|
||||
TextSpan(children: spans),
|
||||
maxLines: 1,
|
||||
overflow: .ellipsis,
|
||||
style: theme.textTheme.bodyMedium?.copyWith(
|
||||
color: theme.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -13,6 +13,9 @@ class const ChatTimeline({
|
|||
required final IList<PopupMenuEntry> Function(Event) getEventOptions,
|
||||
required final int? highlightedEvent,
|
||||
required final double composerHeight,
|
||||
|
||||
/// The typing indicator's height, or zero while it's hidden.
|
||||
required final double typingIndicatorHeight,
|
||||
final void Function(int rowId)? onSwipe,
|
||||
super.key,
|
||||
}) extends StatelessWidget {
|
||||
|
|
@ -40,7 +43,22 @@ class const ChatTimeline({
|
|||
keyboardDismissBehavior: .onDrag,
|
||||
controller: scroll.scrollController,
|
||||
slivers: [
|
||||
SliverToBoxAdapter(child: SizedBox(height: composerHeight)),
|
||||
SliverToBoxAdapter(
|
||||
child: Column(
|
||||
children: [
|
||||
// Makes room for the typing indicator, with a small gap above it,
|
||||
// so it doesn't cover messages
|
||||
AnimatedContainer(
|
||||
duration: Durations.medium2,
|
||||
curve: Curves.easeInOutCubicEmphasized,
|
||||
height: typingIndicatorHeight == 0
|
||||
? 0
|
||||
: typingIndicatorHeight + 4,
|
||||
),
|
||||
SizedBox(height: composerHeight),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
SuperSliverList.builder(
|
||||
itemCount: scroll.liveRows.length,
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import "package:nexus/helpers/hooks/chat_scroll.dart";
|
|||
import "package:nexus/models/event.dart";
|
||||
import "package:nexus/models/relation_type.dart";
|
||||
import "package:nexus/widgets/composer/composer.dart";
|
||||
import "package:nexus/widgets/composer/typing_indicator.dart";
|
||||
import "package:nexus/widgets/error_dialog.dart";
|
||||
import "package:nexus/widgets/loading.dart";
|
||||
import "package:nexus/widgets/pinned_events_drawer.dart";
|
||||
|
|
@ -46,6 +47,7 @@ final class const RoomChat({
|
|||
final highlightedEvent = useState<int?>(null);
|
||||
|
||||
final composerSize = useState<double>(64);
|
||||
final typingIndicatorSize = useState<double>(0);
|
||||
|
||||
final userId = ref.watch(ClientStateController.provider)?.userId;
|
||||
final memberListOpened = ref
|
||||
|
|
@ -252,6 +254,8 @@ final class const RoomChat({
|
|||
: null,
|
||||
highlightedEvent: highlightedEvent.value,
|
||||
composerHeight: composerSize.value,
|
||||
typingIndicatorHeight:
|
||||
typingIndicatorSize.value,
|
||||
),
|
||||
AsyncLoading _ => Loading(),
|
||||
AsyncError(
|
||||
|
|
@ -262,6 +266,17 @@ final class const RoomChat({
|
|||
},
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: composerSize.value,
|
||||
left: 12,
|
||||
// To give space for jump to bottom button
|
||||
right: 72,
|
||||
child: MeasureSize(
|
||||
onChange: (size) =>
|
||||
typingIndicatorSize.value = size.height,
|
||||
child: TypingIndicator(roomId),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
right: 16,
|
||||
bottom: composerSize.value,
|
||||
|
|
|
|||
Loading…
Reference in a new issue