mirror of
https://github.com/KevinMidboe/immich.git
synced 2025-10-29 17:40:28 +00:00
feat(mobile): map view (#3661)
* feat(mobile): map page - add map view * map: add map-markers * feat(map): add relative date filter * fix: do not let users scroll past map bounds * fix: fetch relative date from store to state on init * feat(mobile):re-fetch markers only on filter change * feat(mobile) - asset bottom sheet in map page * feat(mobile): display markers based on bottom sheet scroll * fix: exif-bottom-sheet - rebase conflict * feat(mobile): map-view - strongly typed map page events * feat(map): zoom to asset * chore: dart analyzer fixes * map-page move attribution to top-right * feat(mobile): map view - asset selection handling * feat(mobile): map-view display map in places row * fix: make asset marker icon responsive * optimise map page rebuilds * refactor(mobile): map page * feat(mobile): map-view: Go to location * map-view(mobile): minor refactor * fix(mobile): Handle invalid coords gracefully * small styling --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
144
mobile/lib/modules/map/ui/asset_marker_icon.dart
Normal file
144
mobile/lib/modules/map/ui/asset_marker_icon.dart
Normal file
@@ -0,0 +1,144 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class AssetMarkerIcon extends StatelessWidget {
|
||||
const AssetMarkerIcon({
|
||||
Key? key,
|
||||
required this.id,
|
||||
this.isDarkTheme = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final String id;
|
||||
final bool isDarkTheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = getThumbnailUrlForRemoteId(id);
|
||||
final cacheKey = getThumbnailCacheKeyForRemoteId(id);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: constraints.maxWidth * 0.5,
|
||||
child: CustomPaint(
|
||||
painter: _PinPainter(
|
||||
primaryColor: isDarkTheme ? Colors.white : Colors.black,
|
||||
secondaryColor: isDarkTheme ? Colors.black : Colors.white,
|
||||
primaryRadius: constraints.maxHeight * 0.06,
|
||||
secondaryRadius: constraints.maxHeight * 0.038,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: constraints.maxHeight * 0.14,
|
||||
width: constraints.maxWidth * 0.14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: constraints.maxHeight * 0.07,
|
||||
left: constraints.maxWidth * 0.17,
|
||||
child: CircleAvatar(
|
||||
radius: constraints.maxHeight * 0.40,
|
||||
backgroundColor: isDarkTheme ? Colors.white : Colors.black,
|
||||
child: CircleAvatar(
|
||||
radius: constraints.maxHeight * 0.37,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
cacheKey: cacheKey,
|
||||
headers: {
|
||||
"Authorization":
|
||||
"Bearer ${Store.get(StoreKey.accessToken)}",
|
||||
},
|
||||
errorListener: () =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinPainter extends CustomPainter {
|
||||
final Color primaryColor;
|
||||
final Color secondaryColor;
|
||||
final double primaryRadius;
|
||||
final double secondaryRadius;
|
||||
|
||||
_PinPainter({
|
||||
this.primaryColor = Colors.black,
|
||||
this.secondaryColor = Colors.white,
|
||||
required this.primaryRadius,
|
||||
required this.secondaryRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint primaryBrush = Paint()
|
||||
..color = primaryColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
Paint secondaryBrush = Paint()
|
||||
..color = secondaryColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
Paint lineBrush = Paint()
|
||||
..color = primaryColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
|
||||
canvas.drawCircle(
|
||||
Offset(size.width / 2, size.height),
|
||||
primaryRadius,
|
||||
primaryBrush,
|
||||
);
|
||||
canvas.drawCircle(
|
||||
Offset(size.width / 2, size.height),
|
||||
secondaryRadius,
|
||||
secondaryBrush,
|
||||
);
|
||||
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
|
||||
// The line is to make the above triangluar path more prominent since it has a slight curve
|
||||
canvas.drawLine(
|
||||
Offset(size.width / 2, 0),
|
||||
Offset(
|
||||
size.width / 2,
|
||||
size.height,
|
||||
),
|
||||
lineBrush,
|
||||
);
|
||||
}
|
||||
|
||||
Path getTrianglePath(double x, double y) {
|
||||
final firstEndPoint = Offset(x / 2, y);
|
||||
final controlPoint = Offset(x / 2, y * 0.3);
|
||||
final secondEndPoint = Offset(x, 0);
|
||||
|
||||
return Path()
|
||||
..quadraticBezierTo(
|
||||
controlPoint.dx,
|
||||
controlPoint.dy,
|
||||
firstEndPoint.dx,
|
||||
firstEndPoint.dy,
|
||||
)
|
||||
..quadraticBezierTo(
|
||||
controlPoint.dx,
|
||||
controlPoint.dy,
|
||||
secondEndPoint.dx,
|
||||
secondEndPoint.dy,
|
||||
)
|
||||
..lineTo(0, 0);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_PinPainter old) {
|
||||
return old.primaryColor != primaryColor ||
|
||||
old.secondaryColor != secondaryColor;
|
||||
}
|
||||
}
|
||||
30
mobile/lib/modules/map/ui/location_dialog.dart
Normal file
30
mobile/lib/modules/map/ui/location_dialog.dart
Normal file
@@ -0,0 +1,30 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
|
||||
|
||||
class LocationServiceDisabledDialog extends ConfirmDialog {
|
||||
LocationServiceDisabledDialog({Key? key})
|
||||
: super(
|
||||
key: key,
|
||||
title: 'map_location_service_disabled_title'.tr(),
|
||||
content: 'map_location_service_disabled_content'.tr(),
|
||||
cancel: 'map_location_dialog_cancel'.tr(),
|
||||
ok: 'map_location_dialog_yes'.tr(),
|
||||
onOk: () async {
|
||||
await Geolocator.openLocationSettings();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class LocationPermissionDisabledDialog extends ConfirmDialog {
|
||||
LocationPermissionDisabledDialog({Key? key})
|
||||
: super(
|
||||
key: key,
|
||||
title: 'map_no_location_permission_title'.tr(),
|
||||
content: 'map_no_location_permission_content'.tr(),
|
||||
cancel: 'map_location_dialog_cancel'.tr(),
|
||||
ok: 'map_location_dialog_yes'.tr(),
|
||||
onOk: () {},
|
||||
);
|
||||
}
|
||||
138
mobile/lib/modules/map/ui/map_page_app_bar.dart
Normal file
138
mobile/lib/modules/map/ui/map_page_app_bar.dart
Normal file
@@ -0,0 +1,138 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/disable_multi_select_button.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_settings_dialog.dart';
|
||||
|
||||
class MapAppBar extends HookWidget implements PreferredSizeWidget {
|
||||
final ValueNotifier<bool> selectionEnabled;
|
||||
final int selectedAssetsLength;
|
||||
final bool isDarkTheme;
|
||||
|
||||
final void Function() onShare;
|
||||
final void Function() onFavorite;
|
||||
final void Function() onArchive;
|
||||
|
||||
const MapAppBar({
|
||||
super.key,
|
||||
required this.selectionEnabled,
|
||||
required this.selectedAssetsLength,
|
||||
required this.onShare,
|
||||
required this.onArchive,
|
||||
required this.onFavorite,
|
||||
this.isDarkTheme = false,
|
||||
});
|
||||
|
||||
List<Widget> buildNonSelectionWidgets(BuildContext context) {
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 15, top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => AutoRouter.of(context).pop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 22),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 15, top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return const MapSettingsDialog();
|
||||
},
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(Icons.more_vert_rounded, size: 22),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> buildSelectionWidgets() {
|
||||
return [
|
||||
DisableMultiSelectButton(
|
||||
onPressed: () {
|
||||
selectionEnabled.value = false;
|
||||
},
|
||||
selectedItemCount: selectedAssetsLength,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// Share button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: onShare,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isAndroid
|
||||
? Icons.share_rounded
|
||||
: Icons.ios_share_rounded,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Favorite button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: onFavorite,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.favorite,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Archive Button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10, top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: onArchive,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.archive,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 30),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (!selectionEnabled.value) ...buildNonSelectionWidgets(context),
|
||||
if (selectionEnabled.value) ...buildSelectionWidgets(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(100);
|
||||
}
|
||||
356
mobile/lib/modules/map/ui/map_page_bottom_sheet.dart
Normal file
356
mobile/lib/modules/map/ui/map_page_bottom_sheet.dart
Normal file
@@ -0,0 +1,356 @@
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
193
mobile/lib/modules/map/ui/map_settings_dialog.dart
Normal file
193
mobile/lib/modules/map/ui/map_settings_dialog.dart
Normal file
@@ -0,0 +1,193 @@
|
||||
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/map/providers/map_state.provider.dart';
|
||||
|
||||
class MapSettingsDialog extends HookConsumerWidget {
|
||||
const MapSettingsDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mapSettingsNotifier = ref.read(mapStateNotifier.notifier);
|
||||
final mapSettings = ref.read(mapStateNotifier);
|
||||
final isDarkMode = useState(mapSettings.isDarkTheme);
|
||||
final showFavoriteOnly = useState(mapSettings.showFavoriteOnly);
|
||||
final showRelativeDate = useState(mapSettings.relativeTime);
|
||||
final ThemeData theme = Theme.of(context);
|
||||
|
||||
Widget buildMapThemeSetting() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: isDarkMode.value,
|
||||
onChanged: (value) {
|
||||
isDarkMode.value = value;
|
||||
},
|
||||
activeColor: theme.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"map_settings_dark_mode".tr(),
|
||||
style:
|
||||
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildFavoriteOnlySetting() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: showFavoriteOnly.value,
|
||||
onChanged: (value) {
|
||||
showFavoriteOnly.value = value;
|
||||
},
|
||||
activeColor: theme.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"map_settings_only_show_favorites".tr(),
|
||||
style:
|
||||
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDateRangeSetting() {
|
||||
final now = DateTime.now();
|
||||
return DropdownMenu(
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
initialSelection: showRelativeDate.value,
|
||||
onSelected: (value) {
|
||||
showRelativeDate.value = value!;
|
||||
},
|
||||
dropdownMenuEntries: [
|
||||
const DropdownMenuEntry(value: 0, label: "All"),
|
||||
const DropdownMenuEntry(
|
||||
value: 1,
|
||||
label: "Past 24 hours",
|
||||
),
|
||||
const DropdownMenuEntry(
|
||||
value: 7,
|
||||
label: "Past 7 days",
|
||||
),
|
||||
const DropdownMenuEntry(
|
||||
value: 30,
|
||||
label: "Past 30 days",
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 1,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "Past year",
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 3,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "Past 3 years",
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> getDialogActions() {
|
||||
return <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor:
|
||||
mapSettings.isDarkTheme ? Colors.grey[100] : Colors.grey[700],
|
||||
),
|
||||
child: Text(
|
||||
"map_settings_dialog_cancel".tr(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
mapSettings.isDarkTheme ? Colors.grey[900] : Colors.grey[100],
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
mapSettingsNotifier.switchTheme(isDarkMode.value);
|
||||
mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value);
|
||||
mapSettingsNotifier.setRelativeTime(showRelativeDate.value);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: theme.primaryColor,
|
||||
),
|
||||
child: Text(
|
||||
"map_settings_dialog_save".tr(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.primaryTextTheme.labelLarge?.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
title: Center(
|
||||
child: Text(
|
||||
"map_settings_dialog_title".tr(),
|
||||
style: TextStyle(
|
||||
color: theme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||
),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
buildMapThemeSetting(),
|
||||
buildFavoriteOnlySetting(),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"map_settings_only_relative_range".tr(),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
buildDateRangeSetting(),
|
||||
],
|
||||
),
|
||||
),
|
||||
].toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: getDialogActions(),
|
||||
actionsAlignment: MainAxisAlignment.spaceEvenly,
|
||||
);
|
||||
}
|
||||
}
|
||||
76
mobile/lib/modules/map/ui/map_thumbnail.dart
Normal file
76
mobile/lib/modules/map/ui/map_thumbnail.dart
Normal file
@@ -0,0 +1,76 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
// A non-interactive thumbnail of a map in the given coordinates with optional markers
|
||||
class MapThumbnail extends HookConsumerWidget {
|
||||
final Function(TapPosition, LatLng)? onTap;
|
||||
final LatLng coords;
|
||||
final double zoom;
|
||||
final List<Marker> markers;
|
||||
final double height;
|
||||
final bool showAttribution;
|
||||
final bool isDarkTheme;
|
||||
|
||||
const MapThumbnail({
|
||||
super.key,
|
||||
required this.coords,
|
||||
required this.height,
|
||||
this.onTap,
|
||||
this.zoom = 1,
|
||||
this.showAttribution = true,
|
||||
this.isDarkTheme = false,
|
||||
this.markers = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
interactiveFlags: InteractiveFlag.none,
|
||||
center: coords,
|
||||
zoom: zoom,
|
||||
onTap: onTap,
|
||||
),
|
||||
nonRotatedChildren: [
|
||||
if (showAttribution)
|
||||
RichAttributionWidget(
|
||||
animationConfig: const ScaleRAWA(),
|
||||
attributions: [
|
||||
TextSourceAttribution(
|
||||
'OpenStreetMap contributors',
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
children: [
|
||||
isDarkTheme
|
||||
? InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: tileLayer,
|
||||
),
|
||||
)
|
||||
: tileLayer,
|
||||
if (markers.isNotEmpty) MarkerLayer(markers: markers),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user