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/models/search.dart'; import 'package:cwtch/views/globalsettingsview.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 '../config.dart'; import '../main.dart'; import '../models/redaction.dart'; import '../settings.dart'; import '../themes/opaque.dart'; import 'addcontactview.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:qr_flutter/qr_flutter.dart'; import 'addeditprofileview.dart'; import 'messageview.dart'; enum ShareMenu { copyCode, qrcode } enum ProfileStatusMenu { available, away, busy, appearOnline, appearOffline, allowUnknownContacts, blockUnknownContacts, enableProfile, disableProfile, editProfile } 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, int? messageIndex) { int? index; if (messageIndex != null) { // this message is loaded Provider.of(context, listen: false).selectedSearchMessage = messageIndex; Provider.of(context, listen: false).initialScrollIndex = messageIndex; EnvironmentConfig.debugLog("Looked up index $messageIndex"); } if (handle == Provider.of(context, listen: false).selectedConversation) { if (messageIndex != null) { Provider.of(context, listen: false).messageScrollController.scrollTo(index: messageIndex, duration: Duration(milliseconds: 100)); } 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).hoveredIndex = -1; Provider.of(context, listen: false).selectedConversation = handle; // 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) { var enabled = Provider.of(context, listen: false).enabled; var appearOffline = Provider.of(context, listen: false).appearOffline; var settings = Provider.of(context, listen: false); return ScaffoldMessenger( key: scaffoldKey, child: Scaffold( backgroundColor: Provider.of(context).theme.backgroundMainColor, 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: [ PopupMenuButton( icon: ProfileImage( imagePath: Provider.of(context).isExperimentEnabled(ImagePreviewsExperiment) ? Provider.of(context).imagePath : Provider.of(context).defaultImagePath, diameter: 42, border: Provider.of(context).getBorderColor(Provider.of(context).theme), badgeTextColor: Colors.red, badgeColor: Colors.red, ), iconSize: 42, tooltip: AppLocalizations.of(context)!.availabilityStatusTooltip, splashRadius: Material.defaultSplashRadius / 2, onSelected: (ProfileStatusMenu item) { String onion = Provider.of(context, listen: false).onion; switch (item) { case ProfileStatusMenu.available: Provider.of(context, listen: false).cwtch.SetProfileAttribute(onion, "profile.profile-status", "available"); break; case ProfileStatusMenu.away: Provider.of(context, listen: false).cwtch.SetProfileAttribute(onion, "profile.profile-status", "away"); break; case ProfileStatusMenu.busy: Provider.of(context, listen: false).cwtch.SetProfileAttribute(onion, "profile.profile-status", "busy"); break; case ProfileStatusMenu.appearOffline: Provider.of(context, listen: false).appearOffline = true; Provider.of(context, listen: false).deactivatePeerEngine(context); Provider.of(context, listen: false).cwtch.ConfigureConnections(onion, false, false, false); break; case ProfileStatusMenu.editProfile: Navigator.of(context).push( PageRouteBuilder( pageBuilder: (bcontext, a1, a2) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: Provider.of(context, listen: false)), ], builder: (context, widget) => AddEditProfileView(key: Key('addprofile')), ); }, transitionsBuilder: (c, anim, a2, child) => FadeTransition(opacity: anim, child: child), transitionDuration: Duration(milliseconds: 200), ), ); break; case ProfileStatusMenu.appearOnline: Provider.of(context, listen: false).appearOffline = false; // we only need to toggle all connections on here.. Provider.of(context, listen: false).cwtch.ConfigureConnections(onion, true, true, true); break; case ProfileStatusMenu.allowUnknownContacts: settings.blockUnknownConnections = false; saveSettings(context); break; case ProfileStatusMenu.blockUnknownContacts: settings.blockUnknownConnections = true; saveSettings(context); break; case ProfileStatusMenu.enableProfile: Provider.of(context, listen: false).enabled = true; if (Provider.of(context, listen: false).appearOffline) { Provider.of(context, listen: false).cwtch.ConfigureConnections(onion, false, false, false); } else { Provider.of(context, listen: false).cwtch.ConfigureConnections(onion, true, true, true); } break; case ProfileStatusMenu.disableProfile: Provider.of(context, listen: false).enabled = false; Provider.of(context, listen: false).deactivatePeerEngine(context); break; } }, itemBuilder: (BuildContext context) => >[ PopupMenuItem( value: ProfileStatusMenu.available, enabled: enabled, child: Row(children: [ Icon( CwtchIcons.account_circle_24px, color: Colors.white, ), Expanded( child: Text(AppLocalizations.of(context)!.availabilityStatusAvailable, textAlign: TextAlign.right, style: Provider.of(context, listen: false).scaleFonts(defaultTextButtonStyle))) ]), ), PopupMenuItem( value: ProfileStatusMenu.away, enabled: enabled, child: Row(children: [ Icon( CwtchIcons.account_circle_24px, color: Colors.yellowAccent, ), Expanded( child: Text(AppLocalizations.of(context)!.availabilityStatusAway, textAlign: TextAlign.right, style: Provider.of(context, listen: false).scaleFonts(defaultTextButtonStyle))) ]), ), PopupMenuItem( value: ProfileStatusMenu.busy, enabled: enabled, child: Row(children: [ Icon( CwtchIcons.account_circle_24px, color: Colors.redAccent, ), Expanded( child: Text(AppLocalizations.of(context)!.availabilityStatusBusy, textAlign: TextAlign.right, style: Provider.of(context, listen: false).scaleFonts(defaultTextButtonStyle))) ]), ), PopupMenuDivider(), PopupMenuItem( value: ProfileStatusMenu.appearOffline, enabled: enabled && !appearOffline, child: Row(children: [ Icon(CwtchIcons.disconnect_from_contact), Expanded( child: Text(AppLocalizations.of(context)!.profileAppearOffline, textAlign: TextAlign.right, style: Provider.of(context, listen: false).scaleFonts(defaultTextButtonStyle))) ]), ), PopupMenuItem( value: ProfileStatusMenu.appearOnline, enabled: enabled && appearOffline, child: Row(children: [ Icon(CwtchIcons.disconnect_from_contact), Expanded( child: Text(AppLocalizations.of(context)!.profileAppearOnline, textAlign: TextAlign.right, style: Provider.of(context, listen: false).scaleFonts(defaultTextButtonStyle))) ]), ), PopupMenuDivider(), PopupMenuItem( value: !settings.blockUnknownConnections ? ProfileStatusMenu.blockUnknownContacts : ProfileStatusMenu.allowUnknownContacts, child: Row(children: [ Icon( CwtchIcons.block_unknown, color: settings.theme.mainTextColor, ), Expanded( child: Text((settings.blockUnknownConnections ? AppLocalizations.of(context)!.profileAllowUnknownContacts : AppLocalizations.of(context)!.profileBlockUnknownContacts), textAlign: TextAlign.right, style: Provider.of(context, listen: false).scaleFonts(defaultTextButtonStyle))) ]), ), PopupMenuDivider(), PopupMenuItem( value: enabled ? ProfileStatusMenu.disableProfile : ProfileStatusMenu.enableProfile, child: Row(children: [ Icon(CwtchIcons.favorite_24dp, color: settings.theme.mainTextColor), Expanded( child: Text((enabled ? AppLocalizations.of(context)!.profileDisableProfile : AppLocalizations.of(context)!.profileEnableProfile), textAlign: TextAlign.right, style: Provider.of(context, listen: false).scaleFonts(defaultTextButtonStyle))) ]), ), PopupMenuItem( value: ProfileStatusMenu.editProfile, enabled: true, child: Row(children: [ Icon( CwtchIcons.edit_24px, color: settings.theme.mainTextColor, ), Expanded( child: Text(AppLocalizations.of(context)!.editProfile, textAlign: TextAlign.right, style: Provider.of(context, listen: false).scaleFonts(defaultTextButtonStyle))) ]), ), ], ), SizedBox( width: 10, ), Expanded( child: Text( "%1 ยป %2" .replaceAll("%1", redactedNick(context, Provider.of(context).onion, Provider.of(context).nickname)) .replaceAll("%2", AppLocalizations.of(context)!.titleManageContacts), overflow: TextOverflow.ellipsis, style: TextStyle( color: Provider.of(context).current().mainTextColor, fontFamily: "Inter", fontWeight: FontWeight.bold, fontSize: 14.0 * Provider.of(context).fontScaling))), ]), 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, style: Provider.of(context, listen: false).scaleFonts(defaultTextButtonStyle)), ), PopupMenuItem( value: ShareMenu.qrcode, child: Text(AppLocalizations.of(context)!.shareMenuQRCode, style: Provider.of(context, listen: false).scaleFonts(defaultTextButtonStyle)), ), ], )); } 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) { String profileHandle = Provider.of(context, listen: false).onion; Provider.of(context, listen: false).cwtch.SearchConversations(profileHandle, newVal).then((value) { Provider.of(context, listen: false).newSearch(value); }); Provider.of(context, listen: false).filter = newVal; }, ); return Column(children: [Padding(padding: EdgeInsets.all(8), child: txtfield), Expanded(child: _buildContactList())]); } Widget _buildContactList() { var tilesSearchResult = 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; } if (showSearchBar) { List searchResults = Provider.of(context).activeSearchResults; tilesSearchResult = searchResults.map((SearchResult searchResult) { return MultiProvider( providers: [ ChangeNotifierProvider.value(value: Provider.of(context).contactList.getContact(searchResult.conversationIdentifier)), ChangeNotifierProvider.value(value: Provider.of(context).serverList), ], builder: (context, child) => ContactRow(messageIndex: searchResult.messageIndex), ); }); } else {} var contactList = ScrollablePositionedList.separated( itemScrollController: Provider.of(context).contactListScrollController, itemCount: tilesSearchResult.length, initialScrollIndex: initialScroll, shrinkWrap: true, physics: BouncingScrollPhysics(), semanticChildCount: tilesSearchResult.length, itemBuilder: (context, index) { if (tilesSearchResult.length > index) { return tilesSearchResult.elementAt(index); } return Container(); }, 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 profileInfoState = Provider.of(context, listen: false); Navigator.of(context).push( PageRouteBuilder( settings: RouteSettings(name: "profileremoteservers"), pageBuilder: (bcontext, a1, a2) { return MultiProvider( providers: [ChangeNotifierProvider.value(value: profileInfoState), 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(fontFamily: "Inter", fontSize: 10.0 * Provider.of(context).fontScaling, 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(fontFamily: "Inter", fontSize: 10.0 * Provider.of(context).fontScaling, 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(fontFamily: "Inter", fontSize: 10.0 * Provider.of(context).fontScaling, fontWeight: FontWeight.bold), ), onPressed: groupsEnabled ? () { _pushAddContact(true); } : null, ))), SizedBox( height: 20, ), ], ))), ))); }); } void _showQRCode(String profileCode) { showModalBottomSheet( context: context, builder: (BuildContext context) { return Wrap(children: [ Center( child: QrImageView( data: profileCode, version: QrVersions.auto, size: 400.0, backgroundColor: Provider.of(context).theme.backgroundPaneColor, foregroundColor: Provider.of(context).theme.mainTextColor, gapless: false, ), ) ]); }, ); } }