Merge branch 'main' into feature/bottom-buttons-order

This commit is contained in:
idubnori
2026-02-03 08:36:11 +09:00
committed by GitHub
1296 changed files with 82172 additions and 13571 deletions
@@ -92,7 +92,9 @@ class AssetViewer extends ConsumerStatefulWidget {
if (asset.isVideo || asset.isMotionPhoto) {
ref.read(videoPlaybackValueProvider.notifier).reset();
ref.read(videoPlayerControlsProvider.notifier).pause();
// Hide controls by default for videos and motion photos
}
// Hide controls by default for videos
if (asset.isVideo) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
}
@@ -118,7 +120,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
bool dragInProgress = false;
bool shouldPopOnDrag = false;
bool assetReloadRequested = false;
double? initialScale;
double previousExtent = _kBottomSheetMinimumExtent;
Offset dragDownPosition = Offset.zero;
int totalAssets = 0;
@@ -148,6 +149,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
if (asset != null) {
_stackChildrenKeepAlive = ref.read(stackChildrenNotifier(asset).notifier).ref.keepAlive();
}
if (ref.read(assetViewerProvider).showingControls) {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge));
} else {
unawaited(SystemChrome.setEnabledSystemUIMode(SystemUiMode.immersiveSticky));
}
}
@override
@@ -264,7 +270,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
(context.height * bottomSheetController.size) - (context.height * _kBottomSheetMinimumExtent);
controller.position = Offset(0, -verticalOffset);
// Apply the zoom effect when the bottom sheet is showing
initialScale = controller.scale;
controller.scale = (controller.scale ?? 1.0) + 0.01;
}
}
@@ -316,7 +321,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
hasDraggedDown = null;
viewController?.animateMultiple(
position: initialPhotoViewState.position,
scale: initialPhotoViewState.scale,
scale: viewController?.initialScale ?? initialPhotoViewState.scale,
rotation: initialPhotoViewState.rotation,
);
ref.read(assetViewerProvider.notifier).setOpacity(255);
@@ -366,8 +371,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
final maxScaleDistance = ctx.height * 0.5;
final scaleReduction = (distance / maxScaleDistance).clamp(0.0, dragRatio);
double? updatedScale;
if (initialPhotoViewState.scale != null) {
updatedScale = initialPhotoViewState.scale! * (1.0 - scaleReduction);
double? initialScale = viewController?.initialScale ?? initialPhotoViewState.scale;
if (initialScale != null) {
updatedScale = initialScale * (1.0 - scaleReduction);
}
final backgroundOpacity = (255 * (1.0 - (scaleReduction / dragRatio))).round();
@@ -481,8 +487,6 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _openBottomSheet(BuildContext ctx, {double extent = _kBottomSheetMinimumExtent, bool activitiesMode = false}) {
ref.read(assetViewerProvider.notifier).setBottomSheet(true);
initialScale = viewController?.scale;
// viewController?.updateMultiple(scale: (viewController?.scale ?? 1.0) + 0.01);
previousExtent = _kBottomSheetMinimumExtent;
sheetCloseController = showBottomSheet(
context: ctx,
@@ -504,7 +508,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _handleSheetClose() {
viewController?.animateMultiple(position: Offset.zero);
viewController?.updateMultiple(scale: initialScale);
viewController?.updateMultiple(scale: viewController?.initialScale);
ref.read(assetViewerProvider.notifier).setBottomSheet(false);
sheetCloseController = null;
shouldPopOnDrag = false;
@@ -527,7 +531,9 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
void _onScaleStateChanged(PhotoViewScaleState scaleState) {
if (scaleState != PhotoViewScaleState.initial) {
ref.read(assetViewerProvider.notifier).setControls(false);
if (!dragInProgress) {
ref.read(assetViewerProvider.notifier).setControls(false);
}
ref.read(videoPlayerControlsProvider.notifier).pause();
return;
}
@@ -611,6 +617,7 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
filterQuality: FilterQuality.high,
maxScale: 1.0,
basePosition: Alignment.center,
disableScaleGestures: true,
child: SizedBox(
width: ctx.width,
height: ctx.height,
@@ -10,16 +10,19 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/exif.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/duration_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/presentation/widgets/album/album_tile.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_location_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/bottom_sheet/sheet_people_details.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/rating_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/routing/router.dart';
@@ -164,11 +167,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
children: [
if (albums.isNotEmpty)
SheetTile(
title: 'appears_in'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
title: 'appears_in'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
Padding(
padding: const EdgeInsets.only(left: 24),
@@ -206,6 +206,9 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
final cameraTitle = _getCameraInfoTitle(exifInfo);
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : null);
final isRatingEnabled = ref
.watch(userMetadataPreferencesProvider)
.maybeWhen(data: (prefs) => prefs?.ratingsEnabled ?? false, orElse: () => false);
// Build file info tile based on asset type
Widget buildFileInfoTile() {
@@ -224,9 +227,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
);
},
);
@@ -241,9 +242,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
color: context.textTheme.labelLarge?.color,
),
subtitle: _getFileInfo(asset, exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
);
}
}
@@ -262,11 +261,8 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
const SheetLocationDetails(),
// Details header
SheetTile(
title: 'details'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
title: 'details'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
// File info
buildFileInfoTile(),
@@ -278,9 +274,7 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_alt_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getCameraInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Lens info
@@ -291,15 +285,42 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
titleStyle: context.textTheme.labelLarge,
leading: Icon(Icons.camera_outlined, size: 24, color: context.textTheme.labelLarge?.color),
subtitle: _getLensInfoSubtitle(exifInfo),
subtitleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
subtitleStyle: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
// Rating bar
if (isRatingEnabled) ...[
Padding(
padding: const EdgeInsets.only(left: 16.0, top: 16.0),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 8,
children: [
Text(
'rating'.t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
RatingBar(
initialRating: exifInfo?.rating?.toDouble() ?? 0,
filledColor: context.themeData.colorScheme.primary,
unfilledColor: context.themeData.colorScheme.onSurface.withAlpha(100),
itemSize: 40,
onRatingUpdate: (rating) async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, rating.round());
},
onClearRating: () async {
await ref.read(actionProvider.notifier).updateRating(ActionSource.viewer, 0);
},
),
],
),
),
],
// Appears in (Albums)
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
// padding at the bottom to avoid cut-off
const SizedBox(height: 30),
const SizedBox(height: 60),
],
);
}
@@ -4,6 +4,7 @@ import 'package:immich_mobile/constants/enums.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/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/presentation/widgets/asset_viewer/sheet_tile.widget.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
@@ -64,11 +65,10 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
final hasCoordinates = exifInfo?.hasCoordinates ?? false;
// Guard local assets
if (asset != null && asset is LocalAsset && asset.hasRemote) {
if (asset is! RemoteAsset) {
return const SizedBox.shrink();
}
final remoteId = asset is LocalAsset ? asset.remoteId : (asset as RemoteAsset).id;
final locationName = _getLocationName(exifInfo);
final coordinates = "${exifInfo?.latitude?.toStringAsFixed(4)}, ${exifInfo?.longitude?.toStringAsFixed(4)}";
@@ -78,11 +78,8 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SheetTile(
title: 'location'.t(context: context).toUpperCase(),
titleStyle: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
title: 'location'.t(context: context),
titleStyle: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
trailing: hasCoordinates ? const Icon(Icons.edit_location_alt, size: 20) : null,
onTap: editLocation,
),
@@ -92,7 +89,12 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ExifMap(exifInfo: exifInfo!, markerId: remoteId, onMapCreated: _onMapCreated),
ExifMap(
exifInfo: exifInfo!,
markerId: asset.id,
markerAssetThumbhash: asset.thumbHash,
onMapCreated: _onMapCreated,
),
const SizedBox(height: 16),
if (locationName != null)
Padding(
@@ -101,9 +103,7 @@ class _SheetLocationDetailsState extends ConsumerState<SheetLocationDetails> {
),
Text(
coordinates,
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
),
style: context.textTheme.bodySmall?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
],
),
@@ -4,6 +4,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/domain/models/person.model.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/presentation/widgets/people/person_edit_name_modal.widget.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
@@ -53,11 +54,8 @@ class _SheetPeopleDetailsState extends ConsumerState<SheetPeopleDetails> {
Padding(
padding: const EdgeInsets.only(left: 16, top: 16, bottom: 16),
child: Text(
"people".t(context: context).toUpperCase(),
style: context.textTheme.labelMedium?.copyWith(
color: context.textTheme.labelMedium?.color?.withAlpha(200),
fontWeight: FontWeight.w600,
),
"people".t(context: context),
style: context.textTheme.labelLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
),
),
SizedBox(
@@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
class RatingBar extends StatefulWidget {
final double initialRating;
final int itemCount;
final double itemSize;
final Color filledColor;
final Color unfilledColor;
final ValueChanged<int>? onRatingUpdate;
final VoidCallback? onClearRating;
final Widget? itemBuilder;
final double starPadding;
const RatingBar({
super.key,
this.initialRating = 0.0,
this.itemCount = 5,
this.itemSize = 40.0,
this.filledColor = Colors.amber,
this.unfilledColor = Colors.grey,
this.onRatingUpdate,
this.onClearRating,
this.itemBuilder,
this.starPadding = 4.0,
});
@override
State<RatingBar> createState() => _RatingBarState();
}
class _RatingBarState extends State<RatingBar> {
late double _currentRating;
@override
void initState() {
super.initState();
_currentRating = widget.initialRating;
}
void _updateRating(Offset localPosition, bool isRTL, {bool isTap = false}) {
final totalWidth = widget.itemCount * widget.itemSize + (widget.itemCount - 1) * widget.starPadding;
double dx = localPosition.dx;
if (isRTL) dx = totalWidth - dx;
double newRating;
if (dx <= 0) {
newRating = 0;
} else if (dx >= totalWidth) {
newRating = widget.itemCount.toDouble();
} else {
double starWithPadding = widget.itemSize + widget.starPadding;
int tappedIndex = (dx / starWithPadding).floor().clamp(0, widget.itemCount - 1);
newRating = tappedIndex + 1.0;
if (isTap && newRating == _currentRating && _currentRating != 0) {
newRating = 0;
}
}
if (_currentRating != newRating) {
setState(() {
_currentRating = newRating;
});
widget.onRatingUpdate?.call(newRating.round());
}
}
@override
Widget build(BuildContext context) {
final isRTL = Directionality.of(context) == TextDirection.rtl;
final double visualAlignmentOffset = 5.0;
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Transform.translate(
offset: Offset(isRTL ? visualAlignmentOffset : -visualAlignmentOffset, 0),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapDown: (details) => _updateRating(details.localPosition, isRTL, isTap: true),
onPanUpdate: (details) => _updateRating(details.localPosition, isRTL, isTap: false),
child: Row(
mainAxisSize: MainAxisSize.min,
textDirection: isRTL ? TextDirection.rtl : TextDirection.ltr,
children: List.generate(widget.itemCount * 2 - 1, (i) {
if (i.isOdd) {
return SizedBox(width: widget.starPadding);
}
int index = i ~/ 2;
bool filled = _currentRating > index;
return widget.itemBuilder ??
Icon(
Icons.star_rounded,
size: widget.itemSize,
color: filled ? widget.filledColor : widget.unfilledColor,
);
}),
),
),
),
if (_currentRating > 0)
Padding(
padding: const EdgeInsets.only(top: 12.0),
child: GestureDetector(
onTap: () {
setState(() {
_currentRating = 0;
});
widget.onClearRating?.call();
},
child: Text(
'rating_clear'.t(context: context),
style: TextStyle(color: context.themeData.colorScheme.primary),
),
),
),
],
);
}
}
@@ -96,7 +96,7 @@ class NativeVideoViewer extends HookConsumerWidget {
try {
if (videoAsset.hasLocal && videoAsset.livePhotoVideoId == null) {
final id = videoAsset is LocalAsset ? videoAsset.id : (videoAsset as RemoteAsset).localId!;
final file = await const StorageRepository().getFileForAsset(id);
final file = await StorageRepository().getFileForAsset(id);
if (!context.mounted) {
return null;
}