Compare commits

..

1 Commits

Author SHA1 Message Date
Alex a564d46017 feat: new feature board 2026-06-22 16:59:23 -05:00
84 changed files with 897 additions and 1081 deletions
+1 -1
View File
@@ -103,7 +103,7 @@ jobs:
working-directory: ./mobile
run: printf "%s" $KEY_JKS | base64 -d > android/key.jks
- uses: actions/setup-java@ad2b38190b15e4d6bdf0c97fb4fca8412226d287 # v5.3.0
- uses: actions/setup-java@be666c2fcd27ec809703dec50e508c2fdc7f6654 # v5.2.0
with:
distribution: 'zulu'
java-version: '17'
+1 -2
View File
@@ -25,12 +25,11 @@ jobs:
persist-credentials: false
- name: Check for breaking API changes
uses: oasdiff/oasdiff-action/breaking@e24529087d93f837b28b50bb66ba9016380a7fcc # v0.1.2
uses: oasdiff/oasdiff-action/breaking@3530478ec30f84adedbfeb28f0d9527a290f50a9 # v0.0.57
with:
base: https://raw.githubusercontent.com/${{ github.repository }}/main/open-api/immich-openapi-specs.json
revision: open-api/immich-openapi-specs.json
fail-on: ERR
review: false
check-mobile-patches:
runs-on: ubuntu-latest
+2 -2
View File
@@ -406,7 +406,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
@@ -483,7 +483,7 @@ jobs:
token: ${{ steps.token.outputs.token }}
- name: Setup pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
- name: Setup Node
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
+1 -1
View File
@@ -85,7 +85,7 @@ services:
container_name: immich_prometheus
ports:
- 9090:9090
image: prom/prometheus@sha256:a75c5a35bc21d7afe69551eefa3cb1e1fb1775fe759408007a66b54ec3de1f29
image: prom/prometheus@sha256:69f5241418838263316593f7274a304b095c40bcf22e57272865da91bd60a8ac
volumes:
- ./prometheus.yml:/etc/prometheus/prometheus.yml
- prometheus-data:/prometheus
+18 -1
View File
@@ -1251,6 +1251,22 @@
"favorite_or_unfavorite_photo": "Favorite or unfavorite photo",
"favorites": "Favorites",
"favorites_page_no_favorites": "No favorite assets found",
"feature_message_non_destructive_editing_body": "Edit your photos freely — the original is always kept untouched.",
"feature_message_non_destructive_editing_title": "Non-destructive editing",
"feature_message_ocr_body": "Immich now reads the text inside your photos, so you can search for them by what they say.",
"feature_message_ocr_title": "Search text in your photos",
"feature_message_open_in_immich_body": "Set Immich as your gallery on Android to open photos straight from other apps.",
"feature_message_open_in_immich_title": "Open photos in Immich",
"feature_message_recently_added_body": "Jump straight to everything you've added lately on a dedicated page.",
"feature_message_recently_added_title": "Recently added",
"feature_message_settings_subtitle": "See what's new in version {version}",
"feature_message_share_quality_body": "Press and hold the share button to choose the image quality before you share.",
"feature_message_share_quality_title": "Choose your share quality",
"feature_message_slideshow_body": "Sit back and watch your photos play in a full-screen slideshow.",
"feature_message_slideshow_title": "Slideshow",
"feature_message_upload_to_album_body": "Add photos directly into an album as you upload them.",
"feature_message_upload_to_album_title": "Upload straight to an album",
"feature_message_version": "Version {version}",
"feature_photo_updated": "Feature photo updated",
"features": "Features",
"features_in_development": "Features in Development",
@@ -1548,7 +1564,7 @@
"map_location_picker_page_use_location": "Use this location",
"map_location_service_disabled_content": "Location service needs to be enabled to display assets from your current location. Do you want to enable it now?",
"map_location_service_disabled_title": "Location Service disabled",
"map_marker_for_image": "Map marker for image taken in {city}, {country}",
"map_marker_for_images": "Map marker for images taken in {city}, {country}",
"map_marker_with_image": "Map marker with image",
"map_no_location_permission_content": "Location permission is needed to display assets from your current location. Do you want to allow it now?",
"map_no_location_permission_title": "Location Permission denied",
@@ -2530,6 +2546,7 @@
"week": "Week",
"welcome": "Welcome",
"welcome_to_immich": "Welcome to Immich",
"whats_new": "What's new",
"when": "When",
"width": "Width",
"wifi_name": "Wi-Fi Name",
Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 502 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 508 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 500 B

