more refactors

This commit is contained in:
shenlong-tanwen 2024-10-20 16:50:34 +05:30
parent 7ea21d636f
commit 8f47645cdb
35 changed files with 399 additions and 83 deletions

View File

@ -1,4 +1,5 @@
{ {
"immich": "Immich",
"tab_controller": { "tab_controller": {
"photos": "Photos", "photos": "Photos",
"search": "Search", "search": "Search",
@ -34,5 +35,8 @@
"oauth_button": "OAuth", "oauth_button": "OAuth",
"login_disabled": "Login Disabled" "login_disabled": "Login Disabled"
} }
},
"logs": {
"title": "Logs"
} }
} }

View File

@ -11,4 +11,7 @@ abstract interface class IAlbumRepository {
/// Removes album with the given [id] /// Removes album with the given [id]
FutureOr<void> deleteId(int id); FutureOr<void> deleteId(int id);
/// Removes all albums
FutureOr<void> deleteAll();
} }

View File

@ -14,4 +14,7 @@ abstract interface class IAlbumToAssetRepository {
/// Removes album with the given [albumId] /// Removes album with the given [albumId]
FutureOr<void> deleteAlbumId(int albumId); FutureOr<void> deleteAlbumId(int albumId);
/// Removes all album to asset mappings
FutureOr<void> deleteAll();
} }

View File

@ -8,4 +8,7 @@ abstract interface class IAlbumETagRepository {
/// Fetches the album etag for the given [albumId] /// Fetches the album etag for the given [albumId]
FutureOr<AlbumETag?> get(int albumId); FutureOr<AlbumETag?> get(int albumId);
/// Removes all album eTags
FutureOr<void> deleteAll();
} }

View File

@ -8,4 +8,7 @@ abstract interface class IUserRepository {
/// Fetches user /// Fetches user
FutureOr<User?> getForId(String userId); FutureOr<User?> getForId(String userId);
/// Removes all users
FutureOr<void> deleteAll();
} }

View File

