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