mirror of
https://github.com/immich-app/immich.git
synced 2024-11-27 09:21:05 +02:00
refactor(mobile): maplibre (#6087)
* chore: maplibre gl pubspec * refactor(wip): maplibre for maps * refactor(wip): dual pane + location button * chore: remove flutter_map and deps * refactor(wip): map zoom to location * refactor: location picker * open gallery_viewer on marker tap * remove detectScaleGesture param * test: debounce and throttle * chore: rename get location method * feat(mobile): Adds gps locator to map prompt for easy geolocation (#6282) * Refactored get gps coords * Use var for linter's sake, should handle errors better * Cleanup * Fix linter issues * chore(dep): update maplibre to official lib --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Joshua Herrera <joshua.herrera227@gmail.com>
This commit is contained in:
parent
aa8c54e248
commit
e6c0f0e3aa
@ -253,7 +253,7 @@
|
||||
"map_no_assets_in_bounds": "No photos in this area",
|
||||
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
|
||||
"map_no_location_permission_title": "Location Permission denied",
|
||||
"map_settings_dark_mode": "Dark mode",
|
||||
"map_settings_theme_settings": "Map Theme",
|
||||
"map_settings_date_range_option_all": "All",
|
||||
"map_settings_date_range_option_day": "Past 24 hours",
|
||||
"map_settings_date_range_option_days": "Past {} days",
|
||||
|
Binary file not shown.
Before Width: | Height: | Size: 50 KiB After Width: | Height: | Size: 23 KiB |
@ -28,6 +28,10 @@ PODS:
|
||||
- Flutter
|
||||
- isar_flutter_libs (1.0.0):
|
||||
- Flutter
|
||||
- MapLibre (5.14.0-pre3)
|
||||
- maplibre_gl (0.0.1):
|
||||
- Flutter
|
||||
- MapLibre (= 5.14.0-pre3)
|
||||
- package_info_plus (0.4.5):
|
||||
- Flutter
|
||||
- path_provider_foundation (0.0.1):
|
||||
@ -71,6 +75,7 @@ DEPENDENCIES:
|
||||
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||
- maplibre_gl (from `.symlinks/plugins/maplibre_gl/ios`)
|
||||
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
|
||||
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
|
||||
- path_provider_ios (from `.symlinks/plugins/path_provider_ios/ios`)
|
||||
@ -86,6 +91,7 @@ DEPENDENCIES:
|
||||
SPEC REPOS:
|
||||
trunk:
|
||||
- FMDB
|
||||
- MapLibre
|
||||
- ReachabilitySwift
|
||||
- SAMKeychain
|
||||
- Toast
|
||||
@ -115,6 +121,8 @@ EXTERNAL SOURCES:
|
||||
:path: ".symlinks/plugins/integration_test/ios"
|
||||
isar_flutter_libs:
|
||||
:path: ".symlinks/plugins/isar_flutter_libs/ios"
|
||||
maplibre_gl:
|
||||
:path: ".symlinks/plugins/maplibre_gl/ios"
|
||||
package_info_plus:
|
||||
:path: ".symlinks/plugins/package_info_plus/ios"
|
||||
path_provider_foundation:
|
||||
@ -152,6 +160,8 @@ SPEC CHECKSUMS:
|
||||
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
|
||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
||||
MapLibre: 620fc933c1d6029b33738c905c1490d024e5d4ef
|
||||
maplibre_gl: a2efec727dd340e4c65e26d2b03b584f14881fd9
|
||||
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
|
||||
path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943
|
||||
path_provider_ios: 14f3d2fd28c4fdb42f44e0f751d12861c43cee02
|
||||
|
@ -96,3 +96,9 @@ extension AssetListExtension on Iterable<Asset> {
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
extension SortedByProperty<T> on Iterable<T> {
|
||||
Iterable<T> sortedByField(Comparable Function(T e) key) {
|
||||
return sorted((a, b) => key(a).compareTo(key(b)));
|
||||
}
|
||||
}
|
||||
|
@ -1,67 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'dart:math' as math;
|
||||
|
||||
extension MoveByBounds on MapController {
|
||||
// TODO: Remove this in favor of built-in method when upgrading flutter_map to 5.0.0
|
||||
LatLng? centerBoundsWithPadding(
|
||||
LatLng coordinates,
|
||||
Offset offset, {
|
||||
double? zoomLevel,
|
||||
}) {
|
||||
const crs = Epsg3857();
|
||||
final oldCenterPt = crs.latLngToPoint(coordinates, zoomLevel ?? zoom);
|
||||
final mapCenterPoint = _rotatePoint(
|
||||
oldCenterPt,
|
||||
oldCenterPt - CustomPoint(offset.dx, offset.dy),
|
||||
);
|
||||
return crs.pointToLatLng(mapCenterPoint, zoomLevel ?? zoom);
|
||||
}
|
||||
|
||||
CustomPoint<double> _rotatePoint(
|
||||
CustomPoint<double> mapCenter,
|
||||
CustomPoint<double> point, {
|
||||
bool counterRotation = true,
|
||||
}) {
|
||||
final counterRotationFactor = counterRotation ? -1 : 1;
|
||||
|
||||
final m = Matrix4.identity()
|
||||
..translate(mapCenter.x, mapCenter.y)
|
||||
..rotateZ(degToRadian(rotation) * counterRotationFactor)
|
||||
..translate(-mapCenter.x, -mapCenter.y);
|
||||
|
||||
final tp = MatrixUtils.transformPoint(m, Offset(point.x, point.y));
|
||||
|
||||
return CustomPoint(tp.dx, tp.dy);
|
||||
}
|
||||
|
||||
double getTapThresholdForZoomLevel() {
|
||||
const scale = [
|
||||
25000000,
|
||||
15000000,
|
||||
8000000,
|
||||
4000000,
|
||||
2000000,
|
||||
1000000,
|
||||
500000,
|
||||
250000,
|
||||
100000,
|
||||
50000,
|
||||
25000,
|
||||
15000,
|
||||
8000,
|
||||
4000,
|
||||
2000,
|
||||
1000,
|
||||
500,
|
||||
250,
|
||||
100,
|
||||
50,
|
||||
25,
|
||||
10,
|
||||
5,
|
||||
];
|
||||
return scale[math.max(0, math.min(20, zoom.round() + 2))].toDouble() / 6;
|
||||
}
|
||||
}
|
20
mobile/lib/extensions/latlngbounds_extension.dart
Normal file
20
mobile/lib/extensions/latlngbounds_extension.dart
Normal file
@ -0,0 +1,20 @@
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
extension WithinBounds on LatLngBounds {
|
||||
/// Checks whether [point] is inside bounds
|
||||
bool contains(LatLng point) {
|
||||
final sw = point;
|
||||
final ne = point;
|
||||
return containsBounds(LatLngBounds(southwest: sw, northeast: ne));
|
||||
}
|
||||
|
||||
/// Checks whether [bounds] is contained inside bounds
|
||||
bool containsBounds(LatLngBounds bounds) {
|
||||
final sw = bounds.southwest;
|
||||
final ne = bounds.northeast;
|
||||
return (sw.latitude >= southwest.latitude) &&
|
||||
(ne.latitude <= northeast.latitude) &&
|
||||
(sw.longitude >= southwest.longitude) &&
|
||||
(ne.longitude <= northeast.longitude);
|
||||
}
|
||||
}
|
71
mobile/lib/extensions/maplibrecontroller_extensions.dart
Normal file
71
mobile/lib/extensions/maplibrecontroller_extensions.dart
Normal file
@ -0,0 +1,71 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_marker.dart';
|
||||
import 'package:immich_mobile/modules/map/utils/map_utils.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
extension MapMarkers on MaplibreMapController {
|
||||
Future<void> addGeoJSONSourceForMarkers(List<MapMarker> markers) async {
|
||||
return addSource(
|
||||
MapUtils.defaultSourceId,
|
||||
GeojsonSourceProperties(
|
||||
data: MapUtils.generateGeoJsonForMarkers(markers.toList()),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> reloadAllLayersForMarkers(List<MapMarker> markers) async {
|
||||
// !! Make sure to remove layers before sources else the native
|
||||
// maplibre library would crash when removing the source saying that
|
||||
// the source is still in use
|
||||
final existingLayers = await getLayerIds();
|
||||
if (existingLayers.contains(MapUtils.defaultHeatMapLayerId)) {
|
||||
await removeLayer(MapUtils.defaultHeatMapLayerId);
|
||||
}
|
||||
|
||||
final existingSources = await getSourceIds();
|
||||
if (existingSources.contains(MapUtils.defaultSourceId)) {
|
||||
await removeSource(MapUtils.defaultSourceId);
|
||||
}
|
||||
|
||||
await addGeoJSONSourceForMarkers(markers);
|
||||
|
||||
await addHeatmapLayer(
|
||||
MapUtils.defaultSourceId,
|
||||
MapUtils.defaultHeatMapLayerId,
|
||||
MapUtils.defaultHeatMapLayerProperties,
|
||||
);
|
||||
}
|
||||
|
||||
Future<Symbol?> addMarkerAtLatLng(LatLng centre) async {
|
||||
// no marker is displayed if asset-path is incorrect
|
||||
try {
|
||||
final ByteData bytes = await rootBundle.load("assets/location-pin.png");
|
||||
await addImage("mapMarker", bytes.buffer.asUint8List());
|
||||
return addSymbol(
|
||||
SymbolOptions(
|
||||
geometry: centre,
|
||||
iconImage: "mapMarker",
|
||||
iconSize: 0.15,
|
||||
iconAnchor: "bottom",
|
||||
),
|
||||
);
|
||||
} finally {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Future<LatLngBounds> getBoundsFromPoint(
|
||||
Point<double> point,
|
||||
double distance,
|
||||
) async {
|
||||
final southWestPx = Point(point.x - distance, point.y + distance);
|
||||
final northEastPx = Point(point.x + distance, point.y - distance);
|
||||
|
||||
final southWest = await toLatLng(southWestPx);
|
||||
final northEast = await toLatLng(northEastPx);
|
||||
|
||||
return LatLngBounds(southwest: southWest, northeast: northEast);
|
||||
}
|
||||
}
|
Binary file not shown.
@ -2,19 +2,18 @@ import 'dart:io';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asset_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ExifBottomSheet extends HookConsumerWidget {
|
||||
@ -92,26 +91,14 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return MapThumbnail(
|
||||
showAttribution: false,
|
||||
coords: LatLng(
|
||||
centre: LatLng(
|
||||
exifInfo?.latitude ?? 0,
|
||||
exifInfo?.longitude ?? 0,
|
||||
),
|
||||
height: 150,
|
||||
width: constraints.maxWidth,
|
||||
zoom: 12.0,
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: LatLng(
|
||||
exifInfo?.latitude ?? 0,
|
||||
exifInfo?.longitude ?? 0,
|
||||
),
|
||||
builder: (ctx) => const Image(
|
||||
image: AssetImage('assets/location-pin.png'),
|
||||
),
|
||||
),
|
||||
],
|
||||
assetMarkerRemoteId: asset.remoteId,
|
||||
onTap: (tapPosition, latLong) async {
|
||||
Uri? uri = await createCoordinatesUri();
|
||||
|
||||
|
@ -27,7 +27,7 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
final bool canDeselect;
|
||||
final bool? dynamicLayout;
|
||||
final bool showMultiSelectIndicator;
|
||||
final void Function(ItemPosition start, ItemPosition end)?
|
||||
final void Function(Iterable<ItemPosition> itemPositions)?
|
||||
visibleItemsListener;
|
||||
final Widget? topWidget;
|
||||
final bool shrinkWrap;
|
||||
@ -89,8 +89,10 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||
};
|
||||
|
||||
scale.onUpdate = (details) {
|
||||
scaleFactor.value =
|
||||
max(min(5.0, baseScaleFactor.value * details.scale), 1.0);
|
||||
scaleFactor.value = max(
|
||||
min(5.0, baseScaleFactor.value * details.scale),
|
||||
1.0,
|
||||
);
|
||||
if (7 - scaleFactor.value.toInt() != perRow.value) {
|
||||
perRow.value = 7 - scaleFactor.value.toInt();
|
||||
}
|
||||
|
@ -32,7 +32,7 @@ class ImmichAssetGridView extends StatefulWidget {
|
||||
final bool canDeselect;
|
||||
final bool dynamicLayout;
|
||||
final bool showMultiSelectIndicator;
|
||||
final void Function(ItemPosition start, ItemPosition end)?
|
||||
final void Function(Iterable<ItemPosition> itemPositions)?
|
||||
visibleItemsListener;
|
||||
final Widget? topWidget;
|
||||
final int heroOffset;
|
||||
@ -421,15 +421,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||
|
||||
void _positionListener() {
|
||||
final values = _itemPositionsListener.itemPositions.value;
|
||||
final start = values.firstOrNull;
|
||||
final end = values.lastOrNull;
|
||||
if (start != null && end != null) {
|
||||
if (start.index <= end.index) {
|
||||
widget.visibleItemsListener?.call(start, end);
|
||||
} else {
|
||||
widget.visibleItemsListener?.call(end, start);
|
||||
}
|
||||
}
|
||||
widget.visibleItemsListener?.call(values);
|
||||
}
|
||||
|
||||
void _scrollToTop() {
|
||||
|
13
mobile/lib/modules/map/models/map_event.model.dart
Normal file
13
mobile/lib/modules/map/models/map_event.model.dart
Normal file
@ -0,0 +1,13 @@
|
||||
// ignore_for_file: add-copy-with
|
||||
|
||||
sealed class MapEvent {
|
||||
const MapEvent();
|
||||
}
|
||||
|
||||
class MapAssetsInBoundsUpdated extends MapEvent {
|
||||
final List<String> assetRemoteIds;
|
||||
|
||||
const MapAssetsInBoundsUpdated(this.assetRemoteIds);
|
||||
}
|
||||
|
||||
class MapCloseBottomSheet extends MapEvent {}
|
39
mobile/lib/modules/map/models/map_marker.dart
Normal file
39
mobile/lib/modules/map/models/map_marker.dart
Normal file
@ -0,0 +1,39 @@
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
class MapMarker {
|
||||
final LatLng latLng;
|
||||
final String assetRemoteId;
|
||||
MapMarker({
|
||||
required this.latLng,
|
||||
required this.assetRemoteId,
|
||||
});
|
||||
|
||||
MapMarker copyWith({
|
||||
LatLng? latLng,
|
||||
String? assetRemoteId,
|
||||
}) {
|
||||
return MapMarker(
|
||||
latLng: latLng ?? this.latLng,
|
||||
assetRemoteId: assetRemoteId ?? this.assetRemoteId,
|
||||
);
|
||||
}
|
||||
|
||||
MapMarker.fromDto(MapMarkerResponseDto dto)
|
||||
: latLng = LatLng(dto.lat, dto.lon),
|
||||
assetRemoteId = dto.id;
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'MapMarker(latLng: $latLng, assetRemoteId: $assetRemoteId)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant MapMarker other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.latLng == latLng && other.assetRemoteId == assetRemoteId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => latLng.hashCode ^ assetRemoteId.hashCode;
|
||||
}
|
@ -1,40 +0,0 @@
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
|
||||
enum MapPageEventType {
|
||||
mapTap,
|
||||
bottomSheetScrolled,
|
||||
assetsInBoundUpdated,
|
||||
zoomToAsset,
|
||||
zoomToCurrentLocation,
|
||||
}
|
||||
|
||||
class MapPageEventBase {
|
||||
final MapPageEventType type;
|
||||
|
||||
const MapPageEventBase(this.type);
|
||||
}
|
||||
|
||||
class MapPageOnTapEvent extends MapPageEventBase {
|
||||
const MapPageOnTapEvent() : super(MapPageEventType.mapTap);
|
||||
}
|
||||
|
||||
class MapPageAssetsInBoundUpdated extends MapPageEventBase {
|
||||
List<Asset> assets;
|
||||
MapPageAssetsInBoundUpdated(this.assets)
|
||||
: super(MapPageEventType.assetsInBoundUpdated);
|
||||
}
|
||||
|
||||
class MapPageBottomSheetScrolled extends MapPageEventBase {
|
||||
Asset? asset;
|
||||
MapPageBottomSheetScrolled(this.asset)
|
||||
: super(MapPageEventType.bottomSheetScrolled);
|
||||
}
|
||||
|
||||
class MapPageZoomToAsset extends MapPageEventBase {
|
||||
Asset? asset;
|
||||
MapPageZoomToAsset(this.asset) : super(MapPageEventType.zoomToAsset);
|
||||
}
|
||||
|
||||
class MapPageZoomToLocation extends MapPageEventBase {
|
||||
const MapPageZoomToLocation() : super(MapPageEventType.zoomToCurrentLocation);
|
||||
}
|
@ -1,65 +1,71 @@
|
||||
import 'package:vector_map_tiles/vector_map_tiles.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
class MapState {
|
||||
final bool isDarkTheme;
|
||||
final ThemeMode themeMode;
|
||||
final bool showFavoriteOnly;
|
||||
final bool includeArchived;
|
||||
final int relativeTime;
|
||||
final Style? mapStyle;
|
||||
final bool isLoading;
|
||||
final bool shouldRefetchMarkers;
|
||||
final AsyncValue<String> lightStyleFetched;
|
||||
final AsyncValue<String> darkStyleFetched;
|
||||
|
||||
MapState({
|
||||
this.isDarkTheme = false,
|
||||
this.themeMode = ThemeMode.system,
|
||||
this.showFavoriteOnly = false,
|
||||
this.includeArchived = false,
|
||||
this.relativeTime = 0,
|
||||
this.mapStyle,
|
||||
this.isLoading = false,
|
||||
this.shouldRefetchMarkers = false,
|
||||
this.lightStyleFetched = const AsyncLoading(),
|
||||
this.darkStyleFetched = const AsyncLoading(),
|
||||
});
|
||||
|
||||
MapState copyWith({
|
||||
bool? isDarkTheme,
|
||||
ThemeMode? themeMode,
|
||||
bool? showFavoriteOnly,
|
||||
bool? includeArchived,
|
||||
int? relativeTime,
|
||||
Style? mapStyle,
|
||||
bool? isLoading,
|
||||
bool? shouldRefetchMarkers,
|
||||
AsyncValue<String>? lightStyleFetched,
|
||||
AsyncValue<String>? darkStyleFetched,
|
||||
}) {
|
||||
return MapState(
|
||||
isDarkTheme: isDarkTheme ?? this.isDarkTheme,
|
||||
themeMode: themeMode ?? this.themeMode,
|
||||
showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
|
||||
includeArchived: includeArchived ?? this.includeArchived,
|
||||
relativeTime: relativeTime ?? this.relativeTime,
|
||||
mapStyle: mapStyle ?? this.mapStyle,
|
||||
isLoading: isLoading ?? this.isLoading,
|
||||
shouldRefetchMarkers: shouldRefetchMarkers ?? this.shouldRefetchMarkers,
|
||||
lightStyleFetched: lightStyleFetched ?? this.lightStyleFetched,
|
||||
darkStyleFetched: darkStyleFetched ?? this.darkStyleFetched,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime, includeArchived: $includeArchived, mapStyle: $mapStyle, isLoading: $isLoading)';
|
||||
return 'MapState(themeMode: $themeMode, showFavoriteOnly: $showFavoriteOnly, includeArchived: $includeArchived, relativeTime: $relativeTime, shouldRefetchMarkers: $shouldRefetchMarkers, lightStyleFetched: $lightStyleFetched, darkStyleFetched: $darkStyleFetched)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
bool operator ==(covariant MapState other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is MapState &&
|
||||
other.isDarkTheme == isDarkTheme &&
|
||||
return other.themeMode == themeMode &&
|
||||
other.showFavoriteOnly == showFavoriteOnly &&
|
||||
other.relativeTime == relativeTime &&
|
||||
other.includeArchived == includeArchived &&
|
||||
other.mapStyle == mapStyle &&
|
||||
other.isLoading == isLoading;
|
||||
other.relativeTime == relativeTime &&
|
||||
other.shouldRefetchMarkers == shouldRefetchMarkers &&
|
||||
other.lightStyleFetched == lightStyleFetched &&
|
||||
other.darkStyleFetched == darkStyleFetched;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return isDarkTheme.hashCode ^
|
||||
return themeMode.hashCode ^
|
||||
showFavoriteOnly.hashCode ^
|
||||
relativeTime.hashCode ^
|
||||
includeArchived.hashCode ^
|
||||
mapStyle.hashCode ^
|
||||
isLoading.hashCode;
|
||||
relativeTime.hashCode ^
|
||||
shouldRefetchMarkers.hashCode ^
|
||||
lightStyleFetched.hashCode ^
|
||||
darkStyleFetched.hashCode;
|
||||
}
|
||||
}
|
||||
|
@ -1,13 +1,14 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_marker.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_service.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/services/map.service.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
final mapMarkersProvider =
|
||||
FutureProvider.autoDispose<Set<AssetMarkerData>>((ref) async {
|
||||
part 'map_marker.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
Future<List<MapMarker>> mapMarkers(MapMarkersRef ref) async {
|
||||
final service = ref.read(mapServiceProvider);
|
||||
final mapState = ref.read(mapStateNotifier);
|
||||
final mapState = ref.read(mapStateNotifierProvider);
|
||||
DateTime? fileCreatedAfter;
|
||||
bool? isFavorite;
|
||||
bool? isIncludeArchived;
|
||||
@ -31,34 +32,5 @@ final mapMarkersProvider =
|
||||
fileCreatedAfter: fileCreatedAfter,
|
||||
);
|
||||
|
||||
final assetMarkerData = await Future.wait(
|
||||
markers.map((e) async {
|
||||
final asset = await service.getAssetForMarkerId(e.id);
|
||||
bool hasInvalidCoords = e.lat < -90 || e.lat > 90;
|
||||
hasInvalidCoords = hasInvalidCoords || (e.lon < -180 || e.lon > 180);
|
||||
if (asset == null || hasInvalidCoords) return null;
|
||||
return AssetMarkerData(asset, LatLng(e.lat, e.lon));
|
||||
}),
|
||||
);
|
||||
|
||||
return assetMarkerData.nonNulls.toSet();
|
||||
});
|
||||
|
||||
class AssetMarkerData {
|
||||
final LatLng point;
|
||||
final Asset asset;
|
||||
|
||||
const AssetMarkerData(this.asset, this.point);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is AssetMarkerData && other.asset.remoteId == asset.remoteId;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return asset.remoteId.hashCode;
|
||||
}
|
||||
return markers.toList();
|
||||
}
|
||||
|
BIN
mobile/lib/modules/map/providers/map_marker.provider.g.dart
generated
Normal file
BIN
mobile/lib/modules/map/providers/map_marker.provider.g.dart
generated
Normal file
Binary file not shown.
@ -0,0 +1,9 @@
|
||||
import 'package:immich_mobile/modules/map/services/map.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
part 'map_service.provider.g.dart';
|
||||
|
||||
@riverpod
|
||||
MapSerivce mapService(MapServiceRef ref) =>
|
||||
MapSerivce(ref.watch(apiServiceProvider));
|
BIN
mobile/lib/modules/map/providers/map_service.provider.g.dart
generated
Normal file
BIN
mobile/lib/modules/map/providers/map_service.provider.g.dart
generated
Normal file
Binary file not shown.
@ -1,159 +1,138 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
|
||||
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:vector_map_tiles/vector_map_tiles.dart';
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||
|
||||
class MapStateNotifier extends StateNotifier<MapState> {
|
||||
MapStateNotifier(this._appSettingsProvider, this._apiService)
|
||||
: super(
|
||||
MapState(
|
||||
isDarkTheme: _appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapThemeMode),
|
||||
showFavoriteOnly: _appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
|
||||
includeArchived: _appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
|
||||
relativeTime: _appSettingsProvider
|
||||
.getSetting<int>(AppSettingsEnum.mapRelativeDate),
|
||||
isLoading: true,
|
||||
),
|
||||
) {
|
||||
_fetchStyleFromServer(
|
||||
_appSettingsProvider.getSetting<bool>(AppSettingsEnum.mapThemeMode),
|
||||
part 'map_state.provider.g.dart';
|
||||
|
||||
@Riverpod(keepAlive: true)
|
||||
class MapStateNotifier extends _$MapStateNotifier {
|
||||
final _log = Logger("MapStateNotifier");
|
||||
|
||||
@override
|
||||
MapState build() {
|
||||
final appSettingsProvider = ref.read(appSettingsServiceProvider);
|
||||
|
||||
// Fetch and save the Style JSONs
|
||||
loadStyles();
|
||||
return MapState(
|
||||
themeMode: ThemeMode.values[
|
||||
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapThemeMode)],
|
||||
showFavoriteOnly: appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
|
||||
includeArchived: appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapIncludeArchived),
|
||||
relativeTime:
|
||||
appSettingsProvider.getSetting<int>(AppSettingsEnum.mapRelativeDate),
|
||||
);
|
||||
}
|
||||
|
||||
final AppSettingsService _appSettingsProvider;
|
||||
final ApiService _apiService;
|
||||
final Logger _log = Logger("MapStateNotifier");
|
||||
void loadStyles() async {
|
||||
final documents = (await getApplicationDocumentsDirectory()).path;
|
||||
|
||||
bool get isRaster =>
|
||||
state.mapStyle != null && state.mapStyle!.rasterTileProvider != null;
|
||||
// Set to loading
|
||||
state = state.copyWith(lightStyleFetched: const AsyncLoading());
|
||||
|
||||
double get maxZoom =>
|
||||
(isRaster ? state.mapStyle!.rasterTileProvider!.maximumZoom : 18)
|
||||
.toDouble();
|
||||
// Fetch and save light theme
|
||||
final lightResponse = await ref
|
||||
.read(apiServiceProvider)
|
||||
.systemConfigApi
|
||||
.getMapStyleWithHttpInfo(MapTheme.light);
|
||||
|
||||
void switchTheme(bool isDarkTheme) {
|
||||
_updateThemeMode(isDarkTheme);
|
||||
_fetchStyleFromServer(isDarkTheme);
|
||||
}
|
||||
|
||||
void _updateThemeMode(bool isDarkTheme) {
|
||||
_appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapThemeMode,
|
||||
isDarkTheme,
|
||||
);
|
||||
state = state.copyWith(isDarkTheme: isDarkTheme, isLoading: true);
|
||||
}
|
||||
|
||||
void _fetchStyleFromServer(bool isDarkTheme) async {
|
||||
final styleResponse = await _apiService.systemConfigApi
|
||||
.getMapStyleWithHttpInfo(isDarkTheme ? MapTheme.dark : MapTheme.light);
|
||||
if (styleResponse.statusCode >= HttpStatus.badRequest) {
|
||||
throw ApiException(styleResponse.statusCode, styleResponse.body);
|
||||
}
|
||||
final styleJsonString = styleResponse.body.isNotEmpty &&
|
||||
styleResponse.statusCode != HttpStatus.noContent
|
||||
? styleResponse.body
|
||||
: null;
|
||||
|
||||
if (styleJsonString == null) {
|
||||
_log.severe('Style JSON from server is empty');
|
||||
if (lightResponse.statusCode >= HttpStatus.badRequest) {
|
||||
state = state.copyWith(
|
||||
lightStyleFetched: AsyncError(lightResponse.body, StackTrace.current),
|
||||
);
|
||||
_log.severe(
|
||||
"Cannot fetch map light style with status - ${lightResponse.statusCode} and response - ${lightResponse.body}",
|
||||
);
|
||||
return;
|
||||
}
|
||||
final styleJson = await compute(jsonDecode, styleJsonString);
|
||||
if (styleJson is! Map<String, dynamic>) {
|
||||
_log.severe('Style JSON from server is invalid');
|
||||
|
||||
final lightJSON = lightResponse.body;
|
||||
final lightFile = await File("$documents/map-style-light.json")
|
||||
.writeAsString(lightJSON, flush: true);
|
||||
|
||||
// Update state with path
|
||||
state =
|
||||
state.copyWith(lightStyleFetched: AsyncData(lightFile.absolute.path));
|
||||
|
||||
// Set to loading
|
||||
state = state.copyWith(darkStyleFetched: const AsyncLoading());
|
||||
|
||||
// Fetch and save dark theme
|
||||
final darkResponse = await ref
|
||||
.read(apiServiceProvider)
|
||||
.systemConfigApi
|
||||
.getMapStyleWithHttpInfo(MapTheme.dark);
|
||||
|
||||
if (darkResponse.statusCode >= HttpStatus.badRequest) {
|
||||
state = state.copyWith(
|
||||
darkStyleFetched: AsyncError(darkResponse.body, StackTrace.current),
|
||||
);
|
||||
_log.severe(
|
||||
"Cannot fetch map dark style with status - ${darkResponse.statusCode} and response - ${darkResponse.body}",
|
||||
);
|
||||
return;
|
||||
}
|
||||
final styleReader = StyleReader(uri: '');
|
||||
Style? style;
|
||||
try {
|
||||
style = await styleReader.readFromMap(styleJson);
|
||||
} finally {
|
||||
// Consume all error
|
||||
}
|
||||
state = state.copyWith(
|
||||
mapStyle: style,
|
||||
isLoading: false,
|
||||
);
|
||||
|
||||
final darkJSON = darkResponse.body;
|
||||
final darkFile = await File("$documents/map-style-dark.json")
|
||||
.writeAsString(darkJSON, flush: true);
|
||||
|
||||
// Update state with path
|
||||
state = state.copyWith(darkStyleFetched: AsyncData(darkFile.absolute.path));
|
||||
}
|
||||
|
||||
void switchTheme(ThemeMode mode) {
|
||||
ref.read(appSettingsServiceProvider).setSetting(
|
||||
AppSettingsEnum.mapThemeMode,
|
||||
mode.index,
|
||||
);
|
||||
state = state.copyWith(themeMode: mode);
|
||||
}
|
||||
|
||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||
_appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapShowFavoriteOnly,
|
||||
isFavoriteOnly,
|
||||
ref.read(appSettingsServiceProvider).setSetting(
|
||||
AppSettingsEnum.mapShowFavoriteOnly,
|
||||
isFavoriteOnly,
|
||||
);
|
||||
state = state.copyWith(
|
||||
showFavoriteOnly: isFavoriteOnly,
|
||||
shouldRefetchMarkers: true,
|
||||
);
|
||||
state = state.copyWith(showFavoriteOnly: isFavoriteOnly);
|
||||
}
|
||||
|
||||
void setRefetchMarkers(bool shouldRefetch) {
|
||||
state = state.copyWith(shouldRefetchMarkers: shouldRefetch);
|
||||
}
|
||||
|
||||
void switchIncludeArchived(bool isIncludeArchived) {
|
||||
_appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapIncludeArchived,
|
||||
isIncludeArchived,
|
||||
ref.read(appSettingsServiceProvider).setSetting(
|
||||
AppSettingsEnum.mapIncludeArchived,
|
||||
isIncludeArchived,
|
||||
);
|
||||
state = state.copyWith(
|
||||
includeArchived: isIncludeArchived,
|
||||
shouldRefetchMarkers: true,
|
||||
);
|
||||
state = state.copyWith(includeArchived: isIncludeArchived);
|
||||
}
|
||||
|
||||
void setRelativeTime(int relativeTime) {
|
||||
_appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapRelativeDate,
|
||||
relativeTime,
|
||||
ref.read(appSettingsServiceProvider).setSetting(
|
||||
AppSettingsEnum.mapRelativeDate,
|
||||
relativeTime,
|
||||
);
|
||||
state = state.copyWith(
|
||||
relativeTime: relativeTime,
|
||||
shouldRefetchMarkers: true,
|
||||
);
|
||||
state = state.copyWith(relativeTime: relativeTime);
|
||||
}
|
||||
|
||||
Widget getTileLayer([bool forceDark = false]) {
|
||||
if (isRaster) {
|
||||
final rasterProvider = state.mapStyle!.rasterTileProvider;
|
||||
final rasterLayer = TileLayer(
|
||||
urlTemplate: rasterProvider!.url,
|
||||
maxNativeZoom: rasterProvider.maximumZoom,
|
||||
maxZoom: rasterProvider.maximumZoom.toDouble(),
|
||||
);
|
||||
return state.isDarkTheme || forceDark
|
||||
? InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: BrightnessFilter(
|
||||
brightness: -1,
|
||||
child: rasterLayer,
|
||||
),
|
||||
),
|
||||
)
|
||||
: rasterLayer;
|
||||
}
|
||||
if (state.mapStyle != null && !isRaster) {
|
||||
return VectorTileLayer(
|
||||
// Tiles and themes will be set for vector providers
|
||||
tileProviders: state.mapStyle!.providers!,
|
||||
theme: state.mapStyle!.theme!,
|
||||
sprites: state.mapStyle!.sprites,
|
||||
concurrency: 6,
|
||||
);
|
||||
}
|
||||
return const Center(child: ImmichLoadingIndicator());
|
||||
}
|
||||
}
|
||||
|
||||
final mapStateNotifier =
|
||||
StateNotifierProvider<MapStateNotifier, MapState>((ref) {
|
||||
return MapStateNotifier(
|
||||
ref.watch(appSettingsServiceProvider),
|
||||
ref.watch(apiServiceProvider),
|
||||
);
|
||||
});
|
||||
|
BIN
mobile/lib/modules/map/providers/map_state.provider.g.dart
generated
Normal file
BIN
mobile/lib/modules/map/providers/map_state.provider.g.dart
generated
Normal file
Binary file not shown.
@ -1,62 +1,33 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/mixins/error_logger.mixin.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_marker.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final mapServiceProvider = Provider(
|
||||
(ref) => MapSerivce(
|
||||
ref.read(apiServiceProvider),
|
||||
ref.read(dbProvider),
|
||||
),
|
||||
);
|
||||
|
||||
class MapSerivce {
|
||||
class MapSerivce with ErrorLoggerMixin {
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
final _log = Logger("MapService");
|
||||
@override
|
||||
final logger = Logger("MapService");
|
||||
|
||||
MapSerivce(this._apiService, this._db);
|
||||
MapSerivce(this._apiService);
|
||||
|
||||
Future<List<MapMarkerResponseDto>> getMapMarkers({
|
||||
Future<Iterable<MapMarker>> getMapMarkers({
|
||||
bool? isFavorite,
|
||||
bool? withArchived,
|
||||
DateTime? fileCreatedAfter,
|
||||
DateTime? fileCreatedBefore,
|
||||
}) async {
|
||||
try {
|
||||
final markers = await _apiService.assetApi.getMapMarkers(
|
||||
isFavorite: isFavorite,
|
||||
isArchived: withArchived,
|
||||
fileCreatedAfter: fileCreatedAfter,
|
||||
fileCreatedBefore: fileCreatedBefore,
|
||||
);
|
||||
return logError(
|
||||
() async {
|
||||
final markers = await _apiService.assetApi.getMapMarkers(
|
||||
isFavorite: isFavorite,
|
||||
isArchived: withArchived,
|
||||
fileCreatedAfter: fileCreatedAfter,
|
||||
fileCreatedBefore: fileCreatedBefore,
|
||||
);
|
||||
|
||||
return markers ?? [];
|
||||
} catch (error, stack) {
|
||||
_log.severe("Cannot get map markers ${error.toString()}", error, stack);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
Future<Asset?> getAssetForMarkerId(String remoteId) async {
|
||||
try {
|
||||
final assets = await _db.assets.getAllByRemoteId([remoteId]);
|
||||
if (assets.isNotEmpty) return assets[0];
|
||||
|
||||
final dto = await _apiService.assetApi.getAssetById(remoteId);
|
||||
if (dto == null) return null;
|
||||
return _db.assets.getByRemoteId(dto.id);
|
||||
} catch (error, stack) {
|
||||
_log.severe(
|
||||
"Cannot get asset for marker ${error.toString()}",
|
||||
error,
|
||||
stack,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
return markers?.map(MapMarker.fromDto) ?? [];
|
||||
},
|
||||
defaultValue: [],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,30 +0,0 @@
|
||||
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: () {},
|
||||
);
|
||||
}
|
@ -1,114 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
class MapLocationPickerPage extends HookConsumerWidget {
|
||||
final LatLng? initialLatLng;
|
||||
|
||||
const MapLocationPickerPage({super.key, this.initialLatLng});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedLatLng = useState<LatLng>(initialLatLng ?? LatLng(0, 0));
|
||||
final isDarkTheme =
|
||||
ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
|
||||
final isLoading =
|
||||
ref.watch(mapStateNotifier.select((state) => state.isLoading));
|
||||
final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom;
|
||||
|
||||
return Theme(
|
||||
// Override app theme based on map theme
|
||||
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
|
||||
child: Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
if (!isLoading)
|
||||
FlutterMap(
|
||||
options: MapOptions(
|
||||
maxBounds:
|
||||
LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
|
||||
interactiveFlags: InteractiveFlag.doubleTapZoom |
|
||||
InteractiveFlag.drag |
|
||||
InteractiveFlag.flingAnimation |
|
||||
InteractiveFlag.pinchMove |
|
||||
InteractiveFlag.pinchZoom,
|
||||
center: LatLng(20, 20),
|
||||
zoom: 2,
|
||||
minZoom: 1,
|
||||
maxZoom: maxZoom,
|
||||
onTap: (tapPosition, point) => selectedLatLng.value = point,
|
||||
),
|
||||
children: [
|
||||
ref.read(mapStateNotifier.notifier).getTileLayer(),
|
||||
MarkerLayer(
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: selectedLatLng.value,
|
||||
builder: (ctx) => const Image(
|
||||
image: AssetImage('assets/location-pin.png'),
|
||||
),
|
||||
height: 40,
|
||||
width: 40,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isLoading)
|
||||
Positioned(
|
||||
top: context.height * 0.35,
|
||||
left: context.width * 0.425,
|
||||
child: const ImmichLoadingIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
bottomSheet: BottomSheet(
|
||||
onClosing: () {},
|
||||
builder: (context) => SizedBox(
|
||||
height: 150,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"${selectedLatLng.value.latitude.toStringAsFixed(4)}, ${selectedLatLng.value.longitude.toStringAsFixed(4)}",
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () => context.popRoute(selectedLatLng.value),
|
||||
child: const Text("map_location_picker_page_use_location")
|
||||
.tr(),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => context.popRoute(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: context.colorScheme.error,
|
||||
),
|
||||
child: const Text("action_common_cancel").tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,138 +0,0 @@
|
||||
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: () => context.popRoute(),
|
||||
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: EdgeInsets.only(top: MediaQuery.of(context).padding.top + 15),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (!selectionEnabled.value) ...buildNonSelectionWidgets(context),
|
||||
if (selectionEnabled.value) ...buildSelectionWidgets(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(100);
|
||||
}
|
@ -1,356 +0,0 @@
|
||||
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/extensions/build_context_extensions.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 isDarkTheme = context.isDarkTheme;
|
||||
final bottomPadding =
|
||||
Platform.isAndroid ? MediaQuery.of(context).padding.bottom - 10 : 0.0;
|
||||
final maxHeight = context.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: isDarkTheme
|
||||
? 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: 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
|
||||
? "map_assets_in_bounds"
|
||||
.tr(args: [assetsInBound.value.length.toString()])
|
||||
: "map_no_assets_in_bounds".tr();
|
||||
final dragHandle = Container(
|
||||
height: 70,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkTheme ? 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: context.textTheme.bodyLarge,
|
||||
),
|
||||
Divider(
|
||||
height: 10,
|
||||
color:
|
||||
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: 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: isDarkTheme ? 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,228 +0,0 @@
|
||||
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/extensions/build_context_extensions.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 showIncludeArchived = useState(mapSettings.includeArchived);
|
||||
final showRelativeDate = useState(mapSettings.relativeTime);
|
||||
final ThemeData theme = context.themeData;
|
||||
|
||||
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 buildIncludeArchivedSetting() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: showIncludeArchived.value,
|
||||
onChanged: (value) {
|
||||
showIncludeArchived.value = value;
|
||||
},
|
||||
activeColor: theme.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"map_settings_include_show_archived".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: [
|
||||
DropdownMenuEntry(
|
||||
value: 0,
|
||||
label: "map_settings_date_range_option_all".tr(),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 1,
|
||||
label: "map_settings_date_range_option_day".tr(),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 7,
|
||||
label: "map_settings_date_range_option_days".tr(
|
||||
args: ["7"],
|
||||
),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 30,
|
||||
label: "map_settings_date_range_option_days".tr(
|
||||
args: ["30"],
|
||||
),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 1,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_year".tr(),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 3,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_years".tr(args: ["3"]),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> getDialogActions() {
|
||||
return <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor:
|
||||
mapSettings.isDarkTheme ? Colors.grey[100] : Colors.grey[700],
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
"map_settings_dialog_cancel".tr(),
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
color: mapSettings.isDarkTheme
|
||||
? Colors.grey[900]
|
||||
: Colors.grey[100],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
mapSettingsNotifier.switchTheme(isDarkMode.value);
|
||||
mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value);
|
||||
mapSettingsNotifier.setRelativeTime(showRelativeDate.value);
|
||||
mapSettingsNotifier
|
||||
.switchIncludeArchived(showIncludeArchived.value);
|
||||
context.pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: theme.primaryColor,
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16.0),
|
||||
child: Text(
|
||||
"map_settings_dialog_save".tr(),
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
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: context.height * 0.6,
|
||||
),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
buildMapThemeSetting(),
|
||||
buildFavoriteOnlySetting(),
|
||||
buildIncludeArchivedSetting(),
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
@ -1,86 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/utils/map_controller_hook.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 double width;
|
||||
final bool showAttribution;
|
||||
final bool isDarkTheme;
|
||||
|
||||
const MapThumbnail({
|
||||
super.key,
|
||||
required this.coords,
|
||||
this.height = 100,
|
||||
this.width = 100,
|
||||
this.onTap,
|
||||
this.zoom = 1,
|
||||
this.showAttribution = true,
|
||||
this.isDarkTheme = false,
|
||||
this.markers = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mapController = useMapController();
|
||||
final isMapReady = useRef(false);
|
||||
ref.watch(mapStateNotifier.select((s) => s.mapStyle));
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
if (isMapReady.value && mapController.center != coords) {
|
||||
mapController.move(coords, zoom);
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[coords],
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
width: width,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
child: FlutterMap(
|
||||
mapController: mapController,
|
||||
options: MapOptions(
|
||||
interactiveFlags: InteractiveFlag.none,
|
||||
center: coords,
|
||||
zoom: zoom,
|
||||
onTap: onTap,
|
||||
onMapReady: () => isMapReady.value = true,
|
||||
),
|
||||
nonRotatedChildren: [
|
||||
if (showAttribution)
|
||||
RichAttributionWidget(
|
||||
animationConfig: const ScaleRAWA(),
|
||||
attributions: [
|
||||
TextSourceAttribution(
|
||||
'OpenStreetMap contributors',
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
children: [
|
||||
ref.read(mapStateNotifier.notifier).getTileLayer(isDarkTheme),
|
||||
if (markers.isNotEmpty) MarkerLayer(markers: markers),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,32 +0,0 @@
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
|
||||
MapController useMapController({
|
||||
String? debugLabel,
|
||||
List<Object?>? keys,
|
||||
}) {
|
||||
return use(_MapControllerHook(keys: keys));
|
||||
}
|
||||
|
||||
class _MapControllerHook extends Hook<MapController> {
|
||||
const _MapControllerHook({List<Object?>? keys}) : super(keys: keys);
|
||||
|
||||
@override
|
||||
HookState<MapController, Hook<MapController>> createState() =>
|
||||
_MapControllerHookState();
|
||||
}
|
||||
|
||||
class _MapControllerHookState
|
||||
extends HookState<MapController, _MapControllerHook> {
|
||||
late final controller = MapController();
|
||||
|
||||
@override
|
||||
MapController build(BuildContext context) => controller;
|
||||
|
||||
@override
|
||||
void dispose() => controller.dispose();
|
||||
|
||||
@override
|
||||
String get debugLabel => 'useMapController';
|
||||
}
|
138
mobile/lib/modules/map/utils/map_utils.dart
Normal file
138
mobile/lib/modules/map/utils/map_utils.dart
Normal file
@ -0,0 +1,138 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_marker.dart';
|
||||
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapUtils {
|
||||
MapUtils._();
|
||||
|
||||
static final Logger _log = Logger("MapUtils");
|
||||
static const defaultSourceId = 'asset-map-markers';
|
||||
static const defaultHeatMapLayerId = 'asset-heatmap-layer';
|
||||
|
||||
static const defaultHeatMapLayerProperties = HeatmapLayerProperties(
|
||||
heatmapColor: [
|
||||
Expressions.interpolate,
|
||||
["linear"],
|
||||
["heatmap-density"],
|
||||
0.0,
|
||||
"rgba(246,239,247,0.0)",
|
||||
0.2,
|
||||
"rgb(208,209,230)",
|
||||
0.4,
|
||||
"rgb(166,189,219)",
|
||||
0.6,
|
||||
"rgb(103,169,207)",
|
||||
0.8,
|
||||
"rgb(28,144,153)",
|
||||
1.0,
|
||||
"rgb(1,108,89)",
|
||||
],
|
||||
heatmapIntensity: [
|
||||
Expressions.interpolate, ["linear"], //
|
||||
[Expressions.zoom],
|
||||
0, 0.5,
|
||||
9, 2,
|
||||
],
|
||||
heatmapRadius: [
|
||||
Expressions.interpolate, ["linear"], //
|
||||
[Expressions.zoom],
|
||||
0, 4,
|
||||
4, 8,
|
||||
9, 16,
|
||||
],
|
||||
);
|
||||
|
||||
static Map<String, dynamic> _addFeature(MapMarker marker) => {
|
||||
'type': 'Feature',
|
||||
'id': marker.assetRemoteId,
|
||||
'geometry': {
|
||||
'type': 'Point',
|
||||
'coordinates': [marker.latLng.longitude, marker.latLng.latitude],
|
||||
},
|
||||
};
|
||||
|
||||
static Map<String, dynamic> generateGeoJsonForMarkers(
|
||||
List<MapMarker> markers,
|
||||
) =>
|
||||
{
|
||||
'type': 'FeatureCollection',
|
||||
'features': markers.map(_addFeature).toList(),
|
||||
};
|
||||
|
||||
static Future<(Position?, LocationPermission?)> checkPermAndGetLocation(
|
||||
BuildContext context,
|
||||
) async {
|
||||
try {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => _LocationServiceDisabledDialog(),
|
||||
);
|
||||
return (null, LocationPermission.deniedForever);
|
||||
}
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
bool shouldRequestPermission = false;
|
||||
|
||||
if (permission == LocationPermission.denied) {
|
||||
shouldRequestPermission = await showDialog(
|
||||
context: context,
|
||||
builder: (context) => _LocationPermissionDisabledDialog(),
|
||||
);
|
||||
if (shouldRequestPermission) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.denied ||
|
||||
permission == LocationPermission.deniedForever) {
|
||||
// Open app settings only if you did not request for permission before
|
||||
if (permission == LocationPermission.deniedForever &&
|
||||
!shouldRequestPermission) {
|
||||
await Geolocator.openAppSettings();
|
||||
}
|
||||
return (null, LocationPermission.deniedForever);
|
||||
}
|
||||
|
||||
Position currentUserLocation = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.medium,
|
||||
timeLimit: const Duration(seconds: 5),
|
||||
);
|
||||
return (currentUserLocation, null);
|
||||
} catch (error) {
|
||||
_log.severe(
|
||||
"Cannot get user's current location due to ${error.toString()}",
|
||||
);
|
||||
return (null, LocationPermission.unableToDetermine);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class _LocationServiceDisabledDialog extends ConfirmDialog {
|
||||
_LocationServiceDisabledDialog()
|
||||
: super(
|
||||
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()
|
||||
: super(
|
||||
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: () {},
|
||||
);
|
||||
}
|
185
mobile/lib/modules/map/views/map_location_picker_page.dart
Normal file
185
mobile/lib/modules/map/views/map_location_picker_page.dart
Normal file
@ -0,0 +1,185 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:immich_mobile/modules/map/utils/map_utils.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
|
||||
class MapLocationPickerPage extends HookConsumerWidget {
|
||||
final LatLng initialLatLng;
|
||||
|
||||
const MapLocationPickerPage({
|
||||
super.key,
|
||||
this.initialLatLng = const LatLng(0, 0),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final selectedLatLng = useValueNotifier<LatLng>(initialLatLng);
|
||||
final controller = useRef<MaplibreMapController?>(null);
|
||||
final marker = useRef<Symbol?>(null);
|
||||
|
||||
Future<void> onStyleLoaded() async {
|
||||
marker.value = await controller.value?.addMarkerAtLatLng(initialLatLng);
|
||||
}
|
||||
|
||||
Future<void> onMapClick(Point<num> point, LatLng centre) async {
|
||||
selectedLatLng.value = centre;
|
||||
controller.value?.animateCamera(CameraUpdate.newLatLng(centre));
|
||||
if (marker.value != null) {
|
||||
await controller.value
|
||||
?.updateSymbol(marker.value!, SymbolOptions(geometry: centre));
|
||||
}
|
||||
}
|
||||
|
||||
void onClose([LatLng? selected]) {
|
||||
context.popRoute(selected);
|
||||
}
|
||||
|
||||
Future<void> getCurrentLocation() async {
|
||||
var (currentLocation, locationPermission) = await MapUtils.checkPermAndGetLocation(context);
|
||||
if (locationPermission == LocationPermission.denied ||
|
||||
locationPermission == LocationPermission.deniedForever) {
|
||||
return;
|
||||
}
|
||||
if (currentLocation == null) {
|
||||
return;
|
||||
}
|
||||
var currentLatLng = LatLng(currentLocation.latitude, currentLocation.longitude);
|
||||
selectedLatLng.value = currentLatLng;
|
||||
controller.value?.animateCamera(CameraUpdate.newLatLng(currentLatLng));
|
||||
}
|
||||
|
||||
return MapThemeOveride(
|
||||
mapBuilder: (style) => Builder(
|
||||
builder: (ctx) => Scaffold(
|
||||
backgroundColor: ctx.themeData.cardColor,
|
||||
appBar: _AppBar(onClose: onClose),
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Column(
|
||||
children: [
|
||||
style.widgetWhen(
|
||||
onData: (style) => Expanded(
|
||||
child: Container(
|
||||
clipBehavior: Clip.antiAliasWithSaveLayer,
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.only(
|
||||
bottomLeft: Radius.circular(40),
|
||||
bottomRight: Radius.circular(40),
|
||||
),
|
||||
),
|
||||
child: MaplibreMap(
|
||||
initialCameraPosition:
|
||||
CameraPosition(target: initialLatLng, zoom: 12),
|
||||
styleString: style,
|
||||
onMapCreated: (mapController) =>
|
||||
controller.value = mapController,
|
||||
onStyleLoadedCallback: onStyleLoaded,
|
||||
onMapClick: onMapClick,
|
||||
dragEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
attributionButtonMargins: const Point(20, 15),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
_BottomBar(
|
||||
selectedLatLng: selectedLatLng,
|
||||
onUseLocation: () => onClose(selectedLatLng.value),
|
||||
onGetCurrentLocation: getCurrentLocation,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
final Function() onClose;
|
||||
|
||||
const _AppBar({required this.onClose});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top + 25),
|
||||
child: Expanded(
|
||||
child: Align(
|
||||
alignment: Alignment.centerLeft,
|
||||
child: ElevatedButton(
|
||||
onPressed: onClose,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(100);
|
||||
}
|
||||
|
||||
class _BottomBar extends StatelessWidget {
|
||||
final ValueNotifier<LatLng> selectedLatLng;
|
||||
final Function() onUseLocation;
|
||||
final Function() onGetCurrentLocation;
|
||||
|
||||
const _BottomBar({
|
||||
required this.selectedLatLng,
|
||||
required this.onUseLocation,
|
||||
required this.onGetCurrentLocation,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
height: 150,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(Icons.public, size: 18),
|
||||
const SizedBox(width: 15),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: selectedLatLng,
|
||||
builder: (_, value, __) => Text(
|
||||
"${value.latitude.toStringAsFixed(4)}, ${value.longitude.toStringAsFixed(4)}",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: onUseLocation,
|
||||
child: const Text("map_location_picker_page_use_location").tr(),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: onGetCurrentLocation,
|
||||
child: const Icon(Icons.my_location),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,250 +1,225 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'dart:math';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:flutter_map_heatmap/flutter_map_heatmap.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
|
||||
import 'package:immich_mobile/extensions/latlngbounds_extension.dart';
|
||||
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_event.model.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_marker.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_marker.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/asset_marker_icon.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/location_dialog.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_page_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_page_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/map/utils/map_utils.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_app_bar.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/positioned_asset_marker_icon.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/extensions/flutter_map_extensions.dart';
|
||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapPage extends StatefulHookConsumerWidget {
|
||||
class MapPage extends HookConsumerWidget {
|
||||
const MapPage({super.key});
|
||||
|
||||
@override
|
||||
MapPageState createState() => MapPageState();
|
||||
}
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mapController = useRef<MaplibreMapController?>(null);
|
||||
final markers = useRef<List<MapMarker>>([]);
|
||||
final markersInBounds = useRef<List<MapMarker>>([]);
|
||||
final bottomSheetStreamController = useStreamController<MapEvent>();
|
||||
final selectedMarker = useValueNotifier<_AssetMarkerMeta?>(null);
|
||||
final assetsDebouncer = useDebouncer();
|
||||
final isLoading = useProcessingOverlay();
|
||||
final scrollController = useScrollController();
|
||||
final markerDebouncer =
|
||||
useDebouncer(interval: const Duration(milliseconds: 800));
|
||||
final selectedAssets = useValueNotifier<Set<Asset>>({});
|
||||
const mapZoomToAssetLevel = 12.0;
|
||||
|
||||
class MapPageState extends ConsumerState<MapPage> {
|
||||
// Non-State variables
|
||||
late final MapController mapController;
|
||||
// Streams are used instead of callbacks to prevent unnecessary rebuilds on events
|
||||
final StreamController mapPageEventSC =
|
||||
StreamController<MapPageEventBase>.broadcast();
|
||||
final StreamController bottomSheetEventSC =
|
||||
StreamController<MapPageEventBase>.broadcast();
|
||||
late final Stream bottomSheetEventStream;
|
||||
// Making assets in bounds as a state variable will result in un-necessary rebuilds of the bottom sheet
|
||||
// resulting in it getting reloaded each time a map move occurs
|
||||
Set<AssetMarkerData> assetsInBounds = {};
|
||||
// TODO: Migrate the handling to MapEventMove#id when flutter_map is upgraded
|
||||
// https://github.com/fleaflet/flutter_map/issues/1542
|
||||
// The below is used instead of MapEventMove#id to handle event from controller
|
||||
// in onMapEvent() since MapEventMove#id is not populated properly in the
|
||||
// current version of flutter_map(4.0.0) used
|
||||
bool forceAssetUpdate = false;
|
||||
bool isMapReady = false;
|
||||
late final Debounce debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
mapController = MapController();
|
||||
bottomSheetEventStream = bottomSheetEventSC.stream;
|
||||
// Map zoom events and move events are triggered often. Throttle the call to limit rebuilds
|
||||
debounce = Debounce(
|
||||
const Duration(milliseconds: 300),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
debounce.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void reloadAssetsInBound(
|
||||
Set<AssetMarkerData>? assetMarkers, {
|
||||
bool forceReload = false,
|
||||
}) {
|
||||
try {
|
||||
final bounds = isMapReady ? mapController.bounds : null;
|
||||
if (bounds != null) {
|
||||
final oldAssetsInBounds = assetsInBounds.toSet();
|
||||
assetsInBounds =
|
||||
assetMarkers?.where((e) => bounds.contains(e.point)).toSet() ?? {};
|
||||
final shouldReload = forceReload ||
|
||||
assetsInBounds.difference(oldAssetsInBounds).isNotEmpty ||
|
||||
assetsInBounds.length != oldAssetsInBounds.length;
|
||||
if (shouldReload) {
|
||||
mapPageEventSC.add(
|
||||
MapPageAssetsInBoundUpdated(
|
||||
assetsInBounds.map((e) => e.asset).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
// updates the markersInBounds value with the map markers that are visible in the current
|
||||
// map camera bounds
|
||||
Future<void> updateAssetsInBounds() async {
|
||||
// Guard map not created
|
||||
if (mapController.value == null) {
|
||||
return;
|
||||
}
|
||||
} finally {
|
||||
// Consume all error
|
||||
}
|
||||
}
|
||||
|
||||
void openAssetInViewer(Asset asset) {
|
||||
context.pushRoute(
|
||||
GalleryViewerRoute(
|
||||
initialIndex: 0,
|
||||
loadAsset: (index) => asset,
|
||||
totalAssets: 1,
|
||||
heroOffset: 0,
|
||||
),
|
||||
final bounds = await mapController.value!.getVisibleRegion();
|
||||
final inBounds = markers.value
|
||||
.where(
|
||||
(m) =>
|
||||
bounds.contains(LatLng(m.latLng.latitude, m.latLng.longitude)),
|
||||
)
|
||||
.toList();
|
||||
// Notify bottom sheet to update asset grid only when there are new assets
|
||||
if (markersInBounds.value.length != inBounds.length) {
|
||||
bottomSheetStreamController.add(
|
||||
MapAssetsInBoundsUpdated(
|
||||
inBounds.map((e) => e.assetRemoteId).toList(),
|
||||
),
|
||||
);
|
||||
}
|
||||
markersInBounds.value = inBounds;
|
||||
}
|
||||
|
||||
// removes all sources and layers and re-adds them with the updated markers
|
||||
Future<void> reloadLayers() async {
|
||||
if (mapController.value != null) {
|
||||
mapController.value!.reloadAllLayersForMarkers(markers.value);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> loadMarkers() async {
|
||||
try {
|
||||
isLoading.value = true;
|
||||
markers.value = await ref.read(mapMarkersProvider.future);
|
||||
assetsDebouncer.run(updateAssetsInBounds);
|
||||
reloadLayers();
|
||||
} finally {
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
loadMarkers();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final log = Logger("MapService");
|
||||
final isDarkTheme =
|
||||
ref.watch(mapStateNotifier.select((state) => state.isDarkTheme));
|
||||
final ValueNotifier<Set<AssetMarkerData>> mapMarkerData =
|
||||
useState(<AssetMarkerData>{});
|
||||
final ValueNotifier<AssetMarkerData?> closestAssetMarker = useState(null);
|
||||
final selectionEnabledHook = useState(false);
|
||||
final selectedAssets = useState(<Asset>{});
|
||||
final showLoadingIndicator = useState(false);
|
||||
final refetchMarkers = useState(true);
|
||||
final isLoading =
|
||||
ref.watch(mapStateNotifier.select((state) => state.isLoading));
|
||||
final maxZoom = ref.read(mapStateNotifier.notifier).maxZoom;
|
||||
final zoomLevel = math.min(maxZoom, 14.0);
|
||||
|
||||
if (refetchMarkers.value) {
|
||||
mapMarkerData.value = ref.watch(mapMarkersProvider).when(
|
||||
skipLoadingOnRefresh: false,
|
||||
error: (error, stackTrace) {
|
||||
log.warning(
|
||||
"Cannot get map markers ${error.toString()}",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
showLoadingIndicator.value = false;
|
||||
return {};
|
||||
},
|
||||
loading: () {
|
||||
showLoadingIndicator.value = true;
|
||||
return {};
|
||||
},
|
||||
data: (data) {
|
||||
showLoadingIndicator.value = false;
|
||||
refetchMarkers.value = false;
|
||||
closestAssetMarker.value = null;
|
||||
debounce(
|
||||
() => reloadAssetsInBound(
|
||||
mapMarkerData.value,
|
||||
forceReload: true,
|
||||
),
|
||||
);
|
||||
return data;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
ref.listen(mapStateNotifier, (previous, next) {
|
||||
bool shouldRefetch =
|
||||
previous?.showFavoriteOnly != next.showFavoriteOnly ||
|
||||
previous?.relativeTime != next.relativeTime ||
|
||||
previous?.includeArchived != next.includeArchived;
|
||||
if (shouldRefetch) {
|
||||
refetchMarkers.value = shouldRefetch;
|
||||
ref.invalidate(mapMarkersProvider);
|
||||
// Refetch markers when map state is changed
|
||||
ref.listen(mapStateNotifierProvider, (_, current) {
|
||||
if (current.shouldRefetchMarkers) {
|
||||
markerDebouncer.run(() {
|
||||
ref.invalidate(mapMarkersProvider);
|
||||
// Reset marker
|
||||
selectedMarker.value = null;
|
||||
loadMarkers();
|
||||
ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(false);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
void onZoomToAssetEvent(Asset? assetInBottomSheet) {
|
||||
if (assetInBottomSheet != null) {
|
||||
final mapMarker = mapMarkerData.value
|
||||
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
|
||||
if (mapMarker != null) {
|
||||
LatLng? newCenter = mapController.centerBoundsWithPadding(
|
||||
mapMarker.point,
|
||||
const Offset(0, -120),
|
||||
zoomLevel: zoomLevel,
|
||||
);
|
||||
if (newCenter != null) {
|
||||
forceAssetUpdate = true;
|
||||
mapController.move(newCenter, zoomLevel);
|
||||
}
|
||||
// updates the selected markers position based on the current map camera
|
||||
Future<void> updateAssetMarkerPosition(
|
||||
MapMarker marker, {
|
||||
bool shouldAnimate = true,
|
||||
}) async {
|
||||
final assetPoint =
|
||||
await mapController.value!.toScreenLocation(marker.latLng);
|
||||
selectedMarker.value = _AssetMarkerMeta(
|
||||
point: assetPoint,
|
||||
marker: marker,
|
||||
shouldAnimate: shouldAnimate,
|
||||
);
|
||||
(assetPoint, marker, shouldAnimate);
|
||||
}
|
||||
|
||||
// finds the nearest asset marker from the tap point and store it as the selectedMarker
|
||||
Future<void> onMarkerClicked(Point<double> point, LatLng coords) async {
|
||||
// Guard map not created
|
||||
if (mapController.value == null) {
|
||||
return;
|
||||
}
|
||||
final latlngBound =
|
||||
await mapController.value!.getBoundsFromPoint(point, 50);
|
||||
final marker = markersInBounds.value.firstWhereOrNull(
|
||||
(m) =>
|
||||
latlngBound.contains(LatLng(m.latLng.latitude, m.latLng.longitude)),
|
||||
);
|
||||
|
||||
if (marker != null) {
|
||||
updateAssetMarkerPosition(marker);
|
||||
} else {
|
||||
// If no asset was previously selected and no new asset is available, close the bottom sheet
|
||||
if (selectedMarker.value == null) {
|
||||
bottomSheetStreamController.add(MapCloseBottomSheet());
|
||||
}
|
||||
selectedMarker.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
void onMapCreated(MaplibreMapController controller) async {
|
||||
mapController.value = controller;
|
||||
controller.addListener(() {
|
||||
if (controller.isCameraMoving && selectedMarker.value != null) {
|
||||
updateAssetMarkerPosition(
|
||||
selectedMarker.value!.marker,
|
||||
shouldAnimate: false,
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Future<void> onMarkerTapped() async {
|
||||
final assetId = selectedMarker.value?.marker.assetRemoteId;
|
||||
if (assetId == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final asset = await ref.read(dbProvider).assets.getByRemoteId(assetId);
|
||||
if (asset == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
context.pushRoute(
|
||||
GalleryViewerRoute(
|
||||
initialIndex: 0,
|
||||
loadAsset: (index) => asset,
|
||||
totalAssets: 1,
|
||||
heroOffset: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// BOTTOM SHEET CALLBACKS
|
||||
|
||||
Future<void> onMapMoved() async {
|
||||
assetsDebouncer.run(updateAssetsInBounds);
|
||||
}
|
||||
|
||||
void onBottomSheetScrolled(String assetRemoteId) {
|
||||
final assetMarker = markersInBounds.value
|
||||
.firstWhereOrNull((m) => m.assetRemoteId == assetRemoteId);
|
||||
if (assetMarker != null) {
|
||||
updateAssetMarkerPosition(assetMarker);
|
||||
}
|
||||
}
|
||||
|
||||
void onZoomToAsset(String assetRemoteId) {
|
||||
final assetMarker = markersInBounds.value
|
||||
.firstWhereOrNull((m) => m.assetRemoteId == assetRemoteId);
|
||||
if (mapController.value != null && assetMarker != null) {
|
||||
// Offset the latitude a little to show the marker just above the viewports center
|
||||
final offset = context.isMobile ? 0.02 : 0;
|
||||
final latlng = LatLng(
|
||||
assetMarker.latLng.latitude - offset,
|
||||
assetMarker.latLng.longitude,
|
||||
);
|
||||
mapController.value!.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(latlng, mapZoomToAssetLevel),
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onZoomToLocation() async {
|
||||
try {
|
||||
bool serviceEnabled = await Geolocator.isLocationServiceEnabled();
|
||||
if (!serviceEnabled) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (context) => Theme(
|
||||
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
|
||||
child: LocationServiceDisabledDialog(),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
LocationPermission permission = await Geolocator.checkPermission();
|
||||
bool shouldRequestPermission = false;
|
||||
|
||||
if (permission == LocationPermission.denied) {
|
||||
shouldRequestPermission = await showDialog(
|
||||
context: context,
|
||||
builder: (context) => Theme(
|
||||
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
|
||||
child: LocationPermissionDisabledDialog(),
|
||||
),
|
||||
);
|
||||
if (shouldRequestPermission) {
|
||||
permission = await Geolocator.requestPermission();
|
||||
}
|
||||
}
|
||||
|
||||
if (permission == LocationPermission.denied ||
|
||||
permission == LocationPermission.deniedForever) {
|
||||
// Open app settings only if you did not request for permission before
|
||||
if (permission == LocationPermission.deniedForever &&
|
||||
!shouldRequestPermission) {
|
||||
await Geolocator.openAppSettings();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
Position currentUserLocation = await Geolocator.getCurrentPosition(
|
||||
desiredAccuracy: LocationAccuracy.medium,
|
||||
timeLimit: const Duration(seconds: 5),
|
||||
);
|
||||
|
||||
forceAssetUpdate = true;
|
||||
mapController.move(
|
||||
LatLng(currentUserLocation.latitude, currentUserLocation.longitude),
|
||||
zoomLevel,
|
||||
);
|
||||
} catch (error) {
|
||||
log.severe(
|
||||
"Cannot get user's current location due to ${error.toString()}",
|
||||
);
|
||||
if (context.mounted) {
|
||||
final location = await MapUtils.checkPermAndGetLocation(context);
|
||||
if (location.$2 != null) {
|
||||
if (location.$2 == LocationPermission.unableToDetermine &&
|
||||
context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
@ -252,253 +227,180 @@ class MapPageState extends ConsumerState<MapPage> {
|
||||
msg: "map_cannot_get_user_location".tr(),
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
void handleBottomSheetEvents(dynamic event) {
|
||||
if (event is MapPageBottomSheetScrolled) {
|
||||
final assetInBottomSheet = event.asset;
|
||||
if (assetInBottomSheet != null) {
|
||||
final mapMarker = mapMarkerData.value
|
||||
.firstWhereOrNull((e) => e.asset.id == assetInBottomSheet.id);
|
||||
closestAssetMarker.value = mapMarker;
|
||||
if (mapMarker != null && mapController.zoom >= 5) {
|
||||
LatLng? newCenter = mapController.centerBoundsWithPadding(
|
||||
mapMarker.point,
|
||||
const Offset(0, -120),
|
||||
);
|
||||
if (newCenter != null) {
|
||||
mapController.move(
|
||||
newCenter,
|
||||
mapController.zoom,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (event is MapPageZoomToAsset) {
|
||||
onZoomToAssetEvent(event.asset);
|
||||
} else if (event is MapPageZoomToLocation) {
|
||||
onZoomToLocation();
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
final bottomSheetEventSubscription =
|
||||
bottomSheetEventStream.listen(handleBottomSheetEvents);
|
||||
return bottomSheetEventSubscription.cancel;
|
||||
},
|
||||
[bottomSheetEventStream],
|
||||
);
|
||||
|
||||
void handleMapTapEvent(LatLng tapPosition) {
|
||||
const d = Distance();
|
||||
final assetsInBoundsList = assetsInBounds.toList();
|
||||
assetsInBoundsList.sort(
|
||||
(a, b) => d
|
||||
.distance(a.point, tapPosition)
|
||||
.compareTo(d.distance(b.point, tapPosition)),
|
||||
);
|
||||
// First asset less than the threshold from the tap point
|
||||
final nearestAsset = assetsInBoundsList.firstWhereOrNull(
|
||||
(element) =>
|
||||
d.distance(element.point, tapPosition) <
|
||||
mapController.getTapThresholdForZoomLevel(),
|
||||
);
|
||||
// Reset marker if no assets are near the tap point
|
||||
if (nearestAsset == null && closestAssetMarker.value != null) {
|
||||
selectionEnabledHook.value = false;
|
||||
mapPageEventSC.add(
|
||||
const MapPageOnTapEvent(),
|
||||
if (mapController.value != null && location.$1 != null) {
|
||||
mapController.value!.animateCamera(
|
||||
CameraUpdate.newLatLngZoom(
|
||||
LatLng(location.$1!.latitude, location.$1!.longitude),
|
||||
mapZoomToAssetLevel,
|
||||
),
|
||||
duration: const Duration(milliseconds: 800),
|
||||
);
|
||||
}
|
||||
closestAssetMarker.value = nearestAsset;
|
||||
}
|
||||
|
||||
void onMapEvent(MapEvent mapEvent) {
|
||||
if (mapEvent is MapEventMove || mapEvent is MapEventDoubleTapZoom) {
|
||||
if (forceAssetUpdate ||
|
||||
mapEvent.source != MapEventSource.mapController) {
|
||||
debounce(() {
|
||||
if (selectionEnabledHook.value) {
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
reloadAssetsInBound(
|
||||
mapMarkerData.value,
|
||||
forceReload: forceAssetUpdate,
|
||||
);
|
||||
forceAssetUpdate = false;
|
||||
});
|
||||
}
|
||||
} else if (mapEvent is MapEventTap) {
|
||||
handleMapTapEvent(mapEvent.tapPosition);
|
||||
}
|
||||
void onAssetsSelected(bool selected, Set<Asset> selection) {
|
||||
selectedAssets.value = selected ? selection : {};
|
||||
}
|
||||
|
||||
void onShareAsset() {
|
||||
handleShareAssets(ref, context, selectedAssets.value.toList());
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
return MapThemeOveride(
|
||||
mapBuilder: (style) => context.isMobile
|
||||
// Single-column
|
||||
? Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: MapAppBar(selectedAssets: selectedAssets),
|
||||
body: Stack(
|
||||
children: [
|
||||
_MapWithMarker(
|
||||
style: style,
|
||||
selectedMarker: selectedMarker,
|
||||
onMapCreated: onMapCreated,
|
||||
onMapMoved: onMapMoved,
|
||||
onMapClicked: onMarkerClicked,
|
||||
onStyleLoaded: reloadLayers,
|
||||
onMarkerTapped: onMarkerTapped,
|
||||
),
|
||||
// Should be a part of the body and not scaffold::bottomsheet for the
|
||||
// location button to be hit testable
|
||||
MapBottomSheet(
|
||||
mapEventStream: bottomSheetStreamController.stream,
|
||||
onGridAssetChanged: onBottomSheetScrolled,
|
||||
onZoomToAsset: onZoomToAsset,
|
||||
onAssetsSelected: onAssetsSelected,
|
||||
onZoomToLocation: onZoomToLocation,
|
||||
selectedAssets: selectedAssets,
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
// Two-pane
|
||||
: Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: Scaffold(
|
||||
extendBodyBehindAppBar: true,
|
||||
appBar: MapAppBar(selectedAssets: selectedAssets),
|
||||
body: Stack(
|
||||
children: [
|
||||
_MapWithMarker(
|
||||
style: style,
|
||||
selectedMarker: selectedMarker,
|
||||
onMapCreated: onMapCreated,
|
||||
onMapMoved: onMapMoved,
|
||||
onMapClicked: onMarkerClicked,
|
||||
onStyleLoaded: reloadLayers,
|
||||
onMarkerTapped: onMarkerTapped,
|
||||
),
|
||||
Positioned(
|
||||
right: 0,
|
||||
bottom: 30,
|
||||
child: ElevatedButton(
|
||||
onPressed: onZoomToLocation,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.my_location),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LayoutBuilder(
|
||||
builder: (ctx, constraints) => MapAssetGrid(
|
||||
controller: scrollController,
|
||||
mapEventStream: bottomSheetStreamController.stream,
|
||||
onGridAssetChanged: onBottomSheetScrolled,
|
||||
onZoomToAsset: onZoomToAsset,
|
||||
onAssetsSelected: onAssetsSelected,
|
||||
selectedAssets: selectedAssets,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onFavoriteAsset() async {
|
||||
showLoadingIndicator.value = true;
|
||||
try {
|
||||
await handleFavoriteAssets(ref, context, selectedAssets.value.toList());
|
||||
} finally {
|
||||
showLoadingIndicator.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
refetchMarkers.value = true;
|
||||
}
|
||||
}
|
||||
class _AssetMarkerMeta {
|
||||
final Point<num> point;
|
||||
final MapMarker marker;
|
||||
final bool shouldAnimate;
|
||||
|
||||
void onArchiveAsset() async {
|
||||
showLoadingIndicator.value = true;
|
||||
try {
|
||||
await handleArchiveAssets(ref, context, selectedAssets.value.toList());
|
||||
} finally {
|
||||
showLoadingIndicator.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
refetchMarkers.value = true;
|
||||
}
|
||||
}
|
||||
const _AssetMarkerMeta({
|
||||
required this.point,
|
||||
required this.marker,
|
||||
required this.shouldAnimate,
|
||||
});
|
||||
|
||||
void selectionListener(bool isMultiSelect, Set<Asset> selection) {
|
||||
selectionEnabledHook.value = isMultiSelect;
|
||||
selectedAssets.value = selection;
|
||||
}
|
||||
@override
|
||||
String toString() =>
|
||||
'_AssetMarkerMeta(point: $point, marker: $marker, shouldAnimate: $shouldAnimate)';
|
||||
}
|
||||
|
||||
final markerLayer = MarkerLayer(
|
||||
markers: [
|
||||
if (closestAssetMarker.value != null)
|
||||
AssetMarker(
|
||||
remoteId: closestAssetMarker.value!.asset.remoteId!,
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: closestAssetMarker.value!.point,
|
||||
width: 100,
|
||||
height: 100,
|
||||
builder: (ctx) => GestureDetector(
|
||||
onTap: () => openAssetInViewer(closestAssetMarker.value!.asset),
|
||||
child: AssetMarkerIcon(
|
||||
key: Key(closestAssetMarker.value!.asset.remoteId!),
|
||||
isDarkTheme: isDarkTheme,
|
||||
id: closestAssetMarker.value!.asset.remoteId!,
|
||||
class _MapWithMarker extends StatelessWidget {
|
||||
final AsyncValue<String> style;
|
||||
final MapCreatedCallback onMapCreated;
|
||||
final OnCameraIdleCallback onMapMoved;
|
||||
final OnMapClickCallback onMapClicked;
|
||||
final OnStyleLoadedCallback onStyleLoaded;
|
||||
final Function()? onMarkerTapped;
|
||||
final ValueNotifier<_AssetMarkerMeta?> selectedMarker;
|
||||
|
||||
const _MapWithMarker({
|
||||
required this.style,
|
||||
required this.onMapCreated,
|
||||
required this.onMapMoved,
|
||||
required this.onMapClicked,
|
||||
required this.onStyleLoaded,
|
||||
required this.selectedMarker,
|
||||
this.onMarkerTapped,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (ctx, constraints) => SizedBox(
|
||||
height: constraints.maxHeight,
|
||||
width: constraints.maxWidth,
|
||||
child: Stack(
|
||||
children: [
|
||||
style.widgetWhen(
|
||||
onData: (style) => MaplibreMap(
|
||||
initialCameraPosition:
|
||||
const CameraPosition(target: LatLng(0, 0)),
|
||||
styleString: style,
|
||||
// This is needed to update the selectedMarker's position on map camera updates
|
||||
// The changes are notified through the mapController ValueListener which is added in [onMapCreated]
|
||||
trackCameraPosition: true,
|
||||
onMapCreated: onMapCreated,
|
||||
onCameraIdle: onMapMoved,
|
||||
onMapClick: onMapClicked,
|
||||
onStyleLoadedCallback: onStyleLoaded,
|
||||
tiltGesturesEnabled: false,
|
||||
dragEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
attributionButtonPosition: AttributionButtonPosition.TopRight,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final heatMapLayer = mapMarkerData.value.isNotEmpty
|
||||
? HeatMapLayer(
|
||||
heatMapDataSource: InMemoryHeatMapDataSource(
|
||||
data: mapMarkerData.value
|
||||
.map(
|
||||
(e) => WeightedLatLng(
|
||||
LatLng(e.point.latitude, e.point.longitude),
|
||||
1,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: selectedMarker,
|
||||
builder: (ctx, value, _) => value != null
|
||||
? PositionedAssetMarkerIcon(
|
||||
point: value.point,
|
||||
assetRemoteId: value.marker.assetRemoteId,
|
||||
durationInMilliseconds: value.shouldAnimate ? 100 : 0,
|
||||
onTap: onMarkerTapped,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
heatMapOptions: HeatMapOptions(
|
||||
radius: 60,
|
||||
layerOpacity: 0.5,
|
||||
gradient: {
|
||||
0.20: Colors.deepPurple,
|
||||
0.40: Colors.blue,
|
||||
0.60: Colors.green,
|
||||
0.95: Colors.yellow,
|
||||
1.0: Colors.deepOrange,
|
||||
},
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
|
||||
return AnnotatedRegion<SystemUiOverlayStyle>(
|
||||
value: SystemUiOverlayStyle(
|
||||
statusBarColor:
|
||||
(isDarkTheme ? Colors.black : Colors.white).withOpacity(0.5),
|
||||
statusBarIconBrightness:
|
||||
isDarkTheme ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarColor:
|
||||
isDarkTheme ? Colors.grey[900] : Colors.grey[100],
|
||||
systemNavigationBarIconBrightness:
|
||||
isDarkTheme ? Brightness.light : Brightness.dark,
|
||||
systemNavigationBarDividerColor: Colors.transparent,
|
||||
),
|
||||
child: Theme(
|
||||
// Override app theme based on map theme
|
||||
data: isDarkTheme ? immichDarkTheme : immichLightTheme,
|
||||
child: Scaffold(
|
||||
appBar: MapAppBar(
|
||||
isDarkTheme: isDarkTheme,
|
||||
selectionEnabled: selectionEnabledHook,
|
||||
selectedAssetsLength: selectedAssets.value.length,
|
||||
onShare: onShareAsset,
|
||||
onArchive: onArchiveAsset,
|
||||
onFavorite: onFavoriteAsset,
|
||||
),
|
||||
extendBodyBehindAppBar: true,
|
||||
body: Stack(
|
||||
children: [
|
||||
if (!isLoading)
|
||||
FlutterMap(
|
||||
mapController: mapController,
|
||||
options: MapOptions(
|
||||
maxBounds:
|
||||
LatLngBounds(LatLng(-90, -180.0), LatLng(90.0, 180.0)),
|
||||
interactiveFlags: InteractiveFlag.doubleTapZoom |
|
||||
InteractiveFlag.drag |
|
||||
InteractiveFlag.flingAnimation |
|
||||
InteractiveFlag.pinchMove |
|
||||
InteractiveFlag.pinchZoom,
|
||||
center: LatLng(20, 20),
|
||||
zoom: 2,
|
||||
minZoom: 1,
|
||||
maxZoom: maxZoom,
|
||||
onMapReady: () {
|
||||
isMapReady = true;
|
||||
mapController.mapEventStream.listen(onMapEvent);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
ref.read(mapStateNotifier.notifier).getTileLayer(),
|
||||
heatMapLayer,
|
||||
markerLayer,
|
||||
],
|
||||
),
|
||||
if (!isLoading)
|
||||
MapPageBottomSheet(
|
||||
mapPageEventStream: mapPageEventSC.stream,
|
||||
bottomSheetEventSC: bottomSheetEventSC,
|
||||
selectionEnabled: selectionEnabledHook.value,
|
||||
selectionlistener: selectionListener,
|
||||
isDarkTheme: isDarkTheme,
|
||||
),
|
||||
if (showLoadingIndicator.value || isLoading)
|
||||
Positioned(
|
||||
top: context.height * 0.35,
|
||||
left: context.width * 0.425,
|
||||
child: const ImmichLoadingIndicator(),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class AssetMarker extends Marker {
|
||||
String remoteId;
|
||||
|
||||
AssetMarker({
|
||||
super.key,
|
||||
required this.remoteId,
|
||||
super.anchorPos,
|
||||
required super.point,
|
||||
super.width = 100.0,
|
||||
super.height = 100.0,
|
||||
required super.builder,
|
||||
});
|
||||
}
|
||||
|
159
mobile/lib/modules/map/widgets/map_app_bar.dart
Normal file
159
mobile/lib/modules/map/widgets/map_app_bar.dart
Normal file
@ -0,0 +1,159 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_settings_sheet.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
|
||||
class MapAppBar extends HookWidget implements PreferredSizeWidget {
|
||||
final ValueNotifier<Set<Asset>> selectedAssets;
|
||||
|
||||
const MapAppBar({super.key, required this.selectedAssets});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(top: MediaQuery.paddingOf(context).top + 25),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: selectedAssets,
|
||||
builder: (ctx, value, child) => value.isNotEmpty
|
||||
? _SelectionRow(selectedAssets: selectedAssets)
|
||||
: _NonSelectionRow(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(100);
|
||||
}
|
||||
|
||||
class _NonSelectionRow extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void onSettingsPressed() {
|
||||
showModalBottomSheet(
|
||||
elevation: 0.0,
|
||||
showDragHandle: true,
|
||||
isScrollControlled: true,
|
||||
context: context,
|
||||
builder: (_) => const MapSettingsSheet(),
|
||||
);
|
||||
}
|
||||
|
||||
return Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () => context.popRoute(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.arrow_back_ios_new_rounded),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: onSettingsPressed,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.more_vert_rounded),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SelectionRow extends HookConsumerWidget {
|
||||
final ValueNotifier<Set<Asset>> selectedAssets;
|
||||
|
||||
const _SelectionRow({required this.selectedAssets});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isProcessing = useProcessingOverlay();
|
||||
|
||||
Future<void> handleProcessing(
|
||||
FutureOr<void> Function() action, [
|
||||
bool reloadMarkers = false,
|
||||
]) async {
|
||||
isProcessing.value = true;
|
||||
await action();
|
||||
// Reset state
|
||||
selectedAssets.value = {};
|
||||
isProcessing.value = false;
|
||||
if (reloadMarkers) {
|
||||
ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(true);
|
||||
}
|
||||
}
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: () => selectedAssets.value = {},
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
label: Text(
|
||||
'${selectedAssets.value.length}',
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
color: context.colorScheme.onPrimary,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
ElevatedButton(
|
||||
onPressed: () => handleProcessing(
|
||||
() => handleShareAssets(
|
||||
ref,
|
||||
context,
|
||||
selectedAssets.value.toList(),
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.ios_share_rounded),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => handleProcessing(
|
||||
() => handleFavoriteAssets(
|
||||
ref,
|
||||
context,
|
||||
selectedAssets.value.toList(),
|
||||
),
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.favorite),
|
||||
),
|
||||
ElevatedButton(
|
||||
onPressed: () => handleProcessing(
|
||||
() => handleArchiveAssets(
|
||||
ref,
|
||||
context,
|
||||
selectedAssets.value.toList(),
|
||||
),
|
||||
true,
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.archive),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
273
mobile/lib/modules/map/widgets/map_asset_grid.dart
Normal file
273
mobile/lib/modules/map/widgets/map_asset_grid.dart
Normal file
@ -0,0 +1,273 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:collection/collection.dart';
|
||||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/collection_extensions.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/map/models/map_event.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/utils/throttle.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
class MapAssetGrid extends HookConsumerWidget {
|
||||
final Stream<MapEvent> mapEventStream;
|
||||
final Function(String)? onGridAssetChanged;
|
||||
final Function(String)? onZoomToAsset;
|
||||
final Function(bool, Set<Asset>)? onAssetsSelected;
|
||||
final ValueNotifier<Set<Asset>> selectedAssets;
|
||||
final ScrollController controller;
|
||||
|
||||
const MapAssetGrid({
|
||||
required this.mapEventStream,
|
||||
this.onGridAssetChanged,
|
||||
this.onZoomToAsset,
|
||||
this.onAssetsSelected,
|
||||
required this.selectedAssets,
|
||||
required this.controller,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final log = Logger("MapAssetGrid");
|
||||
final assetsInBounds = useState<List<Asset>>([]);
|
||||
final cachedRenderList = useRef<RenderList?>(null);
|
||||
final lastRenderElementIndex = useRef<int?>(null);
|
||||
final assetInSheet = useValueNotifier<String?>(null);
|
||||
final gridScrollThrottler =
|
||||
useThrottler(interval: const Duration(milliseconds: 300));
|
||||
|
||||
void handleMapEvents(MapEvent event) async {
|
||||
if (event is MapAssetsInBoundsUpdated) {
|
||||
assetsInBounds.value = await ref
|
||||
.read(dbProvider)
|
||||
.assets
|
||||
.getAllByRemoteId(event.assetRemoteIds);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
|
||||
|
||||
// Hard-restrict to 4 assets / row in portrait mode
|
||||
const assetsPerRow = 4;
|
||||
|
||||
void handleVisibleItems(Iterable<ItemPosition> positions) {
|
||||
final orderedPos = positions.sortedByField((p) => p.index);
|
||||
// Index of row where the items are mostly visible
|
||||
const partialOffset = 0.20;
|
||||
final item = orderedPos
|
||||
.firstWhereOrNull((p) => p.itemTrailingEdge > partialOffset);
|
||||
|
||||
// Guard no elements, reset state
|
||||
// Also fail fast when the sheet is just opened and the user is yet to scroll (i.e leading = 0)
|
||||
if (item == null || item.itemLeadingEdge == 0) {
|
||||
lastRenderElementIndex.value = null;
|
||||
return;
|
||||
}
|
||||
|
||||
final renderElement =
|
||||
cachedRenderList.value?.elements.elementAtOrNull(item.index);
|
||||
// Guard no render list or render element
|
||||
if (renderElement == null) {
|
||||
return;
|
||||
}
|
||||
// Reset index
|
||||
lastRenderElementIndex.value == item.index;
|
||||
|
||||
// <RenderElement:offset:0>
|
||||
// | 1 | 2 | 3 | 4 | 5 | 6 |
|
||||
// <RenderElement:offset:6>
|
||||
// | 7 | 8 | 9 |
|
||||
// <RenderElement:offset:9>
|
||||
// | 10 |
|
||||
|
||||
// Skip through the assets from the previous row
|
||||
final rowOffset = renderElement.offset;
|
||||
// Column offset = (total trailingEdge - trailingEdge crossed) / offset for each asset
|
||||
final totalOffset = item.itemTrailingEdge - item.itemLeadingEdge;
|
||||
final edgeOffset = (totalOffset - partialOffset) /
|
||||
// Round the total count to the next multiple of [assetsPerRow]
|
||||
((renderElement.totalCount / assetsPerRow) * assetsPerRow).floor();
|
||||
|
||||
// trailing should never be above the totalOffset
|
||||
final columnOffset =
|
||||
(totalOffset - math.min(item.itemTrailingEdge, totalOffset)) ~/
|
||||
edgeOffset;
|
||||
final assetOffset = rowOffset + columnOffset;
|
||||
final selectedAsset = cachedRenderList.value?.allAssets
|
||||
?.elementAtOrNull(assetOffset)
|
||||
?.remoteId;
|
||||
|
||||
if (selectedAsset != null) {
|
||||
onGridAssetChanged?.call(selectedAsset);
|
||||
assetInSheet.value = selectedAsset;
|
||||
}
|
||||
}
|
||||
|
||||
return Card(
|
||||
margin: EdgeInsets.zero,
|
||||
child: Stack(
|
||||
children: [
|
||||
/// The Align and FractionallySizedBox are to prevent the Asset Grid from going behind the
|
||||
/// _MapSheetDragRegion and thereby displaying content behind the top right and top left curves
|
||||
Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: FractionallySizedBox(
|
||||
// Place it just below the drag handle
|
||||
heightFactor: 0.80,
|
||||
child: assetsInBounds.value.isNotEmpty
|
||||
? ref.watch(renderListProvider(assetsInBounds.value)).when(
|
||||
data: (renderList) {
|
||||
// Cache render list here to use it back during visibleItemsListener
|
||||
cachedRenderList.value = renderList;
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: selectedAssets,
|
||||
builder: (_, value, __) => ImmichAssetGrid(
|
||||
shrinkWrap: true,
|
||||
renderList: renderList,
|
||||
showDragScroll: false,
|
||||
assetsPerRow: assetsPerRow,
|
||||
showMultiSelectIndicator: false,
|
||||
selectionActive: value.isNotEmpty,
|
||||
listener: onAssetsSelected,
|
||||
visibleItemsListener: (pos) => gridScrollThrottler
|
||||
.run(() => handleVisibleItems(pos)),
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, stackTrace) {
|
||||
log.warning(
|
||||
"Cannot get assets in the current map bounds $error",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
)
|
||||
: _MapNoAssetsInSheet(),
|
||||
),
|
||||
),
|
||||
_MapSheetDragRegion(
|
||||
controller: controller,
|
||||
assetsInBoundCount: assetsInBounds.value.length,
|
||||
assetInSheet: assetInSheet,
|
||||
onZoomToAsset: onZoomToAsset,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapNoAssetsInSheet extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
const image = Image(
|
||||
height: 150,
|
||||
width: 150,
|
||||
image: AssetImage('assets/lighthouse.png'),
|
||||
);
|
||||
|
||||
return Center(
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
context.isDarkTheme
|
||||
? const InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: BrightnessFilter(
|
||||
brightness: -5,
|
||||
child: image,
|
||||
),
|
||||
),
|
||||
)
|
||||
: image,
|
||||
const SizedBox(height: 20),
|
||||
Center(
|
||||
child: Text(
|
||||
"map_zoom_to_see_photos".tr(),
|
||||
style: context.textTheme.displayLarge?.copyWith(fontSize: 18),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapSheetDragRegion extends StatelessWidget {
|
||||
final ScrollController controller;
|
||||
final int assetsInBoundCount;
|
||||
final ValueNotifier<String?> assetInSheet;
|
||||
final Function(String)? onZoomToAsset;
|
||||
|
||||
const _MapSheetDragRegion({
|
||||
required this.controller,
|
||||
required this.assetsInBoundCount,
|
||||
required this.assetInSheet,
|
||||
this.onZoomToAsset,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final assetsInBoundsText = assetsInBoundCount > 0
|
||||
? "map_assets_in_bounds".tr(args: [assetsInBoundCount.toString()])
|
||||
: "map_no_assets_in_bounds".tr();
|
||||
|
||||
return SingleChildScrollView(
|
||||
controller: controller,
|
||||
physics: const ClampingScrollPhysics(),
|
||||
child: Card(
|
||||
margin: EdgeInsets.zero,
|
||||
shape: context.isMobile ? null : const BeveledRectangleBorder(),
|
||||
elevation: 0.0,
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 15),
|
||||
const CustomDraggingHandle(),
|
||||
const SizedBox(height: 15),
|
||||
Text(assetsInBoundsText, style: context.textTheme.bodyLarge),
|
||||
const Divider(height: 35),
|
||||
],
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: assetInSheet,
|
||||
builder: (_, value, __) => Visibility(
|
||||
visible: value != null,
|
||||
child: Positioned(
|
||||
right: 15,
|
||||
top: 15,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.map_outlined,
|
||||
color: context.textTheme.displayLarge?.color,
|
||||
),
|
||||
iconSize: 20,
|
||||
tooltip: 'Zoom to bounds',
|
||||
onPressed: () => onZoomToAsset?.call(value!),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
97
mobile/lib/modules/map/widgets/map_bottom_sheet.dart
Normal file
97
mobile/lib/modules/map/widgets/map_bottom_sheet.dart
Normal file
@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_event.model.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_asset_grid.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
|
||||
|
||||
class MapBottomSheet extends HookConsumerWidget {
|
||||
final Stream<MapEvent> mapEventStream;
|
||||
final Function(String)? onGridAssetChanged;
|
||||
final Function(String)? onZoomToAsset;
|
||||
final Function()? onZoomToLocation;
|
||||
final Function(bool, Set<Asset>)? onAssetsSelected;
|
||||
final ValueNotifier<Set<Asset>> selectedAssets;
|
||||
|
||||
const MapBottomSheet({
|
||||
required this.mapEventStream,
|
||||
this.onGridAssetChanged,
|
||||
this.onZoomToAsset,
|
||||
this.onAssetsSelected,
|
||||
this.onZoomToLocation,
|
||||
required this.selectedAssets,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const sheetMinExtent = 0.1;
|
||||
final sheetController = useDraggableScrollController();
|
||||
final bottomSheetOffset = useValueNotifier(sheetMinExtent);
|
||||
final isBottomSheetOpened = useRef(false);
|
||||
|
||||
void handleMapEvents(MapEvent event) async {
|
||||
if (event is MapCloseBottomSheet) {
|
||||
sheetController.animateTo(
|
||||
0.1,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
|
||||
|
||||
bool onScrollNotification(DraggableScrollableNotification notification) {
|
||||
isBottomSheetOpened.value =
|
||||
notification.extent > (notification.maxExtent * 0.9);
|
||||
bottomSheetOffset.value = notification.extent;
|
||||
// do not bubble
|
||||
return true;
|
||||
}
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
NotificationListener<DraggableScrollableNotification>(
|
||||
onNotification: onScrollNotification,
|
||||
child: DraggableScrollableSheet(
|
||||
controller: sheetController,
|
||||
minChildSize: sheetMinExtent,
|
||||
maxChildSize: 0.5,
|
||||
initialChildSize: sheetMinExtent,
|
||||
snap: true,
|
||||
shouldCloseOnMinExtent: false,
|
||||
builder: (ctx, scrollController) => MapAssetGrid(
|
||||
controller: scrollController,
|
||||
mapEventStream: mapEventStream,
|
||||
selectedAssets: selectedAssets,
|
||||
onAssetsSelected: onAssetsSelected,
|
||||
// Do not bother with the event if the bottom sheet is not user scrolled
|
||||
onGridAssetChanged: (assetId) => isBottomSheetOpened.value
|
||||
? onGridAssetChanged?.call(assetId)
|
||||
: null,
|
||||
onZoomToAsset: onZoomToAsset,
|
||||
),
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: bottomSheetOffset,
|
||||
builder: (ctx, value, child) => Positioned(
|
||||
right: 0,
|
||||
bottom: context.height * (value + 0.02),
|
||||
child: child!,
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: onZoomToLocation,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.my_location),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,31 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class MapSettingsListTile extends StatelessWidget {
|
||||
final String title;
|
||||
final bool selected;
|
||||
final Function(bool) onChanged;
|
||||
|
||||
const MapSettingsListTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile.adaptive(
|
||||
activeColor: context.primaryColor,
|
||||
title: Text(
|
||||
title,
|
||||
style:
|
||||
context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
value: selected,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,92 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MapTimeDropDown extends StatelessWidget {
|
||||
final int relativeTime;
|
||||
final Function(int) onTimeChange;
|
||||
|
||||
const MapTimeDropDown({
|
||||
super.key,
|
||||
required this.relativeTime,
|
||||
required this.onTimeChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
"map_settings_only_relative_range".tr(),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (_, constraints) => DropdownMenu(
|
||||
width: constraints.maxWidth * 0.9,
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
initialSelection: relativeTime,
|
||||
onSelected: (value) => onTimeChange(value!),
|
||||
dropdownMenuEntries: [
|
||||
DropdownMenuEntry(
|
||||
value: 0,
|
||||
label: "map_settings_date_range_option_all".tr(),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 1,
|
||||
label: "map_settings_date_range_option_day".tr(),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 7,
|
||||
label: "map_settings_date_range_option_days".tr(
|
||||
args: ["7"],
|
||||
),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 30,
|
||||
label: "map_settings_date_range_option_days".tr(
|
||||
args: ["30"],
|
||||
),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 1,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_year".tr(),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 3,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_years".tr(args: ["3"]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
@ -0,0 +1,109 @@
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapThemePicker extends StatelessWidget {
|
||||
final ThemeMode themeMode;
|
||||
final Function(ThemeMode) onThemeChange;
|
||||
|
||||
const MapThemePicker({
|
||||
super.key,
|
||||
required this.themeMode,
|
||||
required this.onThemeChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"map_settings_theme_settings",
|
||||
style: context.textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_BorderedMapThumbnail(
|
||||
name: "Light",
|
||||
mode: ThemeMode.light,
|
||||
shouldHighlight: themeMode == ThemeMode.light,
|
||||
onThemeChange: onThemeChange,
|
||||
),
|
||||
_BorderedMapThumbnail(
|
||||
name: "Dark",
|
||||
mode: ThemeMode.dark,
|
||||
shouldHighlight: themeMode == ThemeMode.dark,
|
||||
onThemeChange: onThemeChange,
|
||||
),
|
||||
_BorderedMapThumbnail(
|
||||
name: "System",
|
||||
mode: ThemeMode.system,
|
||||
shouldHighlight: themeMode == ThemeMode.system,
|
||||
onThemeChange: onThemeChange,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BorderedMapThumbnail extends StatelessWidget {
|
||||
final ThemeMode mode;
|
||||
final String name;
|
||||
final bool shouldHighlight;
|
||||
final Function(ThemeMode) onThemeChange;
|
||||
|
||||
const _BorderedMapThumbnail({
|
||||
required this.mode,
|
||||
required this.name,
|
||||
required this.shouldHighlight,
|
||||
required this.onThemeChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.fromBorderSide(
|
||||
BorderSide(
|
||||
width: 4,
|
||||
color: shouldHighlight
|
||||
? context.colorScheme.onSurface
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: MapThumbnail(
|
||||
zoom: 2,
|
||||
centre: const LatLng(47, 5),
|
||||
onTap: (_, __) => onThemeChange(mode),
|
||||
themeMode: mode,
|
||||
showAttribution: false,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(
|
||||
name,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: shouldHighlight ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
61
mobile/lib/modules/map/widgets/map_settings_sheet.dart
Normal file
61
mobile/lib/modules/map/widgets/map_settings_sheet.dart
Normal file
@ -0,0 +1,61 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_settings/map_settings_list_tile.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_settings/map_settings_time_dropdown.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_settings/map_theme_picker.dart';
|
||||
|
||||
class MapSettingsSheet extends HookConsumerWidget {
|
||||
const MapSettingsSheet({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mapState = ref.watch(mapStateNotifierProvider);
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
expand: false,
|
||||
initialChildSize: 0.6,
|
||||
builder: (ctx, scrollController) => SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: Card(
|
||||
elevation: 0.0,
|
||||
shadowColor: Colors.transparent,
|
||||
margin: EdgeInsets.zero,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.max,
|
||||
children: [
|
||||
MapThemePicker(
|
||||
themeMode: mapState.themeMode,
|
||||
onThemeChange: (mode) => ref
|
||||
.read(mapStateNotifierProvider.notifier)
|
||||
.switchTheme(mode),
|
||||
),
|
||||
const Divider(height: 30, thickness: 2),
|
||||
MapSettingsListTile(
|
||||
title: "map_settings_only_show_favorites",
|
||||
selected: mapState.showFavoriteOnly,
|
||||
onChanged: (favoriteOnly) => ref
|
||||
.read(mapStateNotifierProvider.notifier)
|
||||
.switchFavoriteOnly(favoriteOnly),
|
||||
),
|
||||
MapSettingsListTile(
|
||||
title: "map_settings_include_show_archived",
|
||||
selected: mapState.includeArchived,
|
||||
onChanged: (includeArchive) => ref
|
||||
.read(mapStateNotifierProvider.notifier)
|
||||
.switchIncludeArchived(includeArchive),
|
||||
),
|
||||
MapTimeDropDown(
|
||||
relativeTime: mapState.relativeTime,
|
||||
onTimeChange: (time) => ref
|
||||
.read(mapStateNotifierProvider.notifier)
|
||||
.setRelativeTime(time),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
96
mobile/lib/modules/map/widgets/map_theme_override.dart
Normal file
96
mobile/lib/modules/map/widgets/map_theme_override.dart
Normal file
@ -0,0 +1,96 @@
|
||||
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';
|
||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||
|
||||
/// Overrides the theme below the widget tree to use the theme data based on the
|
||||
/// map settings instead of the one from the app settings
|
||||
class MapThemeOveride extends StatefulHookConsumerWidget {
|
||||
final ThemeMode? themeMode;
|
||||
final Widget Function(AsyncValue<String> style) mapBuilder;
|
||||
|
||||
const MapThemeOveride({required this.mapBuilder, this.themeMode, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _MapThemeOverideState();
|
||||
}
|
||||
|
||||
class _MapThemeOverideState extends ConsumerState<MapThemeOveride>
|
||||
with WidgetsBindingObserver {
|
||||
late ThemeMode _theme;
|
||||
bool _isDarkTheme = false;
|
||||
|
||||
bool get _isSystemDark =>
|
||||
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
|
||||
Brightness.dark;
|
||||
|
||||
bool checkDarkTheme() {
|
||||
return _theme == ThemeMode.dark ||
|
||||
_theme == ThemeMode.system && _isSystemDark;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_theme = widget.themeMode ??
|
||||
ref.read(mapStateNotifierProvider.select((v) => v.themeMode));
|
||||
setState(() {
|
||||
_isDarkTheme = checkDarkTheme();
|
||||
});
|
||||
if (_theme == ThemeMode.system) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_theme != ThemeMode.system) {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangePlatformBrightness() {
|
||||
super.didChangePlatformBrightness();
|
||||
|
||||
if (_theme == ThemeMode.system) {
|
||||
setState(() => _isDarkTheme = _isSystemDark);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_theme = widget.themeMode ??
|
||||
ref.watch(mapStateNotifierProvider.select((v) => v.themeMode));
|
||||
|
||||
useValueChanged<ThemeMode, void>(_theme, (_, __) {
|
||||
if (_theme == ThemeMode.system) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
} else {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
setState(() {
|
||||
_isDarkTheme = checkDarkTheme();
|
||||
});
|
||||
});
|
||||
|
||||
return Theme(
|
||||
data: _isDarkTheme ? immichDarkTheme : immichLightTheme,
|
||||
child: widget.mapBuilder.call(
|
||||
ref.watch(
|
||||
mapStateNotifierProvider.select(
|
||||
(v) => _isDarkTheme ? v.darkStyleFetched : v.lightStyleFetched,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
110
mobile/lib/modules/map/widgets/map_thumbnail.dart
Normal file
110
mobile/lib/modules/map/widgets/map_thumbnail.dart
Normal file
@ -0,0 +1,110 @@
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/positioned_asset_marker_icon.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
/// A non-interactive thumbnail of a map in the given coordinates with optional markers
|
||||
///
|
||||
/// User can provide either a [assetMarkerRemoteId] to display the asset's thumbnail or set
|
||||
/// [showMarkerPin] to true which would display a marker pin instead. If both are provided,
|
||||
/// [assetMarkerRemoteId] will take precedence
|
||||
class MapThumbnail extends HookConsumerWidget {
|
||||
final Function(Point<double>, LatLng)? onTap;
|
||||
final LatLng centre;
|
||||
final String? assetMarkerRemoteId;
|
||||
final bool showMarkerPin;
|
||||
final double zoom;
|
||||
final double height;
|
||||
final double width;
|
||||
final ThemeMode? themeMode;
|
||||
final bool showAttribution;
|
||||
|
||||
const MapThumbnail({
|
||||
super.key,
|
||||
required this.centre,
|
||||
this.height = 100,
|
||||
this.width = 100,
|
||||
this.onTap,
|
||||
this.zoom = 8,
|
||||
this.assetMarkerRemoteId,
|
||||
this.showMarkerPin = false,
|
||||
this.themeMode,
|
||||
this.showAttribution = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude);
|
||||
final controller = useRef<MaplibreMapController?>(null);
|
||||
final position = useValueNotifier<Point<num>?>(null);
|
||||
|
||||
Future<void> onMapCreated(MaplibreMapController mapController) async {
|
||||
controller.value = mapController;
|
||||
if (assetMarkerRemoteId != null) {
|
||||
// The iOS impl returns wrong toScreenLocation without the delay
|
||||
Future.delayed(
|
||||
const Duration(milliseconds: 100),
|
||||
() async =>
|
||||
position.value = await mapController.toScreenLocation(centre),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onStyleLoaded() async {
|
||||
if (showMarkerPin && controller.value != null) {
|
||||
await controller.value?.addMarkerAtLatLng(centre);
|
||||
}
|
||||
}
|
||||
|
||||
return MapThemeOveride(
|
||||
themeMode: themeMode,
|
||||
mapBuilder: (style) => SizedBox(
|
||||
height: height,
|
||||
width: width,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
style.widgetWhen(
|
||||
onData: (style) => MaplibreMap(
|
||||
initialCameraPosition:
|
||||
CameraPosition(target: offsettedCentre, zoom: zoom),
|
||||
styleString: style,
|
||||
onMapCreated: onMapCreated,
|
||||
onStyleLoadedCallback: onStyleLoaded,
|
||||
onMapClick: onTap,
|
||||
doubleClickZoomEnabled: false,
|
||||
dragEnabled: false,
|
||||
zoomGesturesEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
scrollGesturesEnabled: false,
|
||||
rotateGesturesEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
attributionButtonMargins:
|
||||
showAttribution == false ? const Point(-100, 0) : null,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: position,
|
||||
builder: (_, value, __) => value != null
|
||||
? PositionedAssetMarkerIcon(
|
||||
size: height / 2,
|
||||
point: value,
|
||||
assetRemoteId: assetMarkerRemoteId!,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -1,17 +1,57 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class AssetMarkerIcon extends StatelessWidget {
|
||||
const AssetMarkerIcon({
|
||||
class PositionedAssetMarkerIcon extends StatelessWidget {
|
||||
final Point<num> point;
|
||||
final String assetRemoteId;
|
||||
final double size;
|
||||
final int durationInMilliseconds;
|
||||
|
||||
final Function()? onTap;
|
||||
|
||||
const PositionedAssetMarkerIcon({
|
||||
required this.point,
|
||||
required this.assetRemoteId,
|
||||
this.size = 100,
|
||||
this.durationInMilliseconds = 100,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = Platform.isIOS ? 1.0 : MediaQuery.devicePixelRatioOf(context);
|
||||
return AnimatedPositioned(
|
||||
left: point.x / ratio - size / 2,
|
||||
top: point.y / ratio - size,
|
||||
duration: Duration(milliseconds: durationInMilliseconds),
|
||||
child: GestureDetector(
|
||||
onTap: () => onTap?.call(),
|
||||
child: SizedBox.square(
|
||||
dimension: size,
|
||||
child: _AssetMarkerIcon(
|
||||
id: assetRemoteId,
|
||||
key: Key(assetRemoteId),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetMarkerIcon extends StatelessWidget {
|
||||
const _AssetMarkerIcon({
|
||||
required this.id,
|
||||
this.isDarkTheme = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String id;
|
||||
final bool isDarkTheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
@ -26,8 +66,8 @@ class AssetMarkerIcon extends StatelessWidget {
|
||||
left: constraints.maxWidth * 0.5,
|
||||
child: CustomPaint(
|
||||
painter: _PinPainter(
|
||||
primaryColor: isDarkTheme ? Colors.white : Colors.black,
|
||||
secondaryColor: isDarkTheme ? Colors.black : Colors.white,
|
||||
primaryColor: context.colorScheme.onSurface,
|
||||
secondaryColor: context.colorScheme.surface,
|
||||
primaryRadius: constraints.maxHeight * 0.06,
|
||||
secondaryRadius: constraints.maxHeight * 0.038,
|
||||
),
|
||||
@ -42,7 +82,7 @@ class AssetMarkerIcon extends StatelessWidget {
|
||||
left: constraints.maxWidth * 0.17,
|
||||
child: CircleAvatar(
|
||||
radius: constraints.maxHeight * 0.40,
|
||||
backgroundColor: isDarkTheme ? Colors.white : Colors.black,
|
||||
backgroundColor: context.colorScheme.onSurface,
|
||||
child: CircleAvatar(
|
||||
radius: constraints.maxHeight * 0.37,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
@ -72,8 +112,8 @@ class _PinPainter extends CustomPainter {
|
||||
final double secondaryRadius;
|
||||
|
||||
_PinPainter({
|
||||
this.primaryColor = Colors.black,
|
||||
this.secondaryColor = Colors.white,
|
||||
required this.primaryColor,
|
||||
required this.secondaryColor,
|
||||
required this.primaryRadius,
|
||||
required this.secondaryRadius,
|
||||
});
|
BIN
mobile/lib/modules/search/services/person.service.g.dart
generated
BIN
mobile/lib/modules/search/services/person.service.g.dart
generated
Binary file not shown.
@ -1,13 +1,12 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/curated_row.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class CuratedPlacesRow extends CuratedRow {
|
||||
final bool isMapEnabled;
|
||||
@ -38,14 +37,13 @@ class CuratedPlacesRow extends CuratedRow {
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: MapThumbnail(
|
||||
zoom: 2,
|
||||
coords: LatLng(
|
||||
centre: const LatLng(
|
||||
47,
|
||||
5,
|
||||
),
|
||||
height: imageSize,
|
||||
width: imageSize,
|
||||
showAttribution: false,
|
||||
isDarkTheme: context.isDarkTheme,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
|
@ -46,7 +46,7 @@ enum AppSettingsEnum<T> {
|
||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
||||
mapThemeMode<bool>(StoreKey.mapThemeMode, null, false),
|
||||
mapThemeMode<int>(StoreKey.mapThemeMode, null, 0),
|
||||
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
|
||||
mapIncludeArchived<bool>(StoreKey.mapIncludeArchived, null, false),
|
||||
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
|
||||
|
@ -9,7 +9,7 @@ import 'package:immich_mobile/modules/album/views/asset_selection_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/create_album_page.dart';
|
||||
import 'package:immich_mobile/modules/album/views/library_page.dart';
|
||||
import 'package:immich_mobile/modules/backup/views/backup_options_page.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_location_picker.dart';
|
||||
import 'package:immich_mobile/modules/map/views/map_location_picker_page.dart';
|
||||
import 'package:immich_mobile/modules/map/views/map_page.dart';
|
||||
import 'package:immich_mobile/modules/memories/models/memory.dart';
|
||||
import 'package:immich_mobile/modules/memories/views/memory_page.dart';
|
||||
@ -59,8 +59,8 @@ import 'package:immich_mobile/shared/views/app_log_page.dart';
|
||||
import 'package:immich_mobile/shared/views/splash_screen.dart';
|
||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:photo_manager/photo_manager.dart' hide LatLng;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
||||
part 'router.gr.dart';
|
||||
|
||||
|
@ -1593,7 +1593,7 @@ class ActivitiesRoute extends PageRouteInfo<void> {
|
||||
class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
|
||||
MapLocationPickerRoute({
|
||||
Key? key,
|
||||
LatLng? initialLatLng,
|
||||
LatLng initialLatLng = const LatLng(0, 0),
|
||||
}) : super(
|
||||
MapLocationPickerRoute.name,
|
||||
path: '/map-location-picker-page',
|
||||
@ -1609,12 +1609,12 @@ class MapLocationPickerRoute extends PageRouteInfo<MapLocationPickerRouteArgs> {
|
||||
class MapLocationPickerRouteArgs {
|
||||
const MapLocationPickerRouteArgs({
|
||||
this.key,
|
||||
this.initialLatLng,
|
||||
this.initialLatLng = const LatLng(0, 0),
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final LatLng? initialLatLng;
|
||||
final LatLng initialLatLng;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
|
@ -1,6 +1,7 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
part 'store.g.dart';
|
||||
|
||||
@ -8,6 +9,7 @@ part 'store.g.dart';
|
||||
/// Supports String, int and JSON-serializable Objects
|
||||
/// Can be used concurrently from multiple isolates
|
||||
class Store {
|
||||
static final Logger _log = Logger("Store");
|
||||
static late final Isar _db;
|
||||
static final List<dynamic> _cache =
|
||||
List.filled(StoreKey.values.map((e) => e.id).max + 1, null);
|
||||
@ -72,8 +74,12 @@ class Store {
|
||||
static void _onChangeListener(List<StoreValue>? data) {
|
||||
if (data != null) {
|
||||
for (StoreValue value in data) {
|
||||
_cache[value.id] =
|
||||
value._extract(StoreKey.values.firstWhere((e) => e.id == value.id));
|
||||
final key = StoreKey.values.firstWhereOrNull((e) => e.id == value.id);
|
||||
if (key != null) {
|
||||
_cache[value.id] = value._extract(key);
|
||||
} else {
|
||||
_log.warning("No key available for value id - ${value.id}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -177,13 +183,13 @@ enum StoreKey<T> {
|
||||
logLevel<int>(115, type: int),
|
||||
preferRemoteImage<bool>(116, type: bool),
|
||||
// map related settings
|
||||
mapThemeMode<bool>(117, type: bool),
|
||||
mapShowFavoriteOnly<bool>(118, type: bool),
|
||||
mapRelativeDate<int>(119, type: int),
|
||||
selfSignedCert<bool>(120, type: bool),
|
||||
mapIncludeArchived<bool>(121, type: bool),
|
||||
ignoreIcloudAssets<bool>(122, type: bool),
|
||||
selectedAlbumSortReverse<bool>(123, type: bool),
|
||||
mapThemeMode<int>(124, type: int),
|
||||
;
|
||||
|
||||
const StoreKey(
|
||||
|
@ -94,7 +94,8 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
|
||||
final _log = Logger('WebsocketNotifier');
|
||||
final Ref _ref;
|
||||
final Debounce _debounce = Debounce(const Duration(milliseconds: 500));
|
||||
final Debouncer _debounce =
|
||||
Debouncer(interval: const Duration(milliseconds: 500));
|
||||
|
||||
/// Connects websocket to server unless already connected
|
||||
void connect() {
|
||||
@ -194,7 +195,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
|
||||
PendingChange(now.millisecondsSinceEpoch.toString(), action, value),
|
||||
],
|
||||
);
|
||||
_debounce(handlePendingChanges);
|
||||
_debounce.run(handlePendingChanges);
|
||||
}
|
||||
|
||||
Future<void> _handlePendingDeletes() async {
|
||||
|
@ -11,8 +11,8 @@ import 'package:immich_mobile/shared/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/api.service.dart';
|
||||
import 'package:immich_mobile/shared/services/sync.service.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
|
||||
final assetServiceProvider = Provider(
|
||||
|
@ -1,4 +1,5 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class CustomDraggingHandle extends StatelessWidget {
|
||||
const CustomDraggingHandle({super.key});
|
||||
@ -6,11 +7,11 @@ class CustomDraggingHandle extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 5,
|
||||
height: 4,
|
||||
width: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey[500],
|
||||
borderRadius: BorderRadius.circular(16),
|
||||
color: context.themeData.dividerColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
@ -3,12 +3,11 @@ import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
Future<LatLng?> showLocationPicker({
|
||||
required BuildContext context,
|
||||
@ -25,16 +24,6 @@ Future<LatLng?> showLocationPicker({
|
||||
|
||||
enum _LocationPickerMode { map, manual }
|
||||
|
||||
bool _validateLat(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -90 && l < 90;
|
||||
}
|
||||
|
||||
bool _validateLong(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -180 && l < 180;
|
||||
}
|
||||
|
||||
class _LocationPicker extends HookWidget {
|
||||
final LatLng? initialLatLng;
|
||||
|
||||
@ -48,187 +37,35 @@ class _LocationPicker extends HookWidget {
|
||||
final longitude = useState(initialLatLng?.longitude ?? 0.0);
|
||||
final latlng = LatLng(latitude.value, longitude.value);
|
||||
final pickerMode = useState(_LocationPickerMode.map);
|
||||
final latitudeController = useTextEditingController();
|
||||
final isValidLatitude = useState(true);
|
||||
final latitiudeFocusNode = useFocusNode();
|
||||
final longitudeController = useTextEditingController();
|
||||
final longitudeFocusNode = useFocusNode();
|
||||
final isValidLongitude = useState(true);
|
||||
|
||||
void validateInputs() {
|
||||
isValidLatitude.value = _validateLat(latitudeController.text);
|
||||
if (isValidLatitude.value) {
|
||||
latitude.value = latitudeController.text.toDouble();
|
||||
Future<void> onMapTap() async {
|
||||
final newLatLng = await context.pushRoute<LatLng?>(
|
||||
MapLocationPickerRoute(initialLatLng: latlng),
|
||||
);
|
||||
if (newLatLng != null) {
|
||||
latitude.value = newLatLng.latitude;
|
||||
longitude.value = newLatLng.longitude;
|
||||
}
|
||||
isValidLongitude.value = _validateLong(longitudeController.text);
|
||||
if (isValidLongitude.value) {
|
||||
longitude.value = longitudeController.text.toDouble();
|
||||
}
|
||||
}
|
||||
|
||||
void validateAndPop() {
|
||||
if (pickerMode.value == _LocationPickerMode.manual) {
|
||||
validateInputs();
|
||||
}
|
||||
if (isValidLatitude.value && isValidLongitude.value) {
|
||||
return context.pop(latlng);
|
||||
}
|
||||
}
|
||||
|
||||
List<Widget> buildMapPickerMode() {
|
||||
return [
|
||||
TextButton.icon(
|
||||
icon: Text(
|
||||
"${latitude.value.toStringAsFixed(4)}, ${longitude.value.toStringAsFixed(4)}",
|
||||
),
|
||||
label: const Icon(Icons.edit_outlined, size: 16),
|
||||
onPressed: () {
|
||||
latitudeController.text = latitude.value.toStringAsFixed(4);
|
||||
longitudeController.text = longitude.value.toStringAsFixed(4);
|
||||
pickerMode.value = _LocationPickerMode.manual;
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
MapThumbnail(
|
||||
coords: latlng,
|
||||
height: 200,
|
||||
width: 200,
|
||||
zoom: 6,
|
||||
showAttribution: false,
|
||||
onTap: (p0, p1) async {
|
||||
final newLatLng = await context.pushRoute<LatLng?>(
|
||||
MapLocationPickerRoute(initialLatLng: latlng),
|
||||
);
|
||||
if (newLatLng != null) {
|
||||
latitude.value = newLatLng.latitude;
|
||||
longitude.value = newLatLng.longitude;
|
||||
}
|
||||
},
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: LatLng(
|
||||
latitude.value,
|
||||
longitude.value,
|
||||
),
|
||||
builder: (ctx) => const Image(
|
||||
image: AssetImage('assets/location-pin.png'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> buildManualPickerMode() {
|
||||
return [
|
||||
TextButton.icon(
|
||||
icon: const Text("location_picker_choose_on_map").tr(),
|
||||
label: const Icon(Icons.map_outlined, size: 16),
|
||||
onPressed: () {
|
||||
validateInputs();
|
||||
if (isValidLatitude.value && isValidLongitude.value) {
|
||||
pickerMode.value = _LocationPickerMode.map;
|
||||
}
|
||||
},
|
||||
),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
TextField(
|
||||
controller: latitudeController,
|
||||
focusNode: latitiudeFocusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'location_picker_latitude'.tr(),
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'location_picker_latitude_hint'.tr(),
|
||||
hintStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
errorText: isValidLatitude.value
|
||||
? null
|
||||
: "location_picker_latitude_error".tr(),
|
||||
),
|
||||
onEditingComplete: () {
|
||||
isValidLatitude.value = _validateLat(latitudeController.text);
|
||||
if (isValidLatitude.value) {
|
||||
latitude.value = latitudeController.text.toDouble();
|
||||
longitudeFocusNode.requestFocus();
|
||||
}
|
||||
},
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(8)],
|
||||
onTapOutside: (_) => latitiudeFocusNode.unfocus(),
|
||||
),
|
||||
const SizedBox(
|
||||
height: 24,
|
||||
),
|
||||
TextField(
|
||||
controller: longitudeController,
|
||||
focusNode: longitudeFocusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'location_picker_longitude'.tr(),
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'location_picker_longitude_hint'.tr(),
|
||||
hintStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
errorText: isValidLongitude.value
|
||||
? null
|
||||
: "location_picker_longitude_error".tr(),
|
||||
),
|
||||
onEditingComplete: () {
|
||||
isValidLongitude.value = _validateLong(longitudeController.text);
|
||||
if (isValidLongitude.value) {
|
||||
longitude.value = longitudeController.text.toDouble();
|
||||
longitudeFocusNode.unfocus();
|
||||
}
|
||||
},
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(8)],
|
||||
onTapOutside: (_) => longitudeFocusNode.unfocus(),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
contentPadding: const EdgeInsets.all(30),
|
||||
alignment: Alignment.center,
|
||||
content: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"edit_location_dialog_title",
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
const SizedBox(
|
||||
height: 12,
|
||||
),
|
||||
if (pickerMode.value == _LocationPickerMode.manual)
|
||||
...buildManualPickerMode(),
|
||||
if (pickerMode.value == _LocationPickerMode.map)
|
||||
...buildMapPickerMode(),
|
||||
],
|
||||
),
|
||||
child: pickerMode.value == _LocationPickerMode.map
|
||||
? _MapPicker(
|
||||
key: ValueKey(latlng),
|
||||
latlng: latlng,
|
||||
onModeSwitch: () =>
|
||||
pickerMode.value = _LocationPickerMode.manual,
|
||||
onMapTap: onMapTap,
|
||||
)
|
||||
: _ManualPicker(
|
||||
latlng: latlng,
|
||||
onModeSwitch: () => pickerMode.value = _LocationPickerMode.map,
|
||||
onLatUpdated: (value) => latitude.value = value,
|
||||
onLonUpdated: (value) => longitude.value = value,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
@ -242,7 +79,7 @@ class _LocationPicker extends HookWidget {
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: validateAndPop,
|
||||
onPressed: () => context.popRoute(latlng),
|
||||
child: Text(
|
||||
"action_common_update",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
@ -255,3 +92,177 @@ class _LocationPicker extends HookWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ManualPickerInput extends HookWidget {
|
||||
final String initialValue;
|
||||
final String decorationText;
|
||||
final String hintText;
|
||||
final String errorText;
|
||||
final FocusNode focusNode;
|
||||
final bool Function(String value) validator;
|
||||
final Function(double value) onUpdated;
|
||||
|
||||
const _ManualPickerInput({
|
||||
required this.initialValue,
|
||||
required this.decorationText,
|
||||
required this.hintText,
|
||||
required this.errorText,
|
||||
required this.focusNode,
|
||||
required this.validator,
|
||||
required this.onUpdated,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isValid = useState(true);
|
||||
final controller = useTextEditingController(text: initialValue);
|
||||
|
||||
void onEditingComplete() {
|
||||
isValid.value = validator(controller.text);
|
||||
if (isValid.value) {
|
||||
onUpdated(controller.text.toDouble());
|
||||
}
|
||||
}
|
||||
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: decorationText.tr(),
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: hintText.tr(),
|
||||
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
|
||||
errorText: isValid.value ? null : errorText.tr(),
|
||||
),
|
||||
onEditingComplete: onEditingComplete,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(8)],
|
||||
onTapOutside: (_) => focusNode.unfocus(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ManualPicker extends HookWidget {
|
||||
final LatLng latlng;
|
||||
final Function() onModeSwitch;
|
||||
final Function(double) onLatUpdated;
|
||||
final Function(double) onLonUpdated;
|
||||
|
||||
const _ManualPicker({
|
||||
required this.latlng,
|
||||
required this.onModeSwitch,
|
||||
required this.onLatUpdated,
|
||||
required this.onLonUpdated,
|
||||
});
|
||||
|
||||
bool _validateLat(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -90 && l < 90;
|
||||
}
|
||||
|
||||
bool _validateLong(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -180 && l < 180;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latitiudeFocusNode = useFocusNode();
|
||||
final longitudeFocusNode = useFocusNode();
|
||||
|
||||
void onLatitudeUpdated(double value) {
|
||||
onLatUpdated(value);
|
||||
longitudeFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
void onLongitudeEditingCompleted(double value) {
|
||||
onLonUpdated(value);
|
||||
longitudeFocusNode.unfocus();
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"edit_location_dialog_title",
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
icon: const Text("location_picker_choose_on_map").tr(),
|
||||
label: const Icon(Icons.map_outlined, size: 16),
|
||||
onPressed: onModeSwitch,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ManualPickerInput(
|
||||
initialValue: latlng.latitude.toStringAsFixed(4),
|
||||
decorationText: "location_picker_latitude",
|
||||
hintText: "location_picker_latitude_hint",
|
||||
errorText: "location_picker_latitude_error",
|
||||
focusNode: latitiudeFocusNode,
|
||||
validator: _validateLat,
|
||||
onUpdated: onLatitudeUpdated,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_ManualPickerInput(
|
||||
initialValue: latlng.longitude.toStringAsFixed(4),
|
||||
decorationText: "location_picker_longitude",
|
||||
hintText: "location_picker_longitude_hint",
|
||||
errorText: "location_picker_longitude_error",
|
||||
focusNode: latitiudeFocusNode,
|
||||
validator: _validateLong,
|
||||
onUpdated: onLongitudeEditingCompleted,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapPicker extends StatelessWidget {
|
||||
final LatLng latlng;
|
||||
final Function() onModeSwitch;
|
||||
final Function() onMapTap;
|
||||
|
||||
const _MapPicker({
|
||||
required this.latlng,
|
||||
required this.onModeSwitch,
|
||||
required this.onMapTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"edit_location_dialog_title",
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
icon: Text(
|
||||
"${latlng.latitude.toStringAsFixed(4)}, ${latlng.longitude.toStringAsFixed(4)}",
|
||||
),
|
||||
label: const Icon(Icons.edit_outlined, size: 16),
|
||||
onPressed: onModeSwitch,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MapThumbnail(
|
||||
centre: latlng,
|
||||
height: 200,
|
||||
width: 200,
|
||||
zoom: 8,
|
||||
showMarkerPin: true,
|
||||
onTap: (_, __) => onMapTap(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -1,26 +1,61 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
class Debounce {
|
||||
Debounce(Duration interval) : _interval = interval.inMilliseconds;
|
||||
final int _interval;
|
||||
/// Used to debounce function calls with the [interval] provided.
|
||||
class Debouncer {
|
||||
Debouncer({required this.interval});
|
||||
final Duration interval;
|
||||
Timer? _timer;
|
||||
VoidCallback? action;
|
||||
FutureOr<void> Function()? _lastAction;
|
||||
|
||||
void call(VoidCallback? action) {
|
||||
this.action = action;
|
||||
void run(FutureOr<void> Function() action) {
|
||||
_lastAction = action;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(Duration(milliseconds: _interval), _callAndRest);
|
||||
_timer = Timer(interval, _callAndRest);
|
||||
}
|
||||
|
||||
void _callAndRest() {
|
||||
action?.call();
|
||||
_lastAction?.call();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
_lastAction = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a [Debouncer] that will be disposed automatically. If no [interval] is provided, a
|
||||
/// default interval of 300ms is used to debounce the function calls
|
||||
Debouncer useDebouncer({
|
||||
Duration interval = const Duration(milliseconds: 300),
|
||||
List<Object?>? keys,
|
||||
}) =>
|
||||
use(_DebouncerHook(interval: interval, keys: keys));
|
||||
|
||||
class _DebouncerHook extends Hook<Debouncer> {
|
||||
const _DebouncerHook({
|
||||
required this.interval,
|
||||
List<Object?>? keys,
|
||||
}) : super(keys: keys);
|
||||
|
||||
final Duration interval;
|
||||
|
||||
@override
|
||||
HookState<Debouncer, Hook<Debouncer>> createState() => _DebouncerHookState();
|
||||
}
|
||||
|
||||
class _DebouncerHookState extends HookState<Debouncer, _DebouncerHook> {
|
||||
late final debouncer = Debouncer(interval: hook.interval);
|
||||
|
||||
@override
|
||||
Debouncer build(_) => debouncer;
|
||||
|
||||
@override
|
||||
void dispose() => debouncer.dispose();
|
||||
|
||||
@override
|
||||
String get debugLabel => 'useDebouncer';
|
||||
}
|
||||
|
41
mobile/lib/utils/draggable_scroll_controller.dart
Normal file
41
mobile/lib/utils/draggable_scroll_controller.dart
Normal file
@ -0,0 +1,41 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Creates a [DraggableScrollableController] that will be disposed automatically.
|
||||
///
|
||||
/// See also:
|
||||
/// - [DraggableScrollableController]
|
||||
DraggableScrollableController useDraggableScrollController({
|
||||
List<Object?>? keys,
|
||||
}) {
|
||||
return use(
|
||||
_DraggableScrollControllerHook(
|
||||
keys: keys,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _DraggableScrollControllerHook
|
||||
extends Hook<DraggableScrollableController> {
|
||||
const _DraggableScrollControllerHook({
|
||||
List<Object?>? keys,
|
||||
}) : super(keys: keys);
|
||||
|
||||
@override
|
||||
HookState<DraggableScrollableController, Hook<DraggableScrollableController>>
|
||||
createState() => _DraggableScrollControllerHookState();
|
||||
}
|
||||
|
||||
class _DraggableScrollControllerHookState extends HookState<
|
||||
DraggableScrollableController, _DraggableScrollControllerHook> {
|
||||
late final controller = DraggableScrollableController();
|
||||
|
||||
@override
|
||||
DraggableScrollableController build(BuildContext context) => controller;
|
||||
|
||||
@override
|
||||
void dispose() => controller.dispose();
|
||||
|
||||
@override
|
||||
String get debugLabel => 'useDraggableScrollController';
|
||||
}
|
@ -12,7 +12,7 @@ import 'package:immich_mobile/shared/ui/date_time_picker.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/location_picker.dart';
|
||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
void handleShareAssets(
|
||||
WidgetRef ref,
|
||||
|
57
mobile/lib/utils/throttle.dart
Normal file
57
mobile/lib/utils/throttle.dart
Normal file
@ -0,0 +1,57 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
|
||||
/// Throttles function calls with the [interval] provided.
|
||||
/// Also make sures to call the last Action after the elapsed interval
|
||||
class Throttler {
|
||||
final Duration interval;
|
||||
DateTime? _lastActionTime;
|
||||
|
||||
Throttler({required this.interval});
|
||||
|
||||
void run(FutureOr<void> Function() action) {
|
||||
if (_lastActionTime == null ||
|
||||
(DateTime.now().difference(_lastActionTime!) > interval)) {
|
||||
action();
|
||||
_lastActionTime = DateTime.now();
|
||||
}
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_lastActionTime = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Creates a [Throttler] that will be disposed automatically. If no [interval] is provided, a
|
||||
/// default interval of 300ms is used to throttle the function calls
|
||||
Throttler useThrottler({
|
||||
Duration interval = const Duration(milliseconds: 300),
|
||||
List<Object?>? keys,
|
||||
}) =>
|
||||
use(_ThrottleHook(interval: interval, keys: keys));
|
||||
|
||||
class _ThrottleHook extends Hook<Throttler> {
|
||||
const _ThrottleHook({
|
||||
required this.interval,
|
||||
List<Object?>? keys,
|
||||
}) : super(keys: keys);
|
||||
|
||||
final Duration interval;
|
||||
|
||||
@override
|
||||
HookState<Throttler, Hook<Throttler>> createState() => _ThrottlerHookState();
|
||||
}
|
||||
|
||||
class _ThrottlerHookState extends HookState<Throttler, _ThrottleHook> {
|
||||
late final throttler = Throttler(interval: hook.interval);
|
||||
|
||||
@override
|
||||
Throttler build(_) => throttler;
|
||||
|
||||
@override
|
||||
void dispose() => throttler.dispose();
|
||||
|
||||
@override
|
||||
String get debugLabel => 'useThrottler';
|
||||
}
|
@ -25,14 +25,22 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.11.2"
|
||||
ansicolor:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: ansicolor
|
||||
sha256: "8bf17a8ff6ea17499e40a2d2542c2f481cd7615760c6d34065cb22bfd22e6880"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
archive:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: archive
|
||||
sha256: "0c8368c9b3f0abbc193b9d6133649a614204b528982bebc7026372d61677ce3a"
|
||||
sha256: "7b875fd4a20b165a3084bd2d210439b22ebc653f21cea4842729c0c30c82596b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.7"
|
||||
version: "3.4.9"
|
||||
args:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -385,14 +393,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.2"
|
||||
executor_lib:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: executor_lib
|
||||
sha256: "544889daa5726462657dab6410b75f2f8e3a77479d85b307a25c346e243bc38e"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.1.1"
|
||||
fake_async:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -503,10 +503,10 @@ packages:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_launcher_icons
|
||||
sha256: "559c600f056e7c704bd843723c21e01b5fba47e8824bd02422165bcc02a5de1d"
|
||||
sha256: "526faf84284b86a4cb36d20a5e45147747b7563d921373d4ee0559c54fcdbcea"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.9.3"
|
||||
version: "0.13.1"
|
||||
flutter_lints:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
@ -544,30 +544,14 @@ packages:
|
||||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
flutter_map:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map
|
||||
sha256: "52c65a977daae42f9aae6748418dd1535eaf27186e9bac9bf431843082bc75a3"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
flutter_map_heatmap:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: flutter_map_heatmap
|
||||
sha256: "2d16cf5bf41f40a79ae79bcdf2afc92ec45fea0cc311b3a51e3eae661392df88"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.0.4+2"
|
||||
flutter_native_splash:
|
||||
dependency: "direct dev"
|
||||
description:
|
||||
name: flutter_native_splash
|
||||
sha256: "6777a3abb974021a39b5fdd2d46a03ca390e03903b6351f21d10e7ecc969f12d"
|
||||
sha256: "17d9671396fb8ec45ad10f4a975eb8a0f70bedf0fdaf0720b31ea9de6da8c4da"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.16"
|
||||
version: "2.3.7"
|
||||
flutter_plugin_android_lifecycle:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -755,10 +739,10 @@ packages:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: image
|
||||
sha256: "8e9d133755c3e84c73288363e6343157c383a0c6c56fc51afcc5d4d7180306d6"
|
||||
sha256: "028f61960d56f26414eb616b48b04eb37d700cbe477b7fb09bf1d7ce57fd9271"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.3.0"
|
||||
version: "4.1.3"
|
||||
image_picker:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -884,14 +868,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.8.1"
|
||||
latlong2:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: latlong2
|
||||
sha256: "08ef7282ba9f76e8495e49e2dc4d653015ac929dce5f92b375a415d30b407ea0"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.8.2"
|
||||
lints:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -900,14 +876,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.1"
|
||||
lists:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: lists
|
||||
sha256: "4ca5c19ae4350de036a7e996cdd1ee39c93ac0a2b840f4915459b7d0a7d4ab27"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
logging:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -916,6 +884,33 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.2.0"
|
||||
maplibre_gl:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
|
||||
resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
|
||||
url: "https://github.com/maplibre/flutter-maplibre-gl.git"
|
||||
source: git
|
||||
version: "0.18.0"
|
||||
maplibre_gl_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: maplibre_gl_platform_interface
|
||||
ref: main
|
||||
resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
|
||||
url: "https://github.com/maplibre/flutter-maplibre-gl.git"
|
||||
source: git
|
||||
version: "0.18.0"
|
||||
maplibre_gl_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
path: maplibre_gl_web
|
||||
ref: main
|
||||
resolved-ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
|
||||
url: "https://github.com/maplibre/flutter-maplibre-gl.git"
|
||||
source: git
|
||||
version: "0.18.0"
|
||||
matcher:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -940,14 +935,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.9.1"
|
||||
mgrs_dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: mgrs_dart
|
||||
sha256: fb89ae62f05fa0bb90f70c31fc870bcbcfd516c843fb554452ab3396f78586f7
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
mime:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1163,14 +1150,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.7.3"
|
||||
polylabel:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: polylabel
|
||||
sha256: "41b9099afb2aa6c1730bdd8a0fab1400d287694ec7615dd8516935fa3144214b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
pool:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1187,22 +1166,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.4"
|
||||
proj4dart:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: proj4dart
|
||||
sha256: c8a659ac9b6864aa47c171e78d41bbe6f5e1d7bd790a5814249e6b68bc44324e
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
protobuf:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: protobuf
|
||||
sha256: "01dd9bd0fa02548bf2ceee13545d4a0ec6046459d847b6b061d8a27237108a08"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.0"
|
||||
provider:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1520,14 +1483,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.1"
|
||||
tuple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: tuple
|
||||
sha256: a97ce2013f240b2f3807bcbaf218765b6f301c3eff91092bcfa23a039e7dd151
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.2"
|
||||
typed_data:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1536,14 +1491,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.3.2"
|
||||
unicode:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: unicode
|
||||
sha256: "0f69e46593d65245774d4f17125c6084d2c20b4e473a983f6e21b7d7762218f1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.3.1"
|
||||
universal_io:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1624,15 +1571,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "3.0.7"
|
||||
vector_map_tiles:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
path: "."
|
||||
ref: immich_above_4
|
||||
resolved-ref: dc685bdbcca2ff2b49b4d0fb77b7bc17fad48608
|
||||
url: "https://github.com/shenlong-tanwen/flutter-vector-map-tiles.git"
|
||||
source: git
|
||||
version: "4.0.0"
|
||||
vector_math:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -1641,22 +1579,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.4"
|
||||
vector_tile:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_tile
|
||||
sha256: "2ac77f6bbd7ddd97efe059207d67bb7eaf807ab98ad58d99fe41a22c230f44e1"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "1.0.0"
|
||||
vector_tile_renderer:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: vector_tile_renderer
|
||||
sha256: de212da0f5e48107d3b763a940a428eb1f49d8a4664d41ac0b654f77209a2d0b
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.0"
|
||||
video_player:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
@ -1761,14 +1683,6 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.1.4"
|
||||
wkt_parser:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: wkt_parser
|
||||
sha256: "8a555fc60de3116c00aad67891bcab20f81a958e4219cc106e3c037aa3937f13"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.0.0"
|
||||
xdg_directories:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
@ -25,13 +25,12 @@ dependencies:
|
||||
video_player: ^2.2.18
|
||||
chewie: ^1.4.0
|
||||
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
||||
flutter_map: ^4.0.0
|
||||
flutter_map_heatmap: ^0.0.4
|
||||
# Update it to tag once next stable release
|
||||
maplibre_gl:
|
||||
git:
|
||||
url: https://github.com/maplibre/flutter-maplibre-gl.git
|
||||
ref: acb428a005efd9832a0a8e7ef0945f899dfb3ca5
|
||||
geolocator: ^10.0.0 # used to move to current location in map view
|
||||
vector_map_tiles:
|
||||
git:
|
||||
url: https://github.com/shenlong-tanwen/flutter-vector-map-tiles.git
|
||||
ref: immich_above_4
|
||||
flutter_udid: ^2.0.0
|
||||
package_info_plus: ^4.1.0
|
||||
url_launcher: ^6.1.3
|
||||
@ -40,10 +39,9 @@ dependencies:
|
||||
easy_localization: ^3.0.1
|
||||
share_plus: ^7.1.0
|
||||
flutter_displaymode: ^0.4.0
|
||||
scrollable_positioned_list: ^0.3.4
|
||||
scrollable_positioned_list: ^0.3.8
|
||||
path: ^1.8.1
|
||||
path_provider: ^2.0.11
|
||||
latlong2: ^0.8.1
|
||||
collection: ^1.16.0
|
||||
http_parser: ^4.0.1
|
||||
flutter_web_auth: ^0.5.0
|
||||
@ -79,7 +77,7 @@ dev_dependencies:
|
||||
flutter_lints: ^2.0.1
|
||||
build_runner: ^2.2.1
|
||||
auto_route_generator: ^5.0.2
|
||||
flutter_launcher_icons: "^0.9.2"
|
||||
flutter_launcher_icons: ^0.13.1
|
||||
flutter_native_splash: ^2.2.16
|
||||
isar_generator: *isar_version
|
||||
integration_test:
|
||||
@ -117,11 +115,12 @@ flutter:
|
||||
fonts:
|
||||
- asset: fonts/overpass/OverpassMono.ttf
|
||||
|
||||
flutter_icons:
|
||||
flutter_launcher_icons:
|
||||
image_path_android: "assets/immich-logo-no-outline.png"
|
||||
image_path_ios: "assets/immich-logo-no-outline.png"
|
||||
android: true # can specify file name here e.g. "ic_launcher"
|
||||
ios: true # can specify file name here e.g. "My-Launcher-Icon
|
||||
remove_alpha_ios: true
|
||||
|
||||
analyzer:
|
||||
exclude:
|
||||
|
@ -203,7 +203,7 @@ void main() {
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() async {
|
||||
settingsMock = AppSettingsServiceMock();
|
||||
settingsMock = MockAppSettingsService();
|
||||
container = TestUtils.createContainer(
|
||||
overrides: [
|
||||
appSettingsServiceProvider.overrideWith((ref) => settingsMock),
|
||||
@ -283,7 +283,7 @@ void main() {
|
||||
late ProviderContainer container;
|
||||
|
||||
setUp(() async {
|
||||
settingsMock = AppSettingsServiceMock();
|
||||
settingsMock = MockAppSettingsService();
|
||||
container = TestUtils.createContainer(
|
||||
overrides: [
|
||||
appSettingsServiceProvider.overrideWith((ref) => settingsMock),
|
||||
|
18
mobile/test/modules/map/map_mocks.dart
Normal file
18
mobile/test/modules/map/map_mocks.dart
Normal file
@ -0,0 +1,18 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class MockMapStateNotifier extends Notifier<MapState>
|
||||
with Mock
|
||||
implements MapStateNotifier {
|
||||
final MapState initState;
|
||||
|
||||
MockMapStateNotifier(this.initState);
|
||||
|
||||
@override
|
||||
MapState build() => initState;
|
||||
|
||||
@override
|
||||
set state(MapState mapState) => super.state = mapState;
|
||||
}
|
165
mobile/test/modules/map/map_theme_override_test.dart
Normal file
165
mobile/test/modules/map/map_theme_override_test.dart
Normal file
@ -0,0 +1,165 @@
|
||||
@Tags(['widget'])
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_state.model.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/map/widgets/map_theme_override.dart';
|
||||
|
||||
import '../../test_utils.dart';
|
||||
import '../../widget_tester_extensions.dart';
|
||||
import 'map_mocks.dart';
|
||||
|
||||
void main() {
|
||||
late MockMapStateNotifier mapStateNotifier;
|
||||
late List<Override> overrides;
|
||||
late MapState mapState;
|
||||
|
||||
setUpAll(() async {
|
||||
TestUtils.init();
|
||||
});
|
||||
|
||||
setUp(() {
|
||||
mapState = MapState(themeMode: ThemeMode.dark);
|
||||
mapStateNotifier = MockMapStateNotifier(mapState);
|
||||
overrides = [mapStateNotifierProvider.overrideWith(() => mapStateNotifier)];
|
||||
});
|
||||
|
||||
testWidgets("Return dark theme style when theme mode is dark",
|
||||
(tester) async {
|
||||
AsyncValue<String>? mapStyle;
|
||||
await tester.pumpConsumerWidget(
|
||||
MapThemeOveride(
|
||||
mapBuilder: (AsyncValue<String> style) {
|
||||
mapStyle = style;
|
||||
return const Text("Mock");
|
||||
},
|
||||
),
|
||||
overrides: overrides,
|
||||
);
|
||||
|
||||
mapStateNotifier.state =
|
||||
mapState.copyWith(darkStyleFetched: const AsyncData("dark"));
|
||||
await tester.pumpAndSettle();
|
||||
expect(mapStyle?.valueOrNull, "dark");
|
||||
});
|
||||
|
||||
testWidgets("Return error when style is not fetched", (tester) async {
|
||||
AsyncValue<String>? mapStyle;
|
||||
await tester.pumpConsumerWidget(
|
||||
MapThemeOveride(
|
||||
mapBuilder: (AsyncValue<String> style) {
|
||||
mapStyle = style;
|
||||
return const Text("Mock");
|
||||
},
|
||||
),
|
||||
overrides: overrides,
|
||||
);
|
||||
|
||||
mapStateNotifier.state = mapState.copyWith(
|
||||
darkStyleFetched: const AsyncError("Error", StackTrace.empty),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(mapStyle?.hasError, isTrue);
|
||||
});
|
||||
|
||||
testWidgets("Return light theme style when theme mode is light",
|
||||
(tester) async {
|
||||
AsyncValue<String>? mapStyle;
|
||||
await tester.pumpConsumerWidget(
|
||||
MapThemeOveride(
|
||||
mapBuilder: (AsyncValue<String> style) {
|
||||
mapStyle = style;
|
||||
return const Text("Mock");
|
||||
},
|
||||
),
|
||||
overrides: overrides,
|
||||
);
|
||||
|
||||
mapStateNotifier.state = mapState.copyWith(
|
||||
themeMode: ThemeMode.light,
|
||||
lightStyleFetched: const AsyncData("light"),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(mapStyle?.valueOrNull, "light");
|
||||
});
|
||||
|
||||
group("System mode", () {
|
||||
testWidgets("Return dark theme style when system is dark", (tester) async {
|
||||
AsyncValue<String>? mapStyle;
|
||||
await tester.pumpConsumerWidget(
|
||||
MapThemeOveride(
|
||||
mapBuilder: (AsyncValue<String> style) {
|
||||
mapStyle = style;
|
||||
return const Text("Mock");
|
||||
},
|
||||
),
|
||||
overrides: overrides,
|
||||
);
|
||||
|
||||
tester.binding.platformDispatcher.platformBrightnessTestValue =
|
||||
Brightness.dark;
|
||||
mapStateNotifier.state = mapState.copyWith(
|
||||
themeMode: ThemeMode.system,
|
||||
darkStyleFetched: const AsyncData("dark"),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(mapStyle?.valueOrNull, "dark");
|
||||
});
|
||||
|
||||
testWidgets("Return light theme style when system is light",
|
||||
(tester) async {
|
||||
AsyncValue<String>? mapStyle;
|
||||
await tester.pumpConsumerWidget(
|
||||
MapThemeOveride(
|
||||
mapBuilder: (AsyncValue<String> style) {
|
||||
mapStyle = style;
|
||||
return const Text("Mock");
|
||||
},
|
||||
),
|
||||
overrides: overrides,
|
||||
);
|
||||
|
||||
tester.binding.platformDispatcher.platformBrightnessTestValue =
|
||||
Brightness.light;
|
||||
mapStateNotifier.state = mapState.copyWith(
|
||||
themeMode: ThemeMode.system,
|
||||
lightStyleFetched: const AsyncData("light"),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(mapStyle?.valueOrNull, "light");
|
||||
});
|
||||
|
||||
testWidgets("Switches style when system brightness changes",
|
||||
(tester) async {
|
||||
AsyncValue<String>? mapStyle;
|
||||
await tester.pumpConsumerWidget(
|
||||
MapThemeOveride(
|
||||
mapBuilder: (AsyncValue<String> style) {
|
||||
mapStyle = style;
|
||||
return const Text("Mock");
|
||||
},
|
||||
),
|
||||
overrides: overrides,
|
||||
);
|
||||
|
||||
tester.binding.platformDispatcher.platformBrightnessTestValue =
|
||||
Brightness.light;
|
||||
mapStateNotifier.state = mapState.copyWith(
|
||||
themeMode: ThemeMode.system,
|
||||
lightStyleFetched: const AsyncData("light"),
|
||||
darkStyleFetched: const AsyncData("dark"),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(mapStyle?.valueOrNull, "light");
|
||||
|
||||
tester.binding.platformDispatcher.platformBrightnessTestValue =
|
||||
Brightness.dark;
|
||||
await tester.pumpAndSettle();
|
||||
expect(mapStyle?.valueOrNull, "dark");
|
||||
});
|
||||
});
|
||||
}
|
@ -1,4 +1,4 @@
|
||||
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
|
||||
import 'package:mocktail/mocktail.dart';
|
||||
|
||||
class AppSettingsServiceMock extends Mock implements AppSettingsService {}
|
||||
class MockAppSettingsService extends Mock implements AppSettingsService {}
|
||||
|
41
mobile/test/modules/utils/debouncer_test.dart
Normal file
41
mobile/test/modules/utils/debouncer_test.dart
Normal file
@ -0,0 +1,41 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
|
||||
class _Counter {
|
||||
int _count = 0;
|
||||
_Counter();
|
||||
|
||||
int get count => _count;
|
||||
void increment() => _count = _count + 1;
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('Executes the method after the interval', () async {
|
||||
var counter = _Counter();
|
||||
final debouncer = Debouncer(interval: const Duration(milliseconds: 300));
|
||||
debouncer.run(() => counter.increment());
|
||||
expect(counter.count, 0);
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
expect(counter.count, 1);
|
||||
});
|
||||
|
||||
test('Executes the method immediately if zero interval', () async {
|
||||
var counter = _Counter();
|
||||
final debouncer = Debouncer(interval: const Duration(milliseconds: 0));
|
||||
debouncer.run(() => counter.increment());
|
||||
// Even though it is supposed to be executed immediately, it is added to the async queue and so
|
||||
// we need this delay to make sure the actual debounced method is called
|
||||
await Future.delayed(const Duration(milliseconds: 0));
|
||||
expect(counter.count, 1);
|
||||
});
|
||||
|
||||
test('Delayes method execution after all the calls are completed', () async {
|
||||
var counter = _Counter();
|
||||
final debouncer = Debouncer(interval: const Duration(milliseconds: 100));
|
||||
debouncer.run(() => counter.increment());
|
||||
debouncer.run(() => counter.increment());
|
||||
debouncer.run(() => counter.increment());
|
||||
await Future.delayed(const Duration(milliseconds: 300));
|
||||
expect(counter.count, 1);
|
||||
});
|
||||
}
|
47
mobile/test/modules/utils/throttler_test.dart
Normal file
47
mobile/test/modules/utils/throttler_test.dart
Normal file
@ -0,0 +1,47 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/throttle.dart';
|
||||
|
||||
class _Counter {
|
||||
int _count = 0;
|
||||
_Counter();
|
||||
|
||||
int get count => _count;
|
||||
void increment() {
|
||||
debugPrint("Counter inside increment: $count");
|
||||
_count = _count + 1;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
test('Executes the method immediately if no calls received previously',
|
||||
() async {
|
||||
var counter = _Counter();
|
||||
final throttler = Throttler(interval: const Duration(milliseconds: 300));
|
||||
throttler.run(() => counter.increment());
|
||||
expect(counter.count, 1);
|
||||
});
|
||||
|
||||
test('Does not execute calls before throttle interval', () async {
|
||||
var counter = _Counter();
|
||||
final throttler = Throttler(interval: const Duration(milliseconds: 100));
|
||||
throttler.run(() => counter.increment());
|
||||
throttler.run(() => counter.increment());
|
||||
throttler.run(() => counter.increment());
|
||||
throttler.run(() => counter.increment());
|
||||
throttler.run(() => counter.increment());
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
expect(counter.count, 1);
|
||||
});
|
||||
|
||||
test('Executes the method if received in intervals', () async {
|
||||
var counter = _Counter();
|
||||
final throttler = Throttler(interval: const Duration(milliseconds: 100));
|
||||
for (final _ in Iterable<int>.generate(10)) {
|
||||
throttler.run(() => counter.increment());
|
||||
await Future.delayed(const Duration(milliseconds: 50));
|
||||
}
|
||||
await Future.delayed(const Duration(seconds: 1));
|
||||
expect(counter.count, 5);
|
||||
});
|
||||
}
|
Loading…
Reference in New Issue
Block a user