code_widget.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401
  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. ? Column(
  207. crossAxisAlignment: CrossAxisAlignment.end,
  208. children: [
  209. Text(
  210. l10n.nextTotpTitle,
  211. style: Theme.of(context).textTheme.bodySmall,
  212. ),
  213. ValueListenableBuilder<String>(
  214. valueListenable: _nextCode,
  215. builder: (context, value, child) {
  216. return Material(
  217. type: MaterialType.transparency,
  218. child: Text(
  219. value,
  220. style: const TextStyle(
  221. fontSize: 18,
  222. color: Colors.grey,
  223. ),
  224. ),
  225. );
  226. },
  227. ),
  228. ],
  229. )
  230. : Column(
  231. crossAxisAlignment: CrossAxisAlignment.end,
  232. children: [
  233. Text(
  234. l10n.nextTotpTitle,
  235. style: Theme.of(context).textTheme.bodySmall,
  236. ),
  237. InkWell(
  238. onTap: _onNextHotpTapped,
  239. child: const Icon(
  240. Icons.forward_outlined,
  241. size: 32,
  242. color: Colors.grey,
  243. ),
  244. ),
  245. ],
  246. ),
  247. ],
  248. ),
  249. ),
  250. );
  251. }
  252. Widget _getTopRow() {
  253. return Padding(
  254. padding: const EdgeInsets.only(left: 16, right: 16),
  255. child: Row(
  256. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  257. crossAxisAlignment: CrossAxisAlignment.start,
  258. children: [
  259. LocalHero(
  260. tag: _key + "_top_row",
  261. child: Column(
  262. crossAxisAlignment: CrossAxisAlignment.start,
  263. children: [
  264. Text(
  265. safeDecode(widget.code.issuer).trim(),
  266. style: Theme.of(context).textTheme.titleLarge,
  267. ),
  268. const SizedBox(height: 2),
  269. Text(
  270. safeDecode(widget.code.account).trim(),
  271. style: Theme.of(context).textTheme.bodySmall?.copyWith(
  272. fontSize: 12,
  273. color: Colors.grey,
  274. ),
  275. ),
  276. ],
  277. ),
  278. ),
  279. Row(
  280. mainAxisAlignment: MainAxisAlignment.end,
  281. children: [
  282. (widget.code.hasSynced != null && widget.code.hasSynced!) ||
  283. !hasConfiguredAccount
  284. ? const SizedBox.shrink()
  285. : const Icon(
  286. Icons.sync_disabled,
  287. size: 20,
  288. color: Colors.amber,
  289. ),
  290. const SizedBox(width: 12),
  291. _shouldShowLargeIcon ? const SizedBox.shrink() : _getIcon(),
  292. ],
  293. ),
  294. ],
  295. ),
  296. );
  297. }
  298. Widget _getIcon() {
  299. return LocalHero(
  300. tag: _key,
  301. child: Padding(
  302. padding: _shouldShowLargeIcon
  303. ? const EdgeInsets.only(left: 16)
  304. : const EdgeInsets.all(0),
  305. child: GestureDetector(
  306. onTap: () {
  307. PreferenceService.instance.setShowLargeIcons(!_shouldShowLargeIcon);
  308. },
  309. child: IconUtils.instance.getIcon(
  310. safeDecode(widget.code.issuer).trim(),
  311. width: _shouldShowLargeIcon ? 42 : 24,
  312. ),
  313. ),
  314. ),
  315. );
  316. }
  317. void _copyToClipboard() {
  318. FlutterClipboard.copy(_getCurrentOTP())
  319. .then((value) => showToast(context, context.l10n.copiedToClipboard));
  320. }
  321. void _onNextHotpTapped() {
  322. if (widget.code.type == Type.hotp) {
  323. CodeStore.instance
  324. .addCode(
  325. widget.code.copyWith(counter: widget.code.counter + 1),
  326. shouldSync: true,
  327. )
  328. .ignore();
  329. }
  330. }
  331. Future<void> _onEditPressed(_) async {
  332. final Code? code = await Navigator.of(context).push(
  333. MaterialPageRoute(
  334. builder: (BuildContext context) {
  335. return SetupEnterSecretKeyPage(code: widget.code);
  336. },
  337. ),
  338. );
  339. if (code != null) {
  340. CodeStore.instance.addCode(code);
  341. }
  342. }
  343. Future<void> _onShowQrPressed(_) async {
  344. // ignore: unused_local_variable
  345. final Code? code = await Navigator.of(context).push(
  346. MaterialPageRoute(
  347. builder: (BuildContext context) {
  348. return ViewQrPage(code: widget.code);
  349. },
  350. ),
  351. );
  352. }
  353. void _onDeletePressed(_) async {
  354. final l10n = context.l10n;
  355. await showChoiceActionSheet(
  356. context,
  357. title: l10n.deleteCodeTitle,
  358. body: l10n.deleteCodeMessage,
  359. firstButtonLabel: l10n.delete,
  360. isCritical: true,
  361. firstButtonOnTap: () async {
  362. await CodeStore.instance.removeCode(widget.code);
  363. },
  364. );
  365. }
  366. String _getCurrentOTP() {
  367. try {
  368. return getOTP(widget.code);
  369. } catch (e) {
  370. return context.l10n.error;
  371. }
  372. }
  373. String _getNextTotp() {
  374. try {
  375. assert(widget.code.type == Type.totp);
  376. return getNextTotp(widget.code);
  377. } catch (e) {
  378. return context.l10n.error;
  379. }
  380. }
  381. }