code_widget.dart 20 KB

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