import 'dart:io'; import 'package:cwtch/cwtch/cwtch.dart'; import 'package:cwtch/cwtch_icons_icons.dart'; import 'package:cwtch/models/appstate.dart'; import 'package:cwtch/models/contact.dart'; import 'package:cwtch/models/contactlist.dart'; import 'package:cwtch/models/profile.dart'; import 'package:cwtch/models/profilelist.dart'; import 'package:cwtch/views/profileserversview.dart'; import 'package:flutter/material.dart'; import 'package:cwtch/widgets/contactrow.dart'; import 'package:cwtch/widgets/profileimage.dart'; import 'package:cwtch/widgets/textfield.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; import '../main.dart'; import '../settings.dart'; import 'addcontactview.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'messageview.dart'; enum ShareMenu { copyCode, qrcode } class ContactsView extends StatefulWidget { const ContactsView({Key? key}) : super(key: key); @override _ContactsViewState createState() => _ContactsViewState(); } // selectConversation can be called from anywhere to set the active conversation void selectConversation(BuildContext context, int handle) { if (handle == Provider.of(context, listen: false).selectedConversation) { return; } // requery instead of using contactinfostate directly because sometimes listview gets confused about data that resorts var unread = Provider.of(context, listen: false).contactList.getContact(handle)!.unreadMessages; var previouslySelected = Provider.of(context, listen: false).selectedConversation; if (previouslySelected != null) { Provider.of(context, listen: false).contactList.getContact(previouslySelected)!.unselected(); } Provider.of(context, listen: false).contactList.getContact(handle)!.selected(); // triggers update in Double/TripleColumnView Provider.of(context, listen: false).initialScrollIndex = unread; Provider.of(context, listen: false).selectedConversation = handle; Provider.of(context, listen: false).selectedIndex = null; Provider.of(context, listen: false).hoveredIndex = -1; // if in singlepane mode, push to the stack var isLandscape = Provider.of(context, listen: false).isLandscape(context); if (Provider.of(context, listen: false).uiColumns(isLandscape).length == 1) _pushMessageView(context, handle); // Set last message seen time in backend Provider.of(context, listen: false) .cwtch .SetConversationAttribute(Provider.of(context, listen: false).onion, handle, LastMessageSeenTimeKey, DateTime.now().toUtc().toIso8601String()); } void _pushMessageView(BuildContext context, int handle) { var profileOnion = Provider.of(context, listen: false).onion; Navigator.of(context).push( PageRouteBuilder( settings: RouteSettings(name: "messages"), pageBuilder: (builderContext, a1, a2) { var profile = Provider.of(builderContext).profs.getProfile(profileOnion)!; return MultiProvider( providers: [ ChangeNotifierProvider.value(value: profile), ChangeNotifierProvider.value(value: profile.contactList.getContact(handle)!), ], builder: (context, child) => MessageView(), ); }, transitionsBuilder: (c, anim, a2, child) => FadeTransition(opacity: anim, child: child), transitionDuration: Duration(milliseconds: 200), ), ); } class _ContactsViewState extends State { late TextEditingController ctrlrFilter; bool showSearchBar = false; final scaffoldKey = GlobalKey(); @override void initState() { super.initState(); ctrlrFilter = new TextEditingController(text: Provider.of(context, listen: false).filter); } @override Widget build(BuildContext context) { return ScaffoldMessenger( key: scaffoldKey, child: Scaffold( endDrawerEnableOpenDragGesture: false, drawerEnableOpenDragGesture: false, appBar: AppBar( leading: Stack(children: [ Align( alignment: Alignment.center, child: IconButton( icon: Icon(Icons.arrow_back), tooltip: MaterialLocalizations.of(context).backButtonTooltip, onPressed: () { Provider.of(context, listen: false).recountUnread(); Provider.of(context, listen: false).selectedProfile = ""; Navigator.of(context).pop(); }, )), Positioned( bottom: 5.0, right: 5.0, child: StreamBuilder( stream: Provider.of(context).getUnreadProfileNotifyStream(), builder: (BuildContext context, AsyncSnapshot unreadCountSnapshot) { int unreadCount = Provider.of(context).generateUnreadCount(Provider.of(context).selectedProfile ?? ""); return Visibility( visible: unreadCount > 0, child: CircleAvatar( radius: 10.0, backgroundColor: Provider.of(context).theme.portraitProfileBadgeColor, child: Text(unreadCount > 99 ? "99+" : unreadCount.toString(), style: TextStyle(color: Provider.of(context).theme.portraitProfileBadgeTextColor, fontSize: 8.0)), )); }), ) ]), title: Row(children: [ ProfileImage( imagePath: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) ? Provider.of(context).imagePath : Provider.of(context).defaultImagePath, diameter: 42, border: Provider.of(context).isOnline ? Provider.of(context).current().portraitOnlineBorderColor : Provider.of(context).current().portraitOfflineBorderColor, badgeTextColor: Colors.red, badgeColor: Colors.red, ), SizedBox( width: 10, ), Expanded( child: Text("%1 ยป %2".replaceAll("%1", Provider.of(context).nickname).replaceAll("%2", AppLocalizations.of(context)!.titleManageContacts), overflow: TextOverflow.ellipsis, style: TextStyle(color: Provider.of(context).current().mainTextColor))), ]), actions: getActions(context), ), floatingActionButton: FloatingActionButton( onPressed: _modalAddImportChoice, tooltip: AppLocalizations.of(context)!.tooltipAddContact, child: Icon( CwtchIcons.person_add_alt_1_24px, color: Provider.of(context).theme.defaultButtonTextColor, ), ), body: showSearchBar || Provider.of(context).isFiltered ? _buildFilterable() : _buildContactList())); } List getActions(context) { var actions = List.empty(growable: true); if (Provider.of(context).blockUnknownConnections) { actions.add(Tooltip(message: AppLocalizations.of(context)!.blockUnknownConnectionsEnabledDescription, child: Icon(CwtchIcons.block_unknown))); } if (Provider.of(context, listen: false).isExperimentEnabled(QRCodeExperiment)) { actions.add(PopupMenuButton( icon: Icon(CwtchIcons.address_copy), tooltip: AppLocalizations.of(context)!.shareProfileMenuTooltop, splashRadius: Material.defaultSplashRadius / 2, onSelected: (ShareMenu item) { switch (item) { case ShareMenu.copyCode: { Clipboard.setData(new ClipboardData(text: Provider.of(context, listen: false).onion)); final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.copiedToClipboardNotification)); scaffoldKey.currentState?.showSnackBar(snackBar); } break; case ShareMenu.qrcode: { _showQRCode("cwtch:" + Provider.of(context, listen: false).onion); } break; } }, itemBuilder: (BuildContext context) => >[ PopupMenuItem( value: ShareMenu.copyCode, child: Text(AppLocalizations.of(context)!.copyAddress), ), PopupMenuItem( value: ShareMenu.qrcode, child: Text(AppLocalizations.of(context)!.shareMenuQRCode), ), ], )); } else { actions.add(IconButton( icon: Icon(CwtchIcons.address_copy), tooltip: AppLocalizations.of(context)!.copyAddress, splashRadius: Material.defaultSplashRadius / 2, onPressed: () { Clipboard.setData(new ClipboardData(text: Provider.of(context, listen: false).onion)); final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.copiedToClipboardNotification)); scaffoldKey.currentState?.showSnackBar(snackBar); })); } // Manage known Servers if (Provider.of(context, listen: false).isExperimentEnabled(TapirGroupsExperiment) || Provider.of(context, listen: false).isExperimentEnabled(ServerManagementExperiment)) { actions.add(IconButton( icon: Icon(CwtchIcons.dns_24px), tooltip: AppLocalizations.of(context)!.manageKnownServersButton, splashRadius: Material.defaultSplashRadius / 2, onPressed: () { _pushServers(); })); } // Search contacts actions.add(IconButton( // need both conditions for displaying initial empty textfield and also allowing filters to be cleared if this widget gets lost/reset icon: Icon(showSearchBar || Provider.of(context).isFiltered ? Icons.search_off : Icons.search), splashRadius: Material.defaultSplashRadius / 2, onPressed: () { Provider.of(context, listen: false).filter = ""; setState(() { showSearchBar = !showSearchBar; }); })); return actions; } Widget _buildFilterable() { Widget txtfield = CwtchTextField( controller: ctrlrFilter, hintText: AppLocalizations.of(context)!.search, onChanged: (newVal) { Provider.of(context, listen: false).filter = newVal; }, ); return Column(children: [Padding(padding: EdgeInsets.all(8), child: txtfield), Expanded(child: _buildContactList())]); } Widget _buildContactList() { final tiles = Provider.of(context).filteredList().map((ContactInfoState contact) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: contact), ChangeNotifierProvider.value(value: Provider.of(context).serverList), ], builder: (context, child) => ContactRow(), ); }); var initialScroll = Provider.of(context, listen: false).contactList.filteredList().indexWhere((element) => element.identifier == Provider.of(context).selectedConversation); if (initialScroll < 0) { initialScroll = 0; } var contactList = ScrollablePositionedList.separated( itemScrollController: Provider.of(context).contactListScrollController, itemCount: Provider.of(context).numFiltered, initialScrollIndex: initialScroll, shrinkWrap: true, physics: BouncingScrollPhysics(), semanticChildCount: Provider.of(context).numFiltered, itemBuilder: (context, index) { return tiles.elementAt(index); }, separatorBuilder: (BuildContext context, int index) { return Divider(height: 1); }, ); return contactList; } void _pushAddContact(bool newGroup) { // close modal Navigator.popUntil(context, (route) => route.settings.name == "conversations"); Navigator.of(context).push( PageRouteBuilder( settings: RouteSettings(name: "addcontact"), pageBuilder: (builderContext, a1, a2) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: Provider.of(context)), ], child: AddContactView(newGroup: newGroup), ); }, transitionsBuilder: (c, anim, a2, child) => FadeTransition(opacity: anim, child: child), transitionDuration: Duration(milliseconds: 200), ), ); } void _pushServers() { var profile = Provider.of(context); Navigator.of(context).push( PageRouteBuilder( pageBuilder: (bcontext, a1, a2) { return MultiProvider( providers: [ChangeNotifierProvider(create: (context) => profile), Provider.value(value: Provider.of(context))], child: ProfileServersView(), ); }, transitionsBuilder: (c, anim, a2, child) => FadeTransition(opacity: anim, child: child), transitionDuration: Duration(milliseconds: 200), ), ); } void _modalAddImportChoice() { bool groupsEnabled = Provider.of(context, listen: false).isExperimentEnabled(TapirGroupsExperiment); showModalBottomSheet( context: context, isScrollControlled: true, builder: (BuildContext context) { return Padding( padding: MediaQuery.of(context).viewInsets, child: RepaintBoundary( child: Container( height: Platform.isAndroid ? 250 : 200, // bespoke value courtesy of the [TextField] docs child: Center( child: Padding( padding: EdgeInsets.all(2.0), child: Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, mainAxisSize: MainAxisSize.max, children: [ SizedBox( height: 20, ), Expanded( child: Tooltip( message: AppLocalizations.of(context)!.tooltipAddContact, child: ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: Size.fromWidth(399), maximumSize: Size.fromWidth(400), shape: RoundedRectangleBorder(borderRadius: BorderRadius.horizontal(left: Radius.circular(180), right: Radius.circular(180))), ), child: Text( AppLocalizations.of(context)!.addContact, semanticsLabel: AppLocalizations.of(context)!.addContact, textAlign: TextAlign.center, style: TextStyle(fontWeight: FontWeight.bold), ), onPressed: () { _pushAddContact(false); }, ))), SizedBox( height: 20, ), Expanded( child: Tooltip( message: groupsEnabled ? AppLocalizations.of(context)!.addServerTooltip : AppLocalizations.of(context)!.thisFeatureRequiresGroupExpermientsToBeEnabled, child: ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: Size.fromWidth(399), maximumSize: Size.fromWidth(400), shape: RoundedRectangleBorder(borderRadius: BorderRadius.horizontal(left: Radius.circular(180), right: Radius.circular(180))), ), child: Text( AppLocalizations.of(context)!.addServerTitle, semanticsLabel: AppLocalizations.of(context)!.addServerTitle, textAlign: TextAlign.center, style: TextStyle(fontWeight: FontWeight.bold), ), onPressed: groupsEnabled ? () { _pushAddContact(false); } : null, )), ), SizedBox( height: 20, ), Expanded( child: Tooltip( message: groupsEnabled ? AppLocalizations.of(context)!.createGroupTitle : AppLocalizations.of(context)!.thisFeatureRequiresGroupExpermientsToBeEnabled, child: ElevatedButton( style: ElevatedButton.styleFrom( minimumSize: Size.fromWidth(399), maximumSize: Size.fromWidth(400), shape: RoundedRectangleBorder(borderRadius: BorderRadius.horizontal(left: Radius.circular(180), right: Radius.circular(180))), ), child: Text( AppLocalizations.of(context)!.createGroupTitle, semanticsLabel: AppLocalizations.of(context)!.createGroupTitle, textAlign: TextAlign.center, style: TextStyle(fontWeight: FontWeight.bold), ), onPressed: groupsEnabled ? () { _pushAddContact(true); } : null, ))), SizedBox( height: 20, ), ], ))), ))); }); } void _showQRCode(String profile_code) { showModalBottomSheet( context: context, builder: (BuildContext context) { return Wrap(children: [ Center( child: QrImage( data: profile_code, version: QrVersions.auto, size: 400.0, backgroundColor: Provider.of(context).theme.backgroundPaneColor, foregroundColor: Provider.of(context).theme.mainTextColor, gapless: false, ), ) ]); }, ); } }