code_widget.dart 12 KB

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