Rewrite HomePhotos

This commit is contained in:
Ming Ming 2024-01-13 02:53:14 +08:00
parent 378da23360
commit 4950bcef8f
15 changed files with 1794 additions and 30 deletions

View file

@ -1,4 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/single_child_widget.dart';
mixin BlocLogger {
String? get tag => null;
@ -6,6 +8,28 @@ mixin BlocLogger {
bool Function(dynamic currentState, dynamic nextState)? get shouldLog => null;
}
class BlocListenerT<B extends StateStreamable<S>, S, T>
extends SingleChildStatelessWidget {
const BlocListenerT({
super.key,
required this.selector,
required this.listener,
});
@override
Widget buildWithChild(BuildContext context, Widget? child) {
return BlocListener<B, S>(
listenWhen: (previous, current) =>
selector(previous) != selector(current),
listener: (context, state) => listener(context, selector(state)),
child: child,
);
}
final BlocWidgetSelector<S, T> selector;
final void Function(BuildContext context, T state) listener;
}
/// Wrap around a string such that two strings with the same value will fail
/// the identical check
class StateMessage {
@ -25,3 +49,11 @@ extension EmitterExtension<State> on Emitter<State> {
onError: (_, __) {},
);
}
extension BlocExtension<E, S> on Bloc<E, S> {
void safeAdd(E event) {
if (!isClosed) {
add(event);
}
}
}

View file

@ -27,6 +27,15 @@ class PrefController {
value: value,
);
ValueStream<int> get homePhotosZoomLevel =>
_homePhotosZoomLevelController.stream;
Future<void> setHomePhotosZoomLevel(int value) => _set<int>(
controller: _homePhotosZoomLevelController,
setter: (pref, value) => pref.setHomePhotosZoomLevel(value),
value: value,
);
ValueStream<int> get albumBrowserZoomLevel =>
_albumBrowserZoomLevelController.stream;
@ -237,6 +246,8 @@ class PrefController {
final DiContainer _c;
late final _languageController =
BehaviorSubject.seeded(_langIdToAppLanguage(_c.pref.getLanguageOr(0)));
late final _homePhotosZoomLevelController =
BehaviorSubject.seeded(_c.pref.getHomePhotosZoomLevelOr(0));
late final _albumBrowserZoomLevelController =
BehaviorSubject.seeded(_c.pref.getAlbumBrowserZoomLevelOr(0));
late final _homeAlbumsSortController =

View file

@ -20,6 +20,7 @@ import 'package:nc_photos/theme/dimension.dart';
import 'package:nc_photos/use_case/import_potential_shared_album.dart';
import 'package:nc_photos/widget/home_collections.dart';
import 'package:nc_photos/widget/home_photos.dart';
import 'package:nc_photos/widget/home_photos2.dart';
import 'package:nc_photos/widget/home_search.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_common/or_null.dart';
@ -141,9 +142,7 @@ class _HomeState extends State<Home> with TickerProviderStateMixin {
Widget _buildPage(BuildContext context, int index) {
switch (index) {
case 0:
return HomePhotos(
account: widget.account,
);
return const HomePhotos2();
case 1:
return HomeSearch(

View file

@ -0,0 +1,124 @@
part of '../home_photos2.dart';
class _AppBar extends StatelessWidget {
const _AppBar();
@override
Widget build(BuildContext context) {
return _BlocBuilder(
buildWhen: (previous, current) => previous.isLoading != current.isLoading,
builder: (context, state) => HomeSliverAppBar(
account: context.bloc.account,
isShowProgressIcon: state.isLoading,
),
);
}
}
@npLog
class _SelectionAppBar extends StatelessWidget {
const _SelectionAppBar();
@override
Widget build(BuildContext context) {
return _BlocBuilder(
buildWhen: (previous, current) =>
previous.selectedItems != current.selectedItems,
builder: (context, state) => SelectionAppBar(
count: state.selectedItems.length,
onClosePressed: () {
context.addEvent(const _SetSelectedItems(items: {}));
},
actions: [
IconButton(
icon: const Icon(Icons.share_outlined),
tooltip: L10n.global().shareTooltip,
onPressed: () => _onSharePressed(context),
),
IconButton(
icon: const Icon(Icons.add),
tooltip: L10n.global().addItemToCollectionTooltip,
onPressed: () => _onAddPressed(context),
),
const _SelectionAppBarMenu(),
],
),
);
}
Future<void> _onAddPressed(BuildContext context) async {
final collection = await Navigator.of(context)
.pushNamed<Collection>(CollectionPicker.routeName);
if (collection == null) {
return;
}
context.bloc.add(_AddSelectedItemsToCollection(collection));
}
Future<void> _onSharePressed(BuildContext context) async {
final bloc = context.read<_Bloc>();
final selected = bloc.state.selectedItems
.whereType<_FileItem>()
.map((e) => e.file)
.toList();
if (selected.isEmpty) {
SnackBarManager().showSnackBar(SnackBar(
content: Text(L10n.global().shareSelectedEmptyNotification),
duration: k.snackBarDurationNormal,
));
return;
}
final result = await showDialog(
context: context,
builder: (context) => FileSharerDialog(
account: bloc.account,
files: selected,
),
);
if (result ?? false) {
bloc.add(const _SetSelectedItems(items: {}));
}
}
}
@npLog
class _SelectionAppBarMenu extends StatelessWidget {
const _SelectionAppBarMenu();
@override
Widget build(BuildContext context) {
return PopupMenuButton<_SelectionMenuOption>(
tooltip: MaterialLocalizations.of(context).moreButtonTooltip,
itemBuilder: (context) => [
PopupMenuItem(
value: _SelectionMenuOption.download,
child: Text(L10n.global().downloadTooltip),
),
PopupMenuItem(
value: _SelectionMenuOption.archive,
child: Text(L10n.global().archiveTooltip),
),
PopupMenuItem(
value: _SelectionMenuOption.delete,
child: Text(L10n.global().deleteTooltip),
),
],
onSelected: (option) {
switch (option) {
case _SelectionMenuOption.archive:
context.addEvent(const _ArchiveSelectedItems());
break;
case _SelectionMenuOption.delete:
context.addEvent(const _DeleteSelectedItems());
break;
case _SelectionMenuOption.download:
context.addEvent(const _DownloadSelectedItems());
break;
default:
_log.shout("[build] Unknown option: $option");
break;
}
},
);
}
}

View file

@ -0,0 +1,334 @@
part of '../home_photos2.dart';
@npLog
class _Bloc extends Bloc<_Event, _State> with BlocLogger {
_Bloc(
this._c, {
required this.account,
required this.controller,
required this.prefController,
required this.accountPrefController,
required this.collectionsController,
}) : super(_State.init(
zoom: prefController.homePhotosZoomLevel.value,
isEnableMemoryCollection:
accountPrefController.isEnableMemoryAlbum.value,
)) {
on<_LoadItems>(_onLoad);
on<_Reload>(_onReload);
on<_TransformItems>(_onTransformItems);
on<_OnItemTransformed>(_onOnItemTransformed);
on<_SetSelectedItems>(_onSetSelectedItems);
on<_AddSelectedItemsToCollection>(_onAddSelectedItemsToCollection);
on<_ArchiveSelectedItems>(_onArchiveSelectedItems);
on<_DeleteSelectedItems>(_onDeleteSelectedItems);
on<_DownloadSelectedItems>(_onDownloadSelectedItems);
on<_AddVisibleItem>(_onAddVisibleItem);
on<_RemoveVisibleItem>(_onRemoveVisibleItem);
on<_SetContentListMaxExtent>(_onSetContentListMaxExtent);
on<_StartScaling>(_onStartScaling);
on<_EndScaling>(_onEndScaling);
on<_SetScale>(_onSetScale);
on<_SetEnableMemoryCollection>(_onSetEnableMemoryCollection);
on<_SetError>(_onSetError);
_subscriptions
.add(accountPrefController.isEnableMemoryAlbum.listen((event) {
add(_SetEnableMemoryCollection(event));
}));
}
@override
Future<void> close() {
for (final s in _subscriptions) {
s.cancel();
}
return super.close();
}
@override
String get tag => _log.fullName;
@override
bool Function(dynamic, dynamic)? get shouldLog => (currentState, nextState) {
currentState = currentState as _State;
nextState = nextState as _State;
return currentState.scale == nextState.scale &&
currentState.visibleItems == nextState.visibleItems;
};
@override
void onError(Object error, StackTrace stackTrace) {
// we need this to prevent onError being triggered recursively
if (!isClosed && !_isHandlingError) {
_isHandlingError = true;
try {
add(_SetError(error, stackTrace));
} catch (_) {}
_isHandlingError = false;
}
super.onError(error, stackTrace);
}
Future<void> _onLoad(_LoadItems ev, Emitter<_State> emit) {
_log.info(ev);
return emit.forEach<FilesStreamEvent>(
controller.stream,
onData: (data) => state.copyWith(
files: data.data,
isLoading: data.hasNext || _itemTransformerQueue.isProcessing,
),
onError: (e, stackTrace) {
_log.severe("[_onLoad] Uncaught exception", e, stackTrace);
return state.copyWith(
isLoading: _itemTransformerQueue.isProcessing,
error: ExceptionEvent(e, stackTrace),
);
},
);
}
void _onReload(_Reload ev, Emitter<_State> emit) {
_log.info(ev);
unawaited(controller.reload());
}
void _onTransformItems(_TransformItems ev, Emitter<_State> emit) {
_log.info(ev);
_transformItems(ev.items);
emit(state.copyWith(isLoading: true));
}
void _onOnItemTransformed(_OnItemTransformed ev, Emitter<_State> emit) {
_log.info(ev);
emit(state.copyWith(
transformedItems: ev.items,
memoryCollections: ev.memoryCollections,
isLoading: _itemTransformerQueue.isProcessing,
));
}
void _onSetSelectedItems(_SetSelectedItems ev, Emitter<_State> emit) {
_log.info(ev);
emit(state.copyWith(selectedItems: ev.items));
}
void _onAddSelectedItemsToCollection(
_AddSelectedItemsToCollection ev, Emitter<_State> emit) {
_log.info(ev);
final selected = state.selectedItems;
_clearSelection(emit);
final selectedFiles =
selected.whereType<_FileItem>().map((e) => e.file).toList();
if (selectedFiles.isNotEmpty) {
final targetController = collectionsController.stream.value
.itemsControllerByCollection(ev.collection);
targetController.addFiles(selectedFiles).onError((e, stackTrace) {
if (e != null) {
add(_SetError(e, stackTrace));
}
});
}
}
void _onArchiveSelectedItems(_ArchiveSelectedItems ev, Emitter<_State> emit) {
_log.info(ev);
final selected = state.selectedItems;
_clearSelection(emit);
final selectedFiles =
selected.whereType<_FileItem>().map((e) => e.file).toList();
if (selectedFiles.isNotEmpty) {
controller.updateProperty(
selectedFiles,
isArchived: const OrNull(true),
errorBuilder: (fileIds) => _ArchiveFailedError(fileIds.length),
);
}
}
void _onDeleteSelectedItems(_DeleteSelectedItems ev, Emitter<_State> emit) {
_log.info(ev);
final selected = state.selectedItems;
_clearSelection(emit);
final selectedFiles =
selected.whereType<_FileItem>().map((e) => e.file).toList();
if (selectedFiles.isNotEmpty) {
controller.remove(
selectedFiles,
errorBuilder: (fileIds) => _RemoveFailedError(fileIds.length),
);
}
}
void _onDownloadSelectedItems(
_DownloadSelectedItems ev, Emitter<_State> emit) {
_log.info(ev);
final selected = state.selectedItems;
_clearSelection(emit);
final selectedFiles =
selected.whereType<_FileItem>().map((e) => e.file).toList();
if (selectedFiles.isNotEmpty) {
unawaited(DownloadHandler(_c).downloadFiles(account, selectedFiles));
}
}
void _onAddVisibleItem(_AddVisibleItem ev, Emitter<_State> emit) {
// _log.info(ev);
if (state.visibleItems.contains(ev.item)) {
return;
}
emit(state.copyWith(
visibleItems: state.visibleItems.added(ev.item),
));
}
void _onRemoveVisibleItem(_RemoveVisibleItem ev, Emitter<_State> emit) {
// _log.info(ev);
if (!state.visibleItems.contains(ev.item)) {
return;
}
emit(state.copyWith(
visibleItems: state.visibleItems.removed(ev.item),
));
}
void _onSetContentListMaxExtent(
_SetContentListMaxExtent ev, Emitter<_State> emit) {
_log.info(ev);
emit(state.copyWith(contentListMaxExtent: ev.value));
}
void _onStartScaling(_StartScaling ev, Emitter<_State> emit) {
_log.info(ev);
}
void _onEndScaling(_EndScaling ev, Emitter<_State> emit) {
_log.info(ev);
if (state.scale == null) {
return;
}
final int newZoom;
if (state.scale! >= 1.25) {
// scale up
newZoom = (state.zoom + 1).clamp(-1, 2);
} else if (state.scale! <= 0.75) {
newZoom = (state.zoom - 1).clamp(-1, 2);
} else {
newZoom = state.zoom;
}
emit(state.copyWith(
zoom: newZoom,
scale: null,
));
unawaited(prefController.setHomePhotosZoomLevel(newZoom));
}
void _onSetScale(_SetScale ev, Emitter<_State> emit) {
// _log.info(ev);
emit(state.copyWith(scale: ev.scale));
}
void _onSetEnableMemoryCollection(
_SetEnableMemoryCollection ev, Emitter<_State> emit) {
_log.info(ev);
emit(state.copyWith(isEnableMemoryCollection: ev.value));
}
void _onSetError(_SetError ev, Emitter<_State> emit) {
_log.info(ev);
emit(state.copyWith(error: ExceptionEvent(ev.error, ev.stackTrace)));
}
Future _transformItems(List<FileDescriptor> files) async {
_log.info("[_transformItems] Queue ${files.length} items");
_itemTransformerQueue.addJob(
_ItemTransformerArgument(
account: account,
files: files,
memoriesDayRange: prefController.memoriesRange.value,
locale: language_util.getSelectedLocale() ??
PlatformDispatcher.instance.locale,
),
_buildItem,
(result) {
if (!isClosed) {
add(_OnItemTransformed(result.items, result.memoryCollections));
}
},
);
}
void _clearSelection(Emitter<_State> emit) {
emit(state.copyWith(selectedItems: const {}));
}
final DiContainer _c;
final Account account;
final FilesController controller;
final PrefController prefController;
final AccountPrefController accountPrefController;
final CollectionsController collectionsController;
final _itemTransformerQueue =
ComputeQueue<_ItemTransformerArgument, _ItemTransformerResult>();
final _subscriptions = <StreamSubscription>[];
var _isHandlingError = false;
}
_ItemTransformerResult _buildItem(_ItemTransformerArgument arg) {
final sortedFiles = arg.files
.where((f) => f.fdIsArchived != true)
.sorted(compareFileDescriptorDateTimeDescending);
final dateHelper = photo_list_util.DateGroupHelper(isMonthOnly: false);
final today = clock.now();
final memoryCollectionHelper = photo_list_util.MemoryCollectionHelper(
arg.account,
today: today,
dayRange: arg.memoriesDayRange,
);
final transformed = <_Item>[];
for (int i = 0; i < sortedFiles.length; ++i) {
final file = sortedFiles[i];
final item = _buildSingleItem(arg.account, file);
if (item == null) {
continue;
}
final date = dateHelper.onFile(file);
if (date != null) {
transformed.add(_DateItem(date: date));
}
transformed.add(item);
memoryCollectionHelper.addFile(file);
}
final memoryCollections = memoryCollectionHelper
.build((year) => L10n.of(arg.locale).memoryAlbumName(today.year - year));
return _ItemTransformerResult(
items: transformed,
memoryCollections: memoryCollections,
);
}
_Item? _buildSingleItem(Account account, FileDescriptor file) {
if (file_util.isSupportedImageFormat(file)) {
return _PhotoItem(
file: file,
account: account,
);
} else if (file_util.isSupportedVideoFormat(file)) {
return _VideoItem(
file: file,
account: account,
);
} else {
_$__NpLog.log
.shout("[_buildSingleItem] Unsupported file format: ${file.fdMime}");
return null;
}
}

View file

@ -0,0 +1,218 @@
part of '../home_photos2.dart';
@genCopyWith
@toString
class _State {
const _State({
required this.files,
required this.isLoading,
required this.transformedItems,
required this.selectedItems,
required this.visibleItems,
required this.isEnableMemoryCollection,
required this.memoryCollections,
this.contentListMaxExtent,
required this.zoom,
this.scale,
this.error,
});
factory _State.init({
required bool isEnableMemoryCollection,
required int zoom,
}) =>
_State(
files: const [],
isLoading: false,
transformedItems: const [],
selectedItems: const {},
visibleItems: const {},
isEnableMemoryCollection: isEnableMemoryCollection,
memoryCollections: const [],
zoom: zoom,
);
@override
String toString() => _$toString();
final List<FileDescriptor> files;
final bool isLoading;
final List<_Item> transformedItems;
final Set<_Item> selectedItems;
final Set<_VisibleItem> visibleItems;
final bool isEnableMemoryCollection;
final List<Collection> memoryCollections;
final double? contentListMaxExtent;
final int zoom;
final double? scale;
final ExceptionEvent? error;
}
abstract class _Event {}
/// Load the files
@toString
class _LoadItems implements _Event {
const _LoadItems();
@override
String toString() => _$toString();
}
@toString
class _Reload implements _Event {
const _Reload();
@override
String toString() => _$toString();
}
/// Transform the file list (e.g., filtering, sorting, etc)
@toString
class _TransformItems implements _Event {
const _TransformItems(this.items);
@override
String toString() => _$toString();
final List<FileDescriptor> items;
}
@toString
class _OnItemTransformed implements _Event {
const _OnItemTransformed(this.items, this.memoryCollections);
@override
String toString() => _$toString();
final List<_Item> items;
final List<Collection> memoryCollections;
}
/// Set the currently selected items
@toString
class _SetSelectedItems implements _Event {
const _SetSelectedItems({
required this.items,
});
@override
String toString() => _$toString();
final Set<_Item> items;
}
@toString
class _AddSelectedItemsToCollection implements _Event {
const _AddSelectedItemsToCollection(this.collection);
@override
String toString() => _$toString();
final Collection collection;
}
@toString
class _ArchiveSelectedItems implements _Event {
const _ArchiveSelectedItems();
@override
String toString() => _$toString();
}
@toString
class _DeleteSelectedItems implements _Event {
const _DeleteSelectedItems();
@override
String toString() => _$toString();
}
@toString
class _DownloadSelectedItems implements _Event {
const _DownloadSelectedItems();
@override
String toString() => _$toString();
}
@toString
class _AddVisibleItem implements _Event {
const _AddVisibleItem(this.item);
@override
String toString() => _$toString();
final _VisibleItem item;
}
@toString
class _RemoveVisibleItem implements _Event {
const _RemoveVisibleItem(this.item);
@override
String toString() => _$toString();
final _VisibleItem item;
}
@toString
class _SetContentListMaxExtent implements _Event {
const _SetContentListMaxExtent(this.value);
@override
String toString() => _$toString();
final double? value;
}
@toString
class _StartScaling implements _Event {
const _StartScaling();
@override
String toString() => _$toString();
}
@toString
class _EndScaling implements _Event {
const _EndScaling();
@override
String toString() => _$toString();
}
@toString
class _SetScale implements _Event {
const _SetScale(this.scale);
@override
String toString() => _$toString();
final double scale;
}
@toString
class _SetEnableMemoryCollection implements _Event {
const _SetEnableMemoryCollection(this.value);
@override
String toString() => _$toString();
final bool value;
}
@toString
class _SetError implements _Event {
const _SetError(this.error, [this.stackTrace]);
@override
String toString() => _$toString();
final Object error;
final StackTrace? stackTrace;
}

View file

@ -0,0 +1,167 @@
part of '../home_photos2.dart';
abstract class _Item implements SelectableItemMetadata {
const _Item();
StaggeredTile get staggeredTile;
Widget buildWidget(BuildContext context);
}
abstract class _FileItem extends _Item {
const _FileItem({
required this.file,
});
@override
bool get isSelectable => true;
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is _FileItem && file.compareServerIdentity(other.file));
@override
int get hashCode => file.identityHashCode;
final FileDescriptor file;
}
class _PhotoItem extends _FileItem {
_PhotoItem({
required super.file,
required this.account,
}) : _previewUrl = NetworkRectThumbnail.imageUrlForFile(account, file);
@override
StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1);
@override
Widget buildWidget(BuildContext context) {
return PhotoListImage(
account: account,
previewUrl: _previewUrl,
isGif: file.fdMime == "image/gif",
isFavorite: file.fdIsFavorite,
heroKey: flutter_util.getImageHeroTag(file),
);
}
final Account account;
final String _previewUrl;
}
class _VideoItem extends _FileItem {
_VideoItem({
required super.file,
required this.account,
}) : _previewUrl = NetworkRectThumbnail.imageUrlForFile(account, file);
@override
StaggeredTile get staggeredTile => const StaggeredTile.count(1, 1);
@override
Widget buildWidget(BuildContext context) {
return PhotoListVideo(
account: account,
previewUrl: _previewUrl,
isFavorite: file.fdIsFavorite,
);
}
final Account account;
final String _previewUrl;
}
class _DateItem extends _Item {
const _DateItem({
required this.date,
});
@override
bool get isSelectable => false;
@override
StaggeredTile get staggeredTile => const StaggeredTile.extent(99, 32);
@override
Widget buildWidget(BuildContext context) {
return PhotoListDate(
date: date,
);
}
final DateTime date;
}
class _ItemTransformerArgument {
const _ItemTransformerArgument({
required this.account,
required this.files,
required this.memoriesDayRange,
required this.locale,
});
final Account account;
final List<FileDescriptor> files;
final int memoriesDayRange;
final Locale locale;
}
class _ItemTransformerResult {
const _ItemTransformerResult({
required this.items,
required this.memoryCollections,
});
final List<_Item> items;
final List<Collection> memoryCollections;
}
class _MemoryCollectionItem {
static const width = 96.0;
static const height = width * 1.15;
}
class _VisibleItem implements Comparable<_VisibleItem> {
const _VisibleItem(this.index, this.item);
@override
bool operator ==(Object? other) =>
other is _VisibleItem && index == other.index;
@override
int compareTo(_VisibleItem other) => index.compareTo(other.index);
@override
int get hashCode => index.hashCode;
final int index;
final _Item item;
}
enum _SelectionMenuOption {
archive,
delete,
download,
}
@toString
class _ArchiveFailedError implements Exception {
const _ArchiveFailedError(this.count);
@override
String toString() => _$toString();
final int count;
}
@toString
class _RemoveFailedError implements Exception {
const _RemoveFailedError(this.count);
@override
String toString() => _$toString();
final int count;
}

View file

@ -0,0 +1,260 @@
part of '../home_photos2.dart';
class _ContentList extends StatelessWidget {
const _ContentList();
@override
Widget build(BuildContext context) {
return _BlocSelector<int>(
selector: (state) => state.zoom,
builder: (context, zoom) => _ContentListBody(
maxCrossAxisExtent: photo_list_util.getThumbSize(zoom).toDouble(),
isNeedVisibilityInfo: true,
),
);
}
}
class _ScalingList extends StatelessWidget {
const _ScalingList();
@override
Widget build(BuildContext context) {
return _BlocBuilder(
buildWhen: (previous, current) => previous.scale != current.scale,
builder: (context, state) {
if (state.scale == null) {
return const SizedBox.shrink();
}
int nextZoom;
if (state.scale! > 1) {
nextZoom = state.zoom + 1;
} else {
nextZoom = state.zoom - 1;
}
nextZoom = nextZoom.clamp(-1, 2);
return _ContentListBody(
maxCrossAxisExtent: photo_list_util.getThumbSize(nextZoom).toDouble(),
isNeedVisibilityInfo: false,
);
},
);
}
}
@npLog
class _ContentListBody extends StatelessWidget {
const _ContentListBody({
required this.maxCrossAxisExtent,
required this.isNeedVisibilityInfo,
});
@override
Widget build(BuildContext context) {
return _BlocBuilder(
buildWhen: (previous, current) =>
previous.transformedItems != current.transformedItems ||
previous.selectedItems != current.selectedItems,
builder: (context, state) => SelectableItemList<_Item>(
maxCrossAxisExtent: maxCrossAxisExtent,
items: state.transformedItems,
itemBuilder: (context, index, item) {
final w = item.buildWidget(context);
if (isNeedVisibilityInfo) {
return VisibilityDetector(
key: Key("${_log.fullName}.$index"),
onVisibilityChanged: (info) {
if (context.mounted) {
if (info.visibleFraction >= 0.2) {
context
.addEvent(_AddVisibleItem(_VisibleItem(index, item)));
} else {
context.addEvent(
_RemoveVisibleItem(_VisibleItem(index, item)));
}
}
},
child: w,
);
} else {
return w;
}
},
staggeredTileBuilder: (_, item) => item.staggeredTile,
selectedItems: state.selectedItems,
onSelectionChange: (_, selected) {
context.addEvent(_SetSelectedItems(items: selected.cast()));
},
onItemTap: (context, index, _) {
if (state.transformedItems[index] is! _FileItem) {
return;
}
final actualIndex = index -
state.transformedItems
.sublist(0, index)
.where((e) => e is! _FileItem)
.length;
Navigator.of(context).pushNamed(
Viewer.routeName,
arguments: ViewerArguments(
context.bloc.account,
state.transformedItems
.whereType<_FileItem>()
.map((e) => e.file)
.toList(),
actualIndex,
),
);
},
onMaxExtentChange: (value) {
context.addEvent(_SetContentListMaxExtent(value));
},
),
);
}
final double maxCrossAxisExtent;
final bool isNeedVisibilityInfo;
}
class _MemoryCollectionList extends StatelessWidget {
const _MemoryCollectionList();
@override
Widget build(BuildContext context) {
return SliverToBoxAdapter(
child: SizedBox(
height: _MemoryCollectionItem.height,
child: _BlocSelector<List<Collection>>(
selector: (state) => state.memoryCollections,
builder: (context, memoryCollections) => ListView.separated(
scrollDirection: Axis.horizontal,
padding: const EdgeInsets.symmetric(horizontal: 8),
itemCount: memoryCollections.length,
itemBuilder: (context, index) {
final c = memoryCollections[index];
return _MemoryCollectionItemView(
coverUrl: c.getCoverUrl(
k.photoThumbSize,
k.photoThumbSize,
isKeepAspectRatio: true,
),
label: c.name,
onTap: () {
Navigator.of(context).pushNamed(
CollectionBrowser.routeName,
arguments: CollectionBrowserArguments(c),
);
},
);
},
separatorBuilder: (context, index) => const SizedBox(width: 8),
),
),
),
);
}
}
class _MemoryCollectionItemView extends StatelessWidget {
static const width = 96.0;
static const height = width * 1.15;
const _MemoryCollectionItemView({
required this.coverUrl,
required this.label,
this.onTap,
});
@override
Widget build(BuildContext context) {
return Align(
alignment: AlignmentDirectional.topStart,
child: ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: width,
height: height,
child: Stack(
fit: StackFit.expand,
children: [
PhotoListImage(
account: context.bloc.account,
previewUrl: coverUrl,
padding: const EdgeInsets.all(0),
),
Positioned.fill(
child: Container(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.center,
end: Alignment.bottomCenter,
colors: [Colors.transparent, Colors.black87],
),
),
),
),
Positioned.fill(
child: Align(
alignment: AlignmentDirectional.bottomStart,
child: Padding(
padding: const EdgeInsets.all(4),
child: Text(
label,
style: Theme.of(context).textTheme.labelLarge!.copyWith(
color: Theme.of(context).onDarkSurface,
),
),
),
),
),
if (onTap != null)
Positioned.fill(
child: Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
),
),
),
],
),
),
),
);
}
final String? coverUrl;
final String label;
final VoidCallback? onTap;
}
class _ScrollLabel extends StatelessWidget {
const _ScrollLabel();
@override
Widget build(BuildContext context) {
return _BlocSelector<Set<_VisibleItem>>(
selector: (state) => state.visibleItems,
builder: (context, visibleItems) {
final firstVisibleItem =
visibleItems.sorted().firstWhereOrNull((e) => e.item is _FileItem);
final date = firstVisibleItem?.item.as<_FileItem>()?.file.fdDateTime;
if (date == null) {
return const SizedBox.shrink();
}
final text = DateFormat(DateFormat.YEAR_ABBR_MONTH,
Localizations.localeOf(context).languageCode)
.format(date.toLocal());
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: DefaultTextStyle(
style: Theme.of(context).textTheme.titleMedium!.copyWith(
color: Theme.of(context).colorScheme.onInverseSurface),
child: Text(text),
),
);
},
);
}
}

