1
0
mirror of https://github.com/immich-app/immich.git synced 2025-10-31 00:18:28 +02:00

feat(mobile): drift people page

This commit is contained in:
wuzihao051119
2025-07-11 21:12:18 +08:00
parent b346318d05
commit f5a165f540
13 changed files with 293 additions and 199 deletions

View File

@@ -0,0 +1,12 @@
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/infrastructure/repositories/person.repository.dart';
class PersonService {
final DriftPersonRepository _repository;
const PersonService(this._repository);
Future<List<Person>> getAll(String userId) {
return _repository.getAll(userId);
}
}

View File

@@ -9,7 +9,7 @@ class DriftPersonRepository extends DriftDatabaseRepository {
Future<List<Person>> getAll(String userId) {
final query = _db.personEntity.select()
..where((e) => e.ownerId.equals(userId));
..where((row) => row.ownerId.equals(userId) & row.isHidden.equals(false));
return query.map((person) {
return person.toDto();

View File

@@ -1,32 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/providers/search/all_motion_photos.provider.dart';
@RoutePage()
class AllMotionPhotosPage extends HookConsumerWidget {
const AllMotionPhotosPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final motionPhotos = ref.watch(allMotionPhotosProvider);
return Scaffold(
appBar: AppBar(
title: const Text('search_page_motion_photos').tr(),
leading: IconButton(
onPressed: () => context.maybePop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: motionPhotos.widgetWhen(
onData: (assets) => ImmichAssetGrid(
assets: assets,
),
),
);
}
}

View File

@@ -1,38 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/widgets/search/explore_grid.dart';
@RoutePage()
class AllPeoplePage extends HookConsumerWidget {
const AllPeoplePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final curatedPeople = ref.watch(getAllPeopleProvider);
return Scaffold(
appBar: AppBar(
title: const Text(
'people',
).tr(),
leading: IconButton(
onPressed: () => context.maybePop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: curatedPeople.widgetWhen(
onData: (people) => ExploreGrid(
isPeople: true,
curatedContent: people
.map((e) => SearchCuratedContent(label: e.name, id: e.id))
.toList(),
),
),
);
}
}

View File

@@ -1,36 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
import 'package:immich_mobile/widgets/search/explore_grid.dart';
@RoutePage()
class AllPlacesPage extends HookConsumerWidget {
const AllPlacesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<List<SearchCuratedContent>> places =
ref.watch(getAllPlacesProvider);
return Scaffold(
appBar: AppBar(
title: const Text(
'places',
).tr(),
leading: IconButton(
onPressed: () => context.maybePop(),
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: places.widgetWhen(
onData: (data) => ExploreGrid(
curatedContent: data,
),
),
);
}
}

View File

@@ -8,7 +8,6 @@ import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/local_album_sliver_app_bar.dart';
@RoutePage()
class DriftLocalAlbumsPage extends StatelessWidget {
@@ -19,7 +18,7 @@ class DriftLocalAlbumsPage extends StatelessWidget {
return const Scaffold(
body: CustomScrollView(
slivers: [
LocalAlbumsSliverAppBar(),
_LocalAlbumsSliverAppBar(),
_AlbumList(),
],
),
@@ -27,6 +26,28 @@ class DriftLocalAlbumsPage extends StatelessWidget {
}
}
class _LocalAlbumsSliverAppBar extends StatelessWidget {
const _LocalAlbumsSliverAppBar();
@override
Widget build(BuildContext context) {
return SliverAppBar(
floating: true,
pinned: true,
snap: false,
backgroundColor: context.colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(5)),
),
automaticallyImplyLeading: true,
centerTitle: true,
title: Text(
"on_this_device".t(context: context),
),
);
}
}
class _AlbumList extends ConsumerWidget {
const _AlbumList();

View File

@@ -17,7 +17,7 @@ class DriftVideoPage extends StatelessWidget {
(ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to video');
throw Exception('User must be logged in to access video');
}
final timelineService =

View File

@@ -182,7 +182,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const PeopleCollectionRoute()),
onTap: () => context.pushRoute(const DriftPeopleRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@@ -0,0 +1,222 @@
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/providers/infrastructure/person.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
@RoutePage()
class DriftPeoplePage extends StatelessWidget {
const DriftPeoplePage({super.key});
@override
Widget build(BuildContext context) {
final ValueNotifier<String?> search = ValueNotifier(null);
return Scaffold(
body: LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final isPortrait = context.orientation == Orientation.portrait;
return CustomScrollView(
slivers: [
_PeopleSliverAppBar(search: search),
_PeopleGrid(
search: search,
isTablet: isTablet,
isPortrait: isPortrait,
),
],
);
},
),
);
}
}
class _PeopleSliverAppBar extends StatelessWidget {
const _PeopleSliverAppBar({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_people'.t(context: context),
autofocus: true,
)
: Text('people'.t(context: context)),
actions: [
IconButton(
icon: Icon(search.value != null ? Icons.close : Icons.search),
onPressed: () {
search.value = search.value == null ? '' : null;
},
),
],
);
}
}
class _PeopleGrid extends ConsumerWidget {
const _PeopleGrid({
required this.search,
required this.isTablet,
required this.isPortrait,
});
final ValueNotifier<String?> search;
final bool isTablet;
final bool isPortrait;
@override
Widget build(BuildContext context, WidgetRef ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access people');
}
final people = ref.watch(personProvider(user.id));
// TODO: migrate to new modal widget and update name in SQLite
showNameEditModel(
String personId,
String personName,
) {
return showDialog(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return PersonNameEditForm(personId: personId, personName: personName);
},
);
}
return SliverSafeArea(
sliver: people.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 people: $error, stack: $stack',
style: TextStyle(
color: context.colorScheme.error,
),
),
),
),
),
data: (people) {
if (search.value != null) {
people = people
.where(
(person) => person.name
.toLowerCase()
.contains(search.value!.toLowerCase()),
)
.toList();
}
return SliverGrid.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isTablet ? 6 : 3,
childAspectRatio: 0.85,
mainAxisSpacing: isPortrait && isTablet ? 36 : 0,
),
itemCount: people.length,
itemBuilder: (context, index) {
final person = people[index];
return Padding(
padding: const EdgeInsets.symmetric(vertical: 32),
child: Column(
children: [
GestureDetector(
onTap: () {
context.pushRoute(
// TODO: migrate to drift after face sync
PersonResultRoute(
personId: person.id,
personName: person.name,
),
);
},
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: isTablet ? 120 / 2 : 96 / 2,
// TODO: migrate to face asset id after face sync
backgroundImage: NetworkImage(
getFaceThumbnailUrl(person.id),
headers: ApiService.getRequestHeaders(),
),
),
),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () => showNameEditModel(person.id, person.name),
child: person.name.isEmpty
? Text(
'add_a_name'.t(context: context),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.primary,
),
)
: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16.0),
child: Text(
person.name,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w500,
),
),
),
),
],
),
);
},
);
},
),
);
}
}

