code_widget.dart 13 KB

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