mirror of https://github.com/immich-app/immich.git
feat: people page/sheet/detail (#20309)
parent
268b411a6f
commit
29f16c6a47
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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()),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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)),
|
||||
);
|
||||
@ -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';
|
||||
|
||||
final inLockedViewProvider = StateProvider<bool>((ref) => false);
|
||||
final currentRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||
final previousRouteNameProvider = StateProvider<String?>((ref) => null);
|
||||
final previousRouteDataProvider = StateProvider<RouteSettings?>((ref) => null);
|
||||
|
||||
@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@ -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]),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue