import 'dart:async'; import 'package:cryptography_plus/cryptography_plus.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:provider/provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/database/daos/contacts_dao.dart'; import 'package:twonly/src/database/tables/messages_table.dart'; import 'package:twonly/src/database/twonly_database.dart'; import 'package:twonly/src/model/json/userdata.dart'; import 'package:twonly/src/providers/connection.provider.dart'; import 'package:twonly/src/services/api/media_download.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/camera/camera_send_to_view.dart'; import 'package:twonly/src/views/chats/add_new_user.view.dart'; import 'package:twonly/src/views/chats/chat_list_components/backup_notice.card.dart'; import 'package:twonly/src/views/chats/chat_list_components/connection_info.comp.dart'; import 'package:twonly/src/views/chats/chat_list_components/feedback_btn.dart'; import 'package:twonly/src/views/chats/chat_list_components/last_message_time.dart'; import 'package:twonly/src/views/chats/chat_messages.view.dart'; import 'package:twonly/src/views/chats/chat_messages_components/message_send_state_icon.dart'; import 'package:twonly/src/views/chats/media_viewer.view.dart'; import 'package:twonly/src/views/chats/start_new_chat.view.dart'; import 'package:twonly/src/views/components/flame.dart'; import 'package:twonly/src/views/components/initialsavatar.dart'; import 'package:twonly/src/views/components/notification_badge.dart'; import 'package:twonly/src/views/components/user_context_menu.dart'; import 'package:twonly/src/views/settings/help/changelog.view.dart'; import 'package:twonly/src/views/settings/profile/profile.view.dart'; import 'package:twonly/src/views/settings/settings_main.view.dart'; import 'package:twonly/src/views/settings/subscription/subscription.view.dart'; import 'package:twonly/src/views/tutorial/tutorials.dart'; class ChatListView extends StatefulWidget { const ChatListView({super.key}); @override State createState() => _ChatListViewState(); } class _ChatListViewState extends State { late StreamSubscription> _contactsSub; List _contacts = []; List _pinnedContacts = []; UserData? _user; GlobalKey firstUserListItemKey = GlobalKey(); GlobalKey searchForOtherUsers = GlobalKey(); Timer? tutorial; bool showFeedbackShortcut = false; @override void initState() { initAsync(); super.initState(); } Future initAsync() async { final stream = twonlyDB.contactsDao.watchContactsForChatList(); _contactsSub = stream.listen((contacts) { setState(() { _contacts = contacts.where((x) => !x.pinned).toList(); _pinnedContacts = contacts.where((x) => x.pinned).toList(); }); }); tutorial = Timer(const Duration(seconds: 1), () async { tutorial = null; if (!mounted) return; await showChatListTutorialSearchOtherUsers(context, searchForOtherUsers); if (!mounted) return; if (_contacts.isNotEmpty) { await showChatListTutorialContextMenu(context, firstUserListItemKey); } }); final user = await getUser(); if (user == null) return; _user = user; final changeLog = await rootBundle.loadString('CHANGELOG.md'); final changeLogHash = (await compute(Sha256().hash, changeLog.codeUnits)).bytes; if (!user.hideChangeLog && user.lastChangeLogHash.toString() != changeLogHash.toString()) { await updateUserdata((u) { u.lastChangeLogHash = changeLogHash; return u; }); if (!mounted) return; // only show changelog to people who already have contacts // this prevents that this is shown directly after the user registered if (_contacts.isNotEmpty) { await Navigator.push(context, MaterialPageRoute(builder: (context) { return ChangeLogView( changeLog: changeLog, ); })); } } } @override void dispose() { tutorial?.cancel(); _contactsSub.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final isConnected = context.watch().isConnected; final planId = context.watch().plan; return Scaffold( appBar: AppBar( title: Row(children: [ GestureDetector( onTap: () async { await Navigator.push(context, MaterialPageRoute(builder: (context) { return const ProfileView(); })); _user = await getUser(); if (!mounted) return; setState(() {}); }, child: ContactAvatar( userData: _user, fontSize: 14, color: context.color.onSurface.withAlpha(20), ), ), const SizedBox(width: 10), const Text('twonly '), if (planId != 'Free') GestureDetector( onTap: () { Navigator.push(context, MaterialPageRoute(builder: (context) { return const SubscriptionView(); })); }, child: Container( decoration: BoxDecoration( color: context.color.primary, borderRadius: BorderRadius.circular(15), ), padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 3), child: Text( planId, style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: isDarkMode(context) ? Colors.black : Colors.white, ), ), ), ), ]), actions: [ const FeedbackIconButton(), StreamBuilder( stream: twonlyDB.contactsDao.watchContactsRequested(), builder: (context, snapshot) { var count = 0; if (snapshot.hasData && snapshot.data != null) { count = snapshot.data!; } return NotificationBadge( count: count.toString(), child: IconButton( key: searchForOtherUsers, icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18), onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (context) => const AddNewUserView(), ), ); }, ), ); }, ), IconButton( onPressed: () async { await Navigator.push( context, MaterialPageRoute( builder: (context) => const SettingsMainView(), ), ); _user = await getUser(); if (!mounted) return; setState(() {}); }, icon: const FaIcon(FontAwesomeIcons.gear, size: 19), ) ], ), body: Stack( children: [ Positioned( top: 0, left: 0, right: 0, child: isConnected ? Container() : const ConnectionInfo(), ), Positioned.fill( child: (_contacts.isEmpty && _pinnedContacts.isEmpty) ? Center( child: Padding( padding: const EdgeInsets.all(10), child: OutlinedButton.icon( icon: const Icon(Icons.person_add), onPressed: () { Navigator.push( context, MaterialPageRoute( builder: (context) => const AddNewUserView(), ), ); }, label: Text(context.lang.chatListViewSearchUserNameBtn)), ), ) : RefreshIndicator( onRefresh: () async { await apiService.close(() {}); await apiService.connect(force: true); await Future.delayed(const Duration(seconds: 1)); }, child: ListView.builder( itemCount: _pinnedContacts.length + (_pinnedContacts.isNotEmpty ? 1 : 0) + _contacts.length + 1, itemBuilder: (context, index) { if (index == 0) { return const BackupNoticeCard(); } index -= 1; // Check if the index is for the pinned users if (index < _pinnedContacts.length) { final contact = _pinnedContacts[index]; return UserListItem( key: ValueKey(contact.userId), user: contact, firstUserListItemKey: (index == 0 || index == 1) ? firstUserListItemKey : null, ); } // If there are pinned users, account for the Divider var adjustedIndex = index - _pinnedContacts.length; if (_pinnedContacts.isNotEmpty && adjustedIndex == 0) { return const Divider(); } // Adjust the index for the contacts list adjustedIndex -= (_pinnedContacts.isNotEmpty ? 1 : 0); // Get the contacts that are not pinned final contact = _contacts.elementAt( adjustedIndex, ); return UserListItem( key: ValueKey(contact.userId), user: contact, firstUserListItemKey: (index == 0) ? firstUserListItemKey : null, ); }, ), ), ), ], ), floatingActionButton: Padding( padding: const EdgeInsets.only(bottom: 30), child: FloatingActionButton( onPressed: () { Navigator.push( context, MaterialPageRoute(builder: (context) { return const StartNewChatView(); }), ); }, child: const FaIcon(FontAwesomeIcons.penToSquare), ), ), ); } } class UserListItem extends StatefulWidget { const UserListItem({ required this.user, required this.firstUserListItemKey, super.key, }); final Contact user; final GlobalKey? firstUserListItemKey; @override State createState() => _UserListItem(); } class _UserListItem extends State { MessageSendState state = MessageSendState.send; Message? currentMessage; List messagesNotOpened = []; late StreamSubscription> messagesNotOpenedStream; List lastMessages = []; late StreamSubscription> lastMessageStream; List previewMessages = []; bool hasNonOpenedMediaFile = false; @override void initState() { super.initState(); initStreams(); } @override void dispose() { messagesNotOpenedStream.cancel(); lastMessageStream.cancel(); super.dispose(); } void initStreams() { lastMessageStream = twonlyDB.messagesDao .watchLastMessage(widget.user.userId) .listen((update) { updateState(update, messagesNotOpened); }); messagesNotOpenedStream = twonlyDB.messagesDao .watchMessageNotOpened(widget.user.userId) .listen((update) { updateState(lastMessages, update); }); } void updateState( List newLastMessages, List newMessagesNotOpened, ) { if (newLastMessages.isEmpty) { // there are no messages at all currentMessage = null; previewMessages = []; } else if (newMessagesNotOpened.isEmpty) { // there are no not opened messages show just the last message in the table currentMessage = newLastMessages.last; previewMessages = newLastMessages; } else { // filter first for received messages final receivedMessages = newMessagesNotOpened.where((x) => x.messageOtherId != null).toList(); if (receivedMessages.isNotEmpty) { previewMessages = receivedMessages; currentMessage = receivedMessages.first; } else { previewMessages = newMessagesNotOpened; currentMessage = newMessagesNotOpened.first; } } final msgs = previewMessages.where((x) => x.kind == MessageKind.media).toList(); if (msgs.isNotEmpty && msgs.first.kind == MessageKind.media && msgs.first.messageOtherId != null && msgs.first.openedAt == null) { hasNonOpenedMediaFile = true; } else { hasNonOpenedMediaFile = false; } lastMessages = newLastMessages; messagesNotOpened = newMessagesNotOpened; setState(() { // sets lastMessages, messagesNotOpened and currentMessage }); } Future onTap() async { if (currentMessage == null) { await Navigator.push(context, MaterialPageRoute( builder: (context) { return CameraSendToView(widget.user); }, )); return; } if (hasNonOpenedMediaFile) { final msgs = previewMessages.where((x) => x.kind == MessageKind.media).toList(); switch (msgs.first.downloadState) { case DownloadState.pending: await startDownloadMedia(msgs.first, true); return; case DownloadState.downloaded: await Navigator.push( context, MaterialPageRoute(builder: (context) { return MediaViewerView(widget.user); }), ); return; case DownloadState.downloading: return; } } if (!mounted) return; await Navigator.push( context, MaterialPageRoute(builder: (context) { return ChatMessagesView(widget.user); }), ); } @override Widget build(BuildContext context) { final flameCounter = getFlameCounterFromContact(widget.user); return Stack( children: [ Positioned( top: 0, bottom: 0, left: 50, child: SizedBox( key: widget.firstUserListItemKey, height: 20, width: 20, ), ), UserContextMenu( contact: widget.user, child: ListTile( title: Text( getContactDisplayName(widget.user), ), subtitle: (widget.user.deleted) ? Text(context.lang.userDeletedAccount) : (currentMessage == null) ? Text(context.lang.chatsTapToSend) : Row( children: [ MessageSendStateIcon(previewMessages), const Text('•'), const SizedBox(width: 5), if (currentMessage != null) LastMessageTime(message: currentMessage!), if (flameCounter > 0) FlameCounterWidget( widget.user, flameCounter, prefix: true, ), ], ), leading: ContactAvatar(contact: widget.user), trailing: (widget.user.deleted) ? null : IconButton( onPressed: () { Navigator.push(context, MaterialPageRoute( builder: (context) { if (hasNonOpenedMediaFile) { return ChatMessagesView(widget.user); } else { return CameraSendToView(widget.user); } }, )); }, icon: FaIcon( hasNonOpenedMediaFile ? FontAwesomeIcons.solidComments : FontAwesomeIcons.camera, color: context.color.outline.withAlpha(150), ), ), onTap: onTap, ), ) ], ); } }