refactor: yeet old timeline (#27666)

* refactor: yank old timeline

# Conflicts:
#	mobile/lib/presentation/pages/editing/drift_edit.page.dart
#	mobile/lib/providers/websocket.provider.dart
#	mobile/lib/routing/router.dart

* more cleanup

* remove native code

* chore: bump sqlite-data version

* remove old background tasks from BGTaskSchedulerPermittedIdentifiers

* rebase

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong
2026-04-15 23:00:27 +05:30
committed by GitHub
parent 6dd6053222
commit 79fccdbee0
367 changed files with 332 additions and 50870 deletions
@@ -1,115 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@RoutePage()
class AlbumAdditionalSharedUserSelectionPage extends HookConsumerWidget {
final Album album;
const AlbumAdditionalSharedUserSelectionPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final AsyncValue<List<UserDto>> suggestedShareUsers = ref.watch(otherUsersProvider);
final sharedUsersList = useState<Set<UserDto>>({});
addNewUsersHandler() {
context.maybePop(sharedUsersList.value.map((e) => e.id).toList());
}
buildTileIcon(UserDto user) {
if (sharedUsersList.value.contains(user)) {
return CircleAvatar(backgroundColor: context.primaryColor, child: const Icon(Icons.check_rounded, size: 25));
} else {
return UserCircleAvatar(user: user);
}
}
buildUserList(List<UserDto> users) {
List<Widget> usersChip = [];
for (var user in sharedUsersList.value) {
usersChip.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Chip(
backgroundColor: context.primaryColor.withValues(alpha: 0.15),
label: Text(user.name, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.bold)),
),
),
);
}
return ListView(
children: [
Wrap(children: [...usersChip]),
Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
'suggestions'.tr(),
style: const TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
),
),
ListView.builder(
primary: false,
shrinkWrap: true,
itemBuilder: ((context, index) {
return ListTile(
leading: buildTileIcon(users[index]),
dense: true,
title: Text(users[index].name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
subtitle: Text(users[index].email, style: const TextStyle(fontSize: 12)),
onTap: () {
if (sharedUsersList.value.contains(users[index])) {
sharedUsersList.value = sharedUsersList.value
.where((selectedUser) => selectedUser.id != users[index].id)
.toSet();
} else {
sharedUsersList.value = {...sharedUsersList.value, users[index]};
}
},
);
}),
itemCount: users.length,
),
],
);
}
return Scaffold(
appBar: AppBar(
title: const Text('invite_to_album').tr(),
elevation: 0,
centerTitle: false,
leading: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
context.maybePop(null);
},
),
actions: [
TextButton(
onPressed: sharedUsersList.value.isEmpty ? null : addNewUsersHandler,
child: const Text("add", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
),
],
),
body: suggestedShareUsers.widgetWhen(
onData: (users) {
for (var sharedUsers in album.sharedUsers) {
users.removeWhere((u) => u.id == sharedUsers.id || u.id == album.ownerId);
}
return buildUserList(users);
},
),
);
}
}
@@ -1,74 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
@RoutePage()
class AlbumAssetSelectionPage extends HookConsumerWidget {
const AlbumAssetSelectionPage({super.key, required this.existingAssets, this.canDeselect = false});
final Set<Asset> existingAssets;
final bool canDeselect;
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetSelectionRenderList = ref.watch(assetSelectionTimelineProvider);
final selected = useState<Set<Asset>>(existingAssets);
final selectionEnabledHook = useState(true);
Widget buildBody(RenderList renderList) {
return ImmichAssetGrid(
renderList: renderList,
listener: (active, assets) {
selectionEnabledHook.value = active;
selected.value = assets;
},
selectionActive: true,
preselectedAssets: existingAssets,
canDeselect: canDeselect,
showMultiSelectIndicator: false,
);
}
return Scaffold(
appBar: AppBar(
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
AutoRouter.of(context).popForced(null);
},
),
title: selected.value.isEmpty
? const Text('add_photos', style: TextStyle(fontSize: 18)).tr()
: const Text(
'share_assets_selected',
style: TextStyle(fontSize: 18),
).tr(namedArgs: {'count': selected.value.length.toString()}),
centerTitle: false,
actions: [
if (selected.value.isNotEmpty || canDeselect)
TextButton(
onPressed: () {
var payload = AssetSelectionPageResult(selectedAssets: selected.value);
AutoRouter.of(context).popForced<AssetSelectionPageResult>(payload);
},
child: Text(
canDeselect ? "done" : "add",
style: TextStyle(fontWeight: FontWeight.bold, color: context.primaryColor),
).tr(),
),
],
),
body: assetSelectionRenderList.widgetWhen(onData: (data) => buildBody(data)),
);
}
}
@@ -1,37 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
class AlbumControlButton extends ConsumerWidget {
final void Function()? onAddPhotosPressed;
final void Function()? onAddUsersPressed;
const AlbumControlButton({super.key, this.onAddPhotosPressed, this.onAddUsersPressed});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SizedBox(
height: 36,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
if (onAddPhotosPressed != null)
AlbumActionFilledButton(
key: const ValueKey('add_photos_button'),
iconData: Icons.add_photo_alternate_outlined,
onPressed: onAddPhotosPressed,
labelText: "add_photos".tr(),
),
if (onAddUsersPressed != null)
AlbumActionFilledButton(
key: const ValueKey('add_users_button'),
iconData: Icons.person_add_alt_rounded,
onPressed: onAddUsersPressed,
labelText: "album_viewer_page_share_add_users".tr(),
),
],
),
);
}
}
@@ -1,53 +0,0 @@
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/album/current_album.provider.dart';
class AlbumDateRange extends ConsumerWidget {
const AlbumDateRange({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final data = ref.watch(
currentAlbumProvider.select((album) {
if (album == null || album.assets.isEmpty) {
return null;
}
final startDate = album.startDate;
final endDate = album.endDate;
if (startDate == null || endDate == null) {
return null;
}
return (startDate, endDate, album.shared);
}),
);
if (data == null) {
return const SizedBox();
}
final (startDate, endDate, shared) = data;
return Padding(
padding: const EdgeInsets.only(left: 16.0),
child: Text(
_getDateRangeText(startDate, endDate),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
);
}
@pragma('vm:prefer-inline')
String _getDateRangeText(DateTime startDate, DateTime endDate) {
if (startDate.day == endDate.day && startDate.month == endDate.month && startDate.year == endDate.year) {
return DateFormat.yMMMd().format(startDate);
}
final String startDateText = (startDate.year == endDate.year ? DateFormat.MMMd() : DateFormat.yMMMd()).format(
startDate,
);
final String endDateText = DateFormat.yMMMd().format(endDate);
return "$startDateText - $endDateText";
}
}
@@ -1,42 +0,0 @@
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/album/current_album.provider.dart';
import 'package:immich_mobile/widgets/album/album_viewer_editable_description.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
class AlbumDescription extends ConsumerWidget {
const AlbumDescription({super.key, required this.descriptionFocusNode});
final FocusNode descriptionFocusNode;
@override
Widget build(BuildContext context, WidgetRef ref) {
final userId = ref.watch(authProvider).userId;
final (isOwner, isRemote, albumDescription) = ref.watch(
currentAlbumProvider.select((album) {
if (album == null) {
return const (false, false, '');
}
return (album.ownerId == userId, album.isRemote, album.description);
}),
);
if (isOwner && isRemote) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: AlbumViewerEditableDescription(
albumDescription: albumDescription ?? 'add_a_description'.tr(),
descriptionFocusNode: descriptionFocusNode,
),
);
}
return Padding(
padding: const EdgeInsets.only(left: 16, right: 8),
child: Text(albumDescription ?? 'add_a_description'.tr(), style: context.textTheme.bodyLarge),
);
}
}
@@ -1,192 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/infrastructure/entities/user.entity.dart' as entity;
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@RoutePage()
class AlbumOptionsPage extends HookConsumerWidget {
const AlbumOptionsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentAlbumProvider);
if (album == null) {
return const SizedBox();
}
final sharedUsers = useState(album.sharedUsers.map((u) => u.toDto()).toList());
final owner = album.owner.value;
final userId = ref.watch(authProvider).userId;
final activityEnabled = useState(album.activityEnabled);
final isProcessing = useProcessingOverlay();
final isOwner = owner?.id == userId;
void showErrorMessage() {
context.pop();
ImmichToast.show(
context: context,
msg: "shared_album_section_people_action_error".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
void leaveAlbum() async {
isProcessing.value = true;
try {
final isSuccess = await ref.read(albumProvider.notifier).leaveAlbum(album);
if (isSuccess) {
unawaited(context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])));
} else {
showErrorMessage();
}
} catch (_) {
showErrorMessage();
}
isProcessing.value = false;
}
void removeUserFromAlbum(UserDto user) async {
isProcessing.value = true;
try {
await ref.read(albumProvider.notifier).removeUser(album, user);
album.sharedUsers.remove(entity.User.fromDto(user));
sharedUsers.value = album.sharedUsers.map((u) => u.toDto()).toList();
} catch (error) {
showErrorMessage();
}
context.pop();
isProcessing.value = false;
}
void handleUserClick(UserDto user) {
var actions = [];
if (user.id == userId) {
actions = [
ListTile(
leading: const Icon(Icons.exit_to_app_rounded),
title: const Text("shared_album_section_people_action_leave").tr(),
onTap: leaveAlbum,
),
];
}
if (isOwner) {
actions = [
ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text("shared_album_section_people_action_remove_user").tr(),
onTap: () => removeUserFromAlbum(user),
),
];
}
showModalBottomSheet(
backgroundColor: context.colorScheme.surfaceContainer,
isScrollControlled: false,
context: context,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(mainAxisSize: MainAxisSize.min, children: [...actions]),
),
);
},
);
}
buildOwnerInfo() {
return ListTile(
leading: owner != null ? UserCircleAvatar(user: owner.toDto()) : const SizedBox(),
title: Text(album.owner.value?.name ?? "", style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(album.owner.value?.email ?? "", style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
trailing: Text("owner", style: context.textTheme.labelLarge).tr(),
);
}
buildSharedUsersList() {
return ListView.builder(
primary: false,
shrinkWrap: true,
itemCount: sharedUsers.value.length,
itemBuilder: (context, index) {
final user = sharedUsers.value[index];
return ListTile(
leading: UserCircleAvatar(user: user),
title: Text(user.name, style: const TextStyle(fontWeight: FontWeight.w500)),
subtitle: Text(user.email, style: TextStyle(color: context.colorScheme.onSurfaceSecondary)),
trailing: userId == user.id || isOwner ? const Icon(Icons.more_horiz_rounded) : const SizedBox(),
onTap: userId == user.id || isOwner ? () => handleUserClick(user) : null,
);
},
);
}
buildSectionTitle(String text) {
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text(text, style: context.textTheme.bodySmall),
);
}
return Scaffold(
appBar: AppBar(
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new_rounded),
onPressed: () => context.maybePop(null),
),
centerTitle: true,
title: Text("options".tr()),
),
body: ListView(
children: [
if (isOwner && album.shared)
SwitchListTile.adaptive(
value: activityEnabled.value,
onChanged: (bool value) async {
activityEnabled.value = value;
if (await ref.read(albumProvider.notifier).setActivitystatus(album, value)) {
album.activityEnabled = value;
}
},
activeThumbColor: activityEnabled.value ? context.primaryColor : context.themeData.disabledColor,
dense: true,
title: Text(
"comments_and_likes",
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500),
).tr(),
subtitle: Text(
"let_others_respond",
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
).tr(),
),
buildSectionTitle("shared_album_section_people_title".tr()),
buildOwnerInfo(),
buildSharedUsersList(),
],
),
);
}
}
@@ -1,52 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
class AlbumSharedUserIcons extends HookConsumerWidget {
const AlbumSharedUserIcons({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedUsers = useRef<List<UserDto>>(const []);
sharedUsers.value = ref.watch(
currentAlbumProvider.select((album) {
if (album == null) {
return const [];
}
if (album.sharedUsers.length == sharedUsers.value.length) {
return sharedUsers.value;
}
return album.sharedUsers.map((u) => u.toDto()).toList(growable: false);
}),
);
if (sharedUsers.value.isEmpty) {
return const SizedBox();
}
return GestureDetector(
onTap: () => context.pushRoute(const AlbumOptionsRoute()),
child: SizedBox(
height: 50,
child: ListView.builder(
padding: const EdgeInsets.only(left: 16, bottom: 8),
scrollDirection: Axis.horizontal,
itemBuilder: ((context, index) {
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: UserCircleAvatar(user: sharedUsers.value[index], size: 36),
);
}),
itemCount: sharedUsers.value.length,
),
),
);
}
}
@@ -1,140 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_title.provider.dart';
import 'package:immich_mobile/providers/album/suggested_shared_users.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
@RoutePage()
class AlbumSharedUserSelectionPage extends HookConsumerWidget {
const AlbumSharedUserSelectionPage({super.key, required this.assets});
final Set<Asset> assets;
@override
Widget build(BuildContext context, WidgetRef ref) {
final sharedUsersList = useState<Set<UserDto>>({});
final suggestedShareUsers = ref.watch(otherUsersProvider);
createSharedAlbum() async {
var newAlbum = await ref.watch(albumProvider.notifier).createAlbum(ref.watch(albumTitleProvider), assets);
if (newAlbum != null) {
ref.watch(albumTitleProvider.notifier).clearAlbumTitle();
unawaited(context.maybePop(true));
unawaited(context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])));
}
ScaffoldMessenger(
child: SnackBar(
content: Text(
'select_user_for_sharing_page_err_album',
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
).tr(),
),
);
}
buildTileIcon(UserDto user) {
if (sharedUsersList.value.contains(user)) {
return CircleAvatar(backgroundColor: context.primaryColor, child: const Icon(Icons.check_rounded, size: 25));
} else {
return UserCircleAvatar(user: user);
}
}
buildUserList(List<UserDto> users) {
List<Widget> usersChip = [];
for (var user in sharedUsersList.value) {
usersChip.add(
Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Chip(
backgroundColor: context.primaryColor.withValues(alpha: 0.15),
label: Text(
user.email,
style: const TextStyle(fontSize: 12, color: Colors.black87, fontWeight: FontWeight.bold),
),
),
),
);
}
return ListView(
children: [
Wrap(children: [...usersChip]),
Padding(
padding: const EdgeInsets.all(16.0),
child: const Text(
'suggestions',
style: TextStyle(fontSize: 14, color: Colors.grey, fontWeight: FontWeight.bold),
).tr(),
),
ListView.builder(
primary: false,
shrinkWrap: true,
itemBuilder: ((context, index) {
return ListTile(
leading: buildTileIcon(users[index]),
title: Text(users[index].email, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
onTap: () {
if (sharedUsersList.value.contains(users[index])) {
sharedUsersList.value = sharedUsersList.value
.where((selectedUser) => selectedUser.id != users[index].id)
.toSet();
} else {
sharedUsersList.value = {...sharedUsersList.value, users[index]};
}
},
);
}),
itemCount: users.length,
),
],
);
}
return Scaffold(
appBar: AppBar(
title: Text('invite_to_album', style: TextStyle(color: context.primaryColor)).tr(),
elevation: 0,
centerTitle: false,
leading: IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () {
unawaited(context.maybePop());
},
),
actions: [
TextButton(
style: TextButton.styleFrom(foregroundColor: context.primaryColor),
onPressed: sharedUsersList.value.isEmpty ? null : createSharedAlbum,
child: const Text(
"create_album",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
// color: context.primaryColor,
),
).tr(),
),
],
),
body: suggestedShareUsers.widgetWhen(
onData: (users) {
return buildUserList(users);
},
),
);
}
}
-38
View File
@@ -1,38 +0,0 @@
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/album/current_album.provider.dart';
import 'package:immich_mobile/widgets/album/album_viewer_editable_title.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
class AlbumTitle extends ConsumerWidget {
const AlbumTitle({super.key, required this.titleFocusNode});
final FocusNode titleFocusNode;
@override
Widget build(BuildContext context, WidgetRef ref) {
final userId = ref.watch(authProvider).userId;
final (isOwner, isRemote, albumName) = ref.watch(
currentAlbumProvider.select((album) {
if (album == null) {
return const (false, false, '');
}
return (album.ownerId == userId, album.isRemote, album.name);
}),
);
if (isOwner && isRemote) {
return Padding(
padding: const EdgeInsets.only(left: 8, right: 8),
child: AlbumViewerEditableTitle(albumName: albumName, titleFocusNode: titleFocusNode),
);
}
return Padding(
padding: const EdgeInsets.only(left: 16, right: 8),
child: Text(albumName, style: context.textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w700)),
);
}
}
-165
View File
@@ -1,165 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart';
import 'package:immich_mobile/pages/album/album_control_button.dart';
import 'package:immich_mobile/pages/album/album_date_range.dart';
import 'package:immich_mobile/pages/album/album_description.dart';
import 'package:immich_mobile/pages/album/album_shared_user_icons.dart';
import 'package:immich_mobile/pages/album/album_title.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/widgets/album/album_viewer_appbar.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class AlbumViewer extends HookConsumerWidget {
const AlbumViewer({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentAlbumProvider);
if (album == null) {
return const SizedBox();
}
final titleFocusNode = useFocusNode();
final descriptionFocusNode = useFocusNode();
final userId = ref.watch(authProvider).userId;
final isMultiselecting = ref.watch(multiselectProvider);
final isProcessing = useProcessingOverlay();
final isOwner = ref.watch(
currentAlbumProvider.select((album) {
return album?.ownerId == userId;
}),
);
Future<bool> onRemoveFromAlbumPressed(Iterable<Asset> assets) async {
final bool isSuccess = await ref.read(albumProvider.notifier).removeAsset(album, assets);
if (!isSuccess) {
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_remove".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
return isSuccess;
}
/// Find out if the assets in album exist on the device
/// If they exist, add to selected asset state to show they are already selected.
void onAddPhotosPressed() async {
AssetSelectionPageResult? returnPayload = await context.pushRoute<AssetSelectionPageResult?>(
AlbumAssetSelectionRoute(existingAssets: album.assets, canDeselect: false),
);
if (returnPayload != null && returnPayload.selectedAssets.isNotEmpty) {
// Check if there is new assets add
isProcessing.value = true;
await ref.watch(albumProvider.notifier).addAssets(album, returnPayload.selectedAssets);
isProcessing.value = false;
}
}
void onAddUsersPressed() async {
List<String>? sharedUserIds = await context.pushRoute<List<String>?>(
AlbumAdditionalSharedUserSelectionRoute(album: album),
);
if (sharedUserIds != null) {
isProcessing.value = true;
await ref.watch(albumProvider.notifier).addUsers(album, sharedUserIds);
isProcessing.value = false;
}
}
onActivitiesPressed() {
if (album.remoteId != null) {
ref.read(currentAssetProvider.notifier).set(null);
context.pushRoute(const ActivitiesRoute());
}
}
return Stack(
children: [
MultiselectGrid(
key: const ValueKey("albumViewerMultiselectGrid"),
renderListProvider: albumTimelineProvider(album.id),
topWidget: Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
context.primaryColor.withValues(alpha: 0.06),
context.primaryColor.withValues(alpha: 0.04),
Colors.indigo.withValues(alpha: 0.02),
Colors.transparent,
],
stops: const [0.0, 0.3, 0.7, 1.0],
),
),
child: Column(
mainAxisAlignment: MainAxisAlignment.end,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 32),
const AlbumDateRange(),
AlbumTitle(key: const ValueKey("albumTitle"), titleFocusNode: titleFocusNode),
AlbumDescription(key: const ValueKey("albumDescription"), descriptionFocusNode: descriptionFocusNode),
const AlbumSharedUserIcons(),
if (album.isRemote)
Padding(
padding: const EdgeInsets.only(left: 16.0),
child: AlbumControlButton(
key: const ValueKey("albumControlButton"),
onAddPhotosPressed: onAddPhotosPressed,
onAddUsersPressed: isOwner ? onAddUsersPressed : null,
),
),
const SizedBox(height: 8),
],
),
),
onRemoveFromAlbum: onRemoveFromAlbumPressed,
editEnabled: album.ownerId == userId,
),
AnimatedPositioned(
key: const ValueKey("albumViewerAppbarPositioned"),
duration: const Duration(milliseconds: 300),
top: isMultiselecting ? -(kToolbarHeight + context.padding.top) : 0,
left: 0,
right: 0,
child: AlbumViewerAppbar(
key: const ValueKey("albumViewerAppbar"),
titleFocusNode: titleFocusNode,
descriptionFocusNode: descriptionFocusNode,
userId: userId,
onAddPhotos: onAddPhotosPressed,
onAddUsers: onAddUsersPressed,
onActivities: onActivitiesPressed,
),
),
],
);
}
}
@@ -1,29 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/pages/album/album_viewer.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
@RoutePage()
class AlbumViewerPage extends HookConsumerWidget {
final int albumId;
const AlbumViewerPage({super.key, required this.albumId});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Listen provider to prevent autoDispose when navigating to other routes from within the viewer page
ref.listen(currentAlbumProvider, (_, __) {});
// This call helps rendering the asset selection instantly
ref.listen(assetSelectionTimelineProvider, (_, __) {});
ref.listen(albumWatcher(albumId), (_, albumFuture) {
albumFuture.whenData((value) => ref.read(currentAlbumProvider.notifier).set(value));
});
return const Scaffold(body: AlbumViewer());
}
}
-359
View File
@@ -1,359 +0,0 @@
import 'dart:async';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/albums/album_search.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
@RoutePage()
class AlbumsPage extends HookConsumerWidget {
const AlbumsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider).where((album) => album.isRemote).toList();
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
final sorted = albumSortOption.sortFn(albums, albumSortIsReverse);
final isGrid = useState(false);
final searchController = useTextEditingController();
final debounceTimer = useRef<Timer?>(null);
final filterMode = useState(QuickFilterMode.all);
final userId = ref.watch(currentUserProvider)?.id;
final searchFocusNode = useFocusNode();
toggleViewMode() {
isGrid.value = !isGrid.value;
}
onSearch(String searchTerm, QuickFilterMode mode) {
debounceTimer.value?.cancel();
debounceTimer.value = Timer(const Duration(milliseconds: 300), () {
ref.read(albumProvider.notifier).searchAlbums(searchTerm, mode);
});
}
changeFilter(QuickFilterMode mode) {
filterMode.value = mode;
}
useEffect(() {
searchController.addListener(() {
onSearch(searchController.text, filterMode.value);
});
return () {
searchController.removeListener(() {
onSearch(searchController.text, filterMode.value);
});
debounceTimer.value?.cancel();
};
}, []);
clearSearch() {
filterMode.value = QuickFilterMode.all;
searchController.clear();
onSearch('', QuickFilterMode.all);
}
return Scaffold(
appBar: ImmichAppBar(
showUploadButton: false,
actions: [
IconButton(
icon: const Icon(Icons.add_rounded, size: 28),
onPressed: () => context.pushRoute(CreateAlbumRoute()),
),
],
),
body: RefreshIndicator(
displacement: 70,
onRefresh: () async {
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
},
child: ListView(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12),
children: [
Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(0), width: 0),
borderRadius: const BorderRadius.all(Radius.circular(24)),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withValues(alpha: 0.075),
context.colorScheme.primary.withValues(alpha: 0.09),
context.colorScheme.primary.withValues(alpha: 0.075),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
transform: const GradientRotation(0.5 * pi),
),
),
child: SearchField(
autofocus: false,
contentPadding: const EdgeInsets.all(16),
hintText: 'search_albums'.tr(),
prefixIcon: const Icon(Icons.search_rounded),
suffixIcon: searchController.text.isNotEmpty
? IconButton(icon: const Icon(Icons.clear_rounded), onPressed: clearSearch)
: null,
controller: searchController,
onChanged: (_) => onSearch(searchController.text, filterMode.value),
focusNode: searchFocusNode,
onTapOutside: (_) => searchFocusNode.unfocus(),
),
),
const SizedBox(height: 8),
Wrap(
spacing: 4,
runSpacing: 4,
children: [
QuickFilterButton(
label: 'all'.tr(),
isSelected: filterMode.value == QuickFilterMode.all,
onTap: () {
changeFilter(QuickFilterMode.all);
onSearch(searchController.text, QuickFilterMode.all);
},
),
QuickFilterButton(
label: 'shared_with_me'.tr(),
isSelected: filterMode.value == QuickFilterMode.sharedWithMe,
onTap: () {
changeFilter(QuickFilterMode.sharedWithMe);
onSearch(searchController.text, QuickFilterMode.sharedWithMe);
},
),
QuickFilterButton(
label: 'my_albums'.tr(),
isSelected: filterMode.value == QuickFilterMode.myAlbums,
onTap: () {
changeFilter(QuickFilterMode.myAlbums);
onSearch(searchController.text, QuickFilterMode.myAlbums);
},
),
],
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const SortButton(),
IconButton(
icon: Icon(isGrid.value ? Icons.view_list_outlined : Icons.grid_view_outlined, size: 24),
onPressed: toggleViewMode,
),
],
),
const SizedBox(height: 5),
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: isGrid.value
? GridView.builder(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
itemBuilder: (context, index) {
return AlbumThumbnailCard(
album: sorted[index],
onTap: () => context.pushRoute(AlbumViewerRoute(albumId: sorted[index].id)),
showOwner: true,
);
},
itemCount: sorted.length,
)
: ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: sorted.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: LargeLeadingTile(
title: Text(
sorted[index].name,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: sorted[index].ownerId != null
? Text(
'${'items_count'.t(context: context, args: {'count': sorted[index].assetCount})} • ${sorted[index].ownerId != userId ? 'shared_by_user'.t(context: context, args: {'user': sorted[index].ownerName!}) : 'owned'.t(context: context)}',
overflow: TextOverflow.ellipsis,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceSecondary,
),
)
: null,
onTap: () => context.pushRoute(AlbumViewerRoute(albumId: sorted[index].id)),
leadingPadding: const EdgeInsets.only(right: 16),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: ImmichThumbnail(asset: sorted[index].thumbnail.value, width: 80, height: 80),
),
// minVerticalPadding: 1,
),
);
},
),
),
],
),
),
resizeToAvoidBottomInset: false,
);
}
}
class QuickFilterButton extends StatelessWidget {
const QuickFilterButton({super.key, required this.isSelected, required this.onTap, required this.label});
final bool isSelected;
final VoidCallback onTap;
final String label;
@override
Widget build(BuildContext context) {
return TextButton(
onPressed: onTap,
style: ButtonStyle(
backgroundColor: WidgetStateProperty.all(isSelected ? context.colorScheme.primary : Colors.transparent),
shape: WidgetStateProperty.all(
RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(color: context.colorScheme.onSurface.withAlpha(25), width: 1),
),
),
),
child: Text(
label,
style: TextStyle(
color: isSelected ? context.colorScheme.onPrimary : context.colorScheme.onSurface,
fontSize: 14,
),
),
);
}
}
class SortButton extends ConsumerWidget {
const SortButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumSortOption = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
return MenuAnchor(
style: MenuStyle(
elevation: const WidgetStatePropertyAll(1),
shape: WidgetStateProperty.all(
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
),
consumeOutsideTap: true,
menuChildren: AlbumSortMode.values
.map(
(mode) => MenuItemButton(
leadingIcon: albumSortOption == mode
? albumSortIsReverse
? Icon(
Icons.keyboard_arrow_down,
color: albumSortOption == mode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface,
)
: Icon(
Icons.keyboard_arrow_up_rounded,
color: albumSortOption == mode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface,
)
: const Icon(Icons.abc, color: Colors.transparent),
onPressed: () {
final selected = albumSortOption == mode;
// Switch direction
if (selected) {
ref.read(albumSortOrderProvider.notifier).changeSortDirection(!albumSortIsReverse);
} else {
ref.read(albumSortByOptionsProvider.notifier).changeSortMode(mode);
}
},
style: ButtonStyle(
padding: WidgetStateProperty.all(const EdgeInsets.fromLTRB(16, 16, 32, 16)),
backgroundColor: WidgetStateProperty.all(
albumSortOption == mode ? context.colorScheme.primary : Colors.transparent,
),
shape: WidgetStateProperty.all(
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
),
),
child: Text(
mode.label.tr(),
style: context.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: albumSortOption == mode
? context.colorScheme.onPrimary
: context.colorScheme.onSurface.withAlpha(185),
),
),
),
)
.toList(),
builder: (context, controller, child) {
return GestureDetector(
onTap: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
child: Row(
children: [
Padding(
padding: const EdgeInsets.only(right: 5),
child: Transform.rotate(
angle: 90 * pi / 180,
child: Icon(
Icons.compare_arrows_rounded,
size: 18,
color: context.colorScheme.onSurface.withAlpha(225),
),
),
),
Text(
albumSortOption.label.tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: context.colorScheme.onSurface.withAlpha(225),
),
),
],
),
);
},
);
}
}
@@ -1,64 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/repositories/album_media.repository.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class AlbumPreviewPage extends HookConsumerWidget {
final Album album;
const AlbumPreviewPage({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assets = useState<List<Asset>>([]);
getAssetsInAlbum() async {
assets.value = await ref.read(albumMediaRepositoryProvider).getAssets(album.localId!);
}
useEffect(() {
getAssetsInAlbum();
return null;
}, []);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Column(
children: [
Text(album.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
Padding(
padding: const EdgeInsets.only(top: 4.0),
child: Text(
"ID ${album.id}",
style: TextStyle(
fontSize: 10,
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
),
),
),
],
),
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_new_rounded)),
),
body: GridView.builder(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 5,
crossAxisSpacing: 2,
mainAxisSpacing: 2,
),
itemCount: assets.value.length,
itemBuilder: (context, index) {
return ImmichThumbnail(asset: assets.value[index], width: 100, height: 100);
},
),
);
}
}
@@ -1,225 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/backup/album_info_card.dart';
import 'package:immich_mobile/widgets/backup/album_info_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
@RoutePage()
class BackupAlbumSelectionPage extends HookConsumerWidget {
const BackupAlbumSelectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
final enableSyncUploadAlbum = useAppSettingsState(AppSettingsEnum.syncAlbums);
final isDarkTheme = context.isDarkTheme;
final albums = ref.watch(backupProvider).availableAlbums;
useEffect(() {
ref.watch(backupProvider.notifier).getBackupInfo();
return null;
}, []);
buildAlbumSelectionList() {
if (albums.isEmpty) {
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
}
return SliverPadding(
padding: const EdgeInsets.symmetric(vertical: 12.0),
sliver: SliverList(
delegate: SliverChildBuilderDelegate(((context, index) {
return AlbumInfoListTile(album: albums[index]);
}), childCount: albums.length),
),
);
}
buildAlbumSelectionGrid() {
if (albums.isEmpty) {
return const SliverToBoxAdapter(child: Center(child: CircularProgressIndicator()));
}
return SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 300,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
),
itemCount: albums.length,
itemBuilder: ((context, index) {
return AlbumInfoCard(album: albums[index]);
}),
),
);
}
buildSelectedAlbumNameChip() {
return selectedBackupAlbums.map((album) {
void removeSelection() => ref.read(backupProvider.notifier).removeAlbumForBackup(album);
return Padding(
padding: const EdgeInsets.only(right: 8.0),
child: GestureDetector(
onTap: removeSelection,
child: Chip(
label: Text(
album.name,
style: TextStyle(
fontSize: 12,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
),
backgroundColor: context.primaryColor,
deleteIconColor: isDarkTheme ? Colors.black : Colors.white,
deleteIcon: const Icon(Icons.cancel_rounded, size: 15),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
buildExcludedAlbumNameChip() {
return excludedBackupAlbums.map((album) {
void removeSelection() {
ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(album);
}
return GestureDetector(
onTap: removeSelection,
child: Padding(
padding: const EdgeInsets.only(right: 8.0),
child: Chip(
label: Text(
album.name,
style: TextStyle(fontSize: 12, color: context.scaffoldBackgroundColor, fontWeight: FontWeight.bold),
),
backgroundColor: Colors.red[300],
deleteIconColor: context.scaffoldBackgroundColor,
deleteIcon: const Icon(Icons.cancel_rounded, size: 15),
onDeleted: removeSelection,
),
),
);
}).toSet();
}
handleSyncAlbumToggle(bool isEnable) async {
if (isEnable) {
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
for (final album in selectedBackupAlbums) {
await ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
}
}
return Scaffold(
appBar: AppBar(
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
title: const Text("backup_album_selection_page_select_albums").tr(),
elevation: 0,
),
body: CustomScrollView(
physics: const ClampingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
child: Text("backup_album_selection_page_selection_info", style: context.textTheme.titleSmall).tr(),
),
// Selected Album Chips
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Wrap(children: [...buildSelectedAlbumNameChip(), ...buildExcludedAlbumNameChip()]),
),
SettingsSwitchListTile(
valueNotifier: enableSyncUploadAlbum,
title: "sync_albums".tr(),
subtitle: "sync_upload_album_setting_subtitle".tr(),
contentPadding: const EdgeInsets.symmetric(horizontal: 16),
titleStyle: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.bold),
subtitleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.primary),
onChanged: handleSyncAlbumToggle,
),
ListTile(
title: Text(
"backup_album_selection_page_albums_device".tr(
namedArgs: {'count': ref.watch(backupProvider).availableAlbums.length.toString()},
),
style: context.textTheme.titleSmall,
),
subtitle: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
"backup_album_selection_page_albums_tap",
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
).tr(),
),
trailing: IconButton(
splashRadius: 16,
icon: Icon(Icons.info, size: 20, color: context.primaryColor),
onPressed: () {
// show the dialog
showDialog(
context: context,
builder: (BuildContext context) {
return AlertDialog(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
elevation: 5,
title: Text(
'backup_album_selection_page_selection_info',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold, color: context.primaryColor),
).tr(),
content: SingleChildScrollView(
child: ListBody(
children: [
const Text(
'backup_album_selection_page_assets_scatter',
style: TextStyle(fontSize: 14),
).tr(),
],
),
),
);
},
);
},
),
),
// buildSearchBar(),
],
),
),
SliverLayoutBuilder(
builder: (context, constraints) {
if (constraints.crossAxisExtent > 600) {
return buildAlbumSelectionGrid();
} else {
return buildAlbumSelectionList();
}
},
),
],
),
);
}
}
@@ -1,286 +0,0 @@
import 'dart:io';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/backup_info_card.dart';
import 'package:immich_mobile/widgets/backup/current_backup_asset_info_box.dart';
import 'package:wakelock_plus/wakelock_plus.dart';
@RoutePage()
class BackupControllerPage extends HookConsumerWidget {
const BackupControllerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
BackUpState backupState = ref.watch(backupProvider);
final hasAnyAlbum = backupState.selectedBackupAlbums.isNotEmpty;
final didGetBackupInfo = useState(false);
bool hasExclusiveAccess = backupState.backupProgress != BackUpProgressEnum.inBackground;
bool shouldBackup =
backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ||
!hasExclusiveAccess
? false
: true;
useEffect(() {
// Update the background settings information just to make sure we
// have the latest, since the platform channel will not update
// automatically
if (Platform.isIOS) {
ref.watch(iOSBackgroundSettingsProvider.notifier).refresh();
}
ref.watch(websocketProvider.notifier).stopListenToEvent('on_upload_success');
return () {
WakelockPlus.disable();
};
}, []);
useEffect(() {
if (backupState.backupProgress == BackUpProgressEnum.idle && !didGetBackupInfo.value) {
ref.watch(backupProvider.notifier).getBackupInfo();
didGetBackupInfo.value = true;
}
return null;
}, [backupState.backupProgress]);
useEffect(() {
if (backupState.backupProgress == BackUpProgressEnum.inProgress) {
WakelockPlus.enable();
} else {
WakelockPlus.disable();
}
return null;
}, [backupState.backupProgress]);
Widget buildSelectedAlbumName() {
var text = "backup_controller_page_backup_selected".tr();
var albums = ref.watch(backupProvider).selectedBackupAlbums;
if (albums.isNotEmpty) {
for (var album in albums) {
if (album.name == "Recent" || album.name == "Recents") {
text += "${album.name} (${'all'.tr()}), ";
} else {
text += "${album.name}, ";
}
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
),
);
} else {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
"backup_controller_page_none_selected".tr(),
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
),
);
}
}
Widget buildExcludedAlbumName() {
var text = "backup_controller_page_excluded".tr();
var albums = ref.watch(backupProvider).excludedBackupAlbums;
if (albums.isNotEmpty) {
for (var album in albums) {
text += "${album.name}, ";
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: context.textTheme.labelLarge?.copyWith(color: Colors.red[300]),
),
);
} else {
return const SizedBox();
}
}
buildFolderSelectionTile() {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(20)),
side: BorderSide(color: context.colorScheme.outlineVariant, width: 1),
),
elevation: 0,
borderOnForeground: false,
child: ListTile(
minVerticalPadding: 18,
title: Text("backup_controller_page_albums", style: context.textTheme.titleMedium).tr(),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"backup_controller_page_to_backup",
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
).tr(),
buildSelectedAlbumName(),
buildExcludedAlbumName(),
],
),
),
trailing: ElevatedButton(
onPressed: () async {
await context.pushRoute(const BackupAlbumSelectionRoute());
// waited until returning from selection
await ref.read(backupProvider.notifier).backupAlbumSelectionDone();
// waited until backup albums are stored in DB
await ref.read(albumProvider.notifier).refreshDeviceAlbums();
},
child: const Text("select", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
),
),
),
);
}
void startBackup() {
ref.watch(errorBackupListProvider.notifier).empty();
if (ref.watch(backupProvider).backupProgress != BackUpProgressEnum.inBackground) {
ref.watch(backupProvider.notifier).startBackupProcess();
}
}
Widget buildBackupButton() {
return Padding(
padding: const EdgeInsets.only(top: 24),
child: Container(
child:
backupState.backupProgress == BackUpProgressEnum.inProgress ||
backupState.backupProgress == BackUpProgressEnum.manualInProgress
? ElevatedButton(
style: ElevatedButton.styleFrom(
foregroundColor: Colors.grey[50],
backgroundColor: Colors.red[300],
// padding: const EdgeInsets.all(14),
),
onPressed: () {
if (backupState.backupProgress == BackUpProgressEnum.manualInProgress) {
ref.read(manualUploadProvider.notifier).cancelBackup();
} else {
ref.read(backupProvider.notifier).cancelBackup();
}
},
child: const Text("cancel", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
)
: ElevatedButton(
onPressed: shouldBackup ? startBackup : null,
child: const Text(
"backup_controller_page_start_backup",
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
).tr(),
),
),
);
}
buildBackgroundBackupInfo() {
return ListTile(
leading: const Icon(Icons.info_outline_rounded),
title: Text('background_backup_running_error'.tr()),
);
}
buildLoadingIndicator() {
return const Padding(
padding: EdgeInsets.only(top: 42.0),
child: Center(child: CircularProgressIndicator()),
);
}
return Scaffold(
appBar: AppBar(
elevation: 0,
title: const Text("backup_controller_page_backup").tr(),
leading: IconButton(
onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent();
context.maybePop(true);
},
splashRadius: 24,
icon: const Icon(Icons.arrow_back_ios_rounded),
),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8.0),
child: IconButton(
onPressed: () => context.pushRoute(const BackupOptionsRoute()),
splashRadius: 24,
icon: const Icon(Icons.settings_outlined),
),
),
],
),
body: Stack(
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
child: ListView(
// crossAxisAlignment: CrossAxisAlignment.start,
children: hasAnyAlbum
? [
buildFolderSelectionTile(),
BackupInfoCard(
title: "total".tr(),
subtitle: "backup_controller_page_total_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${backupState.allUniqueAssets.length}",
),
BackupInfoCard(
title: "backup_controller_page_backup".tr(),
subtitle: "backup_controller_page_backup_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${backupState.selectedAlbumsBackupAssetsIds.length}",
),
BackupInfoCard(
title: "backup_controller_page_remainder".tr(),
subtitle: "backup_controller_page_remainder_sub".tr(),
info: ref.watch(backupProvider).availableAlbums.isEmpty
? "..."
: "${max(0, backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length)}",
),
const Divider(),
const CurrentUploadingAssetInfoBox(),
if (!hasExclusiveAccess) buildBackgroundBackupInfo(),
buildBackupButton(),
]
: [buildFolderSelectionTile(), if (!didGetBackupInfo.value) buildLoadingIndicator()],
),
),
],
),
);
}
}
@@ -1,24 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart';
@RoutePage()
class BackupOptionsPage extends StatelessWidget {
const BackupOptionsPage({super.key});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
elevation: 0,
title: const Text("backup_options_page_title").tr(),
leading: IconButton(
onPressed: () => context.maybePop(true),
splashRadius: 24,
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: const BackupSettings(),
);
}
}
@@ -1,116 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:intl/intl.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset;
@RoutePage()
class FailedBackupStatusPage extends HookConsumerWidget {
const FailedBackupStatusPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final errorBackupList = ref.watch(errorBackupListProvider);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Text(
"Failed Backup (${errorBackupList.length})",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
leading: IconButton(
onPressed: () {
context.maybePop(true);
},
splashRadius: 24,
icon: const Icon(Icons.arrow_back_ios_rounded),
),
),
body: ListView.builder(
shrinkWrap: true,
itemCount: errorBackupList.length,
itemBuilder: ((context, index) {
var errorAsset = errorBackupList.elementAt(index);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 4),
child: Card(
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(
Radius.circular(15), // if you need this
),
side: BorderSide(color: Colors.black12, width: 1),
),
elevation: 0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ConstrainedBox(
constraints: const BoxConstraints(minWidth: 100, minHeight: 100, maxWidth: 100, maxHeight: 150),
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(15),
topLeft: Radius.circular(15),
),
clipBehavior: Clip.hardEdge,
child: Image(
fit: BoxFit.cover,
image: LocalThumbProvider(id: errorAsset.asset.localId!, assetType: base_asset.AssetType.video),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
DateFormat.yMMMMd().format(
DateTime.parse(errorAsset.fileCreatedAt.toString()).toLocal(),
),
style: TextStyle(
fontWeight: FontWeight.w600,
color: context.isDarkTheme ? Colors.white70 : Colors.grey[800],
),
),
Icon(Icons.error, color: Colors.red.withAlpha(200), size: 18),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
errorAsset.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(fontWeight: FontWeight.bold, color: context.primaryColor),
),
),
Text(
errorAsset.errorMessage,
style: TextStyle(
fontWeight: FontWeight.w500,
color: context.isDarkTheme ? Colors.white70 : Colors.grey[800],
),
),
],
),
),
),
],
),
),
);
}),
),
);
}
}
@@ -1,97 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/activities/activity_text_field.dart';
import 'package:immich_mobile/widgets/activities/activity_tile.dart';
import 'package:immich_mobile/widgets/activities/dismissible_activity.dart';
@RoutePage()
class ActivitiesPage extends HookConsumerWidget {
const ActivitiesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
// Album has to be set in the provider before reaching this page
final album = ref.watch(currentAlbumProvider)!;
final asset = ref.watch(currentAssetProvider);
final user = ref.watch(currentUserProvider);
final activityNotifier = ref.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final activities = ref.watch(albumActivityProvider(album.remoteId!, asset?.remoteId));
final listViewScrollController = useScrollController();
Future<void> onAddComment(String comment) async {
await activityNotifier.addComment(comment);
// Scroll to the end of the list to show the newly added activity
await listViewScrollController.animateTo(
listViewScrollController.position.maxScrollExtent + 200,
duration: const Duration(milliseconds: 600),
curve: Curves.fastOutSlowIn,
);
}
return Scaffold(
appBar: AppBar(title: asset == null ? Text(album.name) : null),
body: activities.widgetWhen(
onData: (data) {
final liked = data.firstWhereOrNull(
(a) => a.type == ActivityType.like && a.user.id == user?.id && a.assetId == asset?.remoteId,
);
return SafeArea(
child: Stack(
children: [
ListView.builder(
controller: listViewScrollController,
// +1 to display an additional over-scroll space after the last element
itemCount: data.length + 1,
itemBuilder: (context, index) {
// Additional vertical gap after the last element
if (index == data.length) {
return const SizedBox(height: 80);
}
final activity = data[index];
final canDelete = activity.user.id == user?.id || album.ownerId == user?.id;
return Padding(
padding: const EdgeInsets.all(5),
child: DismissibleActivity(
activity.id,
ActivityTile(activity),
onDismiss: canDelete
? (activityId) async => await activityNotifier.removeActivity(activity.id)
: null,
),
);
},
),
Align(
alignment: Alignment.bottomCenter,
child: Container(
color: context.scaffoldBackgroundColor,
child: ActivityTextField(
isEnabled: album.activityEnabled,
likeId: liked?.id,
onSubmit: onAddComment,
),
),
),
],
),
);
},
),
);
}
}
@@ -1,168 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/background.service.dart';
import 'package:immich_mobile/utils/migration.dart';
import 'package:logging/logging.dart';
import 'package:permission_handler/permission_handler.dart';
@RoutePage()
class ChangeExperiencePage extends ConsumerStatefulWidget {
final bool switchingToBeta;
const ChangeExperiencePage({super.key, required this.switchingToBeta});
@override
ConsumerState createState() => _ChangeExperiencePageState();
}
class _ChangeExperiencePageState extends ConsumerState<ChangeExperiencePage> {
AsyncValue<bool> hasMigrated = const AsyncValue.loading();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _handleMigration());
}
Future<void> _handleMigration() async {
try {
await _performMigrationLogic().timeout(
const Duration(minutes: 3),
onTimeout: () async {
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
await DriftStoreRepository(ref.read(driftProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
},
);
if (mounted) {
setState(() {
HapticFeedback.heavyImpact();
hasMigrated = const AsyncValue.data(true);
});
}
} catch (e, s) {
Logger("ChangeExperiencePage").severe("Error during migration", e, s);
if (mounted) {
setState(() {
hasMigrated = AsyncValue.error(e, s);
});
}
}
}
Future<void> _performMigrationLogic() async {
if (widget.switchingToBeta) {
final assetNotifier = ref.read(assetProvider.notifier);
if (assetNotifier.mounted) {
assetNotifier.dispose();
}
final albumNotifier = ref.read(albumProvider.notifier);
if (albumNotifier.mounted) {
albumNotifier.dispose();
}
// Cancel uploads
await Store.put(StoreKey.backgroundBackup, false);
ref
.read(backupProvider.notifier)
.configureBackgroundBackup(enabled: false, onBatteryInfo: () {}, onError: (_) {});
ref.read(backupProvider.notifier).setAutoBackup(false);
ref.read(backupProvider.notifier).cancelBackup();
ref.read(manualUploadProvider.notifier).cancelBackup();
// Start listening to new websocket events
ref.read(websocketProvider.notifier).stopListenToOldEvents();
ref.read(websocketProvider.notifier).startListeningToBetaEvents();
await ref.read(driftProvider).reset();
await Store.put(StoreKey.shouldResetSync, true);
final delay = Store.get(StoreKey.backupTriggerDelay, AppSettingsEnum.backupTriggerDelay.defaultValue);
if (delay >= 1000) {
await Store.put(StoreKey.backupTriggerDelay, (delay / 1000).toInt());
}
final permission = await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (permission.isGranted) {
await ref.read(backgroundSyncProvider).syncLocal(full: true);
await migrateDeviceAssetToSqlite(ref.read(isarProvider), ref.read(driftProvider));
await migrateBackupAlbumsToSqlite(ref.read(isarProvider), ref.read(driftProvider));
await migrateStoreToSqlite(ref.read(isarProvider), ref.read(driftProvider));
await ref.read(backgroundServiceProvider).disableService();
}
} else {
await ref.read(backgroundSyncProvider).cancel();
ref.read(websocketProvider.notifier).stopListeningToBetaEvents();
ref.read(websocketProvider.notifier).startListeningToOldEvents();
ref.read(readonlyModeProvider.notifier).setReadonlyMode(false);
await migrateStoreToIsar(ref.read(isarProvider), ref.read(driftProvider));
await ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
await ref.read(backgroundWorkerFgServiceProvider).disable();
}
await IsarStoreRepository(ref.read(isarProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
await DriftStoreRepository(ref.read(driftProvider)).upsert(StoreKey.betaTimeline, widget.switchingToBeta);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedSwitcher(
duration: Durations.long4,
child: hasMigrated.when(
data: (data) => const Icon(Icons.check_circle_rounded, color: Colors.green, size: 48.0),
error: (error, stackTrace) => const Icon(Icons.error, color: Colors.red, size: 48.0),
loading: () => const SizedBox(width: 50.0, height: 50.0, child: CircularProgressIndicator()),
),
),
const SizedBox(height: 16.0),
SizedBox(
width: 300.0,
child: AnimatedSwitcher(
duration: Durations.long4,
child: hasMigrated.when(
data: (data) => Text(
"Migration success!\nPlease close and reopen the app to apply changes",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
error: (error, stackTrace) => Text(
"Migration failed!\nError: $error",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
loading: () => Text(
"Data migration in progress...\nPlease wait and don't close this page",
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
),
),
),
),
],
),
),
);
}
}
@@ -1,238 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/albums/asset_selection_page_result.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_title.provider.dart';
import 'package:immich_mobile/providers/album/album_viewer.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/album/album_action_filled_button.dart';
import 'package:immich_mobile/widgets/album/album_title_text_field.dart';
import 'package:immich_mobile/widgets/album/album_viewer_editable_description.dart';
import 'package:immich_mobile/widgets/album/shared_album_thumbnail_image.dart';
@RoutePage()
// ignore: must_be_immutable
class CreateAlbumPage extends HookConsumerWidget {
final List<Asset>? assets;
const CreateAlbumPage({super.key, this.assets});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumTitleController = useTextEditingController.fromValue(TextEditingValue.empty);
final albumTitleTextFieldFocusNode = useFocusNode();
final albumDescriptionTextFieldFocusNode = useFocusNode();
final isAlbumTitleTextFieldFocus = useState(false);
final isAlbumTitleEmpty = useState(true);
final selectedAssets = useState<Set<Asset>>(assets != null ? Set.from(assets!) : const {});
void onBackgroundTapped() {
albumTitleTextFieldFocusNode.unfocus();
albumDescriptionTextFieldFocusNode.unfocus();
isAlbumTitleTextFieldFocus.value = false;
if (albumTitleController.text.isEmpty) {
albumTitleController.text = 'create_album_page_untitled'.tr();
isAlbumTitleEmpty.value = false;
ref.watch(albumTitleProvider.notifier).setAlbumTitle('create_album_page_untitled'.tr());
}
}
onSelectPhotosButtonPressed() async {
AssetSelectionPageResult? selectedAsset = await context.pushRoute<AssetSelectionPageResult?>(
AlbumAssetSelectionRoute(existingAssets: selectedAssets.value, canDeselect: true),
);
if (selectedAsset == null) {
selectedAssets.value = const {};
} else {
selectedAssets.value = selectedAsset.selectedAssets;
}
}
buildTitleInputField() {
return Padding(
padding: const EdgeInsets.only(right: 10, left: 10),
child: AlbumTitleTextField(
isAlbumTitleEmpty: isAlbumTitleEmpty,
albumTitleTextFieldFocusNode: albumTitleTextFieldFocusNode,
albumTitleController: albumTitleController,
isAlbumTitleTextFieldFocus: isAlbumTitleTextFieldFocus,
),
);
}
buildDescriptionInputField() {
return Padding(
padding: const EdgeInsets.only(right: 10, left: 10),
child: AlbumViewerEditableDescription(
albumDescription: '',
descriptionFocusNode: albumDescriptionTextFieldFocusNode,
),
);
}
buildTitle() {
if (selectedAssets.value.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 200, left: 18),
child: Text('create_shared_album_page_share_add_assets', style: context.textTheme.labelLarge).tr(),
),
);
}
return const SliverToBoxAdapter();
}
buildSelectPhotosButton() {
if (selectedAssets.value.isEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(top: 16, left: 16, right: 16),
child: FilledButton.icon(
style: FilledButton.styleFrom(
alignment: Alignment.centerLeft,
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 16),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(10))),
backgroundColor: context.colorScheme.surfaceContainerHigh,
),
onPressed: onSelectPhotosButtonPressed,
icon: Icon(Icons.add_rounded, color: context.primaryColor),
label: Padding(
padding: const EdgeInsets.only(left: 8.0),
child: Text(
'create_shared_album_page_share_select_photos',
style: context.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w600,
color: context.primaryColor,
),
).tr(),
),
),
),
);
}
return const SliverToBoxAdapter();
}
buildControlButton() {
return Padding(
padding: const EdgeInsets.only(left: 12.0, top: 16, bottom: 16),
child: SizedBox(
height: 42,
child: ListView(
scrollDirection: Axis.horizontal,
children: [
AlbumActionFilledButton(
iconData: Icons.add_photo_alternate_outlined,
onPressed: onSelectPhotosButtonPressed,
labelText: "add_photos".tr(),
),
],
),
),
);
}
buildSelectedImageGrid() {
if (selectedAssets.value.isNotEmpty) {
return SliverPadding(
padding: const EdgeInsets.only(top: 16),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
crossAxisSpacing: 5.0,
mainAxisSpacing: 5,
),
delegate: SliverChildBuilderDelegate((BuildContext context, int index) {
return GestureDetector(
onTap: onBackgroundTapped,
child: SharedAlbumThumbnailImage(asset: selectedAssets.value.elementAt(index)),
);
}, childCount: selectedAssets.value.length),
),
);
}
return const SliverToBoxAdapter();
}
Future<void> createAlbum() async {
onBackgroundTapped();
var newAlbum = await ref
.watch(albumProvider.notifier)
.createAlbum(ref.read(albumTitleProvider), selectedAssets.value);
if (newAlbum != null) {
await ref.read(albumProvider.notifier).refreshRemoteAlbums();
selectedAssets.value = {};
ref.read(albumTitleProvider.notifier).clearAlbumTitle();
ref.read(albumViewerProvider.notifier).disableEditAlbum();
unawaited(context.replaceRoute(AlbumViewerRoute(albumId: newAlbum.id)));
}
}
return Scaffold(
appBar: AppBar(
elevation: 0,
centerTitle: false,
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
onPressed: () {
selectedAssets.value = {};
context.maybePop();
},
icon: const Icon(Icons.close_rounded),
),
title: const Text('create_album').tr(),
actions: [
TextButton(
onPressed: albumTitleController.text.isNotEmpty ? createAlbum : null,
child: Text(
'create'.tr(),
style: TextStyle(
fontWeight: FontWeight.bold,
color: albumTitleController.text.isNotEmpty ? context.primaryColor : context.themeData.disabledColor,
),
),
),
],
),
body: GestureDetector(
onTap: onBackgroundTapped,
child: CustomScrollView(
slivers: [
SliverAppBar(
backgroundColor: context.scaffoldBackgroundColor,
elevation: 5,
automaticallyImplyLeading: false,
pinned: true,
floating: false,
bottom: PreferredSize(
preferredSize: const Size.fromHeight(125.0),
child: Column(
children: [
buildTitleInputField(),
buildDescriptionInputField(),
if (selectedAssets.value.isNotEmpty) buildControlButton(),
],
),
),
),
buildTitle(),
buildSelectPhotosButton(),
buildSelectedImageGrid(),
],
),
),
);
}
}
@@ -1,85 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
class GalleryStackedChildren extends HookConsumerWidget {
final ValueNotifier<int> stackIndex;
const GalleryStackedChildren(this.stackIndex, {super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetProvider);
if (asset == null) {
return const SizedBox();
}
final stackId = asset.stackId;
if (stackId == null) {
return const SizedBox();
}
final stackElements = ref.watch(assetStackStateProvider(stackId));
final showControls = ref.watch(showControlsProvider);
return IgnorePointer(
ignoring: !showControls,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: showControls ? 1.0 : 0.0,
child: SizedBox(
height: 80,
child: ListView.builder(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
itemCount: stackElements.length,
padding: const EdgeInsets.only(left: 5, right: 5, bottom: 30),
itemBuilder: (context, index) {
final currentAsset = stackElements.elementAt(index);
final assetId = currentAsset.remoteId;
if (assetId == null) {
return const SizedBox();
}
return Padding(
key: ValueKey(currentAsset.id),
padding: const EdgeInsets.only(right: 5),
child: GestureDetector(
onTap: () {
stackIndex.value = index;
ref.read(currentAssetProvider.notifier).set(currentAsset);
},
child: Container(
width: 60,
height: 60,
decoration: index == stackIndex.value
? const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: Border.fromBorderSide(BorderSide(color: Colors.white, width: 2)),
)
: const BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.all(Radius.circular(6)),
border: null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(4)),
child: Image(
fit: BoxFit.cover,
image: RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: asset.thumbhash ?? ""),
),
),
),
),
);
},
),
),
),
);
}
}
@@ -1,438 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'dart:math';
import 'dart:ui' as ui;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/scroll_extensions.dart';
import 'package:immich_mobile/pages/common/download_panel.dart';
import 'package:immich_mobile/pages/common/gallery_stacked_children.dart';
import 'package:immich_mobile/pages/common/native_video_viewer.page.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_viewer/advanced_bottom_sheet.dart';
import 'package:immich_mobile/widgets/asset_viewer/bottom_gallery_bar.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/detail_panel.dart';
import 'package:immich_mobile/widgets/asset_viewer/gallery_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
import 'package:immich_mobile/widgets/photo_view/photo_view_gallery.dart';
import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.dart';
import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart';
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
@RoutePage()
// ignore: must_be_immutable
/// Expects [currentAssetProvider] to be set before navigating to this page
class GalleryViewerPage extends HookConsumerWidget {
final int initialIndex;
final int heroOffset;
final bool showStack;
final RenderList renderList;
GalleryViewerPage({
super.key,
required this.renderList,
this.initialIndex = 0,
this.heroOffset = 0,
this.showStack = false,
}) : controller = PageController(initialPage: initialIndex);
final PageController controller;
@override
Widget build(BuildContext context, WidgetRef ref) {
final totalAssets = useState(renderList.totalAssets);
final isZoomed = useState(false);
final stackIndex = useState(0);
final localPosition = useRef<Offset?>(null);
final currentIndex = useValueNotifier(initialIndex);
final loadAsset = renderList.loadAsset;
final isPlayingMotionVideo = ref.watch(isPlayingMotionVideoProvider);
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final videoPlayerKeys = useRef<Map<int, GlobalKey>>({});
GlobalKey getVideoPlayerKey(int id) {
videoPlayerKeys.value.putIfAbsent(id, () => GlobalKey());
return videoPlayerKeys.value[id]!;
}
Future<void> precacheNextImage(int index) async {
if (!context.mounted) {
return;
}
void onError(Object exception, StackTrace? stackTrace) {
// swallow error silently
log.severe('Error precaching next image: $exception, $stackTrace');
}
try {
if (index < totalAssets.value && index >= 0) {
final asset = loadAsset(index);
await precacheImage(
ImmichImage.imageProvider(asset: asset, width: context.width, height: context.height),
context,
onError: onError,
);
}
} catch (e) {
// swallow error silently
log.severe('Error precaching next image: $e');
await context.maybePop();
}
}
useEffect(() {
if (ref.read(showControlsProvider)) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
} else {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
}
// Delay this a bit so we can finish loading the page
Timer(const Duration(milliseconds: 400), () {
precacheNextImage(currentIndex.value + 1);
});
return null;
}, const []);
useEffect(() {
final asset = loadAsset(currentIndex.value);
if (asset.isRemote) {
ref.read(castProvider.notifier).loadMediaOld(asset, false);
} else {
if (isCasting) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
ref.read(castProvider.notifier).stop();
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 1),
content: Text(
"local_asset_cast_failed".tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
}
});
}
}
return null;
}, [ref.watch(castProvider).isCasting]);
void showInfo() {
final asset = ref.read(currentAssetProvider);
if (asset == null) {
return;
}
showModalBottomSheet(
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15.0))),
barrierColor: Colors.transparent,
isScrollControlled: true,
showDragHandle: true,
enableDrag: true,
context: context,
useSafeArea: true,
builder: (context) {
return DraggableScrollableSheet(
minChildSize: 0.5,
maxChildSize: 1,
initialChildSize: 0.75,
expand: false,
builder: (context, scrollController) {
return Padding(
padding: EdgeInsets.only(bottom: context.viewInsets.bottom),
child: ref.watch(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.advancedTroubleshooting)
? AdvancedBottomSheet(assetDetail: asset, scrollController: scrollController)
: DetailPanel(asset: asset, scrollController: scrollController),
);
},
);
},
);
}
void handleSwipeUpDown(DragUpdateDetails details) {
const int sensitivity = 15;
const int dxThreshold = 50;
const double ratioThreshold = 3.0;
if (isZoomed.value) {
return;
}
// Guard [localPosition] null
if (localPosition.value == null) {
return;
}
// Check for delta from initial down point
final d = details.localPosition - localPosition.value!;
// If the magnitude of the dx swipe is large, we probably didn't mean to go down
if (d.dx.abs() > dxThreshold) {
return;
}
final ratio = d.dy / max(d.dx.abs(), 1);
if (d.dy > sensitivity && ratio > ratioThreshold) {
context.maybePop();
} else if (d.dy < -sensitivity && ratio < -ratioThreshold) {
showInfo();
}
}
ref.listen(showControlsProvider, (_, show) {
if (show || Platform.isIOS) {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
return;
}
// This prevents the bottom bar from "dropping" while the controls are being hidden
Timer(const Duration(milliseconds: 100), () {
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
});
});
PhotoViewGalleryPageOptions buildImage(Asset asset) {
return PhotoViewGalleryPageOptions(
onDragStart: (_, details, __, ___) {
localPosition.value = details.localPosition;
},
onDragUpdate: (_, details, __) {
handleSwipeUpDown(details);
},
onTapDown: (ctx, tapDownDetails, _) {
final tapToNavigate = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.tapToNavigate);
if (!tapToNavigate) {
ref.read(showControlsProvider.notifier).toggle();
return;
}
double tapX = tapDownDetails.globalPosition.dx;
double screenWidth = ctx.width;
// We want to change images if the user taps in the leftmost or
// rightmost quarter of the screen
bool tappedLeftSide = tapX < screenWidth / 4;
bool tappedRightSide = tapX > screenWidth * (3 / 4);
int? currentPage = controller.page?.toInt();
int maxPage = renderList.totalAssets - 1;
if (tappedLeftSide && currentPage != null) {
// Nested if because we don't want to fallback to show/hide controls
if (currentPage != 0) {
controller.jumpToPage(currentPage - 1);
}
} else if (tappedRightSide && currentPage != null) {
// Nested if because we don't want to fallback to show/hide controls
if (currentPage != maxPage) {
controller.jumpToPage(currentPage + 1);
}
} else {
ref.read(showControlsProvider.notifier).toggle();
}
},
onLongPressStart: asset.isMotionPhoto
? (_, __, ___) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = true;
}
: null,
imageProvider: ImmichImage.imageProvider(asset: asset),
heroAttributes: _getHeroAttributes(asset),
filterQuality: FilterQuality.high,
tightMode: true,
initialScale: PhotoViewComputedScale.contained * 0.99,
minScale: PhotoViewComputedScale.contained * 0.99,
errorBuilder: (context, error, stackTrace) => ImmichImage(asset, fit: BoxFit.contain),
);
}
PhotoViewGalleryPageOptions buildVideo(BuildContext context, Asset asset) {
return PhotoViewGalleryPageOptions.customChild(
onDragStart: (_, details, __, ___) => localPosition.value = details.localPosition,
onDragUpdate: (_, details, __) => handleSwipeUpDown(details),
heroAttributes: _getHeroAttributes(asset),
filterQuality: FilterQuality.high,
initialScale: PhotoViewComputedScale.contained * 0.99,
maxScale: 1.0,
minScale: PhotoViewComputedScale.contained * 0.99,
basePosition: Alignment.center,
child: SizedBox(
width: context.width,
height: context.height,
child: NativeVideoViewerPage(
key: getVideoPlayerKey(asset.id),
asset: asset,
image: Image(
key: ValueKey(asset),
image: ImmichImage.imageProvider(asset: asset, width: context.width, height: context.height),
fit: BoxFit.contain,
height: context.height,
width: context.width,
alignment: Alignment.center,
),
),
),
);
}
PhotoViewGalleryPageOptions buildAsset(BuildContext context, int index) {
var newAsset = loadAsset(index);
final stackId = newAsset.stackId;
if (stackId != null && currentIndex.value == index) {
final stackElements = ref.read(assetStackStateProvider(newAsset.stackId!));
if (stackIndex.value < stackElements.length) {
newAsset = stackElements.elementAt(stackIndex.value);
}
}
if (newAsset.isImage && !isPlayingMotionVideo) {
return buildImage(newAsset);
}
return buildVideo(context, newAsset);
}
return PopScope(
// Change immersive mode back to normal "edgeToEdge" mode
onPopInvokedWithResult: (didPop, _) => SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge),
child: Scaffold(
backgroundColor: Colors.black,
body: Stack(
children: [
PhotoViewGallery.builder(
key: const ValueKey('gallery'),
scaleStateChangedCallback: (state) {
final asset = ref.read(currentAssetProvider);
if (asset == null) {
return;
}
if (asset.isImage && !ref.read(isPlayingMotionVideoProvider)) {
isZoomed.value = state != PhotoViewScaleState.initial;
ref.read(showControlsProvider.notifier).show = !isZoomed.value;
}
},
gaplessPlayback: true,
loadingBuilder: (context, event, index) {
final asset = loadAsset(index);
return ClipRect(
child: Stack(
fit: StackFit.expand,
children: [
BackdropFilter(filter: ui.ImageFilter.blur(sigmaX: 10, sigmaY: 10)),
ImmichThumbnail(key: ValueKey(asset), asset: asset, fit: BoxFit.contain),
],
),
);
},
pageController: controller,
scrollPhysics: isZoomed.value
? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in
: (Platform.isIOS
? const FastScrollPhysics() // Use bouncing physics for iOS
: const FastClampingScrollPhysics() // Use heavy physics for Android
),
itemCount: totalAssets.value,
scrollDirection: Axis.horizontal,
onPageChanged: (value, _) {
final next = currentIndex.value < value ? value + 1 : value - 1;
ref.read(hapticFeedbackProvider.notifier).selectionClick();
final newAsset = loadAsset(value);
currentIndex.value = value;
stackIndex.value = 0;
ref.read(currentAssetProvider.notifier).set(newAsset);
// Wait for page change animation to finish, then precache the next image
Timer(const Duration(milliseconds: 400), () {
precacheNextImage(next);
});
context.scaffoldMessenger.hideCurrentSnackBar();
// send image to casting if the server has it
if (newAsset.isRemote) {
ref.read(castProvider.notifier).loadMediaOld(newAsset, false);
} else {
context.scaffoldMessenger.clearSnackBars();
if (isCasting) {
ref.read(castProvider.notifier).stop();
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
"local_asset_cast_failed".tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
}
}
},
builder: buildAsset,
),
Positioned(
top: 0,
left: 0,
right: 0,
child: GalleryAppBar(key: const ValueKey('app-bar'), showInfo: showInfo),
),
Positioned(
bottom: 0,
left: 0,
right: 0,
child: Column(
children: [
GalleryStackedChildren(stackIndex),
BottomGalleryBar(
key: const ValueKey('bottom-bar'),
renderList: renderList,
totalAssets: totalAssets,
controller: controller,
showStack: showStack,
stackIndex: stackIndex,
assetIndex: currentIndex,
),
],
),
),
const DownloadPanel(),
],
),
),
);
}
@pragma('vm:prefer-inline')
PhotoViewHeroAttributes _getHeroAttributes(Asset asset) {
return PhotoViewHeroAttributes(
tag: asset.isInDb ? asset.id + heroOffset : '${asset.remoteId}-$heroOffset',
transitionOnUserGestures: true,
);
}
}
@@ -1,282 +0,0 @@
import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
import 'package:logging/logging.dart';
import 'package:native_video_player/native_video_player.dart';
@RoutePage()
class NativeVideoViewerPage extends HookConsumerWidget {
static final log = Logger('NativeVideoViewer');
final Asset asset;
final bool showControls;
final int playbackDelayFactor;
final Widget image;
const NativeVideoViewerPage({
super.key,
required this.asset,
required this.image,
this.showControls = true,
this.playbackDelayFactor = 1,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final videoId = asset.id.toString();
final controller = useState<NativeVideoPlayerController?>(null);
final shouldPlayOnForeground = useRef(true);
final currentAsset = useState(ref.read(currentAssetProvider));
final isCurrent = currentAsset.value == asset;
// Used to show the placeholder during hero animations for remote videos to avoid a stutter
final isVisible = useState(Platform.isIOS && asset.isLocal);
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final isVideoReady = useState(false);
Future<VideoSource?> createSource() async {
if (!context.mounted) {
return null;
}
try {
final local = asset.local;
if (local != null && asset.livePhotoVideoId == null) {
final file = await local.file;
if (file == null) {
throw Exception('No file found for the video');
}
final source = await VideoSource.init(path: file.path, type: VideoSourceType.file);
return source;
}
// Use a network URL for the video player controller
final serverEndpoint = Store.get(StoreKey.serverEndpoint);
final isOriginalVideo = ref
.read(appSettingsServiceProvider)
.getSetting<bool>(AppSettingsEnum.loadOriginalVideo);
final String postfixUrl = isOriginalVideo ? 'original' : 'video/playback';
final String videoUrl = asset.livePhotoVideoId != null
? '$serverEndpoint/assets/${asset.livePhotoVideoId}/$postfixUrl'
: '$serverEndpoint/assets/${asset.remoteId}/$postfixUrl';
final source = await VideoSource.init(
path: videoUrl,
type: VideoSourceType.network,
headers: ApiService.getRequestHeaders(),
);
return source;
} catch (error) {
log.severe('Error creating video source for asset ${asset.fileName}: $error');
return null;
}
}
final videoSource = useMemoized<Future<VideoSource?>>(() => createSource());
final aspectRatio = useState<double?>(asset.aspectRatio);
useMemoized(() async {
if (!context.mounted || aspectRatio.value != null) {
return null;
}
try {
aspectRatio.value = await ref.read(assetServiceProvider).getAspectRatio(asset);
} catch (error) {
log.severe('Error getting aspect ratio for asset ${asset.fileName}: $error');
}
});
void onPlaybackReady() async {
final videoController = controller.value;
if (videoController == null || !isCurrent || !context.mounted) {
return;
}
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
notifier.onNativePlaybackReady();
isVideoReady.value = true;
try {
final autoPlayVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.autoPlayVideo);
if (autoPlayVideo) {
await notifier.play();
}
await notifier.setVolume(1);
} catch (error) {
log.severe('Error playing video: $error');
}
}
void onPlaybackStatusChanged() {
if (!context.mounted) return;
ref.read(videoPlayerProvider(videoId).notifier).onNativeStatusChanged();
}
void onPlaybackPositionChanged() {
if (!context.mounted) return;
ref.read(videoPlayerProvider(videoId).notifier).onNativePositionChanged();
}
void onPlaybackEnded() {
if (!context.mounted) return;
ref.read(videoPlayerProvider(videoId).notifier).onNativePlaybackEnded();
final videoController = controller.value;
if (videoController?.playbackInfo?.status == PlaybackStatus.stopped &&
!ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo)) {
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
}
}
void removeListeners(NativeVideoPlayerController controller) {
controller.onPlaybackPositionChanged.removeListener(onPlaybackPositionChanged);
controller.onPlaybackStatusChanged.removeListener(onPlaybackStatusChanged);
controller.onPlaybackReady.removeListener(onPlaybackReady);
controller.onPlaybackEnded.removeListener(onPlaybackEnded);
}
void initController(NativeVideoPlayerController nc) async {
if (controller.value != null || !context.mounted) {
return;
}
final source = await videoSource;
if (source == null) {
return;
}
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
notifier.attachController(nc);
nc.onPlaybackPositionChanged.addListener(onPlaybackPositionChanged);
nc.onPlaybackStatusChanged.addListener(onPlaybackStatusChanged);
nc.onPlaybackReady.addListener(onPlaybackReady);
nc.onPlaybackEnded.addListener(onPlaybackEnded);
unawaited(
nc.loadVideoSource(source).catchError((error) {
log.severe('Error loading video source: $error');
}),
);
final loopVideo = ref.read(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.loopVideo);
await notifier.setLoop(loopVideo);
controller.value = nc;
}
ref.listen(currentAssetProvider, (_, value) {
final playerController = controller.value;
if (playerController != null && value != asset) {
removeListeners(playerController);
}
final curAsset = currentAsset.value;
if (curAsset == asset) {
return;
}
final imageToVideo = curAsset != null && !curAsset.isVideo;
// No need to delay video playback when swiping from an image to a video
if (imageToVideo && Platform.isIOS) {
currentAsset.value = value;
onPlaybackReady();
return;
}
// Delay the video playback to avoid a stutter in the swipe animation
Timer(
Platform.isIOS
? Duration(milliseconds: 300 * playbackDelayFactor)
: imageToVideo
? Duration(milliseconds: 200 * playbackDelayFactor)
: Duration(milliseconds: 400 * playbackDelayFactor),
() {
if (!context.mounted) {
return;
}
currentAsset.value = value;
if (currentAsset.value == asset) {
onPlaybackReady();
}
},
);
});
useEffect(() {
// If opening a remote video from a hero animation, delay visibility to avoid a stutter
final timer = isVisible.value ? null : Timer(const Duration(milliseconds: 300), () => isVisible.value = true);
return () {
timer?.cancel();
final playerController = controller.value;
if (playerController == null) {
return;
}
removeListeners(playerController);
playerController.stop().catchError((error) {
log.fine('Error stopping video: $error');
});
};
}, const []);
useOnAppLifecycleStateChange((_, state) async {
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
if (state == AppLifecycleState.resumed && shouldPlayOnForeground.value) {
await notifier.play();
} else if (state == AppLifecycleState.paused) {
final videoPlaying = await controller.value?.isPlaying();
if (videoPlaying ?? true) {
shouldPlayOnForeground.value = true;
await notifier.pause();
} else {
shouldPlayOnForeground.value = false;
}
}
});
return Stack(
children: [
// This remains under the video to avoid flickering
// For motion videos, this is the image portion of the asset
if (!isVideoReady.value || asset.isMotionPhoto) Center(key: ValueKey(asset.id), child: image),
if (aspectRatio.value != null && !isCasting)
Visibility.maintain(
key: ValueKey(asset),
visible: isVisible.value,
child: Center(
key: ValueKey(asset),
child: AspectRatio(
key: ValueKey(asset),
aspectRatio: aspectRatio.value!,
child: isCurrent ? NativeVideoPlayerView(key: ValueKey(asset), onViewReady: initController) : null,
),
),
),
if (showControls) Center(child: CustomVideoPlayerControls(videoId: videoId)),
],
);
}
}
+7 -12
View File
@@ -2,14 +2,11 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/settings/advanced_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_viewer_settings/asset_viewer_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/backup_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/drift_backup_settings.dart';
import 'package:immich_mobile/widgets/settings/beta_sync_settings/sync_status_and_actions.dart';
import 'package:immich_mobile/widgets/settings/free_up_space_settings.dart';
@@ -38,8 +35,7 @@ enum SettingSection {
Widget get widget => switch (this) {
SettingSection.advanced => const AdvancedSettings(),
SettingSection.assetViewer => const AssetViewerSettings(),
SettingSection.backup =>
Store.tryGet(StoreKey.betaTimeline) ?? false ? const DriftBackupSettings() : const BackupSettings(),
SettingSection.backup => const DriftBackupSettings(),
SettingSection.freeUpSpace => const FreeUpSpaceSettings(),
SettingSection.languages => const LanguageSettings(),
SettingSection.networking => const NetworkingSettings(),
@@ -74,13 +70,12 @@ class _MobileLayout extends StatelessWidget {
.expand(
(setting) => setting == SettingSection.beta
? [
if (Store.isBetaTimelineEnabled)
SettingsCard(
icon: Icons.sync_outlined,
title: 'sync_status'.tr(),
subtitle: 'sync_status_subtitle'.tr(),
settingRoute: const SyncStatusRoute(),
),
SettingsCard(
icon: Icons.sync_outlined,
title: 'sync_status'.tr(),
subtitle: 'sync_status_subtitle'.tr(),
settingRoute: const SyncStatusRoute(),
),
]
: [
SettingsCard(
+21 -76
View File
@@ -12,13 +12,9 @@ import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/generated/codegen_loader.g.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -27,6 +23,8 @@ import 'package:immich_mobile/theme/theme_data.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:logging/logging.dart';
import 'package:path/path.dart' as path;
import 'package:path_provider/path_provider.dart';
import 'package:url_launcher/url_launcher.dart' show launchUrl, LaunchMode;
class BootstrapErrorWidget extends StatelessWidget {
@@ -323,29 +321,27 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
wsProvider.connect();
unawaited(infoProvider.getServerInfo());
if (Store.isBetaTimelineEnabled) {
bool syncSuccess = false;
bool syncSuccess = false;
await Future.wait([
backgroundManager.syncLocal(full: true),
backgroundManager.syncRemote().then((success) => syncSuccess = success),
]);
if (syncSuccess) {
await Future.wait([
backgroundManager.syncLocal(full: true),
backgroundManager.syncRemote().then((success) => syncSuccess = success),
backgroundManager.hashAssets().then((_) {
_resumeBackup(backupProvider);
}),
_resumeBackup(backupProvider),
// TODO: Bring back when the soft freeze issue is addressed
// backgroundManager.syncCloudIds(),
]);
} else {
await backgroundManager.hashAssets();
}
if (syncSuccess) {
await Future.wait([
backgroundManager.hashAssets().then((_) {
_resumeBackup(backupProvider);
}),
_resumeBackup(backupProvider),
// TODO: Bring back when the soft freeze issue is addressed
// backgroundManager.syncCloudIds(),
]);
} else {
await backgroundManager.hashAssets();
}
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
if (Store.get(StoreKey.syncAlbums, false)) {
await backgroundManager.syncLinkedAlbum();
}
} catch (e) {
log.severe('Failed establishing connection to the server: $e');
@@ -368,58 +364,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
// clean install - change the default of the flag
// current install not using beta timeline
if (context.router.current.name == SplashScreenRoute.name) {
final needBetaMigration = Store.get(StoreKey.needBetaMigration, false);
if (needBetaMigration) {
bool migrate =
(await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("New Timeline Experience"),
content: const Text(
"The old timeline has been deprecated and will be removed in an upcoming release. Would you like to switch to the new timeline now?",
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
],
),
)) ??
false;
if (migrate != true) {
migrate =
(await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text("Are you sure?"),
content: const Text(
"If you choose to remain on the old timeline, you will be automatically migrated to the new timeline in an upcoming release. Would you like to switch now?",
),
actions: [
TextButton(onPressed: () => Navigator.of(ctx).pop(false), child: const Text("No")),
ElevatedButton(onPressed: () => Navigator.of(ctx).pop(true), child: const Text("Yes")),
],
),
)) ??
false;
}
await Store.put(StoreKey.needBetaMigration, false);
if (migrate) {
unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: true)]));
return;
}
}
unawaited(context.replaceRoute(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute()));
}
if (Store.isBetaTimelineEnabled) {
return;
}
final hasPermission = await ref.read(galleryPermissionNotifier.notifier).hasPermission;
if (hasPermission) {
// Resume backup (if enable) then navigate
await ref.watch(backupProvider.notifier).resumeBackup();
unawaited(context.replaceRoute(const TabShellRoute()));
}
}
@@ -1,142 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@RoutePage()
class TabControllerPage extends HookConsumerWidget {
const TabControllerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isRefreshingAssets = ref.watch(assetProvider);
final isRefreshingRemoteAlbums = ref.watch(isRefreshingRemoteAlbumProvider);
final isScreenLandscape = MediaQuery.orientationOf(context) == Orientation.landscape;
Widget buildIcon({required Widget icon, required bool isProcessing}) {
if (!isProcessing) return icon;
return Stack(
alignment: Alignment.center,
clipBehavior: Clip.none,
children: [
icon,
Positioned(
right: -18,
child: SizedBox(
height: 20,
width: 20,
child: CircularProgressIndicator(
strokeWidth: 2,
valueColor: AlwaysStoppedAnimation<Color>(context.primaryColor),
),
),
),
],
);
}
void onNavigationSelected(TabsRouter router, int index) {
// On Photos page menu tapped
if (router.activeIndex == 0 && index == 0) {
scrollToTopNotifierProvider.scrollToTop();
}
// On Search page tapped
if (router.activeIndex == 1 && index == 1) {
ref.read(searchInputFocusProvider).requestFocus();
}
ref.read(hapticFeedbackProvider.notifier).selectionClick();
router.setActiveIndex(index);
ref.read(tabProvider.notifier).state = TabEnum.values[index];
}
final navigationDestinations = [
NavigationDestination(
label: 'photos'.tr(),
icon: const Icon(Icons.photo_library_outlined),
selectedIcon: buildIcon(
isProcessing: isRefreshingAssets,
icon: Icon(Icons.photo_library, color: context.primaryColor),
),
),
NavigationDestination(
label: 'search'.tr(),
icon: const Icon(Icons.search_rounded),
selectedIcon: Icon(Icons.search, color: context.primaryColor),
),
NavigationDestination(
label: 'albums'.tr(),
icon: const Icon(Icons.photo_album_outlined),
selectedIcon: buildIcon(
isProcessing: isRefreshingRemoteAlbums,
icon: Icon(Icons.photo_album_rounded, color: context.primaryColor),
),
),
NavigationDestination(
label: 'library'.tr(),
icon: const Icon(Icons.space_dashboard_outlined),
selectedIcon: buildIcon(
isProcessing: isRefreshingAssets,
icon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor),
),
),
];
Widget bottomNavigationBar(TabsRouter tabsRouter) {
return NavigationBar(
selectedIndex: tabsRouter.activeIndex,
onDestinationSelected: (index) => onNavigationSelected(tabsRouter, index),
destinations: navigationDestinations,
);
}
Widget navigationRail(TabsRouter tabsRouter) {
return NavigationRail(
destinations: navigationDestinations
.map((e) => NavigationRailDestination(icon: e.icon, label: Text(e.label), selectedIcon: e.selectedIcon))
.toList(),
onDestinationSelected: (index) => onNavigationSelected(tabsRouter, index),
selectedIndex: tabsRouter.activeIndex,
labelType: NavigationRailLabelType.all,
groupAlignment: 0.0,
);
}
final multiselectEnabled = ref.watch(multiselectProvider);
return AutoTabsRouter(
routes: [const PhotosRoute(), SearchRoute(), const AlbumsRoute(), const LibraryRoute()],
duration: const Duration(milliseconds: 600),
transitionBuilder: (context, child, animation) => FadeTransition(opacity: animation, child: child),
builder: (context, child) {
final tabsRouter = AutoTabsRouter.of(context);
return PopScope(
canPop: tabsRouter.activeIndex == 0,
onPopInvokedWithResult: (didPop, _) => !didPop ? tabsRouter.setActiveIndex(0) : null,
child: Scaffold(
resizeToAvoidBottomInset: false,
body: isScreenLandscape
? Row(
children: [
navigationRail(tabsRouter),
const VerticalDivider(),
Expanded(child: child),
],
)
: child,
bottomNavigationBar: multiselectEnabled || isScreenLandscape ? null : bottomNavigationBar(tabsRouter),
),
);
},
);
}
}
-177
View File
@@ -1,177 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:crop_image/crop_image.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/pages/editing/edit.page.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hooks/crop_controller_hook.dart';
/// A widget for cropping an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to crop an image and then navigate to the [EditImagePage] with the
/// cropped image.
@RoutePage()
class CropImagePage extends HookWidget {
final Image image;
final Asset asset;
const CropImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final cropController = useCropController();
final aspectRatio = useState<double?>(null);
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("crop".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final croppedImage = await cropController.croppedImage();
unawaited(context.pushRoute(EditImageRoute(asset: asset, image: croppedImage, isEdited: true)));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: SafeArea(
child: LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
return Column(
children: [
Container(
padding: const EdgeInsets.only(top: 20),
width: constraints.maxWidth * 0.9,
height: constraints.maxHeight * 0.6,
child: CropImage(controller: cropController, image: image, gridColor: Colors.white),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.only(
topLeft: Radius.circular(20),
topRight: Radius.circular(20),
),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(left: 20, right: 20, bottom: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
icon: Icon(Icons.rotate_left, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateLeft();
},
),
IconButton(
icon: Icon(Icons.rotate_right, color: context.themeData.iconTheme.color),
onPressed: () {
cropController.rotateRight();
},
),
],
),
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: null,
label: 'Free',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 1.0,
label: '1:1',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 16.0 / 9.0,
label: '16:9',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 3.0 / 2.0,
label: '3:2',
),
_AspectRatioButton(
cropController: cropController,
aspectRatio: aspectRatio,
ratio: 7.0 / 5.0,
label: '7:5',
),
],
),
],
),
),
),
),
],
);
},
),
),
);
}
}
class _AspectRatioButton extends StatelessWidget {
final CropController cropController;
final ValueNotifier<double?> aspectRatio;
final double? ratio;
final String label;
const _AspectRatioButton({
required this.cropController,
required this.aspectRatio,
required this.ratio,
required this.label,
});
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton(
icon: Icon(switch (label) {
'Free' => Icons.crop_free_rounded,
'1:1' => Icons.crop_square_rounded,
'16:9' => Icons.crop_16_9_rounded,
'3:2' => Icons.crop_3_2_rounded,
'7:5' => Icons.crop_7_5_rounded,
_ => Icons.crop_free_rounded,
}, color: aspectRatio.value == ratio ? context.primaryColor : context.themeData.iconTheme.color),
onPressed: () {
cropController.crop = const Rect.fromLTRB(0.1, 0.1, 0.9, 0.9);
aspectRatio.value = ratio;
cropController.aspectRatio = ratio;
},
),
Text(label, style: context.textTheme.displayMedium),
],
);
}
}
-131
View File
@@ -1,131 +0,0 @@
import 'dart:typed_data';
import 'package:auto_route/auto_route.dart';
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/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/repositories/file_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_converter.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:path/path.dart' as p;
/// A stateless widget that provides functionality for editing an image.
///
/// This widget allows users to edit an image provided either as an [Asset] or
/// directly as an [Image]. It ensures that exactly one of these is provided.
///
/// It also includes a conversion method to convert an [Image] to a [Uint8List] to save the image on the user's phone
/// They automatically navigate to the [HomePage] with the edited image saved and they eventually get backed up to the server.
@immutable
@RoutePage()
class EditImagePage extends ConsumerWidget {
final Asset asset;
final Image image;
final bool isEdited;
const EditImagePage({super.key, required this.asset, required this.image, required this.isEdited});
Future<void> _saveEditedImage(BuildContext context, Asset asset, Image image, WidgetRef ref) async {
try {
final Uint8List imageData = await imageToUint8List(image);
await ref
.read(fileMediaRepositoryProvider)
.saveImage(imageData, title: "${p.withoutExtension(asset.fileName)}_edited.jpg");
await ref.read(albumProvider.notifier).refreshDeviceAlbums();
context.navigator.popUntil((route) => route.isFirst);
ImmichToast.show(durationInSecond: 3, context: context, msg: 'Image Saved!', gravity: ToastGravity.CENTER);
} catch (e) {
ImmichToast.show(
durationInSecond: 6,
context: context,
msg: "error_saving_image".tr(namedArgs: {'error': e.toString()}),
gravity: ToastGravity.CENTER,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: Text("edit".tr()),
backgroundColor: context.scaffoldBackgroundColor,
leading: IconButton(
icon: Icon(Icons.close_rounded, color: context.primaryColor, size: 24),
onPressed: () => context.navigator.popUntil((route) => route.isFirst),
),
actions: <Widget>[
TextButton(
onPressed: isEdited ? () => _saveEditedImage(context, asset, image, ref) : null,
child: Text("save_to_gallery".tr(), style: TextStyle(color: isEdited ? context.primaryColor : Colors.grey)),
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Center(
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.7, maxWidth: context.width * 0.9),
child: Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(7)),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.2),
spreadRadius: 2,
blurRadius: 10,
offset: const Offset(0, 3),
),
],
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(7)),
child: Image(image: image.image, fit: BoxFit.contain),
),
),
),
),
bottomNavigationBar: Container(
height: 70,
margin: const EdgeInsets.only(bottom: 60, right: 10, left: 10, top: 10),
decoration: BoxDecoration(
color: context.scaffoldBackgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(30)),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: <Widget>[
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.crop_rotate_rounded, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(CropImageRoute(asset: asset, image: image));
},
),
Text("crop".tr(), style: context.textTheme.displayMedium),
],
),
Column(
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[
IconButton(
icon: Icon(Icons.filter, color: context.themeData.iconTheme.color, size: 25),
onPressed: () {
context.pushRoute(FilterImageRoute(asset: asset, image: image));
},
),
Text("filter".tr(), style: context.textTheme.displayMedium),
],
),
],
),
),
);
}
}
-159
View File
@@ -1,159 +0,0 @@
import 'dart:async';
import 'dart:ui' as ui;
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:immich_mobile/constants/filters.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/routing/router.dart';
/// A widget for filtering an image.
/// This widget uses [HookWidget] to manage its lifecycle and state. It allows
/// users to add filters to an image and then navigate to the [EditImagePage] with the
/// final composition.'
@RoutePage()
class FilterImagePage extends HookWidget {
final Image image;
final Asset asset;
const FilterImagePage({super.key, required this.image, required this.asset});
@override
Widget build(BuildContext context) {
final colorFilter = useState<ColorFilter>(filters[0]);
final selectedFilterIndex = useState<int>(0);
Future<ui.Image> createFilteredImage(ui.Image inputImage, ColorFilter filter) {
final completer = Completer<ui.Image>();
final size = Size(inputImage.width.toDouble(), inputImage.height.toDouble());
final recorder = ui.PictureRecorder();
final canvas = Canvas(recorder);
final paint = Paint()..colorFilter = filter;
canvas.drawImage(inputImage, Offset.zero, paint);
recorder.endRecording().toImage(size.width.round(), size.height.round()).then((image) {
completer.complete(image);
});
return completer.future;
}
void applyFilter(ColorFilter filter, int index) {
colorFilter.value = filter;
selectedFilterIndex.value = index;
}
Future<Image> applyFilterAndConvert(ColorFilter filter) async {
final completer = Completer<ui.Image>();
image.image
.resolve(ImageConfiguration.empty)
.addListener(
ImageStreamListener((ImageInfo info, bool _) {
completer.complete(info.image);
}),
);
final uiImage = await completer.future;
final filteredUiImage = await createFilteredImage(uiImage, filter);
final byteData = await filteredUiImage.toByteData(format: ui.ImageByteFormat.png);
final pngBytes = byteData!.buffer.asUint8List();
return Image.memory(pngBytes, fit: BoxFit.contain);
}
return Scaffold(
appBar: AppBar(
backgroundColor: context.scaffoldBackgroundColor,
title: Text("filter".tr()),
leading: CloseButton(color: context.primaryColor),
actions: [
IconButton(
icon: Icon(Icons.done_rounded, color: context.primaryColor, size: 24),
onPressed: () async {
final filteredImage = await applyFilterAndConvert(colorFilter.value);
unawaited(context.pushRoute(EditImageRoute(asset: asset, image: filteredImage, isEdited: true)));
},
),
],
),
backgroundColor: context.scaffoldBackgroundColor,
body: Column(
children: [
SizedBox(
height: context.height * 0.7,
child: Center(
child: ColorFiltered(colorFilter: colorFilter.value, child: image),
),
),
SizedBox(
height: 120,
child: ListView.builder(
scrollDirection: Axis.horizontal,
itemCount: filters.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: _FilterButton(
image: image,
label: filterNames[index],
filter: filters[index],
isSelected: selectedFilterIndex.value == index,
onTap: () => applyFilter(filters[index], index),
),
);
},
),
),
],
),
);
}
}
class _FilterButton extends StatelessWidget {
final Image image;
final String label;
final ColorFilter filter;
final bool isSelected;
final VoidCallback onTap;
const _FilterButton({
required this.image,
required this.label,
required this.filter,
required this.isSelected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
GestureDetector(
onTap: onTap,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(10)),
border: isSelected ? Border.all(color: context.primaryColor, width: 3) : null,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(10)),
child: ColorFiltered(
colorFilter: filter,
child: FittedBox(fit: BoxFit.cover, child: image),
),
),
),
),
const SizedBox(height: 10),
Text(label, style: context.themeData.textTheme.bodyMedium),
],
);
}
}
@@ -1,37 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
@RoutePage()
class ArchivePage extends HookConsumerWidget {
const ArchivePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
AppBar buildAppBar() {
final archiveRenderList = ref.watch(archiveTimelineProvider);
final count = archiveRenderList.value?.totalAssets.toString() ?? "?";
return AppBar(
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
centerTitle: true,
automaticallyImplyLeading: false,
title: const Text('archive_page_title').tr(namedArgs: {'count': count}),
);
}
return Scaffold(
appBar: ref.watch(multiselectProvider) ? null : buildAppBar(),
body: MultiselectGrid(
renderListProvider: archiveTimelineProvider,
unarchive: true,
archiveEnabled: true,
deleteEnabled: true,
editEnabled: true,
),
);
}
}
@@ -1,34 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
@RoutePage()
class FavoritesPage extends HookConsumerWidget {
const FavoritesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
AppBar buildAppBar() {
return AppBar(
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
centerTitle: true,
automaticallyImplyLeading: false,
title: const Text('favorites').tr(),
);
}
return Scaffold(
appBar: ref.watch(multiselectProvider) ? null : buildAppBar(),
body: MultiselectGrid(
renderListProvider: favoriteTimelineProvider,
favoriteEnabled: true,
editEnabled: true,
unfavorite: true,
),
);
}
}
-383
View File
@@ -1,383 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class LibraryPage extends ConsumerWidget {
const LibraryPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
context.locale;
final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
return Scaffold(
appBar: const ImmichAppBar(),
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: ListView(
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Row(
children: [
ActionButton(
onPressed: () => context.pushRoute(const FavoritesRoute()),
icon: Icons.favorite_outline_rounded,
label: context.t.favorites,
),
const SizedBox(width: 8),
ActionButton(
onPressed: () => context.pushRoute(const ArchiveRoute()),
icon: Icons.archive_outlined,
label: context.t.archived,
),
],
),
),
const SizedBox(height: 8),
Row(
children: [
ActionButton(
onPressed: () => context.pushRoute(const SharedLinkRoute()),
icon: Icons.link_outlined,
label: context.t.shared_links,
),
SizedBox(width: trashEnabled ? 8 : 0),
trashEnabled
? ActionButton(
onPressed: () => context.pushRoute(const TrashRoute()),
icon: Icons.delete_outline_rounded,
label: context.t.trash,
)
: const SizedBox.shrink(),
],
),
const SizedBox(height: 12),
const Wrap(
spacing: 8,
runSpacing: 8,
children: [PeopleCollectionCard(), PlacesCollectionCard(), LocalAlbumsCollectionCard()],
),
const SizedBox(height: 12),
const QuickAccessButtons(),
const SizedBox(height: 32),
],
),
),
);
}
}
class QuickAccessButtons extends ConsumerWidget {
const QuickAccessButtons({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final partners = ref.watch(partnerSharedWithProvider);
return Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(10),
context.colorScheme.primary.withAlpha(15),
context.colorScheme.primary.withAlpha(20),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
topLeft: const Radius.circular(20),
topRight: const Radius.circular(20),
bottomLeft: Radius.circular(partners.isEmpty ? 20 : 0),
bottomRight: Radius.circular(partners.isEmpty ? 20 : 0),
),
),
leading: const Icon(Icons.folder_outlined, size: 26),
title: Text(context.t.folders, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
onTap: () => context.pushRoute(FolderRoute()),
),
ListTile(
leading: const Icon(Icons.lock_outline_rounded, size: 26),
title: Text(
context.t.locked_folder,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500),
),
onTap: () => context.pushRoute(const LockedRoute()),
),
ListTile(
leading: const Icon(Icons.group_outlined, size: 26),
title: Text(context.t.partners, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
onTap: () => context.pushRoute(const PartnerRoute()),
),
PartnerList(partners: partners),
],
),
);
}
}
class PartnerList extends ConsumerWidget {
const PartnerList({super.key, required this.partners});
final List<UserDto> partners;
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView.builder(
physics: const NeverScrollableScrollPhysics(),
itemCount: partners.length,
shrinkWrap: true,
itemBuilder: (context, index) {
final partner = partners[index];
final isLastItem = index == partners.length - 1;
return ListTile(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(isLastItem ? 20 : 0),
bottomRight: Radius.circular(isLastItem ? 20 : 0),
),
),
contentPadding: const EdgeInsets.only(left: 12.0, right: 18.0),
leading: userAvatar(context, partner, radius: 16),
title: const Text(
"partner_list_user_photos",
style: TextStyle(fontWeight: FontWeight.w500),
).tr(namedArgs: {'user': partner.name}),
onTap: () => context.pushRoute((PartnerDetailRoute(partner: partner))),
);
},
);
}
}
class PeopleCollectionCard extends ConsumerWidget {
const PeopleCollectionCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const PeopleCollectionRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
height: size,
width: size,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: people.widgetWhen(
onLoading: () => const Center(child: CircularProgressIndicator()),
onData: (people) {
return GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: people.take(4).map((person) {
return CircleAvatar(backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)));
}).toList(),
);
},
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
context.t.people,
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class LocalAlbumsCollectionCard extends HookConsumerWidget {
const LocalAlbumsCollectionCard({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(localAlbumsProvider);
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(const LocalAlbumsRoute()),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: size,
width: size,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(30), context.colorScheme.primary.withAlpha(25)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: GridView.count(
crossAxisCount: 2,
padding: const EdgeInsets.all(12),
crossAxisSpacing: 8,
mainAxisSpacing: 8,
physics: const NeverScrollableScrollPhysics(),
children: albums.take(4).map((album) {
return AlbumThumbnailCard(album: album, showTitle: false);
}).toList(),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
context.t.on_this_device,
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class PlacesCollectionCard extends StatelessWidget {
const PlacesCollectionCard({super.key});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final widthFactor = isTablet ? 0.25 : 0.5;
final size = context.width * widthFactor - 20.0;
return GestureDetector(
onTap: () => context.pushRoute(PlacesCollectionRoute(currentLocation: null)),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: size,
width: size,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
color: context.colorScheme.secondaryContainer.withAlpha(100),
),
child: IgnorePointer(
child: MapThumbnail(
zoom: 8,
centre: const LatLng(21.44950, -157.91959),
showAttribution: false,
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
context.t.places,
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
],
),
);
},
);
}
}
class ActionButton extends StatelessWidget {
final VoidCallback onPressed;
final IconData icon;
final String label;
const ActionButton({super.key, required this.onPressed, required this.icon, required this.label});
@override
Widget build(BuildContext context) {
return Expanded(
child: FilledButton.icon(
onPressed: onPressed,
label: Padding(
padding: const EdgeInsets.only(left: 4.0),
child: Text(label, style: TextStyle(color: context.colorScheme.onSurface, fontSize: 15)),
),
style: FilledButton.styleFrom(
elevation: 0,
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
backgroundColor: context.colorScheme.surfaceContainerLow,
alignment: Alignment.centerLeft,
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(Radius.circular(25)),
side: BorderSide(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
),
),
icon: Icon(icon, color: context.primaryColor),
),
);
}
}
@@ -1,49 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
@RoutePage()
class LocalAlbumsPage extends HookConsumerWidget {
const LocalAlbumsPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(localAlbumsProvider);
return Scaffold(
appBar: AppBar(title: Text('on_this_device'.tr())),
body: ListView.builder(
padding: const EdgeInsets.all(18.0),
itemCount: albums.length,
itemBuilder: (context, index) {
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: LargeLeadingTile(
leadingPadding: const EdgeInsets.only(right: 16),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(15)),
child: ImmichThumbnail(asset: albums[index].thumbnail.value, width: 80, height: 80),
),
title: Text(
albums[index].name,
style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w600),
),
subtitle: Text(
'items_count'.t(context: context, args: {'count': albums[index].assetCount}),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
onTap: () => context.pushRoute(AlbumViewerRoute(albumId: albums[index].id)),
),
);
},
),
);
}
}
@@ -1,82 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
@RoutePage()
class LockedPage extends HookConsumerWidget {
const LockedPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final appLifeCycle = useAppLifecycleState();
final showOverlay = useState(false);
final authProviderNotifier = ref.read(authProvider.notifier);
// lock the page when it is destroyed
useEffect(() {
return () {
authProviderNotifier.lockPinCode();
};
}, []);
useEffect(() {
if (context.mounted) {
if (appLifeCycle == AppLifecycleState.resumed) {
showOverlay.value = false;
} else {
showOverlay.value = true;
}
}
return null;
}, [appLifeCycle]);
return Scaffold(
appBar: ref.watch(multiselectProvider) ? null : const LockPageAppBar(),
body: showOverlay.value
? const SizedBox()
: MultiselectGrid(
renderListProvider: lockedTimelineProvider,
topWidget: Padding(
padding: const EdgeInsets.all(16.0),
child: Center(child: Text('no_locked_photos_message'.tr(), style: context.textTheme.labelLarge)),
),
editEnabled: false,
favoriteEnabled: false,
unfavorite: false,
archiveEnabled: false,
stackEnabled: false,
unarchive: false,
),
);
}
}
class LockPageAppBar extends ConsumerWidget implements PreferredSizeWidget {
const LockPageAppBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return AppBar(
leading: IconButton(
onPressed: () {
ref.read(authProvider.notifier).lockPinCode();
context.maybePop();
},
icon: const Icon(Icons.arrow_back_ios_rounded),
),
centerTitle: true,
automaticallyImplyLeading: false,
title: const Text('locked_folder').tr(),
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
@@ -5,7 +5,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' show useState;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/local_auth.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -22,7 +21,6 @@ class PinAuthPage extends HookConsumerWidget {
Widget build(BuildContext context, WidgetRef ref) {
final localAuthState = ref.watch(localAuthProvider);
final showPinRegistrationForm = useState(createPinCode);
final isBetaTimeline = Store.isBetaTimelineEnabled;
Future<void> registerBiometric(String pinCode) async {
final isRegistered = await ref.read(localAuthProvider.notifier).registerBiometric(context, pinCode);
@@ -36,11 +34,7 @@ class PinAuthPage extends HookConsumerWidget {
),
);
if (isBetaTimeline) {
unawaited(context.replaceRoute(const DriftLockedFolderRoute()));
} else {
unawaited(context.replaceRoute(const LockedRoute()));
}
unawaited(context.replaceRoute(const DriftLockedFolderRoute()));
}
}
@@ -89,11 +83,7 @@ class PinAuthPage extends HookConsumerWidget {
child: PinVerificationForm(
autoFocus: true,
onSuccess: (_) {
if (isBetaTimeline) {
context.replaceRoute(const DriftLockedFolderRoute());
} else {
context.replaceRoute(const LockedRoute());
}
context.replaceRoute(const DriftLockedFolderRoute());
},
),
),
@@ -1,139 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/services/partner.service.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/common/user_avatar.dart';
@RoutePage()
class PartnerPage extends HookConsumerWidget {
const PartnerPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final List<UserDto> partners = ref.watch(partnerSharedByProvider);
final availableUsers = ref.watch(partnerAvailableProvider);
addNewUsersHandler() async {
final users = availableUsers.value;
if (users == null || users.isEmpty) {
ImmichToast.show(context: context, msg: "partner_page_no_more_users".tr());
return;
}
final selectedUser = await showDialog<UserDto>(
context: context,
builder: (context) {
return SimpleDialog(
title: const Text("partner_page_select_partner").tr(),
children: [
for (UserDto u in users)
SimpleDialogOption(
onPressed: () => context.pop(u),
child: Row(
children: [
Padding(padding: const EdgeInsets.only(right: 8), child: userAvatar(context, u)),
Text(u.name),
],
),
),
],
);
},
);
if (selectedUser != null) {
final ok = await ref.read(partnerServiceProvider).addPartner(selectedUser);
if (ok) {
ref.invalidate(partnerSharedByProvider);
} else {
ImmichToast.show(context: context, msg: "partner_page_partner_add_failed".tr(), toastType: ToastType.error);
}
}
}
onDeleteUser(UserDto u) {
return showDialog(
context: context,
builder: (BuildContext context) {
return ConfirmDialog(
title: "stop_photo_sharing",
content: "partner_page_stop_sharing_content".tr(namedArgs: {'partner': u.name}),
onOk: () => ref.read(partnerServiceProvider).removePartner(u),
);
},
);
}
buildUserList(List<UserDto> users) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Text(
"partner_page_shared_to_title",
style: context.textTheme.titleSmall?.copyWith(color: context.colorScheme.onSurface.withAlpha(200)),
).tr(),
),
if (users.isNotEmpty)
ListView.builder(
shrinkWrap: true,
itemCount: users.length,
itemBuilder: ((context, index) {
return ListTile(
leading: userAvatar(context, users[index]),
title: Text(users[index].email, style: context.textTheme.bodyLarge),
trailing: IconButton(
icon: const Icon(Icons.person_remove),
onPressed: () => onDeleteUser(users[index]),
),
);
}),
),
if (users.isEmpty)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: const Text("partner_page_empty_message", style: TextStyle(fontSize: 14)).tr(),
),
Align(
alignment: Alignment.center,
child: ElevatedButton.icon(
onPressed: availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
icon: const Icon(Icons.person_add),
label: const Text("add_partner").tr(),
),
),
],
),
),
],
);
}
return Scaffold(
appBar: AppBar(
title: const Text("partners").tr(),
elevation: 0,
centerTitle: false,
actions: [
IconButton(
onPressed: availableUsers.whenOrNull(data: (data) => addNewUsersHandler),
icon: const Icon(Icons.person_add),
tooltip: "add_partner".tr(),
),
],
),
body: buildUserList(partners),
);
}
}
@@ -1,99 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class PartnerDetailPage extends HookConsumerWidget {
const PartnerDetailPage({super.key, required this.partner});
final UserDto partner;
@override
Widget build(BuildContext context, WidgetRef ref) {
final inTimeline = useState(partner.inTimeline);
bool toggleInProcess = false;
useEffect(() {
Future.microtask(() async => {await ref.read(assetProvider.notifier).getAllAsset()});
return null;
}, []);
void toggleInTimeline() async {
if (toggleInProcess) return;
toggleInProcess = true;
try {
final ok = await ref
.read(partnerSharedWithProvider.notifier)
.updatePartner(partner, inTimeline: !inTimeline.value);
if (ok) {
inTimeline.value = !inTimeline.value;
final action = inTimeline.value ? "shown on" : "hidden from";
ImmichToast.show(
context: context,
toastType: ToastType.success,
durationInSecond: 1,
msg: "${partner.name}'s assets $action your timeline",
);
} else {
ImmichToast.show(
context: context,
toastType: ToastType.error,
durationInSecond: 1,
msg: "Failed to toggle the timeline setting",
);
}
} finally {
toggleInProcess = false;
}
}
return Scaffold(
appBar: ref.watch(multiselectProvider)
? null
: AppBar(title: Text(partner.name), elevation: 0, centerTitle: false),
body: MultiselectGrid(
topWidget: Padding(
padding: const EdgeInsets.only(left: 8.0, right: 8.0, top: 16.0),
child: Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(10), width: 1),
borderRadius: const BorderRadius.all(Radius.circular(20)),
gradient: LinearGradient(
colors: [context.colorScheme.primary.withAlpha(10), context.colorScheme.primary.withAlpha(15)],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: Padding(
padding: const EdgeInsets.all(8.0),
child: ListTile(
title: Text(
"Show in timeline",
style: context.textTheme.titleSmall?.copyWith(color: context.colorScheme.primary),
),
subtitle: Text(
"Show photos and videos from this user in your timeline",
style: context.textTheme.bodyMedium,
),
trailing: Switch(value: inTimeline.value, onChanged: (_) => toggleInTimeline()),
),
),
),
),
renderListProvider: singleUserTimelineProvider(partner.id),
onRefresh: () => ref.read(assetProvider.notifier).getAllAsset(),
deleteEnabled: false,
favoriteEnabled: false,
),
);
}
}
@@ -1,127 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
@RoutePage()
class PeopleCollectionPage extends HookConsumerWidget {
const PeopleCollectionPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final people = ref.watch(getAllPeopleProvider);
final formFocus = useFocusNode();
final ValueNotifier<String?> search = useState(null);
showNameEditModel(String personId, String personName) {
return showDialog(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return PersonNameEditForm(personId: personId, personName: personName);
},
);
}
return LayoutBuilder(
builder: (context, constraints) {
final isTablet = constraints.maxWidth > 600;
final isPortrait = context.orientation == Orientation.portrait;
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: search.value == null,
title: search.value != null
? SearchField(
focusNode: formFocus,
onTapOutside: (_) => formFocus.unfocus(),
onChanged: (value) => search.value = value,
filled: true,
hintText: 'filter_people'.tr(),
autofocus: true,
)
: Text('people'.tr()),
actions: [
IconButton(
icon: Icon(search.value != null ? Icons.close : Icons.search),
onPressed: () {
search.value = search.value == null ? '' : null;
},
),
],
),
body: SafeArea(
child: people.when(
data: (people) {
if (search.value != null) {
people = people.where((person) {
return person.name.toLowerCase().contains(search.value!.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(PersonResultRoute(personId: person.id, personName: person.name));
},
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: isTablet ? 120 / 2 : 96 / 2,
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
),
),
const SizedBox(height: 12),
GestureDetector(
onTap: () => showNameEditModel(person.id, person.name),
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()),
),
),
);
},
);
}
}
@@ -1,136 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class PlacesCollectionPage extends HookConsumerWidget {
const PlacesCollectionPage({super.key, this.currentLocation});
final LatLng? currentLocation;
@override
Widget build(BuildContext context, WidgetRef ref) {
final places = ref.watch(getAllPlacesProvider);
final formFocus = useFocusNode();
final ValueNotifier<String?> search = useState(null);
return Scaffold(
appBar: AppBar(
automaticallyImplyLeading: search.value == null,
title: search.value != null
? SearchField(
autofocus: true,
filled: true,
focusNode: formFocus,
onChanged: (value) => search.value = value,
onTapOutside: (_) => formFocus.unfocus(),
hintText: 'filter_places'.tr(),
)
: Text('places'.tr()),
actions: [
IconButton(
icon: Icon(search.value != null ? Icons.close : Icons.search),
onPressed: () {
search.value = search.value == null ? '' : null;
},
),
],
),
body: ListView(
shrinkWrap: true,
children: [
if (search.value == null)
Padding(
padding: const EdgeInsets.all(16.0),
child: SizedBox(
height: 200,
width: context.width,
child: MapThumbnail(
onTap: (_, __) => context.pushRoute(MapRoute(initialLocation: currentLocation)),
zoom: 8,
centre: currentLocation ?? const LatLng(21.44950, -157.91959),
showAttribution: false,
themeMode: context.isDarkTheme ? ThemeMode.dark : ThemeMode.light,
),
),
),
places.when(
data: (places) {
if (search.value != null) {
places = places.where((place) {
return place.label.toLowerCase().contains(search.value!.toLowerCase());
}).toList();
}
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: places.length,
itemBuilder: (context, index) {
final place = places[index];
return PlaceTile(id: place.id, name: place.label);
},
);
},
error: (error, stask) => Text('error_getting_places'.tr()),
loading: () => const Center(child: CircularProgressIndicator()),
),
],
),
);
}
}
class PlaceTile extends StatelessWidget {
const PlaceTile({super.key, required this.id, required this.name});
final String id;
final String name;
@override
Widget build(BuildContext context) {
final thumbnailUrl = '${Store.get(StoreKey.serverEndpoint)}/assets/$id/thumbnail';
void navigateToPlace() {
context.pushRoute(
SearchRoute(
prefilter: SearchFilter(
people: {},
location: SearchLocationFilter(city: name),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
),
),
);
}
return LargeLeadingTile(
onTap: () => navigateToPlace(),
title: Text(name, style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w500)),
leading: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: SizedBox(
width: 80,
height: 80,
child: Thumbnail(imageProvider: RemoteImageProvider(url: thumbnailUrl)),
),
),
);
}
}
-225
View File
@@ -1,225 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/providers/trash.provider.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@RoutePage()
class TrashPage extends HookConsumerWidget {
const TrashPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final trashRenderList = ref.watch(trashTimelineProvider);
final trashDays = ref.watch(serverInfoProvider.select((v) => v.serverConfig.trashDays));
final selectionEnabledHook = useState(false);
final selection = useState(<Asset>{});
final processing = useProcessingOverlay();
void selectionListener(bool multiselect, Set<Asset> selectedAssets) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
}
onEmptyTrash() async {
processing.value = true;
await ref.read(trashProvider.notifier).emptyTrash();
processing.value = false;
selectionEnabledHook.value = false;
if (context.mounted) {
ImmichToast.show(context: context, msg: 'trash_emptied'.tr(), gravity: ToastGravity.BOTTOM);
}
}
handleEmptyTrash() async {
await showDialog(
context: context,
builder: (context) => ConfirmDialog(
onOk: () => onEmptyTrash(),
title: "empty_trash".tr(),
ok: "ok".tr(),
content: "trash_page_empty_trash_dialog_content".tr(),
),
);
}
Future<void> onPermanentlyDelete() async {
processing.value = true;
try {
if (selection.value.isNotEmpty) {
final isRemoved = await ref.read(assetProvider.notifier).deleteAssets(selection.value, force: true);
if (isRemoved) {
if (context.mounted) {
ImmichToast.show(
context: context,
msg: 'assets_deleted_permanently'.tr(namedArgs: {'count': "${selection.value.length}"}),
gravity: ToastGravity.BOTTOM,
);
}
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
handlePermanentDelete() async {
await showDialog(
context: context,
builder: (context) => DeleteDialog(alert: "delete_dialog_alert_remote", onDelete: () => onPermanentlyDelete()),
);
}
Future<void> handleRestoreAll() async {
processing.value = true;
await ref.read(trashProvider.notifier).restoreTrash();
processing.value = false;
selectionEnabledHook.value = false;
}
Future<void> handleRestore() async {
processing.value = true;
try {
if (selection.value.isNotEmpty) {
final result = await ref.read(trashProvider.notifier).restoreAssets(selection.value);
if (result && context.mounted) {
ImmichToast.show(
context: context,
msg: 'assets_restored_successfully'.tr(namedArgs: {'count': "${selection.value.length}"}),
gravity: ToastGravity.BOTTOM,
);
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
String getAppBarTitle(String count) {
if (selectionEnabledHook.value) {
return selection.value.isNotEmpty ? "${selection.value.length}" : "trash_page_select_assets_btn".tr();
}
return 'trash_page_title'.tr(namedArgs: {'count': count});
}
AppBar buildAppBar(String count) {
return AppBar(
leading: IconButton(
onPressed: !selectionEnabledHook.value
? () => context.maybePop()
: () {
selectionEnabledHook.value = false;
selection.value = {};
},
icon: !selectionEnabledHook.value
? const Icon(Icons.arrow_back_ios_rounded)
: const Icon(Icons.close_rounded),
),
centerTitle: !selectionEnabledHook.value,
automaticallyImplyLeading: false,
title: Text(getAppBarTitle(count)),
actions: <Widget>[
if (!selectionEnabledHook.value)
PopupMenuButton<void Function()>(
itemBuilder: (context) {
return [
PopupMenuItem(value: () => selectionEnabledHook.value = true, child: const Text('select').tr()),
PopupMenuItem(value: handleEmptyTrash, child: const Text('empty_trash').tr()),
];
},
onSelected: (fn) => fn(),
),
],
);
}
Widget buildBottomBar() {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: context.themeData.canvasColor,
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
TextButton.icon(
icon: Icon(Icons.delete_forever, color: Colors.red[400]),
label: Text(
selection.value.isEmpty ? 'trash_page_delete_all'.tr() : 'delete'.tr(),
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
),
onPressed: processing.value
? null
: selection.value.isEmpty
? handleEmptyTrash
: handlePermanentDelete,
),
TextButton.icon(
icon: const Icon(Icons.history_rounded),
label: Text(
selection.value.isEmpty ? 'trash_page_restore_all'.tr() : 'restore'.tr(),
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
),
onPressed: processing.value
? null
: selection.value.isEmpty
? handleRestoreAll
: handleRestore,
),
],
),
),
),
),
);
}
return Scaffold(
appBar: trashRenderList.maybeWhen(
orElse: () => buildAppBar("?"),
data: (data) => buildAppBar(data.totalAssets.toString()),
),
body: trashRenderList.widgetWhen(
onData: (data) => data.isEmpty
? Center(child: Text('trash_page_no_assets'.tr()))
: Stack(
children: [
SafeArea(
child: ImmichAssetGrid(
renderList: data,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
showMultiSelectIndicator: false,
showStack: true,
topWidget: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 24),
child: const Text("trash_page_info").tr(namedArgs: {"days": "$trashDays"}),
),
),
),
if (selectionEnabledHook.value) buildBottomBar(),
],
),
),
);
}
}
@@ -1,141 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
import 'package:permission_handler/permission_handler.dart';
@RoutePage()
class PermissionOnboardingPage extends HookConsumerWidget {
const PermissionOnboardingPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final PermissionStatus permission = ref.watch(galleryPermissionNotifier);
// Navigate to the main Tab Controller when permission is granted
void goToBackup() => context.replaceRoute(const BackupControllerRoute());
// When the permission is denied, we show a request permission page
buildRequestPermission() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('permission_onboarding_request', style: context.textTheme.titleMedium, textAlign: TextAlign.center).tr(),
const SizedBox(height: 18),
ElevatedButton(
onPressed: () =>
ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission().then((permission) async {
if (permission.isGranted) {
// If permission is limited, we will show the limited
// permission page
goToBackup();
}
}),
child: const Text('continue').tr(),
),
],
);
}
// When permission is granted from outside the app, this will show to
// let them continue on to the main timeline
buildPermissionGranted() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'permission_onboarding_permission_granted',
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 18),
ElevatedButton(onPressed: () => goToBackup(), child: const Text('permission_onboarding_get_started').tr()),
],
);
}
// iOS 14+ has limited permission options, which let someone just share
// a few photos with the app. If someone only has limited permissions, we
// inform that Immich works best when given full permission
buildPermissionLimited() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.warning_outlined, color: Colors.yellow, size: 48),
const SizedBox(height: 8),
Text(
'permission_onboarding_permission_limited',
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 18),
ElevatedButton(
onPressed: () => openAppSettings(),
child: const Text('permission_onboarding_go_to_settings').tr(),
),
const SizedBox(height: 8.0),
TextButton(onPressed: () => goToBackup(), child: const Text('permission_onboarding_continue_anyway').tr()),
],
);
}
buildPermissionDenied() {
return Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(Icons.warning_outlined, color: Colors.red, size: 48),
const SizedBox(height: 8),
Text(
'permission_onboarding_permission_denied',
style: context.textTheme.titleMedium,
textAlign: TextAlign.center,
).tr(),
const SizedBox(height: 18),
ElevatedButton(
onPressed: () => openAppSettings(),
child: const Text('permission_onboarding_go_to_settings').tr(),
),
],
);
}
final Widget child = switch (permission) {
PermissionStatus.limited => buildPermissionLimited(),
PermissionStatus.denied => buildRequestPermission(),
PermissionStatus.granted || PermissionStatus.provisional => buildPermissionGranted(),
PermissionStatus.restricted || PermissionStatus.permanentlyDenied => buildPermissionDenied(),
};
return Scaffold(
body: SafeArea(
child: Center(
child: SizedBox(
width: 380,
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const ImmichLogo(heroTag: 'logo'),
const ImmichTitleText(),
AnimatedSwitcher(
duration: const Duration(milliseconds: 500),
child: Padding(padding: const EdgeInsets.all(18.0), child: child),
),
TextButton(child: const Text('back').tr(), onPressed: () => context.maybePop()),
],
),
),
),
),
);
}
}
-324
View File
@@ -1,324 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/memories/memory_bottom_info.dart';
import 'package:immich_mobile/widgets/memories/memory_card.dart';
import 'package:immich_mobile/widgets/memories/memory_epilogue.dart';
import 'package:immich_mobile/widgets/memories/memory_progress_indicator.dart';
@RoutePage()
/// Expects [currentAssetProvider] to be set before navigating to this page
class MemoryPage extends HookConsumerWidget {
final List<Memory> memories;
final int memoryIndex;
const MemoryPage({required this.memories, required this.memoryIndex, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentMemory = useState(memories[memoryIndex]);
final currentAssetPage = useState(0);
final currentMemoryIndex = useState(memoryIndex);
final assetProgress = useState("${currentAssetPage.value + 1}|${currentMemory.value.assets.length}");
const bgColor = Colors.black;
final currentAsset = useState<Asset?>(null);
/// The list of all of the asset page controllers
final memoryAssetPageControllers = List.generate(memories.length, (i) => usePageController());
/// The main vertically scrolling page controller with each list of memories
final memoryPageController = usePageController(initialPage: memoryIndex);
useEffect(() {
// Memories is an immersive activity
SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersive);
return () {
// Clean up to normal edge to edge when we are done
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
};
});
toNextMemory() {
memoryPageController.nextPage(duration: const Duration(milliseconds: 500), curve: Curves.easeIn);
}
void toPreviousMemory() {
if (currentMemoryIndex.value > 0) {
// Move to the previous memory page
memoryPageController.previousPage(duration: const Duration(milliseconds: 500), curve: Curves.easeIn);
// Wait for the next frame to ensure the page is built
SchedulerBinding.instance.addPostFrameCallback((_) {
final previousIndex = currentMemoryIndex.value - 1;
final previousMemoryController = memoryAssetPageControllers[previousIndex];
// Ensure the controller is attached
if (previousMemoryController.hasClients) {
previousMemoryController.jumpToPage(memories[previousIndex].assets.length - 1);
} else {
// Wait for the next frame until it is attached
SchedulerBinding.instance.addPostFrameCallback((_) {
if (previousMemoryController.hasClients) {
previousMemoryController.jumpToPage(memories[previousIndex].assets.length - 1);
}
});
}
});
}
}
toNextAsset(int currentAssetIndex) {
if (currentAssetIndex + 1 < currentMemory.value.assets.length) {
// Go to the next asset
PageController controller = memoryAssetPageControllers[currentMemoryIndex.value];
controller.nextPage(curve: Curves.easeInOut, duration: const Duration(milliseconds: 500));
} else {
// Go to the next memory since we are at the end of our assets
toNextMemory();
}
}
toPreviousAsset(int currentAssetIndex) {
if (currentAssetIndex > 0) {
// Go to the previous asset
PageController controller = memoryAssetPageControllers[currentMemoryIndex.value];
controller.previousPage(curve: Curves.easeInOut, duration: const Duration(milliseconds: 500));
} else {
// Go to the previous memory since we are at the end of our assets
toPreviousMemory();
}
}
updateProgressText() {
assetProgress.value = "${currentAssetPage.value + 1}|${currentMemory.value.assets.length}";
}
/// Downloads and caches the image for the asset at this [currentMemory]'s index
precacheAsset(int index) async {
// Guard index out of range
if (index < 0) {
return;
}
// Context might be removed due to popping out of Memory Lane during Scroll handling
if (!context.mounted) {
return;
}
late Asset asset;
if (index < currentMemory.value.assets.length) {
// Uses the next asset in this current memory
asset = currentMemory.value.assets[index];
} else {
// Precache the first asset in the next memory if available
final currentMemoryIndex = memories.indexOf(currentMemory.value);
// Guard no memory found
if (currentMemoryIndex == -1) {
return;
}
final nextMemoryIndex = currentMemoryIndex + 1;
// Guard no next memory
if (nextMemoryIndex >= memories.length) {
return;
}
// Get the first asset from the next memory
asset = memories[nextMemoryIndex].assets.first;
}
// Precache the asset
final size = MediaQuery.sizeOf(context);
await precacheImage(
ImmichImage.imageProvider(asset: asset, width: size.width, height: size.height),
context,
size: size,
);
}
// Precache the next page right away if we are on the first page
if (currentAssetPage.value == 0) {
Future.delayed(const Duration(milliseconds: 200)).then((_) => precacheAsset(1));
}
Future<void> onAssetChanged(int otherIndex) async {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
currentAssetPage.value = otherIndex;
updateProgressText();
// Wait for page change animation to finish
await Future.delayed(const Duration(milliseconds: 400));
// And then precache the next asset
await precacheAsset(otherIndex + 1);
final asset = currentMemory.value.assets[otherIndex];
currentAsset.value = asset;
ref.read(currentAssetProvider.notifier).set(asset);
}
/* Notification listener is used instead of OnPageChanged callback since OnPageChanged is called
* when the page in the **center** of the viewer changes. We want to reset currentAssetPage only when the final
* page during the end of scroll is different than the current page
*/
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
// Calculate OverScroll manually using the number of pixels away from maxScrollExtent
// maxScrollExtend contains the sum of horizontal pixels of all assets for depth = 1
// or sum of vertical pixels of all memories for depth = 0
if (notification is ScrollUpdateNotification) {
final isEpiloguePage = (memoryPageController.page?.floor() ?? 0) >= memories.length;
final offset = notification.metrics.pixels;
if (isEpiloguePage && (offset > notification.metrics.maxScrollExtent + 150)) {
context.maybePop();
return true;
}
}
return false;
},
child: Scaffold(
backgroundColor: bgColor,
body: SafeArea(
child: PageView.builder(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
scrollDirection: Axis.vertical,
controller: memoryPageController,
onPageChanged: (pageNumber) {
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
if (pageNumber < memories.length) {
currentMemoryIndex.value = pageNumber;
currentMemory.value = memories[pageNumber];
}
currentAssetPage.value = 0;
updateProgressText();
},
itemCount: memories.length + 1,
itemBuilder: (context, mIndex) {
// Build last page
if (mIndex == memories.length) {
return MemoryEpilogue(
onStartOver: () => memoryPageController.animateToPage(
0,
duration: const Duration(seconds: 1),
curve: Curves.easeInOut,
),
);
}
// Build horizontal page
final assetController = memoryAssetPageControllers[mIndex];
return Column(
children: [
Padding(
padding: const EdgeInsets.only(left: 24.0, right: 24.0, top: 8.0, bottom: 2.0),
child: AnimatedBuilder(
animation: assetController,
builder: (context, child) {
double value = 0.0;
if (assetController.hasClients) {
// We can only access [page] if this has clients
value = assetController.page ?? 0;
}
return MemoryProgressIndicator(
ticks: memories[mIndex].assets.length,
value: (value + 1) / memories[mIndex].assets.length,
);
},
),
),
Expanded(
child: Stack(
children: [
PageView.builder(
physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
controller: assetController,
onPageChanged: onAssetChanged,
scrollDirection: Axis.horizontal,
itemCount: memories[mIndex].assets.length,
itemBuilder: (context, index) {
final asset = memories[mIndex].assets[index];
return Stack(
children: [
Container(
color: Colors.black,
child: MemoryCard(asset: asset, title: memories[mIndex].title, showTitle: index == 0),
),
Positioned.fill(
child: Row(
children: [
// Left side of the screen
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
toPreviousAsset(index);
},
),
),
// Right side of the screen
Expanded(
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
toNextAsset(index);
},
),
),
],
),
),
],
);
},
),
Positioned(
top: 8,
left: 8,
child: MaterialButton(
minWidth: 0,
onPressed: () {
// auto_route doesn't invoke pop scope, so
// turn off full screen mode here
// https://github.com/Milad-Akarie/auto_route_library/issues/1799
context.maybePop();
SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
elevation: 0,
child: const Icon(Icons.close_rounded, color: Colors.white),
),
),
if (currentAsset.value != null && currentAsset.value!.isVideo)
Positioned(
bottom: 24,
right: 32,
child: Icon(Icons.videocam_outlined, color: Colors.grey[200]),
),
],
),
),
MemoryBottomInfo(memory: memories[mIndex]),
],
);
},
),
),
),
);
}
}
-130
View File
@@ -1,130 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/widgets/common/immich_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
import 'package:immich_mobile/widgets/memories/memory_lane.dart';
@RoutePage()
class PhotosPage extends HookConsumerWidget {
const PhotosPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final currentUser = ref.watch(currentUserProvider);
final timelineUsers = ref.watch(timelineUsersIdsProvider);
final tipOneOpacity = useState(0.0);
final refreshCount = useState(0);
useEffect(() {
ref.read(websocketProvider.notifier).connect();
Future(() => ref.read(assetProvider.notifier).getAllAsset());
Future(() => ref.read(albumProvider.notifier).refreshRemoteAlbums());
ref.read(serverInfoProvider.notifier).getServerInfo();
return;
}, []);
Widget buildLoadingIndicator() {
Timer(const Duration(seconds: 2), () => tipOneOpacity.value = 1);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const ImmichLoadingIndicator(),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: Text(
'home_page_building_timeline',
style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor),
).tr(),
),
const SizedBox(height: 8),
AnimatedOpacity(
duration: const Duration(milliseconds: 1000),
opacity: tipOneOpacity.value,
child: Column(
children: [
SizedBox(
width: 320,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
'home_page_first_time_notice',
textAlign: TextAlign.center,
style: context.textTheme.bodyMedium,
).tr(),
),
),
],
),
),
],
),
);
}
Future<void> refreshAssets() async {
final fullRefresh = refreshCount.value > 0;
if (fullRefresh) {
unawaited(
Future.wait([
ref.read(assetProvider.notifier).getAllAsset(clear: true),
ref.read(albumProvider.notifier).refreshRemoteAlbums(),
]),
);
// refresh was forced: user requested another refresh within 2 seconds
refreshCount.value = 0;
} else {
await ref.read(assetProvider.notifier).getAllAsset(clear: false);
refreshCount.value++;
// set counter back to 0 if user does not request refresh again
Timer(const Duration(seconds: 4), () => refreshCount.value = 0);
}
}
return Stack(
children: [
MultiselectGrid(
topWidget: (currentUser != null && currentUser.memoryEnabled) ? const MemoryLane() : const SizedBox(),
renderListProvider: timelineUsers.length > 1
? multiUsersTimelineProvider(timelineUsers)
: singleUserTimelineProvider(currentUser?.id),
buildLoadingIndicator: buildLoadingIndicator,
onRefresh: refreshAssets,
stackEnabled: true,
archiveEnabled: true,
editEnabled: true,
),
AnimatedPositioned(
duration: const Duration(milliseconds: 300),
top: ref.watch(multiselectProvider) ? -(kToolbarHeight + context.padding.top) : 0,
left: 0,
right: 0,
child: Container(
height: kToolbarHeight + context.padding.top,
color: context.themeData.appBarTheme.backgroundColor,
child: const ImmichAppBar(),
),
),
],
);
}
}
@@ -1,25 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/providers/search/all_motion_photos.provider.dart';
@RoutePage()
class AllMotionPhotosPage extends HookConsumerWidget {
const AllMotionPhotosPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final motionPhotos = ref.watch(allMotionPhotosProvider);
return Scaffold(
appBar: AppBar(
title: const Text('search_page_motion_photos').tr(),
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
),
body: motionPhotos.widgetWhen(onData: (assets) => ImmichAssetGrid(assets: assets)),
);
}
}
@@ -1,31 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/widgets/search/explore_grid.dart';
@RoutePage()
class AllPeoplePage extends HookConsumerWidget {
const AllPeoplePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final curatedPeople = ref.watch(getAllPeopleProvider);
return Scaffold(
appBar: AppBar(
title: const Text('people').tr(),
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
),
body: curatedPeople.widgetWhen(
onData: (people) => ExploreGrid(
isPeople: true,
curatedContent: people.map((e) => SearchCuratedContent(label: e.name, id: e.id)).toList(),
),
),
);
}
}
@@ -1,26 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/providers/search/search_page_state.provider.dart';
import 'package:immich_mobile/widgets/search/explore_grid.dart';
@RoutePage()
class AllPlacesPage extends HookConsumerWidget {
const AllPlacesPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
AsyncValue<List<SearchCuratedContent>> places = ref.watch(getAllPlacesProvider);
return Scaffold(
appBar: AppBar(
title: const Text('places').tr(),
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
),
body: places.widgetWhen(onData: (data) => ExploreGrid(curatedContent: data)),
);
}
}
@@ -1,22 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
@RoutePage()
class AllVideosPage extends HookConsumerWidget {
const AllVideosPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return Scaffold(
appBar: AppBar(
title: const Text('videos').tr(),
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
),
body: MultiselectGrid(renderListProvider: allVideosTimelineProvider),
);
}
}
-384
View File
@@ -1,384 +0,0 @@
import 'dart:async';
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:geolocator/geolocator.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/maplibrecontroller_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/models/map/map_marker.model.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/map/map_marker.provider.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/debounce.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/map_utils.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:immich_mobile/widgets/map/map_app_bar.dart';
import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
import 'package:immich_mobile/widgets/map/map_bottom_sheet.dart';
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
@RoutePage()
class MapPage extends HookConsumerWidget {
const MapPage({super.key, this.initialLocation});
final LatLng? initialLocation;
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapController = useRef<MapLibreMapController?>(null);
final markers = useRef<List<MapMarker>>([]);
final markersInBounds = useRef<List<MapMarker>>([]);
final bottomSheetStreamController = useStreamController<MapEvent>();
final selectedMarker = useValueNotifier<_AssetMarkerMeta?>(null);
final assetsDebouncer = useDebouncer();
final layerDebouncer = useDebouncer(interval: const Duration(seconds: 1));
final isLoading = useProcessingOverlay();
final scrollController = useScrollController();
final markerDebouncer = useDebouncer(interval: const Duration(milliseconds: 800));
final selectedAssets = useValueNotifier<Set<Asset>>({});
const mapZoomToAssetLevel = 12.0;
// updates the markersInBounds value with the map markers that are visible in the current
// map camera bounds
Future<void> updateAssetsInBounds() async {
// Guard map not created
if (mapController.value == null) {
return;
}
final bounds = await mapController.value!.getVisibleRegion();
final inBounds = markers.value
.where((m) => bounds.contains(LatLng(m.latLng.latitude, m.latLng.longitude)))
.toList();
// Notify bottom sheet to update asset grid only when there are new assets
if (markersInBounds.value.length != inBounds.length) {
bottomSheetStreamController.add(MapAssetsInBoundsUpdated(inBounds.map((e) => e.assetRemoteId).toList()));
}
markersInBounds.value = inBounds;
}
// removes all sources and layers and re-adds them with the updated markers
Future<void> reloadLayers() async {
if (mapController.value != null) {
layerDebouncer.run(() => mapController.value!.reloadAllLayersForMarkers(markers.value));
}
}
Future<void> loadMarkers() async {
try {
isLoading.value = true;
markers.value = await ref.read(mapMarkersProvider.future);
assetsDebouncer.run(updateAssetsInBounds);
await reloadLayers();
} finally {
isLoading.value = false;
}
}
useEffect(() {
final currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive();
loadMarkers();
return currentAssetLink.close;
}, []);
// Refetch markers when map state is changed
ref.listen(mapStateNotifierProvider, (_, current) {
if (current.shouldRefetchMarkers) {
markerDebouncer.run(() {
ref.invalidate(mapMarkersProvider);
// Reset marker
selectedMarker.value = null;
loadMarkers();
ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(false);
});
}
});
// updates the selected markers position based on the current map camera
Future<void> updateAssetMarkerPosition(MapMarker marker, {bool shouldAnimate = true}) async {
final assetPoint = await mapController.value!.toScreenLocation(marker.latLng);
selectedMarker.value = _AssetMarkerMeta(point: assetPoint, marker: marker, shouldAnimate: shouldAnimate);
(assetPoint, marker, shouldAnimate);
}
// finds the nearest asset marker from the tap point and store it as the selectedMarker
Future<void> onMarkerClicked(Point<double> point, LatLng _) async {
// Guard map not created
if (mapController.value == null) {
return;
}
final latlngBound = await mapController.value!.getBoundsFromPoint(point, 50);
final marker = markersInBounds.value.firstWhereOrNull(
(m) => latlngBound.contains(LatLng(m.latLng.latitude, m.latLng.longitude)),
);
if (marker != null) {
await updateAssetMarkerPosition(marker);
} else {
// If no asset was previously selected and no new asset is available, close the bottom sheet
if (selectedMarker.value == null) {
bottomSheetStreamController.add(const MapCloseBottomSheet());
}
selectedMarker.value = null;
}
}
void onMapCreated(MapLibreMapController controller) async {
mapController.value = controller;
controller.addListener(() {
if (controller.isCameraMoving && selectedMarker.value != null) {
updateAssetMarkerPosition(selectedMarker.value!.marker, shouldAnimate: false);
}
});
}
Future<void> onMarkerTapped() async {
final assetId = selectedMarker.value?.marker.assetRemoteId;
if (assetId == null) {
return;
}
final asset = await ref.read(dbProvider).assets.getByRemoteId(assetId);
if (asset == null) {
return;
}
// Since we only have a single asset, we can just show GroupAssetBy.none
final renderList = await RenderList.fromAssets([asset], GroupAssetsBy.none);
ref.read(currentAssetProvider.notifier).set(asset);
if (asset.isVideo) {
ref.read(showControlsProvider.notifier).show = false;
}
unawaited(context.pushRoute(GalleryViewerRoute(initialIndex: 0, heroOffset: 0, renderList: renderList)));
}
/// BOTTOM SHEET CALLBACKS
Future<void> onMapMoved() async {
assetsDebouncer.run(updateAssetsInBounds);
}
void onBottomSheetScrolled(String assetRemoteId) {
final assetMarker = markersInBounds.value.firstWhereOrNull((m) => m.assetRemoteId == assetRemoteId);
if (assetMarker != null) {
updateAssetMarkerPosition(assetMarker);
}
}
void onZoomToAsset(String assetRemoteId) {
final assetMarker = markersInBounds.value.firstWhereOrNull((m) => m.assetRemoteId == assetRemoteId);
if (mapController.value != null && assetMarker != null) {
// Offset the latitude a little to show the marker just above the viewports center
final offset = context.isMobile ? 0.02 : 0;
final latlng = LatLng(assetMarker.latLng.latitude - offset, assetMarker.latLng.longitude);
mapController.value!.animateCamera(
CameraUpdate.newLatLngZoom(latlng, mapZoomToAssetLevel),
duration: const Duration(milliseconds: 800),
);
}
}
void onZoomToLocation() async {
final (location, error) = await MapUtils.checkPermAndGetLocation(context: context);
if (error != null) {
if (error == LocationPermission.unableToDetermine && context.mounted) {
ImmichToast.show(
context: context,
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
msg: "map_cannot_get_user_location".tr(),
);
}
return;
}
if (mapController.value != null && location != null) {
await mapController.value!.animateCamera(
CameraUpdate.newLatLngZoom(LatLng(location.latitude, location.longitude), mapZoomToAssetLevel),
duration: const Duration(milliseconds: 800),
);
}
}
void onAssetsSelected(bool selected, Set<Asset> selection) {
selectedAssets.value = selected ? selection : {};
}
return MapThemeOverride(
mapBuilder: (style) => context.isMobile
// Single-column
? Scaffold(
extendBodyBehindAppBar: true,
appBar: MapAppBar(selectedAssets: selectedAssets),
body: Stack(
children: [
_MapWithMarker(
initialLocation: initialLocation,
style: style,
selectedMarker: selectedMarker,
onMapCreated: onMapCreated,
onMapMoved: onMapMoved,
onMapClicked: onMarkerClicked,
onStyleLoaded: reloadLayers,
onMarkerTapped: onMarkerTapped,
),
// Should be a part of the body and not scaffold::bottomsheet for the
// location button to be hit testable
MapBottomSheet(
mapEventStream: bottomSheetStreamController.stream,
onGridAssetChanged: onBottomSheetScrolled,
onZoomToAsset: onZoomToAsset,
onAssetsSelected: onAssetsSelected,
onZoomToLocation: onZoomToLocation,
selectedAssets: selectedAssets,
),
],
),
)
// Two-pane
: Row(
children: [
Expanded(
child: Scaffold(
extendBodyBehindAppBar: true,
appBar: MapAppBar(selectedAssets: selectedAssets),
body: Stack(
children: [
_MapWithMarker(
initialLocation: initialLocation,
style: style,
selectedMarker: selectedMarker,
onMapCreated: onMapCreated,
onMapMoved: onMapMoved,
onMapClicked: onMarkerClicked,
onStyleLoaded: reloadLayers,
onMarkerTapped: onMarkerTapped,
),
Positioned(
right: 0,
bottom: context.padding.bottom + 16,
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.my_location),
),
),
],
),
),
),
Expanded(
child: LayoutBuilder(
builder: (ctx, constraints) => MapAssetGrid(
controller: scrollController,
mapEventStream: bottomSheetStreamController.stream,
onGridAssetChanged: onBottomSheetScrolled,
onZoomToAsset: onZoomToAsset,
onAssetsSelected: onAssetsSelected,
selectedAssets: selectedAssets,
),
),
),
],
),
);
}
}
class _AssetMarkerMeta {
final Point<num> point;
final MapMarker marker;
final bool shouldAnimate;
const _AssetMarkerMeta({required this.point, required this.marker, required this.shouldAnimate});
@override
String toString() => '_AssetMarkerMeta(point: $point, marker: $marker, shouldAnimate: $shouldAnimate)';
}
class _MapWithMarker extends StatelessWidget {
final AsyncValue<String> style;
final MapCreatedCallback onMapCreated;
final OnCameraIdleCallback onMapMoved;
final OnMapClickCallback onMapClicked;
final OnStyleLoadedCallback onStyleLoaded;
final Function()? onMarkerTapped;
final ValueNotifier<_AssetMarkerMeta?> selectedMarker;
final LatLng? initialLocation;
const _MapWithMarker({
required this.style,
required this.onMapCreated,
required this.onMapMoved,
required this.onMapClicked,
required this.onStyleLoaded,
required this.selectedMarker,
this.onMarkerTapped,
this.initialLocation,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (ctx, constraints) => SizedBox(
height: constraints.maxHeight,
width: constraints.maxWidth,
child: Stack(
children: [
style.widgetWhen(
onData: (style) => MapLibreMap(
attributionButtonMargins: const Point(8, kToolbarHeight),
initialCameraPosition: CameraPosition(
target: initialLocation ?? const LatLng(0, 0),
zoom: initialLocation != null ? 12 : 0,
),
styleString: style,
// This is needed to update the selectedMarker's position on map camera updates
// The changes are notified through the mapController ValueListener which is added in [onMapCreated]
trackCameraPosition: true,
onMapCreated: onMapCreated,
onCameraIdle: onMapMoved,
onMapClick: onMapClicked,
onStyleLoadedCallback: onStyleLoaded,
tiltGesturesEnabled: false,
dragEnabled: false,
myLocationEnabled: false,
attributionButtonPosition: AttributionButtonPosition.topRight,
rotateGesturesEnabled: false,
),
),
ValueListenableBuilder(
valueListenable: selectedMarker,
builder: (ctx, value, _) => value != null
? PositionedAssetMarkerIcon(
point: value.point,
assetRemoteId: value.marker.assetRemoteId,
assetThumbhash: '',
durationInMilliseconds: value.shouldAnimate ? 100 : 0,
onTap: onMarkerTapped,
)
: const SizedBox.shrink(),
),
],
),
),
);
}
}
@@ -1,101 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/search/people.provider.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
@RoutePage()
class PersonResultPage extends HookConsumerWidget {
final String personId;
final String personName;
const PersonResultPage({super.key, required this.personId, required this.personName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final name = useState(personName);
showEditNameDialog() {
showDialog(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return PersonNameEditForm(personId: personId, personName: name.value);
},
).then((result) {
if (result != null && result.success) {
name.value = result.updatedName;
}
});
}
void buildBottomSheet() {
showModalBottomSheet(
backgroundColor: context.scaffoldBackgroundColor,
isScrollControlled: false,
context: context,
useSafeArea: true,
builder: (context) {
return SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.edit_outlined),
title: const Text('edit_name', style: TextStyle(fontWeight: FontWeight.bold)).tr(),
onTap: showEditNameDialog,
),
],
),
);
},
);
}
buildTitleBlock() {
return GestureDetector(
onTap: showEditNameDialog,
child: name.value.isEmpty
? Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('add_a_name', style: context.textTheme.titleMedium?.copyWith(color: context.primaryColor)).tr(),
Text('find_them_fast', style: context.textTheme.labelLarge).tr(),
],
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [Text(name.value, style: context.textTheme.titleLarge, overflow: TextOverflow.ellipsis)],
),
);
}
return Scaffold(
appBar: AppBar(
title: Text(name.value),
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
actions: [IconButton(onPressed: buildBottomSheet, icon: const Icon(Icons.more_vert_rounded))],
),
body: MultiselectGrid(
renderListProvider: personAssetsProvider(personId),
topWidget: Padding(
padding: const EdgeInsets.only(left: 8.0, top: 24),
child: Row(
children: [
CircleAvatar(radius: 36, backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(personId))),
Expanded(
child: Padding(padding: const EdgeInsets.only(left: 16.0, right: 16.0), child: buildTitleBlock()),
),
],
),
),
),
);
}
}
@@ -1,25 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/providers/search/recently_taken_asset.provider.dart';
@RoutePage()
class RecentlyTakenPage extends HookConsumerWidget {
const RecentlyTakenPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final recents = ref.watch(recentlyTakenAssetProvider);
return Scaffold(
appBar: AppBar(
title: const Text('recently_taken_page_title').tr(),
leading: IconButton(onPressed: () => context.maybePop(), icon: const Icon(Icons.arrow_back_ios_rounded)),
),
body: recents.widgetWhen(onData: (searchResponse) => ImmichAssetGrid(assets: searchResponse)),
);
}
}
-760
View File
@@ -1,760 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/person.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/providers/search/paginated_search.provider.dart';
import 'package:immich_mobile/providers/search/search_input_focus.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_grid/multiselect_grid.dart';
import 'package:immich_mobile/widgets/common/search_field.dart';
import 'package:immich_mobile/widgets/search/search_filter/camera_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/display_option_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/filter_bottom_sheet_scaffold.dart';
import 'package:immich_mobile/widgets/search/search_filter/location_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/media_type_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/people_picker.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_chip.dart';
import 'package:immich_mobile/widgets/search/search_filter/search_filter_utils.dart';
@RoutePage()
class SearchPage extends HookConsumerWidget {
const SearchPage({super.key, this.prefilter});
final SearchFilter? prefilter;
@override
Widget build(BuildContext context, WidgetRef ref) {
final textSearchType = useState<TextSearchType>(TextSearchType.context);
final searchHintText = useState<String>('sunrise_on_the_beach'.tr());
final textSearchController = useTextEditingController();
final filter = useState<SearchFilter>(
SearchFilter(
people: prefilter?.people ?? {},
location: prefilter?.location ?? SearchLocationFilter(),
camera: prefilter?.camera ?? SearchCameraFilter(),
date: prefilter?.date ?? SearchDateFilter(),
display: prefilter?.display ?? SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
mediaType: prefilter?.mediaType ?? AssetType.other,
rating: prefilter?.rating ?? SearchRatingFilter(),
language: "${context.locale.languageCode}-${context.locale.countryCode}",
),
);
final previousFilter = useState<SearchFilter?>(null);
final peopleCurrentFilterWidget = useState<Widget?>(null);
final dateRangeCurrentFilterWidget = useState<Widget?>(null);
final cameraCurrentFilterWidget = useState<Widget?>(null);
final locationCurrentFilterWidget = useState<Widget?>(null);
final mediaTypeCurrentFilterWidget = useState<Widget?>(null);
final displayOptionCurrentFilterWidget = useState<Widget?>(null);
final isSearching = useState(false);
SnackBar searchInfoSnackBar(String message) {
return SnackBar(
content: Text(message, style: context.textTheme.labelLarge),
showCloseIcon: true,
behavior: SnackBarBehavior.fixed,
closeIconColor: context.colorScheme.onSurface,
);
}
search() async {
if (filter.value.isEmpty) {
return;
}
if (prefilter == null && filter.value == previousFilter.value) {
return;
}
isSearching.value = true;
ref.watch(paginatedSearchProvider.notifier).clear();
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_result'.tr()));
}
previousFilter.value = filter.value;
isSearching.value = false;
}
loadMoreSearchResult() async {
isSearching.value = true;
final hasResult = await ref.watch(paginatedSearchProvider.notifier).search(filter.value);
if (!hasResult) {
context.showSnackBar(searchInfoSnackBar('search_no_more_result'.tr()));
}
isSearching.value = false;
}
searchPrefilter() {
if (prefilter != null) {
Future.delayed(Duration.zero, () {
search();
if (prefilter!.location.city != null) {
locationCurrentFilterWidget.value = Text(prefilter!.location.city!, style: context.textTheme.labelLarge);
}
});
}
}
useEffect(() {
Future.microtask(() => ref.invalidate(paginatedSearchProvider));
searchPrefilter();
return null;
}, []);
showPeoplePicker() {
handleOnSelect(Set<PersonDto> value) {
filter.value = filter.value.copyWith(people: value);
peopleCurrentFilterWidget.value = Text(
value.map((e) => e.name != '' ? e.name : 'no_name'.tr()).join(', '),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(people: {});
peopleCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
child: FractionallySizedBox(
heightFactor: 0.8,
child: FilterBottomSheetScaffold(
title: 'search_filter_people_title'.tr(),
expanded: true,
onSearch: search,
onClear: handleClear,
child: PeoplePicker(onSelect: handleOnSelect, filter: filter.value.people),
),
),
);
}
showLocationPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
location: SearchLocationFilter(country: value['country'], city: value['city'], state: value['state']),
);
final locationText = <String>[];
if (value['country'] != null) {
locationText.add(value['country']!);
}
if (value['state'] != null) {
locationText.add(value['state']!);
}
if (value['city'] != null) {
locationText.add(value['city']!);
}
locationCurrentFilterWidget.value = Text(locationText.join(', '), style: context.textTheme.labelLarge);
}
handleClear() {
filter.value = filter.value.copyWith(location: SearchLocationFilter());
locationCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_location_title'.tr(),
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0),
child: Container(
padding: EdgeInsets.only(bottom: context.viewInsets.bottom),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: LocationPicker(onSelected: handleOnSelect, filter: filter.value.location),
),
),
),
),
);
}
showCameraPicker() {
handleOnSelect(Map<String, String?> value) {
filter.value = filter.value.copyWith(
camera: SearchCameraFilter(make: value['make'], model: value['model']),
);
cameraCurrentFilterWidget.value = Text(
'${value['make'] ?? ''} ${value['model'] ?? ''}',
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(camera: SearchCameraFilter());
cameraCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
isScrollControlled: true,
isDismissible: true,
child: FilterBottomSheetScaffold(
title: 'search_filter_camera_title'.tr(),
onSearch: search,
onClear: handleClear,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: CameraPicker(onSelect: handleOnSelect, filter: filter.value.camera),
),
),
);
}
showDatePicker() async {
final firstDate = DateTime(1900);
final lastDate = DateTime.now();
final date = await showDateRangePicker(
context: context,
firstDate: firstDate,
lastDate: lastDate,
currentDate: DateTime.now(),
initialDateRange: DateTimeRange(
start: filter.value.date.takenAfter ?? lastDate,
end: filter.value.date.takenBefore ?? lastDate,
),
helpText: 'search_filter_date_title'.tr(),
cancelText: 'cancel'.tr(),
confirmText: 'select'.tr(),
saveText: 'save'.tr(),
errorFormatText: 'invalid_date_format'.tr(),
errorInvalidText: 'invalid_date'.tr(),
fieldStartHintText: 'start_date'.tr(),
fieldEndHintText: 'end_date'.tr(),
initialEntryMode: DatePickerEntryMode.calendar,
keyboardType: TextInputType.text,
);
if (date == null) {
filter.value = filter.value.copyWith(date: SearchDateFilter());
dateRangeCurrentFilterWidget.value = null;
unawaited(search());
return;
}
filter.value = filter.value.copyWith(
date: SearchDateFilter(
takenAfter: date.start,
takenBefore: date.end.add(const Duration(hours: 23, minutes: 59, seconds: 59)),
),
);
// If date range is less than 24 hours, set the end date to the end of the day
if (date.end.difference(date.start).inHours < 24) {
dateRangeCurrentFilterWidget.value = Text(
DateFormat.yMMMd().format(date.start.toLocal()),
style: context.textTheme.labelLarge,
);
} else {
dateRangeCurrentFilterWidget.value = Text(
'search_filter_date_interval'.tr(
namedArgs: {
"start": DateFormat.yMMMd().format(date.start.toLocal()),
"end": DateFormat.yMMMd().format(date.end.toLocal()),
},
),
style: context.textTheme.labelLarge,
);
}
unawaited(search());
}
// MEDIA PICKER
showMediaTypePicker() {
handleOnSelected(AssetType assetType) {
filter.value = filter.value.copyWith(mediaType: assetType);
mediaTypeCurrentFilterWidget.value = Text(
assetType == AssetType.image
? 'image'.tr()
: assetType == AssetType.video
? 'video'.tr()
: 'all'.tr(),
style: context.textTheme.labelLarge,
);
}
handleClear() {
filter.value = filter.value.copyWith(mediaType: AssetType.other);
mediaTypeCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'search_filter_media_type_title'.tr(),
onSearch: search,
onClear: handleClear,
child: MediaTypePicker(onSelect: handleOnSelected, filter: filter.value.mediaType),
),
);
}
// DISPLAY OPTION
showDisplayOptionPicker() {
handleOnSelect(Map<DisplayOption, bool> value) {
final filterText = <String>[];
value.forEach((key, value) {
switch (key) {
case DisplayOption.notInAlbum:
filter.value = filter.value.copyWith(display: filter.value.display.copyWith(isNotInAlbum: value));
if (value) {
filterText.add('search_filter_display_option_not_in_album'.tr());
}
break;
case DisplayOption.archive:
filter.value = filter.value.copyWith(display: filter.value.display.copyWith(isArchive: value));
if (value) {
filterText.add('archive'.tr());
}
break;
case DisplayOption.favorite:
filter.value = filter.value.copyWith(display: filter.value.display.copyWith(isFavorite: value));
if (value) {
filterText.add('favorite'.tr());
}
break;
}
});
if (filterText.isEmpty) {
displayOptionCurrentFilterWidget.value = null;
return;
}
displayOptionCurrentFilterWidget.value = Text(filterText.join(', '), style: context.textTheme.labelLarge);
}
handleClear() {
filter.value = filter.value.copyWith(
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
);
displayOptionCurrentFilterWidget.value = null;
search();
}
showFilterBottomSheet(
context: context,
child: FilterBottomSheetScaffold(
title: 'display_options'.tr(),
onSearch: search,
onClear: handleClear,
child: DisplayOptionPicker(onSelect: handleOnSelect, filter: filter.value.display),
),
);
}
handleTextSubmitted(String value) {
switch (textSearchType.value) {
case TextSearchType.context:
filter.value = filter.value.copyWith(filename: '', context: value, description: '', ocr: '');
break;
case TextSearchType.filename:
filter.value = filter.value.copyWith(filename: value, context: '', description: '', ocr: '');
break;
case TextSearchType.description:
filter.value = filter.value.copyWith(filename: '', context: '', description: value, ocr: '');
break;
case TextSearchType.ocr:
filter.value = filter.value.copyWith(filename: '', context: '', description: '', ocr: value);
break;
}
search();
}
IconData getSearchPrefixIcon() => switch (textSearchType.value) {
TextSearchType.context => Icons.image_search_rounded,
TextSearchType.filename => Icons.abc_rounded,
TextSearchType.description => Icons.text_snippet_outlined,
TextSearchType.ocr => Icons.document_scanner_outlined,
};
return Scaffold(
resizeToAvoidBottomInset: false,
appBar: AppBar(
automaticallyImplyLeading: true,
actions: [
Padding(
padding: const EdgeInsets.only(right: 16.0),
child: MenuAnchor(
style: MenuStyle(
elevation: const WidgetStatePropertyAll(1),
shape: WidgetStateProperty.all(
const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
),
padding: const WidgetStatePropertyAll(EdgeInsets.all(4)),
),
builder: (BuildContext context, MenuController controller, Widget? child) {
return IconButton(
onPressed: () {
if (controller.isOpen) {
controller.close();
} else {
controller.open();
}
},
icon: const Icon(Icons.more_vert_rounded),
tooltip: 'show_text_search_menu'.tr(),
);
},
menuChildren: [
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.image_search_rounded),
title: Text(
'search_by_context'.tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.context ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.context,
),
onPressed: () {
textSearchType.value = TextSearchType.context;
searchHintText.value = 'sunrise_on_the_beach'.tr();
},
),
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.abc_rounded),
title: Text(
'search_filter_filename'.tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.filename ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.filename,
),
onPressed: () {
textSearchType.value = TextSearchType.filename;
searchHintText.value = 'file_name_or_extension'.tr();
},
),
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.text_snippet_outlined),
title: Text(
'search_by_description'.tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.description ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.description,
),
onPressed: () {
textSearchType.value = TextSearchType.description;
searchHintText.value = 'search_by_description_example'.tr();
},
),
MenuItemButton(
child: ListTile(
leading: const Icon(Icons.document_scanner_outlined),
title: Text(
'search_filter_ocr'.tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
color: textSearchType.value == TextSearchType.ocr ? context.colorScheme.primary : null,
),
),
selectedColor: context.colorScheme.primary,
selected: textSearchType.value == TextSearchType.ocr,
),
onPressed: () {
textSearchType.value = TextSearchType.ocr;
searchHintText.value = 'search_by_ocr_example'.tr();
},
),
],
),
),
],
title: Container(
decoration: BoxDecoration(
border: Border.all(color: context.colorScheme.onSurface.withAlpha(0), width: 0),
borderRadius: const BorderRadius.all(Radius.circular(24)),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withValues(alpha: 0.075),
context.colorScheme.primary.withValues(alpha: 0.09),
context.colorScheme.primary.withValues(alpha: 0.075),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
),
child: SearchField(
hintText: searchHintText.value,
key: const Key('search_text_field'),
controller: textSearchController,
contentPadding: prefilter != null ? const EdgeInsets.only(left: 24) : const EdgeInsets.all(8),
prefixIcon: prefilter != null ? null : Icon(getSearchPrefixIcon(), color: context.colorScheme.primary),
onSubmitted: handleTextSubmitted,
focusNode: ref.watch(searchInputFocusProvider),
),
),
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: SizedBox(
height: 50,
child: ListView(
key: const Key('search_filter_chip_list'),
shrinkWrap: true,
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
children: [
SearchFilterChip(
icon: Icons.people_alt_outlined,
onTap: showPeoplePicker,
label: 'people'.tr(),
currentFilter: peopleCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.location_on_outlined,
onTap: showLocationPicker,
label: 'search_filter_location'.tr(),
currentFilter: locationCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.camera_alt_outlined,
onTap: showCameraPicker,
label: 'camera'.tr(),
currentFilter: cameraCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.date_range_outlined,
onTap: showDatePicker,
label: 'search_filter_date'.tr(),
currentFilter: dateRangeCurrentFilterWidget.value,
),
SearchFilterChip(
key: const Key('media_type_chip'),
icon: Icons.video_collection_outlined,
onTap: showMediaTypePicker,
label: 'search_filter_media_type'.tr(),
currentFilter: mediaTypeCurrentFilterWidget.value,
),
SearchFilterChip(
icon: Icons.display_settings_outlined,
onTap: showDisplayOptionPicker,
label: 'search_filter_display_options'.tr(),
currentFilter: displayOptionCurrentFilterWidget.value,
),
],
),
),
),
if (isSearching.value)
const Expanded(child: Center(child: CircularProgressIndicator()))
else
SearchResultGrid(onScrollEnd: loadMoreSearchResult, isSearching: isSearching.value),
],
),
);
}
}
class SearchResultGrid extends StatelessWidget {
final VoidCallback onScrollEnd;
final bool isSearching;
const SearchResultGrid({super.key, required this.onScrollEnd, this.isSearching = false});
@override
Widget build(BuildContext context) {
return Expanded(
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: NotificationListener<ScrollEndNotification>(
onNotification: (notification) {
final isBottomSheetNotification =
notification.context?.findAncestorWidgetOfExactType<DraggableScrollableSheet>() != null;
final metrics = notification.metrics;
final isVerticalScroll = metrics.axis == Axis.vertical;
if (metrics.pixels >= metrics.maxScrollExtent && isVerticalScroll && !isBottomSheetNotification) {
onScrollEnd();
}
return true;
},
child: MultiselectGrid(
renderListProvider: paginatedSearchRenderListProvider,
archiveEnabled: true,
deleteEnabled: true,
editEnabled: true,
favoriteEnabled: true,
stackEnabled: false,
dragScrollLabelEnabled: false,
emptyIndicator: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: !isSearching ? const SearchEmptyContent() : const SizedBox.shrink(),
),
),
),
),
);
}
}
class SearchEmptyContent extends StatelessWidget {
const SearchEmptyContent({super.key});
@override
Widget build(BuildContext context) {
return NotificationListener<ScrollNotification>(
onNotification: (_) => true,
child: ListView(
shrinkWrap: false,
children: [
const SizedBox(height: 40),
Center(
child: Image.asset(
context.isDarkTheme ? 'assets/polaroid-dark.png' : 'assets/polaroid-light.png',
height: 125,
),
),
const SizedBox(height: 16),
Center(child: Text('search_page_search_photos_videos'.tr(), style: context.textTheme.labelLarge)),
const SizedBox(height: 32),
const QuickLinkList(),
],
),
);
}
}
class QuickLinkList extends StatelessWidget {
const QuickLinkList({super.key});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(20)),
border: Border.all(color: context.colorScheme.outline.withAlpha(10), width: 1),
gradient: LinearGradient(
colors: [
context.colorScheme.primary.withAlpha(10),
context.colorScheme.primary.withAlpha(15),
context.colorScheme.primary.withAlpha(20),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
QuickLink(
title: 'recently_taken'.tr(),
icon: Icons.schedule_outlined,
isTop: true,
onTap: () => context.pushRoute(const RecentlyTakenRoute()),
),
QuickLink(
title: 'videos'.tr(),
icon: Icons.play_circle_outline_rounded,
onTap: () => context.pushRoute(const AllVideosRoute()),
),
QuickLink(
title: 'favorites'.tr(),
icon: Icons.favorite_border_rounded,
isBottom: true,
onTap: () => context.pushRoute(const FavoritesRoute()),
),
],
),
);
}
}
class QuickLink extends StatelessWidget {
final String title;
final IconData icon;
final VoidCallback onTap;
final bool isTop;
final bool isBottom;
const QuickLink({
super.key,
required this.title,
required this.icon,
required this.onTap,
this.isTop = false,
this.isBottom = false,
});
@override
Widget build(BuildContext context) {
final borderRadius = BorderRadius.only(
topLeft: Radius.circular(isTop ? 20 : 0),
topRight: Radius.circular(isTop ? 20 : 0),
bottomLeft: Radius.circular(isBottom ? 20 : 0),
bottomRight: Radius.circular(isBottom ? 20 : 0),
);
return ListTile(
shape: RoundedRectangleBorder(borderRadius: borderRadius),
leading: Icon(icon, size: 26),
title: Text(title, style: context.textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w500)),
onTap: onTap,
);
}
}
@@ -2,7 +2,6 @@ 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/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/upload/share_intent_attachment.model.dart';
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
@@ -66,7 +65,7 @@ class ShareIntentPage extends ConsumerWidget {
),
leading: IconButton(
onPressed: () {
context.navigateTo(Store.isBetaTimelineEnabled ? const TabShellRoute() : const TabControllerRoute());
context.navigateTo(const TabShellRoute());
},
icon: const Icon(Icons.arrow_back),
),