mirror of
https://github.com/immich-app/immich.git
synced 2024-12-28 22:51:59 +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.READ_MEDIA_AUDIO" />
|
||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||||
|
|
||||||
<queries>
|
<queries>
|
||||||
<intent>
|
<intent>
|
||||||
|
|
|
@ -301,5 +301,20 @@
|
||||||
"version_announcement_overlay_text_2": "please take your time to visit the ",
|
"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_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",
|
"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 (2.7.5):
|
||||||
- FMDB/standard (= 2.7.5)
|
- FMDB/standard (= 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):
|
- image_picker_ios (0.0.1):
|
||||||
- Flutter
|
- Flutter
|
||||||
- integration_test (0.0.1):
|
- integration_test (0.0.1):
|
||||||
|
@ -65,6 +67,7 @@ DEPENDENCIES:
|
||||||
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
- flutter_udid (from `.symlinks/plugins/flutter_udid/ios`)
|
||||||
- flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
|
- flutter_web_auth (from `.symlinks/plugins/flutter_web_auth/ios`)
|
||||||
- fluttertoast (from `.symlinks/plugins/fluttertoast/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`)
|
- image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
|
||||||
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
- integration_test (from `.symlinks/plugins/integration_test/ios`)
|
||||||
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
- isar_flutter_libs (from `.symlinks/plugins/isar_flutter_libs/ios`)
|
||||||
|
@ -104,6 +107,8 @@ EXTERNAL SOURCES:
|
||||||
:path: ".symlinks/plugins/flutter_web_auth/ios"
|
:path: ".symlinks/plugins/flutter_web_auth/ios"
|
||||||
fluttertoast:
|
fluttertoast:
|
||||||
:path: ".symlinks/plugins/fluttertoast/ios"
|
:path: ".symlinks/plugins/fluttertoast/ios"
|
||||||
|
geolocator_apple:
|
||||||
|
:path: ".symlinks/plugins/geolocator_apple/ios"
|
||||||
image_picker_ios:
|
image_picker_ios:
|
||||||
:path: ".symlinks/plugins/image_picker_ios/ios"
|
:path: ".symlinks/plugins/image_picker_ios/ios"
|
||||||
integration_test:
|
integration_test:
|
||||||
|
@ -143,6 +148,7 @@ SPEC CHECKSUMS:
|
||||||
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
|
flutter_web_auth: c25208760459cec375a3c39f6a8759165ca0fa4d
|
||||||
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
|
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
|
||||||
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
|
||||||
|
geolocator_apple: cc556e6844d508c95df1e87e3ea6fa4e58c50401
|
||||||
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
|
image_picker_ios: 4a8aadfbb6dc30ad5141a2ce3832af9214a705b5
|
||||||
integration_test: 13825b8a9334a850581300559b8839134b124670
|
integration_test: 13825b8a9334a850581300559b8839134b124670
|
||||||
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
isar_flutter_libs: b69f437aeab9c521821c3f376198c4371fa21073
|
||||||
|
|
|
@ -83,8 +83,6 @@
|
||||||
</dict>
|
</dict>
|
||||||
<key>NSCameraUsageDescription</key>
|
<key>NSCameraUsageDescription</key>
|
||||||
<string>We need to access the camera to let you take beautiful video using this app</string>
|
<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>
|
<key>NSLocationWhenInUseUsageDescription</key>
|
||||||
<string>Enable location setting to show position of assets on map</string>
|
<string>Enable location setting to show position of assets on map</string>
|
||||||
<key>NSMicrophoneUsageDescription</key>
|
<key>NSMicrophoneUsageDescription</key>
|
||||||
|
|
|
@ -2,14 +2,12 @@ import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/archive/providers/archive_asset_provider.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/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.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_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||||
|
|
||||||
class ArchivePage extends HookConsumerWidget {
|
class ArchivePage extends HookConsumerWidget {
|
||||||
const ArchivePage({super.key});
|
const ArchivePage({super.key});
|
||||||
|
@ -68,24 +66,12 @@ class ArchivePage extends HookConsumerWidget {
|
||||||
: () async {
|
: () async {
|
||||||
processing.value = true;
|
processing.value = true;
|
||||||
try {
|
try {
|
||||||
if (selection.value.isNotEmpty) {
|
await handleArchiveAssets(
|
||||||
await ref
|
ref,
|
||||||
.watch(assetProvider.notifier)
|
context,
|
||||||
.toggleArchive(
|
selection.value.toList(),
|
||||||
selection.value.toList(),
|
shouldArchive: false,
|
||||||
false,
|
);
|
||||||
);
|
|
||||||
|
|
||||||
final assetOrAssets = selection.value.length > 1
|
|
||||||
? 'assets'
|
|
||||||
: 'asset';
|
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg:
|
|
||||||
'Moved ${selection.value.length} $assetOrAssets to library',
|
|
||||||
gravity: ToastGravity.CENTER,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
selectionEnabledHook.value = false;
|
selectionEnabledHook.value = false;
|
||||||
|
|
|
@ -5,6 +5,7 @@ import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_map/flutter_map.dart';
|
import 'package:flutter_map/flutter_map.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/asset_viewer/ui/description_input.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/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
import 'package:immich_mobile/shared/ui/drag_sheet.dart';
|
||||||
import 'package:latlong2/latlong.dart';
|
import 'package:latlong2/latlong.dart';
|
||||||
|
@ -41,7 +42,10 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||||
Uri uri = Uri(
|
Uri uri = Uri(
|
||||||
scheme: 'geo',
|
scheme: 'geo',
|
||||||
host: '$latitude,$longitude',
|
host: '$latitude,$longitude',
|
||||||
queryParameters: {'z': '$zoomLevel', 'q': formattedDateTime},
|
queryParameters: {
|
||||||
|
'z': '$zoomLevel',
|
||||||
|
'q': '$latitude,$longitude($formattedDateTime)',
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (await canLaunchUrl(uri)) {
|
if (await canLaunchUrl(uri)) {
|
||||||
return uri;
|
return uri;
|
||||||
|
@ -77,65 +81,35 @@ class ExifBottomSheet extends HookConsumerWidget {
|
||||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||||
child: LayoutBuilder(
|
child: LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
return Container(
|
return MapThumbnail(
|
||||||
height: 150,
|
coords: LatLng(
|
||||||
width: constraints.maxWidth,
|
exifInfo?.latitude ?? 0,
|
||||||
decoration: const BoxDecoration(
|
exifInfo?.longitude ?? 0,
|
||||||
borderRadius: BorderRadius.all(Radius.circular(15)),
|
|
||||||
),
|
),
|
||||||
child: FlutterMap(
|
height: 150,
|
||||||
options: MapOptions(
|
zoom: 16.0,
|
||||||
interactiveFlags: InteractiveFlag.none,
|
markers: [
|
||||||
center: LatLng(
|
Marker(
|
||||||
|
anchorPos: AnchorPos.align(AnchorAlign.top),
|
||||||
|
point: LatLng(
|
||||||
exifInfo?.latitude ?? 0,
|
exifInfo?.latitude ?? 0,
|
||||||
exifInfo?.longitude ?? 0,
|
exifInfo?.longitude ?? 0,
|
||||||
),
|
),
|
||||||
zoom: 16.0,
|
builder: (ctx) => const Image(
|
||||||
onTap: (tapPosition, latLong) async {
|
image: AssetImage('assets/location-pin.png'),
|
||||||
Uri? uri = await _createCoordinatesUri();
|
),
|
||||||
|
|
||||||
if (uri == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
debugPrint('Opening Map Uri: $uri');
|
|
||||||
launchUrl(uri);
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
nonRotatedChildren: [
|
],
|
||||||
RichAttributionWidget(
|
onTap: (tapPosition, latLong) async {
|
||||||
attributions: [
|
Uri? uri = await _createCoordinatesUri();
|
||||||
TextSourceAttribution(
|
|
||||||
'OpenStreetMap contributors',
|
if (uri == null) {
|
||||||
onTap: () => launchUrl(
|
return;
|
||||||
Uri.parse('https://openstreetmap.org/copyright'),
|
}
|
||||||
),
|
|
||||||
),
|
debugPrint('Opening Map Uri: $uri');
|
||||||
],
|
launchUrl(uri);
|
||||||
),
|
},
|
||||||
],
|
|
||||||
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'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -2,13 +2,11 @@ import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:fluttertoast/fluttertoast.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.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/modules/home/ui/asset_grid/immich_asset_grid.dart';
|
||||||
import 'package:immich_mobile/shared/models/asset.dart';
|
import 'package:immich_mobile/shared/models/asset.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
|
||||||
|
|
||||||
class FavoritesPage extends HookConsumerWidget {
|
class FavoritesPage extends HookConsumerWidget {
|
||||||
const FavoritesPage({Key? key}) : super(key: key);
|
const FavoritesPage({Key? key}) : super(key: key);
|
||||||
|
@ -44,16 +42,11 @@ class FavoritesPage extends HookConsumerWidget {
|
||||||
void unfavorite() async {
|
void unfavorite() async {
|
||||||
try {
|
try {
|
||||||
if (selection.value.isNotEmpty) {
|
if (selection.value.isNotEmpty) {
|
||||||
await ref.watch(assetProvider.notifier).toggleFavorite(
|
await handleFavoriteAssets(
|
||||||
selection.value.toList(),
|
ref,
|
||||||
false,
|
context,
|
||||||
);
|
selection.value.toList(),
|
||||||
final assetOrAssets = selection.value.length > 1 ? 'assets' : 'asset';
|
shouldFavorite: false,
|
||||||
ImmichToast.show(
|
|
||||||
context: context,
|
|
||||||
msg:
|
|
||||||
'Removed ${selection.value.length} $assetOrAssets from favorites',
|
|
||||||
gravity: ToastGravity.CENTER,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
|
|
@ -30,6 +30,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||||
final void Function(ItemPosition start, ItemPosition end)?
|
final void Function(ItemPosition start, ItemPosition end)?
|
||||||
visibleItemsListener;
|
visibleItemsListener;
|
||||||
final Widget? topWidget;
|
final Widget? topWidget;
|
||||||
|
final bool shrinkWrap;
|
||||||
|
final bool showDragScroll;
|
||||||
|
|
||||||
const ImmichAssetGrid({
|
const ImmichAssetGrid({
|
||||||
super.key,
|
super.key,
|
||||||
|
@ -47,6 +49,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||||
this.showMultiSelectIndicator = true,
|
this.showMultiSelectIndicator = true,
|
||||||
this.visibleItemsListener,
|
this.visibleItemsListener,
|
||||||
this.topWidget,
|
this.topWidget,
|
||||||
|
this.shrinkWrap = false,
|
||||||
|
this.showDragScroll = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -108,6 +112,8 @@ class ImmichAssetGrid extends HookConsumerWidget {
|
||||||
visibleItemsListener: visibleItemsListener,
|
visibleItemsListener: visibleItemsListener,
|
||||||
topWidget: topWidget,
|
topWidget: topWidget,
|
||||||
heroOffset: heroOffset(),
|
heroOffset: heroOffset(),
|
||||||
|
shrinkWrap: shrinkWrap,
|
||||||
|
showDragScroll: showDragScroll,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -35,6 +35,8 @@ class ImmichAssetGridView extends StatefulWidget {
|
||||||
visibleItemsListener;
|
visibleItemsListener;
|
||||||
final Widget? topWidget;
|
final Widget? topWidget;
|
||||||
final int heroOffset;
|
final int heroOffset;
|
||||||
|
final bool shrinkWrap;
|
||||||
|
final bool showDragScroll;
|
||||||
|
|
||||||
const ImmichAssetGridView({
|
const ImmichAssetGridView({
|
||||||
super.key,
|
super.key,
|
||||||
|
@ -52,6 +54,8 @@ class ImmichAssetGridView extends StatefulWidget {
|
||||||
this.visibleItemsListener,
|
this.visibleItemsListener,
|
||||||
this.topWidget,
|
this.topWidget,
|
||||||
this.heroOffset = 0,
|
this.heroOffset = 0,
|
||||||
|
this.shrinkWrap = false,
|
||||||
|
this.showDragScroll = true,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
|
@ -324,7 +328,8 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildAssetGrid() {
|
Widget _buildAssetGrid() {
|
||||||
final useDragScrolling = widget.renderList.totalAssets >= 20;
|
final useDragScrolling =
|
||||||
|
widget.showDragScroll && widget.renderList.totalAssets >= 20;
|
||||||
|
|
||||||
void dragScrolling(bool active) {
|
void dragScrolling(bool active) {
|
||||||
if (active != _scrolling) {
|
if (active != _scrolling) {
|
||||||
|
@ -344,6 +349,7 @@ class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
||||||
itemCount: widget.renderList.elements.length +
|
itemCount: widget.renderList.elements.length +
|
||||||
(widget.topWidget != null ? 1 : 0),
|
(widget.topWidget != null ? 1 : 0),
|
||||||
addRepaintBoundaries: true,
|
addRepaintBoundaries: true,
|
||||||
|
shrinkWrap: widget.shrinkWrap,
|
||||||
);
|
);
|
||||||
|
|
||||||
final child = useDragScrolling
|
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/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/user.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/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_loading_indicator.dart';
|
||||||
import 'package:immich_mobile/shared/ui/immich_toast.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 {
|
class HomePage extends HookConsumerWidget {
|
||||||
const HomePage({Key? key}) : super(key: key);
|
const HomePage({Key? key}) : super(key: key);
|
||||||
|
@ -88,17 +87,7 @@ class HomePage extends HookConsumerWidget {
|
||||||
}
|
}
|
||||||
|
|
||||||
void onShareAssets() {
|
void onShareAssets() {
|
||||||
showDialog(
|
handleShareAssets(ref, context, selection.value.toList());
|
||||||
context: context,
|
|
||||||
builder: (BuildContext buildContext) {
|
|
||||||
ref
|
|
||||||
.watch(shareServiceProvider)
|
|
||||||
.shareAssets(selection.value.toList())
|
|
||||||
.then((_) => Navigator.of(buildContext).pop());
|
|
||||||
return const ShareDialog();
|
|
||||||
},
|
|
||||||
barrierDismissible: false,
|
|
||||||
);
|
|
||||||
|
|
||||||
selectionEnabledHook.value = false;
|
selectionEnabledHook.value = false;
|
||||||
}
|
}
|
||||||
|
@ -126,16 +115,7 @@ class HomePage extends HookConsumerWidget {
|
||||||
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
||||||
);
|
);
|
||||||
if (remoteAssets.isNotEmpty) {
|
if (remoteAssets.isNotEmpty) {
|
||||||
await ref
|
await handleFavoriteAssets(ref, context, remoteAssets);
|
||||||
.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,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
|
@ -149,18 +129,7 @@ class HomePage extends HookConsumerWidget {
|
||||||
final remoteAssets = remoteOnlySelection(
|
final remoteAssets = remoteOnlySelection(
|
||||||
localErrorMessage: 'home_page_archive_err_local'.tr(),
|
localErrorMessage: 'home_page_archive_err_local'.tr(),
|
||||||
);
|
);
|
||||||
if (remoteAssets.isNotEmpty) {
|
await handleArchiveAssets(ref, context, remoteAssets);
|
||||||
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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
} finally {
|
} finally {
|
||||||
processing.value = false;
|
processing.value = false;
|
||||||
selectionEnabledHook.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/people.provider.dart';
|
||||||
import 'package:immich_mobile/modules/search/providers/search_page_state.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_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/immich_search_bar.dart';
|
||||||
import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
|
import 'package:immich_mobile/modules/search/ui/person_name_edit_form.dart';
|
||||||
import 'package:immich_mobile/modules/search/ui/search_row_title.dart';
|
import 'package:immich_mobile/modules/search/ui/search_row_title.dart';
|
||||||
|
@ -69,7 +69,7 @@ class SearchPage extends HookConsumerWidget {
|
||||||
|
|
||||||
buildPeople() {
|
buildPeople() {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
height: MediaQuery.of(context).size.width / 3,
|
height: imageSize,
|
||||||
child: curatedPeople.when(
|
child: curatedPeople.when(
|
||||||
loading: () => const Center(child: ImmichLoadingIndicator()),
|
loading: () => const Center(child: ImmichLoadingIndicator()),
|
||||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
|
@ -105,7 +105,7 @@ class SearchPage extends HookConsumerWidget {
|
||||||
child: curatedLocation.when(
|
child: curatedLocation.when(
|
||||||
loading: () => const Center(child: ImmichLoadingIndicator()),
|
loading: () => const Center(child: ImmichLoadingIndicator()),
|
||||||
error: (err, stack) => Center(child: Text('Error: $err')),
|
error: (err, stack) => Center(child: Text('Error: $err')),
|
||||||
data: (locations) => CuratedRow(
|
data: (locations) => CuratedPlacesRow(
|
||||||
content: locations
|
content: locations
|
||||||
.map(
|
.map(
|
||||||
(o) => CuratedContent(
|
(o) => CuratedContent(
|
||||||
|
@ -155,6 +155,7 @@ class SearchPage extends HookConsumerWidget {
|
||||||
),
|
),
|
||||||
top: 0,
|
top: 0,
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 10.0),
|
||||||
buildPlaces(),
|
buildPlaces(),
|
||||||
const SizedBox(height: 24.0),
|
const SizedBox(height: 24.0),
|
||||||
Padding(
|
Padding(
|
||||||
|
|
|
@ -46,6 +46,9 @@ enum AppSettingsEnum<T> {
|
||||||
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
|
||||||
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
|
||||||
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
|
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);
|
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/asset_selection_page.dart';
|
||||||
import 'package:immich_mobile/modules/album/views/create_album_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/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/models/memory.dart';
|
||||||
import 'package:immich_mobile/modules/memories/views/memory_page.dart';
|
import 'package:immich_mobile/modules/memories/views/memory_page.dart';
|
||||||
import 'package:immich_mobile/modules/partner/views/partner_detail_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: AllPeoplePage, guards: [AuthGuard, DuplicateGuard]),
|
||||||
AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
|
AutoRoute(page: MemoryPage, guards: [AuthGuard, DuplicateGuard]),
|
||||||
|
AutoRoute(page: MapPage, guards: [AuthGuard, DuplicateGuard]),
|
||||||
AutoRoute(page: AlbumOptionsPage, 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) {
|
AlbumOptionsRoute.name: (routeData) {
|
||||||
final args = routeData.argsAs<AlbumOptionsRouteArgs>();
|
final args = routeData.argsAs<AlbumOptionsRouteArgs>();
|
||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
|
@ -605,6 +611,14 @@ class _$AppRouter extends RootStackRouter {
|
||||||
duplicateGuard,
|
duplicateGuard,
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
RouteConfig(
|
||||||
|
MapRoute.name,
|
||||||
|
path: '/map-page',
|
||||||
|
guards: [
|
||||||
|
authGuard,
|
||||||
|
duplicateGuard,
|
||||||
|
],
|
||||||
|
),
|
||||||
RouteConfig(
|
RouteConfig(
|
||||||
AlbumOptionsRoute.name,
|
AlbumOptionsRoute.name,
|
||||||
path: '/album-options-page',
|
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
|
/// generated route for
|
||||||
/// [AlbumOptionsPage]
|
/// [AlbumOptionsPage]
|
||||||
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
|
class AlbumOptionsRoute extends PageRouteInfo<AlbumOptionsRouteArgs> {
|
||||||
|
|
|
@ -174,6 +174,10 @@ enum StoreKey<T> {
|
||||||
advancedTroubleshooting<bool>(114, type: bool),
|
advancedTroubleshooting<bool>(114, type: bool),
|
||||||
logLevel<int>(115, type: int),
|
logLevel<int>(115, type: int),
|
||||||
preferRemoteImage<bool>(116, type: bool),
|
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(
|
const StoreKey(
|
||||||
|
|
|
@ -26,7 +26,7 @@ class ConfirmDialog extends ConsumerWidget {
|
||||||
content: Text(content).tr(),
|
content: Text(content).tr(),
|
||||||
actions: [
|
actions: [
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () => Navigator.of(context).pop(),
|
onPressed: () => Navigator.of(context).pop(false),
|
||||||
child: Text(
|
child: Text(
|
||||||
cancel,
|
cancel,
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
|
@ -38,7 +38,7 @@ class ConfirmDialog extends ConsumerWidget {
|
||||||
TextButton(
|
TextButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
onOk();
|
onOk();
|
||||||
Navigator.of(context).pop();
|
Navigator.of(context).pop(true);
|
||||||
},
|
},
|
||||||
child: Text(
|
child: Text(
|
||||||
ok,
|
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, {
|
final Asset asset, {
|
||||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
||||||
}) {
|
}) {
|
||||||
return _getThumbnailUrl(asset.remoteId!, type: type);
|
return getThumbnailUrlForRemoteId(asset.remoteId!, type: type);
|
||||||
}
|
}
|
||||||
|
|
||||||
String getThumbnailCacheKey(
|
String getThumbnailCacheKey(
|
||||||
final Asset asset, {
|
final Asset asset, {
|
||||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
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) {
|
if (type == ThumbnailFormat.WEBP) {
|
||||||
return 'thumbnail-image-$id';
|
return 'thumbnail-image-$id';
|
||||||
} else {
|
} else {
|
||||||
|
@ -32,7 +35,8 @@ String getAlbumThumbnailUrl(
|
||||||
if (album.thumbnail.value?.remoteId == null) {
|
if (album.thumbnail.value?.remoteId == null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return _getThumbnailUrl(album.thumbnail.value!.remoteId!, type: type);
|
return getThumbnailUrlForRemoteId(album.thumbnail.value!.remoteId!,
|
||||||
|
type: type,);
|
||||||
}
|
}
|
||||||
|
|
||||||
String getAlbumThumbNailCacheKey(
|
String getAlbumThumbNailCacheKey(
|
||||||
|
@ -42,7 +46,10 @@ String getAlbumThumbNailCacheKey(
|
||||||
if (album.thumbnail.value?.remoteId == null) {
|
if (album.thumbnail.value?.remoteId == null) {
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
return _getThumbnailCacheKey(album.thumbnail.value!.remoteId!, type);
|
return getThumbnailCacheKeyForRemoteId(
|
||||||
|
album.thumbnail.value!.remoteId!,
|
||||||
|
type: type,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
String getImageUrl(final Asset asset) {
|
String getImageUrl(final Asset asset) {
|
||||||
|
@ -53,7 +60,7 @@ String getImageCacheKey(final Asset asset) {
|
||||||
return '${asset.id}_fullStage';
|
return '${asset.id}_fullStage';
|
||||||
}
|
}
|
||||||
|
|
||||||
String _getThumbnailUrl(
|
String getThumbnailUrlForRemoteId(
|
||||||
final String id, {
|
final String id, {
|
||||||
ThumbnailFormat type = ThumbnailFormat.WEBP,
|
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"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.0"
|
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:
|
flutter_native_splash:
|
||||||
dependency: "direct dev"
|
dependency: "direct dev"
|
||||||
description:
|
description:
|
||||||
|
@ -575,6 +583,54 @@ packages:
|
||||||
description: flutter
|
description: flutter
|
||||||
source: sdk
|
source: sdk
|
||||||
version: "0.0.0"
|
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:
|
glob:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -26,6 +26,8 @@ dependencies:
|
||||||
badges: ^2.0.2
|
badges: ^2.0.2
|
||||||
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
socket_io_client: ^2.0.0-beta.4-nullsafety.0
|
||||||
flutter_map: ^4.0.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
|
flutter_udid: ^2.0.0
|
||||||
package_info_plus: ^4.1.0
|
package_info_plus: ^4.1.0
|
||||||
url_launcher: ^6.1.3
|
url_launcher: ^6.1.3
|
||||||
|
|
Loading…
Reference in a new issue