From ba262fbaa859101724645c01b6c262f7bc2e9248 Mon Sep 17 00:00:00 2001 From: Daimolean <92239625+wuzihao051119@users.noreply.github.com> Date: Tue, 15 Jul 2025 23:10:12 +0800 Subject: [PATCH] feat(mobile): drift place page (#19914) * feat(mobile): drift place page * merge main * feat(mobile): drift place detail page (#19915) --------- Co-authored-by: Alex --- mobile/ios/Runner.xcodeproj/project.pbxproj | 12 +- .../xcshareddata/xcschemes/Runner.xcscheme | 2 + mobile/lib/domain/services/asset.service.dart | 4 + .../lib/domain/services/timeline.service.dart | 3 + .../repositories/remote_asset.repository.dart | 36 ++++ .../repositories/timeline.repository.dart | 78 +++++++ .../pages/drift_library.page.dart | 4 +- .../presentation/pages/drift_place.page.dart | 190 ++++++++++++++++++ .../pages/drift_place_detail.page.dart | 38 ++++ .../infrastructure/asset.provider.dart | 7 + mobile/lib/routing/router.dart | 11 +- mobile/lib/routing/router.gr.dart | 79 ++++++++ .../common/mesmerizing_sliver_app_bar.dart | 4 +- 13 files changed, 453 insertions(+), 15 deletions(-) create mode 100644 mobile/lib/presentation/pages/drift_place.page.dart create mode 100644 mobile/lib/presentation/pages/drift_place_detail.page.dart diff --git a/mobile/ios/Runner.xcodeproj/project.pbxproj b/mobile/ios/Runner.xcodeproj/project.pbxproj index 1a39f98db3..fb0908e8b6 100644 --- a/mobile/ios/Runner.xcodeproj/project.pbxproj +++ b/mobile/ios/Runner.xcodeproj/project.pbxproj @@ -3,7 +3,7 @@ archiveVersion = 1; classes = { }; - objectVersion = 77; + objectVersion = 54; objects = { /* Begin PBXBuildFile section */ @@ -117,6 +117,8 @@ /* Begin PBXFileSystemSynchronizedRootGroup section */ B2CF7F8C2DDE4EBB00744BF6 /* Sync */ = { isa = PBXFileSystemSynchronizedRootGroup; + exceptions = ( + ); path = Sync; sourceTree = ""; }; @@ -471,14 +473,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Copy Pods Resources"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-resources.sh\"\n"; @@ -507,14 +505,10 @@ inputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", ); - inputPaths = ( - ); name = "[CP] Embed Pods Frameworks"; outputFileListPaths = ( "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", ); - outputPaths = ( - ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; diff --git a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 656d278d6d..2367c52b97 100644 --- a/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/mobile/ios/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -26,6 +26,7 @@ buildConfiguration = "Debug" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit" shouldUseLaunchSchemeArgsEnv = "YES"> > getPlaces() { + return _remoteAssetRepository.getPlaces(); + } } diff --git a/mobile/lib/domain/services/timeline.service.dart b/mobile/lib/domain/services/timeline.service.dart index 8204572547..14a854a760 100644 --- a/mobile/lib/domain/services/timeline.service.dart +++ b/mobile/lib/domain/services/timeline.service.dart @@ -62,6 +62,9 @@ class TimelineFactory { TimelineService video(String userId) => TimelineService(_timelineRepository.video(userId, groupBy)); + + TimelineService place(String place) => + TimelineService(_timelineRepository.place(place, groupBy)); } class TimelineService { diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart index 95de34d1b4..1f6f1b0891 100644 --- a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -56,6 +56,42 @@ class RemoteAssetRepository extends DriftDatabaseRepository { .getSingleOrNull(); } + Future> getPlaces() { + final asset = Subquery( + _db.remoteAssetEntity.select() + ..orderBy([(row) => OrderingTerm.desc(row.createdAt)]), + "asset", + ); + + final query = asset.selectOnly().join([ + innerJoin( + _db.remoteExifEntity, + _db.remoteExifEntity.assetId + .equalsExp(asset.ref(_db.remoteAssetEntity.id)), + useColumns: false, + ), + ]) + ..addColumns([ + _db.remoteExifEntity.city, + _db.remoteExifEntity.assetId, + ]) + ..where( + _db.remoteExifEntity.city.isNotNull() & + asset.ref(_db.remoteAssetEntity.deletedAt).isNull() & + asset + .ref(_db.remoteAssetEntity.visibility) + .equals(AssetVisibility.timeline.index), + ) + ..groupBy([_db.remoteExifEntity.city]) + ..orderBy([OrderingTerm.asc(_db.remoteExifEntity.city)]); + + return query.map((row) { + final assetId = row.read(_db.remoteExifEntity.assetId); + final city = row.read(_db.remoteExifEntity.city); + return (city!, assetId!); + }).get(); + } + Future updateFavorite(List ids, bool isFavorite) { return _db.batch((batch) async { for (final id in ids) { diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 4db1d03d57..c3c7fc71ab 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -303,6 +303,84 @@ class DriftTimelineRepository extends DriftDatabaseRepository { groupBy: groupBy, ); + TimelineQuery place(String place, GroupAssetsBy groupBy) => ( + bucketSource: () => _watchPlaceBucket( + place, + groupBy: groupBy, + ), + assetSource: (offset, count) => _getPlaceBucketAssets( + place, + offset: offset, + count: count, + ), + ); + + Stream> _watchPlaceBucket( + String place, { + GroupAssetsBy groupBy = GroupAssetsBy.day, + }) { + if (groupBy == GroupAssetsBy.none) { + // TODO: implement GroupAssetBy for place + throw UnsupportedError( + "GroupAssetsBy.none is not supported for watchPlaceBucket", + ); + } + + final assetCountExp = _db.remoteAssetEntity.id.count(); + final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy); + + final query = _db.remoteAssetEntity.selectOnly() + ..addColumns([assetCountExp, dateExp]) + ..join([ + innerJoin( + _db.remoteExifEntity, + _db.remoteExifEntity.assetId.equalsExp(_db.remoteAssetEntity.id), + useColumns: false, + ), + ]) + ..where( + _db.remoteExifEntity.city.equals(place) & + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.visibility + .equalsValue(AssetVisibility.timeline), + ) + ..groupBy([dateExp]) + ..orderBy([OrderingTerm.desc(dateExp)]); + + return query.map((row) { + final timeline = row.read(dateExp)!.dateFmt(groupBy); + final assetCount = row.read(assetCountExp)!; + return TimeBucket(date: timeline, assetCount: assetCount); + }).watch(); + } + + Future> _getPlaceBucketAssets( + String place, { + required int offset, + required int count, + }) { + final query = _db.remoteAssetEntity.select().join( + [ + innerJoin( + _db.remoteExifEntity, + _db.remoteExifEntity.assetId.equalsExp(_db.remoteAssetEntity.id), + useColumns: false, + ), + ], + ) + ..where( + _db.remoteAssetEntity.deletedAt.isNull() & + _db.remoteAssetEntity.visibility + .equalsValue(AssetVisibility.timeline) & + _db.remoteExifEntity.city.equals(place), + ) + ..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)]) + ..limit(count, offset: offset); + return query + .map((row) => row.readTable(_db.remoteAssetEntity).toDto()) + .get(); + } + TimelineQuery _remoteQueryBuilder({ required Expression Function($RemoteAssetEntityTable row) filter, GroupAssetsBy groupBy = GroupAssetsBy.day, diff --git a/mobile/lib/presentation/pages/drift_library.page.dart b/mobile/lib/presentation/pages/drift_library.page.dart index 1e8975dcfa..552733980e 100644 --- a/mobile/lib/presentation/pages/drift_library.page.dart +++ b/mobile/lib/presentation/pages/drift_library.page.dart @@ -256,9 +256,7 @@ class _PlacesCollectionCard extends StatelessWidget { return GestureDetector( onTap: () => context.pushRoute( - PlacesCollectionRoute( - currentLocation: null, - ), + DriftPlaceRoute(currentLocation: null), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, diff --git a/mobile/lib/presentation/pages/drift_place.page.dart b/mobile/lib/presentation/pages/drift_place.page.dart new file mode 100644 index 0000000000..5e5b932f60 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_place.page.dart @@ -0,0 +1,190 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/extensions/build_context_extensions.dart'; +import 'package:immich_mobile/extensions/translate_extensions.dart'; +import 'package:immich_mobile/pages/common/large_leading_tile.dart'; +import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/routing/router.dart'; +import 'package:immich_mobile/widgets/common/search_field.dart'; +import 'package:immich_mobile/widgets/map/map_thumbnail.dart'; +import 'package:maplibre_gl/maplibre_gl.dart'; + +@RoutePage() +class DriftPlacePage extends StatelessWidget { + const DriftPlacePage({super.key, this.currentLocation}); + + final LatLng? currentLocation; + + @override + Widget build(BuildContext context) { + final ValueNotifier search = ValueNotifier(null); + + return Scaffold( + body: CustomScrollView( + slivers: [ + _PlaceSliverAppBar(search: search), + _Map(search: search, currentLocation: currentLocation), + _PlaceList(search: search), + ], + ), + ); + } +} + +class _PlaceSliverAppBar extends StatelessWidget { + const _PlaceSliverAppBar({required this.search}); + + final ValueNotifier search; + + @override + Widget build(BuildContext context) { + final searchFocusNode = FocusNode(); + + return SliverAppBar( + floating: true, + pinned: true, + snap: false, + backgroundColor: context.colorScheme.surfaceContainer, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.all(Radius.circular(5)), + ), + automaticallyImplyLeading: search.value == null, + centerTitle: true, + title: search.value != null + ? SearchField( + focusNode: searchFocusNode, + onTapOutside: (_) => searchFocusNode.unfocus(), + onChanged: (value) => search.value = value, + filled: true, + hintText: 'filter_places'.t(context: context), + autofocus: true, + ) + : Text('places'.t(context: context)), + actions: [ + IconButton( + icon: Icon(search.value != null ? Icons.close : Icons.search), + onPressed: () { + search.value = search.value == null ? '' : null; + }, + ), + ], + ); + } +} + +class _Map extends StatelessWidget { + const _Map({required this.search, this.currentLocation}); + + final ValueNotifier search; + final LatLng? currentLocation; + + @override + Widget build(BuildContext context) { + return search.value == null + ? SliverPadding( + padding: const EdgeInsets.all(16.0), + sliver: SliverToBoxAdapter( + child: SizedBox( + height: 200, + width: context.width, + // TODO: migrate to DriftMapRoute after merging #19898 + child: MapThumbnail( + onTap: (_, __) => context + .pushRoute(MapRoute(initialLocation: currentLocation)), + zoom: 8, + centre: currentLocation ?? + const LatLng( + 21.44950, + -157.91959, + ), + showAttribution: false, + themeMode: + context.isDarkTheme ? ThemeMode.dark : ThemeMode.light, + ), + ), + ), + ) + : const SizedBox.shrink(); + } +} + +class _PlaceList extends ConsumerWidget { + const _PlaceList({required this.search}); + + final ValueNotifier search; + + @override + Widget build(BuildContext context, WidgetRef ref) { + final places = ref.watch(placesProvider); + + return places.when( + loading: () => const SliverToBoxAdapter( + child: Center( + child: Padding( + padding: EdgeInsets.all(20.0), + child: CircularProgressIndicator(), + ), + ), + ), + error: (error, stack) => SliverToBoxAdapter( + child: Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: Text( + 'Error loading places: $error, stack: $stack', + style: TextStyle( + color: context.colorScheme.error, + ), + ), + ), + ), + ), + data: (places) { + if (search.value != null) { + places = places.where((place) { + return place.$1.toLowerCase().contains(search.value!.toLowerCase()); + }).toList(); + } + + return SliverList.builder( + itemCount: places.length, + itemBuilder: (context, index) { + final place = places[index]; + return _PlaceTile(place: place); + }, + ); + }, + ); + } +} + +class _PlaceTile extends StatelessWidget { + const _PlaceTile({required this.place}); + + final (String, String) place; + + @override + Widget build(BuildContext context) { + return LargeLeadingTile( + onTap: () => context.pushRoute(DriftPlaceDetailRoute(place: place.$1)), + title: Text( + place.$1, + style: context.textTheme.titleMedium?.copyWith( + fontWeight: FontWeight.w500, + ), + ), + leading: ClipRRect( + borderRadius: const BorderRadius.all( + Radius.circular(20), + ), + child: Thumbnail( + size: const Size(80, 80), + fit: BoxFit.cover, + remoteId: place.$2, + ), + ), + ); + } +} diff --git a/mobile/lib/presentation/pages/drift_place_detail.page.dart b/mobile/lib/presentation/pages/drift_place_detail.page.dart new file mode 100644 index 0000000000..9999d35297 --- /dev/null +++ b/mobile/lib/presentation/pages/drift_place_detail.page.dart @@ -0,0 +1,38 @@ +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; +import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart'; + +@RoutePage() +class DriftPlaceDetailPage extends StatelessWidget { + final String place; + + const DriftPlaceDetailPage({ + super.key, + required this.place, + }); + + @override + Widget build(BuildContext context) { + return ProviderScope( + overrides: [ + timelineServiceProvider.overrideWith( + (ref) { + final timelineService = + ref.watch(timelineFactoryProvider).place(place); + ref.onDispose(timelineService.dispose); + return timelineService; + }, + ), + ], + child: Timeline( + appBar: MesmerizingSliverAppBar( + title: place, + icon: Icons.location_on, + ), + ), + ); + } +} diff --git a/mobile/lib/providers/infrastructure/asset.provider.dart b/mobile/lib/providers/infrastructure/asset.provider.dart index 0015986243..102e6aa60c 100644 --- a/mobile/lib/providers/infrastructure/asset.provider.dart +++ b/mobile/lib/providers/infrastructure/asset.provider.dart @@ -18,3 +18,10 @@ final assetServiceProvider = Provider( localAssetRepository: ref.watch(localAssetRepository), ), ); + +final placesProvider = FutureProvider>( + (ref) => AssetService( + remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider), + localAssetRepository: ref.watch(localAssetRepository), + ).getPlaces(), +); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 5fcd060b0c..7cd628606b 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -72,6 +72,8 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart'; import 'package:immich_mobile/presentation/pages/drift_favorite.page.dart'; import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_place.page.dart'; +import 'package:immich_mobile/presentation/pages/drift_place_detail.page.dart'; import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'; import 'package:immich_mobile/presentation/pages/drift_video.page.dart'; import 'package:immich_mobile/presentation/pages/drift_trash.page.dart'; @@ -453,7 +455,14 @@ class AppRouter extends RootStackRouter { page: DriftCreateAlbumRoute.page, guards: [_authGuard, _duplicateGuard], ), - + AutoRoute( + page: DriftPlaceRoute.page, + guards: [_authGuard, _duplicateGuard], + ), + AutoRoute( + page: DriftPlaceDetailRoute.page, + guards: [_authGuard, _duplicateGuard], + ), // required to handle all deeplinks in deep_link.service.dart // auto_route_library#1722 RedirectRoute(path: '*', redirectTo: '/'), diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index bd2b148455..e4719697c2 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -853,6 +853,85 @@ class DriftPartnerDetailRouteArgs { } } +/// generated route for +/// [DriftPlaceDetailPage] +class DriftPlaceDetailRoute extends PageRouteInfo { + DriftPlaceDetailRoute({ + Key? key, + required String place, + List? children, + }) : super( + DriftPlaceDetailRoute.name, + args: DriftPlaceDetailRouteArgs(key: key, place: place), + initialChildren: children, + ); + + static const String name = 'DriftPlaceDetailRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs(); + return DriftPlaceDetailPage(key: args.key, place: args.place); + }, + ); +} + +class DriftPlaceDetailRouteArgs { + const DriftPlaceDetailRouteArgs({this.key, required this.place}); + + final Key? key; + + final String place; + + @override + String toString() { + return 'DriftPlaceDetailRouteArgs{key: $key, place: $place}'; + } +} + +/// generated route for +/// [DriftPlacePage] +class DriftPlaceRoute extends PageRouteInfo { + DriftPlaceRoute({ + Key? key, + LatLng? currentLocation, + List? children, + }) : super( + DriftPlaceRoute.name, + args: DriftPlaceRouteArgs(key: key, currentLocation: currentLocation), + initialChildren: children, + ); + + static const String name = 'DriftPlaceRoute'; + + static PageInfo page = PageInfo( + name, + builder: (data) { + final args = data.argsAs( + orElse: () => const DriftPlaceRouteArgs(), + ); + return DriftPlacePage( + key: args.key, + currentLocation: args.currentLocation, + ); + }, + ); +} + +class DriftPlaceRouteArgs { + const DriftPlaceRouteArgs({this.key, this.currentLocation}); + + final Key? key; + + final LatLng? currentLocation; + + @override + String toString() { + return 'DriftPlaceRouteArgs{key: $key, currentLocation: $currentLocation}'; + } +} + /// generated route for /// [DriftRecentlyTakenPage] class DriftRecentlyTakenRoute extends PageRouteInfo { diff --git a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart index 36f944dbcd..faaccfa51a 100644 --- a/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart +++ b/mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart @@ -482,10 +482,10 @@ class _RandomAssetBackgroundState extends State<_RandomAssetBackground> builder: (context, child) { return Transform.scale( scale: _zoomAnimation.value, - filterQuality: FilterQuality.low, + filterQuality: Platform.isAndroid ? FilterQuality.low : null, child: Transform.translate( offset: _panAnimation.value, - filterQuality: FilterQuality.low, + filterQuality: Platform.isAndroid ? FilterQuality.low : null, child: Stack( fit: StackFit.expand, children: [