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

Generated by: LCOV version 1.14