mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
refactor: asset grid
This commit is contained in:
parent
53974e7276
commit
6fce1ebb79
@ -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
|
||||
|
@ -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<List<Asset>> fetchAssets({int? offset, int? limit});
|
||||
|
||||
/// Streams assets as groups grouped by the group type passed
|
||||
Stream<RenderList> getRenderList();
|
||||
Stream<RenderList> watchRenderList();
|
||||
}
|
||||
|
@ -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<T> {
|
||||
appTheme<AppTheme>(StoreKey.appTheme, AppTheme.blue),
|
||||
themeMode<ThemeMode>(StoreKey.themeMode, ThemeMode.system),
|
||||
|
21
mobile-v2/lib/domain/models/render_list.model.dart
Normal file
21
mobile-v2/lib/domain/models/render_list.model.dart
Normal file
@ -0,0 +1,21 @@
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/domain/models/render_list_element.model.dart';
|
||||
|
||||
class RenderList {
|
||||
final List<RenderListElement> elements;
|
||||
late final int totalCount;
|
||||
|
||||
RenderList({required this.elements}) {
|
||||
final lastAssetElement =
|
||||
elements.whereType<RenderListAssetElement>().lastOrNull;
|
||||
if (lastAssetElement == null) {
|
||||
totalCount = 0;
|
||||
} else {
|
||||
totalCount = lastAssetElement.assetCount + lastAssetElement.assetOffset;
|
||||
}
|
||||
}
|
||||
|
||||
factory RenderList.empty() {
|
||||
return RenderList(elements: []);
|
||||
}
|
||||
}
|
@ -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;
|
||||
|
@ -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<RenderList> getRenderList() {
|
||||
Stream<RenderList> 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<RenderListAssetElement>().lastOrNull;
|
||||
if (lastAssetElement == null) {
|
||||
totalCount = 0;
|
||||
} else {
|
||||
totalCount =
|
||||
lastAssetElement.assetCount + lastAssetElement.assetOffset;
|
||||
}
|
||||
|
||||
return RenderList(elements: elements, totalCount: totalCount);
|
||||
});
|
||||
.map((elements) => RenderList(elements: elements));
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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<RenderListElement> elements;
|
||||
final int totalCount;
|
||||
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;
|
||||
@ -17,14 +24,25 @@ class RenderList {
|
||||
/// assets cache loaded from DB with offset [_bufOffset]
|
||||
List<Asset> _buf = [];
|
||||
|
||||
RenderList({required this.elements, required this.totalCount});
|
||||
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 <= 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<IAssetRepository>().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<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});
|
||||
|
||||
|
@ -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);
|
||||
|
||||
|
11
mobile-v2/lib/utils/extensions/color.extension.dart
Normal file
11
mobile-v2/lib/utils/extensions/color.extension.dart
Normal file
@ -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);
|
||||
}
|
||||
}
|
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -19,7 +19,7 @@ class LogManager {
|
||||
|
||||
List<LogMessage> _msgBuffer = [];
|
||||
Timer? _timer;
|
||||
late StreamSubscription<LogRecord> _subscription;
|
||||
late final StreamSubscription<LogRecord> _subscription;
|
||||
|
||||
void _onLogRecord(LogRecord record) {
|
||||
// Only print in development
|
||||
|
Loading…
x
Reference in New Issue
Block a user