backup_folder_selection_page.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439
  1. import 'dart:io';
  2. import 'dart:ui';
  3. import 'package:animated_list_plus/animated_list_plus.dart';
  4. import 'package:animated_list_plus/transitions.dart';
  5. import 'package:flutter/foundation.dart';
  6. import 'package:flutter/material.dart';
  7. import 'package:logging/logging.dart';
  8. import 'package:photos/core/configuration.dart';
  9. import 'package:photos/db/device_files_db.dart';
  10. import 'package:photos/db/files_db.dart';
  11. import 'package:photos/ente_theme_data.dart';
  12. import 'package:photos/generated/l10n.dart';
  13. import 'package:photos/models/device_collection.dart';
  14. import 'package:photos/models/file/file.dart';
  15. import 'package:photos/services/remote_sync_service.dart';
  16. import 'package:photos/ui/common/loading_widget.dart';
  17. import 'package:photos/ui/viewer/file/thumbnail_widget.dart';
  18. import 'package:photos/utils/dialog_util.dart';
  19. class BackupFolderSelectionPage extends StatefulWidget {
  20. final bool isOnboarding;
  21. final String buttonText;
  22. const BackupFolderSelectionPage({
  23. required this.buttonText,
  24. this.isOnboarding = false,
  25. Key? key,
  26. }) : super(key: key);
  27. @override
  28. State<BackupFolderSelectionPage> createState() =>
  29. _BackupFolderSelectionPageState();
  30. }
  31. class _BackupFolderSelectionPageState extends State<BackupFolderSelectionPage> {
  32. final Logger _logger = Logger((_BackupFolderSelectionPageState).toString());
  33. final Set<String> _allDevicePathIDs = <String>{};
  34. final Set<String> _selectedDevicePathIDs = <String>{};
  35. List<DeviceCollection>? _deviceCollections;
  36. Map<String, int>? _pathIDToItemCount;
  37. @override
  38. void initState() {
  39. FilesDB.instance
  40. .getDeviceCollections(includeCoverThumbnail: true)
  41. .then((files) async {
  42. _pathIDToItemCount =
  43. await FilesDB.instance.getDevicePathIDToImportedFileCount();
  44. setState(() {
  45. _deviceCollections = files;
  46. _deviceCollections!.sort((first, second) {
  47. return first.name.toLowerCase().compareTo(second.name.toLowerCase());
  48. });
  49. for (final file in _deviceCollections!) {
  50. _allDevicePathIDs.add(file.id);
  51. if (file.shouldBackup) {
  52. _selectedDevicePathIDs.add(file.id);
  53. }
  54. }
  55. if (widget.isOnboarding) {
  56. _selectedDevicePathIDs.addAll(_allDevicePathIDs);
  57. }
  58. _selectedDevicePathIDs
  59. .removeWhere((folder) => !_allDevicePathIDs.contains(folder));
  60. });
  61. });
  62. super.initState();
  63. }
  64. @override
  65. Widget build(BuildContext context) {
  66. return Scaffold(
  67. appBar: widget.isOnboarding
  68. ? null
  69. : AppBar(
  70. elevation: 0,
  71. title: const Text(""),
  72. ),
  73. body: Column(
  74. mainAxisAlignment: MainAxisAlignment.start,
  75. children: [
  76. const SizedBox(
  77. height: 0,
  78. ),
  79. SafeArea(
  80. child: Container(
  81. padding: const EdgeInsets.fromLTRB(24, 8, 24, 8),
  82. child: Text(
  83. S.of(context).selectFoldersForBackup,
  84. textAlign: TextAlign.left,
  85. style: TextStyle(
  86. color: Theme.of(context).colorScheme.onSurface,
  87. fontFamily: 'Inter-Bold',
  88. fontSize: 32,
  89. fontWeight: FontWeight.bold,
  90. ),
  91. ),
  92. ),
  93. ),
  94. Padding(
  95. padding: const EdgeInsets.only(left: 24, right: 48),
  96. child: Text(
  97. S.of(context).selectedFoldersWillBeEncryptedAndBackedUp,
  98. style:
  99. Theme.of(context).textTheme.bodySmall!.copyWith(height: 1.3),
  100. ),
  101. ),
  102. const Padding(
  103. padding: EdgeInsets.all(10),
  104. ),
  105. _deviceCollections == null
  106. ? const SizedBox.shrink()
  107. : GestureDetector(
  108. behavior: HitTestBehavior.translucent,
  109. child: Padding(
  110. padding: const EdgeInsets.fromLTRB(24, 6, 64, 12),
  111. child: Align(
  112. alignment: Alignment.centerLeft,
  113. child: Text(
  114. _selectedDevicePathIDs.length ==
  115. _allDevicePathIDs.length
  116. ? S.of(context).unselectAll
  117. : S.of(context).selectAll,
  118. textAlign: TextAlign.right,
  119. style: const TextStyle(
  120. decoration: TextDecoration.underline,
  121. fontSize: 16,
  122. ),
  123. ),
  124. ),
  125. ),
  126. onTap: () {
  127. final hasSelectedAll = _selectedDevicePathIDs.length ==
  128. _allDevicePathIDs.length;
  129. // Flip selection
  130. if (hasSelectedAll) {
  131. _selectedDevicePathIDs.clear();
  132. } else {
  133. _selectedDevicePathIDs.addAll(_allDevicePathIDs);
  134. }
  135. _deviceCollections!.sort((first, second) {
  136. return first.name
  137. .toLowerCase()
  138. .compareTo(second.name.toLowerCase());
  139. });
  140. setState(() {});
  141. },
  142. ),
  143. Expanded(child: _getFolders()),
  144. Column(
  145. children: [
  146. Container(
  147. width: double.infinity,
  148. decoration: BoxDecoration(
  149. boxShadow: [
  150. BoxShadow(
  151. color: Theme.of(context).colorScheme.background,
  152. blurRadius: 24,
  153. offset: const Offset(0, -8),
  154. spreadRadius: 4,
  155. ),
  156. ],
  157. ),
  158. padding: widget.isOnboarding
  159. ? const EdgeInsets.only(left: 20, right: 20)
  160. : EdgeInsets.only(
  161. top: 16,
  162. left: 20,
  163. right: 20,
  164. bottom: Platform.isIOS ? 60 : 32,
  165. ),
  166. child: OutlinedButton(
  167. onPressed:
  168. widget.isOnboarding && _selectedDevicePathIDs.isEmpty
  169. ? null
  170. : () async {
  171. await updateFolderSettings();
  172. },
  173. child: Text(widget.buttonText),
  174. ),
  175. ),
  176. widget.isOnboarding
  177. ? GestureDetector(
  178. key: const ValueKey("skipBackupButton"),
  179. behavior: HitTestBehavior.opaque,
  180. onTap: () {
  181. Navigator.of(context).pop();
  182. },
  183. child: Padding(
  184. padding: EdgeInsets.only(
  185. top: 16,
  186. bottom: Platform.isIOS ? 48 : 32,
  187. ),
  188. child: Text(
  189. S.of(context).skip,
  190. style:
  191. Theme.of(context).textTheme.bodySmall!.copyWith(
  192. decoration: TextDecoration.underline,
  193. ),
  194. ),
  195. ),
  196. )
  197. : const SizedBox.shrink(),
  198. ],
  199. ),
  200. ],
  201. ),
  202. );
  203. }
  204. Future<void> updateFolderSettings() async {
  205. final dialog = createProgressDialog(
  206. context,
  207. S.of(context).updatingFolderSelection,
  208. );
  209. await dialog.show();
  210. try {
  211. final Map<String, bool> syncStatus = {};
  212. for (String pathID in _allDevicePathIDs) {
  213. syncStatus[pathID] = _selectedDevicePathIDs.contains(pathID);
  214. }
  215. await Configuration.instance.setHasSelectedAnyBackupFolder(
  216. _selectedDevicePathIDs.isNotEmpty,
  217. );
  218. await Configuration.instance.setSelectAllFoldersForBackup(
  219. _allDevicePathIDs.length == _selectedDevicePathIDs.length,
  220. );
  221. await RemoteSyncService.instance.updateDeviceFolderSyncStatus(syncStatus);
  222. dialog.hide();
  223. Navigator.of(context).pop();
  224. } catch (e, s) {
  225. _logger.severe("Failed to updated backup folder", e, s);
  226. await dialog.hide();
  227. showGenericErrorDialog(context: context);
  228. }
  229. }
  230. Widget _getFolders() {
  231. if (_deviceCollections == null) {
  232. return const EnteLoadingWidget();
  233. }
  234. _sortFiles();
  235. final scrollController = ScrollController();
  236. return Container(
  237. padding: const EdgeInsets.symmetric(horizontal: 20),
  238. child: Scrollbar(
  239. controller: scrollController,
  240. thumbVisibility: true,
  241. child: Padding(
  242. padding: const EdgeInsets.only(right: 4),
  243. child: ImplicitlyAnimatedReorderableList<DeviceCollection>(
  244. controller: scrollController,
  245. items: _deviceCollections!,
  246. areItemsTheSame: (oldItem, newItem) => oldItem.id == newItem.id,
  247. onReorderFinished: (item, from, to, newItems) {
  248. setState(() {
  249. _deviceCollections!
  250. ..clear()
  251. ..addAll(newItems);
  252. });
  253. },
  254. itemBuilder: (context, itemAnimation, file, index) {
  255. return Reorderable(
  256. key: ValueKey(file),
  257. builder: (context, dragAnimation, inDrag) {
  258. final t = dragAnimation.value;
  259. final elevation = lerpDouble(0, 8, t)!;
  260. final themeColor = Theme.of(context).colorScheme.onSurface;
  261. final color =
  262. Color.lerp(themeColor, themeColor.withOpacity(0.8), t);
  263. return SizeFadeTransition(
  264. sizeFraction: 0.7,
  265. curve: Curves.easeInOut,
  266. animation: itemAnimation,
  267. child: Material(
  268. color: color,
  269. elevation: elevation,
  270. type: MaterialType.transparency,
  271. child: _getFileItem(file),
  272. ),
  273. );
  274. },
  275. );
  276. },
  277. ),
  278. ),
  279. ),
  280. );
  281. }
  282. Widget _getFileItem(DeviceCollection deviceCollection) {
  283. final isSelected = _selectedDevicePathIDs.contains(deviceCollection.id);
  284. final importedCount = _pathIDToItemCount != null
  285. ? _pathIDToItemCount![deviceCollection.id] ?? 0
  286. : -1;
  287. return Padding(
  288. padding: const EdgeInsets.only(bottom: 1, right: 1),
  289. child: Container(
  290. decoration: BoxDecoration(
  291. border: Border.all(
  292. color: Theme.of(context).colorScheme.boxUnSelectColor,
  293. ),
  294. borderRadius: const BorderRadius.all(
  295. Radius.circular(12),
  296. ),
  297. // color: isSelected
  298. // ? Theme.of(context).colorScheme.boxSelectColor
  299. // : Theme.of(context).colorScheme.boxUnSelectColor,
  300. gradient: isSelected
  301. ? const LinearGradient(
  302. colors: [Color(0xFF00DD4D), Color(0xFF43BA6C)],
  303. ) //same for both themes
  304. : LinearGradient(
  305. colors: [
  306. Theme.of(context).colorScheme.boxUnSelectColor,
  307. Theme.of(context).colorScheme.boxUnSelectColor,
  308. ],
  309. ),
  310. ),
  311. padding: const EdgeInsets.fromLTRB(8, 4, 4, 4),
  312. child: InkWell(
  313. child: Row(
  314. mainAxisAlignment: MainAxisAlignment.spaceBetween,
  315. children: [
  316. Row(
  317. children: [
  318. Checkbox(
  319. checkColor: Colors.green,
  320. activeColor: Colors.white,
  321. value: isSelected,
  322. onChanged: (value) {
  323. if (value!) {
  324. _selectedDevicePathIDs.add(deviceCollection.id);
  325. } else {
  326. _selectedDevicePathIDs.remove(deviceCollection.id);
  327. }
  328. setState(() {});
  329. },
  330. ),
  331. Column(
  332. crossAxisAlignment: CrossAxisAlignment.start,
  333. children: [
  334. Container(
  335. constraints: const BoxConstraints(maxWidth: 180),
  336. child: Text(
  337. deviceCollection.name,
  338. textAlign: TextAlign.left,
  339. style: TextStyle(
  340. fontFamily: 'Inter-Medium',
  341. fontSize: 18,
  342. fontWeight: FontWeight.w600,
  343. color: isSelected
  344. ? Colors.white
  345. : Theme.of(context)
  346. .colorScheme
  347. .onSurface
  348. .withOpacity(0.7),
  349. ),
  350. overflow: TextOverflow.ellipsis,
  351. maxLines: 2,
  352. ),
  353. ),
  354. const Padding(padding: EdgeInsets.only(top: 2)),
  355. Text(
  356. (kDebugMode ? 'inApp: $importedCount : device ' : '') +
  357. S.of(context).itemCount(deviceCollection.count),
  358. textAlign: TextAlign.left,
  359. style: TextStyle(
  360. fontSize: 12,
  361. color: isSelected
  362. ? Colors.white
  363. : Theme.of(context).colorScheme.onSurface,
  364. ),
  365. ),
  366. ],
  367. ),
  368. ],
  369. ),
  370. _getThumbnail(deviceCollection.thumbnail!, isSelected),
  371. ],
  372. ),
  373. onTap: () {
  374. final value = !_selectedDevicePathIDs.contains(deviceCollection.id);
  375. if (value) {
  376. _selectedDevicePathIDs.add(deviceCollection.id);
  377. } else {
  378. _selectedDevicePathIDs.remove(deviceCollection.id);
  379. }
  380. setState(() {});
  381. },
  382. ),
  383. ),
  384. );
  385. }
  386. void _sortFiles() {
  387. _deviceCollections!.sort((first, second) {
  388. if (_selectedDevicePathIDs.contains(first.id) &&
  389. _selectedDevicePathIDs.contains(second.id)) {
  390. return first.name.toLowerCase().compareTo(second.name.toLowerCase());
  391. } else if (_selectedDevicePathIDs.contains(first.id)) {
  392. return -1;
  393. } else if (_selectedDevicePathIDs.contains(second.id)) {
  394. return 1;
  395. }
  396. return first.name.toLowerCase().compareTo(second.name.toLowerCase());
  397. });
  398. }
  399. Widget _getThumbnail(EnteFile file, bool isSelected) {
  400. return ClipRRect(
  401. borderRadius: BorderRadius.circular(8),
  402. child: SizedBox(
  403. height: 88,
  404. width: 88,
  405. child: Stack(
  406. alignment: AlignmentDirectional.bottomEnd,
  407. children: [
  408. ThumbnailWidget(
  409. file,
  410. shouldShowSyncStatus: false,
  411. key: Key("backup_selection_widget" + file.tag),
  412. ),
  413. Padding(
  414. padding: const EdgeInsets.all(9),
  415. child: isSelected
  416. ? const Icon(
  417. Icons.local_police,
  418. color: Colors.white,
  419. )
  420. : null,
  421. ),
  422. ],
  423. ),
  424. ),
  425. );
  426. }
  427. }