refactor: yeet old timeline (#27666)

* refactor: yank old timeline

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

* more cleanup

* remove native code

* chore: bump sqlite-data version

* remove old background tasks from BGTaskSchedulerPermittedIdentifiers

* rebase

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
shenlong
2026-04-15 23:00:27 +05:30
committed by GitHub
parent 6dd6053222
commit 79fccdbee0
367 changed files with 332 additions and 50870 deletions
@@ -1,85 +0,0 @@
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/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/common/user_circle_avatar.dart';
class ActivityTextField extends HookConsumerWidget {
final bool isEnabled;
final String? likeId;
final Function(String) onSubmit;
const ActivityTextField({required this.onSubmit, this.isEnabled = true, this.likeId, super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final album = ref.watch(currentAlbumProvider)!;
final asset = ref.watch(currentAssetProvider);
final activityNotifier = ref.read(albumActivityProvider(album.remoteId!, asset?.remoteId).notifier);
final user = ref.watch(currentUserProvider);
final inputController = useTextEditingController();
final inputFocusNode = useFocusNode();
final liked = likeId != null;
// Show keyboard immediately on activities open
useEffect(() {
inputFocusNode.requestFocus();
return null;
}, []);
// Pass text to callback and reset controller
void onEditingComplete() {
onSubmit(inputController.text);
inputController.clear();
inputFocusNode.unfocus();
}
Future<void> addLike() async {
await activityNotifier.addLike();
}
Future<void> removeLike() async {
if (liked) {
await activityNotifier.removeActivity(likeId!);
}
}
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: TextField(
controller: inputController,
enabled: isEnabled,
focusNode: inputFocusNode,
textInputAction: TextInputAction.send,
autofocus: false,
decoration: InputDecoration(
border: InputBorder.none,
focusedBorder: InputBorder.none,
prefixIcon: user != null
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 15),
child: UserCircleAvatar(user: user, size: 30),
)
: null,
suffixIcon: Padding(
padding: const EdgeInsets.only(right: 10),
child: IconButton(
icon: Icon(liked ? Icons.thumb_up : Icons.thumb_up_off_alt),
onPressed: liked ? removeLike : addLike,
),
),
suffixIconColor: liked ? context.primaryColor : null,
hintText: !isEnabled ? 'shared_album_activities_input_disable'.tr() : 'say_something'.tr(),
hintStyle: TextStyle(fontWeight: FontWeight.normal, fontSize: 14, color: Colors.grey[600]),
),
onEditingComplete: onEditingComplete,
onTapOutside: (_) => inputFocusNode.unfocus(),
),
);
}
}
@@ -1,113 +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/extensions/datetime_extensions.dart';
import 'package:immich_mobile/models/activities/activity.model.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/providers/activity_service.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
class ActivityTile extends HookConsumerWidget {
final Activity activity;
final bool isBottomSheet;
const ActivityTile(this.activity, {super.key, this.isBottomSheet = false});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetProvider);
final isLike = activity.type == ActivityType.like;
// Asset thumbnail is displayed when we are accessing activities from the album page
// currentAssetProvider will not be set until we open the gallery viewer
final showAssetThumbnail = asset == null && activity.assetId != null && !isBottomSheet;
onTap() async {
final activityService = ref.read(activityServiceProvider);
final route = await activityService.buildAssetViewerRoute(activity.assetId!, ref);
if (route != null) {
await context.pushRoute(route);
}
}
return ListTile(
minVerticalPadding: 15,
leading: isLike
? Container(
width: isBottomSheet ? 30 : 44,
alignment: Alignment.center,
child: Icon(Icons.thumb_up, color: context.primaryColor),
)
: isBottomSheet
? UserCircleAvatar(user: activity.user, size: 30)
: UserCircleAvatar(user: activity.user),
title: _ActivityTitle(
userName: activity.user.name,
createdAt: activity.createdAt.timeAgo(),
leftAlign: isBottomSheet ? false : (isLike || showAssetThumbnail),
),
// No subtitle for like, so center title
titleAlignment: !isLike ? ListTileTitleAlignment.top : ListTileTitleAlignment.center,
trailing: showAssetThumbnail ? _ActivityAssetThumbnail(activity.assetId!, onTap) : null,
subtitle: !isLike ? Text(activity.comment!) : null,
);
}
}
class _ActivityTitle extends StatelessWidget {
final String userName;
final String createdAt;
final bool leftAlign;
const _ActivityTitle({required this.userName, required this.createdAt, required this.leftAlign});
@override
Widget build(BuildContext context) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
final textStyle = context.textTheme.bodyMedium?.copyWith(color: textColor.withValues(alpha: 0.6));
return Row(
mainAxisAlignment: leftAlign ? MainAxisAlignment.start : MainAxisAlignment.spaceBetween,
mainAxisSize: leftAlign ? MainAxisSize.min : MainAxisSize.max,
children: [
Text(userName, style: textStyle, overflow: TextOverflow.ellipsis),
if (leftAlign) Text("", style: textStyle),
Expanded(
child: Text(
createdAt,
style: textStyle,
overflow: TextOverflow.ellipsis,
textAlign: leftAlign ? TextAlign.left : TextAlign.right,
),
),
],
);
}
}
class _ActivityAssetThumbnail extends StatelessWidget {
final String assetId;
final GestureTapCallback? onTap;
const _ActivityAssetThumbnail(this.assetId, this.onTap);
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 40,
height: 30,
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(4)),
image: DecorationImage(
image: RemoteImageProvider.thumbnail(assetId: assetId, thumbhash: ""),
fit: BoxFit.cover,
),
),
child: const SizedBox.shrink(),
),
);
}
}
@@ -1,8 +1,6 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/activities/activity_tile.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
/// Wraps an [ActivityTile] and makes it dismissible
class DismissibleActivity extends StatelessWidget {
final String activityId;
final Widget body;
@@ -1,98 +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/services/album.service.dart';
import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/widgets/common/drag_sheet.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class AddToAlbumBottomSheet extends HookConsumerWidget {
/// The asset to add to an album
final List<Asset> assets;
const AddToAlbumBottomSheet({super.key, required this.assets});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final albumService = ref.watch(albumServiceProvider);
useEffect(() {
// Fetch album updates, e.g., cover image
ref.read(albumProvider.notifier).refreshRemoteAlbums();
return null;
}, []);
void addToAlbum(Album album) async {
final result = await albumService.addAssets(album, assets);
if (result != null) {
if (result.alreadyInAlbum.isNotEmpty) {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_already_exists'.tr(namedArgs: {"album": album.name}),
);
} else {
ImmichToast.show(
context: context,
msg: 'add_to_album_bottom_sheet_added'.tr(namedArgs: {"album": album.name}),
);
}
}
context.pop();
}
return Card(
elevation: 0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(topLeft: Radius.circular(15), topRight: Radius.circular(15)),
),
child: CustomScrollView(
slivers: [
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverToBoxAdapter(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 12),
const Align(alignment: Alignment.center, child: CustomDraggingHandle()),
const SizedBox(height: 12),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('add_to_album'.tr(), style: context.textTheme.displayMedium),
TextButton.icon(
icon: Icon(Icons.add, color: context.primaryColor),
label: Text('common_create_new_album'.tr(), style: TextStyle(color: context.primaryColor)),
onPressed: () {
context.pushRoute(CreateAlbumRoute(assets: assets));
},
),
],
),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: AddToAlbumSliverList(
albums: albums,
sharedAlbums: albums.where((a) => a.shared).toList(),
onAddToAlbum: addToAlbum,
),
),
],
),
);
}
}
@@ -1,65 +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/providers/album/album_sort_by_options.provider.dart';
import 'package:immich_mobile/widgets/album/album_thumbnail_listtile.dart';
import 'package:immich_mobile/entities/album.entity.dart';
class AddToAlbumSliverList extends HookConsumerWidget {
/// The asset to add to an album
final List<Album> albums;
final List<Album> sharedAlbums;
final void Function(Album) onAddToAlbum;
final bool enabled;
const AddToAlbumSliverList({
super.key,
required this.onAddToAlbum,
required this.albums,
required this.sharedAlbums,
this.enabled = true,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumSortMode = ref.watch(albumSortByOptionsProvider);
final albumSortIsReverse = ref.watch(albumSortOrderProvider);
final sortedAlbums = albumSortMode.sortFn(albums, albumSortIsReverse);
final sortedSharedAlbums = albumSortMode.sortFn(sharedAlbums, albumSortIsReverse);
return SliverList(
delegate: SliverChildBuilderDelegate(childCount: albums.length + (sharedAlbums.isEmpty ? 0 : 1), (
context,
index,
) {
// Build shared expander
if (index == 0 && sortedSharedAlbums.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: ExpansionTile(
title: Text('shared'.tr()),
tilePadding: const EdgeInsets.symmetric(horizontal: 10.0),
leading: const Icon(Icons.group),
children: [
ListView.builder(
shrinkWrap: true,
physics: const ClampingScrollPhysics(),
itemCount: sortedSharedAlbums.length,
itemBuilder: (context, index) => AlbumThumbnailListTile(
album: sortedSharedAlbums[index],
onTap: enabled ? () => onAddToAlbum(sortedSharedAlbums[index]) : () {},
),
),
],
),
);
}
// Build albums list
final offset = index - (sharedAlbums.isNotEmpty ? 1 : 0);
final album = sortedAlbums[offset];
return AlbumThumbnailListTile(album: album, onTap: enabled ? () => onAddToAlbum(album) : () {});
}),
);
}
}
@@ -1,111 +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/entities/album.entity.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/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
class AlbumThumbnailCard extends ConsumerWidget {
final Function()? onTap;
/// Whether or not to show the owner of the album (or "Owned")
/// in the subtitle of the album
final bool showOwner;
final bool showTitle;
const AlbumThumbnailCard({super.key, required this.album, this.onTap, this.showOwner = false, this.showTitle = true});
final Album album;
@override
Widget build(BuildContext context, WidgetRef ref) {
return LayoutBuilder(
builder: (context, constraints) {
var cardSize = constraints.maxWidth;
buildEmptyThumbnail() {
return Container(
height: cardSize,
width: cardSize,
decoration: BoxDecoration(color: context.colorScheme.surfaceContainerHigh),
child: Center(
child: Icon(Icons.no_photography, size: cardSize * .15, color: context.colorScheme.primary),
),
);
}
buildAlbumThumbnail() => ImmichThumbnail(asset: album.thumbnail.value, width: cardSize, height: cardSize);
buildAlbumTextRow() {
// Add the owner name to the subtitle
String? owner;
if (showOwner) {
if (album.ownerId == ref.read(currentUserProvider)?.id) {
owner = 'owned'.tr();
} else if (album.ownerName != null) {
owner = 'shared_by_user'.t(context: context, args: {'user': album.ownerName!});
}
}
return Text.rich(
TextSpan(
children: [
TextSpan(
text: 'items_count'.t(context: context, args: {'count': album.assetCount}),
),
if (owner != null) const TextSpan(text: ''),
if (owner != null) TextSpan(text: owner),
],
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
overflow: TextOverflow.fade,
);
}
return GestureDetector(
onTap: onTap,
child: Flex(
direction: Axis.vertical,
children: [
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
width: cardSize,
height: cardSize,
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(20)),
child: album.thumbnail.value == null ? buildEmptyThumbnail() : buildAlbumThumbnail(),
),
),
if (showTitle) ...[
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: SizedBox(
width: cardSize,
child: Text(
album.name,
overflow: TextOverflow.ellipsis,
style: context.textTheme.titleSmall?.copyWith(
color: context.colorScheme.onSurface,
fontWeight: FontWeight.w500,
),
),
),
),
buildAlbumTextRow(),
],
],
),
),
],
),
);
},
);
}
}
@@ -1,93 +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/entities/album.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:openapi/api.dart';
class AlbumThumbnailListTile extends StatelessWidget {
const AlbumThumbnailListTile({super.key, required this.album, this.onTap});
final Album album;
final void Function()? onTap;
@override
Widget build(BuildContext context) {
var cardSize = 68.0;
buildEmptyThumbnail() {
return Container(
decoration: BoxDecoration(color: context.isDarkTheme ? Colors.grey[800] : Colors.grey[200]),
child: SizedBox(
height: cardSize,
width: cardSize,
child: const Center(child: Icon(Icons.no_photography)),
),
);
}
buildAlbumThumbnail() {
return SizedBox(
width: cardSize,
height: cardSize,
child: Thumbnail(
imageProvider: RemoteImageProvider(url: getAlbumThumbnailUrl(album, type: AssetMediaSize.thumbnail)),
),
);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap:
onTap ??
() {
context.pushRoute(AlbumViewerRoute(albumId: album.id));
},
child: Padding(
padding: const EdgeInsets.only(bottom: 12.0),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(8)),
child: album.thumbnail.value == null ? buildEmptyThumbnail() : buildAlbumThumbnail(),
),
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
album.name,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontWeight: FontWeight.bold),
),
Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
'items_count'.t(context: context, args: {'count': album.assetCount}),
style: const TextStyle(fontSize: 12),
),
if (album.shared) ...[
const Text('', style: TextStyle(fontSize: 12)),
Text('shared'.tr(), style: const TextStyle(fontSize: 12)),
],
],
),
],
),
),
),
],
),
),
);
}
}
@@ -1,74 +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/album_title.provider.dart';
class AlbumTitleTextField extends ConsumerWidget {
const AlbumTitleTextField({
super.key,
required this.isAlbumTitleEmpty,
required this.albumTitleTextFieldFocusNode,
required this.albumTitleController,
required this.isAlbumTitleTextFieldFocus,
});
final ValueNotifier<bool> isAlbumTitleEmpty;
final FocusNode albumTitleTextFieldFocusNode;
final TextEditingController albumTitleController;
final ValueNotifier<bool> isAlbumTitleTextFieldFocus;
@override
Widget build(BuildContext context, WidgetRef ref) {
return TextField(
onChanged: (v) {
if (v.isEmpty) {
isAlbumTitleEmpty.value = true;
} else {
isAlbumTitleEmpty.value = false;
}
ref.watch(albumTitleProvider.notifier).setAlbumTitle(v);
},
focusNode: albumTitleTextFieldFocusNode,
style: TextStyle(fontSize: 28, color: context.colorScheme.onSurface, fontWeight: FontWeight.bold),
controller: albumTitleController,
onTap: () {
isAlbumTitleTextFieldFocus.value = true;
if (albumTitleController.text == 'Untitled') {
albumTitleController.clear();
}
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
suffixIcon: !isAlbumTitleEmpty.value && isAlbumTitleTextFieldFocus.value
? IconButton(
onPressed: () {
albumTitleController.clear();
isAlbumTitleEmpty.value = true;
},
icon: Icon(Icons.cancel_rounded, color: context.primaryColor),
splashRadius: 10,
)
: null,
enabledBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.all(Radius.circular(10)),
),
focusedBorder: const OutlineInputBorder(
borderSide: BorderSide(color: Colors.transparent),
borderRadius: BorderRadius.all(Radius.circular(10)),
),
hintText: 'add_a_title'.tr(),
hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith(
fontSize: 28,
fontWeight: FontWeight.bold,
),
focusColor: Colors.grey[300],
fillColor: context.colorScheme.surfaceContainerHigh,
filled: isAlbumTitleTextFieldFocus.value,
),
);
}
}
@@ -1,307 +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/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/album/album_viewer.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class AlbumViewerAppbar extends HookConsumerWidget implements PreferredSizeWidget {
const AlbumViewerAppbar({
super.key,
required this.userId,
required this.titleFocusNode,
required this.descriptionFocusNode,
this.onAddPhotos,
this.onAddUsers,
required this.onActivities,
});
final String userId;
final FocusNode titleFocusNode;
final FocusNode descriptionFocusNode;
final void Function()? onAddPhotos;
final void Function()? onAddUsers;
final void Function() onActivities;
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumState = useState(ref.read(currentAlbumProvider));
final album = albumState.value;
ref.listen(currentAlbumProvider, (_, newAlbum) {
final oldAlbum = albumState.value;
if (oldAlbum != null && newAlbum != null && oldAlbum.id == newAlbum.id) {
return;
}
albumState.value = newAlbum;
});
if (album == null) {
return const SizedBox();
}
final albumViewer = ref.watch(albumViewerProvider);
final newAlbumTitle = albumViewer.editTitleText;
final newAlbumDescription = albumViewer.editDescriptionText;
final isEditAlbum = albumViewer.isEditAlbum;
final comments = album.shared ? ref.watch(activityStatisticsProvider(album.remoteId!)) : 0;
deleteAlbum() async {
final bool success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
unawaited(context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])));
if (!success) {
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_delete".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
}
Future<void> onDeleteAlbumPressed() {
return showDialog<void>(
context: context,
barrierDismissible: false, // user must tap button!
builder: (BuildContext context) {
return AlertDialog(
title: const Text('delete_album').tr(),
content: const Text('album_viewer_appbar_delete_confirm').tr(),
actions: <Widget>[
TextButton(
onPressed: () => context.pop('Cancel'),
child: Text(
'cancel',
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () {
context.pop('Confirm');
deleteAlbum();
},
child: Text(
'confirm',
style: TextStyle(fontWeight: FontWeight.bold, color: context.colorScheme.error),
).tr(),
),
],
);
},
);
}
void onLeaveAlbumPressed() async {
bool isSuccess = await ref.watch(albumProvider.notifier).leaveAlbum(album);
if (isSuccess) {
unawaited(context.navigateTo(const TabControllerRoute(children: [AlbumsRoute()])));
} else {
context.pop();
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_leave".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
}
buildBottomSheetActions() {
return [
album.ownerId == userId
? ListTile(
leading: const Icon(Icons.delete_forever_rounded),
title: const Text('delete_album', style: TextStyle(fontWeight: FontWeight.w500)).tr(),
onTap: onDeleteAlbumPressed,
)
: ListTile(
leading: const Icon(Icons.person_remove_rounded),
title: const Text(
'album_viewer_appbar_share_leave',
style: TextStyle(fontWeight: FontWeight.w500),
).tr(),
onTap: onLeaveAlbumPressed,
),
];
// }
}
void onSortOrderToggled() async {
final updatedAlbum = await ref.read(albumProvider.notifier).toggleSortOrder(album);
if (updatedAlbum == null) {
ImmichToast.show(
context: context,
msg: "error_change_sort_album".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
context.pop();
}
void buildBottomSheet() {
final ownerActions = [
ListTile(
leading: const Icon(Icons.person_add_alt_rounded),
onTap: () {
context.pop();
final onAddUsers = this.onAddUsers;
if (onAddUsers != null) {
onAddUsers();
}
},
title: const Text("album_viewer_page_share_add_users", style: TextStyle(fontWeight: FontWeight.w500)).tr(),
),
ListTile(
leading: const Icon(Icons.swap_vert_rounded),
onTap: onSortOrderToggled,
title: const Text("change_display_order", style: TextStyle(fontWeight: FontWeight.w500)).tr(),
),
ListTile(
leading: const Icon(Icons.link_rounded),
onTap: () {
context.pushRoute(SharedLinkEditRoute(albumId: album.remoteId));
context.pop();
},
title: const Text("control_bottom_app_bar_share_link", style: TextStyle(fontWeight: FontWeight.w500)).tr(),
),
ListTile(
leading: const Icon(Icons.settings_rounded),
onTap: () => context.navigateTo(const AlbumOptionsRoute()),
title: const Text("options", style: TextStyle(fontWeight: FontWeight.w500)).tr(),
),
];
final commonActions = [
ListTile(
leading: const Icon(Icons.add_photo_alternate_outlined),
onTap: () {
context.pop();
final onAddPhotos = this.onAddPhotos;
if (onAddPhotos != null) {
onAddPhotos();
}
},
title: const Text("add_photos", style: TextStyle(fontWeight: FontWeight.w500)).tr(),
),
];
showModalBottomSheet(
backgroundColor: context.scaffoldBackgroundColor,
isScrollControlled: false,
context: context,
builder: (context) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 24.0),
child: ListView(
shrinkWrap: true,
children: [
...buildBottomSheetActions(),
if (onAddPhotos != null) ...commonActions,
if (onAddPhotos != null && userId == album.ownerId) ...ownerActions,
],
),
),
);
},
);
}
Widget buildActivitiesButton() {
return IconButton(
onPressed: onActivities,
icon: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const Icon(Icons.mode_comment_outlined),
if (comments != 0)
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text(
comments.toString(),
style: TextStyle(fontWeight: FontWeight.bold, color: context.primaryColor),
),
),
],
),
);
}
buildLeadingButton() {
if (isEditAlbum) {
return IconButton(
onPressed: () async {
if (newAlbumTitle.isNotEmpty) {
bool isSuccess = await ref.watch(albumViewerProvider.notifier).changeAlbumTitle(album, newAlbumTitle);
if (!isSuccess) {
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_title".tr(),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
titleFocusNode.unfocus();
} else if (newAlbumDescription.isNotEmpty) {
bool isSuccessDescription = await ref
.watch(albumViewerProvider.notifier)
.changeAlbumDescription(album, newAlbumDescription);
if (!isSuccessDescription) {
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_description".tr(),
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
descriptionFocusNode.unfocus();
} else {
titleFocusNode.unfocus();
descriptionFocusNode.unfocus();
ref.read(albumViewerProvider.notifier).disableEditAlbum();
}
},
icon: const Icon(Icons.check_rounded),
splashRadius: 25,
);
} else {
return IconButton(
onPressed: context.maybePop,
icon: const Icon(Icons.arrow_back_ios_rounded),
splashRadius: 25,
);
}
}
return AppBar(
elevation: 0,
backgroundColor: context.scaffoldBackgroundColor,
leading: buildLeadingButton(),
centerTitle: false,
actions: [
if (album.shared && (album.activityEnabled || comments != 0)) buildActivitiesButton(),
if (album.isRemote) ...[
IconButton(splashRadius: 25, onPressed: buildBottomSheet, icon: const Icon(Icons.more_horiz_rounded)),
],
],
);
}
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
}
@@ -1,82 +0,0 @@
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_viewer.provider.dart';
class AlbumViewerEditableDescription extends HookConsumerWidget {
final String albumDescription;
final FocusNode descriptionFocusNode;
const AlbumViewerEditableDescription({super.key, required this.albumDescription, required this.descriptionFocusNode});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumViewerState = ref.watch(albumViewerProvider);
final descriptionTextEditController = useTextEditingController(
text: albumViewerState.isEditAlbum && albumViewerState.editDescriptionText.isNotEmpty
? albumViewerState.editDescriptionText
: albumDescription,
);
void onFocusModeChange() {
if (!descriptionFocusNode.hasFocus && descriptionTextEditController.text.isEmpty) {
ref.watch(albumViewerProvider.notifier).setEditDescriptionText("");
descriptionTextEditController.text = "";
}
}
useEffect(() {
descriptionFocusNode.addListener(onFocusModeChange);
return () {
descriptionFocusNode.removeListener(onFocusModeChange);
};
}, []);
return Material(
color: Colors.transparent,
child: TextField(
onChanged: (value) {
if (value.isEmpty) {
} else {
ref.watch(albumViewerProvider.notifier).setEditDescriptionText(value);
}
},
focusNode: descriptionFocusNode,
style: context.textTheme.bodyLarge,
maxLines: 3,
minLines: 1,
controller: descriptionTextEditController,
onTap: () {
context.focusScope.requestFocus(descriptionFocusNode);
ref.watch(albumViewerProvider.notifier).setEditDescriptionText(albumDescription);
ref.watch(albumViewerProvider.notifier).enableEditAlbum();
if (descriptionTextEditController.text == '') {
descriptionTextEditController.clear();
}
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.all(8),
suffixIcon: descriptionFocusNode.hasFocus
? IconButton(
onPressed: () {
descriptionTextEditController.clear();
},
icon: Icon(Icons.cancel_rounded, color: context.primaryColor),
splashRadius: 10,
)
: null,
enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.transparent)),
focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.transparent)),
focusColor: Colors.grey[300],
fillColor: context.scaffoldBackgroundColor,
filled: descriptionFocusNode.hasFocus,
hintText: 'add_a_description'.tr(),
),
),
);
}
}
@@ -1,81 +0,0 @@
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_viewer.provider.dart';
class AlbumViewerEditableTitle extends HookConsumerWidget {
final String albumName;
final FocusNode titleFocusNode;
const AlbumViewerEditableTitle({super.key, required this.albumName, required this.titleFocusNode});
@override
Widget build(BuildContext context, WidgetRef ref) {
final albumViewerState = ref.watch(albumViewerProvider);
final titleTextEditController = useTextEditingController(
text: albumViewerState.isEditAlbum && albumViewerState.editTitleText.isNotEmpty
? albumViewerState.editTitleText
: albumName,
);
void onFocusModeChange() {
if (!titleFocusNode.hasFocus && titleTextEditController.text.isEmpty) {
ref.watch(albumViewerProvider.notifier).setEditTitleText("Untitled");
titleTextEditController.text = "Untitled";
}
}
useEffect(() {
titleFocusNode.addListener(onFocusModeChange);
return () {
titleFocusNode.removeListener(onFocusModeChange);
};
}, []);
return Material(
color: Colors.transparent,
child: TextField(
onChanged: (value) {
if (value.isEmpty) {
} else {
ref.watch(albumViewerProvider.notifier).setEditTitleText(value);
}
},
focusNode: titleFocusNode,
style: context.textTheme.headlineLarge?.copyWith(fontWeight: FontWeight.w700),
controller: titleTextEditController,
onTap: () {
context.focusScope.requestFocus(titleFocusNode);
ref.watch(albumViewerProvider.notifier).setEditTitleText(albumName);
ref.watch(albumViewerProvider.notifier).enableEditAlbum();
if (titleTextEditController.text == 'Untitled') {
titleTextEditController.clear();
}
},
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 0),
suffixIcon: titleFocusNode.hasFocus
? IconButton(
onPressed: () {
titleTextEditController.clear();
},
icon: Icon(Icons.cancel_rounded, color: context.primaryColor),
splashRadius: 10,
)
: null,
enabledBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.transparent)),
focusedBorder: const OutlineInputBorder(borderSide: BorderSide(color: Colors.transparent)),
focusColor: Colors.grey[300],
fillColor: context.scaffoldBackgroundColor,
filled: titleFocusNode.hasFocus,
hintText: 'add_a_title'.tr(),
hintStyle: context.themeData.inputDecorationTheme.hintStyle?.copyWith(fontSize: 28),
),
),
);
}
}
@@ -1,20 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
class SharedAlbumThumbnailImage extends HookConsumerWidget {
final Asset asset;
const SharedAlbumThumbnailImage({super.key, required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
return GestureDetector(
onTap: () {
// debugPrint("View ${asset.id}");
},
child: Stack(children: [ImmichThumbnail(asset: asset, width: 500, height: 500)]),
);
}
}
@@ -1,207 +0,0 @@
// Based on https://stackoverflow.com/a/52625182
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
class AssetDragRegion extends StatefulWidget {
final Widget child;
final void Function(AssetIndex valueKey)? onStart;
final void Function(AssetIndex valueKey)? onAssetEnter;
final void Function()? onEnd;
final void Function()? onScrollStart;
final void Function(ScrollDirection direction)? onScroll;
const AssetDragRegion({
super.key,
required this.child,
this.onStart,
this.onAssetEnter,
this.onEnd,
this.onScrollStart,
this.onScroll,
});
@override
State createState() => _AssetDragRegionState();
}
class _AssetDragRegionState extends State<AssetDragRegion> {
late AssetIndex? assetUnderPointer;
late AssetIndex? anchorAsset;
// Scroll related state
static const double scrollOffset = 0.10;
double? topScrollOffset;
double? bottomScrollOffset;
Timer? scrollTimer;
late bool scrollNotified;
@override
void initState() {
super.initState();
assetUnderPointer = null;
anchorAsset = null;
scrollNotified = false;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
topScrollOffset = null;
bottomScrollOffset = null;
}
@override
void dispose() {
scrollTimer?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
return RawGestureDetector(
gestures: {
_CustomLongPressGestureRecognizer: GestureRecognizerFactoryWithHandlers<_CustomLongPressGestureRecognizer>(
() => _CustomLongPressGestureRecognizer(),
_registerCallbacks,
),
},
child: widget.child,
);
}
void _registerCallbacks(_CustomLongPressGestureRecognizer recognizer) {
recognizer.onLongPressMoveUpdate = (details) => _onLongPressMove(details);
recognizer.onLongPressStart = (details) => _onLongPressStart(details);
recognizer.onLongPressUp = _onLongPressEnd;
}
AssetIndex? _getValueKeyAtPositon(Offset position) {
final box = context.findAncestorRenderObjectOfType<RenderBox>();
if (box == null) return null;
final hitTestResult = BoxHitTestResult();
final local = box.globalToLocal(position);
if (!box.hitTest(hitTestResult, position: local)) return null;
return (hitTestResult.path.firstWhereOrNull((hit) => hit.target is _AssetIndexProxy)?.target as _AssetIndexProxy?)
?.index;
}
void _onLongPressStart(LongPressStartDetails event) {
/// Calculate widget height and scroll offset when long press starting instead of in [initState]
/// or [didChangeDependencies] as the grid might still be rendering into view to get the actual size
final height = context.size?.height;
if (height != null && (topScrollOffset == null || bottomScrollOffset == null)) {
topScrollOffset = height * scrollOffset;
bottomScrollOffset = height - topScrollOffset!;
}
final initialHit = _getValueKeyAtPositon(event.globalPosition);
anchorAsset = initialHit;
if (initialHit == null) return;
if (anchorAsset != null) {
widget.onStart?.call(anchorAsset!);
}
}
void _onLongPressEnd() {
scrollNotified = false;
scrollTimer?.cancel();
widget.onEnd?.call();
}
void _onLongPressMove(LongPressMoveUpdateDetails event) {
if (anchorAsset == null) return;
if (topScrollOffset == null || bottomScrollOffset == null) return;
final currentDy = event.localPosition.dy;
if (currentDy > bottomScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.forward),
);
} else if (currentDy < topScrollOffset!) {
scrollTimer ??= Timer.periodic(
const Duration(milliseconds: 50),
(_) => widget.onScroll?.call(ScrollDirection.reverse),
);
} else {
scrollTimer?.cancel();
scrollTimer = null;
}
final currentlyTouchingAsset = _getValueKeyAtPositon(event.globalPosition);
if (currentlyTouchingAsset == null) return;
if (assetUnderPointer != currentlyTouchingAsset) {
if (!scrollNotified) {
scrollNotified = true;
widget.onScrollStart?.call();
}
widget.onAssetEnter?.call(currentlyTouchingAsset);
assetUnderPointer = currentlyTouchingAsset;
}
}
}
class _CustomLongPressGestureRecognizer extends LongPressGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
class AssetIndexWrapper extends SingleChildRenderObjectWidget {
final int rowIndex;
final int sectionIndex;
const AssetIndexWrapper({required Widget super.child, required this.rowIndex, required this.sectionIndex, super.key});
@override
// ignore: library_private_types_in_public_api
_AssetIndexProxy createRenderObject(BuildContext context) {
return _AssetIndexProxy(
index: AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex),
);
}
@override
void updateRenderObject(
BuildContext context,
// ignore: library_private_types_in_public_api
_AssetIndexProxy renderObject,
) {
renderObject.index = AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex);
}
}
class _AssetIndexProxy extends RenderProxyBox {
AssetIndex index;
_AssetIndexProxy({required this.index});
}
class AssetIndex {
final int rowIndex;
final int sectionIndex;
const AssetIndex({required this.rowIndex, required this.sectionIndex});
@override
bool operator ==(covariant AssetIndex other) {
if (identical(this, other)) return true;
return other.rowIndex == rowIndex && other.sectionIndex == sectionIndex;
}
@override
int get hashCode => rowIndex.hashCode ^ sectionIndex.hashCode;
}
@@ -1,307 +0,0 @@
import 'dart:math';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart';
final log = Logger('AssetGridDataStructure');
enum RenderAssetGridElementType { assets, assetRow, groupDividerTitle, monthTitle }
class RenderAssetGridElement {
final RenderAssetGridElementType type;
final String? title;
final DateTime date;
final int count;
final int offset;
final int totalCount;
const RenderAssetGridElement(
this.type, {
this.title,
required this.date,
this.count = 0,
this.offset = 0,
this.totalCount = 0,
});
}
enum GroupAssetsBy { day, month, auto, none }
class RenderList {
final List<RenderAssetGridElement> elements;
final List<Asset>? allAssets;
final QueryBuilder<Asset, Asset, QAfterSortBy>? query;
final int totalAssets;
/// reference to batch of assets loaded from DB with offset [_bufOffset]
List<Asset> _buf = [];
/// global offset of assets in [_buf]
int _bufOffset = 0;
RenderList(this.elements, this.query, this.allAssets) : totalAssets = allAssets?.length ?? query!.countSync();
bool get isEmpty => totalAssets == 0;
/// Loads the requested assets from the database to an internal buffer if not cached
/// and returns a slice of that buffer
List<Asset> loadAssets(int offset, int count) {
assert(offset >= 0);
assert(count > 0);
assert(offset + count <= totalAssets);
if (allAssets != null) {
// if we already loaded all assets (e.g. from search result)
// simply return the requested slice of that array
return allAssets!.slice(offset, offset + count);
} else if (query != null) {
// general case: we have the query to load assets via offset from the DB on demand
if (offset < _bufOffset || offset + count > _bufOffset + _buf.length) {
// the requested slice (offset:offset+count) is not contained in the cache buffer `_buf`
// thus, fill the buffer with a new batch of assets that at least contains the requested
// assets and some more
final bool forward = _bufOffset < offset;
// if the requested offset is greater than the cached offset, the user scrolls forward "down"
const batchSize = 256;
const oppositeSize = 64;
// make sure to load a meaningful amount of data (and not only the requested slice)
// otherwise, each call to [loadAssets] would result in DB call trashing performance
// fills small requests to [batchSize], adds some legroom into the opposite scroll direction for large requests
final len = max(batchSize, count + oppositeSize);
// when scrolling forward, start shortly before the requested offset...
// when scrolling backward, end shortly after the requested offset...
// ... to guard against the user scrolling in the other direction
// a tiny bit resulting in a another required load from the DB
final start = max(0, forward ? offset - oppositeSize : (len > batchSize ? offset : offset + count - len));
// load the calculated batch (start:start+len) from the DB and put it into the buffer
_buf = query!.offset(start).limit(len).findAllSync();
_bufOffset = start;
}
assert(_bufOffset <= offset);
assert(_bufOffset + _buf.length >= offset + count);
// return the requested slice from the buffer (we made sure before that the assets are loaded!)
return _buf.slice(offset - _bufOffset, offset - _bufOffset + count);
}
throw Exception("RenderList has neither assets nor query");
}
/// Returns the requested asset either from cached buffer or directly from the database
Asset loadAsset(int index) {
if (allAssets != null) {
// all assets are already loaded (e.g. from search result)
return allAssets![index];
} else if (query != null) {
// general case: we have the DB query to load asset(s) on demand
if (index >= _bufOffset && index < _bufOffset + _buf.length) {
// lucky case: the requested asset is already cached in the buffer!
return _buf[index - _bufOffset];
}
// request the asset from the database (not changing the buffer!)
final asset = query!.offset(index).findFirstSync();
if (asset == null) {
throw Exception("Asset at index $index does no longer exist in database");
}
return asset;
}
throw Exception("RenderList has neither assets nor query");
}
static Future<RenderList> fromQuery(QueryBuilder<Asset, Asset, QAfterSortBy> query, GroupAssetsBy groupBy) =>
_buildRenderList(null, query, groupBy);
static Future<RenderList> _buildRenderList(
List<Asset>? assets,
QueryBuilder<Asset, Asset, QAfterSortBy>? query,
GroupAssetsBy groupBy,
) async {
final List<RenderAssetGridElement> elements = [];
const pageSize = 50000;
const sectionSize = 60; // divides evenly by 2,3,4,5,6
if (groupBy == GroupAssetsBy.none) {
final int total = assets?.length ?? query!.countSync();
final dateLoader = query != null ? DateBatchLoader(query: query, batchSize: 1000 * sectionSize) : null;
for (int i = 0; i < total; i += sectionSize) {
final date = assets != null ? assets[i].fileCreatedAt : await dateLoader?.getDate(i);
final int count = i + sectionSize > total ? total - i : sectionSize;
if (date == null) break;
elements.add(
RenderAssetGridElement(
RenderAssetGridElementType.assets,
date: date,
count: count,
totalCount: total,
offset: i,
),
);
}
return RenderList(elements, query, assets);
}
final formatSameYear = groupBy == GroupAssetsBy.month ? DateFormat.MMMM() : DateFormat.MMMEd();
final formatOtherYear = groupBy == GroupAssetsBy.month ? DateFormat.yMMMM() : DateFormat.yMMMEd();
final currentYear = DateTime.now().year;
final formatMergedSameYear = DateFormat.MMMd();
final formatMergedOtherYear = DateFormat.yMMMd();
int offset = 0;
DateTime? last;
DateTime? current;
int lastOffset = 0;
int count = 0;
int monthCount = 0;
int lastMonthIndex = 0;
String formatDateRange(DateTime from, DateTime to) {
final startDate = (from.year == currentYear ? formatMergedSameYear : formatMergedOtherYear).format(from);
final endDate = (to.year == currentYear ? formatMergedSameYear : formatMergedOtherYear).format(to);
if (DateTime(from.year, from.month, from.day) == DateTime(to.year, to.month, to.day)) {
// format range with time when both dates are on the same day
final startTime = DateFormat.Hm().format(from);
final endTime = DateFormat.Hm().format(to);
return "$startDate $startTime - $endTime";
}
return "$startDate - $endDate";
}
void mergeMonth() {
if (last != null && groupBy == GroupAssetsBy.auto && monthCount <= 30 && elements.length > lastMonthIndex + 1) {
// merge all days into a single section
assert(elements[lastMonthIndex].date.month == last.month);
final e = elements[lastMonthIndex];
elements[lastMonthIndex] = RenderAssetGridElement(
RenderAssetGridElementType.monthTitle,
date: e.date,
count: monthCount,
totalCount: monthCount,
offset: e.offset,
title: formatDateRange(e.date, elements.last.date),
);
elements.removeRange(lastMonthIndex + 1, elements.length);
}
}
void addElems(DateTime d, DateTime? prevDate) {
final bool newMonth = last == null || last.year != d.year || last.month != d.month;
if (newMonth) {
mergeMonth();
lastMonthIndex = elements.length;
monthCount = 0;
}
for (int j = 0; j < count; j += sectionSize) {
final type = j == 0
? (groupBy != GroupAssetsBy.month && newMonth
? RenderAssetGridElementType.monthTitle
: RenderAssetGridElementType.groupDividerTitle)
: (groupBy == GroupAssetsBy.auto
? RenderAssetGridElementType.groupDividerTitle
: RenderAssetGridElementType.assets);
final sectionCount = j + sectionSize > count ? count - j : sectionSize;
assert(sectionCount > 0 && sectionCount <= sectionSize);
elements.add(
RenderAssetGridElement(
type,
date: d,
count: sectionCount,
totalCount: groupBy == GroupAssetsBy.auto ? sectionCount : count,
offset: lastOffset + j,
title: j == 0
? (d.year == currentYear ? formatSameYear.format(d) : formatOtherYear.format(d))
: (groupBy == GroupAssetsBy.auto ? formatDateRange(d, prevDate ?? d) : null),
),
);
}
monthCount += count;
}
DateTime? prevDate;
while (true) {
// this iterates all assets (only their createdAt property) in batches
// memory usage is okay, however runtime is linear with number of assets
// TODO replace with groupBy once Isar supports such queries
final dates = assets != null
? assets.map((a) => a.fileCreatedAt)
: await query!.offset(offset).limit(pageSize).fileCreatedAtProperty().findAll();
int i = 0;
for (final date in dates) {
final d = DateTime(date.year, date.month, groupBy == GroupAssetsBy.month ? 1 : date.day);
current ??= d;
if (current != d) {
addElems(current, prevDate);
last = current;
current = d;
lastOffset = offset + i;
count = 0;
}
prevDate = date;
count++;
i++;
}
if (assets != null || dates.length != pageSize) break;
offset += pageSize;
}
if (count > 0 && current != null) {
addElems(current, prevDate);
mergeMonth();
}
assert(elements.every((e) => e.count <= sectionSize), "too large section");
return RenderList(elements, query, assets);
}
static RenderList empty() => RenderList([], null, []);
static Future<RenderList> fromAssets(List<Asset> assets, GroupAssetsBy groupBy) =>
_buildRenderList(assets, null, groupBy);
/// Deletes an asset from the render list and clears the buffer
/// This is only a workaround for deleted images still appearing in the gallery
void deleteAsset(Asset deleteAsset) {
allAssets?.remove(deleteAsset);
_buf.clear();
_bufOffset = 0;
}
}
class DateBatchLoader {
final QueryBuilder<Asset, Asset, QAfterSortBy> query;
final int batchSize;
List<DateTime> _buffer = [];
int _bufferStart = 0;
DateBatchLoader({required this.query, required this.batchSize});
Future<DateTime?> getDate(int index) async {
if (!_isIndexInBuffer(index)) {
await _loadBatch(index);
}
if (_isIndexInBuffer(index)) {
return _buffer[index - _bufferStart];
}
return null;
}
Future<void> _loadBatch(int targetIndex) async {
final batchStart = (targetIndex ~/ batchSize) * batchSize;
_buffer = await query.offset(batchStart).limit(batchSize).fileCreatedAtProperty().findAll();
_bufferStart = batchStart;
}
bool _isIndexInBuffer(int index) {
return index >= _bufferStart && index < _bufferStart + _buffer.length;
}
}
@@ -1,388 +0,0 @@
import 'dart:io';
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/routes.provider.dart';
import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.dart';
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/models/asset_selection_state.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/widgets/asset_grid/upload_dialog.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/widgets/common/drag_sheet.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
final controlBottomAppBarNotifier = ControlBottomAppBarNotifier();
class ControlBottomAppBarNotifier with ChangeNotifier {
void minimize() {
notifyListeners();
}
}
class ControlBottomAppBar extends HookConsumerWidget {
final void Function(bool shareLocal) onShare;
final void Function()? onFavorite;
final void Function()? onArchive;
final void Function([bool force])? onDelete;
final void Function([bool force])? onDeleteServer;
final void Function(bool onlyBackedUp)? onDeleteLocal;
final Function(Album album) onAddToAlbum;
final void Function() onCreateNewAlbum;
final void Function() onUpload;
final void Function()? onStack;
final void Function()? onEditTime;
final void Function()? onEditLocation;
final void Function()? onRemoveFromAlbum;
final void Function()? onToggleLocked;
final void Function()? onDownload;
final bool enabled;
final bool unfavorite;
final bool unarchive;
final AssetSelectionState selectionAssetState;
final List<Asset> selectedAssets;
const ControlBottomAppBar({
super.key,
required this.onShare,
this.onFavorite,
this.onArchive,
this.onDelete,
this.onDeleteServer,
this.onDeleteLocal,
required this.onAddToAlbum,
required this.onCreateNewAlbum,
required this.onUpload,
this.onDownload,
this.onStack,
this.onEditTime,
this.onEditLocation,
this.onRemoveFromAlbum,
this.onToggleLocked,
this.selectionAssetState = const AssetSelectionState(),
this.selectedAssets = const [],
this.enabled = true,
this.unarchive = false,
this.unfavorite = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasRemote = selectionAssetState.hasRemote || selectionAssetState.hasMerged;
final hasLocal = selectionAssetState.hasLocal || selectionAssetState.hasMerged;
final trashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final sharedAlbums = ref.watch(albumProvider).where((a) => a.shared).toList();
const bottomPadding = 0.24;
final scrollController = useDraggableScrollController();
final isInLockedView = ref.watch(inLockedViewProvider);
void minimize() {
scrollController.animateTo(bottomPadding, duration: const Duration(milliseconds: 300), curve: Curves.easeOut);
}
useEffect(() {
controlBottomAppBarNotifier.addListener(minimize);
return () {
controlBottomAppBarNotifier.removeListener(minimize);
};
}, []);
void showForceDeleteDialog(Function(bool) deleteCb, {String? alertMsg}) {
showDialog(
context: context,
builder: (BuildContext context) {
return DeleteDialog(alert: alertMsg, onDelete: () => deleteCb(true));
},
);
}
/// Show existing AddToAlbumBottomSheet
void showAddToAlbumBottomSheet() {
showModalBottomSheet(
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15.0))),
context: context,
builder: (BuildContext _) {
return AddToAlbumBottomSheet(assets: selectedAssets);
},
);
}
void handleRemoteDelete(bool force, Function(bool) deleteCb, {String? alertMsg}) {
if (!force) {
deleteCb(force);
return;
}
return showForceDeleteDialog(deleteCb, alertMsg: alertMsg);
}
List<Widget> renderActionButtons() {
return [
ControlBoxButton(
iconData: Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
label: "share".tr(),
onPressed: enabled ? () => onShare(true) : null,
),
if (!isInLockedView && hasRemote)
ControlBoxButton(
iconData: Icons.link_rounded,
label: "share_link".tr(),
onPressed: enabled ? () => onShare(false) : null,
),
if (!isInLockedView && hasRemote && albums.isNotEmpty)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 100),
child: ControlBoxButton(
iconData: Icons.photo_album,
label: "add_to_album".tr(),
onPressed: enabled ? showAddToAlbumBottomSheet : null,
),
),
if (hasRemote && onArchive != null)
ControlBoxButton(
iconData: unarchive ? Icons.unarchive_outlined : Icons.archive_outlined,
label: (unarchive ? "unarchive" : "archive").tr(),
onPressed: enabled ? onArchive : null,
),
if (hasRemote && onFavorite != null)
ControlBoxButton(
iconData: unfavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded,
label: (unfavorite ? "unfavorite" : "favorite").tr(),
onPressed: enabled ? onFavorite : null,
),
if (hasRemote && onDownload != null)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 90),
child: ControlBoxButton(iconData: Icons.download, label: "download".tr(), onPressed: onDownload),
),
if (hasLocal && hasRemote && onDelete != null && !isInLockedView)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 90),
child: ControlBoxButton(
iconData: Icons.delete_sweep_outlined,
label: "delete".tr(),
onPressed: enabled ? () => handleRemoteDelete(!trashEnabled, onDelete!) : null,
onLongPressed: enabled ? () => showForceDeleteDialog(onDelete!) : null,
),
),
if (hasRemote && onDeleteServer != null && !isInLockedView)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 85),
child: ControlBoxButton(
iconData: Icons.cloud_off_outlined,
label: trashEnabled
? "control_bottom_app_bar_trash_from_immich".tr()
: "control_bottom_app_bar_delete_from_immich".tr(),
onPressed: enabled
? () => handleRemoteDelete(!trashEnabled, onDeleteServer!, alertMsg: "delete_dialog_alert_remote")
: null,
onLongPressed: enabled
? () => showForceDeleteDialog(onDeleteServer!, alertMsg: "delete_dialog_alert_remote")
: null,
),
),
if (isInLockedView)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 110),
child: ControlBoxButton(
iconData: Icons.delete_forever,
label: "delete_dialog_title".tr(),
onPressed: enabled
? () => showForceDeleteDialog(onDeleteServer!, alertMsg: "delete_dialog_alert_remote")
: null,
),
),
if (hasLocal && onDeleteLocal != null && !isInLockedView)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 95),
child: ControlBoxButton(
iconData: Icons.no_cell_outlined,
label: "control_bottom_app_bar_delete_from_local".tr(),
onPressed: enabled
? () {
if (!selectionAssetState.hasLocal) {
return onDeleteLocal?.call(true);
}
showDialog(
context: context,
builder: (BuildContext context) {
return DeleteLocalOnlyDialog(onDeleteLocal: onDeleteLocal!);
},
);
}
: null,
),
),
if (hasRemote && onEditTime != null)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 95),
child: ControlBoxButton(
iconData: Icons.edit_calendar_outlined,
label: "control_bottom_app_bar_edit_time".tr(),
onPressed: enabled ? onEditTime : null,
),
),
if (hasRemote && onEditLocation != null)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 90),
child: ControlBoxButton(
iconData: Icons.edit_location_alt_outlined,
label: "control_bottom_app_bar_edit_location".tr(),
onPressed: enabled ? onEditLocation : null,
),
),
if (hasRemote)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 100),
child: ControlBoxButton(
iconData: isInLockedView ? Icons.lock_open_rounded : Icons.lock_outline_rounded,
label: isInLockedView ? "remove_from_locked_folder".tr() : "move_to_locked_folder".tr(),
onPressed: enabled ? onToggleLocked : null,
),
),
if (!selectionAssetState.hasLocal && selectionAssetState.selectedCount > 1 && onStack != null)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 90),
child: ControlBoxButton(
iconData: Icons.filter_none_rounded,
label: "stack".tr(),
onPressed: enabled ? onStack : null,
),
),
if (onRemoveFromAlbum != null)
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 90),
child: ControlBoxButton(
iconData: Icons.remove_circle_outline,
label: 'remove_from_album'.tr(),
onPressed: enabled ? onRemoveFromAlbum : null,
),
),
if (selectionAssetState.hasLocal)
ControlBoxButton(
iconData: Icons.backup_outlined,
label: "upload".tr(),
onPressed: enabled
? () => showDialog(
context: context,
builder: (BuildContext context) {
return UploadDialog(onUpload: onUpload);
},
)
: null,
),
];
}
getInitialSize() {
if (isInLockedView) {
return bottomPadding;
}
if (hasRemote) {
return 0.35;
}
return bottomPadding;
}
getMaxChildSize() {
if (isInLockedView) {
return bottomPadding;
}
if (hasRemote) {
return 0.65;
}
return bottomPadding;
}
return DraggableScrollableSheet(
initialChildSize: getInitialSize(),
minChildSize: bottomPadding,
maxChildSize: getMaxChildSize(),
snap: true,
controller: scrollController,
builder: (BuildContext context, ScrollController scrollController) {
return Card(
color: context.colorScheme.surfaceContainerHigh,
surfaceTintColor: context.colorScheme.surfaceContainerHigh,
elevation: 6.0,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)),
),
margin: const EdgeInsets.all(0),
child: CustomScrollView(
controller: scrollController,
slivers: [
SliverToBoxAdapter(
child: Column(
children: <Widget>[
const SizedBox(height: 12),
const CustomDraggingHandle(),
const SizedBox(height: 12),
SizedBox(
height: 120,
child: ListView(
shrinkWrap: true,
scrollDirection: Axis.horizontal,
children: renderActionButtons(),
),
),
if (hasRemote && !isInLockedView) ...[
const Divider(indent: 16, endIndent: 16, thickness: 1),
_AddToAlbumTitleRow(onCreateNewAlbum: enabled ? onCreateNewAlbum : null),
],
],
),
),
if (hasRemote && !isInLockedView)
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: AddToAlbumSliverList(
albums: albums,
sharedAlbums: sharedAlbums,
onAddToAlbum: onAddToAlbum,
enabled: enabled,
),
),
],
),
);
},
);
}
}
class _AddToAlbumTitleRow extends StatelessWidget {
const _AddToAlbumTitleRow({required this.onCreateNewAlbum});
final VoidCallback? onCreateNewAlbum;
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("add_to_album", style: context.textTheme.titleSmall).tr(),
TextButton.icon(
onPressed: onCreateNewAlbum,
icon: Icon(Icons.add, color: context.primaryColor),
label: Text(
"common_create_new_album",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 14),
).tr(),
),
],
),
);
}
}
@@ -1,18 +1,6 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
class DeleteDialog extends ConfirmDialog {
const DeleteDialog({super.key, String? alert, required Function onDelete})
: super(
title: "delete_dialog_title",
content: alert ?? "delete_dialog_alert",
cancel: "cancel",
ok: "delete",
onOk: onDelete,
);
}
class DeleteLocalOnlyDialog extends StatelessWidget {
final void Function(bool onlyMerged) onDeleteLocal;
@@ -1,31 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class DisableMultiSelectButton extends ConsumerWidget {
const DisableMultiSelectButton({super.key, required this.onPressed, required this.selectedItemCount});
final Function onPressed;
final int selectedItemCount;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Align(
alignment: Alignment.topLeft,
child: Padding(
padding: const EdgeInsets.only(left: 16.0, top: 8.0),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: ElevatedButton.icon(
onPressed: () => onPressed(),
icon: Icon(Icons.close_rounded, color: context.colorScheme.onPrimary),
label: Text(
'$selectedItemCount',
style: context.textTheme.titleMedium?.copyWith(height: 2.5, color: context.colorScheme.onPrimary),
),
),
),
),
);
}
}
@@ -1,559 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
/// Build the Scroll Thumb and label using the current configuration
typedef ScrollThumbBuilder =
Widget Function(
Color backgroundColor,
Animation<double> thumbAnimation,
Animation<double> labelAnimation,
double height, {
Text? labelText,
BoxConstraints? labelConstraints,
});
/// Build a Text widget using the current scroll offset
typedef LabelTextBuilder = Text Function(double offsetY);
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
class DraggableScrollbar extends StatefulWidget {
/// The view that will be scrolled with the scroll thumb
final CustomScrollView child;
/// A function that builds a thumb using the current configuration
final ScrollThumbBuilder scrollThumbBuilder;
/// The height of the scroll thumb
final double heightScrollThumb;
/// The background color of the label and thumb
final Color backgroundColor;
/// The amount of padding that should surround the thumb
final EdgeInsetsGeometry? padding;
/// Determines how quickly the scrollbar will animate in and out
final Duration scrollbarAnimationDuration;
/// How long should the thumb be visible before fading out
final Duration scrollbarTimeToFade;
/// Build a Text widget from the current offset in the BoxScrollView
final LabelTextBuilder? labelTextBuilder;
/// Determines box constraints for Container displaying label
final BoxConstraints? labelConstraints;
/// The ScrollController for the BoxScrollView
final ScrollController controller;
/// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder]
final bool alwaysVisibleScrollThumb;
DraggableScrollbar({
super.key,
this.alwaysVisibleScrollThumb = false,
required this.heightScrollThumb,
required this.backgroundColor,
required this.scrollThumbBuilder,
required this.child,
required this.controller,
this.padding,
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
this.labelTextBuilder,
this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical);
DraggableScrollbar.rrect({
super.key,
Key? scrollThumbKey,
this.alwaysVisibleScrollThumb = false,
required this.child,
required this.controller,
this.heightScrollThumb = 48.0,
this.backgroundColor = Colors.white,
this.padding,
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
this.labelTextBuilder,
this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical),
scrollThumbBuilder = _thumbRRectBuilder(alwaysVisibleScrollThumb);
DraggableScrollbar.arrows({
super.key,
Key? scrollThumbKey,
this.alwaysVisibleScrollThumb = false,
required this.child,
required this.controller,
this.heightScrollThumb = 48.0,
this.backgroundColor = Colors.white,
this.padding,
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
this.labelTextBuilder,
this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical),
scrollThumbBuilder = _thumbArrowBuilder(alwaysVisibleScrollThumb);
DraggableScrollbar.semicircle({
super.key,
Key? scrollThumbKey,
this.alwaysVisibleScrollThumb = false,
required this.child,
required this.controller,
this.heightScrollThumb = 48.0,
this.backgroundColor = Colors.white,
this.padding,
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
this.labelTextBuilder,
this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical),
scrollThumbBuilder = _thumbSemicircleBuilder(heightScrollThumb * 0.6, scrollThumbKey, alwaysVisibleScrollThumb);
@override
DraggableScrollbarState createState() => DraggableScrollbarState();
static buildScrollThumbAndLabel({
required Widget scrollThumb,
required Color backgroundColor,
required Animation<double>? thumbAnimation,
required Animation<double>? labelAnimation,
required Text? labelText,
required BoxConstraints? labelConstraints,
required bool alwaysVisibleScrollThumb,
}) {
var scrollThumbAndLabel = labelText == null
? scrollThumb
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
ScrollLabel(
animation: labelAnimation,
backgroundColor: backgroundColor,
constraints: labelConstraints,
child: labelText,
),
scrollThumb,
],
);
if (alwaysVisibleScrollThumb) {
return scrollThumbAndLabel;
}
return SlideFadeTransition(animation: thumbAnimation!, child: scrollThumbAndLabel);
}
static ScrollThumbBuilder _thumbSemicircleBuilder(double width, Key? scrollThumbKey, bool alwaysVisibleScrollThumb) {
return (
Color backgroundColor,
Animation<double> thumbAnimation,
Animation<double> labelAnimation,
double height, {
Text? labelText,
BoxConstraints? labelConstraints,
}) {
final scrollThumb = CustomPaint(
key: scrollThumbKey,
foregroundPainter: ArrowCustomPainter(Colors.white),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(height),
bottomLeft: Radius.circular(height),
topRight: const Radius.circular(4.0),
bottomRight: const Radius.circular(4.0),
),
child: Container(constraints: BoxConstraints.tight(Size(width, height))),
),
);
return buildScrollThumbAndLabel(
scrollThumb: scrollThumb,
backgroundColor: backgroundColor,
thumbAnimation: thumbAnimation,
labelAnimation: labelAnimation,
labelText: labelText,
labelConstraints: labelConstraints,
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
);
};
}
static ScrollThumbBuilder _thumbArrowBuilder(bool alwaysVisibleScrollThumb) {
return (
Color backgroundColor,
Animation<double> thumbAnimation,
Animation<double> labelAnimation,
double height, {
Text? labelText,
BoxConstraints? labelConstraints,
}) {
final scrollThumb = ClipPath(
clipper: const ArrowClipper(),
child: Container(
height: height,
width: 20.0,
decoration: BoxDecoration(
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
),
),
);
return buildScrollThumbAndLabel(
scrollThumb: scrollThumb,
backgroundColor: backgroundColor,
thumbAnimation: thumbAnimation,
labelAnimation: labelAnimation,
labelText: labelText,
labelConstraints: labelConstraints,
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
);
};
}
static ScrollThumbBuilder _thumbRRectBuilder(bool alwaysVisibleScrollThumb) {
return (
Color backgroundColor,
Animation<double> thumbAnimation,
Animation<double> labelAnimation,
double height, {
Text? labelText,
BoxConstraints? labelConstraints,
}) {
final scrollThumb = Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(7.0)),
child: Container(constraints: BoxConstraints.tight(Size(16.0, height))),
);
return buildScrollThumbAndLabel(
scrollThumb: scrollThumb,
backgroundColor: backgroundColor,
thumbAnimation: thumbAnimation,
labelAnimation: labelAnimation,
labelText: labelText,
labelConstraints: labelConstraints,
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
);
};
}
}
class ScrollLabel extends StatelessWidget {
final Animation<double>? animation;
final Color backgroundColor;
final Text child;
final BoxConstraints? constraints;
static const BoxConstraints _defaultConstraints = BoxConstraints.tightFor(width: 72.0, height: 28.0);
const ScrollLabel({
super.key,
required this.child,
required this.animation,
required this.backgroundColor,
this.constraints = _defaultConstraints,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: animation!,
child: Container(
margin: const EdgeInsets.only(right: 12.0),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: Container(constraints: constraints ?? _defaultConstraints, alignment: Alignment.center, child: child),
),
),
);
}
}
class DraggableScrollbarState extends State<DraggableScrollbar> with TickerProviderStateMixin {
late double _barOffset;
late double _viewOffset;
late bool _isDragInProcess;
late AnimationController _thumbAnimationController;
late Animation<double> _thumbAnimation;
late AnimationController _labelAnimationController;
late Animation<double> _labelAnimation;
Timer? _fadeoutTimer;
@override
void initState() {
super.initState();
_barOffset = 0.0;
_viewOffset = 0.0;
_isDragInProcess = false;
_thumbAnimationController = AnimationController(vsync: this, duration: widget.scrollbarAnimationDuration);
_thumbAnimation = CurvedAnimation(parent: _thumbAnimationController, curve: Curves.fastOutSlowIn);
_labelAnimationController = AnimationController(vsync: this, duration: widget.scrollbarAnimationDuration);
_labelAnimation = CurvedAnimation(parent: _labelAnimationController, curve: Curves.fastOutSlowIn);
}
@override
void dispose() {
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeoutTimer?.cancel();
super.dispose();
}
double get barMaxScrollExtent => context.size!.height - widget.heightScrollThumb;
double get barMinScrollExtent => 0;
double get viewMaxScrollExtent => widget.controller.position.maxScrollExtent;
double get viewMinScrollExtent => widget.controller.position.minScrollExtent;
@override
Widget build(BuildContext context) {
Text? labelText;
if (widget.labelTextBuilder != null && _isDragInProcess) {
labelText = widget.labelTextBuilder!(_viewOffset + _barOffset + widget.heightScrollThumb / 2);
}
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
//print("LayoutBuilder constraints=$constraints");
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
changePosition(notification);
return false;
},
child: Stack(
children: <Widget>[
RepaintBoundary(child: widget.child),
RepaintBoundary(
child: GestureDetector(
onVerticalDragStart: _onVerticalDragStart,
onVerticalDragUpdate: _onVerticalDragUpdate,
onVerticalDragEnd: _onVerticalDragEnd,
child: Container(
alignment: Alignment.topRight,
margin: EdgeInsets.only(top: _barOffset),
padding: widget.padding,
child: widget.scrollThumbBuilder(
widget.backgroundColor,
_thumbAnimation,
_labelAnimation,
widget.heightScrollThumb,
labelText: labelText,
labelConstraints: widget.labelConstraints,
),
),
),
),
],
),
);
},
);
}
//scroll bar has received notification that it's view was scrolled
//so it should also changes his position
//but only if it isn't dragged
changePosition(ScrollNotification notification) {
if (_isDragInProcess) {
return;
}
setState(() {
if (notification is ScrollUpdateNotification) {
_barOffset += getBarDelta(notification.scrollDelta!, barMaxScrollExtent, viewMaxScrollExtent);
if (_barOffset < barMinScrollExtent) {
_barOffset = barMinScrollExtent;
}
if (_barOffset > barMaxScrollExtent) {
_barOffset = barMaxScrollExtent;
}
_viewOffset += notification.scrollDelta!;
if (_viewOffset < widget.controller.position.minScrollExtent) {
_viewOffset = widget.controller.position.minScrollExtent;
}
if (_viewOffset > viewMaxScrollExtent) {
_viewOffset = viewMaxScrollExtent;
}
}
if (notification is ScrollUpdateNotification || notification is OverscrollNotification) {
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
_fadeoutTimer?.cancel();
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
_thumbAnimationController.reverse();
_labelAnimationController.reverse();
_fadeoutTimer = null;
});
}
});
}
double getBarDelta(double scrollViewDelta, double barMaxScrollExtent, double viewMaxScrollExtent) {
return scrollViewDelta * barMaxScrollExtent / viewMaxScrollExtent;
}
double getScrollViewDelta(double barDelta, double barMaxScrollExtent, double viewMaxScrollExtent) {
return barDelta * viewMaxScrollExtent / barMaxScrollExtent;
}
void _onVerticalDragStart(DragStartDetails details) {
setState(() {
_isDragInProcess = true;
_labelAnimationController.forward();
_fadeoutTimer?.cancel();
});
}
void _onVerticalDragUpdate(DragUpdateDetails details) {
setState(() {
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
if (_isDragInProcess) {
_barOffset += details.delta.dy;
if (_barOffset < barMinScrollExtent) {
_barOffset = barMinScrollExtent;
}
if (_barOffset > barMaxScrollExtent) {
_barOffset = barMaxScrollExtent;
}
double viewDelta = getScrollViewDelta(details.delta.dy, barMaxScrollExtent, viewMaxScrollExtent);
_viewOffset = widget.controller.position.pixels + viewDelta;
if (_viewOffset < widget.controller.position.minScrollExtent) {
_viewOffset = widget.controller.position.minScrollExtent;
}
if (_viewOffset > viewMaxScrollExtent) {
_viewOffset = viewMaxScrollExtent;
}
widget.controller.jumpTo(_viewOffset);
}
});
}
void _onVerticalDragEnd(DragEndDetails details) {
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
_thumbAnimationController.reverse();
_labelAnimationController.reverse();
_fadeoutTimer = null;
});
setState(() {
_isDragInProcess = false;
});
}
}
/// Draws 2 triangles like arrow up and arrow down
class ArrowCustomPainter extends CustomPainter {
Color color;
ArrowCustomPainter(this.color);
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
const width = 12.0;
const height = 8.0;
final baseX = size.width / 2;
final baseY = size.height / 2;
canvas.drawPath(_trianglePath(Offset(baseX, baseY - 2.0), width, height, true), paint);
canvas.drawPath(_trianglePath(Offset(baseX, baseY + 2.0), width, height, false), paint);
}
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
return Path()
..moveTo(o.dx, o.dy)
..lineTo(o.dx + width, o.dy)
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
..close();
}
}
///This cut 2 lines in arrow shape
class ArrowClipper extends CustomClipper<Path> {
const ArrowClipper();
@override
Path getClip(Size size) {
Path path = Path();
path.lineTo(0.0, size.height);
path.lineTo(size.width, size.height);
path.lineTo(size.width, 0.0);
path.lineTo(0.0, 0.0);
path.close();
double arrowWidth = 8.0;
double startPointX = (size.width - arrowWidth) / 2;
double startPointY = size.height / 2 - arrowWidth / 2;
path.moveTo(startPointX, startPointY);
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
path.lineTo(startPointX + arrowWidth, startPointY);
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2 + 1.0);
path.lineTo(startPointX, startPointY + 1.0);
path.close();
startPointY = size.height / 2 + arrowWidth / 2;
path.moveTo(startPointX + arrowWidth, startPointY);
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
path.lineTo(startPointX, startPointY);
path.lineTo(startPointX, startPointY - 1.0);
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2 - 1.0);
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
class SlideFadeTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const SlideFadeTransition({super.key, required this.animation, required this.child});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) => animation.value == 0.0 ? const SizedBox() : child!,
child: SlideTransition(
position: Tween(begin: const Offset(0.3, 0.0), end: const Offset(0.0, 0.0)).animate(animation),
child: FadeTransition(opacity: animation, child: child),
),
);
}
}
@@ -1,490 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
/// Build the Scroll Thumb and label using the current configuration
typedef ScrollThumbBuilder =
Widget Function(
Color backgroundColor,
Animation<double> thumbAnimation,
Animation<double> labelAnimation,
double height, {
Text? labelText,
BoxConstraints? labelConstraints,
});
/// Build a Text widget using the current scroll offset
typedef LabelTextBuilder = Text Function(int item);
/// A widget that will display a BoxScrollView with a ScrollThumb that can be dragged
/// for quick navigation of the BoxScrollView.
class DraggableScrollbar extends StatefulWidget {
/// The view that will be scrolled with the scroll thumb
final ScrollablePositionedList child;
final ItemPositionsListener itemPositionsListener;
/// A function that builds a thumb using the current configuration
final ScrollThumbBuilder scrollThumbBuilder;
/// The height of the scroll thumb
final double heightScrollThumb;
/// The background color of the label and thumb
final Color backgroundColor;
/// The amount of padding that should surround the thumb
final EdgeInsetsGeometry? padding;
/// The height offset of the thumb/bar from the bottom of the page
final double? heightOffset;
/// Determines how quickly the scrollbar will animate in and out
final Duration scrollbarAnimationDuration;
/// How long should the thumb be visible before fading out
final Duration scrollbarTimeToFade;
/// Build a Text widget from the current offset in the BoxScrollView
final LabelTextBuilder? labelTextBuilder;
/// Determines box constraints for Container displaying label
final BoxConstraints? labelConstraints;
/// The ScrollController for the BoxScrollView
final ItemScrollController controller;
/// Determines scrollThumb displaying. If you draw own ScrollThumb and it is true you just don't need to use animation parameters in [scrollThumbBuilder]
final bool alwaysVisibleScrollThumb;
final Function(bool scrolling) scrollStateListener;
DraggableScrollbar.semicircle({
super.key,
Key? scrollThumbKey,
this.alwaysVisibleScrollThumb = false,
required this.child,
required this.controller,
required this.itemPositionsListener,
required this.scrollStateListener,
this.heightScrollThumb = 48.0,
this.backgroundColor = Colors.white,
this.padding,
this.heightOffset,
this.scrollbarAnimationDuration = const Duration(milliseconds: 300),
this.scrollbarTimeToFade = const Duration(milliseconds: 600),
this.labelTextBuilder,
this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical),
scrollThumbBuilder = _thumbSemicircleBuilder(heightScrollThumb * 0.6, scrollThumbKey, alwaysVisibleScrollThumb);
@override
DraggableScrollbarState createState() => DraggableScrollbarState();
static buildScrollThumbAndLabel({
required Widget scrollThumb,
required Color backgroundColor,
required Animation<double>? thumbAnimation,
required Animation<double>? labelAnimation,
required Text? labelText,
required BoxConstraints? labelConstraints,
required bool alwaysVisibleScrollThumb,
}) {
var scrollThumbAndLabel = labelText == null
? scrollThumb
: Row(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.end,
children: [
ScrollLabel(
animation: labelAnimation,
backgroundColor: backgroundColor,
constraints: labelConstraints,
child: labelText,
),
scrollThumb,
],
);
if (alwaysVisibleScrollThumb) {
return scrollThumbAndLabel;
}
return SlideFadeTransition(animation: thumbAnimation!, child: scrollThumbAndLabel);
}
static ScrollThumbBuilder _thumbSemicircleBuilder(double width, Key? scrollThumbKey, bool alwaysVisibleScrollThumb) {
return (
Color backgroundColor,
Animation<double> thumbAnimation,
Animation<double> labelAnimation,
double height, {
Text? labelText,
BoxConstraints? labelConstraints,
}) {
final scrollThumb = CustomPaint(
key: scrollThumbKey,
foregroundPainter: ArrowCustomPainter(Colors.white),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: BorderRadius.only(
topLeft: Radius.circular(height),
bottomLeft: Radius.circular(height),
topRight: const Radius.circular(4.0),
bottomRight: const Radius.circular(4.0),
),
child: Container(constraints: BoxConstraints.tight(Size(width, height))),
),
);
return buildScrollThumbAndLabel(
scrollThumb: scrollThumb,
backgroundColor: backgroundColor,
thumbAnimation: thumbAnimation,
labelAnimation: labelAnimation,
labelText: labelText,
labelConstraints: labelConstraints,
alwaysVisibleScrollThumb: alwaysVisibleScrollThumb,
);
};
}
}
class ScrollLabel extends StatelessWidget {
final Animation<double>? animation;
final Color backgroundColor;
final Text child;
final BoxConstraints? constraints;
static const BoxConstraints _defaultConstraints = BoxConstraints.tightFor(width: 72.0, height: 28.0);
const ScrollLabel({
super.key,
required this.child,
required this.animation,
required this.backgroundColor,
this.constraints = _defaultConstraints,
});
@override
Widget build(BuildContext context) {
return FadeTransition(
opacity: animation!,
child: Container(
margin: const EdgeInsets.only(right: 12.0),
child: Material(
elevation: 4.0,
color: backgroundColor,
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
child: Container(
constraints: constraints ?? _defaultConstraints,
padding: const EdgeInsets.symmetric(horizontal: 10.0),
alignment: Alignment.center,
child: child,
),
),
),
);
}
}
class DraggableScrollbarState extends State<DraggableScrollbar> with TickerProviderStateMixin {
late double _barOffset;
late bool _isDragInProcess;
late int _currentItem;
late AnimationController _thumbAnimationController;
late Animation<double> _thumbAnimation;
late AnimationController _labelAnimationController;
late Animation<double> _labelAnimation;
Timer? _fadeoutTimer;
@override
void initState() {
super.initState();
_barOffset = 0.0;
_isDragInProcess = false;
_currentItem = 0;
_thumbAnimationController = AnimationController(vsync: this, duration: widget.scrollbarAnimationDuration);
_thumbAnimation = CurvedAnimation(parent: _thumbAnimationController, curve: Curves.fastOutSlowIn);
_labelAnimationController = AnimationController(vsync: this, duration: widget.scrollbarAnimationDuration);
_labelAnimation = CurvedAnimation(parent: _labelAnimationController, curve: Curves.fastOutSlowIn);
}
@override
void dispose() {
_thumbAnimationController.dispose();
_labelAnimationController.dispose();
_fadeoutTimer?.cancel();
super.dispose();
}
double get barMaxScrollExtent => (context.size?.height ?? 0) - widget.heightScrollThumb - (widget.heightOffset ?? 0);
double get barMinScrollExtent => 0;
int get maxItemCount => widget.child.itemCount;
@override
Widget build(BuildContext context) {
Text? labelText;
if (widget.labelTextBuilder != null && _isDragInProcess) {
labelText = widget.labelTextBuilder!(_currentItem);
}
return LayoutBuilder(
builder: (BuildContext context, BoxConstraints constraints) {
//print("LayoutBuilder constraints=$constraints");
return NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
changePosition(notification);
return false;
},
child: Stack(
children: <Widget>[
RepaintBoundary(child: widget.child),
RepaintBoundary(
child: GestureDetector(
onVerticalDragStart: _onVerticalDragStart,
onVerticalDragUpdate: _onVerticalDragUpdate,
onVerticalDragEnd: _onVerticalDragEnd,
child: Container(
alignment: Alignment.topRight,
margin: EdgeInsets.only(top: _barOffset),
padding: widget.padding,
child: widget.scrollThumbBuilder(
widget.backgroundColor,
_thumbAnimation,
_labelAnimation,
widget.heightScrollThumb,
labelText: labelText,
labelConstraints: widget.labelConstraints,
),
),
),
),
],
),
);
},
);
}
// scroll bar has received notification that it's view was scrolled
// so it should also changes his position
// but only if it isn't dragged
changePosition(ScrollNotification notification) {
if (_isDragInProcess) {
return;
}
setState(() {
try {
int firstItemIndex = widget.itemPositionsListener.itemPositions.value.first.index;
if (notification is ScrollUpdateNotification) {
_barOffset = (firstItemIndex / maxItemCount) * barMaxScrollExtent;
if (_barOffset < barMinScrollExtent) {
_barOffset = barMinScrollExtent;
}
if (_barOffset > barMaxScrollExtent) {
_barOffset = barMaxScrollExtent;
}
}
if (notification is ScrollUpdateNotification || notification is OverscrollNotification) {
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
if (itemPosition < maxItemCount) {
_currentItem = itemPosition;
}
_fadeoutTimer?.cancel();
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
_thumbAnimationController.reverse();
_labelAnimationController.reverse();
_fadeoutTimer = null;
});
}
} catch (_) {}
});
}
void _onVerticalDragStart(DragStartDetails details) {
setState(() {
_isDragInProcess = true;
_labelAnimationController.forward();
_fadeoutTimer?.cancel();
});
widget.scrollStateListener(true);
}
int get itemPosition {
int numberOfItems = widget.child.itemCount;
return ((_barOffset / barMaxScrollExtent) * numberOfItems).toInt();
}
void _jumpToBarPosition() {
if (itemPosition > maxItemCount - 1) {
return;
}
_currentItem = itemPosition;
/// If the bar is at the bottom but the item position is still smaller than the max item count (due to rounding error)
/// jump to the end of the list
if (barMaxScrollExtent - _barOffset < 10 && itemPosition < maxItemCount) {
widget.controller.jumpTo(index: maxItemCount);
return;
}
widget.controller.jumpTo(index: itemPosition);
}
Timer? dragHaltTimer;
int lastTimerPosition = 0;
void _onVerticalDragUpdate(DragUpdateDetails details) {
setState(() {
if (_thumbAnimationController.status != AnimationStatus.forward) {
_thumbAnimationController.forward();
}
if (_isDragInProcess) {
_barOffset += details.delta.dy;
if (_barOffset < barMinScrollExtent) {
_barOffset = barMinScrollExtent;
}
if (_barOffset > barMaxScrollExtent) {
_barOffset = barMaxScrollExtent;
}
if (itemPosition != lastTimerPosition) {
lastTimerPosition = itemPosition;
dragHaltTimer?.cancel();
widget.scrollStateListener(true);
dragHaltTimer = Timer(const Duration(milliseconds: 500), () {
widget.scrollStateListener(false);
});
}
_jumpToBarPosition();
}
});
}
void _onVerticalDragEnd(DragEndDetails details) {
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
_thumbAnimationController.reverse();
_labelAnimationController.reverse();
_fadeoutTimer = null;
});
setState(() {
_jumpToBarPosition();
_isDragInProcess = false;
});
widget.scrollStateListener(false);
}
}
/// Draws 2 triangles like arrow up and arrow down
class ArrowCustomPainter extends CustomPainter {
Color color;
ArrowCustomPainter(this.color);
@override
bool shouldRepaint(covariant CustomPainter oldDelegate) => false;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color;
const width = 12.0;
const height = 8.0;
final baseX = size.width / 2;
final baseY = size.height / 2;
canvas.drawPath(_trianglePath(Offset(baseX, baseY - 2.0), width, height, true), paint);
canvas.drawPath(_trianglePath(Offset(baseX, baseY + 2.0), width, height, false), paint);
}
static Path _trianglePath(Offset o, double width, double height, bool isUp) {
return Path()
..moveTo(o.dx, o.dy)
..lineTo(o.dx + width, o.dy)
..lineTo(o.dx + (width / 2), isUp ? o.dy - height : o.dy + height)
..close();
}
}
///This cut 2 lines in arrow shape
class ArrowClipper extends CustomClipper<Path> {
const ArrowClipper();
@override
Path getClip(Size size) {
Path path = Path();
path.lineTo(0.0, size.height);
path.lineTo(size.width, size.height);
path.lineTo(size.width, 0.0);
path.lineTo(0.0, 0.0);
path.close();
double arrowWidth = 8.0;
double startPointX = (size.width - arrowWidth) / 2;
double startPointY = size.height / 2 - arrowWidth / 2;
path.moveTo(startPointX, startPointY);
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2);
path.lineTo(startPointX + arrowWidth, startPointY);
path.lineTo(startPointX + arrowWidth, startPointY + 1.0);
path.lineTo(startPointX + arrowWidth / 2, startPointY - arrowWidth / 2 + 1.0);
path.lineTo(startPointX, startPointY + 1.0);
path.close();
startPointY = size.height / 2 + arrowWidth / 2;
path.moveTo(startPointX + arrowWidth, startPointY);
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2);
path.lineTo(startPointX, startPointY);
path.lineTo(startPointX, startPointY - 1.0);
path.lineTo(startPointX + arrowWidth / 2, startPointY + arrowWidth / 2 - 1.0);
path.lineTo(startPointX + arrowWidth, startPointY - 1.0);
path.close();
return path;
}
@override
bool shouldReclip(CustomClipper<Path> oldClipper) => false;
}
class SlideFadeTransition extends StatelessWidget {
final Animation<double> animation;
final Widget child;
const SlideFadeTransition({super.key, required this.animation, required this.child});
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: animation,
builder: (context, child) => animation.value == 0.0 ? const SizedBox() : child!,
child: SlideTransition(
position: Tween(begin: const Offset(0.3, 0.0), end: const Offset(0.0, 0.0)).animate(animation),
child: FadeTransition(opacity: animation, child: child),
),
);
}
}
@@ -1,84 +0,0 @@
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/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
class GroupDividerTitle extends HookConsumerWidget {
const GroupDividerTitle({
super.key,
required this.text,
required this.multiselectEnabled,
required this.onSelect,
required this.onDeselect,
required this.selected,
});
final String text;
final bool multiselectEnabled;
final Function onSelect;
final Function onDeselect;
final bool selected;
@override
Widget build(BuildContext context, WidgetRef ref) {
final appSettingService = ref.watch(appSettingsServiceProvider);
final groupBy = useState(GroupAssetsBy.day);
useEffect(() {
groupBy.value = GroupAssetsBy.values[appSettingService.getSetting<int>(AppSettingsEnum.groupAssetsBy)];
return null;
}, []);
void handleTitleIconClick() {
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
if (selected) {
onDeselect();
} else {
onSelect();
}
}
return Padding(
padding: EdgeInsets.only(
top: groupBy.value == GroupAssetsBy.month ? 32.0 : 16.0,
bottom: 16.0,
left: 12.0,
right: 12.0,
),
child: Row(
children: [
Text(
text,
style: groupBy.value == GroupAssetsBy.month
? context.textTheme.bodyLarge?.copyWith(fontSize: 24.0)
: context.textTheme.labelLarge?.copyWith(
color: context.textTheme.labelLarge?.color?.withAlpha(250),
fontWeight: FontWeight.w500,
),
),
const Spacer(),
GestureDetector(
onTap: handleTitleIconClick,
child: multiselectEnabled && selected
? Icon(
Icons.check_circle_rounded,
color: context.primaryColor,
semanticLabel: "unselect_all_in".tr(namedArgs: {"group": text}),
)
: Icon(
Icons.check_circle_outline_rounded,
color: context.colorScheme.onSurfaceSecondary,
semanticLabel: "select_all_in".tr(namedArgs: {"group": text}),
),
),
],
),
);
}
}
@@ -1,135 +0,0 @@
import 'dart:math';
import 'package:auto_route/auto_route.dart';
import 'package:flutter/gestures.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/asyncvalue_extensions.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_view.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class ImmichAssetGrid extends HookConsumerWidget {
final int? assetsPerRow;
final double margin;
final bool? showStorageIndicator;
final ImmichAssetGridSelectionListener? listener;
final bool selectionActive;
final List<Asset>? assets;
final RenderList? renderList;
final Future<void> Function()? onRefresh;
final Set<Asset>? preselectedAssets;
final bool canDeselect;
final bool? dynamicLayout;
final bool showMultiSelectIndicator;
final void Function(Iterable<ItemPosition> itemPositions)? visibleItemsListener;
final Widget? topWidget;
final bool shrinkWrap;
final bool showDragScroll;
final bool showDragScrollLabel;
final bool showStack;
const ImmichAssetGrid({
super.key,
this.assets,
this.onRefresh,
this.renderList,
this.assetsPerRow,
this.showStorageIndicator,
this.listener,
this.margin = 2.0,
this.selectionActive = false,
this.preselectedAssets,
this.canDeselect = true,
this.dynamicLayout,
this.showMultiSelectIndicator = true,
this.visibleItemsListener,
this.topWidget,
this.shrinkWrap = false,
this.showDragScroll = true,
this.showDragScrollLabel = true,
this.showStack = false,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
var settings = ref.watch(appSettingsServiceProvider);
final perRow = useState(assetsPerRow ?? settings.getSetting(AppSettingsEnum.tilesPerRow)!);
final scaleFactor = useState(7.0 - perRow.value);
final baseScaleFactor = useState(7.0 - perRow.value);
/// assets need different hero tags across tabs / modals
/// otherwise, hero animations are performed across tabs (looks buggy!)
int heroOffset() {
const int range = 1152921504606846976; // 2^60
final tabScope = TabsRouterScope.of(context);
if (tabScope != null) {
final int tabIndex = tabScope.controller.activeIndex;
return tabIndex * range;
}
return range * 7;
}
Widget buildAssetGridView(RenderList renderList) {
return RawGestureDetector(
gestures: {
CustomScaleGestureRecognizer: GestureRecognizerFactoryWithHandlers<CustomScaleGestureRecognizer>(
() => CustomScaleGestureRecognizer(),
(CustomScaleGestureRecognizer scale) {
scale.onStart = (details) {
baseScaleFactor.value = scaleFactor.value;
};
scale.onUpdate = (details) {
scaleFactor.value = max(min(5.0, baseScaleFactor.value * details.scale), 1.0);
if (7 - scaleFactor.value.toInt() != perRow.value) {
perRow.value = 7 - scaleFactor.value.toInt();
settings.setSetting(AppSettingsEnum.tilesPerRow, perRow.value);
}
};
},
),
},
child: ImmichAssetGridView(
onRefresh: onRefresh,
assetsPerRow: perRow.value,
listener: listener,
showStorageIndicator: showStorageIndicator ?? settings.getSetting(AppSettingsEnum.storageIndicator),
renderList: renderList,
margin: margin,
selectionActive: selectionActive,
preselectedAssets: preselectedAssets,
canDeselect: canDeselect,
dynamicLayout: dynamicLayout ?? settings.getSetting(AppSettingsEnum.dynamicLayout),
showMultiSelectIndicator: showMultiSelectIndicator,
visibleItemsListener: visibleItemsListener,
topWidget: topWidget,
heroOffset: heroOffset(),
shrinkWrap: shrinkWrap,
showDragScroll: showDragScroll,
showStack: showStack,
showLabel: showDragScrollLabel,
),
);
}
if (renderList != null) return buildAssetGridView(renderList!);
final renderListFuture = ref.watch(assetsTimelineProvider(assets!));
return renderListFuture.widgetWhen(onData: (renderList) => buildAssetGridView(renderList));
}
}
/// accepts a gesture even though it should reject it (because child won)
class CustomScaleGestureRecognizer extends ScaleGestureRecognizer {
@override
void rejectGesture(int pointer) {
acceptGesture(pointer);
}
}
@@ -1,828 +0,0 @@
import 'dart:collection';
import 'dart:developer';
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/rendering.dart';
import 'package:flutter/services.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/extensions/collection_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.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/scroll_notifier.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/widgets/asset_grid/disable_multi_select_button.dart';
import 'package:immich_mobile/widgets/asset_grid/draggable_scrollbar_custom.dart';
import 'package:immich_mobile/widgets/asset_grid/group_divider_title.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_image.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
typedef ImmichAssetGridSelectionListener = void Function(bool, Set<Asset>);
class ImmichAssetGridView extends ConsumerStatefulWidget {
final RenderList renderList;
final int assetsPerRow;
final double margin;
final bool showStorageIndicator;
final ImmichAssetGridSelectionListener? listener;
final bool selectionActive;
final Future<void> Function()? onRefresh;
final Set<Asset>? preselectedAssets;
final bool canDeselect;
final bool dynamicLayout;
final bool showMultiSelectIndicator;
final void Function(Iterable<ItemPosition> itemPositions)? visibleItemsListener;
final Widget? topWidget;
final int heroOffset;
final bool shrinkWrap;
final bool showDragScroll;
final bool showStack;
final bool showLabel;
const ImmichAssetGridView({
super.key,
required this.renderList,
required this.assetsPerRow,
required this.showStorageIndicator,
this.listener,
this.margin = 5.0,
this.selectionActive = false,
this.onRefresh,
this.preselectedAssets,
this.canDeselect = true,
this.dynamicLayout = true,
this.showMultiSelectIndicator = true,
this.visibleItemsListener,
this.topWidget,
this.heroOffset = 0,
this.shrinkWrap = false,
this.showDragScroll = true,
this.showStack = false,
this.showLabel = true,
});
@override
createState() {
return ImmichAssetGridViewState();
}
}
class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
final ItemScrollController _itemScrollController = ItemScrollController();
final ScrollOffsetController _scrollOffsetController = ScrollOffsetController();
final ItemPositionsListener _itemPositionsListener = ItemPositionsListener.create();
late final KeepAliveLink currentAssetLink;
/// The timestamp when the haptic feedback was last invoked
int _hapticFeedbackTS = 0;
DateTime? _prevItemTime;
bool _scrolling = false;
final Set<Asset> _selectedAssets = LinkedHashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
bool _dragging = false;
int? _dragAnchorAssetIndex;
int? _dragAnchorSectionIndex;
final Set<Asset> _draggedAssets = HashSet(equals: (a, b) => a.id == b.id, hashCode: (a) => a.id);
ScrollPhysics? _scrollPhysics;
Set<Asset> _getSelectedAssets() {
return Set.from(_selectedAssets);
}
void _callSelectionListener(bool selectionActive) {
widget.listener?.call(selectionActive, _getSelectedAssets());
}
void _selectAssets(List<Asset> assets) {
setState(() {
if (_dragging) {
_draggedAssets.addAll(assets);
}
_selectedAssets.addAll(assets);
_callSelectionListener(true);
});
}
void _deselectAssets(List<Asset> assets) {
final assetsToDeselect = assets.where(
(a) => widget.canDeselect || !(widget.preselectedAssets?.contains(a) ?? false),
);
setState(() {
_selectedAssets.removeAll(assetsToDeselect);
if (_dragging) {
_draggedAssets.removeAll(assetsToDeselect);
}
_callSelectionListener(_selectedAssets.isNotEmpty);
});
}
void _deselectAll() {
setState(() {
_selectedAssets.clear();
_dragAnchorAssetIndex = null;
_dragAnchorSectionIndex = null;
_draggedAssets.clear();
_dragging = false;
if (!widget.canDeselect && widget.preselectedAssets != null && widget.preselectedAssets!.isNotEmpty) {
_selectedAssets.addAll(widget.preselectedAssets!);
}
_callSelectionListener(false);
});
}
bool _allAssetsSelected(List<Asset> assets) {
return widget.selectionActive && assets.firstWhereOrNull((e) => !_selectedAssets.contains(e)) == null;
}
Future<void> _scrollToIndex(int index) async {
// if the index is so far down, that the end of the list is reached on the screen
// the scroll_position widget crashes. This is a workaround to prevent this.
// If the index is within the last 10 elements, we jump instead of scrolling.
if (widget.renderList.elements.length <= index + 10) {
_itemScrollController.jumpTo(index: index);
return;
}
await _itemScrollController.scrollTo(index: index, alignment: 0, duration: const Duration(milliseconds: 500));
}
Widget _itemBuilder(BuildContext c, int position) {
int index = position;
if (widget.topWidget != null) {
if (index == 0) {
return widget.topWidget!;
}
index--;
}
final section = widget.renderList.elements[index];
return _Section(
showStorageIndicator: widget.showStorageIndicator,
selectedAssets: _selectedAssets,
selectionActive: widget.selectionActive,
sectionIndex: index,
section: section,
margin: widget.margin,
renderList: widget.renderList,
assetsPerRow: widget.assetsPerRow,
scrolling: _scrolling,
dynamicLayout: widget.dynamicLayout,
selectAssets: _selectAssets,
deselectAssets: _deselectAssets,
allAssetsSelected: _allAssetsSelected,
showStack: widget.showStack,
heroOffset: widget.heroOffset,
onAssetTap: (asset) {
ref.read(currentAssetProvider.notifier).set(asset);
ref.read(isPlayingMotionVideoProvider.notifier).playing = false;
if (asset.isVideo) {
ref.read(showControlsProvider.notifier).show = false;
}
},
);
}
Text _labelBuilder(int pos) {
final maxLength = widget.renderList.elements.length;
if (pos < 0 || pos >= maxLength) {
return const Text("");
}
final date = widget.renderList.elements[pos % maxLength].date;
return Text(
DateFormat.yMMMM().format(date),
style: const TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
);
}
Widget _buildMultiSelectIndicator() {
return DisableMultiSelectButton(onPressed: () => _deselectAll(), selectedItemCount: _selectedAssets.length);
}
Widget _buildAssetGrid() {
final useDragScrolling = widget.showDragScroll && widget.renderList.totalAssets >= 20;
void dragScrolling(bool active) {
if (active != _scrolling) {
setState(() {
_scrolling = active;
});
}
}
bool appBarOffset() {
return (ref.watch(tabProvider).index == 0 && ModalRoute.of(context)?.settings.name == TabControllerRoute.name) ||
(ModalRoute.of(context)?.settings.name == AlbumViewerRoute.name);
}
final listWidget = ScrollablePositionedList.builder(
padding: EdgeInsets.only(top: appBarOffset() ? 60 : 0, bottom: 220),
itemBuilder: _itemBuilder,
itemPositionsListener: _itemPositionsListener,
physics: _scrollPhysics,
itemScrollController: _itemScrollController,
scrollOffsetController: _scrollOffsetController,
itemCount: widget.renderList.elements.length + (widget.topWidget != null ? 1 : 0),
addRepaintBoundaries: true,
shrinkWrap: widget.shrinkWrap,
);
final child = (useDragScrolling && ModalRoute.of(context) != null)
? DraggableScrollbar.semicircle(
scrollStateListener: dragScrolling,
itemPositionsListener: _itemPositionsListener,
controller: _itemScrollController,
backgroundColor: context.isDarkTheme
? context.colorScheme.primary.darken(amount: .5)
: context.colorScheme.primary,
labelTextBuilder: widget.showLabel ? _labelBuilder : null,
padding: appBarOffset() ? const EdgeInsets.only(top: 60) : const EdgeInsets.only(),
heightOffset: appBarOffset() ? 60 : 0,
labelConstraints: const BoxConstraints(maxHeight: 28),
scrollbarAnimationDuration: const Duration(milliseconds: 300),
scrollbarTimeToFade: const Duration(milliseconds: 1000),
child: listWidget,
)
: listWidget;
return widget.onRefresh == null
? child
: appBarOffset()
? RefreshIndicator(onRefresh: widget.onRefresh!, edgeOffset: 30, child: child)
: RefreshIndicator(onRefresh: widget.onRefresh!, child: child);
}
void _scrollToDate() {
final date = scrollToDateNotifierProvider.value;
if (date == null) {
ImmichToast.show(
context: context,
msg: "Scroll To Date failed, date is null.",
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
return;
}
// Search for the index of the exact date in the list
var index = widget.renderList.elements.indexWhere(
(e) => e.date.year == date.year && e.date.month == date.month && e.date.day == date.day,
);
// If the exact date is not found, the timeline is grouped by month,
// thus we search for the month
if (index == -1) {
index = widget.renderList.elements.indexWhere((e) => e.date.year == date.year && e.date.month == date.month);
}
if (index < widget.renderList.elements.length) {
// Not sure why the index is shifted, but it works. :3
_scrollToIndex(index + 1);
} else {
ImmichToast.show(
context: context,
msg: "The date (${DateFormat.yMd().format(date)}) could not be found in the timeline.",
gravity: ToastGravity.BOTTOM,
toastType: ToastType.error,
);
}
}
@override
void didUpdateWidget(ImmichAssetGridView oldWidget) {
super.didUpdateWidget(oldWidget);
if (!widget.selectionActive) {
setState(() {
_selectedAssets.clear();
});
}
}
@override
void initState() {
super.initState();
currentAssetLink = ref.read(currentAssetProvider.notifier).ref.keepAlive();
scrollToTopNotifierProvider.addListener(_scrollToTop);
scrollToDateNotifierProvider.addListener(_scrollToDate);
if (widget.visibleItemsListener != null) {
_itemPositionsListener.itemPositions.addListener(_positionListener);
}
if (widget.preselectedAssets != null) {
_selectedAssets.addAll(widget.preselectedAssets!);
}
_itemPositionsListener.itemPositions.addListener(_hapticsListener);
}
@override
void dispose() {
scrollToTopNotifierProvider.removeListener(_scrollToTop);
scrollToDateNotifierProvider.removeListener(_scrollToDate);
if (widget.visibleItemsListener != null) {
_itemPositionsListener.itemPositions.removeListener(_positionListener);
}
_itemPositionsListener.itemPositions.removeListener(_hapticsListener);
currentAssetLink.close();
super.dispose();
}
void _positionListener() {
final values = _itemPositionsListener.itemPositions.value;
widget.visibleItemsListener?.call(values);
}
void _hapticsListener() {
/// throttle interval for the haptic feedback in microseconds.
/// Currently set to 100ms.
const feedbackInterval = 100000;
final values = _itemPositionsListener.itemPositions.value;
final start = values.firstOrNull;
if (start != null) {
final pos = start.index;
final maxLength = widget.renderList.elements.length;
if (pos < 0 || pos >= maxLength) {
return;
}
final date = widget.renderList.elements[pos].date;
// only provide the feedback if the prev. date is known.
// Otherwise the app would provide the haptic feedback
// on startup.
if (_prevItemTime == null) {
_prevItemTime = date;
} else if (_prevItemTime?.year != date.year || _prevItemTime?.month != date.month) {
_prevItemTime = date;
final now = Timeline.now;
if (now > (_hapticFeedbackTS + feedbackInterval)) {
_hapticFeedbackTS = now;
ref.read(hapticFeedbackProvider.notifier).mediumImpact();
}
}
}
}
void _scrollToTop() {
// for some reason, this is necessary as well in order
// to correctly reposition the drag thumb scroll bar
_itemScrollController.jumpTo(index: 0);
_itemScrollController.scrollTo(index: 0, duration: const Duration(milliseconds: 200));
}
void _setDragStartIndex(AssetIndex index) {
setState(() {
_scrollPhysics = const ClampingScrollPhysics();
_dragAnchorAssetIndex = index.rowIndex;
_dragAnchorSectionIndex = index.sectionIndex;
_dragging = true;
});
}
void _stopDrag() {
WidgetsBinding.instance.addPostFrameCallback((_) {
// Update the physics post frame to prevent sudden change in physics on iOS.
setState(() {
_scrollPhysics = null;
});
});
setState(() {
_dragging = false;
_draggedAssets.clear();
});
}
void _dragDragScroll(ScrollDirection direction) {
_scrollOffsetController.animateScroll(
offset: direction == ScrollDirection.forward ? 175 : -175,
duration: const Duration(milliseconds: 125),
);
}
void _handleDragAssetEnter(AssetIndex index) {
if (_dragAnchorSectionIndex == null || _dragAnchorAssetIndex == null) {
return;
}
final dragAnchorSectionIndex = _dragAnchorSectionIndex!;
final dragAnchorAssetIndex = _dragAnchorAssetIndex!;
late final int startSectionIndex;
late final int startSectionAssetIndex;
late final int endSectionIndex;
late final int endSectionAssetIndex;
if (index.sectionIndex < dragAnchorSectionIndex) {
startSectionIndex = index.sectionIndex;
startSectionAssetIndex = index.rowIndex;
endSectionIndex = dragAnchorSectionIndex;
endSectionAssetIndex = dragAnchorAssetIndex;
} else if (index.sectionIndex > dragAnchorSectionIndex) {
startSectionIndex = dragAnchorSectionIndex;
startSectionAssetIndex = dragAnchorAssetIndex;
endSectionIndex = index.sectionIndex;
endSectionAssetIndex = index.rowIndex;
} else {
startSectionIndex = dragAnchorSectionIndex;
endSectionIndex = dragAnchorSectionIndex;
// If same section, assign proper start / end asset Index
if (dragAnchorAssetIndex < index.rowIndex) {
startSectionAssetIndex = dragAnchorAssetIndex;
endSectionAssetIndex = index.rowIndex;
} else {
startSectionAssetIndex = index.rowIndex;
endSectionAssetIndex = dragAnchorAssetIndex;
}
}
final selectedAssets = <Asset>{};
var currentSectionIndex = startSectionIndex;
while (currentSectionIndex < endSectionIndex) {
final section = widget.renderList.elements.elementAtOrNull(currentSectionIndex);
if (section == null) continue;
final sectionAssets = widget.renderList.loadAssets(section.offset, section.count);
if (currentSectionIndex == startSectionIndex) {
selectedAssets.addAll(sectionAssets.slice(startSectionAssetIndex, sectionAssets.length));
} else {
selectedAssets.addAll(sectionAssets);
}
currentSectionIndex += 1;
}
final section = widget.renderList.elements.elementAtOrNull(endSectionIndex);
if (section != null) {
final sectionAssets = widget.renderList.loadAssets(section.offset, section.count);
if (startSectionIndex == endSectionIndex) {
selectedAssets.addAll(sectionAssets.slice(startSectionAssetIndex, endSectionAssetIndex + 1));
} else {
selectedAssets.addAll(sectionAssets.slice(0, endSectionAssetIndex + 1));
}
}
_deselectAssets(_draggedAssets.toList());
_draggedAssets.clear();
_draggedAssets.addAll(selectedAssets);
_selectAssets(_draggedAssets.toList());
}
@override
Widget build(BuildContext context) {
return PopScope(
canPop: !(widget.selectionActive && _selectedAssets.isNotEmpty),
onPopInvokedWithResult: (didPop, _) {
if (didPop) {
return;
} else {
/// `preselectedAssets` is only present when opening the asset grid from the
/// "add to album" button.
///
/// `_selectedAssets` includes `preselectedAssets` on initialization.
if (_selectedAssets.length > (widget.preselectedAssets?.length ?? 0)) {
/// `_deselectAll` only deselects the selected assets,
/// doesn't affect the preselected ones.
_deselectAll();
return;
} else {
Navigator.of(context).canPop() ? Navigator.of(context).pop() : null;
}
}
},
child: Stack(
children: [
AssetDragRegion(
onStart: _setDragStartIndex,
onAssetEnter: _handleDragAssetEnter,
onEnd: _stopDrag,
onScroll: _dragDragScroll,
onScrollStart: () =>
WidgetsBinding.instance.addPostFrameCallback((_) => controlBottomAppBarNotifier.minimize()),
child: _buildAssetGrid(),
),
if (widget.showMultiSelectIndicator && widget.selectionActive) _buildMultiSelectIndicator(),
],
),
);
}
}
/// A single row of all placeholder widgets
class _PlaceholderRow extends StatelessWidget {
final int number;
final double width;
final double height;
final double margin;
const _PlaceholderRow({
super.key,
required this.number,
required this.width,
required this.height,
required this.margin,
});
@override
Widget build(BuildContext context) {
return Row(
children: [
for (int i = 0; i < number; i++)
ThumbnailPlaceholder(
key: ValueKey(i),
width: width,
height: height,
margin: EdgeInsets.only(bottom: margin, right: i + 1 == number ? 0.0 : margin),
),
],
);
}
}
/// A section for the render grid
class _Section extends StatelessWidget {
final RenderAssetGridElement section;
final int sectionIndex;
final Set<Asset> selectedAssets;
final bool scrolling;
final double margin;
final int assetsPerRow;
final RenderList renderList;
final bool selectionActive;
final bool dynamicLayout;
final void Function(List<Asset>) selectAssets;
final void Function(List<Asset>) deselectAssets;
final bool Function(List<Asset>) allAssetsSelected;
final bool showStack;
final int heroOffset;
final bool showStorageIndicator;
final void Function(Asset) onAssetTap;
const _Section({
required this.section,
required this.sectionIndex,
required this.scrolling,
required this.margin,
required this.assetsPerRow,
required this.renderList,
required this.selectionActive,
required this.dynamicLayout,
required this.selectAssets,
required this.deselectAssets,
required this.allAssetsSelected,
required this.selectedAssets,
required this.showStack,
required this.heroOffset,
required this.showStorageIndicator,
required this.onAssetTap,
});
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth / assetsPerRow - margin * (assetsPerRow - 1) / assetsPerRow;
final rows = (section.count + assetsPerRow - 1) ~/ assetsPerRow;
final List<Asset> assetsToRender = scrolling ? [] : renderList.loadAssets(section.offset, section.count);
return Column(
key: ValueKey(section.offset),
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (section.type == RenderAssetGridElementType.monthTitle) _MonthTitle(date: section.date),
if (section.type == RenderAssetGridElementType.groupDividerTitle ||
section.type == RenderAssetGridElementType.monthTitle)
_Title(
selectionActive: selectionActive,
title: section.title!,
assets: scrolling ? [] : renderList.loadAssets(section.offset, section.totalCount),
allAssetsSelected: allAssetsSelected,
selectAssets: selectAssets,
deselectAssets: deselectAssets,
),
for (int i = 0; i < rows; i++)
scrolling
? _PlaceholderRow(
key: ValueKey(i),
number: i + 1 == rows ? section.count - i * assetsPerRow : assetsPerRow,
width: width,
height: width,
margin: margin,
)
: _AssetRow(
key: ValueKey(i),
rowStartIndex: i * assetsPerRow,
sectionIndex: sectionIndex,
assets: assetsToRender.nestedSlice(i * assetsPerRow, min((i + 1) * assetsPerRow, section.count)),
absoluteOffset: section.offset + i * assetsPerRow,
width: width,
assetsPerRow: assetsPerRow,
margin: margin,
dynamicLayout: dynamicLayout,
renderList: renderList,
selectedAssets: selectedAssets,
isSelectionActive: selectionActive,
showStack: showStack,
heroOffset: heroOffset,
showStorageIndicator: showStorageIndicator,
selectionActive: selectionActive,
onSelect: (asset) => selectAssets([asset]),
onDeselect: (asset) => deselectAssets([asset]),
onAssetTap: onAssetTap,
),
],
);
},
);
}
}
/// The month title row for a section
class _MonthTitle extends StatelessWidget {
final DateTime date;
const _MonthTitle({required this.date});
@override
Widget build(BuildContext context) {
final monthFormat = DateTime.now().year == date.year ? DateFormat.MMMM() : DateFormat.yMMMM();
final String title = monthFormat.format(date);
return Padding(
key: Key("month-$title"),
padding: const EdgeInsets.only(left: 12.0, top: 24.0),
child: Text(
toBeginningOfSentenceCase(title, context.locale.languageCode),
style: const TextStyle(fontSize: 26, fontWeight: FontWeight.w500),
),
);
}
}
/// A title row
class _Title extends StatelessWidget {
final String title;
final List<Asset> assets;
final bool selectionActive;
final void Function(List<Asset>) selectAssets;
final void Function(List<Asset>) deselectAssets;
final bool Function(List<Asset>) allAssetsSelected;
const _Title({
required this.title,
required this.assets,
required this.selectionActive,
required this.selectAssets,
required this.deselectAssets,
required this.allAssetsSelected,
});
@override
Widget build(BuildContext context) {
return GroupDividerTitle(
text: toBeginningOfSentenceCase(title, context.locale.languageCode),
multiselectEnabled: selectionActive,
onSelect: () => selectAssets(assets),
onDeselect: () => deselectAssets(assets),
selected: allAssetsSelected(assets),
);
}
}
/// The row of assets
class _AssetRow extends StatelessWidget {
final List<Asset> assets;
final int rowStartIndex;
final int sectionIndex;
final Set<Asset> selectedAssets;
final int absoluteOffset;
final double width;
final bool dynamicLayout;
final double margin;
final int assetsPerRow;
final RenderList renderList;
final bool selectionActive;
final bool showStorageIndicator;
final int heroOffset;
final bool showStack;
final void Function(Asset) onAssetTap;
final void Function(Asset)? onSelect;
final void Function(Asset)? onDeselect;
final bool isSelectionActive;
const _AssetRow({
super.key,
required this.rowStartIndex,
required this.sectionIndex,
required this.assets,
required this.absoluteOffset,
required this.width,
required this.dynamicLayout,
required this.margin,
required this.assetsPerRow,
required this.renderList,
required this.selectionActive,
required this.showStorageIndicator,
required this.heroOffset,
required this.showStack,
required this.isSelectionActive,
required this.selectedAssets,
required this.onAssetTap,
this.onSelect,
this.onDeselect,
});
@override
Widget build(BuildContext context) {
// Default: All assets have the same width
final widthDistribution = List.filled(assets.length, 1.0);
if (dynamicLayout) {
final aspectRatios = assets.map((e) => (e.width ?? 1) / (e.height ?? 1)).toList();
final meanAspectRatio = aspectRatios.sum / assets.length;
// 1: mean width
// 0.5: width < mean - threshold
// 1.5: width > mean + threshold
final arConfiguration = aspectRatios.map((e) {
if (e - meanAspectRatio > 0.3) return 1.5;
if (e - meanAspectRatio < -0.3) return 0.5;
return 1.0;
});
// Normalize:
final sum = arConfiguration.sum;
widthDistribution.setRange(0, widthDistribution.length, arConfiguration.map((e) => (e * assets.length) / sum));
}
return Row(
key: key,
children: assets.mapIndexed((int index, Asset asset) {
final bool last = index + 1 == assetsPerRow;
final isSelected = isSelectionActive && selectedAssets.contains(asset);
return Container(
width: width * widthDistribution[index],
height: width,
margin: EdgeInsets.only(bottom: margin, right: last ? 0.0 : margin),
child: GestureDetector(
onTap: () {
if (selectionActive) {
if (isSelected) {
onDeselect?.call(asset);
} else {
onSelect?.call(asset);
}
} else {
final asset = renderList.loadAsset(absoluteOffset + index);
onAssetTap(asset);
context.pushRoute(
GalleryViewerRoute(
renderList: renderList,
initialIndex: absoluteOffset + index,
heroOffset: heroOffset,
showStack: showStack,
),
);
}
},
onLongPress: () {
onSelect?.call(asset);
HapticFeedback.heavyImpact();
},
child: AssetIndexWrapper(
rowIndex: rowStartIndex + index,
sectionIndex: sectionIndex,
child: ThumbnailImage(
asset: asset,
multiselectEnabled: selectionActive,
isSelected: isSelectionActive && selectedAssets.contains(asset),
showStorageIndicator: showStorageIndicator,
heroOffset: heroOffset,
showStack: showStack,
),
),
),
);
}).toList(),
);
}
}
@@ -1,458 +0,0 @@
import 'dart:async';
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/foundation.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/constants/enums.dart';
import 'package:immich_mobile/entities/album.entity.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/collection_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/asset_selection_state.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/download.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/multiselect.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/album.service.dart';
import 'package:immich_mobile/services/stack.service.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class MultiselectGrid extends HookConsumerWidget {
const MultiselectGrid({
super.key,
required this.renderListProvider,
this.onRefresh,
this.buildLoadingIndicator,
this.onRemoveFromAlbum,
this.topWidget,
this.stackEnabled = false,
this.dragScrollLabelEnabled = true,
this.archiveEnabled = false,
this.deleteEnabled = true,
this.favoriteEnabled = true,
this.editEnabled = false,
this.unarchive = false,
this.unfavorite = false,
this.downloadEnabled = true,
this.emptyIndicator,
});
final ProviderListenable<AsyncValue<RenderList>> renderListProvider;
final Future<void> Function()? onRefresh;
final Widget Function()? buildLoadingIndicator;
final Future<bool> Function(Iterable<Asset>)? onRemoveFromAlbum;
final Widget? topWidget;
final bool stackEnabled;
final bool dragScrollLabelEnabled;
final bool archiveEnabled;
final bool unarchive;
final bool deleteEnabled;
final bool downloadEnabled;
final bool favoriteEnabled;
final bool unfavorite;
final bool editEnabled;
final Widget? emptyIndicator;
Widget buildDefaultLoadingIndicator() => const Center(child: CircularProgressIndicator());
Widget buildEmptyIndicator() => emptyIndicator ?? Center(child: const Text("no_assets_to_show").tr());
@override
Widget build(BuildContext context, WidgetRef ref) {
final multiselectEnabled = ref.watch(multiselectProvider.notifier);
final selectionEnabledHook = useState(false);
final selectionAssetState = useState(const AssetSelectionState());
final selection = useState(<Asset>{});
final currentUser = ref.watch(currentUserProvider);
final processing = useProcessingOverlay();
useEffect(() {
selectionEnabledHook.addListener(() {
multiselectEnabled.state = selectionEnabledHook.value;
});
return () {
// This does not work in tests
if (kReleaseMode) {
selectionEnabledHook.dispose();
}
};
}, []);
void selectionListener(bool multiselect, Set<Asset> selectedAssets) {
selectionEnabledHook.value = multiselect;
selection.value = selectedAssets;
selectionAssetState.value = AssetSelectionState.fromSelection(selectedAssets);
}
errorBuilder(String? msg) => msg != null && msg.isNotEmpty
? () => ImmichToast.show(context: context, msg: msg, gravity: ToastGravity.BOTTOM)
: null;
Iterable<Asset> ownedRemoteSelection({String? localErrorMessage, String? ownerErrorMessage}) {
final assets = selection.value;
return assets
.remoteOnly(errorCallback: errorBuilder(localErrorMessage))
.ownedOnly(currentUser, errorCallback: errorBuilder(ownerErrorMessage));
}
Iterable<Asset> remoteSelection({String? errorMessage}) =>
selection.value.remoteOnly(errorCallback: errorBuilder(errorMessage));
void onShareAssets(bool shareLocal) {
processing.value = true;
if (shareLocal) {
// Share = Download + Send to OS specific share sheet
handleShareAssets(ref, context, selection.value);
} else {
final ids = remoteSelection(errorMessage: "home_page_share_err_local".tr()).map((e) => e.remoteId!);
context.pushRoute(SharedLinkEditRoute(assetsList: ids.toList()));
}
processing.value = false;
selectionEnabledHook.value = false;
}
void onFavoriteAssets() async {
processing.value = true;
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_favorite_err_local'.tr(),
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
);
if (remoteAssets.isNotEmpty) {
await handleFavoriteAssets(ref, context, remoteAssets.toList());
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onArchiveAsset() async {
processing.value = true;
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_archive_err_local'.tr(),
ownerErrorMessage: 'home_page_archive_err_partner'.tr(),
);
await handleArchiveAssets(ref, context, remoteAssets.toList());
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onDelete([bool force = false]) async {
processing.value = true;
try {
final toDelete = selection.value
.ownedOnly(currentUser, errorCallback: errorBuilder('home_page_delete_err_partner'.tr()))
.toList();
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(toDelete, force: force);
if (isDeleted) {
ImmichToast.show(
context: context,
msg: force
? 'assets_deleted_permanently'.tr(namedArgs: {'count': "${selection.value.length}"})
: 'assets_trashed'.tr(namedArgs: {'count': "${selection.value.length}"}),
gravity: ToastGravity.BOTTOM,
);
selectionEnabledHook.value = false;
}
} finally {
processing.value = false;
}
}
void onDeleteLocal(bool isMergedAsset) async {
processing.value = true;
try {
final localAssets = selection.value.where((a) => a.isLocal).toList();
final toDelete = isMergedAsset ? localAssets.where((e) => e.storage == AssetState.merged) : localAssets;
if (toDelete.isEmpty) {
return;
}
final isDeleted = await ref.read(assetProvider.notifier).deleteLocalAssets(toDelete.toList());
if (isDeleted) {
final deletedCount = localAssets.where((e) => !isMergedAsset || e.isRemote).length;
ImmichToast.show(
context: context,
msg: 'assets_removed_permanently_from_device'.tr(namedArgs: {'count': "$deletedCount"}),
gravity: ToastGravity.BOTTOM,
);
selectionEnabledHook.value = false;
}
} finally {
processing.value = false;
}
}
void onDownload() async {
processing.value = true;
try {
final toDownload = selection.value.toList();
final results = await ref.read(downloadStateProvider.notifier).downloadAllAsset(toDownload);
final totalCount = toDownload.length;
final successCount = results.where((e) => e).length;
final failedCount = totalCount - successCount;
final msg = failedCount > 0
? 'assets_downloaded_failed'.t(context: context, args: {'count': successCount, 'error': failedCount})
: 'assets_downloaded_successfully'.t(context: context, args: {'count': successCount});
ImmichToast.show(context: context, msg: msg, gravity: ToastGravity.BOTTOM);
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onDeleteRemote([bool shouldDeletePermanently = false]) async {
processing.value = true;
try {
final toDelete = ownedRemoteSelection(
localErrorMessage: 'home_page_delete_remote_err_local'.tr(),
ownerErrorMessage: 'home_page_delete_err_partner'.tr(),
).toList();
final isDeleted = await ref
.read(assetProvider.notifier)
.deleteRemoteAssets(toDelete, shouldDeletePermanently: shouldDeletePermanently);
if (isDeleted) {
ImmichToast.show(
context: context,
msg: shouldDeletePermanently
? 'assets_deleted_permanently_from_server'.tr(namedArgs: {'count': "${toDelete.length}"})
: 'assets_trashed_from_server'.tr(namedArgs: {'count': "${toDelete.length}"}),
gravity: ToastGravity.BOTTOM,
);
}
} finally {
selectionEnabledHook.value = false;
processing.value = false;
}
}
void onUpload() {
processing.value = true;
selectionEnabledHook.value = false;
try {
ref
.read(manualUploadProvider.notifier)
.uploadAssets(context, selection.value.where((a) => a.storage == AssetState.local));
} finally {
processing.value = false;
}
}
void onAddToAlbum(Album album) async {
processing.value = true;
try {
final Iterable<Asset> assets = remoteSelection(errorMessage: "home_page_add_to_album_err_local".tr());
if (assets.isEmpty) {
return;
}
final result = await ref.read(albumServiceProvider).addAssets(album, assets);
if (result != null) {
if (result.alreadyInAlbum.isNotEmpty) {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_conflicts".tr(
namedArgs: {
"album": album.name,
"added": result.successfullyAdded.toString(),
"failed": result.alreadyInAlbum.length.toString(),
},
),
);
} else {
ImmichToast.show(
context: context,
msg: "home_page_add_to_album_success".tr(
namedArgs: {"album": album.name, "added": result.successfullyAdded.toString()},
),
toastType: ToastType.success,
);
}
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onCreateNewAlbum() async {
processing.value = true;
try {
final Iterable<Asset> assets = remoteSelection(errorMessage: "home_page_add_to_album_err_local".tr());
if (assets.isEmpty) {
return;
}
final result = await ref.read(albumServiceProvider).createAlbumWithGeneratedName(assets);
if (result != null) {
unawaited(ref.watch(albumProvider.notifier).refreshRemoteAlbums());
selectionEnabledHook.value = false;
unawaited(context.pushRoute(AlbumViewerRoute(albumId: result.id)));
}
} finally {
processing.value = false;
}
}
void onStack() async {
try {
processing.value = true;
if (!selectionEnabledHook.value || selection.value.length < 2) {
return;
}
await ref.read(stackServiceProvider).createStack(selection.value.map((e) => e.remoteId!).toList());
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
void onEditTime() async {
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_favorite_err_local'.tr(),
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
);
if (remoteAssets.isNotEmpty) {
unawaited(handleEditDateTime(ref, context, remoteAssets.toList()));
}
} finally {
selectionEnabledHook.value = false;
}
}
void onEditLocation() async {
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_favorite_err_local'.tr(),
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
);
if (remoteAssets.isNotEmpty) {
unawaited(handleEditLocation(ref, context, remoteAssets.toList()));
}
} finally {
selectionEnabledHook.value = false;
}
}
void onToggleLockedVisibility() async {
processing.value = true;
try {
final remoteAssets = ownedRemoteSelection(
localErrorMessage: 'home_page_locked_error_local'.tr(),
ownerErrorMessage: 'home_page_locked_error_partner'.tr(),
);
if (remoteAssets.isNotEmpty) {
final isInLockedView = ref.read(inLockedViewProvider);
final visibility = isInLockedView ? AssetVisibilityEnum.timeline : AssetVisibilityEnum.locked;
await handleSetAssetsVisibility(ref, context, visibility, remoteAssets.toList());
}
} finally {
processing.value = false;
selectionEnabledHook.value = false;
}
}
Future<T> Function() wrapLongRunningFun<T>(Future<T> Function() fun, {bool showOverlay = true}) => () async {
if (showOverlay) processing.value = true;
try {
final result = await fun();
if (result.runtimeType != bool || result == true) {
selectionEnabledHook.value = false;
}
return result;
} finally {
if (showOverlay) processing.value = false;
}
};
return SafeArea(
top: true,
bottom: false,
child: Stack(
children: [
ref
.watch(renderListProvider)
.when(
data: (data) => data.isEmpty && (buildLoadingIndicator != null || topWidget == null)
? (buildLoadingIndicator ?? buildEmptyIndicator)()
: ImmichAssetGrid(
renderList: data,
listener: selectionListener,
selectionActive: selectionEnabledHook.value,
onRefresh: onRefresh == null ? null : wrapLongRunningFun(onRefresh!, showOverlay: false),
topWidget: topWidget,
showStack: stackEnabled,
showDragScrollLabel: dragScrollLabelEnabled,
),
error: (error, _) => Center(child: Text(error.toString())),
loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator,
),
if (selectionEnabledHook.value)
ControlBottomAppBar(
key: const ValueKey("controlBottomAppBar"),
onShare: onShareAssets,
onFavorite: favoriteEnabled ? onFavoriteAssets : null,
onArchive: archiveEnabled ? onArchiveAsset : null,
onDelete: deleteEnabled ? onDelete : null,
onDeleteServer: deleteEnabled ? onDeleteRemote : null,
onDownload: downloadEnabled ? onDownload : null,
/// local file deletion is allowed irrespective of [deleteEnabled] since it has
/// nothing to do with the state of the asset in the Immich server
onDeleteLocal: onDeleteLocal,
onAddToAlbum: onAddToAlbum,
onCreateNewAlbum: onCreateNewAlbum,
onUpload: onUpload,
enabled: !processing.value,
selectionAssetState: selectionAssetState.value,
selectedAssets: selection.value.toList(),
onStack: stackEnabled ? onStack : null,
onEditTime: editEnabled ? onEditTime : null,
onEditLocation: editEnabled ? onEditLocation : null,
unfavorite: unfavorite,
unarchive: unarchive,
onToggleLocked: onToggleLockedVisibility,
onRemoveFromAlbum: onRemoveFromAlbum != null
? wrapLongRunningFun(() => onRemoveFromAlbum!(selection.value))
: null,
),
],
),
);
}
}
@@ -1,26 +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/providers/asset_viewer/render_list_status_provider.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class MultiselectGridStatusIndicator extends HookConsumerWidget {
const MultiselectGridStatusIndicator({super.key, this.buildLoadingIndicator, this.emptyIndicator});
final Widget Function()? buildLoadingIndicator;
final Widget? emptyIndicator;
@override
Widget build(BuildContext context, WidgetRef ref) {
final renderListStatus = ref.watch(renderListStatusProvider);
return switch (renderListStatus) {
RenderListStatusEnum.loading =>
buildLoadingIndicator == null
? const Center(child: DelayedLoadingIndicator(delay: Duration(milliseconds: 500)))
: buildLoadingIndicator!(),
RenderListStatusEnum.empty => emptyIndicator ?? Center(child: const Text("no_assets_to_show").tr()),
RenderListStatusEnum.error => Center(child: const Text("error_loading_assets").tr()),
RenderListStatusEnum.complete => const SizedBox(),
};
}
}
@@ -1,259 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
class ThumbnailImage extends StatelessWidget {
/// The asset to show the thumbnail image for
final Asset asset;
/// Whether to show the storage indicator icont over the image or not
final bool showStorageIndicator;
/// Whether to show the show stack icon over the image or not
final bool showStack;
/// Whether to show the checkmark indicating that this image is selected
final bool isSelected;
/// Can override [isSelected] and never show the selection indicator
final bool multiselectEnabled;
/// If we are allowed to deselect this image
final bool canDeselect;
/// The offset index to apply to this hero tag for animation
final int heroOffset;
const ThumbnailImage({
super.key,
required this.asset,
this.showStorageIndicator = true,
this.showStack = false,
this.isSelected = false,
this.multiselectEnabled = false,
this.heroOffset = 0,
this.canDeselect = true,
});
@override
Widget build(BuildContext context) {
final assetContainerColor = context.isDarkTheme
? context.primaryColor.darken(amount: 0.6)
: context.primaryColor.lighten(amount: 0.8);
return Stack(
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 300),
curve: Curves.decelerate,
decoration: BoxDecoration(
border: multiselectEnabled && isSelected
? canDeselect
? Border.all(color: assetContainerColor, width: 8)
: const Border(
top: BorderSide(color: Colors.grey, width: 8),
right: BorderSide(color: Colors.grey, width: 8),
bottom: BorderSide(color: Colors.grey, width: 8),
left: BorderSide(color: Colors.grey, width: 8),
)
: const Border(),
),
child: Stack(
children: [
_ImageIcon(
heroOffset: heroOffset,
asset: asset,
assetContainerColor: assetContainerColor,
multiselectEnabled: multiselectEnabled,
canDeselect: canDeselect,
isSelected: isSelected,
),
if (showStorageIndicator) _StorageIcon(storage: asset.storage),
if (asset.isFavorite)
const Positioned(left: 8, bottom: 5, child: Icon(Icons.favorite, color: Colors.white, size: 16)),
if (asset.isVideo) _VideoIcon(duration: asset.duration),
if (asset.stackCount > 0) _StackIcon(isVideo: asset.isVideo, stackCount: asset.stackCount),
],
),
),
if (multiselectEnabled)
isSelected
? const Padding(
padding: EdgeInsets.all(3.0),
child: Align(alignment: Alignment.topLeft, child: _SelectedIcon()),
)
: const Icon(Icons.circle_outlined, color: Colors.white),
],
);
}
}
class _SelectedIcon extends StatelessWidget {
const _SelectedIcon();
@override
Widget build(BuildContext context) {
final assetContainerColor = context.isDarkTheme
? context.primaryColor.darken(amount: 0.6)
: context.primaryColor.lighten(amount: 0.8);
return DecoratedBox(
decoration: BoxDecoration(shape: BoxShape.circle, color: assetContainerColor),
child: Icon(Icons.check_circle_rounded, color: context.primaryColor),
);
}
}
class _VideoIcon extends StatelessWidget {
final Duration duration;
const _VideoIcon({required this.duration});
@override
Widget build(BuildContext context) {
return Positioned(
top: 5,
right: 8,
child: Row(
children: [
Text(
duration.format(),
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
const SizedBox(width: 3),
const Icon(Icons.play_circle_fill_rounded, color: Colors.white, size: 18),
],
),
);
}
}
class _StackIcon extends StatelessWidget {
final bool isVideo;
final int stackCount;
const _StackIcon({required this.isVideo, required this.stackCount});
@override
Widget build(BuildContext context) {
return Positioned(
top: isVideo ? 28 : 5,
right: 8,
child: Row(
children: [
if (stackCount > 1)
Text(
"$stackCount",
style: const TextStyle(color: Colors.white, fontSize: 10, fontWeight: FontWeight.bold),
),
if (stackCount > 1) const SizedBox(width: 3),
const Icon(Icons.burst_mode_rounded, color: Colors.white, size: 18),
],
),
);
}
}
class _StorageIcon extends StatelessWidget {
final AssetState storage;
const _StorageIcon({required this.storage});
@override
Widget build(BuildContext context) {
return switch (storage) {
AssetState.local => const Positioned(
right: 8,
bottom: 5,
child: Icon(
Icons.cloud_off_outlined,
color: Color.fromRGBO(255, 255, 255, 0.8),
size: 16,
shadows: [Shadow(blurRadius: 5.0, color: Color.fromRGBO(0, 0, 0, 0.6), offset: Offset(0.0, 0.0))],
),
),
AssetState.remote => const Positioned(
right: 8,
bottom: 5,
child: Icon(
Icons.cloud_outlined,
color: Color.fromRGBO(255, 255, 255, 0.8),
size: 16,
shadows: [Shadow(blurRadius: 5.0, color: Color.fromRGBO(0, 0, 0, 0.6), offset: Offset(0.0, 0.0))],
),
),
AssetState.merged => const Positioned(
right: 8,
bottom: 5,
child: Icon(
Icons.cloud_done_outlined,
color: Color.fromRGBO(255, 255, 255, 0.8),
size: 16,
shadows: [Shadow(blurRadius: 5.0, color: Color.fromRGBO(0, 0, 0, 0.6), offset: Offset(0.0, 0.0))],
),
),
};
}
}
class _ImageIcon extends StatelessWidget {
final int heroOffset;
final Asset asset;
final Color assetContainerColor;
final bool multiselectEnabled;
final bool canDeselect;
final bool isSelected;
const _ImageIcon({
required this.heroOffset,
required this.asset,
required this.assetContainerColor,
required this.multiselectEnabled,
required this.canDeselect,
required this.isSelected,
});
@override
Widget build(BuildContext context) {
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
final isDto = asset.id == noDbId;
final image = SizedBox.expand(
child: Hero(
tag: isDto ? '${asset.remoteId}-$heroOffset' : asset.id + heroOffset,
child: Stack(
children: [
SizedBox.expand(child: ImmichThumbnail(asset: asset, height: 250, width: 250)),
const DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Color.fromRGBO(0, 0, 0, 0.1),
Colors.transparent,
Colors.transparent,
Color.fromRGBO(0, 0, 0, 0.1),
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
stops: [0, 0.3, 0.6, 1],
),
),
),
],
),
),
);
if (!multiselectEnabled || !isSelected) {
return image;
}
return DecoratedBox(
decoration: canDeselect ? BoxDecoration(color: assetContainerColor) : const BoxDecoration(color: Colors.grey),
child: ClipRRect(borderRadius: const BorderRadius.all(Radius.circular(15.0)), child: image),
);
}
}
@@ -1,14 +0,0 @@
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
class UploadDialog extends ConfirmDialog {
final Function onUpload;
const UploadDialog({super.key, required this.onUpload})
: super(
title: 'upload_dialog_title',
content: 'upload_dialog_info',
cancel: 'cancel',
ok: 'upload',
onOk: onUpload,
);
}
@@ -1,77 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
class AdvancedBottomSheet extends HookConsumerWidget {
final Asset assetDetail;
final ScrollController? scrollController;
const AdvancedBottomSheet({super.key, required this.assetDetail, this.scrollController});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SingleChildScrollView(
controller: scrollController,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 8.0),
child: LayoutBuilder(
builder: (context, constraints) {
// One column
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Align(child: Text("ADVANCED INFO", style: TextStyle(fontSize: 12.0))),
const SizedBox(height: 32.0),
Container(
decoration: BoxDecoration(
color: context.isDarkTheme ? Colors.grey[900] : Colors.grey[200],
borderRadius: const BorderRadius.all(Radius.circular(15.0)),
),
child: Padding(
padding: const EdgeInsets.only(right: 16.0, left: 16, top: 8, bottom: 16),
child: ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
children: [
Align(
alignment: Alignment.centerRight,
child: IconButton(
onPressed: () {
Clipboard.setData(ClipboardData(text: assetDetail.toString())).then((_) {
context.scaffoldMessenger.showSnackBar(
SnackBar(
content: Text(
"Copied to clipboard",
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
),
);
});
},
icon: Icon(Icons.copy, size: 16.0, color: context.primaryColor),
),
),
SelectableText(
assetDetail.toString(),
style: const TextStyle(
fontSize: 12.0,
fontWeight: FontWeight.bold,
fontFamily: "GoogleSansCode",
),
showCursor: true,
),
],
),
),
),
const SizedBox(height: 32.0),
],
);
},
),
),
);
}
}
@@ -1,362 +0,0 @@
import 'dart:async';
import 'dart:io';
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/pages/editing/edit.page.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.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/download.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/stack.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class BottomGalleryBar extends ConsumerWidget {
final ValueNotifier<int> assetIndex;
final bool showStack;
final ValueNotifier<int> stackIndex;
final ValueNotifier<int> totalAssets;
final PageController controller;
final RenderList renderList;
const BottomGalleryBar({
super.key,
required this.showStack,
required this.stackIndex,
required this.assetIndex,
required this.controller,
required this.totalAssets,
required this.renderList,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isInLockedView = ref.watch(inLockedViewProvider);
final asset = ref.watch(currentAssetProvider);
if (asset == null) {
return const SizedBox();
}
final isOwner = asset.ownerId == fastHash(ref.watch(currentUserProvider)?.id ?? '');
final showControls = ref.watch(showControlsProvider);
final stackId = asset.stackId;
final stackItems = showStack && stackId != null ? ref.watch(assetStackStateProvider(stackId)) : <Asset>[];
bool isStackPrimaryAsset = asset.stackPrimaryAssetId == null;
final navStack = AutoRouter.of(context).stackData;
final isTrashEnabled = ref.watch(serverInfoProvider.select((v) => v.serverFeatures.trash));
final isFromTrash =
isTrashEnabled && navStack.length > 2 && navStack.elementAt(navStack.length - 2).name == TrashRoute.name;
final isInAlbum = ref.watch(currentAlbumProvider)?.isRemote ?? false;
void removeAssetFromStack() {
if (stackIndex.value > 0 && showStack && stackId != null) {
ref.read(assetStackStateProvider(stackId).notifier).removeChild(stackIndex.value - 1);
}
}
void handleDelete() async {
Future<bool> onDelete(bool force) async {
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets({asset}, force: force);
if (isDeleted && isStackPrimaryAsset) {
// Workaround for asset remaining in the gallery
renderList.deleteAsset(asset);
// `assetIndex == totalAssets.value - 1` handle the case of removing the last asset
// to not throw the error when the next preCache index is called
if (totalAssets.value == 1 || assetIndex.value == totalAssets.value - 1) {
// Handle only one asset
await context.maybePop();
}
totalAssets.value -= 1;
}
if (isDeleted) {
ref.read(currentAssetProvider.notifier).set(renderList.loadAsset(assetIndex.value));
}
return isDeleted;
}
// Asset is trashed
if (isTrashEnabled && !isFromTrash) {
final isDeleted = await onDelete(false);
if (isDeleted) {
// Can only trash assets stored in server. Local assets are always permanently removed for now
if (context.mounted && asset.isRemote && isStackPrimaryAsset) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'asset_trashed'.tr(),
gravity: ToastGravity.BOTTOM,
);
}
removeAssetFromStack();
}
return;
}
// Asset is permanently removed
unawaited(
showDialog(
context: context,
builder: (BuildContext _) {
return DeleteDialog(
onDelete: () async {
final isDeleted = await onDelete(true);
if (isDeleted) {
removeAssetFromStack();
}
},
);
},
),
);
}
unStack() async {
if (asset.stackId == null) {
return;
}
await ref.read(stackServiceProvider).deleteStack(asset.stackId!, stackItems);
}
void showStackActionItems() {
showModalBottomSheet<void>(
context: context,
enableDrag: false,
builder: (BuildContext ctx) {
return SafeArea(
child: Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
ListTile(
leading: const Icon(Icons.filter_none_outlined, size: 18),
onTap: () async {
await unStack();
ctx.pop();
await context.maybePop();
},
title: const Text("viewer_unstack", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
),
],
),
),
);
},
);
}
shareAsset() {
if (asset.isOffline) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'asset_action_share_err_offline'.tr(),
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.read(downloadStateProvider.notifier).shareAsset(asset, context);
}
void handleEdit() async {
final image = Image(image: ImmichImage.imageProvider(asset: asset));
unawaited(
context.navigator.push(
MaterialPageRoute(
builder: (context) => EditImagePage(asset: asset, image: image, isEdited: false),
),
),
);
}
handleArchive() {
ref.read(assetProvider.notifier).toggleArchive([asset]);
if (isStackPrimaryAsset) {
context.maybePop();
return;
}
removeAssetFromStack();
}
handleDownload() {
if (asset.isLocal) {
return;
}
if (asset.isOffline) {
ImmichToast.show(
durationInSecond: 1,
context: context,
msg: 'asset_action_share_err_offline'.tr(),
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.read(downloadStateProvider.notifier).downloadAsset(asset);
}
handleRemoveFromAlbum() async {
final album = ref.read(currentAlbumProvider);
final bool isSuccess = album != null && await ref.read(albumProvider.notifier).removeAsset(album, [asset]);
if (isSuccess) {
// Workaround for asset remaining in the gallery
renderList.deleteAsset(asset);
if (totalAssets.value == 1) {
// Handle empty viewer
await context.maybePop();
} else {
// changing this also for the last asset causes the parent to rebuild with an error
totalAssets.value -= 1;
}
if (assetIndex.value == totalAssets.value && assetIndex.value > 0) {
// handle the case of removing the last asset in the list
assetIndex.value -= 1;
}
} else {
ImmichToast.show(
context: context,
msg: "album_viewer_appbar_share_err_remove".tr(),
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
}
}
final List<Map<BottomNavigationBarItem, Function(int)>> albumActions = [
{
BottomNavigationBarItem(
icon: Icon(Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded),
label: 'share'.tr(),
tooltip: 'share'.tr(),
): (_) =>
shareAsset(),
},
if (asset.isImage && !isInLockedView)
{
BottomNavigationBarItem(
icon: const Icon(Icons.tune_outlined),
label: 'edit'.tr(),
tooltip: 'edit'.tr(),
): (_) =>
handleEdit(),
},
if (isOwner && !isInLockedView)
{
asset.isArchived
? BottomNavigationBarItem(
icon: const Icon(Icons.unarchive_rounded),
label: 'unarchive'.tr(),
tooltip: 'unarchive'.tr(),
)
: BottomNavigationBarItem(
icon: const Icon(Icons.archive_outlined),
label: 'archive'.tr(),
tooltip: 'archive'.tr(),
): (_) =>
handleArchive(),
},
if (isOwner && asset.stackCount > 0 && !isInLockedView)
{
BottomNavigationBarItem(
icon: const Icon(Icons.burst_mode_outlined),
label: 'stack'.tr(),
tooltip: 'stack'.tr(),
): (_) =>
showStackActionItems(),
},
if (isOwner && !isInAlbum)
{
BottomNavigationBarItem(
icon: const Icon(Icons.delete_outline),
label: 'delete'.tr(),
tooltip: 'delete'.tr(),
): (_) =>
handleDelete(),
},
if (!isOwner)
{
BottomNavigationBarItem(
icon: const Icon(Icons.download_outlined),
label: 'download'.tr(),
tooltip: 'download'.tr(),
): (_) =>
handleDownload(),
},
if (isInAlbum)
{
BottomNavigationBarItem(
icon: const Icon(Icons.remove_circle_outline),
label: 'remove_from_album'.tr(),
tooltip: 'remove_from_album'.tr(),
): (_) =>
handleRemoveFromAlbum(),
},
];
return IgnorePointer(
ignoring: !showControls,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: showControls ? 1.0 : 0.0,
child: DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.bottomCenter,
end: Alignment.topCenter,
colors: [Colors.black, Colors.transparent],
),
),
position: DecorationPosition.background,
child: Padding(
padding: const EdgeInsets.only(top: 40.0),
child: Column(
children: [
if (asset.isVideo) VideoControls(videoPlayerName: asset.id.toString()),
BottomNavigationBar(
elevation: 0.0,
backgroundColor: Colors.transparent,
unselectedIconTheme: const IconThemeData(color: Colors.white),
selectedIconTheme: const IconThemeData(color: Colors.white),
unselectedLabelStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500, height: 2.3),
selectedLabelStyle: const TextStyle(color: Colors.white, fontWeight: FontWeight.w500, height: 2.3),
unselectedFontSize: 14,
selectedFontSize: 14,
selectedItemColor: Colors.white,
unselectedItemColor: Colors.white,
showSelectedLabels: true,
showUnselectedLabels: true,
items: albumActions.map((e) => e.keys.first).toList(growable: false),
onTap: (index) {
albumActions[index].values.first.call(index);
},
),
],
),
),
),
),
);
}
}
@@ -1,44 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/asset_viewer/animated_play_pause.dart';
class CenterPlayButton extends StatelessWidget {
const CenterPlayButton({
super.key,
required this.backgroundColor,
this.iconColor,
required this.show,
required this.isPlaying,
required this.isFinished,
this.onPressed,
});
final Color backgroundColor;
final Color? iconColor;
final bool show;
final bool isPlaying;
final bool isFinished;
final VoidCallback? onPressed;
@override
Widget build(BuildContext context) {
return Center(
child: UnconstrainedBox(
child: AnimatedOpacity(
opacity: show ? 1.0 : 0.0,
duration: const Duration(milliseconds: 100),
child: DecoratedBox(
decoration: BoxDecoration(color: backgroundColor, shape: BoxShape.circle),
child: IconButton(
iconSize: 32,
padding: const EdgeInsets.all(12.0),
icon: isFinished
? Icon(Icons.replay, color: iconColor)
: AnimatedPlayPause(color: iconColor, playing: isPlaying),
onPressed: onPressed,
),
),
),
),
);
}
}
@@ -1,114 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/cast/cast_manager_state.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/asset_viewer/video_player_provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/utils/hooks/timer_hook.dart';
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
class CustomVideoPlayerControls extends HookConsumerWidget {
final String videoId;
final Duration hideTimerDuration;
const CustomVideoPlayerControls({
super.key,
required this.videoId,
this.hideTimerDuration = const Duration(seconds: 5),
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetIsVideo = ref.watch(currentAssetProvider.select((asset) => asset != null && asset.isVideo));
final showControls = ref.watch(showControlsProvider);
final status = ref.watch(videoPlayerProvider(videoId).select((value) => value.status));
final cast = ref.watch(castProvider);
// A timer to hide the controls
final hideTimer = useTimer(hideTimerDuration, () {
if (!context.mounted) {
return;
}
final s = ref.read(videoPlayerProvider(videoId)).status;
// Do not hide on paused
if (s != VideoPlaybackStatus.paused && s != VideoPlaybackStatus.completed && assetIsVideo) {
ref.read(showControlsProvider.notifier).show = false;
}
});
final showBuffering = status == VideoPlaybackStatus.buffering && !cast.isCasting;
/// Shows the controls and starts the timer to hide them
void showControlsAndStartHideTimer() {
hideTimer.reset();
ref.read(showControlsProvider.notifier).show = true;
}
// When playback starts, reset the hide timer
ref.listen(videoPlayerProvider(videoId).select((v) => v.status), (previous, next) {
if (next == VideoPlaybackStatus.playing) {
hideTimer.reset();
}
});
/// Toggles between playing and pausing depending on the state of the video
void togglePlay() {
showControlsAndStartHideTimer();
if (cast.isCasting) {
if (cast.castState == CastState.playing) {
ref.read(castProvider.notifier).pause();
} else if (cast.castState == CastState.paused) {
ref.read(castProvider.notifier).play();
} else if (cast.castState == CastState.idle) {
// resend the play command since its finished
final asset = ref.read(currentAssetProvider);
if (asset == null) {
return;
}
ref.read(castProvider.notifier).loadMediaOld(asset, true);
}
return;
}
final notifier = ref.read(videoPlayerProvider(videoId).notifier);
if (status == VideoPlaybackStatus.playing) {
notifier.pause();
} else if (status == VideoPlaybackStatus.completed) {
notifier.restart();
} else {
notifier.play();
}
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showControlsAndStartHideTimer,
child: AbsorbPointer(
absorbing: !showControls,
child: Stack(
children: [
if (showBuffering)
const Center(child: DelayedLoadingIndicator(fadeInDuration: Duration(milliseconds: 400)))
else
GestureDetector(
onTap: () => ref.read(showControlsProvider.notifier).show = false,
child: CenterPlayButton(
backgroundColor: Colors.black54,
iconColor: Colors.white,
isFinished: status == VideoPlaybackStatus.completed,
isPlaying:
status == VideoPlaybackStatus.playing || (cast.isCasting && cast.castState == CastState.playing),
show: assetIsVideo && showControls,
onPressed: togglePlay,
),
),
],
),
),
);
}
}
@@ -1,106 +0,0 @@
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/exif.model.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/providers/asset.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'package:logging/logging.dart';
class DescriptionInput extends HookConsumerWidget {
DescriptionInput({super.key, required this.asset, this.exifInfo});
final Asset asset;
final ExifInfo? exifInfo;
final Logger _log = Logger('DescriptionInput');
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController();
final focusNode = useFocusNode();
final isFocus = useState(false);
final isTextEmpty = useState(controller.text.isEmpty);
final assetService = ref.watch(assetServiceProvider);
final owner = ref.watch(currentUserProvider);
final hasError = useState(false);
final assetWithExif = ref.watch(assetDetailProvider(asset));
final hasDescription = useState(false);
final isOwner = fastHash(owner?.id ?? '') == asset.ownerId;
useEffect(() {
assetService.getDescription(asset).then((value) {
controller.text = value;
hasDescription.value = value.isNotEmpty;
});
return null;
}, [assetWithExif.value]);
if (!isOwner && !hasDescription.value) {
return const SizedBox.shrink();
}
submitDescription(String description) async {
hasError.value = false;
try {
await assetService.setDescription(asset, description);
controller.text = description;
} catch (error, stack) {
hasError.value = true;
_log.severe("Error updating description", error, stack);
ImmichToast.show(context: context, msg: "description_input_submit_error".tr(), toastType: ToastType.error);
}
}
Widget? suffixIcon;
if (hasError.value) {
suffixIcon = const Icon(Icons.warning_outlined);
} else if (!isTextEmpty.value && isFocus.value) {
suffixIcon = IconButton(
onPressed: () {
controller.clear();
isTextEmpty.value = true;
},
icon: Icon(Icons.cancel_rounded, color: context.colorScheme.onSurfaceSecondary),
splashRadius: 10,
);
}
return TextField(
enabled: isOwner,
focusNode: focusNode,
onTap: () => isFocus.value = true,
onChanged: (value) {
isTextEmpty.value = false;
},
onTapOutside: (a) async {
isFocus.value = false;
focusNode.unfocus();
if (exifInfo?.description != controller.text) {
await submitDescription(controller.text);
}
},
autofocus: false,
maxLines: null,
keyboardType: TextInputType.multiline,
controller: controller,
style: context.textTheme.labelLarge,
decoration: InputDecoration(
hintText: 'description_input_hint_text'.tr(),
border: InputBorder.none,
suffixIcon: suffixIcon,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
disabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
focusedErrorBorder: InputBorder.none,
),
);
}
}
@@ -1,44 +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/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/asset_extensions.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
class AssetDateTime extends ConsumerWidget {
final Asset asset;
const AssetDateTime({super.key, required this.asset});
String getDateTimeString(Asset a) {
final (deltaTime, timeZone) = a.getTZAdjustedTimeAndOffset();
final date = DateFormat.yMMMEd().format(deltaTime);
final time = DateFormat.jm().format(deltaTime);
return '$date$time GMT${timeZone.formatAsOffset()}';
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final watchedAsset = ref.watch(assetDetailProvider(asset));
String formattedDateTime = getDateTimeString(asset);
void editDateTime() async {
await handleEditDateTime(ref, context, [asset]);
if (watchedAsset.value != null) {
formattedDateTime = getDateTimeString(watchedAsset.value!);
}
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(formattedDateTime, style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600)),
if (asset.isRemote) IconButton(onPressed: editDateTime, icon: const Icon(Icons.edit_outlined), iconSize: 20),
],
);
}
}
@@ -1,40 +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/domain/models/exif.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/camera_info.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/file_info.dart';
class AssetDetails extends ConsumerWidget {
final Asset asset;
final ExifInfo? exifInfo;
const AssetDetails({super.key, required this.asset, this.exifInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetWithExif = ref.watch(assetDetailProvider(asset));
final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo;
return Padding(
padding: const EdgeInsets.only(top: 24.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"exif_bottom_sheet_details",
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
FileInfo(asset: asset),
if (exifInfo?.make != null) CameraInfo(exifInfo: exifInfo!),
],
),
);
}
}
@@ -1,88 +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/domain/models/exif.model.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/exif_map.dart';
class AssetLocation extends HookConsumerWidget {
final Asset asset;
const AssetLocation({super.key, required this.asset});
@override
Widget build(BuildContext context, WidgetRef ref) {
final assetWithExif = ref.watch(assetDetailProvider(asset));
final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo;
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
void editLocation() {
handleEditLocation(ref, context, [assetWithExif.value ?? asset]);
}
// Guard no lat/lng
if (!hasCoordinates) {
return asset.isRemote
? ListTile(
minLeadingWidth: 0,
contentPadding: const EdgeInsets.all(0),
leading: const Icon(Icons.location_on),
title: Text(
"add_a_location",
style: context.textTheme.bodyMedium?.copyWith(fontWeight: FontWeight.w600, color: context.primaryColor),
).tr(),
onTap: editLocation,
)
: const SizedBox.shrink();
}
Widget getLocationName() {
if (exifInfo == null) {
return const SizedBox.shrink();
}
final cityName = exifInfo.city;
final stateName = exifInfo.state;
bool hasLocationName = (cityName != null && stateName != null);
return hasLocationName
? Text("$cityName, $stateName", style: context.textTheme.labelLarge)
: const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
"exif_bottom_sheet_location",
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
if (asset.isRemote)
IconButton(onPressed: editLocation, icon: const Icon(Icons.edit_outlined), iconSize: 20),
],
),
asset.isRemote ? const SizedBox.shrink() : const SizedBox(height: 16),
ExifMap(exifInfo: exifInfo!, markerId: asset.remoteId, markerAssetThumbhash: asset.thumbhash),
const SizedBox(height: 16),
getLocationName(),
Text(
"${exifInfo.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
style: context.textTheme.labelMedium?.copyWith(color: context.textTheme.labelMedium?.color?.withAlpha(150)),
),
],
),
);
}
}
@@ -1,26 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class CameraInfo extends StatelessWidget {
final ExifInfo exifInfo;
const CameraInfo({super.key, required this.exifInfo});
@override
Widget build(BuildContext context) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
return ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: Icon(Icons.camera, color: textColor.withAlpha(200)),
title: Text("${exifInfo.make} ${exifInfo.model}", style: context.textTheme.labelLarge),
subtitle: exifInfo.f != null || exifInfo.exposureSeconds != null || exifInfo.mm != null || exifInfo.iso != null
? Text(
"ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO ${exifInfo.iso ?? ''} ",
style: context.textTheme.bodySmall,
)
: null,
);
}
}
@@ -1,37 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/widgets/asset_viewer/description_input.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/asset_date_time.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/asset_details.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/asset_location.dart';
import 'package:immich_mobile/widgets/asset_viewer/detail_panel/people_info.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
class DetailPanel extends HookConsumerWidget {
final Asset asset;
final ScrollController? scrollController;
const DetailPanel({super.key, required this.asset, this.scrollController});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListView(
controller: scrollController,
shrinkWrap: true,
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: Column(
children: [
AssetDateTime(asset: asset),
asset.isRemote ? DescriptionInput(asset: asset) : const SizedBox.shrink(),
PeopleInfo(asset: asset),
AssetLocation(asset: asset),
AssetDetails(asset: asset),
],
),
),
],
);
}
}
@@ -1,47 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
class FileInfo extends StatelessWidget {
final Asset asset;
const FileInfo({super.key, required this.asset});
@override
Widget build(BuildContext context) {
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
final height = asset.orientatedHeight ?? asset.height;
final width = asset.orientatedWidth ?? asset.width;
String resolution = height != null && width != null ? "$width x $height " : "";
String fileSize = asset.exifInfo?.fileSize != null ? formatBytes(asset.exifInfo!.fileSize!) : "";
String text = resolution + fileSize;
final imgSizeString = text.isNotEmpty ? text : null;
String? title;
String? subtitle;
if (imgSizeString == null && asset.fileName.isNotEmpty) {
// There is only filename
title = asset.fileName;
} else if (imgSizeString != null && asset.fileName.isNotEmpty) {
// There is both filename and size information
title = asset.fileName;
subtitle = imgSizeString;
} else if (imgSizeString != null && asset.fileName.isEmpty) {
title = imgSizeString;
} else {
return const SizedBox.shrink();
}
return ListTile(
contentPadding: const EdgeInsets.all(0),
dense: true,
leading: Icon(Icons.image, color: textColor.withAlpha(200)),
titleAlignment: ListTileTitleAlignment.center,
title: Text(title, style: context.textTheme.labelLarge),
subtitle: subtitle == null ? null : Text(subtitle),
);
}
}
@@ -1,91 +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/entities/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/providers/asset_viewer/asset_people.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/people.utils.dart';
import 'package:immich_mobile/widgets/search/curated_people_row.dart';
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
class PeopleInfo extends ConsumerWidget {
final Asset asset;
final EdgeInsets? padding;
const PeopleInfo({super.key, required this.asset, this.padding});
@override
Widget build(BuildContext context, WidgetRef ref) {
final peopleProvider = ref.watch(assetPeopleNotifierProvider(asset).notifier);
final people = ref.watch(assetPeopleNotifierProvider(asset)).value?.where((p) => !p.isHidden);
showPersonNameEditModel(String personId, String personName) {
return showDialog(
context: context,
useRootNavigator: false,
builder: (BuildContext context) {
return PersonNameEditForm(personId: personId, personName: personName);
},
).then((_) {
// ensure the people list is up-to-date.
peopleProvider.refresh();
});
}
final curatedPeople =
people
?.map(
(p) => SearchCuratedContent(
id: p.id,
label: p.name,
subtitle: p.birthDate != null && p.birthDate!.isBefore(asset.fileCreatedAt)
? formatAge(p.birthDate!, asset.fileCreatedAt)
: null,
),
)
.toList() ??
[];
return AnimatedCrossFade(
crossFadeState: (people?.isEmpty ?? true) ? CrossFadeState.showFirst : CrossFadeState.showSecond,
duration: const Duration(milliseconds: 200),
firstChild: Container(),
secondChild: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
children: [
Padding(
padding: padding ?? EdgeInsets.zero,
child: Align(
alignment: Alignment.topLeft,
child: Text(
"exif_bottom_sheet_people",
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
).tr(),
),
),
Padding(
padding: const EdgeInsets.only(top: 16.0),
child: CuratedPeopleRow(
padding: padding,
content: curatedPeople,
onTap: (content, index) {
context
.pushRoute(PersonResultRoute(personId: content.id, personName: content.label))
.then((_) => peopleProvider.refresh());
},
onNameTap: (person, index) => {showPersonNameEditModel(person.id, person.label)},
),
),
],
),
),
);
}
}
@@ -1,119 +0,0 @@
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/providers/album/current_album.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/partner.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/trash.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
import 'package:immich_mobile/widgets/asset_grid/upload_dialog.dart';
import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class GalleryAppBar extends ConsumerWidget {
final void Function() showInfo;
const GalleryAppBar({super.key, required this.showInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final asset = ref.watch(currentAssetProvider);
if (asset == null) {
return const SizedBox();
}
final album = ref.watch(currentAlbumProvider);
final isOwner = asset.ownerId == fastHash(ref.watch(currentUserProvider)?.id ?? '');
final showControls = ref.watch(showControlsProvider);
final isPartner = ref.watch(partnerSharedWithProvider).map((e) => fastHash(e.id)).contains(asset.ownerId);
toggleFavorite(Asset asset) => ref.read(assetProvider.notifier).toggleFavorite([asset]);
handleActivities() {
if (album != null && album.shared && album.remoteId != null) {
context.pushRoute(const ActivitiesRoute());
}
}
handleRestore(Asset asset) async {
final result = await ref.read(trashProvider.notifier).restoreAssets([asset]);
if (result && context.mounted) {
ImmichToast.show(context: context, msg: 'asset_restored_successfully'.tr(), gravity: ToastGravity.BOTTOM);
}
}
handleUpload(Asset asset) {
showDialog(
context: context,
builder: (BuildContext _) {
return UploadDialog(
onUpload: () {
ref.read(manualUploadProvider.notifier).uploadAssets(context, [asset]);
},
);
},
);
}
addToAlbum(Asset addToAlbumAsset) {
showModalBottomSheet(
elevation: 0,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(15.0))),
context: context,
builder: (BuildContext _) {
return AddToAlbumBottomSheet(assets: [addToAlbumAsset]);
},
);
}
handleDownloadAsset() {
ref.read(downloadStateProvider.notifier).downloadAsset(asset);
}
handleLocateAsset() async {
// Go back to the gallery
await context.maybePop();
await context.navigateTo(const TabControllerRoute(children: [PhotosRoute()]));
ref.read(tabProvider.notifier).update((state) => state = TabEnum.home);
// Scroll to the asset's date
scrollToDateNotifierProvider.scrollToDate(asset.fileCreatedAt);
}
return IgnorePointer(
ignoring: !showControls,
child: AnimatedOpacity(
duration: const Duration(milliseconds: 100),
opacity: showControls ? 1.0 : 0.0,
child: Container(
color: Colors.black.withValues(alpha: 0.4),
child: TopControlAppBar(
isOwner: isOwner,
isPartner: isPartner,
asset: asset,
onMoreInfoPressed: showInfo,
onLocatePressed: handleLocateAsset,
onFavorite: toggleFavorite,
onRestorePressed: () => handleRestore(asset),
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
onDownloadPressed: asset.isLocal ? null : handleDownloadAsset,
onAddToAlbumPressed: () => addToAlbum(asset),
onActivitiesPressed: handleActivities,
),
),
),
);
}
}
@@ -1,22 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/colors.dart';
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
class MotionPhotoButton extends ConsumerWidget {
const MotionPhotoButton({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isPlaying = ref.watch(isPlayingMotionVideoProvider);
return IconButton(
onPressed: () {
ref.read(isPlayingMotionVideoProvider.notifier).toggle();
},
icon: isPlaying
? const Icon(Icons.motion_photos_pause_outlined, color: grey200)
: const Icon(Icons.play_circle_outline_rounded, color: grey200),
);
}
}
@@ -1,182 +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/providers/activity_statistics.provider.dart';
import 'package:immich_mobile/providers/album/current_album.provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/tab.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
import 'package:immich_mobile/widgets/asset_viewer/motion_photo_button.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
class TopControlAppBar extends HookConsumerWidget {
const TopControlAppBar({
super.key,
required this.asset,
required this.onMoreInfoPressed,
required this.onDownloadPressed,
required this.onLocatePressed,
required this.onAddToAlbumPressed,
required this.onRestorePressed,
required this.onFavorite,
required this.onUploadPressed,
required this.isOwner,
required this.onActivitiesPressed,
required this.isPartner,
});
final Asset asset;
final Function onMoreInfoPressed;
final VoidCallback? onUploadPressed;
final VoidCallback? onDownloadPressed;
final VoidCallback onLocatePressed;
final VoidCallback onAddToAlbumPressed;
final VoidCallback onRestorePressed;
final VoidCallback onActivitiesPressed;
final Function(Asset) onFavorite;
final bool isOwner;
final bool isPartner;
@override
Widget build(BuildContext context, WidgetRef ref) {
final isInLockedView = ref.watch(inLockedViewProvider);
const double iconSize = 22.0;
final a = ref.watch(assetWatcher(asset)).value ?? asset;
final album = ref.watch(currentAlbumProvider);
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
final websocketConnected = ref.watch(websocketProvider.select((c) => c.isConnected));
final comments = album != null && album.remoteId != null && asset.remoteId != null
? ref.watch(activityStatisticsProvider(album.remoteId!, asset.remoteId))
: 0;
Widget buildFavoriteButton(a) {
return IconButton(
onPressed: () => onFavorite(a),
icon: Icon(a.isFavorite ? Icons.favorite : Icons.favorite_border, color: Colors.grey[200]),
);
}
Widget buildLocateButton() {
return IconButton(
onPressed: () {
onLocatePressed();
},
icon: Icon(Icons.image_search, color: Colors.grey[200]),
);
}
Widget buildMoreInfoButton() {
return IconButton(
onPressed: () {
onMoreInfoPressed();
},
icon: Icon(Icons.info_outline_rounded, color: Colors.grey[200]),
);
}
Widget buildDownloadButton() {
return IconButton(
onPressed: onDownloadPressed,
icon: Icon(Icons.cloud_download_outlined, color: Colors.grey[200]),
);
}
Widget buildAddToAlbumButton() {
return IconButton(
onPressed: () {
onAddToAlbumPressed();
},
icon: Icon(Icons.add, color: Colors.grey[200]),
);
}
Widget buildRestoreButton() {
return IconButton(
onPressed: () {
onRestorePressed();
},
icon: Icon(Icons.history_rounded, color: Colors.grey[200]),
);
}
Widget buildActivitiesButton() {
return IconButton(
onPressed: () {
onActivitiesPressed();
},
icon: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.mode_comment_outlined, color: Colors.grey[200]),
if (comments != 0)
Padding(
padding: const EdgeInsets.only(left: 5),
child: Text(
comments.toString(),
style: TextStyle(fontWeight: FontWeight.bold, color: Colors.grey[200]),
),
),
],
),
);
}
Widget buildUploadButton() {
return IconButton(
onPressed: onUploadPressed,
icon: Icon(Icons.backup_outlined, color: Colors.grey[200]),
);
}
Widget buildBackButton() {
return IconButton(
onPressed: () {
context.maybePop();
},
icon: Icon(Icons.arrow_back_ios_new_rounded, size: 20.0, color: Colors.grey[200]),
);
}
Widget buildCastButton() {
return IconButton(
onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog());
},
icon: Icon(
isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded,
size: 20.0,
color: isCasting ? context.primaryColor : Colors.grey[200],
),
);
}
bool isInHomePage = ref.read(tabProvider.notifier).state == TabEnum.home;
bool? isInTrash = ref.read(currentAssetProvider)?.isTrashed;
return AppBar(
foregroundColor: Colors.grey[100],
backgroundColor: Colors.transparent,
leading: buildBackButton(),
actionsIconTheme: const IconThemeData(size: iconSize),
shape: const Border(),
actions: [
if (asset.isRemote && isOwner) buildFavoriteButton(a),
if (isOwner && !isInHomePage && !(isInTrash ?? false) && !isInLockedView) buildLocateButton(),
if (asset.livePhotoVideoId != null) const MotionPhotoButton(),
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed && !isInLockedView) buildAddToAlbumButton(),
if (isCasting || (asset.isRemote && websocketConnected)) buildCastButton(),
if (asset.isTrashed) buildRestoreButton(),
if (album != null && album.shared && !isInLockedView) buildActivitiesButton(),
buildMoreInfoButton(),
],
);
}
}
@@ -1,185 +0,0 @@
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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/available_album.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class AlbumInfoCard extends HookConsumerWidget {
final AvailableAlbum album;
const AlbumInfoCard({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected = ref.watch(backupProvider).selectedBackupAlbums.contains(album);
final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(album);
final syncAlbum = ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
final isDarkTheme = context.isDarkTheme;
ColorFilter selectedFilter = ColorFilter.mode(context.primaryColor.withAlpha(100), BlendMode.darken);
ColorFilter excludedFilter = ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
ColorFilter unselectedFilter = const ColorFilter.mode(Colors.black, BlendMode.color);
buildSelectedTextBox() {
if (isSelected) {
return Chip(
visualDensity: VisualDensity.compact,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
label: Text(
"album_info_card_backup_album_included",
style: TextStyle(
fontSize: 10,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
backgroundColor: context.primaryColor,
);
} else if (isExcluded) {
return Chip(
visualDensity: VisualDensity.compact,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
label: Text(
"album_info_card_backup_album_excluded",
style: TextStyle(
fontSize: 10,
color: isDarkTheme ? Colors.black : Colors.white,
fontWeight: FontWeight.bold,
),
).tr(),
backgroundColor: Colors.red[300],
);
}
return const SizedBox();
}
buildImageFilter() {
if (isSelected) {
return selectedFilter;
} else if (isExcluded) {
return excludedFilter;
} else {
return unselectedFilter;
}
}
return GestureDetector(
onTap: () {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
if (isSelected) {
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
} else {
ref.read(backupProvider.notifier).addAlbumForBackup(album);
if (syncAlbum) {
ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
}
},
onDoubleTap: () {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
if (isExcluded) {
// Remove from exclude album list
ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
} else {
// Add to exclude album list
if (album.id == 'isAll' || album.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
}
},
child: Card(
clipBehavior: Clip.hardEdge,
margin: const EdgeInsets.all(1),
shape: RoundedRectangleBorder(
borderRadius: const BorderRadius.all(
Radius.circular(12), // if you need this
),
side: BorderSide(
color: isDarkTheme ? const Color.fromARGB(255, 37, 35, 35) : const Color(0xFFC9C9C9),
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: Stack(
clipBehavior: Clip.hardEdge,
children: [
ColorFiltered(
colorFilter: buildImageFilter(),
child: const Image(
width: double.infinity,
height: double.infinity,
image: AssetImage('assets/immich-logo.png'),
fit: BoxFit.cover,
),
),
Positioned(bottom: 10, right: 25, child: buildSelectedTextBox()),
],
),
),
Padding(
padding: const EdgeInsets.only(left: 25),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
album.name,
style: TextStyle(fontSize: 14, color: context.primaryColor, fontWeight: FontWeight.bold),
),
Padding(
padding: const EdgeInsets.only(top: 2.0),
child: Text(
album.assetCount.toString() + (album.isAll ? " (${'all'.tr()})" : ""),
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
),
],
),
),
IconButton(
onPressed: () {
context.pushRoute(AlbumPreviewRoute(album: album.album));
},
icon: Icon(Icons.image_outlined, color: context.primaryColor, size: 24),
splashRadius: 25,
),
],
),
),
],
),
),
);
}
}
@@ -1,98 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.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/backup/available_album.model.dart';
import 'package:immich_mobile/providers/album/album.provider.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
class AlbumInfoListTile extends HookConsumerWidget {
final AvailableAlbum album;
const AlbumInfoListTile({super.key, required this.album});
@override
Widget build(BuildContext context, WidgetRef ref) {
final bool isSelected = ref.watch(backupProvider).selectedBackupAlbums.contains(album);
final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(album);
final syncAlbum = ref.watch(appSettingsServiceProvider).getSetting(AppSettingsEnum.syncAlbums);
buildTileColor() {
if (isSelected) {
return context.isDarkTheme ? context.primaryColor.withAlpha(100) : context.primaryColor.withAlpha(25);
} else if (isExcluded) {
return context.isDarkTheme ? Colors.red[300]?.withAlpha(150) : Colors.red[100]?.withAlpha(150);
} else {
return Colors.transparent;
}
}
buildIcon() {
if (isSelected) {
return Icon(Icons.check_circle_rounded, color: context.colorScheme.primary);
}
if (isExcluded) {
return Icon(Icons.remove_circle_rounded, color: context.colorScheme.error);
}
return Icon(Icons.circle, color: context.colorScheme.surfaceContainerHighest);
}
return GestureDetector(
onDoubleTap: () {
ref.watch(hapticFeedbackProvider.notifier).selectionClick();
if (isExcluded) {
// Remove from exclude album list
ref.read(backupProvider.notifier).removeExcludedAlbumForBackup(album);
} else {
// Add to exclude album list
if (album.id == 'isAll' || album.name == 'Recents') {
ImmichToast.show(
context: context,
msg: 'Cannot exclude album contains all assets',
toastType: ToastType.error,
gravity: ToastGravity.BOTTOM,
);
return;
}
ref.read(backupProvider.notifier).addExcludedAlbumForBackup(album);
}
},
child: ListTile(
tileColor: buildTileColor(),
contentPadding: const EdgeInsets.symmetric(vertical: 8, horizontal: 16),
onTap: () {
ref.read(hapticFeedbackProvider.notifier).selectionClick();
if (isSelected) {
ref.read(backupProvider.notifier).removeAlbumForBackup(album);
} else {
ref.read(backupProvider.notifier).addAlbumForBackup(album);
if (syncAlbum) {
ref.read(albumProvider.notifier).createSyncAlbum(album.name);
}
}
},
leading: buildIcon(),
title: Text(album.name, style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
subtitle: Text(album.assetCount.toString()),
trailing: IconButton(
onPressed: () {
context.pushRoute(AlbumPreviewRoute(album: album.album));
},
icon: Icon(Icons.image_outlined, color: context.primaryColor, size: 24),
splashRadius: 25,
),
),
);
}
}
@@ -1,105 +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/extensions/theme_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/models/backup/current_upload_asset.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class BackupAssetInfoTable extends ConsumerWidget {
const BackupAssetInfoTable({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select((value) => value.backupProgress == BackUpProgressEnum.manualInProgress),
);
final isUploadInProgress = ref.watch(
backupProvider.select(
(value) =>
value.backupProgress == BackUpProgressEnum.inProgress ||
value.backupProgress == BackUpProgressEnum.inBackground ||
value.backupProgress == BackUpProgressEnum.manualInProgress,
),
);
final asset = isManualUpload
? ref.watch(manualUploadProvider.select((value) => value.currentUploadAsset))
: ref.watch(backupProvider.select((value) => value.currentUploadAsset));
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Table(
border: TableBorder.all(color: context.colorScheme.outlineVariant, width: 1),
children: [
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child:
Text(
'backup_controller_page_filename',
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(
namedArgs: isUploadInProgress
? {'filename': asset.fileName, 'size': asset.fileType.toLowerCase()}
: {'filename': "-", 'size': "-"},
),
),
),
],
),
TableRow(
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"backup_controller_page_created",
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(namedArgs: {'date': isUploadInProgress ? _getAssetCreationDate(asset) : "-"}),
),
),
],
),
TableRow(
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"backup_controller_page_id",
style: TextStyle(
color: context.colorScheme.onSurfaceSecondary,
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
).tr(namedArgs: {'id': isUploadInProgress ? asset.id : "-"}),
),
),
],
),
],
),
);
}
@pragma('vm:prefer-inline')
String _getAssetCreationDate(CurrentUploadAsset asset) {
return DateFormat.yMMMMd().format(asset.fileCreatedAt.toLocal());
}
}
@@ -1,37 +0,0 @@
import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/backup/asset_info_table.dart';
import 'package:immich_mobile/widgets/backup/error_chip.dart';
import 'package:immich_mobile/widgets/backup/icloud_download_progress_bar.dart';
import 'package:immich_mobile/widgets/backup/upload_progress_bar.dart';
import 'package:immich_mobile/widgets/backup/upload_stats.dart';
class CurrentUploadingAssetInfoBox extends StatelessWidget {
const CurrentUploadingAssetInfoBox({super.key});
@override
Widget build(BuildContext context) {
return ListTile(
isThreeLine: true,
leading: Icon(Icons.image_outlined, color: context.primaryColor, size: 30),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text("backup_controller_page_uploading_file_info", style: context.textTheme.titleSmall).tr(),
const BackupErrorChip(),
],
),
subtitle: Column(
children: [
if (Platform.isIOS) const IcloudDownloadProgressBar(),
const BackupUploadProgressBar(),
const BackupUploadStats(),
const BackupAssetInfoTable(),
],
),
);
}
}
-28
View File
@@ -1,28 +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/constants/colors.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/backup/error_chip_text.dart';
class BackupErrorChip extends ConsumerWidget {
const BackupErrorChip({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final hasErrors = ref.watch(errorBackupListProvider.select((value) => value.isNotEmpty));
if (!hasErrors) {
return const SizedBox();
}
return ActionChip(
avatar: const Icon(Icons.info, color: red400),
elevation: 1,
visualDensity: VisualDensity.compact,
label: const BackupErrorChipText(),
backgroundColor: Colors.white,
onPressed: () => context.pushRoute(const FailedBackupStatusRoute()),
);
}
}
@@ -1,22 +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/constants/colors.dart';
import 'package:immich_mobile/providers/backup/error_backup_list.provider.dart';
class BackupErrorChipText extends ConsumerWidget {
const BackupErrorChipText({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final count = ref.watch(errorBackupListProvider).length;
if (count == 0) {
return const SizedBox();
}
return const Text(
"backup_controller_page_failed",
style: TextStyle(color: red400, fontWeight: FontWeight.bold, fontSize: 11),
).tr(namedArgs: {'count': count.toString()});
}
}
@@ -1,43 +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/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class IcloudDownloadProgressBar extends ConsumerWidget {
const IcloudDownloadProgressBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select((value) => value.backupProgress == BackUpProgressEnum.manualInProgress),
);
final isIcloudAsset = isManualUpload
? ref.watch(manualUploadProvider.select((value) => value.currentUploadAsset.isIcloudAsset))
: ref.watch(backupProvider.select((value) => value.currentUploadAsset.isIcloudAsset));
if (!isIcloudAsset) {
return const SizedBox();
}
final iCloudDownloadProgress = ref.watch(backupProvider.select((value) => value.iCloudDownloadProgress));
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
SizedBox(width: 110, child: Text("iCloud Download", style: context.textTheme.labelSmall)),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: iCloudDownloadProgress / 100.0,
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
),
),
Text(" ${iCloudDownloadProgress ~/ 1}%", style: const TextStyle(fontSize: 12)),
],
),
);
}
}
@@ -1,49 +0,0 @@
import 'package:intl/intl.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/translate_extensions.dart';
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
/// This is a simple debug widget which should be removed later on when we are
/// more confident about background sync
class IosDebugInfoTile extends HookConsumerWidget {
final IOSBackgroundSettings settings;
const IosDebugInfoTile({super.key, required this.settings});
@override
Widget build(BuildContext context, WidgetRef ref) {
final fetch = settings.timeOfLastFetch;
final processing = settings.timeOfLastProcessing;
final processes = settings.numberOfBackgroundTasksQueued;
final String title;
if (processes == 0) {
title = 'ios_debug_info_no_processes_queued'.t(context: context);
} else {
title = 'ios_debug_info_processes_queued'.t(context: context, args: {'count': processes});
}
final df = DateFormat.yMd().add_jm();
final String subtitle;
if (fetch == null && processing == null) {
subtitle = 'ios_debug_info_no_sync_yet'.t(context: context);
} else if (fetch != null && processing == null) {
subtitle = 'ios_debug_info_fetch_ran_at'.t(context: context, args: {'dateTime': df.format(fetch)});
} else if (processing != null && fetch == null) {
subtitle = 'ios_debug_info_processing_ran_at'.t(context: context, args: {'dateTime': df.format(processing)});
} else {
final fetchOrProcessing = fetch!.isAfter(processing!) ? fetch : processing;
subtitle = 'ios_debug_info_last_sync_at'.t(context: context, args: {'dateTime': df.format(fetchOrProcessing)});
}
return ListTile(
title: Text(
title,
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: context.primaryColor),
),
subtitle: Text(subtitle, style: const TextStyle(fontSize: 14)),
leading: Icon(Icons.bug_report, color: context.primaryColor),
);
}
}
@@ -1,45 +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/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class BackupUploadProgressBar extends ConsumerWidget {
const BackupUploadProgressBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select((value) => value.backupProgress == BackUpProgressEnum.manualInProgress),
);
final isIcloudAsset = isManualUpload
? ref.watch(manualUploadProvider.select((value) => value.currentUploadAsset.isIcloudAsset))
: ref.watch(backupProvider.select((value) => value.currentUploadAsset.isIcloudAsset));
final uploadProgress = isManualUpload
? ref.watch(manualUploadProvider.select((value) => value.progressInPercentage))
: ref.watch(backupProvider.select((value) => value.progressInPercentage));
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Row(
children: [
if (isIcloudAsset) SizedBox(width: 110, child: Text("Immich Upload", style: context.textTheme.labelSmall)),
Expanded(
child: LinearProgressIndicator(
minHeight: 10.0,
value: uploadProgress / 100.0,
borderRadius: const BorderRadius.all(Radius.circular(10.0)),
),
),
Text(
" ${uploadProgress.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12, fontFamily: "GoogleSansCode"),
),
],
),
);
}
}
@@ -1,51 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
class BackupUploadStats extends ConsumerWidget {
const BackupUploadStats({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isManualUpload = ref.watch(
backupProvider.select((value) => value.backupProgress == BackUpProgressEnum.manualInProgress),
);
final uploadFileProgress = isManualUpload
? ref.watch(manualUploadProvider.select((value) => value.progressInFileSize))
: ref.watch(backupProvider.select((value) => value.progressInFileSize));
final uploadFileSpeed = isManualUpload
? ref.watch(manualUploadProvider.select((value) => value.progressInFileSpeed))
: ref.watch(backupProvider.select((value) => value.progressInFileSpeed));
return Padding(
padding: const EdgeInsets.only(top: 2.0, bottom: 2.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(uploadFileProgress, style: const TextStyle(fontSize: 10, fontFamily: "GoogleSansCode")),
Text(
_formatUploadFileSpeed(uploadFileSpeed),
style: const TextStyle(fontSize: 10, fontFamily: "GoogleSansCode"),
),
],
),
);
}
@pragma('vm:prefer-inline')
String _formatUploadFileSpeed(double uploadFileSpeed) {
if (uploadFileSpeed < 1024) {
return '${uploadFileSpeed.toStringAsFixed(2)} B/s';
} else if (uploadFileSpeed < 1024 * 1024) {
return '${(uploadFileSpeed / 1024).toStringAsFixed(2)} KB/s';
} else if (uploadFileSpeed < 1024 * 1024 * 1024) {
return '${(uploadFileSpeed / (1024 * 1024)).toStringAsFixed(2)} MB/s';
} else {
return '${(uploadFileSpeed / (1024 * 1024 * 1024)).toStringAsFixed(2)} GB/s';
}
}
}
@@ -5,18 +5,15 @@ 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/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/models/server_info/server_disk_info.model.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/providers/auth.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/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/pages/common/settings.page.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart';
@@ -32,7 +29,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
@override
Widget build(BuildContext context, WidgetRef ref) {
ref.watch(localeProvider);
BackUpState backupState = ref.watch(backupProvider);
ServerDiskInfo backupState = ref.watch(backupProvider);
final theme = context.themeData;
bool isHorizontal = !context.isMobile;
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
@@ -128,9 +125,6 @@ class ImmichAppBarDialog extends HookConsumerWidget {
isLoggingOut.value = true;
await ref.read(authProvider.notifier).logout().whenComplete(() => isLoggingOut.value = false);
ref.read(manualUploadProvider.notifier).cancelBackup();
ref.read(backupProvider.notifier).cancelBackup();
unawaited(ref.read(assetProvider.notifier).clearAllAssets());
ref.read(websocketProvider.notifier).disconnect();
unawaited(context.replaceRoute(const LoginRoute()));
},
@@ -146,9 +140,9 @@ class ImmichAppBarDialog extends HookConsumerWidget {
}
Widget buildStorageInformation() {
var percentage = backupState.serverInfo.diskUsagePercentage / 100;
var usedDiskSpace = backupState.serverInfo.diskUse;
var totalDiskSpace = backupState.serverInfo.diskSize;
var percentage = backupState.diskUsagePercentage / 100;
var usedDiskSpace = backupState.diskUse;
var totalDiskSpace = backupState.diskSize;
if (user != null && user.hasQuota) {
usedDiskSpace = formatBytes(user.quotaUsageInBytes);
@@ -275,7 +269,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
],
),
),
if (Store.isBetaTimelineEnabled && isReadonlyModeEnabled) buildReadonlyMessage(),
if (isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(),
buildFreeUpSpaceButton(),
buildSettingButton(),
@@ -4,7 +4,6 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
@@ -62,10 +61,6 @@ class AppBarProfileInfoBox extends HookConsumerWidget {
}
void toggleReadonlyMode() {
// read only mode is only supported int he beta experience
// TODO: remove this check when the beta UI goes stable
if (!Store.isBetaTimelineEnabled) return;
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
ref.read(readonlyModeProvider.notifier).toggleReadonlyMode();
-54
View File
@@ -1,54 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
class CustomDraggingHandle extends StatelessWidget {
const CustomDraggingHandle({super.key});
@override
Widget build(BuildContext context) {
return Container(
height: 4,
width: 30,
decoration: BoxDecoration(
color: context.themeData.dividerColor,
borderRadius: const BorderRadius.all(Radius.circular(20)),
),
);
}
}
class ControlBoxButton extends StatelessWidget {
const ControlBoxButton({super.key, required this.label, required this.iconData, this.onPressed, this.onLongPressed});
final String label;
final IconData iconData;
final void Function()? onPressed;
final void Function()? onLongPressed;
@override
Widget build(BuildContext context) {
final minWidth = context.isMobile ? MediaQuery.sizeOf(context).width / 4.5 : 75.0;
return MaterialButton(
padding: const EdgeInsets.all(10),
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(20))),
onPressed: onPressed,
onLongPress: onLongPressed,
minWidth: minWidth,
child: Column(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(iconData, size: 24),
const SizedBox(height: 8),
Text(
label,
style: const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400),
maxLines: 3,
textAlign: TextAlign.center,
),
],
),
);
}
}
@@ -1,170 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_svg/svg.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart';
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
final List<Widget>? actions;
final bool showUploadButton;
const ImmichAppBar({super.key, this.actions, this.showUploadButton = true});
@override
Widget build(BuildContext context, WidgetRef ref) {
final BackUpState backupState = ref.watch(backupProvider);
final bool isEnableAutoBackup = backupState.backgroundBackup || backupState.autoBackup;
final user = ref.watch(currentUserProvider);
final bool versionWarningPresent = ref.watch(versionWarningPresentProvider(user));
final isDarkTheme = context.isDarkTheme;
const widgetSize = 30.0;
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
buildProfileIndicator() {
return InkWell(
onTap: () =>
showDialog(context: context, useRootNavigator: false, builder: (ctx) => const ImmichAppBarDialog()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
label: Container(
decoration: BoxDecoration(color: Colors.black, borderRadius: BorderRadius.circular(widgetSize / 2)),
child: const Icon(Icons.info, color: Color.fromARGB(255, 243, 188, 106), size: widgetSize / 2),
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: versionWarningPresent,
offset: const Offset(-2, -12),
child: user == null
? const Icon(Icons.face_outlined, size: widgetSize)
: Semantics(
label: "logged_in_as".tr(namedArgs: {"user": user.name}),
child: UserCircleAvatar(size: 32, user: user),
),
),
);
}
getBackupBadgeIcon() {
final iconColor = isDarkTheme ? Colors.white : Colors.black;
if (isEnableAutoBackup) {
if (backupState.backupProgress == BackUpProgressEnum.inProgress) {
return Container(
padding: const EdgeInsets.all(3.5),
child: CircularProgressIndicator(
strokeWidth: 2,
strokeCap: StrokeCap.round,
valueColor: AlwaysStoppedAnimation<Color>(iconColor),
semanticsLabel: 'backup_controller_page_backup'.tr(),
),
);
} else if (backupState.backupProgress != BackUpProgressEnum.inBackground &&
backupState.backupProgress != BackUpProgressEnum.manualInProgress) {
return Icon(
Icons.check_outlined,
size: 9,
color: iconColor,
semanticLabel: 'backup_controller_page_backup'.tr(),
);
}
}
if (!isEnableAutoBackup) {
return Icon(
Icons.cloud_off_rounded,
size: 9,
color: iconColor,
semanticLabel: 'backup_controller_page_backup'.tr(),
);
}
}
buildBackupIndicator() {
final indicatorIcon = getBackupBadgeIcon();
final badgeBackground = context.colorScheme.surfaceContainer;
return InkWell(
onTap: () => context.pushRoute(const BackupControllerRoute()),
borderRadius: const BorderRadius.all(Radius.circular(12)),
child: Badge(
label: Container(
width: widgetSize / 2,
height: widgetSize / 2,
decoration: BoxDecoration(
color: badgeBackground,
border: Border.all(color: context.colorScheme.outline.withValues(alpha: .3)),
borderRadius: BorderRadius.circular(widgetSize / 2),
),
child: indicatorIcon,
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: indicatorIcon != null,
offset: const Offset(-2, -12),
child: Icon(Icons.backup_rounded, size: widgetSize, color: context.primaryColor),
),
);
}
return AppBar(
backgroundColor: context.themeData.appBarTheme.backgroundColor,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(5))),
automaticallyImplyLeading: false,
centerTitle: false,
title: Builder(
builder: (BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Padding(
padding: const EdgeInsets.only(top: 3.0),
child: SvgPicture.asset(
context.isDarkTheme ? 'assets/immich-logo-inline-dark.svg' : 'assets/immich-logo-inline-light.svg',
height: 40,
),
),
const Tooltip(
triggerMode: TooltipTriggerMode.tap,
showDuration: Duration(seconds: 4),
message:
"The old timeline is deprecated and will be removed in a future release. Kindly switch to the new timeline under Advanced Settings.",
child: Padding(
padding: EdgeInsets.only(top: 3.0),
child: Icon(Icons.error_rounded, fill: 1, color: Colors.amber, size: 20),
),
),
],
);
},
),
actions: [
if (actions != null)
...actions!.map((action) => Padding(padding: const EdgeInsets.only(right: 16), child: action)),
if (isCasting)
Padding(
padding: const EdgeInsets.only(right: 12),
child: IconButton(
onPressed: () {
showDialog(context: context, builder: (context) => const CastDialog());
},
icon: Icon(isCasting ? Icons.cast_connected_rounded : Icons.cast_rounded),
),
),
if (showUploadButton) Padding(padding: const EdgeInsets.only(right: 20), child: buildBackupIndicator()),
Padding(padding: const EdgeInsets.only(right: 20), child: buildProfileIndicator()),
],
);
}
}
-100
View File
@@ -1,100 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset;
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/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:octo_image/octo_image.dart';
class ImmichImage extends StatelessWidget {
const ImmichImage(
this.asset, {
this.width,
this.height,
this.fit = BoxFit.cover,
this.placeholder = const ThumbnailPlaceholder(),
super.key,
});
final Asset? asset;
final Widget? placeholder;
final double? width;
final double? height;
final BoxFit fit;
// Helper function to return the image provider for the asset
// either by using the asset ID or the asset itself
/// [asset] is the Asset to request, or else use [assetId] to get a remote
/// image provider
static ImageProvider imageProvider({Asset? asset, String? assetId, double width = 1080, double height = 1920}) {
if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId');
}
if (asset == null) {
return RemoteFullImageProvider(
assetId: assetId!,
thumbhash: '',
assetType: base_asset.AssetType.video,
isAnimated: false,
);
}
if (useLocal(asset)) {
return LocalFullImageProvider(
id: asset.localId!,
assetType: base_asset.AssetType.video,
size: Size(width, height),
isAnimated: false,
);
} else {
return RemoteFullImageProvider(
assetId: asset.remoteId!,
thumbhash: asset.thumbhash ?? '',
assetType: base_asset.AssetType.video,
isAnimated: false,
);
}
}
// Whether to use the local asset image provider or a remote one
static bool useLocal(Asset asset) =>
!asset.isRemote || asset.isLocal && !Store.get(StoreKey.preferRemoteImage, false);
@override
Widget build(BuildContext context) {
if (asset == null) {
return Container(
color: Colors.grey,
width: width,
height: height,
child: const Center(child: Icon(Icons.no_photography)),
);
}
final imageProviderInstance = ImmichImage.imageProvider(asset: asset, width: context.width, height: context.height);
return OctoImage(
fadeInDuration: const Duration(milliseconds: 0),
fadeOutDuration: const Duration(milliseconds: 100),
placeholderBuilder: (context) {
if (placeholder != null) {
return placeholder!;
}
return const SizedBox();
},
image: imageProviderInstance,
width: width,
height: height,
fit: fit,
errorBuilder: (context, error, stackTrace) {
imageProviderInstance.evict();
return Icon(Icons.image_not_supported_outlined, size: 32, color: Colors.red[200]);
},
);
}
}
@@ -1,88 +0,0 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/images/local_image_provider.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/entities/asset.entity.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
import 'package:immich_mobile/utils/thumbnail_utils.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
import 'package:immich_mobile/widgets/common/thumbhash_placeholder.dart';
import 'package:octo_image/octo_image.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart' as base_asset;
class ImmichThumbnail extends HookConsumerWidget {
const ImmichThumbnail({this.asset, this.width = 250, this.height = 250, this.fit = BoxFit.cover, super.key});
final Asset? asset;
final double width;
final double height;
final BoxFit fit;
/// Helper function to return the image provider for the asset thumbnail
/// either by using the asset ID or the asset itself
/// [asset] is the Asset to request, or else use [assetId] to get a remote
/// image provider
static ImageProvider imageProvider({Asset? asset, String? assetId, int thumbnailSize = 256}) {
if (asset == null && assetId == null) {
throw Exception('Must supply either asset or assetId');
}
if (asset == null) {
return RemoteImageProvider.thumbnail(assetId: assetId!, thumbhash: "");
}
if (ImmichImage.useLocal(asset)) {
return LocalThumbProvider(
id: asset.localId!,
assetType: base_asset.AssetType.video,
size: Size(thumbnailSize.toDouble(), thumbnailSize.toDouble()),
);
} else {
return RemoteImageProvider.thumbnail(assetId: asset.remoteId!, thumbhash: asset.thumbhash ?? "");
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
Uint8List? blurhash = useBlurHashRef(asset).value;
if (asset == null) {
return Container(
color: Colors.grey,
width: width,
height: height,
child: const Center(child: Icon(Icons.no_photography)),
);
}
final assetAltText = getAltText(asset!.exifInfo, asset!.fileCreatedAt, asset!.type, []);
final thumbnailProviderInstance = ImmichThumbnail.imageProvider(asset: asset);
customErrorBuilder(BuildContext ctx, Object error, StackTrace? stackTrace) {
thumbnailProviderInstance.evict();
final originalErrorWidgetBuilder = blurHashErrorBuilder(blurhash, fit: fit);
return originalErrorWidgetBuilder(ctx, error, stackTrace);
}
return Semantics(
label: assetAltText,
child: OctoImage.fromSet(
placeholderFadeInDuration: Duration.zero,
fadeInDuration: Duration.zero,
fadeOutDuration: const Duration(milliseconds: 100),
octoSet: OctoSet(
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit),
errorBuilder: customErrorBuilder,
),
image: thumbnailProviderInstance,
width: width,
height: height,
fit: fit,
),
);
}
}
@@ -1,19 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class ShareDialog extends StatelessWidget {
const ShareDialog({super.key});
@override
Widget build(BuildContext context) {
return AlertDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(),
Container(margin: const EdgeInsets.only(top: 12), child: const Text('share_dialog_preparing').tr()),
],
),
);
}
}
@@ -4,15 +4,6 @@ import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/widgets/common/fade_in_placeholder_image.dart';
import 'package:octo_image/octo_image.dart';
/// Simple set to show [OctoPlaceholder.circularProgressIndicator] as
/// placeholder and [OctoError.icon] as error.
OctoSet blurHashOrPlaceholder(Uint8List? blurhash, {BoxFit? fit, Text? errorMessage}) {
return OctoSet(
placeholderBuilder: blurHashPlaceholderBuilder(blurhash, fit: fit),
errorBuilder: blurHashErrorBuilder(blurhash, fit: fit, message: errorMessage),
);
}
OctoPlaceholderBuilder blurHashPlaceholderBuilder(Uint8List? blurhash, {BoxFit? fit}) {
return (context) => blurhash == null
? const ThumbnailPlaceholder()
@@ -1,14 +1,11 @@
import 'package:easy_localization/easy_localization.dart';
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/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
@@ -64,10 +61,6 @@ class ChangePasswordForm extends HookConsumerWidget {
if (isSuccess) {
await ref.read(authProvider.notifier).logout();
ref.read(manualUploadProvider.notifier).cancelBackup();
ref.read(backupProvider.notifier).cancelBackup();
await ref.read(assetProvider.notifier).clearAllAssets();
ref.read(websocketProvider.notifier).disconnect();
AutoRouter.of(context).back();
@@ -1,41 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class EmailInput extends StatelessWidget {
final TextEditingController controller;
final FocusNode? focusNode;
final Function()? onSubmit;
const EmailInput({super.key, required this.controller, this.focusNode, this.onSubmit});
String? _validateInput(String? email) {
if (email == null || email == '') return null;
if (email.endsWith(' ')) return 'login_form_err_trailing_whitespace'.tr();
if (email.startsWith(' ')) return 'login_form_err_leading_whitespace'.tr();
if (email.contains(' ') || !email.contains('@')) {
return 'login_form_err_invalid_email'.tr();
}
return null;
}
@override
Widget build(BuildContext context) {
return TextFormField(
autofocus: true,
controller: controller,
decoration: InputDecoration(
labelText: 'email'.tr(),
border: const OutlineInputBorder(),
hintText: 'login_form_email_hint'.tr(),
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
),
validator: _validateInput,
autovalidateMode: AutovalidateMode.always,
autofillHints: const [AutofillHints.email],
keyboardType: TextInputType.emailAddress,
onFieldSubmitted: (_) => onSubmit?.call(),
focusNode: focusNode,
textInputAction: TextInputAction.next,
);
}
}
@@ -1,13 +0,0 @@
import 'package:flutter/material.dart';
class LoadingIcon extends StatelessWidget {
const LoadingIcon({super.key});
@override
Widget build(BuildContext context) {
return const Padding(
padding: EdgeInsets.only(top: 18.0),
child: SizedBox(width: 24, height: 24, child: FittedBox(child: CircularProgressIndicator(strokeWidth: 2))),
);
}
}
@@ -1,19 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
class LoginButton extends ConsumerWidget {
final Function() onPressed;
const LoginButton({super.key, required this.onPressed});
@override
Widget build(BuildContext context, WidgetRef ref) {
return ElevatedButton.icon(
style: ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 12)),
onPressed: onPressed,
icon: const Icon(Icons.login_rounded),
label: const Text("login", style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold)).tr(),
);
}
}
+13 -27
View File
@@ -17,7 +17,6 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.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/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -34,7 +33,6 @@ import 'package:immich_ui/immich_ui.dart';
import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:package_info_plus/package_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
class LoginForm extends HookConsumerWidget {
LoginForm({super.key});
@@ -246,18 +244,14 @@ class LoginForm extends HookConsumerWidget {
if (result.shouldChangePassword && !result.isAdmin) {
unawaited(context.pushRoute(const ChangePasswordRoute()));
} else {
final isBeta = Store.isBetaTimelineEnabled;
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(context.replaceRoute(const TabShellRoute()));
return;
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(context.replaceRoute(const TabControllerRoute()));
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
} catch (error) {
ImmichToast.show(
@@ -338,21 +332,13 @@ class LoginForm extends HookConsumerWidget {
.saveAuthInfo(accessToken: loginResponseDto.accessToken);
if (isSuccess) {
final permission = ref.watch(galleryPermissionNotifier);
final isBeta = Store.isBetaTimelineEnabled;
if (!isBeta && (permission.isGranted || permission.isLimited)) {
unawaited(ref.watch(backupProvider.notifier).resumeBackup());
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
if (isBeta) {
await ref.read(galleryPermissionNotifier.notifier).requestGalleryPermission();
if (isSyncRemoteDeletionsMode()) {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
unawaited(context.replaceRoute(const TabControllerRoute()));
unawaited(handleSyncFlow());
unawaited(context.replaceRoute(const TabShellRoute()));
return;
}
} catch (error, stack) {
log.severe('Error logging in with OAuth: $error', stack);
-128
View File
@@ -1,128 +0,0 @@
import 'dart:async';
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/asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
import 'package:immich_mobile/utils/selection_handlers.dart';
import 'package:immich_mobile/widgets/map/map_settings_sheet.dart';
class MapAppBar extends HookWidget implements PreferredSizeWidget {
final ValueNotifier<Set<Asset>> selectedAssets;
const MapAppBar({super.key, required this.selectedAssets});
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(top: context.padding.top + 25),
child: ValueListenableBuilder(
valueListenable: selectedAssets,
builder: (ctx, value, child) =>
value.isNotEmpty ? _SelectionRow(selectedAssets: selectedAssets) : const _NonSelectionRow(),
),
);
}
@override
Size get preferredSize => const Size.fromHeight(100);
}
class _NonSelectionRow extends StatelessWidget {
const _NonSelectionRow();
@override
Widget build(BuildContext context) {
void onSettingsPressed() {
showModalBottomSheet(
elevation: 0.0,
showDragHandle: true,
isScrollControlled: true,
context: context,
builder: (_) => const MapSettingsSheet(),
);
}
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ElevatedButton(
onPressed: () => context.maybePop(),
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.arrow_back_ios_new_rounded),
),
ElevatedButton(
onPressed: onSettingsPressed,
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.more_vert_rounded),
),
],
);
}
}
class _SelectionRow extends HookConsumerWidget {
final ValueNotifier<Set<Asset>> selectedAssets;
const _SelectionRow({required this.selectedAssets});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isProcessing = useProcessingOverlay();
Future<void> handleProcessing(FutureOr<void> Function() action, [bool reloadMarkers = false]) async {
isProcessing.value = true;
await action();
// Reset state
selectedAssets.value = {};
isProcessing.value = false;
if (reloadMarkers) {
ref.read(mapStateNotifierProvider.notifier).setRefetchMarkers(true);
}
}
return Row(
children: [
Padding(
padding: const EdgeInsets.only(left: 20),
child: ElevatedButton.icon(
onPressed: () => selectedAssets.value = {},
icon: const Icon(Icons.close_rounded),
label: Text(
'${selectedAssets.value.length}',
style: context.textTheme.titleMedium?.copyWith(color: context.colorScheme.onPrimary),
),
),
),
Expanded(
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
ElevatedButton(
onPressed: () => handleProcessing(() => handleShareAssets(ref, context, selectedAssets.value.toList())),
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.ios_share_rounded),
),
ElevatedButton(
onPressed: () =>
handleProcessing(() => handleFavoriteAssets(ref, context, selectedAssets.value.toList())),
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.favorite),
),
ElevatedButton(
onPressed: () =>
handleProcessing(() => handleArchiveAssets(ref, context, selectedAssets.value.toList()), true),
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.archive),
),
],
),
),
],
);
}
}
-289
View File
@@ -1,289 +0,0 @@
import 'dart:math' as math;
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: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/collection_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/map/map_event.model.dart';
import 'package:immich_mobile/providers/db.provider.dart';
import 'package:immich_mobile/providers/timeline.provider.dart';
import 'package:immich_mobile/utils/color_filter_generator.dart';
import 'package:immich_mobile/utils/throttle.dart';
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
import 'package:immich_mobile/widgets/common/drag_sheet.dart';
import 'package:logging/logging.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
class MapAssetGrid extends HookConsumerWidget {
final Stream<MapEvent> mapEventStream;
final Function(String)? onGridAssetChanged;
final Function(String)? onZoomToAsset;
final Function(bool, Set<Asset>)? onAssetsSelected;
final ValueNotifier<Set<Asset>> selectedAssets;
final ScrollController controller;
const MapAssetGrid({
required this.mapEventStream,
this.onGridAssetChanged,
this.onZoomToAsset,
this.onAssetsSelected,
required this.selectedAssets,
required this.controller,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final log = Logger("MapAssetGrid");
final assetsInBounds = useState<List<Asset>>([]);
final cachedRenderList = useRef<RenderList?>(null);
final lastRenderElementIndex = useRef<int?>(null);
final assetInSheet = useValueNotifier<String?>(null);
final gridScrollThrottler = useThrottler(interval: const Duration(milliseconds: 300));
// Add a cache for assets we've already loaded
final assetCache = useRef<Map<String, Asset>>({});
void handleMapEvents(MapEvent event) async {
if (event is MapAssetsInBoundsUpdated) {
final assetIds = event.assetRemoteIds;
final missingIds = <String>[];
final currentAssets = <Asset>[];
for (final id in assetIds) {
final asset = assetCache.value[id];
if (asset != null) {
currentAssets.add(asset);
} else {
missingIds.add(id);
}
}
// Only fetch missing assets
if (missingIds.isNotEmpty) {
final newAssets = await ref.read(dbProvider).assets.getAllByRemoteId(missingIds);
// Add new assets to cache and current list
for (final asset in newAssets) {
if (asset.remoteId != null) {
assetCache.value[asset.remoteId!] = asset;
currentAssets.add(asset);
}
}
}
assetsInBounds.value = currentAssets;
return;
}
}
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
// Hard-restrict to 4 assets / row in portrait mode
const assetsPerRow = 4;
void handleVisibleItems(Iterable<ItemPosition> positions) {
final orderedPos = positions.sortedByField((p) => p.index);
// Index of row where the items are mostly visible
const partialOffset = 0.20;
final item = orderedPos.firstWhereOrNull((p) => p.itemTrailingEdge > partialOffset);
// Guard no elements, reset state
// Also fail fast when the sheet is just opened and the user is yet to scroll (i.e leading = 0)
if (item == null || item.itemLeadingEdge == 0) {
lastRenderElementIndex.value = null;
return;
}
final renderElement = cachedRenderList.value?.elements.elementAtOrNull(item.index);
// Guard no render list or render element
if (renderElement == null) {
return;
}
// Reset index
lastRenderElementIndex.value == item.index;
// <RenderElement:offset:0>
// | 1 | 2 | 3 | 4 | 5 | 6 |
// <RenderElement:offset:6>
// | 7 | 8 | 9 |
// <RenderElement:offset:9>
// | 10 |
// Skip through the assets from the previous row
final rowOffset = renderElement.offset;
// Column offset = (total trailingEdge - trailingEdge crossed) / offset for each asset
final totalOffset = item.itemTrailingEdge - item.itemLeadingEdge;
final edgeOffset =
(totalOffset - partialOffset) /
// Round the total count to the next multiple of [assetsPerRow]
((renderElement.totalCount / assetsPerRow) * assetsPerRow).floor();
// trailing should never be above the totalOffset
final columnOffset = (totalOffset - math.min(item.itemTrailingEdge, totalOffset)) ~/ edgeOffset;
final assetOffset = rowOffset + columnOffset;
final selectedAsset = cachedRenderList.value?.allAssets?.elementAtOrNull(assetOffset)?.remoteId;
if (selectedAsset != null) {
onGridAssetChanged?.call(selectedAsset);
assetInSheet.value = selectedAsset;
}
}
return Card(
margin: EdgeInsets.zero,
child: Stack(
children: [
/// The Align and FractionallySizedBox are to prevent the Asset Grid from going behind the
/// _MapSheetDragRegion and thereby displaying content behind the top right and top left curves
Align(
alignment: Alignment.bottomCenter,
child: FractionallySizedBox(
// Place it just below the drag handle
heightFactor: 0.87,
child: assetsInBounds.value.isNotEmpty
? ref
.watch(assetsTimelineProvider(assetsInBounds.value))
.when(
data: (renderList) {
// Cache render list here to use it back during visibleItemsListener
cachedRenderList.value = renderList;
return ValueListenableBuilder(
valueListenable: selectedAssets,
builder: (_, value, __) => ImmichAssetGrid(
shrinkWrap: true,
renderList: renderList,
showDragScroll: false,
assetsPerRow: assetsPerRow,
showMultiSelectIndicator: false,
selectionActive: value.isNotEmpty,
listener: onAssetsSelected,
visibleItemsListener: (pos) => gridScrollThrottler.run(() => handleVisibleItems(pos)),
),
);
},
error: (error, stackTrace) {
log.warning("Cannot get assets in the current map bounds", error, stackTrace);
return const SizedBox.shrink();
},
loading: () => const SizedBox.shrink(),
)
: const _MapNoAssetsInSheet(),
),
),
_MapSheetDragRegion(
controller: controller,
assetsInBoundCount: assetsInBounds.value.length,
assetInSheet: assetInSheet,
onZoomToAsset: onZoomToAsset,
),
],
),
);
}
}
class _MapNoAssetsInSheet extends StatelessWidget {
const _MapNoAssetsInSheet();
@override
Widget build(BuildContext context) {
const image = Image(height: 150, width: 150, image: AssetImage('assets/lighthouse.png'));
return Center(
child: ListView(
shrinkWrap: true,
children: [
context.isDarkTheme
? const InvertionFilter(
child: SaturationFilter(saturation: -1, child: BrightnessFilter(brightness: -5, child: image)),
)
: image,
const SizedBox(height: 20),
Center(
child: Text("map_zoom_to_see_photos".tr(), style: context.textTheme.displayLarge?.copyWith(fontSize: 18)),
),
],
),
);
}
}
class _MapSheetDragRegion extends StatelessWidget {
final ScrollController controller;
final int assetsInBoundCount;
final ValueNotifier<String?> assetInSheet;
final Function(String)? onZoomToAsset;
const _MapSheetDragRegion({
required this.controller,
required this.assetsInBoundCount,
required this.assetInSheet,
this.onZoomToAsset,
});
@override
Widget build(BuildContext context) {
final assetsInBoundsText = "map_assets_in_bounds".t(context: context, args: {'count': assetsInBoundCount});
return SingleChildScrollView(
controller: controller,
physics: const ClampingScrollPhysics(),
child: Card(
margin: EdgeInsets.zero,
shape: context.isMobile
? const RoundedRectangleBorder(
borderRadius: BorderRadius.only(topRight: Radius.circular(20), topLeft: Radius.circular(20)),
)
: const BeveledRectangleBorder(),
elevation: 0.0,
child: Stack(
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
const SizedBox(height: 15),
const CustomDraggingHandle(),
const SizedBox(height: 15),
Center(
child: Text(
assetsInBoundsText,
style: TextStyle(
fontSize: 20,
color: context.textTheme.displayLarge?.color?.withValues(alpha: 0.75),
fontWeight: FontWeight.w500,
),
),
),
const SizedBox(height: 8),
],
),
ValueListenableBuilder(
valueListenable: assetInSheet,
builder: (_, value, __) => Visibility(
visible: value != null,
child: Positioned(
right: 18,
top: 24,
child: IconButton(
icon: Icon(Icons.map_outlined, color: context.textTheme.displayLarge?.color),
iconSize: 24,
tooltip: 'zoom_to_bounds'.tr(),
onPressed: () => onZoomToAsset?.call(value!),
),
),
),
),
],
),
),
);
}
}
@@ -1,98 +0,0 @@
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/map/map_event.model.dart';
import 'package:immich_mobile/utils/draggable_scroll_controller.dart';
import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
class MapBottomSheet extends HookConsumerWidget {
final Stream<MapEvent> mapEventStream;
final Function(String)? onGridAssetChanged;
final Function(String)? onZoomToAsset;
final Function()? onZoomToLocation;
final Function(bool, Set<Asset>)? onAssetsSelected;
final ValueNotifier<Set<Asset>> selectedAssets;
const MapBottomSheet({
required this.mapEventStream,
this.onGridAssetChanged,
this.onZoomToAsset,
this.onAssetsSelected,
this.onZoomToLocation,
required this.selectedAssets,
super.key,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
const sheetMinExtent = 0.1;
final sheetController = useDraggableScrollController();
final bottomSheetOffset = useValueNotifier(sheetMinExtent);
final isBottomSheetOpened = useRef(false);
void handleMapEvents(MapEvent event) async {
if (event is MapCloseBottomSheet) {
await sheetController.animateTo(
0.1,
duration: const Duration(milliseconds: 200),
curve: Curves.linearToEaseOut,
);
}
}
useOnStreamChange<MapEvent>(mapEventStream, onData: handleMapEvents);
bool onScrollNotification(DraggableScrollableNotification notification) {
isBottomSheetOpened.value = notification.extent > (notification.maxExtent * 0.9);
bottomSheetOffset.value = notification.extent;
// do not bubble
return true;
}
return Stack(
children: [
NotificationListener<DraggableScrollableNotification>(
onNotification: onScrollNotification,
child: DraggableScrollableSheet(
controller: sheetController,
minChildSize: sheetMinExtent,
maxChildSize: 0.8,
initialChildSize: sheetMinExtent,
snap: true,
snapSizes: [sheetMinExtent, 0.5, 0.8],
shouldCloseOnMinExtent: false,
builder: (ctx, scrollController) => MapAssetGrid(
controller: scrollController,
mapEventStream: mapEventStream,
selectedAssets: selectedAssets,
onAssetsSelected: onAssetsSelected,
// Do not bother with the event if the bottom sheet is not user scrolled
onGridAssetChanged: (assetId) => isBottomSheetOpened.value ? onGridAssetChanged?.call(assetId) : null,
onZoomToAsset: onZoomToAsset,
),
),
),
ValueListenableBuilder(
valueListenable: bottomSheetOffset,
builder: (context, value, child) {
return Positioned(
right: 0,
bottom: context.height * (value + 0.02),
child: AnimatedOpacity(
opacity: value < 0.8 ? 1 : 0,
duration: const Duration(milliseconds: 150),
child: ElevatedButton(
onPressed: onZoomToLocation,
style: ElevatedButton.styleFrom(shape: const CircleBorder()),
child: const Icon(Icons.my_location),
),
),
);
},
),
],
);
}
}
@@ -1,61 +0,0 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/providers/map/map_state.provider.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_list_tile.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_settings_time_dropdown.dart';
import 'package:immich_mobile/widgets/map/map_settings/map_theme_picker.dart';
class MapSettingsSheet extends HookConsumerWidget {
const MapSettingsSheet({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final mapState = ref.watch(mapStateNotifierProvider);
return DraggableScrollableSheet(
expand: false,
initialChildSize: 0.6,
builder: (ctx, scrollController) => SingleChildScrollView(
controller: scrollController,
child: Card(
elevation: 0.0,
shadowColor: Colors.transparent,
margin: EdgeInsets.zero,
child: Column(
mainAxisSize: MainAxisSize.max,
children: [
MapThemePicker(
themeMode: mapState.themeMode,
onThemeChange: (mode) => ref.read(mapStateNotifierProvider.notifier).switchTheme(mode),
),
const Divider(height: 30, thickness: 2),
MapSettingsListTile(
title: "map_settings_only_show_favorites",
selected: mapState.showFavoriteOnly,
onChanged: (favoriteOnly) =>
ref.read(mapStateNotifierProvider.notifier).switchFavoriteOnly(favoriteOnly),
),
MapSettingsListTile(
title: "map_settings_include_show_archived",
selected: mapState.includeArchived,
onChanged: (includeArchive) =>
ref.read(mapStateNotifierProvider.notifier).switchIncludeArchived(includeArchive),
),
MapSettingsListTile(
title: "map_settings_include_show_partners",
selected: mapState.withPartners,
onChanged: (withPartners) =>
ref.read(mapStateNotifierProvider.notifier).switchWithPartners(withPartners),
),
MapTimeDropDown(
relativeTime: mapState.relativeTime,
onTimeChange: (time) => ref.read(mapStateNotifierProvider.notifier).setRelativeTime(time),
),
const SizedBox(height: 20),
],
),
),
),
);
}
}
@@ -1,43 +0,0 @@
import 'dart:io';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/widgets/map/asset_marker_icon.dart';
class PositionedAssetMarkerIcon extends StatelessWidget {
final Point<num> point;
final String assetRemoteId;
final String assetThumbhash;
final double size;
final int durationInMilliseconds;
final Function()? onTap;
const PositionedAssetMarkerIcon({
required this.point,
required this.assetRemoteId,
required this.assetThumbhash,
this.size = 100,
this.durationInMilliseconds = 100,
this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
final ratio = Platform.isIOS ? 1.0 : context.devicePixelRatio;
return AnimatedPositioned(
left: point.x / ratio - size / 2,
top: point.y / ratio - size,
duration: Duration(milliseconds: durationInMilliseconds),
child: GestureDetector(
onTap: () => onTap?.call(),
child: SizedBox.square(
dimension: size,
child: AssetMarkerIcon(id: assetRemoteId, thumbhash: assetThumbhash, key: Key(assetRemoteId)),
),
),
);
}
}
@@ -1,50 +0,0 @@
// ignore_for_file: require_trailing_commas
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
class MemoryBottomInfo extends StatelessWidget {
final Memory memory;
const MemoryBottomInfo({super.key, required this.memory});
@override
Widget build(BuildContext context) {
final df = DateFormat.yMMMMd();
return Padding(
padding: const EdgeInsets.all(16.0),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
memory.title,
style: TextStyle(color: Colors.grey[400], fontSize: 13.0, fontWeight: FontWeight.w500),
),
Text(
df.format(memory.assets[0].fileCreatedAt),
style: const TextStyle(color: Colors.white, fontSize: 15.0, fontWeight: FontWeight.w500),
),
],
),
MaterialButton(
minWidth: 0,
onPressed: () {
context.maybePop();
scrollToDateNotifierProvider.scrollToDate(memory.assets[0].fileCreatedAt);
},
shape: const CircleBorder(),
color: Colors.white.withValues(alpha: 0.2),
elevation: 0,
child: const Icon(Icons.open_in_new, color: Colors.white),
),
],
),
);
}
}
@@ -1,117 +0,0 @@
import 'dart:ui';
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/common/native_video_viewer.page.dart';
import 'package:immich_mobile/utils/hooks/blurhash_hook.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
class MemoryCard extends StatelessWidget {
final Asset asset;
final String title;
final bool showTitle;
final Function()? onVideoEnded;
const MemoryCard({required this.asset, required this.title, required this.showTitle, this.onVideoEnded, super.key});
@override
Widget build(BuildContext context) {
return Card(
color: Colors.black,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.all(Radius.circular(25.0)),
side: BorderSide(color: Colors.black, width: 1.0),
),
clipBehavior: Clip.hardEdge,
child: Stack(
children: [
SizedBox.expand(child: _BlurredBackdrop(asset: asset)),
LayoutBuilder(
builder: (context, constraints) {
// Determine the fit using the aspect ratio
BoxFit fit = BoxFit.contain;
if (asset.width != null && asset.height != null) {
final aspectRatio = asset.width! / asset.height!;
final phoneAspectRatio = constraints.maxWidth / constraints.maxHeight;
// Look for a 25% difference in either direction
if (phoneAspectRatio * .75 < aspectRatio && phoneAspectRatio * 1.25 > aspectRatio) {
// Cover to look nice if we have nearly the same aspect ratio
fit = BoxFit.cover;
}
}
if (asset.isImage) {
return Hero(
tag: 'memory-${asset.id}',
child: ImmichImage(asset, fit: fit, height: double.infinity, width: double.infinity),
);
} else {
return Hero(
tag: 'memory-${asset.id}',
child: SizedBox(
width: context.width,
height: context.height,
child: NativeVideoViewerPage(
key: ValueKey(asset.id),
asset: asset,
showControls: false,
playbackDelayFactor: 2,
image: ImmichImage(asset, width: context.width, height: context.height, fit: BoxFit.contain),
),
),
);
}
},
),
if (showTitle)
Positioned(
left: 18.0,
bottom: 18.0,
child: Text(
title,
style: context.textTheme.headlineMedium?.copyWith(color: Colors.white, fontWeight: FontWeight.w500),
),
),
],
),
);
}
}
class _BlurredBackdrop extends HookWidget {
final Asset asset;
const _BlurredBackdrop({required this.asset});
@override
Widget build(BuildContext context) {
final blurhash = useBlurHashRef(asset).value;
if (blurhash != null) {
// Use a nice cheap blur hash image decoration
return Container(
decoration: BoxDecoration(
image: DecorationImage(image: MemoryImage(blurhash), fit: BoxFit.cover),
),
child: Container(color: Colors.black.withValues(alpha: 0.2)),
);
} else {
// Fall back to using a more expensive image filtered
// Since the ImmichImage is already precached, we can
// safely use that as the image provider
return ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 30, sigmaY: 30),
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: ImmichImage.imageProvider(asset: asset, height: context.height, width: context.width),
fit: BoxFit.cover,
),
),
child: Container(color: Colors.black.withValues(alpha: 0.2)),
),
);
}
}
}
@@ -1,91 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/models/memories/memory.model.dart';
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
import 'package:immich_mobile/providers/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/memory.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
import 'package:immich_mobile/widgets/common/immich_image.dart';
class MemoryLane extends HookConsumerWidget {
const MemoryLane({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final memoryLaneFutureProvider = ref.watch(memoryFutureProvider);
final memoryLane = memoryLaneFutureProvider
.whenData(
(memories) => memories != null
? ConstrainedBox(
constraints: const BoxConstraints(maxHeight: 200),
child: CarouselView(
itemExtent: 145.0,
shrinkExtent: 1.0,
elevation: 2,
backgroundColor: Colors.black,
overlayColor: WidgetStateProperty.all(Colors.white.withValues(alpha: 0.1)),
onTap: (memoryIndex) {
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
if (memories[memoryIndex].assets.isNotEmpty) {
final asset = memories[memoryIndex].assets[0];
ref.read(currentAssetProvider.notifier).set(asset);
}
context.pushRoute(MemoryRoute(memories: memories, memoryIndex: memoryIndex));
},
children: memories
.mapIndexed<Widget>((index, memory) => MemoryCard(index: index, memory: memory))
.toList(),
),
)
: const SizedBox(),
)
.value;
return memoryLane ?? const SizedBox();
}
}
class MemoryCard extends ConsumerWidget {
const MemoryCard({super.key, required this.index, required this.memory});
final int index;
final Memory memory;
@override
Widget build(BuildContext context, WidgetRef ref) {
return Center(
child: Stack(
children: [
ColorFiltered(
colorFilter: ColorFilter.mode(Colors.black.withValues(alpha: 0.2), BlendMode.darken),
child: Hero(
tag: 'memory-${memory.assets[0].id}',
child: ImmichImage(
memory.assets[0],
fit: BoxFit.cover,
width: 205,
height: 200,
placeholder: const ThumbnailPlaceholder(width: 105, height: 200),
),
),
),
Positioned(
bottom: 16,
left: 16,
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 114),
child: Text(
memory.title,
style: const TextStyle(fontWeight: FontWeight.w600, color: Colors.white, fontSize: 15),
),
),
),
],
),
);
}
}
@@ -1,89 +0,0 @@
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/presentation/widgets/images/remote_image_provider.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
class CuratedPeopleRow extends StatelessWidget {
static const double imageSize = 60.0;
final List<SearchCuratedContent> content;
final EdgeInsets? padding;
/// Callback with the content and the index when tapped
final Function(SearchCuratedContent, int)? onTap;
final Function(SearchCuratedContent, int)? onNameTap;
const CuratedPeopleRow({super.key, required this.content, this.onTap, this.padding, required this.onNameTap});
@override
Widget build(BuildContext context) {
return SizedBox(
width: double.infinity,
child: SingleChildScrollView(
padding: padding,
scrollDirection: Axis.horizontal,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(content.length, (index) {
final person = content[index];
return Padding(
padding: const EdgeInsets.only(right: 16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
GestureDetector(
onTap: () => onTap?.call(person, index),
child: SizedBox(
height: imageSize,
child: Material(
shape: const CircleBorder(side: BorderSide.none),
elevation: 3,
child: CircleAvatar(
maxRadius: imageSize / 2,
backgroundImage: RemoteImageProvider(url: getFaceThumbnailUrl(person.id)),
),
),
),
),
const SizedBox(height: 8),
SizedBox(width: imageSize, child: _buildPersonLabel(context, person, index)),
],
),
);
}),
),
),
);
}
Widget _buildPersonLabel(BuildContext context, SearchCuratedContent person, int index) {
if (person.label.isEmpty) {
return GestureDetector(
onTap: () => onNameTap?.call(person, index),
child: Text(
"exif_bottom_sheet_person_add_person",
style: context.textTheme.labelLarge?.copyWith(color: context.primaryColor),
maxLines: 2,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
).tr(),
);
}
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
person.label,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
style: context.textTheme.labelLarge,
maxLines: 2,
),
if (person.subtitle != null) Text(person.subtitle!, textAlign: TextAlign.center),
],
);
}
}
@@ -1,60 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/widgets/search/search_map_thumbnail.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
class CuratedPlacesRow extends StatelessWidget {
const CuratedPlacesRow({
super.key,
required this.content,
required this.imageSize,
this.isMapEnabled = true,
this.onTap,
});
final bool isMapEnabled;
final List<SearchCuratedContent> content;
final double imageSize;
/// Callback with the content and the index when tapped
final Function(SearchCuratedContent, int)? onTap;
@override
Widget build(BuildContext context) {
// Calculating the actual index of the content based on the whether map is enabled or not.
// If enabled, inject map as the first item in the list (index 0) and so the actual content will start from index 1
final int actualContentIndex = isMapEnabled ? 1 : 0;
return SizedBox(
height: imageSize,
child: ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 16),
separatorBuilder: (context, index) => const SizedBox(width: 10),
itemBuilder: (context, index) {
// Injecting Map thumbnail as the first element
if (isMapEnabled && index == 0) {
return SizedBox.square(
dimension: imageSize,
child: SearchMapThumbnail(size: imageSize),
);
}
final actualIndex = index - actualContentIndex;
final object = content[actualIndex];
final thumbnailRequestUrl = '${Store.get(StoreKey.serverEndpoint)}/assets/${object.id}/thumbnail';
return SizedBox.square(
dimension: imageSize,
child: ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: object.label,
onTap: () => onTap?.call(object, actualIndex),
),
);
},
itemCount: content.length + actualContentIndex,
),
);
}
}
@@ -1,69 +0,0 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.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/models/search/search_curated_content.model.dart';
import 'package:immich_mobile/models/search/search_filter.model.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/image_url_builder.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info.dart';
class ExploreGrid extends StatelessWidget {
final List<SearchCuratedContent> curatedContent;
final bool isPeople;
const ExploreGrid({super.key, required this.curatedContent, this.isPeople = false});
@override
Widget build(BuildContext context) {
if (curatedContent.isEmpty) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: SizedBox(
height: 100,
width: 100,
child: ThumbnailWithInfo(textInfo: '', onTap: () {}),
),
);
}
return GridView.builder(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 140,
mainAxisSpacing: 4,
crossAxisSpacing: 4,
),
itemBuilder: (context, index) {
final content = curatedContent[index];
final thumbnailRequestUrl = isPeople
? getFaceThumbnailUrl(content.id)
: '${Store.get(StoreKey.serverEndpoint)}/assets/${content.id}/thumbnail';
return ThumbnailWithInfo(
imageUrl: thumbnailRequestUrl,
textInfo: content.label,
borderRadius: 0,
onTap: () {
isPeople
? context.pushRoute(PersonResultRoute(personId: content.id, personName: content.label))
: context.pushRoute(
SearchRoute(
prefilter: SearchFilter(
people: {},
location: SearchLocationFilter(city: content.label),
camera: SearchCameraFilter(),
date: SearchDateFilter(),
display: SearchDisplayFilters(isNotInAlbum: false, isArchive: false, isFavorite: false),
rating: SearchRatingFilter(),
mediaType: AssetType.other,
),
),
);
},
);
},
itemCount: curatedContent.length,
);
}
}
@@ -1,65 +0,0 @@
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';
class PersonNameEditFormResult {
final bool success;
final String updatedName;
const PersonNameEditFormResult(this.success, this.updatedName);
}
class PersonNameEditForm extends HookConsumerWidget {
final String personId;
final String personName;
const PersonNameEditForm({super.key, required this.personId, required this.personName});
@override
Widget build(BuildContext context, WidgetRef ref) {
final controller = useTextEditingController(text: personName);
final isError = useState(false);
return AlertDialog(
title: const Text("add_a_name", style: TextStyle(fontWeight: FontWeight.bold)).tr(),
content: SingleChildScrollView(
child: TextFormField(
controller: controller,
textCapitalization: TextCapitalization.words,
autofocus: true,
decoration: InputDecoration(
hintText: 'name'.tr(),
border: const OutlineInputBorder(),
errorText: isError.value ? 'Error occurred' : null,
),
),
),
actions: [
TextButton(
onPressed: () => context.pop(const PersonNameEditFormResult(false, '')),
child: Text(
"cancel",
style: TextStyle(color: Colors.red[300], fontWeight: FontWeight.bold),
).tr(),
),
TextButton(
onPressed: () async {
isError.value = false;
final result = await ref.read(updatePersonNameProvider(personId, controller.text).future);
isError.value = !result;
if (result) {
context.pop(PersonNameEditFormResult(true, controller.text));
}
},
child: Text(
"save",
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold),
).tr(),
),
],
);
}
}
@@ -1,7 +1,7 @@
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/domain/models/asset/base_asset.model.dart';
class MediaTypePicker extends HookWidget {
const MediaTypePicker({super.key, required this.onSelect, this.filter});
@@ -1,27 +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/routing/router.dart';
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
import 'package:immich_mobile/widgets/search/thumbnail_with_info_container.dart';
import 'package:maplibre_gl/maplibre_gl.dart';
class SearchMapThumbnail extends StatelessWidget {
const SearchMapThumbnail({super.key, this.size = 60.0});
final double size;
final bool showTitle = true;
@override
Widget build(BuildContext context) {
return ThumbnailWithInfoContainer(
label: 'search_page_your_map'.tr(),
onTap: () {
context.pushRoute(MapRoute());
},
child: IgnorePointer(
child: MapThumbnail(zoom: 2, centre: const LatLng(47, 5), height: size, width: size, showAttribution: false),
),
);
}
}
@@ -1,34 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/widgets/search/search_row_title.dart';
class SearchRowSection extends StatelessWidget {
const SearchRowSection({
super.key,
required this.onViewAllPressed,
required this.title,
this.isEmpty = false,
required this.child,
});
final Function() onViewAllPressed;
final String title;
final bool isEmpty;
final Widget child;
@override
Widget build(BuildContext context) {
if (isEmpty) {
return const SizedBox.shrink();
}
return Column(
children: [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: SearchRowTitle(onViewAllPressed: onViewAllPressed, title: title),
),
child,
],
);
}
}
@@ -6,7 +6,6 @@ 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/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
@@ -14,9 +13,7 @@ import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/utils/bytes_units.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_action_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
@@ -35,7 +32,6 @@ class AdvancedSettings extends HookConsumerWidget {
final manageMediaAndroidPermission = useState(false);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final useAlternatePMFilter = useAppSettingsState(AppSettingsEnum.photoManagerCustomFilter);
final readonlyModeEnabled = useAppSettingsState(AppSettingsEnum.readonlyModeEnabled);
final logLevel = Level.LEVELS[levelId.value].name;
@@ -114,35 +110,26 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_prefer_remote_title".tr(),
subtitle: "advanced_settings_prefer_remote_subtitle".tr(),
),
if (!Store.isBetaTimelineEnabled) const LocalStorageSettings(),
const CustomProxyHeaderSettings(),
const SslClientCertSettings(),
if (!Store.isBetaTimelineEnabled)
SettingsSwitchListTile(
valueNotifier: useAlternatePMFilter,
title: "advanced_settings_enable_alternate_media_filter_title".tr(),
subtitle: "advanced_settings_enable_alternate_media_filter_subtitle".tr(),
),
if (!Store.isBetaTimelineEnabled) const BetaTimelineListTile(),
if (Store.isBetaTimelineEnabled)
SettingsSwitchListTile(
valueNotifier: readonlyModeEnabled,
title: "advanced_settings_readonly_mode_title".tr(),
subtitle: "advanced_settings_readonly_mode_subtitle".tr(),
onChanged: (value) {
readonlyModeEnabled.value = value;
ref.read(readonlyModeProvider.notifier).setReadonlyMode(value);
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
(value ? "readonly_mode_enabled" : "readonly_mode_disabled").tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
SettingsSwitchListTile(
valueNotifier: readonlyModeEnabled,
title: "advanced_settings_readonly_mode_title".tr(),
subtitle: "advanced_settings_readonly_mode_subtitle".tr(),
onChanged: (value) {
readonlyModeEnabled.value = value;
ref.read(readonlyModeProvider.notifier).setReadonlyMode(value);
context.scaffoldMessenger.showSnackBar(
SnackBar(
duration: const Duration(seconds: 2),
content: Text(
(value ? "readonly_mode_enabled" : "readonly_mode_disabled").tr(),
style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor),
),
);
},
),
),
);
},
),
ListTile(
title: Text("advanced_settings_clear_image_cache".tr(), style: const TextStyle(fontWeight: FontWeight.w500)),
leading: const Icon(Icons.playlist_remove_rounded),
@@ -2,11 +2,11 @@ import 'dart:async';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/timeline.model.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.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/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/widgets/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
@@ -1,21 +1,18 @@
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/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.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/settings/setting_group_title.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
class LayoutSettings extends HookConsumerWidget {
const LayoutSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final useDynamicLayout = useAppSettingsState(AppSettingsEnum.dynamicLayout);
final tilesPerRow = useAppSettingsState(AppSettingsEnum.tilesPerRow);
return Column(
@@ -25,12 +22,6 @@ class LayoutSettings extends HookConsumerWidget {
title: "asset_list_layout_sub_title".t(context: context),
icon: Icons.view_module_outlined,
),
if (!Store.isBetaTimelineEnabled)
SettingsSwitchListTile(
valueNotifier: useDynamicLayout,
title: "asset_list_layout_settings_dynamic_layout_title".t(context: context),
onChanged: (_) => ref.invalidate(appSettingsServiceProvider),
),
SettingsSliderListTile(
valueNotifier: tilesPerRow,
text: 'theme_setting_asset_list_tiles_per_row_title'.tr(namedArgs: {'count': "${tilesPerRow.value}"}),
@@ -1,204 +0,0 @@
import 'dart:io';
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/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/ios_background_settings.provider.dart';
import 'package:immich_mobile/widgets/backup/ios_debug_info_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:permission_handler/permission_handler.dart';
import 'package:url_launcher/url_launcher.dart';
class BackgroundBackupSettings extends ConsumerWidget {
const BackgroundBackupSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isBackgroundEnabled = ref.watch(backupProvider.select((s) => s.backgroundBackup));
final iosSettings = ref.watch(iOSBackgroundSettingsProvider);
void showErrorToUser(String msg) {
final snackBar = SnackBar(
content: Text(msg.tr(), style: context.textTheme.bodyLarge?.copyWith(color: context.primaryColor)),
backgroundColor: Colors.red,
);
context.scaffoldMessenger.showSnackBar(snackBar);
}
void showBatteryOptimizationInfoToUser() {
showDialog<void>(
context: context,
barrierDismissible: false,
builder: (BuildContext ctx) {
return AlertDialog(
title: const Text('backup_controller_page_background_battery_info_title').tr(),
content: SingleChildScrollView(
child: const Text('backup_controller_page_background_battery_info_message').tr(),
),
actions: [
ElevatedButton(
onPressed: () =>
launchUrl(Uri.parse('https://dontkillmyapp.com'), mode: LaunchMode.externalApplication),
child: const Text(
"backup_controller_page_background_battery_info_link",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
),
ElevatedButton(
child: const Text(
'backup_controller_page_background_battery_info_ok',
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 12),
).tr(),
onPressed: () => ctx.pop(),
),
],
);
},
);
}
if (!isBackgroundEnabled) {
return SettingsButtonListTile(
icon: Icons.cloud_sync_outlined,
title: 'backup_controller_page_background_is_off'.tr(),
subtileText: 'backup_controller_page_background_description'.tr(),
buttonText: 'backup_controller_page_background_turn_on'.tr(),
onButtonTap: () => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
enabled: true,
onError: showErrorToUser,
onBatteryInfo: showBatteryOptimizationInfoToUser,
),
);
}
return Column(
children: [
if (!Platform.isIOS || iosSettings?.appRefreshEnabled == true)
_BackgroundSettingsEnabled(onError: showErrorToUser, onBatteryInfo: showBatteryOptimizationInfoToUser),
if (Platform.isIOS && iosSettings?.appRefreshEnabled != true) const _IOSBackgroundRefreshDisabled(),
if (Platform.isIOS && iosSettings != null) IosDebugInfoTile(settings: iosSettings),
],
);
}
}
class _IOSBackgroundRefreshDisabled extends StatelessWidget {
const _IOSBackgroundRefreshDisabled();
@override
Widget build(BuildContext context) {
return SettingsButtonListTile(
icon: Icons.task_outlined,
title: 'backup_controller_page_background_app_refresh_disabled_title'.tr(),
subtileText: 'backup_controller_page_background_app_refresh_disabled_content'.tr(),
buttonText: 'backup_controller_page_background_app_refresh_enable_button_text'.tr(),
onButtonTap: () => openAppSettings(),
);
}
}
class _BackgroundSettingsEnabled extends HookConsumerWidget {
final void Function(String msg) onError;
final void Function() onBatteryInfo;
const _BackgroundSettingsEnabled({required this.onError, required this.onBatteryInfo});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isWifiRequired = ref.watch(backupProvider.select((s) => s.backupRequireWifi));
final isWifiRequiredNotifier = useValueNotifier(isWifiRequired);
useValueChanged(
isWifiRequired,
(_, __) => WidgetsBinding.instance.addPostFrameCallback((_) => isWifiRequiredNotifier.value = isWifiRequired),
);
final isChargingRequired = ref.watch(backupProvider.select((s) => s.backupRequireCharging));
final isChargingRequiredNotifier = useValueNotifier(isChargingRequired);
useValueChanged(
isChargingRequired,
(_, __) =>
WidgetsBinding.instance.addPostFrameCallback((_) => isChargingRequiredNotifier.value = isChargingRequired),
);
int backupDelayToSliderValue(int ms) => switch (ms) {
5000 => 0,
30000 => 1,
120000 => 2,
_ => 3,
};
int backupDelayToMilliseconds(int v) => switch (v) {
0 => 5000,
1 => 30000,
2 => 120000,
_ => 600000,
};
String formatBackupDelaySliderValue(int v) => switch (v) {
0 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '5'}),
1 => 'setting_notifications_notify_seconds'.tr(namedArgs: {'count': '30'}),
2 => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '2'}),
_ => 'setting_notifications_notify_minutes'.tr(namedArgs: {'count': '10'}),
};
final backupTriggerDelay = ref.watch(backupProvider.select((s) => s.backupTriggerDelay));
final triggerDelay = useState(backupDelayToSliderValue(backupTriggerDelay));
useValueChanged(
triggerDelay.value,
(_, __) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(
triggerDelay: backupDelayToMilliseconds(triggerDelay.value),
onError: onError,
onBatteryInfo: onBatteryInfo,
),
);
return SettingsButtonListTile(
icon: Icons.cloud_sync_rounded,
iconColor: context.primaryColor,
title: 'backup_controller_page_background_is_on'.tr(),
buttonText: 'backup_controller_page_background_turn_off'.tr(),
onButtonTap: () => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(enabled: false, onError: onError, onBatteryInfo: onBatteryInfo),
subtitle: Column(
children: [
SettingsSwitchListTile(
valueNotifier: isWifiRequiredNotifier,
title: 'backup_controller_page_background_wifi'.tr(),
icon: Icons.wifi,
onChanged: (enabled) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(requireWifi: enabled, onError: onError, onBatteryInfo: onBatteryInfo),
),
SettingsSwitchListTile(
valueNotifier: isChargingRequiredNotifier,
title: 'backup_controller_page_background_charging'.tr(),
icon: Icons.charging_station,
onChanged: (enabled) => ref
.read(backupProvider.notifier)
.configureBackgroundBackup(requireCharging: enabled, onError: onError, onBatteryInfo: onBatteryInfo),
),
if (Platform.isAndroid)
SettingsSliderListTile(
valueNotifier: triggerDelay,
text: 'backup_controller_page_background_delay'.tr(
namedArgs: {'duration': formatBackupDelaySliderValue(triggerDelay.value)},
),
maxValue: 3.0,
noDivisons: 3,
label: formatBackupDelaySliderValue(triggerDelay.value),
),
],
),
);
}
}
@@ -1,82 +0,0 @@
import 'dart:io';
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/providers/backup/backup_verification.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/services/asset.service.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/background_settings.dart';
import 'package:immich_mobile/widgets/settings/backup_settings/foreground_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/utils/hooks/app_settings_update_hook.dart';
class BackupSettings extends HookConsumerWidget {
const BackupSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final ignoreIcloudAssets = useAppSettingsState(AppSettingsEnum.ignoreIcloudAssets);
final isAdvancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final albumSync = useAppSettingsState(AppSettingsEnum.syncAlbums);
final isCorruptCheckInProgress = ref.watch(backupVerificationProvider);
final isAlbumSyncInProgress = useState(false);
syncAlbums() async {
isAlbumSyncInProgress.value = true;
try {
await ref.read(assetServiceProvider).syncUploadedAssetToAlbums();
} catch (_) {
} finally {
Future.delayed(const Duration(seconds: 1), () {
isAlbumSyncInProgress.value = false;
});
}
}
final backupSettings = [
const ForegroundBackupSettings(),
const BackgroundBackupSettings(),
if (Platform.isIOS)
SettingsSwitchListTile(
valueNotifier: ignoreIcloudAssets,
title: 'ignore_icloud_photos'.tr(),
subtitle: 'ignore_icloud_photos_description'.tr(),
),
if (Platform.isAndroid && isAdvancedTroubleshooting.value)
SettingsButtonListTile(
icon: Icons.warning_rounded,
title: 'check_corrupt_asset_backup'.tr(),
subtitle: isCorruptCheckInProgress
? const Column(
children: [
SizedBox(height: 20),
Center(child: CircularProgressIndicator()),
SizedBox(height: 20),
],
)
: null,
subtileText: !isCorruptCheckInProgress ? 'check_corrupt_asset_backup_description'.tr() : null,
buttonText: 'check_corrupt_asset_backup_button'.tr(),
onButtonTap: !isCorruptCheckInProgress
? () => ref.read(backupVerificationProvider.notifier).performBackupCheck(context)
: null,
),
if (albumSync.value)
SettingsButtonListTile(
icon: Icons.photo_album_outlined,
title: 'sync_albums'.tr(),
subtitle: Text("sync_albums_manual_subtitle".tr()),
buttonText: 'sync_albums'.tr(),
child: isAlbumSyncInProgress.value
? const CircularProgressIndicator()
: ElevatedButton(onPressed: syncAlbums, child: Text('sync'.tr())),
),
];
return SettingsSubPageScaffold(settings: backupSettings, showDivider: true);
}
}
@@ -1,35 +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/backup/backup.provider.dart';
import 'package:immich_mobile/widgets/settings/settings_button_list_tile.dart';
class ForegroundBackupSettings extends ConsumerWidget {
const ForegroundBackupSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isAutoBackup = ref.watch(backupProvider.select((s) => s.autoBackup));
void onButtonTap() => ref.read(backupProvider.notifier).setAutoBackup(!isAutoBackup);
if (isAutoBackup) {
return SettingsButtonListTile(
icon: Icons.cloud_done_rounded,
iconColor: context.primaryColor,
title: 'backup_controller_page_status_on'.tr(),
buttonText: 'backup_controller_page_turn_off'.tr(),
onButtonTap: onButtonTap,
);
}
return SettingsButtonListTile(
icon: Icons.cloud_off_rounded,
title: 'backup_controller_page_status_off'.tr(),
subtileText: 'backup_controller_page_desc_backup'.tr(),
buttonText: 'backup_controller_page_turn_on'.tr(),
onButtonTap: onButtonTap,
);
}
}
@@ -1,71 +0,0 @@
import 'dart:async';
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/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'package:immich_mobile/widgets/settings/setting_list_tile.dart';
class BetaTimelineListTile extends ConsumerWidget {
const BetaTimelineListTile({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final betaTimelineValue = ref.watch(appSettingsServiceProvider).getSetting<bool>(AppSettingsEnum.betaTimeline);
final auth = ref.watch(authProvider);
if (!auth.isAuthenticated) {
return const SizedBox.shrink();
}
void onSwitchChanged(bool value) {
showDialog(
context: context,
builder: (context) {
return AlertDialog(
title: value ? const Text("Enable New Timeline") : const Text("Disable New Timeline"),
content: value
? const Text("Are you sure you want to enable the new timeline?")
: const Text("Are you sure you want to disable the new timeline?"),
actions: [
TextButton(
onPressed: () {
context.pop();
},
child: Text(
"cancel".t(context: context),
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w500, color: context.colorScheme.outline),
),
),
ElevatedButton(
onPressed: () async {
Navigator.of(context).pop();
unawaited(context.router.replaceAll([ChangeExperienceRoute(switchingToBeta: value)]));
},
child: Text("ok".t(context: context)),
),
],
);
},
);
}
return Padding(
padding: const EdgeInsets.only(left: 4.0),
child: SettingListTile(
title: "new_timeline".t(context: context),
trailing: Switch.adaptive(
value: betaTimelineValue,
onChanged: onSwitchChanged,
activeThumbColor: context.primaryColor,
),
onTap: () => onSwitchChanged(!betaTimelineValue),
),
);
}
}
@@ -1,51 +0,0 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' show useEffect, useState;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/duplicated_asset.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/providers/db.provider.dart';
class LocalStorageSettings extends HookConsumerWidget {
const LocalStorageSettings({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final isarDb = ref.watch(dbProvider);
final cacheItemCount = useState(0);
useEffect(() {
cacheItemCount.value = isarDb.duplicatedAssets.countSync();
return null;
}, []);
void clearCache() async {
await isarDb.writeTxn(() => isarDb.duplicatedAssets.clear());
cacheItemCount.value = await isarDb.duplicatedAssets.count();
}
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
dense: true,
title: Text(
"cache_settings_duplicated_assets_title",
style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500),
).tr(namedArgs: {'count': "${cacheItemCount.value}"}),
subtitle: Text(
"cache_settings_duplicated_assets_subtitle",
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
).tr(),
trailing: TextButton(
onPressed: cacheItemCount.value > 0 ? clearCache : null,
child: Text(
"cache_settings_duplicated_assets_clear_button",
style: TextStyle(
fontSize: 12,
color: cacheItemCount.value > 0 ? Colors.red : Colors.grey,
fontWeight: FontWeight.bold,
),
).tr(),
),
);
}
}