more refactors and logs page handling
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,62 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/utils/extensions/async_snapshot.extension.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
class SkeletonizedFutureBuilder<T> extends StatelessWidget {
|
||||
const SkeletonizedFutureBuilder({
|
||||
super.key,
|
||||
required this.future,
|
||||
required this.builder,
|
||||
required this.loadingBuilder,
|
||||
required this.errorBuilder,
|
||||
this.emptyBuilder,
|
||||
this.emptyWhen,
|
||||
}) : assert(
|
||||
(emptyBuilder == null && emptyWhen == null) ||
|
||||
(emptyBuilder != null && emptyWhen != null),
|
||||
"Both emptyBuilder and emptyWhen should be provided");
|
||||
|
||||
/// Future to listen to
|
||||
final Future<T> future;
|
||||
|
||||
/// Callback when data is available
|
||||
final Widget Function(BuildContext context, T? snapshot) builder;
|
||||
|
||||
/// Callback when future is loading. Expected a skeletonizer to be returned
|
||||
final Widget Function(BuildContext context) loadingBuilder;
|
||||
|
||||
/// Callback when future resulted in an error
|
||||
final Widget Function(BuildContext context, Object? error) errorBuilder;
|
||||
|
||||
/// Callback when data is available but is empty. Emptiness is determined based on the [emptyWhen] callback
|
||||
final Widget Function(BuildContext context)? emptyBuilder;
|
||||
|
||||
/// Predicate to call [emptyBuilder] when the [data] passes the filter
|
||||
final bool Function(T? data)? emptyWhen;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder<T>(
|
||||
future: future,
|
||||
builder: (ctx, snap) {
|
||||
final Widget child;
|
||||
if (snap.isWaiting) {
|
||||
child = loadingBuilder(ctx);
|
||||
} else {
|
||||
if (snap.hasError) {
|
||||
child = errorBuilder(ctx, snap.error);
|
||||
} else {
|
||||
final isEmpty = emptyWhen?.call(snap.data) ?? false;
|
||||
child = isEmpty ? emptyBuilder!(ctx) : builder(ctx, snap.data);
|
||||
}
|
||||
}
|
||||
|
||||
return Skeletonizer.zone(
|
||||
enabled: snap.isWaiting,
|
||||
enableSwitchAnimation: true,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/immich_cached_network_image.widget.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/transparent_image.dart';
|
||||
import 'package:immich_mobile/utils/immich_image_url_helper.dart';
|
||||
|
||||
class ImUserAvatar extends StatelessWidget {
|
||||
final User user;
|
||||
final double? dimension;
|
||||
final double? radius;
|
||||
|
||||
const ImUserAvatar({
|
||||
super.key,
|
||||
required this.user,
|
||||
this.dimension,
|
||||
this.radius,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
bool isDarkTheme = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final textIcon = Text(
|
||||
user.name[0].toUpperCase(),
|
||||
style: TextStyle(
|
||||
color: isDarkTheme && user.avatarColor == UserAvatarColor.primary
|
||||
? Colors.black
|
||||
: Colors.white,
|
||||
fontSize: 12,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
);
|
||||
|
||||
return CircleAvatar(
|
||||
backgroundColor: user.avatarColor.toColor(),
|
||||
radius: radius,
|
||||
child: user.profileImagePath.isEmpty
|
||||
? textIcon
|
||||
: ClipOval(
|
||||
child: ImCachedNetworkImage(
|
||||
imageUrl: ImImageUrlHelper.getUserAvatarUrl(user),
|
||||
cacheKey: user.profileImagePath,
|
||||
height: dimension,
|
||||
width: dimension,
|
||||
fit: BoxFit.cover,
|
||||
placeholder: (_, __) => Image.memory(
|
||||
kTransparentImage,
|
||||
semanticLabel: 'Transparent',
|
||||
),
|
||||
fadeInDuration: const Duration(milliseconds: 300),
|
||||
errorWidget: (_, error, stackTrace) => SizedBox.square(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -98,11 +98,11 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
required BoxConstraints? labelConstraints,
|
||||
required bool alwaysVisibleScrollThumb,
|
||||
}) {
|
||||
var scrollThumbAndLabel = labelText == null
|
||||
Widget scrollThumbAndLabel = labelText == null
|
||||
? scrollThumb
|
||||
: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
mainAxisAlignment: MainAxisAlignment.end,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
_ScrollLabel(
|
||||
animation: labelAnimation,
|
||||
@@ -145,8 +145,8 @@ class DraggableScrollbar extends StatefulWidget {
|
||||
color: backgroundColor,
|
||||
borderRadius: BorderRadius.only(
|
||||
topLeft: Radius.circular(height),
|
||||
bottomLeft: Radius.circular(height),
|
||||
topRight: const Radius.circular(4.0),
|
||||
bottomLeft: Radius.circular(height),
|
||||
bottomRight: const Radius.circular(4.0),
|
||||
),
|
||||
child: Container(
|
||||
@@ -195,9 +195,9 @@ class _ScrollLabel extends StatelessWidget {
|
||||
color: backgroundColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16.0)),
|
||||
child: Container(
|
||||
constraints: constraints ?? _defaultConstraints,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
alignment: Alignment.center,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 15),
|
||||
constraints: constraints ?? _defaultConstraints,
|
||||
child: child,
|
||||
),
|
||||
),
|
||||
@@ -231,8 +231,8 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
_currentItem = 0;
|
||||
|
||||
_thumbAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.scrollbarAnimationDuration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_thumbAnimation = CurvedAnimation(
|
||||
@@ -241,8 +241,8 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
);
|
||||
|
||||
_labelAnimationController = AnimationController(
|
||||
vsync: this,
|
||||
duration: widget.scrollbarAnimationDuration,
|
||||
vsync: this,
|
||||
);
|
||||
|
||||
_labelAnimation = CurvedAnimation(
|
||||
@@ -291,16 +291,16 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
onVerticalDragEnd: _onVerticalDragEnd,
|
||||
child: Container(
|
||||
alignment: Alignment.topRight,
|
||||
margin: EdgeInsets.only(top: _barOffset),
|
||||
padding: widget.padding,
|
||||
margin: EdgeInsets.only(top: _barOffset),
|
||||
child: widget.scrollThumbBuilder(
|
||||
widget.backgroundColor,
|
||||
widget.foregroundColor,
|
||||
_thumbAnimation,
|
||||
_labelAnimation,
|
||||
widget.heightScrollThumb,
|
||||
labelText: labelText,
|
||||
labelConstraints: widget.labelConstraints,
|
||||
labelText: labelText,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -356,7 +356,7 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
_thumbAnimationController.forward();
|
||||
}
|
||||
|
||||
final lastItemPos = itemPos;
|
||||
final lastItemPos = _itemPos;
|
||||
if (lastItemPos < widget.maxItemCount) {
|
||||
_currentItem = lastItemPos;
|
||||
}
|
||||
@@ -378,7 +378,7 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
widget.scrollStateListener(true);
|
||||
}
|
||||
|
||||
int get itemIndex {
|
||||
int get _itemIndex {
|
||||
int index = 0;
|
||||
double minDiff = 1000;
|
||||
for (final pos in _positions) {
|
||||
@@ -391,21 +391,21 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
return index;
|
||||
}
|
||||
|
||||
int get itemPos =>
|
||||
int get _itemPos =>
|
||||
((_barOffset / (_barMaxScrollExtent)) * widget.maxItemCount).toInt();
|
||||
|
||||
void _jumpToBarPos() {
|
||||
final lastItemPos = itemPos;
|
||||
final lastItemPos = _itemPos;
|
||||
if (lastItemPos > widget.maxItemCount - 1) {
|
||||
return;
|
||||
}
|
||||
|
||||
_currentItem = itemIndex;
|
||||
_currentItem = _itemIndex;
|
||||
widget.controller.sliverController.jumpToIndex(lastItemPos);
|
||||
}
|
||||
|
||||
Timer? _dragHaltTimer;
|
||||
int lastTimerPos = 0;
|
||||
int _lastTimerPos = 0;
|
||||
|
||||
void _onVerticalDragUpdate(DragUpdateDetails details) {
|
||||
setState(() {
|
||||
@@ -418,9 +418,9 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
|
||||
_barOffset =
|
||||
clampDouble(_barOffset, _barMinScrollExtent, _barMaxScrollExtent);
|
||||
|
||||
final lastItemPos = itemPos;
|
||||
if (lastItemPos != lastTimerPos) {
|
||||
lastTimerPos = lastItemPos;
|
||||
final lastItemPos = _itemPos;
|
||||
if (lastItemPos != _lastTimerPos) {
|
||||
_lastTimerPos = lastItemPos;
|
||||
_dragHaltTimer?.cancel();
|
||||
widget.scrollStateListener(true);
|
||||
|
||||
|
||||
@@ -26,6 +26,17 @@ class AssetGridState {
|
||||
renderList: renderList ?? this.renderList,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(covariant AssetGridState other) {
|
||||
if (identical(this, other)) return true;
|
||||
|
||||
return other.renderList == renderList &&
|
||||
other.isDragScrolling == isDragScrolling;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => renderList.hashCode ^ isDragScrolling.hashCode;
|
||||
}
|
||||
|
||||
class AssetGridCubit extends Cubit<AssetGridState> {
|
||||
@@ -43,6 +54,9 @@ class AssetGridCubit extends Cubit<AssetGridState> {
|
||||
super(AssetGridState.empty()) {
|
||||
_renderListSubscription =
|
||||
_renderListProvider.renderStreamProvider().listen((renderList) {
|
||||
if (renderList == state.renderList) {
|
||||
return;
|
||||
}
|
||||
_bufOffset = 0;
|
||||
_buf = [];
|
||||
emit(state.copyWith(renderList: renderList));
|
||||
@@ -87,8 +101,8 @@ class AssetGridCubit extends Cubit<AssetGridState> {
|
||||
|
||||
// load the calculated batch (start:start+len) from the DB and put it into the buffer
|
||||
_buf = await _renderListProvider.renderAssetProvider(
|
||||
offset: start,
|
||||
limit: len,
|
||||
offset: start,
|
||||
);
|
||||
_bufOffset = start;
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ import 'package:intl/intl.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
part 'immich_asset_grid_header.widget.dart';
|
||||
part 'immich_asset_render_grid.widget.dart';
|
||||
|
||||
class ImAssetGrid extends StatefulWidget {
|
||||
/// The padding for the grid
|
||||
@@ -76,7 +77,6 @@ class _ImAssetGridState extends State<ImAssetGrid> {
|
||||
}
|
||||
|
||||
final grid = FlutterListView(
|
||||
controller: _controller,
|
||||
delegate: FlutterListViewDelegate(
|
||||
(_, sectionIndex) {
|
||||
// ignore: avoid-unsafe-collection-methods
|
||||
@@ -89,70 +89,46 @@ class _ImAssetGridState extends State<ImAssetGrid> {
|
||||
RenderListMonthHeaderElement() =>
|
||||
_MonthHeader(text: section.header),
|
||||
RenderListDayHeaderElement() => Text(section.header),
|
||||
RenderListAssetElement() => FutureBuilder(
|
||||
future: context.read<AssetGridCubit>().loadAssets(
|
||||
section.assetOffset,
|
||||
section.assetCount,
|
||||
),
|
||||
builder: (_, assetsSnap) {
|
||||
final assets = assetsSnap.data;
|
||||
return GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
addAutomaticKeepAlives: false,
|
||||
cacheExtent: 100,
|
||||
padding: const EdgeInsets.all(0),
|
||||
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 || state.isDragScrolling
|
||||
? const ImImagePlaceholder()
|
||||
: ImThumbnail(asset),
|
||||
);
|
||||
},
|
||||
itemCount: section.assetCount,
|
||||
);
|
||||
},
|
||||
RenderListAssetElement() => _StaticGrid(
|
||||
section: section,
|
||||
isDragging: state.isDragScrolling,
|
||||
),
|
||||
};
|
||||
},
|
||||
childCount: elements.length,
|
||||
addAutomaticKeepAlives: false,
|
||||
),
|
||||
controller: _controller,
|
||||
);
|
||||
|
||||
final EdgeInsetsGeometry? padding;
|
||||
if (widget.topPadding != null) {
|
||||
padding = EdgeInsets.only(top: widget.topPadding!);
|
||||
} else {
|
||||
if (widget.topPadding == null) {
|
||||
padding = null;
|
||||
} else {
|
||||
padding = EdgeInsets.only(top: widget.topPadding!);
|
||||
}
|
||||
|
||||
return DraggableScrollbar(
|
||||
foregroundColor: context.colorScheme.onSurface,
|
||||
backgroundColor: context.colorScheme.surfaceContainerHighest,
|
||||
scrollStateListener:
|
||||
context.read<AssetGridCubit>().setDragScrolling,
|
||||
controller: _controller,
|
||||
maxItemCount: elements.length,
|
||||
scrollStateListener:
|
||||
context.read<AssetGridCubit>().setDragScrolling,
|
||||
backgroundColor: context.colorScheme.surfaceContainerHighest,
|
||||
foregroundColor: context.colorScheme.onSurface,
|
||||
padding: padding,
|
||||
scrollbarAnimationDuration: Durations.medium2,
|
||||
scrollbarTimeToFade: Durations.extralong4,
|
||||
labelTextBuilder: (int position) =>
|
||||
_labelBuilder(elements, position),
|
||||
labelConstraints: const BoxConstraints(maxHeight: 36),
|
||||
scrollbarAnimationDuration: Durations.medium2,
|
||||
scrollbarTimeToFade: Durations.extralong4,
|
||||
padding: padding,
|
||||
child: grid,
|
||||
);
|
||||
},
|
||||
// no.of elements are not equal or is modified
|
||||
buildWhen: (previous, current) =>
|
||||
(previous.renderList.elements.length !=
|
||||
current.renderList.elements.length) ||
|
||||
!previous.renderList.modifiedTime
|
||||
.isAtSameMomentAs(current.renderList.modifiedTime),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -10,8 +10,8 @@ class _HeaderText extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(
|
||||
top: 32.0,
|
||||
left: 16.0,
|
||||
top: 32.0,
|
||||
right: 24.0,
|
||||
bottom: 16.0,
|
||||
),
|
||||
@@ -40,9 +40,9 @@ class _MonthHeader extends StatelessWidget {
|
||||
return _HeaderText(
|
||||
text: text,
|
||||
style: context.textTheme.bodyLarge?.copyWith(
|
||||
color: context.colorScheme.onSurface,
|
||||
fontSize: 24.0,
|
||||
fontWeight: FontWeight.w500,
|
||||
color: context.colorScheme.onSurface,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
part of 'immich_asset_grid.widget.dart';
|
||||
|
||||
class _StaticGrid extends StatelessWidget {
|
||||
final RenderListAssetElement section;
|
||||
final bool isDragging;
|
||||
|
||||
const _StaticGrid({required this.section, required this.isDragging});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return FutureBuilder(
|
||||
future: context.read<AssetGridCubit>().loadAssets(
|
||||
section.assetOffset,
|
||||
section.assetCount,
|
||||
),
|
||||
builder: (_, assetsSnap) {
|
||||
final assets = assetsSnap.data;
|
||||
return GridView.builder(
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
shrinkWrap: true,
|
||||
padding: const EdgeInsets.all(0),
|
||||
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 || isDragging
|
||||
? const ImImagePlaceholder()
|
||||
: ImThumbnail(asset),
|
||||
);
|
||||
},
|
||||
itemCount: section.assetCount,
|
||||
addAutomaticKeepAlives: false,
|
||||
cacheExtent: 100,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -14,8 +14,8 @@ class ImRemoteThumbnailCacheManager extends CacheManager {
|
||||
: super(
|
||||
Config(
|
||||
kCacheThumbnailsKey,
|
||||
maxNrOfCacheObjects: kCacheMaxNrOfThumbnails,
|
||||
stalePeriod: const Duration(days: kCacheStalePeriod),
|
||||
maxNrOfCacheObjects: kCacheMaxNrOfThumbnails,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -33,8 +33,8 @@ class ImRemoteImageCacheManager extends CacheManager {
|
||||
: super(
|
||||
Config(
|
||||
kCacheFullImagesKey,
|
||||
maxNrOfCacheObjects: kCacheMaxNrOfFullImages,
|
||||
stalePeriod: const Duration(days: kCacheStalePeriod),
|
||||
maxNrOfCacheObjects: kCacheMaxNrOfFullImages,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -19,7 +19,7 @@ class ImageLoadingException implements Exception {
|
||||
///
|
||||
/// Credit to [flutter_cached_network_image](https://github.com/Baseflow/flutter_cached_network_image/blob/develop/cached_network_image/lib/src/image_provider/_image_loader.dart)
|
||||
/// for this wonderful implementation of their image loader
|
||||
class ImageLoader {
|
||||
abstract final class ImageLoader {
|
||||
static Future<ui.Codec> loadImageFromCache(
|
||||
String uri, {
|
||||
required CacheManager cache,
|
||||
@@ -28,8 +28,8 @@ class ImageLoader {
|
||||
}) async {
|
||||
final stream = cache.getFileStream(
|
||||
uri,
|
||||
withProgress: chunkEvents != null,
|
||||
headers: di<ImApiClient>().headers,
|
||||
withProgress: chunkEvents != null,
|
||||
);
|
||||
|
||||
await for (final result in stream) {
|
||||
@@ -44,8 +44,7 @@ class ImageLoader {
|
||||
} else if (result is FileInfo) {
|
||||
// We have the file
|
||||
final buffer = await ui.ImmutableBuffer.fromFilePath(result.file.path);
|
||||
final decoded = await decode(buffer);
|
||||
return decoded;
|
||||
return await decode(buffer);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import 'package:cached_network_image/cached_network_image.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:immich_mobile/utils/immich_api_client.dart';
|
||||
|
||||
class ImCachedNetworkImage extends CachedNetworkImage {
|
||||
ImCachedNetworkImage({
|
||||
super.key,
|
||||
required super.imageUrl,
|
||||
super.cacheKey,
|
||||
super.height,
|
||||
super.width,
|
||||
super.fit,
|
||||
super.placeholder,
|
||||
super.fadeInDuration,
|
||||
super.errorWidget,
|
||||
}) : super(httpHeaders: di<ImApiClient>().headers);
|
||||
}
|
||||
@@ -12,9 +12,9 @@ class ImImagePlaceholder extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
color: context.colorScheme.surfaceContainerHighest,
|
||||
width: 200,
|
||||
height: 200,
|
||||
color: context.colorScheme.surfaceContainerHighest,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -63,13 +63,8 @@ class ImImage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return OctoImage(
|
||||
fadeInDuration: const Duration(milliseconds: 0),
|
||||
fadeOutDuration: Durations.short4,
|
||||
placeholderBuilder: (_) => placeholder,
|
||||
image: ImImage.imageProvider(asset: asset),
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
placeholderBuilder: (_) => placeholder,
|
||||
errorBuilder: (_, error, stackTrace) {
|
||||
if (error is PlatformException &&
|
||||
error.code == "The asset not found!") {
|
||||
@@ -86,6 +81,11 @@ class ImImage extends StatelessWidget {
|
||||
color: context.colorScheme.primary,
|
||||
);
|
||||
},
|
||||
fadeOutDuration: Durations.short4,
|
||||
fadeInDuration: Duration.zero,
|
||||
width: width,
|
||||
height: height,
|
||||
fit: BoxFit.cover,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,13 +4,13 @@ import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||
|
||||
class ImLogo extends StatelessWidget {
|
||||
const ImLogo({
|
||||
this.width,
|
||||
this.dimension,
|
||||
this.filterQuality = FilterQuality.high,
|
||||
super.key,
|
||||
});
|
||||
|
||||
/// The width of the image.
|
||||
final double? width;
|
||||
/// The dimension of the image.
|
||||
final double? dimension;
|
||||
|
||||
/// The rendering quality
|
||||
final FilterQuality filterQuality;
|
||||
@@ -18,11 +18,12 @@ class ImLogo extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Image(
|
||||
width: width,
|
||||
filterQuality: filterQuality,
|
||||
semanticLabel: 'Immich Logo',
|
||||
image: Assets.images.immichLogo.provider(),
|
||||
semanticLabel: 'Immich Logo',
|
||||
width: dimension,
|
||||
height: dimension,
|
||||
isAntiAlias: true,
|
||||
filterQuality: filterQuality,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -43,10 +44,10 @@ class ImLogoText extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Image(
|
||||
semanticLabel: 'Immich Logo Text',
|
||||
image: (context.isDarkTheme
|
||||
? Assets.images.immichTextDark.provider
|
||||
: Assets.images.immichTextLight.provider)(),
|
||||
semanticLabel: 'Immich Logo Text',
|
||||
width: fontSize * 4,
|
||||
filterQuality: FilterQuality.high,
|
||||
);
|
||||
|
||||
@@ -77,9 +77,9 @@ class _PadAlignedIcon extends StatelessWidget {
|
||||
alignment: alignment,
|
||||
child: Icon(
|
||||
icon,
|
||||
color: Colors.white,
|
||||
size: 20,
|
||||
fill: (filled != null && filled!) ? 1 : null,
|
||||
color: Colors.white,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+3
-3
@@ -49,12 +49,12 @@ class ImLocalImageProvider extends ImageProvider<ImLocalImageProvider> {
|
||||
// Load a small thumbnail
|
||||
final thumbBytes =
|
||||
await di<IDeviceAssetRepository>().getThumbnail(a.localId!);
|
||||
if (thumbBytes != null) {
|
||||
if (thumbBytes == null) {
|
||||
debugPrint("Loading thumb for ${a.name} failed");
|
||||
} else {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("Loading thumb for ${a.name} failed");
|
||||
}
|
||||
|
||||
if (asset.isImage) {
|
||||
|
||||
+3
-3
@@ -56,12 +56,12 @@ class ImLocalThumbnailProvider extends ImageProvider<ImLocalThumbnailProvider> {
|
||||
// Load a small thumbnail
|
||||
final thumbBytes = await di<IDeviceAssetRepository>()
|
||||
.getThumbnail(a.localId!, width: 32, height: 32, quality: 75);
|
||||
if (thumbBytes != null) {
|
||||
if (thumbBytes == null) {
|
||||
debugPrint("Loading thumb for ${a.name} failed");
|
||||
} else {
|
||||
final buffer = await ui.ImmutableBuffer.fromUint8List(thumbBytes);
|
||||
final codec = await decode(buffer);
|
||||
yield codec;
|
||||
} else {
|
||||
debugPrint("Loading thumb for ${a.name} failed");
|
||||
}
|
||||
|
||||
final normalThumbBytes = await di<IDeviceAssetRepository>()
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import 'dart:typed_data';
|
||||
|
||||
final Uint8List kTransparentImage = Uint8List.fromList([
|
||||
0x89,
|
||||
0x50,
|
||||
0x4E,
|
||||
0x47,
|
||||
0x0D,
|
||||
0x0A,
|
||||
0x1A,
|
||||
0x0A,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0D,
|
||||
0x49,
|
||||
0x48,
|
||||
0x44,
|
||||
0x52,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x01,
|
||||
0x08,
|
||||
0x06,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x1F,
|
||||
0x15,
|
||||
0xC4,
|
||||
0x89,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x0A,
|
||||
0x49,
|
||||
0x44,
|
||||
0x41,
|
||||
0x54,
|
||||
0x78,
|
||||
0x9C,
|
||||
0x63,
|
||||
0x00,
|
||||
0x01,
|
||||
0x00,
|
||||
0x00,
|
||||
0x05,
|
||||
0x00,
|
||||
0x01,
|
||||
0x0D,
|
||||
0x0A,
|
||||
0x2D,
|
||||
0xB4,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x00,
|
||||
0x49,
|
||||
0x45,
|
||||
0x4E,
|
||||
0x44,
|
||||
0xAE,
|
||||
]);
|
||||
@@ -40,33 +40,33 @@ class ImPasswordFormField extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _ImPasswordFormFieldState extends State<ImPasswordFormField> {
|
||||
final showPassword = ValueNotifier(false);
|
||||
final _showPassword = ValueNotifier(false);
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
showPassword.dispose();
|
||||
_showPassword.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ValueListenableBuilder(
|
||||
valueListenable: showPassword,
|
||||
valueListenable: _showPassword,
|
||||
builder: (_, showPass, child) => ImTextFormField(
|
||||
controller: widget.controller,
|
||||
focusNode: widget.focusNode,
|
||||
onChanged: widget.onChanged,
|
||||
shouldObscure: !showPass,
|
||||
hint: widget.hint,
|
||||
label: widget.label,
|
||||
focusNode: widget.focusNode,
|
||||
suffixIcon: IconButton(
|
||||
onPressed: () => showPassword.value = !showPassword.value,
|
||||
onPressed: () => _showPassword.value = !_showPassword.value,
|
||||
icon: Icon(
|
||||
showPassword.value
|
||||
_showPassword.value
|
||||
? Symbols.visibility_off_rounded
|
||||
: Symbols.visibility_rounded,
|
||||
),
|
||||
),
|
||||
label: widget.label,
|
||||
hint: widget.hint,
|
||||
autoFillHints: const [AutofillHints.password],
|
||||
keyboardType: TextInputType.visiblePassword,
|
||||
textInputAction: widget.textInputAction,
|
||||
|
||||
@@ -29,18 +29,27 @@ class ImSwitchListTile<T> extends StatefulWidget {
|
||||
|
||||
class _ImSwitchListTileState<T> extends State<ImSwitchListTile<T>> {
|
||||
// Actual switch list state
|
||||
late bool isEnabled;
|
||||
late bool _isEnabled;
|
||||
final AppSettingService _appSettingService = di();
|
||||
|
||||
Future<void> set(bool enabled) async {
|
||||
if (isEnabled == enabled) return;
|
||||
Future<void> _set(bool enabled) async {
|
||||
if (_isEnabled == enabled) return;
|
||||
|
||||
final value = T != bool ? widget.toAppSetting!(enabled) : enabled as T;
|
||||
final value = T == bool ? enabled as T : widget.toAppSetting!(enabled);
|
||||
if (value != null &&
|
||||
await _appSettingService.upsert(widget.setting, value) &&
|
||||
context.mounted) {
|
||||
setState(() {
|
||||
isEnabled = enabled;
|
||||
_isEnabled = enabled;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _initSetting() async {
|
||||
final value = await _appSettingService.get(widget.setting);
|
||||
if (context.mounted) {
|
||||
setState(() {
|
||||
_isEnabled = T == bool ? value as bool : widget.fromAppSetting!(value);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -48,20 +57,14 @@ class _ImSwitchListTileState<T> extends State<ImSwitchListTile<T>> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_appSettingService.get(widget.setting).then((value) {
|
||||
if (context.mounted) {
|
||||
setState(() {
|
||||
isEnabled = T != bool ? widget.fromAppSetting!(value) : value as bool;
|
||||
});
|
||||
}
|
||||
});
|
||||
_initSetting().ignore();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SwitchListTile(
|
||||
value: isEnabled,
|
||||
onChanged: (value) => set(value),
|
||||
value: _isEnabled,
|
||||
onChanged: (value) => unawaited(_set(value)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -66,21 +66,21 @@ class ImTextFormField extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return TextFormField(
|
||||
controller: controller,
|
||||
onChanged: onChanged,
|
||||
focusNode: focusNode,
|
||||
obscureText: shouldObscure,
|
||||
validator: validator,
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
hintText: hint,
|
||||
suffixIcon: suffixIcon,
|
||||
),
|
||||
autofillHints: autoFillHints,
|
||||
keyboardType: keyboardType,
|
||||
textInputAction: textInputAction,
|
||||
readOnly: isDisabled,
|
||||
obscureText: shouldObscure,
|
||||
onChanged: onChanged,
|
||||
onTapOutside: (_) => FocusManager.instance.primaryFocus?.unfocus(),
|
||||
onFieldSubmitted: onSubmitted,
|
||||
validator: validator,
|
||||
autofillHints: autoFillHints,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,36 +1,44 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||
|
||||
class ImAdaptiveRoutePrimaryAppBar extends StatelessWidget
|
||||
class ImAdaptiveRouteAppBar extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
const ImAdaptiveRoutePrimaryAppBar({super.key});
|
||||
final String? title;
|
||||
final bool isPrimary;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
leading: BackButton(onPressed: () => context.router.root.maybePop()),
|
||||
);
|
||||
}
|
||||
/// Passed to [AppBar] actions
|
||||
final List<Widget>? actions;
|
||||
|
||||
const ImAdaptiveRouteAppBar({
|
||||
super.key,
|
||||
this.title,
|
||||
this.isPrimary = true,
|
||||
this.actions,
|
||||
});
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
// ignore: prefer-single-widget-per-file
|
||||
class ImAdaptiveRouteSecondaryAppBar extends StatelessWidget
|
||||
implements PreferredSizeWidget {
|
||||
const ImAdaptiveRouteSecondaryAppBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final Widget leading;
|
||||
if (isPrimary) {
|
||||
leading = BackButton(
|
||||
onPressed: () => unawaited(context.router.root.maybePop()),
|
||||
);
|
||||
} else {
|
||||
leading = context.isTablet
|
||||
? CloseButton(onPressed: () => unawaited(context.maybePop()))
|
||||
: BackButton(onPressed: () => unawaited(context.maybePop()));
|
||||
}
|
||||
|
||||
return AppBar(
|
||||
leading: context.isTablet
|
||||
? CloseButton(onPressed: () => context.maybePop())
|
||||
: BackButton(onPressed: () => context.maybePop()),
|
||||
leading: leading,
|
||||
title: title == null ? null : Text(title!),
|
||||
actions: actions,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@ class ImAdaptiveRouteWrapper extends StatelessWidget {
|
||||
return ImAdaptiveScaffoldBody(
|
||||
primaryBody: primaryBody,
|
||||
secondaryBody:
|
||||
ctx.topRoute.name != primaryRoute ? (_) => child : null,
|
||||
ctx.topRoute.name == primaryRoute ? null : (_) => child,
|
||||
bodyRatio: bodyRatio,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,14 +21,11 @@ class ImAdaptiveScaffoldBody extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AdaptiveLayout(
|
||||
internalAnimations: false,
|
||||
transitionDuration: Durations.medium2,
|
||||
bodyRatio: bodyRatio,
|
||||
body: SlotLayout(
|
||||
config: {
|
||||
Breakpoints.standard: SlotLayout.from(
|
||||
key: const Key('ImAdaptiveScaffold Body Standard'),
|
||||
builder: primaryBody,
|
||||
key: const Key('ImAdaptiveScaffold Body Standard'),
|
||||
),
|
||||
},
|
||||
),
|
||||
@@ -37,11 +34,14 @@ class ImAdaptiveScaffoldBody extends StatelessWidget {
|
||||
/// No secondary body in mobile layouts
|
||||
Breakpoints.small: SlotLayoutConfig.empty(),
|
||||
Breakpoints.mediumAndUp: SlotLayout.from(
|
||||
key: const Key('ImAdaptiveScaffold Secondary Body Medium'),
|
||||
builder: secondaryBody,
|
||||
key: const Key('ImAdaptiveScaffold Secondary Body Medium'),
|
||||
),
|
||||
},
|
||||
),
|
||||
bodyRatio: bodyRatio,
|
||||
transitionDuration: Durations.medium2,
|
||||
internalAnimations: false,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,6 +26,8 @@ class _HomePageState extends State<HomePage> {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final gridHasPadding = !context.isTablet && _showAppBar.value;
|
||||
|
||||
return Scaffold(
|
||||
body: BlocProvider(
|
||||
create: (_) => AssetGridCubit(
|
||||
@@ -33,32 +35,35 @@ class _HomePageState extends State<HomePage> {
|
||||
),
|
||||
child: Stack(children: [
|
||||
ImAssetGrid(
|
||||
topPadding: kToolbarHeight + context.mediaQueryPadding.top - 8,
|
||||
),
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _showAppBar,
|
||||
builder: (_, shouldShow, appBar) {
|
||||
final Duration duration;
|
||||
if (shouldShow) {
|
||||
// Animate out app bar slower
|
||||
duration = Durations.short3;
|
||||
} else {
|
||||
// Animate in app bar faster
|
||||
duration = Durations.medium2;
|
||||
}
|
||||
return AnimatedPositioned(
|
||||
duration: duration,
|
||||
curve: Curves.easeOut,
|
||||
left: 0,
|
||||
right: 0,
|
||||
top: shouldShow
|
||||
? 0
|
||||
: -(kToolbarHeight + context.mediaQueryPadding.top),
|
||||
child: appBar!,
|
||||
);
|
||||
},
|
||||
child: const ImAppBar(),
|
||||
topPadding: gridHasPadding
|
||||
? kToolbarHeight + context.mediaQueryPadding.top - 8
|
||||
: null,
|
||||
),
|
||||
if (!context.isTablet)
|
||||
ValueListenableBuilder(
|
||||
valueListenable: _showAppBar,
|
||||
builder: (_, shouldShow, appBar) {
|
||||
final Duration duration;
|
||||
if (shouldShow) {
|
||||
// Animate out app bar slower
|
||||
duration = Durations.short3;
|
||||
} else {
|
||||
// Animate in app bar faster
|
||||
duration = Durations.medium2;
|
||||
}
|
||||
return AnimatedPositioned(
|
||||
left: 0,
|
||||
top: shouldShow
|
||||
? 0
|
||||
: -(kToolbarHeight + context.mediaQueryPadding.top),
|
||||
right: 0,
|
||||
curve: Curves.easeOut,
|
||||
duration: duration,
|
||||
child: appBar!,
|
||||
);
|
||||
},
|
||||
child: const ImAppBar(),
|
||||
),
|
||||
]),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_bloc/flutter_bloc.dart';
|
||||
@@ -55,15 +57,15 @@ class _LoginPageState extends State<LoginPage>
|
||||
|
||||
void _onLoginPageStateChange(BuildContext context, LoginPageState state) {
|
||||
if (state.isLoginSuccessful) {
|
||||
context.replaceRoute(const TabControllerRoute());
|
||||
unawaited(context.replaceRoute(const TabControllerRoute()));
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final PreferredSizeWidget? appBar;
|
||||
late final Widget primaryBody;
|
||||
late final Widget secondaryBody;
|
||||
final Widget primaryBody;
|
||||
final Widget secondaryBody;
|
||||
|
||||
Widget rotatingLogo = GestureDetector(
|
||||
onDoubleTap: _populateDemoCredentials,
|
||||
@@ -73,7 +75,7 @@ class _LoginPageState extends State<LoginPage>
|
||||
children: [
|
||||
RotationTransition(
|
||||
turns: _animationController,
|
||||
child: const ImLogo(width: 100),
|
||||
child: const ImLogo(dimension: 100),
|
||||
),
|
||||
const SizedGap.lh(),
|
||||
const ImLogoText(),
|
||||
@@ -104,7 +106,7 @@ class _LoginPageState extends State<LoginPage>
|
||||
),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => context.navigateRoot(const LogsRoute()),
|
||||
onPressed: () => unawaited(context.navigateRoot(const LogsRoute())),
|
||||
child: const Text('Logs'),
|
||||
),
|
||||
],
|
||||
@@ -122,7 +124,9 @@ class _LoginPageState extends State<LoginPage>
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
child: InkWell(
|
||||
onTap: () => launchUrl(Uri.parse(_serverUrlController.text)),
|
||||
onTap: () => unawaited(
|
||||
launchUrl(Uri.parse(_serverUrlController.text)),
|
||||
),
|
||||
child: Text(
|
||||
_serverUrlController.text,
|
||||
textAlign: TextAlign.center,
|
||||
@@ -157,12 +161,12 @@ class _LoginPageState extends State<LoginPage>
|
||||
bottom,
|
||||
]),
|
||||
);
|
||||
secondaryBody = const SizedBox.shrink();
|
||||
}
|
||||
|
||||
return BlocListener<LoginPageCubit, LoginPageState>(
|
||||
listener: _onLoginPageStateChange,
|
||||
child: Scaffold(
|
||||
resizeToAvoidBottomInset: false,
|
||||
appBar: appBar,
|
||||
body: SafeArea(
|
||||
child: ImAdaptiveScaffoldBody(
|
||||
@@ -170,6 +174,7 @@ class _LoginPageState extends State<LoginPage>
|
||||
secondaryBody: (_) => secondaryBody,
|
||||
),
|
||||
),
|
||||
resizeToAvoidBottomInset: false,
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -182,13 +187,14 @@ class _MobileAppBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
Widget build(BuildContext context) {
|
||||
return AppBar(
|
||||
automaticallyImplyLeading: false,
|
||||
scrolledUnderElevation: 0.0,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: () => context.navigateRoot(const SettingsRoute()),
|
||||
onPressed: () =>
|
||||
unawaited(context.navigateRoot(const SettingsRoute())),
|
||||
icon: const Icon(Symbols.settings),
|
||||
),
|
||||
],
|
||||
scrolledUnderElevation: 0.0,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ class LoginPageCubit extends Cubit<LoginPageState> with LogMixin {
|
||||
// Check for /.well-known/immich
|
||||
url = await loginService.resolveEndpoint(uri);
|
||||
|
||||
di<IStoreRepository>().upsert(StoreKey.serverEndpoint, url);
|
||||
await di<IStoreRepository>().upsert(StoreKey.serverEndpoint, url);
|
||||
await di<LoginService>().handlePostUrlResolution(url);
|
||||
|
||||
emit(state.copyWith(isServerValidated: true));
|
||||
|
||||
@@ -34,14 +34,14 @@ class LoginForm extends StatelessWidget {
|
||||
builder: (_, isServerValidated) => SingleChildScrollView(
|
||||
child: AnimatedSwitcher(
|
||||
duration: Durations.medium1,
|
||||
layoutBuilder: (current, previous) =>
|
||||
current ?? (previous.lastOrNull ?? const SizedBox.shrink()),
|
||||
child: isServerValidated
|
||||
? _CredentialsForm(
|
||||
emailController: emailController,
|
||||
passwordController: passwordController,
|
||||
)
|
||||
: _ServerForm(controller: serverUrlController),
|
||||
layoutBuilder: (current, previous) =>
|
||||
current ?? (previous.lastOrNull ?? const SizedBox.shrink()),
|
||||
),
|
||||
),
|
||||
);
|
||||
@@ -75,13 +75,13 @@ class _ServerFormState extends State<_ServerForm> {
|
||||
child: BlocSelector<LoginPageCubit, LoginPageState, bool>(
|
||||
selector: (model) => model.isValidationInProgress,
|
||||
builder: (_, isValidationInProgress) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
ImTextFormField(
|
||||
controller: widget.controller,
|
||||
label: context.t.login.label.endpoint,
|
||||
validator: context.read<LoginPageCubit>().validateServerUrl,
|
||||
label: context.t.login.label.endpoint,
|
||||
autoFillHints: const [AutofillHints.url],
|
||||
keyboardType: TextInputType.url,
|
||||
textInputAction: TextInputAction.go,
|
||||
@@ -89,10 +89,10 @@ class _ServerFormState extends State<_ServerForm> {
|
||||
),
|
||||
const SizedGap.mh(),
|
||||
ImFilledButton(
|
||||
label: context.t.login.label.next_button,
|
||||
icon: Symbols.arrow_forward_rounded,
|
||||
onPressed: () => unawaited(_validateForm(context)),
|
||||
isDisabled: isValidationInProgress,
|
||||
label: context.t.login.label.next_button,
|
||||
),
|
||||
const SizedGap.mh(),
|
||||
if (isValidationInProgress) const ImLoadingIndicator(),
|
||||
@@ -117,11 +117,11 @@ class _CredentialsForm extends StatefulWidget {
|
||||
}
|
||||
|
||||
class _CredentialsFormState extends State<_CredentialsForm> {
|
||||
final passwordFocusNode = FocusNode();
|
||||
final _passwordFocusNode = FocusNode();
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
passwordFocusNode.dispose();
|
||||
_passwordFocusNode.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -134,28 +134,27 @@ class _CredentialsFormState extends State<_CredentialsForm> {
|
||||
: ValueListenableBuilder(
|
||||
valueListenable: di<ServerFeatureConfigProvider>(),
|
||||
builder: (_, state, __) => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (state.features.hasPasswordLogin) ...[
|
||||
ImTextFormField(
|
||||
controller: widget.emailController,
|
||||
label: context.t.login.label.email,
|
||||
isDisabled: isValidationInProgress,
|
||||
textInputAction: TextInputAction.next,
|
||||
onSubmitted: (_) => passwordFocusNode.requestFocus(),
|
||||
isDisabled: isValidationInProgress,
|
||||
onSubmitted: (_) => _passwordFocusNode.requestFocus(),
|
||||
),
|
||||
const SizedGap.mh(),
|
||||
ImPasswordFormField(
|
||||
controller: widget.passwordController,
|
||||
focusNode: _passwordFocusNode,
|
||||
label: context.t.login.label.password,
|
||||
focusNode: passwordFocusNode,
|
||||
isDisabled: isValidationInProgress,
|
||||
textInputAction: TextInputAction.go,
|
||||
isDisabled: isValidationInProgress,
|
||||
),
|
||||
const SizedGap.mh(),
|
||||
ImFilledButton(
|
||||
label: context.t.login.label.login_button,
|
||||
icon: Symbols.login_rounded,
|
||||
onPressed: () => unawaited(
|
||||
context.read<LoginPageCubit>().passwordLogin(
|
||||
@@ -163,31 +162,32 @@ class _CredentialsFormState extends State<_CredentialsForm> {
|
||||
password: widget.passwordController.text,
|
||||
),
|
||||
),
|
||||
label: context.t.login.label.login_button,
|
||||
),
|
||||
// Divider when both password and oAuth login is enabled
|
||||
if (state.features.hasOAuthLogin) const Divider(),
|
||||
],
|
||||
if (state.features.hasOAuthLogin)
|
||||
ImFilledButton(
|
||||
label: state.config.oauthButtonText ??
|
||||
context.t.login.label.oauth_button,
|
||||
icon: Symbols.pin_rounded,
|
||||
onPressed: () => unawaited(
|
||||
context.read<LoginPageCubit>().oAuthLogin(),
|
||||
),
|
||||
label: state.config.oauthButtonText ??
|
||||
context.t.login.label.oauth_button,
|
||||
),
|
||||
if (!state.features.hasPasswordLogin &&
|
||||
!state.features.hasOAuthLogin)
|
||||
ImFilledButton(
|
||||
label: context.t.login.label.login_disabled,
|
||||
isDisabled: true,
|
||||
label: context.t.login.label.login_disabled,
|
||||
),
|
||||
const SizedGap.sh(),
|
||||
ImTextButton(
|
||||
label: context.t.login.label.back_button,
|
||||
icon: Symbols.arrow_back_rounded,
|
||||
onPressed:
|
||||
context.read<LoginPageCubit>().resetServerValidation,
|
||||
label: context.t.login.label.back_button,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@RoutePage()
|
||||
class LogsPage extends StatelessWidget {
|
||||
const LogsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(body: Center(child: Text("Logs Page")));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:dynamic_color/dynamic_color.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/interfaces/log.interface.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/i18n/strings.g.dart';
|
||||
import 'package:immich_mobile/presentation/components/common/gap.widget.dart';
|
||||
import 'package:immich_mobile/presentation/components/common/skeletonized_future_builder.widget.dart';
|
||||
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_appbar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_wrapper.widget.dart';
|
||||
import 'package:immich_mobile/presentation/router/router.dart';
|
||||
import 'package:immich_mobile/presentation/theme/app_typography.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:immich_mobile/utils/constants/size_constants.dart';
|
||||
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||
import 'package:immich_mobile/utils/extensions/color.extension.dart';
|
||||
import 'package:immich_mobile/utils/log_manager.dart';
|
||||
import 'package:intl/intl.dart';
|
||||
import 'package:material_symbols_icons/material_symbols_icons.dart';
|
||||
import 'package:skeletonizer/skeletonizer.dart';
|
||||
|
||||
@RoutePage()
|
||||
class LogsWrapperPage extends StatelessWidget {
|
||||
const LogsWrapperPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImAdaptiveRouteWrapper(
|
||||
primaryRoute: LogsRoute.name,
|
||||
primaryBody: (_) => const LogsPage(),
|
||||
bodyRatio: RatioConstants.oneThird,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
class LogsPage extends StatefulWidget {
|
||||
const LogsPage({super.key});
|
||||
|
||||
@override
|
||||
State createState() => _LogsPageState();
|
||||
}
|
||||
|
||||
class _LogsPageState extends State<LogsPage> {
|
||||
void _onClearLogs() {
|
||||
// refetch logs on clear
|
||||
setState(() {
|
||||
unawaited(LogManager.I.clearLogs());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: ImAdaptiveRouteAppBar(
|
||||
title: context.t.logs.title,
|
||||
isPrimary: true,
|
||||
actions: [
|
||||
IconButton(
|
||||
onPressed: _onClearLogs,
|
||||
icon: Icon(Symbols.delete_rounded),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: SkeletonizedFutureBuilder(
|
||||
future: di<ILogRepository>().getAll(),
|
||||
builder: (_, data) => _LogList(logs: data!),
|
||||
loadingBuilder: (_) => const _LogListShimmer(),
|
||||
errorBuilder: (_, __) => const _LogListEmpty(),
|
||||
emptyBuilder: (_) => const _LogListEmpty(),
|
||||
emptyWhen: (data) => data == null || data.isEmpty,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogLevelIndicator extends StatelessWidget {
|
||||
final LogLevel level;
|
||||
|
||||
const _LogLevelIndicator({required this.level});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: switch (level) {
|
||||
LogLevel.info => context.colorScheme.primary,
|
||||
LogLevel.error ||
|
||||
LogLevel.wtf =>
|
||||
Colors.redAccent.harmonizeWith(context.colorScheme.primary),
|
||||
LogLevel.warning =>
|
||||
Colors.orangeAccent.harmonizeWith(context.colorScheme.primary),
|
||||
LogLevel.verbose ||
|
||||
LogLevel.debug =>
|
||||
Colors.grey.harmonizeWith(context.colorScheme.primary),
|
||||
},
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
width: 10,
|
||||
height: 10,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogList extends StatelessWidget {
|
||||
final List<LogMessage> logs;
|
||||
|
||||
const _LogList({required this.logs});
|
||||
|
||||
/// Truncate the log message to a [maxLines]] number of lines
|
||||
String _truncateLogMessage(String message) {
|
||||
final msg = message.split("\n").firstOrNull;
|
||||
return msg?.substring(0, 75.clamp(0, msg.length)) ?? message;
|
||||
}
|
||||
|
||||
Color _getTileColor(BuildContext context, LogLevel level) {
|
||||
return switch (level) {
|
||||
LogLevel.info => Colors.transparent,
|
||||
LogLevel.error || LogLevel.wtf => Colors.redAccent
|
||||
.harmonizeWith(context.colorScheme.primary)
|
||||
.withOpacity(RatioConstants.halfQuarter),
|
||||
LogLevel.warning => Colors.orangeAccent
|
||||
.harmonizeWith(context.colorScheme.primary)
|
||||
.withOpacity(RatioConstants.halfQuarter),
|
||||
LogLevel.verbose || LogLevel.debug => context.colorScheme.primary
|
||||
.harmonizeWith(context.colorScheme.primary)
|
||||
.withOpacity(RatioConstants.halfQuarter),
|
||||
};
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
itemBuilder: (_, i) {
|
||||
final log = logs[i];
|
||||
return ListTile(
|
||||
leading: _LogLevelIndicator(level: log.level),
|
||||
title: Text(
|
||||
_truncateLogMessage(log.content),
|
||||
style: AppTypography.bodyMedium,
|
||||
),
|
||||
subtitle: Text(
|
||||
"at ${DateFormat("HH:mm:ss.SSS").format(log.createdAt)} in ${log.logger ?? "<NA>"}",
|
||||
style: AppTypography.bodyMedium.copyWith(
|
||||
color: context.colorScheme.onSurface
|
||||
.darken(amount: RatioConstants.oneThird),
|
||||
),
|
||||
),
|
||||
trailing: const Icon(Symbols.arrow_forward_ios_rounded, size: 18),
|
||||
dense: true,
|
||||
visualDensity: VisualDensity.compact,
|
||||
tileColor: _getTileColor(context, log.level),
|
||||
minLeadingWidth: 10,
|
||||
);
|
||||
},
|
||||
separatorBuilder: (_, __) => Divider(height: 0),
|
||||
itemCount: logs.length,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogListShimmer extends StatelessWidget {
|
||||
const _LogListShimmer();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ListView.separated(
|
||||
itemBuilder: (_, __) => ListTile(
|
||||
leading: Bone.circle(size: 20),
|
||||
title: Bone.text(words: 3),
|
||||
subtitle: Bone.text(words: 1),
|
||||
),
|
||||
separatorBuilder: (_, __) => Divider(height: 5, thickness: 0.5),
|
||||
itemCount: 15,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LogListEmpty extends StatelessWidget {
|
||||
const _LogListEmpty();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(
|
||||
Symbols.comments_disabled_rounded,
|
||||
size: 50,
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
const SizedGap.mh(),
|
||||
Text(context.t.logs.no_logs),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,18 +5,18 @@ import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
enum SettingSection {
|
||||
general._(
|
||||
icon: Symbols.interests_rounded,
|
||||
labelKey: 'settings.sections.general',
|
||||
icon: Symbols.interests_rounded,
|
||||
destination: GeneralSettingsRoute(),
|
||||
),
|
||||
advance._(
|
||||
icon: Symbols.build_rounded,
|
||||
labelKey: 'settings.sections.advance',
|
||||
icon: Symbols.build_rounded,
|
||||
destination: AdvanceSettingsRoute(),
|
||||
),
|
||||
about._(
|
||||
icon: Symbols.help_rounded,
|
||||
labelKey: 'settings.sections.about',
|
||||
icon: Symbols.help_rounded,
|
||||
destination: AboutSettingsRoute(),
|
||||
);
|
||||
|
||||
|
||||
@@ -12,14 +12,14 @@ class AboutSettingsPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: const ImAdaptiveRouteSecondaryAppBar(),
|
||||
appBar: const ImAdaptiveRouteAppBar(isPrimary: false),
|
||||
body: ListTile(
|
||||
title: Text(context.t.settings.about.third_party_title),
|
||||
subtitle: Text(context.t.settings.about.third_party_sub_title),
|
||||
onTap: () => showLicensePage(
|
||||
context: context,
|
||||
applicationName: context.t.immich,
|
||||
applicationIcon: const ImLogo(width: SizeConstants.xl),
|
||||
applicationIcon: const ImLogo(dimension: SizeConstants.xl),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ class AdvanceSettingsPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
appBar: ImAdaptiveRouteSecondaryAppBar(),
|
||||
appBar: ImAdaptiveRouteAppBar(isPrimary: false),
|
||||
body: Center(child: Text('Advanced Settings')),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ class GeneralSettingsPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
appBar: ImAdaptiveRouteSecondaryAppBar(),
|
||||
appBar: ImAdaptiveRouteAppBar(isPrimary: false),
|
||||
body: Center(child: Text('General Settings')),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/i18n/strings.g.dart';
|
||||
@@ -15,32 +17,31 @@ class SettingsWrapperPage extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ImAdaptiveRouteWrapper(
|
||||
primaryBody: (_) => const SettingsPage(),
|
||||
primaryRoute: SettingsRoute.name,
|
||||
bodyRatio: BodyRatioConstants.oneThird,
|
||||
primaryBody: (_) => const SettingsPage(),
|
||||
bodyRatio: RatioConstants.oneThird,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
// ignore: prefer-single-widget-per-file
|
||||
class SettingsPage extends StatelessWidget {
|
||||
const SettingsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
appBar: const ImAdaptiveRoutePrimaryAppBar(),
|
||||
appBar: const ImAdaptiveRouteAppBar(isPrimary: true),
|
||||
body: ListView.builder(
|
||||
itemCount: SettingSection.values.length,
|
||||
itemBuilder: (_, index) {
|
||||
final section = SettingSection.values.elementAt(index);
|
||||
return ListTile(
|
||||
title: Text(context.t[section.labelKey]),
|
||||
onTap: () => context.navigateRoot(section.destination),
|
||||
leading: Icon(section.icon),
|
||||
title: Text(context.t[section.labelKey]),
|
||||
onTap: () => unawaited(context.navigateRoot(section.destination)),
|
||||
);
|
||||
},
|
||||
itemCount: SettingSection.values.length,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ class SplashScreenWrapperPage extends AutoRouter implements AutoRouteWrapper {
|
||||
}
|
||||
|
||||
@RoutePage()
|
||||
// ignore: prefer-single-widget-per-file
|
||||
class SplashScreenPage extends StatefulWidget {
|
||||
const SplashScreenPage({super.key});
|
||||
|
||||
@@ -63,7 +62,7 @@ class _SplashScreenState extends State<SplashScreenPage>
|
||||
future: di.allReady(),
|
||||
builder: (_, snap) {
|
||||
if (snap.hasData) {
|
||||
_tryLogin();
|
||||
unawaited(_tryLogin());
|
||||
} else if (snap.hasError) {
|
||||
log.wtf(
|
||||
"Error while initializing the app",
|
||||
@@ -75,7 +74,7 @@ class _SplashScreenState extends State<SplashScreenPage>
|
||||
return Center(
|
||||
child: RotationTransition(
|
||||
turns: _animationController,
|
||||
child: const ImLogo(width: 100),
|
||||
child: const ImLogo(dimension: 100),
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
@@ -2,7 +2,14 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_adaptive_scaffold/flutter_adaptive_scaffold.dart';
|
||||
import 'package:immich_mobile/i18n/strings.g.dart';
|
||||
import 'package:immich_mobile/presentation/components/common/immich_navigation_rail.dart';
|
||||
import 'package:immich_mobile/presentation/components/common/user_avatar.widget.dart';
|
||||
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
|
||||
import 'package:immich_mobile/presentation/router/router.dart';
|
||||
import 'package:immich_mobile/presentation/states/current_user.state.dart';
|
||||
import 'package:immich_mobile/service_locator.dart';
|
||||
import 'package:immich_mobile/utils/constants/size_constants.dart';
|
||||
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
@RoutePage()
|
||||
@@ -24,7 +31,7 @@ class TabControllerPage extends StatelessWidget {
|
||||
return PopScope(
|
||||
canPop: tabsRouter.activeIndex == 0,
|
||||
onPopInvokedWithResult: (didPop, _) =>
|
||||
!didPop ? tabsRouter.setActiveIndex(0) : null,
|
||||
didPop ? null : tabsRouter.setActiveIndex(0),
|
||||
child: _TabControllerAdaptiveScaffold(
|
||||
body: (ctxx) => child,
|
||||
selectedIndex: tabsRouter.activeIndex,
|
||||
@@ -80,53 +87,117 @@ class _TabControllerAdaptiveScaffold extends StatelessWidget {
|
||||
|
||||
return Scaffold(
|
||||
body: AdaptiveLayout(
|
||||
// No animation on layout change
|
||||
transitionDuration: Duration.zero,
|
||||
primaryNavigation: SlotLayout(
|
||||
config: <Breakpoint, SlotLayoutConfig>{
|
||||
Breakpoints.mediumAndUp: SlotLayout.from(
|
||||
key: const Key(
|
||||
'_TabControllerAdaptiveScaffold Primary Navigation Medium',
|
||||
),
|
||||
builder: (_) => AdaptiveScaffold.standardNavigationRail(
|
||||
selectedIndex: selectedIndex,
|
||||
builder: (_) => _ImNavigationRailBuilder(
|
||||
destinations: destinations
|
||||
.map((NavigationDestination destination) =>
|
||||
AdaptiveScaffold.toRailDestination(destination))
|
||||
.toList(),
|
||||
onDestinationSelected: onSelectedIndexChange,
|
||||
selectedIndex: selectedIndex,
|
||||
backgroundColor: navRailTheme.backgroundColor,
|
||||
leading: ImUserAvatar(
|
||||
user: di<CurrentUserProvider>().value,
|
||||
dimension: SizeConstants.m,
|
||||
radius: SizeConstants.m,
|
||||
),
|
||||
trailing: ImLogo(dimension: SizeConstants.xm),
|
||||
onDestinationSelected: onSelectedIndexChange,
|
||||
selectedIconTheme: navRailTheme.selectedIconTheme,
|
||||
unselectedIconTheme: navRailTheme.unselectedIconTheme,
|
||||
selectedLabelTextStyle: navRailTheme.selectedLabelTextStyle,
|
||||
unSelectedLabelTextStyle: navRailTheme.unselectedLabelTextStyle,
|
||||
),
|
||||
key: const Key(
|
||||
'_TabControllerAdaptiveScaffold Primary Navigation Medium',
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
body: SlotLayout(
|
||||
config: {
|
||||
Breakpoints.standard: SlotLayout.from(
|
||||
key: const Key('_TabControllerAdaptiveScaffold Body'),
|
||||
builder: body,
|
||||
key: const Key('_TabControllerAdaptiveScaffold Body'),
|
||||
),
|
||||
},
|
||||
),
|
||||
// No animation on layout change
|
||||
transitionDuration: Duration.zero,
|
||||
),
|
||||
bottomNavigationBar: SlotLayout(
|
||||
config: <Breakpoint, SlotLayoutConfig>{
|
||||
Breakpoints.small: SlotLayout.from(
|
||||
builder: (_) => AdaptiveScaffold.standardBottomNavigationBar(
|
||||
destinations: destinations,
|
||||
currentIndex: selectedIndex,
|
||||
onDestinationSelected: onSelectedIndexChange,
|
||||
),
|
||||
key: const Key(
|
||||
'_TabControllerAdaptiveScaffold Bottom Navigation Small',
|
||||
),
|
||||
builder: (_) => AdaptiveScaffold.standardBottomNavigationBar(
|
||||
currentIndex: selectedIndex,
|
||||
destinations: destinations,
|
||||
onDestinationSelected: onSelectedIndexChange,
|
||||
),
|
||||
),
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ImNavigationRailBuilder extends StatelessWidget {
|
||||
final List<NavigationRailDestination> destinations;
|
||||
final int? selectedIndex;
|
||||
final Color? backgroundColor;
|
||||
final Function(int)? onDestinationSelected;
|
||||
final IconThemeData? selectedIconTheme;
|
||||
final IconThemeData? unselectedIconTheme;
|
||||
final TextStyle? selectedLabelTextStyle;
|
||||
final TextStyle? unSelectedLabelTextStyle;
|
||||
final Widget? leading;
|
||||
final Widget? trailing;
|
||||
|
||||
const _ImNavigationRailBuilder({
|
||||
required this.destinations,
|
||||
this.selectedIndex,
|
||||
this.backgroundColor,
|
||||
this.leading,
|
||||
this.trailing,
|
||||
this.onDestinationSelected,
|
||||
this.selectedIconTheme,
|
||||
this.unselectedIconTheme,
|
||||
this.selectedLabelTextStyle,
|
||||
this.unSelectedLabelTextStyle,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Builder(builder: (BuildContext _) {
|
||||
return SizedBox(
|
||||
width: 72,
|
||||
height: context.height,
|
||||
child: LayoutBuilder(
|
||||
builder: (BuildContext _, BoxConstraints constraints) {
|
||||
return ConstrainedBox(
|
||||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||
child: IntrinsicHeight(
|
||||
child: ImNavigationRail(
|
||||
backgroundColor: backgroundColor,
|
||||
leading: leading,
|
||||
trailing: trailing,
|
||||
destinations: destinations,
|
||||
selectedIndex: selectedIndex,
|
||||
onDestinationSelected: onDestinationSelected,
|
||||
labelType: NavigationRailLabelType.none,
|
||||
unselectedLabelTextStyle: unSelectedLabelTextStyle,
|
||||
selectedLabelTextStyle: selectedLabelTextStyle,
|
||||
unselectedIconTheme: unselectedIconTheme,
|
||||
selectedIconTheme: selectedIconTheme,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@ import 'package:auto_route/auto_route.dart';
|
||||
import 'package:immich_mobile/presentation/modules/home/pages/home.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/library/pages/library.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/login/pages/login.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/logs/pages/log.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/logs/pages/logs.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/search/pages/search.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/settings/pages/about_settings.page.dart';
|
||||
import 'package:immich_mobile/presentation/modules/settings/pages/advance_settings.page.dart';
|
||||
@@ -29,12 +29,15 @@ class AppRouter extends RootStackRouter {
|
||||
List<AutoRoute> get routes => [
|
||||
AutoRoute(
|
||||
page: SplashScreenWrapperRoute.page,
|
||||
initial: true,
|
||||
children: [
|
||||
AutoRoute(page: SplashScreenRoute.page, initial: true),
|
||||
AutoRoute(page: LoginRoute.page),
|
||||
],
|
||||
initial: true,
|
||||
),
|
||||
AutoRoute(page: LogsWrapperRoute.page, children: [
|
||||
AutoRoute(page: LogsRoute.page),
|
||||
]),
|
||||
AutoRoute(page: LogsRoute.page),
|
||||
AutoRoute(page: TabControllerRoute.page, children: [
|
||||
AutoRoute(page: HomeRoute.page),
|
||||
|
||||
@@ -19,7 +19,7 @@ class AppThemeProvider extends ValueNotifier<AppTheme> {
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_appSettingSubscription.cancel();
|
||||
unawaited(_appSettingSubscription.cancel());
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:device_info_plus/device_info_plus.dart';
|
||||
@@ -17,7 +18,7 @@ enum GalleryPermissionStatus {
|
||||
|
||||
class GalleryPermissionProvider extends ValueNotifier<GalleryPermissionStatus> {
|
||||
GalleryPermissionProvider() : super(GalleryPermissionStatus.yetToRequest) {
|
||||
checkPermission();
|
||||
unawaited(checkPermission());
|
||||
}
|
||||
|
||||
bool get hasPermission => value.isGranted || value.isLimited;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
@immutable
|
||||
abstract class AppColors {
|
||||
abstract final class AppColors {
|
||||
const AppColors();
|
||||
|
||||
/// Blue color
|
||||
@@ -25,9 +25,9 @@ abstract class AppColors {
|
||||
onErrorContainer: Color(0xff410002),
|
||||
surface: Color(0xFFF0EFF4),
|
||||
onSurface: Color(0xff1a1b21),
|
||||
onSurfaceVariant: Color(0xff444651),
|
||||
surfaceContainer: Color(0xfffefbff),
|
||||
surfaceContainerHighest: Color(0xffe0e2ef),
|
||||
onSurfaceVariant: Color(0xff444651),
|
||||
outline: Color(0xff747782),
|
||||
outlineVariant: Color(0xffc4c6d3),
|
||||
shadow: Color(0xff000000),
|
||||
@@ -58,9 +58,9 @@ abstract class AppColors {
|
||||
onErrorContainer: Color(0xffffb4ab),
|
||||
surface: Color(0xFF15181C),
|
||||
onSurface: Color(0xffe2e2e9),
|
||||
onSurfaceVariant: Color(0xffc2c6d2),
|
||||
surfaceContainer: Color(0xff1a1e22),
|
||||
surfaceContainerHighest: Color(0xff424852),
|
||||
onSurfaceVariant: Color(0xffc2c6d2),
|
||||
outline: Color(0xff8c919c),
|
||||
outlineVariant: Color(0xff424751),
|
||||
shadow: Color(0xff000000),
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/presentation/theme/app_colors.dart';
|
||||
import 'package:immich_mobile/presentation/theme/app_typography.dart';
|
||||
import 'package:immich_mobile/utils/extensions/material_state.extension.dart';
|
||||
import 'package:material_symbols_icons/symbols.dart';
|
||||
|
||||
enum AppTheme {
|
||||
blue._(AppColors.blueLight, AppColors.blueDark),
|
||||
@@ -15,9 +16,58 @@ enum AppTheme {
|
||||
|
||||
static ThemeData generateThemeData(ColorScheme color) {
|
||||
return ThemeData(
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: color.error),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: color.primary),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: color.error),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: color.outlineVariant),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
),
|
||||
colorScheme: color,
|
||||
primaryColor: color.primary,
|
||||
iconTheme: const IconThemeData(weight: 500, opticalSize: 24),
|
||||
scaffoldBackgroundColor: color.surface,
|
||||
iconTheme: const IconThemeData(size: 24, weight: 500, opticalSize: 24),
|
||||
textTheme: TextTheme(
|
||||
displayLarge: AppTypography.displayLarge,
|
||||
displayMedium: AppTypography.displayMedium,
|
||||
displaySmall: AppTypography.displaySmall,
|
||||
headlineLarge: AppTypography.headlineLarge,
|
||||
headlineMedium: AppTypography.headlineMedium,
|
||||
headlineSmall: AppTypography.headlineSmall,
|
||||
titleLarge: AppTypography.titleLarge,
|
||||
titleMedium: AppTypography.titleMedium,
|
||||
titleSmall: AppTypography.titleSmall,
|
||||
bodyLarge: AppTypography.bodyLarge,
|
||||
bodyMedium: AppTypography.bodyMedium,
|
||||
bodySmall: AppTypography.bodySmall,
|
||||
labelLarge: AppTypography.labelLarge,
|
||||
labelMedium: AppTypography.labelMedium,
|
||||
labelSmall: AppTypography.labelSmall,
|
||||
),
|
||||
actionIconTheme: ActionIconThemeData(
|
||||
backButtonIconBuilder: (_) => Icon(Symbols.arrow_back_rounded),
|
||||
closeButtonIconBuilder: (_) => Icon(Symbols.close_rounded),
|
||||
),
|
||||
appBarTheme: AppBarTheme(
|
||||
iconTheme: IconThemeData(size: 22, color: color.onSurface),
|
||||
titleTextStyle:
|
||||
AppTypography.titleLarge.copyWith(color: color.onSurface),
|
||||
),
|
||||
navigationBarTheme: NavigationBarThemeData(
|
||||
backgroundColor: color.surfaceContainer,
|
||||
indicatorColor: color.primary,
|
||||
@@ -30,78 +80,38 @@ enum AppTheme {
|
||||
},
|
||||
),
|
||||
),
|
||||
scaffoldBackgroundColor: color.surface,
|
||||
navigationRailTheme: NavigationRailThemeData(
|
||||
backgroundColor: color.surfaceContainer,
|
||||
elevation: 3,
|
||||
indicatorColor: color.primary,
|
||||
selectedIconTheme:
|
||||
IconThemeData(weight: 500, opticalSize: 24, color: color.onPrimary),
|
||||
unselectedIconTheme: IconThemeData(
|
||||
weight: 500,
|
||||
opticalSize: 24,
|
||||
color: color.onSurface.withAlpha(175),
|
||||
),
|
||||
selectedIconTheme:
|
||||
IconThemeData(weight: 500, opticalSize: 24, color: color.onPrimary),
|
||||
indicatorColor: color.primary,
|
||||
),
|
||||
inputDecorationTheme: InputDecorationTheme(
|
||||
focusedBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: color.primary),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
enabledBorder: OutlineInputBorder(
|
||||
borderSide: BorderSide(color: color.outlineVariant),
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
),
|
||||
errorBorder: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
borderSide: BorderSide(color: color.error),
|
||||
),
|
||||
focusedErrorBorder: OutlineInputBorder(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(15)),
|
||||
borderSide: BorderSide(color: color.error),
|
||||
),
|
||||
hintStyle: const TextStyle(
|
||||
fontSize: 16.0,
|
||||
fontWeight: FontWeight.normal,
|
||||
),
|
||||
),
|
||||
textSelectionTheme: TextSelectionThemeData(cursorColor: color.primary),
|
||||
sliderTheme: SliderThemeData(
|
||||
valueIndicatorColor:
|
||||
Color.alphaBlend(color.primary.withAlpha(80), color.onSurface)
|
||||
.withAlpha(240),
|
||||
),
|
||||
textTheme: TextTheme(
|
||||
titleLarge: AppTypography.titleLarge,
|
||||
titleMedium: AppTypography.titleMedium,
|
||||
titleSmall: AppTypography.titleSmall,
|
||||
displayLarge: AppTypography.displayLarge,
|
||||
displayMedium: AppTypography.displayMedium,
|
||||
displaySmall: AppTypography.displaySmall,
|
||||
headlineLarge: AppTypography.headlineLarge,
|
||||
headlineMedium: AppTypography.headlineMedium,
|
||||
headlineSmall: AppTypography.headlineSmall,
|
||||
bodyLarge: AppTypography.bodyLarge,
|
||||
bodyMedium: AppTypography.bodyMedium,
|
||||
bodySmall: AppTypography.bodySmall,
|
||||
labelLarge: AppTypography.labelLarge,
|
||||
labelMedium: AppTypography.labelMedium,
|
||||
labelSmall: AppTypography.labelSmall,
|
||||
),
|
||||
snackBarTheme: SnackBarThemeData(
|
||||
elevation: 4,
|
||||
behavior: SnackBarBehavior.floating,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(4.0)),
|
||||
),
|
||||
insetPadding: const EdgeInsets.fromLTRB(20.0, 5.0, 20.0, 25.0),
|
||||
backgroundColor:
|
||||
Color.alphaBlend(color.primary.withAlpha(80), color.onSurface)
|
||||
.withAlpha(240),
|
||||
actionTextColor: color.inversePrimary,
|
||||
contentTextStyle: TextStyle(color: color.onInverseSurface),
|
||||
elevation: 4,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(4.0)),
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
insetPadding: const EdgeInsets.fromLTRB(20.0, 5.0, 20.0, 25.0),
|
||||
closeIconColor: color.onInverseSurface,
|
||||
),
|
||||
textSelectionTheme: TextSelectionThemeData(cursorColor: color.primary),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class AppTypography {
|
||||
abstract final class AppTypography {
|
||||
const AppTypography();
|
||||
|
||||
static const TextStyle displayLarge = TextStyle(
|
||||
@@ -30,16 +30,16 @@ class AppTypography {
|
||||
);
|
||||
|
||||
static const TextStyle titleLarge = TextStyle(
|
||||
fontSize: 22,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.normal,
|
||||
);
|
||||
static const TextStyle titleMedium = TextStyle(
|
||||
fontSize: 16,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.normal,
|
||||
);
|
||||
static const TextStyle titleSmall = TextStyle(
|
||||
fontSize: 14,
|
||||
fontWeight: FontWeight.bold,
|
||||
fontWeight: FontWeight.normal,
|
||||
);
|
||||
|
||||
static const TextStyle bodyLarge = TextStyle(
|
||||
|
||||
Reference in New Issue
Block a user