mirror of
https://github.com/immich-app/immich.git
synced 2025-06-23 15:30:51 -04:00
timeline monster
This commit is contained in:
parent
6311ecadd4
commit
3a4e9a0129
@ -1,13 +1,20 @@
|
|||||||
|
import 'dart:math';
|
||||||
|
|
||||||
import 'package:collection/collection.dart';
|
import 'package:collection/collection.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_bloc/flutter_bloc.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/domain/models/render_list_element.model.dart';
|
||||||
import 'package:immich_mobile/i18n/strings.g.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/common/page_empty.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/components/grid/asset_grid.state.dart';
|
import 'package:immich_mobile/presentation/components/grid/asset_grid.state.dart';
|
||||||
import 'package:immich_mobile/presentation/components/grid/asset_render_grid.widget.dart';
|
import 'package:immich_mobile/presentation/components/grid/asset_render_grid.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/components/grid/draggable_scrollbar.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/constants/size_constants.dart';
|
||||||
|
import 'package:immich_mobile/utils/extensions/async_snapshot.extension.dart';
|
||||||
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||||
import 'package:immich_mobile/utils/extensions/color.extension.dart';
|
import 'package:immich_mobile/utils/extensions/color.extension.dart';
|
||||||
import 'package:intl/intl.dart';
|
import 'package:intl/intl.dart';
|
||||||
@ -16,6 +23,249 @@ import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
|
|||||||
|
|
||||||
part 'asset_grid_header.widget.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 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.assetCount, 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.isWaiting
|
||||||
|
? _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 ImAssetGrid extends StatefulWidget {
|
class ImAssetGrid extends StatefulWidget {
|
||||||
/// The padding for the grid
|
/// The padding for the grid
|
||||||
final double? topPadding;
|
final double? topPadding;
|
||||||
@ -142,3 +392,33 @@ class _ImGridEmpty extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
extension ListExtension<E> on List<E> {
|
||||||
|
List<E> uniqueConsecutive({
|
||||||
|
int Function(E a, E b)? compare,
|
||||||
|
void Function(E a, E b)? onDuplicate,
|
||||||
|
}) {
|
||||||
|
compare ??= (E a, E b) => a == b ? 0 : 1;
|
||||||
|
int i = 1, j = 1;
|
||||||
|
for (; i < length; i++) {
|
||||||
|
if (compare(this[i - 1], this[i]) != 0) {
|
||||||
|
if (i != j) {
|
||||||
|
this[j] = this[i];
|
||||||
|
}
|
||||||
|
j++;
|
||||||
|
} else if (onDuplicate != null) {
|
||||||
|
onDuplicate(this[i - 1], this[i]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
length = length == 0 ? 0 : j;
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -36,7 +36,7 @@ class ImStaticGrid extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
itemBuilder: (_, i) {
|
itemBuilder: (_, i) {
|
||||||
if (isDragging) {
|
if (isDragging) {
|
||||||
return const ImImagePlaceholder();
|
return const ImImagePlaceholder(width: 200, height: 200);
|
||||||
}
|
}
|
||||||
|
|
||||||
final asset = assetsSnap.isWaiting || assets == null
|
final asset = assetsSnap.isWaiting || assets == null
|
||||||
@ -46,7 +46,7 @@ class ImStaticGrid extends StatelessWidget {
|
|||||||
dimension: 200,
|
dimension: 200,
|
||||||
// Show Placeholder when drag scrolled
|
// Show Placeholder when drag scrolled
|
||||||
child: asset == null
|
child: asset == null
|
||||||
? const ImImagePlaceholder()
|
? const ImImagePlaceholder(width: 200, height: 200)
|
||||||
: ImThumbnail(asset),
|
: ImThumbnail(asset),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -7,14 +7,23 @@ import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
|||||||
import 'package:octo_image/octo_image.dart';
|
import 'package:octo_image/octo_image.dart';
|
||||||
|
|
||||||
class ImImagePlaceholder extends StatelessWidget {
|
class ImImagePlaceholder extends StatelessWidget {
|
||||||
const ImImagePlaceholder({super.key});
|
final double width;
|
||||||
|
final double height;
|
||||||
|
final double? margin;
|
||||||
|
const ImImagePlaceholder({
|
||||||
|
super.key,
|
||||||
|
required this.width,
|
||||||
|
required this.height,
|
||||||
|
this.margin,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Container(
|
return Container(
|
||||||
color: context.colorScheme.surfaceContainerHighest,
|
color: context.colorScheme.surfaceContainerHighest,
|
||||||
width: 200,
|
width: width,
|
||||||
height: 200,
|
height: height,
|
||||||
|
margin: EdgeInsets.all(margin ?? 0),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -30,7 +39,7 @@ class ImImage extends StatefulWidget {
|
|||||||
this.asset, {
|
this.asset, {
|
||||||
this.width,
|
this.width,
|
||||||
this.height,
|
this.height,
|
||||||
this.placeholder = const ImImagePlaceholder(),
|
this.placeholder = const ImImagePlaceholder(width: 200, height: 200),
|
||||||
super.key,
|
super.key,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -26,19 +26,13 @@ class _HomePageState extends State<HomePage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final gridHasPadding = !context.isTablet && _showAppBar.value;
|
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
body: BlocProvider(
|
body: BlocProvider(
|
||||||
create: (_) => AssetGridCubit(
|
create: (_) => AssetGridCubit(
|
||||||
renderListProvider: RenderListProvider.mainTimeline(),
|
renderListProvider: RenderListProvider.mainTimeline(),
|
||||||
),
|
),
|
||||||
child: Stack(children: [
|
child: Stack(children: [
|
||||||
ImAssetGrid(
|
ImmichAssetGridView(),
|
||||||
topPadding: gridHasPadding
|
|
||||||
? kToolbarHeight + context.mediaQueryPadding.top - 8
|
|
||||||
: null,
|
|
||||||
),
|
|
||||||
if (!context.isTablet)
|
if (!context.isTablet)
|
||||||
ValueListenableBuilder(
|
ValueListenableBuilder(
|
||||||
valueListenable: _showAppBar,
|
valueListenable: _showAppBar,
|
||||||
|
Loading…
x
Reference in New Issue
Block a user