address_book.dart 29 KB

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