mirror of
https://github.com/immich-app/immich
synced 2025-11-14 17:36:12 +00:00
parent
7520ffd6c3
commit
5806a3ce25
203 changed files with 318 additions and 318 deletions
105
mobile/lib/widgets/activities/activity_text_field.dart
Normal file
105
mobile/lib/widgets/activities/activity_text_field.dart
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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/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,
|
||||
radius: 15,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
suffixIcon: Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
liked ? Icons.favorite_rounded : Icons.favorite_border_rounded,
|
||||
),
|
||||
onPressed: liked ? removeLike : addLike,
|
||||
),
|
||||
),
|
||||
suffixIconColor: liked ? Colors.red[700] : null,
|
||||
hintText: !isEnabled
|
||||
? 'shared_album_activities_input_disable'.tr()
|
||||
: 'shared_album_activities_input_hint'.tr(),
|
||||
hintStyle: TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
),
|
||||
onEditingComplete: onEditingComplete,
|
||||
onTapOutside: (_) => inputFocusNode.unfocus(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
118
mobile/lib/widgets/activities/activity_tile.dart
Normal file
118
mobile/lib/widgets/activities/activity_tile.dart
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
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/providers/image/immich_remote_thumbnail_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;
|
||||
|
||||
const ActivityTile(this.activity, {super.key});
|
||||
|
||||
@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;
|
||||
|
||||
return ListTile(
|
||||
minVerticalPadding: 15,
|
||||
leading: isLike
|
||||
? Container(
|
||||
width: 44,
|
||||
alignment: Alignment.center,
|
||||
child: Icon(
|
||||
Icons.favorite_rounded,
|
||||
color: Colors.red[700],
|
||||
),
|
||||
)
|
||||
: UserCircleAvatar(user: activity.user),
|
||||
title: _ActivityTitle(
|
||||
userName: activity.user.name,
|
||||
createdAt: activity.createdAt.timeAgo(),
|
||||
leftAlign: isLike || showAssetThumbnail,
|
||||
),
|
||||
// No subtitle for like, so center title
|
||||
titleAlignment:
|
||||
!isLike ? ListTileTitleAlignment.top : ListTileTitleAlignment.center,
|
||||
trailing: showAssetThumbnail
|
||||
? _ActivityAssetThumbnail(activity.assetId!)
|
||||
: 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.withOpacity(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;
|
||||
|
||||
const _ActivityAssetThumbnail(this.assetId);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: 40,
|
||||
height: 30,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(4)),
|
||||
image: DecorationImage(
|
||||
image: ImmichRemoteThumbnailProvider(
|
||||
assetId: assetId,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
||||
75
mobile/lib/widgets/activities/dismissible_activity.dart
Normal file
75
mobile/lib/widgets/activities/dismissible_activity.dart
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
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 ActivityTile body;
|
||||
final Function(String)? onDismiss;
|
||||
|
||||
const DismissibleActivity(
|
||||
this.activityId,
|
||||
this.body, {
|
||||
this.onDismiss,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dismissible(
|
||||
key: Key(activityId),
|
||||
dismissThresholds: const {
|
||||
DismissDirection.horizontal: 0.7,
|
||||
},
|
||||
direction: DismissDirection.horizontal,
|
||||
confirmDismiss: (direction) => onDismiss != null
|
||||
? showDialog(
|
||||
context: context,
|
||||
builder: (context) => ConfirmDialog(
|
||||
onOk: () {},
|
||||
title: "shared_album_activity_remove_title",
|
||||
content: "shared_album_activity_remove_content",
|
||||
ok: "delete_dialog_ok",
|
||||
),
|
||||
)
|
||||
: Future.value(false),
|
||||
onDismissed: (_) async => onDismiss?.call(activityId),
|
||||
// LTR
|
||||
background: _DismissBackground(withDeleteIcon: onDismiss != null),
|
||||
// RTL
|
||||
secondaryBackground: _DismissBackground(
|
||||
withDeleteIcon: onDismiss != null,
|
||||
alignment: AlignmentDirectional.centerEnd,
|
||||
),
|
||||
child: body,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DismissBackground extends StatelessWidget {
|
||||
final AlignmentDirectional alignment;
|
||||
final bool withDeleteIcon;
|
||||
|
||||
const _DismissBackground({
|
||||
required this.withDeleteIcon,
|
||||
this.alignment = AlignmentDirectional.centerStart,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
alignment: alignment,
|
||||
color: withDeleteIcon ? Colors.red[400] : Colors.grey[600],
|
||||
child: withDeleteIcon
|
||||
? const Padding(
|
||||
padding: EdgeInsets.all(15),
|
||||
child: Icon(
|
||||
Icons.delete_sweep_rounded,
|
||||
color: Colors.black,
|
||||
),
|
||||
)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
}
|
||||
134
mobile/lib/widgets/album/add_to_album_bottom_sheet.dart
Normal file
134
mobile/lib/widgets/album/add_to_album_bottom_sheet.dart
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_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);
|
||||
final sharedAlbums = ref.watch(sharedAlbumProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
// Fetch album updates, e.g., cover image
|
||||
ref.read(albumProvider.notifier).getAllAlbums();
|
||||
ref.read(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
void addToAlbum(Album album) async {
|
||||
final result = await albumService.addAdditionalAssetToAlbum(
|
||||
assets,
|
||||
album,
|
||||
);
|
||||
|
||||
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(
|
||||
'common_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(
|
||||
isSharedAlbum: false,
|
||||
initialAssets: assets,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverPadding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
sliver: AddToAlbumSliverList(
|
||||
albums: albums,
|
||||
sharedAlbums: sharedAlbums,
|
||||
onAddToAlbum: addToAlbum,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
70
mobile/lib/widgets/album/add_to_album_sliverlist.dart
Normal file
70
mobile/lib/widgets/album/add_to_album_sliverlist.dart
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
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('common_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) : () {},
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
mobile/lib/widgets/album/album_action_outlined_button.dart
Normal file
48
mobile/lib/widgets/album/album_action_outlined_button.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class AlbumActionOutlinedButton extends StatelessWidget {
|
||||
final VoidCallback? onPressed;
|
||||
final String labelText;
|
||||
final IconData iconData;
|
||||
|
||||
const AlbumActionOutlinedButton({
|
||||
super.key,
|
||||
this.onPressed,
|
||||
required this.labelText,
|
||||
required this.iconData,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(right: 16.0),
|
||||
child: OutlinedButton.icon(
|
||||
style: OutlinedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 10),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25),
|
||||
),
|
||||
side: BorderSide(
|
||||
width: 1,
|
||||
color: context.isDarkTheme
|
||||
? const Color.fromARGB(255, 63, 63, 63)
|
||||
: const Color.fromARGB(255, 206, 206, 206),
|
||||
),
|
||||
),
|
||||
icon: Icon(
|
||||
iconData,
|
||||
size: 18,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
label: Text(
|
||||
labelText,
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
131
mobile/lib/widgets/album/album_thumbnail_card.dart
Normal file
131
mobile/lib/widgets/album/album_thumbnail_card.dart
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
|
||||
|
||||
class AlbumThumbnailCard extends StatelessWidget {
|
||||
final Function()? onTap;
|
||||
|
||||
/// Whether or not to show the owner of the album (or "Owned")
|
||||
/// in the subtitle of the album
|
||||
final bool showOwner;
|
||||
|
||||
const AlbumThumbnailCard({
|
||||
super.key,
|
||||
required this.album,
|
||||
this.onTap,
|
||||
this.showOwner = false,
|
||||
});
|
||||
|
||||
final Album album;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var isDarkTheme = context.isDarkTheme;
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
var cardSize = constraints.maxWidth;
|
||||
|
||||
buildEmptyThumbnail() {
|
||||
return Container(
|
||||
height: cardSize,
|
||||
width: cardSize,
|
||||
decoration: BoxDecoration(
|
||||
color: isDarkTheme ? Colors.grey[800] : Colors.grey[200],
|
||||
),
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.no_photography,
|
||||
size: cardSize * .15,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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 == Store.get(StoreKey.currentUser).id) {
|
||||
owner = 'album_thumbnail_owned'.tr();
|
||||
} else if (album.ownerName != null) {
|
||||
owner = 'album_thumbnail_shared_by'.tr(args: [album.ownerName!]);
|
||||
}
|
||||
}
|
||||
|
||||
return RichText(
|
||||
overflow: TextOverflow.fade,
|
||||
text: TextSpan(
|
||||
children: [
|
||||
TextSpan(
|
||||
text: album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
.tr(args: ['${album.assetCount}'])
|
||||
: 'album_thumbnail_card_items'
|
||||
.tr(args: ['${album.assetCount}']),
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
if (owner != null) const TextSpan(text: ' · '),
|
||||
if (owner != null)
|
||||
TextSpan(
|
||||
text: owner,
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
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: BorderRadius.circular(20),
|
||||
child: album.thumbnail.value == null
|
||||
? buildEmptyThumbnail()
|
||||
: buildAlbumThumbnail(),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: SizedBox(
|
||||
width: cardSize,
|
||||
child: Text(
|
||||
album.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
buildAlbumTextRow(),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
119
mobile/lib/widgets/album/album_thumbnail_listtile.dart
Normal file
119
mobile/lib/widgets/album/album_thumbnail_listtile.dart
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
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/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.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 CachedNetworkImage(
|
||||
width: cardSize,
|
||||
height: cardSize,
|
||||
fit: BoxFit.cover,
|
||||
fadeInDuration: const Duration(milliseconds: 200),
|
||||
imageUrl: getAlbumThumbnailUrl(
|
||||
album,
|
||||
type: ThumbnailFormat.WEBP,
|
||||
),
|
||||
httpHeaders: {
|
||||
"x-immich-user-token": Store.get(StoreKey.accessToken),
|
||||
},
|
||||
cacheKey: getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.WEBP),
|
||||
errorWidget: (context, url, error) =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
);
|
||||
}
|
||||
|
||||
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(
|
||||
album.assetCount == 1
|
||||
? 'album_thumbnail_card_item'
|
||||
: 'album_thumbnail_card_items',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(args: ['${album.assetCount}']),
|
||||
if (album.shared)
|
||||
const Text(
|
||||
'album_thumbnail_card_shared',
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
86
mobile/lib/widgets/album/album_title_text_field.dart
Normal file
86
mobile/lib/widgets/album/album_title_text_field.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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) {
|
||||
final isDarkTheme = context.isDarkTheme;
|
||||
|
||||
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: isDarkTheme ? Colors.grey[300] : Colors.grey[700],
|
||||
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: OutlineInputBorder(
|
||||
borderSide: const BorderSide(color: Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: const BorderSide(color: Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
hintText: 'share_add_title'.tr(),
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 28,
|
||||
color: isDarkTheme ? Colors.grey[300] : Colors.grey[700],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
focusColor: Colors.grey[300],
|
||||
fillColor: isDarkTheme
|
||||
? const Color.fromARGB(255, 32, 33, 35)
|
||||
: Colors.grey[200],
|
||||
filled: isAlbumTitleTextFieldFocus.value,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
304
mobile/lib/widgets/album/album_viewer_appbar.dart
Normal file
304
mobile/lib/widgets/album/album_viewer_appbar.dart
Normal file
|
|
@ -0,0 +1,304 @@
|
|||
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/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/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class AlbumViewerAppbar extends HookConsumerWidget
|
||||
implements PreferredSizeWidget {
|
||||
const AlbumViewerAppbar({
|
||||
super.key,
|
||||
required this.album,
|
||||
required this.userId,
|
||||
required this.titleFocusNode,
|
||||
this.onAddPhotos,
|
||||
this.onAddUsers,
|
||||
required this.onActivities,
|
||||
});
|
||||
|
||||
final Album album;
|
||||
final String userId;
|
||||
final FocusNode titleFocusNode;
|
||||
final Function(Album album)? onAddPhotos;
|
||||
final Function(Album album)? onAddUsers;
|
||||
final Function(Album album) onActivities;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final newAlbumTitle = ref.watch(albumViewerProvider).editTitleText;
|
||||
final isEditAlbum = ref.watch(albumViewerProvider).isEditAlbum;
|
||||
final isProcessing = useProcessingOverlay();
|
||||
final comments = album.shared
|
||||
? ref.watch(activityStatisticsProvider(album.remoteId!))
|
||||
: 0;
|
||||
|
||||
deleteAlbum() async {
|
||||
isProcessing.value = true;
|
||||
|
||||
final bool success;
|
||||
if (album.shared) {
|
||||
success =
|
||||
await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
|
||||
context
|
||||
.navigateTo(const TabControllerRoute(children: [SharingRoute()]));
|
||||
} else {
|
||||
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
|
||||
context
|
||||
.navigateTo(const TabControllerRoute(children: [LibraryRoute()]));
|
||||
}
|
||||
if (!success) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "album_viewer_appbar_share_err_delete".tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
|
||||
isProcessing.value = false;
|
||||
}
|
||||
|
||||
Future<void> showConfirmationDialog() async {
|
||||
return showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false, // user must tap button!
|
||||
builder: (BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: const Text('album_viewer_appbar_share_delete').tr(),
|
||||
content: const Text('album_viewer_appbar_delete_confirm').tr(),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
onPressed: () => context.pop('Cancel'),
|
||||
child: Text(
|
||||
'action_common_cancel',
|
||||
style: TextStyle(
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () {
|
||||
context.pop('Confirm');
|
||||
deleteAlbum();
|
||||
},
|
||||
child: Text(
|
||||
'action_common_confirm',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: !context.isDarkTheme ? Colors.red : Colors.red[300],
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void onDeleteAlbumPressed() async {
|
||||
showConfirmationDialog();
|
||||
}
|
||||
|
||||
void onLeaveAlbumPressed() async {
|
||||
isProcessing.value = true;
|
||||
|
||||
bool isSuccess =
|
||||
await ref.watch(sharedAlbumProvider.notifier).leaveAlbum(album);
|
||||
|
||||
if (isSuccess) {
|
||||
context
|
||||
.navigateTo(const TabControllerRoute(children: [SharingRoute()]));
|
||||
} else {
|
||||
context.pop();
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "album_viewer_appbar_share_err_leave".tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
|
||||
isProcessing.value = false;
|
||||
}
|
||||
|
||||
buildBottomSheetActions() {
|
||||
return [
|
||||
album.ownerId == userId
|
||||
? ListTile(
|
||||
leading: const Icon(Icons.delete_forever_rounded),
|
||||
title: const Text(
|
||||
'album_viewer_appbar_share_delete',
|
||||
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 buildBottomSheet() {
|
||||
final ownerActions = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.person_add_alt_rounded),
|
||||
onTap: () {
|
||||
context.pop();
|
||||
onAddUsers!(album);
|
||||
},
|
||||
title: const Text(
|
||||
"album_viewer_page_share_add_users",
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
).tr(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.share_rounded),
|
||||
onTap: () {
|
||||
context.pushRoute(SharedLinkEditRoute(albumId: album.remoteId));
|
||||
context.pop();
|
||||
},
|
||||
title: const Text(
|
||||
"control_bottom_app_bar_share",
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
).tr(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(Icons.settings_rounded),
|
||||
onTap: () => context.navigateTo(AlbumOptionsRoute(album: album)),
|
||||
title: const Text(
|
||||
"translated_text_options",
|
||||
style: TextStyle(fontWeight: FontWeight.w500),
|
||||
).tr(),
|
||||
),
|
||||
];
|
||||
|
||||
final commonActions = [
|
||||
ListTile(
|
||||
leading: const Icon(Icons.add_photo_alternate_outlined),
|
||||
onTap: () {
|
||||
context.pop();
|
||||
onAddPhotos!(album);
|
||||
},
|
||||
title: const Text(
|
||||
"share_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(album);
|
||||
},
|
||||
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 {
|
||||
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();
|
||||
},
|
||||
icon: const Icon(Icons.check_rounded),
|
||||
splashRadius: 25,
|
||||
);
|
||||
} else {
|
||||
return IconButton(
|
||||
onPressed: () async => await context.popRoute(),
|
||||
icon: const Icon(Icons.arrow_back_ios_rounded),
|
||||
splashRadius: 25,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return AppBar(
|
||||
elevation: 0,
|
||||
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);
|
||||
}
|
||||
95
mobile/lib/widgets/album/album_viewer_editable_title.dart
Normal file
95
mobile/lib/widgets/album/album_viewer_editable_title.dart
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
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';
|
||||
import 'package:immich_mobile/entities/album.entity.dart';
|
||||
|
||||
class AlbumViewerEditableTitle extends HookConsumerWidget {
|
||||
final Album album;
|
||||
final FocusNode titleFocusNode;
|
||||
const AlbumViewerEditableTitle({
|
||||
super.key,
|
||||
required this.album,
|
||||
required this.titleFocusNode,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final titleTextEditController = useTextEditingController(text: album.name);
|
||||
|
||||
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 TextField(
|
||||
onChanged: (value) {
|
||||
if (value.isEmpty) {
|
||||
} else {
|
||||
ref.watch(albumViewerProvider.notifier).setEditTitleText(value);
|
||||
}
|
||||
},
|
||||
focusNode: titleFocusNode,
|
||||
style: context.textTheme.headlineMedium,
|
||||
controller: titleTextEditController,
|
||||
onTap: () {
|
||||
FocusScope.of(context).requestFocus(titleFocusNode);
|
||||
|
||||
ref.watch(albumViewerProvider.notifier).setEditTitleText(album.name);
|
||||
ref.watch(albumViewerProvider.notifier).enableEditAlbum();
|
||||
|
||||
if (titleTextEditController.text == 'Untitled') {
|
||||
titleTextEditController.clear();
|
||||
}
|
||||
},
|
||||
decoration: InputDecoration(
|
||||
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 8),
|
||||
suffixIcon: titleFocusNode.hasFocus
|
||||
? IconButton(
|
||||
onPressed: () {
|
||||
titleTextEditController.clear();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.cancel_rounded,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
splashRadius: 10,
|
||||
)
|
||||
: null,
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: const BorderSide(color: Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: const BorderSide(color: Colors.transparent),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
focusColor: Colors.grey[300],
|
||||
fillColor: context.isDarkTheme
|
||||
? const Color.fromARGB(255, 32, 33, 35)
|
||||
: Colors.grey[200],
|
||||
filled: titleFocusNode.hasFocus,
|
||||
hintText: 'share_add_title'.tr(),
|
||||
hintStyle: TextStyle(
|
||||
fontSize: 28,
|
||||
color: context.isDarkTheme ? Colors.grey[300] : Colors.grey[700],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
28
mobile/lib/widgets/album/shared_album_thumbnail_image.dart
Normal file
28
mobile/lib/widgets/album/shared_album_thumbnail_image.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
222
mobile/lib/widgets/asset_grid/asset_drag_region.dart
Normal file
222
mobile/lib/widgets/asset_grid/asset_drag_region.dart
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
// ignore_for_file: library_private_types_in_public_api
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: prefer-single-widget-per-file
|
||||
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
|
||||
_AssetIndexProxy createRenderObject(BuildContext context) {
|
||||
return _AssetIndexProxy(
|
||||
index: AssetIndex(rowIndex: rowIndex, sectionIndex: sectionIndex),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void updateRenderObject(
|
||||
BuildContext context,
|
||||
_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;
|
||||
}
|
||||
314
mobile/lib/widgets/asset_grid/asset_grid_data_structure.dart
Normal file
314
mobile/lib/widgets/asset_grid/asset_grid_data_structure.dart
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
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;
|
||||
|
||||
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();
|
||||
for (int i = 0; i < total; i += sectionSize) {
|
||||
final date = assets != null
|
||||
? assets[i].fileCreatedAt
|
||||
: await query!.offset(i).fileCreatedAtProperty().findFirst();
|
||||
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);
|
||||
}
|
||||
382
mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart
Normal file
382
mobile/lib/widgets/asset_grid/control_bottom_app_bar.dart
Normal file
|
|
@ -0,0 +1,382 @@
|
|||
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/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/widgets/album/add_to_album_sliverlist.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/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 bool enabled;
|
||||
final bool unfavorite;
|
||||
final bool unarchive;
|
||||
final AssetSelectionState selectionAssetState;
|
||||
|
||||
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.onStack,
|
||||
this.onEditTime,
|
||||
this.onEditLocation,
|
||||
this.onRemoveFromAlbum,
|
||||
this.selectionAssetState = const AssetSelectionState(),
|
||||
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(sharedAlbumProvider);
|
||||
const bottomPadding = 0.20;
|
||||
final scrollController = useDraggableScrollController();
|
||||
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void handleRemoteDelete(
|
||||
bool force,
|
||||
Function(bool) deleteCb, {
|
||||
String? alertMsg,
|
||||
}) {
|
||||
if (!force) {
|
||||
deleteCb(force);
|
||||
return;
|
||||
}
|
||||
return showForceDeleteDialog(deleteCb, alertMsg: alertMsg);
|
||||
}
|
||||
|
||||
List<Widget> renderActionButtons() {
|
||||
return [
|
||||
if (hasRemote)
|
||||
ControlBoxButton(
|
||||
iconData: Icons.share_rounded,
|
||||
label: "control_bottom_app_bar_share".tr(),
|
||||
onPressed: enabled ? () => onShare(false) : null,
|
||||
),
|
||||
ControlBoxButton(
|
||||
iconData: Icons.ios_share_rounded,
|
||||
label: "control_bottom_app_bar_share_to".tr(),
|
||||
onPressed: enabled ? () => onShare(true) : null,
|
||||
),
|
||||
if (hasRemote && onArchive != null)
|
||||
ControlBoxButton(
|
||||
iconData: unarchive ? Icons.unarchive : Icons.archive,
|
||||
label: (unarchive
|
||||
? "control_bottom_app_bar_unarchive"
|
||||
: "control_bottom_app_bar_archive")
|
||||
.tr(),
|
||||
onPressed: enabled ? onArchive : null,
|
||||
),
|
||||
if (hasRemote && onFavorite != null)
|
||||
ControlBoxButton(
|
||||
iconData: unfavorite
|
||||
? Icons.favorite_border_rounded
|
||||
: Icons.favorite_rounded,
|
||||
label: (unfavorite
|
||||
? "control_bottom_app_bar_unfavorite"
|
||||
: "control_bottom_app_bar_favorite")
|
||||
.tr(),
|
||||
onPressed: enabled ? onFavorite : null,
|
||||
),
|
||||
if (hasLocal && hasRemote && onDelete != null)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
child: ControlBoxButton(
|
||||
iconData: Icons.delete_sweep_outlined,
|
||||
label: "control_bottom_app_bar_delete".tr(),
|
||||
onPressed: enabled
|
||||
? () => handleRemoteDelete(!trashEnabled, onDelete!)
|
||||
: null,
|
||||
onLongPressed:
|
||||
enabled ? () => showForceDeleteDialog(onDelete!) : null,
|
||||
),
|
||||
),
|
||||
if (hasRemote && onDeleteServer != null)
|
||||
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 (hasLocal && onDeleteLocal != null)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 85),
|
||||
child: ControlBoxButton(
|
||||
iconData: Icons.no_cell_rounded,
|
||||
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 (!selectionAssetState.hasLocal &&
|
||||
selectionAssetState.selectedCount > 1 &&
|
||||
onStack != null)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
child: ControlBoxButton(
|
||||
iconData: Icons.filter_none_rounded,
|
||||
label: "control_bottom_app_bar_stack".tr(),
|
||||
onPressed: enabled ? onStack : null,
|
||||
),
|
||||
),
|
||||
if (onRemoveFromAlbum != null)
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 90),
|
||||
child: ControlBoxButton(
|
||||
iconData: Icons.delete_sweep_rounded,
|
||||
label: 'album_viewer_appbar_share_remove'.tr(),
|
||||
onPressed: enabled ? onRemoveFromAlbum : null,
|
||||
),
|
||||
),
|
||||
if (selectionAssetState.hasLocal)
|
||||
ControlBoxButton(
|
||||
iconData: Icons.backup_outlined,
|
||||
label: "control_bottom_app_bar_upload".tr(),
|
||||
onPressed: enabled
|
||||
? () => showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return UploadDialog(
|
||||
onUpload: onUpload,
|
||||
);
|
||||
},
|
||||
)
|
||||
: null,
|
||||
),
|
||||
];
|
||||
}
|
||||
|
||||
return DraggableScrollableSheet(
|
||||
controller: scrollController,
|
||||
initialChildSize: hasRemote ? 0.35 : bottomPadding,
|
||||
minChildSize: bottomPadding,
|
||||
maxChildSize: hasRemote ? 0.65 : bottomPadding,
|
||||
snap: true,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
ScrollController scrollController,
|
||||
) {
|
||||
return Card(
|
||||
color: context.isDarkTheme ? Colors.grey[900] : Colors.grey[100],
|
||||
surfaceTintColor: Colors.transparent,
|
||||
elevation: 18.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: 100,
|
||||
child: ListView(
|
||||
shrinkWrap: true,
|
||||
scrollDirection: Axis.horizontal,
|
||||
children: renderActionButtons(),
|
||||
),
|
||||
),
|
||||
if (hasRemote)
|
||||
const Divider(
|
||||
indent: 16,
|
||||
endIndent: 16,
|
||||
thickness: 1,
|
||||
),
|
||||
if (hasRemote)
|
||||
_AddToAlbumTitleRow(
|
||||
onCreateNewAlbum: enabled ? onCreateNewAlbum : null,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (hasRemote)
|
||||
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: [
|
||||
const Text(
|
||||
"common_add_to_album",
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
79
mobile/lib/widgets/asset_grid/delete_dialog.dart
Normal file
79
mobile/lib/widgets/asset_grid/delete_dialog.dart
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
// ignore_for_file: prefer-single-widget-per-file
|
||||
|
||||
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: "delete_dialog_cancel",
|
||||
ok: "delete_dialog_ok",
|
||||
onOk: onDelete,
|
||||
);
|
||||
}
|
||||
|
||||
class DeleteLocalOnlyDialog extends StatelessWidget {
|
||||
final void Function(bool onlyMerged) onDeleteLocal;
|
||||
|
||||
const DeleteLocalOnlyDialog({
|
||||
super.key,
|
||||
required this.onDeleteLocal,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void onDeleteBackedUpOnly() {
|
||||
context.pop();
|
||||
onDeleteLocal(true);
|
||||
}
|
||||
|
||||
void onForceDelete() {
|
||||
context.pop();
|
||||
onDeleteLocal(false);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
title: const Text("delete_dialog_title").tr(),
|
||||
content: const Text("delete_dialog_alert_local_non_backed_up").tr(),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(
|
||||
"delete_dialog_cancel",
|
||||
style: TextStyle(
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onDeleteBackedUpOnly,
|
||||
child: Text(
|
||||
"delete_local_dialog_ok_backed_up_only",
|
||||
style: TextStyle(
|
||||
color: context.colorScheme.tertiary,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onForceDelete,
|
||||
child: Text(
|
||||
"delete_local_dialog_ok_force",
|
||||
style: TextStyle(
|
||||
color: Colors.red[400],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
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: const Icon(Icons.close_rounded),
|
||||
label: Text(
|
||||
'$selectedItemCount',
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
height: 2.5,
|
||||
color: context.isDarkTheme ? Colors.black : Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
647
mobile/lib/widgets/asset_grid/draggable_scrollbar.dart
Normal file
647
mobile/lib/widgets/asset_grid/draggable_scrollbar.dart
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
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(scrollThumbKey, 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(scrollThumbKey, 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(
|
||||
Key? scrollThumbKey,
|
||||
bool alwaysVisibleScrollThumb,
|
||||
) {
|
||||
return (
|
||||
Color backgroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Text? labelText,
|
||||
BoxConstraints? labelConstraints,
|
||||
}) {
|
||||
final scrollThumb = ClipPath(
|
||||
clipper: 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(
|
||||
Key? scrollThumbKey,
|
||||
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> {
|
||||
@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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
534
mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart
Normal file
534
mobile/lib/widgets/asset_grid/draggable_scrollbar_custom.dart
Normal file
|
|
@ -0,0 +1,534 @@
|
|||
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;
|
||||
|
||||
/// 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.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;
|
||||
|
||||
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(() {
|
||||
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 (itemPos < maxItemCount) {
|
||||
_currentItem = itemPos;
|
||||
}
|
||||
|
||||
_fadeoutTimer?.cancel();
|
||||
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onVerticalDragStart(DragStartDetails details) {
|
||||
setState(() {
|
||||
_isDragInProcess = true;
|
||||
_labelAnimationController.forward();
|
||||
_fadeoutTimer?.cancel();
|
||||
});
|
||||
|
||||
widget.scrollStateListener(true);
|
||||
}
|
||||
|
||||
int get itemPos {
|
||||
int numberOfItems = widget.child.itemCount;
|
||||
return ((_barOffset / barMaxScrollExtent) * numberOfItems).toInt();
|
||||
}
|
||||
|
||||
void _jumpToBarPos() {
|
||||
if (itemPos > maxItemCount - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
_currentItem = itemPos;
|
||||
|
||||
widget.controller.jumpTo(
|
||||
index: itemPos,
|
||||
);
|
||||
}
|
||||
|
||||
Timer? dragHaltTimer;
|
||||
int lastTimerPos = 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 (itemPos != lastTimerPos) {
|
||||
lastTimerPos = itemPos;
|
||||
dragHaltTimer?.cancel();
|
||||
widget.scrollStateListener(true);
|
||||
|
||||
dragHaltTimer = Timer(
|
||||
const Duration(milliseconds: 500),
|
||||
() {
|
||||
widget.scrollStateListener(false);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
_jumpToBarPos();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
void _onVerticalDragEnd(DragEndDetails details) {
|
||||
_fadeoutTimer = Timer(widget.scrollbarTimeToFade, () {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
});
|
||||
|
||||
setState(() {
|
||||
_jumpToBarPos();
|
||||
_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> {
|
||||
@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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
86
mobile/lib/widgets/asset_grid/group_divider_title.dart
Normal file
86
mobile/lib/widgets/asset_grid/group_divider_title.dart
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
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/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,
|
||||
)
|
||||
: const Icon(
|
||||
Icons.check_circle_outline_rounded,
|
||||
color: Colors.grey,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
141
mobile/lib/widgets/asset_grid/immich_asset_grid.dart
Normal file
141
mobile/lib/widgets/asset_grid/immich_asset_grid.dart
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
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/asset_viewer/render_list.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 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.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();
|
||||
}
|
||||
};
|
||||
}),
|
||||
},
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
if (renderList != null) return buildAssetGridView(renderList!);
|
||||
|
||||
final renderListFuture = ref.watch(renderListProvider(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);
|
||||
}
|
||||
}
|
||||
843
mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart
Normal file
843
mobile/lib/widgets/asset_grid/immich_asset_grid_view.dart
Normal file
|
|
@ -0,0 +1,843 @@
|
|||
import 'dart:collection';
|
||||
import 'dart:developer';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/rendering.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/scroll_notifier.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_drag_region.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:fluttertoast/fluttertoast.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/scroll_to_date_notifier.provider.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/tab.provider.dart';
|
||||
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
||||
|
||||
import 'asset_grid_data_structure.dart';
|
||||
import 'disable_multi_select_button.dart';
|
||||
import 'draggable_scrollbar_custom.dart';
|
||||
import 'group_divider_title.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;
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@override
|
||||
createState() {
|
||||
return ImmichAssetGridViewState();
|
||||
}
|
||||
}
|
||||
|
||||
class ImmichAssetGridViewState extends ConsumerState<ImmichAssetGridView> {
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final ScrollOffsetController _scrollOffsetController =
|
||||
ScrollOffsetController();
|
||||
final ItemPositionsListener _itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
/// 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);
|
||||
|
||||
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,
|
||||
);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
final listWidget = ScrollablePositionedList.builder(
|
||||
padding: EdgeInsets.only(
|
||||
top: appBarOffset() ? 60 : 0,
|
||||
bottom: 220,
|
||||
),
|
||||
itemBuilder: _itemBuilder,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
itemScrollController: _itemScrollController,
|
||||
scrollOffsetController: _scrollOffsetController,
|
||||
itemCount: widget.renderList.elements.length +
|
||||
(widget.topWidget != null ? 1 : 0),
|
||||
addRepaintBoundaries: true,
|
||||
shrinkWrap: widget.shrinkWrap,
|
||||
);
|
||||
|
||||
final child = useDragScrolling
|
||||
? DraggableScrollbar.semicircle(
|
||||
scrollStateListener: dragScrolling,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
controller: _itemScrollController,
|
||||
backgroundColor: context.themeData.hintColor,
|
||||
labelTextBuilder: _labelBuilder,
|
||||
padding: appBarOffset()
|
||||
? const EdgeInsets.only(top: 60)
|
||||
: const EdgeInsets.only(),
|
||||
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 != -1 && 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();
|
||||
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);
|
||||
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(() {
|
||||
_dragAnchorAssetIndex = index.rowIndex;
|
||||
_dragAnchorSectionIndex = index.sectionIndex;
|
||||
_dragging = true;
|
||||
});
|
||||
}
|
||||
|
||||
void _stopDrag() {
|
||||
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),
|
||||
onPopInvoked: (didPop) => !didPop ? _deselectAll() : 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 Function(List<Asset>) selectAssets;
|
||||
final Function(List<Asset>) deselectAssets;
|
||||
final bool Function(List<Asset>) allAssetsSelected;
|
||||
final bool showStack;
|
||||
final int heroOffset;
|
||||
final bool showStorageIndicator;
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
@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]),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// 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(
|
||||
title,
|
||||
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 Function(List<Asset>) selectAssets;
|
||||
final Function(List<Asset>) deselectAssets;
|
||||
final 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: title,
|
||||
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 Function(Asset)? onSelect;
|
||||
final 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,
|
||||
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;
|
||||
return Container(
|
||||
width: width * widthDistribution[index],
|
||||
height: width,
|
||||
margin: EdgeInsets.only(
|
||||
bottom: margin,
|
||||
right: last ? 0.0 : margin,
|
||||
),
|
||||
child: AssetIndexWrapper(
|
||||
rowIndex: rowStartIndex + index,
|
||||
sectionIndex: sectionIndex,
|
||||
child: ThumbnailImage(
|
||||
asset: asset,
|
||||
index: absoluteOffset + index,
|
||||
loadAsset: renderList.loadAsset,
|
||||
totalAssets: renderList.totalAssets,
|
||||
multiselectEnabled: selectionActive,
|
||||
isSelected: isSelectionActive && selectedAssets.contains(asset),
|
||||
onSelect: () => onSelect?.call(asset),
|
||||
onDeselect: () => onDeselect?.call(asset),
|
||||
showStorageIndicator: showStorageIndicator,
|
||||
heroOffset: heroOffset,
|
||||
showStack: showStack,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
462
mobile/lib/widgets/asset_grid/multiselect_grid.dart
Normal file
462
mobile/lib/widgets/asset_grid/multiselect_grid.dart
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
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/extensions/collection_extensions.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/services/album.service.dart';
|
||||
import 'package:immich_mobile/services/asset_stack.service.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/models/asset_selection_state.dart';
|
||||
import 'package:immich_mobile/providers/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/control_bottom_app_bar.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/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
|
||||
class MultiselectGrid extends HookConsumerWidget {
|
||||
const MultiselectGrid({
|
||||
super.key,
|
||||
required this.renderListProvider,
|
||||
this.onRefresh,
|
||||
this.buildLoadingIndicator,
|
||||
this.onRemoveFromAlbum,
|
||||
this.topWidget,
|
||||
this.stackEnabled = false,
|
||||
this.archiveEnabled = false,
|
||||
this.deleteEnabled = true,
|
||||
this.favoriteEnabled = true,
|
||||
this.editEnabled = false,
|
||||
this.unarchive = false,
|
||||
this.unfavorite = false,
|
||||
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 archiveEnabled;
|
||||
final bool unarchive;
|
||||
final bool deleteEnabled;
|
||||
final bool favoriteEnabled;
|
||||
final bool unfavorite;
|
||||
final bool editEnabled;
|
||||
final Widget? emptyIndicator;
|
||||
Widget buildDefaultLoadingIndicator() =>
|
||||
const Center(child: ImmichLoadingIndicator());
|
||||
|
||||
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
|
||||
// Filter offline assets since we cannot fetch their original file
|
||||
final liveAssets = selection.value.nonOfflineOnly(
|
||||
errorCallback: errorBuilder('asset_action_share_err_offline'.tr()),
|
||||
);
|
||||
handleShareAssets(ref, context, liveAssets);
|
||||
} 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) {
|
||||
final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset';
|
||||
final trashOrRemoved = force ? 'deleted permanently' : 'trashed';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: '${selection.value.length} $assetOrAssets $trashOrRemoved',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void onDeleteLocal(bool onlyBackedUp) async {
|
||||
processing.value = true;
|
||||
try {
|
||||
final localIds = selection.value.where((a) => a.isLocal).toList();
|
||||
|
||||
final isDeleted = await ref
|
||||
.read(assetProvider.notifier)
|
||||
.deleteLocalOnlyAssets(localIds, onlyBackedUp: onlyBackedUp);
|
||||
if (isDeleted) {
|
||||
final assetOrAssets = localIds.length > 1 ? 'assets' : 'asset';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'${localIds.length} $assetOrAssets removed permanently from your device',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
selectionEnabledHook.value = false;
|
||||
}
|
||||
} finally {
|
||||
processing.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
void onDeleteRemote([bool force = 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)
|
||||
.deleteRemoteOnlyAssets(toDelete, force: force);
|
||||
if (isDeleted) {
|
||||
final assetOrAssets = toDelete.length > 1 ? 'assets' : 'asset';
|
||||
final trashOrRemoved = force ? 'deleted permanently' : 'trashed';
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg:
|
||||
'${toDelete.length} $assetOrAssets $trashOrRemoved from the Immich server',
|
||||
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).addAdditionalAssetToAlbum(
|
||||
assets,
|
||||
album,
|
||||
);
|
||||
|
||||
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) {
|
||||
ref.watch(albumProvider.notifier).getAllAlbums();
|
||||
ref.watch(sharedAlbumProvider.notifier).getAllSharedAlbums();
|
||||
selectionEnabledHook.value = false;
|
||||
|
||||
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;
|
||||
}
|
||||
final parent = selection.value.elementAt(0);
|
||||
selection.value.remove(parent);
|
||||
await ref.read(assetStackServiceProvider).updateStack(
|
||||
parent,
|
||||
childrenToAdd: selection.value.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) {
|
||||
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) {
|
||||
handleEditLocation(ref, context, remoteAssets.toList());
|
||||
}
|
||||
} finally {
|
||||
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,
|
||||
),
|
||||
error: (error, _) => Center(child: Text(error.toString())),
|
||||
loading: buildLoadingIndicator ?? buildDefaultLoadingIndicator,
|
||||
),
|
||||
if (selectionEnabledHook.value)
|
||||
ControlBottomAppBar(
|
||||
onShare: onShareAssets,
|
||||
onFavorite: favoriteEnabled ? onFavoriteAssets : null,
|
||||
onArchive: archiveEnabled ? onArchiveAsset : null,
|
||||
onDelete: deleteEnabled ? onDelete : null,
|
||||
onDeleteServer: deleteEnabled ? onDeleteRemote : 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,
|
||||
onStack: stackEnabled ? onStack : null,
|
||||
onEditTime: editEnabled ? onEditTime : null,
|
||||
onEditLocation: editEnabled ? onEditLocation : null,
|
||||
unfavorite: unfavorite,
|
||||
unarchive: unarchive,
|
||||
onRemoveFromAlbum: onRemoveFromAlbum != null
|
||||
? wrapLongRunningFun(
|
||||
() => onRemoveFromAlbum!(selection.value),
|
||||
)
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
243
mobile/lib/widgets/asset_grid/thumbnail_image.dart
Normal file
243
mobile/lib/widgets/asset_grid/thumbnail_image.dart
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
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/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_thumbnail.dart';
|
||||
import 'package:immich_mobile/utils/storage_indicator.dart';
|
||||
import 'package:isar/isar.dart';
|
||||
|
||||
class ThumbnailImage extends ConsumerWidget {
|
||||
final Asset asset;
|
||||
final int index;
|
||||
final Asset Function(int index) loadAsset;
|
||||
final int totalAssets;
|
||||
final bool showStorageIndicator;
|
||||
final bool showStack;
|
||||
final bool isSelected;
|
||||
final bool multiselectEnabled;
|
||||
final Function? onSelect;
|
||||
final Function? onDeselect;
|
||||
final int heroOffset;
|
||||
|
||||
const ThumbnailImage({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.index,
|
||||
required this.loadAsset,
|
||||
required this.totalAssets,
|
||||
this.showStorageIndicator = true,
|
||||
this.showStack = false,
|
||||
this.isSelected = false,
|
||||
this.multiselectEnabled = false,
|
||||
this.onDeselect,
|
||||
this.onSelect,
|
||||
this.heroOffset = 0,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetContainerColor = context.isDarkTheme
|
||||
? Colors.blueGrey
|
||||
: context.themeData.primaryColorLight;
|
||||
// Assets from response DTOs do not have an isar id, querying which would give us the default autoIncrement id
|
||||
final isFromDto = asset.id == Isar.autoIncrement;
|
||||
|
||||
Widget buildSelectionIcon(Asset asset) {
|
||||
if (isSelected) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: assetContainerColor,
|
||||
),
|
||||
child: Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
);
|
||||
} else {
|
||||
return const Icon(
|
||||
Icons.circle_outlined,
|
||||
color: Colors.white,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Widget buildVideoIcon() {
|
||||
final minutes = asset.duration.inMinutes;
|
||||
final durationString = asset.duration.toString();
|
||||
return Positioned(
|
||||
top: 5,
|
||||
right: 8,
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
minutes > 59
|
||||
? durationString.substring(0, 7) // h:mm:ss
|
||||
: minutes > 0
|
||||
? durationString.substring(2, 7) // mm:ss
|
||||
: durationString.substring(3, 7), // m:ss
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildStackIcon() {
|
||||
return Positioned(
|
||||
top: !asset.isImage ? 28 : 5,
|
||||
right: 8,
|
||||
child: Row(
|
||||
children: [
|
||||
if (asset.stackChildrenCount > 1)
|
||||
Text(
|
||||
"${asset.stackChildrenCount}",
|
||||
style: const TextStyle(
|
||||
color: Colors.white,
|
||||
fontSize: 10,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
if (asset.stackChildrenCount > 1)
|
||||
const SizedBox(
|
||||
width: 3,
|
||||
),
|
||||
const Icon(
|
||||
Icons.burst_mode_rounded,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildImage() {
|
||||
final image = SizedBox(
|
||||
width: 300,
|
||||
height: 300,
|
||||
child: Hero(
|
||||
tag: isFromDto
|
||||
? '${asset.remoteId}-$heroOffset'
|
||||
: asset.id + heroOffset,
|
||||
child: ImmichThumbnail(
|
||||
asset: asset,
|
||||
height: 250,
|
||||
width: 250,
|
||||
),
|
||||
),
|
||||
);
|
||||
if (!multiselectEnabled || !isSelected) {
|
||||
return image;
|
||||
}
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
width: 0,
|
||||
color: onDeselect == null ? Colors.grey : assetContainerColor,
|
||||
),
|
||||
color: onDeselect == null ? Colors.grey : assetContainerColor,
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.only(
|
||||
topRight: Radius.circular(15.0),
|
||||
bottomRight: Radius.circular(15.0),
|
||||
bottomLeft: Radius.circular(15.0),
|
||||
topLeft: Radius.zero,
|
||||
),
|
||||
child: image,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
if (multiselectEnabled) {
|
||||
if (isSelected) {
|
||||
onDeselect?.call();
|
||||
} else {
|
||||
onSelect?.call();
|
||||
}
|
||||
} else {
|
||||
context.pushRoute(
|
||||
GalleryViewerRoute(
|
||||
initialIndex: index,
|
||||
loadAsset: loadAsset,
|
||||
totalAssets: totalAssets,
|
||||
heroOffset: heroOffset,
|
||||
showStack: showStack,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
onLongPress: () {
|
||||
onSelect?.call();
|
||||
ref.read(hapticFeedbackProvider.notifier).heavyImpact();
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 300),
|
||||
curve: Curves.decelerate,
|
||||
decoration: BoxDecoration(
|
||||
border: multiselectEnabled && isSelected
|
||||
? Border.all(
|
||||
color: onDeselect == null
|
||||
? Colors.grey
|
||||
: assetContainerColor,
|
||||
width: 8,
|
||||
)
|
||||
: const Border(),
|
||||
),
|
||||
child: buildImage(),
|
||||
),
|
||||
if (multiselectEnabled)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(3.0),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: buildSelectionIcon(asset),
|
||||
),
|
||||
),
|
||||
if (showStorageIndicator)
|
||||
Positioned(
|
||||
right: 8,
|
||||
bottom: 5,
|
||||
child: Icon(
|
||||
storageIcon(asset),
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
if (asset.isFavorite)
|
||||
const Positioned(
|
||||
left: 8,
|
||||
bottom: 5,
|
||||
child: Icon(
|
||||
Icons.favorite,
|
||||
color: Colors.white,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
if (!asset.isImage) buildVideoIcon(),
|
||||
if (asset.stackChildrenCount > 0) buildStackIcon(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
41
mobile/lib/widgets/asset_grid/thumbnail_placeholder.dart
Normal file
41
mobile/lib/widgets/asset_grid/thumbnail_placeholder.dart
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class ThumbnailPlaceholder extends StatelessWidget {
|
||||
final EdgeInsets margin;
|
||||
final double width;
|
||||
final double height;
|
||||
|
||||
const ThumbnailPlaceholder({
|
||||
super.key,
|
||||
this.margin = EdgeInsets.zero,
|
||||
this.width = 250,
|
||||
this.height = 250,
|
||||
});
|
||||
|
||||
static const _brightColors = [
|
||||
Color(0xFFF1F3F4),
|
||||
Color(0xFFB4B6B8),
|
||||
];
|
||||
|
||||
static const _darkColors = [
|
||||
Color(0xFF3B3F42),
|
||||
Color(0xFF2B2F32),
|
||||
];
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: width,
|
||||
height: height,
|
||||
margin: margin,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: context.isDarkTheme ? _darkColors : _brightColors,
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
14
mobile/lib/widgets/asset_grid/upload_dialog.dart
Normal file
14
mobile/lib/widgets/asset_grid/upload_dialog.dart
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
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: 'upload_dialog_cancel',
|
||||
ok: 'upload_dialog_ok',
|
||||
onOk: onUpload,
|
||||
);
|
||||
}
|
||||
98
mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart
Normal file
98
mobile/lib/widgets/asset_viewer/advanced_bottom_sheet.dart
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
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;
|
||||
|
||||
const AdvancedBottomSheet({super.key, required this.assetDetail});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SingleChildScrollView(
|
||||
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: BorderRadius.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((_) {
|
||||
ScaffoldMessenger.of(context).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: "Inconsolata",
|
||||
),
|
||||
showCursor: true,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32.0),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
57
mobile/lib/widgets/asset_viewer/animated_play_pause.dart
Normal file
57
mobile/lib/widgets/asset_viewer/animated_play_pause.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
/// A widget that animates implicitly between a play and a pause icon.
|
||||
class AnimatedPlayPause extends StatefulWidget {
|
||||
const AnimatedPlayPause({
|
||||
super.key,
|
||||
required this.playing,
|
||||
this.size,
|
||||
this.color,
|
||||
});
|
||||
|
||||
final double? size;
|
||||
final bool playing;
|
||||
final Color? color;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() => AnimatedPlayPauseState();
|
||||
}
|
||||
|
||||
class AnimatedPlayPauseState extends State<AnimatedPlayPause>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final animationController = AnimationController(
|
||||
vsync: this,
|
||||
value: widget.playing ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 300),
|
||||
);
|
||||
|
||||
@override
|
||||
void didUpdateWidget(AnimatedPlayPause oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.playing != oldWidget.playing) {
|
||||
if (widget.playing) {
|
||||
animationController.forward();
|
||||
} else {
|
||||
animationController.reverse();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: AnimatedIcon(
|
||||
color: widget.color,
|
||||
size: widget.size,
|
||||
icon: AnimatedIcons.play_pause,
|
||||
progress: animationController,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
335
mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
Normal file
335
mobile/lib/widgets/asset_viewer/bottom_gallery_bar.dart
Normal file
|
|
@ -0,0 +1,335 @@
|
|||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/services/asset_stack.service.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/delete_dialog.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class BottomGalleryBar extends ConsumerWidget {
|
||||
final Asset asset;
|
||||
final bool showStack;
|
||||
final int stackIndex;
|
||||
final int totalAssets;
|
||||
final bool showVideoPlayerControls;
|
||||
final PageController controller;
|
||||
|
||||
const BottomGalleryBar({
|
||||
super.key,
|
||||
required this.showStack,
|
||||
required this.stackIndex,
|
||||
required this.asset,
|
||||
required this.controller,
|
||||
required this.totalAssets,
|
||||
required this.showVideoPlayerControls,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||
|
||||
final stack = showStack && asset.stackChildrenCount > 0
|
||||
? ref.watch(assetStackStateProvider(asset))
|
||||
: <Asset>[];
|
||||
final stackElements = showStack ? [asset, ...stack] : <Asset>[];
|
||||
bool isParent = stackIndex == -1 || stackIndex == 0;
|
||||
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;
|
||||
// !!!! itemsList and actionlist should always be in sync
|
||||
final itemsList = [
|
||||
BottomNavigationBarItem(
|
||||
icon: Icon(
|
||||
Platform.isAndroid ? Icons.share_rounded : Icons.ios_share_rounded,
|
||||
),
|
||||
label: 'control_bottom_app_bar_share'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_share'.tr(),
|
||||
),
|
||||
if (isOwner)
|
||||
asset.isArchived
|
||||
? BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.unarchive_rounded),
|
||||
label: 'control_bottom_app_bar_unarchive'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_unarchive'.tr(),
|
||||
)
|
||||
: BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.archive_outlined),
|
||||
label: 'control_bottom_app_bar_archive'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_archive'.tr(),
|
||||
),
|
||||
if (isOwner && stack.isNotEmpty)
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.burst_mode_outlined),
|
||||
label: 'control_bottom_app_bar_stack'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_stack'.tr(),
|
||||
),
|
||||
if (isOwner)
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.delete_outline),
|
||||
label: 'control_bottom_app_bar_delete'.tr(),
|
||||
tooltip: 'control_bottom_app_bar_delete'.tr(),
|
||||
),
|
||||
if (!isOwner)
|
||||
BottomNavigationBarItem(
|
||||
icon: const Icon(Icons.download_outlined),
|
||||
label: 'download'.tr(),
|
||||
tooltip: 'download'.tr(),
|
||||
),
|
||||
];
|
||||
|
||||
void removeAssetFromStack() {
|
||||
if (stackIndex > 0 && showStack) {
|
||||
ref
|
||||
.read(assetStackStateProvider(asset).notifier)
|
||||
.removeChild(stackIndex - 1);
|
||||
}
|
||||
}
|
||||
|
||||
void handleDelete() async {
|
||||
Future<bool> onDelete(bool force) async {
|
||||
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
|
||||
{asset},
|
||||
force: force,
|
||||
);
|
||||
if (isDeleted && isParent) {
|
||||
if (totalAssets == 1) {
|
||||
// Handle only one asset
|
||||
context.popRoute();
|
||||
} else {
|
||||
// Go to next page otherwise
|
||||
controller.nextPage(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
curve: Curves.fastLinearToSlowEaseIn,
|
||||
);
|
||||
}
|
||||
}
|
||||
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 && isParent) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 1,
|
||||
context: context,
|
||||
msg: 'Asset trashed',
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
}
|
||||
removeAssetFromStack();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Asset is permanently removed
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return DeleteDialog(
|
||||
onDelete: () async {
|
||||
final isDeleted = await onDelete(true);
|
||||
if (isDeleted) {
|
||||
removeAssetFromStack();
|
||||
}
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
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: [
|
||||
if (!isParent)
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.bookmark_border_outlined,
|
||||
size: 24,
|
||||
),
|
||||
onTap: () async {
|
||||
await ref
|
||||
.read(assetStackServiceProvider)
|
||||
.updateStackParent(
|
||||
asset,
|
||||
stackElements.elementAt(stackIndex),
|
||||
);
|
||||
ctx.pop();
|
||||
context.popRoute();
|
||||
},
|
||||
title: const Text(
|
||||
"viewer_stack_use_as_main_asset",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.copy_all_outlined,
|
||||
size: 24,
|
||||
),
|
||||
onTap: () async {
|
||||
if (isParent) {
|
||||
await ref
|
||||
.read(assetStackServiceProvider)
|
||||
.updateStackParent(
|
||||
asset,
|
||||
stackElements
|
||||
.elementAt(1), // Next asset as parent
|
||||
);
|
||||
// Remove itself from stack
|
||||
await ref.read(assetStackServiceProvider).updateStack(
|
||||
stackElements.elementAt(1),
|
||||
childrenToRemove: [asset],
|
||||
);
|
||||
ctx.pop();
|
||||
context.popRoute();
|
||||
} else {
|
||||
await ref.read(assetStackServiceProvider).updateStack(
|
||||
asset,
|
||||
childrenToRemove: [
|
||||
stackElements.elementAt(stackIndex),
|
||||
],
|
||||
);
|
||||
removeAssetFromStack();
|
||||
ctx.pop();
|
||||
}
|
||||
},
|
||||
title: const Text(
|
||||
"viewer_remove_from_stack",
|
||||
style: TextStyle(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
ListTile(
|
||||
leading: const Icon(
|
||||
Icons.filter_none_outlined,
|
||||
size: 18,
|
||||
),
|
||||
onTap: () async {
|
||||
await ref.read(assetStackServiceProvider).updateStack(
|
||||
asset,
|
||||
childrenToRemove: stack,
|
||||
);
|
||||
ctx.pop();
|
||||
context.popRoute();
|
||||
},
|
||||
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(imageViewerStateProvider.notifier).shareAsset(asset, context);
|
||||
}
|
||||
|
||||
handleArchive() {
|
||||
ref.read(assetProvider.notifier).toggleArchive([asset]);
|
||||
if (isParent) {
|
||||
context.popRoute();
|
||||
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(imageViewerStateProvider.notifier).downloadAsset(
|
||||
asset,
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
List<Function(int)> actionslist = [
|
||||
(_) => shareAsset(),
|
||||
if (isOwner) (_) => handleArchive(),
|
||||
if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(),
|
||||
if (isOwner) (_) => handleDelete(),
|
||||
if (!isOwner) (_) => handleDownload(),
|
||||
];
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: !ref.watch(showControlsProvider),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||
child: Column(
|
||||
children: [
|
||||
Visibility(
|
||||
visible: showVideoPlayerControls,
|
||||
child: const VideoControls(),
|
||||
),
|
||||
BottomNavigationBar(
|
||||
backgroundColor: Colors.black.withOpacity(0.4),
|
||||
unselectedIconTheme: const IconThemeData(color: Colors.white),
|
||||
selectedIconTheme: const IconThemeData(color: Colors.white),
|
||||
unselectedLabelStyle: const TextStyle(color: Colors.black),
|
||||
selectedLabelStyle: const TextStyle(color: Colors.black),
|
||||
showSelectedLabels: false,
|
||||
showUnselectedLabels: false,
|
||||
items: itemsList,
|
||||
onTap: (index) {
|
||||
if (index < actionslist.length) {
|
||||
actionslist[index].call(index);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
53
mobile/lib/widgets/asset_viewer/center_play_button.dart
Normal file
53
mobile/lib/widgets/asset_viewer/center_play_button.dart
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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 ColoredBox(
|
||||
color: Colors.transparent,
|
||||
child: 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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,107 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/center_play_button.dart';
|
||||
import 'package:immich_mobile/widgets/common/delayed_loading_indicator.dart';
|
||||
import 'package:immich_mobile/shared/ui/hooks/timer_hook.dart';
|
||||
|
||||
class CustomVideoPlayerControls extends HookConsumerWidget {
|
||||
final Duration hideTimerDuration;
|
||||
|
||||
const CustomVideoPlayerControls({
|
||||
super.key,
|
||||
this.hideTimerDuration = const Duration(seconds: 3),
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// A timer to hide the controls
|
||||
final hideTimer = useTimer(
|
||||
hideTimerDuration,
|
||||
() {
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
// Do not hide on paused
|
||||
if (state != VideoPlaybackState.paused) {
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
final showBuffering = useState(false);
|
||||
final VideoPlaybackState state =
|
||||
ref.watch(videoPlaybackValueProvider).state;
|
||||
|
||||
/// Shows the controls and starts the timer to hide them
|
||||
void showControlsAndStartHideTimer() {
|
||||
hideTimer.reset();
|
||||
ref.read(showControlsProvider.notifier).show = true;
|
||||
}
|
||||
|
||||
// When we mute, show the controls
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.mute),
|
||||
(previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
});
|
||||
|
||||
// When we change position, show or hide timer
|
||||
ref.listen(videoPlayerControlsProvider.select((v) => v.position),
|
||||
(previous, next) {
|
||||
showControlsAndStartHideTimer();
|
||||
});
|
||||
|
||||
ref.listen(videoPlaybackValueProvider.select((value) => value.state),
|
||||
(_, state) {
|
||||
// Show buffering
|
||||
showBuffering.value = state == VideoPlaybackState.buffering;
|
||||
});
|
||||
|
||||
/// Toggles between playing and pausing depending on the state of the video
|
||||
void togglePlay() {
|
||||
showControlsAndStartHideTimer();
|
||||
final state = ref.read(videoPlaybackValueProvider).state;
|
||||
if (state == VideoPlaybackState.playing) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).pause();
|
||||
} else {
|
||||
ref.read(videoPlayerControlsProvider.notifier).play();
|
||||
}
|
||||
}
|
||||
|
||||
return GestureDetector(
|
||||
behavior: HitTestBehavior.opaque,
|
||||
onTap: showControlsAndStartHideTimer,
|
||||
child: AbsorbPointer(
|
||||
absorbing: !ref.watch(showControlsProvider),
|
||||
child: Stack(
|
||||
children: [
|
||||
if (showBuffering.value)
|
||||
const Center(
|
||||
child: DelayedLoadingIndicator(
|
||||
fadeInDuration: Duration(milliseconds: 400),
|
||||
),
|
||||
)
|
||||
else
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
if (state != VideoPlaybackState.playing) {
|
||||
togglePlay();
|
||||
}
|
||||
ref.read(showControlsProvider.notifier).show = false;
|
||||
},
|
||||
child: CenterPlayButton(
|
||||
backgroundColor: Colors.black54,
|
||||
iconColor: Colors.white,
|
||||
isFinished: state == VideoPlaybackState.completed,
|
||||
isPlaying: state == VideoPlaybackState.playing,
|
||||
show: ref.watch(showControlsProvider),
|
||||
onPressed: togglePlay,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
107
mobile/lib/widgets/asset_viewer/description_input.dart
Normal file
107
mobile/lib/widgets/asset_viewer/description_input.dart
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
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/asset_viewer/asset_description.provider.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.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,
|
||||
});
|
||||
|
||||
final Asset asset;
|
||||
final Logger _log = Logger('DescriptionInput');
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final controller = useTextEditingController();
|
||||
final focusNode = useFocusNode();
|
||||
final isFocus = useState(false);
|
||||
final isTextEmpty = useState(controller.text.isEmpty);
|
||||
final descriptionProvider =
|
||||
ref.watch(assetDescriptionProvider(asset).notifier);
|
||||
final description = ref.watch(assetDescriptionProvider(asset));
|
||||
final owner = ref.watch(currentUserProvider);
|
||||
final hasError = useState(false);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
controller.text = description;
|
||||
isTextEmpty.value = description.isEmpty;
|
||||
return null;
|
||||
},
|
||||
[description],
|
||||
);
|
||||
|
||||
submitDescription(String description) async {
|
||||
hasError.value = false;
|
||||
try {
|
||||
await descriptionProvider.setDescription(
|
||||
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: Colors.grey[500],
|
||||
),
|
||||
splashRadius: 10,
|
||||
);
|
||||
}
|
||||
|
||||
return TextField(
|
||||
enabled: owner?.isarId == asset.ownerId,
|
||||
focusNode: focusNode,
|
||||
onTap: () => isFocus.value = true,
|
||||
onChanged: (value) {
|
||||
isTextEmpty.value = false;
|
||||
},
|
||||
onTapOutside: (a) async {
|
||||
isFocus.value = false;
|
||||
focusNode.unfocus();
|
||||
|
||||
if (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,
|
||||
hintStyle: context.textTheme.labelLarge?.copyWith(
|
||||
color: textColor.withOpacity(0.5),
|
||||
),
|
||||
suffixIcon: suffixIcon,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,210 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/widgets/asset_viewer/description_input.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_detail.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_image_properties.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_location.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/exif_sheet/exif_people.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.dart';
|
||||
|
||||
class ExifBottomSheet extends HookConsumerWidget {
|
||||
final Asset asset;
|
||||
|
||||
const ExifBottomSheet({super.key, required this.asset});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetWithExif = ref.watch(assetDetailProvider(asset));
|
||||
var textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
final ExifInfo? exifInfo = (assetWithExif.value ?? asset).exifInfo;
|
||||
// Format the date time with the timezone
|
||||
final (dt, timeZone) =
|
||||
(assetWithExif.value ?? asset).getTZAdjustedTimeAndOffset();
|
||||
final date = DateFormat.yMMMEd().format(dt);
|
||||
final time = DateFormat.jm().format(dt);
|
||||
|
||||
String formattedDateTime = '$date • $time GMT${timeZone.formatAsOffset()}';
|
||||
|
||||
final dateWidget = Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
formattedDateTime,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (asset.isRemote)
|
||||
IconButton(
|
||||
onPressed: () => handleEditDateTime(
|
||||
ref,
|
||||
context,
|
||||
[assetWithExif.value ?? asset],
|
||||
),
|
||||
icon: const Icon(Icons.edit_outlined),
|
||||
iconSize: 20,
|
||||
),
|
||||
],
|
||||
);
|
||||
|
||||
return SingleChildScrollView(
|
||||
padding: const EdgeInsets.only(
|
||||
bottom: 50,
|
||||
),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final horizontalPadding = constraints.maxWidth > 600 ? 24.0 : 16.0;
|
||||
if (constraints.maxWidth > 600) {
|
||||
// Two column
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||
child: Column(
|
||||
children: [
|
||||
dateWidget,
|
||||
if (asset.isRemote) DescriptionInput(asset: asset),
|
||||
],
|
||||
),
|
||||
),
|
||||
ExifPeople(
|
||||
asset: asset,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 8.0),
|
||||
child: ExifLocation(
|
||||
asset: asset,
|
||||
exifInfo: exifInfo,
|
||||
editLocation: () => handleEditLocation(
|
||||
ref,
|
||||
context,
|
||||
[assetWithExif.value ?? asset],
|
||||
),
|
||||
formattedDateTime: formattedDateTime,
|
||||
),
|
||||
),
|
||||
),
|
||||
ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 8.0),
|
||||
child: ExifDetail(asset: asset, exifInfo: exifInfo),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// One column
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
dateWidget,
|
||||
if (asset.isRemote) DescriptionInput(asset: asset),
|
||||
Padding(
|
||||
padding: EdgeInsets.only(top: asset.isRemote ? 0 : 16.0),
|
||||
child: ExifLocation(
|
||||
asset: asset,
|
||||
exifInfo: exifInfo,
|
||||
editLocation: () => handleEditLocation(
|
||||
ref,
|
||||
context,
|
||||
[assetWithExif.value ?? asset],
|
||||
),
|
||||
formattedDateTime: formattedDateTime,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: ExifPeople(
|
||||
asset: asset,
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: horizontalPadding,
|
||||
),
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"exif_bottom_sheet_details",
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color
|
||||
?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
ExifImageProperties(asset: asset),
|
||||
if (exifInfo?.make != null)
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 50),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
mobile/lib/widgets/asset_viewer/exif_sheet/exif_detail.dart
Normal file
60
mobile/lib/widgets/asset_viewer/exif_sheet/exif_detail.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
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/asset_viewer/exif_sheet/exif_image_properties.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
|
||||
class ExifDetail extends StatelessWidget {
|
||||
final Asset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
|
||||
const ExifDetail({
|
||||
super.key,
|
||||
required this.asset,
|
||||
this.exifInfo,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: Text(
|
||||
"exif_bottom_sheet_details",
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
ExifImageProperties(asset: asset),
|
||||
if (exifInfo?.make != null)
|
||||
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,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,58 @@
|
|||
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 ExifImageProperties extends StatelessWidget {
|
||||
final Asset asset;
|
||||
|
||||
const ExifImageProperties({
|
||||
super.key,
|
||||
required this.asset,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final textColor = context.isDarkTheme ? Colors.white : Colors.black;
|
||||
|
||||
String resolution = asset.width != null && asset.height != null
|
||||
? "${asset.height} x ${asset.width} "
|
||||
: "";
|
||||
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),
|
||||
);
|
||||
}
|
||||
}
|
||||
105
mobile/lib/widgets/asset_viewer/exif_sheet/exif_location.dart
Normal file
105
mobile/lib/widgets/asset_viewer/exif_sheet/exif_location.dart
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
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/asset_viewer/exif_sheet/exif_map.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
|
||||
class ExifLocation extends StatelessWidget {
|
||||
final Asset asset;
|
||||
final ExifInfo? exifInfo;
|
||||
final void Function() editLocation;
|
||||
final String formattedDateTime;
|
||||
|
||||
const ExifLocation({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.exifInfo,
|
||||
required this.editLocation,
|
||||
required this.formattedDateTime,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
|
||||
// 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(
|
||||
"exif_bottom_sheet_location_add",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
onTap: editLocation,
|
||||
)
|
||||
: const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Location
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
ExifMap(
|
||||
exifInfo: exifInfo!,
|
||||
formattedDateTime: formattedDateTime,
|
||||
markerId: asset.remoteId,
|
||||
),
|
||||
RichText(
|
||||
text: TextSpan(
|
||||
style: context.textTheme.labelLarge,
|
||||
children: [
|
||||
if (exifInfo != null && exifInfo?.city != null)
|
||||
TextSpan(
|
||||
text: exifInfo!.city,
|
||||
),
|
||||
if (exifInfo != null &&
|
||||
exifInfo?.city != null &&
|
||||
exifInfo?.state != null)
|
||||
const TextSpan(
|
||||
text: ", ",
|
||||
),
|
||||
if (exifInfo != null && exifInfo?.state != null)
|
||||
TextSpan(
|
||||
text: exifInfo!.state,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}",
|
||||
style: context.textTheme.labelMedium?.copyWith(
|
||||
color: context.textTheme.labelMedium?.color?.withAlpha(150),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
94
mobile/lib/widgets/asset_viewer/exif_sheet/exif_map.dart
Normal file
94
mobile/lib/widgets/asset_viewer/exif_sheet/exif_map.dart
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/entities/exif_info.entity.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ExifMap extends StatelessWidget {
|
||||
final ExifInfo exifInfo;
|
||||
final String formattedDateTime;
|
||||
final String? markerId;
|
||||
|
||||
const ExifMap({
|
||||
super.key,
|
||||
required this.exifInfo,
|
||||
required this.formattedDateTime,
|
||||
this.markerId = 'marker',
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final hasCoordinates = exifInfo.hasCoordinates;
|
||||
Future<Uri?> createCoordinatesUri() async {
|
||||
if (!hasCoordinates) {
|
||||
return null;
|
||||
}
|
||||
|
||||
final double latitude = exifInfo.latitude!;
|
||||
final double longitude = exifInfo.longitude!;
|
||||
|
||||
const zoomLevel = 16;
|
||||
|
||||
if (Platform.isAndroid) {
|
||||
Uri uri = Uri(
|
||||
scheme: 'geo',
|
||||
host: '$latitude,$longitude',
|
||||
queryParameters: {
|
||||
'z': '$zoomLevel',
|
||||
'q': '$latitude,$longitude($formattedDateTime)',
|
||||
},
|
||||
);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
}
|
||||
} else if (Platform.isIOS) {
|
||||
var params = {
|
||||
'll': '$latitude,$longitude',
|
||||
'q': formattedDateTime,
|
||||
'z': '$zoomLevel',
|
||||
};
|
||||
Uri uri = Uri.https('maps.apple.com', '/', params);
|
||||
if (await canLaunchUrl(uri)) {
|
||||
return uri;
|
||||
}
|
||||
}
|
||||
|
||||
return Uri(
|
||||
scheme: 'https',
|
||||
host: 'openstreetmap.org',
|
||||
queryParameters: {'mlat': '$latitude', 'mlon': '$longitude'},
|
||||
fragment: 'map=$zoomLevel/$latitude/$longitude',
|
||||
);
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return MapThumbnail(
|
||||
centre: LatLng(
|
||||
exifInfo.latitude ?? 0,
|
||||
exifInfo.longitude ?? 0,
|
||||
),
|
||||
height: 150,
|
||||
width: constraints.maxWidth,
|
||||
zoom: 12.0,
|
||||
assetMarkerRemoteId: markerId,
|
||||
onTap: (tapPosition, latLong) async {
|
||||
Uri? uri = await createCoordinatesUri();
|
||||
|
||||
if (uri == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
debugPrint('Opening Map Uri: $uri');
|
||||
launchUrl(uri);
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
mobile/lib/widgets/asset_viewer/exif_sheet/exif_people.dart
Normal file
97
mobile/lib/widgets/asset_viewer/exif_sheet/exif_people.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
import 'dart:math' as math;
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/asset_people.provider.dart';
|
||||
import 'package:immich_mobile/models/search/search_curated_content.model.dart';
|
||||
import 'package:immich_mobile/widgets/search/curated_people_row.dart';
|
||||
import 'package:immich_mobile/widgets/search/person_name_edit_form.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
|
||||
class ExifPeople extends ConsumerWidget {
|
||||
final Asset asset;
|
||||
final EdgeInsets? padding;
|
||||
|
||||
const ExifPeople({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);
|
||||
final double imageSize = math.min(context.width / 3, 150);
|
||||
|
||||
showPersonNameEditModel(
|
||||
String personId,
|
||||
String personName,
|
||||
) {
|
||||
return showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext context) {
|
||||
return PersonNameEditForm(personId: personId, personName: personName);
|
||||
},
|
||||
).then((_) {
|
||||
// ensure the people list is up-to-date.
|
||||
peopleProvider.refresh();
|
||||
});
|
||||
}
|
||||
|
||||
if (people?.isEmpty ?? true) {
|
||||
// Empty list or loading
|
||||
return Container();
|
||||
}
|
||||
|
||||
final curatedPeople = people
|
||||
?.map((p) => SearchCuratedContent(id: p.id, label: p.name))
|
||||
.toList() ??
|
||||
[];
|
||||
|
||||
return 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(),
|
||||
),
|
||||
),
|
||||
SizedBox(
|
||||
height: imageSize,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.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),
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
126
mobile/lib/widgets/asset_viewer/gallery_app_bar.dart
Normal file
126
mobile/lib/widgets/asset_viewer/gallery_app_bar.dart
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
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/providers/album/current_album.provider.dart';
|
||||
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/providers/trash.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/upload_dialog.dart';
|
||||
import 'package:immich_mobile/providers/partner.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
|
||||
class GalleryAppBar extends ConsumerWidget {
|
||||
final Asset asset;
|
||||
final void Function() showInfo;
|
||||
final void Function() onToggleMotionVideo;
|
||||
final bool isPlayingVideo;
|
||||
|
||||
const GalleryAppBar({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.showInfo,
|
||||
required this.onToggleMotionVideo,
|
||||
required this.isPlayingVideo,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final album = ref.watch(currentAlbumProvider);
|
||||
final isOwner = asset.ownerId == ref.watch(currentUserProvider)?.isarId;
|
||||
|
||||
final isPartner = ref
|
||||
.watch(partnerSharedWithProvider)
|
||||
.map((e) => e.isarId)
|
||||
.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).restoreAsset(asset);
|
||||
|
||||
if (result && context.mounted) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'asset restored successfully',
|
||||
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: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
),
|
||||
context: context,
|
||||
builder: (BuildContext _) {
|
||||
return AddToAlbumBottomSheet(
|
||||
assets: [addToAlbumAsset],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
return IgnorePointer(
|
||||
ignoring: !ref.watch(showControlsProvider),
|
||||
child: AnimatedOpacity(
|
||||
duration: const Duration(milliseconds: 100),
|
||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: TopControlAppBar(
|
||||
isOwner: isOwner,
|
||||
isPartner: isPartner,
|
||||
isPlayingMotionVideo: isPlayingVideo,
|
||||
asset: asset,
|
||||
onMoreInfoPressed: showInfo,
|
||||
onFavorite: toggleFavorite,
|
||||
onRestorePressed: () => handleRestore(asset),
|
||||
onUploadPressed: asset.isLocal ? () => handleUpload(asset) : null,
|
||||
onDownloadPressed: asset.isLocal
|
||||
? null
|
||||
: () =>
|
||||
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
||||
asset,
|
||||
context,
|
||||
),
|
||||
onToggleMotionVideo: onToggleMotionVideo,
|
||||
onAddToAlbumPressed: () => addToAlbum(asset),
|
||||
onActivitiesPressed: handleActivities,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,156 @@
|
|||
import 'package:chewie/chewie.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
/// Provides the initialized video player controller
|
||||
/// If the asset is local, use the local file
|
||||
/// Otherwise, use a video player with a URL
|
||||
ChewieController useChewieController({
|
||||
required VideoPlayerController controller,
|
||||
EdgeInsets controlsSafeAreaMinimum = const EdgeInsets.only(
|
||||
bottom: 100,
|
||||
),
|
||||
bool showOptions = true,
|
||||
bool showControlsOnInitialize = false,
|
||||
bool autoPlay = true,
|
||||
bool allowFullScreen = false,
|
||||
bool allowedScreenSleep = false,
|
||||
bool showControls = true,
|
||||
Widget? customControls,
|
||||
Widget? placeholder,
|
||||
Duration hideControlsTimer = const Duration(seconds: 1),
|
||||
VoidCallback? onPlaying,
|
||||
VoidCallback? onPaused,
|
||||
VoidCallback? onVideoEnded,
|
||||
}) {
|
||||
return use(
|
||||
_ChewieControllerHook(
|
||||
controller: controller,
|
||||
placeholder: placeholder,
|
||||
showOptions: showOptions,
|
||||
controlsSafeAreaMinimum: controlsSafeAreaMinimum,
|
||||
autoPlay: autoPlay,
|
||||
allowFullScreen: allowFullScreen,
|
||||
customControls: customControls,
|
||||
hideControlsTimer: hideControlsTimer,
|
||||
showControlsOnInitialize: showControlsOnInitialize,
|
||||
showControls: showControls,
|
||||
allowedScreenSleep: allowedScreenSleep,
|
||||
onPlaying: onPlaying,
|
||||
onPaused: onPaused,
|
||||
onVideoEnded: onVideoEnded,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _ChewieControllerHook extends Hook<ChewieController> {
|
||||
final VideoPlayerController controller;
|
||||
final EdgeInsets controlsSafeAreaMinimum;
|
||||
final bool showOptions;
|
||||
final bool showControlsOnInitialize;
|
||||
final bool autoPlay;
|
||||
final bool allowFullScreen;
|
||||
final bool allowedScreenSleep;
|
||||
final bool showControls;
|
||||
final Widget? customControls;
|
||||
final Widget? placeholder;
|
||||
final Duration hideControlsTimer;
|
||||
final VoidCallback? onPlaying;
|
||||
final VoidCallback? onPaused;
|
||||
final VoidCallback? onVideoEnded;
|
||||
|
||||
const _ChewieControllerHook({
|
||||
required this.controller,
|
||||
this.controlsSafeAreaMinimum = const EdgeInsets.only(
|
||||
bottom: 100,
|
||||
),
|
||||
this.showOptions = true,
|
||||
this.showControlsOnInitialize = false,
|
||||
this.autoPlay = true,
|
||||
this.allowFullScreen = false,
|
||||
this.allowedScreenSleep = false,
|
||||
this.showControls = true,
|
||||
this.customControls,
|
||||
this.placeholder,
|
||||
this.hideControlsTimer = const Duration(seconds: 3),
|
||||
this.onPlaying,
|
||||
this.onPaused,
|
||||
this.onVideoEnded,
|
||||
});
|
||||
|
||||
@override
|
||||
createState() => _ChewieControllerHookState();
|
||||
}
|
||||
|
||||
class _ChewieControllerHookState
|
||||
extends HookState<ChewieController, _ChewieControllerHook> {
|
||||
late ChewieController chewieController = ChewieController(
|
||||
videoPlayerController: hook.controller,
|
||||
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
|
||||
showOptions: hook.showOptions,
|
||||
showControlsOnInitialize: hook.showControlsOnInitialize,
|
||||
autoPlay: hook.autoPlay,
|
||||
allowFullScreen: hook.allowFullScreen,
|
||||
allowedScreenSleep: hook.allowedScreenSleep,
|
||||
showControls: hook.showControls,
|
||||
customControls: hook.customControls,
|
||||
placeholder: hook.placeholder,
|
||||
hideControlsTimer: hook.hideControlsTimer,
|
||||
);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
chewieController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
ChewieController build(BuildContext context) {
|
||||
return chewieController;
|
||||
}
|
||||
|
||||
/*
|
||||
/// Initializes the chewie controller and video player controller
|
||||
Future<void> _initialize() async {
|
||||
if (hook.asset.isLocal && hook.asset.livePhotoVideoId == null) {
|
||||
// Use a local file for the video player controller
|
||||
final file = await hook.asset.local!.file;
|
||||
if (file == null) {
|
||||
throw Exception('No file found for the video');
|
||||
}
|
||||
videoPlayerController = VideoPlayerController.file(file);
|
||||
} else {
|
||||
// Use a network URL for the video player controller
|
||||
final serverEndpoint = store.Store.get(store.StoreKey.serverEndpoint);
|
||||
final String videoUrl = hook.asset.livePhotoVideoId != null
|
||||
? '$serverEndpoint/asset/file/${hook.asset.livePhotoVideoId}'
|
||||
: '$serverEndpoint/asset/file/${hook.asset.remoteId}';
|
||||
|
||||
final url = Uri.parse(videoUrl);
|
||||
final accessToken = store.Store.get(StoreKey.accessToken);
|
||||
|
||||
videoPlayerController = VideoPlayerController.networkUrl(
|
||||
url,
|
||||
httpHeaders: {"x-immich-user-token": accessToken},
|
||||
);
|
||||
}
|
||||
|
||||
await videoPlayerController!.initialize();
|
||||
|
||||
chewieController = ChewieController(
|
||||
videoPlayerController: videoPlayerController!,
|
||||
controlsSafeAreaMinimum: hook.controlsSafeAreaMinimum,
|
||||
showOptions: hook.showOptions,
|
||||
showControlsOnInitialize: hook.showControlsOnInitialize,
|
||||
autoPlay: hook.autoPlay,
|
||||
allowFullScreen: hook.allowFullScreen,
|
||||
allowedScreenSleep: hook.allowedScreenSleep,
|
||||
showControls: hook.showControls,
|
||||
customControls: hook.customControls,
|
||||
placeholder: hook.placeholder,
|
||||
hideControlsTimer: hook.hideControlsTimer,
|
||||
);
|
||||
}
|
||||
*/
|
||||
}
|
||||
195
mobile/lib/widgets/asset_viewer/top_control_app_bar.dart
Normal file
195
mobile/lib/widgets/asset_viewer/top_control_app_bar.dart
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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';
|
||||
|
||||
class TopControlAppBar extends HookConsumerWidget {
|
||||
const TopControlAppBar({
|
||||
super.key,
|
||||
required this.asset,
|
||||
required this.onMoreInfoPressed,
|
||||
required this.onDownloadPressed,
|
||||
required this.onAddToAlbumPressed,
|
||||
required this.onRestorePressed,
|
||||
required this.onToggleMotionVideo,
|
||||
required this.isPlayingMotionVideo,
|
||||
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 onToggleMotionVideo;
|
||||
final VoidCallback onAddToAlbumPressed;
|
||||
final VoidCallback onRestorePressed;
|
||||
final VoidCallback onActivitiesPressed;
|
||||
final Function(Asset) onFavorite;
|
||||
final bool isPlayingMotionVideo;
|
||||
final bool isOwner;
|
||||
final bool isPartner;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
const double iconSize = 22.0;
|
||||
final a = ref.watch(assetWatcher(asset)).value ?? asset;
|
||||
final album = ref.watch(currentAlbumProvider);
|
||||
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 buildLivePhotoButton() {
|
||||
return IconButton(
|
||||
onPressed: () {
|
||||
onToggleMotionVideo();
|
||||
},
|
||||
icon: isPlayingMotionVideo
|
||||
? Icon(
|
||||
Icons.motion_photos_pause_outlined,
|
||||
color: Colors.grey[200],
|
||||
)
|
||||
: Icon(
|
||||
Icons.play_circle_outline_rounded,
|
||||
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.popRoute();
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.arrow_back_ios_new_rounded,
|
||||
size: 20.0,
|
||||
color: Colors.grey[200],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return AppBar(
|
||||
foregroundColor: Colors.grey[100],
|
||||
backgroundColor: Colors.transparent,
|
||||
leading: buildBackButton(),
|
||||
actionsIconTheme: const IconThemeData(
|
||||
size: iconSize,
|
||||
),
|
||||
actions: [
|
||||
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
||||
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
|
||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||
if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner)
|
||||
buildDownloadButton(),
|
||||
if (asset.isRemote && (isOwner || isPartner) && !asset.isTrashed)
|
||||
buildAddToAlbumButton(),
|
||||
if (asset.isTrashed) buildRestoreButton(),
|
||||
if (album != null && album.shared) buildActivitiesButton(),
|
||||
buildMoreInfoButton(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
125
mobile/lib/widgets/asset_viewer/video_controls.dart
Normal file
125
mobile/lib/widgets/asset_viewer/video_controls.dart
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_controls_provider.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/video_player_value_provider.dart';
|
||||
|
||||
/// The video controls for the [videPlayerControlsProvider]
|
||||
class VideoControls extends ConsumerWidget {
|
||||
const VideoControls({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final duration =
|
||||
ref.watch(videoPlaybackValueProvider.select((v) => v.duration));
|
||||
final position =
|
||||
ref.watch(videoPlaybackValueProvider.select((v) => v.position));
|
||||
|
||||
return AnimatedOpacity(
|
||||
opacity: ref.watch(showControlsProvider) ? 1.0 : 0.0,
|
||||
duration: const Duration(milliseconds: 100),
|
||||
child: OrientationBuilder(
|
||||
builder: (context, orientation) => Container(
|
||||
padding: EdgeInsets.symmetric(
|
||||
horizontal: orientation == Orientation.portrait ? 12.0 : 64.0,
|
||||
),
|
||||
color: Colors.black.withOpacity(0.4),
|
||||
child: Padding(
|
||||
padding: MediaQuery.of(context).orientation == Orientation.portrait
|
||||
? const EdgeInsets.symmetric(horizontal: 12.0)
|
||||
: const EdgeInsets.symmetric(horizontal: 64.0),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
_formatDuration(position),
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Colors.white.withOpacity(.75),
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Slider(
|
||||
value: duration == Duration.zero
|
||||
? 0.0
|
||||
: min(
|
||||
position.inMicroseconds /
|
||||
duration.inMicroseconds *
|
||||
100,
|
||||
100,
|
||||
),
|
||||
min: 0,
|
||||
max: 100,
|
||||
thumbColor: Colors.white,
|
||||
activeColor: Colors.white,
|
||||
inactiveColor: Colors.white.withOpacity(0.75),
|
||||
onChanged: (position) {
|
||||
ref.read(videoPlayerControlsProvider.notifier).position =
|
||||
position;
|
||||
},
|
||||
),
|
||||
),
|
||||
Text(
|
||||
_formatDuration(duration),
|
||||
style: TextStyle(
|
||||
fontSize: 14.0,
|
||||
color: Colors.white.withOpacity(.75),
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
icon: Icon(
|
||||
ref.watch(
|
||||
videoPlayerControlsProvider.select((value) => value.mute),
|
||||
)
|
||||
? Icons.volume_off
|
||||
: Icons.volume_up,
|
||||
),
|
||||
onPressed: () => ref
|
||||
.read(videoPlayerControlsProvider.notifier)
|
||||
.toggleMute(),
|
||||
color: Colors.white,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _formatDuration(Duration position) {
|
||||
final ms = position.inMilliseconds;
|
||||
|
||||
int seconds = ms ~/ 1000;
|
||||
final int hours = seconds ~/ 3600;
|
||||
seconds = seconds % 3600;
|
||||
final minutes = seconds ~/ 60;
|
||||
seconds = seconds % 60;
|
||||
|
||||
final hoursString = hours >= 10
|
||||
? '$hours'
|
||||
: hours == 0
|
||||
? '00'
|
||||
: '0$hours';
|
||||
|
||||
final minutesString = minutes >= 10
|
||||
? '$minutes'
|
||||
: minutes == 0
|
||||
? '00'
|
||||
: '0$minutes';
|
||||
|
||||
final secondsString = seconds >= 10
|
||||
? '$seconds'
|
||||
: seconds == 0
|
||||
? '00'
|
||||
: '0$seconds';
|
||||
|
||||
final formattedTime =
|
||||
'${hoursString == '00' ? '' : '$hoursString:'}$minutesString:$secondsString';
|
||||
|
||||
return formattedTime;
|
||||
}
|
||||
}
|
||||
45
mobile/lib/widgets/asset_viewer/video_player.dart
Normal file
45
mobile/lib/widgets/asset_viewer/video_player.dart
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import 'package:chewie/chewie.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/hooks/chewiew_controller_hook.dart';
|
||||
import 'package:immich_mobile/widgets/asset_viewer/custom_video_player_controls.dart';
|
||||
import 'package:video_player/video_player.dart';
|
||||
|
||||
class VideoPlayerViewer extends HookConsumerWidget {
|
||||
final VideoPlayerController controller;
|
||||
final bool isMotionVideo;
|
||||
final Widget? placeholder;
|
||||
final Duration hideControlsTimer;
|
||||
final bool showControls;
|
||||
final bool showDownloadingIndicator;
|
||||
|
||||
const VideoPlayerViewer({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.isMotionVideo,
|
||||
this.placeholder,
|
||||
required this.hideControlsTimer,
|
||||
required this.showControls,
|
||||
required this.showDownloadingIndicator,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final chewie = useChewieController(
|
||||
controller: controller,
|
||||
controlsSafeAreaMinimum: const EdgeInsets.only(
|
||||
bottom: 100,
|
||||
),
|
||||
placeholder: SizedBox.expand(child: placeholder),
|
||||
customControls: CustomVideoPlayerControls(
|
||||
hideTimerDuration: hideControlsTimer,
|
||||
),
|
||||
showControls: showControls && !isMotionVideo,
|
||||
hideControlsTimer: hideControlsTimer,
|
||||
);
|
||||
|
||||
return Chewie(
|
||||
controller: chewie,
|
||||
);
|
||||
}
|
||||
}
|
||||
217
mobile/lib/widgets/backup/album_info_card.dart
Normal file
217
mobile/lib/widgets/backup/album_info_card.dart
Normal file
|
|
@ -0,0 +1,217 @@
|
|||
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/backup/backup.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_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 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: RoundedRectangleBorder(borderRadius: BorderRadius.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: RoundedRectangleBorder(borderRadius: BorderRadius.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);
|
||||
}
|
||||
},
|
||||
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: BorderRadius.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: FutureBuilder(
|
||||
builder: ((context, snapshot) {
|
||||
if (snapshot.hasData) {
|
||||
return Text(
|
||||
snapshot.data.toString() +
|
||||
(album.isAll
|
||||
? " (${'backup_all'.tr()})"
|
||||
: ""),
|
||||
style: TextStyle(
|
||||
fontSize: 12,
|
||||
color: Colors.grey[600],
|
||||
),
|
||||
);
|
||||
}
|
||||
return const Text("0");
|
||||
}),
|
||||
future: album.assetCount,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
IconButton(
|
||||
onPressed: () {
|
||||
context.pushRoute(
|
||||
AlbumPreviewRoute(album: album.albumEntity),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.image_outlined,
|
||||
color: context.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
splashRadius: 25,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
128
mobile/lib/widgets/backup/album_info_list_tile.dart
Normal file
128
mobile/lib/widgets/backup/album_info_list_tile.dart
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/backup/available_album.model.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/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);
|
||||
var assetCount = useState(0);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
album.assetCount.then((value) => assetCount.value = value);
|
||||
return null;
|
||||
},
|
||||
[album],
|
||||
);
|
||||
|
||||
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 const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Colors.green,
|
||||
);
|
||||
}
|
||||
|
||||
if (isExcluded) {
|
||||
return const Icon(
|
||||
Icons.remove_circle_rounded,
|
||||
color: Colors.red,
|
||||
);
|
||||
}
|
||||
|
||||
return Icon(
|
||||
Icons.circle,
|
||||
color: context.isDarkTheme ? Colors.grey[400] : Colors.black45,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
},
|
||||
leading: buildIcon(),
|
||||
title: Text(
|
||||
album.name,
|
||||
style: const TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
subtitle: Text(assetCount.value.toString()),
|
||||
trailing: IconButton(
|
||||
onPressed: () {
|
||||
context.pushRoute(
|
||||
AlbumPreviewRoute(album: album.albumEntity),
|
||||
);
|
||||
},
|
||||
icon: Icon(
|
||||
Icons.image_outlined,
|
||||
color: context.primaryColor,
|
||||
size: 24,
|
||||
),
|
||||
splashRadius: 25,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
60
mobile/lib/widgets/backup/backup_info_card.dart
Normal file
60
mobile/lib/widgets/backup/backup_info_card.dart
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class BackupInfoCard extends StatelessWidget {
|
||||
final String title;
|
||||
final String subtitle;
|
||||
final String info;
|
||||
const BackupInfoCard({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.subtitle,
|
||||
required this.info,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Card(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20), // if you need this
|
||||
side: BorderSide(
|
||||
color: context.isDarkTheme
|
||||
? const Color.fromARGB(255, 56, 56, 56)
|
||||
: Colors.black12,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
elevation: 0,
|
||||
borderOnForeground: false,
|
||||
child: ListTile(
|
||||
minVerticalPadding: 18,
|
||||
isThreeLine: true,
|
||||
title: Text(
|
||||
title,
|
||||
style: context.textTheme.titleMedium,
|
||||
),
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 4.0, right: 18.0),
|
||||
child: Text(
|
||||
subtitle,
|
||||
style: context.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
trailing: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
info,
|
||||
style: context.textTheme.titleLarge,
|
||||
),
|
||||
Text(
|
||||
"backup_info_card_assets",
|
||||
style: context.textTheme.labelLarge,
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
315
mobile/lib/widgets/backup/current_backup_asset_info_box.dart
Normal file
315
mobile/lib/widgets/backup/current_backup_asset_info_box.dart
Normal file
|
|
@ -0,0 +1,315 @@
|
|||
import 'dart:io';
|
||||
|
||||
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: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/error_backup_list.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:photo_manager/photo_manager.dart';
|
||||
|
||||
class CurrentUploadingAssetInfoBox extends HookConsumerWidget {
|
||||
const CurrentUploadingAssetInfoBox({super.key});
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
var isManualUpload = ref.watch(backupProvider).backupProgress ==
|
||||
BackUpProgressEnum.manualInProgress;
|
||||
var asset = !isManualUpload
|
||||
? ref.watch(backupProvider).currentUploadAsset
|
||||
: ref.watch(manualUploadProvider).currentUploadAsset;
|
||||
var uploadProgress = !isManualUpload
|
||||
? ref.watch(backupProvider).progressInPercentage
|
||||
: ref.watch(manualUploadProvider).progressInPercentage;
|
||||
var uploadFileProgress = !isManualUpload
|
||||
? ref.watch(backupProvider).progressInFileSize
|
||||
: ref.watch(manualUploadProvider).progressInFileSize;
|
||||
var uploadFileSpeed = !isManualUpload
|
||||
? ref.watch(backupProvider).progressInFileSpeed
|
||||
: ref.watch(manualUploadProvider).progressInFileSpeed;
|
||||
var iCloudDownloadProgress =
|
||||
ref.watch(backupProvider).iCloudDownloadProgress;
|
||||
final isShowThumbnail = useState(false);
|
||||
|
||||
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';
|
||||
}
|
||||
}
|
||||
|
||||
String getAssetCreationDate() {
|
||||
return DateFormat.yMMMMd().format(
|
||||
DateTime.parse(
|
||||
asset.fileCreatedAt.toString(),
|
||||
).toLocal(),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildErrorChip() {
|
||||
return ActionChip(
|
||||
avatar: Icon(
|
||||
Icons.info,
|
||||
color: Colors.red[400],
|
||||
),
|
||||
elevation: 1,
|
||||
visualDensity: VisualDensity.compact,
|
||||
label: Text(
|
||||
"backup_controller_page_failed",
|
||||
style: TextStyle(
|
||||
color: Colors.red[400],
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 11,
|
||||
),
|
||||
).tr(
|
||||
args: [ref.watch(errorBackupListProvider).length.toString()],
|
||||
),
|
||||
backgroundColor: Colors.white,
|
||||
onPressed: () => context.pushRoute(const FailedBackupStatusRoute()),
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildAssetInfoTable() {
|
||||
return Table(
|
||||
border: TableBorder.all(
|
||||
color: context.themeData.primaryColorLight,
|
||||
width: 1,
|
||||
),
|
||||
children: [
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(
|
||||
// color: Colors.grey[100],
|
||||
),
|
||||
children: [
|
||||
TableCell(
|
||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: const Text(
|
||||
'backup_controller_page_filename',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10.0,
|
||||
),
|
||||
).tr(
|
||||
args: [asset.fileName, asset.fileType.toLowerCase()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(
|
||||
// color: Colors.grey[200],
|
||||
),
|
||||
children: [
|
||||
TableCell(
|
||||
verticalAlignment: TableCellVerticalAlignment.middle,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: const Text(
|
||||
"backup_controller_page_created",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10.0,
|
||||
),
|
||||
).tr(
|
||||
args: [getAssetCreationDate()],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
TableRow(
|
||||
decoration: const BoxDecoration(
|
||||
// color: Colors.grey[100],
|
||||
),
|
||||
children: [
|
||||
TableCell(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(6.0),
|
||||
child: const Text(
|
||||
"backup_controller_page_id",
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 10.0,
|
||||
),
|
||||
).tr(args: [asset.id]),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
buildAssetThumbnail() async {
|
||||
var assetEntity = await AssetEntity.fromId(asset.id);
|
||||
|
||||
if (assetEntity != null) {
|
||||
return assetEntity.thumbnailDataWithSize(
|
||||
const ThumbnailSize(500, 500),
|
||||
quality: 100,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
buildiCloudDownloadProgerssBar() {
|
||||
if (asset.iCloudAsset != null && asset.iCloudAsset!) {
|
||||
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: uploadProgress / 100.0,
|
||||
backgroundColor: Colors.grey,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" ${iCloudDownloadProgress.toStringAsFixed(0)}%",
|
||||
style: const TextStyle(fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return const SizedBox();
|
||||
}
|
||||
|
||||
buildUploadProgressBar() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (asset.iCloudAsset != null && asset.iCloudAsset!)
|
||||
SizedBox(
|
||||
width: 110,
|
||||
child: Text(
|
||||
"Immich Upload",
|
||||
style: context.textTheme.labelSmall,
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 10.0,
|
||||
value: uploadProgress / 100.0,
|
||||
backgroundColor: Colors.grey,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
" ${uploadProgress.toStringAsFixed(0)}%",
|
||||
style: const TextStyle(fontSize: 12, fontFamily: "OverpassMono"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildUploadStats() {
|
||||
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: "OverpassMono"),
|
||||
),
|
||||
Text(
|
||||
formatUploadFileSpeed(uploadFileSpeed),
|
||||
style: const TextStyle(fontSize: 10, fontFamily: "OverpassMono"),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return FutureBuilder<Uint8List?>(
|
||||
future: buildAssetThumbnail(),
|
||||
builder: (context, thumbnail) => ListTile(
|
||||
isThreeLine: true,
|
||||
leading: AnimatedCrossFade(
|
||||
alignment: Alignment.centerLeft,
|
||||
firstChild: GestureDetector(
|
||||
onTap: () => isShowThumbnail.value = false,
|
||||
child: thumbnail.hasData
|
||||
? ClipRRect(
|
||||
borderRadius: BorderRadius.circular(5),
|
||||
child: Image.memory(
|
||||
thumbnail.data!,
|
||||
fit: BoxFit.cover,
|
||||
width: 50,
|
||||
height: 50,
|
||||
),
|
||||
)
|
||||
: const SizedBox(
|
||||
width: 50,
|
||||
height: 50,
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(8.0),
|
||||
child: CircularProgressIndicator.adaptive(
|
||||
strokeWidth: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
secondChild: GestureDetector(
|
||||
onTap: () => isShowThumbnail.value = true,
|
||||
child: Icon(
|
||||
Icons.image_outlined,
|
||||
color: context.primaryColor,
|
||||
size: 30,
|
||||
),
|
||||
),
|
||||
crossFadeState: isShowThumbnail.value
|
||||
? CrossFadeState.showFirst
|
||||
: CrossFadeState.showSecond,
|
||||
duration: const Duration(milliseconds: 200),
|
||||
),
|
||||
title: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
"backup_controller_page_uploading_file_info",
|
||||
style: context.textTheme.titleSmall,
|
||||
).tr(),
|
||||
if (ref.watch(errorBackupListProvider).isNotEmpty) buildErrorChip(),
|
||||
],
|
||||
),
|
||||
subtitle: Column(
|
||||
children: [
|
||||
if (Platform.isIOS) buildiCloudDownloadProgerssBar(),
|
||||
buildUploadProgressBar(),
|
||||
buildUploadStats(),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: buildAssetInfoTable(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
62
mobile/lib/widgets/backup/ios_debug_info_tile.dart
Normal file
62
mobile/lib/widgets/backup/ios_debug_info_tile.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
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/ios_background_settings.provider.dart';
|
||||
import 'package:intl/intl.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 processOrProcesses = processes == 1 ? 'process' : 'processes';
|
||||
final numberOrZero = processes == 0 ? 'No' : processes.toString();
|
||||
final title = '$numberOrZero background $processOrProcesses queued';
|
||||
|
||||
final df = DateFormat.yMd().add_jm();
|
||||
final String subtitle;
|
||||
if (fetch == null && processing == null) {
|
||||
subtitle = 'No background sync job has run yet';
|
||||
} else if (fetch != null && processing == null) {
|
||||
subtitle = 'Fetch ran ${df.format(fetch)}';
|
||||
} else if (processing != null && fetch == null) {
|
||||
subtitle = 'Processing ran ${df.format(processing)}';
|
||||
} else {
|
||||
final fetchOrProcessing =
|
||||
fetch!.isAfter(processing!) ? fetch : processing;
|
||||
subtitle = 'Last sync ${df.format(fetchOrProcessing)}';
|
||||
}
|
||||
|
||||
return ListTile(
|
||||
key: ValueKey(title),
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
277
mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
Normal file
277
mobile/lib/widgets/common/app_bar_dialog/app_bar_dialog.dart
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
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/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';
|
||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/providers/websocket.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_profile_info.dart';
|
||||
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_server_info.dart';
|
||||
import 'package:immich_mobile/widgets/common/confirm_dialog.dart';
|
||||
import 'package:immich_mobile/utils/bytes_units.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
|
||||
class ImmichAppBarDialog extends HookConsumerWidget {
|
||||
const ImmichAppBarDialog({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
BackUpState backupState = ref.watch(backupProvider);
|
||||
final theme = context.themeData;
|
||||
bool isHorizontal = !context.isMobile;
|
||||
final horizontalPadding = isHorizontal ? 100.0 : 20.0;
|
||||
final user = ref.watch(currentUserProvider);
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
ref.read(backupProvider.notifier).updateServerInfo();
|
||||
ref.read(currentUserProvider.notifier).refresh();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
buildTopRow() {
|
||||
return Stack(
|
||||
children: [
|
||||
Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: InkWell(
|
||||
onTap: () => context.pop(),
|
||||
child: const Icon(
|
||||
Icons.close,
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: Image.asset(
|
||||
context.isDarkTheme
|
||||
? 'assets/immich-text-dark.png'
|
||||
: 'assets/immich-text-light.png',
|
||||
height: 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
buildActionButton(IconData icon, String text, Function() onTap) {
|
||||
return ListTile(
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.standard,
|
||||
contentPadding: const EdgeInsets.only(left: 30),
|
||||
minLeadingWidth: 40,
|
||||
leading: SizedBox(
|
||||
child: Icon(
|
||||
icon,
|
||||
color: theme.textTheme.labelLarge?.color?.withAlpha(250),
|
||||
size: 20,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
text,
|
||||
style: theme.textTheme.labelLarge?.copyWith(
|
||||
color: theme.textTheme.labelLarge?.color?.withAlpha(250),
|
||||
),
|
||||
).tr(),
|
||||
onTap: onTap,
|
||||
);
|
||||
}
|
||||
|
||||
buildSettingButton() {
|
||||
return buildActionButton(
|
||||
Icons.settings_rounded,
|
||||
"profile_drawer_settings",
|
||||
() => context.pushRoute(const SettingsRoute()),
|
||||
);
|
||||
}
|
||||
|
||||
buildAppLogButton() {
|
||||
return buildActionButton(
|
||||
Icons.assignment_outlined,
|
||||
"profile_drawer_app_logs",
|
||||
() => context.pushRoute(const AppLogRoute()),
|
||||
);
|
||||
}
|
||||
|
||||
buildSignOutButton() {
|
||||
return buildActionButton(
|
||||
Icons.logout_rounded,
|
||||
"profile_drawer_sign_out",
|
||||
() async {
|
||||
showDialog(
|
||||
context: context,
|
||||
builder: (BuildContext ctx) {
|
||||
return ConfirmDialog(
|
||||
title: "app_bar_signout_dialog_title",
|
||||
content: "app_bar_signout_dialog_content",
|
||||
ok: "app_bar_signout_dialog_ok",
|
||||
onOk: () async {
|
||||
await ref.read(authenticationProvider.notifier).logout();
|
||||
|
||||
ref.read(manualUploadProvider.notifier).cancelBackup();
|
||||
ref.read(backupProvider.notifier).cancelBackup();
|
||||
ref.read(assetProvider.notifier).clearAllAsset();
|
||||
ref.read(websocketProvider.notifier).disconnect();
|
||||
context.replaceRoute(const LoginRoute());
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget buildStorageInformation() {
|
||||
var percentage = backupState.serverInfo.diskUsagePercentage / 100;
|
||||
var usedDiskSpace = backupState.serverInfo.diskUse;
|
||||
var totalDiskSpace = backupState.serverInfo.diskSize;
|
||||
|
||||
if (user != null && user.hasQuota) {
|
||||
usedDiskSpace = formatBytes(user.quotaUsageInBytes);
|
||||
totalDiskSpace = formatBytes(user.quotaSizeInBytes);
|
||||
percentage = user.quotaUsageInBytes / user.quotaSizeInBytes;
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0, vertical: 3),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDarkTheme
|
||||
? context.scaffoldBackgroundColor
|
||||
: const Color.fromARGB(255, 225, 229, 240),
|
||||
),
|
||||
child: ListTile(
|
||||
minLeadingWidth: 50,
|
||||
leading: Icon(
|
||||
Icons.storage_rounded,
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
title: Text(
|
||||
"backup_controller_page_server_storage",
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).tr(),
|
||||
isThreeLine: true,
|
||||
subtitle: Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 8.0),
|
||||
child: LinearProgressIndicator(
|
||||
minHeight: 5.0,
|
||||
value: percentage,
|
||||
backgroundColor: Colors.grey,
|
||||
color: theme.primaryColor,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 12.0),
|
||||
child:
|
||||
const Text('backup_controller_page_storage_format').tr(
|
||||
args: [
|
||||
usedDiskSpace,
|
||||
totalDiskSpace,
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
buildFooter() {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 10, bottom: 20),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.pop();
|
||||
launchUrl(
|
||||
Uri.parse('https://immich.app'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
"profile_drawer_documentation",
|
||||
style: context.textTheme.bodySmall,
|
||||
).tr(),
|
||||
),
|
||||
const SizedBox(
|
||||
width: 20,
|
||||
child: Text(
|
||||
"•",
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
InkWell(
|
||||
onTap: () {
|
||||
context.pop();
|
||||
launchUrl(
|
||||
Uri.parse('https://github.com/immich-app/immich'),
|
||||
mode: LaunchMode.externalApplication,
|
||||
);
|
||||
},
|
||||
child: Text(
|
||||
"profile_drawer_github",
|
||||
style: context.textTheme.bodySmall,
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Dialog(
|
||||
clipBehavior: Clip.hardEdge,
|
||||
alignment: Alignment.topCenter,
|
||||
insetPadding: EdgeInsets.only(
|
||||
top: isHorizontal ? 20 : 40,
|
||||
left: horizontalPadding,
|
||||
right: horizontalPadding,
|
||||
bottom: isHorizontal ? 20 : 100,
|
||||
),
|
||||
backgroundColor: theme.cardColor,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(20),
|
||||
),
|
||||
child: SizedBox(
|
||||
child: SingleChildScrollView(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: buildTopRow(),
|
||||
),
|
||||
const AppBarProfileInfoBox(),
|
||||
buildStorageInformation(),
|
||||
const AppBarServerInfo(),
|
||||
buildAppLogButton(),
|
||||
buildSettingButton(),
|
||||
buildSignOutButton(),
|
||||
buildFooter(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,139 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/upload_profile_image.provider.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
||||
import 'package:immich_mobile/models/authentication/authentication_state.model.dart';
|
||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
|
||||
class AppBarProfileInfoBox extends HookConsumerWidget {
|
||||
const AppBarProfileInfoBox({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
AuthenticationState authState = ref.watch(authenticationProvider);
|
||||
final uploadProfileImageStatus =
|
||||
ref.watch(uploadProfileImageProvider).status;
|
||||
final user = Store.tryGet(StoreKey.currentUser);
|
||||
|
||||
buildUserProfileImage() {
|
||||
if (user == null) {
|
||||
return const CircleAvatar(
|
||||
radius: 20,
|
||||
backgroundImage: AssetImage('assets/immich-logo.png'),
|
||||
backgroundColor: Colors.transparent,
|
||||
);
|
||||
}
|
||||
|
||||
final userImage = UserCircleAvatar(
|
||||
radius: 22,
|
||||
size: 44,
|
||||
user: user,
|
||||
);
|
||||
|
||||
if (uploadProfileImageStatus == UploadProfileStatus.loading) {
|
||||
return const SizedBox(
|
||||
height: 40,
|
||||
width: 40,
|
||||
child: ImmichLoadingIndicator(borderRadius: 20),
|
||||
);
|
||||
}
|
||||
|
||||
return userImage;
|
||||
}
|
||||
|
||||
pickUserProfileImage() async {
|
||||
final XFile? image = await ImagePicker().pickImage(
|
||||
source: ImageSource.gallery,
|
||||
maxHeight: 1024,
|
||||
maxWidth: 1024,
|
||||
);
|
||||
|
||||
if (image != null) {
|
||||
var success =
|
||||
await ref.watch(uploadProfileImageProvider.notifier).upload(image);
|
||||
|
||||
if (success) {
|
||||
final profileImagePath =
|
||||
ref.read(uploadProfileImageProvider).profileImagePath;
|
||||
ref.watch(authenticationProvider.notifier).updateUserProfileImagePath(
|
||||
profileImagePath,
|
||||
);
|
||||
if (user != null) {
|
||||
user.profileImagePath = profileImagePath;
|
||||
Store.put(StoreKey.currentUser, user);
|
||||
ref.read(currentUserProvider.notifier).refresh();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 10.0),
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDarkTheme
|
||||
? context.scaffoldBackgroundColor
|
||||
: const Color.fromARGB(255, 225, 229, 240),
|
||||
borderRadius: const BorderRadius.only(
|
||||
topLeft: Radius.circular(10),
|
||||
topRight: Radius.circular(10),
|
||||
),
|
||||
),
|
||||
child: ListTile(
|
||||
minLeadingWidth: 50,
|
||||
leading: GestureDetector(
|
||||
onTap: pickUserProfileImage,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
buildUserProfileImage(),
|
||||
Positioned(
|
||||
bottom: -5,
|
||||
right: -8,
|
||||
child: Material(
|
||||
color: context.isDarkTheme
|
||||
? Colors.blueGrey[800]
|
||||
: Colors.white,
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(50.0),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(5.0),
|
||||
child: Icon(
|
||||
Icons.camera_alt_outlined,
|
||||
color: context.primaryColor,
|
||||
size: 14,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
authState.name,
|
||||
style: context.textTheme.titleMedium?.copyWith(
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
authState.userEmail,
|
||||
style: context.textTheme.bodySmall?.copyWith(
|
||||
color: context.textTheme.bodySmall?.color?.withAlpha(200),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,273 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_info.model.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:package_info_plus/package_info_plus.dart';
|
||||
|
||||
class AppBarServerInfo extends HookConsumerWidget {
|
||||
const AppBarServerInfo({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
ServerInfo serverInfoState = ref.watch(serverInfoProvider);
|
||||
|
||||
final appInfo = useState({});
|
||||
const titleFontSize = 12.0;
|
||||
const contentFontSize = 11.0;
|
||||
|
||||
getPackageInfo() async {
|
||||
PackageInfo packageInfo = await PackageInfo.fromPlatform();
|
||||
|
||||
appInfo.value = {
|
||||
"version": packageInfo.version,
|
||||
"buildNumber": packageInfo.buildNumber,
|
||||
};
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
getPackageInfo();
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0, right: 10.0, bottom: 10.0),
|
||||
child: Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDarkTheme
|
||||
? context.scaffoldBackgroundColor
|
||||
: const Color.fromARGB(255, 225, 229, 240),
|
||||
borderRadius: const BorderRadius.only(
|
||||
bottomLeft: Radius.circular(10),
|
||||
bottomRight: Radius.circular(10),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12.0, vertical: 8),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(8.0),
|
||||
child: Text(
|
||||
serverInfoState.isVersionMismatch
|
||||
? serverInfoState.versionMismatchErrorMessage
|
||||
: "profile_drawer_client_server_up_to_date".tr(),
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(
|
||||
fontSize: 11,
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Divider(
|
||||
color: Color.fromARGB(101, 201, 201, 201),
|
||||
thickness: 1,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Text(
|
||||
"server_info_box_app_version".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Text(
|
||||
"${appInfo.value["version"]} build.${appInfo.value["buildNumber"]}",
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.textTheme.labelSmall?.color
|
||||
?.withOpacity(0.5),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Divider(
|
||||
color: Color.fromARGB(101, 201, 201, 201),
|
||||
thickness: 1,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Text(
|
||||
"server_info_box_server_version".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Text(
|
||||
serverInfoState.serverVersion.major > 0
|
||||
? "${serverInfoState.serverVersion.major}.${serverInfoState.serverVersion.minor}.${serverInfoState.serverVersion.patch}"
|
||||
: "--",
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.textTheme.labelSmall?.color
|
||||
?.withOpacity(0.5),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Divider(
|
||||
color: Color.fromARGB(101, 201, 201, 201),
|
||||
thickness: 1,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Text(
|
||||
"server_info_box_server_url".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Container(
|
||||
width: 200,
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Tooltip(
|
||||
verticalOffset: 0,
|
||||
decoration: BoxDecoration(
|
||||
color: context.primaryColor.withOpacity(0.9),
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
textStyle: TextStyle(
|
||||
color:
|
||||
context.isDarkTheme ? Colors.black : Colors.white,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
message: getServerUrl() ?? '--',
|
||||
preferBelow: false,
|
||||
triggerMode: TooltipTriggerMode.tap,
|
||||
child: Text(
|
||||
getServerUrl() ?? '--',
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.textTheme.labelSmall?.color
|
||||
?.withOpacity(0.5),
|
||||
fontWeight: FontWeight.bold,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
textAlign: TextAlign.end,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.symmetric(horizontal: 10),
|
||||
child: Divider(
|
||||
color: Color.fromARGB(101, 201, 201, 201),
|
||||
thickness: 1,
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(left: 10.0),
|
||||
child: Row(
|
||||
children: [
|
||||
if (serverInfoState.isNewReleaseAvailable)
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(right: 5.0),
|
||||
child: Icon(
|
||||
Icons.info,
|
||||
color: Color.fromARGB(255, 243, 188, 106),
|
||||
size: 12,
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"server_info_box_latest_release".tr(),
|
||||
style: TextStyle(
|
||||
fontSize: titleFontSize,
|
||||
color: context.textTheme.labelSmall?.color,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
flex: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 10.0),
|
||||
child: Text(
|
||||
serverInfoState.latestVersion.major > 0
|
||||
? "${serverInfoState.latestVersion.major}.${serverInfoState.latestVersion.minor}.${serverInfoState.latestVersion.patch}"
|
||||
: "--",
|
||||
style: TextStyle(
|
||||
fontSize: contentFontSize,
|
||||
color: context.textTheme.labelSmall?.color
|
||||
?.withOpacity(0.5),
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
58
mobile/lib/widgets/common/confirm_dialog.dart
Normal file
58
mobile/lib/widgets/common/confirm_dialog.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class ConfirmDialog extends StatelessWidget {
|
||||
final Function onOk;
|
||||
final String title;
|
||||
final String content;
|
||||
final String cancel;
|
||||
final String ok;
|
||||
|
||||
const ConfirmDialog({
|
||||
super.key,
|
||||
required this.onOk,
|
||||
required this.title,
|
||||
required this.content,
|
||||
this.cancel = "delete_dialog_cancel",
|
||||
this.ok = "backup_controller_page_background_battery_info_ok",
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
void onOkPressed() {
|
||||
onOk();
|
||||
context.pop(true);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(10)),
|
||||
),
|
||||
title: Text(title).tr(),
|
||||
content: Text(content).tr(),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(false),
|
||||
child: Text(
|
||||
cancel,
|
||||
style: TextStyle(
|
||||
color: context.primaryColor,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: onOkPressed,
|
||||
child: Text(
|
||||
ok,
|
||||
style: TextStyle(
|
||||
color: Colors.red[400],
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
260
mobile/lib/widgets/common/date_time_picker.dart
Normal file
260
mobile/lib/widgets/common/date_time_picker.dart
Normal file
|
|
@ -0,0 +1,260 @@
|
|||
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:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/duration_extensions.dart';
|
||||
import 'package:timezone/timezone.dart' as tz;
|
||||
import 'package:timezone/timezone.dart';
|
||||
|
||||
Future<String?> showDateTimePicker({
|
||||
required BuildContext context,
|
||||
DateTime? initialDateTime,
|
||||
String? initialTZ,
|
||||
Duration? initialTZOffset,
|
||||
}) {
|
||||
return showDialog<String?>(
|
||||
context: context,
|
||||
builder: (context) => _DateTimePicker(
|
||||
initialDateTime: initialDateTime,
|
||||
initialTZ: initialTZ,
|
||||
initialTZOffset: initialTZOffset,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _getFormattedOffset(int offsetInMilli, tz.Location location) {
|
||||
return "${location.name} (UTC${Duration(milliseconds: offsetInMilli).formatAsOffset()})";
|
||||
}
|
||||
|
||||
class _DateTimePicker extends HookWidget {
|
||||
final DateTime? initialDateTime;
|
||||
final String? initialTZ;
|
||||
final Duration? initialTZOffset;
|
||||
|
||||
const _DateTimePicker({
|
||||
this.initialDateTime,
|
||||
this.initialTZ,
|
||||
this.initialTZOffset,
|
||||
});
|
||||
|
||||
_TimeZoneOffset _getInitiationLocation() {
|
||||
if (initialTZ != null) {
|
||||
try {
|
||||
return _TimeZoneOffset.fromLocation(
|
||||
tz.timeZoneDatabase.get(initialTZ!),
|
||||
);
|
||||
} on LocationNotFoundException {
|
||||
// no-op
|
||||
}
|
||||
}
|
||||
|
||||
Duration? tzOffset = initialTZOffset ?? initialDateTime?.timeZoneOffset;
|
||||
|
||||
if (tzOffset != null) {
|
||||
final offsetInMilli = tzOffset.inMilliseconds;
|
||||
// get all locations with matching offset
|
||||
final locations = tz.timeZoneDatabase.locations.values.where(
|
||||
(location) => location.currentTimeZone.offset == offsetInMilli,
|
||||
);
|
||||
// Prefer locations with abbreviation first
|
||||
final location = locations.firstWhereOrNull(
|
||||
(e) => !e.currentTimeZone.abbreviation.contains("0"),
|
||||
) ??
|
||||
locations.firstOrNull;
|
||||
if (location != null) {
|
||||
return _TimeZoneOffset.fromLocation(location);
|
||||
}
|
||||
}
|
||||
|
||||
return _TimeZoneOffset.fromLocation(tz.getLocation("UTC"));
|
||||
}
|
||||
|
||||
// returns a list of location<name> along with it's offset in duration
|
||||
List<_TimeZoneOffset> getAllTimeZones() {
|
||||
return tz.timeZoneDatabase.locations.values
|
||||
.where((l) => !l.currentTimeZone.abbreviation.contains("0"))
|
||||
.map(_TimeZoneOffset.fromLocation)
|
||||
.sorted()
|
||||
.toList();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final date = useState<DateTime>(initialDateTime ?? DateTime.now());
|
||||
final tzOffset = useState<_TimeZoneOffset>(_getInitiationLocation());
|
||||
final timeZones = useMemoized(() => getAllTimeZones(), const []);
|
||||
|
||||
void pickDate() async {
|
||||
final now = DateTime.now();
|
||||
// Handles cases where the date from the asset is far off in the future
|
||||
final initialDate = date.value.isAfter(now) ? now : date.value;
|
||||
final newDate = await showDatePicker(
|
||||
context: context,
|
||||
initialDate: initialDate,
|
||||
firstDate: DateTime(1800),
|
||||
lastDate: now,
|
||||
);
|
||||
if (newDate == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
final newTime = await showTimePicker(
|
||||
context: context,
|
||||
initialTime: TimeOfDay.fromDateTime(date.value),
|
||||
);
|
||||
|
||||
if (newTime == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
date.value = newDate.copyWith(hour: newTime.hour, minute: newTime.minute);
|
||||
}
|
||||
|
||||
void popWithDateTime() {
|
||||
final formattedDateTime =
|
||||
DateFormat("yyyy-MM-dd'T'HH:mm:ss").format(date.value);
|
||||
final dtWithOffset = formattedDateTime +
|
||||
Duration(milliseconds: tzOffset.value.offsetInMilliseconds)
|
||||
.formatAsOffset();
|
||||
context.pop(dtWithOffset);
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
contentPadding: const EdgeInsets.all(30),
|
||||
alignment: Alignment.center,
|
||||
content: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"edit_date_time_dialog_date_time",
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
TextButton.icon(
|
||||
onPressed: pickDate,
|
||||
icon: Text(
|
||||
DateFormat("dd-MM-yyyy hh:mm a").format(date.value),
|
||||
style: context.textTheme.bodyLarge
|
||||
?.copyWith(color: context.primaryColor),
|
||||
),
|
||||
label: const Icon(
|
||||
Icons.edit_outlined,
|
||||
size: 18,
|
||||
),
|
||||
),
|
||||
const Text(
|
||||
"edit_date_time_dialog_timezone",
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
DropdownMenu(
|
||||
menuHeight: 300,
|
||||
width: 280,
|
||||
inputDecorationTheme: const InputDecorationTheme(
|
||||
border: InputBorder.none,
|
||||
contentPadding: EdgeInsets.zero,
|
||||
),
|
||||
trailingIcon: Padding(
|
||||
padding: const EdgeInsets.only(right: 10),
|
||||
child: Icon(
|
||||
Icons.arrow_drop_down,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
textStyle: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
menuStyle: const MenuStyle(
|
||||
fixedSize: MaterialStatePropertyAll(Size.fromWidth(350)),
|
||||
alignment: Alignment(-1.25, 0.5),
|
||||
),
|
||||
onSelected: (value) => tzOffset.value = value!,
|
||||
initialSelection: tzOffset.value,
|
||||
dropdownMenuEntries: timeZones
|
||||
.map(
|
||||
(t) => DropdownMenuEntry<_TimeZoneOffset>(
|
||||
value: t,
|
||||
label: t.display,
|
||||
style: ButtonStyle(
|
||||
textStyle: MaterialStatePropertyAll(
|
||||
context.textTheme.bodyMedium,
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.toList(),
|
||||
),
|
||||
],
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(
|
||||
"action_common_cancel",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: popWithDateTime,
|
||||
child: Text(
|
||||
"action_common_update",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TimeZoneOffset implements Comparable<_TimeZoneOffset> {
|
||||
final String display;
|
||||
final Location location;
|
||||
|
||||
const _TimeZoneOffset({
|
||||
required this.display,
|
||||
required this.location,
|
||||
});
|
||||
|
||||
_TimeZoneOffset copyWith({
|
||||
String? display,
|
||||
Location? location,
|
||||
}) {
|
||||
return _TimeZoneOffset(
|
||||
display: display ?? this.display,
|
||||
location: location ?? this.location,
|
||||
);
|
||||
}
|
||||
|
||||
int get offsetInMilliseconds => location.currentTimeZone.offset;
|
||||
|
||||
_TimeZoneOffset.fromLocation(tz.Location l)
|
||||
: display = _getFormattedOffset(l.currentTimeZone.offset, l),
|
||||
location = l;
|
||||
|
||||
@override
|
||||
int compareTo(_TimeZoneOffset other) {
|
||||
return offsetInMilliseconds.compareTo(other.offsetInMilliseconds);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() =>
|
||||
'_TimeZoneOffset(display: $display, location: $location)';
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other is _TimeZoneOffset &&
|
||||
other.display == display &&
|
||||
other.offsetInMilliseconds == offsetInMilliseconds;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
display.hashCode ^ offsetInMilliseconds.hashCode ^ location.hashCode;
|
||||
}
|
||||
43
mobile/lib/widgets/common/delayed_loading_indicator.dart
Normal file
43
mobile/lib/widgets/common/delayed_loading_indicator.dart
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_loading_indicator.dart';
|
||||
|
||||
class DelayedLoadingIndicator extends StatelessWidget {
|
||||
/// The delay to avoid showing the loading indicator
|
||||
final Duration delay;
|
||||
|
||||
/// Defaults to using the [ImmichLoadingIndicator]
|
||||
final Widget? child;
|
||||
|
||||
/// An optional fade in duration to animate the loading
|
||||
final Duration? fadeInDuration;
|
||||
|
||||
const DelayedLoadingIndicator({
|
||||
super.key,
|
||||
this.delay = const Duration(seconds: 3),
|
||||
this.child,
|
||||
this.fadeInDuration,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: Future.delayed(delay),
|
||||
builder: (context, snapshot) {
|
||||
late Widget c;
|
||||
if (snapshot.connectionState == ConnectionState.done) {
|
||||
c = child ??
|
||||
const ImmichLoadingIndicator(
|
||||
key: ValueKey('loading'),
|
||||
);
|
||||
} else {
|
||||
c = Container(key: const ValueKey('hiding'));
|
||||
}
|
||||
|
||||
return AnimatedSwitcher(
|
||||
duration: fadeInDuration ?? Duration.zero,
|
||||
child: c,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
58
mobile/lib/widgets/common/drag_sheet.dart
Normal file
58
mobile/lib/widgets/common/drag_sheet.dart
Normal file
|
|
@ -0,0 +1,58 @@
|
|||
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) {
|
||||
return MaterialButton(
|
||||
padding: const EdgeInsets.all(10),
|
||||
shape: const CircleBorder(),
|
||||
onPressed: onPressed,
|
||||
onLongPress: onLongPressed,
|
||||
minWidth: 75.0,
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Icon(iconData, size: 24),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
label,
|
||||
style: const TextStyle(fontSize: 12.0),
|
||||
maxLines: 2,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
35
mobile/lib/widgets/common/fade_in_placeholder_image.dart
Normal file
35
mobile/lib/widgets/common/fade_in_placeholder_image.dart
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/widgets/common/transparent_image.dart';
|
||||
|
||||
class FadeInPlaceholderImage extends StatelessWidget {
|
||||
final Widget placeholder;
|
||||
final ImageProvider image;
|
||||
final Duration duration;
|
||||
final BoxFit fit;
|
||||
|
||||
const FadeInPlaceholderImage({
|
||||
super.key,
|
||||
required this.placeholder,
|
||||
required this.image,
|
||||
this.duration = const Duration(milliseconds: 100),
|
||||
this.fit = BoxFit.cover,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox.expand(
|
||||
child: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
placeholder,
|
||||
FadeInImage(
|
||||
fadeInDuration: duration,
|
||||
image: image,
|
||||
fit: fit,
|
||||
placeholder: MemoryImage(kTransparentImage),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
200
mobile/lib/widgets/common/immich_app_bar.dart
Normal file
200
mobile/lib/widgets/common/immich_app_bar.dart
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
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/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/immich_logo_provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/app_bar_dialog/app_bar_dialog.dart';
|
||||
import 'package:immich_mobile/widgets/common/user_circle_avatar.dart';
|
||||
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/models/backup/backup_state.model.dart';
|
||||
import 'package:immich_mobile/models/server_info/server_info.model.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
|
||||
class ImmichAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
final Widget? action;
|
||||
|
||||
const ImmichAppBar({super.key, this.action});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final BackUpState backupState = ref.watch(backupProvider);
|
||||
final bool isEnableAutoBackup =
|
||||
backupState.backgroundBackup || backupState.autoBackup;
|
||||
final ServerInfo serverInfoState = ref.watch(serverInfoProvider);
|
||||
final immichLogo = ref.watch(immichLogoProvider);
|
||||
final user = Store.tryGet(StoreKey.currentUser);
|
||||
final isDarkTheme = context.isDarkTheme;
|
||||
const widgetSize = 30.0;
|
||||
|
||||
buildProfileIndicator() {
|
||||
return InkWell(
|
||||
onTap: () => showDialog(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (ctx) => const ImmichAppBarDialog(),
|
||||
),
|
||||
borderRadius: BorderRadius.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: serverInfoState.isVersionMismatch ||
|
||||
((user?.isAdmin ?? false) &&
|
||||
serverInfoState.isNewReleaseAvailable),
|
||||
offset: const Offset(2, 2),
|
||||
child: user == null
|
||||
? const Icon(
|
||||
Icons.face_outlined,
|
||||
size: widgetSize,
|
||||
)
|
||||
: UserCircleAvatar(
|
||||
radius: 15,
|
||||
size: 27,
|
||||
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 = isDarkTheme ? Colors.blueGrey[800] : Colors.white;
|
||||
|
||||
return InkWell(
|
||||
onTap: () => context.pushRoute(const BackupControllerRoute()),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
child: Badge(
|
||||
label: Container(
|
||||
width: widgetSize / 2,
|
||||
height: widgetSize / 2,
|
||||
decoration: BoxDecoration(
|
||||
color: badgeBackground,
|
||||
border: Border.all(
|
||||
color: isDarkTheme ? Colors.black : Colors.grey,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(widgetSize / 2),
|
||||
),
|
||||
child: indicatorIcon,
|
||||
),
|
||||
backgroundColor: Colors.transparent,
|
||||
alignment: Alignment.bottomRight,
|
||||
isLabelVisible: indicatorIcon != null,
|
||||
offset: const Offset(2, 2),
|
||||
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(
|
||||
children: [
|
||||
Builder(
|
||||
builder: (context) {
|
||||
final today = DateTime.now();
|
||||
if (today.month == 4 && today.day == 1) {
|
||||
if (immichLogo.value == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
return Image.memory(
|
||||
immichLogo.value!,
|
||||
fit: BoxFit.cover,
|
||||
height: 80,
|
||||
);
|
||||
}
|
||||
return 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,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
actions: [
|
||||
if (action != null)
|
||||
Padding(padding: const EdgeInsets.only(right: 20), child: action!),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: buildBackupIndicator(),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(right: 20),
|
||||
child: buildProfileIndicator(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
112
mobile/lib/widgets/common/immich_image.dart
Normal file
112
mobile/lib/widgets/common/immich_image.dart
Normal file
|
|
@ -0,0 +1,112 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_local_image_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_image_provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.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
|
||||
/// Use [isThumbnail] and [thumbnailSize] if you'd like to request a thumbnail
|
||||
/// The size of the square thumbnail to request. Ignored if isThumbnail
|
||||
/// is not true
|
||||
static ImageProvider imageProvider({
|
||||
Asset? asset,
|
||||
String? assetId,
|
||||
}) {
|
||||
if (asset == null && assetId == null) {
|
||||
throw Exception('Must supply either asset or assetId');
|
||||
}
|
||||
|
||||
if (asset == null) {
|
||||
return ImmichRemoteImageProvider(
|
||||
assetId: assetId!,
|
||||
);
|
||||
}
|
||||
|
||||
if (useLocal(asset)) {
|
||||
return ImmichLocalImageProvider(
|
||||
asset: asset,
|
||||
);
|
||||
} else {
|
||||
return ImmichRemoteImageProvider(
|
||||
assetId: asset.remoteId!,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// 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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return OctoImage(
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fadeOutDuration: const Duration(milliseconds: 200),
|
||||
placeholderBuilder: (context) {
|
||||
if (placeholder != null) {
|
||||
// Use the gray box placeholder
|
||||
return placeholder!;
|
||||
}
|
||||
// No placeholder
|
||||
return const SizedBox();
|
||||
},
|
||||
image: ImmichImage.imageProvider(
|
||||
asset: asset,
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
errorBuilder: (context, error, stackTrace) {
|
||||
if (error is PlatformException &&
|
||||
error.code == "The asset not found!") {
|
||||
debugPrint(
|
||||
"Asset ${asset?.localId} does not exist anymore on device!",
|
||||
);
|
||||
} else {
|
||||
debugPrint(
|
||||
"Error getting thumb for assetId=${asset?.localId}: $error",
|
||||
);
|
||||
}
|
||||
return Icon(
|
||||
Icons.image_not_supported_outlined,
|
||||
color: context.primaryColor,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
28
mobile/lib/widgets/common/immich_loading_indicator.dart
Normal file
28
mobile/lib/widgets/common/immich_loading_indicator.dart
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class ImmichLoadingIndicator extends StatelessWidget {
|
||||
final double? borderRadius;
|
||||
|
||||
const ImmichLoadingIndicator({
|
||||
super.key,
|
||||
this.borderRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
height: 60,
|
||||
width: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: context.primaryColor.withAlpha(200),
|
||||
borderRadius: BorderRadius.circular(borderRadius ?? 10),
|
||||
),
|
||||
padding: const EdgeInsets.all(15),
|
||||
child: const CircularProgressIndicator(
|
||||
color: Colors.white,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
25
mobile/lib/widgets/common/immich_logo.dart
Normal file
25
mobile/lib/widgets/common/immich_logo.dart
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class ImmichLogo extends StatelessWidget {
|
||||
final double size;
|
||||
final dynamic heroTag;
|
||||
|
||||
const ImmichLogo({
|
||||
super.key,
|
||||
this.size = 100,
|
||||
this.heroTag,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Hero(
|
||||
tag: heroTag,
|
||||
child: Image(
|
||||
image: const AssetImage('assets/immich-logo.png'),
|
||||
width: size,
|
||||
filterQuality: FilterQuality.high,
|
||||
isAntiAlias: true,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
88
mobile/lib/widgets/common/immich_thumbnail.dart
Normal file
88
mobile/lib/widgets/common/immich_thumbnail.dart
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_local_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/providers/image/immich_remote_thumbnail_provider.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/shared/ui/hooks/blurhash_hook.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';
|
||||
|
||||
class ImmichThumbnail extends HookWidget {
|
||||
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 ImmichRemoteThumbnailProvider(
|
||||
assetId: assetId!,
|
||||
);
|
||||
}
|
||||
|
||||
if (ImmichImage.useLocal(asset)) {
|
||||
return ImmichLocalThumbnailProvider(
|
||||
asset: asset,
|
||||
height: thumbnailSize,
|
||||
width: thumbnailSize,
|
||||
);
|
||||
} else {
|
||||
return ImmichRemoteThumbnailProvider(
|
||||
assetId: asset.remoteId!,
|
||||
height: thumbnailSize,
|
||||
width: thumbnailSize,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return OctoImage.fromSet(
|
||||
placeholderFadeInDuration: Duration.zero,
|
||||
fadeInDuration: Duration.zero,
|
||||
fadeOutDuration: const Duration(milliseconds: 100),
|
||||
octoSet: blurHashOrPlaceholder(blurhash),
|
||||
image: ImmichThumbnail.imageProvider(
|
||||
asset: asset,
|
||||
),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: fit,
|
||||
);
|
||||
}
|
||||
}
|
||||
26
mobile/lib/widgets/common/immich_title_text.dart
Normal file
26
mobile/lib/widgets/common/immich_title_text.dart
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class ImmichTitleText extends StatelessWidget {
|
||||
final double fontSize;
|
||||
final Color? color;
|
||||
|
||||
const ImmichTitleText({
|
||||
super.key,
|
||||
this.fontSize = 48,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Image(
|
||||
image: AssetImage(
|
||||
context.isDarkTheme
|
||||
? 'assets/immich-text-dark.png'
|
||||
: 'assets/immich-text-light.png',
|
||||
),
|
||||
width: fontSize * 4,
|
||||
filterQuality: FilterQuality.high,
|
||||
);
|
||||
}
|
||||
}
|
||||
84
mobile/lib/widgets/common/immich_toast.dart
Normal file
84
mobile/lib/widgets/common/immich_toast.dart
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
enum ToastType { info, success, error }
|
||||
|
||||
class ImmichToast {
|
||||
static show({
|
||||
required BuildContext context,
|
||||
required String msg,
|
||||
ToastType toastType = ToastType.info,
|
||||
ToastGravity gravity = ToastGravity.BOTTOM,
|
||||
int durationInSecond = 3,
|
||||
}) {
|
||||
final fToast = FToast();
|
||||
fToast.init(context);
|
||||
|
||||
Color getColor(ToastType type, BuildContext context) {
|
||||
switch (type) {
|
||||
case ToastType.info:
|
||||
return context.primaryColor;
|
||||
case ToastType.success:
|
||||
return const Color.fromARGB(255, 78, 140, 124);
|
||||
case ToastType.error:
|
||||
return const Color.fromARGB(255, 220, 48, 85);
|
||||
}
|
||||
}
|
||||
|
||||
Icon getIcon(ToastType type) {
|
||||
switch (type) {
|
||||
case ToastType.info:
|
||||
return Icon(
|
||||
Icons.info_outline_rounded,
|
||||
color: context.primaryColor,
|
||||
);
|
||||
case ToastType.success:
|
||||
return const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Color.fromARGB(255, 78, 140, 124),
|
||||
);
|
||||
case ToastType.error:
|
||||
return const Icon(
|
||||
Icons.error_outline_rounded,
|
||||
color: Color.fromARGB(255, 240, 162, 156),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
fToast.showToast(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(5.0),
|
||||
color: context.isDarkTheme ? Colors.grey[900] : Colors.grey[50],
|
||||
border: Border.all(
|
||||
color: Colors.black12,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
getIcon(toastType),
|
||||
const SizedBox(
|
||||
width: 12.0,
|
||||
),
|
||||
Flexible(
|
||||
child: Text(
|
||||
msg,
|
||||
style: TextStyle(
|
||||
color: getColor(toastType, context),
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
gravity: gravity,
|
||||
toastDuration: Duration(seconds: durationInSecond),
|
||||
);
|
||||
}
|
||||
}
|
||||
268
mobile/lib/widgets/common/location_picker.dart
Normal file
268
mobile/lib/widgets/common/location_picker.dart
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/string_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
Future<LatLng?> showLocationPicker({
|
||||
required BuildContext context,
|
||||
LatLng? initialLatLng,
|
||||
}) {
|
||||
return showDialog<LatLng?>(
|
||||
context: context,
|
||||
useRootNavigator: false,
|
||||
builder: (ctx) => _LocationPicker(
|
||||
initialLatLng: initialLatLng,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
enum _LocationPickerMode { map, manual }
|
||||
|
||||
class _LocationPicker extends HookWidget {
|
||||
final LatLng? initialLatLng;
|
||||
|
||||
const _LocationPicker({
|
||||
this.initialLatLng,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latitude = useState(initialLatLng?.latitude ?? 0.0);
|
||||
final longitude = useState(initialLatLng?.longitude ?? 0.0);
|
||||
final latlng = LatLng(latitude.value, longitude.value);
|
||||
final pickerMode = useState(_LocationPickerMode.map);
|
||||
|
||||
Future<void> onMapTap() async {
|
||||
final newLatLng = await context.pushRoute<LatLng?>(
|
||||
MapLocationPickerRoute(initialLatLng: latlng),
|
||||
);
|
||||
if (newLatLng != null) {
|
||||
latitude.value = newLatLng.latitude;
|
||||
longitude.value = newLatLng.longitude;
|
||||
}
|
||||
}
|
||||
|
||||
return AlertDialog(
|
||||
contentPadding: const EdgeInsets.all(30),
|
||||
alignment: Alignment.center,
|
||||
content: SingleChildScrollView(
|
||||
child: pickerMode.value == _LocationPickerMode.map
|
||||
? _MapPicker(
|
||||
key: ValueKey(latlng),
|
||||
latlng: latlng,
|
||||
onModeSwitch: () =>
|
||||
pickerMode.value = _LocationPickerMode.manual,
|
||||
onMapTap: onMapTap,
|
||||
)
|
||||
: _ManualPicker(
|
||||
latlng: latlng,
|
||||
onModeSwitch: () => pickerMode.value = _LocationPickerMode.map,
|
||||
onLatUpdated: (value) => latitude.value = value,
|
||||
onLonUpdated: (value) => longitude.value = value,
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => context.pop(),
|
||||
child: Text(
|
||||
"action_common_cancel",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.popRoute(latlng),
|
||||
child: Text(
|
||||
"action_common_update",
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ManualPickerInput extends HookWidget {
|
||||
final String initialValue;
|
||||
final String decorationText;
|
||||
final String hintText;
|
||||
final String errorText;
|
||||
final FocusNode focusNode;
|
||||
final bool Function(String value) validator;
|
||||
final Function(double value) onUpdated;
|
||||
|
||||
const _ManualPickerInput({
|
||||
required this.initialValue,
|
||||
required this.decorationText,
|
||||
required this.hintText,
|
||||
required this.errorText,
|
||||
required this.focusNode,
|
||||
required this.validator,
|
||||
required this.onUpdated,
|
||||
});
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isValid = useState(true);
|
||||
final controller = useTextEditingController(text: initialValue);
|
||||
|
||||
void onEditingComplete() {
|
||||
isValid.value = validator(controller.text);
|
||||
if (isValid.value) {
|
||||
onUpdated(controller.text.toDouble());
|
||||
}
|
||||
}
|
||||
|
||||
return TextField(
|
||||
controller: controller,
|
||||
focusNode: focusNode,
|
||||
textInputAction: TextInputAction.done,
|
||||
autofocus: false,
|
||||
decoration: InputDecoration(
|
||||
labelText: decorationText.tr(),
|
||||
labelStyle: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
floatingLabelBehavior: FloatingLabelBehavior.auto,
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: hintText.tr(),
|
||||
hintStyle: const TextStyle(fontWeight: FontWeight.normal, fontSize: 14),
|
||||
errorText: isValid.value ? null : errorText.tr(),
|
||||
),
|
||||
onEditingComplete: onEditingComplete,
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
inputFormatters: [LengthLimitingTextInputFormatter(8)],
|
||||
onTapOutside: (_) => focusNode.unfocus(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ManualPicker extends HookWidget {
|
||||
final LatLng latlng;
|
||||
final Function() onModeSwitch;
|
||||
final Function(double) onLatUpdated;
|
||||
final Function(double) onLonUpdated;
|
||||
|
||||
const _ManualPicker({
|
||||
required this.latlng,
|
||||
required this.onModeSwitch,
|
||||
required this.onLatUpdated,
|
||||
required this.onLonUpdated,
|
||||
});
|
||||
|
||||
bool _validateLat(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -90 && l < 90;
|
||||
}
|
||||
|
||||
bool _validateLong(String value) {
|
||||
final l = double.tryParse(value);
|
||||
return l != null && l > -180 && l < 180;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final latitiudeFocusNode = useFocusNode();
|
||||
final longitudeFocusNode = useFocusNode();
|
||||
|
||||
void onLatitudeUpdated(double value) {
|
||||
onLatUpdated(value);
|
||||
longitudeFocusNode.requestFocus();
|
||||
}
|
||||
|
||||
void onLongitudeEditingCompleted(double value) {
|
||||
onLonUpdated(value);
|
||||
longitudeFocusNode.unfocus();
|
||||
}
|
||||
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"edit_location_dialog_title",
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
icon: const Text("location_picker_choose_on_map").tr(),
|
||||
label: const Icon(Icons.map_outlined, size: 16),
|
||||
onPressed: onModeSwitch,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_ManualPickerInput(
|
||||
initialValue: latlng.latitude.toStringAsFixed(4),
|
||||
decorationText: "location_picker_latitude",
|
||||
hintText: "location_picker_latitude_hint",
|
||||
errorText: "location_picker_latitude_error",
|
||||
focusNode: latitiudeFocusNode,
|
||||
validator: _validateLat,
|
||||
onUpdated: onLatitudeUpdated,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
_ManualPickerInput(
|
||||
initialValue: latlng.longitude.toStringAsFixed(4),
|
||||
decorationText: "location_picker_longitude",
|
||||
hintText: "location_picker_longitude_hint",
|
||||
errorText: "location_picker_longitude_error",
|
||||
focusNode: latitiudeFocusNode,
|
||||
validator: _validateLong,
|
||||
onUpdated: onLongitudeEditingCompleted,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapPicker extends StatelessWidget {
|
||||
final LatLng latlng;
|
||||
final Function() onModeSwitch;
|
||||
final Function() onMapTap;
|
||||
|
||||
const _MapPicker({
|
||||
required this.latlng,
|
||||
required this.onModeSwitch,
|
||||
required this.onMapTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const Text(
|
||||
"edit_location_dialog_title",
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
icon: Text(
|
||||
"${latlng.latitude.toStringAsFixed(4)}, ${latlng.longitude.toStringAsFixed(4)}",
|
||||
),
|
||||
label: const Icon(Icons.edit_outlined, size: 16),
|
||||
onPressed: onModeSwitch,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
MapThumbnail(
|
||||
centre: latlng,
|
||||
height: 200,
|
||||
width: 200,
|
||||
zoom: 8,
|
||||
showMarkerPin: true,
|
||||
onTap: (_, __) => onMapTap(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
46
mobile/lib/widgets/common/scaffold_error_body.dart
Normal file
46
mobile/lib/widgets/common/scaffold_error_body.dart
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
// Error widget to be used in Scaffold when an AsyncError is received
|
||||
class ScaffoldErrorBody extends StatelessWidget {
|
||||
final bool withIcon;
|
||||
final String? errorMsg;
|
||||
|
||||
const ScaffoldErrorBody({super.key, this.withIcon = true, this.errorMsg});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
"scaffold_body_error_occurred",
|
||||
style: context.textTheme.displayMedium,
|
||||
textAlign: TextAlign.center,
|
||||
).tr(),
|
||||
if (withIcon)
|
||||
Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 15),
|
||||
child: Icon(
|
||||
Icons.error_outline,
|
||||
size: 100,
|
||||
color: context.themeData.iconTheme.color?.withOpacity(0.5),
|
||||
),
|
||||
),
|
||||
),
|
||||
if (withIcon && errorMsg != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Text(
|
||||
errorMsg!,
|
||||
style: context.textTheme.displaySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
22
mobile/lib/widgets/common/share_dialog.dart
Normal file
22
mobile/lib/widgets/common/share_dialog.dart
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
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(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
48
mobile/lib/widgets/common/thumbhash_placeholder.dart
Normal file
48
mobile/lib/widgets/common/thumbhash_placeholder.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
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),
|
||||
);
|
||||
}
|
||||
|
||||
OctoPlaceholderBuilder blurHashPlaceholderBuilder(
|
||||
Uint8List? blurhash, {
|
||||
BoxFit? fit,
|
||||
}) {
|
||||
return (context) => blurhash == null
|
||||
? const ThumbnailPlaceholder()
|
||||
: FadeInPlaceholderImage(
|
||||
placeholder: const ThumbnailPlaceholder(),
|
||||
image: MemoryImage(blurhash),
|
||||
fit: fit ?? BoxFit.cover,
|
||||
);
|
||||
}
|
||||
|
||||
OctoErrorBuilder blurHashErrorBuilder(
|
||||
Uint8List? blurhash, {
|
||||
BoxFit? fit,
|
||||
Text? message,
|
||||
IconData? icon,
|
||||
Color? iconColor,
|
||||
double? iconSize,
|
||||
}) {
|
||||
return OctoError.placeholderWithErrorIcon(
|
||||
blurHashPlaceholderBuilder(blurhash, fit: fit),
|
||||
message: message,
|
||||
icon: icon,
|
||||
iconColor: iconColor,
|
||||
iconSize: iconSize,
|
||||
);
|
||||
}
|
||||
68
mobile/lib/widgets/common/transparent_image.dart
Normal file
68
mobile/lib/widgets/common/transparent_image.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
final Uint8List kTransparentImage = Uint8List.fromList(<int>[
|
||||
0x89,
|
||||
0x50,
|
||||
0x4E,
|
||||
0x47,
|
||||
0x0D,
|
||||
0x0A,
|
||||
0x1A,
|
||||
0x0A,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0D,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x08,
|
||||
0x06,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1F,
|
||||
0x15,
|
||||
0xC4,
|
||||
0x89,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0A,
|
||||
0x49,
|
||||
0x44,
|
||||
0x41,
|
||||
0x54,
|
||||
0x78,
|
||||
0x9C,
|
||||
0x63,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x05,
|
||||
0x00,
|
||||
0x01,
|
||||
0x0D,
|
||||
0x0A,
|
||||
0x2D,
|
||||
0xB4,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x49,
|
||||
0x45,
|
||||
0x4E,
|
||||
0x44,
|
||||
0xAE,
|
||||
]);
|
||||
23
mobile/lib/widgets/common/user_avatar.dart
Normal file
23
mobile/lib/widgets/common/user_avatar.dart
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
|
||||
Widget userAvatar(BuildContext context, User u, {double? radius}) {
|
||||
final url =
|
||||
"${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${u.id}";
|
||||
final nameFirstLetter = u.name.isNotEmpty ? u.name[0] : "";
|
||||
return CircleAvatar(
|
||||
radius: radius,
|
||||
backgroundColor: context.primaryColor.withAlpha(50),
|
||||
foregroundImage: CachedNetworkImageProvider(
|
||||
url,
|
||||
headers: {"x-immich-user-token": Store.get(StoreKey.accessToken)},
|
||||
cacheKey: "user-${u.id}-profile",
|
||||
),
|
||||
// silence errors if user has no profile image, use initials as fallback
|
||||
onForegroundImageError: (exception, stackTrace) {},
|
||||
child: Text(nameFirstLetter.toUpperCase()),
|
||||
);
|
||||
}
|
||||
62
mobile/lib/widgets/common/user_circle_avatar.dart
Normal file
62
mobile/lib/widgets/common/user_circle_avatar.dart
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
import 'dart:math';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.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/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/widgets/common/transparent_image.dart';
|
||||
|
||||
// ignore: must_be_immutable
|
||||
class UserCircleAvatar extends ConsumerWidget {
|
||||
final User user;
|
||||
double radius;
|
||||
double size;
|
||||
|
||||
UserCircleAvatar({
|
||||
super.key,
|
||||
this.radius = 22,
|
||||
this.size = 44,
|
||||
required this.user,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
bool isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
final profileImageUrl =
|
||||
'${Store.get(StoreKey.serverEndpoint)}/user/profile-image/${user.id}?d=${Random().nextInt(1024)}';
|
||||
|
||||
final textIcon = Text(
|
||||
user.name[0].toUpperCase(),
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 12,
|
||||
color: isDarkTheme && user.avatarColor == AvatarColorEnum.primary
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
),
|
||||
);
|
||||
return CircleAvatar(
|
||||
backgroundColor: user.avatarColor.toColor(),
|
||||
radius: radius,
|
||||
child: user.profileImagePath.isEmpty
|
||||
? textIcon
|
||||
: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(50)),
|
||||
child: CachedNetworkImage(
|
||||
fit: BoxFit.cover,
|
||||
cacheKey: user.profileImagePath,
|
||||
width: size,
|
||||
height: size,
|
||||
placeholder: (_, __) => Image.memory(kTransparentImage),
|
||||
imageUrl: profileImageUrl,
|
||||
httpHeaders: {
|
||||
"x-immich-user-token": Store.get(StoreKey.accessToken),
|
||||
},
|
||||
fadeInDuration: const Duration(milliseconds: 300),
|
||||
errorWidget: (context, error, stackTrace) => textIcon,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
206
mobile/lib/widgets/forms/change_password_form.dart
Normal file
206
mobile/lib/widgets/forms/change_password_form.dart
Normal file
|
|
@ -0,0 +1,206 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:auto_route/auto_route.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/authentication.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';
|
||||
|
||||
class ChangePasswordForm extends HookConsumerWidget {
|
||||
const ChangePasswordForm({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final passwordController =
|
||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final confirmPasswordController =
|
||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final authState = ref.watch(authenticationProvider);
|
||||
final formKey = GlobalKey<FormState>();
|
||||
|
||||
return Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: SingleChildScrollView(
|
||||
child: Wrap(
|
||||
spacing: 16,
|
||||
runSpacing: 16,
|
||||
alignment: WrapAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'common_change_password'.tr(),
|
||||
style: TextStyle(
|
||||
fontSize: 24,
|
||||
fontWeight: FontWeight.bold,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 24.0),
|
||||
child: Text(
|
||||
'change_password_form_description'.tr(
|
||||
namedArgs: {
|
||||
'name': authState.name,
|
||||
},
|
||||
),
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: Colors.grey[700],
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
children: [
|
||||
PasswordInput(controller: passwordController),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16.0),
|
||||
child: ConfirmPasswordInput(
|
||||
originalController: passwordController,
|
||||
confirmController: confirmPasswordController,
|
||||
),
|
||||
),
|
||||
ChangePasswordButton(
|
||||
passwordController: passwordController,
|
||||
onPressed: () async {
|
||||
if (formKey.currentState!.validate()) {
|
||||
var isSuccess = await ref
|
||||
.read(authenticationProvider.notifier)
|
||||
.changePassword(passwordController.value.text);
|
||||
|
||||
if (isSuccess) {
|
||||
await ref
|
||||
.read(authenticationProvider.notifier)
|
||||
.logout();
|
||||
|
||||
ref
|
||||
.read(manualUploadProvider.notifier)
|
||||
.cancelBackup();
|
||||
ref.read(backupProvider.notifier).cancelBackup();
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.clearAllAsset();
|
||||
ref.read(websocketProvider.notifier).disconnect();
|
||||
|
||||
AutoRouter.of(context).back();
|
||||
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_password_changed_success".tr(),
|
||||
toastType: ToastType.success,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_password_changed_error".tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => AutoRouter.of(context).back(),
|
||||
label: const Text('action_common_back').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
|
||||
const PasswordInput({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
obscureText: true,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'change_password_form_new_password'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'change_password_form_new_password'.tr(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ConfirmPasswordInput extends StatelessWidget {
|
||||
final TextEditingController originalController;
|
||||
final TextEditingController confirmController;
|
||||
|
||||
const ConfirmPasswordInput({
|
||||
super.key,
|
||||
required this.originalController,
|
||||
required this.confirmController,
|
||||
});
|
||||
|
||||
String? _validateInput(String? email) {
|
||||
if (confirmController.value != originalController.value) {
|
||||
return 'change_password_form_password_mismatch'.tr();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
obscureText: true,
|
||||
controller: confirmController,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'change_password_form_confirm_password'.tr(),
|
||||
hintText: 'change_password_form_reenter_new_password'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ChangePasswordButton extends ConsumerWidget {
|
||||
final TextEditingController passwordController;
|
||||
final VoidCallback onPressed;
|
||||
const ChangePasswordButton({
|
||||
super.key,
|
||||
required this.passwordController,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
visualDensity: VisualDensity.standard,
|
||||
backgroundColor: context.primaryColor,
|
||||
foregroundColor: Colors.grey[50],
|
||||
elevation: 2,
|
||||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 25),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
child: Text(
|
||||
'common_change_password'.tr(),
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
647
mobile/lib/widgets/forms/login_form.dart
Normal file
647
mobile/lib/widgets/forms/login_form.dart
Normal file
|
|
@ -0,0 +1,647 @@
|
|||
import 'dart:io';
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/oauth.provider.dart';
|
||||
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
import 'package:immich_mobile/providers/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/authentication.provider.dart';
|
||||
import 'package:immich_mobile/providers/backup/backup.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_logo.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_title_text.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
||||
import 'package:immich_mobile/utils/url_helper.dart';
|
||||
import 'package:openapi/api.dart';
|
||||
import 'package:permission_handler/permission_handler.dart';
|
||||
|
||||
class LoginForm extends HookConsumerWidget {
|
||||
const LoginForm({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final usernameController =
|
||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final passwordController =
|
||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final serverEndpointController =
|
||||
useTextEditingController.fromValue(TextEditingValue.empty);
|
||||
final apiService = ref.watch(apiServiceProvider);
|
||||
final emailFocusNode = useFocusNode();
|
||||
final passwordFocusNode = useFocusNode();
|
||||
final serverEndpointFocusNode = useFocusNode();
|
||||
final isLoading = useState<bool>(false);
|
||||
final isLoadingServer = useState<bool>(false);
|
||||
final isOauthEnable = useState<bool>(false);
|
||||
final isPasswordLoginEnable = useState<bool>(false);
|
||||
final oAuthButtonLabel = useState<String>('OAuth');
|
||||
final logoAnimationController = useAnimationController(
|
||||
duration: const Duration(seconds: 60),
|
||||
)..repeat();
|
||||
|
||||
final ValueNotifier<String?> serverEndpoint = useState<String?>(null);
|
||||
|
||||
/// Fetch the server login credential and enables oAuth login if necessary
|
||||
/// Returns true if successful, false otherwise
|
||||
Future<bool> getServerLoginCredential() async {
|
||||
final serverUrl = sanitizeUrl(serverEndpointController.text);
|
||||
|
||||
// Guard empty URL
|
||||
if (serverUrl.isEmpty) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_form_server_empty".tr(),
|
||||
toastType: ToastType.error,
|
||||
);
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
try {
|
||||
isLoadingServer.value = true;
|
||||
final endpoint = await apiService.resolveAndSetEndpoint(serverUrl);
|
||||
|
||||
// Fetch and load server config and features
|
||||
await ref.read(serverInfoProvider.notifier).getServerInfo();
|
||||
|
||||
final serverInfo = ref.read(serverInfoProvider);
|
||||
final features = serverInfo.serverFeatures;
|
||||
final config = serverInfo.serverConfig;
|
||||
|
||||
isOauthEnable.value = features.oauthEnabled;
|
||||
isPasswordLoginEnable.value = features.passwordLogin;
|
||||
oAuthButtonLabel.value = config.oauthButtonText.isNotEmpty
|
||||
? config.oauthButtonText
|
||||
: 'OAuth';
|
||||
|
||||
serverEndpoint.value = endpoint;
|
||||
} on ApiException catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: e.message ?? 'login_form_api_exception'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
isOauthEnable.value = false;
|
||||
isPasswordLoginEnable.value = true;
|
||||
isLoadingServer.value = false;
|
||||
return false;
|
||||
} on HandshakeException {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'login_form_handshake_exception'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
isOauthEnable.value = false;
|
||||
isPasswordLoginEnable.value = true;
|
||||
isLoadingServer.value = false;
|
||||
return false;
|
||||
} catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: 'login_form_server_error'.tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
isOauthEnable.value = false;
|
||||
isPasswordLoginEnable.value = true;
|
||||
isLoadingServer.value = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
isLoadingServer.value = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
useEffect(
|
||||
() {
|
||||
final serverUrl = Store.tryGet(StoreKey.serverUrl);
|
||||
if (serverUrl != null) {
|
||||
serverEndpointController.text = serverUrl;
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
populateTestLoginInfo() {
|
||||
usernameController.text = 'demo@immich.app';
|
||||
passwordController.text = 'demo';
|
||||
serverEndpointController.text = 'https://demo.immich.app';
|
||||
}
|
||||
|
||||
populateTestLoginInfo1() {
|
||||
usernameController.text = 'testuser@email.com';
|
||||
passwordController.text = 'password';
|
||||
serverEndpointController.text = 'http://10.1.15.216:2283/api';
|
||||
}
|
||||
|
||||
login() async {
|
||||
// Start loading
|
||||
isLoading.value = true;
|
||||
|
||||
// This will remove current cache asset state of previous user login.
|
||||
ref.read(assetProvider.notifier).clearAllAsset();
|
||||
|
||||
try {
|
||||
final isAuthenticated =
|
||||
await ref.read(authenticationProvider.notifier).login(
|
||||
usernameController.text,
|
||||
passwordController.text,
|
||||
sanitizeUrl(serverEndpointController.text),
|
||||
);
|
||||
if (isAuthenticated) {
|
||||
// Resume backup (if enable) then navigate
|
||||
if (ref.read(authenticationProvider).shouldChangePassword &&
|
||||
!ref.read(authenticationProvider).isAdmin) {
|
||||
context.pushRoute(const ChangePasswordRoute());
|
||||
} else {
|
||||
final hasPermission = await ref
|
||||
.read(galleryPermissionNotifier.notifier)
|
||||
.hasPermission;
|
||||
if (hasPermission) {
|
||||
// Don't resume the backup until we have gallery permission
|
||||
ref.read(backupProvider.notifier).resumeBackup();
|
||||
}
|
||||
context.replaceRoute(const TabControllerRoute());
|
||||
}
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_form_failed_login".tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
// Make sure we stop loading
|
||||
isLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
oAuthLogin() async {
|
||||
var oAuthService = ref.watch(oAuthServiceProvider);
|
||||
ref.watch(assetProvider.notifier).clearAllAsset();
|
||||
String? oAuthServerUrl;
|
||||
|
||||
try {
|
||||
oAuthServerUrl = await oAuthService
|
||||
.getOAuthServerUrl(sanitizeUrl(serverEndpointController.text));
|
||||
|
||||
isLoading.value = true;
|
||||
} catch (e) {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_form_failed_get_oauth_server_config".tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (oAuthServerUrl != null) {
|
||||
var loginResponseDto = await oAuthService.oAuthLogin(oAuthServerUrl);
|
||||
|
||||
if (loginResponseDto != null) {
|
||||
var isSuccess = await ref
|
||||
.watch(authenticationProvider.notifier)
|
||||
.setSuccessLoginInfo(
|
||||
accessToken: loginResponseDto.accessToken,
|
||||
serverUrl: sanitizeUrl(serverEndpointController.text),
|
||||
);
|
||||
|
||||
if (isSuccess) {
|
||||
isLoading.value = false;
|
||||
final permission = ref.watch(galleryPermissionNotifier);
|
||||
if (permission.isGranted || permission.isLimited) {
|
||||
ref.watch(backupProvider.notifier).resumeBackup();
|
||||
}
|
||||
context.replaceRoute(const TabControllerRoute());
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_form_failed_login".tr(),
|
||||
toastType: ToastType.error,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
isLoading.value = false;
|
||||
} else {
|
||||
ImmichToast.show(
|
||||
context: context,
|
||||
msg: "login_form_failed_get_oauth_server_disable".tr(),
|
||||
toastType: ToastType.info,
|
||||
gravity: ToastGravity.TOP,
|
||||
);
|
||||
isLoading.value = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
buildSelectServer() {
|
||||
const buttonRadius = 25.0;
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ServerEndpointInput(
|
||||
controller: serverEndpointController,
|
||||
focusNode: serverEndpointFocusNode,
|
||||
onSubmit: getServerLoginCredential,
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(buttonRadius),
|
||||
bottomLeft: Radius.circular(buttonRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed: () => context.pushRoute(const SettingsRoute()),
|
||||
icon: const Icon(Icons.settings_rounded),
|
||||
label: const SizedBox.shrink(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 1),
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.only(
|
||||
topRight: Radius.circular(buttonRadius),
|
||||
bottomRight: Radius.circular(buttonRadius),
|
||||
),
|
||||
),
|
||||
),
|
||||
onPressed:
|
||||
isLoadingServer.value ? null : getServerLoginCredential,
|
||||
icon: const Icon(Icons.arrow_forward_rounded),
|
||||
label: const Text(
|
||||
'login_form_next_button',
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
if (isLoadingServer.value) const LoadingIcon(),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
buildLogin() {
|
||||
return AutofillGroup(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
sanitizeUrl(serverEndpointController.text),
|
||||
style: context.textTheme.displaySmall,
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
if (isPasswordLoginEnable.value) ...[
|
||||
const SizedBox(height: 18),
|
||||
EmailInput(
|
||||
controller: usernameController,
|
||||
focusNode: emailFocusNode,
|
||||
onSubmit: passwordFocusNode.requestFocus,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
PasswordInput(
|
||||
controller: passwordController,
|
||||
focusNode: passwordFocusNode,
|
||||
onSubmit: login,
|
||||
),
|
||||
],
|
||||
|
||||
// Note: This used to have an AnimatedSwitcher, but was removed
|
||||
// because of https://github.com/flutter/flutter/issues/120874
|
||||
isLoading.value
|
||||
? const LoadingIcon()
|
||||
: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const SizedBox(height: 18),
|
||||
if (isPasswordLoginEnable.value)
|
||||
LoginButton(onPressed: login),
|
||||
if (isOauthEnable.value) ...[
|
||||
if (isPasswordLoginEnable.value)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16.0,
|
||||
),
|
||||
child: Divider(
|
||||
color: context.isDarkTheme
|
||||
? Colors.white
|
||||
: Colors.black,
|
||||
),
|
||||
),
|
||||
OAuthLoginButton(
|
||||
serverEndpointController: serverEndpointController,
|
||||
buttonLabel: oAuthButtonLabel.value,
|
||||
isLoading: isLoading,
|
||||
onPressed: oAuthLogin,
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
if (!isOauthEnable.value && !isPasswordLoginEnable.value)
|
||||
Center(
|
||||
child: const Text('login_disabled').tr(),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
TextButton.icon(
|
||||
icon: const Icon(Icons.arrow_back),
|
||||
onPressed: () => serverEndpoint.value = null,
|
||||
label: const Text('login_form_back_button_text').tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
final serverSelectionOrLogin =
|
||||
serverEndpoint.value == null ? buildSelectServer() : buildLogin();
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return SingleChildScrollView(
|
||||
child: Center(
|
||||
child: Container(
|
||||
constraints: const BoxConstraints(maxWidth: 300),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
SizedBox(
|
||||
height: constraints.maxHeight / 5,
|
||||
),
|
||||
Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
children: [
|
||||
GestureDetector(
|
||||
onDoubleTap: () => populateTestLoginInfo(),
|
||||
onLongPress: () => populateTestLoginInfo1(),
|
||||
child: RotationTransition(
|
||||
turns: logoAnimationController,
|
||||
child: const ImmichLogo(
|
||||
heroTag: 'logo',
|
||||
),
|
||||
),
|
||||
),
|
||||
const Padding(
|
||||
padding: EdgeInsets.only(top: 8.0, bottom: 16),
|
||||
child: ImmichTitleText(),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 18),
|
||||
|
||||
// Note: This used to have an AnimatedSwitcher, but was removed
|
||||
// because of https://github.com/flutter/flutter/issues/120874
|
||||
serverSelectionOrLogin,
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class ServerEndpointInput extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode focusNode;
|
||||
final Function()? onSubmit;
|
||||
|
||||
const ServerEndpointInput({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.focusNode,
|
||||
this.onSubmit,
|
||||
});
|
||||
|
||||
String? _validateInput(String? url) {
|
||||
if (url == null || url.isEmpty) return null;
|
||||
|
||||
final parsedUrl = Uri.tryParse(sanitizeUrl(url));
|
||||
if (parsedUrl == null ||
|
||||
!parsedUrl.isAbsolute ||
|
||||
!parsedUrl.scheme.startsWith("http") ||
|
||||
parsedUrl.host.isEmpty) {
|
||||
return 'login_form_err_invalid_url'.tr();
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'login_form_endpoint_url'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'login_form_endpoint_hint'.tr(),
|
||||
errorMaxLines: 4,
|
||||
),
|
||||
validator: _validateInput,
|
||||
autovalidateMode: AutovalidateMode.always,
|
||||
focusNode: focusNode,
|
||||
autofillHints: const [AutofillHints.url],
|
||||
keyboardType: TextInputType.url,
|
||||
autocorrect: false,
|
||||
onFieldSubmitted: (_) => onSubmit?.call(),
|
||||
textInputAction: TextInputAction.go,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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: 'login_form_label_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,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PasswordInput extends HookConsumerWidget {
|
||||
final TextEditingController controller;
|
||||
final FocusNode? focusNode;
|
||||
final Function()? onSubmit;
|
||||
|
||||
const PasswordInput({
|
||||
super.key,
|
||||
required this.controller,
|
||||
this.focusNode,
|
||||
this.onSubmit,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final isPasswordVisible = useState<bool>(false);
|
||||
|
||||
return TextFormField(
|
||||
obscureText: !isPasswordVisible.value,
|
||||
controller: controller,
|
||||
decoration: InputDecoration(
|
||||
labelText: 'login_form_label_password'.tr(),
|
||||
border: const OutlineInputBorder(),
|
||||
hintText: 'login_form_password_hint'.tr(),
|
||||
hintStyle: const TextStyle(
|
||||
fontWeight: FontWeight.normal,
|
||||
fontSize: 14,
|
||||
),
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () => isPasswordVisible.value = !isPasswordVisible.value,
|
||||
icon: Icon(
|
||||
isPasswordVisible.value
|
||||
? Icons.visibility_off_sharp
|
||||
: Icons.visibility_sharp,
|
||||
),
|
||||
),
|
||||
),
|
||||
autofillHints: const [AutofillHints.password],
|
||||
keyboardType: TextInputType.text,
|
||||
onFieldSubmitted: (_) => onSubmit?.call(),
|
||||
focusNode: focusNode,
|
||||
textInputAction: TextInputAction.go,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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_form_button_text",
|
||||
style: TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class OAuthLoginButton extends ConsumerWidget {
|
||||
final TextEditingController serverEndpointController;
|
||||
final ValueNotifier<bool> isLoading;
|
||||
final String buttonLabel;
|
||||
final Function() onPressed;
|
||||
|
||||
const OAuthLoginButton({
|
||||
super.key,
|
||||
required this.serverEndpointController,
|
||||
required this.isLoading,
|
||||
required this.buttonLabel,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ElevatedButton.icon(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: context.primaryColor.withAlpha(230),
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
),
|
||||
onPressed: onPressed,
|
||||
icon: const Icon(Icons.pin_rounded),
|
||||
label: Text(
|
||||
buttonLabel,
|
||||
style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
159
mobile/lib/widgets/map/map_app_bar.dart
Normal file
159
mobile/lib/widgets/map/map_app_bar.dart
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_settings_sheet.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/utils/immich_loading_overlay.dart';
|
||||
import 'package:immich_mobile/utils/selection_handlers.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: MediaQuery.paddingOf(context).top + 25),
|
||||
child: ValueListenableBuilder(
|
||||
valueListenable: selectedAssets,
|
||||
builder: (ctx, value, child) => value.isNotEmpty
|
||||
? _SelectionRow(selectedAssets: selectedAssets)
|
||||
: _NonSelectionRow(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(100);
|
||||
}
|
||||
|
||||
class _NonSelectionRow extends StatelessWidget {
|
||||
@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.popRoute(),
|
||||
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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
280
mobile/lib/widgets/map/map_asset_grid.dart
Normal file
280
mobile/lib/widgets/map/map_asset_grid.dart
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||
import 'package:immich_mobile/providers/asset_viewer/render_list.provider.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/immich_asset_grid.dart';
|
||||
import 'package:immich_mobile/models/map/map_event.model.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/providers/db.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/drag_sheet.dart';
|
||||
import 'package:immich_mobile/utils/color_filter_generator.dart';
|
||||
import 'package:immich_mobile/utils/throttle.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));
|
||||
|
||||
void handleMapEvents(MapEvent event) async {
|
||||
if (event is MapAssetsInBoundsUpdated) {
|
||||
assetsInBounds.value = await ref
|
||||
.read(dbProvider)
|
||||
.assets
|
||||
.getAllByRemoteId(event.assetRemoteIds);
|
||||
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.80,
|
||||
child: assetsInBounds.value.isNotEmpty
|
||||
? ref.watch(renderListProvider(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(),
|
||||
)
|
||||
: _MapNoAssetsInSheet(),
|
||||
),
|
||||
),
|
||||
_MapSheetDragRegion(
|
||||
controller: controller,
|
||||
assetsInBoundCount: assetsInBounds.value.length,
|
||||
assetInSheet: assetInSheet,
|
||||
onZoomToAsset: onZoomToAsset,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _MapNoAssetsInSheet extends StatelessWidget {
|
||||
@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 = assetsInBoundCount > 0
|
||||
? "map_assets_in_bounds".tr(args: [assetsInBoundCount.toString()])
|
||||
: "map_no_assets_in_bounds".tr();
|
||||
|
||||
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),
|
||||
Text(assetsInBoundsText, style: context.textTheme.bodyLarge),
|
||||
const Divider(height: 35),
|
||||
],
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: assetInSheet,
|
||||
builder: (_, value, __) => Visibility(
|
||||
visible: value != null,
|
||||
child: Positioned(
|
||||
right: 15,
|
||||
top: 15,
|
||||
child: IconButton(
|
||||
icon: Icon(
|
||||
Icons.map_outlined,
|
||||
color: context.textTheme.displayLarge?.color,
|
||||
),
|
||||
iconSize: 20,
|
||||
tooltip: 'Zoom to bounds',
|
||||
onPressed: () => onZoomToAsset?.call(value!),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
97
mobile/lib/widgets/map/map_bottom_sheet.dart
Normal file
97
mobile/lib/widgets/map/map_bottom_sheet.dart
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
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/models/map/map_event.model.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_asset_grid.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/utils/draggable_scroll_controller.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) {
|
||||
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.5,
|
||||
initialChildSize: sheetMinExtent,
|
||||
snap: true,
|
||||
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: (ctx, value, child) => Positioned(
|
||||
right: 0,
|
||||
bottom: context.height * (value + 0.02),
|
||||
child: child!,
|
||||
),
|
||||
child: ElevatedButton(
|
||||
onPressed: onZoomToLocation,
|
||||
style: ElevatedButton.styleFrom(
|
||||
shape: const CircleBorder(),
|
||||
),
|
||||
child: const Icon(Icons.my_location),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class MapSettingsListTile extends StatelessWidget {
|
||||
final String title;
|
||||
final bool selected;
|
||||
final Function(bool) onChanged;
|
||||
|
||||
const MapSettingsListTile({
|
||||
super.key,
|
||||
required this.title,
|
||||
required this.selected,
|
||||
required this.onChanged,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile.adaptive(
|
||||
activeColor: context.primaryColor,
|
||||
title: Text(
|
||||
title,
|
||||
style:
|
||||
context.textTheme.labelLarge?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
value: selected,
|
||||
onChanged: onChanged,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class MapTimeDropDown extends StatelessWidget {
|
||||
final int relativeTime;
|
||||
final Function(int) onTimeChange;
|
||||
|
||||
const MapTimeDropDown({
|
||||
super.key,
|
||||
required this.relativeTime,
|
||||
required this.onTimeChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final now = DateTime.now();
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Text(
|
||||
"map_settings_only_relative_range".tr(),
|
||||
style: const TextStyle(fontWeight: FontWeight.bold),
|
||||
),
|
||||
),
|
||||
LayoutBuilder(
|
||||
builder: (_, constraints) => DropdownMenu(
|
||||
width: constraints.maxWidth * 0.9,
|
||||
enableSearch: false,
|
||||
enableFilter: false,
|
||||
initialSelection: relativeTime,
|
||||
onSelected: (value) => onTimeChange(value!),
|
||||
dropdownMenuEntries: [
|
||||
DropdownMenuEntry(
|
||||
value: 0,
|
||||
label: "map_settings_date_range_option_all".tr(),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 1,
|
||||
label: "map_settings_date_range_option_day".tr(),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 7,
|
||||
label: "map_settings_date_range_option_days".tr(
|
||||
args: ["7"],
|
||||
),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: 30,
|
||||
label: "map_settings_date_range_option_days".tr(
|
||||
args: ["30"],
|
||||
),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 1,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_year".tr(),
|
||||
),
|
||||
DropdownMenuEntry(
|
||||
value: now
|
||||
.difference(
|
||||
DateTime(
|
||||
now.year - 3,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
),
|
||||
)
|
||||
.inDays,
|
||||
label: "map_settings_date_range_option_years".tr(args: ["3"]),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
109
mobile/lib/widgets/map/map_settings/map_theme_picker.dart
Normal file
109
mobile/lib/widgets/map/map_settings/map_theme_picker.dart
Normal file
|
|
@ -0,0 +1,109 @@
|
|||
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/map/map_thumbnail.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
class MapThemePicker extends StatelessWidget {
|
||||
final ThemeMode themeMode;
|
||||
final Function(ThemeMode) onThemeChange;
|
||||
|
||||
const MapThemePicker({
|
||||
super.key,
|
||||
required this.themeMode,
|
||||
required this.onThemeChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(bottom: 20),
|
||||
child: Center(
|
||||
child: Text(
|
||||
"map_settings_theme_settings",
|
||||
style: context.textTheme.bodyMedium
|
||||
?.copyWith(fontWeight: FontWeight.bold),
|
||||
).tr(),
|
||||
),
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
_BorderedMapThumbnail(
|
||||
name: "Light",
|
||||
mode: ThemeMode.light,
|
||||
shouldHighlight: themeMode == ThemeMode.light,
|
||||
onThemeChange: onThemeChange,
|
||||
),
|
||||
_BorderedMapThumbnail(
|
||||
name: "Dark",
|
||||
mode: ThemeMode.dark,
|
||||
shouldHighlight: themeMode == ThemeMode.dark,
|
||||
onThemeChange: onThemeChange,
|
||||
),
|
||||
_BorderedMapThumbnail(
|
||||
name: "System",
|
||||
mode: ThemeMode.system,
|
||||
shouldHighlight: themeMode == ThemeMode.system,
|
||||
onThemeChange: onThemeChange,
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BorderedMapThumbnail extends StatelessWidget {
|
||||
final ThemeMode mode;
|
||||
final String name;
|
||||
final bool shouldHighlight;
|
||||
final Function(ThemeMode) onThemeChange;
|
||||
|
||||
const _BorderedMapThumbnail({
|
||||
required this.mode,
|
||||
required this.name,
|
||||
required this.shouldHighlight,
|
||||
required this.onThemeChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
Container(
|
||||
decoration: BoxDecoration(
|
||||
border: Border.fromBorderSide(
|
||||
BorderSide(
|
||||
width: 4,
|
||||
color: shouldHighlight
|
||||
? context.colorScheme.onSurface
|
||||
: Colors.transparent,
|
||||
),
|
||||
),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
child: MapThumbnail(
|
||||
zoom: 2,
|
||||
centre: const LatLng(47, 5),
|
||||
onTap: (_, __) => onThemeChange(mode),
|
||||
themeMode: mode,
|
||||
showAttribution: false,
|
||||
),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 10),
|
||||
child: Text(
|
||||
name,
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
fontWeight: shouldHighlight ? FontWeight.bold : null,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
68
mobile/lib/widgets/map/map_settings_sheet.dart
Normal file
68
mobile/lib/widgets/map/map_settings_sheet.dart
Normal file
|
|
@ -0,0 +1,68 @@
|
|||
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),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
96
mobile/lib/widgets/map/map_theme_override.dart
Normal file
96
mobile/lib/widgets/map/map_theme_override.dart
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/providers/map/map_state.provider.dart';
|
||||
import 'package:immich_mobile/utils/immich_app_theme.dart';
|
||||
|
||||
/// Overrides the theme below the widget tree to use the theme data based on the
|
||||
/// map settings instead of the one from the app settings
|
||||
class MapThemeOveride extends StatefulHookConsumerWidget {
|
||||
final ThemeMode? themeMode;
|
||||
final Widget Function(AsyncValue<String> style) mapBuilder;
|
||||
|
||||
const MapThemeOveride({required this.mapBuilder, this.themeMode, super.key});
|
||||
|
||||
@override
|
||||
ConsumerState createState() => _MapThemeOverideState();
|
||||
}
|
||||
|
||||
class _MapThemeOverideState extends ConsumerState<MapThemeOveride>
|
||||
with WidgetsBindingObserver {
|
||||
late ThemeMode _theme;
|
||||
bool _isDarkTheme = false;
|
||||
|
||||
bool get _isSystemDark =>
|
||||
WidgetsBinding.instance.platformDispatcher.platformBrightness ==
|
||||
Brightness.dark;
|
||||
|
||||
bool checkDarkTheme() {
|
||||
return _theme == ThemeMode.dark ||
|
||||
_theme == ThemeMode.system && _isSystemDark;
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_theme = widget.themeMode ??
|
||||
ref.read(mapStateNotifierProvider.select((v) => v.themeMode));
|
||||
setState(() {
|
||||
_isDarkTheme = checkDarkTheme();
|
||||
});
|
||||
if (_theme == ThemeMode.system) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
super.didChangeDependencies();
|
||||
if (_theme != ThemeMode.system) {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangePlatformBrightness() {
|
||||
super.didChangePlatformBrightness();
|
||||
|
||||
if (_theme == ThemeMode.system) {
|
||||
setState(() => _isDarkTheme = _isSystemDark);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
_theme = widget.themeMode ??
|
||||
ref.watch(mapStateNotifierProvider.select((v) => v.themeMode));
|
||||
|
||||
useValueChanged<ThemeMode, void>(_theme, (_, __) {
|
||||
if (_theme == ThemeMode.system) {
|
||||
WidgetsBinding.instance.addObserver(this);
|
||||
} else {
|
||||
WidgetsBinding.instance.removeObserver(this);
|
||||
}
|
||||
setState(() {
|
||||
_isDarkTheme = checkDarkTheme();
|
||||
});
|
||||
});
|
||||
|
||||
return Theme(
|
||||
data: _isDarkTheme ? immichDarkTheme : immichLightTheme,
|
||||
child: widget.mapBuilder.call(
|
||||
ref.watch(
|
||||
mapStateNotifierProvider.select(
|
||||
(v) => _isDarkTheme ? v.darkStyleFetched : v.lightStyleFetched,
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
110
mobile/lib/widgets/map/map_thumbnail.dart
Normal file
110
mobile/lib/widgets/map/map_thumbnail.dart
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
import 'dart:math';
|
||||
|
||||
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/extensions/maplibrecontroller_extensions.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_theme_override.dart';
|
||||
import 'package:immich_mobile/widgets/map/positioned_asset_marker_icon.dart';
|
||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||
|
||||
/// A non-interactive thumbnail of a map in the given coordinates with optional markers
|
||||
///
|
||||
/// User can provide either a [assetMarkerRemoteId] to display the asset's thumbnail or set
|
||||
/// [showMarkerPin] to true which would display a marker pin instead. If both are provided,
|
||||
/// [assetMarkerRemoteId] will take precedence
|
||||
class MapThumbnail extends HookConsumerWidget {
|
||||
final Function(Point<double>, LatLng)? onTap;
|
||||
final LatLng centre;
|
||||
final String? assetMarkerRemoteId;
|
||||
final bool showMarkerPin;
|
||||
final double zoom;
|
||||
final double height;
|
||||
final double width;
|
||||
final ThemeMode? themeMode;
|
||||
final bool showAttribution;
|
||||
|
||||
const MapThumbnail({
|
||||
super.key,
|
||||
required this.centre,
|
||||
this.height = 100,
|
||||
this.width = 100,
|
||||
this.onTap,
|
||||
this.zoom = 8,
|
||||
this.assetMarkerRemoteId,
|
||||
this.showMarkerPin = false,
|
||||
this.themeMode,
|
||||
this.showAttribution = true,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final offsettedCentre = LatLng(centre.latitude + 0.002, centre.longitude);
|
||||
final controller = useRef<MaplibreMapController?>(null);
|
||||
final position = useValueNotifier<Point<num>?>(null);
|
||||
|
||||
Future<void> onMapCreated(MaplibreMapController mapController) async {
|
||||
controller.value = mapController;
|
||||
if (assetMarkerRemoteId != null) {
|
||||
// The iOS impl returns wrong toScreenLocation without the delay
|
||||
Future.delayed(
|
||||
const Duration(milliseconds: 100),
|
||||
() async =>
|
||||
position.value = await mapController.toScreenLocation(centre),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> onStyleLoaded() async {
|
||||
if (showMarkerPin && controller.value != null) {
|
||||
await controller.value?.addMarkerAtLatLng(centre);
|
||||
}
|
||||
}
|
||||
|
||||
return MapThemeOveride(
|
||||
themeMode: themeMode,
|
||||
mapBuilder: (style) => SizedBox(
|
||||
height: height,
|
||||
width: width,
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
style.widgetWhen(
|
||||
onData: (style) => MaplibreMap(
|
||||
initialCameraPosition:
|
||||
CameraPosition(target: offsettedCentre, zoom: zoom),
|
||||
styleString: style,
|
||||
onMapCreated: onMapCreated,
|
||||
onStyleLoadedCallback: onStyleLoaded,
|
||||
onMapClick: onTap,
|
||||
doubleClickZoomEnabled: false,
|
||||
dragEnabled: false,
|
||||
zoomGesturesEnabled: false,
|
||||
tiltGesturesEnabled: false,
|
||||
scrollGesturesEnabled: false,
|
||||
rotateGesturesEnabled: false,
|
||||
myLocationEnabled: false,
|
||||
attributionButtonMargins:
|
||||
showAttribution == false ? const Point(-100, 0) : null,
|
||||
),
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: position,
|
||||
builder: (_, value, __) => value != null
|
||||
? PositionedAssetMarkerIcon(
|
||||
size: height / 2,
|
||||
point: value,
|
||||
assetRemoteId: assetMarkerRemoteId!,
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
183
mobile/lib/widgets/map/positioned_asset_marker_icon.dart
Normal file
183
mobile/lib/widgets/map/positioned_asset_marker_icon.dart
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/entities/store.entity.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
|
||||
class PositionedAssetMarkerIcon extends StatelessWidget {
|
||||
final Point<num> point;
|
||||
final String assetRemoteId;
|
||||
final double size;
|
||||
final int durationInMilliseconds;
|
||||
|
||||
final Function()? onTap;
|
||||
|
||||
const PositionedAssetMarkerIcon({
|
||||
required this.point,
|
||||
required this.assetRemoteId,
|
||||
this.size = 100,
|
||||
this.durationInMilliseconds = 100,
|
||||
this.onTap,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final ratio = Platform.isIOS ? 1.0 : MediaQuery.devicePixelRatioOf(context);
|
||||
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,
|
||||
key: Key(assetRemoteId),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AssetMarkerIcon extends StatelessWidget {
|
||||
const _AssetMarkerIcon({
|
||||
required this.id,
|
||||
super.key,
|
||||
});
|
||||
|
||||
final String id;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final imageUrl = getThumbnailUrlForRemoteId(id);
|
||||
final cacheKey = getThumbnailCacheKeyForRemoteId(id);
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
return Stack(
|
||||
children: [
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: constraints.maxWidth * 0.5,
|
||||
child: CustomPaint(
|
||||
painter: _PinPainter(
|
||||
primaryColor: context.colorScheme.onSurface,
|
||||
secondaryColor: context.colorScheme.surface,
|
||||
primaryRadius: constraints.maxHeight * 0.06,
|
||||
secondaryRadius: constraints.maxHeight * 0.038,
|
||||
),
|
||||
child: SizedBox(
|
||||
height: constraints.maxHeight * 0.14,
|
||||
width: constraints.maxWidth * 0.14,
|
||||
),
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
top: constraints.maxHeight * 0.07,
|
||||
left: constraints.maxWidth * 0.17,
|
||||
child: CircleAvatar(
|
||||
radius: constraints.maxHeight * 0.40,
|
||||
backgroundColor: context.colorScheme.onSurface,
|
||||
child: CircleAvatar(
|
||||
radius: constraints.maxHeight * 0.37,
|
||||
backgroundImage: CachedNetworkImageProvider(
|
||||
imageUrl,
|
||||
cacheKey: cacheKey,
|
||||
headers: {
|
||||
"x-immich-user-token": Store.get(StoreKey.accessToken),
|
||||
},
|
||||
errorListener: (_) =>
|
||||
const Icon(Icons.image_not_supported_outlined),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PinPainter extends CustomPainter {
|
||||
final Color primaryColor;
|
||||
final Color secondaryColor;
|
||||
final double primaryRadius;
|
||||
final double secondaryRadius;
|
||||
|
||||
_PinPainter({
|
||||
required this.primaryColor,
|
||||
required this.secondaryColor,
|
||||
required this.primaryRadius,
|
||||
required this.secondaryRadius,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
Paint primaryBrush = Paint()
|
||||
..color = primaryColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
Paint secondaryBrush = Paint()
|
||||
..color = secondaryColor
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
Paint lineBrush = Paint()
|
||||
..color = primaryColor
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = 2;
|
||||
|
||||
canvas.drawCircle(
|
||||
Offset(size.width / 2, size.height),
|
||||
primaryRadius,
|
||||
primaryBrush,
|
||||
);
|
||||
canvas.drawCircle(
|
||||
Offset(size.width / 2, size.height),
|
||||
secondaryRadius,
|
||||
secondaryBrush,
|
||||
);
|
||||
canvas.drawPath(getTrianglePath(size.width, size.height), primaryBrush);
|
||||
// The line is to make the above triangluar path more prominent since it has a slight curve
|
||||
canvas.drawLine(
|
||||
Offset(size.width / 2, 0),
|
||||
Offset(
|
||||
size.width / 2,
|
||||
size.height,
|
||||
),
|
||||
lineBrush,
|
||||
);
|
||||
}
|
||||
|
||||
Path getTrianglePath(double x, double y) {
|
||||
final firstEndPoint = Offset(x / 2, y);
|
||||
final controlPoint = Offset(x / 2, y * 0.3);
|
||||
final secondEndPoint = Offset(x, 0);
|
||||
|
||||
return Path()
|
||||
..quadraticBezierTo(
|
||||
controlPoint.dx,
|
||||
controlPoint.dy,
|
||||
firstEndPoint.dx,
|
||||
firstEndPoint.dy,
|
||||
)
|
||||
..quadraticBezierTo(
|
||||
controlPoint.dx,
|
||||
controlPoint.dy,
|
||||
secondEndPoint.dx,
|
||||
secondEndPoint.dy,
|
||||
)
|
||||
..lineTo(0, 0);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_PinPainter old) {
|
||||
return old.primaryColor != primaryColor ||
|
||||
old.secondaryColor != secondaryColor;
|
||||
}
|
||||
}
|
||||
61
mobile/lib/widgets/memories/memory_bottom_info.dart
Normal file
61
mobile/lib/widgets/memories/memory_bottom_info.dart
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
// 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.popRoute();
|
||||
scrollToDateNotifierProvider
|
||||
.scrollToDate(memory.assets[0].fileCreatedAt);
|
||||
},
|
||||
shape: const CircleBorder(),
|
||||
color: Colors.white.withOpacity(0.2),
|
||||
elevation: 0,
|
||||
child: const Icon(
|
||||
Icons.open_in_new,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
151
mobile/lib/widgets/memories/memory_card.dart
Normal file
151
mobile/lib/widgets/memories/memory_card.dart
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/entities/asset.entity.dart';
|
||||
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
|
||||
import 'package:immich_mobile/shared/ui/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: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(25.0),
|
||||
side: const 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: VideoViewerPage(
|
||||
key: ValueKey(asset),
|
||||
asset: asset,
|
||||
showDownloadingIndicator: false,
|
||||
placeholder: SizedBox.expand(
|
||||
child: ImmichImage(
|
||||
asset,
|
||||
fit: fit,
|
||||
),
|
||||
),
|
||||
hideControlsTimer: const Duration(seconds: 2),
|
||||
showControls: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
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.withOpacity(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,
|
||||
),
|
||||
fit: BoxFit.cover,
|
||||
),
|
||||
),
|
||||
child: Container(
|
||||
color: Colors.black.withOpacity(0.2),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
123
mobile/lib/widgets/memories/memory_epilogue.dart
Normal file
123
mobile/lib/widgets/memories/memory_epilogue.dart
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
|
||||
class MemoryEpilogue extends StatefulWidget {
|
||||
final Function()? onStartOver;
|
||||
|
||||
const MemoryEpilogue({super.key, this.onStartOver});
|
||||
|
||||
@override
|
||||
State<MemoryEpilogue> createState() => _MemoryEpilogueState();
|
||||
}
|
||||
|
||||
class _MemoryEpilogueState extends State<MemoryEpilogue>
|
||||
with TickerProviderStateMixin {
|
||||
late final _animationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(
|
||||
seconds: 2,
|
||||
),
|
||||
)..repeat(
|
||||
reverse: true,
|
||||
);
|
||||
|
||||
late final Animation _animation;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_animation = CurvedAnimation(
|
||||
parent: _animationController,
|
||||
curve: Curves.easeIn,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SafeArea(
|
||||
child: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const Icon(
|
||||
Icons.check_circle_outline_sharp,
|
||||
color: immichDarkThemePrimaryColor,
|
||||
size: 64.0,
|
||||
),
|
||||
const SizedBox(height: 16.0),
|
||||
Text(
|
||||
"memories_all_caught_up",
|
||||
style: Theme.of(context).textTheme.headlineMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
).tr(),
|
||||
const SizedBox(height: 16.0),
|
||||
Text(
|
||||
"memories_check_back_tomorrow",
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
).tr(),
|
||||
const SizedBox(height: 16.0),
|
||||
TextButton(
|
||||
onPressed: widget.onStartOver,
|
||||
child: Text(
|
||||
"memories_start_over",
|
||||
style: context.textTheme.displayMedium?.copyWith(
|
||||
color: immichDarkThemePrimaryColor,
|
||||
),
|
||||
).tr(),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
Positioned(
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16.0),
|
||||
child: Column(
|
||||
children: [
|
||||
SizedBox(
|
||||
height: 48,
|
||||
child: AnimatedBuilder(
|
||||
animation: _animation,
|
||||
builder: (context, child) {
|
||||
return Transform.translate(
|
||||
offset: Offset(0, 8 * _animationController.value),
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
child: const Icon(
|
||||
size: 32,
|
||||
Icons.expand_less_sharp,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
),
|
||||
Text(
|
||||
"memories_swipe_to_close",
|
||||
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
|
||||
color: Colors.white,
|
||||
),
|
||||
).tr(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
103
mobile/lib/widgets/memories/memory_lane.dart
Normal file
103
mobile/lib/widgets/memories/memory_lane.dart
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/widgets/asset_grid/thumbnail_placeholder.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
|
||||
? SizedBox(
|
||||
height: 200,
|
||||
child: ListView.builder(
|
||||
scrollDirection: Axis.horizontal,
|
||||
shrinkWrap: true,
|
||||
itemCount: memories.length,
|
||||
padding: const EdgeInsets.only(
|
||||
right: 8.0,
|
||||
bottom: 8,
|
||||
top: 10,
|
||||
left: 10,
|
||||
),
|
||||
itemBuilder: (context, index) {
|
||||
final memory = memories[index];
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () {
|
||||
ref
|
||||
.read(hapticFeedbackProvider.notifier)
|
||||
.heavyImpact();
|
||||
context.pushRoute(
|
||||
MemoryRoute(
|
||||
memories: memories,
|
||||
memoryIndex: index,
|
||||
),
|
||||
);
|
||||
},
|
||||
child: Stack(
|
||||
children: [
|
||||
Card(
|
||||
elevation: 3,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(13.0),
|
||||
),
|
||||
clipBehavior: Clip.hardEdge,
|
||||
child: ColorFiltered(
|
||||
colorFilter: ColorFilter.mode(
|
||||
Colors.black.withOpacity(0.2),
|
||||
BlendMode.darken,
|
||||
),
|
||||
child: Hero(
|
||||
tag: 'memory-${memory.assets[0].id}',
|
||||
child: ImmichImage(
|
||||
memory.assets[0],
|
||||
fit: BoxFit.cover,
|
||||
width: 130,
|
||||
height: 200,
|
||||
placeholder: const ThumbnailPlaceholder(
|
||||
width: 130,
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
)
|
||||
: const SizedBox(),
|
||||
)
|
||||
.value;
|
||||
|
||||
return memoryLane ?? const SizedBox();
|
||||
}
|
||||
}
|
||||
57
mobile/lib/widgets/memories/memory_progress_indicator.dart
Normal file
57
mobile/lib/widgets/memories/memory_progress_indicator.dart
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/constants/immich_colors.dart';
|
||||
|
||||
class MemoryProgressIndicator extends StatelessWidget {
|
||||
/// The number of ticks in the progress indicator
|
||||
final int ticks;
|
||||
|
||||
/// The current value of the indicator
|
||||
final double value;
|
||||
|
||||
const MemoryProgressIndicator({
|
||||
super.key,
|
||||
required this.ticks,
|
||||
required this.value,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final tickWidth = constraints.maxWidth / ticks;
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(2.0)),
|
||||
child: Stack(
|
||||
children: [
|
||||
LinearProgressIndicator(
|
||||
value: value,
|
||||
backgroundColor: Colors.grey[600],
|
||||
color: immichDarkThemePrimaryColor,
|
||||
),
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||
children: List.generate(
|
||||
ticks,
|
||||
(i) => Container(
|
||||
width: tickWidth,
|
||||
height: 4,
|
||||
decoration: BoxDecoration(
|
||||
border: i == 0
|
||||
? null
|
||||
: const Border(
|
||||
left: BorderSide(
|
||||
color: Colors.black,
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
48
mobile/lib/widgets/partner/partner_list.dart
Normal file
48
mobile/lib/widgets/partner/partner_list.dart
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/entities/user.entity.dart';
|
||||
import 'package:immich_mobile/widgets/common/user_avatar.dart';
|
||||
|
||||
class PartnerList extends HookConsumerWidget {
|
||||
const PartnerList({super.key, required this.partner});
|
||||
|
||||
final List<User> partner;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return SliverList(
|
||||
delegate:
|
||||
SliverChildBuilderDelegate(listEntry, childCount: partner.length),
|
||||
);
|
||||
}
|
||||
|
||||
Widget listEntry(BuildContext context, int index) {
|
||||
final User p = partner[index];
|
||||
return ListTile(
|
||||
contentPadding: const EdgeInsets.only(
|
||||
left: 12.0,
|
||||
right: 18.0,
|
||||
),
|
||||
leading: userAvatar(context, p, radius: 24),
|
||||
title: Text(
|
||||
"partner_list_user_photos",
|
||||
style: context.textTheme.labelLarge,
|
||||
).tr(
|
||||
namedArgs: {
|
||||
'user': p.name,
|
||||
},
|
||||
),
|
||||
trailing: Text(
|
||||
"partner_list_view_all",
|
||||
style: context.textTheme.labelLarge?.copyWith(
|
||||
color: context.primaryColor,
|
||||
),
|
||||
).tr(),
|
||||
onTap: () => context.pushRoute((PartnerDetailRoute(partner: p))),
|
||||
);
|
||||
}
|
||||
}
|
||||
672
mobile/lib/widgets/photo_view/photo_view.dart
Normal file
672
mobile/lib/widgets/photo_view/photo_view.dart
Normal file
|
|
@ -0,0 +1,672 @@
|
|||
library photo_view;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_core.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/photo_view_computed_scale.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/photo_view_wrappers.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||
|
||||
export 'src/controller/photo_view_controller.dart';
|
||||
export 'src/controller/photo_view_scalestate_controller.dart';
|
||||
export 'src/core/photo_view_gesture_detector.dart'
|
||||
show PhotoViewGestureDetectorScope, PhotoViewPageViewScrollPhysics;
|
||||
export 'src/photo_view_computed_scale.dart';
|
||||
export 'src/photo_view_scale_state.dart';
|
||||
export 'src/utils/photo_view_hero_attributes.dart';
|
||||
|
||||
/// A [StatefulWidget] that contains all the photo view rendering elements.
|
||||
///
|
||||
/// Sample code to use within an image:
|
||||
///
|
||||
/// ```
|
||||
/// PhotoView(
|
||||
/// imageProvider: imageProvider,
|
||||
/// loadingBuilder: (context, progress) => Center(
|
||||
/// child: Container(
|
||||
/// width: 20.0,
|
||||
/// height: 20.0,
|
||||
/// child: CircularProgressIndicator(
|
||||
/// value: _progress == null
|
||||
/// ? null
|
||||
/// : _progress.cumulativeBytesLoaded /
|
||||
/// _progress.expectedTotalBytes,
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// backgroundDecoration: BoxDecoration(color: Colors.black),
|
||||
/// gaplessPlayback: false,
|
||||
/// customSize: MediaQuery.of(context).size,
|
||||
/// heroAttributes: const HeroAttributes(
|
||||
/// tag: "someTag",
|
||||
/// transitionOnUserGestures: true,
|
||||
/// ),
|
||||
/// scaleStateChangedCallback: this.onScaleStateChanged,
|
||||
/// enableRotation: true,
|
||||
/// controller: controller,
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.8,
|
||||
/// initialScale: PhotoViewComputedScale.contained,
|
||||
/// basePosition: Alignment.center,
|
||||
/// scaleStateCycle: scaleStateCycle
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// You can customize to show an custom child instead of an image:
|
||||
///
|
||||
/// ```
|
||||
/// PhotoView.customChild(
|
||||
/// child: Container(
|
||||
/// width: 220.0,
|
||||
/// height: 250.0,
|
||||
/// child: const Text(
|
||||
/// "Hello there, this is a text",
|
||||
/// )
|
||||
/// ),
|
||||
/// childSize: const Size(220.0, 250.0),
|
||||
/// backgroundDecoration: BoxDecoration(color: Colors.black),
|
||||
/// gaplessPlayback: false,
|
||||
/// customSize: MediaQuery.of(context).size,
|
||||
/// heroAttributes: const HeroAttributes(
|
||||
/// tag: "someTag",
|
||||
/// transitionOnUserGestures: true,
|
||||
/// ),
|
||||
/// scaleStateChangedCallback: this.onScaleStateChanged,
|
||||
/// enableRotation: true,
|
||||
/// controller: controller,
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.8,
|
||||
/// initialScale: PhotoViewComputedScale.contained,
|
||||
/// basePosition: Alignment.center,
|
||||
/// scaleStateCycle: scaleStateCycle
|
||||
/// );
|
||||
/// ```
|
||||
/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
|
||||
///
|
||||
/// Sample using [maxScale], [minScale] and [initialScale]
|
||||
///
|
||||
/// ```
|
||||
/// PhotoView(
|
||||
/// imageProvider: imageProvider,
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.8,
|
||||
/// initialScale: PhotoViewComputedScale.contained * 1.1,
|
||||
/// );
|
||||
/// ```
|
||||
///
|
||||
/// [customSize] is used to define the viewPort size in which the image will be
|
||||
/// scaled to. This argument is rarely used. By default is the size that this widget assumes.
|
||||
///
|
||||
/// The argument [gaplessPlayback] is used to continue showing the old image
|
||||
/// (`true`), or briefly show nothing (`false`), when the [imageProvider]
|
||||
/// changes.By default it's set to `false`.
|
||||
///
|
||||
/// To use within an hero animation, specify [heroAttributes]. When
|
||||
/// [heroAttributes] is specified, the image provider retrieval process should
|
||||
/// be sync.
|
||||
///
|
||||
/// Sample using hero animation:
|
||||
/// ```
|
||||
/// // screen1
|
||||
/// ...
|
||||
/// Hero(
|
||||
/// tag: "someTag",
|
||||
/// child: Image.asset(
|
||||
/// "assets/large-image.jpg",
|
||||
/// width: 150.0
|
||||
/// ),
|
||||
/// )
|
||||
/// // screen2
|
||||
/// ...
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/large-image.jpg"),
|
||||
/// heroAttributes: const HeroAttributes(tag: "someTag"),
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// **Note: If you don't want to the zoomed image do not overlaps the size of the container, use [ClipRect](https://docs.flutter.io/flutter/widgets/ClipRect-class.html)**
|
||||
///
|
||||
/// ## Controllers
|
||||
///
|
||||
/// Controllers, when specified to PhotoView widget, enables the author(you) to listen for state updates through a `Stream` and change those values externally.
|
||||
///
|
||||
/// While [PhotoViewScaleStateController] is only responsible for the `scaleState`, [PhotoViewController] is responsible for all fields os [PhotoViewControllerValue].
|
||||
///
|
||||
/// To use them, pass a instance of those items on [controller] or [scaleStateController];
|
||||
///
|
||||
/// Since those follows the standard controller pattern found in widgets like [PageView] and [ScrollView], whoever instantiates it, should [dispose] it afterwards.
|
||||
///
|
||||
/// Example of [controller] usage, only listening for state changes:
|
||||
///
|
||||
/// ```
|
||||
/// class _ExampleWidgetState extends State<ExampleWidget> {
|
||||
///
|
||||
/// PhotoViewController controller;
|
||||
/// double scaleCopy;
|
||||
///
|
||||
/// @override
|
||||
/// void initState() {
|
||||
/// super.initState();
|
||||
/// controller = PhotoViewController()
|
||||
/// ..outputStateStream.listen(listener);
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// void dispose() {
|
||||
/// controller.dispose();
|
||||
/// super.dispose();
|
||||
/// }
|
||||
///
|
||||
/// void listener(PhotoViewControllerValue value){
|
||||
/// setState((){
|
||||
/// scaleCopy = value.scale;
|
||||
/// })
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return Stack(
|
||||
/// children: <Widget>[
|
||||
/// Positioned.fill(
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/pudim.png"),
|
||||
/// controller: controller,
|
||||
/// );
|
||||
/// ),
|
||||
/// Text("Scale applied: $scaleCopy")
|
||||
/// ],
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
/// An example of [scaleStateController] with state changes:
|
||||
/// ```
|
||||
/// class _ExampleWidgetState extends State<ExampleWidget> {
|
||||
///
|
||||
/// PhotoViewScaleStateController scaleStateController;
|
||||
///
|
||||
/// @override
|
||||
/// void initState() {
|
||||
/// super.initState();
|
||||
/// scaleStateController = PhotoViewScaleStateController();
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// void dispose() {
|
||||
/// scaleStateController.dispose();
|
||||
/// super.dispose();
|
||||
/// }
|
||||
///
|
||||
/// void goBack(){
|
||||
/// scaleStateController.scaleState = PhotoViewScaleState.originalSize;
|
||||
/// }
|
||||
///
|
||||
/// @override
|
||||
/// Widget build(BuildContext context) {
|
||||
/// return Stack(
|
||||
/// children: <Widget>[
|
||||
/// Positioned.fill(
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/pudim.png"),
|
||||
/// scaleStateController: scaleStateController,
|
||||
/// );
|
||||
/// ),
|
||||
/// FlatButton(
|
||||
/// child: Text("Go to original size"),
|
||||
/// onPressed: goBack,
|
||||
/// );
|
||||
/// ],
|
||||
/// );
|
||||
/// }
|
||||
/// }
|
||||
/// ```
|
||||
///
|
||||
class PhotoView extends StatefulWidget {
|
||||
/// Creates a widget that displays a zoomable image.
|
||||
///
|
||||
/// To show an image from the network or from an asset bundle, use their respective
|
||||
/// image providers, ie: [AssetImage] or [NetworkImage]
|
||||
///
|
||||
/// Internally, the image is rendered within an [Image] widget.
|
||||
const PhotoView({
|
||||
super.key,
|
||||
required this.imageProvider,
|
||||
required this.index,
|
||||
this.loadingBuilder,
|
||||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.heroAttributes,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.maxScale,
|
||||
this.minScale,
|
||||
this.initialScale,
|
||||
this.basePosition,
|
||||
this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
this.customSize,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
this.errorBuilder,
|
||||
this.enablePanAlways,
|
||||
}) : child = null,
|
||||
childSize = null;
|
||||
|
||||
/// Creates a widget that displays a zoomable child.
|
||||
///
|
||||
/// It has been created to resemble [PhotoView] behavior within widgets that aren't an image, such as [Container], [Text] or a svg.
|
||||
///
|
||||
/// Instead of a [imageProvider], this constructor will receive a [child] and a [childSize].
|
||||
///
|
||||
const PhotoView.customChild({
|
||||
super.key,
|
||||
required this.child,
|
||||
this.childSize,
|
||||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.heroAttributes,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.maxScale,
|
||||
this.minScale,
|
||||
this.initialScale,
|
||||
this.basePosition,
|
||||
this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
this.customSize,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
this.enablePanAlways,
|
||||
}) : errorBuilder = null,
|
||||
imageProvider = null,
|
||||
gaplessPlayback = false,
|
||||
loadingBuilder = null,
|
||||
index = 0;
|
||||
|
||||
/// Given a [imageProvider] it resolves into an zoomable image widget using. It
|
||||
/// is required
|
||||
final ImageProvider? imageProvider;
|
||||
|
||||
/// While [imageProvider] is not resolved, [loadingBuilder] is called by [PhotoView]
|
||||
/// into the screen, by default it is a centered [CircularProgressIndicator]
|
||||
final LoadingBuilder? loadingBuilder;
|
||||
|
||||
/// Show loadFailedChild when the image failed to load
|
||||
final ImageErrorWidgetBuilder? errorBuilder;
|
||||
|
||||
/// Changes the background behind image, defaults to `Colors.black`.
|
||||
final BoxDecoration? backgroundDecoration;
|
||||
|
||||
/// This is used to keep the state of an image in the gallery (e.g. scale state).
|
||||
/// `false` -> resets the state (default)
|
||||
/// `true` -> keeps the state
|
||||
final bool wantKeepAlive;
|
||||
|
||||
/// This is used to continue showing the old image (`true`), or briefly show
|
||||
/// nothing (`false`), when the `imageProvider` changes. By default it's set
|
||||
/// to `false`.
|
||||
final bool gaplessPlayback;
|
||||
|
||||
/// Attributes that are going to be passed to [PhotoViewCore]'s
|
||||
/// [Hero]. Leave this property undefined if you don't want a hero animation.
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
|
||||
/// Defines the size of the scaling base of the image inside [PhotoView],
|
||||
/// by default it is `MediaQuery.of(context).size`.
|
||||
final Size? customSize;
|
||||
|
||||
/// A [Function] to be called whenever the scaleState changes, this happens when the user double taps the content ou start to pinch-in.
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
|
||||
/// A flag that enables the rotation gesture support
|
||||
final bool enableRotation;
|
||||
|
||||
/// The specified custom child to be shown instead of a image
|
||||
final Widget? child;
|
||||
|
||||
/// The size of the custom [child]. [PhotoView] uses this value to compute the relation between the child and the container's size to calculate the scale value.
|
||||
final Size? childSize;
|
||||
|
||||
/// Defines the maximum size in which the image will be allowed to assume, it
|
||||
/// is proportional to the original image size. Can be either a double (absolute value) or a
|
||||
/// [PhotoViewComputedScale], that can be multiplied by a double
|
||||
final dynamic maxScale;
|
||||
|
||||
/// Defines the minimum size in which the image will be allowed to assume, it
|
||||
/// is proportional to the original image size. Can be either a double (absolute value) or a
|
||||
/// [PhotoViewComputedScale], that can be multiplied by a double
|
||||
final dynamic minScale;
|
||||
|
||||
/// Defines the initial size in which the image will be assume in the mounting of the component, it
|
||||
/// is proportional to the original image size. Can be either a double (absolute value) or a
|
||||
/// [PhotoViewComputedScale], that can be multiplied by a double
|
||||
final dynamic initialScale;
|
||||
|
||||
/// A way to control PhotoView transformation factors externally and listen to its updates
|
||||
final PhotoViewControllerBase? controller;
|
||||
|
||||
/// A way to control PhotoViewScaleState value externally and listen to its updates
|
||||
final PhotoViewScaleStateController? scaleStateController;
|
||||
|
||||
/// The alignment of the scale origin in relation to the widget size. Default is [Alignment.center]
|
||||
final Alignment? basePosition;
|
||||
|
||||
/// Defines de next [PhotoViewScaleState] given the actual one. Default is [defaultScaleStateCycle]
|
||||
final ScaleStateCycle? scaleStateCycle;
|
||||
|
||||
/// A pointer that will trigger a tap has stopped contacting the screen at a
|
||||
/// particular location.
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
|
||||
/// A pointer that will trigger a scale has stopped contacting the screen at a
|
||||
/// particular location.
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
|
||||
/// A pointer that might cause a tap has contacted the screen at a particular
|
||||
/// location.
|
||||
final PhotoViewImageLongPressStartCallback? onLongPressStart;
|
||||
|
||||
/// [HitTestBehavior] to be passed to the internal gesture detector.
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
|
||||
/// Enables tight mode, making background container assume the size of the image/child.
|
||||
/// Useful when inside a [Dialog]
|
||||
final bool? tightMode;
|
||||
|
||||
/// Quality levels for image filters.
|
||||
final FilterQuality? filterQuality;
|
||||
|
||||
// Removes gesture detector if `true`.
|
||||
// Useful when custom gesture detector is used in child widget.
|
||||
final bool? disableGestures;
|
||||
|
||||
/// Enable pan the widget even if it's smaller than the hole parent widget.
|
||||
/// Useful when you want to drag a widget without restrictions.
|
||||
final bool? enablePanAlways;
|
||||
|
||||
final int index;
|
||||
|
||||
bool get _isCustomChild {
|
||||
return child != null;
|
||||
}
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _PhotoViewState();
|
||||
}
|
||||
}
|
||||
|
||||
class _PhotoViewState extends State<PhotoView>
|
||||
with AutomaticKeepAliveClientMixin {
|
||||
// image retrieval
|
||||
|
||||
// controller
|
||||
late bool _controlledController;
|
||||
late PhotoViewControllerBase _controller;
|
||||
late bool _controlledScaleStateController;
|
||||
late PhotoViewScaleStateController _scaleStateController;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
if (widget.controller == null) {
|
||||
_controlledController = true;
|
||||
_controller = PhotoViewController();
|
||||
} else {
|
||||
_controlledController = false;
|
||||
_controller = widget.controller!;
|
||||
}
|
||||
|
||||
if (widget.scaleStateController == null) {
|
||||
_controlledScaleStateController = true;
|
||||
_scaleStateController = PhotoViewScaleStateController();
|
||||
} else {
|
||||
_controlledScaleStateController = false;
|
||||
_scaleStateController = widget.scaleStateController!;
|
||||
}
|
||||
|
||||
_scaleStateController.outputScaleStateStream.listen(scaleStateListener);
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(PhotoView oldWidget) {
|
||||
if (widget.controller == null) {
|
||||
if (!_controlledController) {
|
||||
_controlledController = true;
|
||||
_controller = PhotoViewController();
|
||||
}
|
||||
} else {
|
||||
_controlledController = false;
|
||||
_controller = widget.controller!;
|
||||
}
|
||||
|
||||
if (widget.scaleStateController == null) {
|
||||
if (!_controlledScaleStateController) {
|
||||
_controlledScaleStateController = true;
|
||||
_scaleStateController = PhotoViewScaleStateController();
|
||||
}
|
||||
} else {
|
||||
_controlledScaleStateController = false;
|
||||
_scaleStateController = widget.scaleStateController!;
|
||||
}
|
||||
super.didUpdateWidget(oldWidget);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
if (_controlledController) {
|
||||
_controller.dispose();
|
||||
}
|
||||
if (_controlledScaleStateController) {
|
||||
_scaleStateController.dispose();
|
||||
}
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void scaleStateListener(PhotoViewScaleState scaleState) {
|
||||
if (widget.scaleStateChangedCallback != null) {
|
||||
widget.scaleStateChangedCallback!(_scaleStateController.scaleState);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
super.build(context);
|
||||
return LayoutBuilder(
|
||||
builder: (
|
||||
BuildContext context,
|
||||
BoxConstraints constraints,
|
||||
) {
|
||||
final computedOuterSize = widget.customSize ?? constraints.biggest;
|
||||
final backgroundDecoration = widget.backgroundDecoration ??
|
||||
const BoxDecoration(color: Colors.black);
|
||||
|
||||
return widget._isCustomChild
|
||||
? CustomChildWrapper(
|
||||
childSize: widget.childSize,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
heroAttributes: widget.heroAttributes,
|
||||
scaleStateChangedCallback: widget.scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
controller: _controller,
|
||||
scaleStateController: _scaleStateController,
|
||||
maxScale: widget.maxScale,
|
||||
minScale: widget.minScale,
|
||||
initialScale: widget.initialScale,
|
||||
basePosition: widget.basePosition,
|
||||
scaleStateCycle: widget.scaleStateCycle,
|
||||
onTapUp: widget.onTapUp,
|
||||
onTapDown: widget.onTapDown,
|
||||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
onLongPressStart: widget.onLongPressStart,
|
||||
outerSize: computedOuterSize,
|
||||
gestureDetectorBehavior: widget.gestureDetectorBehavior,
|
||||
tightMode: widget.tightMode,
|
||||
filterQuality: widget.filterQuality,
|
||||
disableGestures: widget.disableGestures,
|
||||
enablePanAlways: widget.enablePanAlways,
|
||||
child: widget.child,
|
||||
)
|
||||
: ImageWrapper(
|
||||
imageProvider: widget.imageProvider!,
|
||||
loadingBuilder: widget.loadingBuilder,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
heroAttributes: widget.heroAttributes,
|
||||
scaleStateChangedCallback: widget.scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
controller: _controller,
|
||||
scaleStateController: _scaleStateController,
|
||||
maxScale: widget.maxScale,
|
||||
minScale: widget.minScale,
|
||||
initialScale: widget.initialScale,
|
||||
basePosition: widget.basePosition,
|
||||
scaleStateCycle: widget.scaleStateCycle,
|
||||
onTapUp: widget.onTapUp,
|
||||
onTapDown: widget.onTapDown,
|
||||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
onLongPressStart: widget.onLongPressStart,
|
||||
outerSize: computedOuterSize,
|
||||
gestureDetectorBehavior: widget.gestureDetectorBehavior,
|
||||
tightMode: widget.tightMode,
|
||||
filterQuality: widget.filterQuality,
|
||||
disableGestures: widget.disableGestures,
|
||||
errorBuilder: widget.errorBuilder,
|
||||
enablePanAlways: widget.enablePanAlways,
|
||||
index: widget.index,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool get wantKeepAlive => widget.wantKeepAlive;
|
||||
}
|
||||
|
||||
/// The default [ScaleStateCycle]
|
||||
PhotoViewScaleState defaultScaleStateCycle(PhotoViewScaleState actual) {
|
||||
switch (actual) {
|
||||
case PhotoViewScaleState.initial:
|
||||
return PhotoViewScaleState.covering;
|
||||
case PhotoViewScaleState.covering:
|
||||
return PhotoViewScaleState.originalSize;
|
||||
case PhotoViewScaleState.originalSize:
|
||||
return PhotoViewScaleState.initial;
|
||||
case PhotoViewScaleState.zoomedIn:
|
||||
case PhotoViewScaleState.zoomedOut:
|
||||
return PhotoViewScaleState.initial;
|
||||
default:
|
||||
return PhotoViewScaleState.initial;
|
||||
}
|
||||
}
|
||||
|
||||
/// A type definition for a [Function] that receives the actual [PhotoViewScaleState] and returns the next one
|
||||
/// It is used internally to walk in the "doubletap gesture cycle".
|
||||
/// It is passed to [PhotoView.scaleStateCycle]
|
||||
typedef ScaleStateCycle = PhotoViewScaleState Function(
|
||||
PhotoViewScaleState actual,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user taps up the photoview region
|
||||
typedef PhotoViewImageTapUpCallback = Function(
|
||||
BuildContext context,
|
||||
TapUpDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user taps down the photoview region
|
||||
typedef PhotoViewImageTapDownCallback = Function(
|
||||
BuildContext context,
|
||||
TapDownDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user drags up
|
||||
typedef PhotoViewImageDragStartCallback = Function(
|
||||
BuildContext context,
|
||||
DragStartDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user drags
|
||||
typedef PhotoViewImageDragUpdateCallback = Function(
|
||||
BuildContext context,
|
||||
DragUpdateDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user taps down the photoview region
|
||||
typedef PhotoViewImageDragEndCallback = Function(
|
||||
BuildContext context,
|
||||
DragEndDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when a user finished scale
|
||||
typedef PhotoViewImageScaleEndCallback = Function(
|
||||
BuildContext context,
|
||||
ScaleEndDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback when the user long press start
|
||||
typedef PhotoViewImageLongPressStartCallback = Function(
|
||||
BuildContext context,
|
||||
LongPressStartDetails details,
|
||||
PhotoViewControllerValue controllerValue,
|
||||
);
|
||||
|
||||
/// A type definition for a callback to show a widget while the image is loading, a [ImageChunkEvent] is passed to inform progress
|
||||
typedef LoadingBuilder = Widget Function(
|
||||
BuildContext context,
|
||||
ImageChunkEvent? event,
|
||||
int index,
|
||||
);
|
||||
456
mobile/lib/widgets/photo_view/photo_view_gallery.dart
Normal file
456
mobile/lib/widgets/photo_view/photo_view_gallery.dart
Normal file
|
|
@ -0,0 +1,456 @@
|
|||
library photo_view_gallery;
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'
|
||||
show
|
||||
LoadingBuilder,
|
||||
PhotoView,
|
||||
PhotoViewImageTapDownCallback,
|
||||
PhotoViewImageTapUpCallback,
|
||||
PhotoViewImageDragStartCallback,
|
||||
PhotoViewImageDragEndCallback,
|
||||
PhotoViewImageDragUpdateCallback,
|
||||
PhotoViewImageScaleEndCallback,
|
||||
PhotoViewImageLongPressStartCallback,
|
||||
ScaleStateCycle;
|
||||
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_gesture_detector.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_hero_attributes.dart';
|
||||
|
||||
/// A type definition for a [Function] that receives a index after a page change in [PhotoViewGallery]
|
||||
typedef PhotoViewGalleryPageChangedCallback = void Function(int index);
|
||||
|
||||
/// A type definition for a [Function] that defines a page in [PhotoViewGallery.build]
|
||||
typedef PhotoViewGalleryBuilder = PhotoViewGalleryPageOptions Function(
|
||||
BuildContext context,
|
||||
int index,
|
||||
);
|
||||
|
||||
/// A [StatefulWidget] that shows multiple [PhotoView] widgets in a [PageView]
|
||||
///
|
||||
/// Some of [PhotoView] constructor options are passed direct to [PhotoViewGallery] constructor. Those options will affect the gallery in a whole.
|
||||
///
|
||||
/// Some of the options may be defined to each image individually, such as `initialScale` or `PhotoViewHeroAttributes`. Those must be passed via each [PhotoViewGalleryPageOptions].
|
||||
///
|
||||
/// Example of usage as a list of options:
|
||||
/// ```
|
||||
/// PhotoViewGallery(
|
||||
/// pageOptions: <PhotoViewGalleryPageOptions>[
|
||||
/// PhotoViewGalleryPageOptions(
|
||||
/// imageProvider: AssetImage("assets/gallery1.jpg"),
|
||||
/// heroAttributes: const PhotoViewHeroAttributes(tag: "tag1"),
|
||||
/// ),
|
||||
/// PhotoViewGalleryPageOptions(
|
||||
/// imageProvider: AssetImage("assets/gallery2.jpg"),
|
||||
/// heroAttributes: const PhotoViewHeroAttributes(tag: "tag2"),
|
||||
/// maxScale: PhotoViewComputedScale.contained * 0.3
|
||||
/// ),
|
||||
/// PhotoViewGalleryPageOptions(
|
||||
/// imageProvider: AssetImage("assets/gallery3.jpg"),
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.1,
|
||||
/// heroAttributes: const HeroAttributes(tag: "tag3"),
|
||||
/// ),
|
||||
/// ],
|
||||
/// loadingBuilder: (context, progress) => Center(
|
||||
/// child: Container(
|
||||
/// width: 20.0,
|
||||
/// height: 20.0,
|
||||
/// child: CircularProgressIndicator(
|
||||
/// value: _progress == null
|
||||
/// ? null
|
||||
/// : _progress.cumulativeBytesLoaded /
|
||||
/// _progress.expectedTotalBytes,
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// backgroundDecoration: widget.backgroundDecoration,
|
||||
/// pageController: widget.pageController,
|
||||
/// onPageChanged: onPageChanged,
|
||||
/// )
|
||||
/// ```
|
||||
///
|
||||
/// Example of usage with builder pattern:
|
||||
/// ```
|
||||
/// PhotoViewGallery.builder(
|
||||
/// scrollPhysics: const BouncingScrollPhysics(),
|
||||
/// builder: (BuildContext context, int index) {
|
||||
/// return PhotoViewGalleryPageOptions(
|
||||
/// imageProvider: AssetImage(widget.galleryItems[index].image),
|
||||
/// initialScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// minScale: PhotoViewComputedScale.contained * 0.8,
|
||||
/// maxScale: PhotoViewComputedScale.covered * 1.1,
|
||||
/// heroAttributes: HeroAttributes(tag: galleryItems[index].id),
|
||||
/// );
|
||||
/// },
|
||||
/// itemCount: galleryItems.length,
|
||||
/// loadingBuilder: (context, progress) => Center(
|
||||
/// child: Container(
|
||||
/// width: 20.0,
|
||||
/// height: 20.0,
|
||||
/// child: CircularProgressIndicator(
|
||||
/// value: _progress == null
|
||||
/// ? null
|
||||
/// : _progress.cumulativeBytesLoaded /
|
||||
/// _progress.expectedTotalBytes,
|
||||
/// ),
|
||||
/// ),
|
||||
/// ),
|
||||
/// backgroundDecoration: widget.backgroundDecoration,
|
||||
/// pageController: widget.pageController,
|
||||
/// onPageChanged: onPageChanged,
|
||||
/// )
|
||||
/// ```
|
||||
class PhotoViewGallery extends StatefulWidget {
|
||||
/// Construct a gallery with static items through a list of [PhotoViewGalleryPageOptions].
|
||||
const PhotoViewGallery({
|
||||
super.key,
|
||||
required this.pageOptions,
|
||||
this.loadingBuilder,
|
||||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.reverse = false,
|
||||
this.pageController,
|
||||
this.onPageChanged,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.scrollPhysics,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.customSize,
|
||||
this.allowImplicitScrolling = false,
|
||||
}) : itemCount = null,
|
||||
builder = null;
|
||||
|
||||
/// Construct a gallery with dynamic items.
|
||||
///
|
||||
/// The builder must return a [PhotoViewGalleryPageOptions].
|
||||
const PhotoViewGallery.builder({
|
||||
super.key,
|
||||
required this.itemCount,
|
||||
required this.builder,
|
||||
this.loadingBuilder,
|
||||
this.backgroundDecoration,
|
||||
this.wantKeepAlive = false,
|
||||
this.gaplessPlayback = false,
|
||||
this.reverse = false,
|
||||
this.pageController,
|
||||
this.onPageChanged,
|
||||
this.scaleStateChangedCallback,
|
||||
this.enableRotation = false,
|
||||
this.scrollPhysics,
|
||||
this.scrollDirection = Axis.horizontal,
|
||||
this.customSize,
|
||||
this.allowImplicitScrolling = false,
|
||||
}) : pageOptions = null,
|
||||
assert(itemCount != null),
|
||||
assert(builder != null);
|
||||
|
||||
/// A list of options to describe the items in the gallery
|
||||
final List<PhotoViewGalleryPageOptions>? pageOptions;
|
||||
|
||||
/// The count of items in the gallery, only used when constructed via [PhotoViewGallery.builder]
|
||||
final int? itemCount;
|
||||
|
||||
/// Called to build items for the gallery when using [PhotoViewGallery.builder]
|
||||
final PhotoViewGalleryBuilder? builder;
|
||||
|
||||
/// [ScrollPhysics] for the internal [PageView]
|
||||
final ScrollPhysics? scrollPhysics;
|
||||
|
||||
/// Mirror to [PhotoView.loadingBuilder]
|
||||
final LoadingBuilder? loadingBuilder;
|
||||
|
||||
/// Mirror to [PhotoView.backgroundDecoration]
|
||||
final BoxDecoration? backgroundDecoration;
|
||||
|
||||
/// Mirror to [PhotoView.wantKeepAlive]
|
||||
final bool wantKeepAlive;
|
||||
|
||||
/// Mirror to [PhotoView.gaplessPlayback]
|
||||
final bool gaplessPlayback;
|
||||
|
||||
/// Mirror to [PageView.reverse]
|
||||
final bool reverse;
|
||||
|
||||
/// An object that controls the [PageView] inside [PhotoViewGallery]
|
||||
final PageController? pageController;
|
||||
|
||||
/// An callback to be called on a page change
|
||||
final PhotoViewGalleryPageChangedCallback? onPageChanged;
|
||||
|
||||
/// Mirror to [PhotoView.scaleStateChangedCallback]
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
|
||||
/// Mirror to [PhotoView.enableRotation]
|
||||
final bool enableRotation;
|
||||
|
||||
/// Mirror to [PhotoView.customSize]
|
||||
final Size? customSize;
|
||||
|
||||
/// The axis along which the [PageView] scrolls. Mirror to [PageView.scrollDirection]
|
||||
final Axis scrollDirection;
|
||||
|
||||
/// When user attempts to move it to the next element, focus will traverse to the next page in the page view.
|
||||
final bool allowImplicitScrolling;
|
||||
|
||||
bool get _isBuilder => builder != null;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return _PhotoViewGalleryState();
|
||||
}
|
||||
}
|
||||
|
||||
class _PhotoViewGalleryState extends State<PhotoViewGallery> {
|
||||
late final PageController _controller =
|
||||
widget.pageController ?? PageController();
|
||||
|
||||
void scaleStateChangedCallback(PhotoViewScaleState scaleState) {
|
||||
if (widget.scaleStateChangedCallback != null) {
|
||||
widget.scaleStateChangedCallback!(scaleState);
|
||||
}
|
||||
}
|
||||
|
||||
int get actualPage {
|
||||
return _controller.hasClients ? _controller.page!.floor() : 0;
|
||||
}
|
||||
|
||||
int get itemCount {
|
||||
if (widget._isBuilder) {
|
||||
return widget.itemCount!;
|
||||
}
|
||||
return widget.pageOptions!.length;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Enable corner hit test
|
||||
return PhotoViewGestureDetectorScope(
|
||||
axis: widget.scrollDirection,
|
||||
child: PageView.builder(
|
||||
reverse: widget.reverse,
|
||||
controller: _controller,
|
||||
onPageChanged: widget.onPageChanged,
|
||||
itemCount: itemCount,
|
||||
itemBuilder: _buildItem,
|
||||
scrollDirection: widget.scrollDirection,
|
||||
physics: widget.scrollPhysics,
|
||||
allowImplicitScrolling: widget.allowImplicitScrolling,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildItem(BuildContext context, int index) {
|
||||
final pageOption = _buildPageOption(context, index);
|
||||
final isCustomChild = pageOption.child != null;
|
||||
|
||||
final PhotoView photoView = isCustomChild
|
||||
? PhotoView.customChild(
|
||||
key: ObjectKey(index),
|
||||
childSize: pageOption.childSize,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
wantKeepAlive: widget.wantKeepAlive,
|
||||
controller: pageOption.controller,
|
||||
scaleStateController: pageOption.scaleStateController,
|
||||
customSize: widget.customSize,
|
||||
scaleStateChangedCallback: scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
initialScale: pageOption.initialScale,
|
||||
minScale: pageOption.minScale,
|
||||
maxScale: pageOption.maxScale,
|
||||
scaleStateCycle: pageOption.scaleStateCycle,
|
||||
onTapUp: pageOption.onTapUp,
|
||||
onTapDown: pageOption.onTapDown,
|
||||
onDragStart: pageOption.onDragStart,
|
||||
onDragEnd: pageOption.onDragEnd,
|
||||
onDragUpdate: pageOption.onDragUpdate,
|
||||
onScaleEnd: pageOption.onScaleEnd,
|
||||
onLongPressStart: pageOption.onLongPressStart,
|
||||
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
|
||||
tightMode: pageOption.tightMode,
|
||||
filterQuality: pageOption.filterQuality,
|
||||
basePosition: pageOption.basePosition,
|
||||
disableGestures: pageOption.disableGestures,
|
||||
heroAttributes: pageOption.heroAttributes,
|
||||
child: pageOption.child,
|
||||
)
|
||||
: PhotoView(
|
||||
key: ObjectKey(index),
|
||||
index: index,
|
||||
imageProvider: pageOption.imageProvider,
|
||||
loadingBuilder: widget.loadingBuilder,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
wantKeepAlive: widget.wantKeepAlive,
|
||||
controller: pageOption.controller,
|
||||
scaleStateController: pageOption.scaleStateController,
|
||||
customSize: widget.customSize,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
scaleStateChangedCallback: scaleStateChangedCallback,
|
||||
enableRotation: widget.enableRotation,
|
||||
initialScale: pageOption.initialScale,
|
||||
minScale: pageOption.minScale,
|
||||
maxScale: pageOption.maxScale,
|
||||
scaleStateCycle: pageOption.scaleStateCycle,
|
||||
onTapUp: pageOption.onTapUp,
|
||||
onTapDown: pageOption.onTapDown,
|
||||
onDragStart: pageOption.onDragStart,
|
||||
onDragEnd: pageOption.onDragEnd,
|
||||
onDragUpdate: pageOption.onDragUpdate,
|
||||
onScaleEnd: pageOption.onScaleEnd,
|
||||
onLongPressStart: pageOption.onLongPressStart,
|
||||
gestureDetectorBehavior: pageOption.gestureDetectorBehavior,
|
||||
tightMode: pageOption.tightMode,
|
||||
filterQuality: pageOption.filterQuality,
|
||||
basePosition: pageOption.basePosition,
|
||||
disableGestures: pageOption.disableGestures,
|
||||
errorBuilder: pageOption.errorBuilder,
|
||||
heroAttributes: pageOption.heroAttributes,
|
||||
);
|
||||
|
||||
return ClipRect(
|
||||
child: photoView,
|
||||
);
|
||||
}
|
||||
|
||||
PhotoViewGalleryPageOptions _buildPageOption(
|
||||
BuildContext context,
|
||||
int index,
|
||||
) {
|
||||
if (widget._isBuilder) {
|
||||
return widget.builder!(context, index);
|
||||
}
|
||||
return widget.pageOptions![index];
|
||||
}
|
||||
}
|
||||
|
||||
/// A helper class that wraps individual options of a page in [PhotoViewGallery]
|
||||
///
|
||||
/// The [maxScale], [minScale] and [initialScale] options may be [double] or a [PhotoViewComputedScale] constant
|
||||
///
|
||||
class PhotoViewGalleryPageOptions {
|
||||
PhotoViewGalleryPageOptions({
|
||||
Key? key,
|
||||
required this.imageProvider,
|
||||
this.heroAttributes,
|
||||
this.minScale,
|
||||
this.maxScale,
|
||||
this.initialScale,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.basePosition,
|
||||
this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
this.errorBuilder,
|
||||
}) : child = null,
|
||||
childSize = null,
|
||||
assert(imageProvider != null);
|
||||
|
||||
PhotoViewGalleryPageOptions.customChild({
|
||||
required this.child,
|
||||
this.childSize,
|
||||
this.heroAttributes,
|
||||
this.minScale,
|
||||
this.maxScale,
|
||||
this.initialScale,
|
||||
this.controller,
|
||||
this.scaleStateController,
|
||||
this.basePosition,
|
||||
this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
this.gestureDetectorBehavior,
|
||||
this.tightMode,
|
||||
this.filterQuality,
|
||||
this.disableGestures,
|
||||
}) : errorBuilder = null,
|
||||
imageProvider = null;
|
||||
|
||||
/// Mirror to [PhotoView.imageProvider]
|
||||
final ImageProvider? imageProvider;
|
||||
|
||||
/// Mirror to [PhotoView.heroAttributes]
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
|
||||
/// Mirror to [PhotoView.minScale]
|
||||
final dynamic minScale;
|
||||
|
||||
/// Mirror to [PhotoView.maxScale]
|
||||
final dynamic maxScale;
|
||||
|
||||
/// Mirror to [PhotoView.initialScale]
|
||||
final dynamic initialScale;
|
||||
|
||||
/// Mirror to [PhotoView.controller]
|
||||
final PhotoViewController? controller;
|
||||
|
||||
/// Mirror to [PhotoView.scaleStateController]
|
||||
final PhotoViewScaleStateController? scaleStateController;
|
||||
|
||||
/// Mirror to [PhotoView.basePosition]
|
||||
final Alignment? basePosition;
|
||||
|
||||
/// Mirror to [PhotoView.child]
|
||||
final Widget? child;
|
||||
|
||||
/// Mirror to [PhotoView.childSize]
|
||||
final Size? childSize;
|
||||
|
||||
/// Mirror to [PhotoView.scaleStateCycle]
|
||||
final ScaleStateCycle? scaleStateCycle;
|
||||
|
||||
/// Mirror to [PhotoView.onTapUp]
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
|
||||
/// Mirror to [PhotoView.onDragUp]
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
|
||||
/// Mirror to [PhotoView.onDragDown]
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
|
||||
/// Mirror to [PhotoView.onDraUpdate]
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
|
||||
/// Mirror to [PhotoView.onTapDown]
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
|
||||
/// Mirror to [PhotoView.onScaleEnd]
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
|
||||
/// Mirror to [PhotoView.onLongPressStart]
|
||||
final PhotoViewImageLongPressStartCallback? onLongPressStart;
|
||||
|
||||
/// Mirror to [PhotoView.gestureDetectorBehavior]
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
|
||||
/// Mirror to [PhotoView.tightMode]
|
||||
final bool? tightMode;
|
||||
|
||||
/// Mirror to [PhotoView.disableGestures]
|
||||
final bool? disableGestures;
|
||||
|
||||
/// Quality levels for image filters.
|
||||
final FilterQuality? filterQuality;
|
||||
|
||||
/// Mirror to [PhotoView.errorBuilder]
|
||||
final ImageErrorWidgetBuilder? errorBuilder;
|
||||
}
|
||||
|
|
@ -0,0 +1,291 @@
|
|||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/ignorable_change_notifier.dart';
|
||||
|
||||
/// The interface in which controllers will be implemented.
|
||||
///
|
||||
/// It concerns storing the state ([PhotoViewControllerValue]) and streaming its updates.
|
||||
/// [PhotoViewImageWrapper] will respond to user gestures setting thew fields in the instance of a controller.
|
||||
///
|
||||
/// Any instance of a controller must be disposed after unmount. So if you instantiate a [PhotoViewController] or your custom implementation, do not forget to dispose it when not using it anymore.
|
||||
///
|
||||
/// The controller exposes value fields like [scale] or [rotationFocus]. Usually those fields will be only getters and setters serving as hooks to the internal [PhotoViewControllerValue].
|
||||
///
|
||||
/// The default implementation used by [PhotoView] is [PhotoViewController].
|
||||
///
|
||||
/// This was created to allow customization (you can create your own controller class)
|
||||
///
|
||||
/// Previously it controlled `scaleState` as well, but duw to some [concerns](https://github.com/renancaraujo/photo_view/issues/127)
|
||||
/// [ScaleStateListener is responsible for tat value now
|
||||
///
|
||||
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
|
||||
///
|
||||
abstract class PhotoViewControllerBase<T extends PhotoViewControllerValue> {
|
||||
/// The output for state/value updates. Usually a broadcast [Stream]
|
||||
Stream<T> get outputStateStream;
|
||||
|
||||
/// The state value before the last change or the initial state if the state has not been changed.
|
||||
late T prevValue;
|
||||
|
||||
/// The actual state value
|
||||
late T value;
|
||||
|
||||
/// Resets the state to the initial value;
|
||||
void reset();
|
||||
|
||||
/// Closes streams and removes eventual listeners.
|
||||
void dispose();
|
||||
|
||||
/// Add a listener that will ignore updates made internally
|
||||
///
|
||||
/// Since it is made for internal use, it is not performatic to use more than one
|
||||
/// listener. Prefer [outputStateStream]
|
||||
void addIgnorableListener(VoidCallback callback);
|
||||
|
||||
/// Remove a listener that will ignore updates made internally
|
||||
///
|
||||
/// Since it is made for internal use, it is not performatic to use more than one
|
||||
/// listener. Prefer [outputStateStream]
|
||||
void removeIgnorableListener(VoidCallback callback);
|
||||
|
||||
/// The position of the image in the screen given its offset after pan gestures.
|
||||
late Offset position;
|
||||
|
||||
/// The scale factor to transform the child (image or a customChild).
|
||||
late double? scale;
|
||||
|
||||
/// Nevermind this method :D, look away
|
||||
void setScaleInvisibly(double? scale);
|
||||
|
||||
/// The rotation factor to transform the child (image or a customChild).
|
||||
late double rotation;
|
||||
|
||||
/// The center of the rotation transformation. It is a coordinate referring to the absolute dimensions of the image.
|
||||
Offset? rotationFocusPoint;
|
||||
|
||||
/// Update multiple fields of the state with only one update streamed.
|
||||
void updateMultiple({
|
||||
Offset? position,
|
||||
double? scale,
|
||||
double? rotation,
|
||||
Offset? rotationFocusPoint,
|
||||
});
|
||||
}
|
||||
|
||||
/// The state value stored and streamed by [PhotoViewController].
|
||||
@immutable
|
||||
class PhotoViewControllerValue {
|
||||
const PhotoViewControllerValue({
|
||||
required this.position,
|
||||
required this.scale,
|
||||
required this.rotation,
|
||||
required this.rotationFocusPoint,
|
||||
});
|
||||
|
||||
final Offset position;
|
||||
final double? scale;
|
||||
final double rotation;
|
||||
final Offset? rotationFocusPoint;
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PhotoViewControllerValue &&
|
||||
runtimeType == other.runtimeType &&
|
||||
position == other.position &&
|
||||
scale == other.scale &&
|
||||
rotation == other.rotation &&
|
||||
rotationFocusPoint == other.rotationFocusPoint;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
position.hashCode ^
|
||||
scale.hashCode ^
|
||||
rotation.hashCode ^
|
||||
rotationFocusPoint.hashCode;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'PhotoViewControllerValue{position: $position, scale: $scale, rotation: $rotation, rotationFocusPoint: $rotationFocusPoint}';
|
||||
}
|
||||
}
|
||||
|
||||
/// The default implementation of [PhotoViewControllerBase].
|
||||
///
|
||||
/// Containing a [ValueNotifier] it stores the state in the [value] field and streams
|
||||
/// updates via [outputStateStream].
|
||||
///
|
||||
/// For details of fields and methods, check [PhotoViewControllerBase].
|
||||
///
|
||||
class PhotoViewController
|
||||
implements PhotoViewControllerBase<PhotoViewControllerValue> {
|
||||
PhotoViewController({
|
||||
Offset initialPosition = Offset.zero,
|
||||
double initialRotation = 0.0,
|
||||
double? initialScale,
|
||||
}) : _valueNotifier = IgnorableValueNotifier(
|
||||
PhotoViewControllerValue(
|
||||
position: initialPosition,
|
||||
rotation: initialRotation,
|
||||
scale: initialScale,
|
||||
rotationFocusPoint: null,
|
||||
),
|
||||
),
|
||||
super() {
|
||||
initial = value;
|
||||
prevValue = initial;
|
||||
|
||||
_valueNotifier.addListener(_changeListener);
|
||||
_outputCtrl = StreamController<PhotoViewControllerValue>.broadcast();
|
||||
_outputCtrl.sink.add(initial);
|
||||
}
|
||||
|
||||
final IgnorableValueNotifier<PhotoViewControllerValue> _valueNotifier;
|
||||
|
||||
late PhotoViewControllerValue initial;
|
||||
|
||||
late StreamController<PhotoViewControllerValue> _outputCtrl;
|
||||
|
||||
@override
|
||||
Stream<PhotoViewControllerValue> get outputStateStream => _outputCtrl.stream;
|
||||
|
||||
@override
|
||||
late PhotoViewControllerValue prevValue;
|
||||
|
||||
@override
|
||||
void reset() {
|
||||
value = initial;
|
||||
}
|
||||
|
||||
void _changeListener() {
|
||||
_outputCtrl.sink.add(value);
|
||||
}
|
||||
|
||||
@override
|
||||
void addIgnorableListener(VoidCallback callback) {
|
||||
_valueNotifier.addIgnorableListener(callback);
|
||||
}
|
||||
|
||||
@override
|
||||
void removeIgnorableListener(VoidCallback callback) {
|
||||
_valueNotifier.removeIgnorableListener(callback);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_outputCtrl.close();
|
||||
_valueNotifier.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
set position(Offset position) {
|
||||
if (value.position == position) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset get position => value.position;
|
||||
|
||||
@override
|
||||
set scale(double? scale) {
|
||||
if (value.scale == scale) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double? get scale => value.scale;
|
||||
|
||||
@override
|
||||
void setScaleInvisibly(double? scale) {
|
||||
if (value.scale == scale) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
_valueNotifier.updateIgnoring(
|
||||
PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
set rotation(double rotation) {
|
||||
if (value.rotation == rotation) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double get rotation => value.rotation;
|
||||
|
||||
@override
|
||||
set rotationFocusPoint(Offset? rotationFocusPoint) {
|
||||
if (value.rotationFocusPoint == rotationFocusPoint) {
|
||||
return;
|
||||
}
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Offset? get rotationFocusPoint => value.rotationFocusPoint;
|
||||
|
||||
@override
|
||||
void updateMultiple({
|
||||
Offset? position,
|
||||
double? scale,
|
||||
double? rotation,
|
||||
Offset? rotationFocusPoint,
|
||||
}) {
|
||||
prevValue = value;
|
||||
value = PhotoViewControllerValue(
|
||||
position: position ?? value.position,
|
||||
scale: scale ?? value.scale,
|
||||
rotation: rotation ?? value.rotation,
|
||||
rotationFocusPoint: rotationFocusPoint ?? value.rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
PhotoViewControllerValue get value => _valueNotifier.value;
|
||||
|
||||
@override
|
||||
set value(PhotoViewControllerValue newValue) {
|
||||
if (_valueNotifier.value == newValue) {
|
||||
return;
|
||||
}
|
||||
_valueNotifier.value = newValue;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'
|
||||
show
|
||||
PhotoViewControllerBase,
|
||||
PhotoViewScaleState,
|
||||
PhotoViewScaleStateController,
|
||||
ScaleStateCycle;
|
||||
import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_core.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart';
|
||||
|
||||
/// A class to hold internal layout logic to sync both controller states
|
||||
///
|
||||
/// It reacts to layout changes (eg: enter landscape or widget resize) and syncs the two controllers.
|
||||
mixin PhotoViewControllerDelegate on State<PhotoViewCore> {
|
||||
PhotoViewControllerBase get controller => widget.controller;
|
||||
|
||||
PhotoViewScaleStateController get scaleStateController =>
|
||||
widget.scaleStateController;
|
||||
|
||||
ScaleBoundaries get scaleBoundaries => widget.scaleBoundaries;
|
||||
|
||||
ScaleStateCycle get scaleStateCycle => widget.scaleStateCycle;
|
||||
|
||||
Alignment get basePosition => widget.basePosition;
|
||||
Function(double prevScale, double nextScale)? _animateScale;
|
||||
|
||||
/// Mark if scale need recalculation, useful for scale boundaries changes.
|
||||
bool markNeedsScaleRecalc = true;
|
||||
|
||||
void initDelegate() {
|
||||
controller.addIgnorableListener(_blindScaleListener);
|
||||
scaleStateController.addIgnorableListener(_blindScaleStateListener);
|
||||
}
|
||||
|
||||
void _blindScaleStateListener() {
|
||||
if (!scaleStateController.hasChanged) {
|
||||
return;
|
||||
}
|
||||
if (_animateScale == null || scaleStateController.isZooming) {
|
||||
controller.setScaleInvisibly(scale);
|
||||
return;
|
||||
}
|
||||
final double prevScale = controller.scale ??
|
||||
getScaleForScaleState(
|
||||
scaleStateController.prevScaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
|
||||
final double nextScale = getScaleForScaleState(
|
||||
scaleStateController.scaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
|
||||
_animateScale!(prevScale, nextScale);
|
||||
}
|
||||
|
||||
void addAnimateOnScaleStateUpdate(
|
||||
void Function(double prevScale, double nextScale) animateScale,
|
||||
) {
|
||||
_animateScale = animateScale;
|
||||
}
|
||||
|
||||
void _blindScaleListener() {
|
||||
if (!widget.enablePanAlways) {
|
||||
controller.position = clampPosition();
|
||||
}
|
||||
if (controller.scale == controller.prevValue.scale) {
|
||||
return;
|
||||
}
|
||||
final PhotoViewScaleState newScaleState =
|
||||
(scale > scaleBoundaries.initialScale)
|
||||
? PhotoViewScaleState.zoomedIn
|
||||
: PhotoViewScaleState.zoomedOut;
|
||||
|
||||
scaleStateController.setInvisibly(newScaleState);
|
||||
}
|
||||
|
||||
Offset get position => controller.position;
|
||||
|
||||
double get scale {
|
||||
// for figuring out initial scale
|
||||
final needsRecalc = markNeedsScaleRecalc &&
|
||||
!scaleStateController.scaleState.isScaleStateZooming;
|
||||
|
||||
final scaleExistsOnController = controller.scale != null;
|
||||
if (needsRecalc || !scaleExistsOnController) {
|
||||
final newScale = getScaleForScaleState(
|
||||
scaleStateController.scaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
markNeedsScaleRecalc = false;
|
||||
scale = newScale;
|
||||
return newScale;
|
||||
}
|
||||
return controller.scale!;
|
||||
}
|
||||
|
||||
set scale(double scale) => controller.setScaleInvisibly(scale);
|
||||
|
||||
void updateMultiple({
|
||||
Offset? position,
|
||||
double? scale,
|
||||
double? rotation,
|
||||
Offset? rotationFocusPoint,
|
||||
}) {
|
||||
controller.updateMultiple(
|
||||
position: position,
|
||||
scale: scale,
|
||||
rotation: rotation,
|
||||
rotationFocusPoint: rotationFocusPoint,
|
||||
);
|
||||
}
|
||||
|
||||
void updateScaleStateFromNewScale(double newScale) {
|
||||
PhotoViewScaleState newScaleState = PhotoViewScaleState.initial;
|
||||
if (scale != scaleBoundaries.initialScale) {
|
||||
newScaleState = (newScale > scaleBoundaries.initialScale)
|
||||
? PhotoViewScaleState.zoomedIn
|
||||
: PhotoViewScaleState.zoomedOut;
|
||||
}
|
||||
scaleStateController.setInvisibly(newScaleState);
|
||||
}
|
||||
|
||||
void nextScaleState() {
|
||||
final PhotoViewScaleState scaleState = scaleStateController.scaleState;
|
||||
if (scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleState == PhotoViewScaleState.zoomedOut) {
|
||||
scaleStateController.scaleState = scaleStateCycle(scaleState);
|
||||
return;
|
||||
}
|
||||
final double originalScale = getScaleForScaleState(
|
||||
scaleState,
|
||||
scaleBoundaries,
|
||||
);
|
||||
|
||||
double prevScale = originalScale;
|
||||
PhotoViewScaleState prevScaleState = scaleState;
|
||||
double nextScale = originalScale;
|
||||
PhotoViewScaleState nextScaleState = scaleState;
|
||||
|
||||
do {
|
||||
prevScale = nextScale;
|
||||
prevScaleState = nextScaleState;
|
||||
nextScaleState = scaleStateCycle(prevScaleState);
|
||||
nextScale = getScaleForScaleState(nextScaleState, scaleBoundaries);
|
||||
} while (prevScale == nextScale && scaleState != nextScaleState);
|
||||
|
||||
if (originalScale == nextScale) {
|
||||
return;
|
||||
}
|
||||
scaleStateController.scaleState = nextScaleState;
|
||||
}
|
||||
|
||||
CornersRange cornersX({double? scale}) {
|
||||
final double s = scale ?? this.scale;
|
||||
|
||||
final double computedWidth = scaleBoundaries.childSize.width * s;
|
||||
final double screenWidth = scaleBoundaries.outerSize.width;
|
||||
|
||||
final double positionX = basePosition.x;
|
||||
final double widthDiff = computedWidth - screenWidth;
|
||||
|
||||
final double minX = ((positionX - 1).abs() / 2) * widthDiff * -1;
|
||||
final double maxX = ((positionX + 1).abs() / 2) * widthDiff;
|
||||
return CornersRange(minX, maxX);
|
||||
}
|
||||
|
||||
CornersRange cornersY({double? scale}) {
|
||||
final double s = scale ?? this.scale;
|
||||
|
||||
final double computedHeight = scaleBoundaries.childSize.height * s;
|
||||
final double screenHeight = scaleBoundaries.outerSize.height;
|
||||
|
||||
final double positionY = basePosition.y;
|
||||
final double heightDiff = computedHeight - screenHeight;
|
||||
|
||||
final double minY = ((positionY - 1).abs() / 2) * heightDiff * -1;
|
||||
final double maxY = ((positionY + 1).abs() / 2) * heightDiff;
|
||||
return CornersRange(minY, maxY);
|
||||
}
|
||||
|
||||
Offset clampPosition({Offset? position, double? scale}) {
|
||||
final double s = scale ?? this.scale;
|
||||
final Offset p = position ?? this.position;
|
||||
|
||||
final double computedWidth = scaleBoundaries.childSize.width * s;
|
||||
final double computedHeight = scaleBoundaries.childSize.height * s;
|
||||
|
||||
final double screenWidth = scaleBoundaries.outerSize.width;
|
||||
final double screenHeight = scaleBoundaries.outerSize.height;
|
||||
|
||||
double finalX = 0.0;
|
||||
if (screenWidth < computedWidth) {
|
||||
final cornersX = this.cornersX(scale: s);
|
||||
finalX = p.dx.clamp(cornersX.min, cornersX.max);
|
||||
}
|
||||
|
||||
double finalY = 0.0;
|
||||
if (screenHeight < computedHeight) {
|
||||
final cornersY = this.cornersY(scale: s);
|
||||
finalY = p.dy.clamp(cornersY.min, cornersY.max);
|
||||
}
|
||||
|
||||
return Offset(finalX, finalY);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_animateScale = null;
|
||||
controller.removeIgnorableListener(_blindScaleListener);
|
||||
scaleStateController.removeIgnorableListener(_blindScaleStateListener);
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/widgets.dart' show VoidCallback;
|
||||
import 'package:immich_mobile/widgets/photo_view/src/photo_view_scale_state.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/ignorable_change_notifier.dart';
|
||||
|
||||
typedef ScaleStateListener = void Function(double prevScale, double nextScale);
|
||||
|
||||
/// A controller responsible only by [scaleState].
|
||||
///
|
||||
/// Scale state is a common value with represents the step in which the [PhotoView.scaleStateCycle] is.
|
||||
/// This cycle is triggered by the "doubleTap" gesture.
|
||||
///
|
||||
/// Any change in its [scaleState] should animate the scale of image/content.
|
||||
///
|
||||
/// As it is a controller, whoever instantiates it, should [dispose] it afterwards.
|
||||
///
|
||||
/// The updates should be done via [scaleState] setter and the updated listened via [outputScaleStateStream]
|
||||
///
|
||||
class PhotoViewScaleStateController {
|
||||
late final IgnorableValueNotifier<PhotoViewScaleState> _scaleStateNotifier =
|
||||
IgnorableValueNotifier(PhotoViewScaleState.initial)
|
||||
..addListener(_scaleStateChangeListener);
|
||||
final StreamController<PhotoViewScaleState> _outputScaleStateCtrl =
|
||||
StreamController<PhotoViewScaleState>.broadcast()
|
||||
..sink.add(PhotoViewScaleState.initial);
|
||||
|
||||
/// The output for state/value updates
|
||||
Stream<PhotoViewScaleState> get outputScaleStateStream =>
|
||||
_outputScaleStateCtrl.stream;
|
||||
|
||||
/// The state value before the last change or the initial state if the state has not been changed.
|
||||
PhotoViewScaleState prevScaleState = PhotoViewScaleState.initial;
|
||||
|
||||
/// The actual state value
|
||||
PhotoViewScaleState get scaleState => _scaleStateNotifier.value;
|
||||
|
||||
/// Updates scaleState and notify all listeners (and the stream)
|
||||
set scaleState(PhotoViewScaleState newValue) {
|
||||
if (_scaleStateNotifier.value == newValue) {
|
||||
return;
|
||||
}
|
||||
|
||||
prevScaleState = _scaleStateNotifier.value;
|
||||
_scaleStateNotifier.value = newValue;
|
||||
}
|
||||
|
||||
/// Checks if its actual value is different than previousValue
|
||||
bool get hasChanged => prevScaleState != scaleState;
|
||||
|
||||
/// Check if is `zoomedIn` & `zoomedOut`
|
||||
bool get isZooming =>
|
||||
scaleState == PhotoViewScaleState.zoomedIn ||
|
||||
scaleState == PhotoViewScaleState.zoomedOut;
|
||||
|
||||
/// Resets the state to the initial value;
|
||||
void reset() {
|
||||
prevScaleState = scaleState;
|
||||
scaleState = PhotoViewScaleState.initial;
|
||||
}
|
||||
|
||||
/// Closes streams and removes eventual listeners
|
||||
void dispose() {
|
||||
_outputScaleStateCtrl.close();
|
||||
_scaleStateNotifier.dispose();
|
||||
}
|
||||
|
||||
/// Nevermind this method :D, look away
|
||||
void setInvisibly(PhotoViewScaleState newValue) {
|
||||
if (_scaleStateNotifier.value == newValue) {
|
||||
return;
|
||||
}
|
||||
prevScaleState = _scaleStateNotifier.value;
|
||||
_scaleStateNotifier.updateIgnoring(newValue);
|
||||
}
|
||||
|
||||
void _scaleStateChangeListener() {
|
||||
_outputScaleStateCtrl.sink.add(scaleState);
|
||||
}
|
||||
|
||||
/// Add a listener that will ignore updates made internally
|
||||
///
|
||||
/// Since it is made for internal use, it is not performatic to use more than one
|
||||
/// listener. Prefer [outputScaleStateStream]
|
||||
void addIgnorableListener(VoidCallback callback) {
|
||||
_scaleStateNotifier.addIgnorableListener(callback);
|
||||
}
|
||||
|
||||
/// Remove a listener that will ignore updates made internally
|
||||
///
|
||||
/// Since it is made for internal use, it is not performatic to use more than one
|
||||
/// listener. Prefer [outputScaleStateStream]
|
||||
void removeIgnorableListener(VoidCallback callback) {
|
||||
_scaleStateNotifier.removeIgnorableListener(callback);
|
||||
}
|
||||
}
|
||||
467
mobile/lib/widgets/photo_view/src/core/photo_view_core.dart
Normal file
467
mobile/lib/widgets/photo_view/src/core/photo_view_core.dart
Normal file
|
|
@ -0,0 +1,467 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/photo_view.dart'
|
||||
show
|
||||
PhotoViewScaleState,
|
||||
PhotoViewHeroAttributes,
|
||||
PhotoViewImageTapDownCallback,
|
||||
PhotoViewImageTapUpCallback,
|
||||
PhotoViewImageScaleEndCallback,
|
||||
PhotoViewImageDragEndCallback,
|
||||
PhotoViewImageDragStartCallback,
|
||||
PhotoViewImageDragUpdateCallback,
|
||||
PhotoViewImageLongPressStartCallback,
|
||||
ScaleStateCycle;
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller_delegate.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_scalestate_controller.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_gesture_detector.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/core/photo_view_hit_corners.dart';
|
||||
import 'package:immich_mobile/widgets/photo_view/src/utils/photo_view_utils.dart';
|
||||
|
||||
const _defaultDecoration = BoxDecoration(
|
||||
color: Color.fromRGBO(0, 0, 0, 1.0),
|
||||
);
|
||||
|
||||
/// Internal widget in which controls all animations lifecycle, core responses
|
||||
/// to user gestures, updates to the controller state and mounts the entire PhotoView Layout
|
||||
class PhotoViewCore extends StatefulWidget {
|
||||
const PhotoViewCore({
|
||||
super.key,
|
||||
required this.imageProvider,
|
||||
required this.backgroundDecoration,
|
||||
required this.gaplessPlayback,
|
||||
required this.heroAttributes,
|
||||
required this.enableRotation,
|
||||
required this.onTapUp,
|
||||
required this.onTapDown,
|
||||
required this.onDragStart,
|
||||
required this.onDragEnd,
|
||||
required this.onDragUpdate,
|
||||
required this.onScaleEnd,
|
||||
required this.onLongPressStart,
|
||||
required this.gestureDetectorBehavior,
|
||||
required this.controller,
|
||||
required this.scaleBoundaries,
|
||||
required this.scaleStateCycle,
|
||||
required this.scaleStateController,
|
||||
required this.basePosition,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.enablePanAlways,
|
||||
}) : customChild = null;
|
||||
|
||||
const PhotoViewCore.customChild({
|
||||
super.key,
|
||||
required this.customChild,
|
||||
required this.backgroundDecoration,
|
||||
this.heroAttributes,
|
||||
required this.enableRotation,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
this.gestureDetectorBehavior,
|
||||
required this.controller,
|
||||
required this.scaleBoundaries,
|
||||
required this.scaleStateCycle,
|
||||
required this.scaleStateController,
|
||||
required this.basePosition,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.enablePanAlways,
|
||||
}) : imageProvider = null,
|
||||
gaplessPlayback = false;
|
||||
|
||||
final Decoration? backgroundDecoration;
|
||||
final ImageProvider? imageProvider;
|
||||
final bool? gaplessPlayback;
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
final bool enableRotation;
|
||||
final Widget? customChild;
|
||||
|
||||
final PhotoViewControllerBase controller;
|
||||
final PhotoViewScaleStateController scaleStateController;
|
||||
final ScaleBoundaries scaleBoundaries;
|
||||
final ScaleStateCycle scaleStateCycle;
|
||||
final Alignment basePosition;
|
||||
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
|
||||
final PhotoViewImageLongPressStartCallback? onLongPressStart;
|
||||
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
final bool tightMode;
|
||||
final bool disableGestures;
|
||||
final bool enablePanAlways;
|
||||
|
||||
final FilterQuality filterQuality;
|
||||
|
||||
@override
|
||||
State<StatefulWidget> createState() {
|
||||
return PhotoViewCoreState();
|
||||
}
|
||||
|
||||
bool get hasCustomChild => customChild != null;
|
||||
}
|
||||
|
||||
class PhotoViewCoreState extends State<PhotoViewCore>
|
||||
with
|
||||
TickerProviderStateMixin,
|
||||
PhotoViewControllerDelegate,
|
||||
HitCornersDetector {
|
||||
Offset? _normalizedPosition;
|
||||
double? _scaleBefore;
|
||||
double? _rotationBefore;
|
||||
|
||||
late final AnimationController _scaleAnimationController;
|
||||
Animation<double>? _scaleAnimation;
|
||||
|
||||
late final AnimationController _positionAnimationController;
|
||||
Animation<Offset>? _positionAnimation;
|
||||
|
||||
late final AnimationController _rotationAnimationController =
|
||||
AnimationController(vsync: this)..addListener(handleRotationAnimation);
|
||||
Animation<double>? _rotationAnimation;
|
||||
|
||||
PhotoViewHeroAttributes? get heroAttributes => widget.heroAttributes;
|
||||
|
||||
late ScaleBoundaries cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
|
||||
void handleScaleAnimation() {
|
||||
scale = _scaleAnimation!.value;
|
||||
}
|
||||
|
||||
void handlePositionAnimate() {
|
||||
controller.position = _positionAnimation!.value;
|
||||
}
|
||||
|
||||
void handleRotationAnimation() {
|
||||
controller.rotation = _rotationAnimation!.value;
|
||||
}
|
||||
|
||||
void onScaleStart(ScaleStartDetails details) {
|
||||
_rotationBefore = controller.rotation;
|
||||
_scaleBefore = scale;
|
||||
_normalizedPosition = details.focalPoint - controller.position;
|
||||
_scaleAnimationController.stop();
|
||||
_positionAnimationController.stop();
|
||||
_rotationAnimationController.stop();
|
||||
}
|
||||
|
||||
void onScaleUpdate(ScaleUpdateDetails details) {
|
||||
final double newScale = _scaleBefore! * details.scale;
|
||||
final Offset delta = details.focalPoint - _normalizedPosition!;
|
||||
|
||||
updateScaleStateFromNewScale(newScale);
|
||||
|
||||
updateMultiple(
|
||||
scale: newScale,
|
||||
position: widget.enablePanAlways
|
||||
? delta
|
||||
: clampPosition(position: delta * details.scale),
|
||||
rotation:
|
||||
widget.enableRotation ? _rotationBefore! + details.rotation : null,
|
||||
rotationFocusPoint: widget.enableRotation ? details.focalPoint : null,
|
||||
);
|
||||
}
|
||||
|
||||
void onScaleEnd(ScaleEndDetails details) {
|
||||
final double s = scale;
|
||||
final Offset p = controller.position;
|
||||
final double maxScale = scaleBoundaries.maxScale;
|
||||
final double minScale = scaleBoundaries.minScale;
|
||||
|
||||
widget.onScaleEnd?.call(context, details, controller.value);
|
||||
|
||||
//animate back to maxScale if gesture exceeded the maxScale specified
|
||||
if (s > maxScale) {
|
||||
final double scaleComebackRatio = maxScale / s;
|
||||
animateScale(s, maxScale);
|
||||
final Offset clampedPosition = clampPosition(
|
||||
position: p * scaleComebackRatio,
|
||||
scale: maxScale,
|
||||
);
|
||||
animatePosition(p, clampedPosition);
|
||||
return;
|
||||
}
|
||||
|
||||
//animate back to minScale if gesture fell smaller than the minScale specified
|
||||
if (s < minScale) {
|
||||
final double scaleComebackRatio = minScale / s;
|
||||
animateScale(s, minScale);
|
||||
animatePosition(
|
||||
p,
|
||||
clampPosition(
|
||||
position: p * scaleComebackRatio,
|
||||
scale: minScale,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// get magnitude from gesture velocity
|
||||
final double magnitude = details.velocity.pixelsPerSecond.distance;
|
||||
|
||||
// animate velocity only if there is no scale change and a significant magnitude
|
||||
if (_scaleBefore! / s == 1.0 && magnitude >= 400.0) {
|
||||
final Offset direction = details.velocity.pixelsPerSecond / magnitude;
|
||||
animatePosition(
|
||||
p,
|
||||
clampPosition(position: p + direction * 100.0),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void onDoubleTap() {
|
||||
nextScaleState();
|
||||
}
|
||||
|
||||
void animateScale(double from, double to) {
|
||||
_scaleAnimation = Tween<double>(
|
||||
begin: from,
|
||||
end: to,
|
||||
).animate(_scaleAnimationController);
|
||||
_scaleAnimationController
|
||||
..value = 0.0
|
||||
..fling(velocity: 0.4);
|
||||
}
|
||||
|
||||
void animatePosition(Offset from, Offset to) {
|
||||
_positionAnimation = Tween<Offset>(begin: from, end: to)
|
||||
.animate(_positionAnimationController);
|
||||
_positionAnimationController
|
||||
..value = 0.0
|
||||
..fling(velocity: 0.4);
|
||||
}
|
||||
|
||||
void animateRotation(double from, double to) {
|
||||
_rotationAnimation = Tween<double>(begin: from, end: to)
|
||||
.animate(_rotationAnimationController);
|
||||
_rotationAnimationController
|
||||
..value = 0.0
|
||||
..fling(velocity: 0.4);
|
||||
}
|
||||
|
||||
void onAnimationStatus(AnimationStatus status) {
|
||||
if (status == AnimationStatus.completed) {
|
||||
onAnimationStatusCompleted();
|
||||
}
|
||||
}
|
||||
|
||||
/// Check if scale is equal to initial after scale animation update
|
||||
void onAnimationStatusCompleted() {
|
||||
if (scaleStateController.scaleState != PhotoViewScaleState.initial &&
|
||||
scale == scaleBoundaries.initialScale) {
|
||||
scaleStateController.setInvisibly(PhotoViewScaleState.initial);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
initDelegate();
|
||||
addAnimateOnScaleStateUpdate(animateOnScaleStateUpdate);
|
||||
|
||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
|
||||
_scaleAnimationController = AnimationController(vsync: this)
|
||||
..addListener(handleScaleAnimation)
|
||||
..addStatusListener(onAnimationStatus);
|
||||
_positionAnimationController = AnimationController(vsync: this)
|
||||
..addListener(handlePositionAnimate);
|
||||
}
|
||||
|
||||
void animateOnScaleStateUpdate(double prevScale, double nextScale) {
|
||||
animateScale(prevScale, nextScale);
|
||||
animatePosition(controller.position, Offset.zero);
|
||||
animateRotation(controller.rotation, 0.0);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_scaleAnimationController.removeStatusListener(onAnimationStatus);
|
||||
_scaleAnimationController.dispose();
|
||||
_positionAnimationController.dispose();
|
||||
_rotationAnimationController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void onTapUp(TapUpDetails details) {
|
||||
widget.onTapUp?.call(context, details, controller.value);
|
||||
}
|
||||
|
||||
void onTapDown(TapDownDetails details) {
|
||||
widget.onTapDown?.call(context, details, controller.value);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
// Check if we need a recalc on the scale
|
||||
if (widget.scaleBoundaries != cachedScaleBoundaries) {
|
||||
markNeedsScaleRecalc = true;
|
||||
cachedScaleBoundaries = widget.scaleBoundaries;
|
||||
}
|
||||
|
||||
return StreamBuilder(
|
||||
stream: controller.outputStateStream,
|
||||
initialData: controller.prevValue,
|
||||
builder: (
|
||||
BuildContext context,
|
||||
AsyncSnapshot<PhotoViewControllerValue> snapshot,
|
||||
) {
|
||||
if (snapshot.hasData) {
|
||||
final PhotoViewControllerValue value = snapshot.data!;
|
||||
final useImageScale = widget.filterQuality != FilterQuality.none;
|
||||
|
||||
final computedScale = useImageScale ? 1.0 : scale;
|
||||
|
||||
final matrix = Matrix4.identity()
|
||||
..translate(value.position.dx, value.position.dy)
|
||||
..scale(computedScale)
|
||||
..rotateZ(value.rotation);
|
||||
|
||||
final Widget customChildLayout = CustomSingleChildLayout(
|
||||
delegate: _CenterWithOriginalSizeDelegate(
|
||||
scaleBoundaries.childSize,
|
||||
basePosition,
|
||||
useImageScale,
|
||||
),
|
||||
child: _buildHero(),
|
||||
);
|
||||
|
||||
final child = Container(
|
||||
constraints: widget.tightMode
|
||||
? BoxConstraints.tight(scaleBoundaries.childSize * scale)
|
||||
: null,
|
||||
decoration: widget.backgroundDecoration ?? _defaultDecoration,
|
||||
child: Center(
|
||||
child: Transform(
|
||||
transform: matrix,
|
||||
alignment: basePosition,
|
||||
child: customChildLayout,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
if (widget.disableGestures) {
|
||||
return child;
|
||||
}
|
||||
|
||||
return PhotoViewGestureDetector(
|
||||
onDoubleTap: nextScaleState,
|
||||
onScaleStart: onScaleStart,
|
||||
onScaleUpdate: onScaleUpdate,
|
||||
onScaleEnd: onScaleEnd,
|
||||
onDragStart: widget.onDragStart != null
|
||||
? (details) => widget.onDragStart!(context, details, value)
|
||||
: null,
|
||||
onDragEnd: widget.onDragEnd != null
|
||||
? (details) => widget.onDragEnd!(context, details, value)
|
||||
: null,
|
||||
onDragUpdate: widget.onDragUpdate != null
|
||||
? (details) => widget.onDragUpdate!(context, details, value)
|
||||
: null,
|
||||
hitDetector: this,
|
||||
onTapUp: widget.onTapUp != null
|
||||
? (details) => widget.onTapUp!(context, details, value)
|
||||
: null,
|
||||
onTapDown: widget.onTapDown != null
|
||||
? (details) => widget.onTapDown!(context, details, value)
|
||||
: null,
|
||||
onLongPressStart: widget.onLongPressStart != null
|
||||
? (details) => widget.onLongPressStart!(context, details, value)
|
||||
: null,
|
||||
child: child,
|
||||
);
|
||||
} else {
|
||||
return Container();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildHero() {
|
||||
return heroAttributes != null
|
||||
? Hero(
|
||||
tag: heroAttributes!.tag,
|
||||
createRectTween: heroAttributes!.createRectTween,
|
||||
flightShuttleBuilder: heroAttributes!.flightShuttleBuilder,
|
||||
placeholderBuilder: heroAttributes!.placeholderBuilder,
|
||||
transitionOnUserGestures: heroAttributes!.transitionOnUserGestures,
|
||||
child: _buildChild(),
|
||||
)
|
||||
: _buildChild();
|
||||
}
|
||||
|
||||
Widget _buildChild() {
|
||||
return widget.hasCustomChild
|
||||
? widget.customChild!
|
||||
: Image(
|
||||
image: widget.imageProvider!,
|
||||
gaplessPlayback: widget.gaplessPlayback ?? false,
|
||||
filterQuality: widget.filterQuality,
|
||||
width: scaleBoundaries.childSize.width * scale,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CenterWithOriginalSizeDelegate extends SingleChildLayoutDelegate {
|
||||
const _CenterWithOriginalSizeDelegate(
|
||||
this.subjectSize,
|
||||
this.basePosition,
|
||||
this.useImageScale,
|
||||
);
|
||||
|
||||
final Size subjectSize;
|
||||
final Alignment basePosition;
|
||||
final bool useImageScale;
|
||||
|
||||
@override
|
||||
Offset getPositionForChild(Size size, Size childSize) {
|
||||
final childWidth = useImageScale ? childSize.width : subjectSize.width;
|
||||
final childHeight = useImageScale ? childSize.height : subjectSize.height;
|
||||
|
||||
final halfWidth = (size.width - childWidth) / 2;
|
||||
final halfHeight = (size.height - childHeight) / 2;
|
||||
|
||||
final double offsetX = halfWidth * (basePosition.x + 1);
|
||||
final double offsetY = halfHeight * (basePosition.y + 1);
|
||||
return Offset(offsetX, offsetY);
|
||||
}
|
||||
|
||||
@override
|
||||
BoxConstraints getConstraintsForChild(BoxConstraints constraints) {
|
||||
return useImageScale
|
||||
? const BoxConstraints()
|
||||
: BoxConstraints.tight(subjectSize);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRelayout(_CenterWithOriginalSizeDelegate oldDelegate) {
|
||||
return oldDelegate != this;
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is _CenterWithOriginalSizeDelegate &&
|
||||
runtimeType == other.runtimeType &&
|
||||
subjectSize == other.subjectSize &&
|
||||
basePosition == other.basePosition &&
|
||||
useImageScale == other.useImageScale;
|
||||
|
||||
@override
|
||||
int get hashCode =>
|
||||
subjectSize.hashCode ^ basePosition.hashCode ^ useImageScale.hashCode;
|
||||
}
|
||||
|
|
@ -0,0 +1,301 @@
|
|||
import 'package:flutter/gestures.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'photo_view_hit_corners.dart';
|
||||
|
||||
/// Credit to [eduribas](https://github.com/eduribas/photo_view/commit/508d9b77dafbcf88045b4a7fee737eed4064ea2c)
|
||||
/// for the gist
|
||||
class PhotoViewGestureDetector extends StatelessWidget {
|
||||
const PhotoViewGestureDetector({
|
||||
super.key,
|
||||
this.hitDetector,
|
||||
this.onScaleStart,
|
||||
this.onScaleUpdate,
|
||||
this.onScaleEnd,
|
||||
this.onDoubleTap,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onLongPressStart,
|
||||
this.child,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.behavior,
|
||||
});
|
||||
|
||||
final GestureDoubleTapCallback? onDoubleTap;
|
||||
final HitCornersDetector? hitDetector;
|
||||
|
||||
final GestureScaleStartCallback? onScaleStart;
|
||||
final GestureScaleUpdateCallback? onScaleUpdate;
|
||||
final GestureScaleEndCallback? onScaleEnd;
|
||||
|
||||
final GestureDragEndCallback? onDragEnd;
|
||||
final GestureDragStartCallback? onDragStart;
|
||||
final GestureDragUpdateCallback? onDragUpdate;
|
||||
|
||||
final GestureTapUpCallback? onTapUp;
|
||||
final GestureTapDownCallback? onTapDown;
|
||||
|
||||
final GestureLongPressStartCallback? onLongPressStart;
|
||||
|
||||
final Widget? child;
|
||||
|
||||
final HitTestBehavior? behavior;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scope = PhotoViewGestureDetectorScope.of(context);
|
||||
|
||||
final Axis? axis = scope?.axis;
|
||||
final touchSlopFactor = scope?.touchSlopFactor ?? 2;
|
||||
|
||||
final Map<Type, GestureRecognizerFactory> gestures =
|
||||
<Type, GestureRecognizerFactory>{};
|
||||
|
||||
if (onTapDown != null || onTapUp != null) {
|
||||
gestures[TapGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<TapGestureRecognizer>(
|
||||
() => TapGestureRecognizer(debugOwner: this),
|
||||
(TapGestureRecognizer instance) {
|
||||
instance
|
||||
..onTapDown = onTapDown
|
||||
..onTapUp = onTapUp;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
if (onDragStart != null || onDragEnd != null || onDragUpdate != null) {
|
||||
gestures[VerticalDragGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<VerticalDragGestureRecognizer>(
|
||||
() => VerticalDragGestureRecognizer(debugOwner: this),
|
||||
(VerticalDragGestureRecognizer instance) {
|
||||
instance
|
||||
..onStart = onDragStart
|
||||
..onUpdate = onDragUpdate
|
||||
..onEnd = onDragEnd;
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
gestures[DoubleTapGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<DoubleTapGestureRecognizer>(
|
||||
() => DoubleTapGestureRecognizer(debugOwner: this),
|
||||
(DoubleTapGestureRecognizer instance) {
|
||||
instance.onDoubleTap = onDoubleTap;
|
||||
},
|
||||
);
|
||||
|
||||
gestures[PhotoViewGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<PhotoViewGestureRecognizer>(
|
||||
() => PhotoViewGestureRecognizer(
|
||||
hitDetector: hitDetector,
|
||||
debugOwner: this,
|
||||
validateAxis: axis,
|
||||
touchSlopFactor: touchSlopFactor,
|
||||
),
|
||||
(PhotoViewGestureRecognizer instance) {
|
||||
instance
|
||||
..onStart = onScaleStart
|
||||
..onUpdate = onScaleUpdate
|
||||
..onEnd = onScaleEnd;
|
||||
},
|
||||
);
|
||||
|
||||
gestures[LongPressGestureRecognizer] =
|
||||
GestureRecognizerFactoryWithHandlers<LongPressGestureRecognizer>(
|
||||
() => LongPressGestureRecognizer(debugOwner: this),
|
||||
(LongPressGestureRecognizer instance) {
|
||||
instance.onLongPressStart = onLongPressStart;
|
||||
});
|
||||
|
||||
return RawGestureDetector(
|
||||
behavior: behavior,
|
||||
gestures: gestures,
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PhotoViewGestureRecognizer extends ScaleGestureRecognizer {
|
||||
PhotoViewGestureRecognizer({
|
||||
this.hitDetector,
|
||||
super.debugOwner,
|
||||
this.validateAxis,
|
||||
this.touchSlopFactor = 1,
|
||||
PointerDeviceKind? kind,
|
||||
}) : super(supportedDevices: null);
|
||||
final HitCornersDetector? hitDetector;
|
||||
final Axis? validateAxis;
|
||||
final double touchSlopFactor;
|
||||
|
||||
Map<int, Offset> _pointerLocations = <int, Offset>{};
|
||||
|
||||
Offset? _initialFocalPoint;
|
||||
Offset? _currentFocalPoint;
|
||||
double? _initialSpan;
|
||||
double? _currentSpan;
|
||||
|
||||
bool ready = true;
|
||||
|
||||
@override
|
||||
void addAllowedPointer(PointerDownEvent event) {
|
||||
if (ready) {
|
||||
ready = false;
|
||||
_pointerLocations = <int, Offset>{};
|
||||
}
|
||||
super.addAllowedPointer(event);
|
||||
}
|
||||
|
||||
@override
|
||||
void didStopTrackingLastPointer(int pointer) {
|
||||
ready = true;
|
||||
super.didStopTrackingLastPointer(pointer);
|
||||
}
|
||||
|
||||
@override
|
||||
void handleEvent(PointerEvent event) {
|
||||
if (validateAxis != null) {
|
||||
bool didChangeConfiguration = false;
|
||||
if (event is PointerMoveEvent) {
|
||||
if (!event.synthesized) {
|
||||
_pointerLocations[event.pointer] = event.position;
|
||||
}
|
||||
} else if (event is PointerDownEvent) {
|
||||
_pointerLocations[event.pointer] = event.position;
|
||||
didChangeConfiguration = true;
|
||||
} else if (event is PointerUpEvent || event is PointerCancelEvent) {
|
||||
_pointerLocations.remove(event.pointer);
|
||||
didChangeConfiguration = true;
|
||||
}
|
||||
|
||||
_updateDistances();
|
||||
|
||||
if (didChangeConfiguration) {
|
||||
// cf super._reconfigure
|
||||
_initialFocalPoint = _currentFocalPoint;
|
||||
_initialSpan = _currentSpan;
|
||||
}
|
||||
|
||||
_decideIfWeAcceptEvent(event);
|
||||
}
|
||||
super.handleEvent(event);
|
||||
}
|
||||
|
||||
void _updateDistances() {
|
||||
// cf super._update
|
||||
final int count = _pointerLocations.keys.length;
|
||||
|
||||
// Compute the focal point
|
||||
Offset focalPoint = Offset.zero;
|
||||
for (final int pointer in _pointerLocations.keys) {
|
||||
focalPoint += _pointerLocations[pointer]!;
|
||||
}
|
||||
_currentFocalPoint =
|
||||
count > 0 ? focalPoint / count.toDouble() : Offset.zero;
|
||||
|
||||
// Span is the average deviation from focal point. Horizontal and vertical
|
||||
// spans are the average deviations from the focal point's horizontal and
|
||||
// vertical coordinates, respectively.
|
||||
double totalDeviation = 0.0;
|
||||
for (final int pointer in _pointerLocations.keys) {
|
||||
totalDeviation +=
|
||||
(_currentFocalPoint! - _pointerLocations[pointer]!).distance;
|
||||
}
|
||||
_currentSpan = count > 0 ? totalDeviation / count : 0.0;
|
||||
}
|
||||
|
||||
void _decideIfWeAcceptEvent(PointerEvent event) {
|
||||
final move = _initialFocalPoint! - _currentFocalPoint!;
|
||||
final bool shouldMove = validateAxis == Axis.vertical
|
||||
? hitDetector!.shouldMove(move, Axis.vertical)
|
||||
: hitDetector!.shouldMove(move, Axis.horizontal);
|
||||
if (shouldMove || _pointerLocations.keys.length > 1) {
|
||||
final double spanDelta = (_currentSpan! - _initialSpan!).abs();
|
||||
final double focalPointDelta =
|
||||
(_currentFocalPoint! - _initialFocalPoint!).distance;
|
||||
// warning: do not compare `focalPointDelta` to `kPanSlop`
|
||||
// `ScaleGestureRecognizer` uses `kPanSlop`, but `HorizontalDragGestureRecognizer` uses `kTouchSlop`
|
||||
// and PhotoView recognizer may compete with the `HorizontalDragGestureRecognizer` from a containing `PageView`
|
||||
// setting `touchSlopFactor` to 2 restores default `ScaleGestureRecognizer` behaviour as `kPanSlop = kTouchSlop * 2.0`
|
||||
// setting `touchSlopFactor` in [0, 1] will allow this recognizer to accept the gesture before the one from `PageView`
|
||||
if (spanDelta > kScaleSlop ||
|
||||
focalPointDelta > kTouchSlop * touchSlopFactor) {
|
||||
acceptGesture(event.pointer);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// An [InheritedWidget] responsible to give a axis aware scope to [PhotoViewGestureRecognizer].
|
||||
///
|
||||
/// When using this, PhotoView will test if the content zoomed has hit edge every time user pinches,
|
||||
/// if so, it will let parent gesture detectors win the gesture arena
|
||||
///
|
||||
/// Useful when placing PhotoView inside a gesture sensitive context,
|
||||
/// such as [PageView], [Dismissible], [BottomSheet].
|
||||
///
|
||||
/// Usage example:
|
||||
/// ```
|
||||
/// PhotoViewGestureDetectorScope(
|
||||
/// axis: Axis.vertical,
|
||||
/// child: PhotoView(
|
||||
/// imageProvider: AssetImage("assets/pudim.jpg"),
|
||||
/// ),
|
||||
/// );
|
||||
/// ```
|
||||
class PhotoViewGestureDetectorScope extends InheritedWidget {
|
||||
const PhotoViewGestureDetectorScope({
|
||||
super.key,
|
||||
this.axis,
|
||||
this.touchSlopFactor = .2,
|
||||
required super.child,
|
||||
});
|
||||
|
||||
static PhotoViewGestureDetectorScope? of(BuildContext context) {
|
||||
final PhotoViewGestureDetectorScope? scope = context
|
||||
.dependOnInheritedWidgetOfExactType<PhotoViewGestureDetectorScope>();
|
||||
return scope;
|
||||
}
|
||||
|
||||
final Axis? axis;
|
||||
|
||||
// in [0, 1[
|
||||
// 0: most reactive but will not let tap recognizers accept gestures
|
||||
// <1: less reactive but gives the most leeway to other recognizers
|
||||
// 1: will not be able to compete with a `HorizontalDragGestureRecognizer` up the widget tree
|
||||
final double touchSlopFactor;
|
||||
|
||||
@override
|
||||
bool updateShouldNotify(PhotoViewGestureDetectorScope oldWidget) {
|
||||
return axis != oldWidget.axis &&
|
||||
touchSlopFactor != oldWidget.touchSlopFactor;
|
||||
}
|
||||
}
|
||||
|
||||
// `PageView` contains a `Scrollable` which sets up a `HorizontalDragGestureRecognizer`
|
||||
// this recognizer will win in the gesture arena when the drag distance reaches `kTouchSlop`
|
||||
// we cannot change that, but we can prevent the scrollable from panning until this threshold is reached
|
||||
// and let other recognizers accept the gesture instead
|
||||
class PhotoViewPageViewScrollPhysics extends ScrollPhysics {
|
||||
const PhotoViewPageViewScrollPhysics({
|
||||
this.touchSlopFactor = 0.1,
|
||||
super.parent,
|
||||
});
|
||||
|
||||
// in [0, 1]
|
||||
// 0: most reactive but will not let PhotoView recognizers accept gestures
|
||||
// 1: less reactive but gives the most leeway to PhotoView recognizers
|
||||
final double touchSlopFactor;
|
||||
|
||||
@override
|
||||
PhotoViewPageViewScrollPhysics applyTo(ScrollPhysics? ancestor) {
|
||||
return PhotoViewPageViewScrollPhysics(
|
||||
touchSlopFactor: touchSlopFactor,
|
||||
parent: buildParent(ancestor),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
double get dragStartDistanceMotionThreshold => kTouchSlop * touchSlopFactor;
|
||||
}
|
||||
|
|
@ -0,0 +1,81 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import 'package:immich_mobile/widgets/photo_view/src/controller/photo_view_controller_delegate.dart'
|
||||
show PhotoViewControllerDelegate;
|
||||
|
||||
mixin HitCornersDetector on PhotoViewControllerDelegate {
|
||||
HitCorners _hitCornersX() {
|
||||
final double childWidth = scaleBoundaries.childSize.width * scale;
|
||||
final double screenWidth = scaleBoundaries.outerSize.width;
|
||||
if (screenWidth >= childWidth) {
|
||||
return const HitCorners(true, true);
|
||||
}
|
||||
final x = -position.dx;
|
||||
final cornersX = this.cornersX();
|
||||
return HitCorners(x <= cornersX.min, x >= cornersX.max);
|
||||
}
|
||||
|
||||
HitCorners _hitCornersY() {
|
||||
final double childHeight = scaleBoundaries.childSize.height * scale;
|
||||
final double screenHeight = scaleBoundaries.outerSize.height;
|
||||
if (screenHeight >= childHeight) {
|
||||
return const HitCorners(true, true);
|
||||
}
|
||||
final y = -position.dy;
|
||||
final cornersY = this.cornersY();
|
||||
return HitCorners(y <= cornersY.min, y >= cornersY.max);
|
||||
}
|
||||
|
||||
bool _shouldMoveAxis(
|
||||
HitCorners hitCorners,
|
||||
double mainAxisMove,
|
||||
double crossAxisMove,
|
||||
) {
|
||||
if (mainAxisMove == 0) {
|
||||
return false;
|
||||
}
|
||||
if (!hitCorners.hasHitAny) {
|
||||
return true;
|
||||
}
|
||||
final axisBlocked = hitCorners.hasHitBoth ||
|
||||
(hitCorners.hasHitMax ? mainAxisMove > 0 : mainAxisMove < 0);
|
||||
if (axisBlocked) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
bool _shouldMoveX(Offset move) {
|
||||
final hitCornersX = _hitCornersX();
|
||||
final mainAxisMove = move.dx;
|
||||
final crossAxisMove = move.dy;
|
||||
|
||||
return _shouldMoveAxis(hitCornersX, mainAxisMove, crossAxisMove);
|
||||
}
|
||||
|
||||
bool _shouldMoveY(Offset move) {
|
||||
final hitCornersY = _hitCornersY();
|
||||
final mainAxisMove = move.dy;
|
||||
final crossAxisMove = move.dx;
|
||||
|
||||
return _shouldMoveAxis(hitCornersY, mainAxisMove, crossAxisMove);
|
||||
}
|
||||
|
||||
bool shouldMove(Offset move, Axis mainAxis) {
|
||||
if (mainAxis == Axis.vertical) {
|
||||
return _shouldMoveY(move);
|
||||
}
|
||||
return _shouldMoveX(move);
|
||||
}
|
||||
}
|
||||
|
||||
class HitCorners {
|
||||
const HitCorners(this.hasHitMin, this.hasHitMax);
|
||||
|
||||
final bool hasHitMin;
|
||||
final bool hasHitMax;
|
||||
|
||||
bool get hasHitAny => hasHitMin || hasHitMax;
|
||||
|
||||
bool get hasHitBoth => hasHitMin && hasHitMax;
|
||||
}
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
/// A class that work as a enum. It overloads the operator `*` saving the double as a multiplier.
|
||||
///
|
||||
/// ```
|
||||
/// PhotoViewComputedScale.contained * 2
|
||||
/// ```
|
||||
///
|
||||
class PhotoViewComputedScale {
|
||||
const PhotoViewComputedScale._internal(this._value, [this.multiplier = 1.0]);
|
||||
|
||||
final String _value;
|
||||
final double multiplier;
|
||||
|
||||
@override
|
||||
String toString() => 'Enum.$_value';
|
||||
|
||||
static const contained = PhotoViewComputedScale._internal('contained');
|
||||
static const covered = PhotoViewComputedScale._internal('covered');
|
||||
|
||||
PhotoViewComputedScale operator *(double multiplier) {
|
||||
return PhotoViewComputedScale._internal(_value, multiplier);
|
||||
}
|
||||
|
||||
PhotoViewComputedScale operator /(double divider) {
|
||||
return PhotoViewComputedScale._internal(_value, 1 / divider);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) ||
|
||||
other is PhotoViewComputedScale &&
|
||||
runtimeType == other.runtimeType &&
|
||||
_value == other._value;
|
||||
|
||||
@override
|
||||
int get hashCode => _value.hashCode;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
import 'package:flutter/material.dart';
|
||||
|
||||
class PhotoViewDefaultError extends StatelessWidget {
|
||||
const PhotoViewDefaultError({super.key, required this.decoration});
|
||||
|
||||
final BoxDecoration decoration;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DecoratedBox(
|
||||
decoration: decoration,
|
||||
child: Center(
|
||||
child: Icon(
|
||||
Icons.broken_image,
|
||||
color: Colors.grey[400],
|
||||
size: 40.0,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class PhotoViewDefaultLoading extends StatelessWidget {
|
||||
const PhotoViewDefaultLoading({super.key, this.event});
|
||||
|
||||
final ImageChunkEvent? event;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final expectedBytes = event?.expectedTotalBytes;
|
||||
final loadedBytes = event?.cumulativeBytesLoaded;
|
||||
final value = loadedBytes != null && expectedBytes != null
|
||||
? loadedBytes / expectedBytes
|
||||
: null;
|
||||
|
||||
return Center(
|
||||
child: SizedBox(
|
||||
width: 20.0,
|
||||
height: 20.0,
|
||||
child: CircularProgressIndicator(value: value),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
/// A way to represent the step of the "doubletap gesture cycle" in which PhotoView is.
|
||||
enum PhotoViewScaleState {
|
||||
initial,
|
||||
covering,
|
||||
originalSize,
|
||||
zoomedIn,
|
||||
zoomedOut;
|
||||
|
||||
bool get isScaleStateZooming =>
|
||||
this == PhotoViewScaleState.zoomedIn ||
|
||||
this == PhotoViewScaleState.zoomedOut;
|
||||
}
|
||||
336
mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart
Normal file
336
mobile/lib/widgets/photo_view/src/photo_view_wrappers.dart
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import 'package:flutter/widgets.dart';
|
||||
|
||||
import '../photo_view.dart';
|
||||
import 'core/photo_view_core.dart';
|
||||
import 'photo_view_default_widgets.dart';
|
||||
import 'utils/photo_view_utils.dart';
|
||||
|
||||
class ImageWrapper extends StatefulWidget {
|
||||
const ImageWrapper({
|
||||
super.key,
|
||||
required this.imageProvider,
|
||||
required this.loadingBuilder,
|
||||
required this.backgroundDecoration,
|
||||
required this.gaplessPlayback,
|
||||
required this.heroAttributes,
|
||||
required this.scaleStateChangedCallback,
|
||||
required this.enableRotation,
|
||||
required this.controller,
|
||||
required this.scaleStateController,
|
||||
required this.maxScale,
|
||||
required this.minScale,
|
||||
required this.initialScale,
|
||||
required this.basePosition,
|
||||
required this.scaleStateCycle,
|
||||
required this.onTapUp,
|
||||
required this.onTapDown,
|
||||
required this.onDragStart,
|
||||
required this.onDragEnd,
|
||||
required this.onDragUpdate,
|
||||
required this.onScaleEnd,
|
||||
required this.onLongPressStart,
|
||||
required this.outerSize,
|
||||
required this.gestureDetectorBehavior,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.errorBuilder,
|
||||
required this.enablePanAlways,
|
||||
required this.index,
|
||||
});
|
||||
|
||||
final ImageProvider imageProvider;
|
||||
final LoadingBuilder? loadingBuilder;
|
||||
final ImageErrorWidgetBuilder? errorBuilder;
|
||||
final BoxDecoration backgroundDecoration;
|
||||
final bool gaplessPlayback;
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
final bool enableRotation;
|
||||
final dynamic maxScale;
|
||||
final dynamic minScale;
|
||||
final dynamic initialScale;
|
||||
final PhotoViewControllerBase controller;
|
||||
final PhotoViewScaleStateController scaleStateController;
|
||||
final Alignment? basePosition;
|
||||
final ScaleStateCycle? scaleStateCycle;
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
final PhotoViewImageLongPressStartCallback? onLongPressStart;
|
||||
final Size outerSize;
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
final bool? tightMode;
|
||||
final FilterQuality? filterQuality;
|
||||
final bool? disableGestures;
|
||||
final bool? enablePanAlways;
|
||||
final int index;
|
||||
|
||||
@override
|
||||
createState() => _ImageWrapperState();
|
||||
}
|
||||
|
||||
class _ImageWrapperState extends State<ImageWrapper> {
|
||||
ImageStreamListener? _imageStreamListener;
|
||||
ImageStream? _imageStream;
|
||||
ImageChunkEvent? _loadingProgress;
|
||||
ImageInfo? _imageInfo;
|
||||
bool _loading = true;
|
||||
Size? _imageSize;
|
||||
Object? _lastException;
|
||||
StackTrace? _lastStack;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
super.dispose();
|
||||
_stopImageStream();
|
||||
}
|
||||
|
||||
@override
|
||||
void didChangeDependencies() {
|
||||
_resolveImage();
|
||||
super.didChangeDependencies();
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(ImageWrapper oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
if (widget.imageProvider != oldWidget.imageProvider) {
|
||||
_resolveImage();
|
||||
}
|
||||
}
|
||||
|
||||
// retrieve image from the provider
|
||||
void _resolveImage() {
|
||||
final ImageStream newStream = widget.imageProvider.resolve(
|
||||
const ImageConfiguration(),
|
||||
);
|
||||
_updateSourceStream(newStream);
|
||||
}
|
||||
|
||||
ImageStreamListener _getOrCreateListener() {
|
||||
void handleImageChunk(ImageChunkEvent event) {
|
||||
setState(() {
|
||||
_loadingProgress = event;
|
||||
_lastException = null;
|
||||
});
|
||||
}
|
||||
|
||||
void handleImageFrame(ImageInfo info, bool synchronousCall) {
|
||||
setupCB() {
|
||||
_imageSize = Size(
|
||||
info.image.width.toDouble(),
|
||||
info.image.height.toDouble(),
|
||||
);
|
||||
_loading = false;
|
||||
_imageInfo = _imageInfo;
|
||||
|
||||
_loadingProgress = null;
|
||||
_lastException = null;
|
||||
_lastStack = null;
|
||||
}
|
||||
|
||||
synchronousCall ? setupCB() : setState(setupCB);
|
||||
}
|
||||
|
||||
void handleError(dynamic error, StackTrace? stackTrace) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_lastException = error;
|
||||
_lastStack = stackTrace;
|
||||
});
|
||||
assert(() {
|
||||
if (widget.errorBuilder == null) {
|
||||
throw error;
|
||||
}
|
||||
return true;
|
||||
}());
|
||||
}
|
||||
|
||||
_imageStreamListener = ImageStreamListener(
|
||||
handleImageFrame,
|
||||
onChunk: handleImageChunk,
|
||||
onError: handleError,
|
||||
);
|
||||
|
||||
return _imageStreamListener!;
|
||||
}
|
||||
|
||||
void _updateSourceStream(ImageStream newStream) {
|
||||
if (_imageStream?.key == newStream.key) {
|
||||
return;
|
||||
}
|
||||
_imageStream?.removeListener(_imageStreamListener!);
|
||||
_imageStream = newStream;
|
||||
_imageStream!.addListener(_getOrCreateListener());
|
||||
}
|
||||
|
||||
void _stopImageStream() {
|
||||
_imageStream?.removeListener(_imageStreamListener!);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (_loading) {
|
||||
return _buildLoading(context);
|
||||
}
|
||||
|
||||
if (_lastException != null) {
|
||||
return _buildError(context);
|
||||
}
|
||||
|
||||
final scaleBoundaries = ScaleBoundaries(
|
||||
widget.minScale ?? 0.0,
|
||||
widget.maxScale ?? double.infinity,
|
||||
widget.initialScale ?? PhotoViewComputedScale.contained,
|
||||
widget.outerSize,
|
||||
_imageSize!,
|
||||
);
|
||||
|
||||
return PhotoViewCore(
|
||||
imageProvider: widget.imageProvider,
|
||||
backgroundDecoration: widget.backgroundDecoration,
|
||||
gaplessPlayback: widget.gaplessPlayback,
|
||||
enableRotation: widget.enableRotation,
|
||||
heroAttributes: widget.heroAttributes,
|
||||
basePosition: widget.basePosition ?? Alignment.center,
|
||||
controller: widget.controller,
|
||||
scaleStateController: widget.scaleStateController,
|
||||
scaleStateCycle: widget.scaleStateCycle ?? defaultScaleStateCycle,
|
||||
scaleBoundaries: scaleBoundaries,
|
||||
onTapUp: widget.onTapUp,
|
||||
onTapDown: widget.onTapDown,
|
||||
onDragStart: widget.onDragStart,
|
||||
onDragEnd: widget.onDragEnd,
|
||||
onDragUpdate: widget.onDragUpdate,
|
||||
onScaleEnd: widget.onScaleEnd,
|
||||
onLongPressStart: widget.onLongPressStart,
|
||||
gestureDetectorBehavior: widget.gestureDetectorBehavior,
|
||||
tightMode: widget.tightMode ?? false,
|
||||
filterQuality: widget.filterQuality ?? FilterQuality.none,
|
||||
disableGestures: widget.disableGestures ?? false,
|
||||
enablePanAlways: widget.enablePanAlways ?? false,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildLoading(BuildContext context) {
|
||||
if (widget.loadingBuilder != null) {
|
||||
return widget.loadingBuilder!(context, _loadingProgress, widget.index);
|
||||
}
|
||||
|
||||
return PhotoViewDefaultLoading(
|
||||
event: _loadingProgress,
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildError(
|
||||
BuildContext context,
|
||||
) {
|
||||
if (widget.errorBuilder != null) {
|
||||
return widget.errorBuilder!(context, _lastException!, _lastStack);
|
||||
}
|
||||
return PhotoViewDefaultError(
|
||||
decoration: widget.backgroundDecoration,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class CustomChildWrapper extends StatelessWidget {
|
||||
const CustomChildWrapper({
|
||||
super.key,
|
||||
this.child,
|
||||
required this.childSize,
|
||||
required this.backgroundDecoration,
|
||||
this.heroAttributes,
|
||||
this.scaleStateChangedCallback,
|
||||
required this.enableRotation,
|
||||
required this.controller,
|
||||
required this.scaleStateController,
|
||||
required this.maxScale,
|
||||
required this.minScale,
|
||||
required this.initialScale,
|
||||
required this.basePosition,
|
||||
required this.scaleStateCycle,
|
||||
this.onTapUp,
|
||||
this.onTapDown,
|
||||
this.onDragStart,
|
||||
this.onDragEnd,
|
||||
this.onDragUpdate,
|
||||
this.onScaleEnd,
|
||||
this.onLongPressStart,
|
||||
required this.outerSize,
|
||||
this.gestureDetectorBehavior,
|
||||
required this.tightMode,
|
||||
required this.filterQuality,
|
||||
required this.disableGestures,
|
||||
required this.enablePanAlways,
|
||||
});
|
||||
|
||||
final Widget? child;
|
||||
final Size? childSize;
|
||||
final Decoration backgroundDecoration;
|
||||
final PhotoViewHeroAttributes? heroAttributes;
|
||||
final ValueChanged<PhotoViewScaleState>? scaleStateChangedCallback;
|
||||
final bool enableRotation;
|
||||
|
||||
final PhotoViewControllerBase controller;
|
||||
final PhotoViewScaleStateController scaleStateController;
|
||||
|
||||
final dynamic maxScale;
|
||||
final dynamic minScale;
|
||||
final dynamic initialScale;
|
||||
|
||||
final Alignment? basePosition;
|
||||
final ScaleStateCycle? scaleStateCycle;
|
||||
final PhotoViewImageTapUpCallback? onTapUp;
|
||||
final PhotoViewImageTapDownCallback? onTapDown;
|
||||
final PhotoViewImageDragStartCallback? onDragStart;
|
||||
final PhotoViewImageDragEndCallback? onDragEnd;
|
||||
final PhotoViewImageDragUpdateCallback? onDragUpdate;
|
||||
final PhotoViewImageScaleEndCallback? onScaleEnd;
|
||||
final PhotoViewImageLongPressStartCallback? onLongPressStart;
|
||||
final Size outerSize;
|
||||
final HitTestBehavior? gestureDetectorBehavior;
|
||||
final bool? tightMode;
|
||||
final FilterQuality? filterQuality;
|
||||
final bool? disableGestures;
|
||||
final bool? enablePanAlways;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scaleBoundaries = ScaleBoundaries(
|
||||
minScale ?? 0.0,
|
||||
maxScale ?? double.infinity,
|
||||
initialScale ?? PhotoViewComputedScale.contained,
|
||||
outerSize,
|
||||
childSize ?? outerSize,
|
||||
);
|
||||
|
||||
return PhotoViewCore.customChild(
|
||||
customChild: child,
|
||||
backgroundDecoration: backgroundDecoration,
|
||||
enableRotation: enableRotation,
|
||||
heroAttributes: heroAttributes,
|
||||
controller: controller,
|
||||
scaleStateController: scaleStateController,
|
||||
scaleStateCycle: scaleStateCycle ?? defaultScaleStateCycle,
|
||||
basePosition: basePosition ?? Alignment.center,
|
||||
scaleBoundaries: scaleBoundaries,
|
||||
onTapUp: onTapUp,
|
||||
onTapDown: onTapDown,
|
||||
onDragStart: onDragStart,
|
||||
onDragEnd: onDragEnd,
|
||||
onDragUpdate: onDragUpdate,
|
||||
onScaleEnd: onScaleEnd,
|
||||
onLongPressStart: onLongPressStart,
|
||||
gestureDetectorBehavior: gestureDetectorBehavior,
|
||||
tightMode: tightMode ?? false,
|
||||
filterQuality: filterQuality ?? FilterQuality.none,
|
||||
disableGestures: disableGestures ?? false,
|
||||
enablePanAlways: enablePanAlways ?? false,
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue