mirror of
https://github.com/immich-app/immich.git
synced 2025-01-09 13:26:47 +01:00
a147dee4b6
* maplibre on web, custom styles from server Actually use new vector tile server, custom style.json support multiple style files, light/dark mode cleanup, use new map everywhere send file directly instead of loading first better light/dark mode switching remove leaflet fix mapstyles dto, first draft of map settings delete and add styles fix delete default styles fix tests only allow one light and one dark style url revert config core changes fix server config store fix tests move axios fetches to repo fix package-lock fix tests * open api * add assets to docker container * web: use mapSettings color for style * style: add unique ids to map styles * mobile: use style json for vector / raster * do not use svelte-material-icons * add click events to markers, simplify asset detail map * improve map performance by using asset thumbnails for markers instead of original file * Remove custom attribution (by request) * mobile: update map attribution * style: map dark mode * style: map light mode * zoom level for state * styling * overflow gradient * Limit maxZoom to 14 * mobile: listen for mapStyle changes in MapThumbnail * mobile: update concurrency --------- Co-authored-by: shalong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: bo0tzz <git@bo0tzz.me> Co-authored-by: Alex <alex.tran1502@gmail.com>
361 lines
12 KiB
Dart
361 lines
12 KiB
Dart
import 'dart:async';
|
|
import 'dart:io';
|
|
|
|
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';
|
|
|
|
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 assetOffsetInSheet = -1;
|
|
late final DraggableScrollableController bottomSheetController;
|
|
late final Debounce debounce;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
bottomSheetController = DraggableScrollableController();
|
|
debounce = Debounce(
|
|
const Duration(milliseconds: 100),
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
|
final bottomPadding =
|
|
Platform.isAndroid ? MediaQuery.of(context).padding.bottom - 10 : 0.0;
|
|
final maxHeight = MediaQuery.of(context).size.height - bottomPadding;
|
|
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;
|
|
assetOffsetInSheet = -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;
|
|
assetOffsetInSheet = rowOffset + columnOffset;
|
|
final asset = _cachedRenderList?.allAssets?[assetOffsetInSheet];
|
|
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 (assetOffsetInSheet != -1) {
|
|
widget.bottomSheetEventSC.add(
|
|
MapPageZoomToAsset(
|
|
_cachedRenderList?.allAssets?[assetOffsetInSheet],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
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: 60,
|
|
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: 5),
|
|
const CustomDraggingHandle(),
|
|
const SizedBox(height: 15),
|
|
Text(
|
|
textToDisplay,
|
|
style: TextStyle(
|
|
fontSize: 16,
|
|
color: Theme.of(context).textTheme.displayLarge?.color,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
),
|
|
Divider(
|
|
height: 10,
|
|
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,
|
|
physics: const ClampingScrollPhysics(),
|
|
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;
|
|
assetOffsetInSheet = -1;
|
|
isSheetScrolled.value = false;
|
|
}
|
|
|
|
return true;
|
|
},
|
|
child: Padding(
|
|
padding: EdgeInsets.only(
|
|
bottom: bottomPadding,
|
|
),
|
|
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: 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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|