You've already forked immich
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:
@ -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 &&
|
||||||
|
@ -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) {
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -103,7 +103,7 @@ class _AlbumList extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
onTap: () =>
|
onTap: () =>
|
||||||
context.pushRoute(LocalTimelineRoute(albumId: album.id)),
|
context.pushRoute(LocalTimelineRoute(album: album)),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
@ -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,
|
||||||
|
),
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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(),
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
@ -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,
|
||||||
|
@ -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 {
|
||||||
|
@ -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';
|
||||||
|
@ -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}';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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);
|
||||||
|
@ -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,
|
||||||
),
|
),
|
||||||
|
563
mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart
Normal file
563
mobile/lib/widgets/common/mesmerizing_sliver_app_bar.dart
Normal 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],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user