code_widget.dart 12 KB

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