1
0
mirror of https://github.com/immich-app/immich.git synced 2025-07-16 07:24:40 +02:00

feat: expanded sliver app bar (#19827)

* use mutex

* feat: cool app bar

* animation

* adapt to more pages

* animation

* better animation

* fix: asset count

* Revert "fix: asset count"

This reverts commit 673a5b264b.

* fix: asset count

* fix: shaky animation on Android

* tunning

* offset SizedBox to fix scroll jump on multiselect

---------

Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com>
This commit is contained in:
Alex
2025-07-10 10:13:46 -05:00
committed by GitHub
parent 977d6452f6
commit feff1899ee
21 changed files with 733 additions and 75 deletions

View File

@ -11,7 +11,7 @@ enum AlbumUserRole {
} }
// Model for an album stored in the server // Model for an album stored in the server
class Album { class RemoteAlbum {
final String id; final String id;
final String name; final String name;
final String ownerId; final String ownerId;
@ -24,7 +24,7 @@ class Album {
final int assetCount; final int assetCount;
final String ownerName; final String ownerName;
const Album({ const RemoteAlbum({
required this.id, required this.id,
required this.name, required this.name,
required this.ownerId, required this.ownerId,
@ -57,7 +57,7 @@ class Album {
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
if (other is! Album) return false; if (other is! RemoteAlbum) return false;
if (identical(this, other)) return true; if (identical(this, other)) return true;
return id == other.id && return id == other.id &&
name == other.name && name == other.name &&

View File

@ -8,26 +8,26 @@ class RemoteAlbumService {
const RemoteAlbumService(this._repository); const RemoteAlbumService(this._repository);
Future<List<Album>> getAll() { Future<List<RemoteAlbum>> getAll() {
return _repository.getAll(); return _repository.getAll();
} }
List<Album> sortAlbums( List<RemoteAlbum> sortAlbums(
List<Album> albums, List<RemoteAlbum> albums,
RemoteAlbumSortMode sortMode, { RemoteAlbumSortMode sortMode, {
bool isReverse = false, bool isReverse = false,
}) { }) {
return sortMode.sortFn(albums, isReverse); return sortMode.sortFn(albums, isReverse);
} }
List<Album> searchAlbums( List<RemoteAlbum> searchAlbums(
List<Album> albums, List<RemoteAlbum> albums,
String query, String query,
String? userId, [ String? userId, [
QuickFilterMode filterMode = QuickFilterMode.all, QuickFilterMode filterMode = QuickFilterMode.all,
]) { ]) {
final lowerQuery = query.toLowerCase(); final lowerQuery = query.toLowerCase();
List<Album> filtered = albums; List<RemoteAlbum> filtered = albums;
// Apply text search filter // Apply text search filter
if (query.isNotEmpty) { if (query.isNotEmpty) {

View File

@ -210,6 +210,9 @@ class TimelineService {
Future<void> preCacheAssets(int index) => Future<void> preCacheAssets(int index) =>
_mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index))); _mutex.run(() => _loadAssets(index, math.min(5, _totalAssets - index)));
BaseAsset getRandomAsset() =>
_buffer.elementAt(math.Random().nextInt(_buffer.length));
BaseAsset getAsset(int index) { BaseAsset getAsset(int index) {
if (!hasRange(index, 1)) { if (!hasRange(index, 1)) {
throw RangeError( throw RangeError(

View File

@ -9,7 +9,9 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
final Drift _db; final Drift _db;
const DriftRemoteAlbumRepository(this._db) : super(_db); const DriftRemoteAlbumRepository(this._db) : super(_db);
Future<List<Album>> getAll({Set<SortRemoteAlbumsBy> sortBy = const {}}) { Future<List<RemoteAlbum>> getAll({
Set<SortRemoteAlbumsBy> sortBy = const {},
}) {
final assetCount = _db.remoteAlbumAssetEntity.assetId.count(); final assetCount = _db.remoteAlbumAssetEntity.assetId.count();
final query = _db.remoteAlbumEntity.select().join([ final query = _db.remoteAlbumEntity.select().join([
@ -59,8 +61,8 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
} }
extension on RemoteAlbumEntityData { extension on RemoteAlbumEntityData {
Album toDto({int assetCount = 0, required String ownerName}) { RemoteAlbum toDto({int assetCount = 0, required String ownerName}) {
return Album( return RemoteAlbum(
id: id, id: id,
name: name, name: name,
ownerId: ownerId, ownerId: ownerId,

View File

@ -1,9 +1,11 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage() @RoutePage()
class DriftArchivePage extends StatelessWidget { class DriftArchivePage extends StatelessWidget {
@ -27,7 +29,12 @@ class DriftArchivePage extends StatelessWidget {
}, },
), ),
], ],
child: const Timeline(), child: Timeline(
appBar: MesmerizingSliverAppBar(
title: 'archive'.t(context: context),
icon: Icons.archive_outlined,
),
),
); );
} }
} }

View File

@ -1,9 +1,11 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage() @RoutePage()
class DriftFavoritePage extends StatelessWidget { class DriftFavoritePage extends StatelessWidget {
@ -27,7 +29,12 @@ class DriftFavoritePage extends StatelessWidget {
}, },
), ),
], ],
child: const Timeline(), child: Timeline(
appBar: MesmerizingSliverAppBar(
title: 'favorites'.t(context: context),
icon: Icons.favorite_outline,
),
),
); );
} }
} }

View File

@ -103,7 +103,7 @@ class _AlbumList extends ConsumerWidget {
), ),
), ),
onTap: () => onTap: () =>
context.pushRoute(LocalTimelineRoute(albumId: album.id)), context.pushRoute(LocalTimelineRoute(album: album)),
), ),
); );
}, },

