mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
feat: adds bottom sheet map and actions (#19726)
* reduce timeline rebuilds * feat: adds bottom sheet map and actions (#19692) * adds bottom sheet map and actions * PR feedbacks * only reload the asset viewer if asset is changed * styling tweak --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com> * rename singleton and remove event prefix * adds bottom sheet map and actions * PR feedbacks * refactor: use provider for viewer state * feat: adds top and bottom app bar * add safe area to bottom app bar * change app and bottom bar color * viewer - always have black background * use the full width for the bottom sheet on landscape as well * constraint the bottom sheet to not expand all the way * add padding for location details in landscape --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex <alex.tran1502@gmail.com>
This commit is contained in:
parent
4a2cf28882
commit
73733370a2
@ -78,4 +78,38 @@ class RemoteAsset extends BaseAsset {
|
|||||||
localId.hashCode ^
|
localId.hashCode ^
|
||||||
thumbHash.hashCode ^
|
thumbHash.hashCode ^
|
||||||
visibility.hashCode;
|
visibility.hashCode;
|
||||||
|
|
||||||
|
RemoteAsset copyWith({
|
||||||
|
String? id,
|
||||||
|
String? localId,
|
||||||
|
String? name,
|
||||||
|
String? ownerId,
|
||||||
|
String? checksum,
|
||||||
|
AssetType? type,
|
||||||
|
DateTime? createdAt,
|
||||||
|
DateTime? updatedAt,
|
||||||
|
int? width,
|
||||||
|
int? height,
|
||||||
|
int? durationInSeconds,
|
||||||
|
bool? isFavorite,
|
||||||
|
String? thumbHash,
|
||||||
|
AssetVisibility? visibility,
|
||||||
|
}) {
|
||||||
|
return RemoteAsset(
|
||||||
|
id: id ?? this.id,
|
||||||
|
localId: localId ?? this.localId,
|
||||||
|
name: name ?? this.name,
|
||||||
|
ownerId: ownerId ?? this.ownerId,
|
||||||
|
checksum: checksum ?? this.checksum,
|
||||||
|
type: type ?? this.type,
|
||||||
|
createdAt: createdAt ?? this.createdAt,
|
||||||
|
updatedAt: updatedAt ?? this.updatedAt,
|
||||||
|
width: width ?? this.width,
|
||||||
|
height: height ?? this.height,
|
||||||
|
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
|
||||||
|
isFavorite: isFavorite ?? this.isFavorite,
|
||||||
|
thumbHash: thumbHash ?? this.thumbHash,
|
||||||
|
visibility: visibility ?? this.visibility,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -1,13 +1,24 @@
|
|||||||
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/exif.model.dart';
|
import 'package:immich_mobile/domain/models/exif.model.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
|
||||||
|
|
||||||
class AssetService {
|
class AssetService {
|
||||||
final RemoteAssetRepository _remoteAssetRepository;
|
final RemoteAssetRepository _remoteAssetRepository;
|
||||||
|
final DriftLocalAssetRepository _localAssetRepository;
|
||||||
|
|
||||||
const AssetService({
|
const AssetService({
|
||||||
required RemoteAssetRepository remoteAssetRepository,
|
required RemoteAssetRepository remoteAssetRepository,
|
||||||
}) : _remoteAssetRepository = remoteAssetRepository;
|
required DriftLocalAssetRepository localAssetRepository,
|
||||||
|
}) : _remoteAssetRepository = remoteAssetRepository,
|
||||||
|
_localAssetRepository = localAssetRepository;
|
||||||
|
|
||||||
|
Stream<BaseAsset?> watchAsset(BaseAsset asset) {
|
||||||
|
final id = asset is LocalAsset ? asset.id : (asset as RemoteAsset).id;
|
||||||
|
return asset is LocalAsset
|
||||||
|
? _localAssetRepository.watchAsset(id)
|
||||||
|
: _remoteAssetRepository.watchAsset(id);
|
||||||
|
}
|
||||||
|
|
||||||
Future<ExifInfo?> getExif(BaseAsset asset) async {
|
Future<ExifInfo?> getExif(BaseAsset asset) async {
|
||||||
if (asset is LocalAsset || asset is! RemoteAsset) {
|
if (asset is LocalAsset || asset is! RemoteAsset) {
|
||||||
|
@ -8,6 +8,10 @@ class TimelineReloadEvent extends Event {
|
|||||||
const TimelineReloadEvent();
|
const TimelineReloadEvent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
class ViewerOpenBottomSheetEvent extends Event {
|
||||||
|
const ViewerOpenBottomSheetEvent();
|
||||||
|
}
|
||||||
|
|
||||||
class EventStream {
|
class EventStream {
|
||||||
EventStream._();
|
EventStream._();
|
||||||
|
|
||||||
|
@ -28,5 +28,8 @@ extension LocalAssetEntityDataDomainEx on LocalAssetEntityData {
|
|||||||
updatedAt: updatedAt,
|
updatedAt: updatedAt,
|
||||||
durationInSeconds: durationInSeconds,
|
durationInSeconds: durationInSeconds,
|
||||||
isFavorite: isFavorite,
|
isFavorite: isFavorite,
|
||||||
|
height: height,
|
||||||
|
width: width,
|
||||||
|
remoteId: null,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import 'package:drift/drift.dart';
|
import 'package:drift/drift.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/infrastructure/entities/local_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
|
|
||||||
@ -7,6 +8,26 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
|
|||||||
final Drift _db;
|
final Drift _db;
|
||||||
const DriftLocalAssetRepository(this._db) : super(_db);
|
const DriftLocalAssetRepository(this._db) : super(_db);
|
||||||
|
|
||||||
|
Stream<LocalAsset?> watchAsset(String id) {
|
||||||
|
final query = _db.localAssetEntity
|
||||||
|
.select()
|
||||||
|
.addColumns([_db.localAssetEntity.id]).join([
|
||||||
|
leftOuterJoin(
|
||||||
|
_db.remoteAssetEntity,
|
||||||
|
_db.localAssetEntity.checksum.equalsExp(_db.remoteAssetEntity.checksum),
|
||||||
|
useColumns: false,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
..where(_db.localAssetEntity.id.equals(id));
|
||||||
|
|
||||||
|
return query.map((row) {
|
||||||
|
final asset = row.readTable(_db.localAssetEntity).toDto();
|
||||||
|
return asset.copyWith(
|
||||||
|
remoteId: row.read(_db.remoteAssetEntity.id),
|
||||||
|
);
|
||||||
|
}).watchSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
|
Future<void> updateHashes(Iterable<LocalAsset> hashes) {
|
||||||
if (hashes.isEmpty) {
|
if (hashes.isEmpty) {
|
||||||
return Future.value();
|
return Future.value();
|
||||||
|
@ -4,6 +4,7 @@ import 'package:immich_mobile/domain/models/exif.model.dart';
|
|||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.dart'
|
||||||
hide ExifInfo;
|
hide ExifInfo;
|
||||||
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart';
|
||||||
|
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.dart';
|
||||||
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
|
||||||
import 'package:maplibre_gl/maplibre_gl.dart';
|
import 'package:maplibre_gl/maplibre_gl.dart';
|
||||||
@ -12,6 +13,26 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
final Drift _db;
|
final Drift _db;
|
||||||
const RemoteAssetRepository(this._db) : super(_db);
|
const RemoteAssetRepository(this._db) : super(_db);
|
||||||
|
|
||||||
|
Stream<RemoteAsset?> watchAsset(String id) {
|
||||||
|
final query = _db.remoteAssetEntity
|
||||||
|
.select()
|
||||||
|
.addColumns([_db.localAssetEntity.id]).join([
|
||||||
|
leftOuterJoin(
|
||||||
|
_db.localAssetEntity,
|
||||||
|
_db.remoteAssetEntity.checksum.equalsExp(_db.localAssetEntity.checksum),
|
||||||
|
useColumns: false,
|
||||||
|
),
|
||||||
|
])
|
||||||
|
..where(_db.remoteAssetEntity.id.equals(id));
|
||||||
|
|
||||||
|
return query.map((row) {
|
||||||
|
final asset = row.readTable(_db.remoteAssetEntity).toDto();
|
||||||
|
return asset.copyWith(
|
||||||
|
localId: row.read(_db.localAssetEntity.id),
|
||||||
|
);
|
||||||
|
}).watchSingleOrNull();
|
||||||
|
}
|
||||||
|
|
||||||
Future<ExifInfo?> getExif(String id) {
|
Future<ExifInfo?> getExif(String id) {
|
||||||
return _db.managers.remoteExifEntity
|
return _db.managers.remoteExifEntity
|
||||||
.filter((row) => row.assetId.id.equals(id))
|
.filter((row) => row.assetId.id.equals(id))
|
||||||
|
@ -9,17 +9,33 @@ class BaseActionButton extends StatelessWidget {
|
|||||||
this.onPressed,
|
this.onPressed,
|
||||||
this.onLongPressed,
|
this.onLongPressed,
|
||||||
this.maxWidth = 90.0,
|
this.maxWidth = 90.0,
|
||||||
|
this.minWidth,
|
||||||
|
this.menuItem = false,
|
||||||
});
|
});
|
||||||
|
|
||||||
final String label;
|
final String label;
|
||||||
final IconData iconData;
|
final IconData iconData;
|
||||||
final double maxWidth;
|
final double maxWidth;
|
||||||
|
final double? minWidth;
|
||||||
|
final bool menuItem;
|
||||||
final void Function()? onPressed;
|
final void Function()? onPressed;
|
||||||
final void Function()? onLongPressed;
|
final void Function()? onLongPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final minWidth = context.isMobile ? context.width / 4.5 : 75.0;
|
final miniWidth =
|
||||||
|
minWidth ?? (context.isMobile ? context.width / 4.5 : 75.0);
|
||||||
|
final iconTheme = IconTheme.of(context);
|
||||||
|
final iconSize = iconTheme.size ?? 24.0;
|
||||||
|
final iconColor = iconTheme.color ?? context.themeData.iconTheme.color;
|
||||||
|
final textColor = context.themeData.textTheme.labelLarge?.color;
|
||||||
|
|
||||||
|
if (menuItem) {
|
||||||
|
return IconButton(
|
||||||
|
onPressed: onPressed,
|
||||||
|
icon: Icon(iconData, size: iconSize, color: iconColor),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return ConstrainedBox(
|
return ConstrainedBox(
|
||||||
constraints: BoxConstraints(
|
constraints: BoxConstraints(
|
||||||
@ -30,19 +46,22 @@ class BaseActionButton extends StatelessWidget {
|
|||||||
shape: const RoundedRectangleBorder(
|
shape: const RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||||
),
|
),
|
||||||
|
textColor: textColor,
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
onLongPress: onLongPressed,
|
onLongPress: onLongPressed,
|
||||||
minWidth: minWidth,
|
minWidth: miniWidth,
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.start,
|
mainAxisAlignment: MainAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(iconData, size: 24),
|
Icon(iconData, size: iconSize, color: iconColor),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
Text(
|
Text(
|
||||||
label,
|
label,
|
||||||
style:
|
style: const TextStyle(
|
||||||
const TextStyle(fontSize: 14.0, fontWeight: FontWeight.w400),
|
fontSize: 14.0,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
),
|
||||||
maxLines: 3,
|
maxLines: 3,
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
),
|
),
|
||||||
|
@ -10,8 +10,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
|
|
||||||
class FavoriteActionButton extends ConsumerWidget {
|
class FavoriteActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const FavoriteActionButton({super.key, required this.source});
|
const FavoriteActionButton({
|
||||||
|
super.key,
|
||||||
|
required this.source,
|
||||||
|
this.menuItem = false,
|
||||||
|
});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@ -19,6 +24,11 @@ class FavoriteActionButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).favorite(source);
|
final result = await ref.read(actionProvider.notifier).favorite(source);
|
||||||
|
|
||||||
|
if (source == ActionSource.viewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
final successMessage = 'favorite_action_prompt'.t(
|
final successMessage = 'favorite_action_prompt'.t(
|
||||||
@ -43,6 +53,7 @@ class FavoriteActionButton extends ConsumerWidget {
|
|||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.favorite_border_rounded,
|
iconData: Icons.favorite_border_rounded,
|
||||||
label: "favorite".t(context: context),
|
label: "favorite".t(context: context),
|
||||||
|
menuItem: menuItem,
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -10,8 +10,13 @@ import 'package:immich_mobile/widgets/common/immich_toast.dart';
|
|||||||
|
|
||||||
class UnFavoriteActionButton extends ConsumerWidget {
|
class UnFavoriteActionButton extends ConsumerWidget {
|
||||||
final ActionSource source;
|
final ActionSource source;
|
||||||
|
final bool menuItem;
|
||||||
|
|
||||||
const UnFavoriteActionButton({super.key, required this.source});
|
const UnFavoriteActionButton({
|
||||||
|
super.key,
|
||||||
|
required this.source,
|
||||||
|
this.menuItem = false,
|
||||||
|
});
|
||||||
|
|
||||||
void _onTap(BuildContext context, WidgetRef ref) async {
|
void _onTap(BuildContext context, WidgetRef ref) async {
|
||||||
if (!context.mounted) {
|
if (!context.mounted) {
|
||||||
@ -19,6 +24,11 @@ class UnFavoriteActionButton extends ConsumerWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final result = await ref.read(actionProvider.notifier).unFavorite(source);
|
final result = await ref.read(actionProvider.notifier).unFavorite(source);
|
||||||
|
|
||||||
|
if (source == ActionSource.viewer) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
ref.read(multiSelectProvider.notifier).reset();
|
ref.read(multiSelectProvider.notifier).reset();
|
||||||
|
|
||||||
final successMessage = 'unfavorite_action_prompt'.t(
|
final successMessage = 'unfavorite_action_prompt'.t(
|
||||||
@ -44,6 +54,7 @@ class UnFavoriteActionButton extends ConsumerWidget {
|
|||||||
iconData: Icons.favorite_rounded,
|
iconData: Icons.favorite_rounded,
|
||||||
label: "unfavorite".t(context: context),
|
label: "unfavorite".t(context: context),
|
||||||
onPressed: () => _onTap(context, ref),
|
onPressed: () => _onTap(context, ref),
|
||||||
|
menuItem: menuItem,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,10 @@ import 'package:immich_mobile/domain/services/timeline.service.dart';
|
|||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
import 'package:immich_mobile/extensions/scroll_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_bar.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/top_app_bar.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
import 'package:immich_mobile/presentation/widgets/images/image_provider.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/images/thumbnail.widget.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
@ -58,10 +61,10 @@ const double _kBottomSheetSnapExtent = 0.7;
|
|||||||
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
class _AssetViewerState extends ConsumerState<AssetViewer> {
|
||||||
late PageController pageController;
|
late PageController pageController;
|
||||||
late DraggableScrollableController bottomSheetController;
|
late DraggableScrollableController bottomSheetController;
|
||||||
PersistentBottomSheetController? sheetCloseNotifier;
|
PersistentBottomSheetController? sheetCloseController;
|
||||||
// PhotoViewGallery takes care of disposing it's controllers
|
// PhotoViewGallery takes care of disposing it's controllers
|
||||||
PhotoViewControllerBase? viewController;
|
PhotoViewControllerBase? viewController;
|
||||||
StreamSubscription? _reloadSubscription;
|
StreamSubscription? reloadSubscription;
|
||||||
|
|
||||||
late Platform platform;
|
late Platform platform;
|
||||||
late PhotoViewControllerValue initialPhotoViewState;
|
late PhotoViewControllerValue initialPhotoViewState;
|
||||||
@ -70,12 +73,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
bool blockGestures = false;
|
bool blockGestures = false;
|
||||||
bool dragInProgress = false;
|
bool dragInProgress = false;
|
||||||
bool shouldPopOnDrag = false;
|
bool shouldPopOnDrag = false;
|
||||||
bool showingBottomSheet = false;
|
|
||||||
double? initialScale;
|
double? initialScale;
|
||||||
double previousExtent = _kBottomSheetMinimumExtent;
|
double previousExtent = _kBottomSheetMinimumExtent;
|
||||||
Offset dragDownPosition = Offset.zero;
|
Offset dragDownPosition = Offset.zero;
|
||||||
int totalAssets = 0;
|
int totalAssets = 0;
|
||||||
int backgroundOpacity = 255;
|
BuildContext? scaffoldContext;
|
||||||
|
|
||||||
// Delayed operations that should be cancelled on disposal
|
// Delayed operations that should be cancelled on disposal
|
||||||
final List<Timer> _delayedOperations = [];
|
final List<Timer> _delayedOperations = [];
|
||||||
@ -90,8 +92,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
_onAssetChanged(widget.initialIndex);
|
_onAssetChanged(widget.initialIndex);
|
||||||
});
|
});
|
||||||
_reloadSubscription =
|
reloadSubscription = EventStream.shared.listen(_onEvent);
|
||||||
EventStream.shared.listen<TimelineReloadEvent>(_onTimelineReload);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -99,36 +100,17 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
pageController.dispose();
|
pageController.dispose();
|
||||||
bottomSheetController.dispose();
|
bottomSheetController.dispose();
|
||||||
_cancelTimers();
|
_cancelTimers();
|
||||||
_reloadSubscription?.cancel();
|
reloadSubscription?.cancel();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onTimelineReload(_) {
|
bool get showingBottomSheet =>
|
||||||
setState(() {
|
ref.read(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||||
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
|
||||||
if (totalAssets == 0) {
|
|
||||||
context.maybePop();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final index = pageController.page?.round() ?? 0;
|
|
||||||
final newAsset = ref.read(timelineServiceProvider).getAsset(index);
|
|
||||||
final currentAsset = ref.read(currentAssetNotifier);
|
|
||||||
// Do not reload / close the bottom sheet if the asset has not changed
|
|
||||||
if (newAsset.heroTag == currentAsset.heroTag) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
_onAssetChanged(pageController.page!.round());
|
|
||||||
sheetCloseNotifier?.close();
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Color get backgroundColor {
|
Color get backgroundColor {
|
||||||
if (showingBottomSheet && !context.isDarkTheme) {
|
final opacity =
|
||||||
return Colors.white;
|
ref.read(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||||
}
|
return Colors.black.withAlpha(opacity);
|
||||||
return Colors.black.withAlpha(backgroundOpacity);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _cancelTimers() {
|
void _cancelTimers() {
|
||||||
@ -145,6 +127,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
(viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) +
|
(viewController?.prevValue.scale ?? viewController?.value.scale ?? 1.0) +
|
||||||
0.01;
|
0.01;
|
||||||
|
|
||||||
|
double _getVerticalOffsetForBottomSheet(double extent) =>
|
||||||
|
(context.height * extent) - (context.height * _kBottomSheetMinimumExtent);
|
||||||
|
|
||||||
Future<void> _precacheImage(int index) async {
|
Future<void> _precacheImage(int index) async {
|
||||||
if (!mounted || index < 0 || index >= totalAssets) {
|
if (!mounted || index < 0 || index >= totalAssets) {
|
||||||
return;
|
return;
|
||||||
@ -247,16 +232,14 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setState(() {
|
shouldPopOnDrag = false;
|
||||||
shouldPopOnDrag = false;
|
hasDraggedDown = null;
|
||||||
hasDraggedDown = null;
|
viewController?.animateMultiple(
|
||||||
backgroundOpacity = 255;
|
position: initialPhotoViewState.position,
|
||||||
viewController?.animateMultiple(
|
scale: initialPhotoViewState.scale,
|
||||||
position: initialPhotoViewState.position,
|
rotation: initialPhotoViewState.rotation,
|
||||||
scale: initialPhotoViewState.scale,
|
);
|
||||||
rotation: initialPhotoViewState.rotation,
|
ref.read(assetViewerProvider.notifier).setOpacity(255);
|
||||||
);
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
|
void _onDragUpdate(BuildContext ctx, DragUpdateDetails details, _) {
|
||||||
@ -277,18 +260,10 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
|
|
||||||
void _handleDragUp(BuildContext ctx, Offset delta) {
|
void _handleDragUp(BuildContext ctx, Offset delta) {
|
||||||
const double openThreshold = 50;
|
const double openThreshold = 50;
|
||||||
const double closeThreshold = 25;
|
|
||||||
|
|
||||||
final position = initialPhotoViewState.position + Offset(0, delta.dy);
|
final position = initialPhotoViewState.position + Offset(0, delta.dy);
|
||||||
final distanceToOrigin = position.distance;
|
final distanceToOrigin = position.distance;
|
||||||
|
|
||||||
if (showingBottomSheet && distanceToOrigin < closeThreshold) {
|
|
||||||
// Prevents the user from dragging the bottom sheet further down
|
|
||||||
blockGestures = true;
|
|
||||||
sheetCloseNotifier?.close();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
viewController?.updateMultiple(position: position);
|
viewController?.updateMultiple(position: position);
|
||||||
// Moves the bottom sheet when the asset is being dragged up
|
// Moves the bottom sheet when the asset is being dragged up
|
||||||
if (showingBottomSheet && bottomSheetController.isAttached) {
|
if (showingBottomSheet && bottomSheetController.isAttached) {
|
||||||
@ -301,66 +276,37 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _openBottomSheet(BuildContext ctx) {
|
void _handleDragDown(BuildContext ctx, Offset delta) {
|
||||||
setState(() {
|
const double dragRatio = 0.2;
|
||||||
initialScale = viewController?.scale;
|
const double popThreshold = 75;
|
||||||
viewController?.updateMultiple(scale: _getScaleForBottomSheet);
|
|
||||||
showingBottomSheet = true;
|
|
||||||
previousExtent = _kBottomSheetMinimumExtent;
|
|
||||||
sheetCloseNotifier = showBottomSheet(
|
|
||||||
context: ctx,
|
|
||||||
sheetAnimationStyle: AnimationStyle(
|
|
||||||
duration: Duration.zero,
|
|
||||||
reverseDuration: Duration.zero,
|
|
||||||
),
|
|
||||||
shape: const RoundedRectangleBorder(
|
|
||||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)),
|
|
||||||
),
|
|
||||||
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
|
|
||||||
builder: (_) {
|
|
||||||
return NotificationListener<Notification>(
|
|
||||||
onNotification: _onNotification,
|
|
||||||
child: AssetDetailBottomSheet(
|
|
||||||
controller: bottomSheetController,
|
|
||||||
initialChildSize: _kBottomSheetMinimumExtent,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
|
||||||
sheetCloseNotifier?.closed.then((_) => _handleSheetClose());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _handleSheetClose() {
|
final distance = delta.distance;
|
||||||
setState(() {
|
shouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
|
||||||
showingBottomSheet = false;
|
|
||||||
sheetCloseNotifier = null;
|
|
||||||
viewController?.animateMultiple(position: Offset.zero);
|
|
||||||
viewController?.updateMultiple(scale: initialScale);
|
|
||||||
shouldPopOnDrag = false;
|
|
||||||
hasDraggedDown = null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
void _snapBottomSheet() {
|
final maxScaleDistance = ctx.height * 0.5;
|
||||||
if (bottomSheetController.size > _kBottomSheetSnapExtent ||
|
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
||||||
bottomSheetController.size < 0.4) {
|
double? updatedScale;
|
||||||
return;
|
if (initialPhotoViewState.scale != null) {
|
||||||
|
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
|
||||||
}
|
}
|
||||||
isSnapping = true;
|
|
||||||
bottomSheetController.animateTo(
|
final backgroundOpacity =
|
||||||
_kBottomSheetSnapExtent,
|
(255 * (1.0 - (scaleReduction / dragRatio))).round();
|
||||||
duration: Durations.short3,
|
|
||||||
curve: Curves.easeOut,
|
viewController?.updateMultiple(
|
||||||
|
position: initialPhotoViewState.position + delta,
|
||||||
|
scale: updatedScale,
|
||||||
);
|
);
|
||||||
|
ref.read(assetViewerProvider.notifier).setOpacity(backgroundOpacity);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTapDown(_, __, ___) {
|
||||||
|
if (!showingBottomSheet) {
|
||||||
|
ref.read(assetViewerProvider.notifier).toggleControls();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
bool _onNotification(Notification delta) {
|
bool _onNotification(Notification delta) {
|
||||||
// Ignore notifications when user dragging the asset
|
|
||||||
if (dragInProgress) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (delta is DraggableScrollableNotification) {
|
if (delta is DraggableScrollableNotification) {
|
||||||
_handleDraggableNotification(delta);
|
_handleDraggableNotification(delta);
|
||||||
}
|
}
|
||||||
@ -375,50 +321,117 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
void _handleDraggableNotification(DraggableScrollableNotification delta) {
|
void _handleDraggableNotification(DraggableScrollableNotification delta) {
|
||||||
final verticalOffset = (context.height * delta.extent) -
|
final currentExtent = delta.extent;
|
||||||
(context.height * _kBottomSheetMinimumExtent);
|
final isDraggingDown = currentExtent < previousExtent;
|
||||||
|
previousExtent = currentExtent;
|
||||||
|
// Closes the bottom sheet if the user is dragging down
|
||||||
|
if (isDraggingDown && delta.extent < 0.5) {
|
||||||
|
if (dragInProgress) {
|
||||||
|
blockGestures = true;
|
||||||
|
}
|
||||||
|
sheetCloseController?.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
// If the asset is being dragged down, we do not want to update the asset position again
|
||||||
|
if (dragInProgress) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final verticalOffset = _getVerticalOffsetForBottomSheet(delta.extent);
|
||||||
// Moves the asset when the bottom sheet is being dragged
|
// Moves the asset when the bottom sheet is being dragged
|
||||||
if (verticalOffset > 0) {
|
if (verticalOffset > 0) {
|
||||||
viewController?.position = Offset(0, -verticalOffset);
|
viewController?.position = Offset(0, -verticalOffset);
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
final currentExtent = delta.extent;
|
void _onEvent(Event event) {
|
||||||
final isDraggingDown = currentExtent < previousExtent;
|
if (event is TimelineReloadEvent) {
|
||||||
previousExtent = currentExtent;
|
_onTimelineReload(event);
|
||||||
// Closes the bottom sheet if the user is dragging down and the extent is less than the snap extent
|
return;
|
||||||
if (isDraggingDown && delta.extent < _kBottomSheetSnapExtent - 0.1) {
|
}
|
||||||
sheetCloseNotifier?.close();
|
|
||||||
|
if (event is ViewerOpenBottomSheetEvent) {
|
||||||
|
final extent = _kBottomSheetMinimumExtent + 0.3;
|
||||||
|
_openBottomSheet(scaffoldContext!, extent: extent);
|
||||||
|
final offset = _getVerticalOffsetForBottomSheet(extent);
|
||||||
|
viewController?.position = Offset(0, -offset);
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleDragDown(BuildContext ctx, Offset delta) {
|
void _onTimelineReload(_) {
|
||||||
const double dragRatio = 0.2;
|
setState(() {
|
||||||
const double popThreshold = 75;
|
totalAssets = ref.read(timelineServiceProvider).totalAssets;
|
||||||
|
if (totalAssets == 0) {
|
||||||
|
context.maybePop();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final distance = delta.distance;
|
final index = pageController.page?.round() ?? 0;
|
||||||
final newShouldPopOnDrag = delta.dy > 0 && distance > popThreshold;
|
final newAsset = ref.read(timelineServiceProvider).getAsset(index);
|
||||||
|
final currentAsset = ref.read(currentAssetNotifier);
|
||||||
|
// Do not reload / close the bottom sheet if the asset has not changed
|
||||||
|
if (newAsset.heroTag == currentAsset?.heroTag) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
final maxScaleDistance = ctx.height * 0.5;
|
_onAssetChanged(pageController.page!.round());
|
||||||
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
|
sheetCloseController?.close();
|
||||||
double? updatedScale;
|
});
|
||||||
if (initialPhotoViewState.scale != null) {
|
}
|
||||||
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
|
|
||||||
}
|
|
||||||
|
|
||||||
final newBackgroundOpacity =
|
void _openBottomSheet(
|
||||||
(255 * (1.0 - (scaleReduction / dragRatio))).round();
|
BuildContext ctx, {
|
||||||
|
double extent = _kBottomSheetMinimumExtent,
|
||||||
viewController?.updateMultiple(
|
}) {
|
||||||
position: initialPhotoViewState.position + delta,
|
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
|
||||||
scale: updatedScale,
|
initialScale = viewController?.scale;
|
||||||
|
viewController?.updateMultiple(scale: _getScaleForBottomSheet);
|
||||||
|
previousExtent = _kBottomSheetMinimumExtent;
|
||||||
|
sheetCloseController = showBottomSheet(
|
||||||
|
context: ctx,
|
||||||
|
sheetAnimationStyle: AnimationStyle(
|
||||||
|
duration: Durations.short4,
|
||||||
|
reverseDuration: Durations.short2,
|
||||||
|
),
|
||||||
|
constraints: const BoxConstraints(maxWidth: double.infinity),
|
||||||
|
shape: const RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20.0)),
|
||||||
|
),
|
||||||
|
backgroundColor: ctx.colorScheme.surfaceContainerLowest,
|
||||||
|
builder: (_) {
|
||||||
|
return NotificationListener<Notification>(
|
||||||
|
onNotification: _onNotification,
|
||||||
|
child: AssetDetailBottomSheet(
|
||||||
|
controller: bottomSheetController,
|
||||||
|
initialChildSize: extent,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
if (shouldPopOnDrag != newShouldPopOnDrag ||
|
sheetCloseController?.closed.then((_) => _handleSheetClose());
|
||||||
backgroundOpacity != newBackgroundOpacity) {
|
}
|
||||||
setState(() {
|
|
||||||
shouldPopOnDrag = newShouldPopOnDrag;
|
void _handleSheetClose() {
|
||||||
backgroundOpacity = newBackgroundOpacity;
|
viewController?.animateMultiple(position: Offset.zero);
|
||||||
});
|
viewController?.updateMultiple(scale: initialScale);
|
||||||
|
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
|
||||||
|
sheetCloseController = null;
|
||||||
|
shouldPopOnDrag = false;
|
||||||
|
hasDraggedDown = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
void _snapBottomSheet() {
|
||||||
|
if (bottomSheetController.size > _kBottomSheetSnapExtent ||
|
||||||
|
bottomSheetController.size < 0.4) {
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
isSnapping = true;
|
||||||
|
bottomSheetController.animateTo(
|
||||||
|
_kBottomSheetSnapExtent,
|
||||||
|
duration: Durations.short3,
|
||||||
|
curve: Curves.easeOut,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _placeholderBuilder(
|
Widget _placeholderBuilder(
|
||||||
@ -443,6 +456,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
PhotoViewGalleryPageOptions _assetBuilder(BuildContext ctx, int index) {
|
||||||
|
scaffoldContext ??= ctx;
|
||||||
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
final asset = ref.read(timelineServiceProvider).getAsset(index);
|
||||||
final size = Size(ctx.width, ctx.height);
|
final size = Size(ctx.width, ctx.height);
|
||||||
|
|
||||||
@ -458,6 +472,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
onDragStart: _onDragStart,
|
onDragStart: _onDragStart,
|
||||||
onDragUpdate: _onDragUpdate,
|
onDragUpdate: _onDragUpdate,
|
||||||
onDragEnd: _onDragEnd,
|
onDragEnd: _onDragEnd,
|
||||||
|
onTapDown: _onTapDown,
|
||||||
errorBuilder: (_, __, ___) => Container(
|
errorBuilder: (_, __, ___) => Container(
|
||||||
width: ctx.width,
|
width: ctx.width,
|
||||||
height: ctx.height,
|
height: ctx.height,
|
||||||
@ -477,13 +492,21 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
// Rebuild the widget when the asset viewer state changes
|
||||||
|
// Using multiple selectors to avoid unnecessary rebuilds for other state changes
|
||||||
|
ref.watch(assetViewerProvider.select((s) => s.showingBottomSheet));
|
||||||
|
ref.watch(assetViewerProvider.select((s) => s.backgroundOpacity));
|
||||||
|
|
||||||
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
|
// Currently it is not possible to scroll the asset when the bottom sheet is open all the way.
|
||||||
// Issue: https://github.com/flutter/flutter/issues/109037
|
// Issue: https://github.com/flutter/flutter/issues/109037
|
||||||
// TODO: Add a custom scrum builder once the fix lands on stable
|
// TODO: Add a custom scrum builder once the fix lands on stable
|
||||||
return PopScope(
|
return PopScope(
|
||||||
onPopInvokedWithResult: _onPop,
|
onPopInvokedWithResult: _onPop,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor: Colors.black.withAlpha(backgroundOpacity),
|
backgroundColor: backgroundColor,
|
||||||
|
appBar: const ViewerTopAppBar(),
|
||||||
|
extendBody: true,
|
||||||
|
extendBodyBehindAppBar: true,
|
||||||
body: PhotoViewGallery.builder(
|
body: PhotoViewGallery.builder(
|
||||||
gaplessPlayback: true,
|
gaplessPlayback: true,
|
||||||
loadingBuilder: _placeholderBuilder,
|
loadingBuilder: _placeholderBuilder,
|
||||||
@ -499,6 +522,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
|
|||||||
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
backgroundDecoration: BoxDecoration(color: backgroundColor),
|
||||||
enablePanAlways: true,
|
enablePanAlways: true,
|
||||||
),
|
),
|
||||||
|
bottomNavigationBar: const ViewerBottomBar(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -0,0 +1,76 @@
|
|||||||
|
import 'package:riverpod_annotation/riverpod_annotation.dart';
|
||||||
|
|
||||||
|
class AssetViewerState {
|
||||||
|
final int backgroundOpacity;
|
||||||
|
final bool showingBottomSheet;
|
||||||
|
final bool showingControls;
|
||||||
|
|
||||||
|
const AssetViewerState({
|
||||||
|
this.backgroundOpacity = 255,
|
||||||
|
this.showingBottomSheet = false,
|
||||||
|
this.showingControls = true,
|
||||||
|
});
|
||||||
|
|
||||||
|
AssetViewerState copyWith({
|
||||||
|
int? backgroundOpacity,
|
||||||
|
bool? showingBottomSheet,
|
||||||
|
bool? showingControls,
|
||||||
|
}) {
|
||||||
|
return AssetViewerState(
|
||||||
|
backgroundOpacity: backgroundOpacity ?? this.backgroundOpacity,
|
||||||
|
showingBottomSheet: showingBottomSheet ?? this.showingBottomSheet,
|
||||||
|
showingControls: showingControls ?? this.showingControls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
String toString() {
|
||||||
|
return 'AssetViewerState(opacity: $backgroundOpacity, bottomSheet: $showingBottomSheet, controls: $showingControls)';
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
bool operator ==(Object other) {
|
||||||
|
if (identical(this, other)) return true;
|
||||||
|
if (other.runtimeType != runtimeType) return false;
|
||||||
|
return other is AssetViewerState &&
|
||||||
|
other.backgroundOpacity == backgroundOpacity &&
|
||||||
|
other.showingBottomSheet == showingBottomSheet &&
|
||||||
|
other.showingControls == showingControls;
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
int get hashCode =>
|
||||||
|
backgroundOpacity.hashCode ^
|
||||||
|
showingBottomSheet.hashCode ^
|
||||||
|
showingControls.hashCode;
|
||||||
|
}
|
||||||
|
|
||||||
|
class AssetViewerStateNotifier extends AutoDisposeNotifier<AssetViewerState> {
|
||||||
|
@override
|
||||||
|
AssetViewerState build() {
|
||||||
|
return const AssetViewerState();
|
||||||
|
}
|
||||||
|
|
||||||
|
void setOpacity(int opacity) {
|
||||||
|
state = state.copyWith(
|
||||||
|
backgroundOpacity: opacity,
|
||||||
|
showingControls: opacity == 255 ? true : state.showingControls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void setBottomSheet(bool showing) {
|
||||||
|
state = state.copyWith(
|
||||||
|
showingBottomSheet: showing,
|
||||||
|
showingControls: showing ? true : state.showingControls,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void toggleControls() {
|
||||||
|
state = state.copyWith(showingControls: !state.showingControls);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final assetViewerProvider =
|
||||||
|
AutoDisposeNotifierProvider<AssetViewerStateNotifier, AssetViewerState>(
|
||||||
|
AssetViewerStateNotifier.new,
|
||||||
|
);
|
@ -0,0 +1,93 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/enums.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/presentation/widgets/action_buttons/archive_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
|
||||||
|
class ViewerBottomBar extends ConsumerWidget {
|
||||||
|
const ViewerBottomBar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asset = ref.watch(currentAssetNotifier);
|
||||||
|
if (asset == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||||
|
final isSheetOpen = ref.watch(
|
||||||
|
assetViewerProvider.select((s) => s.showingBottomSheet),
|
||||||
|
);
|
||||||
|
int opacity = ref.watch(
|
||||||
|
assetViewerProvider.select((state) => state.backgroundOpacity),
|
||||||
|
);
|
||||||
|
final showControls =
|
||||||
|
ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
|
|
||||||
|
if (!showControls) {
|
||||||
|
opacity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final actions = <Widget>[
|
||||||
|
const ShareActionButton(),
|
||||||
|
const _EditActionButton(),
|
||||||
|
if (asset.hasRemote && isOwner)
|
||||||
|
const ArchiveActionButton(source: ActionSource.viewer),
|
||||||
|
];
|
||||||
|
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: opacity < 255,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: opacity / 255,
|
||||||
|
duration: Durations.short2,
|
||||||
|
child: AnimatedSwitcher(
|
||||||
|
duration: Durations.short4,
|
||||||
|
child: isSheetOpen
|
||||||
|
? const SizedBox.shrink()
|
||||||
|
: SafeArea(
|
||||||
|
child: Theme(
|
||||||
|
data: context.themeData.copyWith(
|
||||||
|
iconTheme:
|
||||||
|
const IconThemeData(size: 22, color: Colors.white),
|
||||||
|
textTheme: context.themeData.textTheme.copyWith(
|
||||||
|
labelLarge:
|
||||||
|
context.themeData.textTheme.labelLarge?.copyWith(
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Container(
|
||||||
|
height: 80,
|
||||||
|
color: Colors.black.withAlpha(125),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
|
||||||
|
children: actions,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditActionButton extends ConsumerWidget {
|
||||||
|
const _EditActionButton();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return BaseActionButton(
|
||||||
|
iconData: Icons.tune_outlined,
|
||||||
|
label: 'edit'.t(context: context),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -10,7 +10,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/download_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_lock_folder_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/share_link_action_button.widget.dart';
|
||||||
@ -37,6 +36,10 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = ref.watch(currentAssetNotifier);
|
||||||
|
if (asset == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
final isTrashEnable = ref.watch(
|
final isTrashEnable = ref.watch(
|
||||||
serverInfoProvider.select((state) => state.serverFeatures.trash),
|
serverInfoProvider.select((state) => state.serverFeatures.trash),
|
||||||
);
|
);
|
||||||
@ -46,7 +49,6 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
if (asset.hasRemote) ...[
|
if (asset.hasRemote) ...[
|
||||||
const ShareLinkActionButton(source: ActionSource.viewer),
|
const ShareLinkActionButton(source: ActionSource.viewer),
|
||||||
const ArchiveActionButton(source: ActionSource.viewer),
|
const ArchiveActionButton(source: ActionSource.viewer),
|
||||||
const FavoriteActionButton(source: ActionSource.viewer),
|
|
||||||
if (!asset.hasLocal) const DownloadActionButton(),
|
if (!asset.hasLocal) const DownloadActionButton(),
|
||||||
isTrashEnable
|
isTrashEnable
|
||||||
? const TrashActionButton(source: ActionSource.viewer)
|
? const TrashActionButton(source: ActionSource.viewer)
|
||||||
@ -67,7 +69,7 @@ class AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
controller: controller,
|
controller: controller,
|
||||||
initialChildSize: initialChildSize,
|
initialChildSize: initialChildSize,
|
||||||
minChildSize: 0.1,
|
minChildSize: 0.1,
|
||||||
maxChildSize: 1.0,
|
maxChildSize: 0.88,
|
||||||
expand: false,
|
expand: false,
|
||||||
shouldCloseOnMinExtent: false,
|
shouldCloseOnMinExtent: false,
|
||||||
resizeOnScroll: false,
|
resizeOnScroll: false,
|
||||||
@ -136,6 +138,10 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
final asset = ref.watch(currentAssetNotifier);
|
final asset = ref.watch(currentAssetNotifier);
|
||||||
|
if (asset == null) {
|
||||||
|
return const SliverToBoxAdapter(child: SizedBox.shrink());
|
||||||
|
}
|
||||||
|
|
||||||
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
final exifInfo = ref.watch(currentAssetExifProvider).valueOrNull;
|
||||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||||
|
|
||||||
|
@ -16,7 +16,7 @@ class SheetLocationDetails extends ConsumerStatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
||||||
late BaseAsset asset;
|
BaseAsset? asset;
|
||||||
ExifInfo? exifInfo;
|
ExifInfo? exifInfo;
|
||||||
MapLibreMapController? _mapController;
|
MapLibreMapController? _mapController;
|
||||||
|
|
||||||
@ -84,7 +84,10 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
|
|||||||
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}";
|
"${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo!.longitude!.toStringAsFixed(4)}";
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: EdgeInsets.symmetric(
|
||||||
|
vertical: 16.0,
|
||||||
|
horizontal: context.isMobile ? 16.0 : 56.0,
|
||||||
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
|
@ -0,0 +1,114 @@
|
|||||||
|
import 'package:auto_route/auto_route.dart';
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
|
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||||
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/favorite_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_action_button.widget.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
|
||||||
|
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
|
const ViewerTopAppBar({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final asset = ref.watch(currentAssetNotifier);
|
||||||
|
if (asset == null) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
||||||
|
|
||||||
|
final isShowingSheet = ref
|
||||||
|
.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||||
|
int opacity = ref.watch(
|
||||||
|
assetViewerProvider.select((state) => state.backgroundOpacity),
|
||||||
|
);
|
||||||
|
final showControls =
|
||||||
|
ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
|
|
||||||
|
if (!showControls) {
|
||||||
|
opacity = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
final actions = <Widget>[
|
||||||
|
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
||||||
|
const FavoriteActionButton(source: ActionSource.viewer, menuItem: true),
|
||||||
|
if (asset.hasRemote && isOwner && asset.isFavorite)
|
||||||
|
const UnFavoriteActionButton(
|
||||||
|
source: ActionSource.viewer,
|
||||||
|
menuItem: true,
|
||||||
|
),
|
||||||
|
const _KebabMenu(),
|
||||||
|
];
|
||||||
|
|
||||||
|
return IgnorePointer(
|
||||||
|
ignoring: opacity < 255,
|
||||||
|
child: AnimatedOpacity(
|
||||||
|
opacity: opacity / 255,
|
||||||
|
duration: Durations.short2,
|
||||||
|
child: AppBar(
|
||||||
|
backgroundColor:
|
||||||
|
isShowingSheet ? Colors.transparent : Colors.black.withAlpha(125),
|
||||||
|
leading: const _AppBarBackButton(),
|
||||||
|
iconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||||
|
actionsIconTheme: const IconThemeData(size: 22, color: Colors.white),
|
||||||
|
shape: const Border(),
|
||||||
|
actions: isShowingSheet ? null : actions,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Size get preferredSize => const Size.fromHeight(60.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
class _KebabMenu extends ConsumerWidget {
|
||||||
|
const _KebabMenu();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
return IconButton(
|
||||||
|
onPressed: () {
|
||||||
|
EventStream.shared.emit(const ViewerOpenBottomSheetEvent());
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.more_vert_rounded),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _AppBarBackButton extends ConsumerWidget {
|
||||||
|
const _AppBarBackButton();
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
|
final isShowingSheet = ref
|
||||||
|
.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||||
|
final backgroundColor =
|
||||||
|
isShowingSheet && !context.isDarkTheme ? Colors.white : Colors.black;
|
||||||
|
final foregroundColor =
|
||||||
|
isShowingSheet && !context.isDarkTheme ? Colors.black : Colors.white;
|
||||||
|
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(left: 12.0),
|
||||||
|
child: ElevatedButton(
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: backgroundColor,
|
||||||
|
shape: const CircleBorder(),
|
||||||
|
iconSize: 22,
|
||||||
|
iconColor: foregroundColor,
|
||||||
|
padding: EdgeInsets.zero,
|
||||||
|
elevation: isShowingSheet ? 4 : 0,
|
||||||
|
),
|
||||||
|
onPressed: context.maybePop,
|
||||||
|
child: const Icon(Icons.arrow_back_rounded),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -56,7 +56,10 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
final Set<BaseAsset> assets = switch (source) {
|
final Set<BaseAsset> assets = switch (source) {
|
||||||
ActionSource.timeline =>
|
ActionSource.timeline =>
|
||||||
ref.read(multiSelectProvider.select((s) => s.selectedAssets)),
|
ref.read(multiSelectProvider.select((s) => s.selectedAssets)),
|
||||||
ActionSource.viewer => {ref.read(currentAssetNotifier)},
|
ActionSource.viewer => switch (ref.read(currentAssetNotifier)) {
|
||||||
|
BaseAsset asset => {asset},
|
||||||
|
null => {},
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
return switch (T) {
|
return switch (T) {
|
||||||
|
@ -15,5 +15,6 @@ final remoteAssetRepositoryProvider = Provider<RemoteAssetRepository>(
|
|||||||
final assetServiceProvider = Provider(
|
final assetServiceProvider = Provider(
|
||||||
(ref) => AssetService(
|
(ref) => AssetService(
|
||||||
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
|
remoteAssetRepository: ref.watch(remoteAssetRepositoryProvider),
|
||||||
|
localAssetRepository: ref.watch(localAssetRepository),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
@ -1,36 +1,48 @@
|
|||||||
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.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/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
|
||||||
|
|
||||||
final currentAssetNotifier =
|
final currentAssetNotifier =
|
||||||
AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset>(
|
AutoDisposeNotifierProvider<CurrentAssetNotifier, BaseAsset?>(
|
||||||
CurrentAssetNotifier.new,
|
CurrentAssetNotifier.new,
|
||||||
);
|
);
|
||||||
|
|
||||||
class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset> {
|
class CurrentAssetNotifier extends AutoDisposeNotifier<BaseAsset?> {
|
||||||
KeepAliveLink? _keepAliveLink;
|
KeepAliveLink? _keepAliveLink;
|
||||||
|
StreamSubscription<BaseAsset?>? _assetSubscription;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
BaseAsset build() {
|
BaseAsset? build() => null;
|
||||||
throw UnimplementedError(
|
|
||||||
'An asset must be set before using the currentAssetProvider.',
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
void setAsset(BaseAsset asset) {
|
void setAsset(BaseAsset asset) {
|
||||||
_keepAliveLink?.close();
|
_keepAliveLink?.close();
|
||||||
|
_assetSubscription?.cancel();
|
||||||
state = asset;
|
state = asset;
|
||||||
|
_assetSubscription = ref
|
||||||
|
.watch(assetServiceProvider)
|
||||||
|
.watchAsset(asset)
|
||||||
|
.listen((updatedAsset) {
|
||||||
|
if (updatedAsset != null) {
|
||||||
|
state = updatedAsset;
|
||||||
|
}
|
||||||
|
});
|
||||||
_keepAliveLink = ref.keepAlive();
|
_keepAliveLink = ref.keepAlive();
|
||||||
}
|
}
|
||||||
|
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_keepAliveLink?.close();
|
_keepAliveLink?.close();
|
||||||
|
_assetSubscription?.cancel();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final currentAssetExifProvider = FutureProvider.autoDispose(
|
final currentAssetExifProvider = FutureProvider.autoDispose(
|
||||||
(ref) {
|
(ref) {
|
||||||
final currentAsset = ref.watch(currentAssetNotifier);
|
final currentAsset = ref.watch(currentAssetNotifier);
|
||||||
|
if (currentAsset == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
return ref.watch(assetServiceProvider).getExif(currentAsset);
|
return ref.watch(assetServiceProvider).getExif(currentAsset);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
Loading…
x
Reference in New Issue
Block a user