View file

@ -0,0 +1,325 @@
import 'dart:async';
import 'package:clock/clock.dart';
import 'package:collection/collection.dart';
import 'package:copy_with/copy_with.dart';
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_staggered_grid_view/flutter_staggered_grid_view.dart';
import 'package:intl/intl.dart';
import 'package:kiwi/kiwi.dart';
import 'package:logging/logging.dart';
import 'package:nc_photos/account.dart';
import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/bloc_util.dart';
import 'package:nc_photos/controller/account_controller.dart';
import 'package:nc_photos/controller/account_pref_controller.dart';
import 'package:nc_photos/controller/collections_controller.dart';
import 'package:nc_photos/controller/files_controller.dart';
import 'package:nc_photos/controller/pref_controller.dart';
import 'package:nc_photos/di_container.dart';
import 'package:nc_photos/download_handler.dart';
import 'package:nc_photos/entity/collection.dart';
import 'package:nc_photos/entity/file_descriptor.dart';
import 'package:nc_photos/entity/file_util.dart' as file_util;
import 'package:nc_photos/exception_event.dart';
import 'package:nc_photos/exception_util.dart' as exception_util;
import 'package:nc_photos/flutter_util.dart' as flutter_util;
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/language_util.dart' as language_util;
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/theme.dart';
import 'package:nc_photos/theme/dimension.dart';
import 'package:nc_photos/widget/collection_browser.dart';
import 'package:nc_photos/widget/collection_picker.dart';
import 'package:nc_photos/widget/file_sharer_dialog.dart';
import 'package:nc_photos/widget/finger_listener.dart';
import 'package:nc_photos/widget/home_app_bar.dart';
import 'package:nc_photos/widget/navigation_bar_blur_filter.dart';
import 'package:nc_photos/widget/network_thumbnail.dart';
import 'package:nc_photos/widget/photo_list_item.dart';
import 'package:nc_photos/widget/photo_list_util.dart' as photo_list_util;
import 'package:nc_photos/widget/selectable_item_list.dart';
import 'package:nc_photos/widget/selection_app_bar.dart';
import 'package:nc_photos/widget/sliver_visualized_scale.dart';
import 'package:nc_photos/widget/viewer.dart';
import 'package:np_async/np_async.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_collection/np_collection.dart';
import 'package:np_common/object_util.dart';
import 'package:np_common/or_null.dart';
import 'package:to_string/to_string.dart';
import 'package:visibility_detector/visibility_detector.dart';
part 'home_photos/app_bar.dart';
part 'home_photos/bloc.dart';
part 'home_photos/state_event.dart';
part 'home_photos/type.dart';
part 'home_photos/view.dart';
part 'home_photos2.g.dart';
class HomePhotos2 extends StatelessWidget {
const HomePhotos2({super.key});
@override
Widget build(BuildContext context) {
final accountController = context.read<AccountController>();
return BlocProvider(
create: (_) => _Bloc(
KiwiContainer().resolve(),
account: accountController.account,
controller: accountController.filesController,
prefController: context.read(),
accountPrefController: accountController.accountPrefController,
collectionsController: accountController.collectionsController,
),
child: const _WrappedHomePhotos(),
);
}
}
class _WrappedHomePhotos extends StatefulWidget {
const _WrappedHomePhotos();
@override
State<StatefulWidget> createState() => _WrappedHomePhotosState();
}
@npLog
class _WrappedHomePhotosState extends State<_WrappedHomePhotos> {
@override
void initState() {
super.initState();
_bloc.add(const _LoadItems());
}
@override
Widget build(BuildContext context) {
return VisibilityDetector(
key: _key,
onVisibilityChanged: (info) {
final isVisible = info.visibleFraction >= 0.2;
if (isVisible != _isVisible) {
if (mounted) {
setState(() {
_isVisible = isVisible;
});
}
}
},
child: MultiBlocListener(
listeners: [
_BlocListenerT<List<FileDescriptor>>(
selector: (state) => state.files,
listener: (context, files) {
_bloc.add(_TransformItems(files));
},
),
_BlocListenerT<ExceptionEvent?>(
selector: (state) => state.error,
listener: (context, error) {
if (error != null && _isVisible == true) {
final String content;
if (error.error is _ArchiveFailedError) {
content = L10n.global().archiveSelectedFailureNotification(
(error.error as _ArchiveFailedError).count);
} else if (error.error is _RemoveFailedError) {
content = L10n.global().deleteSelectedFailureNotification(
(error.error as _RemoveFailedError).count);
} else {
content = exception_util.toUserString(error.error);
}
SnackBarManager().showSnackBar(SnackBar(
content: Text(content),
duration: k.snackBarDurationNormal,
));
}
},
),
],
child: FingerListener(
onFingerChanged: (finger) {
setState(() {
_finger = finger;
});
},
child: GestureDetector(
onScaleStart: (_) {
_bloc.add(const _StartScaling());
},
onScaleUpdate: (details) {
_bloc.add(_SetScale(details.scale));
},
onScaleEnd: (_) {
_bloc.add(const _EndScaling());
},
child: LayoutBuilder(
builder: (context, constraints) => _BlocBuilder(
buildWhen: (previous, current) =>
previous.contentListMaxExtent !=
current.contentListMaxExtent ||
(previous.isEnableMemoryCollection &&
previous.memoryCollections.isNotEmpty) !=
(current.isEnableMemoryCollection &&
current.memoryCollections.isNotEmpty),
builder: (context, state) {
final scrollExtent = _getScrollViewExtent(
context: context,
constraints: constraints,
hasMemoryCollection: state.isEnableMemoryCollection &&
state.memoryCollections.isNotEmpty,
contentListMaxExtent: state.contentListMaxExtent,
);
return Stack(
children: [
DraggableScrollbar.semicircle(
controller: _scrollController,
overrideMaxScrollExtent: scrollExtent,
// status bar + app bar
topOffset: _getAppBarExtent(context),
bottomOffset:
AppDimension.of(context).homeBottomAppBarHeight,
labelTextBuilder: (_) => const _ScrollLabel(),
labelPadding:
const EdgeInsets.symmetric(horizontal: 40),
backgroundColor: Theme.of(context).elevate(
Theme.of(context).colorScheme.inverseSurface, 3),
heightScrollThumb: 60,
child: ScrollConfiguration(
behavior: ScrollConfiguration.of(context)
.copyWith(scrollbars: false),
child: RefreshIndicator(
onRefresh: () async {
_bloc.add(const _Reload());
await _bloc.stream.first;
},
child: CustomScrollView(
controller: _scrollController,
physics: _finger >= 2
? const NeverScrollableScrollPhysics()
: null,
slivers: [
_BlocSelector<bool>(
selector: (state) =>
state.selectedItems.isEmpty,
builder: (context, isEmpty) => isEmpty
? const _AppBar()
: const _SelectionAppBar(),
),
_BlocBuilder(
buildWhen: (previous, current) =>
(previous.isEnableMemoryCollection &&
previous
.memoryCollections.isNotEmpty) !=
(current.isEnableMemoryCollection &&
current.memoryCollections.isNotEmpty),
builder: (context, state) {
if (state.isEnableMemoryCollection &&
state.memoryCollections.isNotEmpty) {
return const _MemoryCollectionList();
} else {
return const SliverToBoxAdapter();
}
},
),
_BlocSelector<double?>(
selector: (state) => state.scale,
builder: (context, scale) =>
SliverTransitionedScale(
scale: scale,
baseSliver: const _ContentList(),
overlaySliver: const _ScalingList(),
),
),
SliverToBoxAdapter(
child: SizedBox(
height: AppDimension.of(context)
.homeBottomAppBarHeight,
),
),
],
),
),
),
),
Align(
alignment: Alignment.bottomCenter,
child: NavigationBarBlurFilter(
height:
AppDimension.of(context).homeBottomAppBarHeight,
),
),
],
);
},
),
),
),
),
),
);
}
/// Return the estimated scroll extent of the custom scroll view, or null
double? _getScrollViewExtent({
required BuildContext context,
required BoxConstraints constraints,
required bool hasMemoryCollection,
required double? contentListMaxExtent,
}) {
if (contentListMaxExtent != null && constraints.hasBoundedHeight) {
final appBarExtent = _getAppBarExtent(context);
final bottomAppBarExtent =
AppDimension.of(context).homeBottomAppBarHeight;
// final metadataTaskHeaderExtent = _web?.getHeaderHeight() ?? 0;
final smartAlbumListHeight =
hasMemoryCollection ? _MemoryCollectionItem.height : 0;
// scroll extent = list height - widget viewport height
// + sliver app bar height + bottom app bar height
// + metadata task header height + smart album list height
final scrollExtent = contentListMaxExtent -
constraints.maxHeight +
appBarExtent +
bottomAppBarExtent +
// metadataTaskHeaderExtent +
smartAlbumListHeight;
_log.info("[_getScrollViewExtent] $contentListMaxExtent "
"- ${constraints.maxHeight} "
"+ $appBarExtent "
"+ $bottomAppBarExtent "
// "+ $metadataTaskHeaderExtent "
"+ $smartAlbumListHeight "
"= $scrollExtent");
return scrollExtent;
} else {
return null;
}
}
double _getAppBarExtent(BuildContext context) =>
MediaQuery.of(context).padding.top + kToolbarHeight;
late final _bloc = context.bloc;
final _key = GlobalKey();
final _scrollController = ScrollController();
bool? _isVisible;
var _finger = 0;
}
typedef _BlocBuilder = BlocBuilder<_Bloc, _State>;
// typedef _BlocListener = BlocListener<_Bloc, _State>;
typedef _BlocListenerT<T> = BlocListenerT<_Bloc, _State, T>;
typedef _BlocSelector<T> = BlocSelector<_Bloc, _State, T>;
extension on BuildContext {
_Bloc get bloc => read<_Bloc>();
// _State get state => bloc.state;
void addEvent(_Event event) => bloc.add(event);
}
@npLog
// ignore: camel_case_types
class __ {}

