294 lines
9.3 KiB
Dart
294 lines
9.3 KiB
Dart
import 'dart:math';
|
|
|
|
import 'package:collection/collection.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_bloc/flutter_bloc.dart';
|
|
import 'package:immich_mobile/domain/models/asset.model.dart';
|
|
import 'package:immich_mobile/domain/models/render_list.model.dart';
|
|
import 'package:immich_mobile/domain/models/render_list_element.model.dart';
|
|
import 'package:immich_mobile/i18n/strings.g.dart';
|
|
import 'package:immich_mobile/presentation/components/common/page_empty.widget.dart';
|
|
import 'package:immich_mobile/presentation/components/grid/asset_grid.state.dart';
|
|
import 'package:immich_mobile/presentation/components/grid/draggable_scrollbar.dart';
|
|
import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart';
|
|
import 'package:immich_mobile/presentation/components/image/immich_thumbnail.widget.dart';
|
|
import 'package:immich_mobile/utils/constants/size_constants.dart';
|
|
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
|
import 'package:immich_mobile/utils/extensions/color.extension.dart';
|
|
import 'package:intl/intl.dart';
|
|
import 'package:material_symbols_icons/symbols.dart';
|
|
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
|
|
|
part 'asset_grid_header.widget.dart';
|
|
|
|
class ImmichAssetGridView extends StatefulWidget {
|
|
const ImmichAssetGridView({super.key});
|
|
|
|
@override
|
|
createState() {
|
|
return ImmichAssetGridViewState();
|
|
}
|
|
}
|
|
|
|
class ImmichAssetGridViewState extends State<ImmichAssetGridView> {
|
|
final ItemScrollController _itemScrollController = ItemScrollController();
|
|
final ScrollOffsetController _scrollOffsetController =
|
|
ScrollOffsetController();
|
|
final ItemPositionsListener _itemPositionsListener =
|
|
ItemPositionsListener.create();
|
|
bool _scrolling = false;
|
|
|
|
Widget _itemBuilder(BuildContext c, int position) {
|
|
int index = position;
|
|
|
|
return BlocSelector<AssetGridCubit, AssetGridState, RenderList>(
|
|
selector: (state) => state.renderList,
|
|
builder: (_, renderList) {
|
|
final section = renderList.elements.elementAtOrNull(index);
|
|
|
|
if (renderList.totalCount == 0 || section == null) {
|
|
return const _ImGridEmpty();
|
|
}
|
|
|
|
return _Section(sectionIndex: index);
|
|
}, // no.of elements are not equal or is modified
|
|
);
|
|
}
|
|
|
|
Text? _labelBuilder(List<RenderListElement> elements, int currentPosition) {
|
|
final element = elements.elementAtOrNull(currentPosition);
|
|
if (element == null) {
|
|
return null;
|
|
}
|
|
|
|
return Text(
|
|
DateFormat.yMMMM().format(element.date),
|
|
style: TextStyle(
|
|
color: context.colorScheme.onSurface,
|
|
fontWeight: FontWeight.bold,
|
|
),
|
|
);
|
|
}
|
|
|
|
Widget _buildAssetGrid() {
|
|
final useDragScrolling = true;
|
|
|
|
// ignore: avoid-local-functions
|
|
void dragScrolling(bool active) {
|
|
if (active != _scrolling) {
|
|
setState(() {
|
|
_scrolling = active;
|
|
});
|
|
}
|
|
}
|
|
|
|
// ignore: avoid-local-functions
|
|
bool appBarOffset() => true;
|
|
|
|
return BlocSelector<AssetGridCubit, AssetGridState, RenderList>(
|
|
selector: (state) => state.renderList,
|
|
builder: (_, renderList) {
|
|
final listWidget = ScrollablePositionedList.builder(
|
|
itemCount: renderList.elements.length,
|
|
itemBuilder: _itemBuilder,
|
|
itemScrollController: _itemScrollController,
|
|
itemPositionsListener: _itemPositionsListener,
|
|
scrollOffsetController: _scrollOffsetController,
|
|
padding: EdgeInsets.only(top: appBarOffset() ? 60 : 0, bottom: 220),
|
|
addRepaintBoundaries: true,
|
|
);
|
|
|
|
return (useDragScrolling && ModalRoute.of(context) != null)
|
|
? DraggableScrollbar.semicircle(
|
|
controller: _itemScrollController,
|
|
itemPositionsListener: _itemPositionsListener,
|
|
scrollStateListener: dragScrolling,
|
|
backgroundColor: context.colorScheme.surfaceContainerHighest,
|
|
foregroundColor: context.colorScheme.onSurface,
|
|
padding: appBarOffset()
|
|
? const EdgeInsets.only(top: 120)
|
|
: const EdgeInsets.only(),
|
|
heightOffset: appBarOffset() ? 60 : 0,
|
|
scrollbarAnimationDuration: const Duration(milliseconds: 300),
|
|
scrollbarTimeToFade: const Duration(milliseconds: 1000),
|
|
labelTextBuilder: (pos) =>
|
|
_labelBuilder(renderList.elements, pos),
|
|
labelConstraints: const BoxConstraints(maxHeight: 28),
|
|
child: listWidget,
|
|
)
|
|
: listWidget;
|
|
},
|
|
);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _buildAssetGrid();
|
|
}
|
|
}
|
|
|
|
/// A single row of all placeholder widgets
|
|
class _PlaceholderRow extends StatelessWidget {
|
|
final int number;
|
|
final double width;
|
|
final double height;
|
|
|
|
const _PlaceholderRow({
|
|
super.key,
|
|
required this.number,
|
|
required this.width,
|
|
required this.height,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
children: [
|
|
for (int i = 0; i < 4; i++)
|
|
ImImagePlaceholder(
|
|
key: ValueKey(i),
|
|
width: width,
|
|
height: height,
|
|
margin: 1,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// A section for the render grid
|
|
class _Section extends StatelessWidget {
|
|
final int sectionIndex;
|
|
|
|
const _Section({required this.sectionIndex});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return BlocBuilder<AssetGridCubit, AssetGridState>(
|
|
builder: (_, state) => LayoutBuilder(
|
|
builder: (_, constraints) {
|
|
// ignore: avoid-unsafe-collection-methods
|
|
final section = state.renderList.elements[sectionIndex];
|
|
|
|
if (section is RenderListMonthHeaderElement) {
|
|
return _MonthHeader(text: section.header);
|
|
}
|
|
|
|
if (section is RenderListDayHeaderElement) {
|
|
return _DayHeader(text: section.header);
|
|
}
|
|
|
|
if (section is! RenderListAssetElement) {
|
|
return const SizedBox();
|
|
}
|
|
|
|
final scrolling = state.isDragScrolling;
|
|
final assetsPerRow = 4;
|
|
final margin = 1.0;
|
|
|
|
final width = constraints.maxWidth / 4 - (4 - 1) * margin / 4;
|
|
final rows = (section.assetCount + 4 - 1) ~/ 4;
|
|
final Future<List<Asset>> assetsToRender = scrolling
|
|
? Future.value([])
|
|
: context
|
|
.read<AssetGridCubit>()
|
|
.loadAssets(section.assetOffset, section.assetCount);
|
|
return FutureBuilder(
|
|
future: assetsToRender,
|
|
builder: (_, snap) => Column(
|
|
key: ValueKey(section.assetCount),
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
for (int i = 0; i < rows; i++)
|
|
scrolling || !snap.hasData
|
|
? _PlaceholderRow(
|
|
key: ValueKey(i),
|
|
number: assetsPerRow,
|
|
width: width - 1.5,
|
|
height: width,
|
|
)
|
|
: _AssetRow(
|
|
key: ValueKey(i),
|
|
assets: snap.data!.nestedSlice(
|
|
i * assetsPerRow,
|
|
min((i + 1) * assetsPerRow, section.assetCount),
|
|
),
|
|
width: width,
|
|
margin: margin,
|
|
assetsPerRow: assetsPerRow,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
// no.of elements are not equal or is modified
|
|
buildWhen: (previous, current) =>
|
|
(previous.renderList.elements.length !=
|
|
current.renderList.elements.length) ||
|
|
!previous.renderList.modifiedTime
|
|
.isAtSameMomentAs(current.renderList.modifiedTime),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The row of assets
|
|
class _AssetRow extends StatelessWidget {
|
|
final List<Asset> assets;
|
|
final double width;
|
|
final double margin;
|
|
final int assetsPerRow;
|
|
|
|
const _AssetRow({
|
|
super.key,
|
|
required this.assets,
|
|
required this.width,
|
|
required this.margin,
|
|
required this.assetsPerRow,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
key: key,
|
|
children: assets.mapIndexed((int index, Asset asset) {
|
|
final bool last = index + 1 == assetsPerRow;
|
|
return Container(
|
|
width: width,
|
|
height: width,
|
|
margin: EdgeInsets.only(right: last ? 0.0 : margin, bottom: margin),
|
|
child: ImThumbnail(asset),
|
|
);
|
|
}).toList(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ImGridEmpty extends StatelessWidget {
|
|
const _ImGridEmpty();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ImPageEmptyIndicator(
|
|
icon: Symbols.photo_camera_rounded,
|
|
subtitle: SizedBox(
|
|
width: context.width * RatioConstants.twoThird,
|
|
child: Text(
|
|
context.t.common.components.grid_empty_message,
|
|
textAlign: TextAlign.center,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
extension ListExtension<E> on List<E> {
|
|
ListSlice<E> nestedSlice(int start, int end) {
|
|
if (this is ListSlice) {
|
|
final ListSlice<E> self = this as ListSlice<E>;
|
|
return ListSlice<E>(self.source, self.start + start, self.start + end);
|
|
}
|
|
return ListSlice<E>(this, start, end);
|
|
}
|
|
}
|