View File

@ -1,6 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart'; import 'package:immich_mobile/providers/user.provider.dart';
@ -27,7 +28,16 @@ class DriftTrashPage extends StatelessWidget {
}, },
), ),
], ],
child: const Timeline(), child: Timeline(
appBar: SliverAppBar(
title: Text('trash'.t(context: context)),
floating: true,
snap: true,
pinned: true,
centerTitle: true,
elevation: 0,
),
),
); );
} }
} }

View File

@ -1,14 +1,16 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage() @RoutePage()
class LocalTimelinePage extends StatelessWidget { class LocalTimelinePage extends StatelessWidget {
final String albumId; final LocalAlbum album;
const LocalTimelinePage({super.key, required this.albumId}); const LocalTimelinePage({super.key, required this.album});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -16,14 +18,17 @@ class LocalTimelinePage extends StatelessWidget {
overrides: [ overrides: [
timelineServiceProvider.overrideWith( timelineServiceProvider.overrideWith(
(ref) { (ref) {
final timelineService = final timelineService = ref
ref.watch(timelineFactoryProvider).localAlbum(albumId: albumId); .watch(timelineFactoryProvider)
.localAlbum(albumId: album.id);
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}, },
), ),
], ],
child: const Timeline(), child: Timeline(
appBar: MesmerizingSliverAppBar(title: album.name),
),
); );
} }
} }

View File

@ -125,7 +125,7 @@ class LocalMediaSummaryPage extends StatelessWidget {
name: album.name, name: album.name,
countFuture: countFuture, countFuture: countFuture,
onTap: () => context.router.push( onTap: () => context.router.push(
LocalTimelineRoute(albumId: album.id), LocalTimelineRoute(album: album),
), ),
); );
}, },
@ -226,7 +226,7 @@ class RemoteMediaSummaryPage extends StatelessWidget {
name: album.name, name: album.name,
countFuture: countFuture, countFuture: countFuture,
onTap: () => context.router.push( onTap: () => context.router.push(
RemoteTimelineRoute(albumId: album.id), RemoteTimelineRoute(album: album),
), ),
); );
}, },

View File

@ -1,14 +1,16 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart'; import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
@RoutePage() @RoutePage()
class RemoteTimelinePage extends StatelessWidget { class RemoteTimelinePage extends StatelessWidget {
final String albumId; final RemoteAlbum album;
const RemoteTimelinePage({super.key, required this.albumId}); const RemoteTimelinePage({super.key, required this.album});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -18,13 +20,18 @@ class RemoteTimelinePage extends StatelessWidget {
(ref) { (ref) {
final timelineService = ref final timelineService = ref
.watch(timelineFactoryProvider) .watch(timelineFactoryProvider)
.remoteAlbum(albumId: albumId); .remoteAlbum(albumId: album.id);
ref.onDispose(timelineService.dispose); ref.onDispose(timelineService.dispose);
return timelineService; return timelineService;
}, },
), ),
], ],
child: const Timeline(), child: Timeline(
appBar: MesmerizingSliverAppBar(
title: album.name,
icon: Icons.photo_album_outlined,
),
),
); );
} }
} }

View File

@ -475,7 +475,7 @@ class _AlbumList extends StatelessWidget {
final bool isLoading; final bool isLoading;
final String? error; final String? error;
final List<Album> albums; final List<RemoteAlbum> albums;
final String? userId; final String? userId;
@override @override
@ -555,7 +555,7 @@ class _AlbumList extends StatelessWidget {
), ),
), ),
onTap: () => context.router.push( onTap: () => context.router.push(
RemoteTimelineRoute(albumId: album.id), RemoteTimelineRoute(album: album),
), ),
leadingPadding: const EdgeInsets.only( leadingPadding: const EdgeInsets.only(
right: 16, right: 16,
@ -573,13 +573,24 @@ class _AlbumList extends StatelessWidget {
), ),
), ),
) )
: const SizedBox( : SizedBox(
width: 80, width: 80,
height: 80, height: 80,
child: Icon( child: Container(
Icons.photo_album_rounded, decoration: BoxDecoration(
size: 40, color: context.colorScheme.surfaceContainer,
color: Colors.grey, borderRadius:
const BorderRadius.all(Radius.circular(16)),
border: Border.all(
color: context.colorScheme.outline.withAlpha(50),
width: 1,
),
),
child: const Icon(
Icons.photo_album_rounded,
size: 24,
color: Colors.grey,
),
), ),
), ),
), ),
@ -599,7 +610,7 @@ class _AlbumGrid extends StatelessWidget {
required this.error, required this.error,
}); });
final List<Album> albums; final List<RemoteAlbum> albums;
final String? userId; final String? userId;
final bool isLoading; final bool isLoading;
final String? error; final String? error;
@ -674,14 +685,14 @@ class _GridAlbumCard extends StatelessWidget {
required this.userId, required this.userId,
}); });
final Album album; final RemoteAlbum album;
final String? userId; final String? userId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return GestureDetector( return GestureDetector(
onTap: () => context.router.push( onTap: () => context.router.push(
RemoteTimelineRoute(albumId: album.id), RemoteTimelineRoute(album: album),
), ),
child: Card( child: Card(
elevation: 0, elevation: 0,

View File

@ -27,7 +27,12 @@ class DriftLibraryPage extends ConsumerWidget {
return const Scaffold( return const Scaffold(
body: CustomScrollView( body: CustomScrollView(
slivers: [ slivers: [
ImmichSliverAppBar(), ImmichSliverAppBar(
snap: false,
floating: false,
pinned: true,
showUploadButton: false,
),
_ActionButtonGrid(), _ActionButtonGrid(),
_CollectionCards(), _CollectionCards(),
_QuickAccessButtonList(), _QuickAccessButtonList(),

View File

@ -335,7 +335,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final isDraggingDown = currentExtent < previousExtent; final isDraggingDown = currentExtent < previousExtent;
previousExtent = currentExtent; previousExtent = currentExtent;
// Closes the bottom sheet if the user is dragging down // Closes the bottom sheet if the user is dragging down
if (isDraggingDown && delta.extent < 0.5) { if (isDraggingDown && delta.extent < 0.55) {
if (dragInProgress) { if (dragInProgress) {
blockGestures = true; blockGestures = true;
} }

View File

@ -18,6 +18,7 @@ import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart'; import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/mesmerizing_sliver_app_bar.dart';
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart'; import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
class Timeline extends StatelessWidget { class Timeline extends StatelessWidget {
@ -25,10 +26,12 @@ class Timeline extends StatelessWidget {
super.key, super.key,
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.appBar,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? appBar;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -49,6 +52,7 @@ class Timeline extends StatelessWidget {
child: _SliverTimeline( child: _SliverTimeline(
topSliverWidget: topSliverWidget, topSliverWidget: topSliverWidget,
topSliverWidgetHeight: topSliverWidgetHeight, topSliverWidgetHeight: topSliverWidgetHeight,
appBar: appBar,
), ),
), ),
), ),
@ -60,10 +64,12 @@ class _SliverTimeline extends ConsumerStatefulWidget {
const _SliverTimeline({ const _SliverTimeline({
this.topSliverWidget, this.topSliverWidget,
this.topSliverWidgetHeight, this.topSliverWidgetHeight,
this.appBar,
}); });
final Widget? topSliverWidget; final Widget? topSliverWidget;
final double? topSliverWidgetHeight; final double? topSliverWidgetHeight;
final Widget? appBar;
@override @override
ConsumerState createState() => _SliverTimelineState(); ConsumerState createState() => _SliverTimelineState();
@ -100,6 +106,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
onData: (segments) { onData: (segments) {
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1; final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
final statusBarHeight = context.padding.top; final statusBarHeight = context.padding.top;
final double appBarExpandedHeight =
widget.appBar != null && widget.appBar is MesmerizingSliverAppBar
? 200
: 0;
final totalAppBarHeight = statusBarHeight + kToolbarHeight; final totalAppBarHeight = statusBarHeight + kToolbarHeight;
const scrubberBottomPadding = 100.0; const scrubberBottomPadding = 100.0;
@ -112,7 +122,8 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
timelineHeight: maxHeight, timelineHeight: maxHeight,
topPadding: totalAppBarHeight + 10, topPadding: totalAppBarHeight + 10,
bottomPadding: context.padding.bottom + scrubberBottomPadding, bottomPadding: context.padding.bottom + scrubberBottomPadding,
monthSegmentSnappingOffset: widget.topSliverWidgetHeight, monthSegmentSnappingOffset:
widget.topSliverWidgetHeight ?? 0 + appBarExpandedHeight,
child: CustomScrollView( child: CustomScrollView(
primary: true, primary: true,
cacheExtent: maxHeight * 2, cacheExtent: maxHeight * 2,
@ -120,11 +131,12 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
if (isSelectionMode) if (isSelectionMode)
const SelectionSliverAppBar() const SelectionSliverAppBar()
else else
const ImmichSliverAppBar( widget.appBar ??
floating: true, const ImmichSliverAppBar(
pinned: false, floating: true,
snap: false, pinned: false,
), snap: false,
),
if (widget.topSliverWidget != null) widget.topSliverWidget!, if (widget.topSliverWidget != null) widget.topSliverWidget!,
_SliverSegmentedList( _SliverSegmentedList(
segments: segments, segments: segments,

View File

@ -8,21 +8,21 @@ import 'package:riverpod_annotation/riverpod_annotation.dart';
import 'album.provider.dart'; import 'album.provider.dart';
class RemoteAlbumState { class RemoteAlbumState {
final List<Album> albums; final List<RemoteAlbum> albums;
final List<Album> filteredAlbums; final List<RemoteAlbum> filteredAlbums;
final bool isLoading; final bool isLoading;
final String? error; final String? error;
const RemoteAlbumState({ const RemoteAlbumState({
required this.albums, required this.albums,
List<Album>? filteredAlbums, List<RemoteAlbum>? filteredAlbums,
this.isLoading = false, this.isLoading = false,
this.error, this.error,
}) : filteredAlbums = filteredAlbums ?? albums; }) : filteredAlbums = filteredAlbums ?? albums;
RemoteAlbumState copyWith({ RemoteAlbumState copyWith({
List<Album>? albums, List<RemoteAlbum>? albums,
List<Album>? filteredAlbums, List<RemoteAlbum>? filteredAlbums,
bool? isLoading, bool? isLoading,
String? error, String? error,
}) { }) {
@ -66,7 +66,7 @@ class RemoteAlbumNotifier extends Notifier<RemoteAlbumState> {
return const RemoteAlbumState(albums: [], filteredAlbums: []); return const RemoteAlbumState(albums: [], filteredAlbums: []);
} }
Future<List<Album>> getAll() async { Future<List<RemoteAlbum>> getAll() async {
state = state.copyWith(isLoading: true, error: null); state = state.copyWith(isLoading: true, error: null);
try { try {

View File

@ -1,6 +1,8 @@
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:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart';
import 'package:immich_mobile/domain/models/album/local_album.model.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/log.model.dart'; import 'package:immich_mobile/domain/models/log.model.dart';
import 'package:immich_mobile/domain/models/memory.model.dart'; import 'package:immich_mobile/domain/models/memory.model.dart';

View File

@ -1211,11 +1211,11 @@ class LocalMediaSummaryRoute extends PageRouteInfo<void> {
class LocalTimelineRoute extends PageRouteInfo<LocalTimelineRouteArgs> { class LocalTimelineRoute extends PageRouteInfo<LocalTimelineRouteArgs> {
LocalTimelineRoute({ LocalTimelineRoute({
Key? key, Key? key,
required String albumId, required LocalAlbum album,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
LocalTimelineRoute.name, LocalTimelineRoute.name,
args: LocalTimelineRouteArgs(key: key, albumId: albumId), args: LocalTimelineRouteArgs(key: key, album: album),
initialChildren: children, initialChildren: children,
); );
@ -1225,21 +1225,21 @@ class LocalTimelineRoute extends PageRouteInfo<LocalTimelineRouteArgs> {
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<LocalTimelineRouteArgs>(); final args = data.argsAs<LocalTimelineRouteArgs>();
return LocalTimelinePage(key: args.key, albumId: args.albumId); return LocalTimelinePage(key: args.key, album: args.album);
}, },
); );
} }
class LocalTimelineRouteArgs { class LocalTimelineRouteArgs {
const LocalTimelineRouteArgs({this.key, required this.albumId}); const LocalTimelineRouteArgs({this.key, required this.album});
final Key? key; final Key? key;
final String albumId; final LocalAlbum album;
@override @override
String toString() { String toString() {
return 'LocalTimelineRouteArgs{key: $key, albumId: $albumId}'; return 'LocalTimelineRouteArgs{key: $key, album: $album}';
} }
} }
@ -1765,11 +1765,11 @@ class RemoteMediaSummaryRoute extends PageRouteInfo<void> {
class RemoteTimelineRoute extends PageRouteInfo<RemoteTimelineRouteArgs> { class RemoteTimelineRoute extends PageRouteInfo<RemoteTimelineRouteArgs> {
RemoteTimelineRoute({ RemoteTimelineRoute({
Key? key, Key? key,
required String albumId, required RemoteAlbum album,
List<PageRouteInfo>? children, List<PageRouteInfo>? children,
}) : super( }) : super(
RemoteTimelineRoute.name, RemoteTimelineRoute.name,
args: RemoteTimelineRouteArgs(key: key, albumId: albumId), args: RemoteTimelineRouteArgs(key: key, album: album),
initialChildren: children, initialChildren: children,
); );
@ -1779,21 +1779,21 @@ class RemoteTimelineRoute extends PageRouteInfo<RemoteTimelineRouteArgs> {
name, name,
builder: (data) { builder: (data) {
final args = data.argsAs<RemoteTimelineRouteArgs>(); final args = data.argsAs<RemoteTimelineRouteArgs>();
return RemoteTimelinePage(key: args.key, albumId: args.albumId); return RemoteTimelinePage(key: args.key, album: args.album);
}, },
); );
} }
class RemoteTimelineRouteArgs { class RemoteTimelineRouteArgs {
const RemoteTimelineRouteArgs({this.key, required this.albumId}); const RemoteTimelineRouteArgs({this.key, required this.album});
final Key? key; final Key? key;
final String albumId; final RemoteAlbum album;
@override @override
String toString() { String toString() {
return 'RemoteTimelineRouteArgs{key: $key, albumId: $albumId}'; return 'RemoteTimelineRouteArgs{key: $key, album: $album}';
} }
} }

View File

@ -1,38 +1,56 @@
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/album/album.model.dart'; import 'package:immich_mobile/domain/models/album/album.model.dart';
typedef AlbumSortFn = List<Album> Function(List<Album> albums, bool isReverse); typedef AlbumSortFn = List<RemoteAlbum> Function(
List<RemoteAlbum> albums,
bool isReverse,
);
class _RemoteAlbumSortHandlers { class _RemoteAlbumSortHandlers {
const _RemoteAlbumSortHandlers._(); const _RemoteAlbumSortHandlers._();
static const AlbumSortFn created = _sortByCreated; static const AlbumSortFn created = _sortByCreated;
static List<Album> _sortByCreated(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByCreated(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = albums.sortedBy((album) => album.createdAt); final sorted = albums.sortedBy((album) => album.createdAt);
return (isReverse ? sorted.reversed : sorted).toList(); return (isReverse ? sorted.reversed : sorted).toList();
} }
static const AlbumSortFn title = _sortByTitle; static const AlbumSortFn title = _sortByTitle;
static List<Album> _sortByTitle(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByTitle(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = albums.sortedBy((album) => album.name); final sorted = albums.sortedBy((album) => album.name);
return (isReverse ? sorted.reversed : sorted).toList(); return (isReverse ? sorted.reversed : sorted).toList();
} }
static const AlbumSortFn lastModified = _sortByLastModified; static const AlbumSortFn lastModified = _sortByLastModified;
static List<Album> _sortByLastModified(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByLastModified(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = albums.sortedBy((album) => album.updatedAt); final sorted = albums.sortedBy((album) => album.updatedAt);
return (isReverse ? sorted.reversed : sorted).toList(); return (isReverse ? sorted.reversed : sorted).toList();
} }
static const AlbumSortFn assetCount = _sortByAssetCount; static const AlbumSortFn assetCount = _sortByAssetCount;
static List<Album> _sortByAssetCount(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByAssetCount(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = final sorted =
albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount)); albums.sorted((a, b) => a.assetCount.compareTo(b.assetCount));
return (isReverse ? sorted.reversed : sorted).toList(); return (isReverse ? sorted.reversed : sorted).toList();
} }
static const AlbumSortFn mostRecent = _sortByMostRecent; static const AlbumSortFn mostRecent = _sortByMostRecent;
static List<Album> _sortByMostRecent(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByMostRecent(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = albums.sorted((a, b) { final sorted = albums.sorted((a, b) {
// For most recent, we sort by updatedAt in descending order // For most recent, we sort by updatedAt in descending order
return b.updatedAt.compareTo(a.updatedAt); return b.updatedAt.compareTo(a.updatedAt);
@ -41,7 +59,10 @@ class _RemoteAlbumSortHandlers {
} }
static const AlbumSortFn mostOldest = _sortByMostOldest; static const AlbumSortFn mostOldest = _sortByMostOldest;
static List<Album> _sortByMostOldest(List<Album> albums, bool isReverse) { static List<RemoteAlbum> _sortByMostOldest(
List<RemoteAlbum> albums,
bool isReverse,
) {
final sorted = albums.sorted((a, b) { final sorted = albums.sorted((a, b) {
// For oldest, we sort by createdAt in ascending order // For oldest, we sort by createdAt in ascending order
return a.createdAt.compareTo(b.createdAt); return a.createdAt.compareTo(b.createdAt);

View File

@ -73,7 +73,10 @@ class ImmichSliverAppBar extends ConsumerWidget {
onPressed: () => context.pop(), onPressed: () => context.pop(),
), ),
IconButton( IconButton(
onPressed: () => ref.read(backgroundSyncProvider).syncRemote(), onPressed: () {
ref.read(backgroundSyncProvider).syncLocal(full: true);
ref.read(backgroundSyncProvider).syncRemote();
},
icon: const Icon( icon: const Icon(
Icons.sync, Icons.sync,
), ),

View File

@ -0,0 +1,563 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/services/timeline.service.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
class MesmerizingSliverAppBar extends ConsumerStatefulWidget {
const MesmerizingSliverAppBar({
super.key,
required this.title,
this.icon = Icons.camera,
});
final String title;
final IconData icon;
@override
ConsumerState<MesmerizingSliverAppBar> createState() =>
_MesmerizingSliverAppBarState();
}
class _MesmerizingSliverAppBarState
extends ConsumerState<MesmerizingSliverAppBar> {
double _scrollProgress = 0.0;
double _calculateScrollProgress(FlexibleSpaceBarSettings? settings) {
if (settings?.maxExtent == null || settings?.minExtent == null) {
return 1.0;
}
final deltaExtent = settings!.maxExtent - settings.minExtent;
if (deltaExtent <= 0.0) {
return 1.0;
}
return (1.0 - (settings.currentExtent - settings.minExtent) / deltaExtent)
.clamp(0.0, 1.0);
}
@override
Widget build(BuildContext context) {
final isMultiSelectEnabled =
ref.watch(multiSelectProvider.select((s) => s.isEnabled));
return isMultiSelectEnabled
? SliverToBoxAdapter(
child: switch (_scrollProgress) {
< 0.8 => const SizedBox(height: 120),
_ => const SizedBox(height: 352),
},
)
: SliverAppBar(
expandedHeight: 300.0,
floating: false,
pinned: true,
snap: false,
elevation: 0,
leading: IconButton(
icon: Icon(
Platform.isIOS
? Icons.arrow_back_ios_new_rounded
: Icons.arrow_back,
color: Color.lerp(
Colors.white,
context.primaryColor,
_scrollProgress,
),
shadows: [
_scrollProgress < 0.95
? Shadow(
offset: const Offset(0, 2),
blurRadius: 5,
color: Colors.black.withValues(alpha: 0.5),
)
: const Shadow(
offset: Offset(0, 2),
blurRadius: 0,
color: Colors.transparent,
),
],
),
onPressed: () {
context.pop();
},
),
flexibleSpace: Builder(
builder: (context) {
final settings = context.dependOnInheritedWidgetOfExactType<
FlexibleSpaceBarSettings>();
final scrollProgress = _calculateScrollProgress(settings);
// Update scroll progress for the leading button
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _scrollProgress != scrollProgress) {
setState(() {
_scrollProgress = scrollProgress;
});
}
});
return FlexibleSpaceBar(
centerTitle: true,
title: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: scrollProgress > 0.95
? Text(
widget.title,
style: TextStyle(
color: context.primaryColor,
fontWeight: FontWeight.w600,
fontSize: 18,
),
)
: null,
),
background: _ExpandedBackground(
scrollProgress: scrollProgress,
title: widget.title,
icon: widget.icon,
),
);
},
),
);
}
}
class _ExpandedBackground extends ConsumerStatefulWidget {
final double scrollProgress;
final String title;
final IconData icon;
const _ExpandedBackground({
required this.scrollProgress,
required this.title,
required this.icon,
});
@override
ConsumerState<_ExpandedBackground> createState() =>
_ExpandedBackgroundState();
}
class _ExpandedBackgroundState extends ConsumerState<_ExpandedBackground>
with SingleTickerProviderStateMixin {
late AnimationController _slideController;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_slideController = AnimationController(
duration: const Duration(milliseconds: 800),
vsync: this,
);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1.5),
end: Offset.zero,
).animate(
CurvedAnimation(
parent: _slideController,
curve: Curves.easeOutCubic,
),
);
Future.delayed(const Duration(milliseconds: 100), () {
if (mounted) {
_slideController.forward();
}
});
}
@override
void dispose() {
_slideController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final timelineService = ref.watch(timelineServiceProvider);
return Stack(
fit: StackFit.expand,
children: [
Transform.translate(
offset: Offset(0, widget.scrollProgress * 50),
child: Transform.scale(
scale: 1.4 - (widget.scrollProgress * 0.2),
child: _RandomAssetBackground(
timelineService: timelineService,
icon: widget.icon,
),
),
),
Container(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
Colors.transparent,
Colors.transparent,
Colors.black.withValues(
alpha: 0.6 + (widget.scrollProgress * 0.2),
),
],
stops: const [0.0, 0.65, 1.0],
),
),
),
Positioned(
bottom: 16,
left: 16,
right: 16,
child: SlideTransition(
position: _slideAnimation,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: double.infinity,
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Text(
widget.title,
maxLines: 1,
style: const TextStyle(
color: Colors.white,
fontSize: 36,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
shadows: [
Shadow(
offset: Offset(0, 2),
blurRadius: 12,
color: Colors.black45,
),
],
),
),
),
),
AnimatedContainer(
duration: const Duration(milliseconds: 300),
child: const _ItemCountText(),
),
],
),
),
),
],
);
}
}
class _ItemCountText extends ConsumerStatefulWidget {
const _ItemCountText();
@override
ConsumerState<_ItemCountText> createState() => _ItemCountTextState();
}
class _ItemCountTextState extends ConsumerState<_ItemCountText> {
StreamSubscription? _reloadSubscription;
@override
void initState() {
super.initState();
_reloadSubscription =
EventStream.shared.listen<TimelineReloadEvent>((_) => setState(() {}));
}
@override
void dispose() {
_reloadSubscription?.cancel();
super.dispose();
}
@override
Widget build(BuildContext context) {
final assetCount = ref.watch(
timelineServiceProvider.select((s) => s.totalAssets),
);
return Text(
'items_count'.t(
context: context,
args: {"count": assetCount},
),
style: context.textTheme.labelLarge?.copyWith(
// letterSpacing: 0.2,
fontWeight: FontWeight.bold,
color: Colors.white,
shadows: [
const Shadow(
offset: Offset(0, 1),
blurRadius: 6,
color: Colors.black45,
),
],
),
);
}
}
class _RandomAssetBackground extends StatefulWidget {
final TimelineService timelineService;
final IconData icon;
const _RandomAssetBackground({
required this.timelineService,
required this.icon,
});
@override
State<_RandomAssetBackground> createState() => _RandomAssetBackgroundState();
}
class _RandomAssetBackgroundState extends State<_RandomAssetBackground>
with TickerProviderStateMixin {
late AnimationController _zoomController;
late AnimationController _crossFadeController;
late Animation<double> _zoomAnimation;
late Animation<Offset> _panAnimation;
late Animation<double> _crossFadeAnimation;
BaseAsset? _currentAsset;
BaseAsset? _nextAsset;
bool _isZoomingIn = true;
@override
void initState() {
super.initState();
_zoomController = AnimationController(
duration: const Duration(seconds: 12),
vsync: this,
);
_crossFadeController = AnimationController(
duration: const Duration(milliseconds: 1200),
vsync: this,
);
_zoomAnimation = Tween<double>(
begin: 1.0,
end: 1.2,
).animate(
CurvedAnimation(
parent: _zoomController,
curve: Curves.easeInOut,
),
);
_panAnimation = Tween<Offset>(
begin: Offset.zero,
end: const Offset(0.5, -0.5),
).animate(
CurvedAnimation(
parent: _zoomController,
curve: Curves.easeInOut,
),
);
_crossFadeAnimation = Tween<double>(
begin: 0.0,
end: 1.0,
).animate(
CurvedAnimation(
parent: _crossFadeController,
curve: Curves.easeInOutCubic,
),
);
Future.delayed(
Durations.medium1,
() => _loadFirstAsset(),
);
}
@override
void dispose() {
_zoomController.dispose();
_crossFadeController.dispose();
super.dispose();
}
void _startAnimationCycle() {
if (_isZoomingIn) {
_zoomController.forward().then((_) {
_loadNextAsset();
});
} else {
_zoomController.reverse().then((_) {
_loadNextAsset();
});
}
}
Future<void> _loadFirstAsset() async {
if (!mounted) {
return;
}
if (widget.timelineService.totalAssets == 0) {
setState(() {
_currentAsset = null;
});
return;
}
setState(() {
_currentAsset = widget.timelineService.getRandomAsset();
});
await _crossFadeController.forward();
if (_zoomController.status == AnimationStatus.dismissed) {
if (_isZoomingIn) {
_zoomController.reset();
} else {
_zoomController.value = 1.0;
}
_startAnimationCycle();
}
}
Future<void> _loadNextAsset() async {
if (!mounted) {
return;
}
try {
if (widget.timelineService.totalAssets > 1) {
// Load next asset while keeping current one visible
final nextAsset = widget.timelineService.getRandomAsset();
setState(() {
_nextAsset = nextAsset;
});
await _crossFadeController.reverse();
setState(() {
_currentAsset = _nextAsset;
_nextAsset = null;
});
_crossFadeController.value = 1.0;
_isZoomingIn = !_isZoomingIn;
_startAnimationCycle();
}
} catch (e) {
_zoomController.reset();
_startAnimationCycle();
}
}
@override
Widget build(BuildContext context) {
if (widget.timelineService.totalAssets == 0) {
return const SizedBox.shrink();
}
return AnimatedBuilder(
animation: Listenable.merge(
[_zoomAnimation, _panAnimation, _crossFadeAnimation],
),
builder: (context, child) {
return Transform.scale(
scale: _zoomAnimation.value,
filterQuality: FilterQuality.low,
child: Transform.translate(
offset: _panAnimation.value,
filterQuality: FilterQuality.low,
child: Stack(
fit: StackFit.expand,
children: [
// Current image
if (_currentAsset != null)
Opacity(
opacity: _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_currentAsset!),
fit: BoxFit.cover,
frameBuilder:
(context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return Container();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(
Icons.error_outline_rounded,
size: 24,
color: Colors.red[300],
),
);
},
),
),
),
if (_nextAsset != null)
Opacity(
opacity: 1.0 - _crossFadeAnimation.value,
child: SizedBox(
width: double.infinity,
height: double.infinity,
child: Image(
alignment: Alignment.topRight,
image: getFullImageProvider(_nextAsset!),
fit: BoxFit.cover,
frameBuilder:
(context, child, frame, wasSynchronouslyLoaded) {
if (wasSynchronouslyLoaded || frame != null) {
return child;
}
return const SizedBox.shrink();
},
errorBuilder: (context, error, stackTrace) {
return SizedBox(
width: double.infinity,
height: double.infinity,
child: Icon(
Icons.error_outline_rounded,
size: 24,
color: Colors.red[300],
),
);
},
),
),
),
],
),
),
);
},
);
}
}