add_location_sheet.dart 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481
  1. import 'dart:developer' as dev;
  2. import 'package:flutter/material.dart';
  3. import "package:modal_bottom_sheet/modal_bottom_sheet.dart";
  4. import "package:photos/core/configuration.dart";
  5. import "package:photos/core/constants.dart";
  6. import "package:photos/db/files_db.dart";
  7. import "package:photos/models/file.dart";
  8. import "package:photos/models/file_load_result.dart";
  9. import "package:photos/models/typedefs.dart";
  10. import "package:photos/services/collections_service.dart";
  11. import "package:photos/services/ignored_files_service.dart";
  12. import "package:photos/services/location_service.dart";
  13. import "package:photos/theme/colors.dart";
  14. import "package:photos/theme/ente_theme.dart";
  15. import "package:photos/ui/common/loading_widget.dart";
  16. import "package:photos/ui/components/bottom_of_title_bar_widget.dart";
  17. import "package:photos/ui/components/divider_widget.dart";
  18. import "package:photos/ui/components/text_input_widget.dart";
  19. import "package:photos/ui/components/title_bar_title_widget.dart";
  20. import "package:photos/ui/viewer/gallery/gallery.dart";
  21. import "package:photos/utils/debouncer.dart";
  22. import "package:photos/utils/local_settings.dart";
  23. showAddLocationSheet(BuildContext context, List<double> coordinates) {
  24. showBarModalBottomSheet(
  25. context: context,
  26. builder: (context) {
  27. return LocationTagDataStateProvider(
  28. coordinates,
  29. const AddLocationSheet(),
  30. );
  31. },
  32. shape: const RoundedRectangleBorder(
  33. side: BorderSide(width: 0),
  34. borderRadius: BorderRadius.vertical(
  35. top: Radius.circular(5),
  36. ),
  37. ),
  38. topControl: const SizedBox.shrink(),
  39. backgroundColor: getEnteColorScheme(context).backgroundElevated,
  40. barrierColor: backdropFaintDark,
  41. enableDrag: false,
  42. );
  43. }
  44. class LocationTagDataStateProvider extends StatefulWidget {
  45. final List<double> coordinates;
  46. final Widget child;
  47. const LocationTagDataStateProvider(this.coordinates, this.child, {super.key});
  48. @override
  49. State<LocationTagDataStateProvider> createState() =>
  50. _LocationTagDataStateProviderState();
  51. }
  52. class _LocationTagDataStateProviderState
  53. extends State<LocationTagDataStateProvider> {
  54. int selectedIndex = defaultRadiusValueIndex;
  55. late List<double> coordinates;
  56. final Debouncer _debouncer = Debouncer(const Duration(milliseconds: 300));
  57. @override
  58. void initState() {
  59. coordinates = widget.coordinates;
  60. super.initState();
  61. }
  62. void _updateSelectedIndex(int index) {
  63. _debouncer.cancelDebounce();
  64. _debouncer.run(() async {
  65. if (mounted) {
  66. setState(() {
  67. selectedIndex = index;
  68. });
  69. }
  70. });
  71. }
  72. @override
  73. Widget build(BuildContext context) {
  74. return InheritedLocationTagData(
  75. selectedIndex,
  76. coordinates,
  77. _updateSelectedIndex,
  78. child: widget.child,
  79. );
  80. }
  81. }
  82. class InheritedLocationTagData extends InheritedWidget {
  83. final int selectedIndex;
  84. final List<double> coordinates;
  85. final VoidCallbackParamInt updateSelectedIndex;
  86. const InheritedLocationTagData(
  87. this.selectedIndex,
  88. this.coordinates,
  89. this.updateSelectedIndex, {
  90. required super.child,
  91. super.key,
  92. });
  93. static InheritedLocationTagData of(BuildContext context) {
  94. return context
  95. .dependOnInheritedWidgetOfExactType<InheritedLocationTagData>()!;
  96. }
  97. @override
  98. bool updateShouldNotify(InheritedLocationTagData oldWidget) {
  99. return oldWidget.selectedIndex != selectedIndex;
  100. }
  101. }
  102. class AddLocationSheet extends StatefulWidget {
  103. const AddLocationSheet({super.key});
  104. @override
  105. State<AddLocationSheet> createState() => _AddLocationSheetState();
  106. }
  107. class _AddLocationSheetState extends State<AddLocationSheet> {
  108. ValueNotifier<int?> memoriesCountNotifier = ValueNotifier(null);
  109. @override
  110. Widget build(BuildContext context) {
  111. final textTheme = getEnteTextTheme(context);
  112. final colorScheme = getEnteColorScheme(context);
  113. return Padding(
  114. padding: const EdgeInsets.fromLTRB(0, 32, 0, 8),
  115. child: Column(
  116. children: [
  117. const Padding(
  118. padding: EdgeInsets.only(bottom: 16),
  119. child: BottomOfTitleBarWidget(
  120. title: TitleBarTitleWidget(title: "Add location"),
  121. ),
  122. ),
  123. Expanded(
  124. child: SingleChildScrollView(
  125. child: Column(
  126. mainAxisSize: MainAxisSize.min,
  127. children: [
  128. Padding(
  129. padding: const EdgeInsets.symmetric(horizontal: 16),
  130. child: Column(
  131. children: [
  132. const TextInputWidget(
  133. hintText: "Location name",
  134. borderRadius: 2,
  135. ),
  136. const SizedBox(height: 24),
  137. RadiusPickerWidget(memoriesCountNotifier),
  138. const SizedBox(height: 24),
  139. Text(
  140. "A location tag groups all photos that were taken within some radius of a photo",
  141. style: textTheme.smallMuted,
  142. ),
  143. ],
  144. ),
  145. ),
  146. const DividerWidget(
  147. dividerType: DividerType.solid,
  148. padding: EdgeInsets.only(top: 24, bottom: 20),
  149. ),
  150. SizedBox(
  151. width: double.infinity,
  152. child: Padding(
  153. padding: const EdgeInsets.symmetric(horizontal: 16),
  154. child: ValueListenableBuilder(
  155. valueListenable: memoriesCountNotifier,
  156. builder: (context, value, _) {
  157. Widget widget;
  158. if (value == null) {
  159. widget = RepaintBoundary(
  160. child: EnteLoadingWidget(
  161. size: 14,
  162. color: colorScheme.strokeMuted,
  163. alignment: Alignment.centerLeft,
  164. padding: 3,
  165. ),
  166. );
  167. } else {
  168. widget = Text(
  169. value == 1 ? "1 memory" : "$value memories",
  170. style: textTheme.body,
  171. );
  172. }
  173. return Align(
  174. alignment: Alignment.centerLeft,
  175. child: AnimatedSwitcher(
  176. duration: const Duration(milliseconds: 250),
  177. switchInCurve: Curves.easeInOutExpo,
  178. switchOutCurve: Curves.easeInOutExpo,
  179. child: widget,
  180. ),
  181. );
  182. },
  183. ),
  184. ),
  185. ),
  186. const SizedBox(height: 24),
  187. AddToLocationGalleryWidget(memoriesCountNotifier),
  188. ],
  189. ),
  190. ),
  191. ),
  192. ],
  193. ),
  194. );
  195. }
  196. }
  197. class CustomTrackShape extends RoundedRectSliderTrackShape {
  198. @override
  199. Rect getPreferredRect({
  200. required RenderBox parentBox,
  201. Offset offset = Offset.zero,
  202. required SliderThemeData sliderTheme,
  203. bool isEnabled = false,
  204. bool isDiscrete = false,
  205. }) {
  206. const trackHeight = 2.0;
  207. final trackWidth = parentBox.size.width;
  208. return Rect.fromLTWH(0, 0, trackWidth, trackHeight);
  209. }
  210. }
  211. class RadiusPickerWidget extends StatefulWidget {
  212. final ValueNotifier<int?> memoriesCountNotifier;
  213. const RadiusPickerWidget(this.memoriesCountNotifier, {super.key});
  214. @override
  215. State<RadiusPickerWidget> createState() => _RadiusPickerWidgetState();
  216. }
  217. class _RadiusPickerWidgetState extends State<RadiusPickerWidget> {
  218. double selectedIndex = defaultRadiusValueIndex.toDouble();
  219. @override
  220. Widget build(BuildContext context) {
  221. final textTheme = getEnteTextTheme(context);
  222. final colorScheme = getEnteColorScheme(context);
  223. return Row(
  224. children: [
  225. Container(
  226. height: 48,
  227. width: 48,
  228. decoration: BoxDecoration(
  229. color: colorScheme.fillFaint,
  230. borderRadius: const BorderRadius.all(Radius.circular(2)),
  231. ),
  232. padding: const EdgeInsets.all(4),
  233. child: Column(
  234. mainAxisSize: MainAxisSize.min,
  235. mainAxisAlignment: MainAxisAlignment.center,
  236. crossAxisAlignment: CrossAxisAlignment.center,
  237. children: [
  238. Expanded(
  239. flex: 6,
  240. child: Text(
  241. _selectedRadius(context).toInt().toString(),
  242. style: _selectedRadius(context) != 1200
  243. ? textTheme.largeBold
  244. : textTheme.bodyBold,
  245. textAlign: TextAlign.center,
  246. ),
  247. ),
  248. Expanded(
  249. flex: 5,
  250. child: Text(
  251. "km",
  252. style: textTheme.miniMuted,
  253. ),
  254. ),
  255. ],
  256. ),
  257. ),
  258. const SizedBox(width: 4),
  259. Expanded(
  260. child: Padding(
  261. padding: const EdgeInsets.symmetric(horizontal: 8),
  262. child: Column(
  263. crossAxisAlignment: CrossAxisAlignment.start,
  264. mainAxisSize: MainAxisSize.min,
  265. children: [
  266. Text("Radius", style: textTheme.body),
  267. const SizedBox(height: 10),
  268. SizedBox(
  269. height: 12,
  270. child: SliderTheme(
  271. data: SliderThemeData(
  272. overlayColor: Colors.transparent,
  273. thumbColor: strokeSolidMutedLight,
  274. activeTrackColor: strokeSolidMutedLight,
  275. inactiveTrackColor: colorScheme.strokeFaint,
  276. activeTickMarkColor: colorScheme.strokeMuted,
  277. inactiveTickMarkColor: strokeSolidMutedLight,
  278. trackShape: CustomTrackShape(),
  279. thumbShape: const RoundSliderThumbShape(
  280. enabledThumbRadius: 6,
  281. pressedElevation: 0,
  282. elevation: 0,
  283. ),
  284. tickMarkShape: const RoundSliderTickMarkShape(
  285. tickMarkRadius: 1,
  286. ),
  287. ),
  288. child: RepaintBoundary(
  289. child: Slider(
  290. value: selectedIndex,
  291. onChanged: (value) {
  292. setState(() {
  293. selectedIndex = value;
  294. });
  295. InheritedLocationTagData.of(
  296. context,
  297. ).updateSelectedIndex(
  298. value.toInt(),
  299. );
  300. widget.memoriesCountNotifier.value = null;
  301. },
  302. min: 0,
  303. max: radiusValues.length - 1,
  304. divisions: radiusValues.length - 1,
  305. ),
  306. ),
  307. ),
  308. ),
  309. ],
  310. ),
  311. ),
  312. ),
  313. ],
  314. );
  315. }
  316. double _selectedRadius(BuildContext context) {
  317. return radiusValues[InheritedLocationTagData.of(context).selectedIndex];
  318. }
  319. }
  320. class AddToLocationGalleryWidget extends StatefulWidget {
  321. final ValueNotifier<int?> memoriesCountNotifier;
  322. const AddToLocationGalleryWidget(this.memoriesCountNotifier, {super.key});
  323. @override
  324. State<AddToLocationGalleryWidget> createState() =>
  325. _AddToLocationGalleryWidgetState();
  326. }
  327. class _AddToLocationGalleryWidgetState
  328. extends State<AddToLocationGalleryWidget> {
  329. late final Future<FileLoadResult> fileLoadResult;
  330. late Future<void> removeIgnoredFiles;
  331. double heightOfGallery = 0;
  332. @override
  333. void initState() {
  334. final ownerID = Configuration.instance.getUserID();
  335. final hasSelectedAllForBackup =
  336. Configuration.instance.hasSelectedAllFoldersForBackup();
  337. final collectionsToHide =
  338. CollectionsService.instance.collectionsHiddenFromTimeline();
  339. if (hasSelectedAllForBackup) {
  340. fileLoadResult = FilesDB.instance.getAllLocalAndUploadedFiles(
  341. galleryLoadStartTime,
  342. galleryLoadEndTime,
  343. ownerID!,
  344. limit: null,
  345. asc: true,
  346. ignoredCollectionIDs: collectionsToHide,
  347. onlyFilesWithLocation: true,
  348. );
  349. } else {
  350. fileLoadResult = FilesDB.instance.getAllPendingOrUploadedFiles(
  351. galleryLoadStartTime,
  352. galleryLoadEndTime,
  353. ownerID!,
  354. limit: null,
  355. asc: true,
  356. ignoredCollectionIDs: collectionsToHide,
  357. onlyFilesWithLocation: true,
  358. );
  359. }
  360. removeIgnoredFiles = _removeIgnoredFiles(fileLoadResult);
  361. super.initState();
  362. }
  363. @override
  364. Widget build(BuildContext context) {
  365. final selectedRadius = _selectedRadius().toInt();
  366. late final int memoryCount;
  367. Future<FileLoadResult> filterFiles() async {
  368. final FileLoadResult result = await fileLoadResult;
  369. //wait for ignored files to be removed after init
  370. await removeIgnoredFiles;
  371. final stopWatch = Stopwatch()..start();
  372. final copyOfFiles = List<File>.from(result.files);
  373. copyOfFiles.removeWhere((f) {
  374. assert(
  375. f.location != null &&
  376. f.location!.latitude != null &&
  377. f.location!.longitude != null,
  378. );
  379. return !LocationService.instance.isFileInsideLocationTag(
  380. InheritedLocationTagData.of(context).coordinates,
  381. [f.location!.latitude!, f.location!.longitude!],
  382. selectedRadius,
  383. );
  384. });
  385. dev.log(
  386. "Time taken to get all files in a location tag: ${stopWatch.elapsedMilliseconds} ms",
  387. );
  388. stopWatch.stop();
  389. memoryCount = copyOfFiles.length;
  390. widget.memoriesCountNotifier.value = copyOfFiles.length;
  391. return Future.value(
  392. FileLoadResult(
  393. copyOfFiles,
  394. result.hasMore,
  395. ),
  396. );
  397. }
  398. return FutureBuilder(
  399. key: ValueKey(selectedRadius),
  400. builder: (context, snapshot) {
  401. if (snapshot.hasData) {
  402. return SizedBox(
  403. height: _galleryHeight(memoryCount),
  404. child: Gallery(
  405. key: ValueKey(selectedRadius),
  406. loadingWidget: const SizedBox.shrink(),
  407. disableScroll: true,
  408. asyncLoader: (
  409. creationStartTime,
  410. creationEndTime, {
  411. limit,
  412. asc,
  413. }) async {
  414. return snapshot.data as FileLoadResult;
  415. },
  416. tagPrefix: "Add location",
  417. shouldCollateFilesByDay: false,
  418. ),
  419. );
  420. } else {
  421. return const SizedBox.shrink();
  422. }
  423. },
  424. future: filterFiles(),
  425. );
  426. }
  427. double _selectedRadius() {
  428. return radiusValues[InheritedLocationTagData.of(context).selectedIndex];
  429. }
  430. Future<void> _removeIgnoredFiles(Future<FileLoadResult> result) async {
  431. final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs;
  432. (await result).files.removeWhere(
  433. (f) =>
  434. f.uploadedFileID == null &&
  435. IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, f),
  436. );
  437. }
  438. double _galleryHeight(int memoryCount) {
  439. final photoGridSize = LocalSettings.instance.getPhotoGridSize();
  440. final totalWhiteSpaceBetweenPhotos =
  441. galleryGridSpacing * (photoGridSize - 1);
  442. final thumbnailHeight =
  443. ((MediaQuery.of(context).size.width - totalWhiteSpaceBetweenPhotos) /
  444. photoGridSize);
  445. final numberOfRows = (memoryCount / photoGridSize).ceil();
  446. final galleryHeight = (thumbnailHeight * numberOfRows) +
  447. (galleryGridSpacing * (numberOfRows - 1));
  448. return galleryHeight + 120;
  449. }
  450. }