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:go_router/go_router.dart'; import 'package:provider/provider.dart'; import 'package:twonly/globals.dart'; import 'package:twonly/src/constants/routes.keys.dart'; import 'package:twonly/src/database/twonly.db.dart'; import 'package:twonly/src/providers/purchases.provider.dart'; import 'package:twonly/src/services/subscription.service.dart'; import 'package:twonly/src/themes/light.dart'; import 'package:twonly/src/utils/misc.dart'; import 'package:twonly/src/utils/storage.dart'; import 'package:twonly/src/views/chats/chat_list_components/feedback_btn.dart'; import 'package:twonly/src/views/chats/chat_list_components/group_list_item.dart'; import 'package:twonly/src/views/components/avatar_icon.component.dart'; import 'package:twonly/src/views/components/connection_status_badge.dart'; import 'package:twonly/src/views/components/notification_badge.dart'; class ChatListView extends StatefulWidget { const ChatListView({super.key}); @override State createState() => _ChatListViewState(); } class _ChatListViewState extends State { late StreamSubscription> _contactsSub; List _groupsNotPinned = []; List _groupsPinned = []; List _groupsArchived = []; GlobalKey searchForOtherUsers = GlobalKey(); bool showFeedbackShortcut = false; int _countContactRequest = 0; int _countAnnouncedUsers = 0; late StreamSubscription _countContactRequestStream; late StreamSubscription _countAnnouncedStream; @override void initState() { initAsync(); super.initState(); } Future initAsync() async { final stream = twonlyDB.groupsDao.watchGroupsForChatList(); _contactsSub = stream.listen((groups) { if (!mounted) return; setState(() { _groupsNotPinned = groups .where((x) => !x.pinned && !x.archived) .toList(); _groupsPinned = groups.where((x) => x.pinned && !x.archived).toList(); _groupsArchived = groups.where((x) => x.archived).toList(); }); }); _countContactRequestStream = twonlyDB.contactsDao .watchContactsRequestedCount() .listen((update) { if (update != null) { if (!mounted) return; setState(() { _countContactRequest = update; }); } }); _countAnnouncedStream = twonlyDB.userDiscoveryDao .watchNewAnnouncementsWithDataCount() .listen((update) { if (!mounted) return; setState(() { _countAnnouncedUsers = update; }); }); WidgetsBinding.instance.addPostFrameCallback((_) async { final changeLog = await rootBundle.loadString('CHANGELOG.md'); final changeLogHash = (await compute( Sha256().hash, changeLog.codeUnits, )).bytes; if (!gUser.hideChangeLog && gUser.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 (_groupsNotPinned.isNotEmpty) { await context.push( Routes.settingsHelpChangelog, extra: changeLog, ); } } }); } @override void dispose() { _contactsSub.cancel(); _countContactRequestStream.cancel(); _countAnnouncedStream.cancel(); super.dispose(); } @override Widget build(BuildContext context) { final plan = context.watch().plan; return Scaffold( appBar: AppBar( title: Row( children: [ ConnectionStatusBadge( child: GestureDetector( onTap: () async { await context.push(Routes.settingsProfile); if (!mounted) return; setState(() {}); // gUser has updated }, child: AvatarIcon( myAvatar: true, fontSize: 14, color: context.color.onSurface.withAlpha(20), ), ), ), const SizedBox(width: 10), const Text('twonly '), if (plan != SubscriptionPlan.Free) GestureDetector( onTap: () => context.push(Routes.settingsSubscription), child: Container( decoration: BoxDecoration( color: context.color.primary, borderRadius: BorderRadius.circular(15), ), padding: const EdgeInsets.symmetric( horizontal: 5, vertical: 3, ), child: Text( plan.name, style: TextStyle( fontSize: 10, fontWeight: FontWeight.bold, color: isDarkMode(context) ? Colors.black : Colors.white, ), ), ), ), ], ), actions: [ const FeedbackIconButton(), Stack( children: [ if (_countAnnouncedUsers + _countContactRequest > 0) Positioned.fill( child: Center( child: Container( width: 40, height: 40, decoration: const BoxDecoration( color: primaryColor, shape: BoxShape.circle, ), ), ), ), Center( child: NotificationBadge( backgroundColor: isDarkMode(context) ? Colors.white : Colors.black, textColor: isDarkMode(context) ? Colors.black : Colors.white, count: (_countAnnouncedUsers + _countContactRequest) .toString(), child: IconButton( color: (_countAnnouncedUsers + _countContactRequest > 0) ? Colors.black : null, key: searchForOtherUsers, icon: const FaIcon(FontAwesomeIcons.userPlus, size: 18), onPressed: () => context.push(Routes.chatsAddNewUser), ), ), ), ], ), IconButton( onPressed: () async { await context.push(Routes.settings); if (mounted) setState(() {}); // gUser may has changed... }, icon: const FaIcon(FontAwesomeIcons.gear, size: 19), ), ], ), body: RefreshIndicator( onRefresh: () async { await apiService.close(() {}); await apiService.connect(); await Future.delayed(const Duration(seconds: 1)); }, child: (_groupsNotPinned.isEmpty && _groupsPinned.isEmpty && _groupsArchived.isEmpty) ? Center( child: Padding( padding: const EdgeInsets.all(10), child: OutlinedButton.icon( icon: const Icon(Icons.person_add), onPressed: () => context.push(Routes.chatsAddNewUser), label: Text( context.lang.chatListViewSearchUserNameBtn, ), ), ), ) : ListView.builder( itemCount: _groupsPinned.length + (_groupsPinned.isNotEmpty ? 1 : 0) + _groupsNotPinned.length + (_groupsArchived.isNotEmpty ? 1 : 0), itemBuilder: (context, index) { if (index >= _groupsNotPinned.length + _groupsPinned.length + (_groupsPinned.isNotEmpty ? 1 : 0)) { if (_groupsArchived.isEmpty) return Container(); return ListTile( title: Text( '${context.lang.archivedChats} (${_groupsArchived.length})', textAlign: TextAlign.center, style: const TextStyle(fontSize: 13), ), onTap: () => context.push(Routes.chatsArchived), ); } // Check if the index is for the pinned users if (index < _groupsPinned.length) { final group = _groupsPinned[index]; return GroupListItem( key: ValueKey(group.groupId), group: group, ); } // If there are pinned users, account for the Divider var adjustedIndex = index - _groupsPinned.length; if (_groupsPinned.isNotEmpty && adjustedIndex == 0) { return const Divider(); } // Adjust the index for the contacts list adjustedIndex -= (_groupsPinned.isNotEmpty ? 1 : 0); // Get the contacts that are not pinned final group = _groupsNotPinned.elementAt( adjustedIndex, ); return GroupListItem( key: ValueKey(group.groupId), group: group, ); }, ), ), floatingActionButton: Padding( padding: const EdgeInsets.only(bottom: 30), child: Column( mainAxisAlignment: MainAxisAlignment.end, children: [ Material( elevation: 3, shape: const CircleBorder(), color: context.color.primary, child: InkWell( borderRadius: BorderRadius.circular(12), onTap: () => context.push(Routes.settingsPublicProfile), child: SizedBox( width: 45, height: 45, child: Center( child: FaIcon( FontAwesomeIcons.qrcode, color: isDarkMode(context) ? Colors.black : Colors.white, ), ), ), ), ), const SizedBox(height: 12), FloatingActionButton( backgroundColor: context.color.primary, onPressed: () => context.push(Routes.chatsStartNewChat), child: FaIcon( FontAwesomeIcons.penToSquare, color: isDarkMode(context) ? Colors.black : Colors.white, ), ), ], ), ), ); } }