mirror of
https://github.com/twonlyapp/twonly-app.git
synced 2026-04-22 16:12:52 +00:00
show typing indicator in the chat view
This commit is contained in:
parent
6ed3af5a92
commit
f321e35027
4 changed files with 192 additions and 86 deletions
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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(),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue