feat(mobile): drift place page (#19914)

* feat(mobile): drift place page

* merge main

* feat(mobile): drift place detail page (#19915)

---------

Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
Daimolean 2025-07-15 23:10:12 +08:00 committed by GitHub
parent 59e7754bdc
commit ba262fbaa8
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 453 additions and 15 deletions

View File

@ -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 = "<group>";
};
@ -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";

View File

@ -26,6 +26,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
shouldUseLaunchSchemeArgsEnv = "YES">
<MacroExpansion>
<BuildableReference
@ -43,6 +44,7 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
customLLDBInitFile = "$(SRCROOT)/Flutter/ephemeral/flutter_lldbinit"
disableMainThreadChecker = "YES"
launchStyle = "0"
useCustomWorkingDirectory = "NO"

View File

@ -61,4 +61,8 @@ class AssetService {
return 1.0;
}
Future<List<(String, String)>> getPlaces() {
return _remoteAssetRepository.getPlaces();
}
}

View File

@ -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 {

View File

@ -56,6 +56,42 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
.getSingleOrNull();
}
Future<List<(String, String)>> 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<void> updateFavorite(List<String> ids, bool isFavorite) {
return _db.batch((batch) async {
for (final id in ids) {

View File

@ -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<List<Bucket>> _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<List<BaseAsset>> _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<bool> Function($RemoteAssetEntityTable row) filter,
GroupAssetsBy groupBy = GroupAssetsBy.day,

View File

@ -256,9 +256,7 @@ class _PlacesCollectionCard extends StatelessWidget {
return GestureDetector(
onTap: () => context.pushRoute(
PlacesCollectionRoute(
currentLocation: null,
),
DriftPlaceRoute(currentLocation: null),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,

View File

@ -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<String?> 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<String?> 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<String?> 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<String?> 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,
),
),
);
}
}

View File

@ -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,
),
),
);
}
}

View File

@ -18,3 +18,10 @@ final assetServiceProvider = Provider(
localAssetRepository: ref.watch(localAssetRepository),
),
);
final placesProvider = FutureProvider<List<(String, String)>>(
(ref) => AssetService(
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
).getPlaces(),
);

View File

@ -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: '/'),

View File

@ -853,6 +853,85 @@ class DriftPartnerDetailRouteArgs {
}
}
/// generated route for
/// [DriftPlaceDetailPage]
class DriftPlaceDetailRoute extends PageRouteInfo<DriftPlaceDetailRouteArgs> {
DriftPlaceDetailRoute({
Key? key,
required String place,
List<PageRouteInfo>? 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<DriftPlaceDetailRouteArgs>();
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<DriftPlaceRouteArgs> {
DriftPlaceRoute({
Key? key,
LatLng? currentLocation,
List<PageRouteInfo>? 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<DriftPlaceRouteArgs>(
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<void> {

View File

@ -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: [