address_book.dart 29 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864
  1. import 'dart:math';
  2. import 'package:dropdown_button2/dropdown_button2.dart';
  3. import 'package:dynamic_layouts/dynamic_layouts.dart';
  4. import 'package:flutter/material.dart';
  5. import 'package:flutter_hbb/common/formatter/id_formatter.dart';
  6. import 'package:flutter_hbb/common/hbbs/hbbs.dart';
  7. import 'package:flutter_hbb/common/widgets/peer_card.dart';
  8. import 'package:flutter_hbb/common/widgets/peers_view.dart';
  9. import 'package:flutter_hbb/consts.dart';
  10. import 'package:flutter_hbb/desktop/widgets/popup_menu.dart';
  11. import 'package:flutter_hbb/models/ab_model.dart';
  12. import 'package:flutter_hbb/models/platform_model.dart';
  13. import 'package:flutter_hbb/models/state_model.dart';
  14. import 'package:url_launcher/url_launcher_string.dart';
  15. import '../../desktop/widgets/material_mod_popup_menu.dart' as mod_menu;
  16. import 'package:get/get.dart';
  17. import 'package:flex_color_picker/flex_color_picker.dart';
  18. import '../../common.dart';
  19. import 'dialog.dart';
  20. import 'login.dart';
  21. final hideAbTagsPanel = false.obs;
  22. class AddressBook extends StatefulWidget {
  23. final EdgeInsets? menuPadding;
  24. const AddressBook({Key? key, this.menuPadding}) : super(key: key);
  25. @override
  26. State<StatefulWidget> createState() {
  27. return _AddressBookState();
  28. }
  29. }
  30. class _AddressBookState extends State<AddressBook> {
  31. var menuPos = RelativeRect.fill;
  32. @override
  33. Widget build(BuildContext context) => Obx(() {
  34. if (!gFFI.userModel.isLogin) {
  35. return Center(
  36. child: ElevatedButton(
  37. onPressed: loginDialog, child: Text(translate("Login"))));
  38. } else if (gFFI.userModel.networkError.isNotEmpty) {
  39. return netWorkErrorWidget();
  40. } else {
  41. return Column(
  42. children: [
  43. // NOT use Offstage to wrap LinearProgressIndicator
  44. if (gFFI.abModel.currentAbLoading.value &&
  45. gFFI.abModel.currentAbEmpty)
  46. const LinearProgressIndicator(),
  47. buildErrorBanner(context,
  48. loading: gFFI.abModel.currentAbLoading,
  49. err: gFFI.abModel.currentAbPullError,
  50. retry: null,
  51. close: () => gFFI.abModel.currentAbPullError.value = ''),
  52. buildErrorBanner(context,
  53. loading: gFFI.abModel.currentAbLoading,
  54. err: gFFI.abModel.currentAbPushError,
  55. retry: null, // remove retry
  56. close: () => gFFI.abModel.currentAbPushError.value = ''),
  57. Expanded(
  58. child: Obx(() => stateGlobal.isPortrait.isTrue
  59. ? _buildAddressBookPortrait()
  60. : _buildAddressBookLandscape()),
  61. ),
  62. ],
  63. );
  64. }
  65. });
  66. Widget _buildAddressBookLandscape() {
  67. return Row(
  68. children: [
  69. Offstage(
  70. offstage: hideAbTagsPanel.value,
  71. child: Container(
  72. decoration: BoxDecoration(
  73. borderRadius: BorderRadius.circular(12),
  74. border: Border.all(
  75. color: Theme.of(context).colorScheme.background)),
  76. child: Container(
  77. width: 200,
  78. height: double.infinity,
  79. child: Column(
  80. children: [
  81. _buildAbDropdown(),
  82. _buildTagHeader().marginOnly(
  83. left: 8.0,
  84. right: gFFI.abModel.legacyMode.value ? 8.0 : 0,
  85. top: gFFI.abModel.legacyMode.value ? 8.0 : 0),
  86. Expanded(
  87. child: Container(
  88. width: double.infinity,
  89. height: double.infinity,
  90. child: _buildTags(),
  91. ),
  92. ),
  93. _buildAbPermission(),
  94. ],
  95. ),
  96. ),
  97. ).marginOnly(right: 12.0)),
  98. _buildPeersViews()
  99. ],
  100. );
  101. }
  102. Widget _buildAddressBookPortrait() {
  103. const padding = 8.0;
  104. return Column(
  105. children: [
  106. Offstage(
  107. offstage: hideAbTagsPanel.value,
  108. child: Container(
  109. decoration: BoxDecoration(
  110. borderRadius: BorderRadius.circular(6),
  111. border: Border.all(
  112. color: Theme.of(context).colorScheme.background)),
  113. child: Container(
  114. padding:
  115. const EdgeInsets.fromLTRB(padding, 0, padding, padding),
  116. child: Column(
  117. mainAxisSize: MainAxisSize.min,
  118. children: [
  119. _buildAbDropdown(),
  120. _buildTagHeader().marginOnly(left: 8.0, right: 0),
  121. Container(
  122. width: double.infinity,
  123. child: _buildTags(),
  124. ),
  125. ],
  126. ),
  127. ),
  128. ).marginOnly(bottom: 12.0)),
  129. _buildPeersViews()
  130. ],
  131. );
  132. }
  133. Widget _buildAbPermission() {
  134. icon(IconData data, String tooltip) {
  135. return Tooltip(
  136. message: translate(tooltip),
  137. waitDuration: Duration.zero,
  138. child: Icon(data, size: 12.0).marginSymmetric(horizontal: 2.0));
  139. }
  140. return Obx(() {
  141. if (gFFI.abModel.legacyMode.value) return Offstage();
  142. if (gFFI.abModel.current.isPersonal()) {
  143. return Row(
  144. mainAxisAlignment: MainAxisAlignment.end,
  145. children: [
  146. icon(Icons.cloud_off, "Personal"),
  147. ],
  148. );
  149. } else {
  150. List<Widget> children = [];
  151. final rule = gFFI.abModel.current.sharedProfile()?.rule;
  152. if (rule == ShareRule.read.value) {
  153. children.add(
  154. icon(Icons.visibility, ShareRule.desc(ShareRule.read.value)));
  155. } else if (rule == ShareRule.readWrite.value) {
  156. children
  157. .add(icon(Icons.edit, ShareRule.desc(ShareRule.readWrite.value)));
  158. } else if (rule == ShareRule.fullControl.value) {
  159. children.add(icon(
  160. Icons.security, ShareRule.desc(ShareRule.fullControl.value)));
  161. }
  162. final owner = gFFI.abModel.current.sharedProfile()?.owner;
  163. if (owner != null) {
  164. children.add(icon(Icons.person, "${translate("Owner")}: $owner"));
  165. }
  166. return Row(
  167. mainAxisAlignment: MainAxisAlignment.end,
  168. children: children,
  169. );
  170. }
  171. });
  172. }
  173. Widget _buildAbDropdown() {
  174. if (gFFI.abModel.legacyMode.value) {
  175. return Offstage();
  176. }
  177. final names = gFFI.abModel.addressBookNames();
  178. if (!names.contains(gFFI.abModel.currentName.value)) {
  179. return Offstage();
  180. }
  181. // order: personal, divider, character order
  182. // https://pub.dev/packages/dropdown_button2#3-dropdownbutton2-with-items-of-different-heights-like-dividers
  183. final personalAddressBookName = gFFI.abModel.personalAddressBookName();
  184. bool contains = names.remove(personalAddressBookName);
  185. names.sort((a, b) => a.toLowerCase().compareTo(b.toLowerCase()));
  186. if (contains) {
  187. names.insert(0, personalAddressBookName);
  188. }
  189. Row buildItem(String e, {bool button = false}) {
  190. return Row(
  191. children: [
  192. Expanded(
  193. child: Tooltip(
  194. waitDuration: Duration(milliseconds: 500),
  195. message: gFFI.abModel.translatedName(e),
  196. child: Text(
  197. gFFI.abModel.translatedName(e),
  198. style: button ? null : TextStyle(fontSize: 14.0),
  199. maxLines: 1,
  200. overflow: TextOverflow.ellipsis,
  201. textAlign: button ? TextAlign.center : null,
  202. )),
  203. ),
  204. ],
  205. );
  206. }
  207. final items = names
  208. .map((e) => DropdownMenuItem(value: e, child: buildItem(e)))
  209. .toList();
  210. var menuItemStyleData = MenuItemStyleData(height: 36);
  211. if (contains && items.length > 1) {
  212. items.insert(1, DropdownMenuItem(enabled: false, child: Divider()));
  213. List<double> customHeights = List.filled(items.length, 36);
  214. customHeights[1] = 4;
  215. menuItemStyleData = MenuItemStyleData(customHeights: customHeights);
  216. }
  217. final TextEditingController textEditingController = TextEditingController();
  218. final isOptFixed = isOptionFixed(kOptionCurrentAbName);
  219. return DropdownButton2<String>(
  220. value: gFFI.abModel.currentName.value,
  221. onChanged: isOptFixed
  222. ? null
  223. : (value) {
  224. if (value != null) {
  225. gFFI.abModel.setCurrentName(value);
  226. bind.setLocalFlutterOption(k: kOptionCurrentAbName, v: value);
  227. }
  228. },
  229. customButton: Obx(() => Container(
  230. height: stateGlobal.isPortrait.isFalse ? 48 : 40,
  231. child: Row(children: [
  232. Expanded(
  233. child:
  234. buildItem(gFFI.abModel.currentName.value, button: true)),
  235. Icon(Icons.arrow_drop_down),
  236. ]),
  237. )),
  238. underline: Container(
  239. height: 0.7,
  240. color: Theme.of(context).dividerColor.withOpacity(0.1),
  241. ),
  242. menuItemStyleData: menuItemStyleData,
  243. items: items,
  244. isExpanded: true,
  245. isDense: true,
  246. dropdownSearchData: DropdownSearchData(
  247. searchController: textEditingController,
  248. searchInnerWidgetHeight: 50,
  249. searchInnerWidget: Container(
  250. height: 50,
  251. padding: const EdgeInsets.only(
  252. top: 8,
  253. bottom: 4,
  254. right: 8,
  255. left: 8,
  256. ),
  257. child: TextFormField(
  258. expands: true,
  259. maxLines: null,
  260. controller: textEditingController,
  261. decoration: InputDecoration(
  262. isDense: true,
  263. contentPadding: const EdgeInsets.symmetric(
  264. horizontal: 10,
  265. vertical: 8,
  266. ),
  267. hintText: translate('Search'),
  268. hintStyle: const TextStyle(fontSize: 12),
  269. border: OutlineInputBorder(
  270. borderRadius: BorderRadius.circular(8),
  271. ),
  272. ),
  273. ),
  274. ),
  275. searchMatchFn: (item, searchValue) {
  276. return item.value
  277. .toString()
  278. .toLowerCase()
  279. .contains(searchValue.toLowerCase());
  280. },
  281. ),
  282. );
  283. }
  284. Widget _buildTagHeader() {
  285. return Row(
  286. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  287. children: [
  288. Text(translate('Tags')),
  289. Listener(
  290. onPointerDown: (e) {
  291. final x = e.position.dx;
  292. final y = e.position.dy;
  293. menuPos = RelativeRect.fromLTRB(x, y, x, y);
  294. },
  295. onPointerUp: (_) => _showMenu(menuPos),
  296. child: build_more(context, invert: true)),
  297. ],
  298. );
  299. }
  300. Widget _buildTags() {
  301. return Obx(() {
  302. final List tags;
  303. if (gFFI.abModel.sortTags.value) {
  304. tags = gFFI.abModel.currentAbTags.toList();
  305. tags.sort();
  306. } else {
  307. tags = gFFI.abModel.currentAbTags;
  308. }
  309. final editPermission = gFFI.abModel.current.canWrite();
  310. tagBuilder(String e) {
  311. return AddressBookTag(
  312. name: e,
  313. tags: gFFI.abModel.selectedTags,
  314. onTap: () {
  315. if (gFFI.abModel.selectedTags.contains(e)) {
  316. gFFI.abModel.selectedTags.remove(e);
  317. } else {
  318. gFFI.abModel.selectedTags.add(e);
  319. }
  320. },
  321. showActionMenu: editPermission);
  322. }
  323. gridView(bool isPortrait) => DynamicGridView.builder(
  324. shrinkWrap: isPortrait,
  325. gridDelegate: SliverGridDelegateWithWrapping(),
  326. itemCount: tags.length,
  327. itemBuilder: (BuildContext context, int index) {
  328. final e = tags[index];
  329. return tagBuilder(e);
  330. });
  331. final maxHeight = max(MediaQuery.of(context).size.height / 6, 100.0);
  332. return Obx(() => stateGlobal.isPortrait.isFalse
  333. ? gridView(false)
  334. : LimitedBox(maxHeight: maxHeight, child: gridView(true)));
  335. });
  336. }
  337. Widget _buildPeersViews() {
  338. return Expanded(
  339. child: Align(
  340. alignment: Alignment.topLeft,
  341. child: AddressBookPeersView(
  342. menuPadding: widget.menuPadding,
  343. )),
  344. );
  345. }
  346. @protected
  347. MenuEntryBase<String> syncMenuItem() {
  348. final isOptFixed = isOptionFixed(syncAbOption);
  349. return MenuEntrySwitch<String>(
  350. switchType: SwitchType.scheckbox,
  351. text: translate('Sync with recent sessions'),
  352. getter: () async {
  353. return shouldSyncAb();
  354. },
  355. setter: (bool v) async {
  356. gFFI.abModel.setShouldAsync(v);
  357. },
  358. dismissOnClicked: true,
  359. enabled: (!isOptFixed).obs,
  360. );
  361. }
  362. @protected
  363. MenuEntryBase<String> sortMenuItem() {
  364. final isOptFixed = isOptionFixed(sortAbTagsOption);
  365. return MenuEntrySwitch<String>(
  366. switchType: SwitchType.scheckbox,
  367. text: translate('Sort tags'),
  368. getter: () async {
  369. return shouldSortTags();
  370. },
  371. setter: (bool v) async {
  372. bind.mainSetLocalOption(
  373. key: sortAbTagsOption, value: v ? 'Y' : defaultOptionNo);
  374. gFFI.abModel.sortTags.value = v;
  375. },
  376. dismissOnClicked: true,
  377. enabled: (!isOptFixed).obs,
  378. );
  379. }
  380. @protected
  381. MenuEntryBase<String> filterMenuItem() {
  382. final isOptFixed = isOptionFixed(filterAbTagOption);
  383. return MenuEntrySwitch<String>(
  384. switchType: SwitchType.scheckbox,
  385. text: translate('Filter by intersection'),
  386. getter: () async {
  387. return filterAbTagByIntersection();
  388. },
  389. setter: (bool v) async {
  390. bind.mainSetLocalOption(
  391. key: filterAbTagOption, value: v ? 'Y' : defaultOptionNo);
  392. gFFI.abModel.filterByIntersection.value = v;
  393. },
  394. dismissOnClicked: true,
  395. enabled: (!isOptFixed).obs,
  396. );
  397. }
  398. void _showMenu(RelativeRect pos) {
  399. final canWrite = gFFI.abModel.current.canWrite();
  400. final items = [
  401. if (canWrite) getEntry(translate("Add ID"), addIdToCurrentAb),
  402. if (canWrite) getEntry(translate("Add Tag"), abAddTag),
  403. getEntry(translate("Unselect all tags"), gFFI.abModel.unsetSelectedTags),
  404. if (gFFI.abModel.legacyMode.value)
  405. sortMenuItem(), // It's already sorted after pulling down
  406. if (canWrite) syncMenuItem(),
  407. filterMenuItem(),
  408. if (!gFFI.abModel.legacyMode.value && canWrite)
  409. MenuEntryDivider<String>(),
  410. if (!gFFI.abModel.legacyMode.value && canWrite)
  411. getEntry(translate("ab_web_console_tip"), () async {
  412. final url = await bind.mainGetApiServer();
  413. if (await canLaunchUrlString(url)) {
  414. launchUrlString(url);
  415. }
  416. }),
  417. ];
  418. mod_menu.showMenu(
  419. context: context,
  420. position: pos,
  421. items: items
  422. .map((e) => e.build(
  423. context,
  424. MenuConfig(
  425. commonColor: CustomPopupMenuTheme.commonColor,
  426. height: CustomPopupMenuTheme.height,
  427. dividerHeight: CustomPopupMenuTheme.dividerHeight)))
  428. .expand((i) => i)
  429. .toList(),
  430. elevation: 8,
  431. );
  432. }
  433. void addIdToCurrentAb() async {
  434. if (gFFI.abModel.isCurrentAbFull(true)) {
  435. return;
  436. }
  437. var isInProgress = false;
  438. var passwordVisible = false;
  439. IDTextEditingController idController = IDTextEditingController(text: '');
  440. TextEditingController aliasController = TextEditingController(text: '');
  441. TextEditingController passwordController = TextEditingController(text: '');
  442. final tags = List.of(gFFI.abModel.currentAbTags);
  443. var selectedTag = List<dynamic>.empty(growable: true).obs;
  444. final style = TextStyle(fontSize: 14.0);
  445. String? errorMsg;
  446. final isCurrentAbShared = !gFFI.abModel.current.isPersonal();
  447. gFFI.dialogManager.show((setState, close, context) {
  448. submit() async {
  449. setState(() {
  450. isInProgress = true;
  451. errorMsg = null;
  452. });
  453. String id = idController.id;
  454. if (id.isEmpty) {
  455. // pass
  456. } else {
  457. if (gFFI.abModel.idContainByCurrent(id)) {
  458. setState(() {
  459. isInProgress = false;
  460. errorMsg = translate('ID already exists');
  461. });
  462. return;
  463. }
  464. var password = '';
  465. if (isCurrentAbShared) {
  466. password = passwordController.text;
  467. }
  468. String? errMsg2 = await gFFI.abModel.addIdToCurrent(
  469. id, aliasController.text.trim(), password, selectedTag);
  470. if (errMsg2 != null) {
  471. setState(() {
  472. isInProgress = false;
  473. errorMsg = errMsg2;
  474. });
  475. return;
  476. }
  477. // final currentPeers
  478. }
  479. close();
  480. }
  481. double marginBottom = 4;
  482. row({required Widget lable, required Widget input}) {
  483. makeChild(bool isPortrait) => Row(
  484. children: [
  485. !isPortrait
  486. ? ConstrainedBox(
  487. constraints: const BoxConstraints(minWidth: 100),
  488. child: lable.marginOnly(right: 10))
  489. : SizedBox.shrink(),
  490. Expanded(
  491. child: ConstrainedBox(
  492. constraints: const BoxConstraints(minWidth: 200),
  493. child: input),
  494. ),
  495. ],
  496. ).marginOnly(bottom: !isPortrait ? 8 : 0);
  497. return Obx(() => makeChild(stateGlobal.isPortrait.isTrue));
  498. }
  499. return CustomAlertDialog(
  500. title: Text(translate("Add ID")),
  501. content: Column(
  502. crossAxisAlignment: CrossAxisAlignment.start,
  503. children: [
  504. Column(
  505. children: [
  506. row(
  507. lable: Row(
  508. children: [
  509. Text(
  510. '*',
  511. style: TextStyle(color: Colors.red, fontSize: 14),
  512. ),
  513. Text(
  514. 'ID',
  515. style: style,
  516. ),
  517. ],
  518. ),
  519. input: Obx(() => TextField(
  520. controller: idController,
  521. inputFormatters: [IDTextInputFormatter()],
  522. decoration: InputDecoration(
  523. labelText: stateGlobal.isPortrait.isFalse
  524. ? null
  525. : translate('ID'),
  526. errorText: errorMsg,
  527. errorMaxLines: 5),
  528. ))),
  529. row(
  530. lable: Text(
  531. translate('Alias'),
  532. style: style,
  533. ),
  534. input: Obx(() => TextField(
  535. controller: aliasController,
  536. decoration: InputDecoration(
  537. labelText: stateGlobal.isPortrait.isFalse
  538. ? null
  539. : translate('Alias'),
  540. ),
  541. )),
  542. ),
  543. if (isCurrentAbShared)
  544. row(
  545. lable: Text(
  546. translate('Password'),
  547. style: style,
  548. ),
  549. input: Obx(
  550. () => TextField(
  551. controller: passwordController,
  552. obscureText: !passwordVisible,
  553. decoration: InputDecoration(
  554. labelText: stateGlobal.isPortrait.isFalse
  555. ? null
  556. : translate('Password'),
  557. suffixIcon: IconButton(
  558. icon: Icon(
  559. passwordVisible
  560. ? Icons.visibility
  561. : Icons.visibility_off,
  562. color: MyTheme.lightTheme.primaryColor),
  563. onPressed: () {
  564. setState(() {
  565. passwordVisible = !passwordVisible;
  566. });
  567. },
  568. ),
  569. ),
  570. ),
  571. )),
  572. if (gFFI.abModel.currentAbTags.isNotEmpty)
  573. Align(
  574. alignment: Alignment.centerLeft,
  575. child: Text(
  576. translate('Tags'),
  577. style: style,
  578. ),
  579. ).marginOnly(top: 8, bottom: marginBottom),
  580. if (gFFI.abModel.currentAbTags.isNotEmpty)
  581. Align(
  582. alignment: Alignment.centerLeft,
  583. child: Wrap(
  584. children: tags
  585. .map((e) => AddressBookTag(
  586. name: e,
  587. tags: selectedTag,
  588. onTap: () {
  589. if (selectedTag.contains(e)) {
  590. selectedTag.remove(e);
  591. } else {
  592. selectedTag.add(e);
  593. }
  594. },
  595. showActionMenu: false))
  596. .toList(growable: false),
  597. ),
  598. ),
  599. ],
  600. ),
  601. const SizedBox(
  602. height: 4.0,
  603. ),
  604. if (!gFFI.abModel.current.isPersonal())
  605. Row(children: [
  606. Icon(Icons.info, color: Colors.amber).marginOnly(right: 4),
  607. Text(
  608. translate('share_warning_tip'),
  609. style: TextStyle(fontSize: 12),
  610. )
  611. ]).marginSymmetric(vertical: 10),
  612. // NOT use Offstage to wrap LinearProgressIndicator
  613. if (isInProgress) const LinearProgressIndicator(),
  614. ],
  615. ),
  616. actions: [
  617. dialogButton("Cancel", onPressed: close, isOutline: true),
  618. dialogButton("OK", onPressed: submit),
  619. ],
  620. onSubmit: submit,
  621. onCancel: close,
  622. );
  623. });
  624. }
  625. void abAddTag() async {
  626. var field = "";
  627. var msg = "";
  628. var isInProgress = false;
  629. TextEditingController controller = TextEditingController(text: field);
  630. gFFI.dialogManager.show((setState, close, context) {
  631. submit() async {
  632. setState(() {
  633. msg = "";
  634. isInProgress = true;
  635. });
  636. field = controller.text.trim();
  637. if (field.isEmpty) {
  638. // pass
  639. } else {
  640. final tags = field.trim().split(RegExp(r"[\s,;\n]+"));
  641. field = tags.join(',');
  642. gFFI.abModel.addTags(tags);
  643. // final currentPeers
  644. }
  645. close();
  646. }
  647. return CustomAlertDialog(
  648. title: Text(translate("Add Tag")),
  649. content: Column(
  650. crossAxisAlignment: CrossAxisAlignment.start,
  651. children: [
  652. Text(translate("whitelist_sep")),
  653. const SizedBox(
  654. height: 8.0,
  655. ),
  656. Row(
  657. children: [
  658. Expanded(
  659. child: TextField(
  660. maxLines: null,
  661. decoration: InputDecoration(
  662. errorText: msg.isEmpty ? null : translate(msg),
  663. ),
  664. controller: controller,
  665. autofocus: true,
  666. ),
  667. ),
  668. ],
  669. ),
  670. const SizedBox(
  671. height: 4.0,
  672. ),
  673. // NOT use Offstage to wrap LinearProgressIndicator
  674. if (isInProgress) const LinearProgressIndicator(),
  675. ],
  676. ),
  677. actions: [
  678. dialogButton("Cancel", onPressed: close, isOutline: true),
  679. dialogButton("OK", onPressed: submit),
  680. ],
  681. onSubmit: submit,
  682. onCancel: close,
  683. );
  684. });
  685. }
  686. }
  687. class AddressBookTag extends StatelessWidget {
  688. final String name;
  689. final RxList<dynamic> tags;
  690. final Function()? onTap;
  691. final bool showActionMenu;
  692. const AddressBookTag(
  693. {Key? key,
  694. required this.name,
  695. required this.tags,
  696. this.onTap,
  697. this.showActionMenu = true})
  698. : super(key: key);
  699. @override
  700. Widget build(BuildContext context) {
  701. var pos = RelativeRect.fill;
  702. void setPosition(TapDownDetails e) {
  703. final x = e.globalPosition.dx;
  704. final y = e.globalPosition.dy;
  705. pos = RelativeRect.fromLTRB(x, y, x, y);
  706. }
  707. const double radius = 8;
  708. return GestureDetector(
  709. onTap: onTap,
  710. onTapDown: showActionMenu ? setPosition : null,
  711. onSecondaryTapDown: showActionMenu ? setPosition : null,
  712. onSecondaryTap: showActionMenu ? () => _showMenu(context, pos) : null,
  713. onLongPress: showActionMenu ? () => _showMenu(context, pos) : null,
  714. child: Obx(() => Container(
  715. decoration: BoxDecoration(
  716. color: tags.contains(name)
  717. ? gFFI.abModel.getCurrentAbTagColor(name)
  718. : Theme.of(context).colorScheme.background,
  719. borderRadius: BorderRadius.circular(4)),
  720. margin: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
  721. padding: const EdgeInsets.symmetric(vertical: 2.0, horizontal: 6.0),
  722. child: IntrinsicWidth(
  723. child: Row(
  724. children: [
  725. Container(
  726. width: radius,
  727. height: radius,
  728. decoration: BoxDecoration(
  729. shape: BoxShape.circle,
  730. color: tags.contains(name)
  731. ? Colors.white
  732. : gFFI.abModel.getCurrentAbTagColor(name)),
  733. ).marginOnly(right: radius / 2),
  734. Expanded(
  735. child: Text(name,
  736. style: TextStyle(
  737. overflow: TextOverflow.ellipsis,
  738. color: tags.contains(name) ? Colors.white : null)),
  739. ),
  740. ],
  741. ),
  742. ),
  743. )),
  744. );
  745. }
  746. void _showMenu(BuildContext context, RelativeRect pos) {
  747. final items = [
  748. getEntry(translate("Rename"), () {
  749. renameDialog(
  750. oldName: name,
  751. validator: (String? newName) {
  752. if (newName == null || newName.isEmpty) {
  753. return translate('Can not be empty');
  754. }
  755. if (newName != name &&
  756. gFFI.abModel.currentAbTags.contains(newName)) {
  757. return translate('Already exists');
  758. }
  759. return null;
  760. },
  761. onSubmit: (String newName) {
  762. if (name != newName) {
  763. gFFI.abModel.renameTag(name, newName);
  764. }
  765. Future.delayed(Duration.zero, () => Get.back());
  766. },
  767. onCancel: () {
  768. Future.delayed(Duration.zero, () => Get.back());
  769. });
  770. }),
  771. getEntry(translate(translate('Change Color')), () async {
  772. final model = gFFI.abModel;
  773. Color oldColor = model.getCurrentAbTagColor(name);
  774. Color newColor = await showColorPickerDialog(
  775. context,
  776. oldColor,
  777. pickersEnabled: {
  778. ColorPickerType.accent: false,
  779. ColorPickerType.wheel: true,
  780. },
  781. pickerTypeLabels: {
  782. ColorPickerType.primary: translate("Primary Color"),
  783. ColorPickerType.wheel: translate("HSV Color"),
  784. },
  785. actionButtons: ColorPickerActionButtons(
  786. dialogOkButtonLabel: translate("OK"),
  787. dialogCancelButtonLabel: translate("Cancel")),
  788. showColorCode: true,
  789. );
  790. if (oldColor != newColor) {
  791. model.setTagColor(name, newColor);
  792. }
  793. }),
  794. getEntry(translate("Delete"), () {
  795. gFFI.abModel.deleteTag(name);
  796. Future.delayed(Duration.zero, () => Get.back());
  797. }),
  798. ];
  799. mod_menu.showMenu(
  800. context: context,
  801. position: pos,
  802. items: items
  803. .map((e) => e.build(
  804. context,
  805. MenuConfig(
  806. commonColor: CustomPopupMenuTheme.commonColor,
  807. height: CustomPopupMenuTheme.height,
  808. dividerHeight: CustomPopupMenuTheme.dividerHeight)))
  809. .expand((i) => i)
  810. .toList(),
  811. elevation: 8,
  812. );
  813. }
  814. }
  815. MenuEntryButton<String> getEntry(String title, VoidCallback proc) {
  816. return MenuEntryButton<String>(
  817. childBuilder: (TextStyle? style) => Text(
  818. title,
  819. style: style,
  820. ),
  821. proc: proc,
  822. dismissOnClicked: true,
  823. );
  824. }