mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 06:31:58 +00:00
feat(mobile): map view (#3661)
* feat(mobile): map page - add map view * map: add map-markers * feat(map): add relative date filter * fix: do not let users scroll past map bounds * fix: fetch relative date from store to state on init * feat(mobile):re-fetch markers only on filter change * feat(mobile) - asset bottom sheet in map page * feat(mobile): display markers based on bottom sheet scroll * fix: exif-bottom-sheet - rebase conflict * feat(mobile): map-view - strongly typed map page events * feat(map): zoom to asset * chore: dart analyzer fixes * map-page move attribution to top-right * feat(mobile): map view - asset selection handling * feat(mobile): map-view display map in places row * fix: make asset marker icon responsive * optimise map page rebuilds * refactor(mobile): map page * feat(mobile): map-view: Go to location * map-view(mobile): minor refactor * fix(mobile): Handle invalid coords gracefully * small styling --------- Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
305889f32b
commit
cb391342d7
37 changed files with 2268 additions and 139 deletions
|
@ -64,6 +64,7 @@
|
|||
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
|
||||
<queries>
|
||||
<intent>
|
||||
|
|
|
@ -301,5 +301,20 @@
|
|||
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
||||
"version_announcement_overlay_text_3": " and ensure your docker-compose and .env setup is up-to-date to prevent any misconfigurations, especially if you use WatchTower or any mechanism that handles updating your server application automatically.",
|
||||
"version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
|
||||
"translated_text_options": "Options"
|
||||
"translated_text_options": "Options",
|
||||
"map_no_assets_in_bounds": "No photos in this area",
|
||||
"map_zoom_to_see_photos": "Zoom out to see photos",
|
||||
"map_settings_dialog_title": "Map Settings",
|
||||
"map_settings_dark_mode": "Dark mode",
|
||||
"map_settings_only_show_favorites": "Show Favorite Only",
|
||||
"map_settings_only_relative_range": "Date range",
|
||||
"map_settings_dialog_cancel": "Cancel",
|
||||
"map_settings_dialog_save": "Save",
|
||||
"map_cannot_get_user_location": "Cannot get user's location",
|
||||
"map_location_service_disabled_title": "Location Service disabled",
|
||||
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
|
||||
"map_no_location_permission_title": "Location Permission denied",
|
||||
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
|
||||
"map_location_dialog_cancel": "Cancel",
|
||||
"map_location_dialog_yes": "Yes"
|
||||
}
|
||||
|
|
BIN
mobile/assets/lighthouse.png
Normal file
BIN
mobile/assets/lighthouse.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 45 KiB |
|
@ -20,6 +20,8 @@ PODS:
|
|||
- FMDB (2.7.5):
|
||||
- FMDB/standard (= 2.7.5)
|
||||
- FMDB/standard (2.7.5)
|
||||
- geolocator_apple (1.2.0):
|
||||
- Flutter
|
||||
- image_picker_ios (0.0.1):
|
||||
- Flutter
|
||||
- integration_test (0.0.1):
|
||||
|
@ -65,6 +67,7 @@ DEPENDENCIES:
|
|||
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
||||
- flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
|
||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
|
||||
- geolocator_apple (from `.symlinks/plugins/geolocator_apple/ios`)
|
||||
- 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`)
|
||||
|
@ -104,6 +107,8 @@ EXTERNAL SOURCES:
|
|||
:path: ".symlinks/plugins/flutter_web_auth/ios"
|
||||
fluttertoast:
|
||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||
geolocator_apple:
|
||||
:path: ".symlinks/plugins/geolocator_apple/ios"
|
||||
image_picker_ios:
|
||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||
integration_test:
|
||||
|
@ -143,6 +148,7 @@ SPEC CHECKSUMS:
|
|||
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
|
||||
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
|
||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||
geolocator_apple: cc556e6844d508c95df1e87e3ea6fa4e58c50401
|
||||
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
|
||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
||||
|
|
|
@ -83,8 +83,6 @@
|
|||
</dict>
|
||||
<key>NSCameraUsageDescription</key>
|
||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
||||
<key>NSLocationAlwaysUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
<key>NSLocationWhenInUseUsageDescription</key>
|
||||
<string>Enable location setting to show position of assets on map</string>
|
||||
<key>NSMicrophoneUsageDescription</key>
|
||||
|
|
|
@ -2,14 +2,12 @@ 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:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
|
||||
class ArchivePage extends HookConsumerWidget {
|
||||
const ArchivePage({super.key});
|
||||
|
@ -68,24 +66,12 @@ class ArchivePage extends HookConsumerWidget {
|
|||
: () async {
|
||||
processing.value = true;
|
||||
try {
|
||||
if (selection.value.isNotEmpty) {
|
||||
await ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleArchive(
|
||||
selection.value.toList(),
|
||||
false,
|
||||
);
|
||||
|
||||
final assetOrAssets = selection.value.length > 1
|
||||
? 'assets'
|
||||
: 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'Moved ${selection.value.length} $assetOrAssets to library',
|
||||
gravity: ToastGravity.CENTER,
|
||||
);
|
||||
}
|
||||
await handleArchiveAssets(
|
||||
ref,
|
||||
context,
|
||||
selection.value.toList(),
|
||||
shouldArchive: false,
|
||||
);
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
|
@ -41,7 +42,10 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
Uri uri = Uri(
|
||||
scheme: 'geo',
|
||||
host: '$latitude,$longitude',
|
||||
queryParameters: {'z': '$zoomLevel', 'q': formattedDateTime},
|
||||
queryParameters: {
|
||||
'z': '$zoomLevel',
|
||||
'q': '$latitude,$longitude($formattedDateTime)',
|
||||
},
|
||||
);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
|
@ -77,65 +81,35 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Container(
|
||||
height: 150,
|
||||
width: constraints.maxWidth,
|
||||
decoration: const BoxDecoration(
|
||||
borderRadius: BorderRadius.all(Radius.circular(15)),
|
||||
return MapThumbnail(
|
||||
coords: LatLng(
|
||||
exifInfo?.latitude ?? 0,
|
||||
exifInfo?.longitude ?? 0,
|
||||
),
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
interactiveFlags: InteractiveFlag.none,
|
||||
center: LatLng(
|
||||
height: 150,
|
||||
zoom: 16.0,
|
||||
markers: [
|
||||
Marker(
|
||||
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||
point: LatLng(
|
||||
exifInfo?.latitude ?? 0,
|
||||
exifInfo?.longitude ?? 0,
|
||||
),
|
||||
zoom: 16.0,
|
||||
onTap: (tapPosition, latLong) async {
|
||||
Uri? uri = await _createCoordinatesUri();
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('Opening Map Uri: $uri');
|
||||
launchUrl(uri);
|
||||
},
|
||||
builder: (ctx) => const Image(
|
||||
image: AssetImage('assets/location-pin.png'),
|
||||
),
|
||||
),
|
||||
nonRotatedChildren: [
|
||||
RichAttributionWidget(
|
||||
attributions: [
|
||||
TextSourceAttribution(
|
||||
'OpenStreetMap contributors',
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
children: [
|
||||
TileLayer(
|
||||
urlTemplate:
|
||||
"https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
),
|
||||
MarkerLayer(
|
||||
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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
onTap: (tapPosition, latLong) async {
|
||||
Uri? uri = await _createCoordinatesUri();
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('Opening Map Uri: $uri');
|
||||
launchUrl(uri);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
|
|
|
@ -2,13 +2,11 @@ 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:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
|
||||
class FavoritesPage extends HookConsumerWidget {
|
||||
const FavoritesPage({Key? key}) : super(key: key);
|
||||
|
@ -44,16 +42,11 @@ class FavoritesPage extends HookConsumerWidget {
|
|||
void unfavorite() async {
|
||||
try {
|
||||
if (selection.value.isNotEmpty) {
|
||||
await ref.watch(assetProvider.notifier).toggleFavorite(
|
||||
selection.value.toList(),
|
||||
false,
|
||||
);
|
||||
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'Removed ${selection.value.length} $assetOrAssets from favorites',
|
||||
gravity: ToastGravity.CENTER,
|
||||
await handleFavoriteAssets(
|
||||
ref,
|
||||
context,
|
||||
selection.value.toList(),
|
||||
shouldFavorite: false,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
|
|
|
@ -30,6 +30,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
|||
final void Function(ItemPosition start, ItemPosition end)?
|
||||
visibleItemsListener;
|
||||
final Widget? topWidget;
|
||||
final bool shrinkWrap;
|
||||
final bool showDragScroll;
|
||||
|
||||
const ImmichAssetGrid({
|
||||
super.key,
|
||||
|
@ -47,6 +49,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
|||
this.showMultiSelectIndicator = true,
|
||||
this.visibleItemsListener,
|
||||
this.topWidget,
|
||||
this.shrinkWrap = false,
|
||||
this.showDragScroll = true,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -108,6 +112,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
|||
visibleItemsListener: visibleItemsListener,
|
||||
topWidget: topWidget,
|
||||
heroOffset: heroOffset(),
|
||||
shrinkWrap: shrinkWrap,
|
||||
showDragScroll: showDragScroll,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
|
|
@ -35,6 +35,8 @@ class ImmichAssetGridView extends StatefulWidget {
|
|||
visibleItemsListener;
|
||||
final Widget? topWidget;
|
||||
final int heroOffset;
|
||||
final bool shrinkWrap;
|
||||
final bool showDragScroll;
|
||||
|
||||
const ImmichAssetGridView({
|
||||
super.key,
|
||||
|
@ -52,6 +54,8 @@ class ImmichAssetGridView extends StatefulWidget {
|
|||
this.visibleItemsListener,
|
||||
this.topWidget,
|
||||
this.heroOffset = 0,
|
||||
this.shrinkWrap = false,
|
||||
this.showDragScroll = true,
|
||||
});
|
||||
|
||||
@override
|
||||
|
@ -324,7 +328,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||
}
|
||||
|
||||
Widget _buildAssetGrid() {
|
||||
final useDragScrolling = widget.renderList.totalAssets >= 20;
|
||||
final useDragScrolling =
|
||||
widget.showDragScroll && widget.renderList.totalAssets >= 20;
|
||||
|
||||
void dragScrolling(bool active) {
|
||||
if (active != _scrolling) {
|
||||
|
@ -344,6 +349,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|||
itemCount: widget.renderList.elements.length +
|
||||
(widget.topWidget != null ? 1 : 0),
|
||||
addRepaintBoundaries: true,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
);
|
||||
|
||||
final child = useDragScrolling
|
||||
|
|
|
@ -25,10 +25,9 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
|||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
|
||||
class HomePage extends HookConsumerWidget {
|
||||
const HomePage({Key? key}) : super(key: key);
|
||||
|
@ -88,17 +87,7 @@ class HomePage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
void onShareAssets() {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref
|
||||
.watch(shareServiceProvider)
|
||||
.shareAssets(selection.value.toList())
|
||||
.then((_) => Navigator.of(buildContext).pop());
|
||||
return const ShareDialog();
|
||||
},
|
||||
barrierDismissible: false,
|
||||
);
|
||||
handleShareAssets(ref, context, selection.value.toList());
|
||||
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
|
@ -126,16 +115,7 @@ class HomePage extends HookConsumerWidget {
|
|||
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
await ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleFavorite(remoteAssets, true);
|
||||
|
||||
final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Added ${remoteAssets.length} $assetOrAssets to favorites',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
await handleFavoriteAssets(ref, context, remoteAssets);
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
|
@ -149,18 +129,7 @@ class HomePage extends HookConsumerWidget {
|
|||
final remoteAssets = remoteOnlySelection(
|
||||
localErrorMessage: 'home_page_archive_err_local'.tr(),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.toggleArchive(remoteAssets, true);
|
||||
|
||||
final assetOrAssets = remoteAssets.length > 1 ? 'assets' : 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Moved ${remoteAssets.length} $assetOrAssets to archive',
|
||||
gravity: ToastGravity.CENTER,
|
||||
);
|
||||
}
|
||||
await handleArchiveAssets(ref, context, remoteAssets);
|
||||
} finally {
|
||||
processing.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
|
|
40
mobile/lib/modules/map/models/map_page_event.model.dart
Normal file
40
mobile/lib/modules/map/models/map_page_event.model.dart
Normal file
|
@ -0,0 +1,40 @@
|
|||
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);
|
||||
}
|
45
mobile/lib/modules/map/models/map_state.model.dart
Normal file
45
mobile/lib/modules/map/models/map_state.model.dart
Normal file
|
@ -0,0 +1,45 @@
|
|||
class MapState {
|
||||
final bool isDarkTheme;
|
||||
final bool showFavoriteOnly;
|
||||
final int relativeTime;
|
||||
|
||||
MapState({
|
||||
this.isDarkTheme = false,
|
||||
this.showFavoriteOnly = false,
|
||||
this.relativeTime = 0,
|
||||
});
|
||||
|
||||
MapState copyWith({
|
||||
bool? isDarkTheme,
|
||||
bool? showFavoriteOnly,
|
||||
int? relativeTime,
|
||||
}) {
|
||||
return MapState(
|
||||
isDarkTheme: isDarkTheme ?? this.isDarkTheme,
|
||||
showFavoriteOnly: showFavoriteOnly ?? this.showFavoriteOnly,
|
||||
relativeTime: relativeTime ?? this.relativeTime,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'MapSettingsState(isDarkTheme: $isDarkTheme, showFavoriteOnly: $showFavoriteOnly, relativeTime: $relativeTime)';
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is MapState &&
|
||||
other.isDarkTheme == isDarkTheme &&
|
||||
other.showFavoriteOnly == showFavoriteOnly &&
|
||||
other.relativeTime == relativeTime;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode {
|
||||
return isDarkTheme.hashCode ^
|
||||
showFavoriteOnly.hashCode ^
|
||||
relativeTime.hashCode;
|
||||
}
|
||||
}
|
58
mobile/lib/modules/map/providers/map_marker.provider.dart
Normal file
58
mobile/lib/modules/map/providers/map_marker.provider.dart
Normal file
|
@ -0,0 +1,58 @@
|
|||
import 'package:hooks_riverpod/hooks_riverpod.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';
|
||||
|
||||
final mapMarkersProvider =
|
||||
FutureProvider.autoDispose<Set<AssetMarkerData>>((ref) async {
|
||||
final service = ref.read(mapServiceProvider);
|
||||
final mapState = ref.read(mapStateNotifier);
|
||||
DateTime? fileCreatedAfter;
|
||||
bool? isFavorite;
|
||||
|
||||
if (mapState.relativeTime != 0) {
|
||||
fileCreatedAfter =
|
||||
DateTime.now().subtract(Duration(days: mapState.relativeTime));
|
||||
}
|
||||
|
||||
if (mapState.showFavoriteOnly) {
|
||||
isFavorite = true;
|
||||
}
|
||||
|
||||
final markers = await service.getMapMarkers(
|
||||
isFavorite: isFavorite,
|
||||
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;
|
||||
}
|
||||
}
|
51
mobile/lib/modules/map/providers/map_state.provider.dart
Normal file
51
mobile/lib/modules/map/providers/map_state.provider.dart
Normal file
|
@ -0,0 +1,51 @@
|
|||
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';
|
||||
|
||||
class MapStateNotifier extends StateNotifier<MapState> {
|
||||
MapStateNotifier(this.appSettingsProvider)
|
||||
: super(
|
||||
MapState(
|
||||
isDarkTheme: appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapThemeMode),
|
||||
showFavoriteOnly: appSettingsProvider
|
||||
.getSetting<bool>(AppSettingsEnum.mapShowFavoriteOnly),
|
||||
relativeTime: appSettingsProvider
|
||||
.getSetting<int>(AppSettingsEnum.mapRelativeDate),
|
||||
),
|
||||
);
|
||||
|
||||
final AppSettingsService appSettingsProvider;
|
||||
|
||||
bool get isDarkTheme => state.isDarkTheme;
|
||||
|
||||
void switchTheme(bool isDarkTheme) {
|
||||
appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapThemeMode,
|
||||
isDarkTheme,
|
||||
);
|
||||
state = state.copyWith(isDarkTheme: isDarkTheme);
|
||||
}
|
||||
|
||||
void switchFavoriteOnly(bool isFavoriteOnly) {
|
||||
appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapShowFavoriteOnly,
|
||||
appSettingsProvider,
|
||||
);
|
||||
state = state.copyWith(showFavoriteOnly: isFavoriteOnly);
|
||||
}
|
||||
|
||||
void setRelativeTime(int relativeTime) {
|
||||
appSettingsProvider.setSetting(
|
||||
AppSettingsEnum.mapRelativeDate,
|
||||
relativeTime,
|
||||
);
|
||||
state = state.copyWith(relativeTime: relativeTime);
|
||||
}
|
||||
}
|
||||
|
||||
final mapStateNotifier =
|
||||
StateNotifierProvider<MapStateNotifier, MapState>((ref) {
|
||||
return MapStateNotifier(ref.watch(appSettingsServiceProvider));
|
||||
});
|
62
mobile/lib/modules/map/services/map.service.dart
Normal file
62
mobile/lib/modules/map/services/map.service.dart
Normal file
|
@ -0,0 +1,62 @@
|
|||
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/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 {
|
||||
final ApiService _apiService;
|
||||
final Isar _db;
|
||||
final log = Logger("MapService");
|
||||
|
||||
MapSerivce(this._apiService, this._db);
|
||||
|
||||
Future<List<MapMarkerResponseDto>> getMapMarkers({
|
||||
bool? isFavorite,
|
||||
DateTime? fileCreatedAfter,
|
||||
DateTime? fileCreatedBefore,
|
||||
}) async {
|
||||
try {
|
||||
final markers = await _apiService.assetApi.getMapMarkers(
|
||||
isFavorite: isFavorite,
|
||||
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 Asset.remote(dto);
|
||||
} catch (error, stack) {
|
||||
log.severe(
|
||||
"Cannot get asset for marker ${error.toString()}",
|
||||
error,
|
||||
stack,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
144
mobile/lib/modules/map/ui/asset_marker_icon.dart
Normal file
144
mobile/lib/modules/map/ui/asset_marker_icon.dart
Normal file
|
@ -0,0 +1,144 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/shared/models/store.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class AssetMarkerIcon extends StatelessWidget {
|
||||
const AssetMarkerIcon({
|
||||
Key? key,
|
||||
required this.id,
|
||||
this.isDarkTheme = false,
|
||||
}) : super(key: key);
|
||||
|
||||
final String id;
|
||||
final bool isDarkTheme;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = getThumbnailUrlForRemoteId(id);
|
||||
final cacheKey = getThumbnailCacheKeyForRemoteId(id);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: constraints.maxWidth * 0.5,
|
||||
child: CustomPaint(
|
||||
painter: _PinPainter(
|
||||
primaryColor: isDarkTheme ? Colors.white : Colors.black,
|
||||
secondaryColor: isDarkTheme ? Colors.black : Colors.white,
|
||||
primaryRadius: constraints.maxHeight * 0.06,
|
||||
secondaryRadius: constraints.maxHeight * 0.038,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: constraints.maxHeight * 0.14,
|
||||
width: constraints.maxWidth * 0.14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: constraints.maxHeight * 0.07,
|
||||
left: constraints.maxWidth * 0.17,
|
||||
child: CircleAvatar(
|
||||
radius: constraints.maxHeight * 0.40,
|
||||
backgroundColor: isDarkTheme ? Colors.white : Colors.black,
|
||||
child: CircleAvatar(
|
||||
radius: constraints.maxHeight * 0.37,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
cacheKey: cacheKey,
|
||||
headers: {
|
||||
"Authorization":
|
||||
"Bearer ${Store.get(StoreKey.accessToken)}",
|
||||
},
|
||||
errorListener: () =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinPainter extends CustomPainter {
|
||||
final Color primaryColor;
|
||||
final Color secondaryColor;
|
||||
final double primaryRadius;
|
||||
final double secondaryRadius;
|
||||
|
||||
_PinPainter({
|
||||
this.primaryColor = Colors.black,
|
||||
this.secondaryColor = Colors.white,
|
||||
required this.primaryRadius,
|
||||
required this.secondaryRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint primaryBrush = Paint()
|
||||
..color = primaryColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
Paint secondaryBrush = Paint()
|
||||
..color = secondaryColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
Paint lineBrush = Paint()
|
||||
..color = primaryColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
|
||||
canvas.drawCircle(
|
||||
Offset(size.width / 2, size.height),
|
||||
primaryRadius,
|
||||
primaryBrush,
|
||||
);
|
||||
canvas.drawCircle(
|
||||
Offset(size.width / 2, size.height),
|
||||
secondaryRadius,
|
||||
secondaryBrush,
|
||||
);
|
||||
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
|
||||
// The line is to make the above triangluar path more prominent since it has a slight curve
|
||||
canvas.drawLine(
|
||||
Offset(size.width / 2, 0),
|
||||
Offset(
|
||||
size.width / 2,
|
||||
size.height,
|
||||
),
|
||||
lineBrush,
|
||||
);
|
||||
}
|
||||
|
||||
Path getTrianglePath(double x, double y) {
|
||||
final firstEndPoint = Offset(x / 2, y);
|
||||
final controlPoint = Offset(x / 2, y * 0.3);
|
||||
final secondEndPoint = Offset(x, 0);
|
||||
|
||||
return Path()
|
||||
..quadraticBezierTo(
|
||||
controlPoint.dx,
|
||||
controlPoint.dy,
|
||||
firstEndPoint.dx,
|
||||
firstEndPoint.dy,
|
||||
)
|
||||
..quadraticBezierTo(
|
||||
controlPoint.dx,
|
||||
controlPoint.dy,
|
||||
secondEndPoint.dx,
|
||||
secondEndPoint.dy,
|
||||
)
|
||||
..lineTo(0, 0);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_PinPainter old) {
|
||||
return old.primaryColor != primaryColor ||
|
||||
old.secondaryColor != secondaryColor;
|
||||
}
|
||||
}
|
30
mobile/lib/modules/map/ui/location_dialog.dart
Normal file
30
mobile/lib/modules/map/ui/location_dialog.dart
Normal file
|
@ -0,0 +1,30 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:geolocator/geolocator.dart';
|
||||
import 'package:immich_mobile/shared/ui/confirm_dialog.dart';
|
||||
|
||||
class LocationServiceDisabledDialog extends ConfirmDialog {
|
||||
LocationServiceDisabledDialog({Key? key})
|
||||
: super(
|
||||
key: key,
|
||||
title: 'map_location_service_disabled_title'.tr(),
|
||||
content: 'map_location_service_disabled_content'.tr(),
|
||||
cancel: 'map_location_dialog_cancel'.tr(),
|
||||
ok: 'map_location_dialog_yes'.tr(),
|
||||
onOk: () async {
|
||||
await Geolocator.openLocationSettings();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class LocationPermissionDisabledDialog extends ConfirmDialog {
|
||||
LocationPermissionDisabledDialog({Key? key})
|
||||
: super(
|
||||
key: key,
|
||||
title: 'map_no_location_permission_title'.tr(),
|
||||
content: 'map_no_location_permission_content'.tr(),
|
||||
cancel: 'map_location_dialog_cancel'.tr(),
|
||||
ok: 'map_location_dialog_yes'.tr(),
|
||||
onOk: () {},
|
||||
);
|
||||
}
|
138
mobile/lib/modules/map/ui/map_page_app_bar.dart
Normal file
138
mobile/lib/modules/map/ui/map_page_app_bar.dart
Normal file
|
@ -0,0 +1,138 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/disable_multi_select_button.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/map_settings_dialog.dart';
|
||||
|
||||
class MapAppBar extends HookWidget implements PreferredSizeWidget {
|
||||
final ValueNotifier<bool> selectionEnabled;
|
||||
final int selectedAssetsLength;
|
||||
final bool isDarkTheme;
|
||||
|
||||
final void Function() onShare;
|
||||
final void Function() onFavorite;
|
||||
final void Function() onArchive;
|
||||
|
||||
const MapAppBar({
|
||||
super.key,
|
||||
required this.selectionEnabled,
|
||||
required this.selectedAssetsLength,
|
||||
required this.onShare,
|
||||
required this.onArchive,
|
||||
required this.onFavorite,
|
||||
this.isDarkTheme = false,
|
||||
});
|
||||
|
||||
List<Widget> buildNonSelectionWidgets(BuildContext context) {
|
||||
return [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 15, top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => AutoRouter.of(context).pop(),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(Icons.arrow_back_ios_new_rounded, size: 22),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 15, top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: () => showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return const MapSettingsDialog();
|
||||
},
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(Icons.more_vert_rounded, size: 22),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
List<Widget> buildSelectionWidgets() {
|
||||
return [
|
||||
DisableMultiSelectButton(
|
||||
onPressed: () {
|
||||
selectionEnabled.value = false;
|
||||
},
|
||||
selectedItemCount: selectedAssetsLength,
|
||||
),
|
||||
Row(
|
||||
children: [
|
||||
// Share button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: onShare,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: Icon(
|
||||
Platform.isAndroid
|
||||
? Icons.share_rounded
|
||||
: Icons.ios_share_rounded,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Favorite button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: onFavorite,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.favorite,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
// Archive Button
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10, top: 15),
|
||||
child: ElevatedButton(
|
||||
onPressed: onArchive,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.archive,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 30),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
if (!selectionEnabled.value) ...buildNonSelectionWidgets(context),
|
||||
if (selectionEnabled.value) ...buildSelectionWidgets(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(100);
|
||||
}
|
356
mobile/lib/modules/map/ui/map_page_bottom_sheet.dart
Normal file
356
mobile/lib/modules/map/ui/map_page_bottom_sheet.dart
Normal file
|
@ -0,0 +1,356 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/asset_viewer/providers/render_list.provider.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/modules/home/ui/asset_grid/immich_asset_grid_view.dart';
|
||||
import 'package:immich_mobile/modules/map/models/map_page_event.model.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class MapPageBottomSheet extends StatefulHookConsumerWidget {
|
||||
final Stream mapPageEventStream;
|
||||
final StreamController bottomSheetEventSC;
|
||||
final bool selectionEnabled;
|
||||
final ImmichAssetGridSelectionListener selectionlistener;
|
||||
final bool isDarkTheme;
|
||||
|
||||
const MapPageBottomSheet({
|
||||
super.key,
|
||||
required this.mapPageEventStream,
|
||||
required this.bottomSheetEventSC,
|
||||
required this.selectionEnabled,
|
||||
required this.selectionlistener,
|
||||
this.isDarkTheme = false,
|
||||
});
|
||||
|
||||
@override
|
||||
AssetsInBoundBottomSheetState createState() =>
|
||||
AssetsInBoundBottomSheetState();
|
||||
}
|
||||
|
||||
class AssetsInBoundBottomSheetState extends ConsumerState<MapPageBottomSheet> {
|
||||
// Non-State variables
|
||||
bool userTappedOnMap = false;
|
||||
RenderList? _cachedRenderList;
|
||||
int lastAssetOffsetInSheet = -1;
|
||||
late final DraggableScrollableController bottomSheetController;
|
||||
late final Debounce debounce;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
bottomSheetController = DraggableScrollableController();
|
||||
debounce = Debounce(
|
||||
const Duration(milliseconds: 200),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var isDarkMode = Theme.of(context).brightness == Brightness.dark;
|
||||
double maxHeight = MediaQuery.of(context).size.height;
|
||||
final isSheetScrolled = useState(false);
|
||||
final isSheetExpanded = useState(false);
|
||||
final assetsInBound = useState(<Asset>[]);
|
||||
final currentExtend = useState(0.1);
|
||||
|
||||
void handleMapPageEvents(dynamic event) {
|
||||
if (event is MapPageAssetsInBoundUpdated) {
|
||||
assetsInBound.value = event.assets;
|
||||
} else if (event is MapPageOnTapEvent) {
|
||||
userTappedOnMap = true;
|
||||
lastAssetOffsetInSheet = -1;
|
||||
bottomSheetController.animateTo(
|
||||
0.1,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
curve: Curves.linearToEaseOut,
|
||||
);
|
||||
isSheetScrolled.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
final mapPageEventSubscription =
|
||||
widget.mapPageEventStream.listen(handleMapPageEvents);
|
||||
return mapPageEventSubscription.cancel;
|
||||
},
|
||||
[widget.mapPageEventStream],
|
||||
);
|
||||
|
||||
void handleVisibleItems(ItemPosition start, ItemPosition end) {
|
||||
final renderElement = _cachedRenderList?.elements[start.index];
|
||||
if (renderElement == null) {
|
||||
return;
|
||||
}
|
||||
final rowOffset = renderElement.offset;
|
||||
if ((-start.itemLeadingEdge) != 0) {
|
||||
var columnOffset = -start.itemLeadingEdge ~/ 0.05;
|
||||
columnOffset = columnOffset < renderElement.totalCount
|
||||
? columnOffset
|
||||
: renderElement.totalCount - 1;
|
||||
lastAssetOffsetInSheet = rowOffset + columnOffset;
|
||||
final asset = _cachedRenderList?.allAssets?[lastAssetOffsetInSheet];
|
||||
userTappedOnMap = false;
|
||||
if (!userTappedOnMap && isSheetExpanded.value) {
|
||||
widget.bottomSheetEventSC.add(
|
||||
MapPageBottomSheetScrolled(asset),
|
||||
);
|
||||
}
|
||||
if (isSheetExpanded.value) {
|
||||
isSheetScrolled.value = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void visibleItemsListener(ItemPosition start, ItemPosition end) {
|
||||
if (_cachedRenderList == null) {
|
||||
debounce.dispose();
|
||||
return;
|
||||
}
|
||||
debounce.call(() => handleVisibleItems(start, end));
|
||||
}
|
||||
|
||||
Widget buildNoPhotosWidget() {
|
||||
const image = Image(
|
||||
image: AssetImage('assets/lighthouse.png'),
|
||||
);
|
||||
|
||||
return isSheetExpanded.value
|
||||
? Column(
|
||||
children: [
|
||||
const SizedBox(
|
||||
height: 80,
|
||||
),
|
||||
SizedBox(
|
||||
height: 150,
|
||||
width: 150,
|
||||
child: isDarkMode
|
||||
? const InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: BrightnessFilter(
|
||||
brightness: -5,
|
||||
child: image,
|
||||
),
|
||||
),
|
||||
)
|
||||
: image,
|
||||
),
|
||||
const SizedBox(
|
||||
height: 20,
|
||||
),
|
||||
Text(
|
||||
"map_zoom_to_see_photos".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 20,
|
||||
color: Theme.of(context).textTheme.displayLarge?.color,
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
|
||||
void onTapMapButton() {
|
||||
if (lastAssetOffsetInSheet != -1) {
|
||||
widget.bottomSheetEventSC.add(
|
||||
MapPageZoomToAsset(
|
||||
_cachedRenderList?.allAssets?[lastAssetOffsetInSheet],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildDragHandle(ScrollController scrollController) {
|
||||
final textToDisplay = assetsInBound.value.isNotEmpty
|
||||
? "${assetsInBound.value.length} photo${assetsInBound.value.length > 1 ? "s" : ""}"
|
||||
: "map_no_assets_in_bounds".tr();
|
||||
final dragHandle = Container(
|
||||
height: 75,
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
|
||||
),
|
||||
child: Stack(
|
||||
children: [
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 12),
|
||||
const CustomDraggingHandle(),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
textToDisplay,
|
||||
style: TextStyle(
|
||||
fontSize: 16,
|
||||
color: Theme.of(context).textTheme.displayLarge?.color,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
Divider(
|
||||
color: Theme.of(context)
|
||||
.textTheme
|
||||
.displayLarge
|
||||
?.color
|
||||
?.withOpacity(0.5),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (isSheetExpanded.value && isSheetScrolled.value)
|
||||
Positioned(
|
||||
top: 5,
|
||||
right: 10,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.map_outlined,
|
||||
color: Theme.of(context).textTheme.displayLarge?.color,
|
||||
),
|
||||
iconSize: 20,
|
||||
tooltip: 'Zoom to bounds',
|
||||
onPressed: onTapMapButton,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
return SingleChildScrollView(
|
||||
controller: scrollController,
|
||||
child: dragHandle,
|
||||
);
|
||||
}
|
||||
|
||||
return NotificationListener<DraggableScrollableNotification>(
|
||||
onNotification: (DraggableScrollableNotification notification) {
|
||||
final sheetExtended = notification.extent > 0.2;
|
||||
isSheetExpanded.value = sheetExtended;
|
||||
currentExtend.value = notification.extent;
|
||||
if (!sheetExtended) {
|
||||
// reset state
|
||||
userTappedOnMap = false;
|
||||
lastAssetOffsetInSheet = -1;
|
||||
isSheetScrolled.value = false;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
DraggableScrollableSheet(
|
||||
controller: bottomSheetController,
|
||||
initialChildSize: 0.1,
|
||||
minChildSize: 0.1,
|
||||
maxChildSize: 0.55,
|
||||
snap: true,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
) {
|
||||
return Card(
|
||||
color: isDarkMode ? Colors.grey[900] : Colors.grey[100],
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 18.0,
|
||||
margin: const EdgeInsets.all(0),
|
||||
child: Column(
|
||||
children: [
|
||||
buildDragHandle(scrollController),
|
||||
if (isSheetExpanded.value && assetsInBound.value.isNotEmpty)
|
||||
ref
|
||||
.watch(
|
||||
renderListProvider(
|
||||
assetsInBound.value,
|
||||
),
|
||||
)
|
||||
.when(
|
||||
data: (renderList) {
|
||||
_cachedRenderList = renderList;
|
||||
final assetGrid = ImmichAssetGrid(
|
||||
shrinkWrap: true,
|
||||
renderList: renderList,
|
||||
showDragScroll: false,
|
||||
selectionActive: widget.selectionEnabled,
|
||||
showMultiSelectIndicator: false,
|
||||
listener: widget.selectionlistener,
|
||||
visibleItemsListener: visibleItemsListener,
|
||||
);
|
||||
|
||||
return Expanded(child: assetGrid);
|
||||
},
|
||||
error: (error, stackTrace) {
|
||||
log.warning(
|
||||
"Cannot get assets in the current map bounds ${error.toString()}",
|
||||
error,
|
||||
stackTrace,
|
||||
);
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
loading: () => const SizedBox.shrink(),
|
||||
),
|
||||
if (isSheetExpanded.value && assetsInBound.value.isEmpty)
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
child: buildNoPhotosWidget(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
Positioned(
|
||||
bottom: maxHeight * currentExtend.value,
|
||||
left: 0,
|
||||
child: GestureDetector(
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
),
|
||||
child: ColoredBox(
|
||||
color:
|
||||
(widget.isDarkTheme ? Colors.grey[900] : Colors.grey[100])!,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(3),
|
||||
child: Text(
|
||||
'© OpenStreetMap contributors',
|
||||
style: TextStyle(
|
||||
fontSize: 6,
|
||||
color: !widget.isDarkTheme
|
||||
? Colors.grey[900]
|
||||
: Colors.grey[100],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: maxHeight * (0.14 + (currentExtend.value - 0.1)),
|
||||
right: 15,
|
||||
child: ElevatedButton(
|
||||
onPressed: () =>
|
||||
widget.bottomSheetEventSC.add(const MapPageZoomToLocation()),
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
padding: const EdgeInsets.all(12),
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.my_location,
|
||||
size: 22,
|
||||
fill: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
193
mobile/lib/modules/map/ui/map_settings_dialog.dart
Normal file
193
mobile/lib/modules/map/ui/map_settings_dialog.dart
Normal file
|
@ -0,0 +1,193 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/modules/map/providers/map_state.provider.dart';
|
||||
|
||||
class MapSettingsDialog extends HookConsumerWidget {
|
||||
const MapSettingsDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final mapSettingsNotifier = ref.read(mapStateNotifier.notifier);
|
||||
final mapSettings = ref.read(mapStateNotifier);
|
||||
final isDarkMode = useState(mapSettings.isDarkTheme);
|
||||
final showFavoriteOnly = useState(mapSettings.showFavoriteOnly);
|
||||
final showRelativeDate = useState(mapSettings.relativeTime);
|
||||
final ThemeData theme = Theme.of(context);
|
||||
|
||||
Widget buildMapThemeSetting() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: isDarkMode.value,
|
||||
onChanged: (value) {
|
||||
isDarkMode.value = value;
|
||||
},
|
||||
activeColor: theme.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"map_settings_dark_mode".tr(),
|
||||
style:
|
||||
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildFavoriteOnlySetting() {
|
||||
return SwitchListTile.adaptive(
|
||||
value: showFavoriteOnly.value,
|
||||
onChanged: (value) {
|
||||
showFavoriteOnly.value = value;
|
||||
},
|
||||
activeColor: theme.primaryColor,
|
||||
dense: true,
|
||||
title: Text(
|
||||
"map_settings_only_show_favorites".tr(),
|
||||
style:
|
||||
theme.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildDateRangeSetting() {
|
||||
final now = DateTime.now();
|
||||
return DropdownMenu(
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
initialSelection: showRelativeDate.value,
|
||||
onSelected: (value) {
|
||||
showRelativeDate.value = value!;
|
||||
},
|
||||
dropdownMenuEntries: [
|
||||
const DropdownMenuEntry(value: 0, label: "All"),
|
||||
const DropdownMenuEntry(
|
||||
value: 1,
|
||||
label: "Past 24 hours",
|
||||
),
|
||||
const DropdownMenuEntry(
|
||||
value: 7,
|
||||
label: "Past 7 days",
|
||||
),
|
||||
const DropdownMenuEntry(
|
||||
value: 30,
|
||||
label: "Past 30 days",
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 1,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "Past year",
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 3,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "Past 3 years",
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> getDialogActions() {
|
||||
return <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor:
|
||||
mapSettings.isDarkTheme ? Colors.grey[100] : Colors.grey[700],
|
||||
),
|
||||
child: Text(
|
||||
"map_settings_dialog_cancel".tr(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
mapSettings.isDarkTheme ? Colors.grey[900] : Colors.grey[100],
|
||||
),
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
mapSettingsNotifier.switchTheme(isDarkMode.value);
|
||||
mapSettingsNotifier.switchFavoriteOnly(showFavoriteOnly.value);
|
||||
mapSettingsNotifier.setRelativeTime(showRelativeDate.value);
|
||||
Navigator.of(context).pop();
|
||||
},
|
||||
style: TextButton.styleFrom(
|
||||
backgroundColor: theme.primaryColor,
|
||||
),
|
||||
child: Text(
|
||||
"map_settings_dialog_save".tr(),
|
||||
style: theme.textTheme.labelSmall?.copyWith(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: theme.primaryTextTheme.labelLarge?.color,
|
||||
),
|
||||
),
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
||||
title: Center(
|
||||
child: Text(
|
||||
"map_settings_dialog_title".tr(),
|
||||
style: TextStyle(
|
||||
color: theme.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 18,
|
||||
),
|
||||
),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: double.maxFinite,
|
||||
child: ConstrainedBox(
|
||||
constraints: BoxConstraints(
|
||||
maxHeight: MediaQuery.of(context).size.height * 0.6,
|
||||
),
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
children: [
|
||||
buildMapThemeSetting(),
|
||||
buildFavoriteOnlySetting(),
|
||||
const SizedBox(
|
||||
height: 10,
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(left: 20),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
"map_settings_only_relative_range".tr(),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
buildDateRangeSetting(),
|
||||
],
|
||||
),
|
||||
),
|
||||
].toList(),
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: getDialogActions(),
|
||||
actionsAlignment: MainAxisAlignment.spaceEvenly,
|
||||
);
|
||||
}
|
||||
}
|
76
mobile/lib/modules/map/ui/map_thumbnail.dart
Normal file
76
mobile/lib/modules/map/ui/map_thumbnail.dart
Normal file
|
@ -0,0 +1,76 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_map/plugin_api.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
// A non-interactive thumbnail of a map in the given coordinates with optional markers
|
||||
class MapThumbnail extends HookConsumerWidget {
|
||||
final Function(TapPosition, LatLng)? onTap;
|
||||
final LatLng coords;
|
||||
final double zoom;
|
||||
final List<Marker> markers;
|
||||
final double height;
|
||||
final bool showAttribution;
|
||||
final bool isDarkTheme;
|
||||
|
||||
const MapThumbnail({
|
||||
super.key,
|
||||
required this.coords,
|
||||
required this.height,
|
||||
this.onTap,
|
||||
this.zoom = 1,
|
||||
this.showAttribution = true,
|
||||
this.isDarkTheme = false,
|
||||
this.markers = const [],
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
);
|
||||
|
||||
return SizedBox(
|
||||
height: height,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
child: FlutterMap(
|
||||
options: MapOptions(
|
||||
interactiveFlags: InteractiveFlag.none,
|
||||
center: coords,
|
||||
zoom: zoom,
|
||||
onTap: onTap,
|
||||
),
|
||||
nonRotatedChildren: [
|
||||
if (showAttribution)
|
||||
RichAttributionWidget(
|
||||
animationConfig: const ScaleRAWA(),
|
||||
attributions: [
|
||||
TextSourceAttribution(
|
||||
'OpenStreetMap contributors',
|
||||
onTap: () => launchUrl(
|
||||
Uri.parse('https://openstreetmap.org/copyright'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
children: [
|
||||
isDarkTheme
|
||||
? InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: tileLayer,
|
||||
),
|
||||
)
|
||||
: tileLayer,
|
||||
if (markers.isNotEmpty) MarkerLayer(markers: markers),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
499
mobile/lib/modules/map/views/map_page.dart
Normal file
499
mobile/lib/modules/map/views/map_page.dart
Normal file
|
@ -0,0 +1,499 @@
|
|||
import 'dart:async';
|
||||
|
||||
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/modules/map/models/map_page_event.model.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/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/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/utils/debounce.dart';
|
||||
import 'package:immich_mobile/utils/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';
|
||||
|
||||
class MapPage extends StatefulHookConsumerWidget {
|
||||
const MapPage({super.key});
|
||||
|
||||
@override
|
||||
MapPageState createState() => MapPageState();
|
||||
}
|
||||
|
||||
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;
|
||||
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,
|
||||
}) {
|
||||
final bounds = mapController.bounds;
|
||||
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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void openAssetInViewer(Asset asset) {
|
||||
AutoRouter.of(context).push(
|
||||
GalleryViewerRoute(
|
||||
initialIndex: 0,
|
||||
loadAsset: (index) => asset,
|
||||
totalAssets: 1,
|
||||
heroOffset: 0,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@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);
|
||||
|
||||
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;
|
||||
if (shouldRefetch) {
|
||||
refetchMarkers.value = shouldRefetch;
|
||||
ref.invalidate(mapMarkersProvider);
|
||||
}
|
||||
});
|
||||
|
||||
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: 6,
|
||||
);
|
||||
if (newCenter != null) {
|
||||
forceAssetUpdate = true;
|
||||
mapController.move(newCenter, 6);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
12,
|
||||
);
|
||||
} catch (error) {
|
||||
log.severe(
|
||||
"Cannot get user's current location due to ${error.toString()}",
|
||||
);
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
toastType: ToastType.error,
|
||||
msg: "map_cannot_get_user_location".tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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(),
|
||||
);
|
||||
}
|
||||
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 onShareAsset() {
|
||||
handleShareAssets(ref, context, selectedAssets.value.toList());
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
|
||||
void onFavoriteAsset() async {
|
||||
showLoadingIndicator.value = true;
|
||||
try {
|
||||
await handleFavoriteAssets(ref, context, selectedAssets.value.toList());
|
||||
} finally {
|
||||
showLoadingIndicator.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
refetchMarkers.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
void onArchiveAsset() async {
|
||||
showLoadingIndicator.value = true;
|
||||
try {
|
||||
await handleArchiveAssets(ref, context, selectedAssets.value.toList());
|
||||
} finally {
|
||||
showLoadingIndicator.value = false;
|
||||
selectionEnabledHook.value = false;
|
||||
refetchMarkers.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
void selectionListener(bool isMultiSelect, Set<Asset> selection) {
|
||||
selectionEnabledHook.value = isMultiSelect;
|
||||
selectedAssets.value = selection;
|
||||
}
|
||||
|
||||
final tileLayer = TileLayer(
|
||||
urlTemplate: "https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",
|
||||
subdomains: const ['a', 'b', 'c'],
|
||||
maxNativeZoom: 19,
|
||||
maxZoom: 19,
|
||||
);
|
||||
|
||||
final darkTileLayer = InvertionFilter(
|
||||
child: SaturationFilter(
|
||||
saturation: -1,
|
||||
child: BrightnessFilter(
|
||||
brightness: -1,
|
||||
child: tileLayer,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
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(
|
||||
isDarkTheme: isDarkTheme,
|
||||
id: closestAssetMarker.value!.asset.remoteId!,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
final heatMapLayer = mapMarkerData.value.isNotEmpty
|
||||
? HeatMapLayer(
|
||||
heatMapDataSource: InMemoryHeatMapDataSource(
|
||||
data: mapMarkerData.value
|
||||
.map(
|
||||
(e) => WeightedLatLng(
|
||||
LatLng(e.point.latitude, e.point.longitude),
|
||||
1,
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
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: Colors.black.withOpacity(0.5),
|
||||
statusBarIconBrightness: Brightness.light,
|
||||
),
|
||||
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: [
|
||||
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: 18, // max level supported by OSM,
|
||||
onMapReady: () {
|
||||
mapController.mapEventStream.listen(onMapEvent);
|
||||
},
|
||||
),
|
||||
children: [
|
||||
isDarkTheme ? darkTileLayer : tileLayer,
|
||||
heatMapLayer,
|
||||
markerLayer,
|
||||
],
|
||||
),
|
||||
MapPageBottomSheet(
|
||||
mapPageEventStream: mapPageEventSC.stream,
|
||||
bottomSheetEventSC: bottomSheetEventSC,
|
||||
selectionEnabled: selectionEnabledHook.value,
|
||||
selectionlistener: selectionListener,
|
||||
isDarkTheme: isDarkTheme,
|
||||
),
|
||||
if (showLoadingIndicator.value)
|
||||
Positioned(
|
||||
top: MediaQuery.of(context).size.height * 0.35,
|
||||
left: MediaQuery.of(context).size.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,
|
||||
});
|
||||
}
|
110
mobile/lib/modules/search/ui/curated_places_row.dart
Normal file
110
mobile/lib/modules/search/ui/curated_places_row.dart
Normal file
|
@ -0,0 +1,110 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/modules/map/ui/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';
|
||||
|
||||
class CuratedPlacesRow extends CuratedRow {
|
||||
const CuratedPlacesRow({
|
||||
super.key,
|
||||
required super.content,
|
||||
super.imageSize,
|
||||
super.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget buildMapThumbnail() {
|
||||
return GestureDetector(
|
||||
onTap: () => AutoRouter.of(context).push(
|
||||
const MapRoute(),
|
||||
),
|
||||
child: SizedBox(
|
||||
height: imageSize,
|
||||
width: imageSize,
|
||||
child: Stack(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: MapThumbnail(
|
||||
zoom: 2,
|
||||
coords: LatLng(
|
||||
47,
|
||||
5,
|
||||
),
|
||||
height: imageSize,
|
||||
showAttribution: false,
|
||||
isDarkTheme: Theme.of(context).brightness == Brightness.dark,
|
||||
),
|
||||
),
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
color: Colors.black,
|
||||
gradient: LinearGradient(
|
||||
begin: FractionalOffset.topCenter,
|
||||
end: FractionalOffset.bottomCenter,
|
||||
colors: [
|
||||
Colors.blueGrey.withOpacity(0.0),
|
||||
Colors.black.withOpacity(0.4),
|
||||
],
|
||||
stops: const [0.0, 1.0],
|
||||
),
|
||||
),
|
||||
),
|
||||
const Align(
|
||||
alignment: Alignment.bottomCenter,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.only(bottom: 10),
|
||||
child: Text(
|
||||
"Your Map",
|
||||
style: TextStyle(
|
||||
color: Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
// Injecting Map thumbnail as the first element
|
||||
if (index == 0) {
|
||||
return buildMapThumbnail();
|
||||
}
|
||||
// The actual index is 1 less than the virutal index since we inject map into the first position
|
||||
final actualIndex = index - 1;
|
||||
final object = content[actualIndex];
|
||||
final thumbnailRequestUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/asset/thumbnail/${object.id}';
|
||||
return SizedBox(
|
||||
width: imageSize,
|
||||
height: imageSize,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: ThumbnailWithInfo(
|
||||
imageUrl: thumbnailRequestUrl,
|
||||
textInfo: object.label,
|
||||
onTap: () => onTap?.call(object, actualIndex),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
// Adding 1 to inject map thumbnail as first element
|
||||
itemCount: content.length + 1,
|
||||
);
|
||||
}
|
||||
}
|
|
@ -7,7 +7,7 @@ import 'package:immich_mobile/modules/search/models/curated_content.dart';
|
|||
import 'package:immich_mobile/modules/search/providers/people.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/curated_people_row.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/curated_row.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/curated_places_row.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/immich_search_bar.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
|
||||
import 'package:immich_mobile/modules/search/ui/search_row_title.dart';
|
||||
|
@ -69,7 +69,7 @@ class SearchPage extends HookConsumerWidget {
|
|||
|
||||
buildPeople() {
|
||||
return SizedBox(
|
||||
height: MediaQuery.of(context).size.width / 3,
|
||||
height: imageSize,
|
||||
child: curatedPeople.when(
|
||||
loading: () => const Center(child: ImmichLoadingIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
|
@ -105,7 +105,7 @@ class SearchPage extends HookConsumerWidget {
|
|||
child: curatedLocation.when(
|
||||
loading: () => const Center(child: ImmichLoadingIndicator()),
|
||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||
data: (locations) => CuratedRow(
|
||||
data: (locations) => CuratedPlacesRow(
|
||||
content: locations
|
||||
.map(
|
||||
(o) => CuratedContent(
|
||||
|
@ -155,6 +155,7 @@ class SearchPage extends HookConsumerWidget {
|
|||
),
|
||||
top: 0,
|
||||
),
|
||||
const SizedBox(height: 10.0),
|
||||
buildPlaces(),
|
||||
const SizedBox(height: 24.0),
|
||||
Padding(
|
||||
|
|
|
@ -46,6 +46,9 @@ 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),
|
||||
mapShowFavoriteOnly<bool>(StoreKey.mapShowFavoriteOnly, null, false),
|
||||
mapRelativeDate<int>(StoreKey.mapRelativeDate, null, 0),
|
||||
;
|
||||
|
||||
const AppSettingsEnum(this.storeKey, this.hiveKey, this.defaultValue);
|
||||
|
|
|
@ -7,6 +7,7 @@ import 'package:immich_mobile/modules/album/views/album_viewer_page.dart';
|
|||
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/map/views/map_page.dart';
|
||||
import 'package:immich_mobile/modules/memories/models/memory.dart';
|
||||
import 'package:immich_mobile/modules/memories/views/memory_page.dart';
|
||||
import 'package:immich_mobile/modules/partner/views/partner_detail_page.dart';
|
||||
|
@ -153,6 +154,7 @@ part 'router.gr.dart';
|
|||
),
|
||||
AutoRoute(page: AllPeoplePage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
AutoRoute(page: AlbumOptionsPage, guards: [AuthGuard, DuplicateGuard]),
|
||||
],
|
||||
)
|
||||
|
|
|
@ -296,6 +296,12 @@ class _$AppRouter extends RootStackRouter {
|
|||
),
|
||||
);
|
||||
},
|
||||
MapRoute.name: (routeData) {
|
||||
return MaterialPageX<dynamic>(
|
||||
routeData: routeData,
|
||||
child: const MapPage(),
|
||||
);
|
||||
},
|
||||
AlbumOptionsRoute.name: (routeData) {
|
||||
final args = routeData.argsAs<AlbumOptionsRouteArgs>();
|
||||
return MaterialPageX<dynamic>(
|
||||
|
@ -605,6 +611,14 @@ class _$AppRouter extends RootStackRouter {
|
|||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
MapRoute.name,
|
||||
path: '/map-page',
|
||||
guards: [
|
||||
authGuard,
|
||||
duplicateGuard,
|
||||
],
|
||||
),
|
||||
RouteConfig(
|
||||
AlbumOptionsRoute.name,
|
||||
path: '/album-options-page',
|
||||
|
@ -1337,6 +1351,17 @@ class MemoryRouteArgs {
|
|||
}
|
||||
}
|
||||
|
||||
/// [MapPage]
|
||||
class MapRoute extends PageRouteInfo<void> {
|
||||
const MapRoute()
|
||||
: super(
|
||||
MapRoute.name,
|
||||
path: '/map-page',
|
||||
);
|
||||
|
||||
static const String name = 'MapRoute';
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [AlbumOptionsPage]
|
||||
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
|
||||
|
|
|
@ -174,6 +174,10 @@ enum StoreKey<T> {
|
|||
advancedTroubleshooting<bool>(114, type: bool),
|
||||
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),
|
||||
;
|
||||
|
||||
const StoreKey(
|
||||
|
|
|
@ -26,7 +26,7 @@ class ConfirmDialog extends ConsumerWidget {
|
|||
content: Text(content).tr(),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
onPressed: () => Navigator.of(context).pop(false),
|
||||
child: Text(
|
||||
cancel,
|
||||
style: TextStyle(
|
||||
|
@ -38,7 +38,7 @@ class ConfirmDialog extends ConsumerWidget {
|
|||
TextButton(
|
||||
onPressed: () {
|
||||
onOk();
|
||||
Navigator.of(context).pop();
|
||||
Navigator.of(context).pop(true);
|
||||
},
|
||||
child: Text(
|
||||
ok,
|
||||
|
|
104
mobile/lib/utils/color_filter_generator.dart
Normal file
104
mobile/lib/utils/color_filter_generator.dart
Normal file
|
@ -0,0 +1,104 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
class InvertionFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
const InvertionFilter({super.key, this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: const ColorFilter.matrix(<double>[
|
||||
-1, 0, 0, 0, 255, //
|
||||
0, -1, 0, 0, 255, //
|
||||
0, 0, -1, 0, 255, //
|
||||
0, 0, 0, 1, 0, //
|
||||
]),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -1 - darkest, 1 - brightest, 0 - unchanged
|
||||
class BrightnessFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final double brightness;
|
||||
const BrightnessFilter({super.key, this.child, this.brightness = 0});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: ColorFilter.matrix(
|
||||
_ColorFilterGenerator.brightnessAdjustMatrix(brightness),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// -1 - greyscale, 1 - most saturated, 0 - unchanged
|
||||
class SaturationFilter extends StatelessWidget {
|
||||
final Widget? child;
|
||||
final double saturation;
|
||||
const SaturationFilter({super.key, this.child, this.saturation = 0});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ColorFiltered(
|
||||
colorFilter: ColorFilter.matrix(
|
||||
_ColorFilterGenerator.saturationAdjustMatrix(saturation),
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ColorFilterGenerator {
|
||||
static List<double> brightnessAdjustMatrix(double value) {
|
||||
value = value * 10;
|
||||
|
||||
if (value == 0) {
|
||||
return [
|
||||
1, 0, 0, 0, 0, //
|
||||
0, 1, 0, 0, 0, //
|
||||
0, 0, 1, 0, 0, //
|
||||
0, 0, 0, 1, 0, //
|
||||
];
|
||||
}
|
||||
|
||||
return List<double>.from(<double>[
|
||||
1, 0, 0, 0, value, 0, 1, 0, 0, value, 0, 0, 1, 0, value, 0, 0, 0, 1, 0, //
|
||||
]).map((i) => i.toDouble()).toList();
|
||||
}
|
||||
|
||||
static List<double> saturationAdjustMatrix(double value) {
|
||||
value = value * 100;
|
||||
|
||||
if (value == 0) {
|
||||
return [
|
||||
1, 0, 0, 0, 0, //
|
||||
0, 1, 0, 0, 0, //
|
||||
0, 0, 1, 0, 0, //
|
||||
0, 0, 0, 1, 0, //
|
||||
];
|
||||
}
|
||||
|
||||
double x =
|
||||
((1 + ((value > 0) ? ((3 * value) / 100) : (value / 100)))).toDouble();
|
||||
double lumR = 0.3086;
|
||||
double lumG = 0.6094;
|
||||
double lumB = 0.082;
|
||||
|
||||
return List<double>.from(<double>[
|
||||
(lumR * (1 - x)) + x, lumG * (1 - x), lumB * (1 - x), //
|
||||
0, 0, //
|
||||
lumR * (1 - x), //
|
||||
(lumG * (1 - x)) + x, //
|
||||
lumB * (1 - x), //
|
||||
0, 0, //
|
||||
lumR * (1 - x), //
|
||||
lumG * (1 - x), //
|
||||
(lumB * (1 - x)) + x, //
|
||||
0, 0, 0, 0, 0, 1, 0, //
|
||||
]).map((i) => i.toDouble()).toList();
|
||||
}
|
||||
}
|
26
mobile/lib/utils/debounce.dart
Normal file
26
mobile/lib/utils/debounce.dart
Normal file
|
@ -0,0 +1,26 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class Debounce {
|
||||
Debounce(Duration interval) : _interval = interval.inMilliseconds;
|
||||
final int _interval;
|
||||
Timer? _timer;
|
||||
VoidCallback? action;
|
||||
|
||||
void call(VoidCallback? action) {
|
||||
this.action = action;
|
||||
_timer?.cancel();
|
||||
_timer = Timer(Duration(milliseconds: _interval), _callAndRest);
|
||||
}
|
||||
|
||||
void _callAndRest() {
|
||||
action?.call();
|
||||
_timer = null;
|
||||
}
|
||||
|
||||
void dispose() {
|
||||
_timer?.cancel();
|
||||
_timer = null;
|
||||
}
|
||||
}
|
67
mobile/lib/utils/flutter_map_extensions.dart
Normal file
67
mobile/lib/utils/flutter_map_extensions.dart
Normal file
|
@ -0,0 +1,67 @@
|
|||
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;
|
||||
}
|
||||
}
|
|
@ -7,17 +7,20 @@ String getThumbnailUrl(
|
|||
final Asset asset, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
return _getThumbnailUrl(asset.remoteId!, type: type);
|
||||
return getThumbnailUrlForRemoteId(asset.remoteId!, type: type);
|
||||
}
|
||||
|
||||
String getThumbnailCacheKey(
|
||||
final Asset asset, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
return _getThumbnailCacheKey(asset.remoteId!, type);
|
||||
return getThumbnailCacheKeyForRemoteId(asset.remoteId!, type: type);
|
||||
}
|
||||
|
||||
String _getThumbnailCacheKey(final String id, final ThumbnailFormat type) {
|
||||
String getThumbnailCacheKeyForRemoteId(
|
||||
final String id, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
if (type == ThumbnailFormat.WEBP) {
|
||||
return 'thumbnail-image-$id';
|
||||
} else {
|
||||
|
@ -32,7 +35,8 @@ String getAlbumThumbnailUrl(
|
|||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
}
|
||||
return _getThumbnailUrl(album.thumbnail.value!.remoteId!, type: type);
|
||||
return getThumbnailUrlForRemoteId(album.thumbnail.value!.remoteId!,
|
||||
type: type,);
|
||||
}
|
||||
|
||||
String getAlbumThumbNailCacheKey(
|
||||
|
@ -42,7 +46,10 @@ String getAlbumThumbNailCacheKey(
|
|||
if (album.thumbnail.value?.remoteId == null) {
|
||||
return '';
|
||||
}
|
||||
return _getThumbnailCacheKey(album.thumbnail.value!.remoteId!, type);
|
||||
return getThumbnailCacheKeyForRemoteId(
|
||||
album.thumbnail.value!.remoteId!,
|
||||
type: type,
|
||||
);
|
||||
}
|
||||
|
||||
String getImageUrl(final Asset asset) {
|
||||
|
@ -53,7 +60,7 @@ String getImageCacheKey(final Asset asset) {
|
|||
return '${asset.id}_fullStage';
|
||||
}
|
||||
|
||||
String _getThumbnailUrl(
|
||||
String getThumbnailUrlForRemoteId(
|
||||
final String id, {
|
||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||
}) {
|
||||
|
|
76
mobile/lib/utils/selection_handlers.dart
Normal file
76
mobile/lib/utils/selection_handlers.dart
Normal file
|
@ -0,0 +1,76 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/shared/services/share.service.dart';
|
||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||
import 'package:immich_mobile/shared/ui/share_dialog.dart';
|
||||
|
||||
void handleShareAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext buildContext) {
|
||||
ref
|
||||
.watch(shareServiceProvider)
|
||||
.shareAssets(selection.toList())
|
||||
.then((_) => Navigator.of(buildContext).pop());
|
||||
return const ShareDialog();
|
||||
},
|
||||
barrierDismissible: false,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> handleArchiveAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection, {
|
||||
bool shouldArchive = true,
|
||||
ToastGravity toastGravity = ToastGravity.BOTTOM,
|
||||
}) async {
|
||||
if (selection.isNotEmpty) {
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.toggleArchive(selection, shouldArchive);
|
||||
|
||||
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
|
||||
final archiveOrLibrary = shouldArchive ? 'archive' : 'library';
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'Moved ${selection.length} $assetOrAssets to $archiveOrLibrary',
|
||||
gravity: toastGravity,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> handleFavoriteAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection, {
|
||||
bool shouldFavorite = true,
|
||||
ToastGravity toastGravity = ToastGravity.BOTTOM,
|
||||
}) async {
|
||||
if (selection.isNotEmpty) {
|
||||
await ref
|
||||
.watch(assetProvider.notifier)
|
||||
.toggleFavorite(selection, shouldFavorite);
|
||||
|
||||
final assetOrAssets = selection.length > 1 ? 'assets' : 'asset';
|
||||
final toastMessage = shouldFavorite
|
||||
? 'Added ${selection.length} $assetOrAssets to favorites'
|
||||
: 'Removed ${selection.length} $assetOrAssets from favorites';
|
||||
if (context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: toastMessage,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
|
@ -504,6 +504,14 @@ packages:
|
|||
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:
|
||||
|
@ -575,6 +583,54 @@ packages:
|
|||
description: flutter
|
||||
source: sdk
|
||||
version: "0.0.0"
|
||||
geolocator:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: geolocator
|
||||
sha256: "9d6eff112971b9f195271834b390fc0e1899a9a6c96225ead72efd5d4aaa80c7"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "10.0.0"
|
||||
geolocator_android:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_android
|
||||
sha256: "835ff5b4888a2f8eba128996494faf9c5d422785322a81dc0565b99e0f6c379d"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.2.2"
|
||||
geolocator_apple:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_apple
|
||||
sha256: "36527c555f4c425f7d8fa8c7c07d67b78e3ff7590d40448051959e1860c1cfb4"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.2.7"
|
||||
geolocator_platform_interface:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_platform_interface
|
||||
sha256: af4d69231452f9620718588f41acc4cb58312368716bfff2e92e770b46ce6386
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "4.0.7"
|
||||
geolocator_web:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_web
|
||||
sha256: f68a122da48fcfff68bbc9846bb0b74ef651afe84a1b1f6ec20939de4d6860e1
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.6"
|
||||
geolocator_windows:
|
||||
dependency: transitive
|
||||
description:
|
||||
name: geolocator_windows
|
||||
sha256: "463045515b08bd83f73e014359c4ad063b902eb3899952cfb784497ae6c6583b"
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "0.2.0"
|
||||
glob:
|
||||
dependency: transitive
|
||||
description:
|
||||
|
|
|
@ -26,6 +26,8 @@ dependencies:
|
|||
badges: ^2.0.2
|
||||
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
||||
flutter_map: ^4.0.0
|
||||
flutter_map_heatmap: ^0.0.4
|
||||
geolocator: ^10.0.0 # used to move to current location in map view
|
||||
flutter_udid: ^2.0.0
|
||||
package_info_plus: ^4.1.0
|
||||
url_launcher: ^6.1.3
|
||||
|
|
Loading…
Reference in a new issue