mirror of
https://github.com/immich-app/immich.git
synced 2026-05-22 15:02:32 -04:00
refactor: yeet old timeline (#27666)
* refactor: yank old timeline # Conflicts: # mobile/lib/presentation/pages/editing/drift_edit.page.dart # mobile/lib/providers/websocket.provider.dart # mobile/lib/routing/router.dart * more cleanup * remove native code * chore: bump sqlite-data version * remove old background tasks from BGTaskSchedulerPermittedIdentifiers * rebase --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
@@ -1,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(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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()),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user