merge main
This commit is contained in:
@@ -0,0 +1,17 @@
|
||||
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/infrastructure/repositories/local_album.repository.dart';
|
||||
|
||||
class LocalAlbumService {
|
||||
final DriftLocalAlbumRepository _repository;
|
||||
|
||||
const LocalAlbumService(this._repository);
|
||||
|
||||
Future<List<LocalAlbum>> getAll() {
|
||||
return _repository.getAll();
|
||||
}
|
||||
|
||||
Future<LocalAsset?> getThumbnail(String albumId) {
|
||||
return _repository.getThumbnail(albumId);
|
||||
}
|
||||
}
|
||||
@@ -42,16 +42,29 @@ class TimelineFactory {
|
||||
|
||||
TimelineService localAlbum({required String albumId}) => TimelineService(
|
||||
assetSource: (offset, count) => _timelineRepository
|
||||
.getLocalBucketAssets(albumId, offset: offset, count: count),
|
||||
bucketSource: () =>
|
||||
_timelineRepository.watchLocalBucket(albumId, groupBy: groupBy),
|
||||
.getLocalAlbumBucketAssets(albumId, offset: offset, count: count),
|
||||
bucketSource: () => _timelineRepository.watchLocalAlbumBucket(
|
||||
albumId,
|
||||
groupBy: groupBy,
|
||||
),
|
||||
);
|
||||
|
||||
TimelineService remoteAlbum({required String albumId}) => TimelineService(
|
||||
assetSource: (offset, count) => _timelineRepository
|
||||
.getRemoteBucketAssets(albumId, offset: offset, count: count),
|
||||
bucketSource: () =>
|
||||
_timelineRepository.watchRemoteBucket(albumId, groupBy: groupBy),
|
||||
.getRemoteAlbumBucketAssets(albumId, offset: offset, count: count),
|
||||
bucketSource: () => _timelineRepository.watchRemoteAlbumBucket(
|
||||
albumId,
|
||||
groupBy: groupBy,
|
||||
),
|
||||
);
|
||||
|
||||
TimelineService remoteAssets(String ownerId) => TimelineService(
|
||||
assetSource: (offset, count) => _timelineRepository
|
||||
.getRemoteBucketAssets(ownerId, offset: offset, count: count),
|
||||
bucketSource: () => _timelineRepository.watchRemoteBucket(
|
||||
ownerId,
|
||||
groupBy: GroupAssetsBy.month,
|
||||
),
|
||||
);
|
||||
|
||||
TimelineService favorite(String userId) => TimelineService(
|
||||
|
||||
@@ -361,6 +361,24 @@ class DriftLocalAlbumRepository extends DriftDatabaseRepository {
|
||||
batch.deleteWhere(_db.localAssetEntity, (f) => f.id.isIn(ids));
|
||||
});
|
||||
}
|
||||
|
||||
Future<LocalAsset?> getThumbnail(String albumId) async {
|
||||
final query = _db.localAlbumAssetEntity.select().join([
|
||||
innerJoin(
|
||||
_db.localAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
|
||||
),
|
||||
])
|
||||
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
|
||||
..orderBy([OrderingTerm.asc(_db.localAssetEntity.id)])
|
||||
..limit(1);
|
||||
|
||||
final results = await query
|
||||
.map((row) => row.readTable(_db.localAssetEntity).toDto())
|
||||
.get();
|
||||
|
||||
return results.isNotEmpty ? results.first : null;
|
||||
}
|
||||
}
|
||||
|
||||
extension on LocalAlbumEntityData {
|
||||
|
||||
@@ -18,13 +18,21 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAlbumAssetEntity.albumId.equalsExp(_db.remoteAlbumEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.remoteAssetEntity,
|
||||
_db.remoteAssetEntity.id.equalsExp(_db.remoteAlbumAssetEntity.assetId),
|
||||
useColumns: false,
|
||||
),
|
||||
leftOuterJoin(
|
||||
_db.userEntity,
|
||||
_db.userEntity.id.equalsExp(_db.remoteAlbumEntity.ownerId),
|
||||
useColumns: false,
|
||||
),
|
||||
]);
|
||||
query
|
||||
..where(_db.remoteAssetEntity.deletedAt.isNull())
|
||||
..addColumns([assetCount])
|
||||
..addColumns([_db.userEntity.name])
|
||||
..groupBy([_db.remoteAlbumEntity.id]);
|
||||
|
||||
if (sortBy.isNotEmpty) {
|
||||
@@ -43,7 +51,7 @@ class DriftRemoteAlbumRepository extends DriftDatabaseRepository {
|
||||
.map(
|
||||
(row) => row.readTable(_db.remoteAlbumEntity).toDto(
|
||||
assetCount: row.read(assetCount) ?? 0,
|
||||
ownerName: row.readTable(_db.userEntity).name,
|
||||
ownerName: row.read(_db.userEntity.name)!,
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
@@ -13,6 +13,22 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
||||
final Drift _db;
|
||||
const RemoteAssetRepository(this._db) : super(_db);
|
||||
|
||||
/// For testing purposes
|
||||
Future<List<RemoteAsset>> getSome(String userId) {
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where(
|
||||
(row) =>
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.visibility
|
||||
.equalsValue(AssetVisibility.timeline),
|
||||
)
|
||||
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
|
||||
..limit(10);
|
||||
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
|
||||
Stream<RemoteAsset?> watchAsset(String id) {
|
||||
final query = _db.remoteAssetEntity
|
||||
.select()
|
||||
|
||||
@@ -104,7 +104,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
).get();
|
||||
}
|
||||
|
||||
Stream<List<Bucket>> watchLocalBucket(
|
||||
Stream<List<Bucket>> watchLocalAlbumBucket(
|
||||
String albumId, {
|
||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||
}) {
|
||||
@@ -124,6 +124,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
innerJoin(
|
||||
_db.localAlbumAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(_db.localAlbumAssetEntity.albumId.equals(albumId))
|
||||
@@ -137,7 +138,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
}).watch();
|
||||
}
|
||||
|
||||
Future<List<BaseAsset>> getLocalBucketAssets(
|
||||
Future<List<BaseAsset>> getLocalAlbumBucketAssets(
|
||||
String albumId, {
|
||||
required int offset,
|
||||
required int count,
|
||||
@@ -147,6 +148,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
innerJoin(
|
||||
_db.localAlbumAssetEntity,
|
||||
_db.localAlbumAssetEntity.assetId.equalsExp(_db.localAssetEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
],
|
||||
)
|
||||
@@ -158,7 +160,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
.get();
|
||||
}
|
||||
|
||||
Stream<List<Bucket>> watchRemoteBucket(
|
||||
Stream<List<Bucket>> watchRemoteAlbumBucket(
|
||||
String albumId, {
|
||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||
}) {
|
||||
@@ -179,9 +181,13 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAlbumAssetEntity,
|
||||
_db.remoteAlbumAssetEntity.assetId
|
||||
.equalsExp(_db.remoteAssetEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
])
|
||||
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||
..where(
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAlbumAssetEntity.albumId.equals(albumId),
|
||||
)
|
||||
..groupBy([dateExp])
|
||||
..orderBy([OrderingTerm.desc(dateExp)]);
|
||||
|
||||
@@ -192,7 +198,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
}).watch();
|
||||
}
|
||||
|
||||
Future<List<BaseAsset>> getRemoteBucketAssets(
|
||||
Future<List<BaseAsset>> getRemoteAlbumBucketAssets(
|
||||
String albumId, {
|
||||
required int offset,
|
||||
required int count,
|
||||
@@ -203,10 +209,14 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
_db.remoteAlbumAssetEntity,
|
||||
_db.remoteAlbumAssetEntity.assetId
|
||||
.equalsExp(_db.remoteAssetEntity.id),
|
||||
useColumns: false,
|
||||
),
|
||||
],
|
||||
)
|
||||
..where(_db.remoteAlbumAssetEntity.albumId.equals(albumId))
|
||||
..where(
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAlbumAssetEntity.albumId.equals(albumId),
|
||||
)
|
||||
..orderBy([OrderingTerm.desc(_db.remoteAssetEntity.createdAt)])
|
||||
..limit(count, offset: offset);
|
||||
return query
|
||||
@@ -214,15 +224,17 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
.get();
|
||||
}
|
||||
|
||||
Stream<List<Bucket>> watchFavoriteBucket(
|
||||
String userId, {
|
||||
Stream<List<Bucket>> watchRemoteBucket(
|
||||
String ownerId, {
|
||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||
}) {
|
||||
if (groupBy == GroupAssetsBy.none) {
|
||||
return _db.remoteAssetEntity
|
||||
.count(
|
||||
where: (row) =>
|
||||
row.isFavorite.equals(true) & row.ownerId.equals(userId),
|
||||
row.deletedAt.isNull() &
|
||||
row.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
row.ownerId.equals(ownerId),
|
||||
)
|
||||
.map(_generateBuckets)
|
||||
.watchSingle();
|
||||
@@ -234,7 +246,63 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
final query = _db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([assetCountExp, dateExp])
|
||||
..where(
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.visibility
|
||||
.equalsValue(AssetVisibility.timeline) &
|
||||
_db.remoteAssetEntity.ownerId.equals(ownerId),
|
||||
)
|
||||
..groupBy([dateExp])
|
||||
..orderBy([OrderingTerm.desc(dateExp)]);
|
||||
|
||||
return query.map((row) {
|
||||
final timeline = row.read(dateExp)!.dateFmt(groupBy);
|
||||
final assetCount = row.read(assetCountExp)!;
|
||||
return TimeBucket(date: timeline, assetCount: assetCount);
|
||||
}).watch();
|
||||
}
|
||||
|
||||
Future<List<BaseAsset>> getRemoteBucketAssets(
|
||||
String ownerId, {
|
||||
required int offset,
|
||||
required int count,
|
||||
}) {
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where(
|
||||
(row) =>
|
||||
row.deletedAt.isNull() &
|
||||
row.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
row.ownerId.equals(ownerId),
|
||||
)
|
||||
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
|
||||
..limit(count, offset: offset);
|
||||
|
||||
return query.map((row) => row.toDto()).get();
|
||||
}
|
||||
|
||||
Stream<List<Bucket>> watchFavoriteBucket(
|
||||
String userId, {
|
||||
GroupAssetsBy groupBy = GroupAssetsBy.day,
|
||||
}) {
|
||||
if (groupBy == GroupAssetsBy.none) {
|
||||
return _db.remoteAssetEntity
|
||||
.count(
|
||||
where: (row) =>
|
||||
row.deletedAt.isNull() &
|
||||
row.isFavorite.equals(true) &
|
||||
row.ownerId.equals(userId),
|
||||
)
|
||||
.map(_generateBuckets)
|
||||
.watchSingle();
|
||||
}
|
||||
|
||||
final assetCountExp = _db.remoteAssetEntity.id.count();
|
||||
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
|
||||
|
||||
final query = _db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([assetCountExp, dateExp])
|
||||
..where(
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.isFavorite.equals(true),
|
||||
)
|
||||
..groupBy([dateExp])
|
||||
@@ -254,7 +322,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
}) {
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where(
|
||||
(row) => row.isFavorite.equals(true) & row.ownerId.equals(userId),
|
||||
(row) =>
|
||||
row.deletedAt.isNull() &
|
||||
row.isFavorite.equals(true) &
|
||||
row.ownerId.equals(userId),
|
||||
)
|
||||
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
|
||||
..limit(count, offset: offset);
|
||||
@@ -318,6 +389,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
return _db.remoteAssetEntity
|
||||
.count(
|
||||
where: (row) =>
|
||||
row.deletedAt.isNull() &
|
||||
row.visibility.equalsValue(AssetVisibility.archive) &
|
||||
row.ownerId.equals(userId),
|
||||
)
|
||||
@@ -331,7 +403,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
final query = _db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([assetCountExp, dateExp])
|
||||
..where(
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.visibility
|
||||
.equalsValue(AssetVisibility.archive),
|
||||
)
|
||||
@@ -353,6 +426,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where(
|
||||
(row) =>
|
||||
row.deletedAt.isNull() &
|
||||
row.ownerId.equals(userId) &
|
||||
row.visibility.equalsValue(AssetVisibility.archive),
|
||||
)
|
||||
@@ -370,6 +444,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
return _db.remoteAssetEntity
|
||||
.count(
|
||||
where: (row) =>
|
||||
row.deletedAt.isNull() &
|
||||
row.visibility.equalsValue(AssetVisibility.locked) &
|
||||
row.ownerId.equals(userId),
|
||||
)
|
||||
@@ -383,7 +458,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
final query = _db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([assetCountExp, dateExp])
|
||||
..where(
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.visibility
|
||||
.equalsValue(AssetVisibility.locked),
|
||||
)
|
||||
@@ -405,6 +481,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where(
|
||||
(row) =>
|
||||
row.deletedAt.isNull() &
|
||||
row.visibility.equalsValue(AssetVisibility.locked) &
|
||||
row.ownerId.equals(userId),
|
||||
)
|
||||
@@ -422,6 +499,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
return _db.remoteAssetEntity
|
||||
.count(
|
||||
where: (row) =>
|
||||
row.deletedAt.isNull() &
|
||||
row.type.equalsValue(AssetType.video) &
|
||||
row.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
row.ownerId.equals(userId),
|
||||
@@ -436,7 +514,8 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
final query = _db.remoteAssetEntity.selectOnly()
|
||||
..addColumns([assetCountExp, dateExp])
|
||||
..where(
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.deletedAt.isNull() &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId) &
|
||||
_db.remoteAssetEntity.type.equalsValue(AssetType.video) &
|
||||
_db.remoteAssetEntity.visibility
|
||||
.equalsValue(AssetVisibility.timeline),
|
||||
@@ -459,10 +538,10 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
|
||||
final query = _db.remoteAssetEntity.select()
|
||||
..where(
|
||||
(row) =>
|
||||
_db.remoteAssetEntity.type.equalsValue(AssetType.video) &
|
||||
_db.remoteAssetEntity.visibility
|
||||
.equalsValue(AssetVisibility.timeline) &
|
||||
_db.remoteAssetEntity.ownerId.equals(userId),
|
||||
row.deletedAt.isNull() &
|
||||
row.type.equalsValue(AssetType.video) &
|
||||
row.visibility.equalsValue(AssetVisibility.timeline) &
|
||||
row.ownerId.equals(userId),
|
||||
)
|
||||
..orderBy([(row) => OrderingTerm.desc(row.createdAt)])
|
||||
..limit(count, offset: offset);
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/pages/common/large_leading_tile.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/widgets/common/local_album_sliver_app_bar.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftLocalAlbumsPage extends StatelessWidget {
|
||||
const DriftLocalAlbumsPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return const Scaffold(
|
||||
body: CustomScrollView(
|
||||
slivers: [
|
||||
LocalAlbumsSliverAppBar(),
|
||||
_AlbumList(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AlbumList extends ConsumerWidget {
|
||||
const _AlbumList();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final albums = ref.watch(localAlbumProvider);
|
||||
|
||||
return albums.when(
|
||||
loading: () => const SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20.0),
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
),
|
||||
),
|
||||
error: (error, stack) => SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20.0),
|
||||
child: Text(
|
||||
'Error loading albums: $error, stack: $stack',
|
||||
style: TextStyle(
|
||||
color: context.colorScheme.error,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
data: (albums) {
|
||||
if (albums.isEmpty) {
|
||||
return const SliverToBoxAdapter(
|
||||
child: Center(
|
||||
child: Padding(
|
||||
padding: EdgeInsets.all(20.0),
|
||||
child: Text('No albums found'),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return SliverPadding(
|
||||
padding: const EdgeInsets.all(18.0),
|
||||
sliver: SliverList.builder(
|
||||
itemBuilder: (_, index) {
|
||||
final album = albums[index];
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 8.0),
|
||||
child: LargeLeadingTile(
|
||||
leadingPadding: const EdgeInsets.only(
|
||||
right: 16,
|
||||
),
|
||||
leading: SizedBox(
|
||||
width: 80,
|
||||
height: 80,
|
||||
child: LocalAlbumThumbnail(
|
||||
albumId: album.id,
|
||||
),
|
||||
),
|
||||
title: Text(
|
||||
album.name,
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
),
|
||||
subtitle: Text(
|
||||
'items_count'.t(
|
||||
context: context,
|
||||
args: {'count': album.assetCount},
|
||||
),
|
||||
style: context.textTheme.bodyMedium?.copyWith(
|
||||
color: context.colorScheme.onSurfaceSecondary,
|
||||
),
|
||||
),
|
||||
onTap: () =>
|
||||
context.pushRoute(LocalTimelineRoute(albumId: album.id)),
|
||||
),
|
||||
);
|
||||
},
|
||||
itemCount: albums.length,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftPartnerDetailPage extends StatelessWidget {
|
||||
final String partnerId;
|
||||
|
||||
const DriftPartnerDetailPage({super.key, required this.partnerId});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith(
|
||||
(ref) {
|
||||
final timelineService =
|
||||
ref.watch(timelineFactoryProvider).remoteAssets(partnerId);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
},
|
||||
),
|
||||
],
|
||||
child: const Timeline(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/widgets.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/user.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftRecentlyTakenPage extends StatelessWidget {
|
||||
const DriftRecentlyTakenPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
timelineServiceProvider.overrideWith(
|
||||
(ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) {
|
||||
throw Exception(
|
||||
'User must be logged in to access recently taken',
|
||||
);
|
||||
}
|
||||
|
||||
final timelineService =
|
||||
ref.watch(timelineFactoryProvider).remoteAssets(user.id);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
},
|
||||
),
|
||||
],
|
||||
child: const Timeline(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,15 +5,43 @@ import 'package:drift/drift.dart' hide Column;
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/dev_logger.dart';
|
||||
import 'package:immich_mobile/providers/background_sync.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
final _features = [
|
||||
_Feature(
|
||||
name: 'Selection Mode Timeline',
|
||||
icon: Icons.developer_mode_rounded,
|
||||
onTap: (ctx, ref) async {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) {
|
||||
return Future.value();
|
||||
}
|
||||
|
||||
final assets =
|
||||
await ref.read(remoteAssetRepositoryProvider).getSome(user.id);
|
||||
|
||||
final selectedAssets = await ctx.pushRoute<Set<BaseAsset>>(
|
||||
DriftAssetSelectionTimelineRoute(
|
||||
lockedSelectionAssets: assets.toSet(),
|
||||
),
|
||||
);
|
||||
|
||||
DLog.log(
|
||||
"Selected ${selectedAssets?.length ?? 0} assets",
|
||||
);
|
||||
|
||||
return Future.value();
|
||||
},
|
||||
),
|
||||
_Feature(
|
||||
name: 'Sync Local',
|
||||
icon: Icons.photo_album_rounded,
|
||||
@@ -104,6 +132,11 @@ final _features = [
|
||||
icon: Icons.video_collection_outlined,
|
||||
onTap: (ctx, _) => ctx.pushRoute(const DriftVideoRoute()),
|
||||
),
|
||||
_Feature(
|
||||
name: 'Recently Taken',
|
||||
icon: Icons.schedule_outlined,
|
||||
onTap: (ctx, _) => ctx.pushRoute(const DriftRecentlyTakenRoute()),
|
||||
),
|
||||
];
|
||||
|
||||
@RoutePage()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/presentation/widgets/timeline/timeline.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/providers/user.provider.dart';
|
||||
|
||||
@RoutePage()
|
||||
class DriftAssetSelectionTimelinePage extends ConsumerWidget {
|
||||
final Set<BaseAsset> lockedSelectionAssets;
|
||||
const DriftAssetSelectionTimelinePage({
|
||||
super.key,
|
||||
this.lockedSelectionAssets = const {},
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
return ProviderScope(
|
||||
overrides: [
|
||||
multiSelectProvider.overrideWith(
|
||||
() => MultiSelectNotifier(
|
||||
MultiSelectState(
|
||||
selectedAssets: {},
|
||||
lockedSelectionAssets: lockedSelectionAssets,
|
||||
forceEnable: true,
|
||||
),
|
||||
),
|
||||
),
|
||||
timelineServiceProvider.overrideWith(
|
||||
(ref) {
|
||||
final user = ref.watch(currentUserProvider);
|
||||
if (user == null) {
|
||||
throw Exception(
|
||||
'User must be logged in to access recently taken',
|
||||
);
|
||||
}
|
||||
|
||||
final timelineService =
|
||||
ref.watch(timelineFactoryProvider).remoteAssets(user.id);
|
||||
ref.onDispose(timelineService.dispose);
|
||||
return timelineService;
|
||||
},
|
||||
),
|
||||
],
|
||||
child: const Timeline(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,14 +5,14 @@ import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
import 'package:immich_mobile/extensions/asyncvalue_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/album/album.provider.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/local_album_thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/partner.provider.dart';
|
||||
import 'package:immich_mobile/providers/search/people.provider.dart';
|
||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/utils/image_url_builder.dart';
|
||||
import 'package:immich_mobile/widgets/album/album_thumbnail_card.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/user_avatar.dart';
|
||||
import 'package:immich_mobile/widgets/map/map_thumbnail.dart';
|
||||
@@ -310,8 +310,7 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
// TODO: Migrate to the drift after local album page
|
||||
final albums = ref.watch(localAlbumsProvider);
|
||||
final albums = ref.watch(localAlbumProvider);
|
||||
|
||||
return LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
@@ -320,9 +319,7 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
|
||||
final size = context.width * widthFactor - 20.0;
|
||||
|
||||
return GestureDetector(
|
||||
onTap: () => context.pushRoute(
|
||||
const LocalAlbumsRoute(),
|
||||
),
|
||||
onTap: () => context.pushRoute(const DriftLocalAlbumsRoute()),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
@@ -347,12 +344,29 @@ class _LocalAlbumsCollectionCard extends ConsumerWidget {
|
||||
crossAxisSpacing: 8,
|
||||
mainAxisSpacing: 8,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
children: albums.take(4).map((album) {
|
||||
return AlbumThumbnailCard(
|
||||
album: album,
|
||||
showTitle: false,
|
||||
);
|
||||
}).toList(),
|
||||
children: albums.when(
|
||||
data: (data) {
|
||||
return data.take(4).map((album) {
|
||||
return LocalAlbumThumbnail(
|
||||
albumId: album.id,
|
||||
);
|
||||
}).toList();
|
||||
},
|
||||
error: (error, _) {
|
||||
return [
|
||||
Center(
|
||||
child: Text('Error: $error'),
|
||||
),
|
||||
];
|
||||
},
|
||||
loading: () {
|
||||
return [
|
||||
const Center(
|
||||
child: CircularProgressIndicator(),
|
||||
),
|
||||
];
|
||||
},
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -498,7 +512,8 @@ class _PartnerList extends StatelessWidget {
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
).t(context: context, args: {'user': partner.name}),
|
||||
onTap: () => context.pushRoute(PartnerDetailRoute(partner: partner)),
|
||||
onTap: () =>
|
||||
context.pushRoute(DriftPartnerDetailRoute(partnerId: partner.id)),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
|
||||
class LocalAlbumThumbnail extends ConsumerWidget {
|
||||
const LocalAlbumThumbnail({
|
||||
super.key,
|
||||
required this.albumId,
|
||||
});
|
||||
|
||||
final String albumId;
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final localAlbumThumbnail = ref.watch(localAlbumThumbnailProvider(albumId));
|
||||
return localAlbumThumbnail.when(
|
||||
data: (data) {
|
||||
if (data == null) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainer,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
border: Border.all(
|
||||
color: context.colorScheme.outline.withAlpha(50),
|
||||
width: 1,
|
||||
),
|
||||
),
|
||||
child: Icon(
|
||||
Icons.collections,
|
||||
size: 24,
|
||||
color: context.primaryColor,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(16)),
|
||||
child: Thumbnail(
|
||||
asset: data,
|
||||
),
|
||||
);
|
||||
},
|
||||
error: (error, stack) {
|
||||
return const Icon(Icons.error, size: 24);
|
||||
},
|
||||
loading: () => const SizedBox(
|
||||
width: 24,
|
||||
height: 24,
|
||||
child: Center(child: CircularProgressIndicator()),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -12,7 +12,7 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
this.size = const Size.square(256),
|
||||
this.fit = BoxFit.cover,
|
||||
this.showStorageIndicator = true,
|
||||
this.canDeselect = true,
|
||||
this.lockSelection = false,
|
||||
super.key,
|
||||
});
|
||||
|
||||
@@ -20,15 +20,13 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
final Size size;
|
||||
final BoxFit fit;
|
||||
final bool showStorageIndicator;
|
||||
|
||||
/// If we are allowed to deselect this image
|
||||
final bool canDeselect;
|
||||
final bool lockSelection;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final assetContainerColor = context.isDarkTheme
|
||||
? context.primaryColor.darken(amount: 0.6)
|
||||
: context.primaryColor.lighten(amount: 0.8);
|
||||
? context.primaryColor.darken(amount: 0.4)
|
||||
: context.primaryColor.lighten(amount: 0.75);
|
||||
|
||||
final isSelected = ref.watch(
|
||||
multiSelectProvider.select(
|
||||
@@ -36,24 +34,29 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
),
|
||||
);
|
||||
|
||||
final borderStyle = lockSelection
|
||||
? BoxDecoration(
|
||||
color: context.colorScheme.surfaceContainerHighest,
|
||||
border: Border.all(
|
||||
color: context.colorScheme.surfaceContainerHighest,
|
||||
width: 6,
|
||||
),
|
||||
)
|
||||
: isSelected
|
||||
? BoxDecoration(
|
||||
color: assetContainerColor,
|
||||
border: Border.all(color: assetContainerColor, width: 6),
|
||||
)
|
||||
: const BoxDecoration();
|
||||
|
||||
return Stack(
|
||||
children: [
|
||||
AnimatedContainer(
|
||||
duration: Durations.short4,
|
||||
curve: Curves.decelerate,
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? (canDeselect ? assetContainerColor : Colors.grey)
|
||||
: null,
|
||||
border: isSelected
|
||||
? Border.all(
|
||||
color: canDeselect ? assetContainerColor : Colors.grey,
|
||||
width: 8,
|
||||
)
|
||||
: const Border(),
|
||||
),
|
||||
decoration: borderStyle,
|
||||
child: ClipRRect(
|
||||
borderRadius: isSelected
|
||||
borderRadius: isSelected || lockSelection
|
||||
? const BorderRadius.all(Radius.circular(15.0))
|
||||
: BorderRadius.zero,
|
||||
child: Stack(
|
||||
@@ -102,14 +105,17 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
if (isSelected || lockSelection)
|
||||
Padding(
|
||||
padding: const EdgeInsets.all(3.0),
|
||||
child: Align(
|
||||
alignment: Alignment.topLeft,
|
||||
child: _SelectionIndicator(
|
||||
isSelected: isSelected,
|
||||
color: assetContainerColor,
|
||||
isLocked: lockSelection,
|
||||
color: lockSelection
|
||||
? context.colorScheme.surfaceContainerHighest
|
||||
: assetContainerColor,
|
||||
),
|
||||
),
|
||||
),
|
||||
@@ -120,15 +126,29 @@ class ThumbnailTile extends ConsumerWidget {
|
||||
|
||||
class _SelectionIndicator extends StatelessWidget {
|
||||
final bool isSelected;
|
||||
final bool isLocked;
|
||||
final Color? color;
|
||||
|
||||
const _SelectionIndicator({
|
||||
required this.isSelected,
|
||||
required this.isLocked,
|
||||
this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (isSelected) {
|
||||
if (isLocked) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: color,
|
||||
),
|
||||
child: const Icon(
|
||||
Icons.check_circle_rounded,
|
||||
color: Colors.grey,
|
||||
),
|
||||
);
|
||||
} else if (isSelected) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
|
||||
@@ -166,22 +166,22 @@ class _AssetTileWidget extends ConsumerWidget {
|
||||
BaseAsset asset,
|
||||
) {
|
||||
final multiSelectState = ref.read(multiSelectProvider);
|
||||
if (!multiSelectState.isEnabled) {
|
||||
|
||||
if (multiSelectState.forceEnable || multiSelectState.isEnabled) {
|
||||
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
||||
} else {
|
||||
ctx.pushRoute(
|
||||
AssetViewerRoute(
|
||||
initialIndex: assetIndex,
|
||||
timelineService: ref.read(timelineServiceProvider),
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
||||
}
|
||||
|
||||
void _handleOnLongPress(WidgetRef ref, BaseAsset asset) {
|
||||
final multiSelectState = ref.read(multiSelectProvider);
|
||||
if (multiSelectState.isEnabled) {
|
||||
if (multiSelectState.isEnabled || multiSelectState.forceEnable) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -189,13 +189,35 @@ class _AssetTileWidget extends ConsumerWidget {
|
||||
ref.read(multiSelectProvider.notifier).toggleAssetSelection(asset);
|
||||
}
|
||||
|
||||
bool _getLockSelectionStatus(WidgetRef ref) {
|
||||
final lockSelectionAssets = ref.read(
|
||||
multiSelectProvider.select(
|
||||
(state) => state.lockedSelectionAssets,
|
||||
),
|
||||
);
|
||||
|
||||
if (lockSelectionAssets.isEmpty) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return lockSelectionAssets.contains(asset);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context, WidgetRef ref) {
|
||||
final lockSelection = _getLockSelectionStatus(ref);
|
||||
|
||||
return RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onTap: () => _handleOnTap(context, ref, assetIndex, asset),
|
||||
onLongPress: () => _handleOnLongPress(ref, asset),
|
||||
child: ThumbnailTile(asset),
|
||||
onTap: () => lockSelection
|
||||
? null
|
||||
: _handleOnTap(context, ref, assetIndex, asset),
|
||||
onLongPress: () =>
|
||||
lockSelection ? null : _handleOnLongPress(ref, asset),
|
||||
child: ThumbnailTile(
|
||||
asset,
|
||||
lockSelection: lockSelection,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -354,22 +354,24 @@ class ScrubberState extends ConsumerState<Scrubber>
|
||||
isDragging: _isDragging,
|
||||
),
|
||||
),
|
||||
PositionedDirectional(
|
||||
top: _thumbTopOffset + widget.topPadding,
|
||||
end: 0,
|
||||
child: RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: _onDragStart,
|
||||
onVerticalDragUpdate: _onDragUpdate,
|
||||
onVerticalDragEnd: _onDragEnd,
|
||||
child: _Scrubber(
|
||||
thumbAnimation: _thumbAnimation,
|
||||
labelAnimation: _labelAnimation,
|
||||
label: label,
|
||||
if (_scrollController.hasClients &&
|
||||
_scrollController.position.maxScrollExtent > 0)
|
||||
PositionedDirectional(
|
||||
top: _thumbTopOffset + widget.topPadding,
|
||||
end: 0,
|
||||
child: RepaintBoundary(
|
||||
child: GestureDetector(
|
||||
onVerticalDragStart: _onDragStart,
|
||||
onVerticalDragUpdate: _onDragUpdate,
|
||||
onVerticalDragEnd: _onDragEnd,
|
||||
child: _Scrubber(
|
||||
thumbAnimation: _thumbAnimation,
|
||||
labelAnimation: _labelAnimation,
|
||||
label: label,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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/timeline/multiselect.provider.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_sliver_app_bar.dart';
|
||||
import 'package:immich_mobile/widgets/common/selection_sliver_app_bar.dart';
|
||||
|
||||
class Timeline extends StatelessWidget {
|
||||
const Timeline({
|
||||
@@ -96,6 +97,10 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
final asyncSegments = ref.watch(timelineSegmentProvider);
|
||||
final maxHeight =
|
||||
ref.watch(timelineArgsProvider.select((args) => args.maxHeight));
|
||||
final isSelectionMode = ref.watch(
|
||||
multiSelectProvider.select((s) => s.forceEnable),
|
||||
);
|
||||
|
||||
return asyncSegments.widgetWhen(
|
||||
onData: (segments) {
|
||||
final childCount = (segments.lastOrNull?.lastIndex ?? -1) + 1;
|
||||
@@ -117,12 +122,15 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
primary: true,
|
||||
cacheExtent: maxHeight * 2,
|
||||
slivers: [
|
||||
widget.appBar ??
|
||||
const ImmichSliverAppBar(
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
),
|
||||
if (isSelectionMode)
|
||||
const SelectionSliverAppBar()
|
||||
else
|
||||
widget.appBar ??
|
||||
const ImmichSliverAppBar(
|
||||
floating: true,
|
||||
pinned: false,
|
||||
snap: false,
|
||||
),
|
||||
if (widget.topSliverWidget != null) widget.topSliverWidget!,
|
||||
_SliverSegmentedList(
|
||||
segments: segments,
|
||||
@@ -147,40 +155,42 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
||||
],
|
||||
),
|
||||
),
|
||||
Consumer(
|
||||
builder: (_, consumerRef, child) {
|
||||
final isMultiSelectEnabled = consumerRef.watch(
|
||||
multiSelectProvider.select(
|
||||
(s) => s.isEnabled,
|
||||
),
|
||||
);
|
||||
if (!isSelectionMode) ...[
|
||||
Consumer(
|
||||
builder: (_, consumerRef, child) {
|
||||
final isMultiSelectEnabled = consumerRef.watch(
|
||||
multiSelectProvider.select(
|
||||
(s) => s.isEnabled,
|
||||
),
|
||||
);
|
||||
|
||||
if (isMultiSelectEnabled) {
|
||||
return child!;
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
child: const Positioned(
|
||||
top: 60,
|
||||
left: 25,
|
||||
child: _MultiSelectStatusButton(),
|
||||
if (isMultiSelectEnabled) {
|
||||
return child!;
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
child: const Positioned(
|
||||
top: 60,
|
||||
left: 25,
|
||||
child: _MultiSelectStatusButton(),
|
||||
),
|
||||
),
|
||||
),
|
||||
Consumer(
|
||||
builder: (_, consumerRef, child) {
|
||||
final isMultiSelectEnabled = consumerRef.watch(
|
||||
multiSelectProvider.select(
|
||||
(s) => s.isEnabled,
|
||||
),
|
||||
);
|
||||
Consumer(
|
||||
builder: (_, consumerRef, child) {
|
||||
final isMultiSelectEnabled = consumerRef.watch(
|
||||
multiSelectProvider.select(
|
||||
(s) => s.isEnabled,
|
||||
),
|
||||
);
|
||||
|
||||
if (isMultiSelectEnabled) {
|
||||
return child!;
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
child: const HomeBottomAppBar(),
|
||||
),
|
||||
if (isMultiSelectEnabled) {
|
||||
return child!;
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
},
|
||||
child: const HomeBottomAppBar(),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.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/services/local_album.service.dart';
|
||||
import 'package:immich_mobile/domain/services/remote_album.service.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
|
||||
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
|
||||
@@ -9,6 +12,19 @@ final localAlbumRepository = Provider<DriftLocalAlbumRepository>(
|
||||
(ref) => DriftLocalAlbumRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
final localAlbumServiceProvider = Provider<LocalAlbumService>(
|
||||
(ref) => LocalAlbumService(ref.watch(localAlbumRepository)),
|
||||
);
|
||||
|
||||
final localAlbumProvider = FutureProvider<List<LocalAlbum>>(
|
||||
(ref) => LocalAlbumService(ref.watch(localAlbumRepository)).getAll(),
|
||||
);
|
||||
|
||||
final localAlbumThumbnailProvider = FutureProvider.family<LocalAsset?, String>(
|
||||
(ref, albumId) =>
|
||||
LocalAlbumService(ref.watch(localAlbumRepository)).getThumbnail(albumId),
|
||||
);
|
||||
|
||||
final remoteAlbumRepository = Provider<DriftRemoteAlbumRepository>(
|
||||
(ref) => DriftRemoteAlbumRepository(ref.watch(driftProvider)),
|
||||
);
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import 'package:collection/collection.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/providers/infrastructure/timeline.provider.dart';
|
||||
@@ -12,8 +13,14 @@ final multiSelectProvider =
|
||||
|
||||
class MultiSelectState {
|
||||
final Set<BaseAsset> selectedAssets;
|
||||
final Set<BaseAsset> lockedSelectionAssets;
|
||||
final bool forceEnable;
|
||||
|
||||
const MultiSelectState({required this.selectedAssets});
|
||||
const MultiSelectState({
|
||||
required this.selectedAssets,
|
||||
required this.lockedSelectionAssets,
|
||||
this.forceEnable = false,
|
||||
});
|
||||
|
||||
bool get isEnabled => selectedAssets.isNotEmpty;
|
||||
bool get hasRemote => selectedAssets.any(
|
||||
@@ -25,33 +32,54 @@ class MultiSelectState {
|
||||
(asset) => asset.storage == AssetState.local,
|
||||
);
|
||||
|
||||
MultiSelectState copyWith({Set<BaseAsset>? selectedAssets}) {
|
||||
MultiSelectState copyWith({
|
||||
Set<BaseAsset>? selectedAssets,
|
||||
Set<BaseAsset>? lockedSelectionAssets,
|
||||
bool? forceEnable,
|
||||
}) {
|
||||
return MultiSelectState(
|
||||
selectedAssets: selectedAssets ?? this.selectedAssets,
|
||||
lockedSelectionAssets:
|
||||
lockedSelectionAssets ?? this.lockedSelectionAssets,
|
||||
forceEnable: forceEnable ?? this.forceEnable,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String toString() => 'MultiSelectState(selectedAssets: $selectedAssets)';
|
||||
String toString() =>
|
||||
'MultiSelectState(selectedAssets: $selectedAssets, lockedSelectionAssets: $lockedSelectionAssets, forceEnable: $forceEnable)';
|
||||
|
||||
@override
|
||||
bool operator ==(covariant MultiSelectState other) {
|
||||
if (identical(this, other)) return true;
|
||||
final listEquals = const DeepCollectionEquality().equals;
|
||||
final setEquals = const DeepCollectionEquality().equals;
|
||||
|
||||
return listEquals(other.selectedAssets, selectedAssets);
|
||||
return setEquals(other.selectedAssets, selectedAssets) &&
|
||||
setEquals(other.lockedSelectionAssets, lockedSelectionAssets) &&
|
||||
other.forceEnable == forceEnable;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => selectedAssets.hashCode;
|
||||
int get hashCode =>
|
||||
selectedAssets.hashCode ^
|
||||
lockedSelectionAssets.hashCode ^
|
||||
forceEnable.hashCode;
|
||||
}
|
||||
|
||||
class MultiSelectNotifier extends Notifier<MultiSelectState> {
|
||||
MultiSelectNotifier([this._defaultState]);
|
||||
final MultiSelectState? _defaultState;
|
||||
|
||||
TimelineService get _timelineService => ref.read(timelineServiceProvider);
|
||||
|
||||
@override
|
||||
MultiSelectState build() {
|
||||
return const MultiSelectState(selectedAssets: {});
|
||||
return _defaultState ??
|
||||
const MultiSelectState(
|
||||
selectedAssets: {},
|
||||
lockedSelectionAssets: {},
|
||||
forceEnable: false,
|
||||
);
|
||||
}
|
||||
|
||||
void selectAsset(BaseAsset asset) {
|
||||
@@ -83,7 +111,11 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
|
||||
}
|
||||
|
||||
void reset() {
|
||||
state = const MultiSelectState(selectedAssets: {});
|
||||
state = const MultiSelectState(
|
||||
selectedAssets: {},
|
||||
lockedSelectionAssets: {},
|
||||
forceEnable: false,
|
||||
);
|
||||
}
|
||||
|
||||
/// Bucket bulk operations
|
||||
@@ -131,6 +163,12 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
|
||||
|
||||
state = state.copyWith(selectedAssets: selectedAssets);
|
||||
}
|
||||
|
||||
void setLockedSelectionAssets(Set<BaseAsset> assets) {
|
||||
state = state.copyWith(
|
||||
lockedSelectionAssets: assets,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final bucketSelectionProvider = Provider.family<bool, List<BaseAsset>>(
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:immich_mobile/routing/router.dart';
|
||||
|
||||
/// Handles duplicate navigation to this route (primarily for deep linking)
|
||||
class GalleryGuard extends AutoRouteGuard {
|
||||
const GalleryGuard();
|
||||
@override
|
||||
void onNavigation(NavigationResolver resolver, StackRouter router) async {
|
||||
final newRouteName = resolver.route.name;
|
||||
final currentTopRouteName =
|
||||
router.stack.isNotEmpty ? router.stack.last.name : null;
|
||||
|
||||
if (currentTopRouteName == newRouteName) {
|
||||
// Replace instead of pushing duplicate
|
||||
final args = resolver.route.args as GalleryViewerRouteArgs;
|
||||
|
||||
router.replace(
|
||||
GalleryViewerRoute(
|
||||
renderList: args.renderList,
|
||||
initialIndex: args.initialIndex,
|
||||
heroOffset: args.heroOffset,
|
||||
showStack: args.showStack,
|
||||
),
|
||||
);
|
||||
// Prevent further navigation since we replaced the route
|
||||
resolver.next(false);
|
||||
return;
|
||||
}
|
||||
resolver.next(true);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/memory.model.dart';
|
||||
import 'package:immich_mobile/domain/models/user.model.dart';
|
||||
@@ -67,6 +68,9 @@ import 'package:immich_mobile/pages/search/recently_taken.page.dart';
|
||||
import 'package:immich_mobile/pages/search/search.page.dart';
|
||||
import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/drift_favorite.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/drift_partner_detail.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/drift_local_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/drift_recently_taken.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/drift_video.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/drift_trash.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/drift_archive.page.dart';
|
||||
@@ -78,6 +82,7 @@ import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/remote_timeline.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_library.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_asset_selection_timeline.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_memory.page.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.page.dart';
|
||||
import 'package:immich_mobile/providers/api.provider.dart';
|
||||
@@ -86,6 +91,7 @@ import 'package:immich_mobile/routing/auth_guard.dart';
|
||||
import 'package:immich_mobile/routing/backup_permission_guard.dart';
|
||||
import 'package:immich_mobile/routing/custom_transition_builders.dart';
|
||||
import 'package:immich_mobile/routing/duplicate_guard.dart';
|
||||
import 'package:immich_mobile/routing/gallery_guard.dart';
|
||||
import 'package:immich_mobile/routing/locked_guard.dart';
|
||||
import 'package:immich_mobile/services/api.service.dart';
|
||||
import 'package:immich_mobile/services/local_auth.service.dart';
|
||||
@@ -111,6 +117,7 @@ class AppRouter extends RootStackRouter {
|
||||
late final DuplicateGuard _duplicateGuard;
|
||||
late final BackupPermissionGuard _backupPermissionGuard;
|
||||
late final LockedGuard _lockedGuard;
|
||||
late final GalleryGuard _galleryGuard;
|
||||
|
||||
AppRouter(
|
||||
ApiService apiService,
|
||||
@@ -123,6 +130,7 @@ class AppRouter extends RootStackRouter {
|
||||
_lockedGuard =
|
||||
LockedGuard(apiService, secureStorageService, localAuthService);
|
||||
_backupPermissionGuard = BackupPermissionGuard(galleryPermissionNotifier);
|
||||
_galleryGuard = const GalleryGuard();
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -192,7 +200,7 @@ class AppRouter extends RootStackRouter {
|
||||
),
|
||||
CustomRoute(
|
||||
page: GalleryViewerRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
guards: [_authGuard, _galleryGuard],
|
||||
transitionsBuilder: CustomTransitionsBuilders.zoomedPage,
|
||||
),
|
||||
AutoRoute(
|
||||
@@ -422,6 +430,22 @@ class AppRouter extends RootStackRouter {
|
||||
page: DriftLibraryRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
AutoRoute(
|
||||
page: DriftAssetSelectionTimelineRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
AutoRoute(
|
||||
page: DriftPartnerDetailRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
AutoRoute(
|
||||
page: DriftRecentlyTakenRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
AutoRoute(
|
||||
page: DriftLocalAlbumsRoute.page,
|
||||
guards: [_authGuard, _duplicateGuard],
|
||||
),
|
||||
// required to handle all deeplinks in deep_link.service.dart
|
||||
// auto_route_library#1722
|
||||
RedirectRoute(path: '*', redirectTo: '/'),
|
||||
|
||||
@@ -634,6 +634,55 @@ class DriftArchiveRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftAssetSelectionTimelinePage]
|
||||
class DriftAssetSelectionTimelineRoute
|
||||
extends PageRouteInfo<DriftAssetSelectionTimelineRouteArgs> {
|
||||
DriftAssetSelectionTimelineRoute({
|
||||
Key? key,
|
||||
Set<BaseAsset> lockedSelectionAssets = const {},
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftAssetSelectionTimelineRoute.name,
|
||||
args: DriftAssetSelectionTimelineRouteArgs(
|
||||
key: key,
|
||||
lockedSelectionAssets: lockedSelectionAssets,
|
||||
),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'DriftAssetSelectionTimelineRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<DriftAssetSelectionTimelineRouteArgs>(
|
||||
orElse: () => const DriftAssetSelectionTimelineRouteArgs(),
|
||||
);
|
||||
return DriftAssetSelectionTimelinePage(
|
||||
key: args.key,
|
||||
lockedSelectionAssets: args.lockedSelectionAssets,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class DriftAssetSelectionTimelineRouteArgs {
|
||||
const DriftAssetSelectionTimelineRouteArgs({
|
||||
this.key,
|
||||
this.lockedSelectionAssets = const {},
|
||||
});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final Set<BaseAsset> lockedSelectionAssets;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftAssetSelectionTimelineRouteArgs{key: $key, lockedSelectionAssets: $lockedSelectionAssets}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftFavoritePage]
|
||||
class DriftFavoriteRoute extends PageRouteInfo<void> {
|
||||
@@ -666,6 +715,22 @@ class DriftLibraryRoute extends PageRouteInfo<void> {
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftLocalAlbumsPage]
|
||||
class DriftLocalAlbumsRoute extends PageRouteInfo<void> {
|
||||
const DriftLocalAlbumsRoute({List<PageRouteInfo>? children})
|
||||
: super(DriftLocalAlbumsRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'DriftLocalAlbumsRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const DriftLocalAlbumsPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftLockedFolderPage]
|
||||
class DriftLockedFolderRoute extends PageRouteInfo<void> {
|
||||
@@ -734,6 +799,60 @@ class DriftMemoryRouteArgs {
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftPartnerDetailPage]
|
||||
class DriftPartnerDetailRoute
|
||||
extends PageRouteInfo<DriftPartnerDetailRouteArgs> {
|
||||
DriftPartnerDetailRoute({
|
||||
Key? key,
|
||||
required String partnerId,
|
||||
List<PageRouteInfo>? children,
|
||||
}) : super(
|
||||
DriftPartnerDetailRoute.name,
|
||||
args: DriftPartnerDetailRouteArgs(key: key, partnerId: partnerId),
|
||||
initialChildren: children,
|
||||
);
|
||||
|
||||
static const String name = 'DriftPartnerDetailRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
final args = data.argsAs<DriftPartnerDetailRouteArgs>();
|
||||
return DriftPartnerDetailPage(key: args.key, partnerId: args.partnerId);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
class DriftPartnerDetailRouteArgs {
|
||||
const DriftPartnerDetailRouteArgs({this.key, required this.partnerId});
|
||||
|
||||
final Key? key;
|
||||
|
||||
final String partnerId;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'DriftPartnerDetailRouteArgs{key: $key, partnerId: $partnerId}';
|
||||
}
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftRecentlyTakenPage]
|
||||
class DriftRecentlyTakenRoute extends PageRouteInfo<void> {
|
||||
const DriftRecentlyTakenRoute({List<PageRouteInfo>? children})
|
||||
: super(DriftRecentlyTakenRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'DriftRecentlyTakenRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const DriftRecentlyTakenPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [DriftTrashPage]
|
||||
class DriftTrashRoute extends PageRouteInfo<void> {
|
||||
|
||||
@@ -106,7 +106,6 @@ class DeepLinkService {
|
||||
|
||||
Future<PageRouteInfo?> _buildAssetDeepLink(String assetId) async {
|
||||
final asset = await _assetService.getAssetByRemoteId(assetId);
|
||||
|
||||
if (asset == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
|
||||
class LocalAlbumsSliverAppBar extends StatelessWidget {
|
||||
const LocalAlbumsSliverAppBar({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SliverAppBar(
|
||||
floating: true,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
backgroundColor: context.colorScheme.surfaceContainer,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||
),
|
||||
automaticallyImplyLeading: true,
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
"on_this_device".t(context: context),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
||||
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
|
||||
|
||||
class SelectionSliverAppBar extends ConsumerStatefulWidget {
|
||||
const SelectionSliverAppBar({
|
||||
super.key,
|
||||
});
|
||||
|
||||
@override
|
||||
ConsumerState<SelectionSliverAppBar> createState() =>
|
||||
_SelectionSliverAppBarState();
|
||||
}
|
||||
|
||||
class _SelectionSliverAppBarState extends ConsumerState<SelectionSliverAppBar> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final selection = ref.watch(
|
||||
multiSelectProvider.select((s) => s.selectedAssets),
|
||||
);
|
||||
|
||||
final toExclude = ref.watch(
|
||||
multiSelectProvider.select((s) => s.lockedSelectionAssets),
|
||||
);
|
||||
|
||||
final filteredAssets = selection.where((asset) {
|
||||
return !toExclude.contains(asset);
|
||||
}).toSet();
|
||||
|
||||
onDone(Set<BaseAsset> selected) {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
context.maybePop<Set<BaseAsset>>(selected);
|
||||
}
|
||||
|
||||
return SliverAppBar(
|
||||
floating: true,
|
||||
pinned: true,
|
||||
snap: false,
|
||||
backgroundColor: context.colorScheme.surfaceContainer,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||
),
|
||||
automaticallyImplyLeading: false,
|
||||
leading: IconButton(
|
||||
icon: const Icon(Icons.close_rounded),
|
||||
onPressed: () {
|
||||
ref.read(multiSelectProvider.notifier).reset();
|
||||
context.pop<Set<BaseAsset>>(null);
|
||||
},
|
||||
),
|
||||
centerTitle: true,
|
||||
title: Text(
|
||||
"Select {count}".t(
|
||||
context: context,
|
||||
args: {
|
||||
'count': filteredAssets.length.toString(),
|
||||
},
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => onDone(filteredAssets),
|
||||
child: Text(
|
||||
'done'.t(context: context),
|
||||
style: context.textTheme.titleSmall?.copyWith(
|
||||
color: context.colorScheme.primary,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user