diff --git a/mobile-v2/assets/i18n/strings.i18n.json b/mobile-v2/assets/i18n/strings.i18n.json index 7f61322bf1..08c7883cb8 100644 --- a/mobile-v2/assets/i18n/strings.i18n.json +++ b/mobile-v2/assets/i18n/strings.i18n.json @@ -1,4 +1,5 @@ { + "immich": "Immich", "tab_controller": { "photos": "Photos", "search": "Search", @@ -34,5 +35,8 @@ "oauth_button": "OAuth", "login_disabled": "Login Disabled" } + }, + "logs": { + "title": "Logs" } } \ No newline at end of file diff --git a/mobile-v2/lib/domain/interfaces/album.interface.dart b/mobile-v2/lib/domain/interfaces/album.interface.dart index 42d62a2249..cdd55d499a 100644 --- a/mobile-v2/lib/domain/interfaces/album.interface.dart +++ b/mobile-v2/lib/domain/interfaces/album.interface.dart @@ -11,4 +11,7 @@ abstract interface class IAlbumRepository { /// Removes album with the given [id] FutureOr deleteId(int id); + + /// Removes all albums + FutureOr deleteAll(); } diff --git a/mobile-v2/lib/domain/interfaces/album_asset.interface.dart b/mobile-v2/lib/domain/interfaces/album_asset.interface.dart index cdb5ba555c..6d8e31255b 100644 --- a/mobile-v2/lib/domain/interfaces/album_asset.interface.dart +++ b/mobile-v2/lib/domain/interfaces/album_asset.interface.dart @@ -14,4 +14,7 @@ abstract interface class IAlbumToAssetRepository { /// Removes album with the given [albumId] FutureOr deleteAlbumId(int albumId); + + /// Removes all album to asset mappings + FutureOr deleteAll(); } diff --git a/mobile-v2/lib/domain/interfaces/album_etag.interface.dart b/mobile-v2/lib/domain/interfaces/album_etag.interface.dart index e95162fd6b..0e496158d7 100644 --- a/mobile-v2/lib/domain/interfaces/album_etag.interface.dart +++ b/mobile-v2/lib/domain/interfaces/album_etag.interface.dart @@ -8,4 +8,7 @@ abstract interface class IAlbumETagRepository { /// Fetches the album etag for the given [albumId] FutureOr get(int albumId); + + /// Removes all album eTags + FutureOr deleteAll(); } diff --git a/mobile-v2/lib/domain/interfaces/user.interface.dart b/mobile-v2/lib/domain/interfaces/user.interface.dart index ffc7025ffb..e051091c5c 100644 --- a/mobile-v2/lib/domain/interfaces/user.interface.dart +++ b/mobile-v2/lib/domain/interfaces/user.interface.dart @@ -8,4 +8,7 @@ abstract interface class IUserRepository { /// Fetches user FutureOr getForId(String userId); + + /// Removes all users + FutureOr deleteAll(); } diff --git a/mobile-v2/lib/domain/models/album_etag.model.dart b/mobile-v2/lib/domain/models/album_etag.model.dart index 03fa10eec0..58a46b0cc8 100644 --- a/mobile-v2/lib/domain/models/album_etag.model.dart +++ b/mobile-v2/lib/domain/models/album_etag.model.dart @@ -11,7 +11,7 @@ class AlbumETag { required this.modifiedTime, }); - factory AlbumETag.empty() { + factory AlbumETag.initial() { return AlbumETag( albumId: -1, assetCount: 0, diff --git a/mobile-v2/lib/domain/models/render_list_element.model.dart b/mobile-v2/lib/domain/models/render_list_element.model.dart index 4a8dc52f49..1ac768ea96 100644 --- a/mobile-v2/lib/domain/models/render_list_element.model.dart +++ b/mobile-v2/lib/domain/models/render_list_element.model.dart @@ -18,6 +18,35 @@ sealed class RenderListElement { 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 { late final String header; diff --git a/mobile-v2/lib/domain/repositories/album.repository.dart b/mobile-v2/lib/domain/repositories/album.repository.dart index 37792e44ad..cf2eb4b5d8 100644 --- a/mobile-v2/lib/domain/repositories/album.repository.dart +++ b/mobile-v2/lib/domain/repositories/album.repository.dart @@ -50,7 +50,12 @@ class AlbumRepository with LogMixin implements IAlbumRepository { @override FutureOr deleteId(int id) async { - await _db.asset.deleteWhere((row) => row.id.equals(id)); + await _db.album.deleteWhere((row) => row.id.equals(id)); + } + + @override + FutureOr deleteAll() async { + await _db.album.deleteAll(); } } diff --git a/mobile-v2/lib/domain/repositories/album_asset.repository.dart b/mobile-v2/lib/domain/repositories/album_asset.repository.dart index 0ea683fb86..7253092d40 100644 --- a/mobile-v2/lib/domain/repositories/album_asset.repository.dart +++ b/mobile-v2/lib/domain/repositories/album_asset.repository.dart @@ -75,4 +75,9 @@ class AlbumToAssetRepository with LogMixin implements IAlbumToAssetRepository { FutureOr deleteAlbumId(int albumId) async { await _db.albumToAsset.deleteWhere((row) => row.albumId.equals(albumId)); } + + @override + FutureOr deleteAll() async { + await _db.albumToAsset.deleteAll(); + } } diff --git a/mobile-v2/lib/domain/repositories/album_etag.repository.dart b/mobile-v2/lib/domain/repositories/album_etag.repository.dart index 6e11b5e158..511bdec5a4 100644 --- a/mobile-v2/lib/domain/repositories/album_etag.repository.dart +++ b/mobile-v2/lib/domain/repositories/album_etag.repository.dart @@ -33,6 +33,11 @@ class AlbumETagRepository with LogMixin implements IAlbumETagRepository { ..where((r) => r.albumId.equals(albumId)); return await query.map(_toModel).getSingleOrNull(); } + + @override + FutureOr deleteAll() async { + await _db.albumETag.deleteAll(); + } } AlbumETagCompanion _toEntity(AlbumETag albumETag) { diff --git a/mobile-v2/lib/domain/repositories/user.repository.dart b/mobile-v2/lib/domain/repositories/user.repository.dart index ef496896e1..a12e73ca39 100644 --- a/mobile-v2/lib/domain/repositories/user.repository.dart +++ b/mobile-v2/lib/domain/repositories/user.repository.dart @@ -44,6 +44,11 @@ class UserRepository with LogMixin implements IUserRepository { return false; } } + + @override + FutureOr deleteAll() async { + await _db.user.deleteAll(); + } } User _toModel(UserData user) { diff --git a/mobile-v2/lib/domain/services/album_sync.service.dart b/mobile-v2/lib/domain/services/album_sync.service.dart index b4937666dd..75ca4030f8 100644 --- a/mobile-v2/lib/domain/services/album_sync.service.dart +++ b/mobile-v2/lib/domain/services/album_sync.service.dart @@ -22,6 +22,7 @@ class AlbumSyncService with LogMixin { Future performFullDeviceSync() async { try { + final Stopwatch stopwatch = Stopwatch()..start(); final deviceAlbums = await di().getAll(); final dbAlbums = await di().getAll(localOnly: true); final hasChange = await CollectionUtil.diffLists( @@ -34,6 +35,7 @@ class AlbumSyncService with LogMixin { onlySecond: _addDeviceAlbum, ); + log.i("Full device sync took - ${stopwatch.elapsedMilliseconds}ms"); return hasChange; } catch (e, s) { log.e("Error performing full device sync", e, s); @@ -47,8 +49,8 @@ class AlbumSyncService with LogMixin { DateTime? modifiedUntil, }) async { assert(dbAlbum.id != null, "Album ID from DB is null"); - final albumEtag = - await di().get(dbAlbum.id!) ?? AlbumETag.empty(); + final albumEtag = await di().get(dbAlbum.id!) ?? + AlbumETag.initial(); final assetCountInDevice = await di().getAssetCount(deviceAlbum.localId!); @@ -66,6 +68,7 @@ class AlbumSyncService with LogMixin { Future _addDeviceAlbum(Album album, {DateTime? modifiedUntil}) async { try { + log.i("Syncing device album ${album.name}"); final albumId = (await di().upsert(album))?.id; // break fast if we cannot add an album if (albumId == null) { @@ -115,6 +118,7 @@ class AlbumSyncService with LogMixin { Future _removeDeviceAlbum(Album album) async { assert(album.id != null, "Album ID from DB is null"); + log.i("Removing device album ${album.name}"); final albumId = album.id!; try { await di().txn(() async { diff --git a/mobile-v2/lib/domain/services/asset_sync.service.dart b/mobile-v2/lib/domain/services/asset_sync.service.dart index c866c628f3..44362c0629 100644 --- a/mobile-v2/lib/domain/services/asset_sync.service.dart +++ b/mobile-v2/lib/domain/services/asset_sync.service.dart @@ -35,6 +35,7 @@ class AssetSyncService with LogMixin { int? limit, }) async { try { + final Stopwatch stopwatch = Stopwatch()..start(); final db = di(); final assetRepo = di(); final syncApiRepo = di(); @@ -74,6 +75,7 @@ class AssetSyncService with LogMixin { if (assetsFromServer.length != chunkSize) break; } + log.i("Full remote sync took - ${stopwatch.elapsedMilliseconds}ms"); return true; } catch (e, s) { log.e("Error performing full remote sync for user - ${user.name}", e, s); diff --git a/mobile-v2/lib/domain/services/login.service.dart b/mobile-v2/lib/domain/services/login.service.dart index 1e8c85b615..5242b0e23a 100644 --- a/mobile-v2/lib/domain/services/login.service.dart +++ b/mobile-v2/lib/domain/services/login.service.dart @@ -1,14 +1,25 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_web_auth_2/flutter_web_auth_2.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/server_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/user.interface.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/utils/immich_api_client.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; @@ -99,6 +110,38 @@ class LoginService with LogMixin { return null; } + Future handlePostUrlResolution(String serverEndpoint) async { + await ServiceLocator.registerApiClient(serverEndpoint); + ServiceLocator.registerPostGlobalStates(); + + // Fetch server features + await di().getFeatures(); + } + + Future handlePostLogin() async { + final user = await di().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().performFullRemoteSyncIsolate(user)); + if (di().hasPermission) { + unawaited(di().performFullDeviceSyncIsolate()); + } + + return user; + } + Future tryAutoLogin() async { final serverEndpoint = await di().tryGet(StoreKey.serverEndpoint); @@ -106,8 +149,7 @@ class LoginService with LogMixin { return false; } - await ServiceLocator.registerApiClient(serverEndpoint); - ServiceLocator.registerPostGlobalStates(); + await handlePostUrlResolution(serverEndpoint); final accessToken = await di().tryGet(StoreKey.accessToken); @@ -118,19 +160,20 @@ class LoginService with LogMixin { // Set token to interceptor await di().init(accessToken: accessToken); - final user = await di().getMyUser().timeout( - const Duration(seconds: 10), - // ignore: function-always-returns-null - onTimeout: () { - log.w("Timedout while fetching user details using saved credentials"); - return null; - }, - ); + final user = await handlePostLogin(); if (user == null) { return false; } - ServiceLocator.registerCurrentUser(user); return true; } + + Future logout() async { + // Remove existing assets + await di().deleteAll(); + await di().deleteAll(); + await di().deleteAll(); + await di().deleteAll(); + await di().deleteAll(); + } } diff --git a/mobile-v2/lib/presentation/components/appbar/immich_app_bar.widget.dart b/mobile-v2/lib/presentation/components/appbar/immich_app_bar.widget.dart new file mode 100644 index 0000000000..38bf8d706e --- /dev/null +++ b/mobile-v2/lib/presentation/components/appbar/immich_app_bar.widget.dart @@ -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), + ); + } +} diff --git a/mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart b/mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart index 5712c283a2..be723903bd 100644 --- a/mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart +++ b/mobile-v2/lib/presentation/components/grid/draggable_scrollbar.dart @@ -75,8 +75,8 @@ class DraggableScrollbar extends StatefulWidget { this.backgroundColor = Colors.white, this.foregroundColor = Colors.black, this.padding, - this.scrollbarAnimationDuration = const Duration(milliseconds: 300), - this.scrollbarTimeToFade = const Duration(milliseconds: 600), + this.scrollbarAnimationDuration = Durations.medium2, + this.scrollbarTimeToFade = Durations.long4, this.labelTextBuilder, this.labelConstraints, }) : assert(child.scrollDirection == Axis.vertical), @@ -219,6 +219,10 @@ class _DraggableScrollbarState extends State Timer? _fadeoutTimer; List _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 void initState() { super.initState(); @@ -246,14 +250,19 @@ class _DraggableScrollbarState extends State curve: Curves.fastOutSlowIn, ); + _oldCallback = + widget.controller.sliverController.onPaintItemPositionsCallback; widget.controller.sliverController.onPaintItemPositionsCallback = (height, pos) { _positions = pos; + _oldCallback?.call(height, pos); }; } @override void dispose() { + widget.controller.sliverController.onPaintItemPositionsCallback = + _oldCallback; _thumbAnimationController.dispose(); _labelAnimationController.dispose(); _fadeoutTimer?.cancel(); @@ -304,7 +313,9 @@ class _DraggableScrollbarState extends State } double get _barMaxScrollExtent => - (context.size?.height ?? 0) - widget.heightScrollThumb; + (context.size?.height ?? 0) - + widget.heightScrollThumb - + (widget.padding?.vertical ?? 0); double get _maxScrollRatio => _barMaxScrollExtent / widget.controller.position.maxScrollExtent; @@ -414,7 +425,7 @@ class _DraggableScrollbarState extends State widget.scrollStateListener(true); _dragHaltTimer = Timer( - const Duration(milliseconds: 500), + Durations.long2, () => widget.scrollStateListener(false), ); } diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart index cb29fd6a31..0504e1673b 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.state.dart @@ -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/utils/constants/globals.dart'; -class AssetGridCubit extends Cubit { +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 { final RenderListProvider _renderListProvider; late final StreamSubscription _renderListSubscription; @@ -20,21 +40,27 @@ class AssetGridCubit extends Cubit { AssetGridCubit({required RenderListProvider renderListProvider}) : _renderListProvider = renderListProvider, - super(RenderList.empty()) { + super(AssetGridState.empty()) { _renderListSubscription = _renderListProvider.renderStreamProvider().listen((renderList) { _bufOffset = 0; _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 /// and returns a slice of that buffer Future> loadAssets(int offset, int count) async { assert(offset >= 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` // thus, fill the buffer with a new batch of assets that at least contains the requested diff --git a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart index 48ae1f3fff..12f3dfe432 100644 --- a/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart +++ b/mobile-v2/lib/presentation/components/grid/immich_asset_grid.widget.dart @@ -1,7 +1,7 @@ +import 'package:collection/collection.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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/presentation/components/grid/draggable_scrollbar.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'; 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 State createState() => _ImAssetGridState(); } class _ImAssetGridState extends State { - bool _isDragScrolling = false; - final FlutterListViewController _controller = FlutterListViewController(); + late final FlutterListViewController _controller; + + @override + void initState() { + super.initState(); + _controller = widget.controller ?? FlutterListViewController(); + } @override void dispose() { - _controller.dispose(); - super.dispose(); - } - - void _onDragScrolling(bool isScrolling) { - if (_isDragScrolling != isScrolling) { - setState(() { - _isDragScrolling = isScrolling; - }); + // Dispose controller if it was created here + if (widget.controller == null) { + _controller.dispose(); } + super.dispose(); } Text? _labelBuilder(List elements, int currentPosition) { @@ -55,9 +60,21 @@ class _ImAssetGridState extends State { } @override - Widget build(BuildContext context) => BlocBuilder( - builder: (_, renderList) { - final elements = renderList.elements; + Widget build(BuildContext context) => + BlocBuilder( + 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( controller: _controller, delegate: FlutterListViewDelegate( @@ -66,6 +83,9 @@ class _ImAssetGridState extends State { final section = elements[sectionIndex]; return switch (section) { + RenderListPaddingElement() => Padding( + padding: EdgeInsets.only(top: section.topPadding), + ), RenderListMonthHeaderElement() => _MonthHeader(text: section.header), RenderListDayHeaderElement() => Text(section.header), @@ -95,7 +115,7 @@ class _ImAssetGridState extends State { return SizedBox.square( dimension: 200, // Show Placeholder when drag scrolled - child: asset == null || _isDragScrolling + child: asset == null || state.isDragScrolling ? const ImImagePlaceholder() : ImThumbnail(asset), ); @@ -111,17 +131,26 @@ class _ImAssetGridState extends State { ), ); + final EdgeInsetsGeometry? padding; + if (widget.topPadding != null) { + padding = EdgeInsets.only(top: widget.topPadding!); + } else { + padding = null; + } + return DraggableScrollbar( foregroundColor: context.colorScheme.onSurface, backgroundColor: context.colorScheme.surfaceContainerHighest, - scrollStateListener: _onDragScrolling, + scrollStateListener: + context.read().setDragScrolling, controller: _controller, maxItemCount: elements.length, labelTextBuilder: (int position) => _labelBuilder(elements, position), labelConstraints: const BoxConstraints(maxHeight: 36), - scrollbarAnimationDuration: const Duration(milliseconds: 300), - scrollbarTimeToFade: const Duration(milliseconds: 1000), + scrollbarAnimationDuration: Durations.medium2, + scrollbarTimeToFade: Durations.extralong4, + padding: padding, child: grid, ); }, diff --git a/mobile-v2/lib/presentation/components/image/immich_image.widget.dart b/mobile-v2/lib/presentation/components/image/immich_image.widget.dart index e9fc73f59c..de17bb8f51 100644 --- a/mobile-v2/lib/presentation/components/image/immich_image.widget.dart +++ b/mobile-v2/lib/presentation/components/image/immich_image.widget.dart @@ -64,7 +64,7 @@ class ImImage extends StatelessWidget { Widget build(BuildContext context) { return OctoImage( fadeInDuration: const Duration(milliseconds: 0), - fadeOutDuration: const Duration(milliseconds: 200), + fadeOutDuration: Durations.short4, placeholderBuilder: (_) => placeholder, image: ImImage.imageProvider(asset: asset), width: width, diff --git a/mobile-v2/lib/presentation/components/scaffold/adaptive_scaffold_body.widget.dart b/mobile-v2/lib/presentation/components/scaffold/adaptive_scaffold_body.widget.dart index 0f874f94ad..bdf87d27bd 100644 --- a/mobile-v2/lib/presentation/components/scaffold/adaptive_scaffold_body.widget.dart +++ b/mobile-v2/lib/presentation/components/scaffold/adaptive_scaffold_body.widget.dart @@ -22,7 +22,7 @@ class ImAdaptiveScaffoldBody extends StatelessWidget { Widget build(BuildContext context) { return AdaptiveLayout( internalAnimations: false, - transitionDuration: const Duration(milliseconds: 300), + transitionDuration: Durations.medium2, bodyRatio: bodyRatio, body: SlotLayout( config: { diff --git a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart index 11157de362..4eafbf643d 100644 --- a/mobile-v2/lib/presentation/modules/home/pages/home.page.dart +++ b/mobile-v2/lib/presentation/modules/home/pages/home.page.dart @@ -2,13 +2,28 @@ import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.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.widget.dart'; +import 'package:immich_mobile/utils/extensions/build_context.extension.dart'; @RoutePage() -class HomePage extends StatelessWidget { +class HomePage extends StatefulWidget { const HomePage({super.key}); + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + final _showAppBar = ValueNotifier(true); + + @override + void dispose() { + _showAppBar.dispose(); + super.dispose(); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -16,7 +31,35 @@ class HomePage extends StatelessWidget { create: (_) => AssetGridCubit( 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(), + ), + ]), ), ); } diff --git a/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart b/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart index f27089d1f3..d38bced92f 100644 --- a/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart +++ b/mobile-v2/lib/presentation/modules/login/states/login_page.state.dart @@ -1,18 +1,12 @@ import 'dart:async'; 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/user.interface.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/i18n/strings.g.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/utils/immich_api_client.dart'; import 'package:immich_mobile/utils/mixins/log.mixin.dart'; @@ -68,11 +62,7 @@ class LoginPageCubit extends Cubit with LogMixin { url = await loginService.resolveEndpoint(uri); di().upsert(StoreKey.serverEndpoint, url); - await ServiceLocator.registerApiClient(url); - ServiceLocator.registerPostGlobalStates(); - - // Fetch server features - await di().getFeatures(); + await di().handlePostUrlResolution(url); emit(state.copyWith(isServerValidated: true)); } finally { @@ -129,20 +119,13 @@ class LoginPageCubit extends Cubit with LogMixin { /// Set token to interceptor await di().init(accessToken: accessToken); - final user = await di().getMyUser(); + final user = await di().handlePostLogin(); if (user == null) { SnackbarManager.showError(t.login.error.error_login); return; } - // Register user - ServiceLocator.registerCurrentUser(user); await di().upsert(user); - // Remove and Sync assets in background - await di().deleteAll(); - await di().requestPermission(); - unawaited(di().performFullRemoteSyncIsolate(user)); - unawaited(di().performFullDeviceSyncIsolate()); emit(state.copyWith( isValidationInProgress: false, diff --git a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart index 44217e1ff7..05cf1966b2 100644 --- a/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart +++ b/mobile-v2/lib/presentation/modules/login/widgets/login_form.widget.dart @@ -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/modules/login/models/login_page.model.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:material_symbols_icons/symbols.dart'; diff --git a/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart b/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart index 253a971eb1..fc681f0f8c 100644 --- a/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart +++ b/mobile-v2/lib/presentation/modules/settings/pages/about_settings.page.dart @@ -3,7 +3,6 @@ import 'package:flutter/material.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/scaffold/adaptive_route_appbar.widget.dart'; -import 'package:immich_mobile/utils/constants/globals.dart'; import 'package:immich_mobile/utils/constants/size_constants.dart'; @RoutePage() @@ -19,7 +18,7 @@ class AboutSettingsPage extends StatelessWidget { subtitle: Text(context.t.settings.about.third_party_sub_title), onTap: () => showLicensePage( context: context, - applicationName: kImmichAppName, + applicationName: context.t.immich, applicationIcon: const ImLogo(width: SizeConstants.xl), ), ), diff --git a/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart b/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart index 0d63f8556c..25daab3ee5 100644 --- a/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart +++ b/mobile-v2/lib/presentation/modules/settings/pages/settings.page.dart @@ -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/modules/settings/models/settings_section.model.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'; @RoutePage() @@ -16,7 +17,7 @@ class SettingsWrapperPage extends StatelessWidget { return ImAdaptiveRouteWrapper( primaryBody: (_) => const SettingsPage(), primaryRoute: SettingsRoute.name, - bodyRatio: 0.3, + bodyRatio: BodyRatioConstants.oneThird, ); } } diff --git a/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart b/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart index b451a15832..a7cdca3d4c 100644 --- a/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart +++ b/mobile-v2/lib/presentation/router/pages/splash_screen.page.dart @@ -3,14 +3,10 @@ import 'dart:async'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.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/presentation/components/image/immich_logo.widget.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/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/utils/mixins/log.mixin.dart'; @@ -53,11 +49,7 @@ class _SplashScreenState extends State } Future _tryLogin() async { - await di().requestPermission(); if (await di().tryAutoLogin() && mounted) { - unawaited(di() - .performFullRemoteSyncIsolate(di().value)); - unawaited(di().performFullDeviceSyncIsolate()); unawaited(context.replaceRoute(const TabControllerRoute())); } else if (mounted) { unawaited(context.replaceRoute(const LoginRoute())); diff --git a/mobile-v2/lib/presentation/states/server_info/server_feature_config.state.dart b/mobile-v2/lib/presentation/states/server_feature_config.state.dart similarity index 100% rename from mobile-v2/lib/presentation/states/server_info/server_feature_config.state.dart rename to mobile-v2/lib/presentation/states/server_feature_config.state.dart diff --git a/mobile-v2/lib/presentation/theme/app_theme.dart b/mobile-v2/lib/presentation/theme/app_theme.dart index 212832835c..d5adbd280d 100644 --- a/mobile-v2/lib/presentation/theme/app_theme.dart +++ b/mobile-v2/lib/presentation/theme/app_theme.dart @@ -1,5 +1,6 @@ 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'; enum AppTheme { @@ -70,6 +71,23 @@ enum AppTheme { 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, diff --git a/mobile-v2/lib/presentation/theme/app_typography.dart b/mobile-v2/lib/presentation/theme/app_typography.dart new file mode 100644 index 0000000000..299ed012bb --- /dev/null +++ b/mobile-v2/lib/presentation/theme/app_typography.dart @@ -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, + ); +} diff --git a/mobile-v2/lib/service_locator.dart b/mobile-v2/lib/service_locator.dart index 0b58b83936..67d488575d 100644 --- a/mobile-v2/lib/service_locator.dart +++ b/mobile-v2/lib/service_locator.dart @@ -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/current_user.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'; final di = GetIt.I; diff --git a/mobile-v2/lib/utils/constants/globals.dart b/mobile-v2/lib/utils/constants/globals.dart index 3708cf9df2..75ecff36c6 100644 --- a/mobile-v2/lib/utils/constants/globals.dart +++ b/mobile-v2/lib/utils/constants/globals.dart @@ -1,7 +1,5 @@ import 'package:flutter/material.dart'; -const String kImmichAppName = "Immich"; - /// Log messages stored in the DB const int kLogMessageLimit = 500; @@ -13,6 +11,7 @@ const String kCacheThumbnailsKey = 'ImThumbnailCacheKey'; const int kCacheMaxNrOfThumbnails = 500; /// Grid constants +const double kGridAutoHideAppBarOffset = 30; const int kGridThumbnailSize = 200; const int kGridThumbnailQuality = 80; diff --git a/mobile-v2/lib/utils/constants/size_constants.dart b/mobile-v2/lib/utils/constants/size_constants.dart index e4dbe1c86e..027407cd44 100644 --- a/mobile-v2/lib/utils/constants/size_constants.dart +++ b/mobile-v2/lib/utils/constants/size_constants.dart @@ -9,3 +9,9 @@ class SizeConstants { static const l = 32.0; static const xl = 64.0; } + +class BodyRatioConstants { + const BodyRatioConstants._(); + + static const oneThird = 1 / 3; +} diff --git a/mobile-v2/lib/utils/extensions/build_context.extension.dart b/mobile-v2/lib/utils/extensions/build_context.extension.dart index 652e04de4d..9d1e0dd6ae 100644 --- a/mobile-v2/lib/utils/extensions/build_context.extension.dart +++ b/mobile-v2/lib/utils/extensions/build_context.extension.dart @@ -17,6 +17,9 @@ extension BuildContextHelper on BuildContext { /// Get the [Size] of [MediaQuery] Size get mediaQuerySize => MediaQuery.sizeOf(this); + /// Get the [Padding] of [MediaQuery] + EdgeInsets get mediaQueryPadding => MediaQuery.paddingOf(this); + /// Get the [EdgeInsets] of [MediaQuery] EdgeInsets get viewInsets => MediaQuery.viewInsetsOf(this); diff --git a/mobile-v2/lib/utils/immich_api_client.dart b/mobile-v2/lib/utils/immich_api_client.dart index 111291e6dd..5c79263f62 100644 --- a/mobile-v2/lib/utils/immich_api_client.dart +++ b/mobile-v2/lib/utils/immich_api_client.dart @@ -4,6 +4,7 @@ import 'package:device_info_plus/device_info_plus.dart'; import 'package:http/http.dart'; import 'package:immich_mobile/domain/interfaces/store.interface.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/service_locator.dart'; import 'package:immich_mobile/utils/constants/globals.dart'; @@ -57,6 +58,7 @@ class ImApiClient extends ApiClient with LogMixin { if (res.statusCode == HttpStatus.unauthorized) { log.e("Token invalid. Redirecting to login route"); + await di().logout(); await di().replaceAll([const LoginRoute()]); throw ApiException(res.statusCode, "Unauthorized"); } diff --git a/mobile-v2/lib/utils/isolate_helper.dart b/mobile-v2/lib/utils/isolate_helper.dart index 1a87ed40b3..21025aeb98 100644 --- a/mobile-v2/lib/utils/isolate_helper.dart +++ b/mobile-v2/lib/utils/isolate_helper.dart @@ -69,12 +69,12 @@ class IsolateHelper { BackgroundIsolateBinaryMessenger.ensureInitialized(token); DartPluginRegistrant.ensureInitialized(); // Delay to ensure the isolate is ready - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(Durations.short2); helper.postIsolateHandling(); try { final result = await computation(); // Delay to ensure the isolate is not killed prematurely - await Future.delayed(const Duration(milliseconds: 100)); + await Future.delayed(Durations.short2); return result; } finally { // Always close the new database connection on Isolate end