mirror of
https://github.com/immich-app/immich.git
synced 2025-12-22 09:15:34 +03:00
Compare commits
4 Commits
fix-high-c
...
feat/star-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3dec648dfc | ||
|
|
d2cb376f77 | ||
|
|
63c1c9e376 | ||
|
|
46e2d6e71e |
@@ -652,7 +652,6 @@
|
|||||||
"backup_options_page_title": "Backup options",
|
"backup_options_page_title": "Backup options",
|
||||||
"backup_setting_subtitle": "Manage background and foreground upload settings",
|
"backup_setting_subtitle": "Manage background and foreground upload settings",
|
||||||
"backup_settings_subtitle": "Manage upload settings",
|
"backup_settings_subtitle": "Manage upload settings",
|
||||||
"backup_upload_details_page_more_details": "Tap for more details",
|
|
||||||
"backward": "Backward",
|
"backward": "Backward",
|
||||||
"biometric_auth_enabled": "Biometric authentication enabled",
|
"biometric_auth_enabled": "Biometric authentication enabled",
|
||||||
"biometric_locked_out": "You are locked out of biometric authentication",
|
"biometric_locked_out": "You are locked out of biometric authentication",
|
||||||
@@ -719,7 +718,6 @@
|
|||||||
"check_corrupt_asset_backup_button": "Perform check",
|
"check_corrupt_asset_backup_button": "Perform check",
|
||||||
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
|
"check_corrupt_asset_backup_description": "Run this check only over Wi-Fi and once all assets have been backed-up. The procedure might take a few minutes.",
|
||||||
"check_logs": "Check Logs",
|
"check_logs": "Check Logs",
|
||||||
"checksum": "Checksum",
|
|
||||||
"choose_matching_people_to_merge": "Choose matching people to merge",
|
"choose_matching_people_to_merge": "Choose matching people to merge",
|
||||||
"city": "City",
|
"city": "City",
|
||||||
"clear": "Clear",
|
"clear": "Clear",
|
||||||
@@ -1168,7 +1166,6 @@
|
|||||||
"header_settings_header_name_input": "Header name",
|
"header_settings_header_name_input": "Header name",
|
||||||
"header_settings_header_value_input": "Header value",
|
"header_settings_header_value_input": "Header value",
|
||||||
"headers_settings_tile_title": "Custom proxy headers",
|
"headers_settings_tile_title": "Custom proxy headers",
|
||||||
"height": "Height",
|
|
||||||
"hi_user": "Hi {name} ({email})",
|
"hi_user": "Hi {name} ({email})",
|
||||||
"hide_all_people": "Hide all people",
|
"hide_all_people": "Hide all people",
|
||||||
"hide_gallery": "Hide gallery",
|
"hide_gallery": "Hide gallery",
|
||||||
@@ -1291,7 +1288,6 @@
|
|||||||
"local": "Local",
|
"local": "Local",
|
||||||
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
|
"local_asset_cast_failed": "Unable to cast an asset that is not uploaded to the server",
|
||||||
"local_assets": "Local Assets",
|
"local_assets": "Local Assets",
|
||||||
"local_id": "Local ID",
|
|
||||||
"local_media_summary": "Local Media Summary",
|
"local_media_summary": "Local Media Summary",
|
||||||
"local_network": "Local network",
|
"local_network": "Local network",
|
||||||
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
|
"local_network_sheet_info": "The app will connect to the server through this URL when using the specified Wi-Fi network",
|
||||||
@@ -2222,7 +2218,6 @@
|
|||||||
"week": "Week",
|
"week": "Week",
|
||||||
"welcome": "Welcome",
|
"welcome": "Welcome",
|
||||||
"welcome_to_immich": "Welcome to Immich",
|
"welcome_to_immich": "Welcome to Immich",
|
||||||
"width": "Width",
|
|
||||||
"wifi_name": "Wi-Fi Name",
|
"wifi_name": "Wi-Fi Name",
|
||||||
"workflow": "Workflow",
|
"workflow": "Workflow",
|
||||||
"wrong_pin_code": "Wrong PIN code",
|
"wrong_pin_code": "Wrong PIN code",
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ class ExifInfo {
|
|||||||
final String? orientation;
|
final String? orientation;
|
||||||
final String? timeZone;
|
final String? timeZone;
|
||||||
final DateTime? dateTimeOriginal;
|
final DateTime? dateTimeOriginal;
|
||||||
|
final int? rating;
|
||||||
|
|
||||||
// GPS
|
// GPS
|
||||||
final double? latitude;
|
final double? latitude;
|
||||||
@@ -46,6 +47,7 @@ class ExifInfo {
|
|||||||
this.orientation,
|
this.orientation,
|
||||||
this.timeZone,
|
this.timeZone,
|
||||||
this.dateTimeOriginal,
|
this.dateTimeOriginal,
|
||||||
|
this.rating,
|
||||||
this.isFlipped = false,
|
this.isFlipped = false,
|
||||||
this.latitude,
|
this.latitude,
|
||||||
this.longitude,
|
this.longitude,
|
||||||
@@ -71,6 +73,7 @@ class ExifInfo {
|
|||||||
other.orientation == orientation &&
|
other.orientation == orientation &&
|
||||||
other.timeZone == timeZone &&
|
other.timeZone == timeZone &&
|
||||||
other.dateTimeOriginal == dateTimeOriginal &&
|
other.dateTimeOriginal == dateTimeOriginal &&
|
||||||
|
other.rating == rating &&
|
||||||
other.latitude == latitude &&
|
other.latitude == latitude &&
|
||||||
other.longitude == longitude &&
|
other.longitude == longitude &&
|
||||||
other.city == city &&
|
other.city == city &&
|
||||||
@@ -94,6 +97,7 @@ class ExifInfo {
|
|||||||
isFlipped.hashCode ^
|
isFlipped.hashCode ^
|
||||||
timeZone.hashCode ^
|
timeZone.hashCode ^
|
||||||
dateTimeOriginal.hashCode ^
|
dateTimeOriginal.hashCode ^
|
||||||
|
rating.hashCode ^
|
||||||
latitude.hashCode ^
|
latitude.hashCode ^
|
||||||
longitude.hashCode ^
|
longitude.hashCode ^
|
||||||
city.hashCode ^
|
city.hashCode ^
|
||||||
@@ -118,6 +122,7 @@ orientation: ${orientation ?? 'NA'},
|
|||||||
isFlipped: $isFlipped,
|
isFlipped: $isFlipped,
|
||||||
timeZone: ${timeZone ?? 'NA'},
|
timeZone: ${timeZone ?? 'NA'},
|
||||||
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
dateTimeOriginal: ${dateTimeOriginal ?? 'NA'},
|
||||||
|
rating: ${rating ?? 'NA'},
|
||||||
latitude: ${latitude ?? 'NA'},
|
latitude: ${latitude ?? 'NA'},
|
||||||
longitude: ${longitude ?? 'NA'},
|
longitude: ${longitude ?? 'NA'},
|
||||||
city: ${city ?? 'NA'},
|
city: ${city ?? 'NA'},
|
||||||
@@ -140,6 +145,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
|||||||
String? orientation,
|
String? orientation,
|
||||||
String? timeZone,
|
String? timeZone,
|
||||||
DateTime? dateTimeOriginal,
|
DateTime? dateTimeOriginal,
|
||||||
|
int? rating,
|
||||||
double? latitude,
|
double? latitude,
|
||||||
double? longitude,
|
double? longitude,
|
||||||
String? city,
|
String? city,
|
||||||
@@ -161,6 +167,7 @@ exposureSeconds: ${exposureSeconds ?? 'NA'},
|
|||||||
orientation: orientation ?? this.orientation,
|
orientation: orientation ?? this.orientation,
|
||||||
timeZone: timeZone ?? this.timeZone,
|
timeZone: timeZone ?? this.timeZone,
|
||||||
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
|
dateTimeOriginal: dateTimeOriginal ?? this.dateTimeOriginal,
|
||||||
|
rating: rating ?? this.rating,
|
||||||
isFlipped: isFlipped ?? this.isFlipped,
|
isFlipped: isFlipped ?? this.isFlipped,
|
||||||
latitude: latitude ?? this.latitude,
|
latitude: latitude ?? this.latitude,
|
||||||
longitude: longitude ?? this.longitude,
|
longitude: longitude ?? this.longitude,
|
||||||
|
|||||||
@@ -151,6 +151,7 @@ extension RemoteExifEntityDataDomainEx on RemoteExifEntityData {
|
|||||||
domain.ExifInfo toDto() => domain.ExifInfo(
|
domain.ExifInfo toDto() => domain.ExifInfo(
|
||||||
fileSize: fileSize,
|
fileSize: fileSize,
|
||||||
dateTimeOriginal: dateTimeOriginal,
|
dateTimeOriginal: dateTimeOriginal,
|
||||||
|
rating: rating,
|
||||||
timeZone: timeZone,
|
timeZone: timeZone,
|
||||||
make: make,
|
make: make,
|
||||||
model: model,
|
model: model,
|
||||||
|
|||||||
@@ -255,6 +255,12 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateRating(String assetId, int rating) async {
|
||||||
|
await (_db.remoteExifEntity.update()..where((row) => row.assetId.equals(assetId))).write(
|
||||||
|
RemoteExifEntityCompanion(rating: Value(rating)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Future<int> getCount() {
|
Future<int> getCount() {
|
||||||
return _db.managers.remoteAssetEntity.count();
|
return _db.managers.remoteAssetEntity.count();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,7 +98,7 @@ class DriftUploadDetailPage extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
Text(
|
Text(
|
||||||
"backup_upload_details_page_more_details".t(context: context),
|
'Tap for more details',
|
||||||
style: context.textTheme.bodySmall?.copyWith(
|
style: context.textTheme.bodySmall?.copyWith(
|
||||||
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
color: context.colorScheme.onSurface.withValues(alpha: 0.6),
|
||||||
),
|
),
|
||||||
@@ -239,20 +239,14 @@ class FileDetailDialog extends ConsumerWidget {
|
|||||||
const SizedBox(height: 24),
|
const SizedBox(height: 24),
|
||||||
if (asset != null) ...[
|
if (asset != null) ...[
|
||||||
_buildInfoSection(context, [
|
_buildInfoSection(context, [
|
||||||
_buildInfoRow(context, "filename".t(context: context), path.basename(uploadStatus.filename)),
|
_buildInfoRow(context, "Filename", path.basename(uploadStatus.filename)),
|
||||||
_buildInfoRow(context, "local_id".t(context: context), asset.id),
|
_buildInfoRow(context, "Local ID", asset.id),
|
||||||
_buildInfoRow(
|
_buildInfoRow(context, "File Size", formatHumanReadableBytes(uploadStatus.fileSize, 2)),
|
||||||
context,
|
if (asset.width != null) _buildInfoRow(context, "Width", "${asset.width}px"),
|
||||||
"file_size".t(context: context),
|
if (asset.height != null) _buildInfoRow(context, "Height", "${asset.height}px"),
|
||||||
formatHumanReadableBytes(uploadStatus.fileSize, 2),
|
_buildInfoRow(context, "Created At", asset.createdAt.toString()),
|
||||||
),
|
_buildInfoRow(context, "Updated At", asset.updatedAt.toString()),
|
||||||
if (asset.width != null) _buildInfoRow(context, "width".t(context: context), "${asset.width}px"),
|
if (asset.checksum != null) _buildInfoRow(context, "Checksum", asset.checksum!),
|
||||||
if (asset.height != null)
|
|
||||||
_buildInfoRow(context, "height".t(context: context), "${asset.height}px"),
|
|
||||||
_buildInfoRow(context, "created_at".t(context: context), asset.createdAt.toString()),
|
|
||||||
_buildInfoRow(context, "updated_at".t(context: context), asset.updatedAt.toString()),
|
|
||||||
if (asset.checksum != null)
|
|
||||||
_buildInfoRow(context, "checksum".t(context: context), asset.checksum!),
|
|
||||||
]),
|
]),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -27,19 +27,8 @@ class _DriftCreateAlbumPageState extends ConsumerState<DriftCreateAlbumPage> {
|
|||||||
bool isAlbumTitleTextFieldFocus = false;
|
bool isAlbumTitleTextFieldFocus = false;
|
||||||
Set<BaseAsset> selectedAssets = {};
|
Set<BaseAsset> selectedAssets = {};
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
albumTitleController.addListener(_onTitleChanged);
|
|
||||||
}
|
|
||||||
|
|
||||||
void _onTitleChanged() {
|
|
||||||
setState(() {});
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
albumTitleController.removeListener(_onTitleChanged);
|
|
||||||
albumTitleController.dispose();
|
albumTitleController.dispose();
|
||||||
albumDescriptionController.dispose();
|
albumDescriptionController.dispose();
|
||||||
albumTitleTextFieldFocusNode.dispose();
|
albumTitleTextFieldFocusNode.dispose();
|
||||||
|
|||||||
@@ -22,9 +22,7 @@ import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_shee
|
|||||||
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
enum AddToMenuItem { album, archive, unarchive, lockedFolder }
|
||||||
|
|
||||||
class AddActionButton extends ConsumerStatefulWidget {
|
class AddActionButton extends ConsumerStatefulWidget {
|
||||||
const AddActionButton({super.key, this.originalTheme});
|
const AddActionButton({super.key});
|
||||||
|
|
||||||
final ThemeData? originalTheme;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
|
ConsumerState<AddActionButton> createState() => _AddActionButtonState();
|
||||||
@@ -73,7 +71,7 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
),
|
),
|
||||||
|
|
||||||
if (isOwner) ...[
|
if (isOwner) ...[
|
||||||
const Divider(),
|
const PopupMenuDivider(),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
|
child: Text("move_to".tr(), style: context.textTheme.labelMedium),
|
||||||
@@ -168,27 +166,16 @@ class _AddActionButtonState extends ConsumerState<AddActionButton> {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final themeData = widget.originalTheme ?? context.themeData;
|
|
||||||
|
|
||||||
return MenuAnchor(
|
return MenuAnchor(
|
||||||
consumeOutsideTap: true,
|
consumeOutsideTap: true,
|
||||||
style: MenuStyle(
|
style: MenuStyle(
|
||||||
backgroundColor: WidgetStatePropertyAll(themeData.scaffoldBackgroundColor),
|
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
||||||
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
|
|
||||||
elevation: const WidgetStatePropertyAll(4),
|
elevation: const WidgetStatePropertyAll(4),
|
||||||
shape: const WidgetStatePropertyAll(
|
shape: const WidgetStatePropertyAll(
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||||
),
|
),
|
||||||
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
|
|
||||||
),
|
),
|
||||||
menuChildren: widget.originalTheme != null
|
menuChildren: _buildMenuChildren(),
|
||||||
? [
|
|
||||||
Theme(
|
|
||||||
data: widget.originalTheme!,
|
|
||||||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: _buildMenuChildren()),
|
|
||||||
),
|
|
||||||
]
|
|
||||||
: _buildMenuChildren(),
|
|
||||||
builder: (context, controller, child) {
|
builder: (context, controller, child) {
|
||||||
return BaseActionButton(
|
return BaseActionButton(
|
||||||
iconData: Icons.add,
|
iconData: Icons.add,
|
||||||
|
|||||||
@@ -1,8 +1,7 @@
|
|||||||
import 'package:flutter/material.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/build_context_extensions.dart';
|
||||||
|
|
||||||
class BaseActionButton extends ConsumerWidget {
|
class BaseActionButton extends StatelessWidget {
|
||||||
const BaseActionButton({
|
const BaseActionButton({
|
||||||
super.key,
|
super.key,
|
||||||
required this.label,
|
required this.label,
|
||||||
@@ -31,7 +30,7 @@ class BaseActionButton extends ConsumerWidget {
|
|||||||
final void Function()? onLongPressed;
|
final void Function()? onLongPressed;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context) {
|
||||||
final miniWidth = 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 iconTheme = IconTheme.of(context);
|
||||||
final iconSize = iconTheme.size ?? 24.0;
|
final iconSize = iconTheme.size ?? 24.0;
|
||||||
@@ -47,13 +46,14 @@ class BaseActionButton extends ConsumerWidget {
|
|||||||
|
|
||||||
if (menuItem) {
|
if (menuItem) {
|
||||||
final theme = context.themeData;
|
final theme = context.themeData;
|
||||||
|
final effectiveStyle = theme.textTheme.labelLarge;
|
||||||
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
|
final effectiveIconColor = iconColor ?? theme.iconTheme.color ?? theme.colorScheme.onSurfaceVariant;
|
||||||
|
|
||||||
return MenuItemButton(
|
return MenuItemButton(
|
||||||
style: MenuItemButton.styleFrom(alignment: Alignment.centerLeft, padding: const EdgeInsets.all(16)),
|
style: MenuItemButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12)),
|
||||||
leadingIcon: Icon(iconData, color: effectiveIconColor),
|
leadingIcon: Icon(iconData, color: effectiveIconColor, size: 20),
|
||||||
onPressed: onPressed,
|
onPressed: onPressed,
|
||||||
child: Text(label, style: theme.textTheme.labelLarge?.copyWith(fontSize: 16)),
|
child: Text(label, style: effectiveStyle),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import 'package:immich_mobile/providers/cast.provider.dart';
|
|||||||
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
import 'package:immich_mobile/widgets/asset_viewer/cast_dialog.dart';
|
||||||
|
|
||||||
class CastActionButton extends ConsumerWidget {
|
class CastActionButton extends ConsumerWidget {
|
||||||
const CastActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
const CastActionButton({super.key, this.iconOnly = true, this.menuItem = false});
|
||||||
|
|
||||||
final bool iconOnly;
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_bu
|
|||||||
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
import 'package:immich_mobile/providers/asset_viewer/is_motion_video_playing.provider.dart';
|
||||||
|
|
||||||
class MotionPhotoActionButton extends ConsumerWidget {
|
class MotionPhotoActionButton extends ConsumerWidget {
|
||||||
const MotionPhotoActionButton({super.key, this.iconOnly = false, this.menuItem = false});
|
const MotionPhotoActionButton({super.key, this.iconOnly = true, this.menuItem = false});
|
||||||
|
|
||||||
final bool iconOnly;
|
final bool iconOnly;
|
||||||
final bool menuItem;
|
final bool menuItem;
|
||||||
|
|||||||
@@ -38,13 +38,11 @@ class ViewerBottomBar extends ConsumerWidget {
|
|||||||
opacity = 0;
|
opacity = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
final originalTheme = context.themeData;
|
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
const ShareActionButton(source: ActionSource.viewer),
|
const ShareActionButton(source: ActionSource.viewer),
|
||||||
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
|
||||||
if (asset.type == AssetType.image) const EditImageActionButton(),
|
if (asset.type == AssetType.image) const EditImageActionButton(),
|
||||||
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
|
if (asset.hasRemote) const AddActionButton(),
|
||||||
|
|
||||||
if (isOwner) ...[
|
if (isOwner) ...[
|
||||||
asset.isLocalOnly
|
asset.isLocalOnly
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ 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/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_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/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/asset_viewer/sheet_tile.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.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/action.provider.dart';
|
||||||
@@ -23,6 +24,7 @@ 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/asset_viewer/current_asset.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/user_metadata.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/server_info.provider.dart';
|
import 'package:immich_mobile/providers/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
@@ -233,6 +235,9 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
final cameraTitle = _getCameraInfoTitle(exifInfo);
|
||||||
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
final lensTitle = exifInfo?.lens != null && exifInfo!.lens!.isNotEmpty ? exifInfo.lens : null;
|
||||||
final isOwner = ref.watch(currentUserProvider)?.id == (asset is RemoteAsset ? asset.ownerId : 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
|
// Build file info tile based on asset type
|
||||||
Widget buildFileInfoTile() {
|
Widget buildFileInfoTile() {
|
||||||
@@ -323,6 +328,38 @@ class _AssetDetailBottomSheet extends ConsumerWidget {
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
// 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).toUpperCase(),
|
||||||
|
style: context.textTheme.labelMedium?.copyWith(
|
||||||
|
color: context.textTheme.labelMedium?.color?.withAlpha(200),
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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)
|
// Appears in (Albums)
|
||||||
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
Padding(padding: const EdgeInsets.only(top: 16.0), child: _buildAppearsInList(ref, context)),
|
||||||
// padding at the bottom to avoid cut-off
|
// padding at the bottom to avoid cut-off
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
import 'package:flutter/material.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;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
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, size: widget.itemSize, color: filled ? widget.filledColor : widget.unfilledColor);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (_currentRating > 0)
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => {
|
||||||
|
setState(() {
|
||||||
|
_currentRating = 0;
|
||||||
|
}),
|
||||||
|
widget.onClearRating?.call(),
|
||||||
|
},
|
||||||
|
child: Text('rating_clear'.t(context: context)),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,19 +4,26 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
import 'package:immich_mobile/domain/models/events.model.dart';
|
||||||
|
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/translate_extensions.dart';
|
||||||
|
import 'package:immich_mobile/presentation/widgets/action_buttons/cast_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/favorite_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/motion_photo_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unfavorite_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/presentation/widgets/asset_viewer/asset_viewer.state.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/asset_viewer/viewer_kebab_menu.widget.dart';
|
||||||
import 'package:immich_mobile/providers/activity.provider.dart';
|
import 'package:immich_mobile/providers/activity.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/cast.provider.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';
|
||||||
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
||||||
import 'package:immich_mobile/providers/routes.provider.dart';
|
import 'package:immich_mobile/providers/routes.provider.dart';
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
|
|
||||||
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
||||||
const ViewerTopAppBar({super.key});
|
const ViewerTopAppBar({super.key});
|
||||||
@@ -35,6 +42,15 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
final isInLockedView = ref.watch(inLockedViewProvider);
|
final isInLockedView = ref.watch(inLockedViewProvider);
|
||||||
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
|
||||||
|
|
||||||
|
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
||||||
|
final showViewInTimelineButton =
|
||||||
|
timelineOrigin != TimelineOrigin.main &&
|
||||||
|
timelineOrigin != TimelineOrigin.deepLink &&
|
||||||
|
timelineOrigin != TimelineOrigin.trash &&
|
||||||
|
timelineOrigin != TimelineOrigin.archive &&
|
||||||
|
timelineOrigin != TimelineOrigin.localAlbum &&
|
||||||
|
isOwner;
|
||||||
|
|
||||||
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
final isShowingSheet = ref.watch(assetViewerProvider.select((state) => state.showingBottomSheet));
|
||||||
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
int opacity = ref.watch(assetViewerProvider.select((state) => state.backgroundOpacity));
|
||||||
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
|
||||||
@@ -47,10 +63,11 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
opacity = 0;
|
opacity = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
final originalTheme = context.themeData;
|
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
||||||
|
|
||||||
final actions = <Widget>[
|
final actions = <Widget>[
|
||||||
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
if (asset.isRemoteOnly) const DownloadActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||||
|
if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true),
|
||||||
if (album != null && album.isActivityEnabled && album.isShared)
|
if (album != null && album.isActivityEnabled && album.isShared)
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.chat_outlined),
|
icon: const Icon(Icons.chat_outlined),
|
||||||
@@ -58,16 +75,28 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
|
|||||||
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
|
EventStream.shared.emit(const ViewerOpenBottomSheetEvent(activitiesMode: true));
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
if (showViewInTimelineButton)
|
||||||
|
IconButton(
|
||||||
|
onPressed: () async {
|
||||||
|
await context.maybePop();
|
||||||
|
await context.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
|
||||||
|
EventStream.shared.emit(ScrollToDateEvent(asset.createdAt));
|
||||||
|
},
|
||||||
|
icon: const Icon(Icons.image_search),
|
||||||
|
tooltip: 'view_in_timeline'.t(context: context),
|
||||||
|
),
|
||||||
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
if (asset.hasRemote && isOwner && !asset.isFavorite)
|
||||||
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||||
if (asset.hasRemote && isOwner && asset.isFavorite)
|
if (asset.hasRemote && isOwner && asset.isFavorite)
|
||||||
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
|
||||||
|
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
|
||||||
ViewerKebabMenu(originalTheme: originalTheme),
|
const ViewerKebabMenu(),
|
||||||
];
|
];
|
||||||
|
|
||||||
final lockedViewActions = <Widget>[ViewerKebabMenu(originalTheme: originalTheme)];
|
final lockedViewActions = <Widget>[
|
||||||
|
if (isCasting || (asset.hasRemote)) const CastActionButton(iconOnly: true),
|
||||||
|
const ViewerKebabMenu(),
|
||||||
|
];
|
||||||
|
|
||||||
return IgnorePointer(
|
return IgnorePointer(
|
||||||
ignoring: opacity < 255,
|
ignoring: opacity < 255,
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/events.model.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/providers/cast.provider.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.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';
|
||||||
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
|
|
||||||
import 'package:immich_mobile/providers/user.provider.dart';
|
|
||||||
import 'package:immich_mobile/utils/action_button.utils.dart';
|
|
||||||
|
|
||||||
class ViewerKebabMenu extends ConsumerWidget {
|
class ViewerKebabMenu extends ConsumerWidget {
|
||||||
const ViewerKebabMenu({super.key, this.originalTheme});
|
const ViewerKebabMenu({super.key});
|
||||||
|
|
||||||
final ThemeData? originalTheme;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
@@ -20,42 +17,25 @@ class ViewerKebabMenu extends ConsumerWidget {
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
final user = ref.watch(currentUserProvider);
|
final menuChildren = <Widget>[
|
||||||
final isOwner = asset is RemoteAsset && asset.ownerId == user?.id;
|
BaseActionButton(
|
||||||
final isCasting = ref.watch(castProvider.select((c) => c.isCasting));
|
label: 'about'.tr(),
|
||||||
final timelineOrigin = ref.read(timelineServiceProvider).origin;
|
iconData: Icons.info_outline,
|
||||||
|
menuItem: true,
|
||||||
final kebabContext = ViewerKebabMenuButtonContext(
|
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
||||||
asset: asset,
|
),
|
||||||
isOwner: isOwner,
|
];
|
||||||
isCasting: isCasting,
|
|
||||||
timelineOrigin: timelineOrigin,
|
|
||||||
originalTheme: originalTheme,
|
|
||||||
);
|
|
||||||
|
|
||||||
final menuChildren = ViewerKebabMenuButtonBuilder.build(kebabContext, context, ref);
|
|
||||||
|
|
||||||
return MenuAnchor(
|
return MenuAnchor(
|
||||||
consumeOutsideTap: true,
|
consumeOutsideTap: true,
|
||||||
style: MenuStyle(
|
style: MenuStyle(
|
||||||
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
backgroundColor: WidgetStatePropertyAll(context.themeData.scaffoldBackgroundColor),
|
||||||
surfaceTintColor: const WidgetStatePropertyAll(Colors.grey),
|
|
||||||
elevation: const WidgetStatePropertyAll(4),
|
elevation: const WidgetStatePropertyAll(4),
|
||||||
shape: const WidgetStatePropertyAll(
|
shape: const WidgetStatePropertyAll(
|
||||||
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(12))),
|
||||||
),
|
),
|
||||||
padding: const WidgetStatePropertyAll(EdgeInsets.symmetric(vertical: 6)),
|
|
||||||
),
|
),
|
||||||
menuChildren: [
|
menuChildren: menuChildren,
|
||||||
ConstrainedBox(
|
|
||||||
constraints: const BoxConstraints(minWidth: 150),
|
|
||||||
child: Column(
|
|
||||||
mainAxisSize: MainAxisSize.min,
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
||||||
children: menuChildren,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
builder: (context, controller, child) {
|
builder: (context, controller, child) {
|
||||||
return IconButton(
|
return IconButton(
|
||||||
icon: const Icon(Icons.more_vert_rounded),
|
icon: const Icon(Icons.more_vert_rounded),
|
||||||
|
|||||||
@@ -324,11 +324,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
|
final topPadding = context.padding.top + (widget.appBar == null ? 0 : kToolbarHeight) + 10;
|
||||||
|
|
||||||
const scrubberBottomPadding = 100.0;
|
const scrubberBottomPadding = 100.0;
|
||||||
const bottomSheetOpenModifier = 120.0;
|
final bottomPadding = context.padding.bottom + (widget.appBar == null ? 0 : scrubberBottomPadding);
|
||||||
final bottomPadding =
|
|
||||||
context.padding.bottom +
|
|
||||||
(widget.appBar == null ? 0 : scrubberBottomPadding) +
|
|
||||||
(isMultiSelectEnabled ? bottomSheetOpenModifier : 0);
|
|
||||||
|
|
||||||
final grid = CustomScrollView(
|
final grid = CustomScrollView(
|
||||||
primary: true,
|
primary: true,
|
||||||
@@ -351,7 +347,7 @@ class _SliverTimelineState extends ConsumerState<_SliverTimeline> {
|
|||||||
addRepaintBoundaries: false,
|
addRepaintBoundaries: false,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverPadding(padding: EdgeInsets.only(bottom: bottomPadding)),
|
const SliverPadding(padding: EdgeInsets.only(bottom: scrubberBottomPadding)),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -357,6 +357,22 @@ class ActionNotifier extends Notifier<void> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<ActionResult> updateRating(ActionSource source, int rating) async {
|
||||||
|
final ids = _getRemoteIdsForSource(source);
|
||||||
|
if (ids.length != 1) {
|
||||||
|
_logger.warning('updateRating called with multiple assets, expected single asset');
|
||||||
|
return ActionResult(count: ids.length, success: false, error: 'Expected single asset for rating update');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
final isUpdated = await _service.updateRating(ids.first, rating);
|
||||||
|
return ActionResult(count: 1, success: isUpdated);
|
||||||
|
} catch (error, stack) {
|
||||||
|
_logger.severe('Failed to update rating for asset', error, stack);
|
||||||
|
return ActionResult(count: 1, success: false, error: error.toString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<ActionResult> stack(String userId, ActionSource source) async {
|
Future<ActionResult> stack(String userId, ActionSource source) async {
|
||||||
final ids = _getOwnedRemoteIdsForSource(source);
|
final ids = _getOwnedRemoteIdsForSource(source);
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -1,7 +1,22 @@
|
|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/domain/models/user_metadata.model.dart';
|
||||||
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
|
import 'package:immich_mobile/infrastructure/repositories/user_metadata.repository.dart';
|
||||||
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
|
||||||
|
import 'package:immich_mobile/providers/user.provider.dart';
|
||||||
|
|
||||||
final userMetadataRepository = Provider<DriftUserMetadataRepository>(
|
final userMetadataRepository = Provider<DriftUserMetadataRepository>(
|
||||||
(ref) => DriftUserMetadataRepository(ref.watch(driftProvider)),
|
(ref) => DriftUserMetadataRepository(ref.watch(driftProvider)),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
final userMetadataProvider = FutureProvider<List<UserMetadata>>((ref) async {
|
||||||
|
final repository = ref.watch(userMetadataRepository);
|
||||||
|
final user = ref.watch(currentUserProvider);
|
||||||
|
if (user == null) return [];
|
||||||
|
return repository.getUserMetadata(user.id);
|
||||||
|
});
|
||||||
|
|
||||||
|
final userMetadataPreferencesProvider = FutureProvider<Preferences?>((ref) async {
|
||||||
|
final metadataList = await ref.watch(userMetadataProvider.future);
|
||||||
|
final metadataWithPrefs = metadataList.firstWhere((meta) => meta.preferences != null);
|
||||||
|
return metadataWithPrefs.preferences;
|
||||||
|
});
|
||||||
|
|||||||
@@ -101,6 +101,10 @@ class AssetApiRepository extends ApiRepository {
|
|||||||
Future<void> updateDescription(String assetId, String description) {
|
Future<void> updateDescription(String assetId, String description) {
|
||||||
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
|
return _api.updateAsset(assetId, UpdateAssetDto(description: description));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> updateRating(String assetId, int rating) {
|
||||||
|
return _api.updateAsset(assetId, UpdateAssetDto(rating: rating));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
extension on StackResponseDto {
|
extension on StackResponseDto {
|
||||||
|
|||||||
@@ -225,6 +225,14 @@ class ActionService {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<bool> updateRating(String assetId, int rating) async {
|
||||||
|
// update remote first, then local to ensure consistency
|
||||||
|
await _assetApiRepository.updateRating(assetId, rating);
|
||||||
|
await _remoteAssetRepository.updateRating(assetId, rating);
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> stack(String userId, List<String> remoteIds) async {
|
Future<void> stack(String userId, List<String> remoteIds) async {
|
||||||
final stack = await _assetApiRepository.stack(remoteIds);
|
final stack = await _assetApiRepository.stack(remoteIds);
|
||||||
await _remoteAssetRepository.stack(userId, stack);
|
await _remoteAssetRepository.stack(userId, stack);
|
||||||
|
|||||||
@@ -1,18 +1,9 @@
|
|||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:flutter/widgets.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
|
||||||
import 'package:immich_mobile/constants/enums.dart';
|
import 'package:immich_mobile/constants/enums.dart';
|
||||||
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
import 'package:immich_mobile/domain/models/album/album.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
|
||||||
import 'package:immich_mobile/domain/models/events.model.dart';
|
|
||||||
import 'package:immich_mobile/domain/services/timeline.service.dart';
|
|
||||||
import 'package:immich_mobile/domain/utils/event_stream.dart';
|
|
||||||
import 'package:immich_mobile/extensions/translate_extensions.dart';
|
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/advanced_info_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.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/cast_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_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/delete_permanent_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/delete_permanent_action_button.widget.dart';
|
||||||
@@ -28,7 +19,6 @@ import 'package:immich_mobile/presentation/widgets/action_buttons/trash_action_b
|
|||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unarchive_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/unstack_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
|
||||||
|
|
||||||
class ActionButtonContext {
|
class ActionButtonContext {
|
||||||
final BaseAsset asset;
|
final BaseAsset asset;
|
||||||
@@ -174,98 +164,3 @@ class ActionButtonBuilder {
|
|||||||
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
return _actionTypes.where((type) => type.shouldShow(context)).map((type) => type.buildButton(context)).toList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class ViewerKebabMenuButtonContext {
|
|
||||||
final BaseAsset asset;
|
|
||||||
final bool isOwner;
|
|
||||||
final bool isCasting;
|
|
||||||
final TimelineOrigin timelineOrigin;
|
|
||||||
final ThemeData? originalTheme;
|
|
||||||
|
|
||||||
const ViewerKebabMenuButtonContext({
|
|
||||||
required this.asset,
|
|
||||||
required this.isOwner,
|
|
||||||
required this.isCasting,
|
|
||||||
required this.timelineOrigin,
|
|
||||||
this.originalTheme,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
enum ViewerKebabMenuButtonType {
|
|
||||||
openInfo,
|
|
||||||
viewInTimeline,
|
|
||||||
cast,
|
|
||||||
download;
|
|
||||||
|
|
||||||
/// Defines which group each button belongs to.
|
|
||||||
/// Buttons in the same group will be displayed together,
|
|
||||||
/// with dividers separating different groups.
|
|
||||||
int get group => switch (this) {
|
|
||||||
ViewerKebabMenuButtonType.openInfo => 0,
|
|
||||||
ViewerKebabMenuButtonType.viewInTimeline => 1,
|
|
||||||
ViewerKebabMenuButtonType.cast => 1,
|
|
||||||
ViewerKebabMenuButtonType.download => 1,
|
|
||||||
};
|
|
||||||
|
|
||||||
bool shouldShow(ViewerKebabMenuButtonContext context) {
|
|
||||||
return switch (this) {
|
|
||||||
ViewerKebabMenuButtonType.openInfo => true,
|
|
||||||
ViewerKebabMenuButtonType.viewInTimeline =>
|
|
||||||
context.timelineOrigin != TimelineOrigin.main &&
|
|
||||||
context.timelineOrigin != TimelineOrigin.deepLink &&
|
|
||||||
context.timelineOrigin != TimelineOrigin.trash &&
|
|
||||||
context.timelineOrigin != TimelineOrigin.archive &&
|
|
||||||
context.timelineOrigin != TimelineOrigin.localAlbum &&
|
|
||||||
context.isOwner,
|
|
||||||
ViewerKebabMenuButtonType.cast => context.isCasting || context.asset.hasRemote,
|
|
||||||
ViewerKebabMenuButtonType.download => context.asset.isRemoteOnly,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
ConsumerWidget buildButton(ViewerKebabMenuButtonContext context, BuildContext buildContext) {
|
|
||||||
return switch (this) {
|
|
||||||
ViewerKebabMenuButtonType.openInfo => BaseActionButton(
|
|
||||||
label: 'info'.tr(),
|
|
||||||
iconData: Icons.info_outline,
|
|
||||||
iconColor: context.originalTheme?.iconTheme.color,
|
|
||||||
menuItem: true,
|
|
||||||
onPressed: () => EventStream.shared.emit(const ViewerOpenBottomSheetEvent()),
|
|
||||||
),
|
|
||||||
|
|
||||||
ViewerKebabMenuButtonType.viewInTimeline => BaseActionButton(
|
|
||||||
label: 'view_in_timeline'.t(context: buildContext),
|
|
||||||
iconData: Icons.image_search,
|
|
||||||
iconColor: context.originalTheme?.iconTheme.color,
|
|
||||||
menuItem: true,
|
|
||||||
onPressed: () async {
|
|
||||||
await buildContext.maybePop();
|
|
||||||
await buildContext.navigateTo(const TabShellRoute(children: [MainTimelineRoute()]));
|
|
||||||
EventStream.shared.emit(ScrollToDateEvent(context.asset.createdAt));
|
|
||||||
},
|
|
||||||
),
|
|
||||||
ViewerKebabMenuButtonType.cast => const CastActionButton(menuItem: true),
|
|
||||||
ViewerKebabMenuButtonType.download => const DownloadActionButton(source: ActionSource.viewer, menuItem: true),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class ViewerKebabMenuButtonBuilder {
|
|
||||||
static List<Widget> build(ViewerKebabMenuButtonContext context, BuildContext buildContext, WidgetRef ref) {
|
|
||||||
final visibleButtons = ViewerKebabMenuButtonType.values.where((type) => type.shouldShow(context)).toList();
|
|
||||||
|
|
||||||
if (visibleButtons.isEmpty) return [];
|
|
||||||
|
|
||||||
final List<Widget> result = [];
|
|
||||||
int? lastGroup;
|
|
||||||
|
|
||||||
for (final type in visibleButtons) {
|
|
||||||
if (lastGroup != null && type.group != lastGroup) {
|
|
||||||
result.add(const Divider(height: 1));
|
|
||||||
}
|
|
||||||
result.add(type.buildButton(context, buildContext).build(buildContext, ref));
|
|
||||||
lastGroup = type.group;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -2,27 +2,26 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||||
|
|
||||||
class EntityCountTile extends StatelessWidget {
|
class EntitiyCountTile extends StatelessWidget {
|
||||||
final int count;
|
final int count;
|
||||||
final String label;
|
final String label;
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
|
|
||||||
const EntityCountTile({super.key, required this.count, required this.label, required this.icon});
|
const EntitiyCountTile({super.key, required this.count, required this.label, required this.icon});
|
||||||
|
|
||||||
String zeroPadding(int number, int targetWidth) {
|
String zeroPadding(int number, int targetWidth) {
|
||||||
final numStr = number.toString();
|
final numStr = number.toString();
|
||||||
return numStr.length < targetWidth ? "0" * (targetWidth - numStr.length) : "";
|
return numStr.length < targetWidth ? "0" * (targetWidth - numStr.length) : "";
|
||||||
}
|
}
|
||||||
|
|
||||||
|
int calculateMaxDigits(double availableWidth) {
|
||||||
|
const double charWidth = 11.0;
|
||||||
|
return (availableWidth / charWidth).floor().clamp(1, 8);
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
|
||||||
final availableWidth = (screenWidth - 32 - 8) / 2;
|
|
||||||
const double charWidth = 11.0;
|
|
||||||
final maxDigits = ((availableWidth - 32) / charWidth).floor().clamp(1, 8);
|
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
height: double.infinity,
|
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: context.colorScheme.surfaceContainerLow,
|
color: context.colorScheme.surfaceContainerLow,
|
||||||
@@ -30,6 +29,7 @@ class EntityCountTile extends StatelessWidget {
|
|||||||
border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)),
|
border: Border.all(width: 0.5, color: context.colorScheme.outline.withAlpha(25)),
|
||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
// Icon and Label
|
// Icon and Label
|
||||||
@@ -38,30 +38,33 @@ class EntityCountTile extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
Icon(icon, color: context.primaryColor),
|
Icon(icon, color: context.primaryColor),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Flexible(
|
Text(
|
||||||
child: Text(
|
label,
|
||||||
label,
|
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
style: TextStyle(color: context.primaryColor, fontWeight: FontWeight.bold, fontSize: 16),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
const SizedBox(height: 12),
|
||||||
// Number
|
// Number
|
||||||
const Spacer(),
|
LayoutBuilder(
|
||||||
RichText(
|
builder: (context, constraints) {
|
||||||
text: TextSpan(
|
final maxDigits = calculateMaxDigits(constraints.maxWidth);
|
||||||
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
|
return RichText(
|
||||||
children: [
|
text: TextSpan(
|
||||||
TextSpan(
|
style: const TextStyle(fontSize: 18, fontFamily: 'OverpassMono', fontWeight: FontWeight.w600),
|
||||||
text: zeroPadding(count, maxDigits),
|
children: [
|
||||||
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
|
TextSpan(
|
||||||
|
text: zeroPadding(count, maxDigits),
|
||||||
|
style: TextStyle(color: context.colorScheme.onSurfaceSecondary.withAlpha(75)),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: count.toString(),
|
||||||
|
style: TextStyle(color: context.primaryColor),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
TextSpan(
|
);
|
||||||
text: count.toString(),
|
},
|
||||||
style: TextStyle(color: context.primaryColor),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@@ -282,87 +282,76 @@ class _SyncStatsCounts extends ConsumerWidget {
|
|||||||
_SectionHeaderText(text: "assets".t(context: context)),
|
_SectionHeaderText(text: "assets".t(context: context)),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
// 1. Wrap in IntrinsicHeight
|
child: Flex(
|
||||||
child: IntrinsicHeight(
|
direction: Axis.horizontal,
|
||||||
child: Flex(
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
direction: Axis.horizontal,
|
spacing: 8.0,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
children: [
|
||||||
// 2. Stretch children vertically to fill the IntrinsicHeight
|
Expanded(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
child: EntitiyCountTile(
|
||||||
spacing: 8.0,
|
label: "local".t(context: context),
|
||||||
children: [
|
count: localAssetCount,
|
||||||
Expanded(
|
icon: Icons.smartphone,
|
||||||
child: EntityCountTile(
|
|
||||||
label: "local".t(context: context),
|
|
||||||
count: localAssetCount,
|
|
||||||
icon: Icons.smartphone,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: EntityCountTile(
|
Expanded(
|
||||||
label: "remote".t(context: context),
|
child: EntitiyCountTile(
|
||||||
count: remoteAssetCount,
|
label: "remote".t(context: context),
|
||||||
icon: Icons.cloud,
|
count: remoteAssetCount,
|
||||||
),
|
icon: Icons.cloud,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_SectionHeaderText(text: "albums".t(context: context)),
|
_SectionHeaderText(text: "albums".t(context: context)),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: IntrinsicHeight(
|
child: Flex(
|
||||||
child: Flex(
|
direction: Axis.horizontal,
|
||||||
direction: Axis.horizontal,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
spacing: 8.0,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
|
children: [
|
||||||
spacing: 8.0,
|
Expanded(
|
||||||
children: [
|
child: EntitiyCountTile(
|
||||||
Expanded(
|
label: "local".t(context: context),
|
||||||
child: EntityCountTile(
|
count: localAlbumCount,
|
||||||
label: "local".t(context: context),
|
icon: Icons.smartphone,
|
||||||
count: localAlbumCount,
|
|
||||||
icon: Icons.smartphone,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: EntityCountTile(
|
Expanded(
|
||||||
label: "remote".t(context: context),
|
child: EntitiyCountTile(
|
||||||
count: remoteAlbumCount,
|
label: "remote".t(context: context),
|
||||||
icon: Icons.cloud,
|
count: remoteAlbumCount,
|
||||||
),
|
icon: Icons.cloud,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
_SectionHeaderText(text: "other".t(context: context)),
|
_SectionHeaderText(text: "other".t(context: context)),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: IntrinsicHeight(
|
child: Flex(
|
||||||
child: Flex(
|
direction: Axis.horizontal,
|
||||||
direction: Axis.horizontal,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
spacing: 8.0,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
|
children: [
|
||||||
spacing: 8.0,
|
Expanded(
|
||||||
children: [
|
child: EntitiyCountTile(
|
||||||
Expanded(
|
label: "memories".t(context: context),
|
||||||
child: EntityCountTile(
|
count: memoryCount,
|
||||||
label: "memories".t(context: context),
|
icon: Icons.calendar_today,
|
||||||
count: memoryCount,
|
|
||||||
icon: Icons.calendar_today,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: EntityCountTile(
|
Expanded(
|
||||||
label: "hashed_assets".t(context: context),
|
child: EntitiyCountTile(
|
||||||
count: localHashedCount,
|
label: "hashed_assets".t(context: context),
|
||||||
icon: Icons.tag,
|
count: localHashedCount,
|
||||||
),
|
icon: Icons.tag,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// To be removed once the experimental feature is stable
|
// To be removed once the experimental feature is stable
|
||||||
@@ -375,29 +364,26 @@ class _SyncStatsCounts extends ConsumerWidget {
|
|||||||
return counts.when(
|
return counts.when(
|
||||||
data: (c) => Padding(
|
data: (c) => Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
|
||||||
child: IntrinsicHeight(
|
child: Flex(
|
||||||
child: Flex(
|
direction: Axis.horizontal,
|
||||||
direction: Axis.horizontal,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
spacing: 8.0,
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch, // Added
|
children: [
|
||||||
spacing: 8.0,
|
Expanded(
|
||||||
children: [
|
child: EntitiyCountTile(
|
||||||
Expanded(
|
label: "local".t(context: context),
|
||||||
child: EntityCountTile(
|
count: c.total,
|
||||||
label: "local".t(context: context),
|
icon: Icons.delete_outline,
|
||||||
count: c.total,
|
|
||||||
icon: Icons.delete_outline,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Expanded(
|
),
|
||||||
child: EntityCountTile(
|
Expanded(
|
||||||
label: "hashed_assets".t(context: context),
|
child: EntitiyCountTile(
|
||||||
count: c.hashed,
|
label: "hashed_assets".t(context: context),
|
||||||
icon: Icons.tag,
|
count: c.hashed,
|
||||||
),
|
icon: Icons.tag,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
loading: () => const CircularProgressIndicator(),
|
loading: () => const CircularProgressIndicator(),
|
||||||
|
|||||||
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -717,8 +717,8 @@ importers:
|
|||||||
specifier: file:../open-api/typescript-sdk
|
specifier: file:../open-api/typescript-sdk
|
||||||
version: link:../open-api/typescript-sdk
|
version: link:../open-api/typescript-sdk
|
||||||
'@immich/ui':
|
'@immich/ui':
|
||||||
specifier: ^0.50.1
|
specifier: ^0.50.0
|
||||||
version: 0.50.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
|
version: 0.50.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)
|
||||||
'@mapbox/mapbox-gl-rtl-text':
|
'@mapbox/mapbox-gl-rtl-text':
|
||||||
specifier: 0.2.3
|
specifier: 0.2.3
|
||||||
version: 0.2.3(mapbox-gl@1.13.3)
|
version: 0.2.3(mapbox-gl@1.13.3)
|
||||||
@@ -2989,8 +2989,8 @@ packages:
|
|||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
|
|
||||||
'@immich/ui@0.50.1':
|
'@immich/ui@0.50.0':
|
||||||
resolution: {integrity: sha512-fNlQGh75ZFa/UZAgJaYk9/ItHOXHNNzN4CunjCmE7WocVVkUZbUxopN9Ku3F5GULSqD/zJ5gNO6PQAZ1ZoSaaQ==}
|
resolution: {integrity: sha512-7AW9SRZTAgal8xlkUAxm7o4+pSG7HcKb+Bh9JpWLaDRRdGyPCZMmsNa9CjZglOQ7wkAD07tQ9u4+zezBLe0dlQ==}
|
||||||
peerDependencies:
|
peerDependencies:
|
||||||
svelte: ^5.0.0
|
svelte: ^5.0.0
|
||||||
|
|
||||||
@@ -14701,7 +14701,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
svelte: 5.45.2
|
svelte: 5.45.2
|
||||||
|
|
||||||
'@immich/ui@0.50.1(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)':
|
'@immich/ui@0.50.0(@sveltejs/kit@2.49.0(@opentelemetry/api@1.9.0)(@sveltejs/vite-plugin-svelte@6.2.1(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)(vite@7.2.4(@types/node@24.10.1)(jiti@2.6.1)(lightningcss@1.30.2)(terser@5.44.0)(yaml@2.8.2)))(svelte@5.45.2)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.45.2)
|
'@immich/svelte-markdown-preprocess': 0.1.0(svelte@5.45.2)
|
||||||
'@internationalized/date': 3.10.0
|
'@internationalized/date': 3.10.0
|
||||||
|
|||||||
@@ -50,15 +50,13 @@ RUN --mount=type=cache,id=pnpm-cli,target=/buildcache/pnpm-store \
|
|||||||
|
|
||||||
FROM builder AS plugins
|
FROM builder AS plugins
|
||||||
|
|
||||||
ARG TARGETPLATFORM
|
|
||||||
|
|
||||||
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise
|
COPY --from=ghcr.io/jdx/mise:2025.11.3@sha256:ac26f5978c0e2783f3e68e58ce75eddb83e41b89bf8747c503bac2aa9baf22c5 /usr/local/bin/mise /usr/local/bin/mise
|
||||||
|
|
||||||
WORKDIR /usr/src/app
|
WORKDIR /usr/src/app
|
||||||
COPY ./plugins/mise.toml ./plugins/
|
COPY ./plugins/mise.toml ./plugins/
|
||||||
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
|
ENV MISE_TRUSTED_CONFIG_PATHS=/usr/src/app/plugins/mise.toml
|
||||||
ENV MISE_DATA_DIR=/buildcache/mise
|
ENV MISE_DATA_DIR=/buildcache/mise
|
||||||
RUN --mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
RUN --mount=type=cache,id=mise-tools,target=/buildcache/mise \
|
||||||
mise install --cd plugins
|
mise install --cd plugins
|
||||||
|
|
||||||
COPY ./plugins ./plugins/
|
COPY ./plugins ./plugins/
|
||||||
@@ -68,7 +66,7 @@ RUN --mount=type=cache,id=pnpm-plugins,target=/buildcache/pnpm-store \
|
|||||||
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
|
--mount=type=bind,source=.pnpmfile.cjs,target=.pnpmfile.cjs \
|
||||||
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
|
--mount=type=bind,source=pnpm-lock.yaml,target=pnpm-lock.yaml \
|
||||||
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
|
--mount=type=bind,source=pnpm-workspace.yaml,target=pnpm-workspace.yaml \
|
||||||
--mount=type=cache,id=mise-tools-${TARGETPLATFORM},target=/buildcache/mise \
|
--mount=type=cache,id=mise-tools,target=/buildcache/mise \
|
||||||
cd plugins && mise run build
|
cd plugins && mise run build
|
||||||
|
|
||||||
FROM ghcr.io/immich-app/base-server-prod:202511261514@sha256:c04c1c38dd90e53455b180aedf93c3c63474c8d20ffe2c6d7a3a61a2181e6d29
|
FROM ghcr.io/immich-app/base-server-prod:202511261514@sha256:c04c1c38dd90e53455b180aedf93c3c63474c8d20ffe2c6d7a3a61a2181e6d29
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { Injectable } from '@nestjs/common';
|
|||||||
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
import { BinaryField, DefaultReadTaskOptions, ExifTool, Tags } from 'exiftool-vendored';
|
||||||
import geotz from 'geo-tz';
|
import geotz from 'geo-tz';
|
||||||
import { LoggingRepository } from 'src/repositories/logging.repository';
|
import { LoggingRepository } from 'src/repositories/logging.repository';
|
||||||
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
|
|
||||||
interface ExifDuration {
|
interface ExifDuration {
|
||||||
Value: number;
|
Value: number;
|
||||||
@@ -81,7 +82,6 @@ export class MetadataRepository {
|
|||||||
inferTimezoneFromDatestamps: true,
|
inferTimezoneFromDatestamps: true,
|
||||||
inferTimezoneFromTimeStamp: true,
|
inferTimezoneFromTimeStamp: true,
|
||||||
useMWG: true,
|
useMWG: true,
|
||||||
taskTimeoutMillis: 1 * 60 * 1000,
|
|
||||||
numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength', 'FileSize'],
|
numericTags: [...DefaultReadTaskOptions.numericTags, 'FocalLength', 'FileSize'],
|
||||||
/* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */
|
/* eslint unicorn/no-array-callback-reference: off, unicorn/no-array-method-this-argument: off */
|
||||||
geoTz: (lat, lon) => geotz.find(lat, lon)[0],
|
geoTz: (lat, lon) => geotz.find(lat, lon)[0],
|
||||||
@@ -104,7 +104,8 @@ export class MetadataRepository {
|
|||||||
}
|
}
|
||||||
|
|
||||||
readTags(path: string): Promise<ImmichTags> {
|
readTags(path: string): Promise<ImmichTags> {
|
||||||
return this.exiftool.read(path).catch((error) => {
|
const args = mimeTypes.isVideo(path) ? ['-ee'] : [];
|
||||||
|
return this.exiftool.read(path, args).catch((error) => {
|
||||||
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
|
this.logger.warn(`Error reading exif data (${path}): ${error}\n${error?.stack}`);
|
||||||
return {};
|
return {};
|
||||||
}) as Promise<ImmichTags>;
|
}) as Promise<ImmichTags>;
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
"@formatjs/icu-messageformat-parser": "^2.9.8",
|
||||||
"@immich/justified-layout-wasm": "^0.4.3",
|
"@immich/justified-layout-wasm": "^0.4.3",
|
||||||
"@immich/sdk": "file:../open-api/typescript-sdk",
|
"@immich/sdk": "file:../open-api/typescript-sdk",
|
||||||
"@immich/ui": "^0.50.1",
|
"@immich/ui": "^0.50.0",
|
||||||
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
"@mapbox/mapbox-gl-rtl-text": "0.2.3",
|
||||||
"@mdi/js": "^7.4.47",
|
"@mdi/js": "^7.4.47",
|
||||||
"@photo-sphere-viewer/core": "^5.14.0",
|
"@photo-sphere-viewer/core": "^5.14.0",
|
||||||
|
|||||||
@@ -32,7 +32,7 @@
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<tr
|
<tr
|
||||||
class="flex w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:px-5 md:py-2"
|
class="flex h-12 w-full place-items-center border-3 border-transparent p-2 text-center even:bg-subtle/20 odd:bg-subtle/80 hover:cursor-pointer hover:border-immich-primary/75 odd:dark:bg-immich-dark-gray/75 even:dark:bg-immich-dark-gray/50 dark:hover:border-immich-dark-primary/75 md:p-5"
|
||||||
onclick={() => goto(resolve(`${AppRoute.ALBUMS}/${album.id}`))}
|
onclick={() => goto(resolve(`${AppRoute.ALBUMS}/${album.id}`))}
|
||||||
{oncontextmenu}
|
{oncontextmenu}
|
||||||
>
|
>
|
||||||
|
|||||||
Reference in New Issue
Block a user