show typing indicator in the chat view

This commit is contained in:
otsmr 2026-04-20 12:37:05 +02:00
parent 6ed3af5a92
commit f321e35027
4 changed files with 192 additions and 86 deletions

View file

@ -2,6 +2,7 @@
## 0.1.6 ## 0.1.6
- Improved: Show typing indicator also in the chat overview
- Fix: Phantom push notification - Fix: Phantom push notification
- Fix: Smaller UI fixes - Fix: Smaller UI fixes

View file

@ -13,6 +13,7 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/services/api/mediafiles/download.service.dart'; import 'package:twonly/src/services/api/mediafiles/download.service.dart';
import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/misc.dart';
import 'package:twonly/src/views/chats/chat_list_components/last_message_time.dart'; import 'package:twonly/src/views/chats/chat_list_components/last_message_time.dart';
import 'package:twonly/src/views/chats/chat_list_components/typing_indicator_subtitle.dart';
import 'package:twonly/src/views/chats/chat_messages_components/message_send_state_icon.dart'; import 'package:twonly/src/views/chats/chat_messages_components/message_send_state_icon.dart';
import 'package:twonly/src/views/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart';
import 'package:twonly/src/views/components/flame.dart'; import 'package:twonly/src/views/components/flame.dart';
@ -251,6 +252,9 @@ class _UserListItem extends State<GroupListItem> {
) )
: Row( : Row(
children: [ children: [
TypingIndicatorSubtitle(
groupId: widget.group.groupId,
),
MessageSendStateIcon( MessageSendStateIcon(
_previewMessages, _previewMessages,
_previewMediaFiles, _previewMediaFiles,

View file

@ -0,0 +1,75 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:twonly/globals.dart';
import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/views/chats/chat_messages.view.dart';
import 'package:twonly/src/views/chats/chat_messages_components/typing_indicator.dart';
class TypingIndicatorSubtitle extends StatefulWidget {
const TypingIndicatorSubtitle({required this.groupId, super.key});
final String groupId;
@override
State<TypingIndicatorSubtitle> createState() =>
_TypingIndicatorSubtitleState();
}
class _TypingIndicatorSubtitleState extends State<TypingIndicatorSubtitle> {
List<GroupMember> _groupMembers = [];
late StreamSubscription<List<(Contact, GroupMember)>> membersSub;
late Timer _periodicUpdate;
@override
void initState() {
super.initState();
_periodicUpdate = Timer.periodic(const Duration(seconds: 1), (_) {
filterOpenUsers(_groupMembers);
});
final membersStream = twonlyDB.groupsDao.watchGroupMembers(
widget.groupId,
);
membersSub = membersStream.listen((update) {
filterOpenUsers(update.map((m) => m.$2).toList());
});
}
void filterOpenUsers(List<GroupMember> input) {
setState(() {
_groupMembers = input.where(isTyping).toList();
});
}
@override
void dispose() {
membersSub.cancel();
_periodicUpdate.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_groupMembers.isEmpty) return Container();
return Padding(
padding: const EdgeInsets.only(right: 5),
child: Container(
padding: const EdgeInsets.symmetric(vertical: 3),
decoration: BoxDecoration(
color: getMessageColor(true),
borderRadius: BorderRadius.circular(12),
),
child: Transform.scale(
scale: 0.6,
child: const AnimatedTypingDots(
isTyping: true,
),
),
),
);
}
}

View file

@ -9,6 +9,28 @@ import 'package:twonly/src/database/twonly.db.dart';
import 'package:twonly/src/views/chats/chat_messages.view.dart'; import 'package:twonly/src/views/chats/chat_messages.view.dart';
import 'package:twonly/src/views/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart';
bool isTyping(GroupMember member) {
return member.lastTypeIndicator != null &&
clock
.now()
.difference(
member.lastTypeIndicator!,
)
.inSeconds <=
2;
}
bool hasChatOpen(GroupMember member) {
return member.lastChatOpened != null &&
clock
.now()
.difference(
member.lastChatOpened!,
)
.inSeconds <=
6;
}
class TypingIndicator extends StatefulWidget { class TypingIndicator extends StatefulWidget {
const TypingIndicator({required this.group, super.key}); const TypingIndicator({required this.group, super.key});
@ -18,10 +40,8 @@ class TypingIndicator extends StatefulWidget {
State<TypingIndicator> createState() => _TypingIndicatorState(); State<TypingIndicator> createState() => _TypingIndicatorState();
} }
class _TypingIndicatorState extends State<TypingIndicator> class _TypingIndicatorState extends State<TypingIndicator> {
with SingleTickerProviderStateMixin {
late AnimationController _controller; late AnimationController _controller;
late List<Animation<double>> _animations;
List<GroupMember> _groupMembers = []; List<GroupMember> _groupMembers = [];
@ -43,7 +63,88 @@ class _TypingIndicatorState extends State<TypingIndicator>
membersSub = membersStream.listen((update) { membersSub = membersStream.listen((update) {
filterOpenUsers(update.map((m) => m.$2).toList()); filterOpenUsers(update.map((m) => m.$2).toList());
}); });
}
void filterOpenUsers(List<GroupMember> input) {
setState(() {
_groupMembers = input.where(hasChatOpen).toList();
});
}
@override
void dispose() {
_controller.dispose();
membersSub.cancel();
_periodicUpdate.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (_groupMembers.isEmpty) return Container();
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.all(12),
child: Column(
children: _groupMembers
.map(
(member) => Padding(
key: Key('typing_indicator_${member.contactId}'),
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!widget.group.isDirectChat)
GestureDetector(
onTap: () => context.push(
Routes.profileContact(member.contactId),
),
child: AvatarIcon(
contactId: member.contactId,
fontSize: 12,
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: getMessageColor(true),
borderRadius: BorderRadius.circular(12),
),
child: AnimatedTypingDots(
isTyping: isTyping(member),
),
),
Expanded(child: Container()),
],
),
),
)
.toList(),
),
),
);
}
}
class AnimatedTypingDots extends StatefulWidget {
const AnimatedTypingDots({required this.isTyping, super.key});
final bool isTyping;
@override
State<AnimatedTypingDots> createState() => _AnimatedTypingDotsState();
}
class _AnimatedTypingDotsState extends State<AnimatedTypingDots>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late List<Animation<double>> _animations;
@override
void initState() {
_controller = AnimationController( _controller = AnimationController(
duration: const Duration(milliseconds: 1000), duration: const Duration(milliseconds: 1000),
vsync: this, vsync: this,
@ -77,93 +178,18 @@ class _TypingIndicatorState extends State<TypingIndicator>
), ),
); );
}); });
} super.initState();
void filterOpenUsers(List<GroupMember> input) {
setState(() {
_groupMembers = input.where(hasChatOpen).toList();
});
}
@override
void dispose() {
_controller.dispose();
membersSub.cancel();
_periodicUpdate.cancel();
super.dispose();
}
bool isTyping(GroupMember member) {
return member.lastTypeIndicator != null &&
clock
.now()
.difference(
member.lastTypeIndicator!,
)
.inSeconds <=
2;
}
bool hasChatOpen(GroupMember member) {
return member.lastChatOpened != null &&
clock
.now()
.difference(
member.lastChatOpened!,
)
.inSeconds <=
6;
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_groupMembers.isEmpty) return Container(); return Row(
mainAxisSize: MainAxisSize.min,
return Align( children: List.generate(
alignment: Alignment.centerLeft, 3,
child: Padding( (index) => _AnimatedDot(
padding: const EdgeInsets.all(12), isTyping: widget.isTyping,
child: Column( animation: _animations[index],
children: _groupMembers
.map(
(member) => Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!widget.group.isDirectChat)
GestureDetector(
onTap: () => context.push(
Routes.profileContact(member.contactId),
),
child: AvatarIcon(
contactId: member.contactId,
fontSize: 12,
),
),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: getMessageColor(true),
borderRadius: BorderRadius.circular(12),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: List.generate(
3,
(index) => _AnimatedDot(
isTyping: isTyping(member),
animation: _animations[index],
),
),
),
),
Expanded(child: Container()),
],
),
),
)
.toList(),
), ),
), ),
); );