map_page_bottom_sheet.dart 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356
  1. import 'dart:async';
  2. import 'package:easy_localization/easy_localization.dart';
  3. import 'package:flutter/material.dart';
  4. import 'package:flutter_hooks/flutter_hooks.dart';
  5. import 'package:hooks_riverpod/hooks_riverpod.dart';
  6. import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
  7. import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
  8. import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
  9. import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
  10. import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
  11. import 'package:immich_mobile/shared/models/asset.dart';
  12. import 'package:immich_mobile/shared/ui/drag_sheet.dart';
  13. import 'package:immich_mobile/utils/color_filter_generator.dart';
  14. import 'package:immich_mobile/utils/debounce.dart';
  15. import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
  16. import 'package:url_launcher/url_launcher.dart';
  17. class MapPageBottomSheet extends StatefulHookConsumerWidget {
  18. final Stream mapPageEventStream;
  19. final StreamController bottomSheetEventSC;
  20. final bool selectionEnabled;
  21. final ImmichAssetGridSelectionListener selectionlistener;
  22. final bool isDarkTheme;
  23. const MapPageBottomSheet({
  24. super.key,
  25. required this.mapPageEventStream,
  26. required this.bottomSheetEventSC,
  27. required this.selectionEnabled,
  28. required this.selectionlistener,
  29. this.isDarkTheme = false,
  30. });
  31. @override
  32. AssetsInBoundBottomSheetState createState() =>
  33. AssetsInBoundBottomSheetState();
  34. }
  35. class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
  36. // Non-State variables
  37. bool userTappedOnMap = false;
  38. RenderList? _cachedRenderList;
  39. int lastAssetOffsetInSheet = -1;
  40. late final DraggableScrollableController bottomSheetController;
  41. late final Debounce debounce;
  42. @override
  43. void initState() {
  44. super.initState();
  45. bottomSheetController = DraggableScrollableController();
  46. debounce = Debounce(
  47. const Duration(milliseconds: 200),
  48. );
  49. }
  50. @override
  51. Widget build(BuildContext context) {
  52. var isDarkMode = Theme.of(context).brightness == Brightness.dark;
  53. double maxHeight = MediaQuery.of(context).size.height;
  54. final isSheetScrolled = useState(false);
  55. final isSheetExpanded = useState(false);
  56. final assetsInBound = useState(<Asset>[]);
  57. final currentExtend = useState(0.1);
  58. void handleMapPageEvents(dynamic event) {
  59. if (event is MapPageAssetsInBoundUpdated) {
  60. assetsInBound.value = event.assets;
  61. } else if (event is MapPageOnTapEvent) {
  62. userTappedOnMap = true;
  63. lastAssetOffsetInSheet = -1;
  64. bottomSheetController.animateTo(
  65. 0.1,
  66. duration: const Duration(milliseconds: 200),
  67. curve: Curves.linearToEaseOut,
  68. );
  69. isSheetScrolled.value = false;
  70. }
  71. }
  72. useEffect(
  73. () {
  74. final mapPageEventSubscription =
  75. widget.mapPageEventStream.listen(handleMapPageEvents);
  76. return mapPageEventSubscription.cancel;
  77. },
  78. [widget.mapPageEventStream],
  79. );
  80. void handleVisibleItems(ItemPosition start, ItemPosition end) {
  81. final renderElement = _cachedRenderList?.elements[start.index];
  82. if (renderElement == null) {
  83. return;
  84. }
  85. final rowOffset = renderElement.offset;
  86. if ((-start.itemLeadingEdge) != 0) {
  87. var columnOffset = -start.itemLeadingEdge ~/ 0.05;
  88. columnOffset = columnOffset < renderElement.totalCount
  89. ? columnOffset
  90. : renderElement.totalCount - 1;
  91. lastAssetOffsetInSheet = rowOffset + columnOffset;
  92. final asset = _cachedRenderList?.allAssets?[lastAssetOffsetInSheet];
  93. userTappedOnMap = false;
  94. if (!userTappedOnMap && isSheetExpanded.value) {
  95. widget.bottomSheetEventSC.add(
  96. MapPageBottomSheetScrolled(asset),
  97. );
  98. }
  99. if (isSheetExpanded.value) {
  100. isSheetScrolled.value = true;
  101. }
  102. }
  103. }
  104. void visibleItemsListener(ItemPosition start, ItemPosition end) {
  105. if (_cachedRenderList == null) {
  106. debounce.dispose();
  107. return;
  108. }
  109. debounce.call(() => handleVisibleItems(start, end));
  110. }
  111. Widget buildNoPhotosWidget() {
  112. const image = Image(
  113. image: AssetImage('assets/lighthouse.png'),
  114. );
  115. return isSheetExpanded.value
  116. ? Column(
  117. children: [
  118. const SizedBox(
  119. height: 80,
  120. ),
  121. SizedBox(
  122. height: 150,
  123. width: 150,
  124. child: isDarkMode
  125. ? const InvertionFilter(
  126. child: SaturationFilter(
  127. saturation: -1,
  128. child: BrightnessFilter(
  129. brightness: -5,
  130. child: image,
  131. ),
  132. ),
  133. )
  134. : image,
  135. ),
  136. const SizedBox(
  137. height: 20,
  138. ),
  139. Text(
  140. "map_zoom_to_see_photos".tr(),
  141. style: TextStyle(
  142. fontSize: 20,
  143. color: Theme.of(context).textTheme.displayLarge?.color,
  144. ),
  145. ),
  146. ],
  147. )
  148. : const SizedBox.shrink();
  149. }
  150. void onTapMapButton() {
  151. if (lastAssetOffsetInSheet != -1) {
  152. widget.bottomSheetEventSC.add(
  153. MapPageZoomToAsset(
  154. _cachedRenderList?.allAssets?[lastAssetOffsetInSheet],
  155. ),
  156. );
  157. }
  158. }
  159. Widget buildDragHandle(ScrollController scrollController) {
  160. final textToDisplay = assetsInBound.value.isNotEmpty
  161. ? "${assetsInBound.value.length} photo${assetsInBound.value.length > 1 ? "s" : ""}"
  162. : "map_no_assets_in_bounds".tr();
  163. final dragHandle = Container(
  164. height: 75,
  165. width: double.infinity,
  166. decoration: BoxDecoration(
  167. color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
  168. ),
  169. child: Stack(
  170. children: [
  171. Column(
  172. crossAxisAlignment: CrossAxisAlignment.center,
  173. mainAxisAlignment: MainAxisAlignment.center,
  174. children: [
  175. const SizedBox(height: 12),
  176. const CustomDraggingHandle(),
  177. const SizedBox(height: 12),
  178. Text(
  179. textToDisplay,
  180. style: TextStyle(
  181. fontSize: 16,
  182. color: Theme.of(context).textTheme.displayLarge?.color,
  183. fontWeight: FontWeight.bold,
  184. ),
  185. ),
  186. Divider(
  187. color: Theme.of(context)
  188. .textTheme
  189. .displayLarge
  190. ?.color
  191. ?.withOpacity(0.5),
  192. ),
  193. ],
  194. ),
  195. if (isSheetExpanded.value && isSheetScrolled.value)
  196. Positioned(
  197. top: 5,
  198. right: 10,
  199. child: IconButton(
  200. icon: Icon(
  201. Icons.map_outlined,
  202. color: Theme.of(context).textTheme.displayLarge?.color,
  203. ),
  204. iconSize: 20,
  205. tooltip: 'Zoom to bounds',
  206. onPressed: onTapMapButton,
  207. ),
  208. ),
  209. ],
  210. ),
  211. );
  212. return SingleChildScrollView(
  213. controller: scrollController,
  214. child: dragHandle,
  215. );
  216. }
  217. return NotificationListener<DraggableScrollableNotification>(
  218. onNotification: (DraggableScrollableNotification notification) {
  219. final sheetExtended = notification.extent > 0.2;
  220. isSheetExpanded.value = sheetExtended;
  221. currentExtend.value = notification.extent;
  222. if (!sheetExtended) {
  223. // reset state
  224. userTappedOnMap = false;
  225. lastAssetOffsetInSheet = -1;
  226. isSheetScrolled.value = false;
  227. }
  228. return true;
  229. },
  230. child: Stack(
  231. children: [
  232. DraggableScrollableSheet(
  233. controller: bottomSheetController,
  234. initialChildSize: 0.1,
  235. minChildSize: 0.1,
  236. maxChildSize: 0.55,
  237. snap: true,
  238. builder: (
  239. BuildContext context,
  240. ScrollController scrollController,
  241. ) {
  242. return Card(
  243. color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
  244. surfaceTintColor: Colors.transparent,
  245. elevation: 18.0,
  246. margin: const EdgeInsets.all(0),
  247. child: Column(
  248. children: [
  249. buildDragHandle(scrollController),
  250. if (isSheetExpanded.value && assetsInBound.value.isNotEmpty)
  251. ref
  252. .watch(
  253. renderListProvider(
  254. assetsInBound.value,
  255. ),
  256. )
  257. .when(
  258. data: (renderList) {
  259. _cachedRenderList = renderList;
  260. final assetGrid = ImmichAssetGrid(
  261. shrinkWrap: true,
  262. renderList: renderList,
  263. showDragScroll: false,
  264. selectionActive: widget.selectionEnabled,
  265. showMultiSelectIndicator: false,
  266. listener: widget.selectionlistener,
  267. visibleItemsListener: visibleItemsListener,
  268. );
  269. return Expanded(child: assetGrid);
  270. },
  271. error: (error, stackTrace) {
  272. log.warning(
  273. "Cannot get assets in the current map bounds ${error.toString()}",
  274. error,
  275. stackTrace,
  276. );
  277. return const SizedBox.shrink();
  278. },
  279. loading: () => const SizedBox.shrink(),
  280. ),
  281. if (isSheetExpanded.value && assetsInBound.value.isEmpty)
  282. Expanded(
  283. child: SingleChildScrollView(
  284. child: buildNoPhotosWidget(),
  285. ),
  286. ),
  287. ],
  288. ),
  289. );
  290. },
  291. ),
  292. Positioned(
  293. bottom: maxHeight * currentExtend.value,
  294. left: 0,
  295. child: GestureDetector(
  296. onTap: () => launchUrl(
  297. Uri.parse('https://openstreetmap.org/copyright'),
  298. ),
  299. child: ColoredBox(
  300. color:
  301. (widget.isDarkTheme ? Colors.grey[900] : Colors.grey[100])!,
  302. child: Padding(
  303. padding: const EdgeInsets.all(3),
  304. child: Text(
  305. '© OpenStreetMap contributors',
  306. style: TextStyle(
  307. fontSize: 6,
  308. color: !widget.isDarkTheme
  309. ? Colors.grey[900]
  310. : Colors.grey[100],
  311. ),
  312. ),
  313. ),
  314. ),
  315. ),
  316. ),
  317. Positioned(
  318. bottom: maxHeight * (0.14 + (currentExtend.value - 0.1)),
  319. right: 15,
  320. child: ElevatedButton(
  321. onPressed: () =>
  322. widget.bottomSheetEventSC.add(const MapPageZoomToLocation()),
  323. style: ElevatedButton.styleFrom(
  324. shape: const CircleBorder(),
  325. padding: const EdgeInsets.all(12),
  326. ),
  327. child: const Icon(
  328. Icons.my_location,
  329. size: 22,
  330. fill: 1,
  331. ),
  332. ),
  333. ),
  334. ],
  335. ),
  336. );
  337. }
  338. }