@ -11,7 +11,7 @@ class AlbumETag {
required this.modifiedTime, required this.modifiedTime,
}); });
factory AlbumETag.empty() { factory AlbumETag.initial() {
return AlbumETag( return AlbumETag(
albumId: -1, albumId: -1,
assetCount: 0, assetCount: 0,

View File

@ -18,6 +18,35 @@ sealed class RenderListElement {
int get hashCode => date.hashCode; int get hashCode => date.hashCode;
} }
/// Used to pad the render list elements
class RenderListPaddingElement extends RenderListElement {
final double topPadding;
const RenderListPaddingElement({
required this.topPadding,
required super.date,
});
factory RenderListPaddingElement.beforeElement({
required double top,
RenderListElement? before,
}) =>
RenderListPaddingElement(
topPadding: top,
date: before?.date ?? DateTime.now(),
);
@override
bool operator ==(covariant RenderListPaddingElement other) {
if (identical(this, other)) return true;
return super == other && other.topPadding == topPadding;
}
@override
int get hashCode => super.hashCode ^ topPadding.hashCode;
}
class RenderListMonthHeaderElement extends RenderListElement { class RenderListMonthHeaderElement extends RenderListElement {
late final String header; late final String header;

View File

@ -50,7 +50,12 @@ class AlbumRepository with LogMixin implements IAlbumRepository {
@override @override
FutureOr<void> deleteId(int id) async { FutureOr<void> deleteId(int id) async {
await _db.asset.deleteWhere((row) => row.id.equals(id)); await _db.album.deleteWhere((row) => row.id.equals(id));
}
@override
FutureOr<void> deleteAll() async {
await _db.album.deleteAll();
} }
} }

View File

@ -75,4 +75,9 @@ class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository {
FutureOr<void> deleteAlbumId(int albumId) async { FutureOr<void> deleteAlbumId(int albumId) async {
await _db.albumToAsset.deleteWhere((row) => row.albumId.equals(albumId)); await _db.albumToAsset.deleteWhere((row) => row.albumId.equals(albumId));
} }
@override
FutureOr<void> deleteAll() async {
await _db.albumToAsset.deleteAll();
}
} }

View File

@ -33,6 +33,11 @@ class AlbumETagRepository with LogMixin implements IAlbumETagRepository {
..where((r) => r.albumId.equals(albumId)); ..where((r) => r.albumId.equals(albumId));
return await query.map(_toModel).getSingleOrNull(); return await query.map(_toModel).getSingleOrNull();
} }
@override
FutureOr<void> deleteAll() async {
await _db.albumETag.deleteAll();
}
} }
AlbumETagCompanion _toEntity(AlbumETag albumETag) { AlbumETagCompanion _toEntity(AlbumETag albumETag) {

View File

@ -44,6 +44,11 @@ class UserRepository with LogMixin implements IUserRepository {
return false; return false;
} }
} }
@override
FutureOr<void> deleteAll() async {
await _db.user.deleteAll();
}
} }
User _toModel(UserData user) { User _toModel(UserData user) {

View File

@ -22,6 +22,7 @@ class AlbumSyncService with LogMixin {
Future<bool> performFullDeviceSync() async { Future<bool> performFullDeviceSync() async {
try { try {
final Stopwatch stopwatch = Stopwatch()..start();
final deviceAlbums = await di<IDeviceAlbumRepository>().getAll(); final deviceAlbums = await di<IDeviceAlbumRepository>().getAll();
final dbAlbums = await di<IAlbumRepository>().getAll(localOnly: true); final dbAlbums = await di<IAlbumRepository>().getAll(localOnly: true);
final hasChange = await CollectionUtil.diffLists( final hasChange = await CollectionUtil.diffLists(
@ -34,6 +35,7 @@ class AlbumSyncService with LogMixin {
onlySecond: _addDeviceAlbum, onlySecond: _addDeviceAlbum,
); );
log.i("Full device sync took - ${stopwatch.elapsedMilliseconds}ms");
return hasChange; return hasChange;
} catch (e, s) { } catch (e, s) {
log.e("Error performing full device sync", e, s); log.e("Error performing full device sync", e, s);
@ -47,8 +49,8 @@ class AlbumSyncService with LogMixin {
DateTime? modifiedUntil, DateTime? modifiedUntil,
}) async { }) async {
assert(dbAlbum.id != null, "Album ID from DB is null"); assert(dbAlbum.id != null, "Album ID from DB is null");
final albumEtag = final albumEtag = await di<IAlbumETagRepository>().get(dbAlbum.id!) ??
await di<IAlbumETagRepository>().get(dbAlbum.id!) ?? AlbumETag.empty(); AlbumETag.initial();
final assetCountInDevice = final assetCountInDevice =
await di<IDeviceAlbumRepository>().getAssetCount(deviceAlbum.localId!); await di<IDeviceAlbumRepository>().getAssetCount(deviceAlbum.localId!);
@ -66,6 +68,7 @@ class AlbumSyncService with LogMixin {
Future<void> _addDeviceAlbum(Album album, {DateTime? modifiedUntil}) async { Future<void> _addDeviceAlbum(Album album, {DateTime? modifiedUntil}) async {
try { try {
log.i("Syncing device album ${album.name}");
final albumId = (await di<IAlbumRepository>().upsert(album))?.id; final albumId = (await di<IAlbumRepository>().upsert(album))?.id;
// break fast if we cannot add an album // break fast if we cannot add an album
if (albumId == null) { if (albumId == null) {
@ -115,6 +118,7 @@ class AlbumSyncService with LogMixin {
Future<void> _removeDeviceAlbum(Album album) async { Future<void> _removeDeviceAlbum(Album album) async {
assert(album.id != null, "Album ID from DB is null"); assert(album.id != null, "Album ID from DB is null");
log.i("Removing device album ${album.name}");
final albumId = album.id!; final albumId = album.id!;
try { try {
await di<IDatabaseRepository>().txn(() async { await di<IDatabaseRepository>().txn(() async {

View File

@ -35,6 +35,7 @@ class AssetSyncService with LogMixin {
int? limit, int? limit,
}) async { }) async {
try { try {
final Stopwatch stopwatch = Stopwatch()..start();
final db = di<IDatabaseRepository>(); final db = di<IDatabaseRepository>();
final assetRepo = di<IAssetRepository>(); final assetRepo = di<IAssetRepository>();
final syncApiRepo = di<ISyncApiRepository>(); final syncApiRepo = di<ISyncApiRepository>();
@ -74,6 +75,7 @@ class AssetSyncService with LogMixin {
if (assetsFromServer.length != chunkSize) break; if (assetsFromServer.length != chunkSize) break;
} }
log.i("Full remote sync took - ${stopwatch.elapsedMilliseconds}ms");
return true; return true;
} catch (e, s) { } catch (e, s) {
log.e("Error performing full remote sync for user - ${user.name}", e, s); log.e("Error performing full remote sync for user - ${user.name}", e, s);

View File

@ -1,14 +1,25 @@
import 'dart:async';
import 'dart:convert'; import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter_web_auth_2/flutter_web_auth_2.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:immich_mobile/domain/interfaces/album.interface.dart';
import 'package:immich_mobile/domain/interfaces/album_asset.interface.dart';
import 'package:immich_mobile/domain/interfaces/album_etag.interface.dart';
import 'package:immich_mobile/domain/interfaces/api/authentication_api.interface.dart'; import 'package:immich_mobile/domain/interfaces/api/authentication_api.interface.dart';
import 'package:immich_mobile/domain/interfaces/api/server_api.interface.dart'; import 'package:immich_mobile/domain/interfaces/api/server_api.interface.dart';
import 'package:immich_mobile/domain/interfaces/api/user_api.interface.dart'; import 'package:immich_mobile/domain/interfaces/api/user_api.interface.dart';
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/domain/interfaces/user.interface.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/user.model.dart';
import 'package:immich_mobile/domain/services/album_sync.service.dart';
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
import 'package:immich_mobile/presentation/states/gallery_permission.state.dart';
import 'package:immich_mobile/presentation/states/server_feature_config.state.dart';
import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/immich_api_client.dart'; import 'package:immich_mobile/utils/immich_api_client.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart';
@ -99,6 +110,38 @@ class LoginService with LogMixin {
return null; return null;
} }
Future<void> handlePostUrlResolution(String serverEndpoint) async {
await ServiceLocator.registerApiClient(serverEndpoint);
ServiceLocator.registerPostGlobalStates();
// Fetch server features
await di<ServerFeatureConfigProvider>().getFeatures();
}
Future<User?> handlePostLogin() async {
final user = await di<IUserApiRepository>().getMyUser().timeout(
const Duration(seconds: 10),
// ignore: function-always-returns-null
onTimeout: () {
log.w("Timedout while fetching user details using saved credentials");
return null;
},
);
if (user == null) {
return null;
}
ServiceLocator.registerCurrentUser(user);
// sync assets in background
unawaited(di<AssetSyncService>().performFullRemoteSyncIsolate(user));
if (di<GalleryPermissionProvider>().hasPermission) {
unawaited(di<AlbumSyncService>().performFullDeviceSyncIsolate());
}
return user;
}
Future<bool> tryAutoLogin() async { Future<bool> tryAutoLogin() async {
final serverEndpoint = final serverEndpoint =
await di<IStoreRepository>().tryGet(StoreKey.serverEndpoint); await di<IStoreRepository>().tryGet(StoreKey.serverEndpoint);
@ -106,8 +149,7 @@ class LoginService with LogMixin {
return false; return false;
} }
await ServiceLocator.registerApiClient(serverEndpoint); await handlePostUrlResolution(serverEndpoint);
ServiceLocator.registerPostGlobalStates();
final accessToken = final accessToken =
await di<IStoreRepository>().tryGet(StoreKey.accessToken); await di<IStoreRepository>().tryGet(StoreKey.accessToken);
@ -118,19 +160,20 @@ class LoginService with LogMixin {
// Set token to interceptor // Set token to interceptor
await di<ImApiClient>().init(accessToken: accessToken); await di<ImApiClient>().init(accessToken: accessToken);
final user = await di<IUserApiRepository>().getMyUser().timeout( final user = await handlePostLogin();
const Duration(seconds: 10),
// ignore: function-always-returns-null
onTimeout: () {
log.w("Timedout while fetching user details using saved credentials");
return null;
},
);
if (user == null) { if (user == null) {
return false; return false;
} }
ServiceLocator.registerCurrentUser(user);
return true; return true;
} }
Future<void> logout() async {
// Remove existing assets
await di<IAssetRepository>().deleteAll();
await di<IAlbumRepository>().deleteAll();
await di<IAlbumToAssetRepository>().deleteAll();
await di<IAlbumETagRepository>().deleteAll();
await di<IUserRepository>().deleteAll();
}
} }

View File

@ -0,0 +1,20 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
class ImAppBar extends StatelessWidget implements PreferredSizeWidget {
const ImAppBar({super.key});
@override
Size get preferredSize => const Size.fromHeight(kToolbarHeight);
@override
Widget build(BuildContext context) {
return AppBar(
backgroundColor: context.theme.appBarTheme.backgroundColor,
automaticallyImplyLeading: false,
centerTitle: false,
title: Text(context.t.immich),
);
}
}

View File

@ -75,8 +75,8 @@ class DraggableScrollbar extends StatefulWidget {
this.backgroundColor = Colors.white, this.backgroundColor = Colors.white,
this.foregroundColor = Colors.black, this.foregroundColor = Colors.black,
this.padding, this.padding,
this.scrollbarAnimationDuration = const Duration(milliseconds: 300), this.scrollbarAnimationDuration = Durations.medium2,
this.scrollbarTimeToFade = const Duration(milliseconds: 600), this.scrollbarTimeToFade = Durations.long4,
this.labelTextBuilder, this.labelTextBuilder,
this.labelConstraints, this.labelConstraints,
}) : assert(child.scrollDirection == Axis.vertical), }) : assert(child.scrollDirection == Axis.vertical),
@ -219,6 +219,10 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
Timer? _fadeoutTimer; Timer? _fadeoutTimer;
List<FlutterListViewItemPosition> _positions = []; List<FlutterListViewItemPosition> _positions = [];
/// The controller can have only one active callback
/// cache the old one, invoke it in the new callback and restore it on dispose
FlutterSliverListControllerOnPaintItemPositionCallback? _oldCallback;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -246,14 +250,19 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
curve: Curves.fastOutSlowIn, curve: Curves.fastOutSlowIn,
); );
_oldCallback =
widget.controller.sliverController.onPaintItemPositionsCallback;
widget.controller.sliverController.onPaintItemPositionsCallback = widget.controller.sliverController.onPaintItemPositionsCallback =
(height, pos) { (height, pos) {
_positions = pos; _positions = pos;
_oldCallback?.call(height, pos);
}; };
} }
@override @override
void dispose() { void dispose() {
widget.controller.sliverController.onPaintItemPositionsCallback =
_oldCallback;
_thumbAnimationController.dispose(); _thumbAnimationController.dispose();
_labelAnimationController.dispose(); _labelAnimationController.dispose();
_fadeoutTimer?.cancel(); _fadeoutTimer?.cancel();
@ -304,7 +313,9 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
} }
double get _barMaxScrollExtent => double get _barMaxScrollExtent =>
(context.size?.height ?? 0) - widget.heightScrollThumb; (context.size?.height ?? 0) -
widget.heightScrollThumb -
(widget.padding?.vertical ?? 0);
double get _maxScrollRatio => double get _maxScrollRatio =>
_barMaxScrollExtent / widget.controller.position.maxScrollExtent; _barMaxScrollExtent / widget.controller.position.maxScrollExtent;
@ -414,7 +425,7 @@ class _DraggableScrollbarState extends State<DraggableScrollbar>
widget.scrollStateListener(true); widget.scrollStateListener(true);
_dragHaltTimer = Timer( _dragHaltTimer = Timer(
const Duration(milliseconds: 500), Durations.long2,
() => widget.scrollStateListener(false), () => widget.scrollStateListener(false),
); );
} }

View File

@ -8,7 +8,27 @@ import 'package:immich_mobile/domain/models/render_list.model.dart';
import 'package:immich_mobile/domain/utils/renderlist_providers.dart'; import 'package:immich_mobile/domain/utils/renderlist_providers.dart';
import 'package:immich_mobile/utils/constants/globals.dart'; import 'package:immich_mobile/utils/constants/globals.dart';
class AssetGridCubit extends Cubit<RenderList> { class AssetGridState {
final bool isDragScrolling;
final RenderList renderList;
const AssetGridState({
required this.isDragScrolling,
required this.renderList,
});
factory AssetGridState.empty() =>
AssetGridState(isDragScrolling: false, renderList: RenderList.empty());
AssetGridState copyWith({bool? isDragScrolling, RenderList? renderList}) {
return AssetGridState(
isDragScrolling: isDragScrolling ?? this.isDragScrolling,
renderList: renderList ?? this.renderList,
);
}
}
class AssetGridCubit extends Cubit<AssetGridState> {
final RenderListProvider _renderListProvider; final RenderListProvider _renderListProvider;
late final StreamSubscription _renderListSubscription; late final StreamSubscription _renderListSubscription;
@ -20,21 +40,27 @@ class AssetGridCubit extends Cubit<RenderList> {
AssetGridCubit({required RenderListProvider renderListProvider}) AssetGridCubit({required RenderListProvider renderListProvider})
: _renderListProvider = renderListProvider, : _renderListProvider = renderListProvider,
super(RenderList.empty()) { super(AssetGridState.empty()) {
_renderListSubscription = _renderListSubscription =
_renderListProvider.renderStreamProvider().listen((renderList) { _renderListProvider.renderStreamProvider().listen((renderList) {
_bufOffset = 0; _bufOffset = 0;
_buf = []; _buf = [];
emit(renderList); emit(state.copyWith(renderList: renderList));
}); });
} }
void setDragScrolling(bool isScrolling) {
if (state.isDragScrolling != isScrolling) {
emit(state.copyWith(isDragScrolling: isScrolling));
}
}
/// Loads the requested assets from the database to an internal buffer if not cached /// Loads the requested assets from the database to an internal buffer if not cached
/// and returns a slice of that buffer /// and returns a slice of that buffer
Future<List<Asset>> loadAssets(int offset, int count) async { Future<List<Asset>> loadAssets(int offset, int count) async {
assert(offset >= 0); assert(offset >= 0);
assert(count > 0); assert(count > 0);
assert(offset + count <= state.totalCount); assert(offset + count <= state.renderList.totalCount);
// the requested slice (offset:offset+count) is not contained in the cache buffer `_buf` // 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 // thus, fill the buffer with a new batch of assets that at least contains the requested

View File

@ -1,7 +1,7 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_list_view/flutter_list_view.dart'; import 'package:flutter_list_view/flutter_list_view.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/models/render_list_element.model.dart';
import 'package:immich_mobile/presentation/components/grid/draggable_scrollbar.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/grid/immich_asset_grid.state.dart';
@ -15,28 +15,33 @@ import 'package:material_symbols_icons/symbols.dart';
part 'immich_asset_grid_header.widget.dart'; part 'immich_asset_grid_header.widget.dart';
class ImAssetGrid extends StatefulWidget { class ImAssetGrid extends StatefulWidget {
const ImAssetGrid({super.key}); /// The padding for the grid
final double? topPadding;
final FlutterListViewController? controller;
const ImAssetGrid({this.controller, this.topPadding, super.key});
@override @override
State createState() => _ImAssetGridState(); State createState() => _ImAssetGridState();
} }
class _ImAssetGridState extends State<ImAssetGrid> { class _ImAssetGridState extends State<ImAssetGrid> {
bool _isDragScrolling = false; late final FlutterListViewController _controller;
final FlutterListViewController _controller = FlutterListViewController();
@override
void initState() {
super.initState();
_controller = widget.controller ?? FlutterListViewController();
}
@override @override
void dispose() { void dispose() {
_controller.dispose(); // Dispose controller if it was created here
super.dispose(); if (widget.controller == null) {
} _controller.dispose();
void _onDragScrolling(bool isScrolling) {
if (_isDragScrolling != isScrolling) {
setState(() {
_isDragScrolling = isScrolling;
});
} }
super.dispose();
} }
Text? _labelBuilder(List<RenderListElement> elements, int currentPosition) { Text? _labelBuilder(List<RenderListElement> elements, int currentPosition) {
@ -55,9 +60,21 @@ class _ImAssetGridState extends State<ImAssetGrid> {
} }
@override @override
Widget build(BuildContext context) => BlocBuilder<AssetGridCubit, RenderList>( Widget build(BuildContext context) =>
builder: (_, renderList) { BlocBuilder<AssetGridCubit, AssetGridState>(
final elements = renderList.elements; builder: (_, state) {
final elements = state.renderList.elements;
if (widget.topPadding != null &&
elements.firstOrNull is! RenderListPaddingElement) {
elements.insert(
0,
RenderListPaddingElement.beforeElement(
top: widget.topPadding!,
before: elements.firstOrNull,
),
);
}
final grid = FlutterListView( final grid = FlutterListView(
controller: _controller, controller: _controller,
delegate: FlutterListViewDelegate( delegate: FlutterListViewDelegate(
@ -66,6 +83,9 @@ class _ImAssetGridState extends State<ImAssetGrid> {
final section = elements[sectionIndex]; final section = elements[sectionIndex];
return switch (section) { return switch (section) {
RenderListPaddingElement() => Padding(
padding: EdgeInsets.only(top: section.topPadding),
),
RenderListMonthHeaderElement() => RenderListMonthHeaderElement() =>
_MonthHeader(text: section.header), _MonthHeader(text: section.header),
RenderListDayHeaderElement() => Text(section.header), RenderListDayHeaderElement() => Text(section.header),
@ -95,7 +115,7 @@ class _ImAssetGridState extends State<ImAssetGrid> {
return SizedBox.square( return SizedBox.square(
dimension: 200, dimension: 200,
// Show Placeholder when drag scrolled // Show Placeholder when drag scrolled
child: asset == null || _isDragScrolling child: asset == null || state.isDragScrolling
? const ImImagePlaceholder() ? const ImImagePlaceholder()
: ImThumbnail(asset), : ImThumbnail(asset),
); );
@ -111,17 +131,26 @@ class _ImAssetGridState extends State<ImAssetGrid> {
), ),
); );
final EdgeInsetsGeometry? padding;
if (widget.topPadding != null) {
padding = EdgeInsets.only(top: widget.topPadding!);
} else {
padding = null;
}
return DraggableScrollbar( return DraggableScrollbar(
foregroundColor: context.colorScheme.onSurface, foregroundColor: context.colorScheme.onSurface,
backgroundColor: context.colorScheme.surfaceContainerHighest, backgroundColor: context.colorScheme.surfaceContainerHighest,
scrollStateListener: _onDragScrolling, scrollStateListener:
context.read<AssetGridCubit>().setDragScrolling,
controller: _controller, controller: _controller,
maxItemCount: elements.length, maxItemCount: elements.length,
labelTextBuilder: (int position) => labelTextBuilder: (int position) =>
_labelBuilder(elements, position), _labelBuilder(elements, position),
labelConstraints: const BoxConstraints(maxHeight: 36), labelConstraints: const BoxConstraints(maxHeight: 36),
scrollbarAnimationDuration: const Duration(milliseconds: 300), scrollbarAnimationDuration: Durations.medium2,
scrollbarTimeToFade: const Duration(milliseconds: 1000), scrollbarTimeToFade: Durations.extralong4,
padding: padding,
child: grid, child: grid,
); );
}, },

View File

@ -64,7 +64,7 @@ class ImImage extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return OctoImage( return OctoImage(
fadeInDuration: const Duration(milliseconds: 0), fadeInDuration: const Duration(milliseconds: 0),
fadeOutDuration: const Duration(milliseconds: 200), fadeOutDuration: Durations.short4,
placeholderBuilder: (_) => placeholder, placeholderBuilder: (_) => placeholder,
image: ImImage.imageProvider(asset: asset), image: ImImage.imageProvider(asset: asset),
width: width, width: width,

View File

@ -22,7 +22,7 @@ class ImAdaptiveScaffoldBody extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AdaptiveLayout( return AdaptiveLayout(
internalAnimations: false, internalAnimations: false,
transitionDuration: const Duration(milliseconds: 300), transitionDuration: Durations.medium2,
bodyRatio: bodyRatio, bodyRatio: bodyRatio,
body: SlotLayout( body: SlotLayout(
config: { config: {

View File

@ -2,13 +2,28 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:immich_mobile/domain/utils/renderlist_providers.dart'; import 'package:immich_mobile/domain/utils/renderlist_providers.dart';
import 'package:immich_mobile/presentation/components/appbar/immich_app_bar.widget.dart';
import 'package:immich_mobile/presentation/components/grid/immich_asset_grid.state.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/presentation/components/grid/immich_asset_grid.widget.dart';
import 'package:immich_mobile/utils/extensions/build_context.extension.dart';
@RoutePage() @RoutePage()
class HomePage extends StatelessWidget { class HomePage extends StatefulWidget {
const HomePage({super.key}); const HomePage({super.key});
@override
State createState() => _HomePageState();
}
class _HomePageState extends State<HomePage> {
final _showAppBar = ValueNotifier<bool>(true);
@override
void dispose() {
_showAppBar.dispose();
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
@ -16,7 +31,35 @@ class HomePage extends StatelessWidget {
create: (_) => AssetGridCubit( create: (_) => AssetGridCubit(
renderListProvider: RenderListProvider.mainTimeline(), renderListProvider: RenderListProvider.mainTimeline(),
), ),
child: const ImAssetGrid(), 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(),
),
]),
), ),
); );
} }

View File

@ -1,18 +1,12 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:immich_mobile/domain/interfaces/api/user_api.interface.dart';
import 'package:immich_mobile/domain/interfaces/asset.interface.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/domain/interfaces/user.interface.dart'; import 'package:immich_mobile/domain/interfaces/user.interface.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/album_sync.service.dart';
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
import 'package:immich_mobile/domain/services/login.service.dart'; import 'package:immich_mobile/domain/services/login.service.dart';
import 'package:immich_mobile/i18n/strings.g.dart'; import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart'; import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
import 'package:immich_mobile/presentation/states/gallery_permission.state.dart';
import 'package:immich_mobile/presentation/states/server_info/server_feature_config.state.dart';
import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/immich_api_client.dart'; import 'package:immich_mobile/utils/immich_api_client.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart';
@ -68,11 +62,7 @@ class LoginPageCubit extends Cubit<LoginPageState> with LogMixin {
url = await loginService.resolveEndpoint(uri); url = await loginService.resolveEndpoint(uri);
di<IStoreRepository>().upsert(StoreKey.serverEndpoint, url); di<IStoreRepository>().upsert(StoreKey.serverEndpoint, url);
await ServiceLocator.registerApiClient(url); await di<LoginService>().handlePostUrlResolution(url);
ServiceLocator.registerPostGlobalStates();
// Fetch server features
await di<ServerFeatureConfigProvider>().getFeatures();
emit(state.copyWith(isServerValidated: true)); emit(state.copyWith(isServerValidated: true));
} finally { } finally {
@ -129,20 +119,13 @@ class LoginPageCubit extends Cubit<LoginPageState> with LogMixin {
/// Set token to interceptor /// Set token to interceptor
await di<ImApiClient>().init(accessToken: accessToken); await di<ImApiClient>().init(accessToken: accessToken);
final user = await di<IUserApiRepository>().getMyUser(); final user = await di<LoginService>().handlePostLogin();
if (user == null) { if (user == null) {
SnackbarManager.showError(t.login.error.error_login); SnackbarManager.showError(t.login.error.error_login);
return; return;
} }
// Register user
ServiceLocator.registerCurrentUser(user);
await di<IUserRepository>().upsert(user); await di<IUserRepository>().upsert(user);
// Remove and Sync assets in background
await di<IAssetRepository>().deleteAll();
await di<GalleryPermissionProvider>().requestPermission();
unawaited(di<AssetSyncService>().performFullRemoteSyncIsolate(user));
unawaited(di<AlbumSyncService>().performFullDeviceSyncIsolate());
emit(state.copyWith( emit(state.copyWith(
isValidationInProgress: false, isValidationInProgress: false,

View File

@ -11,7 +11,7 @@ import 'package:immich_mobile/presentation/components/input/text_button.widget.d
import 'package:immich_mobile/presentation/components/input/text_form_field.widget.dart'; import 'package:immich_mobile/presentation/components/input/text_form_field.widget.dart';
import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart'; import 'package:immich_mobile/presentation/modules/login/models/login_page.model.dart';
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart'; import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
import 'package:immich_mobile/presentation/states/server_info/server_feature_config.state.dart'; import 'package:immich_mobile/presentation/states/server_feature_config.state.dart';
import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/service_locator.dart';
import 'package:material_symbols_icons/symbols.dart'; import 'package:material_symbols_icons/symbols.dart';

View File

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:immich_mobile/i18n/strings.g.dart'; import 'package:immich_mobile/i18n/strings.g.dart';
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart'; import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_appbar.widget.dart'; import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_appbar.widget.dart';
import 'package:immich_mobile/utils/constants/globals.dart';
import 'package:immich_mobile/utils/constants/size_constants.dart'; import 'package:immich_mobile/utils/constants/size_constants.dart';
@RoutePage() @RoutePage()
@ -19,7 +18,7 @@ class AboutSettingsPage extends StatelessWidget {
subtitle: Text(context.t.settings.about.third_party_sub_title), subtitle: Text(context.t.settings.about.third_party_sub_title),
onTap: () => showLicensePage( onTap: () => showLicensePage(
context: context, context: context,
applicationName: kImmichAppName, applicationName: context.t.immich,
applicationIcon: const ImLogo(width: SizeConstants.xl), applicationIcon: const ImLogo(width: SizeConstants.xl),
), ),
), ),

View File

@ -5,6 +5,7 @@ import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_ap
import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_wrapper.widget.dart'; import 'package:immich_mobile/presentation/components/scaffold/adaptive_route_wrapper.widget.dart';
import 'package:immich_mobile/presentation/modules/settings/models/settings_section.model.dart'; import 'package:immich_mobile/presentation/modules/settings/models/settings_section.model.dart';
import 'package:immich_mobile/presentation/router/router.dart'; import 'package:immich_mobile/presentation/router/router.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/build_context.extension.dart';
@RoutePage() @RoutePage()
@ -16,7 +17,7 @@ class SettingsWrapperPage extends StatelessWidget {
return ImAdaptiveRouteWrapper( return ImAdaptiveRouteWrapper(
primaryBody: (_) => const SettingsPage(), primaryBody: (_) => const SettingsPage(),
primaryRoute: SettingsRoute.name, primaryRoute: SettingsRoute.name,
bodyRatio: 0.3, bodyRatio: BodyRatioConstants.oneThird,
); );
} }
} }

View File

@ -3,14 +3,10 @@ import 'dart:async';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:immich_mobile/domain/services/album_sync.service.dart';
import 'package:immich_mobile/domain/services/asset_sync.service.dart';
import 'package:immich_mobile/domain/services/login.service.dart'; import 'package:immich_mobile/domain/services/login.service.dart';
import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart'; import 'package:immich_mobile/presentation/components/image/immich_logo.widget.dart';
import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart'; import 'package:immich_mobile/presentation/modules/login/states/login_page.state.dart';
import 'package:immich_mobile/presentation/router/router.dart'; import 'package:immich_mobile/presentation/router/router.dart';
import 'package:immich_mobile/presentation/states/current_user.state.dart';
import 'package:immich_mobile/presentation/states/gallery_permission.state.dart';
import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/mixins/log.mixin.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart';
@ -53,11 +49,7 @@ class _SplashScreenState extends State<SplashScreenPage>
} }
Future<void> _tryLogin() async { Future<void> _tryLogin() async {
await di<GalleryPermissionProvider>().requestPermission();
if (await di<LoginService>().tryAutoLogin() && mounted) { if (await di<LoginService>().tryAutoLogin() && mounted) {
unawaited(di<AssetSyncService>()
.performFullRemoteSyncIsolate(di<CurrentUserProvider>().value));
unawaited(di<AlbumSyncService>().performFullDeviceSyncIsolate());
unawaited(context.replaceRoute(const TabControllerRoute())); unawaited(context.replaceRoute(const TabControllerRoute()));
} else if (mounted) { } else if (mounted) {
unawaited(context.replaceRoute(const LoginRoute())); unawaited(context.replaceRoute(const LoginRoute()));

View File

@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/presentation/theme/app_colors.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:immich_mobile/utils/extensions/material_state.extension.dart';
enum AppTheme { enum AppTheme {
@ -70,6 +71,23 @@ enum AppTheme {
Color.alphaBlend(color.primary.withAlpha(80), color.onSurface) Color.alphaBlend(color.primary.withAlpha(80), color.onSurface)
.withAlpha(240), .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( snackBarTheme: SnackBarThemeData(
elevation: 4, elevation: 4,
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,

View File

@ -0,0 +1,70 @@
import 'package:flutter/material.dart';
class AppTypography {
const AppTypography();
static const TextStyle displayLarge = TextStyle(
fontSize: 57,
fontWeight: FontWeight.normal,
);
static const TextStyle displayMedium = TextStyle(
fontSize: 45,
fontWeight: FontWeight.normal,
);
static const TextStyle displaySmall = TextStyle(
fontSize: 36,
fontWeight: FontWeight.normal,
);
static const TextStyle headlineLarge = TextStyle(
fontSize: 32,
fontWeight: FontWeight.normal,
);
static const TextStyle headlineMedium = TextStyle(
fontSize: 28,
fontWeight: FontWeight.normal,
);
static const TextStyle headlineSmall = TextStyle(
fontSize: 24,
fontWeight: FontWeight.normal,
);
static const TextStyle titleLarge = TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
);
static const TextStyle titleMedium = TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
);
static const TextStyle titleSmall = TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
);
static const TextStyle bodyLarge = TextStyle(
fontSize: 16,
fontWeight: FontWeight.normal,
);
static const TextStyle bodyMedium = TextStyle(
fontSize: 14,
fontWeight: FontWeight.normal,
);
static const TextStyle bodySmall = TextStyle(
fontSize: 12,
fontWeight: FontWeight.normal,
);
static const TextStyle labelLarge = TextStyle(
fontSize: 14,
fontWeight: FontWeight.w500,
);
static const TextStyle labelMedium = TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
);
static const TextStyle labelSmall = TextStyle(
fontSize: 11,
fontWeight: FontWeight.w500,
);
}

View File

@ -42,7 +42,7 @@ import 'package:immich_mobile/presentation/router/router.dart';
import 'package:immich_mobile/presentation/states/app_theme.state.dart'; import 'package:immich_mobile/presentation/states/app_theme.state.dart';
import 'package:immich_mobile/presentation/states/current_user.state.dart'; import 'package:immich_mobile/presentation/states/current_user.state.dart';
import 'package:immich_mobile/presentation/states/gallery_permission.state.dart'; import 'package:immich_mobile/presentation/states/gallery_permission.state.dart';
import 'package:immich_mobile/presentation/states/server_info/server_feature_config.state.dart'; import 'package:immich_mobile/presentation/states/server_feature_config.state.dart';
import 'package:immich_mobile/utils/immich_api_client.dart'; import 'package:immich_mobile/utils/immich_api_client.dart';
final di = GetIt.I; final di = GetIt.I;

View File

@ -1,7 +1,5 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
const String kImmichAppName = "Immich";
/// Log messages stored in the DB /// Log messages stored in the DB
const int kLogMessageLimit = 500; const int kLogMessageLimit = 500;
@ -13,6 +11,7 @@ const String kCacheThumbnailsKey = 'ImThumbnailCacheKey';
const int kCacheMaxNrOfThumbnails = 500; const int kCacheMaxNrOfThumbnails = 500;
/// Grid constants /// Grid constants
const double kGridAutoHideAppBarOffset = 30;
const int kGridThumbnailSize = 200; const int kGridThumbnailSize = 200;
const int kGridThumbnailQuality = 80; const int kGridThumbnailQuality = 80;

View File

@ -9,3 +9,9 @@ class SizeConstants {
static const l = 32.0; static const l = 32.0;
static const xl = 64.0; static const xl = 64.0;
} }
class BodyRatioConstants {
const BodyRatioConstants._();
static const oneThird = 1 / 3;
}

View File

@ -17,6 +17,9 @@ extension BuildContextHelper on BuildContext {
/// Get the [Size] of [MediaQuery] /// Get the [Size] of [MediaQuery]
Size get mediaQuerySize => MediaQuery.sizeOf(this); Size get mediaQuerySize => MediaQuery.sizeOf(this);
/// Get the [Padding] of [MediaQuery]
EdgeInsets get mediaQueryPadding => MediaQuery.paddingOf(this);
/// Get the [EdgeInsets] of [MediaQuery] /// Get the [EdgeInsets] of [MediaQuery]
EdgeInsets get viewInsets => MediaQuery.viewInsetsOf(this); EdgeInsets get viewInsets => MediaQuery.viewInsetsOf(this);

View File

@ -4,6 +4,7 @@ import 'package:device_info_plus/device_info_plus.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:immich_mobile/domain/interfaces/store.interface.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.dart';
import 'package:immich_mobile/domain/models/store.model.dart'; import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/services/login.service.dart';
import 'package:immich_mobile/presentation/router/router.dart'; import 'package:immich_mobile/presentation/router/router.dart';
import 'package:immich_mobile/service_locator.dart'; import 'package:immich_mobile/service_locator.dart';
import 'package:immich_mobile/utils/constants/globals.dart'; import 'package:immich_mobile/utils/constants/globals.dart';
@ -57,6 +58,7 @@ class ImApiClient extends ApiClient with LogMixin {
if (res.statusCode == HttpStatus.unauthorized) { if (res.statusCode == HttpStatus.unauthorized) {
log.e("Token invalid. Redirecting to login route"); log.e("Token invalid. Redirecting to login route");
await di<LoginService>().logout();
await di<AppRouter>().replaceAll([const LoginRoute()]); await di<AppRouter>().replaceAll([const LoginRoute()]);
throw ApiException(res.statusCode, "Unauthorized"); throw ApiException(res.statusCode, "Unauthorized");
} }

View File

@ -69,12 +69,12 @@ class IsolateHelper {
BackgroundIsolateBinaryMessenger.ensureInitialized(token); BackgroundIsolateBinaryMessenger.ensureInitialized(token);
DartPluginRegistrant.ensureInitialized(); DartPluginRegistrant.ensureInitialized();
// Delay to ensure the isolate is ready // Delay to ensure the isolate is ready
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(Durations.short2);
helper.postIsolateHandling(); helper.postIsolateHandling();
try { try {
final result = await computation(); final result = await computation();
// Delay to ensure the isolate is not killed prematurely // Delay to ensure the isolate is not killed prematurely
await Future.delayed(const Duration(milliseconds: 100)); await Future.delayed(Durations.short2);
return result; return result;
} finally { } finally {
// Always close the new database connection on Isolate end // Always close the new database connection on Isolate end