mirror of
https://github.com/immich-app/immich.git
synced 2025-07-31 15:08:44 -04:00
feat: people page/sheet/detail (#20309)
This commit is contained in:
parent
268b411a6f
commit
29f16c6a47
@ -14,6 +14,7 @@
|
|||||||
"add_a_location": "Add a location",
|
"add_a_location": "Add a location",
|
||||||
"add_a_name": "Add a name",
|
"add_a_name": "Add a name",
|
||||||
"add_a_title": "Add a title",
|
"add_a_title": "Add a title",
|
||||||
|
"add_birthday": "Add a birthday",
|
||||||
"add_endpoint": "Add endpoint",
|
"add_endpoint": "Add endpoint",
|
||||||
"add_exclusion_pattern": "Add exclusion pattern",
|
"add_exclusion_pattern": "Add exclusion pattern",
|
||||||
"add_import_path": "Add import path",
|
"add_import_path": "Add import path",
|
||||||
@ -828,6 +829,7 @@
|
|||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"edit_album": "Edit album",
|
"edit_album": "Edit album",
|
||||||
"edit_avatar": "Edit avatar",
|
"edit_avatar": "Edit avatar",
|
||||||
|
"edit_birthday": "Edit Birthday",
|
||||||
"edit_date": "Edit date",
|
"edit_date": "Edit date",
|
||||||
"edit_date_and_time": "Edit date and time",
|
"edit_date_and_time": "Edit date and time",
|
||||||
"edit_description": "Edit description",
|
"edit_description": "Edit description",
|
||||||
|
@ -91,7 +91,7 @@ class PersonDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Model for a person stored in the server
|
// Model for a person stored in the server
|
||||||
class Person {
|
class DriftPerson {
|
||||||
final String id;
|
final String id;
|
||||||
final DateTime createdAt;
|
final DateTime createdAt;
|
||||||
final DateTime updatedAt;
|
final DateTime updatedAt;
|
||||||
@ -103,7 +103,7 @@ class Person {
|
|||||||
final String? color;
|
final String? color;
|
||||||
final DateTime? birthDate;
|
final DateTime? birthDate;
|
||||||
|
|
||||||
const Person({
|
const DriftPerson({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.createdAt,
|
required this.createdAt,
|
||||||
required this.updatedAt,
|
required this.updatedAt,
|
||||||
@ -116,7 +116,7 @@ class Person {
|
|||||||
this.birthDate,
|
this.birthDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
Person copyWith({
|
DriftPerson copyWith({
|
||||||
String? id,
|
String? id,
|
||||||
DateTime? createdAt,
|
DateTime? createdAt,
|
||||||
DateTime? updatedAt,
|
DateTime? updatedAt,
|
||||||
@ -128,7 +128,7 @@ class Person {
|
|||||||
String? color,
|
String? color,
|
||||||
DateTime? birthDate,
|
DateTime? birthDate,
|
||||||
}) {
|
}) {
|
||||||
return Person(
|
return DriftPerson(
|
||||||
id: id ?? this.id,
|
id: id ?? this.id,
|
||||||
createdAt: createdAt ?? this.createdAt,
|
createdAt: createdAt ?? this.createdAt,
|
||||||
updatedAt: updatedAt ?? this.updatedAt,
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
@ -159,7 +159,7 @@ class Person {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
bool operator ==(covariant Person other) {
|
bool operator ==(covariant DriftPerson other) {
|
||||||
if (identical(this, other)) return true;
|
if (identical(this, other)) return true;
|
||||||
|
|
||||||
return other.id == id &&
|
return other.id == id &&
|
||||||
|
30
mobile/lib/domain/services/people.service.dart
Normal file
30
mobile/lib/domain/services/people.service.dart
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
@ -53,6 +53,9 @@ class TimelineFactory {
|
|||||||
|
|
||||||
TimelineService place(String place) => TimelineService(_timelineRepository.place(place, groupBy));
|
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));
|
TimelineService fromAssets(List<BaseAsset> assets) => TimelineService(_timelineRepository.fromAssets(assets));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -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,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
@ -292,6 +292,11 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
assetSource: (offset, count) => _getPlaceBucketAssets(place, offset: offset, count: count),
|
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}) {
|
Stream<List<Bucket>> _watchPlaceBucket(String place, {GroupAssetsBy groupBy = GroupAssetsBy.day}) {
|
||||||
if (groupBy == GroupAssetsBy.none) {
|
if (groupBy == GroupAssetsBy.none) {
|
||||||
// TODO: implement GroupAssetBy for place
|
// TODO: implement GroupAssetBy for place
|
||||||
@ -344,6 +349,84 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
|||||||
return query.map((row) => row.readTable(_db.remoteAssetEntity).toDto()).get();
|
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({
|
TimelineQuery _remoteQueryBuilder({
|
||||||
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
|
required Expression<bool> Function($RemoteAssetEntityTable row) filter,
|
||||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||||
|
@ -5,7 +5,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_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/partner.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/user.provider.dart';
|
||||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||||
|
@ -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/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/translate_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/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/album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/partner.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/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/services/api.service.dart';
|
import 'package:immich_mobile/services/api.service.dart';
|
||||||
@ -144,7 +144,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final people = ref.watch(getAllPeopleProvider);
|
final people = ref.watch(driftGetAllPeopleProvider);
|
||||||
|
|
||||||
return LayoutBuilder(
|
return LayoutBuilder(
|
||||||
builder: (context, constraints) {
|
builder: (context, constraints) {
|
||||||
@ -153,7 +153,7 @@ class _PeopleCollectionCard extends ConsumerWidget {
|
|||||||
final size = context.width * widthFactor - 20.0;
|
final size = context.width * widthFactor - 20.0;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () => context.pushRoute(const PeopleCollectionRoute()),
|
onTap: () => context.pushRoute(const DriftPeopleCollectionRoute()),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
130
mobile/lib/presentation/pages/drift_people_collection.page.dart
Normal file
130
mobile/lib/presentation/pages/drift_people_collection.page.dart
Normal 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()),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
97
mobile/lib/presentation/pages/drift_person.page.dart
Normal file
97
mobile/lib/presentation/pages/drift_person.page.dart
Normal 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -218,7 +218,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
|
|
||||||
void _onPageBuild(PhotoViewControllerBase controller) {
|
void _onPageBuild(PhotoViewControllerBase controller) {
|
||||||
viewController ??= controller;
|
viewController ??= controller;
|
||||||
if (showingBottomSheet) {
|
if (showingBottomSheet && bottomSheetController.isAttached) {
|
||||||
final verticalOffset =
|
final verticalOffset =
|
||||||
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
|
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
|
||||||
controller.position = Offset(0, -verticalOffset);
|
controller.position = Offset(0, -verticalOffset);
|
||||||
@ -463,7 +463,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _snapBottomSheet() {
|
void _snapBottomSheet() {
|
||||||
if (bottomSheetController.size > _kBottomSheetSnapExtent || bottomSheetController.size < 0.4) {
|
if (!bottomSheetController.isAttached ||
|
||||||
|
bottomSheetController.size > _kBottomSheetSnapExtent ||
|
||||||
|
bottomSheetController.size < 0.4) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
isSnapping = true;
|
isSnapping = true;
|
||||||
|
@ -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/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/trash_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_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/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.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),
|
titleStyle: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600),
|
||||||
),
|
),
|
||||||
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
|
if (exifInfo != null) _SheetAssetDescription(exif: exifInfo),
|
||||||
|
const SheetPeopleDetails(),
|
||||||
const SheetLocationDetails(),
|
const SheetLocationDetails(),
|
||||||
// Details header
|
// Details header
|
||||||
_SheetTile(
|
_SheetTile(
|
||||||
|
@ -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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -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,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -4,6 +4,7 @@ import 'package:auto_route/auto_route.dart';
|
|||||||
import 'package:flutter/widgets.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/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/images/thumbnail_tile.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/fixed/row.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/timeline/header.widget.dart';
|
||||||
@ -99,7 +100,7 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (timelineService.hasRange(assetIndex, assetCount)) {
|
if (timelineService.hasRange(assetIndex, assetCount)) {
|
||||||
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount));
|
return _buildAssetRow(context, timelineService.getAssets(assetIndex, assetCount), timelineService);
|
||||||
}
|
}
|
||||||
|
|
||||||
return FutureBuilder<List<BaseAsset>>(
|
return FutureBuilder<List<BaseAsset>>(
|
||||||
@ -108,7 +109,7 @@ class _FixedSegmentRow extends ConsumerWidget {
|
|||||||
if (snapshot.connectionState != ConnectionState.done) {
|
if (snapshot.connectionState != ConnectionState.done) {
|
||||||
return _buildPlaceholder(context);
|
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);
|
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(
|
return FixedTimelineRow(
|
||||||
dimension: tileHeight,
|
dimension: tileHeight,
|
||||||
spacing: spacing,
|
spacing: spacing,
|
||||||
textDirection: Directionality.of(context),
|
textDirection: Directionality.of(context),
|
||||||
children: [
|
children: [
|
||||||
for (int i = 0; i < assets.length; i++)
|
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,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -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)),
|
|
||||||
);
|
|
24
mobile/lib/providers/infrastructure/people.provider.dart
Normal file
24
mobile/lib/providers/infrastructure/people.provider.dart
Normal 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();
|
||||||
|
});
|
@ -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)));
|
|
@ -1,5 +1,7 @@
|
|||||||
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
|
||||||
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
||||||
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
|
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||||
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
|
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||||
|
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);
|
||||||
|
@ -16,8 +16,8 @@ class PersonApiRepository extends ApiRepository {
|
|||||||
return dto.people.map(_toPerson).toList();
|
return dto.people.map(_toPerson).toList();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<PersonDto> update(String id, {String? name}) async {
|
Future<PersonDto> update(String id, {String? name, DateTime? birthday}) async {
|
||||||
final dto = await checkNull(_api.updatePerson(id, PersonUpdateDto(name: name)));
|
final dto = await checkNull(_api.updatePerson(id, PersonUpdateDto(name: name, birthDate: birthday)));
|
||||||
return _toPerson(dto);
|
return _toPerson(dto);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -22,6 +22,7 @@ class AppNavigationObserver extends AutoRouterObserver {
|
|||||||
Future(() {
|
Future(() {
|
||||||
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
|
ref.read(currentRouteNameProvider.notifier).state = route.settings.name;
|
||||||
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
|
ref.read(previousRouteNameProvider.notifier).state = previousRoute?.settings.name;
|
||||||
|
ref.read(previousRouteDataProvider.notifier).state = previousRoute?.settings;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/log.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/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/models/user.model.dart';
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
||||||
import 'package:immich_mobile/entities/album.entity.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_locked_folder.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_memory.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_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.page.dart';
|
||||||
import 'package:immich_mobile/presentation/pages/drift_place_detail.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_recently_taken.page.dart';
|
||||||
@ -323,6 +326,9 @@ class AppRouter extends RootStackRouter {
|
|||||||
AutoRoute(page: DriftPartnerRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftPartnerRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: DriftUploadDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
AutoRoute(page: DriftUploadDetailRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||||
AutoRoute(page: BetaSyncSettingsRoute.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
|
// required to handle all deeplinks in deep_link.service.dart
|
||||||
// auto_route_library#1722
|
// auto_route_library#1722
|
||||||
RedirectRoute(path: '*', redirectTo: '/'),
|
RedirectRoute(path: '*', redirectTo: '/'),
|
||||||
|
@ -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
|
/// generated route for
|
||||||
/// [DriftPlaceDetailPage]
|
/// [DriftPlaceDetailPage]
|
||||||
class DriftPlaceDetailRoute extends PageRouteInfo<DriftPlaceDetailRouteArgs> {
|
class DriftPlaceDetailRoute extends PageRouteInfo<DriftPlaceDetailRouteArgs> {
|
||||||
|
54
mobile/lib/utils/people.utils.dart
Normal file
54
mobile/lib/utils/people.utils.dart
Normal 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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
562
mobile/lib/widgets/common/person_sliver_app_bar.dart
Normal file
562
mobile/lib/widgets/common/person_sliver_app_bar.dart
Normal 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]),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -267,7 +267,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||||||
key: pageOption.key ?? ObjectKey(index),
|
key: pageOption.key ?? ObjectKey(index),
|
||||||
childSize: pageOption.childSize,
|
childSize: pageOption.childSize,
|
||||||
backgroundDecoration: widget.backgroundDecoration,
|
backgroundDecoration: widget.backgroundDecoration,
|
||||||
wantKeepAlive: widget.wantKeepAlive,
|
wantKeepAlive: false,
|
||||||
controller: pageOption.controller,
|
controller: pageOption.controller,
|
||||||
scaleStateController: pageOption.scaleStateController,
|
scaleStateController: pageOption.scaleStateController,
|
||||||
customSize: widget.customSize,
|
customSize: widget.customSize,
|
||||||
@ -303,7 +303,7 @@ class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
|||||||
loadingBuilder: widget.loadingBuilder,
|
loadingBuilder: widget.loadingBuilder,
|
||||||
backgroundDecoration: widget.backgroundDecoration,
|
backgroundDecoration: widget.backgroundDecoration,
|
||||||
semanticLabel: pageOption.semanticLabel,
|
semanticLabel: pageOption.semanticLabel,
|
||||||
wantKeepAlive: widget.wantKeepAlive,
|
wantKeepAlive: false,
|
||||||
controller: pageOption.controller,
|
controller: pageOption.controller,
|
||||||
onPageBuild: widget.onPageBuild,
|
onPageBuild: widget.onPageBuild,
|
||||||
controllerCallbackBuilder: _getControllerCallbackBuilder,
|
controllerCallbackBuilder: _getControllerCallbackBuilder,
|
||||||
|
@ -1556,6 +1556,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "0.28.0"
|
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:
|
scrollable_positioned_list:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
@ -72,6 +72,7 @@ dependencies:
|
|||||||
uuid: ^4.5.1
|
uuid: ^4.5.1
|
||||||
wakelock_plus: ^1.2.10
|
wakelock_plus: ^1.2.10
|
||||||
worker_manager: ^7.2.3
|
worker_manager: ^7.2.3
|
||||||
|
scroll_date_picker: ^3.8.0
|
||||||
|
|
||||||
native_video_player:
|
native_video_player:
|
||||||
git:
|
git:
|
||||||
|
Loading…
x
Reference in New Issue
Block a user