LCOV - code coverage report
Current view: top level - lib/views - membershipview.dart (source / functions) Hit Total Coverage
Test: lcov.info Lines: 0 206 0.0 %
Date: 2026-08-30 22:38:45 Functions: 0 0 -

          Line data    Source code
       1             : import 'dart:io';
       2             : 
       3             : import 'package:cwtch/config.dart';
       4             : import 'package:cwtch/cwtch_icons_icons.dart';
       5             : import 'package:cwtch/controllers/filesharing.dart' as filesharing;
       6             : import 'package:cwtch/settings.dart';
       7             : import 'package:cwtch/models/appstate.dart';
       8             : import 'package:cwtch/models/contact.dart';
       9             : import 'package:cwtch/models/contactlist.dart';
      10             : import 'package:cwtch/models/groupmembers.dart';
      11             : import 'package:cwtch/models/profile.dart';
      12             : import 'package:cwtch/models/redaction.dart';
      13             : import 'package:cwtch/widgets/buttontextfield.dart';
      14             : import 'package:cwtch/widgets/cwtchlabel.dart';
      15             : import 'package:cwtch/widgets/DropdownContacts.dart';
      16             : import 'package:cwtch/widgets/profileimage.dart';
      17             : import 'package:flutter/services.dart';
      18             : import 'package:flutter/material.dart';
      19             : import 'package:provider/provider.dart';
      20             : import 'package:cwtch/l10n/app_localizations.dart';
      21             : 
      22             : import '../constants.dart';
      23             : import '../main.dart';
      24             : import '../themes/opaque.dart';
      25             : 
      26             : enum RolesMenu { admin, op, mod, voice, user }
      27             : 
      28             : class MembershipView extends StatefulWidget {
      29           0 :   @override
      30           0 :   _MembershipViewState createState() => _MembershipViewState();
      31             : }
      32             : 
      33             : class _MembershipViewState extends State<MembershipView> {
      34             :   String selectedContact = "";
      35             :   ScrollController membersScrollController = ScrollController();
      36             :   ScrollController bansScrollController = ScrollController();
      37             : 
      38             :   final scaffoldKey = GlobalKey<ScaffoldMessengerState>();
      39             : 
      40           0 :   @override
      41             :   void dispose() {
      42           0 :     super.dispose();
      43             :   }
      44             : 
      45             :   ScrollController peerSettingsScrollController = ScrollController();
      46             : 
      47           0 :   @override
      48             :   void initState() {
      49           0 :     super.initState();
      50             :   }
      51             : 
      52           0 :   @override
      53             :   Widget build(BuildContext context) {
      54           0 :     var handle = Provider.of<ContactInfoState>(context).nickname;
      55           0 :     if (handle.isEmpty) {
      56           0 :       handle = Provider.of<ContactInfoState>(context).onion;
      57             :     }
      58           0 :     return DefaultTabController(
      59             :       length: 2,
      60           0 :       child: Scaffold(
      61           0 :         appBar: AppBar(
      62           0 :           title: Container(
      63           0 :             height: Provider.of<Settings>(context).fontScaling * 24.0,
      64             :             clipBehavior: Clip.hardEdge,
      65           0 :             decoration: BoxDecoration(),
      66           0 :             child: Text(handle + " ยป " + AppLocalizations.of(context)!.editMembershipTitle),
      67             :           ),
      68           0 :           bottom: TabBar(
      69             :             isScrollable: true,
      70           0 :             tabs: [
      71           0 :               Tab(key: Key("OpenGroupMembers"), icon: Icon(CwtchIcons.edit_member_list), text: AppLocalizations.of(context)!.editMembersTabTitle),
      72           0 :               Tab(key: Key("OpenGroupBans"), icon: Icon(CwtchIcons.block_peer), text: AppLocalizations.of(context)!.editBansTabTitle),
      73             :             ],
      74             :           ),
      75             :         ),
      76           0 :         body: _buildMembersList(),
      77             :       ),
      78             :     );
      79             :   }
      80             : 
      81           0 :   Widget _buildMembersList() {
      82           0 :     return Consumer<Settings>(
      83           0 :       builder: (context, settings, child) {
      84           0 :         return LayoutBuilder(
      85           0 :           builder: (BuildContext context, BoxConstraints viewportConstraints) {
      86           0 :             return TabBarView(
      87           0 :               children: [_tabHelper(_buildMembersTab(settings), "EditGroupMembers", this.membersScrollController), _tabHelper(_buildBansTab(settings), "EditGroupBans", this.bansScrollController)],
      88             :             );
      89             :           },
      90             :         );
      91             :       },
      92             :     );
      93             :   }
      94             : 
      95           0 :   Widget _tabHelper(Widget tab, String scrollKey, ScrollController sc) {
      96           0 :     return Consumer<Settings>(
      97           0 :       builder: (ccontext, settings, child) {
      98           0 :         return LayoutBuilder(
      99           0 :           builder: (BuildContext context, BoxConstraints viewportConstraints) {
     100           0 :             return Scrollbar(
     101           0 :               key: Key(scrollKey),
     102             :               trackVisibility: true,
     103             :               controller: sc,
     104           0 :               child: SingleChildScrollView(
     105             :                 clipBehavior: Clip.antiAlias,
     106             :                 controller: sc,
     107           0 :                 child: ConstrainedBox(
     108           0 :                   constraints: BoxConstraints(minHeight: viewportConstraints.maxHeight, maxWidth: viewportConstraints.maxWidth),
     109           0 :                   child: Material(
     110           0 :                     color: settings.theme.backgroundPaneColor,
     111           0 :                     child: Container(width: viewportConstraints.maxWidth / 2, margin: EdgeInsetsGeometry.all(16.0), child: tab),
     112             :                   ),
     113             :                 ),
     114             :               ),
     115             :             );
     116             :           },
     117             :         );
     118             :       },
     119             :     );
     120             :   }
     121             : 
     122           0 :   Widget _buildMembersTab(Settings settings) {
     123           0 :     List<Widget> rolesLists = List<Widget>.empty(growable: true);
     124           0 :     rolesLists.addAll(_buildRolesBox("admin", AppLocalizations.of(context)!.roleAdminPlural));
     125           0 :     rolesLists.addAll(_buildRolesBox("op", AppLocalizations.of(context)!.roleOpPlural));
     126           0 :     rolesLists.addAll(_buildRolesBox("mod", AppLocalizations.of(context)!.roleModPlural));
     127           0 :     rolesLists.addAll(_buildRolesBox("voice", AppLocalizations.of(context)!.roleVoicePlural));
     128           0 :     rolesLists.addAll(_buildRolesBox("user", AppLocalizations.of(context)!.roleUserPlural));
     129             : 
     130           0 :     bool pending = Provider.of<ContactInfoState>(context).isOpPending;
     131             : 
     132           0 :     return Column(
     133             :       mainAxisAlignment: MainAxisAlignment.start,
     134             :       crossAxisAlignment: CrossAxisAlignment.stretch,
     135             :       spacing: 8.0,
     136           0 :       children: [
     137           0 :         Column(crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 8.0, children: rolesLists),
     138           0 :         _addMemberBox(),
     139             :       ],
     140             :     );
     141             :   }
     142             : 
     143           0 :   Widget _buildBansTab(Settings settings) {
     144           0 :     List<Widget> rolesLists = List<Widget>.empty(growable: true);
     145           0 :     rolesLists.addAll(_buildRolesBox("blocked", AppLocalizations.of(context)!.roleBanned));
     146           0 :     rolesLists.addAll(_buildRolesBox("", AppLocalizations.of(context)!.roleUnknown));
     147           0 :     if (rolesLists.length == 0) {
     148           0 :       return Text(AppLocalizations.of(context)!.noBansMessage);
     149             :     }
     150           0 :     rolesLists.add(Text(AppLocalizations.of(context)!.explainUnban));
     151             : 
     152           0 :     bool pending = Provider.of<ContactInfoState>(context).isOpPending;
     153             : 
     154           0 :     return Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.stretch, spacing: 8.0, children: rolesLists);
     155             :   }
     156             : 
     157           0 :   List<Widget> _buildRolesBox(String role, String header) {
     158           0 :     List<GroupMember> members = Provider.of<ContactInfoState>(context).getMembersOfRole(role);
     159           0 :     if (members.length == 0) return List<Widget>.empty(growable: true);
     160             : 
     161           0 :     List<Widget> retVal = List<Widget>.from([CwtchLabel(label: header)]);
     162           0 :     retVal.addAll(members.map((ev) => _memberListEntry(ev)).toList());
     163             : 
     164             :     return retVal;
     165             :   }
     166             : 
     167             :   // note this method closely resembles MembersDrawer::_contactEntry
     168           0 :   Widget _memberListEntry(dynamic member) {
     169           0 :     bool pending = Provider.of<ContactInfoState>(context).isOpPending;
     170           0 :     bool isMe = member.onion == Provider.of<ProfileInfoState>(context).onion;
     171           0 :     var contact = Provider.of<ProfileInfoState>(context).contactList.findContact(member.onion);
     172             :     if (contact == null) {
     173           0 :       contact = ContactInfoState(member.onion, -1, member.onion); //cheeky
     174             :     }
     175           0 :     String name = isMe ? redactedNick(context, Provider.of<ProfileInfoState>(context).onion, Provider.of<ProfileInfoState>(context).nickname) : redactedNick(context, contact.onion, contact.nickname);
     176             : 
     177           0 :     return Flex(
     178             :       direction: Axis.horizontal,
     179             :       mainAxisAlignment: MainAxisAlignment.spaceBetween,
     180             :       spacing: 16.0,
     181           0 :       children: <Widget>[
     182           0 :         Flexible(
     183             :           flex: 1,
     184             :           child: isMe
     185           0 :               ? ProfileImage.fromProfile(
     186           0 :                   Provider.of<Settings>(context),
     187           0 :                   Provider.of<ProfileInfoState>(context),
     188             :                   48.0, //diameter
     189             :                 )
     190           0 :               : ProfileImage.fromContact(
     191           0 :                   Provider.of<Settings>(context),
     192             :                   contact!,
     193             :                   48.0, //diameter
     194             :                 ),
     195             :         ),
     196           0 :         Expanded(
     197           0 :           child: Column(
     198           0 :             children: [
     199           0 :               Visibility(
     200           0 :                 visible: name.isNotEmpty && name != member.onion,
     201           0 :                 child: Container(
     202           0 :                   height: 18.0 * Provider.of<Settings>(context).fontScaling + 18.0,
     203             :                   clipBehavior: Clip.hardEdge,
     204           0 :                   decoration: BoxDecoration(),
     205           0 :                   padding: EdgeInsets.fromLTRB(0, 5, 0, 0),
     206           0 :                   child: Text(
     207             :                     name,
     208             :                     semanticsLabel: name,
     209           0 :                     style: TextStyle(fontFamily: "Inter", fontSize: 18.0 * Provider.of<Settings>(context).fontScaling, fontWeight: FontWeight.bold),
     210             :                     softWrap: true,
     211             :                     overflow: TextOverflow.ellipsis,
     212             :                   ),
     213             :                 ),
     214             :               ),
     215           0 :               Visibility(
     216           0 :                 visible: !Provider.of<Settings>(context).streamerMode,
     217           0 :                 child: Container(
     218             :                   clipBehavior: Clip.hardEdge,
     219           0 :                   decoration: BoxDecoration(),
     220           0 :                   height: 14.0 * Provider.of<Settings>(context).fontScaling + 14.0,
     221           0 :                   padding: EdgeInsets.fromLTRB(0, 0, 0, 0),
     222           0 :                   child: ExcludeSemantics(
     223           0 :                     child: Text(
     224           0 :                       member.onion,
     225             :                       softWrap: true,
     226           0 :                       style: TextStyle(
     227             :                         fontFamily: "RobotoMono",
     228           0 :                         fontSize: 14.0 * Provider.of<Settings>(context).fontScaling,
     229           0 :                         color: ((Provider.of<Settings>(context).theme.mainTextColor) as Color).withOpacity(0.8),
     230             :                       ),
     231             :                       overflow: TextOverflow.ellipsis,
     232             :                     ),
     233             :                   ),
     234             :                 ),
     235             :               ),
     236             :             ],
     237             :           ),
     238             :         ),
     239           0 :         Flexible(
     240             :           flex: 1,
     241           0 :           child: Row(
     242             :             mainAxisAlignment: MainAxisAlignment.end,
     243           0 :             children: [
     244           0 :               Visibility(visible: member.role != "blocked", child: _changeRoleMenu(member.onion, member.role)),
     245           0 :               Visibility(
     246           0 :                 visible: member.role != "blocked" && CanKick(member.role),
     247           0 :                 child: IconButton(
     248             :                   enableFeedback: true,
     249           0 :                   splashRadius: Material.defaultSplashRadius / 2,
     250           0 :                   tooltip: AppLocalizations.of(context)!.buttonKick,
     251           0 :                   icon: Icon(CwtchIcons.block_peer, color: Provider.of<Settings>(context).current().mainTextColor),
     252             :                   onPressed: pending
     253             :                       ? null
     254           0 :                       : () {
     255           0 :                           _kickPressed(member.onion);
     256             :                         },
     257             :                 ),
     258             :               ),
     259             :             ],
     260             :           ),
     261             :         ),
     262             :       ],
     263             :     );
     264             :   }
     265             : 
     266           0 :   Widget _changeRoleMenu(String onion, String currentRole) {
     267           0 :     String? myRole = MyRole();
     268           0 :     return PopupMenuButton<RolesMenu>(
     269             :       enabled: true,
     270           0 :       icon: GroupMember.roleIcon(currentRole, 32.0, fgColor: Provider.of<Settings>(context, listen: false).theme.mainTextColor),
     271           0 :       tooltip: AppLocalizations.of(context)!.buttonChangeRole,
     272           0 :       splashRadius: Material.defaultSplashRadius / 2,
     273           0 :       onSelected: (RolesMenu item) {
     274             :         switch (item) {
     275           0 :           case RolesMenu.admin:
     276           0 :             _changeRolePressed(onion, 'admin');
     277             :             break;
     278           0 :           case RolesMenu.op:
     279           0 :             _changeRolePressed(onion, 'op');
     280             :             break;
     281           0 :           case RolesMenu.mod:
     282           0 :             _changeRolePressed(onion, 'mod');
     283             :             break;
     284           0 :           case RolesMenu.voice:
     285           0 :             _changeRolePressed(onion, 'voice');
     286             :             break;
     287           0 :           case RolesMenu.user:
     288           0 :             _changeRolePressed(onion, 'user');
     289             :             break;
     290             :           default:
     291             :             break;
     292             :         }
     293             :       },
     294           0 :       itemBuilder: (BuildContext context) => <PopupMenuEntry<RolesMenu>>[
     295           0 :         _popupItem(myRole, currentRole, 'admin', RolesMenu.admin),
     296           0 :         _popupItem(myRole, currentRole, 'op', RolesMenu.op),
     297           0 :         _popupItem(myRole, currentRole, 'mod', RolesMenu.mod),
     298           0 :         _popupItem(myRole, currentRole, 'voice', RolesMenu.voice),
     299           0 :         _popupItem(myRole, currentRole, 'user', RolesMenu.user),
     300             :       ],
     301             :     );
     302             :   }
     303             : 
     304           0 :   PopupMenuItem<RolesMenu> _popupItem(String? myRole, String? currentRole, String? targetRole, RolesMenu targetRoleEnum) {
     305           0 :     return PopupMenuItem<RolesMenu>(
     306           0 :       enabled: CanChangeRole(myRole, currentRole, targetRole),
     307             :       value: targetRoleEnum,
     308           0 :       child: Row(
     309             :         spacing: 12.0,
     310           0 :         children: [
     311           0 :           GroupMember.roleIcon(targetRole, 24.0, fgColor: Provider.of<Settings>(context, listen: false).theme.mainTextColor),
     312           0 :           Text(GroupMember.roleL10n(context, targetRole), style: Provider.of<Settings>(context, listen: false).scaleFonts(defaultTextButtonStyle)),
     313             :         ],
     314             :       ),
     315             :     );
     316             :   }
     317             : 
     318           0 :   Widget _addMemberBox() {
     319           0 :     return Visibility(
     320           0 :       visible: CanInvite(),
     321           0 :       child: Column(
     322           0 :         children: [
     323           0 :           CwtchLabel(label: AppLocalizations.of(context)!.addMemberLabel),
     324           0 :           Visibility(visible: Provider.of<ContactInfoState>(context).modeLine.contains("o"), child: Text(AppLocalizations.of(context)!.explainOpenGroup)),
     325           0 :           _addMemberControl(Provider.of<ContactInfoState>(context).members),
     326             :         ],
     327             :       ),
     328             :     );
     329             :   }
     330             : 
     331           0 :   Widget _addMemberControl(List<GroupMember> members) {
     332           0 :     bool pending = Provider.of<ContactInfoState>(context).isOpPending;
     333           0 :     return Column(
     334           0 :       children: [
     335           0 :         DropdownContacts(
     336           0 :           filter: (contact) {
     337           0 :             return (!members.any((x) => x.onion == contact.onion) || members.any((x) => x.role == "blocked")) && !contact.isManaged && !contact.isGroup;
     338             :           },
     339           0 :           onChanged: (newVal) {
     340           0 :             setState(() {
     341           0 :               this.selectedContact = newVal;
     342             :             });
     343             :           },
     344             :         ),
     345           0 :         ElevatedButton(
     346           0 :           key: Key("addGroupMemberBtn"),
     347           0 :           onPressed: pending || this.selectedContact == "" ? null : _addPressed,
     348           0 :           style: ElevatedButton.styleFrom(
     349           0 :             minimumSize: Size(400, 75),
     350           0 :             maximumSize: Size(800, 75),
     351           0 :             shape: RoundedRectangleBorder(
     352           0 :               borderRadius: BorderRadius.horizontal(left: Radius.circular(180), right: Radius.circular(180)),
     353             :             ),
     354             :           ),
     355           0 :           child: Text(AppLocalizations.of(context)!.addMemberButton, textAlign: TextAlign.center),
     356             :         ),
     357             :       ],
     358             :     );
     359             :   }
     360             : 
     361           0 :   void _addPressed() {
     362           0 :     setState(() {
     363           0 :       Provider.of<ContactInfoState>(context, listen: false).isOpPending = true;
     364           0 :       String handle = this.selectedContact;
     365           0 :       this.selectedContact = "";
     366             : 
     367           0 :       Provider.of<FlwtchState>(
     368           0 :         context,
     369             :         listen: false,
     370           0 :       ).cwtch.InviteMember(Provider.of<ProfileInfoState>(context, listen: false).onion, Provider.of<ContactInfoState>(context, listen: false).identifier, handle);
     371             :     });
     372             :   }
     373             : 
     374           0 :   void _kickPressed(String onion) {
     375           0 :     Provider.of<ContactInfoState>(context, listen: false).isOpPending = true;
     376           0 :     Provider.of<FlwtchState>(
     377           0 :       context,
     378             :       listen: false,
     379           0 :     ).cwtch.KickMember(Provider.of<ProfileInfoState>(context, listen: false).onion, Provider.of<ContactInfoState>(context, listen: false).identifier, onion);
     380             :   }
     381             : 
     382           0 :   void _changeRolePressed(String onion, String newRole) {
     383           0 :     Provider.of<ContactInfoState>(context, listen: false).isOpPending = true;
     384           0 :     Provider.of<FlwtchState>(
     385           0 :       context,
     386             :       listen: false,
     387           0 :     ).cwtch.ChangeRole(Provider.of<ProfileInfoState>(context, listen: false).onion, Provider.of<ContactInfoState>(context, listen: false).identifier, onion, newRole);
     388             :   }
     389             : 
     390           0 :   String? MyRole() {
     391           0 :     return Provider.of<ContactInfoState>(context).members.firstWhere((x) => x.onion == Provider.of<ProfileInfoState>(context, listen: false).onion).role;
     392             :   }
     393             : 
     394           0 :   bool CanInvite() {
     395           0 :     return Provider.of<ContactInfoState>(context).isOnline() &&
     396           0 :         (['admin', 'op'].contains(MyRole()) || Provider.of<ContactInfoState>(context).modeLine.contains('i') || Provider.of<ContactInfoState>(context).modeLine.contains('o'));
     397             :   }
     398             : 
     399           0 :   bool CanChangeRole(String? myRole, String? currentRole, String? targetRole) {
     400           0 :     if (currentRole == targetRole) {
     401             :       return false;
     402             :     }
     403             : 
     404           0 :     if (currentRole == 'admin') {
     405             :       // backend forbids demoting the last admin
     406           0 :       if (Provider.of<ContactInfoState>(context, listen: false).members.where((x) => x.role == 'admin').length == 1) {
     407             :         return false;
     408             :       }
     409             :     }
     410             : 
     411           0 :     return Provider.of<ContactInfoState>(context, listen: false).isOnline() &&
     412           0 :         GroupMember.isAbleToPromoteTo(myRole, currentRole, targetRole, Provider.of<ContactInfoState>(context, listen: false).modeLine);
     413             :   }
     414             : 
     415           0 :   bool CanKick(String? currentRole) {
     416           0 :     if (currentRole == 'admin' && Provider.of<ContactInfoState>(context, listen: false).getMembersOfRole('admin').length == 1) {
     417             :       // we don't allow kicking the last admin
     418             :       return false;
     419             :     }
     420           0 :     return Provider.of<ContactInfoState>(context).isOnline() && (['admin', 'op', 'mod'].contains(MyRole()));
     421             :   }
     422             : }

Generated by: LCOV version 1.14