refactor: asset grid

This commit is contained in:
shenlong-tanwen 2024-09-14 22:29:51 +05:30
parent 53974e7276
commit 6fce1ebb79
23 changed files with 796 additions and 113 deletions

View File

@ -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

View File

@ -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();
}

View File

@ -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),

View 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: []);
}
}

View File

@ -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;

View File

@ -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));
}
}

View File

@ -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),
),
);
}
}

View File

@ -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();
}
}

View File

@ -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,
);
},
);
}

View File

@ -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,
),
);
}
}

View File

@ -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,
),
),
);
}
}

View File

@ -27,6 +27,7 @@ class ImLogo extends StatelessWidget {
}
}
// ignore: prefer-single-widget-per-file
class ImLogoText extends StatelessWidget {
const ImLogoText({
super.key,

View File

@ -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});

View File

@ -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(),
),
);
}
}

View File

@ -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,

View File

@ -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],

View File

@ -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),
);
},

View File

@ -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();
}
}

View File

@ -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});

View File

@ -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);

View 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);
}
}

View File

@ -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();
}
}
}

View File

@ -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