mirror of
https://github.com/immich-app/immich.git
synced 2026-07-01 02:25:16 -07:00
Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bb58b3f518 | |||
| 05d838b560 | |||
| 82b70c1ab6 | |||
| 02506424a7 | |||
| 6a7a34d294 | |||
| 165bca4b0a | |||
| d4b994301f |
+1
-1
@@ -10,7 +10,7 @@ DB_DATA_LOCATION=./postgres
|
||||
# TZ=Etc/UTC
|
||||
|
||||
# The Immich version to use. You can pin this to a specific version like "v2.1.0"
|
||||
IMMICH_VERSION=v2
|
||||
IMMICH_VERSION=v3
|
||||
|
||||
# Connection secret for postgres. You should change it to a random password
|
||||
# Please use only the characters `A-Za-z0-9`, without special characters or spaces
|
||||
|
||||
@@ -19,7 +19,7 @@ If this does not work, try running `docker compose up -d --force-recreate`.
|
||||
|
||||
| Variable | Description | Default | Containers |
|
||||
| :----------------- | :------------------------------ | :-----: | :----------------------- |
|
||||
| `IMMICH_VERSION` | Image tags | `v2` | server, machine learning |
|
||||
| `IMMICH_VERSION` | Image tags | `v3` | server, machine learning |
|
||||
| `UPLOAD_LOCATION` | Host path for uploads | | server |
|
||||
| `DB_DATA_LOCATION` | Host path for Postgres database | | database |
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ docker image prune
|
||||
## Versioning Policy
|
||||
|
||||
Immich follows [semantic versioning][semver], which tags releases in the format `<major>.<minor>.<patch>`. We intend for breaking changes to be limited to major version releases.
|
||||
You can configure your Docker image to point to the current major version by using a metatag, such as `:v2`.
|
||||
You can configure your Docker image to point to the current major version by using a metatag, such as `:v3`.
|
||||
|
||||
Currently, we have no plans to backport patches to earlier versions. We encourage all users to run the most recent release of Immich.
|
||||
Switching back to an earlier version, even within the same minor release tag, is not supported.
|
||||
|
||||
Vendored
+2
-2
@@ -1,7 +1,7 @@
|
||||
[
|
||||
{
|
||||
"label": "v3.0.0-rc.4",
|
||||
"url": "https://docs.v3.0.0-rc.4.archive.immich.app"
|
||||
"label": "v3.0.0",
|
||||
"url": "https://docs.v3.0.0.archive.immich.app"
|
||||
},
|
||||
{
|
||||
"label": "v2.7.5",
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-e2e",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"type": "module",
|
||||
|
||||
@@ -91,12 +91,14 @@ describe('/server', () => {
|
||||
it('should respond with the server version', async () => {
|
||||
const { status, body } = await request(app).get('/server/version');
|
||||
expect(status).toBe(200);
|
||||
expect(body).toEqual({
|
||||
major: expect.any(Number),
|
||||
minor: expect.any(Number),
|
||||
patch: expect.any(Number),
|
||||
prerelease: expect.anything(),
|
||||
});
|
||||
expect(body).toEqual(
|
||||
expect.objectContaining({
|
||||
major: expect.any(Number),
|
||||
minor: expect.any(Number),
|
||||
patch: expect.any(Number),
|
||||
}),
|
||||
);
|
||||
expect(Object.keys(body)).toEqual(expect.arrayContaining(['major', 'minor', 'patch', 'prerelease']));
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -108,6 +108,21 @@
|
||||
"image_thumbnail_quality_description": "Thumbnail quality from 1-100. Higher is better, but produces larger files and can reduce app responsiveness.",
|
||||
"image_thumbnail_title": "Thumbnail Settings",
|
||||
"import_config_from_json_description": "Import system config by uploading a JSON config file",
|
||||
"integrity_checks_checksum_files": "Checksum files",
|
||||
"integrity_checks_checksum_files_description": "Configure the checksum check",
|
||||
"integrity_checks_checksum_files_enable_description": "Enable the checksum check",
|
||||
"integrity_checks_checksum_files_percentage_limit": "Percentage limit",
|
||||
"integrity_checks_checksum_files_percentage_limit_description": "Configure the maximum percentage between 0.01 and 1 for how much the checksum check should run each interval.",
|
||||
"integrity_checks_checksum_files_time_limit": "Time limit",
|
||||
"integrity_checks_checksum_files_time_limit_description": "Configure the maximum duration for which the checksum check should run each interval. (ms)",
|
||||
"integrity_checks_missing_files": "Missing files",
|
||||
"integrity_checks_missing_files_description": "Configure the frequency and enable or disable the missing files check",
|
||||
"integrity_checks_missing_files_enable_description": "Enable the missing files check",
|
||||
"integrity_checks_settings": "Integrity checks",
|
||||
"integrity_checks_settings_description": "Manage integrity checks intervals",
|
||||
"integrity_checks_untracked_files": "Untracked files",
|
||||
"integrity_checks_untracked_files_description": "Configure the frequency and enable or disable the untracked files check",
|
||||
"integrity_checks_untracked_files_enable_description": "Enable the untracked files check",
|
||||
"job_concurrency": "{job} concurrency",
|
||||
"job_created": "Job created",
|
||||
"job_not_concurrency_safe": "This job is not concurrency-safe.",
|
||||
@@ -1461,6 +1476,7 @@
|
||||
"never": "Never",
|
||||
"new_album": "New Album",
|
||||
"new_api_key": "New API Key",
|
||||
"new_feature": "New Feature",
|
||||
"new_password": "New password",
|
||||
"new_person": "New person",
|
||||
"new_pin_code": "New PIN code",
|
||||
@@ -1521,6 +1537,8 @@
|
||||
"obtainium_configurator": "Obtainium Configurator",
|
||||
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
|
||||
"ocr": "OCR",
|
||||
"ocr_body": "Immich now reads the text inside your photos, so you can search for them by what they say.",
|
||||
"ocr_title": "Search text in your photos",
|
||||
"official_immich_resources": "Official Immich Resources",
|
||||
"offline": "Offline",
|
||||
"offset": "Offset",
|
||||
@@ -1539,6 +1557,8 @@
|
||||
"open": "Open",
|
||||
"open_calendar": "Open calendar",
|
||||
"open_in_browser": "Open in browser",
|
||||
"open_in_immich_body": "Set Immich as your gallery on Android to open photos straight from other apps.",
|
||||
"open_in_immich_title": "Open photos in Immich",
|
||||
"open_in_map_view": "Open in map view",
|
||||
"open_in_openstreetmap": "Open in OpenStreetMap",
|
||||
"open_the_search_filters": "Open the search filters",
|
||||
@@ -1697,7 +1717,9 @@
|
||||
"recent": "Recent",
|
||||
"recent_searches": "Recent searches",
|
||||
"recently_added": "Recently added",
|
||||
"recently_added_body": "Jump straight to everything you've added lately on a dedicated page.",
|
||||
"recently_added_page_title": "Recently Added",
|
||||
"recently_added_title": "Recently added",
|
||||
"recently_taken": "Recently taken",
|
||||
"refresh": "Refresh",
|
||||
"refresh_encoded_videos": "Refresh encoded videos",
|
||||
@@ -1904,6 +1926,8 @@
|
||||
"share_link": "Share Link",
|
||||
"share_original": "Use original (large)",
|
||||
"share_preview": "Use thumbnail (small)",
|
||||
"share_quality_body": "Press and hold the share button to choose the image quality before you share.",
|
||||
"share_quality_title": "Choose your share quality",
|
||||
"shared": "Shared",
|
||||
"shared_album_activities_input_disable": "Comment is disabled",
|
||||
"shared_album_activity_remove_content": "Do you want to delete this activity?",
|
||||
@@ -1985,16 +2009,19 @@
|
||||
"sign_out": "Sign Out",
|
||||
"sign_up": "Sign up",
|
||||
"size": "Size",
|
||||
"skip": "Skip",
|
||||
"skip_to_content": "Skip to content",
|
||||
"skip_to_folders": "Skip to folders",
|
||||
"skip_to_tags": "Skip to tags",
|
||||
"slideshow": "Slideshow",
|
||||
"slideshow_body": "Sit back and watch your photos play in a full-screen slideshow.",
|
||||
"slideshow_metadata_overlay_mode": "Overlay content",
|
||||
"slideshow_metadata_overlay_mode_description_only": "Description only",
|
||||
"slideshow_metadata_overlay_mode_full": "Full",
|
||||
"slideshow_repeat": "Repeat slideshow",
|
||||
"slideshow_repeat_description": "Loop back to beginning when slideshow ends",
|
||||
"slideshow_settings": "Slideshow settings",
|
||||
"slideshow_title": "Slideshow",
|
||||
"smart_album": "Smart album",
|
||||
"some_assets_already_have_a_location_warning": "Some of the selected assets already have a location",
|
||||
"sort_albums_by": "Sort albums by...",
|
||||
@@ -2157,6 +2184,8 @@
|
||||
"upload_status_errors": "Errors",
|
||||
"upload_status_uploaded": "Uploaded",
|
||||
"upload_success": "Upload success, refresh the page to see new upload assets.",
|
||||
"upload_to_album_body": "For users that don't utilize the manual upload feature, you can now choose to add local photos directly into an album as you upload them, no need to upload then add to an album later anymore.",
|
||||
"upload_to_album_title": "Upload straight to an album",
|
||||
"upload_to_immich": "Upload to Immich ({count})",
|
||||
"uploading": "Uploading",
|
||||
"uploading_media": "Uploading media",
|
||||
@@ -2224,6 +2253,9 @@
|
||||
"week": "Week",
|
||||
"welcome": "Welcome",
|
||||
"welcome_to_immich": "Welcome to Immich",
|
||||
"whats_new": "What's new",
|
||||
"whats_new_settings_subtitle": "See what's new in Immich",
|
||||
"whats_new_version": "Version {version}",
|
||||
"when": "When",
|
||||
"width": "Width",
|
||||
"wifi_name": "Wi-Fi Name",
|
||||
|
||||
+1
-29
@@ -6,7 +6,7 @@
|
||||
"action": "Aksion",
|
||||
"action_common_update": "Përditëso",
|
||||
"action_description": "Një grup veprimesh për t'u kryer në asetet e filtruara",
|
||||
"actions": "Veprime",
|
||||
"actions": "Aksione",
|
||||
"active": "Aktiv",
|
||||
"active_count": "Aktive: {count}",
|
||||
"activity": "Aktivitet",
|
||||
@@ -536,11 +536,6 @@
|
||||
"api_keys": "Çelësat API",
|
||||
"app_architecture_variant": "Varianta (Arkitektura)",
|
||||
"app_bar_signout_dialog_content": "A je i sigurt që dëshiron të dalësh?",
|
||||
"back": "Mbrapa",
|
||||
"close": "Mbyll",
|
||||
"copy_image": "Kopjo imazhin",
|
||||
"dark": "E errët",
|
||||
"disabled": "I çaktivizuar",
|
||||
"download_original": "Shkarko origjinalin",
|
||||
"download_paused": "Shkarkimi u pezullua",
|
||||
"download_settings": "Shkarko",
|
||||
@@ -549,29 +544,6 @@
|
||||
"downloading_asset_filename": "Duke shkarkuar asetin {filename}",
|
||||
"downloading_from_icloud": "Duke shkarkuar nga iCloud",
|
||||
"downloading_media": "Duke shkarkuar median",
|
||||
"enable": "Aktivizo",
|
||||
"error": "Gabim",
|
||||
"expired": "Skaduar",
|
||||
"image": "Imazhi",
|
||||
"info": "Info",
|
||||
"model": "Modeli",
|
||||
"name": "Emri",
|
||||
"none": "Asnjë",
|
||||
"offline": "Jashtë linje",
|
||||
"ok": "Në rregull",
|
||||
"online": "Online",
|
||||
"path": "Shtegu",
|
||||
"refresh": "Rifresko",
|
||||
"rename": "Riemërto",
|
||||
"search": "Kërko",
|
||||
"settings": "Cilësimet",
|
||||
"size": "Madhësia",
|
||||
"status": "Statusi",
|
||||
"type": "Lloji",
|
||||
"unknown": "E panjohur",
|
||||
"upload_finished": "Ngarkimi përfundoi",
|
||||
"username": "Emri i përdoruesit",
|
||||
"version": "Versioni",
|
||||
"you_dont_have_any_shared_links": "Nuk keni asnjë link të shpërndarë",
|
||||
"your_wifi_name": "Emri i Wi-Fi tuaj",
|
||||
"zoom_image": "Zmadho imazhin"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0rc4"
|
||||
version = "3.0.0"
|
||||
description = ""
|
||||
authors = [{ name = "Hau Tran", email = "alex.tran1502@gmail.com" }]
|
||||
requires-python = ">=3.11,<4.0"
|
||||
|
||||
Generated
+1
-1
@@ -974,7 +974,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "immich-ml"
|
||||
version = "3.0.0rc4"
|
||||
version = "3.0.0"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiocache" },
|
||||
|
||||
@@ -22,8 +22,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3052,
|
||||
"android.injected.version.name" => "3.0.0-rc.4",
|
||||
"android.injected.version.code" => 3053,
|
||||
"android.injected.version.name" => "3.0.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab', track: 'beta')
|
||||
@@ -35,8 +35,8 @@ platform :android do
|
||||
task: 'bundle',
|
||||
build_type: 'Release',
|
||||
properties: {
|
||||
"android.injected.version.code" => 3052,
|
||||
"android.injected.version.name" => "3.0.0-rc.4",
|
||||
"android.injected.version.code" => 3053,
|
||||
"android.injected.version.name" => "3.0.0",
|
||||
}
|
||||
)
|
||||
upload_to_play_store(skip_upload_apk: true, skip_upload_images: true, skip_upload_screenshots: true, aab: '../build/app/outputs/bundle/release/app-release.aab')
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 40 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 5.4 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
@@ -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';
|
||||
@@ -16,6 +17,7 @@ import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/settings_key.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
const defaultConfig = AppConfig();
|
||||
|
||||
@@ -32,6 +34,7 @@ class AppConfig {
|
||||
final BackupConfig backup;
|
||||
final NetworkConfig network;
|
||||
final ShareConfig share;
|
||||
final FeatureMessageConfig featureMessage;
|
||||
|
||||
const AppConfig({
|
||||
this.logLevel = .info,
|
||||
@@ -46,6 +49,7 @@ class AppConfig {
|
||||
this.backup = const .new(),
|
||||
this.network = const .new(),
|
||||
this.share = const .new(),
|
||||
this.featureMessage = const .new(),
|
||||
});
|
||||
|
||||
AppConfig copyWith({
|
||||
@@ -61,6 +65,7 @@ class AppConfig {
|
||||
BackupConfig? backup,
|
||||
NetworkConfig? network,
|
||||
ShareConfig? share,
|
||||
FeatureMessageConfig? featureMessage,
|
||||
}) => .new(
|
||||
logLevel: logLevel ?? this.logLevel,
|
||||
theme: theme ?? this.theme,
|
||||
@@ -74,6 +79,7 @@ class AppConfig {
|
||||
backup: backup ?? this.backup,
|
||||
network: network ?? this.network,
|
||||
share: share ?? this.share,
|
||||
featureMessage: featureMessage ?? this.featureMessage,
|
||||
);
|
||||
|
||||
@override
|
||||
@@ -91,15 +97,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 +166,7 @@ class AppConfig {
|
||||
.slideshowDuration => slideshow.duration,
|
||||
.slideshowLook => slideshow.look,
|
||||
.slideshowDirection => slideshow.direction,
|
||||
.featureMessageSeenRelease => featureMessage.seenRelease,
|
||||
})
|
||||
as T;
|
||||
|
||||
@@ -199,6 +220,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)),
|
||||
.featureMessageSeenRelease => copyWith(featureMessage: featureMessage.copyWith(seenRelease: value as SemVer)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
class FeatureMessageConfig {
|
||||
final SemVer seenRelease;
|
||||
|
||||
const FeatureMessageConfig({this.seenRelease = const SemVer(major: 0, minor: 0, patch: 0)});
|
||||
|
||||
FeatureMessageConfig copyWith({SemVer? seenRelease}) =>
|
||||
FeatureMessageConfig(seenRelease: seenRelease ?? this.seenRelease);
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) =>
|
||||
identical(this, other) || (other is FeatureMessageConfig && other.seenRelease == seenRelease);
|
||||
|
||||
@override
|
||||
int get hashCode => seenRelease.hashCode;
|
||||
|
||||
@override
|
||||
String toString() => 'FeatureMessageConfig(seenRelease: $seenRelease)';
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
class FeatureHighlight {
|
||||
/// Asset path of the feature screenshot, or null to show a placeholder.
|
||||
final String? image;
|
||||
final String titleKey;
|
||||
final String bodyKey;
|
||||
final List<TargetPlatform> platform;
|
||||
|
||||
const FeatureHighlight({
|
||||
this.image,
|
||||
required this.titleKey,
|
||||
required this.bodyKey,
|
||||
this.platform = const [.iOS, .android],
|
||||
});
|
||||
|
||||
bool get isVisibleOnCurrentPlatform => platform.contains(defaultTargetPlatform);
|
||||
}
|
||||
|
||||
/// The release this batch of highlights was authored for. Content-defined:
|
||||
/// bump it only when publishing a new batch, never from the running app version.
|
||||
const featureMessageRelease = SemVer(major: 3, minor: 0, patch: 0);
|
||||
|
||||
/// Highlights relevant to the current platform.
|
||||
List<FeatureHighlight> get visibleFeatureMessageHighlights =>
|
||||
featureMessageHighlights.where((h) => h.isVisibleOnCurrentPlatform).toList();
|
||||
|
||||
const List<FeatureHighlight> featureMessageHighlights = [
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/share_quality.webp',
|
||||
titleKey: 'share_quality_title',
|
||||
bodyKey: 'share_quality_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/slideshow.webp',
|
||||
titleKey: 'slideshow_title',
|
||||
bodyKey: 'slideshow_body',
|
||||
),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/recently_added.webp',
|
||||
titleKey: 'recently_added_title',
|
||||
bodyKey: 'recently_added_body',
|
||||
),
|
||||
|
||||
FeatureHighlight(image: 'assets/feature_message/ocr.webp', titleKey: 'ocr_title', bodyKey: 'ocr_body'),
|
||||
FeatureHighlight(
|
||||
image: 'assets/feature_message/open_in_immich.webp',
|
||||
titleKey: 'open_in_immich_title',
|
||||
bodyKey: 'open_in_immich_body',
|
||||
platform: [.android],
|
||||
),
|
||||
FeatureHighlight(titleKey: 'upload_to_album_title', bodyKey: 'upload_to_album_body'),
|
||||
];
|
||||
@@ -6,6 +6,7 @@ import 'package:immich_mobile/constants/enums.dart';
|
||||
import 'package:immich_mobile/domain/models/log.model.dart';
|
||||
import 'package:immich_mobile/domain/models/timeline.model.dart';
|
||||
import 'package:immich_mobile/providers/album/album_sort_by_options.provider.dart';
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
enum SettingsKey<T> {
|
||||
// Theme
|
||||
@@ -73,7 +74,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
|
||||
featureMessageSeenRelease<SemVer>(codec: _SemVerCodec());
|
||||
|
||||
final _SettingsCodec<T>? _codecOverride;
|
||||
|
||||
@@ -139,6 +143,16 @@ final class _DateTimeCodec extends _SettingsCodec<DateTime> {
|
||||
DateTime decode(String raw) => DateTime.parse(raw);
|
||||
}
|
||||
|
||||
final class _SemVerCodec extends _SettingsCodec<SemVer> {
|
||||
const _SemVerCodec();
|
||||
|
||||
@override
|
||||
String encode(SemVer value) => value.toString();
|
||||
|
||||
@override
|
||||
SemVer decode(String raw) => SemVer.fromString(raw);
|
||||
}
|
||||
|
||||
final class _MapCodec<K extends Object, V extends Object> extends _SettingsCodec<Map<K, V>> {
|
||||
final _SettingsCodec<K> _keyCodec;
|
||||
final _SettingsCodec<V> _valueCodec;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
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.featureMessage.seenRelease;
|
||||
return featureMessageHighlights.isNotEmpty && featureMessageRelease > seen;
|
||||
}
|
||||
|
||||
Future<void> markSeen() => _settingsRepository.write(SettingsKey.featureMessageSeenRelease, featureMessageRelease);
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.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 +88,14 @@ class _MobileLayout extends StatelessWidget {
|
||||
],
|
||||
)
|
||||
.toList();
|
||||
settings.add(
|
||||
SettingsCard(
|
||||
icon: Icons.auto_awesome_outlined,
|
||||
title: context.t.whats_new,
|
||||
subtitle: context.t.whats_new_settings_subtitle,
|
||||
settingRoute: const WhatsNewRoute(),
|
||||
),
|
||||
);
|
||||
return ListView(padding: const EdgeInsets.only(top: 10.0, bottom: 60), children: [...settings]);
|
||||
}
|
||||
}
|
||||
@@ -116,6 +125,13 @@ class _TabletLayout extends HookWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
SliverToBoxAdapter(
|
||||
child: ListTile(
|
||||
title: Text('whats_new'.tr()),
|
||||
leading: const Icon(Icons.auto_awesome_outlined),
|
||||
onTap: () => context.pushRoute(const WhatsNewRoute()),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -3,14 +3,42 @@ 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((_) async {
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
final service = ref.read(featureMessageServiceProvider);
|
||||
if (!service.shouldShow()) {
|
||||
return;
|
||||
}
|
||||
|
||||
await service.markSeen();
|
||||
if (!mounted) {
|
||||
return;
|
||||
}
|
||||
|
||||
await showFeatureMessageDialog(context);
|
||||
});
|
||||
}
|
||||
|
||||
@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,73 @@
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
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/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_placeholder.widget.dart';
|
||||
|
||||
@RoutePage()
|
||||
class WhatsNewPage extends StatelessWidget {
|
||||
const WhatsNewPage({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final highlights = visibleFeatureMessageHighlights;
|
||||
return Scaffold(
|
||||
appBar: AppBar(centerTitle: false, title: Text(context.t.whats_new)),
|
||||
body: ListView.separated(
|
||||
padding: const EdgeInsets.only(top: 16, bottom: 64),
|
||||
itemCount: highlights.length,
|
||||
separatorBuilder: (_, __) => const SizedBox(height: 24),
|
||||
itemBuilder: (_, index) => _HighlightCard(highlight: highlights[index]),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HighlightCard extends StatelessWidget {
|
||||
final FeatureHighlight highlight;
|
||||
|
||||
const _HighlightCard({required this.highlight});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheme = context.colorScheme;
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 16),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(18)),
|
||||
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(18)),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 256,
|
||||
child: highlight.image == null
|
||||
? const FeatureMessagePlaceholder()
|
||||
: Image.asset(
|
||||
highlight.image!,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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,316 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
import 'package:easy_localization/easy_localization.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/domain/models/feature_message.model.dart';
|
||||
import 'package:immich_mobile/presentation/widgets/feature_message/feature_message_placeholder.widget.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/extensions/theme_extensions.dart';
|
||||
|
||||
Future<void> showFeatureMessageDialog(BuildContext context) {
|
||||
return showGeneralDialog<void>(
|
||||
context: context,
|
||||
useRootNavigator: true,
|
||||
barrierDismissible: true,
|
||||
barrierLabel: context.t.whats_new,
|
||||
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> with SingleTickerProviderStateMixin {
|
||||
static const double _radius = 24;
|
||||
|
||||
final PageController _controller = PageController();
|
||||
late final AnimationController _borderController = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 7),
|
||||
)..repeat();
|
||||
final List<FeatureHighlight> _highlights = visibleFeatureMessageHighlights;
|
||||
int _index = 0;
|
||||
|
||||
bool get _isLast => _index >= _highlights.length - 1;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_controller.dispose();
|
||||
_borderController.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _advance() {
|
||||
if (_isLast) {
|
||||
Navigator.of(context).pop();
|
||||
return;
|
||||
}
|
||||
_controller.nextPage(duration: const Duration(milliseconds: 320), curve: Curves.easeOutCubic);
|
||||
}
|
||||
|
||||
List<Color> _borderColors(BuildContext context) {
|
||||
final scheme = context.colorScheme;
|
||||
// Mute the hues toward the surface and drop opacity in dark mode to keep it gentle.
|
||||
Color tone(Color c) => context.isDarkTheme ? Color.lerp(c, scheme.surface, 0.45)!.withValues(alpha: 0.6) : c;
|
||||
return [tone(scheme.primary), tone(scheme.tertiary), tone(scheme.secondary), tone(scheme.primary)];
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Dialog(
|
||||
insetPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 64),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
backgroundColor: context.isDarkTheme ? context.colorScheme.surfaceContainerLow : Colors.white,
|
||||
shape: const RoundedRectangleBorder(borderRadius: BorderRadius.all(Radius.circular(_radius))),
|
||||
child: AnimatedBuilder(
|
||||
animation: _borderController,
|
||||
builder: (context, child) => CustomPaint(
|
||||
foregroundPainter: _GradientBorderPainter(
|
||||
rotation: _borderController.value,
|
||||
colors: _borderColors(context),
|
||||
radius: _radius,
|
||||
strokeWidth: 3,
|
||||
),
|
||||
child: child,
|
||||
),
|
||||
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(
|
||||
context.t.whats_new,
|
||||
style: context.textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w700),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
context.t.whats_new_version(version: featureMessageRelease.toString()),
|
||||
style: context.textTheme.bodyLarge?.copyWith(color: context.colorScheme.onSurfaceSecondary),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 32),
|
||||
|
||||
Expanded(
|
||||
child: PageView.builder(
|
||||
controller: _controller,
|
||||
itemCount: _highlights.length,
|
||||
onPageChanged: (i) => setState(() => _index = i),
|
||||
itemBuilder: (_, index) => _FeaturePage(highlight: _highlights[index]),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
_PageDots(controller: _controller, index: _index, count: _highlights.length),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 20, 26),
|
||||
child: Row(
|
||||
children: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.of(context).pop(),
|
||||
style: TextButton.styleFrom(padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 14)),
|
||||
child: Text(context.t.skip),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(100)),
|
||||
boxShadow: [
|
||||
// Soft wide primary glow.
|
||||
BoxShadow(
|
||||
color: context.primaryColor.withValues(alpha: 0.38),
|
||||
blurRadius: 22,
|
||||
spreadRadius: -4,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
// Tight contact shadow for grounding.
|
||||
BoxShadow(
|
||||
color: context.primaryColor.withValues(alpha: 0.22),
|
||||
blurRadius: 6,
|
||||
offset: const Offset(0, 2),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: FilledButton(
|
||||
onPressed: _advance,
|
||||
style: FilledButton.styleFrom(
|
||||
padding: const EdgeInsets.symmetric(vertical: 16),
|
||||
elevation: 0,
|
||||
textStyle: context.textTheme.labelLarge?.copyWith(
|
||||
fontWeight: FontWeight.w700,
|
||||
fontSize: 16,
|
||||
),
|
||||
),
|
||||
child: AnimatedSwitcher(
|
||||
duration: const Duration(milliseconds: 200),
|
||||
child: Text(_isLast ? context.t.ok : context.t.next, key: ValueKey(_isLast)),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _GradientBorderPainter extends CustomPainter {
|
||||
const _GradientBorderPainter({
|
||||
required this.rotation,
|
||||
required this.colors,
|
||||
required this.radius,
|
||||
this.strokeWidth = 3,
|
||||
});
|
||||
|
||||
final double rotation;
|
||||
final List<Color> colors;
|
||||
final double radius;
|
||||
final double strokeWidth;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final inset = strokeWidth / 2;
|
||||
final rect = (Offset.zero & size).deflate(inset);
|
||||
final rrect = RRect.fromRectAndRadius(rect, Radius.circular(radius - inset));
|
||||
|
||||
final shader = SweepGradient(
|
||||
transform: GradientRotation(rotation * 2 * math.pi),
|
||||
colors: colors,
|
||||
).createShader(rect);
|
||||
|
||||
final paint = Paint()
|
||||
..shader = shader
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth;
|
||||
canvas.drawRRect(rrect, paint);
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_GradientBorderPainter oldDelegate) =>
|
||||
oldDelegate.rotation != rotation || !listEquals(oldDelegate.colors, colors);
|
||||
}
|
||||
|
||||
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: DecoratedBox(
|
||||
decoration: BoxDecoration(
|
||||
color: scheme.surfaceContainerHighest,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(18)),
|
||||
border: Border.all(color: scheme.outlineVariant.withValues(alpha: 0.5)),
|
||||
),
|
||||
child: ClipRRect(
|
||||
borderRadius: const BorderRadius.all(Radius.circular(18)),
|
||||
child: SizedBox(
|
||||
width: double.infinity,
|
||||
height: 256,
|
||||
child: highlight.image == null
|
||||
? const FeatureMessagePlaceholder()
|
||||
: Image.asset(
|
||||
highlight.image!,
|
||||
fit: BoxFit.contain,
|
||||
errorBuilder: (context, _, __) => const FeatureMessagePlaceholder(),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
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, fontSize: 24),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
highlight.bodyKey.tr(),
|
||||
style: context.textTheme.bodyLarge?.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: const BorderRadius.all(Radius.circular(8)),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/widgets/common/immich_logo.dart';
|
||||
|
||||
class _SplatColors {
|
||||
static const primary = Color(0xFF4250AF);
|
||||
static const info = Color(0xFF3B82F6);
|
||||
static const success = Color(0xFF2FB457);
|
||||
static const warning = Color(0xFFF2A73B);
|
||||
static const danger = Color(0xFFE5484D);
|
||||
}
|
||||
|
||||
class FeatureMessagePlaceholder extends StatelessWidget {
|
||||
const FeatureMessagePlaceholder({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final dark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
final cardColor = dark ? const Color(0xFF232228) : const Color(0xFFEEEDF4);
|
||||
final tileColor = dark ? const Color(0xFF2B2A32) : const Color(0xFFFBFAFE);
|
||||
final inkColor = dark ? const Color(0xFFE7E7EC) : const Color(0xFF1A1A1E);
|
||||
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
height: double.infinity,
|
||||
clipBehavior: Clip.antiAlias,
|
||||
// Fill a plain rectangle — the parent's ClipRRect handles the corner rounding,
|
||||
// so the placeholder doesn't round its own corners inside that clip.
|
||||
decoration: BoxDecoration(color: cardColor),
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
// ---- confetti motif (168 × 120 region) ----
|
||||
SizedBox(
|
||||
width: 168,
|
||||
height: 120,
|
||||
child: Stack(
|
||||
clipBehavior: Clip.none,
|
||||
children: [
|
||||
// scattered confetti
|
||||
Positioned(left: 6, top: 24, child: _dot(12, _SplatColors.primary)),
|
||||
Positioned(left: 80, top: -2, child: _dot(9, _SplatColors.danger)),
|
||||
Positioned(left: 148, top: 84, child: _dot(11, _SplatColors.success)),
|
||||
Positioned(left: 140, top: 14, child: _bar(22, 8, 0.49, _SplatColors.danger)), // ~28°
|
||||
Positioned(left: 2, top: 90, child: _bar(20, 8, -0.31, _SplatColors.info)), // ~-18°
|
||||
// tilted spark tile
|
||||
Positioned(
|
||||
left: 46,
|
||||
top: 18,
|
||||
child: Transform.rotate(
|
||||
angle: -0.105, // ~-6°
|
||||
child: Container(
|
||||
width: 84,
|
||||
height: 84,
|
||||
decoration: BoxDecoration(
|
||||
color: tileColor,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(18)),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: const Color(0xFF0F122D).withValues(alpha: 0.22),
|
||||
blurRadius: 22,
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Stack(
|
||||
alignment: Alignment.center,
|
||||
children: [
|
||||
Positioned(left: 12, top: 12, child: _dot(12, _SplatColors.warning)),
|
||||
const ImmichLogo(size: 40),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
context.t.new_feature,
|
||||
style: context.textTheme.titleMedium?.copyWith(fontWeight: FontWeight.w600, color: inkColor),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
static Widget _dot(double d, Color c) => Container(
|
||||
width: d,
|
||||
height: d,
|
||||
decoration: BoxDecoration(color: c, shape: BoxShape.circle),
|
||||
);
|
||||
|
||||
static Widget _bar(double w, double h, double angle, Color c) => Transform.rotate(
|
||||
angle: angle,
|
||||
child: Container(
|
||||
width: w,
|
||||
height: h,
|
||||
decoration: BoxDecoration(color: c, borderRadius: const BorderRadius.all(Radius.circular(99))),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
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)),
|
||||
);
|
||||
@@ -38,6 +38,7 @@ import 'package:immich_mobile/pages/share_intent/share_intent.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/cleanup_preview.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/main_timeline.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/dev/media_stat.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/feature_message/whats_new.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/download_info.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_activities.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/drift_album.page.dart';
|
||||
@@ -131,6 +132,7 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: ProfilePictureCropRoute.page),
|
||||
AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: WhatsNewRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: AppLogRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: AppLogDetailRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: FolderRoute.page, guards: [_authGuard]),
|
||||
|
||||
@@ -1872,3 +1872,19 @@ class TabShellRoute extends PageRouteInfo<void> {
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/// generated route for
|
||||
/// [WhatsNewPage]
|
||||
class WhatsNewRoute extends PageRouteInfo<void> {
|
||||
const WhatsNewRoute({List<PageRouteInfo>? children})
|
||||
: super(WhatsNewRoute.name, initialChildren: children);
|
||||
|
||||
static const String name = 'WhatsNewRoute';
|
||||
|
||||
static PageInfo page = PageInfo(
|
||||
name,
|
||||
builder: (data) {
|
||||
return const WhatsNewPage();
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:immich_mobile/utils/semver.dart';
|
||||
|
||||
String? getVersionCompatibilityMessage(SemVer serverVersion, SemVer appVersion) {
|
||||
String? getVersionCompatibilityMessage({required SemVer serverVersion, required SemVer appVersion}) {
|
||||
// Add latest compat info up top
|
||||
|
||||
// ensure mobile app major version is not behind server major version
|
||||
|
||||
@@ -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';
|
||||
@@ -91,7 +92,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
final packageInfo = await PackageInfo.fromPlatform();
|
||||
final appSemVer = SemVer.fromString(packageInfo.version);
|
||||
final serverSemVer = serverInfo.serverVersion;
|
||||
warningMessage.value = getVersionCompatibilityMessage(appSemVer, serverSemVer);
|
||||
warningMessage.value = getVersionCompatibilityMessage(serverVersion: serverSemVer, appVersion: appSemVer);
|
||||
} catch (error) {
|
||||
warningMessage.value = 'Error checking version compatibility';
|
||||
}
|
||||
@@ -254,6 +255,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 +343,7 @@ class LoginForm extends HookConsumerWidget {
|
||||
await getManageMediaPermission();
|
||||
}
|
||||
unawaited(handleSyncFlow());
|
||||
unawaited(ref.read(featureMessageServiceProvider).markSeen());
|
||||
unawaited(context.router.replaceAll([const TabShellRoute()]));
|
||||
return;
|
||||
}
|
||||
@@ -377,11 +380,21 @@ class LoginForm extends HookConsumerWidget {
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: context.isDarkTheme ? Colors.red.shade700 : Colors.red.shade100,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(8)),
|
||||
border: Border.all(color: context.isDarkTheme ? Colors.red.shade900 : Colors.red[200]!),
|
||||
color: context.isDarkTheme ? Colors.amber.shade700 : Colors.amber.shade100,
|
||||
borderRadius: const BorderRadius.all(Radius.circular(12)),
|
||||
border: Border.all(color: context.isDarkTheme ? Colors.amber.shade800 : Colors.amber[200]!, width: 2),
|
||||
),
|
||||
child: Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Icon(Icons.warning_amber_rounded, color: Colors.amber.shade800),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Padding(padding: const EdgeInsets.only(top: 2), child: Text(warningMessage.value!)),
|
||||
),
|
||||
],
|
||||
),
|
||||
child: Text(warningMessage.value!, textAlign: TextAlign.center),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Generated
+1
-1
@@ -3,7 +3,7 @@ Immich API
|
||||
|
||||
This Dart package is automatically generated by the [OpenAPI Generator](https://openapi-generator.tech) project:
|
||||
|
||||
- API version: 3.0.0-rc.4
|
||||
- API version: 3.0.0
|
||||
- Generator version: 7.22.0
|
||||
- Build package: org.openapitools.codegen.languages.DartClientCodegen
|
||||
|
||||
|
||||
+2
-1
@@ -2,7 +2,7 @@ name: immich_mobile
|
||||
description: Immich - selfhosted backup media file on mobile phone
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.0.0-rc.4+3052
|
||||
version: 3.0.0+3053
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
@@ -119,6 +119,7 @@ flutter:
|
||||
uses-material-design: true
|
||||
assets:
|
||||
- assets/
|
||||
- assets/feature_message/
|
||||
fonts:
|
||||
- family: GoogleSans
|
||||
fonts:
|
||||
|
||||
@@ -9,16 +9,16 @@ void main() {
|
||||
|
||||
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),
|
||||
serverVersion: const SemVer(major: 2, minor: 0, patch: 0),
|
||||
appVersion: const SemVer(major: 1, minor: 200, patch: 0),
|
||||
);
|
||||
expect(result, message);
|
||||
});
|
||||
|
||||
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),
|
||||
serverVersion: const SemVer(major: 2, minor: 0, patch: 0),
|
||||
appVersion: const SemVer(major: 2, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, null);
|
||||
});
|
||||
@@ -30,16 +30,16 @@ void main() {
|
||||
|
||||
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),
|
||||
serverVersion: const SemVer(major: 1, minor: 200, patch: 0),
|
||||
appVersion: const SemVer(major: 3, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, message);
|
||||
});
|
||||
|
||||
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),
|
||||
serverVersion: const SemVer(major: 1, minor: 200, patch: 0),
|
||||
appVersion: const SemVer(major: 2, minor: 0, patch: 0),
|
||||
);
|
||||
expect(result, null);
|
||||
});
|
||||
|
||||
@@ -16206,7 +16206,7 @@
|
||||
"info": {
|
||||
"title": "Immich",
|
||||
"description": "Immich API",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0",
|
||||
"contact": {}
|
||||
},
|
||||
"tags": [
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-monorepo",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0",
|
||||
"description": "Monorepo for Immich",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/cli",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0",
|
||||
"description": "Command Line Interface (CLI) for Immich",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -150,7 +150,7 @@ const methods = wrapper<Manifest>({
|
||||
changes: { asset: { visibility: config.visibility as AssetVisibility } },
|
||||
}),
|
||||
|
||||
webhook: ({ config, data, functions }) => {
|
||||
webhook: ({ config, data, functions, type, trigger }) => {
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
};
|
||||
@@ -161,7 +161,11 @@ const methods = wrapper<Manifest>({
|
||||
|
||||
functions.httpRequest(config.url, {
|
||||
method: config.method ?? 'POST',
|
||||
body: JSON.stringify(data.asset),
|
||||
body: JSON.stringify({
|
||||
type,
|
||||
trigger,
|
||||
data,
|
||||
}),
|
||||
headers,
|
||||
});
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@immich/sdk",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0",
|
||||
"description": "Auto-generated TypeScript SDK for the Immich API",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* Immich
|
||||
* 3.0.0-rc.4
|
||||
* 3.0.0
|
||||
* DO NOT MODIFY - This file has been generated using oazapfts.
|
||||
* See https://www.npmjs.com/package/oazapfts
|
||||
*/
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0",
|
||||
"description": "",
|
||||
"author": "",
|
||||
"private": true,
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "immich-web",
|
||||
"version": "3.0.0-rc.4",
|
||||
"version": "3.0.0",
|
||||
"license": "GNU Affero General Public License version 3",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
mdiBookshelf,
|
||||
mdiClockOutline,
|
||||
mdiDatabaseOutline,
|
||||
mdiFileCheckOutline,
|
||||
mdiFileDocumentOutline,
|
||||
mdiFolderOutline,
|
||||
mdiImageOutline,
|
||||
@@ -47,6 +48,7 @@
|
||||
import type { Component } from 'svelte';
|
||||
import { t } from 'svelte-i18n';
|
||||
import type { PageData } from './$types';
|
||||
import IntegrityChecksSettings from './IntegrityChecksSettings.svelte';
|
||||
|
||||
type Props = {
|
||||
data: PageData;
|
||||
@@ -82,6 +84,13 @@
|
||||
key: 'image',
|
||||
icon: mdiImageOutline,
|
||||
},
|
||||
{
|
||||
component: IntegrityChecksSettings,
|
||||
title: $t('admin.integrity_checks_settings'),
|
||||
subtitle: $t('admin.integrity_checks_settings_description'),
|
||||
key: 'integrity-checks',
|
||||
icon: mdiFileCheckOutline,
|
||||
},
|
||||
{
|
||||
component: JobSettings,
|
||||
title: $t('admin.job_settings'),
|
||||
|
||||
@@ -0,0 +1,173 @@
|
||||
<script lang="ts">
|
||||
import SettingAccordion from '$lib/components/shared-components/settings/SettingAccordion.svelte';
|
||||
import SettingInputField from '$lib/components/shared-components/settings/SettingInputField.svelte';
|
||||
import SettingSwitch from '$lib/components/shared-components/settings/SettingSwitch.svelte';
|
||||
import SettingButtonsRow from '$lib/components/shared-components/settings/SystemConfigButtonRow.svelte';
|
||||
import { SettingInputFieldType } from '$lib/constants';
|
||||
import FormatMessage from '$lib/elements/FormatMessage.svelte';
|
||||
import { featureFlagsManager } from '$lib/managers/feature-flags-manager.svelte';
|
||||
import { systemConfigManager } from '$lib/managers/system-config-manager.svelte';
|
||||
import { Link } from '@immich/ui';
|
||||
import { t } from 'svelte-i18n';
|
||||
import { fade } from 'svelte/transition';
|
||||
|
||||
const disabled = $derived(featureFlagsManager.value.configFile);
|
||||
const config = $derived(systemConfigManager.value);
|
||||
let configToEdit = $state(systemConfigManager.cloneValue());
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<div in:fade={{ duration: 500 }}>
|
||||
<form autocomplete="off" onsubmit={(event) => event.preventDefault()}>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingAccordion
|
||||
key="integrity-checks-missing-files"
|
||||
title={$t('admin.integrity_checks_missing_files')}
|
||||
subtitle={$t('admin.integrity_checks_missing_files_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.integrity_checks_missing_files_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.integrityChecks.missingFiles.enabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.cron_expression')}
|
||||
bind:value={configToEdit.integrityChecks.missingFiles.cronExpression}
|
||||
required={true}
|
||||
{disabled}
|
||||
isEdited={!(
|
||||
configToEdit.integrityChecks.missingFiles.cronExpression ===
|
||||
config.integrityChecks.missingFiles.cronExpression
|
||||
)}
|
||||
>
|
||||
{#snippet descriptionSnippet()}
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.cron_expression_description">
|
||||
{#snippet children({ message })}
|
||||
<Link
|
||||
href="https://crontab.guru/#{configToEdit.backup.database.cronExpression.replaceAll(' ', '_')}"
|
||||
>
|
||||
{message}
|
||||
<br />
|
||||
</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="integrity-checks-untracked-files"
|
||||
title={$t('admin.integrity_checks_untracked_files')}
|
||||
subtitle={$t('admin.integrity_checks_untracked_files_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.integrity_checks_untracked_files_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.integrityChecks.untrackedFiles.enabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.cron_expression')}
|
||||
bind:value={configToEdit.integrityChecks.untrackedFiles.cronExpression}
|
||||
required={true}
|
||||
{disabled}
|
||||
isEdited={!(
|
||||
configToEdit.integrityChecks.untrackedFiles.cronExpression ===
|
||||
config.integrityChecks.untrackedFiles.cronExpression
|
||||
)}
|
||||
>
|
||||
{#snippet descriptionSnippet()}
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.cron_expression_description">
|
||||
{#snippet children({ message })}
|
||||
<Link
|
||||
href="https://crontab.guru/#{configToEdit.backup.database.cronExpression.replaceAll(' ', '_')}"
|
||||
>
|
||||
{message}
|
||||
<br />
|
||||
</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingAccordion
|
||||
key="integrity-checks-checksum-files"
|
||||
title={$t('admin.integrity_checks_checksum_files')}
|
||||
subtitle={$t('admin.integrity_checks_checksum_files_description')}
|
||||
>
|
||||
<div class="ms-4 mt-4 flex flex-col gap-4">
|
||||
<SettingSwitch
|
||||
title={$t('admin.integrity_checks_checksum_files_enable_description')}
|
||||
{disabled}
|
||||
bind:checked={configToEdit.integrityChecks.checksumFiles.enabled}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.TEXT}
|
||||
label={$t('admin.cron_expression')}
|
||||
bind:value={configToEdit.integrityChecks.checksumFiles.cronExpression}
|
||||
required={true}
|
||||
{disabled}
|
||||
isEdited={!(
|
||||
configToEdit.integrityChecks.checksumFiles.cronExpression ===
|
||||
config.integrityChecks.checksumFiles.cronExpression
|
||||
)}
|
||||
>
|
||||
{#snippet descriptionSnippet()}
|
||||
<p class="text-sm dark:text-immich-dark-fg">
|
||||
<FormatMessage key="admin.cron_expression_description">
|
||||
{#snippet children({ message })}
|
||||
<Link
|
||||
href="https://crontab.guru/#{configToEdit.backup.database.cronExpression.replaceAll(' ', '_')}"
|
||||
>
|
||||
{message}
|
||||
<br />
|
||||
</Link>
|
||||
{/snippet}
|
||||
</FormatMessage>
|
||||
</p>
|
||||
{/snippet}
|
||||
</SettingInputField>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.integrity_checks_checksum_files_time_limit')}
|
||||
description={$t('admin.integrity_checks_checksum_files_time_limit_description')}
|
||||
bind:value={configToEdit.integrityChecks.checksumFiles.timeLimit}
|
||||
{disabled}
|
||||
isEdited={configToEdit.integrityChecks.checksumFiles.timeLimit !==
|
||||
config.integrityChecks.checksumFiles.timeLimit}
|
||||
/>
|
||||
|
||||
<SettingInputField
|
||||
inputType={SettingInputFieldType.NUMBER}
|
||||
label={$t('admin.integrity_checks_checksum_files_percentage_limit')}
|
||||
description={$t('admin.integrity_checks_checksum_files_percentage_limit_description')}
|
||||
bind:value={configToEdit.integrityChecks.checksumFiles.percentageLimit}
|
||||
step="0.01"
|
||||
min={0.01}
|
||||
max={1}
|
||||
{disabled}
|
||||
isEdited={configToEdit.integrityChecks.checksumFiles.percentageLimit !==
|
||||
config.integrityChecks.checksumFiles.percentageLimit}
|
||||
/>
|
||||
</div>
|
||||
</SettingAccordion>
|
||||
|
||||
<SettingButtonsRow bind:configToEdit keys={['integrityChecks']} {disabled} />
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
Reference in New Issue
Block a user