import 'dart:async'; import 'dart:io'; import 'package:auto_size_text/auto_size_text.dart'; import 'package:clipboard/clipboard.dart'; import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/ente_theme_data.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/code.dart'; import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart'; import 'package:ente_auth/onboarding/view/view_qr_page.dart'; import 'package:ente_auth/services/local_authentication_service.dart'; import 'package:ente_auth/services/preference_service.dart'; import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/code_timer_progress.dart'; import 'package:ente_auth/ui/components/bottom_action_bar_widget.dart'; import 'package:ente_auth/ui/share/code_share.dart'; import 'package:ente_auth/ui/utils/icon_utils.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/platform_util.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_auth/utils/totp_util.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_context_menu/flutter_context_menu.dart'; import 'package:flutter_svg/flutter_svg.dart'; import 'package:logging/logging.dart'; import 'package:move_to_background/move_to_background.dart'; class CodeWidget extends StatefulWidget { final Code code; final bool isCompactMode; const CodeWidget( this.code, { super.key, required this.isCompactMode, }); @override State createState() => _CodeWidgetState(); } class _CodeWidgetState extends State { Timer? _everySecondTimer; final ValueNotifier _currentCode = ValueNotifier(""); final ValueNotifier _nextCode = ValueNotifier(""); final Logger logger = Logger("_CodeWidgetState"); bool _isInitialized = false; late bool hasConfiguredAccount; late bool _shouldShowLargeIcon; late bool _hideCode; bool isMaskingEnabled = false; int _codeTimeStep = -1; @override void initState() { super.initState(); isMaskingEnabled = PreferenceService.instance.shouldHideCodes(); _hideCode = isMaskingEnabled; _everySecondTimer = Timer.periodic(const Duration(milliseconds: 500), (Timer t) { int newStep = 0; if (widget.code.type != Type.hotp) { newStep = (((DateTime.now().millisecondsSinceEpoch ~/ 1000).round()) ~/ widget.code.period) .floor(); } else { newStep = widget.code.counter; } if (_codeTimeStep != newStep) { _codeTimeStep = newStep; String newCode = _getCurrentOTP(); if (newCode != _currentCode.value) { _currentCode.value = newCode; if (widget.code.type.isTOTPCompatible) { _nextCode.value = _getNextTotp(); } } } }); hasConfiguredAccount = Configuration.instance.hasConfiguredAccount(); } @override void dispose() { _everySecondTimer?.cancel(); _currentCode.dispose(); _nextCode.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final colorScheme = getEnteColorScheme(context); if (isMaskingEnabled != PreferenceService.instance.shouldHideCodes()) { isMaskingEnabled = PreferenceService.instance.shouldHideCodes(); _hideCode = isMaskingEnabled; } _shouldShowLargeIcon = PreferenceService.instance.shouldShowLargeIcons(); if (!_isInitialized) { _currentCode.value = _getCurrentOTP(); if (widget.code.type.isTOTPCompatible) { _nextCode.value = _getNextTotp(); } _isInitialized = true; } final l10n = context.l10n; Widget getCardContents(AppLocalizations l10n) { return Stack( children: [ if (widget.code.isPinned) Align( alignment: Alignment.topRight, child: CustomPaint( painter: PinBgPainter( color: colorScheme.pinnedBgColor, ), size: widget.isCompactMode ? const Size(24, 24) : const Size(39, 39), ), ), if (widget.code.isTrashed && kDebugMode) Align( alignment: Alignment.topLeft, child: CustomPaint( painter: PinBgPainter( color: colorScheme.warning700, ), size: const Size(39, 39), ), ), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ if (widget.code.type.isTOTPCompatible) CodeTimerProgressCache.getCachedWidget( widget.code.period, ), widget.isCompactMode ? const SizedBox(height: 4) : const SizedBox(height: 28), Row( children: [ _shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(), Expanded( child: Column( children: [ _getTopRow(), widget.isCompactMode ? const SizedBox.shrink() : const SizedBox(height: 4), _getBottomRow(l10n), ], ), ), ], ), widget.isCompactMode ? const SizedBox(height: 4) : const SizedBox(height: 32), ], ), if (widget.code.isPinned) ...[ Align( alignment: Alignment.topRight, child: Padding( padding: widget.isCompactMode ? const EdgeInsets.only(right: 4, top: 4) : const EdgeInsets.only(right: 6, top: 6), child: SvgPicture.asset( "assets/svg/pin-card.svg", width: widget.isCompactMode ? 8 : null, height: widget.isCompactMode ? 8 : null, ), ), ), ], ], ); } Widget clippedCard(AppLocalizations l10n) { return Container( decoration: BoxDecoration( borderRadius: BorderRadius.circular(8), color: Theme.of(context).colorScheme.codeCardBackgroundColor, boxShadow: widget.code.isPinned ? colorScheme.pinnedCardBoxShadow : [], ), child: ClipRRect( borderRadius: BorderRadius.circular(8), child: Material( color: Colors.transparent, child: InkWell( customBorder: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), ), onTap: () { _copyCurrentOTPToClipboard(); }, onDoubleTap: isMaskingEnabled ? () { setState( () { _hideCode = !_hideCode; }, ); } : null, onLongPress: () { showModalBottomSheet( context: context, builder: (_) { return BottomActionBarWidget( code: widget.code, onEdit: () => _onEditPressed(true), onShare: () => _onSharePressed(true), onPin: () => _onPinPressed(true), onTrashed: () => _onTrashPressed(true), onDelete: () => _onDeletePressed(true), onRestore: () => _onRestoreClicked(true), onShowQR: () => _onShowQrPressed(true), onCancel: () => Navigator.of(context).pop(), ); }, ); }, child: getCardContents(l10n), ), ), ), ); } return Container( margin: widget.isCompactMode ? const EdgeInsets.only(left: 16, right: 16, bottom: 6, top: 6) : const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8), child: Builder( builder: (context) { if (PlatformUtil.isDesktop()) { return ContextMenuRegion( contextMenu: ContextMenu( entries: [ if (!widget.code.isTrashed && widget.code.type.isTOTPCompatible) MenuItem( label: context.l10n.share, icon: Icons.adaptive.share_outlined, onSelected: () => _onSharePressed(null), ), if (!widget.code.isTrashed) MenuItem( label: 'QR', icon: Icons.qr_code_2_outlined, onSelected: () => _onShowQrPressed(null), ), if (!widget.code.isTrashed) MenuItem( label: widget.code.isPinned ? l10n.unpinText : l10n.pinText, icon: widget.code.isPinned ? Icons.push_pin : Icons.push_pin_outlined, onSelected: () => _onPinPressed(null), ), if (!widget.code.isTrashed) MenuItem( label: l10n.edit, icon: Icons.edit, onSelected: () => _onEditPressed(null), ) else MenuItem( label: l10n.restore, icon: Icons.restore_outlined, onSelected: () => _onRestoreClicked(null), ), const MenuDivider(), MenuItem( label: widget.code.isTrashed ? l10n.delete : l10n.trash, value: "Delete", icon: widget.code.isTrashed ? Icons.delete_forever : Icons.delete, onSelected: () => widget.code.isTrashed ? _onDeletePressed(null) : _onTrashPressed(null), ), ], padding: const EdgeInsets.all(8.0), ), child: clippedCard(l10n), ); } return clippedCard(l10n); }, ), ); } Widget _getBottomRow(AppLocalizations l10n) { return Container( padding: const EdgeInsets.only(left: 16, right: 16), child: Row( mainAxisAlignment: MainAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.end, children: [ Expanded( child: ValueListenableBuilder( valueListenable: _currentCode, builder: (context, value, child) { return Material( type: MaterialType.transparency, child: AutoSizeText( _getFormattedCode(value), style: TextStyle(fontSize: widget.isCompactMode ? 14 : 24), maxLines: 1, ), ); }, ), ), const SizedBox(width: 8), widget.code.type.isTOTPCompatible ? GestureDetector( onTap: () { _copyNextToClipboard(); }, child: Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( l10n.nextTotpTitle, style: Theme.of(context).textTheme.bodySmall, ), ValueListenableBuilder( valueListenable: _nextCode, builder: (context, value, child) { return Material( type: MaterialType.transparency, child: Text( _getFormattedCode(value), style: TextStyle( fontSize: widget.isCompactMode ? 12 : 18, color: Colors.grey, ), ), ); }, ), ], ), ) : Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text( l10n.nextTotpTitle, style: Theme.of(context).textTheme.bodySmall, ), InkWell( onTap: _onNextHotpTapped, child: const Icon( Icons.forward_outlined, size: 32, color: Colors.grey, ), ), ], ), ], ), ); } Widget _getTopRow() { bool isCompactMode = widget.isCompactMode; return Padding( padding: const EdgeInsets.only(left: 16, right: 16), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( safeDecode(widget.code.issuer).trim(), style: isCompactMode ? Theme.of(context).textTheme.bodyMedium : Theme.of(context).textTheme.titleLarge, ), if (!isCompactMode) const SizedBox(height: 2), Text( safeDecode(widget.code.account).trim(), style: Theme.of(context).textTheme.bodySmall?.copyWith( fontSize: isCompactMode ? 12 : 12, color: Colors.grey, ), ), ], ), ), const SizedBox(width: 8), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ (widget.code.hasSynced != null && widget.code.hasSynced!) || !hasConfiguredAccount ? const SizedBox.shrink() : const Icon( Icons.sync_disabled, size: 20, color: Colors.amber, ), const SizedBox(width: 12), _shouldShowLargeIcon ? const SizedBox.shrink() : _getIcon(), ], ), ], ), ); } Widget _getIcon() { return Padding( padding: _shouldShowLargeIcon ? EdgeInsets.only(left: widget.isCompactMode ? 12 : 16) : const EdgeInsets.all(0), child: IconUtils.instance.getIcon( context, safeDecode(widget.code.issuer).trim(), width: widget.isCompactMode ? (_shouldShowLargeIcon ? 32 : 24) : (_shouldShowLargeIcon ? 42 : 24), ), ); } void _copyCurrentOTPToClipboard() async { _copyToClipboard( _getCurrentOTP(), confirmationMessage: context.l10n.copiedToClipboard, ); } void _copyNextToClipboard() { _copyToClipboard( _getNextTotp(), confirmationMessage: context.l10n.copiedNextToClipboard, ); } void _copyToClipboard( String content, { required String confirmationMessage, }) async { final shouldMinimizeOnCopy = PreferenceService.instance.shouldMinimizeOnCopy(); await FlutterClipboard.copy(content); showToast(context, confirmationMessage); if (Platform.isAndroid && shouldMinimizeOnCopy) { // ignore: unawaited_futures MoveToBackground.moveTaskToBack(); } } void _onNextHotpTapped() { if (widget.code.type == Type.hotp) { CodeStore.instance .addCode( widget.code.copyWith(counter: widget.code.counter + 1), shouldSync: true, ) .ignore(); } } Future _onEditPressed([bool? pop]) async { if (mounted && pop == true) { Navigator.of(context).pop(); } bool isAuthSuccessful = await LocalAuthenticationService.instance .requestLocalAuthentication(context, context.l10n.editCodeAuthMessage); await PlatformUtil.refocusWindows(); if (!isAuthSuccessful) { return; } final Code? code = await Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) { return SetupEnterSecretKeyPage( code: widget.code, ); }, ), ); if (code != null) { await CodeStore.instance.addCode(code); } } Future _onShowQrPressed([bool? pop]) async { if (mounted && pop == true) { Navigator.of(context).pop(); } bool isAuthSuccessful = await LocalAuthenticationService.instance .requestLocalAuthentication(context, context.l10n.showQRAuthMessage); await PlatformUtil.refocusWindows(); if (!isAuthSuccessful) { return; } // ignore: unused_local_variable final Code? code = await Navigator.of(context).push( MaterialPageRoute( builder: (BuildContext context) { return ViewQrPage(code: widget.code); }, ), ); } Future _onSharePressed([bool? pop]) async { if (mounted && pop == true) { Navigator.of(context).pop(); } bool isAuthSuccessful = await LocalAuthenticationService.instance .requestLocalAuthentication(context, context.l10n.authenticateGeneric); await PlatformUtil.refocusWindows(); if (!isAuthSuccessful) { return; } showShareDialog(context, widget.code); } Future _onPinPressed([bool? pop]) async { if (mounted && pop == true) { Navigator.of(context).pop(); } bool currentlyPinned = widget.code.isPinned; final display = widget.code.display; final Code code = widget.code.copyWith( display: display.copyWith(pinned: !currentlyPinned), ); unawaited( CodeStore.instance.addCode(code).then( (value) => showToast( context, !currentlyPinned ? context.l10n.pinnedCodeMessage(widget.code.issuer) : context.l10n.unpinnedCodeMessage(widget.code.issuer), ), ), ); } void _onDeletePressed([bool? pop]) async { if (mounted && pop == true) { Navigator.of(context).pop(); } if (!widget.code.isTrashed) { showToast(context, 'Code can only be deleted from trash'); return; } bool isAuthSuccessful = await LocalAuthenticationService.instance.requestLocalAuthentication( context, context.l10n.deleteCodeAuthMessage, ); if (!isAuthSuccessful) { return; } FocusScope.of(context).requestFocus(); final l10n = context.l10n; await showChoiceActionSheet( context, title: l10n.deleteCodeTitle, body: l10n.deleteCodeMessage, firstButtonLabel: l10n.delete, isCritical: true, firstButtonOnTap: () async { await CodeStore.instance.removeCode(widget.code); }, ); } void _onTrashPressed([bool? pop]) async { if (mounted && pop == true) { Navigator.of(context).pop(); } if (widget.code.isTrashed) { showToast(context, 'Code is already trashed'); return; } bool isAuthSuccessful = await LocalAuthenticationService.instance.requestLocalAuthentication( context, context.l10n.deleteCodeAuthMessage, ); if (!isAuthSuccessful) { return; } FocusScope.of(context).requestFocus(); final l10n = context.l10n; await showChoiceActionSheet( context, title: l10n.trashCode, body: l10n .trashCodeMessage('${widget.code.issuer} (${widget.code.account})'), firstButtonLabel: l10n.trash, isCritical: true, firstButtonOnTap: () async { try { final display = widget.code.display; final Code code = widget.code.copyWith( display: display.copyWith(trashed: true), ); await CodeStore.instance.addCode(code); } catch (e) { logger.severe('Failed to trash code: ${e.toString()}'); showGenericErrorDialog(context: context, error: e).ignore(); } }, ); } void _onRestoreClicked([bool? pop]) async { if (mounted && pop == true) { Navigator.of(context).pop(); } if (!widget.code.isTrashed) { showToast(context, 'Code is already restored'); return; } FocusScope.of(context).requestFocus(); try { final display = widget.code.display; final Code code = widget.code.copyWith( display: display.copyWith(trashed: false), ); await CodeStore.instance.addCode(code); } catch (e) { logger.severe('Failed to restore code: ${e.toString()}'); if (mounted) { showGenericErrorDialog(context: context, error: e).ignore(); } } } String _getCurrentOTP() { try { return getOTP(widget.code); } catch (e) { return context.l10n.error; } } String _getNextTotp() { try { assert(widget.code.type.isTOTPCompatible); return getNextTotp(widget.code); } catch (e) { return context.l10n.error; } } String _getFormattedCode(String code) { if (_hideCode) { // replace all digits with • code = code.replaceAll(RegExp(r'\S'), '•'); } if (code.length == 6) { return "${code.substring(0, 3)} ${code.substring(3, 6)}"; } return code; } } class PinBgPainter extends CustomPainter { final Color color; final PaintingStyle paintingStyle; PinBgPainter({ this.color = Colors.black, this.paintingStyle = PaintingStyle.fill, }); @override void paint(Canvas canvas, Size size) { Paint paint = Paint() ..color = color ..style = paintingStyle; canvas.drawPath(getTrianglePath(size.width, size.height), paint); } Path getTrianglePath(double x, double y) { return Path() ..moveTo(0, 0) ..lineTo(x, 0) ..lineTo(x, y) ..lineTo(0, 0); } @override bool shouldRepaint(PinBgPainter oldDelegate) { return oldDelegate.color != color || oldDelegate.paintingStyle != paintingStyle; } }