import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/event_bus.dart'; import 'package:photos/events/user_details_changed_event.dart'; import 'package:photos/models/duplicate_files.dart'; import 'package:photos/models/file.dart'; import 'package:photos/services/deduplication_service.dart'; import 'package:photos/ui/common_elements.dart'; import 'package:photos/ui/detail_page.dart'; import 'package:photos/ui/thumbnail_widget.dart'; import 'package:photos/utils/data_util.dart'; import 'package:photos/utils/delete_file_util.dart'; import 'package:photos/utils/navigation_util.dart'; import 'package:photos/utils/toast_util.dart'; class DeduplicatePage extends StatefulWidget { final List duplicates; DeduplicatePage(this.duplicates, {Key key}) : super(key: key); @override _DeduplicatePageState createState() => _DeduplicatePageState(); } class _DeduplicatePageState extends State { static final kHeaderRowCount = 3; static final kDeleteIconOverlay = Container( decoration: BoxDecoration( gradient: LinearGradient( begin: Alignment.topCenter, end: Alignment.bottomCenter, colors: [ Colors.transparent, Colors.black.withOpacity(0.6), ], stops: const [0.75, 1], ), ), child: Align( alignment: Alignment.bottomRight, child: Padding( padding: const EdgeInsets.only(right: 8, bottom: 4), child: Icon( Icons.delete_forever, size: 18, color: Colors.red[700], ), ), ), ); final Set _selectedFiles = {}; final Map _fileSizeMap = {}; List _duplicates; bool _shouldClubByCaptureTime = true; SortKey sortKey = SortKey.size; @override void initState() { super.initState(); _duplicates = DeduplicationService.instance.clubDuplicatesByTime(widget.duplicates); _selectAllFilesButFirst(); showToast("long-press on an item to view in full-screen"); } void _selectAllFilesButFirst() { _selectedFiles.clear(); for (final duplicate in _duplicates) { for (int index = 0; index < duplicate.files.length; index++) { // Select all items but the first if (index != 0) { _selectedFiles.add(duplicate.files[index]); } // Maintain a map of fileID to fileSize for quick "space freed" computation _fileSizeMap[duplicate.files[index].uploadedFileID] = duplicate.size; } } } @override Widget build(BuildContext context) { _sortDuplicates(); return Scaffold( appBar: AppBar( title: Hero( tag: "deduplicate", child: Material( type: MaterialType.transparency, child: Text( "deduplicate files", style: TextStyle( fontSize: 18, ), ), ), ), ), body: _getBody(), ); } void _sortDuplicates() { _duplicates.sort((first, second) { if (sortKey == SortKey.size) { final aSize = first.files.length * first.size; final bSize = second.files.length * second.size; return bSize - aSize; } else if (sortKey == SortKey.count) { return second.files.length - first.files.length; } else { return second.files.first.creationTime - first.files.first.creationTime; } }); } Widget _getBody() { return Column( mainAxisAlignment: MainAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center, children: [ Expanded( child: ListView.builder( itemBuilder: (context, index) { if (index == 0) { return _getHeader(); } else if (index == 1) { return _getClubbingConfig(); } else if (index == 2) { if (_duplicates.isNotEmpty) { return _getSortMenu(); } else { return Padding( padding: EdgeInsets.only(top: 32), child: nothingToSeeHere, ); } } return Padding( padding: const EdgeInsets.only(top: 10, bottom: 10), child: _getGridView(_duplicates[index - kHeaderRowCount], index - kHeaderRowCount), ); }, itemCount: _duplicates.length + kHeaderRowCount, shrinkWrap: true, ), ), _selectedFiles.isEmpty ? Container() : _getDeleteButton(), ], ); } Padding _getHeader() { return Padding( padding: EdgeInsets.fromLTRB(20, 12, 20, 12), child: Column( children: [ Text( "the following files were clubbed based on their sizes" + (_shouldClubByCaptureTime ? " and capture times" : ""), style: TextStyle( color: Colors.white.withOpacity(0.6), height: 1.2, ), ), Padding( padding: EdgeInsets.all(4), ), Text( "please review and delete the items you believe are duplicates", style: TextStyle( color: Colors.white.withOpacity(0.6), height: 1.2, ), ), Padding( padding: EdgeInsets.all(12), ), Divider( height: 0, ), ], ), ); } Widget _getClubbingConfig() { return Padding( padding: EdgeInsets.fromLTRB(20, 0, 20, 4), child: CheckboxListTile( value: _shouldClubByCaptureTime, onChanged: (value) { _shouldClubByCaptureTime = value; _resetEntriesAndSelection(); setState(() {}); }, title: Text("club by capture time"), ), ); } void _resetEntriesAndSelection() { if (_shouldClubByCaptureTime) { _duplicates = DeduplicationService.instance.clubDuplicatesByTime(_duplicates); } else { _duplicates = widget.duplicates; } _selectAllFilesButFirst(); } Widget _getSortMenu() { Text sortOptionText(SortKey key) { String text = key.toString(); switch (key) { case SortKey.count: text = "count"; break; case SortKey.size: text = "total size"; break; case SortKey.time: text = "time"; break; } return Text( text, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 13, color: Colors.white.withOpacity(0.6), ), ); } return Row( // h4ck to align PopupMenuItems to end mainAxisAlignment: MainAxisAlignment.spaceBetween, crossAxisAlignment: CrossAxisAlignment.end, children: [ Container(), PopupMenuButton( initialValue: sortKey?.index ?? 0, child: Padding( padding: const EdgeInsets.fromLTRB(24, 6, 24, 6), child: Row( mainAxisAlignment: MainAxisAlignment.end, crossAxisAlignment: CrossAxisAlignment.center, children: [ sortOptionText(sortKey), Padding(padding: EdgeInsets.only(left: 5.0)), Icon( Icons.sort, color: Theme.of(context).buttonColor, size: 20, ), ], ), ), onSelected: (int index) { setState(() { sortKey = SortKey.values[index]; }); }, itemBuilder: (context) { return List.generate(SortKey.values.length, (index) { return PopupMenuItem( value: index, child: Align( alignment: Alignment.topRight, child: sortOptionText(SortKey.values[index]), ), ); }); }, ), ], ); } Widget _getDeleteButton() { String text; if (_selectedFiles.length == 1) { text = "delete 1 item"; } else { text = "delete " + _selectedFiles.length.toString() + " items"; } int size = 0; for (final file in _selectedFiles) { size += _fileSizeMap[file.uploadedFileID]; } return SizedBox( width: double.infinity, child: TextButton( style: OutlinedButton.styleFrom( backgroundColor: Colors.red[700], ), child: Column( children: [ Padding(padding: EdgeInsets.all(2)), Text( text, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 14, color: Colors.white, ), textAlign: TextAlign.center, ), Padding(padding: EdgeInsets.all(2)), Text( formatBytes(size), style: TextStyle( color: Colors.white.withOpacity(0.7), fontSize: 12, ), ), Padding(padding: EdgeInsets.all(2)), ], ), onPressed: () async { await deleteFilesFromRemoteOnly(context, _selectedFiles.toList()); Bus.instance.fire(UserDetailsChangedEvent()); Navigator.of(context) .pop(DeduplicationResult(_selectedFiles.length, size)); }, ), ); } Widget _getGridView(DuplicateFiles duplicates, int itemIndex) { return Column( children: [ Padding( padding: const EdgeInsets.fromLTRB(16, 8, 4, 4), child: Text( duplicates.files.length.toString() + " files, " + formatBytes(duplicates.size) + " each", style: TextStyle( color: Colors.white.withOpacity(0.9), ), ), ), GridView.builder( shrinkWrap: true, physics: NeverScrollableScrollPhysics(), // to disable GridView's scrolling itemBuilder: (context, index) { return _buildFile(context, duplicates.files[index], itemIndex); }, itemCount: duplicates.files.length, gridDelegate: SliverGridDelegateWithFixedCrossAxisCount( crossAxisCount: 4, ), padding: EdgeInsets.all(0), ), ], ); } Widget _buildFile(BuildContext context, File file, int index) { return GestureDetector( onTap: () { if (_selectedFiles.contains(file)) { _selectedFiles.remove(file); } else { _selectedFiles.add(file); } setState(() {}); }, onLongPress: () { HapticFeedback.lightImpact(); final files = _duplicates[index].files; routeToPage( context, DetailPage( DetailPageConfiguration( files, null, files.indexOf(file), "deduplicate_", mode: DetailPageMode.minimalistic, ), ), ); }, child: Container( margin: const EdgeInsets.all(2.0), decoration: BoxDecoration( border: _selectedFiles.contains(file) ? Border.all( width: 3, color: Colors.red[700], ) : null, ), child: Stack(children: [ Hero( tag: "deduplicate_" + file.tag(), child: ThumbnailWidget( file, diskLoadDeferDuration: kThumbnailDiskLoadDeferDuration, serverLoadDeferDuration: kThumbnailServerLoadDeferDuration, shouldShowLivePhotoOverlay: true, key: Key("deduplicate_" + file.tag()), ), ), _selectedFiles.contains(file) ? kDeleteIconOverlay : Container(), ]), ), ); } } enum SortKey { size, count, time, } class DeduplicationResult { final int count; final int size; DeduplicationResult(this.count, this.size); }