123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356 |
- import 'dart:async';
- import 'package:easy_localization/easy_localization.dart';
- import 'package:flutter/material.dart';
- import 'package:flutter_hooks/flutter_hooks.dart';
- import 'package:hooks_riverpod/hooks_riverpod.dart';
- import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
- import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
- import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
- import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
- import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
- import 'package:immich_mobile/shared/models/asset.dart';
- import 'package:immich_mobile/shared/ui/drag_sheet.dart';
- import 'package:immich_mobile/utils/color_filter_generator.dart';
- import 'package:immich_mobile/utils/debounce.dart';
- import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
- import 'package:url_launcher/url_launcher.dart';
- class MapPageBottomSheet extends StatefulHookConsumerWidget {
- final Stream mapPageEventStream;
- final StreamController bottomSheetEventSC;
- final bool selectionEnabled;
- final ImmichAssetGridSelectionListener selectionlistener;
- final bool isDarkTheme;
- const MapPageBottomSheet({
- super.key,
- required this.mapPageEventStream,
- required this.bottomSheetEventSC,
- required this.selectionEnabled,
- required this.selectionlistener,
- this.isDarkTheme = false,
- });
- @override
- AssetsInBoundBottomSheetState createState() =>
- AssetsInBoundBottomSheetState();
- }
- class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
- // Non-State variables
- bool userTappedOnMap = false;
- RenderList? _cachedRenderList;
- int lastAssetOffsetInSheet = -1;
- late final DraggableScrollableController bottomSheetController;
- late final Debounce debounce;
- @override
- void initState() {
- super.initState();
- bottomSheetController = DraggableScrollableController();
- debounce = Debounce(
- const Duration(milliseconds: 200),
- );
- }
- @override
- Widget build(BuildContext context) {
- var isDarkMode = Theme.of(context).brightness == Brightness.dark;
- double maxHeight = MediaQuery.of(context).size.height;
- final isSheetScrolled = useState(false);
- final isSheetExpanded = useState(false);
- final assetsInBound = useState(<Asset>[]);
- final currentExtend = useState(0.1);
- void handleMapPageEvents(dynamic event) {
- if (event is MapPageAssetsInBoundUpdated) {
- assetsInBound.value = event.assets;
- } else if (event is MapPageOnTapEvent) {
- userTappedOnMap = true;
- lastAssetOffsetInSheet = -1;
- bottomSheetController.animateTo(
- 0.1,
- duration: const Duration(milliseconds: 200),
- curve: Curves.linearToEaseOut,
- );
- isSheetScrolled.value = false;
- }
- }
- useEffect(
- () {
- final mapPageEventSubscription =
- widget.mapPageEventStream.listen(handleMapPageEvents);
- return mapPageEventSubscription.cancel;
- },
- [widget.mapPageEventStream],
- );
- void handleVisibleItems(ItemPosition start, ItemPosition end) {
- final renderElement = _cachedRenderList?.elements[start.index];
- if (renderElement == null) {
- return;
- }
- final rowOffset = renderElement.offset;
- if ((-start.itemLeadingEdge) != 0) {
- var columnOffset = -start.itemLeadingEdge ~/ 0.05;
- columnOffset = columnOffset < renderElement.totalCount
- ? columnOffset
- : renderElement.totalCount - 1;
- lastAssetOffsetInSheet = rowOffset + columnOffset;
- final asset = _cachedRenderList?.allAssets?[lastAssetOffsetInSheet];
- userTappedOnMap = false;
- if (!userTappedOnMap && isSheetExpanded.value) {
- widget.bottomSheetEventSC.add(
- MapPageBottomSheetScrolled(asset),
- );
- }
- if (isSheetExpanded.value) {
- isSheetScrolled.value = true;
- }
- }
- }
- void visibleItemsListener(ItemPosition start, ItemPosition end) {
- if (_cachedRenderList == null) {
- debounce.dispose();
- return;
- }
- debounce.call(() => handleVisibleItems(start, end));
- }
- Widget buildNoPhotosWidget() {
- const image = Image(
- image: AssetImage('assets/lighthouse.png'),
- );
- return isSheetExpanded.value
- ? Column(
- children: [
- const SizedBox(
- height: 80,
- ),
- SizedBox(
- height: 150,
- width: 150,
- child: isDarkMode
- ? const InvertionFilter(
- child: SaturationFilter(
- saturation: -1,
- child: BrightnessFilter(
- brightness: -5,
- child: image,
- ),
- ),
- )
- : image,
- ),
- const SizedBox(
- height: 20,
- ),
- Text(
- "map_zoom_to_see_photos".tr(),
- style: TextStyle(
- fontSize: 20,
- color: Theme.of(context).textTheme.displayLarge?.color,
- ),
- ),
- ],
- )
- : const SizedBox.shrink();
- }
- void onTapMapButton() {
- if (lastAssetOffsetInSheet != -1) {
- widget.bottomSheetEventSC.add(
- MapPageZoomToAsset(
- _cachedRenderList?.allAssets?[lastAssetOffsetInSheet],
- ),
- );
- }
- }
- Widget buildDragHandle(ScrollController scrollController) {
- final textToDisplay = assetsInBound.value.isNotEmpty
- ? "${assetsInBound.value.length} photo${assetsInBound.value.length > 1 ? "s" : ""}"
- : "map_no_assets_in_bounds".tr();
- final dragHandle = Container(
- height: 75,
- width: double.infinity,
- decoration: BoxDecoration(
- color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
- ),
- child: Stack(
- children: [
- Column(
- crossAxisAlignment: CrossAxisAlignment.center,
- mainAxisAlignment: MainAxisAlignment.center,
- children: [
- const SizedBox(height: 12),
- const CustomDraggingHandle(),
- const SizedBox(height: 12),
- Text(
- textToDisplay,
- style: TextStyle(
- fontSize: 16,
- color: Theme.of(context).textTheme.displayLarge?.color,
- fontWeight: FontWeight.bold,
- ),
- ),
- Divider(
- color: Theme.of(context)
- .textTheme
- .displayLarge
- ?.color
- ?.withOpacity(0.5),
- ),
- ],
- ),
- if (isSheetExpanded.value && isSheetScrolled.value)
- Positioned(
- top: 5,
- right: 10,
- child: IconButton(
- icon: Icon(
- Icons.map_outlined,
- color: Theme.of(context).textTheme.displayLarge?.color,
- ),
- iconSize: 20,
- tooltip: 'Zoom to bounds',
- onPressed: onTapMapButton,
- ),
- ),
- ],
- ),
- );
- return SingleChildScrollView(
- controller: scrollController,
- child: dragHandle,
- );
- }
- return NotificationListener<DraggableScrollableNotification>(
- onNotification: (DraggableScrollableNotification notification) {
- final sheetExtended = notification.extent > 0.2;
- isSheetExpanded.value = sheetExtended;
- currentExtend.value = notification.extent;
- if (!sheetExtended) {
- // reset state
- userTappedOnMap = false;
- lastAssetOffsetInSheet = -1;
- isSheetScrolled.value = false;
- }
- return true;
- },
- child: Stack(
- children: [
- DraggableScrollableSheet(
- controller: bottomSheetController,
- initialChildSize: 0.1,
- minChildSize: 0.1,
- maxChildSize: 0.55,
- snap: true,
- builder: (
- BuildContext context,
- ScrollController scrollController,
- ) {
- return Card(
- color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
- surfaceTintColor: Colors.transparent,
- elevation: 18.0,
- margin: const EdgeInsets.all(0),
- child: Column(
- children: [
- buildDragHandle(scrollController),
- if (isSheetExpanded.value && assetsInBound.value.isNotEmpty)
- ref
- .watch(
- renderListProvider(
- assetsInBound.value,
- ),
- )
- .when(
- data: (renderList) {
- _cachedRenderList = renderList;
- final assetGrid = ImmichAssetGrid(
- shrinkWrap: true,
- renderList: renderList,
- showDragScroll: false,
- selectionActive: widget.selectionEnabled,
- showMultiSelectIndicator: false,
- listener: widget.selectionlistener,
- visibleItemsListener: visibleItemsListener,
- );
- return Expanded(child: assetGrid);
- },
- error: (error, stackTrace) {
- log.warning(
- "Cannot get assets in the current map bounds ${error.toString()}",
- error,
- stackTrace,
- );
- return const SizedBox.shrink();
- },
- loading: () => const SizedBox.shrink(),
- ),
- if (isSheetExpanded.value && assetsInBound.value.isEmpty)
- Expanded(
- child: SingleChildScrollView(
- child: buildNoPhotosWidget(),
- ),
- ),
- ],
- ),
- );
- },
- ),
- Positioned(
- bottom: maxHeight * currentExtend.value,
- left: 0,
- child: GestureDetector(
- onTap: () => launchUrl(
- Uri.parse('https://openstreetmap.org/copyright'),
- ),
- child: ColoredBox(
- color:
- (widget.isDarkTheme ? Colors.grey[900] : Colors.grey[100])!,
- child: Padding(
- padding: const EdgeInsets.all(3),
- child: Text(
- '© OpenStreetMap contributors',
- style: TextStyle(
- fontSize: 6,
- color: !widget.isDarkTheme
- ? Colors.grey[900]
- : Colors.grey[100],
- ),
- ),
- ),
- ),
- ),
- ),
- Positioned(
- bottom: maxHeight * (0.14 + (currentExtend.value - 0.1)),
- right: 15,
- child: ElevatedButton(
- onPressed: () =>
- widget.bottomSheetEventSC.add(const MapPageZoomToLocation()),
- style: ElevatedButton.styleFrom(
- shape: const CircleBorder(),
- padding: const EdgeInsets.all(12),
- ),
- child: const Icon(
- Icons.my_location,
- size: 22,
- fill: 1,
- ),
- ),
- ),
- ],
- ),
- );
- }
- }
|