diff --git a/auth/lib/ui/settings/data/duplicate_code_page.dart b/auth/lib/ui/settings/data/duplicate_code_page.dart new file mode 100644 index 0000000000..4d93e9b079 --- /dev/null +++ b/auth/lib/ui/settings/data/duplicate_code_page.dart @@ -0,0 +1,251 @@ +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/services/deduplication_service.dart'; +import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/code_widget.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart'; +import 'package:logging/logging.dart'; + +class DuplicateCodePage extends StatefulWidget { + final List duplicateCodes; + const DuplicateCodePage({ + super.key, + required this.duplicateCodes, + }); + + @override + State createState() => _DuplicateCodePageState(); +} + +class _DuplicateCodePageState extends State { + final Logger _logger = Logger("DuplicateCodePage"); + late List _duplicateCodes; + final Set selectedGrids = {}; + + @override + void initState() { + _duplicateCodes = widget.duplicateCodes; + super.initState(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Deduplicate Codes'), + elevation: 0, + ), + body: _getBody(), + ); + } + + Widget _getBody() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisAlignment: MainAxisAlignment.start, + children: [ + AnimatedContainer( + duration: const Duration(milliseconds: 200), + curve: Curves.easeOut, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + GestureDetector( + onTap: () { + if (selectedGrids.length == _duplicateCodes.length) { + _removeAllGrids(); + } else { + _selectAllGrids(); + } + setState(() {}); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + selectedGrids.length == _duplicateCodes.length + ? "Deselect All" + : "Select All", + style: + Theme.of(context).textTheme.titleMedium!.copyWith( + fontSize: 14, + color: Theme.of(context) + .iconTheme + .color! + .withOpacity(0.7), + ), + ), + const Padding(padding: EdgeInsets.only(left: 4)), + selectedGrids.length == _duplicateCodes.length + ? const Icon( + Icons.check_circle, + size: 24, + ) + : Icon( + Icons.check_circle_outlined, + color: getEnteColorScheme(context).strokeMuted, + size: 24, + ), + ], + ), + ), + ], + ), + ), + ), + const SizedBox(height: 8), + Expanded( + child: ListView.builder( + itemCount: _duplicateCodes.length, + shrinkWrap: true, + itemBuilder: (context, index) { + final List codes = _duplicateCodes[index].codes; + return _getGridView( + codes, + index, + ); + }, + ), + ), + selectedGrids.isEmpty ? const SizedBox.shrink() : _getDeleteButton(), + ], + ); + } + + Widget _getGridView(List code, int itemIndex) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 4, + ), + child: GestureDetector( + onTap: () { + if (selectedGrids.contains(itemIndex)) { + selectedGrids.remove(itemIndex); + } else { + selectedGrids.add(itemIndex); + } + setState(() {}); + }, + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text( + "${code[0].issuer}, ${code.length} items", + ), + !selectedGrids.contains(itemIndex) + ? Icon( + Icons.check_circle_outlined, + color: getEnteColorScheme(context).strokeMuted, + size: 24, + ) + : const Icon( + Icons.check_circle, + size: 24, + ), + ], + ), + ), + ), + AlignedGridView.count( + crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400) + .clamp(1, double.infinity) + .toInt(), + padding: const EdgeInsets.only(bottom: 40), + shrinkWrap: true, + physics: const NeverScrollableScrollPhysics(), + itemBuilder: (context, index) { + return CodeWidget( + key: ValueKey('${code.hashCode}_$index'), + code[index], + isCompactMode: false, + ); + }, + itemCount: code.length, + ), + ], + ); + } + + Widget _getDeleteButton() { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 20), + child: SizedBox( + width: 400, + child: OutlinedButton( + onPressed: () async { + await deleteDuplicates(); + }, + child: Text( + "Delete ${selectedGrids.length} items", + ), + ), + ), + ); + } + + void _selectAllGrids() { + selectedGrids.clear(); + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + selectedGrids.add(idx); + } + } + + void _removeAllGrids() { + selectedGrids.clear(); + } + + Future deleteDuplicates() async { + bool isAuthSuccessful = + await LocalAuthenticationService.instance.requestLocalAuthentication( + context, + context.l10n.deleteCodeAuthMessage, + ); + if (!isAuthSuccessful) { + return; + } + FocusScope.of(context).requestFocus(); + final l10n = context.l10n; + final String message = + "Are you sure you want to trash ${selectedGrids.length} items?"; + await showChoiceActionSheet( + context, + title: "Delete duplicates", + body: message, + firstButtonLabel: l10n.trash, + isCritical: true, + firstButtonOnTap: () async { + try { + for (int idx = 0; idx < _duplicateCodes.length; idx++) { + if (selectedGrids.contains(idx)) { + final List codes = _duplicateCodes[idx].codes; + for (int i = 1; i < codes.length; i++) { + final display = codes[i].display; + final Code code = codes[i].copyWith( + display: display.copyWith(trashed: true), + ); + await CodeStore.instance.addCode(code); + } + } + } + Navigator.of(context).pop(); + } catch (e) { + _logger.severe('Failed to trash duplicate codes: ${e.toString()}'); + showGenericErrorDialog(context: context, error: e).ignore(); + } + }, + ); + } +}