refactor(mobile): widgets (#9291)

* refactor(mobile): widgets

* update
This commit is contained in:
Alex 2024-05-06 23:04:21 -05:00 committed by GitHub
parent 7520ffd6c3
commit 5806a3ce25
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
203 changed files with 318 additions and 318 deletions

View 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;
}

View 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);
}

View 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(),
),
],
),
);
}
}

View 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(),
),
],
);
}
}

View file

@ -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,
),
),
),
),
),
);
}
}

View 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,
),
),
);
}
}

View 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,
),
),
);
}
}

View 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,
),
),
],
),
);
}
}

View 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);
}
}

View 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(),
);
}
}

View 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,
),
],
),
);
}
}

View 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(),
],
),
);
}
}

View 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,
),
),
);
}
}

View 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,
);
}