View file

@ -0,0 +1,262 @@
// GENERATED CODE - DO NOT MODIFY BY HAND
part of 'home_photos2.dart';
// **************************************************************************
// CopyWithLintRuleGenerator
// **************************************************************************
// ignore_for_file: library_private_types_in_public_api, duplicate_ignore
// **************************************************************************
// CopyWithGenerator
// **************************************************************************
abstract class $_StateCopyWithWorker {
_State call(
{List<FileDescriptor>? files,
bool? isLoading,
List<_Item>? transformedItems,
Set<_Item>? selectedItems,
Set<_VisibleItem>? visibleItems,
bool? isEnableMemoryCollection,
List<Collection>? memoryCollections,
double? contentListMaxExtent,
int? zoom,
double? scale,
ExceptionEvent? error});
}
class _$_StateCopyWithWorkerImpl implements $_StateCopyWithWorker {
_$_StateCopyWithWorkerImpl(this.that);
@override
_State call(
{dynamic files,
dynamic isLoading,
dynamic transformedItems,
dynamic selectedItems,
dynamic visibleItems,
dynamic isEnableMemoryCollection,
dynamic memoryCollections,
dynamic contentListMaxExtent = copyWithNull,
dynamic zoom,
dynamic scale = copyWithNull,
dynamic error = copyWithNull}) {
return _State(
files: files as List<FileDescriptor>? ?? that.files,
isLoading: isLoading as bool? ?? that.isLoading,
transformedItems:
transformedItems as List<_Item>? ?? that.transformedItems,
selectedItems: selectedItems as Set<_Item>? ?? that.selectedItems,
visibleItems: visibleItems as Set<_VisibleItem>? ?? that.visibleItems,
isEnableMemoryCollection:
isEnableMemoryCollection as bool? ?? that.isEnableMemoryCollection,
memoryCollections:
memoryCollections as List<Collection>? ?? that.memoryCollections,
contentListMaxExtent: contentListMaxExtent == copyWithNull
? that.contentListMaxExtent
: contentListMaxExtent as double?,
zoom: zoom as int? ?? that.zoom,
scale: scale == copyWithNull ? that.scale : scale as double?,
error: error == copyWithNull ? that.error : error as ExceptionEvent?);
}
final _State that;
}
extension $_StateCopyWith on _State {
$_StateCopyWithWorker get copyWith => _$copyWith;
$_StateCopyWithWorker get _$copyWith => _$_StateCopyWithWorkerImpl(this);
}
// **************************************************************************
// NpLogGenerator
// **************************************************************************
extension _$_WrappedHomePhotosStateNpLog on _WrappedHomePhotosState {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("widget.home_photos2._WrappedHomePhotosState");
}
extension _$__NpLog on __ {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("widget.home_photos2.__");
}
extension _$_SelectionAppBarNpLog on _SelectionAppBar {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("widget.home_photos2._SelectionAppBar");
}
extension _$_SelectionAppBarMenuNpLog on _SelectionAppBarMenu {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("widget.home_photos2._SelectionAppBarMenu");
}
extension _$_BlocNpLog on _Bloc {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("widget.home_photos2._Bloc");
}
extension _$_ContentListBodyNpLog on _ContentListBody {
// ignore: unused_element
Logger get _log => log;
static final log = Logger("widget.home_photos2._ContentListBody");
}
// **************************************************************************
// ToStringGenerator
// **************************************************************************
extension _$_StateToString on _State {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_State {files: [length: ${files.length}], isLoading: $isLoading, transformedItems: [length: ${transformedItems.length}], selectedItems: {length: ${selectedItems.length}}, visibleItems: {length: ${visibleItems.length}}, isEnableMemoryCollection: $isEnableMemoryCollection, memoryCollections: [length: ${memoryCollections.length}], contentListMaxExtent: ${contentListMaxExtent == null ? null : "${contentListMaxExtent!.toStringAsFixed(3)}"}, zoom: $zoom, scale: ${scale == null ? null : "${scale!.toStringAsFixed(3)}"}, error: $error}";
}
}
extension _$_LoadItemsToString on _LoadItems {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_LoadItems {}";
}
}
extension _$_ReloadToString on _Reload {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_Reload {}";
}
}
extension _$_TransformItemsToString on _TransformItems {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_TransformItems {items: [length: ${items.length}]}";
}
}
extension _$_OnItemTransformedToString on _OnItemTransformed {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_OnItemTransformed {items: [length: ${items.length}], memoryCollections: [length: ${memoryCollections.length}]}";
}
}
extension _$_SetSelectedItemsToString on _SetSelectedItems {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetSelectedItems {items: {length: ${items.length}}}";
}
}
extension _$_AddSelectedItemsToCollectionToString
on _AddSelectedItemsToCollection {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_AddSelectedItemsToCollection {collection: $collection}";
}
}
extension _$_ArchiveSelectedItemsToString on _ArchiveSelectedItems {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_ArchiveSelectedItems {}";
}
}
extension _$_DeleteSelectedItemsToString on _DeleteSelectedItems {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_DeleteSelectedItems {}";
}
}
extension _$_DownloadSelectedItemsToString on _DownloadSelectedItems {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_DownloadSelectedItems {}";
}
}
extension _$_AddVisibleItemToString on _AddVisibleItem {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_AddVisibleItem {item: $item}";
}
}
extension _$_RemoveVisibleItemToString on _RemoveVisibleItem {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_RemoveVisibleItem {item: $item}";
}
}
extension _$_SetContentListMaxExtentToString on _SetContentListMaxExtent {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetContentListMaxExtent {value: ${value == null ? null : "${value!.toStringAsFixed(3)}"}}";
}
}
extension _$_StartScalingToString on _StartScaling {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_StartScaling {}";
}
}
extension _$_EndScalingToString on _EndScaling {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_EndScaling {}";
}
}
extension _$_SetScaleToString on _SetScale {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetScale {scale: ${scale.toStringAsFixed(3)}}";
}
}
extension _$_SetEnableMemoryCollectionToString on _SetEnableMemoryCollection {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetEnableMemoryCollection {value: $value}";
}
}
extension _$_SetErrorToString on _SetError {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_SetError {error: $error, stackTrace: $stackTrace}";
}
}
extension _$_ArchiveFailedErrorToString on _ArchiveFailedError {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_ArchiveFailedError {count: $count}";
}
}
extension _$_RemoveFailedErrorToString on _RemoveFailedError {
String _$toString() {
// ignore: unnecessary_string_interpolations
return "_RemoveFailedError {count: $count}";
}
}

