feat: people page/sheet/detail (#20309)

This commit is contained in:
Alex 2025-07-29 22:07:53 -05:00 committed by GitHub
parent 268b411a6f
commit 29f16c6a47
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
34 changed files with 1562 additions and 97 deletions

View File

@ -14,6 +14,7 @@
"add_a_location": "Add a location",
"add_a_name": "Add a name",
"add_a_title": "Add a title",
"add_birthday": "Add a birthday",
"add_endpoint": "Add endpoint",
"add_exclusion_pattern": "Add exclusion pattern",
"add_import_path": "Add import path",
@ -828,6 +829,7 @@
"edit": "Edit",
"edit_album": "Edit album",
"edit_avatar": "Edit avatar",
"edit_birthday": "Edit Birthday",
"edit_date": "Edit date",
"edit_date_and_time": "Edit date and time",
"edit_description": "Edit description",

View File

@ -91,7 +91,7 @@ class PersonDto {
}
// Model for a person stored in the server
class Person {
class DriftPerson {
final String id;
final DateTime createdAt;
final DateTime updatedAt;
@ -103,7 +103,7 @@ class Person {
final String? color;
final DateTime? birthDate;
const Person({
const DriftPerson({
required this.id,
required this.createdAt,
required this.updatedAt,
@ -116,7 +116,7 @@ class Person {
this.birthDate,
});
Person copyWith({
DriftPerson copyWith({
String? id,
DateTime? createdAt,
DateTime? updatedAt,
@ -128,7 +128,7 @@ class Person {
String? color,
DateTime? birthDate,
}) {
return Person(
return DriftPerson(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
updatedAt: updatedAt ?? this.updatedAt,
@ -159,7 +159,7 @@ class Person {
}
@override
bool operator ==(covariant Person other) {
bool operator ==(covariant DriftPerson other) {
if (identical(this, other)) return true;
return other.id == id &&

View File

@ -0,0 +1,30 @@
import 'dart:async';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/infrastructure/repositories/people.repository.dart';
import 'package:immich_mobile/repositories/person_api.repository.dart';
class DriftPeopleService {
final DriftPeopleRepository _repository;
final PersonApiRepository _personApiRepository;
const DriftPeopleService(this._repository, this._personApiRepository);
Future<List<DriftPerson>> getAssetPeople(String assetId) {
return _repository.getAssetPeople(assetId);
}
Future<List<DriftPerson>> getAllPeople() {
return _repository.getAllPeople();
}
Future<int> updateName(String personId, String name) async {
await _personApiRepository.update(personId, name: name);
return _repository.updateName(personId, name);
}
Future<int> updateBrithday(String personId, DateTime birthday) async {
await _personApiRepository.update(personId, birthday: birthday);
return _repository.updateBirthday(personId, birthday);
}
}

View File

@ -53,6 +53,9 @@ class TimelineFactory {
TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy));
TimelineService person(String userId, String personId) =>
TimelineService(_timelineRepository.person(userId, personId, groupBy));
TimelineService fromAssets(List<BaseAsset> assets) => TimelineService(_timelineRepository.fromAssets(assets));
}

View File

@ -1,30 +0,0 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/asset_face.model.dart';
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftAssetFaceRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftAssetFaceRepository(this._db) : super(_db);
Future<List<AssetFace>> getAll() {
return _db.assetFaceEntity.select().map((assetFace) => assetFace.toDto()).get();
}
}
extension on AssetFaceEntityData {
AssetFace toDto() {
return AssetFace(
id: id,
assetId: assetId,
personId: personId,
imageWidth: imageWidth,
imageHeight: imageHeight,
boundingBoxX1: boundingBoxX1,
boundingBoxY1: boundingBoxY1,
boundingBoxX2: boundingBoxX2,
boundingBoxY2: boundingBoxY2,
sourceType: sourceType,
);
}
}

View File

@ -0,0 +1,67 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftPeopleRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftPeopleRepository(this._db) : super(_db);
Future<List<DriftPerson>> getAssetPeople(String assetId) async {
final query = _db.select(_db.assetFaceEntity).join([
innerJoin(_db.personEntity, _db.personEntity.id.equalsExp(_db.assetFaceEntity.personId)),
])..where(_db.assetFaceEntity.assetId.equals(assetId) & _db.personEntity.isHidden.equals(false));
return query.map((row) {
final person = row.readTable(_db.personEntity);
return person.toDto();
}).get();
}
Future<List<DriftPerson>> getAllPeople() async {
final query =
_db.select(_db.personEntity).join([
leftOuterJoin(_db.assetFaceEntity, _db.assetFaceEntity.personId.equalsExp(_db.personEntity.id)),
])
..where(_db.personEntity.isHidden.equals(false))
..groupBy([_db.personEntity.id])
..orderBy([
OrderingTerm(expression: _db.personEntity.name.equals('').not(), mode: OrderingMode.desc),
OrderingTerm(expression: _db.assetFaceEntity.id.count(), mode: OrderingMode.desc),
]);
return query.map((row) {
final person = row.readTable(_db.personEntity);
return person.toDto();
}).get();
}
Future<int> updateName(String personId, String name) {
final query = _db.update(_db.personEntity)..where((row) => row.id.equals(personId));
return query.write(PersonEntityCompanion(name: Value(name), updatedAt: Value(DateTime.now())));
}
Future<int> updateBirthday(String personId, DateTime birthday) {
final query = _db.update(_db.personEntity)..where((row) => row.id.equals(personId));
return query.write(PersonEntityCompanion(birthDate: Value(birthday), updatedAt: Value(DateTime.now())));
}
}
extension on PersonEntityData {
DriftPerson toDto() {
return DriftPerson(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
name: name,
faceAssetId: faceAssetId,
isFavorite: isFavorite,
isHidden: isHidden,
color: color,
birthDate: birthDate,
);
}
}

View File

@ -1,34 +0,0 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftPersonRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftPersonRepository(this._db) : super(_db);
Future<List<Person>> getAll(String userId) {
final query = _db.personEntity.select()..where((e) => e.ownerId.equals(userId));
return query.map((person) {
return person.toDto();
}).get();
}
}
extension on PersonEntityData {
Person toDto() {
return Person(
id: id,
createdAt: createdAt,
updatedAt: updatedAt,
ownerId: ownerId,
name: name,
faceAssetId: faceAssetId,
isFavorite: isFavorite,
isHidden: isHidden,
color: color,
birthDate: birthDate,
);
}
}

View File

@ -292,6 +292,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
assetSource: (offset, count) => _getPlaceBucketAssets(place, offset: offset, count: count),
);
TimelineQuery person(String userId, String personId, GroupAssetsBy groupBy) => (
bucketSource: () => _watchPersonBucket(userId, personId, groupBy: groupBy),
assetSource: (offset, count) => _getPersonBucketAssets(userId, personId, offset: offset, count: count),
);
Stream<List<Bucket>> _watchPlaceBucket(String place, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
// TODO: implement GroupAssetBy for place
@ -344,6 +349,84 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
}
Stream<List<Bucket>> _watchPersonBucket(String userId, String personId, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([_db.remoteAssetEntity.id.count()])
..join([
innerJoin(
_db.assetFaceEntity,
_db.assetFaceEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..where(
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.assetFaceEntity.personId.equals(personId),
);
return query.map((row) {
final count = row.read(_db.remoteAssetEntity.id.count())!;
return _generateBuckets(count);
}).watchSingle();
}
final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..join([
innerJoin(
_db.assetFaceEntity,
_db.assetFaceEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..where(
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.assetFaceEntity.personId.equals(personId),
)
..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>> _getPersonBucketAssets(
String userId,
String personId, {
required int offset,
required int count,
}) {
final query =
_db.remoteAssetEntity.select().join([
innerJoin(
_db.assetFaceEntity,
_db.assetFaceEntity.assetId.equalsExp(_db.remoteAssetEntity.id),
useColumns: false,
),
])
..where(
_db.remoteAssetEntity.deletedAt.isNull() &
_db.remoteAssetEntity.ownerId.equals(userId) &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.assetFaceEntity.personId.equals(personId),
)
..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

@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/partner_user_avatar.widget.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';

View File

@ -6,10 +6,10 @@ import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
import 'package:immich_mobile/presentation/widgets/partner_user_avatar.widget.dart';
import 'package:immich_mobile/presentation/widgets/people/partner_user_avatar.widget.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/partner.provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
@ -144,7 +144,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
final people = ref.watch(driftGetAllPeopleProvider);
return LayoutBuilder(
builder: (context, constraints) {
@ -153,7 +153,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const PeopleCollectionRoute()),
onTap: () => context.pushRoute(const DriftPeopleCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [

View File

@ -0,0 +1,130 @@
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/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.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/utils/people.utils.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@RoutePage()
class DriftPeopleCollectionPage extends ConsumerStatefulWidget {
const DriftPeopleCollectionPage({super.key});
@override
ConsumerState<DriftPeopleCollectionPage> createState() => _DriftPeopleCollectionPageState();
}
class _DriftPeopleCollectionPageState extends ConsumerState<DriftPeopleCollectionPage> {
final FocusNode _formFocus = FocusNode();
String? _search;
@override
void dispose() {
_formFocus.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final people = ref.watch(driftGetAllPeopleProvider);
final headers = ApiService.getRequestHeaders();
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final isPortrait = context.orientation == Orientation.portrait;
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: _search == null,
title: _search != null
? SearchField(
focusNode: _formFocus,
onTapOutside: (_) => _formFocus.unfocus(),
onChanged: (value) => setState(() => _search = value),
filled: true,
hintText: 'filter_people'.tr(),
autofocus: true,
)
: Text('people'.tr()),
actions: [
IconButton(
icon: Icon(_search != null ? Icons.close : Icons.search),
onPressed: () {
setState(() => _search = _search == null ? '' : null);
},
),
],
),
body: SafeArea(
child: people.when(
data: (people) {
if (_search != null) {
people = people.where((person) {
return person.name.toLowerCase().contains(_search!.toLowerCase());
}).toList();
}
return GridView.builder(
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isTablet ? 6 : 3,
childAspectRatio: 0.85,
mainAxisSpacing: isPortrait && isTablet ? 36 : 0,
),
padding: const EdgeInsets.symmetric(vertical: 32),
itemCount: people.length,
itemBuilder: (context, index) {
final person = people[index];
return Column(
children: [
GestureDetector(
onTap: () {
context.pushRoute(DriftPersonRoute(person: person));
},
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: isTablet ? 100 / 2 : 96 / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
),
),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () => showNameEditModal(context, person),
child: person.name.isEmpty
? Text(
'add_a_name'.tr(),
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),
),
),
),
],
);
},
);
},
error: (error, stack) => const Text("error"),
loading: () => const Center(child: CircularProgressIndicator()),
),
),
);
},
);
}
}

View File

@ -0,0 +1,97 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/person_option_sheet.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/widgets/common/person_sliver_app_bar.dart';
@RoutePage()
class DriftPersonPage extends ConsumerStatefulWidget {
final DriftPerson person;
const DriftPersonPage({super.key, required this.person});
@override
ConsumerState<DriftPersonPage> createState() => _DriftPersonPageState();
}
class _DriftPersonPageState extends ConsumerState<DriftPersonPage> {
late DriftPerson _person;
@override
initState() {
super.initState();
_person = widget.person;
}
Future<void> handleEditName(BuildContext context) async {
final newName = await showNameEditModal(context, _person);
if (newName != null && newName.isNotEmpty) {
setState(() {
_person = _person.copyWith(name: newName);
});
}
}
Future<void> handleEditBirthday(BuildContext context) async {
final birthday = await showBirthdayEditModal(context, _person);
if (birthday != null) {
setState(() {
_person = _person.copyWith(birthDate: birthday);
});
}
}
void showOptionSheet(BuildContext context) {
showModalBottomSheet(
context: context,
backgroundColor: context.colorScheme.surface,
isScrollControlled: false,
builder: (context) {
return PersonOptionSheet(
onEditName: () async {
await handleEditName(context);
context.pop();
},
onEditBirthday: () async {
await handleEditBirthday(context);
context.pop();
},
);
},
);
}
@override
Widget build(BuildContext context) {
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to view person timeline');
}
final timelineService = ref.watch(timelineFactoryProvider).person(user.id, _person.id);
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: PersonSliverAppBar(
person: _person,
onNameTap: () => handleEditName(context),
onBirthdayTap: () => handleEditBirthday(context),
onShowOptions: () => showOptionSheet(context),
),
),
);
}
}

View File

@ -218,7 +218,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onPageBuild(PhotoViewControllerBase controller) {
viewController ??= controller;
if (showingBottomSheet) {
if (showingBottomSheet && bottomSheetController.isAttached) {
final verticalOffset =
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset);
@ -463,7 +463,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _snapBottomSheet() {
if (bottomSheetController.size > _kBottomSheetSnapExtent || bottomSheetController.size < 0.4) {
if (!bottomSheetController.isAttached ||
bottomSheetController.size > _kBottomSheetSnapExtent ||
bottomSheetController.size < 0.4) {
return;
}
isSnapping = true;

View File

@ -16,7 +16,8 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_b
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
@ -150,6 +151,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
),
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
const SheetPeopleDetails(),
const SheetLocationDetails(),
// Details header
_SheetTile(

View File

@ -0,0 +1,175 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class SheetPeopleDetails extends ConsumerStatefulWidget {
const SheetPeopleDetails({super.key});
@override
ConsumerState createState() => _SheetPeopleDetailsState();
}
class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
@override
Widget build(BuildContext context) {
final asset = ref.watch(currentAssetNotifier);
if (asset is! RemoteAsset) {
return const SizedBox.shrink();
}
final peopleFuture = ref.watch(driftPeopleAssetProvider(asset.id));
Future<void> showNameEditModal(DriftPerson person) async {
await showDialog(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return DriftPersonNameEditForm(person: person);
},
);
ref.invalidate(driftPeopleAssetProvider(asset.id));
}
return peopleFuture.when(
data: (people) {
return AnimatedCrossFade(
firstChild: const SizedBox.shrink(),
secondChild: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 16),
child: Text(
"people".t(context: context).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
),
),
SizedBox(
height: 150,
child: ListView(
padding: const EdgeInsets.only(left: 16.0),
scrollDirection: Axis.horizontal,
children: [
for (final person in people)
_PeopleAvatar(
person: person,
assetFileCreatedAt: asset.createdAt,
onTap: () {
final previousRouteData = ref.read(previousRouteDataProvider);
final previousRouteArgs = previousRouteData?.arguments;
// Prevent circular navigation
if (previousRouteArgs is DriftPersonRouteArgs && previousRouteArgs.person.id == person.id) {
context.back();
return;
}
context.back();
context.pushRoute(DriftPersonRoute(person: person));
},
onNameTap: () => showNameEditModal(person),
),
],
),
),
],
),
crossFadeState: people.isEmpty ? CrossFadeState.showFirst : CrossFadeState.showSecond,
duration: Durations.short4,
);
},
error: (error, stack) => Text("error_loading_people".t(context: context), style: context.textTheme.bodyMedium),
loading: () => const SizedBox.shrink(),
);
}
}
class _PeopleAvatar extends StatelessWidget {
final DriftPerson person;
final DateTime assetFileCreatedAt;
final VoidCallback? onTap;
final VoidCallback? onNameTap;
final double imageSize = 96;
const _PeopleAvatar({required this.person, required this.assetFileCreatedAt, this.onTap, this.onNameTap});
@override
Widget build(BuildContext context) {
final headers = ApiService.getRequestHeaders();
return ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 96),
child: Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: onTap,
child: SizedBox(
height: imageSize,
child: Material(
shape: CircleBorder(side: BorderSide(color: context.primaryColor.withAlpha(50), width: 1.0)),
shadowColor: context.colorScheme.shadow,
elevation: 3,
child: CircleAvatar(
maxRadius: imageSize / 2,
backgroundImage: NetworkImage(getFaceThumbnailUrl(person.id), headers: headers),
),
),
),
),
const SizedBox(height: 4),
if (person.name.isEmpty)
GestureDetector(
onTap: () => onNameTap?.call(),
child: Text(
"add_a_name".t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
),
)
else
Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
person.name,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
maxLines: 1,
),
if (person.birthDate != null)
Text(
formatAge(person.birthDate!, assetFileCreatedAt),
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium?.copyWith(
color: context.textTheme.bodyMedium?.color?.withAlpha(175),
),
),
],
),
],
),
),
);
}
}

