mirror of
https://github.com/immich-app/immich.git
synced 2026-05-24 00:22:29 -04:00
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:
@@ -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);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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]),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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)),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
|
||||
Reference in New Issue
Block a user