-1
View File
@@ -5,7 +5,6 @@ const Map<String, Locale> locales = {
'English (en)': Locale('en'),
// Additional locales
'Arabic (ar)': Locale('ar'),
'Basque (eu)': Locale('eu'),
'Bosnian (bl)': Locale('bn'),
'Brazilian Portuguese (pt_BR)': Locale('pt', 'BR'),
'Bulgarian (bg)': Locale('bg'),
@@ -4,6 +4,7 @@ import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/domain/models/config/album_config.dart';
import 'package:immich_mobile/domain/models/config/backup_config.dart';
import 'package:immich_mobile/domain/models/config/cleanup_config.dart';
import 'package:immich_mobile/domain/models/config/feature_message_config.dart';
import 'package:immich_mobile/domain/models/config/image_config.dart';
import 'package:immich_mobile/domain/models/config/map_config.dart';
import 'package:immich_mobile/domain/models/config/network_config.dart';
@@ -32,6 +33,7 @@ class AppConfig {
final BackupConfig backup;
final NetworkConfig network;
final ShareConfig share;
final FeatureMessageConfig featureMessage;
const AppConfig({
this.logLevel = .info,
@@ -46,6 +48,7 @@ class AppConfig {
this.backup = const .new(),
this.network = const .new(),
this.share = const .new(),
this.featureMessage = const .new(),
});
AppConfig copyWith({
@@ -61,6 +64,7 @@ class AppConfig {
BackupConfig? backup,
NetworkConfig? network,
ShareConfig? share,
FeatureMessageConfig? featureMessage,
}) => .new(
logLevel: logLevel ?? this.logLevel,
theme: theme ?? this.theme,
@@ -74,6 +78,7 @@ class AppConfig {
backup: backup ?? this.backup,
network: network ?? this.network,
share: share ?? this.share,
featureMessage: featureMessage ?? this.featureMessage,
);
@override
@@ -91,15 +96,29 @@ class AppConfig {
other.album == album &&
other.backup == backup &&
other.network == network &&
other.share == share);
other.share == share &&
other.featureMessage == featureMessage);
@override
int get hashCode =>
Object.hash(logLevel, theme, cleanup, map, timeline, image, viewer, slideshow, album, backup, network, share);
int get hashCode => Object.hash(
logLevel,
theme,
cleanup,
map,
timeline,
image,
viewer,
slideshow,
album,
backup,
network,
share,
featureMessage,
);
@override
String toString() =>
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share)';
'AppConfig(logLevel: $logLevel, theme: $theme, cleanup: $cleanup, map: $map, timeline: $timeline, image: $image, viewer: $viewer, slideshow: $slideshow, album: $album, backup: $backup, network: $network, share: $share, featureMessage: $featureMessage)';
T read<T>(SettingsKey<T> key) =>
(switch (key) {
@@ -146,6 +165,7 @@ class AppConfig {
.slideshowDuration => slideshow.duration,
.slideshowLook => slideshow.look,
.slideshowDirection => slideshow.direction,
.featureMessageSeenVersion => featureMessage.seenVersion,
})
as T;
@@ -199,6 +219,7 @@ class AppConfig {
.slideshowDuration => copyWith(slideshow: slideshow.copyWith(duration: value as int)),
.slideshowLook => copyWith(slideshow: slideshow.copyWith(look: value as SlideshowLook)),
.slideshowDirection => copyWith(slideshow: slideshow.copyWith(direction: value as SlideshowDirection)),
.featureMessageSeenVersion => copyWith(featureMessage: featureMessage.copyWith(seenVersion: value as int)),
};
}
}
@@ -0,0 +1,18 @@
class FeatureMessageConfig {
final int seenVersion;
const FeatureMessageConfig({this.seenVersion = 0});
FeatureMessageConfig copyWith({int? seenVersion}) =>
FeatureMessageConfig(seenVersion: seenVersion ?? this.seenVersion);
@override
bool operator ==(Object other) =>
identical(this, other) || (other is FeatureMessageConfig && other.seenVersion == seenVersion);
@override
int get hashCode => seenVersion.hashCode;
@override
String toString() => 'FeatureMessageConfig(seenVersion: $seenVersion)';
}
@@ -0,0 +1,49 @@
class FeatureHighlight {
final String image;
final String titleKey;
final String bodyKey;
const FeatureHighlight({required this.image, required this.titleKey, required this.bodyKey});
}
const int featureMessageHighlightVersion = 1;
const String featureMessageReleaseLabel = '3.0.0';
const List<FeatureHighlight> featureMessageHighlights = [
FeatureHighlight(
image: 'assets/feature_message/share_quality.webp',
titleKey: 'feature_message_share_quality_title',
bodyKey: 'feature_message_share_quality_body',
),
FeatureHighlight(
image: 'assets/feature_message/slideshow.webp',
titleKey: 'feature_message_slideshow_title',
bodyKey: 'feature_message_slideshow_body',
),
FeatureHighlight(
image: 'assets/feature_message/recently_added.webp',
titleKey: 'feature_message_recently_added_title',
bodyKey: 'feature_message_recently_added_body',
),
FeatureHighlight(
image: 'assets/feature_message/non_destructive_editing.webp',
titleKey: 'feature_message_non_destructive_editing_title',
bodyKey: 'feature_message_non_destructive_editing_body',
),
FeatureHighlight(
image: 'assets/feature_message/ocr.webp',
titleKey: 'feature_message_ocr_title',
bodyKey: 'feature_message_ocr_body',
),
FeatureHighlight(
image: 'assets/feature_message/open_in_immich.webp',
titleKey: 'feature_message_open_in_immich_title',
bodyKey: 'feature_message_open_in_immich_body',
),
FeatureHighlight(
image: 'assets/feature_message/upload_to_album.webp',
titleKey: 'feature_message_upload_to_album_title',
bodyKey: 'feature_message_upload_to_album_body',
),
];
+4 -1
View File
@@ -73,7 +73,10 @@ enum SettingsKey<T> {
slideshowRepeat<bool>(),
slideshowDuration<int>(),
slideshowLook<SlideshowLook>(codec: _EnumCodec(SlideshowLook.values)),
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values));
slideshowDirection<SlideshowDirection>(codec: _EnumCodec(SlideshowDirection.values)),
// Feature message
featureMessageSeenVersion<int>();
final _SettingsCodec<T>? _codecOverride;
@@ -0,0 +1,17 @@
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/domain/models/settings_key.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
class FeatureMessageService {
final SettingsRepository _settingsRepository;
const FeatureMessageService(this._settingsRepository);
bool shouldShow() {
final seen = _settingsRepository.appConfig.read(SettingsKey.featureMessageSeenVersion);
return featureMessageHighlights.isNotEmpty && featureMessageHighlightVersion > seen;
}
Future<void> markSeen() =>
_settingsRepository.write(SettingsKey.featureMessageSeenVersion, featureMessageHighlightVersion);
}
-1
View File
@@ -263,7 +263,6 @@ class ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserve
child: MaterialApp.router(
title: 'Immich',
debugShowCheckedModeBanner: true,
scaffoldMessengerKey: scaffoldMessengerKey,
localizationsDelegates: context.localizationDelegates,
supportedLocales: context.supportedLocales,
locale: context.locale,
@@ -2,7 +2,9 @@ import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/widgets/settings/advanced_settings.dart';
import 'package:immich_mobile/widgets/settings/asset_list_settings/asset_list_settings.dart';
@@ -87,6 +89,14 @@ class _MobileLayout extends StatelessWidget {
],
)
.toList();
settings.add(
SettingsCard(
icon: Icons.auto_awesome_outlined,
title: 'whats_new'.tr(),
subtitle: 'feature_message_settings_subtitle'.tr(namedArgs: {'version': featureMessageReleaseLabel}),
onTap: () => showFeatureMessageDialog(context),
),
);
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]);
}
}
@@ -116,6 +126,13 @@ class _TabletLayout extends HookWidget {
),
),
),
SliverToBoxAdapter(
child: ListTile(
title: Text('whats_new'.tr()),
leading: const Icon(Icons.auto_awesome_outlined),
onTap: () => showFeatureMessageDialog(context),
),
),
],
),
),
@@ -3,14 +3,37 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/presentation/widgets/memory/memory_lane.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_dialog.widget.dart';
import 'package:immich_mobile/providers/feature_message.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
@RoutePage()
class MainTimelinePage extends ConsumerWidget {
class MainTimelinePage extends ConsumerStatefulWidget {
const MainTimelinePage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
ConsumerState<MainTimelinePage> createState() => _MainTimelinePageState();
}
class _MainTimelinePageState extends ConsumerState<MainTimelinePage> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted || ref.read(featureMessageCheckedProvider)) {
return;
}
ref.read(featureMessageCheckedProvider.notifier).state = true;
final service = ref.read(featureMessageServiceProvider);
// if (service.shouldShow()) {
showFeatureMessageDialog(context).then((_) => service.markSeen());
// }
});
}
@override
Widget build(BuildContext context) {
final hasMemories = ref.watch(driftMemoryFutureProvider.select((state) => state.value?.isNotEmpty ?? false));
return Timeline(
topSliverWidget: const SliverToBoxAdapter(child: DriftMemoryLane()),
@@ -0,0 +1,201 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
Future<void> showFeatureMessageDialog(BuildContext context) {
return showGeneralDialog<void>(
context: context,
useRootNavigator: true,
barrierDismissible: true,
barrierLabel: 'whats_new'.tr(),
barrierColor: Colors.black.withValues(alpha: 0.55),
transitionDuration: const Duration(milliseconds: 280),
pageBuilder: (_, __, ___) => const _FeatureMessageDialog(),
transitionBuilder: (_, animation, __, child) {
final curved = CurvedAnimation(parent: animation, curve: Curves.easeOutCubic, reverseCurve: Curves.easeInCubic);
return FadeTransition(
opacity: animation,
child: ScaleTransition(scale: Tween<double>(begin: 0.94, end: 1.0).animate(curved), child: child),
);
},
);
}
class _FeatureMessageDialog extends StatefulWidget {
const _FeatureMessageDialog();
@override
State<_FeatureMessageDialog> createState() => _FeatureMessageDialogState();
}
class _FeatureMessageDialogState extends State<_FeatureMessageDialog> {
final PageController _controller = PageController();
int _index = 0;
bool get _isLast => _index >= featureMessageHighlights.length - 1;
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _advance() {
if (_isLast) {
Navigator.of(context).pop();
return;
}
_controller.nextPage(duration: const Duration(milliseconds: 320), curve: Curves.easeOutCubic);
}
@override
Widget build(BuildContext context) {
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 64),
clipBehavior: Clip.antiAlias,
backgroundColor: context.colorScheme.surface,
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(24))),
child: ConstrainedBox(
constraints: BoxConstraints(maxHeight: context.height * 0.9, maxWidth: 480),
child: Column(
mainAxisSize: MainAxisSize.min,
mainAxisAlignment: MainAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 4),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text('whats_new'.tr(), style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700)),
const SizedBox(height: 2),
Text(
'feature_message_version'.tr(namedArgs: {'version': featureMessageReleaseLabel}),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
],
),
),
Expanded(
child: PageView.builder(
controller: _controller,
itemCount: featureMessageHighlights.length,
onPageChanged: (i) => setState(() => _index = i),
itemBuilder: (_, index) => _FeaturePage(highlight: featureMessageHighlights[index]),
),
),
const SizedBox(height: 8),
_PageDots(controller: _controller, index: _index, count: featureMessageHighlights.length),
Padding(
padding: const EdgeInsets.fromLTRB(20, 16, 20, 16),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: _advance,
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 200),
child: Text(_isLast ? 'ok'.tr() : 'next'.tr(), key: ValueKey(_isLast)),
),
),
),
),
],
),
),
);
}
}
class _FeaturePage extends StatelessWidget {
final FeatureHighlight highlight;
const _FeaturePage({required this.highlight});
@override
Widget build(BuildContext context) {
final scheme = context.colorScheme;
return SingleChildScrollView(
child: Column(
children: [
Padding(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 0),
child: ClipRRect(
borderRadius: BorderRadius.circular(18),
child: ColoredBox(
color: scheme.surfaceContainerHighest,
child: SizedBox(
width: double.infinity,
height: 300,
child: Image.asset(
highlight.image,
fit: BoxFit.contain,
errorBuilder: (context, _, __) =>
Center(child: Icon(Icons.auto_awesome_outlined, color: scheme.onSurfaceVariant, size: 56)),
),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(24, 18, 24, 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
highlight.titleKey.tr(),
style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
),
const SizedBox(height: 8),
Text(
highlight.bodyKey.tr(),
style: context.textTheme.bodyMedium?.copyWith(color: scheme.onSurfaceVariant, height: 1.4),
),
],
),
),
],
),
);
}
}
class _PageDots extends StatelessWidget {
final PageController controller;
final int index;
final int count;
const _PageDots({required this.controller, required this.index, required this.count});
@override
Widget build(BuildContext context) {
final primary = context.primaryColor;
return AnimatedBuilder(
animation: controller,
builder: (context, _) {
final page = controller.hasClients ? (controller.page ?? index.toDouble()) : index.toDouble();
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: List.generate(count, (i) {
final activeness = (1 - (page - i).abs()).clamp(0.0, 1.0);
return AnimatedContainer(
duration: const Duration(milliseconds: 150),
margin: const EdgeInsets.symmetric(horizontal: 3),
height: 7,
width: 7 + 16 * activeness,
decoration: BoxDecoration(
color: Color.lerp(context.colorScheme.surfaceContainerHighest, primary, activeness),
borderRadius: BorderRadius.circular(8),
),
);
}),
);
},
);
}
}
@@ -0,0 +1,101 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:immich_mobile/domain/models/feature_message.model.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/base_bottom_sheet.widget.dart';
Future<void> showFeatureMessageSheet(BuildContext context) {
return showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
useRootNavigator: true,
builder: (_) => const _FeatureMessageSheet(),
);
}
class _FeatureMessageSheet extends StatelessWidget {
const _FeatureMessageSheet();
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
actions: const [],
resizeOnScroll: false,
expand: false,
initialChildSize: 0.6,
minChildSize: 0.4,
maxChildSize: 0.9,
slivers: [
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 4, 24, 16),
child: Text(
'whats_new'.tr(),
style: context.textTheme.headlineSmall?.copyWith(fontWeight: FontWeight.w600),
),
),
),
SliverList.separated(
itemCount: featureMessageHighlights.length,
separatorBuilder: (_, __) => const SizedBox(height: 8),
itemBuilder: (_, index) => _HighlightCard(highlight: featureMessageHighlights[index]),
),
const SliverToBoxAdapter(child: SizedBox(height: 16)),
],
footer: SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 8, 24, 16),
child: SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: () => Navigator.of(context).pop(),
style: FilledButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: 14)),
child: Text('feature_message_got_it'.tr()),
),
),
),
),
);
}
}
class _HighlightCard extends StatelessWidget {
final FeatureHighlight highlight;
const _HighlightCard({required this.highlight});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(16),
child: AspectRatio(
aspectRatio: 16 / 9,
child: Image.asset(
highlight.image,
fit: BoxFit.cover,
errorBuilder: (context, _, __) => ColoredBox(
color: context.colorScheme.surfaceContainerHighest,
child: Icon(Icons.image_outlined, color: context.colorScheme.onSurfaceVariant, size: 48),
),
),
),
),
const SizedBox(height: 12),
Text(highlight.titleKey.tr(), style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600)),
const SizedBox(height: 4),
Text(
highlight.bodyKey.tr(),
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceVariant),
),
],
),
);
}
}
@@ -0,0 +1,9 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/feature_message.service.dart';
import 'package:immich_mobile/providers/infrastructure/settings.provider.dart';
final featureMessageServiceProvider = Provider<FeatureMessageService>(
(ref) => FeatureMessageService(ref.read(settingsProvider)),
);
final featureMessageCheckedProvider = StateProvider<bool>((ref) => false);
+3 -12
View File
@@ -1,16 +1,7 @@
import 'package:immich_mobile/utils/semver.dart';
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
String? getVersionCompatibilityMessage(int _, int appMinor, int _, int serverMinor) {
// Add latest compat info up top
// ensure mobile app major version is not behind server major version
if (appVersion.major < serverVersion.major) {
return 'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
}
// ensure mobile app major version is not ahead of server major version by more than 1 major version
if (appVersion.major > serverVersion.major + 1) {
return 'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
if (serverMinor < 106 && appMinor >= 106) {
return 'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login';
}
return null;
+15 -4
View File
@@ -18,6 +18,7 @@ import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/settings.repository.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/background_sync.provider.dart';
import 'package:immich_mobile/providers/feature_message.provider.dart';
import 'package:immich_mobile/providers/gallery_permission.provider.dart';
import 'package:immich_mobile/providers/oauth.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
@@ -26,7 +27,6 @@ import 'package:immich_mobile/providers/websocket.provider.dart';
import 'package:immich_mobile/repositories/permission.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/provider_utils.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/url_helper.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
import 'package:immich_mobile/widgets/common/immich_logo.dart';
@@ -89,9 +89,18 @@ class LoginForm extends HookConsumerWidget {
checkVersionMismatch() async {
try {
final packageInfo = await PackageInfo.fromPlatform();
final appSemVer = SemVer.fromString(packageInfo.version);
final serverSemVer = serverInfo.serverVersion;
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
final appVersion = packageInfo.version;
final appMajorVersion = int.parse(appVersion.split('.')[0]);
final appMinorVersion = int.parse(appVersion.split('.')[1]);
final serverMajorVersion = serverInfo.serverVersion.major;
final serverMinorVersion = serverInfo.serverVersion.minor;
warningMessage.value = getVersionCompatibilityMessage(
appMajorVersion,
appMinorVersion,
serverMajorVersion,
serverMinorVersion,
);
} catch (error) {
warningMessage.value = 'Error checking version compatibility';
}
@@ -254,6 +263,7 @@ class LoginForm extends HookConsumerWidget {
}
unawaited(handleSyncFlow());
ref.read(websocketProvider.notifier).connect();
unawaited(ref.read(featureMessageServiceProvider).markSeen());
unawaited(context.router.replaceAll([const TabShellRoute()]));
return;
}
@@ -341,6 +351,7 @@ class LoginForm extends HookConsumerWidget {
await getManageMediaPermission();
}
unawaited(handleSyncFlow());
unawaited(ref.read(featureMessageServiceProvider).markSeen());
unawaited(context.router.replaceAll([const TabShellRoute()]));
return;
}
@@ -8,13 +8,15 @@ class SettingsCard extends StatelessWidget {
required this.icon,
required this.title,
required this.subtitle,
required this.settingRoute,
this.settingRoute,
this.onTap,
});
final IconData icon;
final String title;
final String subtitle;
final PageRouteInfo settingRoute;
final PageRouteInfo? settingRoute;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
@@ -38,7 +40,7 @@ class SettingsCard extends StatelessWidget {
),
title: Text(title, style: context.textTheme.titleMedium!.copyWith(color: context.primaryColor)),
subtitle: Text(subtitle, style: context.textTheme.bodyMedium),
onTap: () => context.pushRoute(settingRoute),
onTap: onTap ?? (settingRoute != null ? () => context.pushRoute(settingRoute!) : null),
),
),
);
@@ -31,7 +31,7 @@ class AssetBulkUploadCheckResult {
///
Optional<String?> assetId;
/// Client-side identifier echoed from the request to match results to inputs
/// Asset ID
String id;
/// Whether existing asset is trashed
-3
View File
@@ -1,15 +1,12 @@
export 'src/components/close_button.dart';
export 'src/components/column_button.dart';
export 'src/components/form.dart';
export 'src/components/formatted_text.dart';
export 'src/components/icon_button.dart';
export 'src/components/menu_item.dart';
export 'src/components/password_input.dart';
export 'src/components/text_button.dart';
export 'src/components/text_input.dart';
export 'src/components/url_input.dart';
export 'src/constants.dart';
export 'src/snackbar.dart';
export 'src/theme.dart';
export 'src/translation.dart';
export 'src/types.dart';
@@ -1,13 +0,0 @@
import 'package:flutter/widgets.dart';
class ImmichColorOverride extends InheritedWidget {
const ImmichColorOverride({super.key, required this.color, required super.child});
final Color color;
static Color? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<ImmichColorOverride>()?.color;
@override
bool updateShouldNotify(ImmichColorOverride oldWidget) => color != oldWidget.color;
}
@@ -16,9 +16,10 @@ class ImmichCloseButton extends StatelessWidget {
@override
Widget build(BuildContext context) => ImmichIconButton(
icon: Icons.close,
color: color,
variant: variant,
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
key: key,
icon: Icons.close,
color: color,
variant: variant,
onPressed: onPressed ?? () => Navigator.of(context).pop(),
);
}
@@ -1,78 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichColumnButton extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final bool disabled;
final bool? loading;
const ImmichColumnButton({
super.key,
required this.icon,
required this.label,
required this.onPressed,
this.disabled = false,
this.loading,
});
@override
State<ImmichColumnButton> createState() => _ImmichColumnButtonState();
}
class _ImmichColumnButtonState extends State<ImmichColumnButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final foreground = context.colorOverride ?? Theme.of(context).colorScheme.onSurface;
return TextButton(
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: TextButton.styleFrom(
foregroundColor: foreground,
padding: const .symmetric(horizontal: ImmichSpacing.sm, vertical: ImmichSpacing.md),
tapTargetSize: .shrinkWrap,
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.xl))),
),
child: ConstrainedBox(
constraints: const .new(maxWidth: 90),
child: Column(
mainAxisSize: .min,
children: [
_isLoading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: Icon(widget.icon, size: ImmichIconSize.md),
const SizedBox(height: ImmichSpacing.sm),
Text(
widget.label,
maxLines: 2,
textAlign: .center,
overflow: .ellipsis,
style: const .new(fontSize: ImmichTextSize.label, fontWeight: .w500),
),
],
),
),
);
}
}
@@ -88,7 +88,7 @@ class _ImmichFormState extends State<ImmichForm> {
builder: (context, _) => ImmichTextButton(
labelText: submitText,
icon: widget.submitIcon,
variant: .filled,
variant: ImmichVariant.filled,
loading: _controller.isLoading,
onPressed: _controller.submit,
disabled: _controller.onSubmit == null,
@@ -94,12 +94,12 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
final tag = match.group(1)!.toLowerCase();
final content = match.group(2)!;
final span = widget.spanBuilder?.call(tag);
final style = span?.style ?? _defaultTextStyle(tag);
final formattedSpan = (widget.spanBuilder ?? _defaultSpanBuilder)(tag);
final style = formattedSpan.style ?? _defaultTextStyle(tag);
GestureRecognizer? recognizer;
if (span?.onTap != null) {
recognizer = TapGestureRecognizer()..onTap = span!.onTap;
if (formattedSpan.onTap != null) {
recognizer = TapGestureRecognizer()..onTap = formattedSpan.onTap;
_recognizers.add(recognizer);
}
spans.add(TextSpan(text: content, style: style, recognizer: recognizer));
@@ -114,12 +114,19 @@ class _ImmichFormattedTextState extends State<ImmichFormattedText> {
return spans;
}
FormattedSpan _defaultSpanBuilder(String tag) => switch (tag) {
'b' => const FormattedSpan(style: TextStyle(fontWeight: FontWeight.bold)),
'link' => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ when tag.endsWith('-link') => const FormattedSpan(style: TextStyle(decoration: TextDecoration.underline)),
_ => const FormattedSpan(),
};
TextStyle? _defaultTextStyle(String tag) => switch (tag) {
'b' => const TextStyle(fontWeight: FontWeight.bold),
'link' => const TextStyle(decoration: TextDecoration.underline),
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
_ => null,
};
'b' => const TextStyle(fontWeight: FontWeight.bold),
'link' => const TextStyle(decoration: TextDecoration.underline),
_ when tag.endsWith('-link') => const TextStyle(decoration: TextDecoration.underline),
_ => null,
};
@override
Widget build(BuildContext context) {
@@ -1,80 +1,54 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_ui/src/internal.dart';
import 'package:immich_ui/src/types.dart';
class ImmichIconButton extends StatefulWidget {
class ImmichIconButton extends StatelessWidget {
final IconData icon;
final FutureOr<void> Function() onPressed;
final VoidCallback onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool disabled;
final bool? loading;
const ImmichIconButton({
super.key,
required this.icon,
required this.onPressed,
this.color = .primary,
this.variant = .filled,
this.color = ImmichColor.primary,
this.variant = ImmichVariant.filled,
this.disabled = false,
this.loading,
});
@override
State<ImmichIconButton> createState() => _ImmichIconButtonState();
}
class _ImmichIconButtonState extends State<ImmichIconButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
}
}
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final background = switch (widget.variant) {
.filled => switch (widget.color) {
.primary => colorScheme.primary,
.secondary => colorScheme.secondary,
},
.ghost => Colors.transparent,
final background = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
ImmichVariant.ghost => Colors.transparent,
};
final foreground =
context.colorOverride ??
switch (widget.variant) {
.filled => switch (widget.color) {
.primary => colorScheme.onPrimary,
.secondary => colorScheme.onSecondary,
},
.ghost => switch (widget.color) {
.primary => colorScheme.primary,
.secondary => colorScheme.secondary,
},
};
final foreground = switch (variant) {
ImmichVariant.filled => switch (color) {
ImmichColor.primary => colorScheme.onPrimary,
ImmichColor.secondary => colorScheme.onSecondary,
},
ImmichVariant.ghost => switch (color) {
ImmichColor.primary => colorScheme.primary,
ImmichColor.secondary => colorScheme.secondary,
},
};
final effectiveOnPressed = disabled ? null : onPressed;
return IconButton(
icon: _isLoading
? const SizedBox.square(
dimension: ImmichIconSize.sm,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.md),
)
: Icon(widget.icon),
onPressed: widget.disabled || _isLoading ? null : _onPressed,
style: IconButton.styleFrom(backgroundColor: background, foregroundColor: foreground),
icon: Icon(icon),
onPressed: effectiveOnPressed,
style: IconButton.styleFrom(
backgroundColor: background,
foregroundColor: foreground,
),
);
}
}
@@ -1,100 +0,0 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/internal.dart';
class ImmichMenu extends StatefulWidget {
final List<Widget> children;
final MenuAnchorChildBuilder builder;
final MenuStyle? style;
final bool consumeOutsideTap;
final Widget? child;
const ImmichMenu({
super.key,
required this.children,
required this.builder,
this.style,
this.consumeOutsideTap = false,
this.child,
});
@override
State<ImmichMenu> createState() => _ImmichMenuState();
}
class _ImmichMenuState extends State<ImmichMenu> {
final _controller = MenuController();
@override
Widget build(BuildContext context) {
return _ImmichMenuScope(
controller: _controller,
child: MenuAnchor(
controller: _controller,
style: widget.style,
consumeOutsideTap: widget.consumeOutsideTap,
menuChildren: widget.children,
builder: widget.builder,
child: widget.child,
),
);
}
}
class _ImmichMenuScope extends InheritedWidget {
final MenuController controller;
const _ImmichMenuScope({required this.controller, required super.child});
static MenuController? maybeOf(BuildContext context) =>
context.dependOnInheritedWidgetOfExactType<_ImmichMenuScope>()?.controller;
@override
bool updateShouldNotify(_ImmichMenuScope oldWidget) => controller != oldWidget.controller;
}
class ImmichMenuItem extends StatefulWidget {
final IconData icon;
final String label;
final FutureOr<void> Function() onPressed;
final bool disabled;
const ImmichMenuItem({
super.key,
required this.icon,
required this.label,
required this.onPressed,
this.disabled = false,
});
@override
State<ImmichMenuItem> createState() => _ImmichMenuItemState();
}
class _ImmichMenuItemState extends State<ImmichMenuItem> {
Future<void> _onPressed(MenuController? controller) async {
try {
await widget.onPressed();
} finally {
controller?.close();
}
}
@override
Widget build(BuildContext context) {
final controller = _ImmichMenuScope.maybeOf(context);
return MenuItemButton(
onPressed: widget.disabled ? null : () => _onPressed(controller),
closeOnActivate: controller == null,
style: MenuItemButton.styleFrom(
foregroundColor: context.colorOverride,
alignment: .centerLeft,
padding: const .symmetric(horizontal: ImmichSpacing.lg, vertical: ImmichSpacing.md),
),
leadingIcon: Icon(widget.icon, size: ImmichIconSize.sm),
child: Text(widget.label, style: const .new(fontSize: ImmichTextSize.body)),
);
}
}
@@ -52,6 +52,7 @@ class _ImmichPasswordInputState extends State<ImmichPasswordInput> {
icon: Icon(_visible ? Icons.visibility_off_rounded : Icons.visibility_rounded),
),
autofillHints: [AutofillHints.password],
keyboardType: TextInputType.text,
);
}
}
@@ -1,72 +1,85 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/types.dart';
class ImmichTextButton extends StatefulWidget {
class ImmichTextButton extends StatelessWidget {
final String labelText;
final IconData? icon;
final FutureOr<void> Function() onPressed;
final ImmichVariant variant;
final ImmichColor color;
final bool expanded;
final bool loading;
final bool disabled;
final bool? loading;
const ImmichTextButton({
super.key,
required this.labelText,
this.icon,
required this.onPressed,
this.variant = .filled,
this.variant = ImmichVariant.filled,
this.color = ImmichColor.primary,
this.expanded = true,
this.loading = false,
this.disabled = false,
this.loading,
});
@override
State<ImmichTextButton> createState() => _ImmichTextButtonState();
}
Widget _buildButton(ImmichVariant variant) {
final Widget? effectiveIcon = loading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: icon != null
? Icon(icon, fontWeight: FontWeight.w600)
: null;
final hasIcon = effectiveIcon != null;
class _ImmichTextButtonState extends State<ImmichTextButton> {
bool _loading = false;
bool get _isLoading => widget.loading ?? _loading;
final label = Text(labelText, style: const TextStyle(fontSize: ImmichTextSize.body, fontWeight: FontWeight.bold));
final style = ElevatedButton.styleFrom(padding: const EdgeInsets.symmetric(vertical: ImmichSpacing.md));
Future<void> _onPressed() async {
setState(() => _loading = true);
try {
await widget.onPressed();
} finally {
if (mounted) {
setState(() => _loading = false);
}
final effectiveOnPressed = disabled || loading ? null : onPressed;
switch (variant) {
case ImmichVariant.filled:
if (hasIcon) {
return ElevatedButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return ElevatedButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
case ImmichVariant.ghost:
if (hasIcon) {
return TextButton.icon(
style: style,
onPressed: effectiveOnPressed,
icon: effectiveIcon,
label: label,
);
}
return TextButton(
style: style,
onPressed: effectiveOnPressed,
child: label,
);
}
}
@override
Widget build(BuildContext context) {
final Widget? icon = _isLoading
? const SizedBox.square(
dimension: ImmichIconSize.md,
child: CircularProgressIndicator(strokeWidth: ImmichBorderWidth.lg),
)
: widget.icon != null
? Icon(widget.icon, fontWeight: .w600)
: null;
final label = Text(
widget.labelText,
style: const .new(fontSize: ImmichTextSize.body, fontWeight: .bold),
);
final style = ElevatedButton.styleFrom(padding: const .symmetric(vertical: ImmichSpacing.md));
final onPressed = widget.disabled || _isLoading ? null : _onPressed;
final button = switch (widget.variant) {
ImmichVariant.filled => ElevatedButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
ImmichVariant.ghost => TextButton.icon(style: style, onPressed: onPressed, icon: icon, label: label),
};
if (widget.expanded) {
final button = _buildButton(variant);
if (expanded) {
return SizedBox(width: double.infinity, child: button);
}
return button;
-5
View File
@@ -1,11 +1,6 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/color_override.dart';
import 'package:immich_ui/src/translation.dart';
extension TranslationHelper on BuildContext {
ImmichTranslations get translations => ImmichTranslationProvider.of(this);
}
extension ColorHelper on BuildContext {
Color? get colorOverride => ImmichColorOverride.maybeOf(this);
}
@@ -1,27 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/column_button.dart';
import 'package:immich_ui/src/previews.dart';
void _previewNoop() {}
@ImmichPreview(group: 'ColumnButton', name: 'Default')
Widget previewColumnButtonDefault() => const Wrap(
spacing: 12,
runSpacing: 12,
children: [
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.favorite_border_rounded, label: 'Favorite'),
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.archive_outlined, label: 'Archive'),
ImmichColumnButton(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete'),
],
);
@ImmichPreview(group: 'ColumnButton', name: 'Loading')
Widget previewColumnButtonLoading() => ImmichColumnButton(
onPressed: () => Future<void>.delayed(const .new(seconds: 2)),
icon: Icons.download,
label: 'Download',
);
@ImmichPreview(group: 'ColumnButton', name: 'Disabled')
Widget previewColumnButtonDisabled() =>
const ImmichColumnButton(onPressed: _previewNoop, icon: Icons.ios_share_rounded, label: 'Share', disabled: true);
@@ -1,19 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/components/menu_item.dart';
import 'package:immich_ui/src/previews.dart';
void _previewNoop() {}
@ImmichPreview(group: 'MenuItem', name: 'Default')
Widget previewMenuItemDefault() => const Column(
mainAxisSize: MainAxisSize.min,
children: [
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.info_outline, label: 'Info'),
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.help_outline_rounded, label: 'Troubleshoot'),
ImmichMenuItem(onPressed: _previewNoop, icon: Icons.cast_rounded, label: 'Cast'),
],
);
@ImmichPreview(group: 'MenuItem', name: 'Disabled')
Widget previewMenuItemDisabled() =>
const ImmichMenuItem(onPressed: _previewNoop, icon: Icons.delete_outline_rounded, label: 'Delete', disabled: true);
@@ -1,32 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:immich_ui/src/previews.dart';
import 'package:immich_ui/src/snackbar.dart';
@ImmichPreview(group: 'Snackbar', name: 'Types')
Widget previewSnackbarTypes() => const _SnackbarDemo();
class _SnackbarDemo extends StatelessWidget {
const _SnackbarDemo();
@override
Widget build(BuildContext context) {
return ScaffoldMessenger(
key: scaffoldMessengerKey,
child: Scaffold(
backgroundColor: Colors.transparent,
body: Center(
child: Wrap(
spacing: ImmichSpacing.md,
runSpacing: ImmichSpacing.md,
children: [
ElevatedButton(onPressed: () => snackbar.info('Info message'), child: const Text('Info')),
ElevatedButton(onPressed: () => snackbar.success('Saved'), child: const Text('Success')),
ElevatedButton(onPressed: () => snackbar.error('Something failed'), child: const Text('Error')),
],
),
),
),
);
}
}
@@ -15,6 +15,16 @@ Widget previewTextButtonVariants() => const Wrap(
],
);
@ImmichPreview(group: 'TextButton', name: 'Colors')
Widget previewTextButtonColors() => const Wrap(
spacing: 12,
runSpacing: 12,
children: [
ImmichTextButton(onPressed: _previewNoop, labelText: 'Primary', expanded: false),
ImmichTextButton(onPressed: _previewNoop, labelText: 'Secondary', color: ImmichColor.secondary, expanded: false),
],
);
@ImmichPreview(group: 'TextButton', name: 'With Icons')
Widget previewTextButtonWithIcons() => const Wrap(
spacing: 12,
@@ -32,11 +42,7 @@ Widget previewTextButtonWithIcons() => const Wrap(
);
@ImmichPreview(group: 'TextButton', name: 'Loading')
Widget previewTextButtonLoading() => ImmichTextButton(
onPressed: () => Future<void>.delayed(const Duration(seconds: 2)),
labelText: 'Click me',
expanded: false,
);
Widget previewTextButtonLoading() => const _PreviewLoadingDemo();
@ImmichPreview(group: 'TextButton', name: 'Disabled')
Widget previewTextButtonDisabled() => const Wrap(
@@ -53,3 +59,30 @@ Widget previewTextButtonDisabled() => const Wrap(
),
],
);
class _PreviewLoadingDemo extends StatefulWidget {
const _PreviewLoadingDemo();
@override
State<_PreviewLoadingDemo> createState() => _PreviewLoadingDemoState();
}
class _PreviewLoadingDemoState extends State<_PreviewLoadingDemo> {
bool _isLoading = false;
@override
Widget build(BuildContext context) {
return ImmichTextButton(
onPressed: () async {
setState(() => _isLoading = true);
await Future<void>.delayed(const Duration(seconds: 2));
if (mounted) {
setState(() => _isLoading = false);
}
},
labelText: _isLoading ? 'Loading...' : 'Click Me',
loading: _isLoading,
expanded: false,
);
}
}
-58
View File
@@ -1,58 +0,0 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/immich_ui.dart';
final scaffoldMessengerKey = GlobalKey<ScaffoldMessengerState>();
class SnackbarManager {
const SnackbarManager();
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? show(String message, SnackbarType type) {
final messenger = scaffoldMessengerKey.currentState;
final context = scaffoldMessengerKey.currentContext;
if (messenger == null || context == null) {
return null;
}
messenger.hideCurrentSnackBar();
return messenger.showSnackBar(_build(context, message, type));
}
SnackBar _build(BuildContext context, String message, SnackbarType type) {
final theme = Theme.of(context);
final colors = theme.extension<ImmichColors>() ?? ImmichColors.harmonized(theme.colorScheme);
final (IconData icon, Color background, Color foreground) = switch (type) {
.info => (Icons.info_rounded, colors.info, colors.onInfo),
.success => (Icons.check_circle_rounded, colors.success, colors.onSuccess),
.error => (Icons.warning_rounded, colors.error, colors.onError),
};
return SnackBar(
behavior: .floating,
backgroundColor: background,
duration: const .new(seconds: 4),
shape: const RoundedRectangleBorder(borderRadius: .all(.circular(ImmichRadius.sm))),
content: Row(
children: [
Icon(icon, color: foreground, size: ImmichIconSize.sm),
const SizedBox(width: ImmichSpacing.md),
Expanded(
child: Text(
message,
maxLines: 2,
overflow: .ellipsis,
style: .new(color: foreground, fontWeight: .w600, fontSize: ImmichTextSize.body),
),
),
],
),
);
}
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? info(String message) => show(message, .info);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? success(String message) => show(message, .success);
ScaffoldFeatureController<SnackBar, SnackBarClosedReason>? error(String message) => show(message, .error);
}
const snackbar = SnackbarManager();
+2 -74
View File
@@ -1,8 +1,5 @@
import 'package:flutter/material.dart';
import 'package:immich_ui/src/constants.dart';
import 'package:material_color_utilities/blend/blend.dart';
import 'package:material_color_utilities/hct/hct.dart';
import 'package:material_color_utilities/palettes/tonal_palette.dart';
class ImmichThemeProvider extends StatelessWidget {
final ColorScheme colorScheme;
@@ -14,7 +11,6 @@ class ImmichThemeProvider extends StatelessWidget {
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
extensions: [ImmichColors.harmonized(colorScheme)],
colorScheme: colorScheme,
brightness: colorScheme.brightness,
inputDecorationTheme: InputDecorationTheme(
@@ -23,8 +19,8 @@ class ImmichThemeProvider extends StatelessWidget {
final color = states.contains(WidgetState.error)
? colorScheme.error
: states.contains(WidgetState.focused)
? colorScheme.primary
: colorScheme.outline;
? colorScheme.primary
: colorScheme.outline;
return OutlineInputBorder(
borderSide: BorderSide(color: color),
borderRadius: const BorderRadius.all(Radius.circular(ImmichRadius.md)),
@@ -42,71 +38,3 @@ class ImmichThemeProvider extends StatelessWidget {
);
}
}
class ImmichColors extends ThemeExtension<ImmichColors> {
final Color info;
final Color onInfo;
final Color success;
final Color onSuccess;
final Color error;
final Color onError;
const ImmichColors({
required this.info,
required this.onInfo,
required this.success,
required this.onSuccess,
required this.error,
required this.onError,
});
factory ImmichColors.harmonized(ColorScheme scheme) {
final (info, onInfo) = scheme.harmonized(const Color(0xFF1984E9));
final (success, onSuccess) = scheme.harmonized(const Color(0xFF10C14D));
final (error, onError) = scheme.harmonized(const Color(0xFFFA2921));
return ImmichColors(
info: info,
onInfo: onInfo,
success: success,
onSuccess: onSuccess,
error: error,
onError: onError,
);
}
@override
ImmichColors copyWith({Color? info, Color? onInfo, Color? success, Color? onSuccess, Color? error, Color? onError}) {
return ImmichColors(
info: info ?? this.info,
onInfo: onInfo ?? this.onInfo,
success: success ?? this.success,
onSuccess: onSuccess ?? this.onSuccess,
error: error ?? this.error,
onError: onError ?? this.onError,
);
}
@override
ImmichColors lerp(ImmichColors? other, double t) {
if (other == null) {
return this;
}
return ImmichColors(
info: Color.lerp(info, other.info, t)!,
onInfo: Color.lerp(onInfo, other.onInfo, t)!,
success: Color.lerp(success, other.success, t)!,
onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!,
error: Color.lerp(error, other.error, t)!,
onError: Color.lerp(onError, other.onError, t)!,
);
}
}
extension on ColorScheme {
(Color container, Color onContainer) harmonized(Color seed) {
final hct = Hct.fromInt(Blend.harmonize(seed.toARGB32(), primary.toARGB32()));
final tones = TonalPalette.of(hct.hue, hct.chroma);
final isDark = brightness == Brightness.dark;
return (Color(tones.get(isDark ? 30 : 90)), Color(tones.get(isDark ? 90 : 10)));
}
}
+8 -4
View File
@@ -1,5 +1,9 @@
enum ImmichVariant { filled, ghost }
enum ImmichVariant {
filled,
ghost,
}
enum ImmichColor { primary, secondary }
enum SnackbarType { info, success, error }
enum ImmichColor {
primary,
secondary,
}
+1 -1
View File
@@ -92,7 +92,7 @@ packages:
source: hosted
version: "0.12.19"
material_color_utilities:
dependency: "direct main"
dependency: transitive
description:
name: material_color_utilities
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
-1
View File
@@ -7,7 +7,6 @@ environment:
dependencies:
flutter:
sdk: flutter
material_color_utilities: any
dev_dependencies:
flutter_test:
@@ -1,53 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/color_override.dart';
import 'package:immich_ui/src/components/icon_button.dart';
import 'test_utils.dart';
void main() {
group('ImmichColorOverride', () {
testWidgets('exposes the override color to descendants', (tester) async {
Color? captured;
await tester.pumpTestWidget(
ImmichColorOverride(
color: Colors.green,
child: Builder(
builder: (context) {
captured = ImmichColorOverride.maybeOf(context);
return const SizedBox.shrink();
},
),
),
);
expect(captured, Colors.green);
});
testWidgets('maybeOf returns null when there is no override', (tester) async {
Color? captured = Colors.black;
await tester.pumpTestWidget(
Builder(
builder: (context) {
captured = ImmichColorOverride.maybeOf(context);
return const SizedBox.shrink();
},
),
);
expect(captured, isNull);
});
testWidgets('a descendant component adopts the override as its foreground', (tester) async {
await tester.pumpTestWidget(
ImmichColorOverride(
color: Colors.green,
child: ImmichIconButton(icon: Icons.add, onPressed: () {}),
),
);
final button = tester.widget<IconButton>(find.byType(IconButton));
expect(button.style?.foregroundColor?.resolve(<WidgetState>{}), Colors.green);
});
});
}
@@ -1,35 +0,0 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/snackbar.dart';
import 'test_utils.dart';
void main() {
group('SnackbarManager', () {
testWidgets('shows the message', (tester) async {
await tester.pumpTestWidget(const SizedBox());
snackbar.success('hello');
await tester.pump();
expect(find.text('hello'), findsOneWidget);
expect(find.byType(SnackBar), findsOneWidget);
});
testWidgets('replaces the current snackbar', (tester) async {
await tester.pumpTestWidget(const SizedBox());
snackbar.info('first');
await tester.pump();
snackbar.error('second');
await tester.pump();
expect(find.text('first'), findsNothing);
expect(find.text('second'), findsOneWidget);
});
testWidgets('no-ops when the messenger is unmounted', (tester) async {
expect(snackbar.show('x', .info), isNull);
});
});
}
+2 -7
View File
@@ -1,14 +1,9 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_ui/src/snackbar.dart';
extension WidgetTesterExtension on WidgetTester {
/// Pumps a widget wrapped in MaterialApp and Scaffold for testing.
Future<void> pumpTestWidget(Widget widget) {
return pumpWidget(
MaterialApp(
scaffoldMessengerKey: scaffoldMessengerKey,
home: Scaffold(body: widget),
),
);
return pumpWidget(MaterialApp(home: Scaffold(body: widget)));
}
}
+34 -34
View File
@@ -69,10 +69,10 @@ packages:
dependency: "direct main"
description:
name: background_downloader
sha256: aceacec2b2a72ec3a8862ab5895fcbbc71ab33765f3619d57963f3110dd268e3
sha256: "4cb23d9ad4f5060944f38164e7b90d4bf99b57b2472a3bd4676e59b2db4afd06"
url: "https://pub.dev"
source: hosted
version: "9.5.5"
version: "9.5.4"
bonsoir:
dependency: "direct overridden"
description:
@@ -229,10 +229,10 @@ packages:
dependency: transitive
description:
name: code_assets
sha256: bf394f466ba9205f1812a0433b392d6af280f155f56651eda7c18cc32ed493b8
sha256: "83ccdaa064c980b5596c35dd64a8d3ecc68620174ab9b90b6343b753aa721687"
url: "https://pub.dev"
source: hosted
version: "1.2.1"
version: "1.0.0"
code_builder:
dependency: transitive
description:
@@ -326,18 +326,18 @@ packages:
dependency: transitive
description:
name: dbus
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.13"
version: "0.7.12"
desktop_webview_window:
dependency: transitive
description:
name: desktop_webview_window
sha256: b6fdae2cbf9571879b1761c12f27facaf82e22d0bdc74d049907c2a09a432957
sha256: "57cf20d81689d5cbb1adfd0017e96b669398a669d927906073b0e42fc64111c0"
url: "https://pub.dev"
source: hosted
version: "0.3.0"
version: "0.2.3"
device_info_plus:
dependency: "direct main"
description:
@@ -549,18 +549,18 @@ packages:
dependency: "direct dev"
description:
name: flutter_native_splash
sha256: "9db4b80b044e9af17cc4b1272137fc7ace0054d879ef8210a76adc34aaf4cdff"
sha256: "4fb9f4113350d3a80841ce05ebf1976a36de622af7d19aca0ca9a9911c7ff002"
url: "https://pub.dev"
source: hosted
version: "2.4.8"
version: "2.4.7"
flutter_plugin_android_lifecycle:
dependency: transitive
description:
name: flutter_plugin_android_lifecycle
sha256: "3854fe5e3bff0b113c658f260b90c95dea17c92db0f2addeac2e343dd9969785"
sha256: "38d1c268de9097ff59cf0e844ac38759fc78f76836d37edad06fa21e182055a0"
url: "https://pub.dev"
source: hosted
version: "2.0.35"
version: "2.0.34"
flutter_riverpod:
dependency: transitive
description:
@@ -642,10 +642,10 @@ packages:
dependency: "direct main"
description:
name: flutter_web_auth_2
sha256: "8f9303471dcd96670878c9b7c0c4e14c37595b2add67465f6a868f17a5872dfc"
sha256: d354998934ddc338e69b999b2abaeb33c6fd09999d3a5f92ead1a6b49b49712e
url: "https://pub.dev"
source: hosted
version: "5.0.3"
version: "5.0.2"
flutter_web_auth_2_platform_interface:
dependency: transitive
description:
@@ -780,10 +780,10 @@ packages:
dependency: transitive
description:
name: hooks
sha256: "9a62a50b50b769a737bc0a8ff381f333529df3ab746b2f6b02e83760231455ba"
sha256: "025f060e86d2d4c3c47b56e33caf7f93bf9283340f26d23424ebcfccf34f621e"
url: "https://pub.dev"
source: hosted
version: "2.0.2"
version: "1.0.3"
hooks_riverpod:
dependency: "direct main"
description:
@@ -844,10 +844,10 @@ packages:
dependency: transitive
description:
name: image
sha256: "6300175e00616bbc832e2fc91bfa4d776af5402c81c7151bee6905bb08473c52"
sha256: f9881ff4998044947ec38d098bc7c8316ae1186fa786eddffdb867b9bc94dfce
url: "https://pub.dev"
source: hosted
version: "4.9.1"
version: "4.8.0"
image_picker:
dependency: "direct main"
description:
@@ -860,10 +860,10 @@ packages:
dependency: transitive
description:
name: image_picker_android
sha256: "6f3a1995eafb000333174fae92202622033b0ee7fd917a6cd3730295264df84a"
sha256: d5b3e1774af29c9ab00103afb0d4614070f924d2e0057ac867ec98800114793f
url: "https://pub.dev"
source: hosted
version: "0.8.13+19"
version: "0.8.13+17"
image_picker_for_web:
dependency: transitive
description:
@@ -1120,10 +1120,10 @@ packages:
dependency: transitive
description:
name: native_toolchain_c
sha256: f59351d28f49520cd3a74eb1f41c5f19ae15e53c65a3231d14af672e46510a96
sha256: "6ba77bb18063eebe9de401f5e6437e95e1438af0a87a3a39084fbd37c90df572"
url: "https://pub.dev"
source: hosted
version: "0.19.1"
version: "0.17.6"
native_video_player:
dependency: "direct main"
description:
@@ -1161,10 +1161,10 @@ packages:
dependency: transitive
description:
name: objective_c
sha256: "6cb691c686fa2838c6deb34980d426145c2a5d537491cb83d463c33cdbc726ed"
sha256: "100a1c87616ab6ed41ec263b083c0ef3261ee6cd1dc3b0f35f8ddfa4f996fe52"
url: "https://pub.dev"
source: hosted
version: "9.4.1"
version: "9.3.0"
octo_image:
dependency: "direct main"
description:
@@ -1297,10 +1297,10 @@ packages:
dependency: transitive
description:
name: permission_handler_apple
sha256: e20daf680eef1ca62ffe8c8c526b778cc386d50137c77ac71c8ec9c88c13fb9d
sha256: f000131e755c54cf4d84a5d8bd6e4149e262cc31c5a8b1d698de1ac85fa41023
url: "https://pub.dev"
source: hosted
version: "9.4.9"
version: "9.4.7"
permission_handler_html:
dependency: transitive
description:
@@ -1529,10 +1529,10 @@ packages:
dependency: transitive
description:
name: shared_preferences_android
sha256: a2c49fc1fed7140cadd892d765bd47edbe4ac0b9c7e7e3c493dcb58126f99cf0
sha256: e8d4762b1e2e8578fc4d0fd548cebf24afd24f49719c08974df92834565e2c53
url: "https://pub.dev"
source: hosted
version: "2.4.25"
version: "2.4.23"
shared_preferences_foundation:
dependency: transitive
description:
@@ -1791,10 +1791,10 @@ packages:
dependency: transitive
description:
name: url_launcher_android
sha256: b413d49b73867ac08dd2f9890efd3cc11f2a0e577618d50843440a1fb3776c32
sha256: "17bc677f0b301615530dd1d67e0a9828cafa2d0b6b6eae4cd3679b7eac4a273c"
url: "https://pub.dev"
source: hosted
version: "6.3.32"
version: "6.3.30"
url_launcher_ios:
dependency: transitive
description:
@@ -1871,10 +1871,10 @@ packages:
dependency: transitive
description:
name: vector_graphics_compiler
sha256: "7ee12e6dffe0fc8e755179d6d91b3b34f5924223fc104d85572ef9180d73d172"
sha256: b9b3f391857781aa96acacef96066f2f49b4cd03cf9fce3ca4d8da2ef5ea129e
url: "https://pub.dev"
source: hosted
version: "1.2.5"
version: "1.2.3"
vector_math:
dependency: transitive
description:
@@ -1991,10 +1991,10 @@ packages:
dependency: transitive
description:
name: xml
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
sha256: "971043b3a0d3da28727e40ed3e0b5d18b742fa5a68665cca88e74b7876d5e025"
url: "https://pub.dev"
source: hosted
version: "7.0.1"
version: "6.6.1"
xxh3:
dependency: transitive
description:
+1
View File
@@ -119,6 +119,7 @@ flutter:
uses-material-design: true
assets:
- assets/
- assets/feature_message/
fonts:
- family: GoogleSans
fonts:
@@ -1,47 +1,29 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/semver.dart';
import 'package:immich_mobile/utils/version_compatibility.dart';
void main() {
group('app major version behind server', () {
const message =
'Your mobile app version is not compatible with the server! Please update your mobile app to the latest version.';
test('getVersionCompatibilityMessage', () {
String? result;
test('returns message when app major is behind server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 1, minor: 200, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 106, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
test('returns null when app major matches server major', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 2, minor: 0, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
});
result = getVersionCompatibilityMessage(1, 107, 1, 105);
expect(
result,
'Your app minor version is not compatible with the server! Please update your server to version v1.106.0 or newer to login',
);
group('app major version too far ahead of server', () {
const message =
'Your server version is not compatible with the mobile app! Please update your server to the latest version.';
result = getVersionCompatibilityMessage(1, 106, 1, 106);
expect(result, null);
test('returns message when app major is more than one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 3, minor: 0, patch: 0),
);
expect(result, message);
});
result = getVersionCompatibilityMessage(1, 107, 1, 106);
expect(result, null);
test('returns null when app major is exactly one ahead of server', () {
final result = getVersionCompatibilityMessage(
const SemVer(major: 1, minor: 200, patch: 0),
const SemVer(major: 2, minor: 0, patch: 0),
);
expect(result, null);
});
result = getVersionCompatibilityMessage(1, 107, 1, 108);
expect(result, null);
});
}
+3 -3
View File
@@ -17012,12 +17012,12 @@
},
"assetId": {
"description": "Existing asset ID if duplicate",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"id": {
"description": "Client-side identifier echoed from the request to match results to inputs",
"description": "Asset ID",
"format": "uuid",
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
"type": "string"
},
"isTrashed": {
+3 -11
View File
@@ -278,9 +278,7 @@
"title": "Album IDs",
"array": true,
"description": "Target album IDs",
"uiHint": {
"type": "AlbumId"
}
"uiHint": "AlbumId"
},
"albumName": {
"type": "string",
@@ -370,20 +368,14 @@
"type": "string",
"title": "Album ID",
"description": "Target album ID",
"uiHint": {
"type": "AlbumId",
"order": 1
}
"uiHint": "AlbumId"
},
"albumIds": {
"type": "string",
"title": "Album IDs",
"description": "Target album IDs",
"array": true,
"uiHint": {
"type": "AlbumId",
"order": 2
}
"uiHint": "AlbumId"
}
}
}
+1 -1
View File
@@ -5,7 +5,7 @@
"main": "src/index.ts",
"scripts": {
"build": "pnpm build:tsc && pnpm build:wasm",
"build:tsc": "mkdir -p dist && echo \"type Manifest = $(cat manifest.json); \nexport default Manifest;\" > dist/manifest.d.ts && tsc --noEmit && node esbuild.js",
"build:tsc": "tsc --noEmit && node esbuild.js",
"build:wasm": "extism-js dist/index.js -i src/index.d.ts -o dist/plugin.wasm"
},
"keywords": [],
+21 -21
View File
@@ -1,11 +1,5 @@
import { getWrapper } from '@immich/plugin-sdk';
import { AssetVisibility } from '@immich/sdk';
import type manifestType from '../dist/manifest';
const wrapper = getWrapper<manifestType>();
type Foo = (manifestType['methods'][number] & {
name: 'assetMissingTimeZoneFilter';
})['schema']['properties']['inverse']['type'];
import { wrapper } from '@immich/plugin-sdk';
import { AssetTypeEnum, AssetVisibility, WorkflowType } from '@immich/sdk';
type AssetFileFilterConfig = {
pattern: string;
@@ -13,7 +7,7 @@ type AssetFileFilterConfig = {
caseSensitive?: boolean;
};
export const assetFileFilter = () => {
return wrapper<'assetFileFilter'>(({ data, config }) => {
return wrapper<WorkflowType.AssetV1, AssetFileFilterConfig>(({ data, config }) => {
const { pattern, matchType = 'contains', caseSensitive = false } = config;
const { asset } = data;
@@ -49,7 +43,7 @@ export const assetFileFilter = () => {
};
export const assetMissingTimeZoneFilter = () => {
return wrapper<'assetMissingTimeZoneFilter'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const hasTimeZone = !!data.asset?.exifInfo?.timeZone;
const needsTimeZone = config.inverse ? true : false;
return { workflow: { continue: hasTimeZone === needsTimeZone } };
@@ -57,7 +51,13 @@ export const assetMissingTimeZoneFilter = () => {
};
export const assetLocationFilter = () => {
return wrapper<'assetLocationFilter'>(({ config, data }) => {
return wrapper<
WorkflowType.AssetV1,
{
region?: { country?: string; state?: string; city?: string };
coordinate?: { latitude?: string; longitude?: string; radius?: number };
}
>(({ config, data }) => {
if (
(config.region?.country && config.region.country !== data.asset.exifInfo?.country) ||
(config.region?.state && config.region.state !== data.asset.exifInfo?.state) ||
@@ -96,13 +96,13 @@ export const assetLocationFilter = () => {
};
export const assetTypeFilter = () => {
return wrapper<'assetTypeFilter'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { allowedTypes: AssetTypeEnum[] }>(({ config, data }) => {
return { workflow: { continue: config.allowedTypes.includes(data.asset.type) } };
});
};
export const assetFavorite = () => {
return wrapper<'assetFavorite'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
const target = config.inverse ? false : true;
if (target !== data.asset.isFavorite) {
return {
@@ -115,13 +115,13 @@ export const assetFavorite = () => {
};
export const assetVisibility = () => {
return wrapper<'assetVisibility'>(({ config }) => ({
return wrapper<WorkflowType.AssetV1, { visibility: AssetVisibility }>(({ config }) => ({
changes: { asset: { visibility: config.visibility } },
}));
};
export const assetArchive = () => {
return wrapper<'assetArchive'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Archive) {
return { changes: { asset: { visibility: AssetVisibility.Archive } } };
}
@@ -135,7 +135,7 @@ export const assetArchive = () => {
};
export const assetLock = () => {
return wrapper<'assetLock'>(({ config, data }) => {
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(({ config, data }) => {
if (!config.inverse && data.asset.visibility !== AssetVisibility.Locked) {
return { changes: { asset: { visibility: AssetVisibility.Locked } } };
}
@@ -148,13 +148,13 @@ export const assetLock = () => {
});
};
// export const assetTrash = () => {
// // TODO use trash/untrash host functions
// return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
// };
export const assetTrash = () => {
// TODO use trash/untrash host functions
return wrapper<WorkflowType.AssetV1, { inverse?: boolean }>(() => ({}));
};
export const assetAddToAlbums = () => {
return wrapper<'assetAddToAlbums'>(({ config, data, functions }) => {
return wrapper<WorkflowType.AssetV1, { albumIds: string[]; albumName?: string }>(({ config, data, functions }) => {
const assetId = data.asset.id;
if (config.albumIds.length === 0) {
+1 -1
View File
@@ -31,7 +31,7 @@
"@types/node": "^24.13.2",
"esbuild": "^0.28.0",
"tsc-alias": "^1.8.16",
"typescript": "^6.0.0"
"typescript": "^5.9.3"
},
"peerDependencies": {
"@extism/js-pdk": "^1.1.1"
+39 -81
View File
@@ -1,95 +1,53 @@
import type { WorkflowType } from '@immich/sdk';
import { hostFunctions } from 'src/host-functions.js';
import type {
ConfigValue,
WorkflowEventPayload,
WorkflowResponse,
WorkflowStepConfig,
} from 'src/types.js';
type Property = {
type: 'string' | 'boolean' | 'number' | 'object';
array?: boolean;
enum?: string[];
};
type RequiredProperties<
Properties extends { [K: string]: unknown },
Required extends string[] | undefined,
RequiredKeys extends string = Required extends undefined
? never
: NonNullable<Required>[number],
> = {
properties: Pick<Properties, RequiredKeys> &
Partial<Omit<Properties, RequiredKeys>>;
};
export const wrapper = <
T extends WorkflowType = WorkflowType,
TConfig extends ConfigValue = ConfigValue,
>(
fn: (
payload: WorkflowEventPayload<T, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<T> | undefined,
) => {
const input = Host.inputString();
type GetConfigType<T extends Property> = 'enum' extends keyof T
? NonNullable<T['enum']>[number]
: T['type'] extends 'boolean'
? boolean
: T['type'] extends 'number'
? number
: T['type'] extends 'string'
? string
: object;
try {
const payload = JSON.parse(input) as WorkflowEventPayload<T, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
type ConfigValue<
T extends { properties: { [K: string]: Property }; required?: string[] },
Properties extends { [K: string]: Property } = T['properties'],
> = T extends never
? never
: RequiredProperties<
{
[K in keyof Properties]: Properties[K]['array'] extends true
? Array<GetConfigType<Properties[K]>>
: GetConfigType<Properties[K]>;
},
'required' extends keyof T ? T['required'] : undefined
>['properties'];
const eventConfigBefore = JSON.stringify(event.config);
export const getWrapper =
<T extends Record<string, any>>() =>
<
K extends T['methods'][number]['name'],
L extends WorkflowType = (T['methods'][number] & { name: K })['types'][0],
TConfig = ConfigValue<(T['methods'][number] & { name: K })['schema']>,
>(
fn: (
payload: WorkflowEventPayload<L, TConfig> & {
functions: ReturnType<typeof hostFunctions>;
},
) => WorkflowResponse<L> | undefined,
) => {
const input = Host.inputString();
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
try {
const payload = JSON.parse(input) as WorkflowEventPayload<K, TConfig>;
const event = {
...payload,
functions: hostFunctions(payload.workflow.authToken),
};
const response = fn(event) ?? {};
const eventConfigBefore = JSON.stringify(event.config);
console.debug(
`Inputs: trigger=${event.trigger}, event=${event.type}, config=${eventConfigBefore}`,
);
const response = fn(event) ?? {};
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
// if config changed, notify host
const eventConfigAfter = JSON.stringify(event.config);
if (!response.config && eventConfigBefore !== eventConfigAfter) {
response.config = event.config as WorkflowStepConfig;
}
};
console.debug(
`Outputs: workflow=${JSON.stringify(response.workflow)}, changes=${JSON.stringify(response.changes)}, data=${JSON.stringify(response.data)}, config=${JSON.stringify(response.config)}`,
);
const output = JSON.stringify(response);
Host.outputString(output);
} catch (error: Error | any) {
console.error(`Unhandled plugin exception: ${error.message || error}`);
throw error;
}
};
+5 -6
View File
@@ -11,7 +11,7 @@ type DeepPartial<T> = T extends Date
export type WorkflowEventMap = {
[WorkflowType.AssetV1]: AssetV1;
// [WorkflowType.AssetPersonV1]: AssetPersonV1;
} & { [K in WorkflowType]: unknown };
};
export type WorkflowEventData<T extends WorkflowType> = WorkflowEventMap[T];
@@ -22,7 +22,7 @@ export enum WorkflowTrigger {
}
export type WorkflowEventPayload<
T extends WorkflowType,
T extends WorkflowType = WorkflowType,
TConfig = WorkflowStepConfig,
> = {
trigger: WorkflowTrigger;
@@ -37,11 +37,10 @@ export type WorkflowEventPayload<
};
};
export type WorkflowChanges<T extends WorkflowType> = DeepPartial<
WorkflowEventData<T>
>;
export type WorkflowChanges<T extends WorkflowType = WorkflowType> =
DeepPartial<WorkflowEventData<T>>;
export type WorkflowResponse<T extends WorkflowType> = {
export type WorkflowResponse<T extends WorkflowType = WorkflowType> = {
workflow?: {
/** stop the workflow */
continue?: boolean;
-1
View File
@@ -20,7 +20,6 @@
"sourceMap": false,
"strict": true,
"target": "esnext",
"typeRoots": ["./node_modules/@types", "./node_modules"],
"types": ["node", "@extism/js-pdk"],
"verbatimModuleSyntax": true
}
+1 -1
View File
@@ -707,7 +707,7 @@ export type AssetBulkUploadCheckResult = {
action: AssetUploadAction;
/** Existing asset ID if duplicate */
assetId?: string;
/** Client-side identifier echoed from the request to match results to inputs */
/** Asset ID */
id: string;
/** Whether existing asset is trashed */
isTrashed?: boolean;
+2 -2
View File
@@ -353,8 +353,8 @@ importers:
specifier: ^1.8.16
version: 1.8.17
typescript:
specifier: ^6.0.0
version: 6.0.3
specifier: ^5.9.3
version: 5.9.3
packages/sdk:
dependencies:
+2 -2
View File
@@ -34,10 +34,10 @@ const AssetRejectReasonSchema = z
const AssetBulkUploadCheckResultSchema = z
.object({
id: z.string().describe('Client-side identifier echoed from the request to match results to inputs'),
id: z.uuidv4().describe('Asset ID'),
action: AssetUploadActionSchema,
reason: AssetRejectReasonSchema.optional(),
assetId: z.uuidv4().optional().describe('Existing asset ID if duplicate'),
assetId: z.string().optional().describe('Existing asset ID if duplicate'),
isTrashed: z.boolean().optional().describe('Whether existing asset is trashed'),
})
.meta({ id: 'AssetBulkUploadCheckResult' });
+1 -6
View File
@@ -14,12 +14,7 @@ const JsonSchemaPropertySchema = z
enum: z.array(z.string()).optional().describe('Valid choices for enum types'),
array: z.boolean().optional().describe('Type is an array type'),
required: z.array(z.string()).optional().describe('A list of required properties'),
uiHint: z
.object({
type: z.string().optional(),
order: z.int().optional(),
})
.optional(),
uiHint: z.string().optional(),
get properties() {
return z.record(z.string(), JsonSchemaPropertySchema).optional();
},
+7 -7
View File
@@ -88,7 +88,7 @@ from
where
"album_asset"."updateId" < $3
and "album_asset"."updateId" <= $4
and "album_asset"."updateId" > $5
and "album_asset"."updateId" >= $5
and "album_asset"."albumId" = $6
order by
"album_asset"."updateId" asc
@@ -202,7 +202,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" > $3
and "album_asset"."updateId" >= $3
and "album_asset"."albumId" = $4
order by
"album_asset"."updateId" asc
@@ -297,7 +297,7 @@ from
where
"album_asset"."updateId" < $1
and "album_asset"."updateId" <= $2
and "album_asset"."updateId" > $3
and "album_asset"."updateId" >= $3
and "album_asset"."albumId" = $4
order by
"album_asset"."updateId" asc
@@ -349,7 +349,7 @@ from
where
"album_user"."updateId" < $1
and "album_user"."updateId" <= $2
and "album_user"."updateId" > $3
and "album_user"."updateId" >= $3
and "albumId" = $4
order by
"album_user"."updateId" asc
@@ -810,7 +810,7 @@ from
where
"asset"."updateId" < $2
and "asset"."updateId" <= $3
and "asset"."updateId" > $4
and "asset"."updateId" >= $4
and "ownerId" = $5
order by
"asset"."updateId" asc
@@ -908,7 +908,7 @@ from
where
"asset_exif"."updateId" < $1
and "asset_exif"."updateId" <= $2
and "asset_exif"."updateId" > $3
and "asset_exif"."updateId" >= $3
and "asset"."ownerId" = $4
order by
"asset_exif"."updateId" asc
@@ -997,7 +997,7 @@ from
where
"stack"."updateId" < $1
and "stack"."updateId" <= $2
and "stack"."updateId" > $3
and "stack"."updateId" >= $3
and "ownerId" = $4
order by
"stack"."updateId" asc
+1 -1
View File
@@ -106,7 +106,7 @@ export class BaseSync {
.selectFrom(table(t).as(t))
.where(updateIdRef, '<', nowId)
.where(updateIdRef, '<=', beforeUpdateId)
.$if(!!afterUpdateId, (qb) => qb.where(updateIdRef, '>', afterUpdateId!))
.$if(!!afterUpdateId, (qb) => qb.where(updateIdRef, '>=', afterUpdateId!))
.orderBy(updateIdRef, 'asc');
}
@@ -369,7 +369,7 @@ export class WorkflowExecutionService extends BaseService {
const readResult = await read(type);
let data = readResult.data;
for (const step of workflow.steps) {
const payload: WorkflowEventPayload<typeof type> = {
const payload: WorkflowEventPayload = {
trigger: workflow.trigger,
type,
config: step.config ?? {},
@@ -155,57 +155,6 @@ describe(SyncRequestType.AlbumToAssetsV1, () => {
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
it('should not resend an already-acked item when backfill resumes', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
// backfill needs assets with an older updateId
const { asset: sharedAsset1 } = await ctx.newAsset({ ownerId: user2.id });
const { asset: sharedAsset2 } = await ctx.newAsset({ ownerId: user2.id });
await wait(2);
const { album: sharedAlbum } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: sharedAlbum.id, assetId: sharedAsset1.id });
await ctx.newAlbumAsset({ albumId: sharedAlbum.id, assetId: sharedAsset2.id });
await wait(2);
// backfill needs an initial ack, otherwise it syncs everything
const { asset: ownedAsset } = await ctx.newAsset({ ownerId: auth.user.id });
const { album: ownedAlbum } = await ctx.newAlbum({ ownerId: auth.user.id });
await ctx.newAlbumAsset({ albumId: ownedAlbum.id, assetId: ownedAsset.id });
const setupResponse = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
await ctx.syncAckAll(auth, setupResponse);
// share album to trigger backfill
await ctx.newAlbumUser({ albumId: sharedAlbum.id, userId: auth.user.id, role: AlbumUserRole.Editor });
const response1 = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response1).toEqual([
// receive both
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset1.id } }),
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset2.id } }),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack 1st
await ctx.sut.setAcks(auth, { acks: [response1[0].ack] });
const response2 = await ctx.syncStream(auth, [SyncRequestType.AlbumToAssetsV1]);
expect(response2).toEqual([
// receive 2nd
expect.objectContaining({ data: { albumId: sharedAlbum.id, assetId: sharedAsset2.id } }),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response2);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.AlbumToAssetsV1]);
});
it('should detect and sync a deleted album to asset relation', async () => {
const { auth, ctx } = await setup();
const albumRepo = ctx.get(AlbumRepository);
@@ -279,68 +279,6 @@ describe(SyncRequestType.PartnerAssetsV2, () => {
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
});
it('should not resend an already-acked item when backfill resumes', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
const { user: user3 } = await ctx.newUser();
// backfill needs assets with an older updateId
const { asset: partnerAsset1 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
const { asset: partnerAsset2 } = await ctx.newAsset({ ownerId: user3.id });
await wait(2);
// backfill needs an initial ack, otherwise it syncs everything
const { asset: initialAsset } = await ctx.newAsset({ ownerId: user2.id });
await ctx.newPartner({ sharedById: user2.id, sharedWithId: auth.user.id });
const setupResponse = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(setupResponse).toEqual([
expect.objectContaining({
data: expect.objectContaining({ id: initialAsset.id }),
type: SyncEntityType.PartnerAssetV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, setupResponse);
// partner share to trigger backfill
await ctx.newPartner({ sharedById: user3.id, sharedWithId: auth.user.id });
const response1 = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(response1).toEqual([
// receive both
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset1.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset2.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
// ack 1st
await ctx.sut.setAcks(auth, { acks: [response1[0].ack] });
const response2 = await ctx.syncStream(auth, [SyncRequestType.PartnerAssetsV2]);
expect(response2).toEqual([
// receive 2nd
expect.objectContaining({
data: expect.objectContaining({ id: partnerAsset2.id }),
type: SyncEntityType.PartnerAssetBackfillV2,
}),
expect.objectContaining({ type: SyncEntityType.SyncAckV1 }),
expect.objectContaining({ type: SyncEntityType.SyncCompleteV1 }),
]);
await ctx.syncAckAll(auth, response2);
await ctx.assertSyncIsComplete(auth, [SyncRequestType.PartnerAssetsV2]);
});
it('should hide isFavorite for partner assets', async () => {
const { auth, ctx } = await setup();
const { user: user2 } = await ctx.newUser();
+1 -3
View File
@@ -159,9 +159,7 @@
}
.text-white-shadow {
text-shadow:
0 0 4px rgba(0, 0, 0, 0.9),
0 1px 3px rgba(0, 0, 0, 0.8);
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
}
.icon-white-drop-shadow {
@@ -51,8 +51,6 @@
};
const setUiHintValue = (values: string[]) => setValue(schema.array ? values : values[0]);
const getSchemaProperties = (schema: JSONSchemaProperty) =>
Object.entries(schema.properties ?? {}).sort((a, b) => (a[1].uiHint?.order ?? 0) - (b[1].uiHint?.order ?? 0));
const getBoolean = (defaultValue = false) => getValue<boolean>(defaultValue);
const getString = () => getValue<string>();
@@ -74,11 +72,11 @@
</div>
{/if}
<div class="flex flex-col gap-4 {root ? '' : 'border-l-4 border-gray-200 ps-2'}">
{#each getSchemaProperties(schema) as [childKey, childSchema] (childKey)}
{#each Object.entries(schema.properties ?? {}) as [childKey, childSchema] (childKey)}
<Self schema={childSchema} key={childKey} bind:config={getValue, setValue} />
{/each}
</div>
{:else if schema.uiHint?.type === 'AlbumId'}
{:else if schema.uiHint === 'AlbumId'}
<SchemaAlbumPicker {label} {description} array={schema.array} bind:albumIds={getUiHintValue, setUiHintValue} />
{:else if schema.enum && schema.array}
<Field {label} {description}>
@@ -110,11 +110,11 @@
let sharedLink = getSharedLink();
let fullscreenElement = $state<Element>();
let isPlayingOriginalVideo = $state($alwaysLoadOriginalVideo);
let playOriginalVideo = $state($alwaysLoadOriginalVideo);
let slideshowStartAssetId = $state<string>();
const setPlayOriginalVideo = (value: boolean) => {
isPlayingOriginalVideo = value;
playOriginalVideo = value;
};
const refreshStack = async () => {
@@ -504,7 +504,7 @@
{onUndoDelete}
onClose={onClose ? () => onClose(stack?.primaryAssetId ?? asset.id) : undefined}
{onRemoveFromAlbum}
{isPlayingOriginalVideo}
{playOriginalVideo}
{setPlayOriginalVideo}
/>
</div>
@@ -542,7 +542,7 @@
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
playOriginalVideo={isPlayingOriginalVideo}
{playOriginalVideo}
/>
{:else if viewerKind === 'LiveVideoViewer'}
<VideoViewer
@@ -554,7 +554,7 @@
onPreviousAsset={() => navigateAsset('previous')}
onNextAsset={() => navigateAsset('next')}
onVideoEnded={() => (assetViewerManager.isPlayingMotionPhoto = false)}
playOriginalVideo={isPlayingOriginalVideo}
{playOriginalVideo}
/>
{:else if viewerKind === 'ImagePanaramaViewer'}
<ImagePanoramaViewer {asset} />
@@ -574,7 +574,7 @@
onClose={closeViewer}
onVideoEnded={() => navigateAsset()}
onVideoStarted={handleVideoStarted}
playOriginalVideo={isPlayingOriginalVideo}
{playOriginalVideo}
/>
{/if}
@@ -1,4 +1,5 @@
<script lang="ts">
import { goto } from '$app/navigation';
import ActionMenuItem from '$lib/components/ActionMenuItem.svelte';
import type { OnAction, PreAction } from '$lib/components/asset-viewer/actions/action';
import AddToStackAction from '$lib/components/asset-viewer/actions/AddToStackAction.svelte';
@@ -9,15 +10,19 @@
import RemoveAssetFromStack from '$lib/components/asset-viewer/actions/RemoveAssetFromStack.svelte';
import RestoreAction from '$lib/components/asset-viewer/actions/RestoreAction.svelte';
import SetFeaturedPhotoAction from '$lib/components/asset-viewer/actions/SetPersonFeaturedAction.svelte';
import SetProfilePictureAction from '$lib/components/asset-viewer/actions/SetProfilePictureAction.svelte';
import SetStackPrimaryAsset from '$lib/components/asset-viewer/actions/SetStackPrimaryAsset.svelte';
import SetVisibilityAction from '$lib/components/asset-viewer/actions/SetVisibilityAction.svelte';
import UnstackAction from '$lib/components/asset-viewer/actions/UnstackAction.svelte';
import LoadingDots from '$lib/components/LoadingDots.svelte';
import ButtonContextMenu from '$lib/components/shared-components/context-menu/ButtonContextMenu.svelte';
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import RemoveFromAlbumAction from '$lib/components/timeline/actions/RemoveFromAlbumAction.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import { languageManager } from '$lib/managers/language-manager.svelte';
import { Route } from '$lib/route';
import { getAlbumAssetActions } from '$lib/services/album.service';
import { getGlobalActions } from '$lib/services/app.service';
import { getAssetActions } from '$lib/services/asset.service';
@@ -33,7 +38,7 @@
type StackResponseDto,
} from '@immich/sdk';
import { ActionButton, CommandPaletteDefaultProvider, Tooltip, type ActionItem } from '@immich/ui';
import { mdiArrowLeft, mdiArrowRight, mdiDotsVertical, mdiVideoOutline } from '@mdi/js';
import { mdiArrowLeft, mdiArrowRight, mdiCompare, mdiDotsVertical, mdiImageSearch, mdiVideoOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
@@ -46,7 +51,7 @@
onUndoDelete?: OnUndoDelete;
onClose?: () => void;
onRemoveFromAlbum?: (assetIds: string[]) => void;
isPlayingOriginalVideo: boolean;
playOriginalVideo: boolean;
setPlayOriginalVideo: (value: boolean) => void;
}
@@ -60,13 +65,14 @@
onUndoDelete = undefined,
onClose,
onRemoveFromAlbum,
isPlayingOriginalVideo = false,
playOriginalVideo = false,
setPlayOriginalVideo,
}: Props = $props();
const isOwner = $derived(authManager.authenticated && asset.ownerId === authManager.user.id);
const isAlbumOwner = $derived(authManager.authenticated && album?.albumUsers[0].user.id === authManager.user.id);
const isLocked = $derived(asset.visibility === AssetVisibility.Locked);
const smartSearchEnabled = $derived(featureFlagsManager.value.smartSearch);
const { Cast } = $derived(getGlobalActions($t));
@@ -78,14 +84,7 @@
shortcuts: [{ key: 'Escape' }],
});
const PlayOriginalVideo: ActionItem = $derived({
title: isPlayingOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video'),
icon: mdiVideoOutline,
$if: () => asset.type === AssetTypeEnum.Video,
onAction: () => setPlayOriginalVideo(!isPlayingOriginalVideo),
});
const Actions = $derived(getAssetActions($t, { ...asset, stackPrimaryAssetId: stack?.primaryAssetId }));
const Actions = $derived(getAssetActions($t, asset));
const sharedLink = getSharedLink();
</script>
@@ -170,21 +169,41 @@
{#if person}
<SetFeaturedPhotoAction {asset} {person} {onAction} />
{/if}
<ActionMenuItem action={Actions.SetProfilePicture} />
{#if isOwner && !isLocked}
<ArchiveAction {asset} {onAction} {preAction} />
{#if asset.type === AssetTypeEnum.Image && !isLocked}
<SetProfilePictureAction {asset} />
{/if}
{#if !isLocked}
{#if isOwner}
<ArchiveAction {asset} {onAction} {preAction} />
{#if !asset.isArchived && !asset.isTrashed}
<MenuOption
icon={mdiImageSearch}
onClick={() => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id }))}
text={$t('view_in_timeline')}
/>
{/if}
{/if}
{#if !asset.isArchived && !asset.isTrashed && smartSearchEnabled}
<MenuOption
icon={mdiCompare}
onClick={() => goto(Route.search({ queryAssetId: stack?.primaryAssetId ?? asset.id }))}
text={$t('view_similar_photos')}
/>
{/if}
{/if}
<ActionMenuItem action={Actions.ViewInTimeline} />
<ActionMenuItem action={Actions.ViewSimilar} />
{#if !asset.isTrashed && isOwner}
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
{/if}
<ActionMenuItem action={PlayOriginalVideo} />
{#if asset.type === AssetTypeEnum.Video}
<MenuOption
icon={mdiVideoOutline}
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
/>
{/if}
{#if isOwner}
<hr />
<ActionMenuItem action={Actions.RefreshFacesJob} />
@@ -23,7 +23,7 @@
type AlbumResponseDto,
type AssetResponseDto,
} from '@immich/sdk';
import { Icon, IconButton, Link, LoadingSpinner, Text } from '@immich/ui';
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
import { onDestroy } from 'svelte';
import { t } from 'svelte-i18n';
@@ -310,13 +310,14 @@
{#snippet popup({ marker })}
{@const { lat, lon } = marker}
<div class="flex flex-col items-center gap-1">
<Text fontWeight="bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</Text>
<Link
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
<a
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
class="text-primary"
target="_blank"
class="font-medium text-primary underline focus:outline-none"
>
{$t('open_in_openstreetmap')}
</Link>
</a>
</div>
{/snippet}
</Map>
@@ -0,0 +1,20 @@
<script lang="ts">
import MenuOption from '$lib/components/shared-components/context-menu/MenuOption.svelte';
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import type { AssetResponseDto } from '@immich/sdk';
import { modalManager } from '@immich/ui';
import { mdiAccountCircleOutline } from '@mdi/js';
import { t } from 'svelte-i18n';
interface Props {
asset: AssetResponseDto;
}
let { asset }: Props = $props();
</script>
<MenuOption
icon={mdiAccountCircleOutline}
onClick={() => modalManager.show(ProfileImageCropperModal, { asset })}
text={$t('set_as_profile_picture')}
/>
@@ -342,7 +342,7 @@
{#if !!assetOwner}
<div class="absolute inset-e-2 bottom-1 z-2 max-w-[50%]">
<p class="text-white-shadow max-w-full truncate p-1 text-xs font-medium text-white">
<p class="max-w-full truncate text-xs font-medium text-white drop-shadow-lg">
{assetOwner.name}
</p>
</div>
@@ -38,6 +38,7 @@
Control,
ControlButton,
ControlGroup,
FullscreenControl,
GeoJSON,
GeolocateControl,
MapLibre,
@@ -342,6 +343,7 @@
{#if !simplified}
<GeolocateControl position="top-left" />
<FullscreenControl position="top-left" />
<ScaleControl />
<AttributionControl compact={false} />
{/if}
@@ -399,13 +401,13 @@
>
{#snippet children({ feature }: { feature: Feature })}
{#if useLocationPin}
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[calc(5px-50%)] text-primary" />
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
{:else}
<img
src={getAssetMediaUrl({ id: feature.properties?.id })}
class="size-15 rounded-full border-2 border-immich-primary bg-immich-primary object-cover shadow-lg transition-all duration-200 hover:scale-150 hover:border-immich-dark-primary"
alt={feature.properties?.city && feature.properties.country
? $t('map_marker_for_image', {
? $t('map_marker_for_images', {
values: { city: feature.properties.city, country: feature.properties.country },
})
: $t('map_marker_with_image')}
@@ -413,7 +415,7 @@
{/if}
{#if popup}
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
{@render popup({ marker: asMarker(feature) })}
{@render popup?.({ marker: asMarker(feature) })}
</Popup>
{/if}
{/snippet}
@@ -31,12 +31,6 @@ vitest.mock('$lib/utils', async () => {
};
});
vi.mock(import('$lib/managers/feature-flags-manager.svelte'), function () {
return {
featureFlagsManager: { init: vi.fn(), loadFeatureFlags: vi.fn(), value: {} } as never,
};
});
describe('AssetService', () => {
describe('getAssetActions', () => {
beforeEach(() => {
+1 -34
View File
@@ -11,10 +11,8 @@ import {
} from '@immich/sdk';
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
import {
mdiAccountCircleOutline,
mdiAlertOutline,
mdiCogRefreshOutline,
mdiCompare,
mdiContentCopy,
mdiDatabaseRefreshOutline,
mdiDownload,
@@ -24,7 +22,6 @@ import {
mdiHeart,
mdiHeartOutline,
mdiImageRefreshOutline,
mdiImageSearch,
mdiInformationOutline,
mdiMagnifyMinusOutline,
mdiMagnifyPlusOutline,
@@ -37,18 +34,14 @@ import {
mdiTune,
} from '@mdi/js';
import type { MessageFormatter } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { ProjectionType } from '$lib/constants';
import { assetMultiSelectManager } from '$lib/managers/asset-multi-select-manager.svelte';
import { assetViewerManager } from '$lib/managers/asset-viewer-manager.svelte';
import { authManager } from '$lib/managers/auth-manager.svelte';
import { eventManager } from '$lib/managers/event-manager.svelte';
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
import AssetAddToAlbumModal from '$lib/modals/AssetAddToAlbumModal.svelte';
import AssetTagModal from '$lib/modals/AssetTagModal.svelte';
import ProfileImageCropperModal from '$lib/modals/ProfileImageCropperModal.svelte';
import SharedLinkCreateModal from '$lib/modals/SharedLinkCreateModal.svelte';
import { Route } from '$lib/route';
import { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
import { downloadUrl } from '$lib/utils';
@@ -99,11 +92,10 @@ export const getAssetBulkActions = ($t: MessageFormatter) => {
return { AddToAlbum, RefreshFacesJob, RefreshMetadataJob, RegenerateThumbnailJob, TranscodeVideoJob };
};
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto & { stackPrimaryAssetId?: string }) => {
export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) => {
const sharedLink = getSharedLink();
const authUser = authManager.authenticated ? authManager.user : undefined;
const isOwner = !!(authUser && authUser.id === asset.ownerId);
const smartSearchEnabled = featureFlagsManager.value.smartSearch;
const Share: ActionItem = {
title: $t('share'),
@@ -250,28 +242,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto &
shortcuts: [{ key: 'e' }],
};
const SetProfilePicture: ActionItem = {
title: $t('set_as_profile_picture'),
icon: mdiAccountCircleOutline,
$if: () => asset.type === AssetTypeEnum.Image && asset.visibility !== AssetVisibility.Locked,
onAction: () => modalManager.show(ProfileImageCropperModal, { asset }),
};
const ViewInTimeline: ActionItem = {
title: $t('view_in_timeline'),
icon: mdiImageSearch,
$if: () => isOwner && asset.visibility !== AssetVisibility.Locked && !asset.isArchived && !asset.isTrashed,
onAction: () => goto(Route.photos({ at: asset.stackPrimaryAssetId ?? asset.id })),
};
const ViewSimilar: ActionItem = {
title: $t('view_similar_photos'),
icon: mdiCompare,
$if: () =>
asset.visibility !== AssetVisibility.Locked && !asset.isArchived && !asset.isTrashed && smartSearchEnabled,
onAction: () => goto(Route.search({ queryAssetId: asset.stackPrimaryAssetId ?? asset.id })),
};
const RefreshFacesJob: ActionItem = {
title: $t('refresh_faces'),
icon: mdiHeadSyncOutline,
@@ -316,9 +286,6 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto &
Tag,
TagPeople,
Edit,
SetProfilePicture,
ViewInTimeline,
ViewSimilar,
RefreshFacesJob,
RefreshMetadataJob,
RegenerateThumbnailJob,
+1 -4
View File
@@ -96,10 +96,7 @@ export type JSONSchemaProperty = {
array?: boolean;
properties?: Record<string, JSONSchemaProperty>;
required?: string[];
uiHint?: {
type?: 'AlbumId' | 'AssetId' | 'PersonId';
order?: number;
};
uiHint?: 'AlbumId' | 'AssetId' | 'PersonId';
};
// eslint-disable-next-line @typescript-eslint/no-explicit-any
@@ -169,9 +169,7 @@
preload={false}
/>
{#if person.name}
<span
class="text-white-shadow absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text"
>
<span class="absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text">
{person.name}
</span>
{/if}
@@ -55,7 +55,7 @@
);
const isGhost = $derived(step.id === 'ghost');
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint?.type;
const getUiHint = (key: string) => schema?.properties?.[key]?.uiHint;
const toIds = (value: unknown): string[] => (Array.isArray(value) ? value.map(String) : [String(value)]);
let dragImage = $state<Element>();
let isDropTarget = $state(false);