View file

@ -8,6 +8,7 @@ import 'package:nc_photos/app_localizations.dart';
import 'package:nc_photos/k.dart' as k;
import 'package:nc_photos/session_storage.dart';
import 'package:nc_photos/snack_bar_manager.dart';
import 'package:nc_photos/widget/measurable_item_list.dart';
import 'package:nc_photos/widget/selectable.dart';
import 'package:np_codegen/np_codegen.dart';
import 'package:np_collection/np_collection.dart';
@ -36,6 +37,7 @@ class SelectableItemList<T extends SelectableItemMetadata>
this.indicatorAlignment = Alignment.topLeft,
this.onItemTap,
this.onSelectionChange,
this.onMaxExtentChange,
});
@override
@ -53,6 +55,7 @@ class SelectableItemList<T extends SelectableItemMetadata>
final void Function(BuildContext context, int index, T metadata)? onItemTap;
final void Function(BuildContext context, Set<T> selected)? onSelectionChange;
final ValueChanged<double?>? onMaxExtentChange;
}
@npLog
@ -90,32 +93,46 @@ class _SelectableItemListState<T extends SelectableItemMetadata>
}
Widget _buildBody(BuildContext context) {
return SliverStaggeredGrid.extentBuilder(
key: ObjectKey(widget.maxCrossAxisExtent),
maxCrossAxisExtent: widget.maxCrossAxisExtent,
itemCount: widget.items.length,
itemBuilder: (context, i) {
final meta = widget.items[i];
if (meta.isSelectable) {
return Selectable(
isSelected: widget.selectedItems.contains(meta),
iconSize: 32,
childBorderRadius:
widget.childBorderRadius ?? BorderRadius.circular(24),
indicatorAlignment: widget.indicatorAlignment,
onTap: _isSelecting
? () => _onItemSelect(context, i, meta)
: () => _onItemTap(context, i, meta),
onLongPress: () => _onItemLongPress(i, meta),
child: widget.itemBuilder(context, i, meta),
);
} else {
return widget.itemBuilder(context, i, meta);
}
},
staggeredTileBuilder: (i) =>
widget.staggeredTileBuilder(i, widget.items[i]),
);
if (widget.onMaxExtentChange != null) {
return MeasurableItemList(
key: _listKey,
maxCrossAxisExtent: widget.maxCrossAxisExtent,
itemCount: widget.items.length,
itemBuilder: _buildItem,
staggeredTileBuilder: (i) =>
widget.staggeredTileBuilder(i, widget.items[i]),
onMaxExtentChanged: widget.onMaxExtentChange,
);
} else {
return SliverStaggeredGrid.extentBuilder(
key: ObjectKey(widget.maxCrossAxisExtent),
maxCrossAxisExtent: widget.maxCrossAxisExtent,
itemCount: widget.items.length,
itemBuilder: _buildItem,
staggeredTileBuilder: (i) =>
widget.staggeredTileBuilder(i, widget.items[i]),
);
}
}
Widget _buildItem(BuildContext context, int index) {
final meta = widget.items[index];
if (meta.isSelectable) {
return Selectable(
isSelected: widget.selectedItems.contains(meta),
iconSize: 32,
childBorderRadius:
widget.childBorderRadius ?? BorderRadius.circular(24),
indicatorAlignment: widget.indicatorAlignment,
onTap: _isSelecting
? () => _onItemSelect(context, index, meta)
: () => _onItemTap(context, index, meta),
onLongPress: () => _onItemLongPress(index, meta),
child: widget.itemBuilder(context, index, meta),
);
} else {
return widget.itemBuilder(context, index, meta);
}
}
void _onItemTap(BuildContext context, int index, T metadata) {
@ -214,6 +231,12 @@ class _SelectableItemListState<T extends SelectableItemMetadata>
"[_remapSelected] ${widget.selectedItems.length - newSelected.length} items not found in the new list");
}
widget.onSelectionChange?.call(context, newSelected);
// TODO remap lastSelectPosition
_log.info("[_remapSelected] updateListHeight: list item changed");
WidgetsBinding.instance.addPostFrameCallback((_) =>
(_listKey.currentState as MeasurableItemListState?)
?.updateListHeight());
}
bool get _isSelecting => widget.selectedItems.isNotEmpty;
@ -222,4 +245,6 @@ class _SelectableItemListState<T extends SelectableItemMetadata>
final _keyboardFocus = FocusNode();
int? _lastSelectPosition;
bool _isKeyboardRangeSelecting = false;
final _listKey = GlobalKey();
}

View file

@ -1254,7 +1254,7 @@ packages:
source: hosted
version: "2.1.0"
provider:
dependency: transitive
dependency: "direct main"
description:
name: provider
sha256: cdbe7530b12ecd9eb455bdaa2fcb8d4dad22e80b8afb4798b41479d5ce26847f

View file

@ -136,6 +136,7 @@ dependencies:
page_view_indicators: ^2.0.0
path: ^1.8.0
path_provider: ^2.0.15
provider: any
rxdart: ^0.27.7
screen_brightness: ^0.2.2
shared_preferences: ^2.0.8

View file

@ -5,3 +5,4 @@ export 'src/iterator_extension.dart';
export 'src/list_extension.dart';
export 'src/list_util.dart';
export 'src/map_extension.dart';
export 'src/set_util.dart';

View file

@ -0,0 +1,5 @@
extension SetExtension<T> on Set<T> {
Set<T> added(T element) => toSet()..add(element);
Set<T> removed(T element) => toSet()..remove(element);
}