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 : class EditGroupView extends StatefulWidget {
26 0 : @override
27 0 : _EditGroupViewState createState() => _EditGroupViewState();
28 : }
29 :
30 : class _EditGroupViewState extends State<EditGroupView> {
31 : bool maskControlsOpen = false;
32 :
33 0 : @override
34 : void dispose() {
35 0 : super.dispose();
36 : }
37 :
38 : final ctrlrNick = TextEditingController(text: "");
39 : ScrollController metaScrollController = ScrollController();
40 : ScrollController modeScrollController = ScrollController();
41 : ScrollController maskScrollController = ScrollController();
42 : final ctrlrRemoteName = TextEditingController(text: "");
43 : final ctrlrAttr1 = TextEditingController(text: "");
44 : final ctrlrAttr2 = TextEditingController(text: "");
45 : final ctrlrAttr3 = TextEditingController(text: "");
46 :
47 0 : @override
48 : void initState() {
49 0 : super.initState();
50 0 : final nickname = Provider.of<ContactInfoState>(context, listen: false).nickname;
51 0 : if (nickname.isNotEmpty) {
52 0 : ctrlrNick.text = nickname;
53 : }
54 0 : if (Provider.of<ContactInfoState>(context, listen: false).remoteNickname != null) {
55 0 : ctrlrRemoteName.text = Provider.of<ContactInfoState>(context, listen: false).remoteNickname!;
56 : }
57 0 : if (Provider.of<ContactInfoState>(context, listen: false).attributes[0] != null) {
58 0 : ctrlrAttr1.text = Provider.of<ContactInfoState>(context, listen: false).attributes[0]!;
59 : }
60 0 : if (Provider.of<ContactInfoState>(context, listen: false).attributes[1] != null) {
61 0 : ctrlrAttr2.text = Provider.of<ContactInfoState>(context, listen: false).attributes[1]!;
62 : }
63 0 : if (Provider.of<ContactInfoState>(context, listen: false).attributes[2] != null) {
64 0 : ctrlrAttr3.text = Provider.of<ContactInfoState>(context, listen: false).attributes[2]!;
65 : }
66 : }
67 :
68 0 : @override
69 : Widget build(BuildContext context) {
70 0 : var handle = Provider.of<ContactInfoState>(context).nickname;
71 0 : if (handle.isEmpty) {
72 0 : handle = Provider.of<ContactInfoState>(context).onion;
73 : }
74 0 : return DefaultTabController(
75 : length: 3,
76 0 : child: Scaffold(
77 0 : appBar: AppBar(
78 0 : title: Container(height: Provider.of<Settings>(context).fontScaling * 24.0, clipBehavior: Clip.hardEdge, decoration: BoxDecoration(), child: Text(handle + " " + "t9n(Edit Group)")),
79 0 : bottom: TabBar(
80 : isScrollable: true,
81 0 : tabs: [
82 0 : Tab(key: Key("OpenGroupMetadata"), icon: Icon(CwtchIcons.edit_24px), text: "t9n(Metadata)"),
83 0 : Tab(key: Key("OpenGroupMode"), icon: Icon(CwtchIcons.anti_spam_1), text: "t9n(Mode)"),
84 0 : Tab(key: Key("OpenGroupMask"), icon: Icon(CwtchIcons.member_mod), text: "t9n(Mask)"),
85 : ],
86 : ),
87 : ),
88 0 : body: _buildGroupEditor(),
89 : ),
90 : );
91 : }
92 :
93 0 : Widget _buildGroupEditor() {
94 0 : return Consumer<Settings>(
95 0 : builder: (context, settings, child) {
96 0 : return LayoutBuilder(
97 0 : builder: (BuildContext context, BoxConstraints viewportConstraints) {
98 0 : return TabBarView(
99 0 : children: [
100 0 : _tabHelper(_buildMetaTab(settings), "EditGroupMeta", this.metaScrollController),
101 0 : _tabHelper(_buildModeTab(settings), "EditGroupMode", this.modeScrollController),
102 0 : _tabHelper(_buildMaskTab(settings), "EditGroupMask", this.maskScrollController),
103 : ],
104 : );
105 : },
106 : );
107 : },
108 : );
109 : }
110 :
111 0 : Widget _tabHelper(Widget tab, String scrollKey, ScrollController sc) {
112 0 : return Consumer<Settings>(
113 0 : builder: (ccontext, settings, child) {
114 0 : return LayoutBuilder(
115 0 : builder: (BuildContext context, BoxConstraints viewportConstraints) {
116 0 : return Scrollbar(
117 0 : key: Key(scrollKey),
118 : trackVisibility: true,
119 : controller: sc,
120 0 : child: SingleChildScrollView(
121 : clipBehavior: Clip.antiAlias,
122 : controller: sc,
123 0 : child: ConstrainedBox(
124 0 : constraints: BoxConstraints(minHeight: viewportConstraints.maxHeight, maxWidth: viewportConstraints.maxWidth),
125 0 : child: Material(
126 0 : color: settings.theme.backgroundPaneColor,
127 0 : child: Container(width: viewportConstraints.maxWidth / 2, margin: EdgeInsetsGeometry.all(16.0), child: tab),
128 : ),
129 : ),
130 : ),
131 : );
132 : },
133 : );
134 : },
135 : );
136 : }
137 :
138 0 : Widget _buildMetaTab(Settings settings) {
139 0 : bool pending = Provider.of<ContactInfoState>(context).isOpPending;
140 0 : bool isOpen = Provider.of<ContactInfoState>(context).modeLine.contains('o');
141 :
142 0 : return Column(
143 : mainAxisAlignment: MainAxisAlignment.start,
144 : crossAxisAlignment: CrossAxisAlignment.center,
145 0 : children: [
146 0 : Column(
147 : crossAxisAlignment: CrossAxisAlignment.center,
148 0 : children: [
149 0 : _buildGroupPictureBox(),
150 0 : SizedBox(width: MediaQuery.of(context).size.width / 2, child: !pending && CanUpdateMetadata() ? _buildMetadataEditor() : _buildMetadataViewer()),
151 : ],
152 : ),
153 :
154 0 : Column(
155 : mainAxisAlignment: MainAxisAlignment.start,
156 : crossAxisAlignment: CrossAxisAlignment.start,
157 0 : children: [
158 0 : CwtchLabel(label: "t9n(Display name (local only))"),
159 0 : SizedBox(height: 20),
160 0 : CwtchButtonTextField(
161 0 : controller: ctrlrNick,
162 : readonly: false,
163 0 : onPressed: () {
164 0 : var profileOnion = Provider.of<ContactInfoState>(context, listen: false).profileOnion;
165 0 : var conversation = Provider.of<ContactInfoState>(context, listen: false).identifier;
166 0 : Provider.of<ContactInfoState>(context, listen: false).localNickname = ctrlrNick.text;
167 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.SetConversationAttribute(profileOnion, conversation, "profile.name", ctrlrNick.text);
168 0 : final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.nickChangeSuccess));
169 0 : ScaffoldMessenger.of(context).showSnackBar(snackBar);
170 : },
171 0 : icon: Icon(Icons.save),
172 0 : tooltip: AppLocalizations.of(context)!.saveBtn,
173 : ),
174 : ],
175 : ),
176 :
177 0 : SizedBox(height: 20),
178 0 : CwtchLabel(label: AppLocalizations.of(context)!.addressLabel),
179 0 : SizedBox(height: 20),
180 :
181 : // Address Copy Button
182 0 : Visibility(
183 0 : visible: settings.streamerMode == false && isOpen,
184 0 : child: Column(
185 : mainAxisAlignment: MainAxisAlignment.start,
186 : crossAxisAlignment: CrossAxisAlignment.start,
187 0 : children: [
188 0 : Text("t9n(Anyone can join this group by adding this group address as a contact.)"),
189 0 : CwtchButtonTextField(
190 0 : controller: TextEditingController(text: "grwp-" + Provider.of<ContactInfoState>(context, listen: false).onion),
191 0 : onPressed: isOpen ? _copyOnion : null,
192 0 : icon: Icon(CwtchIcons.copy_address),
193 0 : tooltip: AppLocalizations.of(context)!.copyBtn,
194 : ),
195 : ],
196 : ),
197 : ),
198 0 : Visibility(visible: !isOpen, child: Text("t9n(New members must be added to this group via the Group Membership controls.)")),
199 : ],
200 : );
201 : }
202 :
203 0 : Widget _buildModeTab(Settings settings) {
204 0 : return Column(
205 : mainAxisAlignment: MainAxisAlignment.start,
206 : crossAxisAlignment: CrossAxisAlignment.start,
207 0 : children: [_buildModeBox(Provider.of<ContactInfoState>(context).modeLine, Provider.of<ContactInfoState>(context).modeMask)],
208 : );
209 : }
210 :
211 0 : Widget _buildMaskTab(Settings settings) {
212 0 : return Column(mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start, children: [_buildModeMaskBox(Provider.of<ContactInfoState>(context).modeMask)]);
213 : }
214 :
215 0 : void _copyOnion() {
216 0 : Clipboard.setData(new ClipboardData(text: "grwp-" + Provider.of<ContactInfoState>(context, listen: false).onion));
217 0 : final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.copiedToClipboardNotification));
218 0 : ScaffoldMessenger.of(context).showSnackBar(snackBar);
219 : }
220 :
221 0 : Widget _buildGroupPictureBox() {
222 0 : return Consumer<Settings>(
223 0 : builder: (context, theme, child) {
224 : var isStatic =
225 0 : Provider.of<AppState>(context, listen: false).disableFilePicker ||
226 0 : !Provider.of<Settings>(context, listen: false).isExperimentEnabled(ImagePreviewsExperiment) ||
227 0 : !CanUpdateMetadata() ||
228 0 : Provider.of<ContactInfoState>(context, listen: false).isOpPending;
229 :
230 0 : return MouseRegion(
231 : cursor: !isStatic ? SystemMouseCursors.click : SystemMouseCursors.basic,
232 0 : child: GestureDetector(
233 : // don't allow setting of images if the image previews experiment is disabled.
234 : onTap: isStatic
235 : ? null
236 0 : : () {
237 0 : filesharing.showFilePicker(
238 : context,
239 : MaxImageFileSharingSize,
240 0 : (File file) {
241 0 : Provider.of<FlwtchState>(
242 : context,
243 : listen: false,
244 0 : ).cwtch.UpdateGroupPicture(Provider.of<ProfileInfoState>(context, listen: false).onion, Provider.of<ContactInfoState>(context, listen: false).identifier, file.path);
245 : // update the image cache locally
246 : // TODO: should put a spinner here instead, so the user knows something is happening while it syncs
247 : // Provider.of<ProfileInfoState>(context, listen: false).imagePath = file.path;
248 : },
249 0 : () {
250 0 : final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.msgFileTooBig), duration: Duration(seconds: 4));
251 0 : ScaffoldMessenger.of(context).showSnackBar(snackBar);
252 : },
253 0 : () {},
254 : );
255 : },
256 0 : child: ProfileImage(
257 0 : imagePath: Provider.of<Settings>(context).isExperimentEnabled(ImagePreviewsExperiment)
258 0 : ? Provider.of<ContactInfoState>(context).imagePath
259 0 : : Provider.of<ContactInfoState>(context).defaultImagePath,
260 : diameter: 120,
261 : tooltip: isStatic ? "" : "t9n(Change group picture)",
262 : maskOut: false,
263 0 : border: theme.theme.portraitOnlineBorderColor,
264 0 : badgeTextColor: theme.theme.portraitContactBadgeTextColor,
265 0 : badgeColor: theme.theme.portraitContactBadgeColor,
266 : badgeEdit: !isStatic,
267 : ),
268 : ),
269 : );
270 : },
271 : );
272 : }
273 :
274 0 : Widget _buildMetadataViewer() {
275 0 : return Column(
276 0 : children: [
277 0 : Padding(
278 0 : padding: EdgeInsets.all(1),
279 0 : child: CwtchLabel(label: Provider.of<ContactInfoState>(context).remoteNickname ?? ""),
280 : ),
281 0 : Padding(
282 0 : padding: EdgeInsets.all(1),
283 0 : child: SelectableText(Provider.of<ContactInfoState>(context).attributes[0] ?? "", textAlign: TextAlign.center),
284 : ),
285 0 : Padding(
286 0 : padding: EdgeInsets.all(1),
287 0 : child: SelectableText(Provider.of<ContactInfoState>(context).attributes[1] ?? "", textAlign: TextAlign.center),
288 : ),
289 0 : Padding(
290 0 : padding: EdgeInsets.all(1),
291 0 : child: SelectableText(Provider.of<ContactInfoState>(context).attributes[2] ?? "", textAlign: TextAlign.center),
292 : ),
293 : ],
294 : );
295 : }
296 :
297 0 : Widget _buildMetadataEditor() {
298 0 : return Column(
299 0 : children: [
300 0 : CwtchLabel(label: "t9n(Group name (public))"),
301 0 : Padding(
302 0 : padding: EdgeInsets.all(5.0),
303 0 : child: CwtchButtonTextField(controller: ctrlrRemoteName, readonly: false, onPressed: _saveMetadataPressed, icon: Icon(Icons.save), tooltip: AppLocalizations.of(context)!.saveBtn),
304 : ),
305 0 : CwtchLabel(label: "t9n(Group topic)"),
306 0 : Padding(
307 0 : padding: EdgeInsets.all(5.0),
308 0 : child: CwtchButtonTextField(controller: ctrlrAttr1, readonly: false, onPressed: _saveMetadataPressed, icon: Icon(Icons.save), tooltip: AppLocalizations.of(context)!.saveBtn),
309 : ),
310 0 : CwtchLabel(label: "t9n(Extra public information 1)"),
311 0 : Padding(
312 0 : padding: EdgeInsets.all(5.0),
313 0 : child: CwtchButtonTextField(controller: ctrlrAttr2, readonly: false, onPressed: _saveMetadataPressed, icon: Icon(Icons.save), tooltip: AppLocalizations.of(context)!.saveBtn),
314 : ),
315 0 : CwtchLabel(label: "t9n(Extra public information 2)"),
316 0 : Padding(
317 0 : padding: EdgeInsets.all(5.0),
318 0 : child: CwtchButtonTextField(controller: ctrlrAttr3, readonly: false, onPressed: _saveMetadataPressed, icon: Icon(Icons.save), tooltip: AppLocalizations.of(context)!.saveBtn),
319 : ),
320 : ],
321 : );
322 : }
323 :
324 0 : void _saveMetadataPressed() {
325 0 : Provider.of<ContactInfoState>(context, listen: false).isOpPending = true;
326 0 : Provider.of<FlwtchState>(context, listen: false).cwtch.UpdateGroupMetadata(
327 0 : Provider.of<ContactInfoState>(context, listen: false).profileOnion,
328 0 : Provider.of<ContactInfoState>(context, listen: false).identifier,
329 0 : ctrlrRemoteName.text,
330 0 : ctrlrAttr1.text,
331 0 : ctrlrAttr2.text,
332 0 : ctrlrAttr3.text,
333 : );
334 : //final snackBar = SnackBar(content: Text(AppLocalizations.of(context)!.nickChangeSuccess));
335 : //ScaffoldMessenger.of(context).showSnackBar(snackBar);
336 : }
337 :
338 0 : Widget _buildModeBox(String mode, String mask) {
339 0 : return Column(
340 0 : children: [
341 0 : CwtchLabel(label: "t9n(Group mode)" + ": +" + mode),
342 0 : _buildModeBoxEntry("t9n(filesharing allowed)", mode, "f", mask, false),
343 0 : _buildModeBoxEntry("t9n(slow mode)", mode, "s", mask, false),
344 0 : _buildModeBoxEntry("t9n(anyone can invite)", mode, "i", mask, false),
345 0 : _buildModeBoxEntry("t9n(open group (invites not required))", mode, "o", mask, false),
346 0 : _buildModeBoxEntry("t9n(voice-only)", mode, "v", mask, false),
347 0 : _buildModeBoxEntry("t9n(hide-by-default)", mode, "m", mask, false),
348 0 : _buildModeBoxEntry("t9n(write-only (no-read))", mode, "w", mask, false),
349 0 : _buildModeBoxEntry("t9n(equality mode)", mode, "e", mask, false),
350 0 : Text("* These modes can be changed by moderators (see: Mask tab)."),
351 : ],
352 : );
353 : }
354 :
355 0 : Widget _buildModeMaskBox(String mask) {
356 0 : return Column(
357 0 : children: [
358 0 : CwtchLabel(label: "t9n(Group mask)" + ": +" + mask),
359 0 : Text(
360 : "t9n(Normally, only admins and ops can change group mode settings. However, by enabling mode masks below, moderators can also toggle those group modes. For example, you might want to allow mods to toggle slow mode for the group.)",
361 : ),
362 0 : _buildModeBoxEntry("t9n(filesharing allowed)", "", "f", mask, true),
363 0 : _buildModeBoxEntry("t9n(slow mode)", "", "s", mask, true),
364 0 : _buildModeBoxEntry("t9n(anyone can invite)", "", "i", mask, true),
365 0 : _buildModeBoxEntry("t9n(open group (invites not required))", "", "o", mask, true),
366 0 : _buildModeBoxEntry("t9n(voice-only)", "", "v", mask, true),
367 0 : _buildModeBoxEntry("t9n(hide-by-default)", "", "m", mask, true),
368 0 : _buildModeBoxEntry("t9n(write-only (no-read))", "", "w", mask, true),
369 0 : _buildModeBoxEntry("t9n(equality mode)", "", "e", mask, true),
370 : ],
371 : );
372 : }
373 :
374 0 : Widget _buildModeBoxEntry(String label, String modeLine, String modeBit, String modeMask, bool isMaskBox) {
375 : String checkStr = isMaskBox ? modeMask : modeLine;
376 0 : bool pending = Provider.of<ContactInfoState>(context).isOpPending;
377 :
378 0 : return SwitchListTile(
379 0 : title: Text(label + (!isMaskBox && modeMask.contains(modeBit) ? "*" : ""), style: TextStyle(color: Provider.of<Settings>(context).current().mainTextColor)),
380 0 : subtitle: Text(_descriptionOf(modeBit)),
381 0 : value: checkStr.contains(modeBit),
382 0 : onChanged: !CanToggleMode(modeBit, isMaskBox) || pending
383 : ? null
384 0 : : (bool newVal) {
385 0 : _toggleModePressed(modeBit, newVal, isMaskBox);
386 : },
387 0 : activeTrackColor: Provider.of<Settings>(context).theme.defaultButtonColor,
388 0 : inactiveTrackColor: Provider.of<Settings>(context).theme.defaultButtonDisabledColor,
389 0 : secondary: _iconFor(modeBit),
390 : );
391 : }
392 :
393 0 : void _toggleModePressed(String modeBit, bool enable, bool isMaskBox) {
394 0 : Provider.of<ContactInfoState>(context, listen: false).isOpPending = true;
395 : if (isMaskBox) {
396 0 : Provider.of<FlwtchState>(
397 0 : context,
398 : listen: false,
399 0 : ).cwtch.ChangeGroupMask(Provider.of<ProfileInfoState>(context, listen: false).onion, Provider.of<ContactInfoState>(context, listen: false).identifier, (enable ? "+" : "-") + modeBit);
400 : } else {
401 0 : Provider.of<FlwtchState>(
402 0 : context,
403 : listen: false,
404 0 : ).cwtch.ChangeGroupMode(Provider.of<ProfileInfoState>(context, listen: false).onion, Provider.of<ContactInfoState>(context, listen: false).identifier, (enable ? "+" : "-") + modeBit);
405 : }
406 : }
407 :
408 0 : String? MyRole() {
409 0 : return Provider.of<ContactInfoState>(context).members.firstWhere((x) => x.onion == Provider.of<ProfileInfoState>(context, listen: false).onion).role;
410 : }
411 :
412 0 : bool CanUpdateMetadata() {
413 0 : return Provider.of<ContactInfoState>(context).isOnline() && ['admin', 'op'].contains(MyRole());
414 : }
415 :
416 0 : bool CanToggleMode(String modeBit, bool isMaskBox) {
417 : if (isMaskBox) {
418 0 : return Provider.of<ContactInfoState>(context).isOnline() && ['admin', 'op'].contains(MyRole());
419 : } else {
420 0 : return Provider.of<ContactInfoState>(context).isOnline() && (['admin', 'op'].contains(MyRole()) || (MyRole() == 'mod' && Provider.of<ContactInfoState>(context).modeMask.contains(modeBit)));
421 : }
422 : }
423 :
424 0 : Icon _iconFor(String modeBit) {
425 : switch (modeBit) {
426 0 : case 'f':
427 0 : return Icon(CwtchIcons.attach_file_24px, color: Provider.of<Settings>(context).current().mainTextColor);
428 0 : case 's':
429 0 : return Icon(Icons.hourglass_empty, color: Provider.of<Settings>(context).current().mainTextColor);
430 0 : case 'i':
431 0 : return Icon(CwtchIcons.send_invitation, color: Provider.of<Settings>(context).current().mainTextColor);
432 0 : case 'o':
433 0 : return Icon(CwtchIcons.person_add_alt_1_24px, color: Provider.of<Settings>(context).current().mainTextColor);
434 0 : case 'v':
435 0 : return Icon(CwtchIcons.member_voice, color: Provider.of<Settings>(context).current().mainTextColor);
436 0 : case 'm':
437 0 : return Icon(CwtchIcons.eye_closed, color: Provider.of<Settings>(context).current().mainTextColor);
438 0 : case 'w':
439 0 : return Icon(Icons.how_to_vote, color: Provider.of<Settings>(context).current().mainTextColor);
440 0 : case 'e':
441 0 : return Icon(CwtchIcons.swap_horiz, color: Provider.of<Settings>(context).current().mainTextColor);
442 : default:
443 0 : return Icon(CwtchIcons.block_peer, color: Provider.of<Settings>(context).current().mainTextColor);
444 : }
445 : }
446 :
447 0 : String _descriptionOf(String modeBit) {
448 : switch (modeBit) {
449 0 : case 'f':
450 : return "t9n(Allows members to share files with the group (if they have the file sharing experiment enabled).)";
451 0 : case 's':
452 : return "t9n(Restricts group members to sending one message every 60 seconds.)";
453 0 : case 'i':
454 : return "t9n(Anyone existing member can add new members to the group, instead of just admins/ops.)";
455 0 : case 'o':
456 : return "t9n(Anyone can join the group without an invitation, if they have the invitation string (see conversation settings).)";
457 0 : case 'v':
458 : return "t9n(Only users with the voice role or higher can send messages to the group.)";
459 0 : case 'm':
460 : return "t9n(All messages to the channel are hidden. Moderators or above can review the contents of messages and decide whether to reveal them.)";
461 0 : case 'w':
462 : return "t9n(Messages can be sent to the channel, but only moderators or above will receive them. Use this, for example, to make a channel for privately sending requests or reporting sensitive issues.)";
463 0 : case 'e':
464 : return "t9n(Normally, users can only promote/demote to roles below their own. With +e enabled, ops can promote/demote other ops, and mods can promote/demote other mods.)";
465 : default:
466 : return '';
467 : }
468 : }
469 : }
|