create_collection_sheet.dart 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import 'dart:math';
  2. import 'package:collection/collection.dart';
  3. import 'package:flutter/material.dart';
  4. import 'package:logging/logging.dart';
  5. import 'package:modal_bottom_sheet/modal_bottom_sheet.dart';
  6. import 'package:photos/core/configuration.dart';
  7. import 'package:photos/db/files_db.dart';
  8. import 'package:photos/models/collection.dart';
  9. import 'package:photos/models/collection_items.dart';
  10. import 'package:photos/models/file.dart';
  11. import 'package:photos/models/selected_files.dart';
  12. import 'package:photos/services/collections_service.dart';
  13. import 'package:photos/services/ignored_files_service.dart';
  14. import 'package:photos/services/remote_sync_service.dart';
  15. import 'package:photos/theme/colors.dart';
  16. import 'package:photos/theme/ente_theme.dart';
  17. import 'package:photos/ui/common/loading_widget.dart';
  18. import 'package:photos/ui/components/album_list_item_widget.dart';
  19. import 'package:photos/ui/components/bottom_of_title_bar_widget.dart';
  20. import 'package:photos/ui/components/button_widget.dart';
  21. import 'package:photos/ui/components/models/button_type.dart';
  22. import 'package:photos/ui/components/new_album_list_widget.dart';
  23. import 'package:photos/ui/components/title_bar_title_widget.dart';
  24. import 'package:photos/ui/viewer/gallery/collection_page.dart';
  25. import 'package:photos/utils/dialog_util.dart';
  26. import 'package:photos/utils/navigation_util.dart';
  27. import 'package:photos/utils/share_util.dart';
  28. import 'package:photos/utils/toast_util.dart';
  29. import 'package:receive_sharing_intent/receive_sharing_intent.dart';
  30. enum CollectionActionType { addFiles, moveFiles, restoreFiles, unHide }
  31. String _actionName(CollectionActionType type, bool plural) {
  32. bool addTitleSuffix = false;
  33. final titleSuffix = (plural ? "s" : "");
  34. String text = "";
  35. switch (type) {
  36. case CollectionActionType.addFiles:
  37. text = "Add item";
  38. addTitleSuffix = true;
  39. break;
  40. case CollectionActionType.moveFiles:
  41. text = "Move item";
  42. addTitleSuffix = true;
  43. break;
  44. case CollectionActionType.restoreFiles:
  45. text = "Restore to album";
  46. break;
  47. case CollectionActionType.unHide:
  48. text = "Unhide to album";
  49. break;
  50. }
  51. return addTitleSuffix ? text + titleSuffix : text;
  52. }
  53. void createCollectionSheet(
  54. SelectedFiles? selectedFiles,
  55. List<SharedMediaFile>? sharedFiles,
  56. BuildContext context, {
  57. CollectionActionType actionType = CollectionActionType.addFiles,
  58. bool showOptionToCreateNewAlbum = true,
  59. }) {
  60. showBarModalBottomSheet(
  61. context: context,
  62. builder: (context) {
  63. return CreateCollectionSheet(
  64. selectedFiles: selectedFiles,
  65. sharedFiles: sharedFiles,
  66. actionType: actionType,
  67. showOptionToCreateNewAlbum: showOptionToCreateNewAlbum,
  68. );
  69. },
  70. shape: const RoundedRectangleBorder(
  71. side: BorderSide(width: 0),
  72. borderRadius: BorderRadius.vertical(
  73. top: Radius.circular(5),
  74. ),
  75. ),
  76. topControl: const SizedBox.shrink(),
  77. backgroundColor: getEnteColorScheme(context).backgroundElevated,
  78. barrierColor: backdropFaintDark,
  79. enableDrag: false,
  80. );
  81. }
  82. class CreateCollectionSheet extends StatefulWidget {
  83. final SelectedFiles? selectedFiles;
  84. final List<SharedMediaFile>? sharedFiles;
  85. final CollectionActionType actionType;
  86. final bool showOptionToCreateNewAlbum;
  87. const CreateCollectionSheet({
  88. required this.selectedFiles,
  89. required this.sharedFiles,
  90. required this.actionType,
  91. required this.showOptionToCreateNewAlbum,
  92. super.key,
  93. });
  94. @override
  95. State<CreateCollectionSheet> createState() => _CreateCollectionSheetState();
  96. }
  97. class _CreateCollectionSheetState extends State<CreateCollectionSheet> {
  98. final _logger = Logger((_CreateCollectionSheetState).toString());
  99. @override
  100. Widget build(BuildContext context) {
  101. final filesCount = widget.sharedFiles != null
  102. ? widget.sharedFiles!.length
  103. : widget.selectedFiles!.files.length;
  104. return Row(
  105. mainAxisAlignment: MainAxisAlignment.center,
  106. children: [
  107. ConstrainedBox(
  108. constraints: BoxConstraints(
  109. maxWidth: min(428, MediaQuery.of(context).size.width),
  110. ),
  111. child: Padding(
  112. padding: const EdgeInsets.fromLTRB(0, 32, 0, 8),
  113. child: Column(
  114. mainAxisSize: MainAxisSize.min,
  115. children: [
  116. BottomOfTitleBarWidget(
  117. title: TitleBarTitleWidget(
  118. title: _actionName(widget.actionType, filesCount > 1),
  119. ),
  120. caption: "Create or select album",
  121. ),
  122. Flexible(
  123. child: Column(
  124. mainAxisSize: MainAxisSize.min,
  125. children: [
  126. Flexible(
  127. child: Padding(
  128. padding: const EdgeInsets.fromLTRB(16, 24, 4, 0),
  129. child: Scrollbar(
  130. radius: const Radius.circular(2),
  131. child: Padding(
  132. padding: const EdgeInsets.only(right: 12),
  133. child: FutureBuilder(
  134. future: _getCollectionsWithThumbnail(),
  135. builder: (context, snapshot) {
  136. if (snapshot.hasError) {
  137. //Need to show an error on the UI here
  138. return const SizedBox.shrink();
  139. } else if (snapshot.hasData) {
  140. final collectionsWithThumbnail = snapshot
  141. .data as List<CollectionWithThumbnail>;
  142. return ListView.separated(
  143. itemBuilder: (context, index) {
  144. if (index == 0 &&
  145. widget.showOptionToCreateNewAlbum) {
  146. return GestureDetector(
  147. onTap: () async {
  148. final result =
  149. await showTextInputDialog(
  150. context,
  151. title: "Album title",
  152. submitButtonLabel: "OK",
  153. hintText: "Enter album name",
  154. onSubmit: _nameAlbum,
  155. showOnlyLoadingState: true,
  156. textCapitalization:
  157. TextCapitalization.words,
  158. );
  159. if (result is Exception) {
  160. showGenericErrorDialog(
  161. context: context,
  162. );
  163. _logger.severe(
  164. "Failed to name album",
  165. result,
  166. );
  167. }
  168. },
  169. behavior: HitTestBehavior.opaque,
  170. child:
  171. const NewAlbumListItemWidget(),
  172. );
  173. }
  174. final item = collectionsWithThumbnail[
  175. index -
  176. (widget.showOptionToCreateNewAlbum
  177. ? 1
  178. : 0)];
  179. return GestureDetector(
  180. behavior: HitTestBehavior.opaque,
  181. onTap: () =>
  182. _albumListItemOnTap(item),
  183. child: AlbumListItemWidget(
  184. item,
  185. ),
  186. );
  187. },
  188. separatorBuilder: (context, index) =>
  189. const SizedBox(
  190. height: 8,
  191. ),
  192. itemCount:
  193. collectionsWithThumbnail.length +
  194. (widget.showOptionToCreateNewAlbum
  195. ? 1
  196. : 0),
  197. shrinkWrap: true,
  198. physics: const BouncingScrollPhysics(),
  199. );
  200. } else {
  201. return const EnteLoadingWidget();
  202. }
  203. },
  204. ),
  205. ),
  206. ),
  207. ),
  208. ),
  209. SafeArea(
  210. child: Container(
  211. //inner stroke of 1pt + 15 pts of top padding = 16 pts
  212. padding: const EdgeInsets.fromLTRB(16, 15, 16, 8),
  213. decoration: BoxDecoration(
  214. border: Border(
  215. top: BorderSide(
  216. color: getEnteColorScheme(context).strokeFaint,
  217. ),
  218. ),
  219. ),
  220. child: const ButtonWidget(
  221. buttonType: ButtonType.secondary,
  222. buttonAction: ButtonAction.cancel,
  223. isInAlert: true,
  224. labelText: "Cancel",
  225. ),
  226. ),
  227. )
  228. ],
  229. ),
  230. ),
  231. ],
  232. ),
  233. ),
  234. ),
  235. ],
  236. );
  237. }
  238. Future<void> _nameAlbum(String albumName) async {
  239. if (albumName.isNotEmpty) {
  240. final collection = await _createAlbum(albumName);
  241. if (collection != null) {
  242. if (await _runCollectionAction(
  243. collectionID: collection.id,
  244. showProgressDialog: false,
  245. )) {
  246. if (widget.actionType == CollectionActionType.restoreFiles) {
  247. showShortToast(
  248. context,
  249. 'Restored files to album ' + albumName,
  250. );
  251. } else {
  252. showShortToast(
  253. context,
  254. "Album '" + albumName + "' created.",
  255. );
  256. }
  257. _navigateToCollection(collection);
  258. }
  259. }
  260. }
  261. }
  262. Future<Collection?> _createAlbum(String albumName) async {
  263. Collection? collection;
  264. try {
  265. collection = await CollectionsService.instance.createAlbum(albumName);
  266. } catch (e, s) {
  267. _logger.severe("Failed to create album", e, s);
  268. rethrow;
  269. }
  270. return collection;
  271. }
  272. Future<void> _albumListItemOnTap(CollectionWithThumbnail item) async {
  273. if (await _runCollectionAction(collectionID: item.collection.id)) {
  274. showShortToast(
  275. context,
  276. widget.actionType == CollectionActionType.addFiles
  277. ? "Added successfully to " + item.collection.name!
  278. : "Moved successfully to " + item.collection.name!,
  279. );
  280. _navigateToCollection(
  281. item.collection,
  282. );
  283. }
  284. }
  285. Future<List<CollectionWithThumbnail>> _getCollectionsWithThumbnail() async {
  286. final List<CollectionWithThumbnail> collectionsWithThumbnail =
  287. await CollectionsService.instance.getCollectionsWithThumbnails(
  288. // in collections where user is a collaborator, only addTo and remove
  289. // action can to be performed
  290. includeCollabCollections:
  291. widget.actionType == CollectionActionType.addFiles,
  292. );
  293. collectionsWithThumbnail.removeWhere(
  294. (element) => (element.collection.type == CollectionType.favorites ||
  295. element.collection.type == CollectionType.uncategorized ||
  296. element.collection.isSharedFilesCollection()),
  297. );
  298. collectionsWithThumbnail.sort((first, second) {
  299. return compareAsciiLowerCaseNatural(
  300. first.collection.name ?? "",
  301. second.collection.name ?? "",
  302. );
  303. });
  304. return collectionsWithThumbnail;
  305. }
  306. void _navigateToCollection(Collection collection) {
  307. Navigator.pop(context);
  308. routeToPage(
  309. context,
  310. CollectionPage(
  311. CollectionWithThumbnail(collection, null),
  312. ),
  313. );
  314. }
  315. Future<bool> _runCollectionAction({
  316. required int collectionID,
  317. bool showProgressDialog = true,
  318. }) async {
  319. switch (widget.actionType) {
  320. case CollectionActionType.addFiles:
  321. return _addToCollection(
  322. collectionID: collectionID,
  323. showProgressDialog: showProgressDialog,
  324. );
  325. case CollectionActionType.moveFiles:
  326. return _moveFilesToCollection(collectionID);
  327. case CollectionActionType.unHide:
  328. return _moveFilesToCollection(collectionID);
  329. case CollectionActionType.restoreFiles:
  330. return _restoreFilesToCollection(collectionID);
  331. }
  332. }
  333. Future<bool> _addToCollection({
  334. required int collectionID,
  335. required bool showProgressDialog,
  336. }) async {
  337. final dialog = showProgressDialog
  338. ? createProgressDialog(
  339. context,
  340. "Uploading files to album"
  341. "...",
  342. isDismissible: true,
  343. )
  344. : null;
  345. await dialog?.show();
  346. try {
  347. final List<File> files = [];
  348. final List<File> filesPendingUpload = [];
  349. final int currentUserID = Configuration.instance.getUserID()!;
  350. if (widget.sharedFiles != null) {
  351. filesPendingUpload.addAll(
  352. await convertIncomingSharedMediaToFile(
  353. widget.sharedFiles!,
  354. collectionID,
  355. ),
  356. );
  357. } else {
  358. for (final file in widget.selectedFiles!.files) {
  359. File? currentFile;
  360. if (file.uploadedFileID != null) {
  361. currentFile = file;
  362. } else if (file.generatedID != null) {
  363. // when file is not uploaded, refresh the state from the db to
  364. // ensure we have latest upload status for given file before
  365. // queueing it up as pending upload
  366. currentFile = await (FilesDB.instance.getFile(file.generatedID!));
  367. } else if (file.generatedID == null) {
  368. _logger.severe("generated id should not be null");
  369. }
  370. if (currentFile == null) {
  371. _logger.severe("Failed to find fileBy genID");
  372. continue;
  373. }
  374. if (currentFile.uploadedFileID == null) {
  375. currentFile.collectionID = collectionID;
  376. filesPendingUpload.add(currentFile);
  377. } else {
  378. files.add(currentFile);
  379. }
  380. }
  381. }
  382. if (filesPendingUpload.isNotEmpty) {
  383. // Newly created collection might not be cached
  384. final Collection? c =
  385. CollectionsService.instance.getCollectionByID(collectionID);
  386. if (c != null && c.owner!.id != currentUserID) {
  387. showToast(context, "Can not upload to albums owned by others");
  388. await dialog?.hide();
  389. return false;
  390. } else {
  391. // filesPendingUpload might be getting ignored during auto-upload
  392. // because the user deleted these files from ente in the past.
  393. await IgnoredFilesService.instance
  394. .removeIgnoredMappings(filesPendingUpload);
  395. await FilesDB.instance.insertMultiple(filesPendingUpload);
  396. }
  397. }
  398. if (files.isNotEmpty) {
  399. await CollectionsService.instance.addToCollection(collectionID, files);
  400. }
  401. RemoteSyncService.instance.sync(silently: true);
  402. await dialog?.hide();
  403. widget.selectedFiles?.clearAll();
  404. return true;
  405. } catch (e, s) {
  406. _logger.severe("Failed to add to album", e, s);
  407. await dialog?.hide();
  408. showGenericErrorDialog(context: context);
  409. rethrow;
  410. }
  411. }
  412. Future<bool> _moveFilesToCollection(int toCollectionID) async {
  413. final String message = widget.actionType == CollectionActionType.moveFiles
  414. ? "Moving files to album..."
  415. : "Unhiding files to album";
  416. final dialog = createProgressDialog(context, message, isDismissible: true);
  417. await dialog.show();
  418. try {
  419. final int fromCollectionID =
  420. widget.selectedFiles!.files.first.collectionID!;
  421. await CollectionsService.instance.move(
  422. toCollectionID,
  423. fromCollectionID,
  424. widget.selectedFiles!.files.toList(),
  425. );
  426. await dialog.hide();
  427. RemoteSyncService.instance.sync(silently: true);
  428. widget.selectedFiles?.clearAll();
  429. return true;
  430. } on AssertionError catch (e) {
  431. await dialog.hide();
  432. showErrorDialog(context, "Oops", e.message as String?);
  433. return false;
  434. } catch (e, s) {
  435. _logger.severe("Could not move to album", e, s);
  436. await dialog.hide();
  437. showGenericErrorDialog(context: context);
  438. return false;
  439. }
  440. }
  441. Future<bool> _restoreFilesToCollection(int toCollectionID) async {
  442. final dialog = createProgressDialog(context, "Restoring files...",
  443. isDismissible: true);
  444. await dialog.show();
  445. try {
  446. await CollectionsService.instance
  447. .restore(toCollectionID, widget.selectedFiles!.files.toList());
  448. RemoteSyncService.instance.sync(silently: true);
  449. widget.selectedFiles?.clearAll();
  450. await dialog.hide();
  451. return true;
  452. } on AssertionError catch (e) {
  453. await dialog.hide();
  454. showErrorDialog(context, "Oops", e.message as String?);
  455. return false;
  456. } catch (e, s) {
  457. _logger.severe("Could not move to album", e, s);
  458. await dialog.hide();
  459. showGenericErrorDialog(context: context);
  460. return false;
  461. }
  462. }
  463. }