mirror of
https://github.com/immich-app/immich.git
synced 2026-06-22 22:56:39 -07:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 3fe3e0960c | |||
| a5198e23a8 | |||
| 51f2905fcc | |||
| 3b7d75c18a |
+1
-1
@@ -1548,7 +1548,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_images": "Map marker for images taken in {city}, {country}",
|
||||
"map_marker_for_image": "Map marker for image 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",
|
||||
|
||||
@@ -1,7 +1,16 @@
|
||||
String? getVersionCompatibilityMessage(int _, int appMinor, int _, int serverMinor) {
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
|
||||
// Add latest compat info up top
|
||||
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';
|
||||
|
||||
// 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.';
|
||||
}
|
||||
|
||||
return null;
|
||||
|
||||
@@ -26,6 +26,7 @@ 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';
|
||||
@@ -88,18 +89,9 @@ class LoginForm extends HookConsumerWidget {
|
||||
checkVersionMismatch() async {
|
||||
try {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
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,
|
||||
);
|
||||
final appSemVer = SemVer.fromString(packageInfo.version);
|
||||
final serverSemVer = serverInfo.serverVersion;
|
||||
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
|
||||
} catch (error) {
|
||||
warningMessage.value = 'Error checking version compatibility';
|
||||
}
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
import 'package:immich_mobile/utils/version_compatibility.dart';
|
||||
|
||||
void main() {
|
||||
test('getVersionCompatibilityMessage', () {
|
||||
String? result;
|
||||
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.';
|
||||
|
||||
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 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, 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',
|
||||
);
|
||||
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, 106, 1, 106);
|
||||
expect(result, null);
|
||||
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, 107, 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, 108);
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
+3
-1
@@ -159,7 +159,9 @@
|
||||
}
|
||||
|
||||
.text-white-shadow {
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.8);
|
||||
text-shadow:
|
||||
0 0 4px rgba(0, 0, 0, 0.9),
|
||||
0 1px 3px rgba(0, 0, 0, 0.8);
|
||||
}
|
||||
|
||||
.icon-white-drop-shadow {
|
||||
|
||||
@@ -10,13 +10,11 @@
|
||||
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';
|
||||
@@ -84,6 +82,27 @@
|
||||
shortcuts: [{ key: 'Escape' }],
|
||||
});
|
||||
|
||||
const PlayOriginalVideo: ActionItem = $derived({
|
||||
title: playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video'),
|
||||
icon: mdiVideoOutline,
|
||||
$if: () => asset.type === AssetTypeEnum.Video,
|
||||
onAction: () => setPlayOriginalVideo(!playOriginalVideo),
|
||||
});
|
||||
|
||||
const ViewInTimeline: ActionItem = $derived({
|
||||
title: $t('view_in_timeline'),
|
||||
icon: mdiImageSearch,
|
||||
$if: () => isOwner && !isLocked && !asset.isArchived && !asset.isTrashed,
|
||||
onAction: () => goto(Route.photos({ at: stack?.primaryAssetId ?? asset.id })),
|
||||
});
|
||||
|
||||
const ViewSimilar: ActionItem = $derived({
|
||||
title: $t('view_similar_photos'),
|
||||
icon: mdiCompare,
|
||||
$if: () => !isLocked && !asset.isArchived && !asset.isTrashed && smartSearchEnabled,
|
||||
onAction: () => goto(Route.search({ queryAssetId: stack?.primaryAssetId ?? asset.id })),
|
||||
});
|
||||
|
||||
const Actions = $derived(getAssetActions($t, asset));
|
||||
const sharedLink = getSharedLink();
|
||||
</script>
|
||||
@@ -169,41 +188,21 @@
|
||||
{#if person}
|
||||
<SetFeaturedPhotoAction {asset} {person} {onAction} />
|
||||
{/if}
|
||||
{#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}
|
||||
<ActionMenuItem action={Actions.SetProfilePicture} />
|
||||
|
||||
{#if isOwner && !isLocked}
|
||||
<ArchiveAction {asset} {onAction} {preAction} />
|
||||
{/if}
|
||||
<ActionMenuItem action={ViewInTimeline} />
|
||||
<ActionMenuItem action={ViewSimilar} />
|
||||
|
||||
{#if !asset.isTrashed && isOwner}
|
||||
<SetVisibilityAction asset={toTimelineAsset(asset)} {onAction} {preAction} />
|
||||
{/if}
|
||||
|
||||
{#if asset.type === AssetTypeEnum.Video}
|
||||
<MenuOption
|
||||
icon={mdiVideoOutline}
|
||||
onClick={() => setPlayOriginalVideo(!playOriginalVideo)}
|
||||
text={playOriginalVideo ? $t('play_transcoded_video') : $t('play_original_video')}
|
||||
/>
|
||||
{/if}
|
||||
<ActionMenuItem action={PlayOriginalVideo} />
|
||||
|
||||
{#if isOwner}
|
||||
<hr />
|
||||
<ActionMenuItem action={Actions.RefreshFacesJob} />
|
||||
|
||||
@@ -23,7 +23,7 @@
|
||||
type AlbumResponseDto,
|
||||
type AssetResponseDto,
|
||||
} from '@immich/sdk';
|
||||
import { Icon, IconButton, LoadingSpinner, Text } from '@immich/ui';
|
||||
import { Icon, IconButton, Link, LoadingSpinner, Text } from '@immich/ui';
|
||||
import { mdiCamera, mdiCameraIris, mdiClose, mdiImageOutline, mdiInformationOutline } from '@mdi/js';
|
||||
import { onDestroy } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
@@ -310,14 +310,13 @@
|
||||
{#snippet popup({ marker })}
|
||||
{@const { lat, lon } = marker}
|
||||
<div class="flex flex-col items-center gap-1">
|
||||
<p class="font-bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</p>
|
||||
<a
|
||||
<Text fontWeight="bold">{lat.toPrecision(6)}, {lon.toPrecision(6)}</Text>
|
||||
<Link
|
||||
href="https://www.openstreetmap.org/?mlat={lat}&mlon={lon}&zoom=13#map=15/{lat}/{lon}"
|
||||
target="_blank"
|
||||
class="font-medium text-primary underline focus:outline-none"
|
||||
class="text-primary"
|
||||
>
|
||||
{$t('open_in_openstreetmap')}
|
||||
</a>
|
||||
</Link>
|
||||
</div>
|
||||
{/snippet}
|
||||
</Map>
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
<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="max-w-full truncate text-xs font-medium text-white drop-shadow-lg">
|
||||
<p class="text-white-shadow max-w-full truncate p-1 text-xs font-medium text-white">
|
||||
{assetOwner.name}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -38,7 +38,6 @@
|
||||
Control,
|
||||
ControlButton,
|
||||
ControlGroup,
|
||||
FullscreenControl,
|
||||
GeoJSON,
|
||||
GeolocateControl,
|
||||
MapLibre,
|
||||
@@ -343,7 +342,6 @@
|
||||
|
||||
{#if !simplified}
|
||||
<GeolocateControl position="top-left" />
|
||||
<FullscreenControl position="top-left" />
|
||||
<ScaleControl />
|
||||
<AttributionControl compact={false} />
|
||||
{/if}
|
||||
@@ -401,13 +399,13 @@
|
||||
>
|
||||
{#snippet children({ feature }: { feature: Feature })}
|
||||
{#if useLocationPin}
|
||||
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[-50%] text-primary" />
|
||||
<Icon icon={mdiMapMarker} size="50px" class="translate-y-[calc(5px-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_images', {
|
||||
? $t('map_marker_for_image', {
|
||||
values: { city: feature.properties.city, country: feature.properties.country },
|
||||
})
|
||||
: $t('map_marker_with_image')}
|
||||
@@ -415,7 +413,7 @@
|
||||
{/if}
|
||||
{#if popup}
|
||||
<Popup offset={[0, -30]} openOn="click" closeOnClickOutside>
|
||||
{@render popup?.({ marker: asMarker(feature) })}
|
||||
{@render popup({ marker: asMarker(feature) })}
|
||||
</Popup>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@immich/sdk';
|
||||
import { modalManager, toastManager, type ActionItem } from '@immich/ui';
|
||||
import {
|
||||
mdiAccountCircleOutline,
|
||||
mdiAlertOutline,
|
||||
mdiCogRefreshOutline,
|
||||
mdiContentCopy,
|
||||
@@ -41,6 +42,7 @@ import { authManager } from '$lib/managers/auth-manager.svelte';
|
||||
import { eventManager } from '$lib/managers/event-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 { SlideshowState, slideshowStore } from '$lib/stores/slideshow.store';
|
||||
import { getAssetMediaUrl, getSharedLink, sleep } from '$lib/utils';
|
||||
@@ -242,6 +244,13 @@ 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 RefreshFacesJob: ActionItem = {
|
||||
title: $t('refresh_faces'),
|
||||
icon: mdiHeadSyncOutline,
|
||||
@@ -286,6 +295,7 @@ export const getAssetActions = ($t: MessageFormatter, asset: AssetResponseDto) =
|
||||
Tag,
|
||||
TagPeople,
|
||||
Edit,
|
||||
SetProfilePicture,
|
||||
RefreshFacesJob,
|
||||
RefreshMetadataJob,
|
||||
RegenerateThumbnailJob,
|
||||
|
||||
@@ -169,7 +169,9 @@
|
||||
preload={false}
|
||||
/>
|
||||
{#if person.name}
|
||||
<span class="absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text">
|
||||
<span
|
||||
class="text-white-shadow absolute inset-s-0 bottom-2 w-full px-1 text-center font-medium text-white select-text"
|
||||
>
|
||||
{person.name}
|
||||
</span>
|
||||
{/if}
|
||||
|
||||
Reference in New Issue
Block a user