refactor: asset grid
This commit is contained in:
@@ -0,0 +1,484 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
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,
|
||||
Color foregroundColor,
|
||||
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 background color of the arrows
|
||||
final Color foregroundColor;
|
||||
|
||||
/// 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({
|
||||
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.foregroundColor = Colors.black,
|
||||
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
|
||||
State 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,
|
||||
Color foregroundColor,
|
||||
Animation<double> thumbAnimation,
|
||||
Animation<double> labelAnimation,
|
||||
double height, {
|
||||
Text? labelText,
|
||||
BoxConstraints? labelConstraints,
|
||||
}) {
|
||||
final scrollThumb = CustomPaint(
|
||||
key: scrollThumbKey,
|
||||
foregroundPainter: _ArrowCustomPainter(foregroundColor),
|
||||
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({
|
||||
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: 15),
|
||||
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();
|
||||
_dragHaltTimer?.cancel();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Text? labelText;
|
||||
if (widget.labelTextBuilder != null && _isDragInProcess) {
|
||||
labelText = widget.labelTextBuilder!(_currentItem);
|
||||
}
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (BuildContext _, BoxConstraints constraints) {
|
||||
return NotificationListener<ScrollNotification>(
|
||||
onNotification: _onScrollNotification,
|
||||
child: Stack(
|
||||
children: [
|
||||
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,
|
||||
widget.foregroundColor,
|
||||
_thumbAnimation,
|
||||
_labelAnimation,
|
||||
widget.heightScrollThumb,
|
||||
labelText: labelText,
|
||||
labelConstraints: widget.labelConstraints,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
double get _barMaxScrollExtent =>
|
||||
(context.size?.height ?? 0) - widget.heightScrollThumb;
|
||||
|
||||
double get _barMinScrollExtent => 0;
|
||||
|
||||
int get maxItemCount => widget.child.itemCount;
|
||||
|
||||
bool _onScrollNotification(ScrollNotification notification) {
|
||||
_changePosition(notification);
|
||||
return false;
|
||||
}
|
||||
|
||||
void _onScrollFade() {
|
||||
_thumbAnimationController.reverse();
|
||||
_labelAnimationController.reverse();
|
||||
_fadeoutTimer = null;
|
||||
}
|
||||
|
||||
// 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
|
||||
void _changePosition(ScrollNotification notification) {
|
||||
if (_isDragInProcess) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
try {
|
||||
if (notification is ScrollUpdateNotification) {
|
||||
int? firstItemIndex = widget
|
||||
.itemPositionsListener.itemPositions.value.firstOrNull?.index;
|
||||
if (firstItemIndex != null) {
|
||||
_barOffset = (firstItemIndex / maxItemCount) * _barMaxScrollExtent;
|
||||
}
|
||||
|
||||
_barOffset =
|
||||
clampDouble(_barOffset, _barMinScrollExtent, _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, _onScrollFade);
|
||||
}
|
||||
} catch (_) {}
|
||||
});
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
final alignment = (_barOffset / _barMaxScrollExtent);
|
||||
|
||||
widget.controller.jumpTo(
|
||||
index: _currentItem,
|
||||
// // Align at the top or middle while scrolling, but always align at the top while
|
||||
// // towards the end.
|
||||
alignment: alignment > 0.95 ? 0 : clampDouble(alignment - 0.2, 0, 1),
|
||||
);
|
||||
}
|
||||
|
||||
Timer? _dragHaltTimer;
|
||||
int lastTimerPos = 0;
|
||||
|
||||
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
if (_thumbAnimationController.status != AnimationStatus.forward) {
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
if (_isDragInProcess) {
|
||||
_barOffset += details.delta.dy;
|
||||
|
||||
_barOffset =
|
||||
clampDouble(_barOffset, _barMinScrollExtent, _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, _onScrollFade);
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
|
||||
class _SlideFadeTransition extends StatelessWidget {
|
||||
final Animation<double> animation;
|
||||
final Widget child;
|
||||
|
||||
const _SlideFadeTransition({required this.animation, required this.child});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AnimatedBuilder(
|
||||
animation: animation,
|
||||
builder: (_, c) => animation.value == 0.0 ? const SizedBox() : c!,
|
||||
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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import 'dart:async';
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:collection/collection.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/utils/constants/globals.dart';
|
||||
|
||||
typedef RenderListProvider = Stream<RenderList> Function();
|
||||
typedef RenderListAssetProvider = Future<List<Asset>> Function({
|
||||
int? offset,
|
||||
int? limit,
|
||||
});
|
||||
|
||||
class ImmichAssetGridCubit extends Cubit<RenderList> {
|
||||
final Stream<RenderList> _renderStream;
|
||||
final RenderListAssetProvider _assetProvider;
|
||||
late final StreamSubscription _renderListSubscription;
|
||||
|
||||
/// offset of the assets from last section in [_buf]
|
||||
int _bufOffset = 0;
|
||||
|
||||
/// assets cache loaded from DB with offset [_bufOffset]
|
||||
List<Asset> _buf = [];
|
||||
|
||||
ImmichAssetGridCubit({
|
||||
required Stream<RenderList> renderStream,
|
||||
required RenderListAssetProvider assetProvider,
|
||||
}) : _renderStream = renderStream,
|
||||
_assetProvider = assetProvider,
|
||||
super(RenderList.empty()) {
|
||||
_renderListSubscription = _renderStream.listen((renderList) {
|
||||
_bufOffset = 0;
|
||||
_buf = [];
|
||||
emit(renderList);
|
||||
});
|
||||
}
|
||||
|
||||
/// Loads the requested assets from the database to an internal buffer if not cached
|
||||
/// and returns a slice of that buffer
|
||||
Future<List<Asset>> loadAssets(int offset, int count) async {
|
||||
assert(offset >= 0);
|
||||
assert(count > 0);
|
||||
assert(offset + count <= state.totalCount);
|
||||
|
||||
// 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
|
||||
if (offset < _bufOffset || offset + count > _bufOffset + _buf.length) {
|
||||
final bool forward = _bufOffset < offset;
|
||||
|
||||
// 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 =
|
||||
math.max(kRenderListBatchSize, count + kRenderListOppositeBatchSize);
|
||||
|
||||
// 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 = math.max(
|
||||
0,
|
||||
forward
|
||||
? offset - kRenderListOppositeBatchSize
|
||||
: (len > kRenderListBatchSize ? offset : offset + count - len),
|
||||
);
|
||||
|
||||
// load the calculated batch (start:start+len) from the DB and put it into the buffer
|
||||
_buf = await _assetProvider(offset: start, limit: len);
|
||||
_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);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_renderListSubscription.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
@@ -1,73 +1,124 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.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/presentation/components/grid/draggable_scrollbar.dart';
|
||||
import 'package:immich_mobile/presentation/components/grid/immich_asset_grid.state.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/immich_image.widget.dart';
|
||||
import 'package:immich_mobile/service_locator.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/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 'immich_asset_grid_header.widget.dart';
|
||||
part 'immich_grid_asset_placeholder.widget.dart';
|
||||
|
||||
class ImAssetGrid extends StatelessWidget {
|
||||
class ImAssetGrid extends StatefulWidget {
|
||||
const ImAssetGrid({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return StreamBuilder(
|
||||
stream: di<IAssetRepository>().getRenderList(),
|
||||
builder: (_, renderSnap) {
|
||||
final renderList = renderSnap.data;
|
||||
if (renderList == null) {
|
||||
return const SizedBox.shrink();
|
||||
}
|
||||
State createState() => _ImAssetGridState();
|
||||
}
|
||||
|
||||
final elements = renderList.elements;
|
||||
return ScrollablePositionedList.builder(
|
||||
itemCount: elements.length,
|
||||
addAutomaticKeepAlives: false,
|
||||
minCacheExtent: 100,
|
||||
itemBuilder: (_, sectionIndex) {
|
||||
final section = elements[sectionIndex];
|
||||
class _ImAssetGridState extends State<ImAssetGrid> {
|
||||
bool _isDragScrolling = false;
|
||||
final ItemScrollController _itemScrollController = ItemScrollController();
|
||||
final ItemPositionsListener _itemPositionsListener =
|
||||
ItemPositionsListener.create();
|
||||
|
||||
return switch (section) {
|
||||
RenderListMonthHeaderElement() =>
|
||||
_MonthHeader(text: section.header),
|
||||
RenderListDayHeaderElement() => Text(section.header),
|
||||
RenderListAssetElement() => FutureBuilder(
|
||||
future: renderList.loadAssets(
|
||||
section.assetOffset,
|
||||
section.assetCount,
|
||||
),
|
||||
builder: (_, assetsSnap) {
|
||||
final assets = assetsSnap.data;
|
||||
return GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
addAutomaticKeepAlives: false,
|
||||
cacheExtent: 100,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
),
|
||||
itemBuilder: (_, i) {
|
||||
return SizedBox.square(
|
||||
dimension: 200,
|
||||
child: assetsSnap.isWaiting || assets == null
|
||||
? Container(color: Colors.grey)
|
||||
// ignore: avoid-unsafe-collection-methods
|
||||
: ImImage(assets[i]),
|
||||
);
|
||||
},
|
||||
itemCount: section.assetCount,
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
},
|
||||
void _onDragScrolling(bool isScrolling) {
|
||||
if (_isDragScrolling != isScrolling) {
|
||||
setState(() {
|
||||
_isDragScrolling = isScrolling;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) =>
|
||||
BlocBuilder<ImmichAssetGridCubit, RenderList>(
|
||||
builder: (_, renderList) {
|
||||
final elements = renderList.elements;
|
||||
final grid = ScrollablePositionedList.builder(
|
||||
itemCount: elements.length,
|
||||
addAutomaticKeepAlives: false,
|
||||
minCacheExtent: 100,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
itemScrollController: _itemScrollController,
|
||||
itemBuilder: (_, sectionIndex) {
|
||||
final section = elements[sectionIndex];
|
||||
|
||||
return switch (section) {
|
||||
RenderListMonthHeaderElement() =>
|
||||
_MonthHeader(text: section.header),
|
||||
RenderListDayHeaderElement() => Text(section.header),
|
||||
RenderListAssetElement() => FutureBuilder(
|
||||
future: context.read<ImmichAssetGridCubit>().loadAssets(
|
||||
section.assetOffset,
|
||||
section.assetCount,
|
||||
),
|
||||
builder: (_, assetsSnap) {
|
||||
final assets = assetsSnap.data;
|
||||
return GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
addAutomaticKeepAlives: false,
|
||||
cacheExtent: 100,
|
||||
gridDelegate:
|
||||
const SliverGridDelegateWithFixedCrossAxisCount(
|
||||
crossAxisCount: 4,
|
||||
mainAxisSpacing: 3,
|
||||
crossAxisSpacing: 3,
|
||||
),
|
||||
itemBuilder: (_, i) {
|
||||
final asset = assetsSnap.isWaiting || assets == null
|
||||
? null
|
||||
: assets.elementAtOrNull(i);
|
||||
return SizedBox.square(
|
||||
dimension: 200,
|
||||
// Show Placeholder when drag scrolled
|
||||
child: asset == null || _isDragScrolling
|
||||
? const _ImImagePlaceholder()
|
||||
: ImImage(asset),
|
||||
);
|
||||
},
|
||||
itemCount: section.assetCount,
|
||||
);
|
||||
},
|
||||
),
|
||||
};
|
||||
},
|
||||
);
|
||||
return DraggableScrollbar(
|
||||
foregroundColor: context.colorScheme.onSurface,
|
||||
backgroundColor: context.colorScheme.surfaceContainerHighest,
|
||||
scrollStateListener: _onDragScrolling,
|
||||
itemPositionsListener: _itemPositionsListener,
|
||||
controller: _itemScrollController,
|
||||
labelTextBuilder: (int position) =>
|
||||
_labelBuilder(elements, position),
|
||||
labelConstraints: const BoxConstraints(maxHeight: 36),
|
||||
scrollbarAnimationDuration: const Duration(milliseconds: 300),
|
||||
scrollbarTimeToFade: const Duration(milliseconds: 1000),
|
||||
child: grid,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,18 +9,15 @@ class _HeaderText extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(top: 32.0, left: 16.0, right: 12.0),
|
||||
padding: const EdgeInsets.only(top: 32.0, left: 16.0, right: 24.0),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Text(text, style: style),
|
||||
const Spacer(),
|
||||
IconButton(
|
||||
// ignore: no-empty-block
|
||||
onPressed: () {},
|
||||
icon: Icon(
|
||||
Symbols.check_circle_rounded,
|
||||
color: context.colorScheme.onSurfaceVariant,
|
||||
),
|
||||
Icon(
|
||||
Symbols.check_circle_rounded,
|
||||
color: context.colorScheme.onSurface,
|
||||
),
|
||||
],
|
||||
),
|
||||
@@ -37,7 +34,11 @@ class _MonthHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return _HeaderText(
|
||||
text: text,
|
||||
style: context.textTheme.bodyLarge?.copyWith(fontSize: 24.0),
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
fontSize: 24.0,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: context.colorScheme.onSurface,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
part of 'immich_asset_grid.widget.dart';
|
||||
|
||||
class _ImImagePlaceholder extends StatelessWidget {
|
||||
const _ImImagePlaceholder();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
var gradientColors = [
|
||||
context.colorScheme.surfaceContainer,
|
||||
context.colorScheme.surfaceContainer.darken(amount: .1),
|
||||
];
|
||||
|
||||
return Container(
|
||||
width: 200,
|
||||
height: 200,
|
||||
decoration: BoxDecoration(
|
||||
gradient: LinearGradient(
|
||||
colors: gradientColors,
|
||||
begin: Alignment.topCenter,
|
||||
end: Alignment.bottomCenter,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,7 @@ class ImLogo extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ignore: prefer-single-widget-per-file
|
||||
class ImLogoText extends StatelessWidget {
|
||||
const ImLogoText({
|
||||
super.key,
|
||||
|
||||
@@ -17,6 +17,7 @@ class ImAdaptiveRoutePrimaryAppBar extends StatelessWidget
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
// ignore: prefer-single-widget-per-file
|
||||
class ImAdaptiveRouteSecondaryAppBar extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
const ImAdaptiveRouteSecondaryAppBar({super.key});
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
|
||||
import 'package:immich_mobile/presentation/components/grid/immich_asset_grid.state.dart';
|
||||
import 'package:immich_mobile/presentation/components/grid/immich_asset_grid.widget.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
|
||||
@RoutePage()
|
||||
class HomePage extends StatelessWidget {
|
||||
@@ -8,6 +12,14 @@ class HomePage extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(body: ImAssetGrid());
|
||||
return Scaffold(
|
||||
body: BlocProvider(
|
||||
create: (_) => ImmichAssetGridCubit(
|
||||
renderStream: di<IAssetRepository>().watchRenderList(),
|
||||
assetProvider: di<IAssetRepository>().fetchAssets,
|
||||
),
|
||||
child: const ImAssetGrid(),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,6 +53,12 @@ class _LoginPageState extends State<LoginPage>
|
||||
_passwordController.text = 'demo';
|
||||
}
|
||||
|
||||
void _onLoginPageStateChange(BuildContext context, LoginPageState state) {
|
||||
if (state.isLoginSuccessful) {
|
||||
context.replaceRoute(const TabControllerRoute());
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PreferredSizeWidget? appBar;
|
||||
@@ -154,11 +160,7 @@ class _LoginPageState extends State<LoginPage>
|
||||
}
|
||||
|
||||
return BlocListener<LoginPageCubit, LoginPageState>(
|
||||
listener: (_, loginState) {
|
||||
if (loginState.isLoginSuccessful) {
|
||||
context.replaceRoute(const TabControllerRoute());
|
||||
}
|
||||
},
|
||||
listener: _onLoginPageStateChange,
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: appBar,
|
||||
|
||||
@@ -49,15 +49,23 @@ class LoginForm extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _ServerForm extends StatelessWidget {
|
||||
class _ServerForm extends StatefulWidget {
|
||||
final TextEditingController controller;
|
||||
final GlobalKey<FormState> _formKey = GlobalKey();
|
||||
|
||||
_ServerForm({required this.controller});
|
||||
const _ServerForm({required this.controller});
|
||||
|
||||
@override
|
||||
State createState() => _ServerFormState();
|
||||
}
|
||||
|
||||
class _ServerFormState extends State<_ServerForm> {
|
||||
final GlobalKey<FormState> _formKey = GlobalKey();
|
||||
|
||||
Future<void> _validateForm(BuildContext context) async {
|
||||
if (_formKey.currentState?.validate() == true) {
|
||||
await context.read<LoginPageCubit>().validateServer(controller.text);
|
||||
await context
|
||||
.read<LoginPageCubit>()
|
||||
.validateServer(widget.controller.text);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -72,7 +80,7 @@ class _ServerForm extends StatelessWidget {
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
ImTextFormField(
|
||||
controller: controller,
|
||||
controller: widget.controller,
|
||||
label: context.t.login.label.endpoint,
|
||||
validator: context.read<LoginPageCubit>().validateServerUrl,
|
||||
autoFillHints: const [AutofillHints.url],
|
||||
|
||||
@@ -22,6 +22,7 @@ class SettingsWrapperPage extends StatelessWidget {
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
// ignore: prefer-single-widget-per-file
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@@ -35,9 +36,7 @@ class SettingsPage extends StatelessWidget {
|
||||
final section = SettingSection.values.elementAt(index);
|
||||
return ListTile(
|
||||
title: Text(context.t[section.labelKey]),
|
||||
onTap: () {
|
||||
context.navigateRoot(section.destination);
|
||||
},
|
||||
onTap: () => context.navigateRoot(section.destination),
|
||||
leading: Icon(section.icon),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.
|
||||
|
||||
class AppThemeCubit extends Cubit<AppTheme> {
|
||||
final AppSettingService _appSettings;
|
||||
StreamSubscription? _appSettingSubscription;
|
||||
late final StreamSubscription _appSettingSubscription;
|
||||
|
||||
AppThemeCubit(this._appSettings) : super(AppTheme.blue) {
|
||||
_appSettingSubscription = _appSettings
|
||||
@@ -17,7 +17,7 @@ class AppThemeCubit extends Cubit<AppTheme> {
|
||||
|
||||
@override
|
||||
Future<void> close() {
|
||||
_appSettingSubscription?.cancel();
|
||||
_appSettingSubscription.cancel();
|
||||
return super.close();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,6 +18,7 @@ class SplashScreenWrapperPage extends AutoRouter implements AutoRouteWrapper {
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
// ignore: prefer-single-widget-per-file
|
||||
class SplashScreenPage extends StatefulWidget {
|
||||
const SplashScreenPage({super.key});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user