code_widget.dart 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650
  1. import 'dart:async';
  2. import 'dart:io';
  3. import 'dart:ui' as ui;
  4. import 'package:clipboard/clipboard.dart';
  5. import 'package:ente_auth/core/configuration.dart';
  6. import 'package:ente_auth/ente_theme_data.dart';
  7. import 'package:ente_auth/l10n/l10n.dart';
  8. import 'package:ente_auth/models/code.dart';
  9. import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
  10. import 'package:ente_auth/onboarding/view/view_qr_page.dart';
  11. import 'package:ente_auth/services/local_authentication_service.dart';
  12. import 'package:ente_auth/services/preference_service.dart';
  13. import 'package:ente_auth/store/code_store.dart';
  14. import 'package:ente_auth/ui/code_timer_progress.dart';
  15. import 'package:ente_auth/ui/utils/icon_utils.dart';
  16. import 'package:ente_auth/utils/dialog_util.dart';
  17. import 'package:ente_auth/utils/platform_util.dart';
  18. import 'package:ente_auth/utils/toast_util.dart';
  19. import 'package:ente_auth/utils/totp_util.dart';
  20. import 'package:flutter/material.dart';
  21. import 'package:flutter_context_menu/flutter_context_menu.dart';
  22. import 'package:flutter_slidable/flutter_slidable.dart';
  23. import 'package:flutter_svg/flutter_svg.dart';
  24. import 'package:fluttertoast/fluttertoast.dart';
  25. import 'package:logging/logging.dart';
  26. import 'package:move_to_background/move_to_background.dart';
  27. class CodeWidget extends StatefulWidget {
  28. final Code code;
  29. final List<String> tags;
  30. const CodeWidget(this.code, this.tags, {super.key});
  31. @override
  32. State<CodeWidget> createState() => _CodeWidgetState();
  33. }
  34. class _CodeWidgetState extends State<CodeWidget> {
  35. Timer? _everySecondTimer;
  36. final ValueNotifier<String> _currentCode = ValueNotifier<String>("");
  37. final ValueNotifier<String> _nextCode = ValueNotifier<String>("");
  38. final Logger logger = Logger("_CodeWidgetState");
  39. bool _isInitialized = false;
  40. late bool hasConfiguredAccount;
  41. late bool _shouldShowLargeIcon;
  42. late bool _hideCode;
  43. bool isMaskingEnabled = false;
  44. @override
  45. void initState() {
  46. super.initState();
  47. isMaskingEnabled = PreferenceService.instance.shouldHideCodes();
  48. _hideCode = isMaskingEnabled;
  49. _everySecondTimer =
  50. Timer.periodic(const Duration(milliseconds: 500), (Timer t) {
  51. String newCode = _getCurrentOTP();
  52. if (newCode != _currentCode.value) {
  53. _currentCode.value = newCode;
  54. if (widget.code.type == Type.totp) {
  55. _nextCode.value = _getNextTotp();
  56. }
  57. }
  58. });
  59. hasConfiguredAccount = Configuration.instance.hasConfiguredAccount();
  60. }
  61. @override
  62. void dispose() {
  63. _everySecondTimer?.cancel();
  64. _currentCode.dispose();
  65. _nextCode.dispose();
  66. super.dispose();
  67. }
  68. @override
  69. Widget build(BuildContext context) {
  70. if (isMaskingEnabled != PreferenceService.instance.shouldHideCodes()) {
  71. isMaskingEnabled = PreferenceService.instance.shouldHideCodes();
  72. _hideCode = isMaskingEnabled;
  73. }
  74. _shouldShowLargeIcon = PreferenceService.instance.shouldShowLargeIcons();
  75. if (!_isInitialized) {
  76. _currentCode.value = _getCurrentOTP();
  77. if (widget.code.type == Type.totp) {
  78. _nextCode.value = _getNextTotp();
  79. }
  80. _isInitialized = true;
  81. }
  82. final l10n = context.l10n;
  83. return Container(
  84. margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8),
  85. child: Builder(
  86. builder: (context) {
  87. if (PlatformUtil.isDesktop()) {
  88. return ContextMenuRegion(
  89. contextMenu: ContextMenu(
  90. entries: <ContextMenuEntry>[
  91. MenuItem(
  92. label: 'QR',
  93. icon: Icons.qr_code_2_outlined,
  94. onSelected: () => _onShowQrPressed(null),
  95. ),
  96. MenuItem(
  97. label: widget.code.isPinned ? l10n.unpinText : l10n.pinText,
  98. icon: widget.code.isPinned
  99. ? Icons.push_pin
  100. : Icons.push_pin_outlined,
  101. onSelected: () => _onShowQrPressed(null),
  102. ),
  103. MenuItem(
  104. label: l10n.edit,
  105. icon: Icons.edit,
  106. onSelected: () => _onEditPressed(null),
  107. ),
  108. const MenuDivider(),
  109. MenuItem(
  110. label: l10n.delete,
  111. value: "Delete",
  112. icon: Icons.delete,
  113. onSelected: () => _onDeletePressed(null),
  114. ),
  115. ],
  116. padding: const EdgeInsets.all(8.0),
  117. ),
  118. child: _clippedCard(l10n),
  119. );
  120. }
  121. return Slidable(
  122. key: ValueKey(widget.code.hashCode),
  123. endActionPane: ActionPane(
  124. extentRatio: 0.90,
  125. motion: const ScrollMotion(),
  126. children: [
  127. const SizedBox(
  128. width: 14,
  129. ),
  130. SlidableAction(
  131. onPressed: _onShowQrPressed,
  132. backgroundColor: Colors.grey.withOpacity(0.1),
  133. borderRadius: const BorderRadius.all(Radius.circular(8)),
  134. foregroundColor:
  135. Theme.of(context).colorScheme.inverseBackgroundColor,
  136. icon: Icons.qr_code_2_outlined,
  137. label: "QR",
  138. padding: const EdgeInsets.only(left: 4, right: 0),
  139. spacing: 8,
  140. ),
  141. const SizedBox(
  142. width: 14,
  143. ),
  144. CustomSlidableAction(
  145. onPressed: _onPinPressed,
  146. backgroundColor: Colors.grey.withOpacity(0.1),
  147. borderRadius: const BorderRadius.all(Radius.circular(8)),
  148. foregroundColor:
  149. Theme.of(context).colorScheme.inverseBackgroundColor,
  150. child: Column(
  151. mainAxisAlignment: MainAxisAlignment.center,
  152. children: [
  153. if (widget.code.isPinned)
  154. SvgPicture.asset(
  155. "assets/svg/pin-active.svg",
  156. colorFilter: ui.ColorFilter.mode(
  157. Theme.of(context).colorScheme.primary,
  158. BlendMode.srcIn,
  159. ),
  160. )
  161. else
  162. SvgPicture.asset(
  163. "assets/svg/pin-inactive.svg",
  164. colorFilter: ui.ColorFilter.mode(
  165. Theme.of(context).colorScheme.primary,
  166. BlendMode.srcIn,
  167. ),
  168. ),
  169. const SizedBox(height: 8),
  170. Text(
  171. widget.code.isPinned ? l10n.unpinText : l10n.pinText,
  172. ),
  173. ],
  174. ),
  175. padding: const EdgeInsets.only(left: 4, right: 0),
  176. ),
  177. const SizedBox(
  178. width: 14,
  179. ),
  180. SlidableAction(
  181. onPressed: _onEditPressed,
  182. backgroundColor: Colors.grey.withOpacity(0.1),
  183. borderRadius: const BorderRadius.all(Radius.circular(8)),
  184. foregroundColor:
  185. Theme.of(context).colorScheme.inverseBackgroundColor,
  186. icon: Icons.edit_outlined,
  187. label: l10n.edit,
  188. padding: const EdgeInsets.only(left: 4, right: 0),
  189. spacing: 8,
  190. ),
  191. const SizedBox(
  192. width: 14,
  193. ),
  194. SlidableAction(
  195. onPressed: _onDeletePressed,
  196. backgroundColor: Colors.grey.withOpacity(0.1),
  197. borderRadius: const BorderRadius.all(Radius.circular(8)),
  198. foregroundColor: const Color(0xFFFE4A49),
  199. icon: Icons.delete,
  200. label: l10n.delete,
  201. padding: const EdgeInsets.only(left: 0, right: 0),
  202. spacing: 8,
  203. ),
  204. ],
  205. ),
  206. child: Builder(
  207. builder: (context) => _clippedCard(l10n),
  208. ),
  209. );
  210. },
  211. ),
  212. );
  213. }
  214. Widget _clippedCard(AppLocalizations l10n) {
  215. return Container(
  216. height: 132,
  217. decoration: BoxDecoration(
  218. borderRadius: BorderRadius.circular(8),
  219. color: Theme.of(context).colorScheme.codeCardBackgroundColor,
  220. boxShadow: widget.code.isPinned
  221. ? [
  222. BoxShadow(
  223. color: const Color(0xFF000000).withOpacity(0.03),
  224. blurRadius: 2,
  225. offset: const Offset(0, 7),
  226. ),
  227. BoxShadow(
  228. color: const Color(0xFF000000).withOpacity(0.09),
  229. blurRadius: 2,
  230. offset: const Offset(0, 4),
  231. ),
  232. BoxShadow(
  233. color: const Color(0xFF000000).withOpacity(0.16),
  234. blurRadius: 1,
  235. offset: const Offset(0, 1),
  236. ),
  237. BoxShadow(
  238. color: const Color(0xFF000000).withOpacity(0.18),
  239. blurRadius: 1,
  240. offset: const Offset(0, 0),
  241. ),
  242. ]
  243. : [],
  244. ),
  245. child: ClipRRect(
  246. borderRadius: BorderRadius.circular(8),
  247. child: Material(
  248. color: Colors.transparent,
  249. child: InkWell(
  250. customBorder: RoundedRectangleBorder(
  251. borderRadius: BorderRadius.circular(10),
  252. ),
  253. onTap: () {
  254. _copyCurrentOTPToClipboard();
  255. },
  256. onDoubleTap: isMaskingEnabled
  257. ? () {
  258. setState(
  259. () {
  260. _hideCode = !_hideCode;
  261. },
  262. );
  263. }
  264. : null,
  265. onLongPress: () {
  266. _copyCurrentOTPToClipboard();
  267. },
  268. child: _getCardContents(l10n),
  269. ),
  270. ),
  271. ),
  272. );
  273. }
  274. Widget _getCardContents(AppLocalizations l10n) {
  275. return Stack(
  276. children: [
  277. if (widget.code.isPinned)
  278. Align(
  279. alignment: Alignment.topRight,
  280. child: CustomPaint(
  281. painter: PinBgPainter(
  282. color: Theme.of(context).brightness == Brightness.dark
  283. ? const Color(0xFF390C4F)
  284. : const Color(0xFFF9ECFF),
  285. ),
  286. size: const Size(39, 39),
  287. ),
  288. ),
  289. Column(
  290. crossAxisAlignment: CrossAxisAlignment.start,
  291. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  292. children: [
  293. if (widget.code.type == Type.totp)
  294. CodeTimerProgress(
  295. period: widget.code.period,
  296. ),
  297. const SizedBox(height: 16),
  298. Row(
  299. children: [
  300. _shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(),
  301. Expanded(
  302. child: Column(
  303. children: [
  304. _getTopRow(),
  305. const SizedBox(height: 4),
  306. _getBottomRow(l10n),
  307. ],
  308. ),
  309. ),
  310. ],
  311. ),
  312. const SizedBox(
  313. height: 20,
  314. ),
  315. ],
  316. ),
  317. if (widget.code.isPinned) ...[
  318. Align(
  319. alignment: Alignment.topRight,
  320. child: Padding(
  321. padding: const EdgeInsets.only(right: 6, top: 6),
  322. child: SvgPicture.asset("assets/svg/pin-card.svg"),
  323. ),
  324. ),
  325. ],
  326. ],
  327. );
  328. }
  329. Widget _getBottomRow(AppLocalizations l10n) {
  330. return Container(
  331. padding: const EdgeInsets.only(left: 16, right: 16),
  332. child: Row(
  333. mainAxisAlignment: MainAxisAlignment.start,
  334. crossAxisAlignment: CrossAxisAlignment.end,
  335. children: [
  336. Expanded(
  337. child: ValueListenableBuilder<String>(
  338. valueListenable: _currentCode,
  339. builder: (context, value, child) {
  340. return Material(
  341. type: MaterialType.transparency,
  342. child: Text(
  343. _getFormattedCode(value),
  344. style: const TextStyle(fontSize: 24),
  345. ),
  346. );
  347. },
  348. ),
  349. ),
  350. widget.code.type == Type.totp
  351. ? GestureDetector(
  352. onTap: () {
  353. _copyNextToClipboard();
  354. },
  355. child: Column(
  356. crossAxisAlignment: CrossAxisAlignment.end,
  357. children: [
  358. Text(
  359. l10n.nextTotpTitle,
  360. style: Theme.of(context).textTheme.bodySmall,
  361. ),
  362. ValueListenableBuilder<String>(
  363. valueListenable: _nextCode,
  364. builder: (context, value, child) {
  365. return Material(
  366. type: MaterialType.transparency,
  367. child: Text(
  368. _getFormattedCode(value),
  369. style: const TextStyle(
  370. fontSize: 18,
  371. color: Colors.grey,
  372. ),
  373. ),
  374. );
  375. },
  376. ),
  377. ],
  378. ),
  379. )
  380. : Column(
  381. crossAxisAlignment: CrossAxisAlignment.end,
  382. children: [
  383. Text(
  384. l10n.nextTotpTitle,
  385. style: Theme.of(context).textTheme.bodySmall,
  386. ),
  387. InkWell(
  388. onTap: _onNextHotpTapped,
  389. child: const Icon(
  390. Icons.forward_outlined,
  391. size: 32,
  392. color: Colors.grey,
  393. ),
  394. ),
  395. ],
  396. ),
  397. ],
  398. ),
  399. );
  400. }
  401. Widget _getTopRow() {
  402. return Padding(
  403. padding: const EdgeInsets.only(left: 16, right: 16),
  404. child: Row(
  405. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  406. crossAxisAlignment: CrossAxisAlignment.start,
  407. children: [
  408. Column(
  409. crossAxisAlignment: CrossAxisAlignment.start,
  410. children: [
  411. Text(
  412. safeDecode(widget.code.issuer).trim(),
  413. style: Theme.of(context).textTheme.titleLarge,
  414. ),
  415. const SizedBox(height: 2),
  416. Text(
  417. safeDecode(widget.code.account).trim(),
  418. style: Theme.of(context).textTheme.bodySmall?.copyWith(
  419. fontSize: 12,
  420. color: Colors.grey,
  421. ),
  422. ),
  423. ],
  424. ),
  425. Row(
  426. mainAxisAlignment: MainAxisAlignment.end,
  427. children: [
  428. (widget.code.hasSynced != null && widget.code.hasSynced!) ||
  429. !hasConfiguredAccount
  430. ? const SizedBox.shrink()
  431. : const Icon(
  432. Icons.sync_disabled,
  433. size: 20,
  434. color: Colors.amber,
  435. ),
  436. const SizedBox(width: 12),
  437. _shouldShowLargeIcon ? const SizedBox.shrink() : _getIcon(),
  438. ],
  439. ),
  440. ],
  441. ),
  442. );
  443. }
  444. Widget _getIcon() {
  445. return Padding(
  446. padding: _shouldShowLargeIcon
  447. ? const EdgeInsets.only(left: 16)
  448. : const EdgeInsets.all(0),
  449. child: IconUtils.instance.getIcon(
  450. context,
  451. safeDecode(widget.code.issuer).trim(),
  452. width: _shouldShowLargeIcon ? 42 : 24,
  453. ),
  454. );
  455. }
  456. void _copyCurrentOTPToClipboard() async {
  457. _copyToClipboard(
  458. _getCurrentOTP(),
  459. confirmationMessage: context.l10n.copiedToClipboard,
  460. );
  461. }
  462. void _copyNextToClipboard() {
  463. _copyToClipboard(
  464. _getNextTotp(),
  465. confirmationMessage: context.l10n.copiedNextToClipboard,
  466. );
  467. }
  468. void _copyToClipboard(
  469. String content, {
  470. required String confirmationMessage,
  471. }) async {
  472. final shouldMinimizeOnCopy =
  473. PreferenceService.instance.shouldMinimizeOnCopy();
  474. await FlutterClipboard.copy(content);
  475. showToast(context, confirmationMessage);
  476. if (Platform.isAndroid && shouldMinimizeOnCopy) {
  477. // ignore: unawaited_futures
  478. MoveToBackground.moveTaskToBack();
  479. }
  480. }
  481. void _onNextHotpTapped() {
  482. if (widget.code.type == Type.hotp) {
  483. CodeStore.instance
  484. .addCode(
  485. widget.code.copyWith(counter: widget.code.counter + 1),
  486. shouldSync: true,
  487. )
  488. .ignore();
  489. }
  490. }
  491. Future<void> _onEditPressed(_) async {
  492. bool isAuthSuccessful = await LocalAuthenticationService.instance
  493. .requestLocalAuthentication(context, context.l10n.editCodeAuthMessage);
  494. await PlatformUtil.refocusWindows();
  495. if (!isAuthSuccessful) {
  496. return;
  497. }
  498. final Code? code = await Navigator.of(context).push(
  499. MaterialPageRoute(
  500. builder: (BuildContext context) {
  501. return SetupEnterSecretKeyPage(
  502. code: widget.code,
  503. tags: widget.tags,
  504. );
  505. },
  506. ),
  507. );
  508. if (code != null) {
  509. await CodeStore.instance.addCode(code);
  510. }
  511. }
  512. Future<void> _onShowQrPressed(_) async {
  513. bool isAuthSuccessful = await LocalAuthenticationService.instance
  514. .requestLocalAuthentication(context, context.l10n.showQRAuthMessage);
  515. await PlatformUtil.refocusWindows();
  516. if (!isAuthSuccessful) {
  517. return;
  518. }
  519. // ignore: unused_local_variable
  520. final Code? code = await Navigator.of(context).push(
  521. MaterialPageRoute(
  522. builder: (BuildContext context) {
  523. return ViewQrPage(code: widget.code);
  524. },
  525. ),
  526. );
  527. }
  528. Future<void> _onPinPressed(_) async {
  529. bool currentlyPinned = widget.code.isPinned;
  530. final display = widget.code.display;
  531. final Code code = widget.code.copyWith(
  532. display: display.copyWith(pinned: !currentlyPinned),
  533. );
  534. unawaited(
  535. CodeStore.instance.addCode(code).then(
  536. (value) => Fluttertoast.showToast(
  537. msg: !currentlyPinned
  538. ? context.l10n.pinnedCodeMessage(widget.code.issuer)
  539. : context.l10n.unpinnedCodeMessage(widget.code.issuer),
  540. ),
  541. ),
  542. );
  543. }
  544. void _onDeletePressed(_) async {
  545. bool isAuthSuccessful =
  546. await LocalAuthenticationService.instance.requestLocalAuthentication(
  547. context,
  548. context.l10n.deleteCodeAuthMessage,
  549. );
  550. if (!isAuthSuccessful) {
  551. return;
  552. }
  553. FocusScope.of(context).requestFocus();
  554. final l10n = context.l10n;
  555. await showChoiceActionSheet(
  556. context,
  557. title: l10n.deleteCodeTitle,
  558. body: l10n.deleteCodeMessage,
  559. firstButtonLabel: l10n.delete,
  560. isCritical: true,
  561. firstButtonOnTap: () async {
  562. await CodeStore.instance.removeCode(widget.code);
  563. },
  564. );
  565. }
  566. String _getCurrentOTP() {
  567. try {
  568. return getOTP(widget.code);
  569. } catch (e) {
  570. return context.l10n.error;
  571. }
  572. }
  573. String _getNextTotp() {
  574. try {
  575. assert(widget.code.type == Type.totp);
  576. return getNextTotp(widget.code);
  577. } catch (e) {
  578. return context.l10n.error;
  579. }
  580. }
  581. String _getFormattedCode(String code) {
  582. if (_hideCode) {
  583. // replace all digits with •
  584. code = code.replaceAll(RegExp(r'\d'), '•');
  585. }
  586. if (code.length == 6) {
  587. return "${code.substring(0, 3)} ${code.substring(3, 6)}";
  588. }
  589. return code;
  590. }
  591. }
  592. class PinBgPainter extends CustomPainter {
  593. final Color color;
  594. final PaintingStyle paintingStyle;
  595. PinBgPainter({
  596. this.color = Colors.black,
  597. this.paintingStyle = PaintingStyle.fill,
  598. });
  599. @override
  600. void paint(Canvas canvas, Size size) {
  601. Paint paint = Paint()
  602. ..color = color
  603. ..style = paintingStyle;
  604. canvas.drawPath(getTrianglePath(size.width, size.height), paint);
  605. }
  606. Path getTrianglePath(double x, double y) {
  607. return Path()
  608. ..moveTo(0, 0)
  609. ..lineTo(x, 0)
  610. ..lineTo(x, y)
  611. ..lineTo(0, 0);
  612. }
  613. @override
  614. bool shouldRepaint(PinBgPainter oldDelegate) {
  615. return oldDelegate.color != color ||
  616. oldDelegate.paintingStyle != paintingStyle;
  617. }
  618. }