View File

@@ -1,7 +1,18 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/services/person.service.dart';
import 'package:immich_mobile/infrastructure/repositories/person.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final driftPersonProvider = Provider<DriftPersonRepository>(
final driftPersonRepository = Provider<DriftPersonRepository>(
(ref) => DriftPersonRepository(ref.watch(driftProvider)),
);
final driftPersonServiceProvider = Provider<PersonService>(
(ref) => PersonService(ref.watch(driftPersonRepository)),
);
final personProvider = FutureProvider.family<List<Person>, String>(
(ref, userId) =>
PersonService(ref.watch(driftPersonRepository)).getAll(userId),
);

View File

@@ -59,9 +59,6 @@ import 'package:immich_mobile/pages/login/login.page.dart';
import 'package:immich_mobile/pages/onboarding/permission_onboarding.page.dart';
import 'package:immich_mobile/pages/photos/memory.page.dart';
import 'package:immich_mobile/pages/photos/photos.page.dart';
import 'package:immich_mobile/pages/search/all_motion_videos.page.dart';
import 'package:immich_mobile/pages/search/all_people.page.dart';
import 'package:immich_mobile/pages/search/all_places.page.dart';
import 'package:immich_mobile/pages/search/all_videos.page.dart';
import 'package:immich_mobile/pages/search/map/map.page.dart';
import 'package:immich_mobile/pages/search/map/map_location_picker.page.dart';
@@ -87,6 +84,7 @@ import 'package:immich_mobile/presentation/pages/drift_library.page.dart';
import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart';
import 'package:immich_mobile/presentation/pages/drift_create_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/pages/drift_people.page.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
import 'package:immich_mobile/providers/api.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
@@ -210,10 +208,6 @@ class AppRouter extends RootStackRouter {
page: BackupControllerRoute.page,
guards: [_authGuard, _duplicateGuard, _backupPermissionGuard],
),
AutoRoute(
page: AllPlacesRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: CreateAlbumRoute.page,
guards: [_authGuard, _duplicateGuard],
@@ -226,11 +220,6 @@ class AppRouter extends RootStackRouter {
guards: [_authGuard, _duplicateGuard],
transitionsBuilder: TransitionsBuilders.slideLeft,
),
AutoRoute(page: AllVideosRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(
page: AllMotionPhotosRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: RecentlyTakenRoute.page,
guards: [_authGuard, _duplicateGuard],
@@ -294,7 +283,6 @@ class AppRouter extends RootStackRouter {
page: PersonResultRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(page: AllPeopleRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: MemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: MapRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(
@@ -453,7 +441,10 @@ class AppRouter extends RootStackRouter {
page: DriftCreateAlbumRoute.page,
guards: [_authGuard, _duplicateGuard],
),
AutoRoute(
page: DriftPeopleRoute.page,
guards: [_authGuard, _duplicateGuard],
),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),

View File

@@ -270,54 +270,6 @@ class AlbumsRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [AllMotionPhotosPage]
class AllMotionPhotosRoute extends PageRouteInfo<void> {
const AllMotionPhotosRoute({List<PageRouteInfo>? children})
: super(AllMotionPhotosRoute.name, initialChildren: children);
static const String name = 'AllMotionPhotosRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const AllMotionPhotosPage();
},
);
}
/// generated route for
/// [AllPeoplePage]
class AllPeopleRoute extends PageRouteInfo<void> {
const AllPeopleRoute({List<PageRouteInfo>? children})
: super(AllPeopleRoute.name, initialChildren: children);
static const String name = 'AllPeopleRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const AllPeoplePage();
},
);
}
/// generated route for
/// [AllPlacesPage]
class AllPlacesRoute extends PageRouteInfo<void> {
const AllPlacesRoute({List<PageRouteInfo>? children})
: super(AllPlacesRoute.name, initialChildren: children);
static const String name = 'AllPlacesRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const AllPlacesPage();
},
);
}
/// generated route for
/// [AllVideosPage]
class AllVideosRoute extends PageRouteInfo<void> {
@@ -853,6 +805,22 @@ class DriftPartnerDetailRouteArgs {
}
}
/// generated route for
/// [DriftPeoplePage]
class DriftPeopleRoute extends PageRouteInfo<void> {
const DriftPeopleRoute({List<PageRouteInfo>? children})
: super(DriftPeopleRoute.name, initialChildren: children);
static const String name = 'DriftPeopleRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftPeoplePage();
},
);
}
/// generated route for
/// [DriftRecentlyTakenPage]
class DriftRecentlyTakenRoute extends PageRouteInfo<void> {

View File

@@ -1,25 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class LocalAlbumsSliverAppBar extends StatelessWidget {
const LocalAlbumsSliverAppBar({super.key});
@override
Widget build(BuildContext context) {
return SliverAppBar(
floating: true,
pinned: true,
snap: false,
backgroundColor: context.colorScheme.surfaceContainer,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(5)),
),
automaticallyImplyLeading: true,
centerTitle: true,
title: Text(
"on_this_device".t(context: context),
),
);
}
}