code_widget.dart 21 KB

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