code_widget.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418
  1. import 'dart:async';
  2. import 'package:clipboard/clipboard.dart';
  3. import 'package:ente_auth/core/configuration.dart';
  4. import 'package:ente_auth/ente_theme_data.dart';
  5. import 'package:ente_auth/l10n/l10n.dart';
  6. import 'package:ente_auth/models/code.dart';
  7. import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
  8. import 'package:ente_auth/onboarding/view/view_qr_page.dart';
  9. import 'package:ente_auth/services/preference_service.dart';
  10. import 'package:ente_auth/store/code_store.dart';
  11. import 'package:ente_auth/ui/code_timer_progress.dart';
  12. import 'package:ente_auth/ui/utils/icon_utils.dart';
  13. import 'package:ente_auth/utils/dialog_util.dart';
  14. import 'package:ente_auth/utils/toast_util.dart';
  15. import 'package:ente_auth/utils/totp_util.dart';
  16. import 'package:flutter/material.dart';
  17. import 'package:flutter_slidable/flutter_slidable.dart';
  18. import 'package:logging/logging.dart';
  19. class CodeWidget extends StatefulWidget {
  20. final Code code;
  21. const CodeWidget(this.code, {Key? key}) : super(key: key);
  22. @override
  23. State<CodeWidget> createState() => _CodeWidgetState();
  24. }
  25. class _CodeWidgetState extends State<CodeWidget> {
  26. Timer? _everySecondTimer;
  27. final ValueNotifier<String> _currentCode = ValueNotifier<String>("");
  28. final ValueNotifier<String> _nextCode = ValueNotifier<String>("");
  29. final Logger logger = Logger("_CodeWidgetState");
  30. bool _isInitialized = false;
  31. late bool hasConfiguredAccount;
  32. late bool _shouldShowLargeIcon;
  33. late bool _hideCode;
  34. bool isMaskingEnabled = false;
  35. @override
  36. void initState() {
  37. super.initState();
  38. isMaskingEnabled = PreferenceService.instance.shouldHideCodes();
  39. _hideCode = isMaskingEnabled;
  40. _everySecondTimer =
  41. Timer.periodic(const Duration(milliseconds: 500), (Timer t) {
  42. String newCode = _getCurrentOTP();
  43. if (newCode != _currentCode.value) {
  44. _currentCode.value = newCode;
  45. if (widget.code.type == Type.totp) {
  46. _nextCode.value = _getNextTotp();
  47. }
  48. }
  49. });
  50. hasConfiguredAccount = Configuration.instance.hasConfiguredAccount();
  51. }
  52. @override
  53. void dispose() {
  54. _everySecondTimer?.cancel();
  55. _currentCode.dispose();
  56. _nextCode.dispose();
  57. super.dispose();
  58. }
  59. @override
  60. Widget build(BuildContext context) {
  61. if (isMaskingEnabled != PreferenceService.instance.shouldHideCodes()) {
  62. isMaskingEnabled = PreferenceService.instance.shouldHideCodes();
  63. _hideCode = isMaskingEnabled;
  64. }
  65. _shouldShowLargeIcon = PreferenceService.instance.shouldShowLargeIcons();
  66. if (!_isInitialized) {
  67. _currentCode.value = _getCurrentOTP();
  68. if (widget.code.type == Type.totp) {
  69. _nextCode.value = _getNextTotp();
  70. }
  71. _isInitialized = true;
  72. }
  73. final l10n = context.l10n;
  74. return Container(
  75. margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8),
  76. child: Slidable(
  77. key: ValueKey(widget.code.hashCode),
  78. endActionPane: ActionPane(
  79. extentRatio: 0.60,
  80. motion: const ScrollMotion(),
  81. children: [
  82. const SizedBox(
  83. width: 4,
  84. ),
  85. SlidableAction(
  86. onPressed: _onShowQrPressed,
  87. backgroundColor: Colors.grey.withOpacity(0.1),
  88. borderRadius: const BorderRadius.all(Radius.circular(12.0)),
  89. foregroundColor:
  90. Theme.of(context).colorScheme.inverseBackgroundColor,
  91. icon: Icons.qr_code_2_outlined,
  92. label: "QR",
  93. padding: const EdgeInsets.only(left: 4, right: 0),
  94. spacing: 8,
  95. ),
  96. const SizedBox(
  97. width: 4,
  98. ),
  99. SlidableAction(
  100. onPressed: _onEditPressed,
  101. backgroundColor: Colors.grey.withOpacity(0.1),
  102. borderRadius: const BorderRadius.all(Radius.circular(12.0)),
  103. foregroundColor:
  104. Theme.of(context).colorScheme.inverseBackgroundColor,
  105. icon: Icons.edit_outlined,
  106. label: l10n.edit,
  107. padding: const EdgeInsets.only(left: 4, right: 0),
  108. spacing: 8,
  109. ),
  110. const SizedBox(
  111. width: 4,
  112. ),
  113. SlidableAction(
  114. onPressed: _onDeletePressed,
  115. backgroundColor: Colors.grey.withOpacity(0.1),
  116. borderRadius: const BorderRadius.all(Radius.circular(12.0)),
  117. foregroundColor: const Color(0xFFFE4A49),
  118. icon: Icons.delete,
  119. label: l10n.delete,
  120. padding: const EdgeInsets.only(left: 0, right: 0),
  121. spacing: 8,
  122. ),
  123. ],
  124. ),
  125. child: ClipRRect(
  126. borderRadius: BorderRadius.circular(8),
  127. child: Container(
  128. color: Theme.of(context).colorScheme.codeCardBackgroundColor,
  129. child: Material(
  130. color: Colors.transparent,
  131. child: InkWell(
  132. customBorder: RoundedRectangleBorder(
  133. borderRadius: BorderRadius.circular(10),
  134. ),
  135. onTap: () {
  136. _copyToClipboard();
  137. },
  138. onDoubleTap: isMaskingEnabled
  139. ? () {
  140. setState(
  141. () {
  142. _hideCode = !_hideCode;
  143. },
  144. );
  145. }
  146. : null,
  147. onLongPress: () {
  148. _copyToClipboard();
  149. },
  150. child: _getCardContents(l10n),
  151. ),
  152. ),
  153. ),
  154. ),
  155. ),
  156. );
  157. }
  158. Widget _getCardContents(AppLocalizations l10n) {
  159. return SizedBox(
  160. child: Column(
  161. crossAxisAlignment: CrossAxisAlignment.start,
  162. mainAxisAlignment: MainAxisAlignment.center,
  163. children: [
  164. if (widget.code.type == Type.totp)
  165. CodeTimerProgress(
  166. period: widget.code.period,
  167. ),
  168. const SizedBox(
  169. height: 16,
  170. ),
  171. Row(
  172. children: [
  173. _shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(),
  174. Expanded(
  175. child: Column(
  176. children: [
  177. _getTopRow(),
  178. const SizedBox(height: 4),
  179. _getBottomRow(l10n),
  180. ],
  181. ),
  182. ),
  183. ],
  184. ),
  185. const SizedBox(
  186. height: 20,
  187. ),
  188. ],
  189. ),
  190. );
  191. }
  192. Widget _getBottomRow(AppLocalizations l10n) {
  193. return Container(
  194. padding: const EdgeInsets.only(left: 16, right: 16),
  195. child: Row(
  196. mainAxisAlignment: MainAxisAlignment.start,
  197. crossAxisAlignment: CrossAxisAlignment.end,
  198. children: [
  199. Expanded(
  200. child: ValueListenableBuilder<String>(
  201. valueListenable: _currentCode,
  202. builder: (context, value, child) {
  203. return Material(
  204. type: MaterialType.transparency,
  205. child: Text(
  206. _getFormattedCode(value),
  207. style: const TextStyle(fontSize: 24),
  208. ),
  209. );
  210. },
  211. ),
  212. ),
  213. widget.code.type == Type.totp
  214. ? GestureDetector(
  215. onTap: () {
  216. _copyNextToClipboard();
  217. },
  218. child: Column(
  219. crossAxisAlignment: CrossAxisAlignment.end,
  220. children: [
  221. Text(
  222. l10n.nextTotpTitle,
  223. style: Theme.of(context).textTheme.bodySmall,
  224. ),
  225. ValueListenableBuilder<String>(
  226. valueListenable: _nextCode,
  227. builder: (context, value, child) {
  228. return Material(
  229. type: MaterialType.transparency,
  230. child: Text(
  231. _getFormattedCode(value),
  232. style: const TextStyle(
  233. fontSize: 18,
  234. color: Colors.grey,
  235. ),
  236. ),
  237. );
  238. },
  239. ),
  240. ],
  241. ),
  242. )
  243. : Column(
  244. crossAxisAlignment: CrossAxisAlignment.end,
  245. children: [
  246. Text(
  247. l10n.nextTotpTitle,
  248. style: Theme.of(context).textTheme.bodySmall,
  249. ),
  250. InkWell(
  251. onTap: _onNextHotpTapped,
  252. child: const Icon(
  253. Icons.forward_outlined,
  254. size: 32,
  255. color: Colors.grey,
  256. ),
  257. ),
  258. ],
  259. ),
  260. ],
  261. ),
  262. );
  263. }
  264. Widget _getTopRow() {
  265. return Padding(
  266. padding: const EdgeInsets.only(left: 16, right: 16),
  267. child: Row(
  268. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  269. crossAxisAlignment: CrossAxisAlignment.start,
  270. children: [
  271. Column(
  272. crossAxisAlignment: CrossAxisAlignment.start,
  273. children: [
  274. Text(
  275. safeDecode(widget.code.issuer).trim(),
  276. style: Theme.of(context).textTheme.titleLarge,
  277. ),
  278. const SizedBox(height: 2),
  279. Text(
  280. safeDecode(widget.code.account).trim(),
  281. style: Theme.of(context).textTheme.bodySmall?.copyWith(
  282. fontSize: 12,
  283. color: Colors.grey,
  284. ),
  285. ),
  286. ],
  287. ),
  288. Row(
  289. mainAxisAlignment: MainAxisAlignment.end,
  290. children: [
  291. (widget.code.hasSynced != null && widget.code.hasSynced!) ||
  292. !hasConfiguredAccount
  293. ? const SizedBox.shrink()
  294. : const Icon(
  295. Icons.sync_disabled,
  296. size: 20,
  297. color: Colors.amber,
  298. ),
  299. const SizedBox(width: 12),
  300. _shouldShowLargeIcon ? const SizedBox.shrink() : _getIcon(),
  301. ],
  302. ),
  303. ],
  304. ),
  305. );
  306. }
  307. Widget _getIcon() {
  308. return Padding(
  309. padding: _shouldShowLargeIcon
  310. ? const EdgeInsets.only(left: 16)
  311. : const EdgeInsets.all(0),
  312. child: IconUtils.instance.getIcon(
  313. safeDecode(widget.code.issuer).trim(),
  314. width: _shouldShowLargeIcon ? 42 : 24,
  315. ),
  316. );
  317. }
  318. void _copyToClipboard() {
  319. FlutterClipboard.copy(_getCurrentOTP())
  320. .then((value) => showToast(context, context.l10n.copiedToClipboard));
  321. }
  322. void _copyNextToClipboard() {
  323. FlutterClipboard.copy(_getNextTotp()).then(
  324. (value) => showToast(context, context.l10n.copiedNextToClipboard),
  325. );
  326. }
  327. void _onNextHotpTapped() {
  328. if (widget.code.type == Type.hotp) {
  329. CodeStore.instance
  330. .addCode(
  331. widget.code.copyWith(counter: widget.code.counter + 1),
  332. shouldSync: true,
  333. )
  334. .ignore();
  335. }
  336. }
  337. Future<void> _onEditPressed(_) async {
  338. final Code? code = await Navigator.of(context).push(
  339. MaterialPageRoute(
  340. builder: (BuildContext context) {
  341. return SetupEnterSecretKeyPage(code: widget.code);
  342. },
  343. ),
  344. );
  345. if (code != null) {
  346. CodeStore.instance.addCode(code);
  347. }
  348. }
  349. Future<void> _onShowQrPressed(_) async {
  350. // ignore: unused_local_variable
  351. final Code? code = await Navigator.of(context).push(
  352. MaterialPageRoute(
  353. builder: (BuildContext context) {
  354. return ViewQrPage(code: widget.code);
  355. },
  356. ),
  357. );
  358. }
  359. void _onDeletePressed(_) async {
  360. final l10n = context.l10n;
  361. await showChoiceActionSheet(
  362. context,
  363. title: l10n.deleteCodeTitle,
  364. body: l10n.deleteCodeMessage,
  365. firstButtonLabel: l10n.delete,
  366. isCritical: true,
  367. firstButtonOnTap: () async {
  368. await CodeStore.instance.removeCode(widget.code);
  369. },
  370. );
  371. }
  372. String _getCurrentOTP() {
  373. try {
  374. return getOTP(widget.code);
  375. } catch (e) {
  376. return context.l10n.error;
  377. }
  378. }
  379. String _getNextTotp() {
  380. try {
  381. assert(widget.code.type == Type.totp);
  382. return getNextTotp(widget.code);
  383. } catch (e) {
  384. return context.l10n.error;
  385. }
  386. }
  387. String _getFormattedCode(String code) {
  388. if (_hideCode) {
  389. // replace all digits with •
  390. code = code.replaceAll(RegExp(r'\d'), '•');
  391. }
  392. if (code.length == 6) {
  393. return code.substring(0, 3) + " " + code.substring(3, 6);
  394. }
  395. return code;
  396. }
  397. }