View File

@ -0,0 +1,116 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:scroll_date_picker/scroll_date_picker.dart';
class DriftPersonBirthdayEditForm extends ConsumerStatefulWidget {
final DriftPerson person;
const DriftPersonBirthdayEditForm({super.key, required this.person});
@override
ConsumerState<DriftPersonBirthdayEditForm> createState() => _DriftPersonNameEditFormState();
}
class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonBirthdayEditForm> {
late DateTime _selectedDate;
@override
void initState() {
super.initState();
_selectedDate = widget.person.birthDate ?? DateTime.now();
}
void saveBirthday() async {
try {
final result = await ref.read(driftPeopleServiceProvider).updateBrithday(widget.person.id, _selectedDate);
if (result != 0) {
ref.invalidate(driftGetAllPeopleProvider);
context.pop<DateTime>(_selectedDate);
}
} catch (error) {
debugPrint('Error updating birthday: $error');
if (!context.mounted) {
return;
}
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Text(
"edit_birthday".t(context: context),
style: const TextStyle(fontWeight: FontWeight.bold),
),
content: SizedBox(
width: double.maxFinite,
height: 300,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: ScrollDatePicker(
options: DatePickerOptions(
backgroundColor: context.colorScheme.surfaceContainerHigh,
itemExtent: 50,
diameterRatio: 5,
),
scrollViewOptions: DatePickerScrollViewOptions(
day: ScrollViewDetailOptions(
margin: const EdgeInsets.all(12),
selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
month: ScrollViewDetailOptions(
margin: const EdgeInsets.all(12),
selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
year: ScrollViewDetailOptions(
margin: const EdgeInsets.all(12),
selectedTextStyle: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
),
),
selectedDate: _selectedDate,
locale: context.locale,
minimumDate: DateTime(1800, 1, 1),
onDateTimeChanged: (DateTime value) {
setState(() {
_selectedDate = value;
});
},
),
),
),
actions: [
TextButton(
onPressed: () => context.pop(null),
child: Text(
"cancel",
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () => saveBirthday(),
child: Text(
"save",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}

View File

@ -0,0 +1,82 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/people.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class DriftPersonNameEditForm extends ConsumerStatefulWidget {
final DriftPerson person;
const DriftPersonNameEditForm({super.key, required this.person});
@override
ConsumerState<DriftPersonNameEditForm> createState() => _DriftPersonNameEditFormState();
}
class _DriftPersonNameEditFormState extends ConsumerState<DriftPersonNameEditForm> {
late TextEditingController _formController;
@override
void initState() {
super.initState();
_formController = TextEditingController(text: widget.person.name);
}
void onEdit(String personId, String newName) async {
try {
final result = await ref.read(driftPeopleServiceProvider).updateName(personId, newName);
if (result != 0) {
ref.invalidate(driftGetAllPeopleProvider);
context.pop<String>(newName);
}
} catch (error) {
debugPrint('Error updating name: $error');
if (!context.mounted) {
return;
}
ImmichToast.show(
context: context,
msg: 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text("edit_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
content: SingleChildScrollView(
child: TextFormField(
controller: _formController,
textCapitalization: TextCapitalization.words,
autofocus: true,
decoration: InputDecoration(hintText: 'name'.tr(), border: const OutlineInputBorder()),
),
),
actions: [
TextButton(
onPressed: () => context.pop(null),
child: Text(
"cancel",
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () => onEdit(widget.person.id, _formController.text),
child: Text(
"save",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}

View File

@ -0,0 +1,36 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class PersonOptionSheet extends ConsumerWidget {
const PersonOptionSheet({super.key, this.onEditName, this.onEditBirthday});
final VoidCallback? onEditName;
final VoidCallback? onEditBirthday;
@override
Widget build(BuildContext context, WidgetRef ref) {
TextStyle textStyle = Theme.of(context).textTheme.bodyLarge!.copyWith(fontWeight: FontWeight.w600);
return SafeArea(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24.0),
child: ListView(
shrinkWrap: true,
children: [
ListTile(
leading: const Icon(Icons.edit),
title: Text('edit_name'.t(context: context), style: textStyle),
onTap: onEditName,
),
ListTile(
leading: const Icon(Icons.cake),
title: Text('edit_birthday'.t(context: context), style: textStyle),
onTap: onEditBirthday,
),
],
),
),
);
}
}

View File

@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
@ -99,7 +100,7 @@ class _FixedSegmentRow extends ConsumerWidget {
}
if (timelineService.hasRange(assetIndex, assetCount)) {
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount));
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
}
return FutureBuilder<List<BaseAsset>>(
@ -108,7 +109,7 @@ class _FixedSegmentRow extends ConsumerWidget {
if (snapshot.connectionState != ConnectionState.done) {
return _buildPlaceholder(context);
}
return _buildAssetRow(context, snapshot.requireData);
return _buildAssetRow(context, snapshot.requireData, timelineService);
},
);
}
@ -117,14 +118,18 @@ class _FixedSegmentRow extends ConsumerWidget {
return SegmentBuilder.buildPlaceholder(context, assetCount, size: Size.square(tileHeight), spacing: spacing);
}
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets) {
Widget _buildAssetRow(BuildContext context, List<BaseAsset> assets, TimelineService timelineService) {
return FixedTimelineRow(
dimension: tileHeight,
spacing: spacing,
textDirection: Directionality.of(context),
children: [
for (int i = 0; i < assets.length; i++)
_AssetTileWidget(key: ValueKey(assets[i].heroTag), asset: assets[i], assetIndex: assetIndex + i),
_AssetTileWidget(
key: ValueKey(Object.hash(assets[i].heroTag, assetIndex + i, timelineService.hashCode)),
asset: assets[i],
assetIndex: assetIndex + i,
),
],
);
}

View File

@ -1,7 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/asset_face.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final driftAssetFaceProvider = Provider<DriftAssetFaceRepository>(
(ref) => DriftAssetFaceRepository(ref.watch(driftProvider)),
);

View File

@ -0,0 +1,24 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/services/people.service.dart';
import 'package:immich_mobile/infrastructure/repositories/people.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/repositories/person_api.repository.dart';
final driftPeopleRepositoryProvider = Provider<DriftPeopleRepository>(
(ref) => DriftPeopleRepository(ref.watch(driftProvider)),
);
final driftPeopleServiceProvider = Provider<DriftPeopleService>(
(ref) => DriftPeopleService(ref.watch(driftPeopleRepositoryProvider), ref.watch(personApiRepositoryProvider)),
);
final driftPeopleAssetProvider = FutureProvider.family<List<DriftPerson>, String>((ref, assetId) async {
final service = ref.watch(driftPeopleServiceProvider);
return service.getAssetPeople(assetId);
});
final driftGetAllPeopleProvider = FutureProvider<List<DriftPerson>>((ref) async {
final service = ref.watch(driftPeopleServiceProvider);
return service.getAllPeople();
});

View File

@ -1,5 +0,0 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/infrastructure/repositories/person.repository.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
final driftPersonProvider = Provider<DriftPersonRepository>((ref) => DriftPersonRepository(ref.watch(driftProvider)));

View File

@ -1,5 +1,7 @@
import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
final inLockedViewProvider = StateProvider<bool>((ref) => false);
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);

View File

@ -16,8 +16,8 @@ class PersonApiRepository extends ApiRepository {
return dto.people.map(_toPerson).toList();
}
Future<PersonDto> update(String id, {String? name}) async {
final dto = await checkNull(_api.updatePerson(id, PersonUpdateDto(name: name)));
Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async {
final dto = await checkNull(_api.updatePerson(id, PersonUpdateDto(name: name, birthDate: birthday)));
return _toPerson(dto);
}

View File

@ -22,6 +22,7 @@ class AppNavigationObserver extends AutoRouterObserver {
Future(() {
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
});
}

View File

@ -6,6 +6,7 @@ import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/entities/album.entity.dart';
@ -88,6 +89,8 @@ import 'package:immich_mobile/presentation/pages/drift_local_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_locked_folder.page.dart';
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
import 'package:immich_mobile/presentation/pages/drift_partner_detail.page.dart';
import 'package:immich_mobile/presentation/pages/drift_people_collection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_person.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';
@ -323,6 +326,9 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftPartnerRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftUploadDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: BetaSyncSettingsRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPeopleCollectionRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftPersonRoute.page, guards: [_authGuard]),
// required to handle all deeplinks in deep_link.service.dart
// auto_route_library#1722
RedirectRoute(path: '*', redirectTo: '/'),

View File

@ -966,6 +966,59 @@ class DriftPartnerRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftPeopleCollectionPage]
class DriftPeopleCollectionRoute extends PageRouteInfo<void> {
const DriftPeopleCollectionRoute({List<PageRouteInfo>? children})
: super(DriftPeopleCollectionRoute.name, initialChildren: children);
static const String name = 'DriftPeopleCollectionRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftPeopleCollectionPage();
},
);
}
/// generated route for
/// [DriftPersonPage]
class DriftPersonRoute extends PageRouteInfo<DriftPersonRouteArgs> {
DriftPersonRoute({
Key? key,
required DriftPerson person,
List<PageRouteInfo>? children,
}) : super(
DriftPersonRoute.name,
args: DriftPersonRouteArgs(key: key, person: person),
initialChildren: children,
);
static const String name = 'DriftPersonRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
final args = data.argsAs<DriftPersonRouteArgs>();
return DriftPersonPage(key: args.key, person: args.person);
},
);
}
class DriftPersonRouteArgs {
const DriftPersonRouteArgs({this.key, required this.person});
final Key? key;
final DriftPerson person;
@override
String toString() {
return 'DriftPersonRouteArgs{key: $key, person: $person}';
}
}
/// generated route for
/// [DriftPlaceDetailPage]
class DriftPlaceDetailRoute extends PageRouteInfo<DriftPlaceDetailRouteArgs> {

View File

@ -0,0 +1,54 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_birthday_modal.widget.dart';
import 'package:immich_mobile/presentation/widgets/people/person_edit_name_modal.widget.dart';
String formatAge(DateTime birthDate, DateTime referenceDate) {
int ageInYears = _calculateAge(birthDate, referenceDate);
int ageInMonths = _calculateAgeInMonths(birthDate, referenceDate);
if (ageInMonths <= 11) {
return "exif_bottom_sheet_person_age_months".t(args: {'months': ageInMonths.toString()});
} else if (ageInMonths > 12 && ageInMonths <= 23) {
return "exif_bottom_sheet_person_age_year_months".t(args: {'months': (ageInMonths - 12).toString()});
} else {
return "exif_bottom_sheet_person_age_years".t(args: {'years': ageInYears.toString()});
}
}
int _calculateAge(DateTime birthDate, DateTime referenceDate) {
int age = referenceDate.year - birthDate.year;
if (referenceDate.month < birthDate.month ||
(referenceDate.month == birthDate.month && referenceDate.day < birthDate.day)) {
age--;
}
return age;
}
int _calculateAgeInMonths(DateTime birthDate, DateTime referenceDate) {
return (referenceDate.year - birthDate.year) * 12 +
referenceDate.month -
birthDate.month -
(referenceDate.day < birthDate.day ? 1 : 0);
}
Future<String?> showNameEditModal(BuildContext context, DriftPerson person) {
return showDialog<String?>(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return DriftPersonNameEditForm(person: person);
},
);
}
Future<DateTime?> showBirthdayEditModal(BuildContext context, DriftPerson person) {
return showDialog<DateTime?>(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return DriftPersonBirthdayEditForm(person: person);
},
);
}

View File

@ -0,0 +1,562 @@
import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class PersonSliverAppBar extends ConsumerStatefulWidget {
const PersonSliverAppBar({
super.key,
required this.person,
required this.onNameTap,
required this.onShowOptions,
required this.onBirthdayTap,
});
final DriftPerson person;
final VoidCallback onNameTap;
final VoidCallback onBirthdayTap;
final VoidCallback onShowOptions;
@override
ConsumerState<PersonSliverAppBar> createState() => _MesmerizingSliverAppBarState();
}
class _MesmerizingSliverAppBarState extends ConsumerState<PersonSliverAppBar> {
double _scrollProgress = 0.0;
double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) {
if (settings?.maxExtent == null || settings?.minExtent == null) {
return 1.0;
}
final deltaExtent = settings!.maxExtent - settings.minExtent;
if (deltaExtent <= 0.0) {
return 1.0;
}
return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent).clamp(0.0, 1.0);
}
@override
Widget build(BuildContext context) {
final isMultiSelectEnabled = ref.watch(multiSelectProvider.select((s) => s.isEnabled));
Color? actionIconColor = Color.lerp(Colors.white, context.primaryColor, _scrollProgress);
List<Shadow> actionIconShadows = [
if (_scrollProgress < 0.95)
Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
else
const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
];
return isMultiSelectEnabled
? SliverToBoxAdapter(
child: switch (_scrollProgress) {
< 0.8 => const SizedBox(height: 120),
_ => const SizedBox(height: 352),
},
)
: SliverAppBar(
expandedHeight: 300.0,
floating: false,
pinned: true,
snap: false,
elevation: 0,
leading: IconButton(
icon: Icon(
Platform.isIOS ? Icons.arrow_back_ios_new_rounded : Icons.arrow_back,
color: Color.lerp(Colors.white, context.primaryColor, _scrollProgress),
shadows: [
_scrollProgress < 0.95
? Shadow(offset: const Offset(0, 2), blurRadius: 5, color: Colors.black.withValues(alpha: 0.5))
: const Shadow(offset: Offset(0, 2), blurRadius: 0, color: Colors.transparent),
],
),
onPressed: () {
context.pop();
},
),
actions: [
IconButton(
icon: Icon(Icons.more_vert, color: actionIconColor, shadows: actionIconShadows),
onPressed: widget.onShowOptions,
),
],
flexibleSpace: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
// Update scroll progress for the leading button
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _scrollProgress != scrollProgress) {
setState(() {
_scrollProgress = scrollProgress;
});
}
});
return FlexibleSpaceBar(
centerTitle: true,
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: scrollProgress > 0.95
? Text(
widget.person.name,
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.w600, fontSize: 18),
)
: null,
),
background: _ExpandedBackground(
scrollProgress: scrollProgress,
person: widget.person,
onNameTap: widget.onNameTap,
onBirthdayTap: widget.onBirthdayTap,
),
);
},
),
);
}
}
class _ExpandedBackground extends ConsumerStatefulWidget {
final double scrollProgress;
final DriftPerson person;
final VoidCallback onNameTap;
final VoidCallback onBirthdayTap;
const _ExpandedBackground({
required this.scrollProgress,
required this.person,
required this.onNameTap,
required this.onBirthdayTap,
});
@override
ConsumerState<_ExpandedBackground> createState() => _ExpandedBackgroundState();
}
class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground> with SingleTickerProviderStateMixin {
late AnimationController _slideController;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_slideController = AnimationController(duration: const Duration(milliseconds: 800), vsync: this);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1.5),
end: Offset.zero,
).animate(CurvedAnimation(parent: _slideController, curve: Curves.easeOutCubic));
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
_slideController.forward();
}
});
}
@override
void dispose() {
_slideController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final timelineService = ref.watch(timelineServiceProvider);
return Stack(
fit: StackFit.expand,
children: [
Transform.translate(
offset: Offset(0, widget.scrollProgress * 50),
child: Transform.scale(
scale: 1.4 - (widget.scrollProgress * 0.2),
child: _RandomAssetBackground(timelineService: timelineService),
),
),
ClipRect(
child: BackdropFilter(
filter: ImageFilter.blur(sigmaX: widget.scrollProgress * 2.0, sigmaY: widget.scrollProgress * 2.0),
child: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.black.withValues(alpha: 0.05),
Colors.transparent,
Colors.black.withValues(alpha: 0.3),
Colors.black.withValues(alpha: 0.6 + (widget.scrollProgress * 0.25)),
],
stops: const [0.0, 0.15, 0.55, 1.0],
),
),
),
),
),
Positioned(
bottom: 16,
left: 16,
right: 16,
child: SlideTransition(
position: _slideAnimation,
child: Row(
children: [
SizedBox(
height: 84,
width: 84,
child: Material(
shape: const CircleBorder(side: BorderSide(color: Colors.grey, width: 1.0)),
elevation: 3,
child: CircleAvatar(
maxRadius: 84 / 2,
backgroundImage: NetworkImage(
getFaceThumbnailUrl(widget.person.id),
headers: ApiService.getRequestHeaders(),
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
GestureDetector(
onTap: () => widget.onNameTap.call(),
child: SizedBox(
width: double.infinity,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: widget.person.name.isNotEmpty
? Text(
widget.person.name,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontSize: 36,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
shadows: [Shadow(offset: Offset(0, 2), blurRadius: 12, color: Colors.black45)],
),
)
: Text(
'add_a_name'.tr(),
style: context.textTheme.titleLarge?.copyWith(
color: Colors.grey[400],
fontSize: 36,
decoration: TextDecoration.underline,
decorationColor: Colors.white,
),
),
),
),
),
AnimatedContainer(duration: const Duration(milliseconds: 300), child: const _ItemCountText()),
const SizedBox(height: 8),
GestureDetector(
onTap: widget.onBirthdayTap,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.cake_rounded, color: Colors.white, size: 14),
const SizedBox(width: 4),
if (widget.person.birthDate != null)
Text(
"${DateFormat.yMMMd(context.locale.toString()).format(widget.person.birthDate!)} (${formatAge(widget.person.birthDate!, DateTime.now())})",
style: context.textTheme.labelLarge?.copyWith(
color: Colors.white,
height: 1.2,
fontSize: 14,
),
)
else
Text(
'add_birthday'.tr(),
style: context.textTheme.labelLarge?.copyWith(
color: Colors.grey[400],
height: 1.2,
fontSize: 14,
decoration: TextDecoration.underline,
decorationColor: Colors.white,
),
),
],
),
),
],
),
),
],
),
),
),
],
);
}
}
class _ItemCountText extends ConsumerStatefulWidget {
const _ItemCountText();
@override
ConsumerState<_ItemCountText> createState() => _ItemCountTextState();
}
class _ItemCountTextState extends ConsumerState<_ItemCountText> {
StreamSubscription? _reloadSubscription;
@override
void initState() {
super.initState();
_reloadSubscription = EventStream.shared.listen<TimelineReloadEvent>((_) => setState(() {}));
}
@override
void dispose() {
_reloadSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final assetCount = ref.watch(timelineServiceProvider.select((s) => s.totalAssets));
return Text(
'items_count'.t(context: context, args: {"count": assetCount}),
style: context.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [const Shadow(offset: Offset(0, 1), blurRadius: 6, color: Colors.black45)],
),
);
}
}
class _RandomAssetBackground extends StatefulWidget {
final TimelineService timelineService;
const _RandomAssetBackground({required this.timelineService});
@override
State<_RandomAssetBackground> createState() => _RandomAssetBackgroundState();
}
class _RandomAssetBackgroundState extends State<_RandomAssetBackground> with TickerProviderStateMixin {
late AnimationController _zoomController;
late AnimationController _crossFadeController;
late Animation<double> _zoomAnimation;
late Animation<Offset> _panAnimation;
late Animation<double> _crossFadeAnimation;
BaseAsset? _currentAsset;
BaseAsset? _nextAsset;
bool _isZoomingIn = true;
@override
void initState() {
super.initState();
_zoomController = AnimationController(duration: const Duration(seconds: 12), vsync: this);
_crossFadeController = AnimationController(duration: const Duration(milliseconds: 1200), vsync: this);
_zoomAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_panAnimation = Tween<Offset>(
begin: Offset.zero,
end: const Offset(0.5, -0.5),
).animate(CurvedAnimation(parent: _zoomController, curve: Curves.easeInOut));
_crossFadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(CurvedAnimation(parent: _crossFadeController, curve: Curves.easeInOutCubic));
Future.delayed(Durations.medium1, () => _loadFirstAsset());
}
@override
void dispose() {
_zoomController.dispose();
_crossFadeController.dispose();
super.dispose();
}
void _startAnimationCycle() {
if (_isZoomingIn) {
_zoomController.forward().then((_) {
_loadNextAsset();
});
} else {
_zoomController.reverse().then((_) {
_loadNextAsset();
});
}
}
Future<void> _loadFirstAsset() async {
if (!mounted) {
return;
}
if (widget.timelineService.totalAssets == 0) {
setState(() {
_currentAsset = null;
});
return;
}
setState(() {
_currentAsset = widget.timelineService.getRandomAsset();
});
await _crossFadeController.forward();
if (_zoomController.status == AnimationStatus.dismissed) {
if (_isZoomingIn) {
_zoomController.reset();
} else {
_zoomController.value = 1.0;
}
_startAnimationCycle();
}
}
Future<void> _loadNextAsset() async {
if (!mounted) {
return;
}
try {
if (widget.timelineService.totalAssets > 1) {
// Load next asset while keeping current one visible
final nextAsset = widget.timelineService.getRandomAsset();
setState(() {
_nextAsset = nextAsset;
});
await _crossFadeController.reverse();
setState(() {
_currentAsset = _nextAsset;
_nextAsset = null;
});
_crossFadeController.value = 1.0;
_isZoomingIn = !_isZoomingIn;
_startAnimationCycle();
}
} catch (e) {
_zoomController.reset();
_startAnimationCycle();
}
}
@override
Widget build(BuildContext context) {
if (widget.timelineService.totalAssets == 0) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: Listenable.merge([_zoomAnimation, _panAnimation, _crossFadeAnimation]),
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: Platform.isAndroid ? FilterQuality.low : null,
child: Stack(
fit: StackFit.expand,
children: [
// Current image
if (_currentAsset != null)
Opacity(
opacity: _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_currentAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
if (_nextAsset != null)
Opacity(
opacity: 1.0 - _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_nextAsset!),
fit: BoxFit.cover,
frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return const SizedBox.shrink();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(Icons.error_outline_rounded, size: 24, color: Colors.red[300]),
);
},
),
),
),
],
),
),
);
},
);
}
}

