code_widget.dart 10 KB

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