From 6fce1ebb799cf9d08830f79bcc35977a47bbcf9e Mon Sep 17 00:00:00 2001 From: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Date: Sat, 14 Sep 2024 22:29:51 +0530 Subject: [PATCH] refactor: asset grid --- mobile-v2/analysis_options.yaml | 42 +- .../domain/interfaces/asset.interface.dart | 4 +- .../lib/domain/models/app_setting.model.dart | 3 + .../lib/domain/models/render_list.model.dart | 21 + .../models/render_list_element.model.dart | 8 + .../domain/repositories/asset.repository.dart | 18 +- .../components/grid/draggable_scrollbar.dart | 484 ++++++++++++++++++ .../grid/immich_asset_grid.state.dart} | 43 +- .../grid/immich_asset_grid.widget.dart | 161 ++++-- .../grid/immich_asset_grid_header.widget.dart | 19 +- .../immich_grid_asset_placeholder.widget.dart | 25 + .../components/image/immich_logo.widget.dart | 1 + .../adaptive_route_appbar.widget.dart | 1 + .../modules/home/pages/home.page.dart | 14 +- .../modules/login/pages/login.page.dart | 12 +- .../login/widgets/login_form.widget.dart | 18 +- .../modules/settings/pages/settings.page.dart | 5 +- .../modules/theme/states/app_theme.state.dart | 4 +- .../router/pages/splash_screen.page.dart | 1 + .../extensions/build_context.extension.dart | 6 + .../lib/utils/extensions/color.extension.dart | 11 + mobile-v2/lib/utils/immich_api_client.dart | 6 +- mobile-v2/lib/utils/log_manager.dart | 2 +- 23 files changed, 796 insertions(+), 113 deletions(-) create mode 100644 mobile-v2/lib/domain/models/render_list.model.dart create mode 100644 mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart rename mobile-v2/lib/{domain/services/render_list.service.dart => presentation/components/grid/immich_asset_grid.state.dart} (67%) create mode 100644 mobile-v2/lib/presentation/components/grid/immich_grid_asset_placeholder.widget.dart create mode 100644 mobile-v2/lib/utils/extensions/color.extension.dart diff --git a/mobile-v2/analysis_options.yaml b/mobile-v2/analysis_options.yaml index 8eefcda6d0..2ac219e3d3 100644 --- a/mobile-v2/analysis_options.yaml +++ b/mobile-v2/analysis_options.yaml @@ -15,9 +15,49 @@ dart_code_metrics: extends: - recommended rules: + # Common + - avoid-accessing-collections-by-constant-index + - avoid-accessing-other-classes-private-members + - avoid-cascade-after-if-null + - avoid-collapsible-if + - avoid-collection-methods-with-unrelated-types + - avoid-double-slash-imports + - avoid-duplicate-cascades + - avoid-duplicate-patterns + - avoid-generics-shadowing + - avoid-global-state + # Flutter + - always-remove-listener + - avoid-border-all + - avoid-empty-setstate + - avoid-expanded-as-spacer + - avoid-incomplete-copy-with + - avoid-inherited-widget-in-initstate + - avoid-late-context + - avoid-recursive-widget-calls + - avoid-returning-widgets + - avoid-shrink-wrap-in-lists + - avoid-single-child-column-or-row + - avoid-state-constructors + - avoid-stateless-widget-initialized-fields + - avoid-unnecessary-overrides-in-state + - avoid-unnecessary-stateful-widgets + - avoid-wrapping-in-padding + - dispose-fields + - prefer-const-border-radius + - prefer-correct-edge-insets-constructor + - prefer-dedicated-media-query-methods + - prefer-define-hero-tag + - prefer-extracting-callbacks + - prefer-sliver-prefix + - prefer-text-rich + - prefer-using-list-view + - proper-super-calls + - use-setstate-synchronously - prefer-match-file-name: false - avoid-passing-self-as-argument: exclude: - lib/domain/repositories/** - - prefer-single-widget-per-file: false + - prefer-single-widget-per-file: + ignore-private-widgets: true - prefer-correct-callback-field-name: false diff --git a/mobile-v2/lib/domain/interfaces/asset.interface.dart b/mobile-v2/lib/domain/interfaces/asset.interface.dart index e21a14760a..531b249617 100644 --- a/mobile-v2/lib/domain/interfaces/asset.interface.dart +++ b/mobile-v2/lib/domain/interfaces/asset.interface.dart @@ -1,5 +1,5 @@ import 'package:immich_mobile/domain/models/asset.model.dart'; -import 'package:immich_mobile/domain/services/render_list.service.dart'; +import 'package:immich_mobile/domain/models/render_list.model.dart'; abstract class IAssetRepository { /// Batch insert asset @@ -12,5 +12,5 @@ abstract class IAssetRepository { Future> fetchAssets({int? offset, int? limit}); /// Streams assets as groups grouped by the group type passed - Stream getRenderList(); + Stream watchRenderList(); } diff --git a/mobile-v2/lib/domain/models/app_setting.model.dart b/mobile-v2/lib/domain/models/app_setting.model.dart index 52afd68032..bfb1953041 100644 --- a/mobile-v2/lib/domain/models/app_setting.model.dart +++ b/mobile-v2/lib/domain/models/app_setting.model.dart @@ -2,6 +2,9 @@ import 'package:flutter/material.dart'; import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model.dart'; +// AppSetting needs to store UI specific settings as well as domain specific settings +// This model is the only exclusion which refers to entities from the presentation layer +// as well as the domain layer enum AppSetting { appTheme(StoreKey.appTheme, AppTheme.blue), themeMode(StoreKey.themeMode, ThemeMode.system), diff --git a/mobile-v2/lib/domain/models/render_list.model.dart b/mobile-v2/lib/domain/models/render_list.model.dart new file mode 100644 index 0000000000..82c911d7cb --- /dev/null +++ b/mobile-v2/lib/domain/models/render_list.model.dart @@ -0,0 +1,21 @@ +import 'package:collection/collection.dart'; +import 'package:immich_mobile/domain/models/render_list_element.model.dart'; + +class RenderList { + final List elements; + late final int totalCount; + + RenderList({required this.elements}) { + final lastAssetElement = + elements.whereType().lastOrNull; + if (lastAssetElement == null) { + totalCount = 0; + } else { + totalCount = lastAssetElement.assetCount + lastAssetElement.assetOffset; + } + } + + factory RenderList.empty() { + return RenderList(elements: []); + } +} diff --git a/mobile-v2/lib/domain/models/render_list_element.model.dart b/mobile-v2/lib/domain/models/render_list_element.model.dart index cb1071fdac..4a8dc52f49 100644 --- a/mobile-v2/lib/domain/models/render_list_element.model.dart +++ b/mobile-v2/lib/domain/models/render_list_element.model.dart @@ -28,6 +28,10 @@ class RenderListMonthHeaderElement extends RenderListElement { header = formatter.format(date); } + @override + String toString() => + 'RenderListMonthHeaderElement(header: $header, date: $date)'; + @override bool operator ==(covariant RenderListMonthHeaderElement other) { if (identical(this, other)) return true; @@ -44,6 +48,10 @@ class RenderListDayHeaderElement extends RenderListElement { const RenderListDayHeaderElement({required super.date, required this.header}); + @override + String toString() => + 'RenderListDayHeaderElement(header: $header, date: $date)'; + @override bool operator ==(covariant RenderListDayHeaderElement other) { if (identical(this, other)) return true; diff --git a/mobile-v2/lib/domain/repositories/asset.repository.dart b/mobile-v2/lib/domain/repositories/asset.repository.dart index d64f852e70..204b8c1cfc 100644 --- a/mobile-v2/lib/domain/repositories/asset.repository.dart +++ b/mobile-v2/lib/domain/repositories/asset.repository.dart @@ -4,9 +4,9 @@ import 'package:drift/drift.dart'; import 'package:immich_mobile/domain/entities/asset.entity.drift.dart'; import 'package:immich_mobile/domain/interfaces/asset.interface.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/repositories/database.repository.dart'; -import 'package:immich_mobile/domain/services/render_list.service.dart'; import 'package:immich_mobile/utils/extensions/drift.extension.dart'; import 'package:immich_mobile/utils/mixins/log_context.mixin.dart'; @@ -54,7 +54,7 @@ class RemoteAssetDriftRepository with LogContext implements IAssetRepository { } @override - Stream getRenderList() { + Stream watchRenderList() { final assetCountExp = _db.asset.id.count(); final createdTimeExp = _db.asset.createdTime; final monthYearExp = _db.asset.createdTime.strftime('%m-%Y'); @@ -83,19 +83,7 @@ class RemoteAssetDriftRepository with LogContext implements IAssetRepository { ]; }) .watch() - .map((elements) { - final int totalCount; - final lastAssetElement = - elements.whereType().lastOrNull; - if (lastAssetElement == null) { - totalCount = 0; - } else { - totalCount = - lastAssetElement.assetCount + lastAssetElement.assetOffset; - } - - return RenderList(elements: elements, totalCount: totalCount); - }); + .map((elements) => RenderList(elements: elements)); } } diff --git a/mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart b/mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart new file mode 100644 index 0000000000..d57baa1cd5 --- /dev/null +++ b/mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart @@ -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 thumbAnimation, + Animation 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? thumbAnimation, + required Animation? 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 thumbAnimation, + Animation 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? 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 + with TickerProviderStateMixin { + late double _barOffset; + late bool _isDragInProcess; + late int _currentItem; + + late AnimationController _thumbAnimationController; + late Animation _thumbAnimation; + late AnimationController _labelAnimationController; + late Animation _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( + 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 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), + ), + ); + } +} diff --git a/mobile-v2/lib/domain/services/render_list.service.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart similarity index 67% rename from mobile-v2/lib/domain/services/render_list.service.dart rename to mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart index 009c8855bd..8bede41578 100644 --- a/mobile-v2/lib/domain/services/render_list.service.dart +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart @@ -1,15 +1,22 @@ +import 'dart:async'; import 'dart:math' as math; import 'package:collection/collection.dart'; -import 'package:immich_mobile/domain/interfaces/asset.interface.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_element.model.dart'; -import 'package:immich_mobile/service_locator.dart'; +import 'package:immich_mobile/domain/models/render_list.model.dart'; import 'package:immich_mobile/utils/constants/globals.dart'; -class RenderList { - final List elements; - final int totalCount; +typedef RenderListProvider = Stream Function(); +typedef RenderListAssetProvider = Future> Function({ + int? offset, + int? limit, +}); + +class ImmichAssetGridCubit extends Cubit { + final Stream _renderStream; + final RenderListAssetProvider _assetProvider; + late final StreamSubscription _renderListSubscription; /// offset of the assets from last section in [_buf] int _bufOffset = 0; @@ -17,14 +24,25 @@ class RenderList { /// assets cache loaded from DB with offset [_bufOffset] List _buf = []; - RenderList({required this.elements, required this.totalCount}); + ImmichAssetGridCubit({ + required Stream 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> loadAssets(int offset, int count) async { assert(offset >= 0); assert(count > 0); - assert(offset + count <= totalCount); + 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 @@ -50,8 +68,7 @@ class RenderList { ); // load the calculated batch (start:start+len) from the DB and put it into the buffer - _buf = - await di().fetchAssets(offset: start, limit: len); + _buf = await _assetProvider(offset: start, limit: len); _bufOffset = start; assert(_bufOffset <= offset); @@ -61,4 +78,10 @@ class RenderList { // 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 close() { + _renderListSubscription.cancel(); + return super.close(); + } } diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart index 36207d0b44..8bb686b7b5 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart @@ -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().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 { + 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 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( + 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().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, + ); + }, + ); } diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart index 29fe39a4de..8b6c10b325 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_grid_header.widget.dart @@ -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, + ), ); } } diff --git a/mobile-v2/lib/presentation/components/grid/immich_grid_asset_placeholder.widget.dart b/mobile-v2/lib/presentation/components/grid/immich_grid_asset_placeholder.widget.dart new file mode 100644 index 0000000000..4138d9c873 --- /dev/null +++ b/mobile-v2/lib/presentation/components/grid/immich_grid_asset_placeholder.widget.dart @@ -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, + ), + ), + ); + } +} diff --git a/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart b/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart index 37e3675652..bff9baf4e1 100644 --- a/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart +++ b/mobile-v2/lib/presentation/components/image/immich_logo.widget.dart @@ -27,6 +27,7 @@ class ImLogo extends StatelessWidget { } } +// ignore: prefer-single-widget-per-file class ImLogoText extends StatelessWidget { const ImLogoText({ super.key, diff --git a/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart b/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart index bb6190b9ae..9186cc3209 100644 --- a/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart +++ b/mobile-v2/lib/presentation/components/scaffold/adaptive_route_appbar.widget.dart @@ -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}); diff --git a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart index bd33fff298..6c473feb4c 100644 --- a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart +++ b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart @@ -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().watchRenderList(), + assetProvider: di().fetchAssets, + ), + child: const ImAssetGrid(), + ), + ); } } diff --git a/mobile-v2/lib/presentation/modules/login/pages/login.page.dart b/mobile-v2/lib/presentation/modules/login/pages/login.page.dart index 617db0f0ad..df693170a9 100644 --- a/mobile-v2/lib/presentation/modules/login/pages/login.page.dart +++ b/mobile-v2/lib/presentation/modules/login/pages/login.page.dart @@ -53,6 +53,12 @@ class _LoginPageState extends State _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 } return BlocListener( - listener: (_, loginState) { - if (loginState.isLoginSuccessful) { - context.replaceRoute(const TabControllerRoute()); - } - }, + listener: _onLoginPageStateChange, child: Scaffold( resizeToAvoidBottomInset: false, appBar: appBar, diff --git a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart index d37e55dabb..fe381cd394 100644 --- a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart +++ b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart @@ -49,15 +49,23 @@ class LoginForm extends StatelessWidget { } } -class _ServerForm extends StatelessWidget { +class _ServerForm extends StatefulWidget { final TextEditingController controller; - final GlobalKey _formKey = GlobalKey(); - _ServerForm({required this.controller}); + const _ServerForm({required this.controller}); + + @override + State createState() => _ServerFormState(); +} + +class _ServerFormState extends State<_ServerForm> { + final GlobalKey _formKey = GlobalKey(); Future _validateForm(BuildContext context) async { if (_formKey.currentState?.validate() == true) { - await context.read().validateServer(controller.text); + await context + .read() + .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().validateServerUrl, autoFillHints: const [AutofillHints.url], diff --git a/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart b/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart index 7657dc175f..0d63f8556c 100644 --- a/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart +++ b/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart @@ -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), ); }, diff --git a/mobile-v2/lib/presentation/modules/theme/states/app_theme.state.dart b/mobile-v2/lib/presentation/modules/theme/states/app_theme.state.dart index 4b38625218..f1cfc03b1e 100644 --- a/mobile-v2/lib/presentation/modules/theme/states/app_theme.state.dart +++ b/mobile-v2/lib/presentation/modules/theme/states/app_theme.state.dart @@ -7,7 +7,7 @@ import 'package:immich_mobile/presentation/modules/theme/models/app_theme.model. class AppThemeCubit extends Cubit { 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 { @override Future close() { - _appSettingSubscription?.cancel(); + _appSettingSubscription.cancel(); return super.close(); } } diff --git a/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart b/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart index 398bb1b12e..389b350d95 100644 --- a/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart +++ b/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart @@ -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}); diff --git a/mobile-v2/lib/utils/extensions/build_context.extension.dart b/mobile-v2/lib/utils/extensions/build_context.extension.dart index 759a995bb3..652e04de4d 100644 --- a/mobile-v2/lib/utils/extensions/build_context.extension.dart +++ b/mobile-v2/lib/utils/extensions/build_context.extension.dart @@ -20,6 +20,12 @@ extension BuildContextHelper on BuildContext { /// Get the [EdgeInsets] of [MediaQuery] EdgeInsets get viewInsets => MediaQuery.viewInsetsOf(this); + // Returns the current width from MediaQuery + double get width => mediaQuerySize.width; + + // Returns the current height from MediaQuery + double get height => mediaQuerySize.height; + /// True if the current device is a Tablet bool get isTablet => (mediaQuerySize.width >= 600); diff --git a/mobile-v2/lib/utils/extensions/color.extension.dart b/mobile-v2/lib/utils/extensions/color.extension.dart new file mode 100644 index 0000000000..a1cd70bbff --- /dev/null +++ b/mobile-v2/lib/utils/extensions/color.extension.dart @@ -0,0 +1,11 @@ +import 'package:flutter/material.dart'; + +extension DarkenLightenExtension on Color { + Color lighten({double amount = 0.1}) { + return Color.alphaBlend(Colors.white.withOpacity(amount), this); + } + + Color darken({double amount = 0.1}) { + return Color.alphaBlend(Colors.black.withOpacity(amount), this); + } +} diff --git a/mobile-v2/lib/utils/immich_api_client.dart b/mobile-v2/lib/utils/immich_api_client.dart index 289129429f..05b42c0f56 100644 --- a/mobile-v2/lib/utils/immich_api_client.dart +++ b/mobile-v2/lib/utils/immich_api_client.dart @@ -70,10 +70,8 @@ class ImmichApiClient extends ApiClient with LogContext { static dynamic _patchDto(dynamic value, String targetType) { switch (targetType) { case 'UserPreferencesResponseDto': - if (value is Map) { - if (value['rating'] == null) { - value['rating'] = RatingResponse().toJson(); - } + if (value is Map && value['rating'] == null) { + value['rating'] = RatingResponse().toJson(); } } } diff --git a/mobile-v2/lib/utils/log_manager.dart b/mobile-v2/lib/utils/log_manager.dart index f7ccb75934..70c1a77b0b 100644 --- a/mobile-v2/lib/utils/log_manager.dart +++ b/mobile-v2/lib/utils/log_manager.dart @@ -19,7 +19,7 @@ class LogManager { List _msgBuffer = []; Timer? _timer; - late StreamSubscription _subscription; + late final StreamSubscription _subscription; void _onLogRecord(LogRecord record) { // Only print in development