View File

@ -267,7 +267,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
key: pageOption.key ?? ObjectKey(index),
childSize: pageOption.childSize,
backgroundDecoration: widget.backgroundDecoration,
wantKeepAlive: widget.wantKeepAlive,
wantKeepAlive: false,
controller: pageOption.controller,
scaleStateController: pageOption.scaleStateController,
customSize: widget.customSize,
@ -303,7 +303,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
loadingBuilder: widget.loadingBuilder,
backgroundDecoration: widget.backgroundDecoration,
semanticLabel: pageOption.semanticLabel,
wantKeepAlive: widget.wantKeepAlive,
wantKeepAlive: false,
controller: pageOption.controller,
onPageBuild: widget.onPageBuild,
controllerCallbackBuilder: _getControllerCallbackBuilder,

View File

@ -1556,6 +1556,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.28.0"
scroll_date_picker:
dependency: "direct main"
description:
name: scroll_date_picker
sha256: "1b00a3e24d92c77aa84d5856cfe6a57fd5df5f645ce1a6af0feb3ec84bdffb34"
url: "https://pub.dev"
source: hosted
version: "3.8.0"
scrollable_positioned_list:
dependency: "direct main"
description:

View File

@ -72,6 +72,7 @@ dependencies:
uuid: ^4.5.1
wakelock_plus: ^1.2.10
worker_manager: ^7.2.3
scroll_date_picker: ^3.8.0
native_video_player:
git: